diff --git a/docs/api-reference.md b/docs/api-reference.md index 6a4895b6..0ab02f61 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -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 diff --git a/src/story-mode.ts b/src/story-mode.ts index dac6000a..90c9222f 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -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) { diff --git a/src/types/story.ts b/src/types/story.ts index c7fe04d1..a3a5a6f4 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -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; } diff --git a/test/fixtures/story-corpus/web-page-node-test.expected.json b/test/fixtures/story-corpus/web-page-node-test.expected.json index f8eb8634..d92b5ef6 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -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, diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 6f0b4703..a8aa10b3 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -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