Skip to content

Require --force to delete a chat line when nobody can confirm it - #654

Open
jeremy wants to merge 1 commit into
close-hang-family-prompt-floorfrom
require-force-for-unconfirmable-deletes
Open

Require --force to delete a chat line when nobody can confirm it#654
jeremy wants to merge 1 commit into
close-hang-family-prompt-floorfrom
require-force-for-unconfirmable-deletes

Conversation

@jeremy

@jeremy jeremy commented Aug 22, 2026

Copy link
Copy Markdown
Member

⚠️ Stacked on #652

Base branch is close-hang-family-prompt-floor, not main. Review and merge #652 first; this will be retargeted to main afterwards. The diff shown here is only this PR's changes — 5 files.

⚠️ BREAKING

basecamp chat delete <id> now fails without --force in machine-output modes (--agent, --json, --quiet, config-driven json/quiet, BASECAMP_NONINTERACTIVE) and wherever stdin cannot answer a prompt. Add --force to restore the previous behavior. The flag is unchanged and was already the documented form in SKILL.md.

The problem

A chat line delete is permanent — the API does not trash it. It used to happen unconfirmed in exactly the modes where nobody could object.

Machine-output mode skips the confirmation prompt, which is right. But it then deleted anyway: basecamp chat delete <id> --json destroyed a message with no statement of intent anywhere in the invocation and nobody in a position to object. Skipping a confirmation is not the same as answering one.

TestChatDeleteSkipsPromptInAgentMode pinned that behavior. It is inverted here.

The invariant

A permanent delete happens only with --force, or with a confirmation that will be shown and can be answered. Three outcomes, no fourth:

--force proceed — intent stated
a prompt that will be shown and can be answered ask, and honor the answer
anything else refuse, naming --force, before any request

