fix(story-mode): whose idea a failure was, new repositories everywhere, honest counts (review round 10)

From the person who asked and a fact-checker, reading fresh pages:
- "as planned" for failing first runs the prompt asked for ("it should
  fail because mode is missing", "run them to watch them fail"): those
  read "The first run fails on purpose, as you asked".
- A repository Claude started was told only on a save panel; the first
  save inside a loop or a test run says it too ("Claude starts a git
  repository and saves the work in 4 commits along the way.").
- `git push && git describe --tags; echo "exit=$?"` exits 0, so a
  failure after the push went unsaid; what it printed after the push
  (`fatal:`, `exit=128`) now counts. The commit subject stays on that
  caption too.
- Tool-call counts no longer include second opinions.
- "a layout check" is "a formatting check"; "Turn over" (a game word)
  is "Not done".
- "the last check finds nothing" told nothing: it now says what came
  back empty ("the last one finds a git setting that is not set, which is
  no error").

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 17:16:03 +02:00
1 parent 05dc6fbd95
commit 30b907860d
15 files changed
+161 -78

No files matched your search

+1 -1
View File
@@ -1056,7 +1056,7 @@ What Story Mode reads (the opt-in header button, App Settings → Header buttons
### Routes
- `GET /api/sessions/:id/story[?turn=<id>]`: 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=<id>` 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=<id>]`: 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=<id>` 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, `<conversation>/subagents/agent-<agentId>.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 `<conversation id>.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).
+66 -34
View File
@@ -2011,7 +2011,9 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] {
}
const NOT_TOOL_CALLS = new Set<StoryIntent>(['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;
+1 -1
View File
@@ -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'
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
@@ -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,
+8 -6
View File
@@ -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.'
);
});
+52 -3
View File
@@ -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.'
);
});