Skip to content

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

Merged
jeremy merged 1 commit into
mainfrom
require-force-for-unconfirmable-deletes
Aug 25, 2026
Merged

Require --force to delete a chat line when nobody can confirm it#654
jeremy merged 1 commit into
mainfrom
require-force-for-unconfirmable-deletes

Conversation

@jeremy

@jeremyjeremy commented Aug 22, 2026

Copy link
Copy Markdown
Member

Rebased onto main. This was stacked on #652, which has since merged; the branch has been rebased so the diff is this PR's own 6 files and nothing else. Earlier review comments referring to a stacked base or a larger changed-file count predate that rebase.

⚠️ 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:

--forceproceed — intent stated
a prompt that will be shown and can be answeredask, and honor the answer
anything elserefuse, 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 undrivable prompts could delete without confirmation; now deletion only proceeds with --force or when a prompt will be shown and can be answered, and we refuse early with a usage hint naming --force.

  • Bolded breadcrumbs now reflect the reader: for machine-readable output we append --force; for human formats we omit it. If a human format flag (--styled, --md) overrides a machine-output config, we carry that flag so the suggestion remains runnable and confirmable.
  • Guard: ensureDeleteConfirmable refuses before any account/project/chat lookup when deleteNeedsForce is true (set by BASECAMP_NONINTERACTIVE, by the rendered audience via EffectiveFormat for machine readers, or when stdin/stderr can’t answer a prompt). We do not widen isNonInteractiveCommand.
  • Tests/docs: invert the agent-mode test, add a confirmation matrix and bounded promptability checks, update e2e/smoke to pass --force under --json, update help text examples, and clarify in skills/basecamp/SKILL.md that --force is required.

Migration

  • Add --force when deleting in --agent, --json, --quiet, config json|quiet, when BASECAMP_NONINTERACTIVE is set, or when stdin/stderr are not terminals.

Written for commit 7ea0be6. Summary will update on new commits.

Review in cubic

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

CopilotAI 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
FileDescription
skills/basecamp/SKILL.mdDocuments forced permanent deletion.
internal/commands/helpers.goImplements the confirmation invariant.
internal/commands/chat.goGates deletion before lookups and prompts safely.
internal/commands/chat_test.goTests confirmation and force scenarios.
e2e/chat.batsVerifies machine-mode refusal.

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

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

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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 threadinternal/commands/helpers.go Outdated
Comment threadinternal/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
MemberAuthor

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
jeremyforce-pushed the close-hang-family-prompt-floor branch from 9d571b9 to dcd04d6CompareAugust 23, 2026 02:31
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 7da1097 to 76efcb1CompareAugust 23, 2026 02:31
@jeremy
jeremyforce-pushed the close-hang-family-prompt-floor branch from dcd04d6 to dd5b89bCompareAugust 23, 2026 02:38
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 76efcb1 to f21770aCompareAugust 23, 2026 02:38
@jeremy
jeremyforce-pushed the close-hang-family-prompt-floor branch from dd5b89b to 9fca545CompareAugust 23, 2026 02:45
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from f21770a to 85d0751CompareAugust 23, 2026 02:45

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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 threadinternal/commands/chat.go Outdated
@jeremy
jeremyforce-pushed the close-hang-family-prompt-floor branch from 9fca545 to c29832fCompareAugust 23, 2026 02:58
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 85d0751 to a9231bcCompareAugust 23, 2026 02:58
@jeremy
jeremyforce-pushed the close-hang-family-prompt-floor branch from c29832f to 2005e9cCompareAugust 23, 2026 03:14
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from a9231bc to 774f60dCompareAugust 23, 2026 03:14
@jeremy
jeremyforce-pushed the close-hang-family-prompt-floor branch from 2005e9c to 5d9dffaCompareAugust 23, 2026 03:27
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 774f60d to 83b04baCompareAugust 23, 2026 03:28
@jeremy
jeremyforce-pushed the close-hang-family-prompt-floor branch from 5d9dffa to b9b5c80CompareAugust 23, 2026 03:40
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 83b04ba to e24ff1cCompareAugust 23, 2026 03:41

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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 threadinternal/commands/chat.go Outdated
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from e24ff1c to d469772CompareAugust 23, 2026 03:49
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 638d1b7 to 9f96500CompareAugust 24, 2026 02:30

CopilotAI 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

Copilot reviewed 27 out of 27 changed files in this pull request and generated 2 comments.

Comment threadinternal/tui/launchers_test.go
Comment threadinternal/commands/chat_test.go
CopilotAI review requested due to automatic review settings August 24, 2026 02:33

CopilotAI 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

Copilot reviewed 27 out of 27 changed files in this pull request and generated 1 comment.

Comment threadinternal/commands/helpers.go Outdated
CopilotAI review requested due to automatic review settings August 24, 2026 02:51
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 9f96500 to 49b90a5CompareAugust 24, 2026 02:51
@github-actionsgithub-actionsBot removed the tui Terminal UI label Aug 24, 2026

CopilotAI 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

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment threadinternal/commands/chat_test.go

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:49b90a597a

ℹ️ 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 threadinternal/commands/chat.go Outdated
Comment threadinternal/commands/chat_test.go
CopilotAI review requested due to automatic review settings August 24, 2026 03:01
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 49b90a5 to 7aa6a9eCompareAugust 24, 2026 03:01

CopilotAI 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

Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.

Comment threadinternal/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.
CopilotAI review requested due to automatic review settings August 24, 2026 03:07
@jeremy
jeremyforce-pushed the require-force-for-unconfirmable-deletes branch from 7aa6a9e to 7ea0be6CompareAugust 24, 2026 03:07

CopilotAI 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

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

@jeremy

Copy link
Copy Markdown
MemberAuthor

One policy decision needs a maintainer before merge

Everything else on this PR is resolved. This is the single open thread, deliberately left unresolved rather than fixed, and it wants an explicit accept or reject.

The edge:App.IsMachineOutput() reads Config.Format, so a saved format: json plus an explicit --styled or --md on a real terminal still reports machine output. This PR therefore refuses that invocation and asks for --force, even though the confirmation could have been shown and answered.

Why it was not fixed here:

  1. The direction is safe. In that corner the old behavior was delete without confirming. The new behavior is a usage error naming --force. Strictly stricter, and the remedy is one documented flag.
  2. A local fix is worse than none.ensureDeleteConfirmable and the prompt block in chat.go must agree. If the guard accepts an invocation the prompt block then skips, the delete proceeds unconfirmed — the exact bug this PR closes. Making them agree means changing IsMachineOutput (or isMachineOutput) to honor explicit format overrides, which also moves missingArg and noChanges across many commands.

The two options:

  • Accept as-is — this corner requires --force; the broader IsMachineOutput fix lands as its own PR where the missingArg/noChanges fallout can be reviewed on its merits. I'm happy to open that.
  • Reject — I widen IsMachineOutput here instead, and this PR grows to cover the fallout across the other call sites.

Related but already fixed: the same audience distinction was applied within this PR's own scope — deleteNeedsForce and deleteLineCmd both use machineReadsThisOutput, which asks the effective rendered format, so chat delete … --md >report.md with terminal stdin+stderr is correctly confirmable. What remains is only the configured-format-vs-explicit-flag case above, which cannot be settled without touching shared helpers.


Also worth recording, since nothing in CI would have caught it: this change broke e2e/smoke/smoke_campfire.bats, which deleted with --json and no --force and asserted success. make smoke needs live QA credentials so it is not part of bin/ci. The invocation now passes --force, but I could not execute make smoke to verify it end to end — that needs a machine with QA access.

@jeremy
jeremy merged commit e64c6d3 into mainAug 25, 2026
25 checks passed
@jeremy
jeremy deleted the require-force-for-unconfirmable-deletes branch August 25, 2026 03:09
jeremy added a commit that referenced this pull request Aug 31, 2026
The force-flag work merged in #654 at 7ea0be6, before two comment
revisions landed on the feature branch. The historical paragraph now
speaks of the old gate in the past tense, and the closing note says
what actually happened to isNonInteractiveCommand: the delete path
bypasses it — machineReadsThisOutput asks the narrower question — and
it is left untouched for its other callers, not "read, never widened".
jeremy added a commit that referenced this pull request Aug 31, 2026
ApplyFlags lets --styled and --md rebuild the writer over a configured
json/quiet format, but IsMachineOutput never read those flags — so
`basecamp --styled <cmd>` on a format=json machine was human to the
renderer and machine to every gate. That split surfaced as the declined
review thread on #654: chat delete demanded --force for an invocation
whose confirmation a person could see and answer, and chat.go grew
humanFormatOverride to state the rule at one call site because the
predicate couldn't.
The clause sits between the machine-flag block and the config switch,
mirroring ApplyFlags' ordering: --json --styled resolves JSON-first
there, so machine flags still win here.
Deliberately not delegated to EffectiveFormat(): that would also pull in
FormatAuto-on-a-pipe, flipping upgrade's stdout, attachment progress,
the skill-update nudge, and quickstart/wizard gating for every
`basecamp foo | grep`. TestMachinePredicatesAgree pins the whole
flag/config matrix against machineReadsThisOutput, asserting that one
divergence explicitly instead of skipping it.
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breakingBreaking changebugSomething isn't workingcommandsCLI command implementationsskillsAgent skillstestsTests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@jeremy