"Anything else" merges two cases that used to end differently:

  • Machine mode skipped the prompt and deleted regardless.
  • A terminal with redirected stdin did show the prompt — isMachineOutput reads flags, the env var and stdout, never stdin — to a caller with nothing to type, where bubbletea waits on /dev/tty rather than failing (the hang class from Refuse to prompt when nothing can answer the prompt #652).

Same missing affirmation, so the same answer.

Placement and scope

The refusal lands before any account, project or chat lookup, so an invocation that cannot proceed does not spend two round trips discovering it. Asserted by a request counter, not by inspection.

isNonInteractiveCommand is read but not widened: missingArg and noChanges use it to choose between showing help and returning a structured error, and changing it would move behavior across many commands at once. This PR changes one command.

Tests

TestChatDeleteConfirmationMatrix covers agent / json / quiet / config-driven json / BASECAMP_NONINTERACTIVE / piped stdin / stdin on /dev/null, plus the forced positives, each asserting zero requests on refusal. Two e2e cases resolve before any request, so no cassette is needed — a delete that reached the wire would itself be the bug.

Release mechanics

  • No .surface-breaking entry belongs here. That file receives removed .surface lines; the snapshot is unchanged at 18438 entries, because chat delete and --force both still exist. This is a behavioral break, not a surface removal, so that check will never fire on it — worth stating explicitly rather than leaving a reviewer to wonder.
  • The breaking label is applied and is the sole input to the release notes' Breaking Changes section (.github/release.yml:8). There is no CHANGELOG to edit.

bin/ci green on this branch.


Summary by cubic

Require explicit intent for permanent chat deletes. Previously machine-output or non-interactive runs skipped the prompt and deleted; now chat delete refuses unless --force, or it prompts only when a confirmation will be shown and can be answered.

  • Refuses before any account/project/chat lookup.
  • Applies to --agent, --json, --quiet, config-driven json/quiet, BASECAMP_NONINTERACTIVE, and when stdin or stderr can’t confirm.
  • Breadcrumbs are context-aware and runnable: machine output includes --force; terminals do not; label is “Delete line (permanent)”.
  • Scope limited to chat delete; no changes to isNonInteractiveCommand. CLI help and skills/basecamp/SKILL.md include a --json --force example.

Migration

  • Add --force when using --agent, --json, --quiet, config-driven json/quiet, BASECAMP_NONINTERACTIVE, or when stdin/stderr cannot confirm.

Written for commit d469772. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings August 22, 2026 19:56
@jeremy jeremy added bug Something isn't working breaking Breaking change labels Aug 22, 2026
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Aug 22, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Requires explicit confirmation or --force before permanently deleting chat lines in non-interactive contexts.

Changes:

  • Rejects unsafe deletes before API lookups.
  • Adds unit and end-to-end coverage for output and stdin modes.
  • Documents the --force requirement for agents.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
skills/basecamp/SKILL.md Documents forced permanent deletion.
internal/commands/helpers.go Implements the confirmation invariant.
internal/commands/chat.go Gates deletion before lookups and prompts safely.
internal/commands/chat_test.go Tests confirmation and force scenarios.
e2e/chat.bats Verifies machine-mode refusal.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from 751f1df to 9d571b9 Compare August 23, 2026 02:16
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from 48a8a37 to 7da1097 Compare August 23, 2026 02:16

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7da10977c9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/helpers.go Outdated
Comment thread internal/commands/helpers.go Outdated
Comment on lines +91 to +92
willPrompt := !isNonInteractiveCommand(cmd)
if force || (willPrompt && stdinarg.InteractivePrompt()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor explicit interactive output overrides

When the saved format is json or quiet but the user explicitly passes --styled or --md in a real terminal, ApplyFlags changes the effective writer to that human-facing format, yet isNonInteractiveCommand still returns true because App.IsMachineOutput consults the stale configured format. This new gate therefore rejects an otherwise confirmable delete and demands --force, despite explicit flags taking precedence over configuration; base the prompt decision on the effective/overridden format.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and deliberately not changing it — flagging for human review rather than resolving.

The premise checks out: ApplyFlags swaps the writer to Styled/Markdown for --styled/--md, but App.IsMachineOutput() reads Config.Format, so a saved format: json plus --styled on a real terminal reports machine output and no prompt is shown.

Two reasons to leave it:

  1. The direction of the change is safe. In that corner the old behavior was delete without confirming. The new behavior is a usage error naming --force. This PR makes that corner stricter, not more dangerous, and the remedy is one documented flag.

  2. The fix cannot be local, and a partial one is worse than none. ensureDeleteConfirmable and the prompt block in chat.go must agree; if the guard says confirmable and isNonInteractiveCommand then skips the prompt, the delete proceeds unconfirmed — precisely the bug this PR exists to close. Agreeing means basing both on the effective format, i.e. changing IsMachineOutput (or isMachineOutput), which also moves missingArg and noChanges across many commands. That is the widening the PR description explicitly declines as a separate decision.

So: real, narrow, and safe-by-default today. Worth its own PR that fixes IsMachineOutput to honor explicit format overrides everywhere at once, with the missingArg/noChanges fallout reviewed on its merits. Happy to open that if a maintainer agrees.

@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from 9d571b9 to dcd04d6 Compare August 23, 2026 02:31
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from 7da1097 to 76efcb1 Compare August 23, 2026 02:31
@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from dcd04d6 to dd5b89b Compare August 23, 2026 02:38
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from 76efcb1 to f21770a Compare August 23, 2026 02:38
@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from dd5b89b to 9fca545 Compare August 23, 2026 02:45
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from f21770a to 85d0751 Compare August 23, 2026 02:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85d0751338

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/chat.go Outdated
@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from 9fca545 to c29832f Compare August 23, 2026 02:58
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from 85d0751 to a9231bc Compare August 23, 2026 02:58
@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from c29832f to 2005e9c Compare August 23, 2026 03:14
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from a9231bc to 774f60d Compare August 23, 2026 03:14
@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from 2005e9c to 5d9dffa Compare August 23, 2026 03:27
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from 774f60d to 83b04ba Compare August 23, 2026 03:28
@jeremy
jeremy force-pushed the close-hang-family-prompt-floor branch from 5d9dffa to b9b5c80 Compare August 23, 2026 03:40
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from 83b04ba to e24ff1c Compare August 23, 2026 03:41

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e24ff1c459

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/chat.go Outdated
BREAKING: `basecamp chat delete <id>` now fails without --force in
machine-output modes (--agent, --json, --quiet, config-driven json/quiet,
BASECAMP_NONINTERACTIVE) and wherever stdin cannot answer a prompt. Add
--force to restore the previous behavior. The flag is unchanged and was
already the documented form in SKILL.md.

A chat line delete is permanent — the API does not trash it. It used to
happen unconfirmed in exactly the modes where nobody could object.
Machine-output mode skips the confirmation prompt, which is right, but it
then deleted anyway: `basecamp chat delete <id> --json` destroyed a
message with no statement of intent anywhere in the invocation. Skipping
a confirmation is not the same as answering one.

So the invariant is now one line — a permanent delete happens only with
--force, or with a confirmation that will be shown and can be answered —
and it leaves three outcomes and no fourth:

  --force                                 proceed, intent stated
  a prompt that will be shown and can be answered   ask, and honor the answer
  anything else                           refuse, naming --force

"Anything else" merges two cases that used to end differently: machine
mode, which skipped the prompt and deleted; and a terminal with redirected
stdin, which showed the prompt to a caller with nothing to type — because
isNonInteractiveCommand reads flags, the env var and stdout, never stdin —
where bubbletea waits on /dev/tty rather than failing. Same missing
affirmation either way.

The refusal lands before any account, project or chat lookup, so an
invocation that cannot proceed does not spend two round trips discovering
it. isNonInteractiveCommand is read but not widened: missingArg and
noChanges use it to choose between help and a structured error, and moving
it would change many commands at once.

TestChatDeleteSkipsPromptInAgentMode pinned the old behavior; it is
inverted, and a matrix covers every mode plus the forced positives.

No .surface-breaking entry: the snapshot is unchanged at 18438 entries —
`chat delete` and --force both still exist, and this is a behavioral
break, not a surface removal, which is the only thing that file tracks.
This PR needs the `breaking` label; it is the sole input to the release
notes' Breaking Changes section, and there is no CHANGELOG to edit.
@jeremy
jeremy force-pushed the require-force-for-unconfirmable-deletes branch from e24ff1c to d469772 Compare August 23, 2026 03:49
@jeremy

jeremy commented Aug 23, 2026

Copy link
Copy Markdown
Member Author

⚠️ This PR gets no automatic CI — read before merging

test.yml, security.yml and codeql.yml all filter pull_request: branches: [main], and that filter matches the PR's base branch. This PR is based on close-hang-family-prompt-floor, so none of them fire: the checks list shows only Label / Sensitive Change Gate / Dependabot, i.e. 4 entries against #652's 25.

Green checks here therefore mean "the two workflows that do run passed", not "the suite passed". That is easy to misread, which is why this comment exists.

Worth noting the gap is not uniform: sibling PR #653 shares this same base and does get Test/Security/CodeQL on its head. I did not determine why. So count the checks per PR rather than reasoning from the base branch.

Evidence on head d469772:

Source Result
bin/ci locally on this exact commit pass — fmt, vet, lint, unit, e2e BATS, naming, CLI surface, skill drift, SDK provenance, go mod tidy
Test (dispatched via workflow_dispatch) ✅ success
Security (dispatched) ✅ success
CodeQL Advanced (dispatched) ✅ success

The dispatched runs are pinned to d469772, not to an ancestor.

Before merging: #652 merges first, at which point GitHub retargets this PR to main, firing a fresh pull_request event that runs the full suite for real. Prefer that over the manual dispatch above — it is the one reflecting the merged-with-main state.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d469772176

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread internal/commands/chat.go
Comment on lines +1058 to +1059
if isNonInteractiveCommand(cmd) {
c += " --force"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep confirmation for one-shot noninteractive displays

When hints are shown by a human-facing invocation such as BASECAMP_NONINTERACTIVE=1 basecamp chat line 111 --styled --hints, this condition appends --force even though that environment assignment ends with the show command and does not change its styled output format. Copying the breadcrumb into the terminal then bypasses the confirmation that would otherwise be available. The fresh evidence beyond the redirected-stdin finding is that the revised audience check still includes the invocation-scoped BASECAMP_NONINTERACTIVE state; use machine output—not the noninteractive environment—to decide whether the breadcrumb needs --force.

Useful? React with 👍 / 👎.

// it would move behavior across many commands.
func ensureDeleteConfirmable(cmd *cobra.Command, force bool) error {
if force || isNonInteractiveCommand(cmd) || stdinarg.InteractivePrompt() {
if force || !deleteNeedsForce(cmd) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Add --force to the campfire smoke deletion

When the pre-release make smoke suite reaches the Campfire lifecycle, e2e/smoke/smoke_campfire.bats:75-78 still runs basecamp campfire delete ... --json without --force and asserts success. This new guard now deterministically returns a usage error before issuing the DELETE, so the smoke suite fails and leaves its posted test message behind; update that invocation to include --force.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change bug Something isn't working commands CLI command implementations skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants