diff --git a/docs/api-reference.md b/docs/api-reference.md index 8c971dab..999f33c2 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1056,7 +1056,7 @@ What Story Mode reads (the opt-in header button, App Settings → Header buttons ### Routes -- `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). `older: true` means older turns exist that `turns` leaves out: before the part of the transcript that was read, or past the 60 listed. +- `GET /api/sessions/:id/story[?turn=]`: one turn as a page. `data` is `{ supported: true, turns, page }`: `turns` lists up to 60 turns, oldest first (`{ id, at, prompt, origin, complete, interrupted, superseded?, toolCalls }`; `toolCalls`, here and on the page, leaves out second opinions, which are no step of the work), and `page` is the newest turn, or the one `turn=` names, or `null` when there is none (an unknown id included). `older: true` means older turns exist that `turns` leaves out: before the part of the transcript that was read, or past the 60 listed. - `GET /api/sessions/:id/story/helpers/:agentId`: one helper (an Agent call, `agentId` from its event's `helper.agentId`) drawn from its own transcript, `/subagents/agent-.jsonl` beside the session's. `data` is `{ supported: true, helper: { agentId, type?, description?, finished? }, page }`, the same page shape, or `page: null` while the helper has written nothing yet. `agentId` must match `[A-Za-z0-9_-]{1,64}` (`400` otherwise). Both answer `{ supported: false, reason }` for a session they cannot draw: its CLI writes no Claude Code transcript (registry `capabilities.transcript` is not `claude-jsonl`), or it is remote or Docker, whose transcript is not on this host. Both are read-only. The session resolves like every session route (ownership via `findSessionOrFail`; another user's session is `404`), and the file read is the session's own transcript, never one the request names: the path the Claude hook reported when it really lies (symlinks resolved) in a Claude projects folder (the session's relocated `CLAUDE_CONFIG_DIR`, or the server's), else `.jsonl` under that folder. Only a regular file is read, through one non-blocking handle, so a FIFO or a device in its place fails at once. An unchanged file is a `304` decided from a stat, before anything is read. Only its tail is read (8 MB, widened in steps to 64 MB while that tail holds no prompt to start the turn); file contents, original files and images never leave the server. On the page route, a transcript that cannot be read is a `500` with `OPERATION_FAILED`; the helper route answers `page: null` for a helper transcript it cannot read (one not written yet, or gone). diff --git a/src/story-mode.ts b/src/story-mode.ts index c3d150fa..0d2599a5 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -2011,7 +2011,9 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { } const NOT_TOOL_CALLS = new Set(['prompt', 'say', 'interrupt', 'compact', 'notice']); -const countToolCalls = (events: StoryEvent[]) => events.filter((e) => !NOT_TOOL_CALLS.has(e.intent)).length; +/** The turn's own tool calls: a second opinion (the advisor) is no step of the work, so it is not counted. */ +const countToolCalls = (events: StoryEvent[]) => + events.filter((e) => !NOT_TOOL_CALLS.has(e.intent) && e.tool !== 'Advisor').length; export function summarizeTurn(turn: StoryTurn): StoryTurnSummary { return { @@ -2332,10 +2334,11 @@ function printableLine(line: string): string | null { return clip(text.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '…@…').replace(/[A-Za-z0-9+/=_-]{32,}/g, '…'), 90); } -/** What a check other than the tests is, by its tool, for a reader: "a layout check", "a style check". */ +/** What a check other than the tests is, by its tool, for a reader: "a formatting check", "a style check". */ function checkName(e: StoryEvent | undefined): string { const cmd = e?.label ?? ''; - if (/\b(ruff format|black|prettier|gofmt|rustfmt|isort|autopep8|biome format)\b/.test(cmd)) return 'a layout check'; + if (/\b(ruff format|black|prettier|gofmt|rustfmt|isort|autopep8|biome format)\b/.test(cmd)) + return 'a formatting check'; if (/\b(ruff|eslint|flake8|pylint|clippy|shellcheck|golangci-lint|stylelint|biome)\b/.test(cmd)) return 'a style check'; if (/\b(tsc|mypy|pyright|vue-tsc)\b/.test(cmd)) return 'a type check'; @@ -2360,13 +2363,36 @@ const emptyAnswer = (e: StoryEvent): boolean => { ); }; +/** The first save of a folder Claude made a repository (`git init`, not `--bare`) with nothing committed before. */ +function startsRepoAt(indices: number[], events: StoryEvent[]): boolean { + const earlier = events.slice(0, Math.min(...indices)); + return ( + !earlier.some((e) => (e.commits ?? 0) > 0) && + [...earlier, ...indices.map((i) => events[i])].some( + (e) => e.tool === 'Bash' && /\bgit init\b(?![^;&|\n]*\s--bare\b)/.test(e.label) + ) + ); +} + +/** What the last lookup of an empty answer came back without, in a reader's words. */ +function emptyAnswerWhat(e: StoryEvent): string { + const step = EMPTY_ANSWER_STEP.exec(e.label.trim())?.[0].replace(/^[;&|\s]+/, '') ?? ''; + return /^git config/.test(step) + ? 'the last one finds a git setting that is not set' + : /^(which|command -v)/.test(step) + ? 'the last one finds a program that is not installed' + : /^(test|\[)/.test(step) + ? 'the last test it makes comes out false' + : 'the last search finds no match'; +} + /** 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); /** You asked for a failing run first (test first), or Claude said the failure was expected. */ const PLANNED_IN_PROMPT = - /\b(test[- ]first|tdd|fail(s|ing)? (first|on purpose)|failing tests? first|on purpose|red first|before (the )?(code|module|function|implementation) exists)\b/i; + /\b(test[- ]first|tdd|fail(s|ing)? (first|on purpose)|failing tests? first|on purpose|red first|before (the )?(code|module|function|implementation) exists|should fail because|(watch|see) (them|it|the tests?) fail|must (error|fail) (at|on|because|while))\b/i; const EXPECTED_BY_CLAUDE = /\b(as expected|as planned|as intended|expected (failure|to fail|red)|deliberately fail\w*|on purpose|test[- ]first|the red state)\b/i; function plannedFailure(turn: StoryTurn, events: StoryEvent[]): false | 'asked' | 'planned' { @@ -2587,7 +2613,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde const testFails = fails - lintFails; // A failure you asked for (test first), or that Claude said it expected, is the plan working. const planned = passed(checks[0]) === false ? plannedFailure(turn, events) : false; - // The check that failed while the tests passed, by name: "a layout check", not "another check". + // The check that failed while the tests passed, by name: "a formatting check", not "another check". const otherCheck = checkName( failed.find((e) => !!e.tests && e.tests.passed > 0 && !e.tests.failed && !e.tests.errors) ); @@ -2617,7 +2643,10 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : `Fix and test, ${plural(n, 'round')}.`; // Commits made inside the loop (a test one-liner that ends in one) are part of its story. const saved = evs.reduce((k, e) => k + (e.commits ?? 0), 0); - if (saved) story += ` Claude saves the work${saved > 1 ? ` in ${saved} commits` : ''} along the way.`; + if (saved) + story += ` Claude ${startsRepoAt(draft.events, events) ? 'starts a git repository and ' : ''}saves the work${ + saved > 1 ? ` in ${saved} commits` : '' + } along the way.`; director = `${plural(checks.length, 'check')} · ${plural(edits.length, 'edit')} · ${fmtCounts(panel.added, panel.removed)} · last: ${ lastCheck ? outcome(lastCheck) : 'running' }`; @@ -2684,7 +2713,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde if (saved) { director += ` · ${plural(saved, 'commit')}`; if (ok) - story = `${story.slice(0, -1)}, and Claude saves the work${checks.some((e) => e.ships === 'push') ? ' and sends it up' : ''}.`; + story = `${story.slice(0, -1)}, and Claude ${startsRepoAt(draft.events, events) ? 'starts a git repository and ' : ''}saves the work${ + checks.some((e) => e.ships === 'push') ? ' and sends it up' : '' + }.`; } break; } @@ -2703,7 +2734,7 @@ 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 && evs.every(emptyAnswer) - ? `Claude checks ${evs.length === 1 ? 'a few things' : 'several things'}; the last check finds nothing.` + ? `Claude looks a few things up; ${emptyAnswerWhat(evs[evs.length - 1])}, which is no error.` : 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 ?? '')) @@ -2731,6 +2762,10 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde (ships === 'push' ? /\[new branch\]|\S+\.\.\S+\s+\S+\s+->|\bset up to track\b|Everything up-to-date/.test(out) : /^\[[^\]\n]+ [0-9a-f]{7,}\]/m.test(out)); + // `git push && git describe; echo "exit=$?"` exits 0, but printed the failure after the push. + const pushedAt = ships === 'push' ? out.search(/\[new branch\]|\S+\.\.\S+\s+\S+\s+->|Everything up-to-date/) : -1; + const hiddenFail = + last?.ok === true && pushedAt >= 0 && /^(fatal|error): |\bexit[= ][1-9]\d*\b/m.test(out.slice(pushedAt)); if (savedAnyway) panel.ok = true; const commits = evs.reduce((n, e) => n + (e.commits ?? 0), 0); const inCommits = commits > 1 ? ` in ${commits} commits` : ''; @@ -2767,33 +2802,30 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde ) ); const stepAfter = looksOnly ? 'a check after that fails' : 'a step after that fails'; - story = savedAnyway - ? uploadOnly - ? `Claude ${uploads('the work')}, but ${stepAfter}.` - : ships === 'push' - ? `Claude saves the work${inCommits} and ${uploads('it')}, but ${stepAfter}.` - : `Claude saves the work${inCommits}, but ${stepAfter}.` - : panel.ok === false - ? ships === 'pr' - ? 'Claude tries to open a pull request, and the command fails.' + story = + savedAnyway || hiddenFail + ? uploadOnly + ? `Claude ${uploads('the work')}, but ${stepAfter}.` : ships === 'push' - ? 'Claude tries to upload the work, and the command fails.' - : 'Claude tries to save the work, and the command fails.' - : ships === 'pr' - ? 'Claude opens a pull request.' - : uploadOnly - ? `Claude ${uploads('the work')}.` + ? `Claude saves the work${inCommits}${said} and ${uploads('it')}, but ${stepAfter}.` + : `Claude saves the work${inCommits}${said}, but ${stepAfter}.` + : panel.ok === false + ? ships === 'pr' + ? 'Claude tries to open a pull request, and the command fails.' : ships === 'push' - ? localTarget && said - ? `Claude saves the work${said}, then ${uploads('it')}.` - : `Claude saves the work${inCommits} and ${uploads('it')}${said}.` - : `Claude saves the work${inCommits}${said}.`; + ? 'Claude tries to upload the work, and the command fails.' + : 'Claude tries to save the work, and the command fails.' + : ships === 'pr' + ? 'Claude opens a pull request.' + : uploadOnly + ? `Claude ${uploads('the work')}.` + : ships === 'push' + ? localTarget && said + ? `Claude saves the work${said}, then ${uploads('it')}.` + : `Claude saves the work${inCommits} and ${uploads('it')}${said}.` + : `Claude saves the work${inCommits}${said}.`; const wrote = unique(edits.map((d) => d.file)); - const firstAt = Math.min(...draft.events); - const earlier = events.slice(0, firstAt); - const startsRepo = - !earlier.some((e) => (e.commits ?? 0) > 0) && - [...earlier, ...evs].some((e) => e.tool === 'Bash' && /\bgit init\b(?![^;&|\n]*\s--bare\b)/.test(e.label)); + const startsRepo = startsRepoAt(draft.events, events); const lead = [ startsRepo ? 'starts a git repository' : '', wrote.length ? `writes ${fileWords(wrote)}` : '', @@ -2804,7 +2836,7 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde story = lead.length === 2 ? `Claude ${lead.join(', ')}, and ${rest}` - : `Claude ${lead[0]}${rest.split('"')[0].includes(' and ') ? ',' : ' and'} ${rest}`; + : `Claude ${lead[0]}${rest.replace(/"[^"]*"/g, '').includes(' and ') ? ',' : ' and'} ${rest}`; } director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${ edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : '' @@ -2975,7 +3007,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { ? { finishedLater: events.filter((e) => e.finishedLater && e.intent !== 'prompt').length } : {}), }; - const calls = events.filter((e) => !NOT_TOOL_CALLS.has(e.intent)); + const calls = events.filter((e) => !NOT_TOOL_CALLS.has(e.intent) && e.tool !== 'Advisor'); // 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; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 186f1364..d86e9bd0 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2524,7 +2524,7 @@ Object.assign(CodemanApp.prototype, { : page.superseded ? 'To be continued…' : page.noneWorked - ? 'Turn over' + ? 'Not done' : page.helpersRunning || page.finishedLater || page.events.some((e) => e.running && !e.helper) ? // Work was still out when the turn ended (and maybe still is): not a clean finish. 'Done for now' diff --git a/test/fixtures/story-corpus/bash-script-background-helper.expected.json b/test/fixtures/story-corpus/bash-script-background-helper.expected.json index e7c5910f..405fba8d 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 121460, - "toolCalls": 14, + "toolCalls": 13, "filesChanged": 2, "added": 96, "removed": 3, @@ -89,7 +89,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 2 minutes. Its helper finished later and reported back.", - "director": "turn_duration · 2 min 1 s · 14 tool calls · 2 files changed · 1 helper", + "director": "turn_duration · 2 min 1 s · 13 tool calls · 2 files changed · 1 helper", "balloon": "All the script tests pass.", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json index 2a519fcc..f2967104 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 72474, - "toolCalls": 7, + "toolCalls": 6, "filesChanged": 2, "added": 37, "removed": 0, @@ -77,7 +77,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", - "director": "turn_duration · 1 min 12 s · 7 tool calls · 2 files changed", + "director": "turn_duration · 1 min 12 s · 6 tool calls · 2 files changed", "balloon": "I wrote clamp and its five tests, and all five pass. I didn't commit CLAUDE.md, .claude/ or .codeman-agent-case.json.", "tests": null, "roundsOk": null, @@ -143,7 +143,7 @@ "complete": true, "interrupted": false, "durationMs": 66973, - "toolCalls": 10, + "toolCalls": 9, "filesChanged": 2, "added": 31, "removed": 1, @@ -237,7 +237,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", - "director": "turn_duration · 1 min 7 s · 10 tool calls · 2 files changed", + "director": "turn_duration · 1 min 7 s · 9 tool calls · 2 files changed", "balloon": "I added wrap() as planned, with 3 new tests. All 8 tests pass and ruff reports no problems.", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/errors-push-append.expected.json b/test/fixtures/story-corpus/errors-push-append.expected.json index dbe68a56..e3c0d936 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 130001, - "toolCalls": 12, + "toolCalls": 10, "filesChanged": 3, "added": 81, "removed": 0, @@ -125,7 +125,7 @@ "kind": "complete", "ok": null, "story": "Done in 2 minutes.", - "director": "turn_duration · 2 min 10 s · 12 tool calls · 3 files changed", + "director": "turn_duration · 2 min 10 s · 10 tool calls · 3 files changed", "balloon": "I built it test-first: the suite failed at collection before calc_stats.py existed, then passed once the module was written. I didn't push, didn't touch origin.git/ and didn't create a README, since none existed to keep in sync.", "tests": null, "roundsOk": null, @@ -164,7 +164,7 @@ { "kind": "loop", "ok": true, - "story": "Test first: the first run fails as planned, the second passes.", + "story": "The first run fails on purpose, as you asked; the second passes.", "director": "2 checks · 3 edits · +40 −1 · last: ok", "balloon": "Green: 18 passed and ruff is clean.", "tests": { @@ -181,7 +181,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work: \"add calc_stats.mode with smallest-value tie-breaking\", then copies it to origin.git, a folder on this computer.", + "story": "Claude saves the work: \"add calc_stats.mode with smallest-value tie-breaking\" and copies it to origin.git, a folder on this computer, but a check after that fails.", "director": "git commit -q -F - · git push -u origin HEAD", "balloon": null, "tests": null, @@ -268,7 +268,7 @@ { "kind": "save", "ok": true, - "story": "Claude saves the work and copies it to origin.git, a folder on this computer, but a check after that fails.", + "story": "Claude saves the work: \"add NOTES.md describing mode behavior\" and copies it to origin.git, a folder on this computer, but a check after that fails.", "director": "git commit -q -F - · git push · 1 error", "balloon": null, "tests": null, diff --git a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json index 6c379b68..40391aeb 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 83905, - "toolCalls": 10, + "toolCalls": 9, "filesChanged": 2, "added": 35, "removed": 1, @@ -83,7 +83,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", - "director": "turn_duration · 1 min 24 s · 10 tool calls · 2 files changed", + "director": "turn_duration · 1 min 24 s · 9 tool calls · 2 files changed", "balloon": "slugify.mjs and its test file are written, all 4 tests pass, and both files are committed. Set git config user.name if you want a different name on future commits.", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json index fc1659ce..998dba01 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 385054, - "toolCalls": 26, + "toolCalls": 24, "filesChanged": 13, "added": 798, "removed": 0, @@ -77,7 +77,7 @@ { "kind": "loop", "ok": true, - "story": "Build and test, 4 rounds. The tests pass every time; a layout check fails once, then passes.", + "story": "Build and test, 4 rounds. The tests pass every time; a formatting check fails once, then passes.", "director": "5 checks · 10 edits · +694 · last: ok", "balloon": "All 4 modules are done: 63 tests pass and ruff is clean.", "tests": { @@ -149,7 +149,7 @@ "kind": "complete", "ok": null, "story": "Done in 6 minutes.", - "director": "turn_duration · 6 min 25 s · 26 tool calls · 13 files changed", + "director": "turn_duration · 6 min 25 s · 24 tool calls · 13 files changed", "balloon": "The inventory/ package is built and committed. Decisions you may want to change: I committed CLAUDE.md. .claude/settings.local.json, .codeman-agent-case.json and the default inventory.json are gitignored. --csv is part of the CLI commit rather than its own commit, because you asked for it before that module existed. Known quirks: remove pear prints the name as you typed it, not as it was stored (\"Pear\"). Because of how the temporary file is created, the saved JSON file is readable only by its owner (permissions 0600).", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json index a4530773..32056d62 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 192853, - "toolCalls": 13, + "toolCalls": 11, "filesChanged": 3, "added": 220, "removed": 1, @@ -40,7 +40,7 @@ { "kind": "loop", "ok": true, - "story": "Fix and test, 2 rounds: a test fails and Claude fixes it, then a layout check fails and Claude fixes that; everything passes. Claude saves the work in 4 commits along the way.", + "story": "Fix and test, 2 rounds: a test fails and Claude fixes it, then a formatting check fails and Claude fixes that; everything passes. Claude starts a git repository and saves the work in 4 commits along the way.", "director": "3 checks · 5 edits · +218 −1 · last: ok · after 1 second opinion", "balloon": "All 19 tests pass, ruff check is clean, and the output matches real wc column for column.", "tests": { @@ -85,7 +85,7 @@ "kind": "complete", "ok": null, "story": "Done in 3 minutes.", - "director": "turn_duration · 3 min 13 s · 13 tool calls · 3 files changed", + "director": "turn_duration · 3 min 13 s · 11 tool calls · 3 files changed", "balloon": "I built wcount.py and test_wcount.py. All 19 tests pass (19 passed in 0.46s) and ruff check . is clean. What failed and how I fixed it: one test failed on the first run.", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/question-tests-first-push.expected.json b/test/fixtures/story-corpus/question-tests-first-push.expected.json index 42f4b940..4740f4f5 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 297209, - "toolCalls": 20, + "toolCalls": 18, "filesChanged": 6, "added": 221, "removed": 2, @@ -51,7 +51,7 @@ { "kind": "loop", "ok": true, - "story": "Test first: the first run fails as planned, the second passes.", + "story": "The first run fails on purpose, as you asked; the second passes.", "director": "2 checks · 4 edits · +151 −1 · last: ok · after 1 second opinion", "balloon": "All 19 test cases pass (13 test functions), and ruff check and ruff format both come back clean.", "tests": { @@ -94,7 +94,7 @@ { "kind": "save", "ok": true, - "story": "Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a check after that fails.", + "story": "Claude starts a git repository, saves the work: \"add kebab CLI that turns text into URL slugs\" and copies it to remote.git, a folder on this computer, but a check after that fails.", "director": "git commit -q -F - · git push -u origin main · 1 error", "balloon": null, "tests": null, @@ -120,7 +120,7 @@ "kind": "complete", "ok": null, "story": "Done in 5 minutes.", - "director": "turn_duration · 4 min 57 s · 20 tool calls · 6 files changed", + "director": "turn_duration · 4 min 57 s · 18 tool calls · 6 files changed", "balloon": "I've built the kebab slug tool in this folder, committed it, and pushed it to the new bare repo remote.git. If you want a different author, run git commit --amend --reset-author and push again. Not tested: the README says pip install . gives you a kebab command.", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index 8e69d1c6..5c852f19 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 103688, - "toolCalls": 8, + "toolCalls": 7, "filesChanged": 3, "added": 73, "removed": 0, @@ -88,7 +88,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 2 minutes. Its 2 helpers finished later and reported back.", - "director": "stop_hook_summary · 1 min 44 s · 8 tool calls · 3 files changed · 2 helpers", + "director": "stop_hook_summary · 1 min 44 s · 7 tool calls · 3 files changed · 2 helpers", "balloon": "I've set up the project, started both helpers, and the tests pass: node --test index.test.mjs ran 8 tests, 8 passed, 0 failed. No linter is set up in this project, so I didn't run one.", "tests": null, "roundsOk": null, @@ -230,7 +230,7 @@ "complete": false, "interrupted": true, "durationMs": 161073, - "toolCalls": 5, + "toolCalls": 4, "filesChanged": 4, "added": 148, "removed": 0, @@ -305,7 +305,7 @@ "kind": "complete", "ok": null, "story": "You stop Claude here, during its step 3 (new tests under test/). Saved before the stop: 2 commits.", - "director": "interrupted · 2 min 41 s · 5 tool calls · 4 files changed", + "director": "interrupted · 2 min 41 s · 4 tool calls · 4 files changed", "balloon": "Step 3: new tests under test/.", "tests": null, "roundsOk": null, diff --git a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json index 59b81325..65e18264 100644 --- a/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json +++ b/test/fixtures/story-corpus/text-plan-dry-run-helper.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 163647, - "toolCalls": 11, + "toolCalls": 10, "filesChanged": 5, "added": 255, "removed": 0, @@ -62,7 +62,7 @@ { "kind": "check", "ok": true, - "story": "Claude writes test_csvstat.py and pyproject.toml; the tests pass, and Claude saves the work.", + "story": "Claude writes test_csvstat.py and pyproject.toml; the tests pass, and Claude starts a git repository and saves the work.", "director": "python3 -m pytest -q 2>&1 → ok ×2 · 17 passed · writes 2 files · 1 commit", "balloon": "Code and tests are committed.", "tests": { @@ -103,7 +103,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 3 minutes. Its helper finished later and reported back.", - "director": "turn_duration · 2 min 44 s · 11 tool calls · 5 files changed · 1 helper", + "director": "turn_duration · 2 min 44 s · 10 tool calls · 5 files changed · 1 helper", "balloon": "I've built csvstat.py, a command-line tool that prints a CSV's row count and the min, max and mean of each column. Its 17 pytest tests pass, the linter (ruff) is clean, and the dry run on a sample CSV worked. A background helper is writing README.md; I didn't wait for it, so it isn't confirmed yet. Problem: numbers are shown to only 6 significant digits. Once it has finished, the fix is a one-line formatting change and an update to the README example; say if you want it.", "tests": null, "roundsOk": null, 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 8561e6ee..0161095c 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -5,7 +5,7 @@ "complete": true, "interrupted": false, "durationMs": 159207, - "toolCalls": 10, + "toolCalls": 8, "filesChanged": 3, "added": 236, "removed": 0, @@ -40,7 +40,7 @@ { "kind": "run", "ok": null, - "story": "Claude checks a few things; the last check finds nothing.", + "story": "Claude looks a few things up; the last one finds a git setting that is not set, which is no error.", "director": "Bash · grep -oE '\"(permissions|env|allow|deny)\"|[A-Za-z_]*(TOKEN|K… · 1 error", "balloon": null, "tests": null, @@ -91,7 +91,7 @@ "kind": "complete", "ok": null, "story": "Done in 3 minutes.", - "director": "turn_duration · 2 min 39 s · 10 tool calls · 3 files changed", + "director": "turn_duration · 2 min 39 s · 8 tool calls · 3 files changed", "balloon": "I built the pomodoro timer. All 7 tests pass under node --test, and everything is committed on main as c4e7b0b feat: add pomodoro timer with tests. Opening the page: it won't work if you double-click index.html. Serve the folder instead, for example with python3 -m http.server in the folder, then open http://localhost:8000. I didn't open it in a browser; I only checked that its script is valid.", "tests": null, "roundsOk": null, diff --git a/test/story-corpus.test.ts b/test/story-corpus.test.ts index 0f7c5a56..6d6fe1f3 100644 --- a/test/story-corpus.test.ts +++ b/test/story-corpus.test.ts @@ -77,7 +77,7 @@ describe('Story Mode on real sessions', () => { expect(panel('python-cli-fix-loop', 0, 'loop')).toMatchObject({ // The failing test was a real bug, the second failure only ruff: said apart, with the 4 commits made inside. story: - 'Fix and test, 2 rounds: a test fails and Claude fixes it, then a layout check fails and Claude fixes that; everything passes. Claude saves the work in 4 commits along the way.', + 'Fix and test, 2 rounds: a test fails and Claude fixes it, then a formatting check fails and Claude fixes that; everything passes. Claude starts a git repository and saves the work in 4 commits along the way.', roundsOk: [false, false, true], }); }); @@ -107,7 +107,7 @@ describe('Story Mode on real sessions', () => { it('a commit at the end of a test one-liner is kept, and a background helper reports back in 9 s', () => { expect(panel('text-plan-dry-run-helper', 0, 'check')?.story).toBe( - 'Claude writes test_csvstat.py and pyproject.toml; the tests pass, and Claude saves the work.' + 'Claude writes test_csvstat.py and pyproject.toml; the tests pass, and Claude starts a git repository and saves the work.' ); expect(panel('text-plan-dry-run-helper', 0, 'plan')?.story).toBe('Claude writes a plan: 4 steps.'); expect(pagesOf('text-plan-dry-run-helper')[1]).toMatchObject({ origin: 'task', durationMs: 9008 }); @@ -127,7 +127,7 @@ describe('Story Mode on real sessions', () => { // One commit, then a push in a command of its own: one commit, not two. // The folder had no repository: the first save starts one, and the reader is told. story: - 'Claude starts a git repository, saves the work and copies it to remote.git, a folder on this computer, but a check after that fails.', + 'Claude starts a git repository, saves the work: "add kebab CLI that turns text into URL slugs" and copies it to remote.git, a folder on this computer, but a check after that fails.', }); }); @@ -135,14 +135,16 @@ describe('Story Mode on real sessions', () => { const [first, second, third] = pagesOf('errors-push-append'); expect(first.panels.find((p) => p.kind === 'check')?.story).toBe("The tests don't even load, as you asked."); expect(second.panels.find((p) => p.kind === 'loop')).toMatchObject({ - story: 'Test first: the first run fails as planned, the second passes.', + // The prompt asked for it ("it should fail because mode is missing"): the user's idea, not Claude's. + story: 'The first run fails on purpose, as you asked; the second passes.', roundsOk: [false, true], }); expect(second.panels.find((p) => p.kind === 'save')?.story).toBe( - 'Claude saves the work: "add calc_stats.mode with smallest-value tie-breaking", then copies it to origin.git, a folder on this computer.' + // `git describe --tags` failed after the push; `; echo "exit=$?"` hid it from the exit code, not from the output. + 'Claude saves the work: "add calc_stats.mode with smallest-value tie-breaking" and copies it to origin.git, a folder on this computer, but a check after that fails.' ); expect(third.panels.find((p) => p.kind === 'save')?.story).toBe( - 'Claude saves the work and copies it to origin.git, a folder on this computer, but a check after that fails.' + 'Claude saves the work: "add NOTES.md describing mode behavior" and copies it to origin.git, a folder on this computer, but a check after that fails.' ); }); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 42165391..311ace64 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -994,10 +994,13 @@ describe('buildStoryPage', () => { 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.' + 'Claude looks a few things up; the last one finds a git setting that is not set, which is no error.' ); 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.' + 'Claude looks a few things up; the last search finds no match, which is no error.' + ); + expect(run('node a.js; which ffmpeg', 'Exit code 1\nok')).toBe( + 'Claude looks a few things up; the last one finds a program that is not installed, which is no error.' ); // 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.'); @@ -1207,6 +1210,52 @@ describe('buildStoryPage', () => { expect(keyKindOf('Decisions you may want to change: I committed CLAUDE.md.')).toBe('offer'); }); + it('a failure after the push that `; echo` hid from the exit code still shows; a second opinion is no tool call', () => { + const page = pageOf([ + prompt('ship it'), + ...bash('b1', 'git commit -qm "add a"', 'Commit', ''), + ...bash( + 'b2', + 'git push -u origin HEAD && git describe --tags; echo "exit=$?"', + 'Push', + 'To ./origin.git\n * [new branch] HEAD -> master\nfatal: No names found, cannot describe anything.\nexit=128' + ), + { + type: 'assistant', + timestamp: '2026-10-18T09:41:20.000Z', + message: { + content: [ + { type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} }, + { type: 'advisor_tool_result', tool_use_id: 'srv1', content: { type: 'advisor_redacted_result' } }, + ], + }, + }, + end(1000), + ]); + expect(page.panels.find((p) => p.kind === 'save')!.caption.story).toBe( + 'Claude saves the work: "add a" and copies it to origin.git, a folder on this computer, but a check after that fails.' + ); + expect(page.toolCalls).toBe(2); + }); + + it('a failing first run the prompt asked for, in its own words, is "as you asked"', () => { + const loop = (ask: string) => + pageOf([ + prompt(ask), + ...edit('e1', '/w/test_a.py', ['+def test_a(): assert f()']), + ...bash('b1', 'pytest -q', 'Test', '1 failed in 0.1s', true), + ...edit('e2', '/w/a.py', ['+def f(): return True']), + ...bash('b2', 'pytest -q', 'Test', '1 passed in 0.1s'), + end(1000), + ]).panels.find((p) => p.kind === 'loop')!.caption.story; + for (const ask of [ + 'Add mode(); run the tests, it should fail because mode is missing.', + 'Write the tests and run them to watch them fail.', + ]) + expect(loop(ask), ask).toBe('The first run fails on purpose, as you asked; the second passes.'); + expect(loop('Add mode() with tests.')).not.toMatch(/as you asked/); + }); + it('a check after a push is named a check, and tests in a fresh copy say so', () => { const page = pageOf([ prompt('ship it'), @@ -1390,7 +1439,7 @@ describe('buildStoryPage', () => { const loop = page.panels.find((p) => p.kind === 'loop')!; expect(loop.roundsOk).toEqual([true, false, true]); expect(loop.caption.story).toBe( - 'Build and test, 2 rounds. The tests pass every time; a layout check fails once, then passes.' + 'Build and test, 2 rounds. The tests pass every time; a formatting check fails once, then passes.' ); });