fix(story-mode): a command whose last check finds nothing answered, it did not fail

From review round 9: "Claude tries a command, and it fails" made up a
problem. The command printed what Claude was after, then exited 1 only
because its last step (`git config user.name`) found nothing set.

A command whose LAST step is a lookup that answers "no" with exit code
1 (grep, `git config`, `test`, `which`), after printing something,
now reads "Claude checks a few things; the last check finds nothing.",
and does not make the turn "Nothing Claude tried worked". A real
failure (another exit code, nothing printed, or the lookup not last)
still fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 16:19:07 +02:00
1 parent 79b37122df
commit 6a6c7c0949
5 files changed
+52 -15

No files matched your search

+1 -1
View File
@@ -1084,7 +1084,7 @@ A `page` carries the turn's summary fields plus:
- `events`: one per prompt, sentence Claude wrote and tool call. Each has `intent`, `tool`, `label` and, when the call returned, `ok` (`false` when the result was flagged as an error, or reported failed tests or lint problems a pipe kept from the exit code). Depending on the call: `edit` (an Edit/Write, hunks in Claude Code's own `structuredPatch` shape), `edits` (files a Bash call wrote through here-documents or `printf`/`echo` redirects, that a python or node script fed by a here-document opens for writing, or that Claude Code says it changed; the last two have no lines, since what changed is not in the command; an append's hunk has `atEnd: true` and no line numbers, since where it lands is not in the command), the tail of `output`, `tests` (`{ passed, failed, errors? }`, counted on the whole output before it is cut), `helper`, `files` (files a Bash call printed with `cat`/`head`/`tail`, resolved against the command's folder), `headline` (the part of a one-liner that decided its intent), `commits`, `subject` (the first commit's message, when the command spells it out with `-m` or a `git commit -F -` here-document), `ships` (`push` / `pr`), `running`, `background` and `finishedLater` (see Background work), `keyPoints` (for a sentence Claude wrote: up to two sentences from further into a long message that a reader must not lose, such as an open problem, something not yet done or checked, or a word on what you asked for; read on the whole message, past the `label`'s clip, and used by the final panel's balloon), `question` and, for prompts, `origin`. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript, so it is never a panel of its own: the panel of the step after it (or the last one, when nothing follows) holds its events and counts them in `secondOpinion`. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), and `ExitPlanMode`'s result carries the plan (`output`) and your answer (`Approved` / `Sent back`).
- `panels`: at most 12, each `{ kind, events, caption: { story, director }, balloon?, files?, notes?, helpers?, todos?, tests?, ok?, rounds?, roundsOk?, question?, live? }`, with `events` as indices into `events`. A run panel's `printed` holds the first lines its last command printed (its last lines when it failed), for the terminal card: the card is drawn on the canvas and goes into a saved image, so lines that look like secrets are left out and addresses and long token-like strings are masked. `balloon.who` is `you`, `claude` or `note` (words that start a turn but are not yours: a background job's report, another session's message). A loop's `roundsOk` lists every test run in order, `true` where it passed and `null` for one still running, so a failure inside a round that passed in the end still shows, and a live loop shows the runs that finished. Kinds: `mission`, `investigate`, `research`, `plan`, `backup`, `fix`, `check`, `loop` (fix and check rounds folded), `run`, `save`, `ask`, `compact`, `montage` (quieter panels folded to fit) and `complete`.
- `filesChanged`, `added`, `removed` (summed per edit), `helpers` (the turn's own helper calls; a report turn's prompt carries the helper it answers, which is not counted), `helpersRunning`, `noneWorked` (every tool call of the turn failed, so the last panel is no celebration), `durationMs`, `partial` (the turn began before the part that was read) and `truncated` (older outputs, hunks and helper texts were dropped to keep the page small: a page carries at most 120,000 characters of them, newest first; an edit that lost its lines keeps its counts and an empty `hunks`).
- `filesChanged`, `added`, `removed` (summed per edit), `helpers` (the turn's own helper calls; a report turn's prompt carries the helper it answers, which is not counted), `helpersRunning`, `noneWorked` (every tool call of the turn failed, so the last panel is no celebration; a command whose last check only found nothing, exit code 1 after its answer, does not count as failed), `durationMs`, `partial` (the turn began before the part that was read) and `truncated` (older outputs, hunks and helper texts were dropped to keep the page small: a page carries at most 120,000 characters of them, newest first; an edit that lost its lines keeps its counts and an empty `hunks`).
## Git worktrees of a case
+26 -12
View File
@@ -2289,6 +2289,17 @@ function checkName(e: StoryEvent | undefined): string {
return 'another check';
}
/**
* A command whose LAST step answers "no" with exit code 1: grep finds nothing, a git setting is unset,
* `test` is false, a program is not installed. What it printed before that is the answer Claude was
* after, so the panel says so instead of "it fails". Anchored on a separator, so linear.
*/
const EMPTY_ANSWER_STEP = /(?:^|[;&|]\s*)(?:e?grep|fgrep|rg|git config|test|\[|which|command -v)\s[^;&|]*$/;
const emptyAnswer = (e: StoryEvent): boolean => {
const out = e.output ?? '';
return e.ok === false && /^Exit code 1\n\s*\S/.test(out) && EMPTY_ANSWER_STEP.test(e.label.trim());
};
/** One or two files by name ("calc.py and test_calc.py"), more by count ("3 files"). */
const fileWords = (files: string[], noun = 'file'): string =>
files.length && files.length <= 2 ? files.map((f) => basename(f)).join(' and ') : plural(files.length, noun);
@@ -2603,17 +2614,19 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
}
story = evs.every((e) => e.running)
? `Claude starts ${evs.length === 1 ? 'a command' : `${evs.length} commands`} in the background.`
: errors && errors === evs.length
? `Claude tries ${evs.length === 1 ? 'a command' : `${evs.length} commands`}, and ${
evs.some((e) => /\bblocked\b|PreToolUse|denied/i.test(e.output ?? ''))
? 'it is blocked'
: evs.length === 1
? 'it fails'
: 'they fail'
}.`
: evs.every((e) => e.tool !== 'Bash')
? 'Claude uses a tool.'
: 'Claude works in the terminal.';
: errors && errors === evs.length && evs.every(emptyAnswer)
? `Claude checks ${evs.length === 1 ? 'a few things' : 'several things'}; the last check finds nothing.`
: errors && errors === evs.length
? `Claude tries ${evs.length === 1 ? 'a command' : `${evs.length} commands`}, and ${
evs.some((e) => /\bblocked\b|PreToolUse|denied/i.test(e.output ?? ''))
? 'it is blocked'
: evs.length === 1
? 'it fails'
: 'they fail'
}.`
: evs.every((e) => e.tool !== 'Bash')
? 'Claude uses a tool.'
: 'Claude works in the terminal.';
director = `${tools} · ${shortCommand(evs[0])}${errors ? ` · ${plural(errors, 'error')}` : ''}`;
break;
}
@@ -2855,7 +2868,8 @@ export function buildStoryPage(turn: StoryTurn): StoryPage {
: {}),
};
const calls = events.filter((e) => !NOT_TOOL_CALLS.has(e.intent));
if (calls.length && calls.every((e) => e.ok === false)) base.noneWorked = true;
// A check that found nothing answered its question: it did not fail to work.
if (calls.length && calls.every((e) => e.ok === false && !emptyAnswer(e))) base.noneWorked = true;
if (turn.endedAt) base.endedAt = turn.endedAt;
if (turn.durationMs !== undefined) base.durationMs = turn.durationMs;
if (turn.ended) {
+4 -1
View File
@@ -218,7 +218,10 @@ export interface StoryPage extends StoryTurnSummary {
openNote?: 'catch' | 'note';
/** Claude's last words ask you something or offer more: the next move is yours. */
asks?: boolean;
/** Every tool call of the turn failed (a blocked command, nothing that worked): no celebration. */
/**
* Every tool call of the turn failed (a blocked command, nothing that worked): no celebration. A
* command whose last check found nothing (grep, `git config`, exit code 1 after its answer) answered.
*/
noneWorked?: boolean;
}
@@ -40,7 +40,7 @@
{
"kind": "run",
"ok": null,
"story": "Claude tries a command, and it fails.",
"story": "Claude checks a few things; the last check finds nothing.",
"director": "Bash · grep -oE '\"(permissions|env|allow|deny)\"|[A-Za-z_]*(TOKEN|K… · 1 error",
"balloon": null,
"tests": null,
+20
View File
@@ -984,6 +984,26 @@ describe('buildStoryPage', () => {
expect(pageOf([prompt('c'), ...bash('b1', 'git commit -qm x', 'Commit', ''), end(1000)]).events[1].commits).toBe(1);
});
it('a command whose last check finds nothing (exit code 1 after its answers) is not a failure', () => {
const pageFor = (command: string, output: string) =>
pageOf([prompt('look'), ...bash('b1', command, 'Look', output, true), end(1000)]);
const run = (command: string, output: string) =>
pageFor(command, output).panels.find((p) => p.kind === 'run')!.caption.story;
// It answered its question: the page does not call the job a failure either.
expect(pageFor('cat .env.example | wc -l; git config user.name', 'Exit code 1\n4').noneWorked).toBeUndefined();
expect(pageFor('./deploy.sh; which deploy', 'Exit code 2\nno target').noneWorked).toBe(true);
expect(run('cat .env.example | wc -l; git config user.name', 'Exit code 1\n4')).toBe(
'Claude checks a few things; the last check finds nothing.'
);
expect(run('node scripts/count.js && grep -c TODO src/a.js', 'Exit code 1\n12 files\n0')).toBe(
'Claude checks a few things; the last check finds nothing.'
);
// A real failure, or one with nothing printed before it, still fails.
expect(run('./deploy.sh; which deploy', 'Exit code 2\nno target')).toBe('Claude tries a command, and it fails.');
expect(run('node a.js; grep -q x a.txt', 'Exit code 1')).toBe('Claude tries a command, and it fails.');
expect(run('git config user.name; ./deploy.sh', 'Exit code 1\nme')).toBe('Claude tries a command, and it fails.');
});
it('a stop names the step Claude was on and what its to-do list still had open', () => {
const stopped = (lines: unknown[]) =>
pageOf([...lines, prompt('[Request interrupted by user]')]).panels.find((p) => p.kind === 'complete')!.caption