From d3c55e04a329f97c5eff87e37e854ac3e16e06eb Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 17:44:06 +0200 Subject: [PATCH] fix(story-mode): what the captions blurred for a reader who skims (review round 11) From an engineering manager skimming titles and captions: - "but a check after that fails" under "Mission complete!" where the prompt said that step may fail now adds ", as you said it might". - Saves no prompt of the turn asked for (a project rule, or Claude's own idea) were indistinguishable from requested ones: the turn's first such save carries a small line, "a save your prompt here did not ask for". "save its log with tee" no longer counts as asking to save the work. - The first save on a branch Claude created says which: "saves the work on a new branch (feat/csvstat)". - "Claude changes 3 files" hid an edit to your CLAUDE.md: a panel that wrote some files and patched others says both ("Claude writes README.md and .gitignore and changes CLAUDE.md."); a file created and then edited in the same panel is written. - A turn with a problem left and work still out read "Done for now"; the catch now wins the title. - A helper's report that is only a path reads "reports back with the file it wrote: README.md." Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- src/story-mode.ts | 95 ++++++++++++++++--- src/types/story.ts | 2 + src/web/public/story-mode.js | 35 ++++--- .../background-helpers-reports.expected.json | 34 ++++++- ...ash-script-background-helper.expected.json | 17 ++++ .../compact-then-plan-mode.expected.json | 17 ++++ .../errors-push-append.expected.json | 27 +++++- .../failing-test-on-purpose.expected.json | 10 ++ .../long-turn-prompt-added.expected.json | 23 +++++ .../python-cli-fix-loop.expected.json | 6 ++ .../question-tests-first-push.expected.json | 11 ++- .../teammates-then-interrupt.expected.json | 24 +++++ .../text-plan-dry-run-helper.expected.json | 13 ++- .../web-page-node-test.expected.json | 7 ++ test/story-corpus.test.ts | 21 +++- test/story-mode-ui.test.ts | 23 +++++ test/story-mode.test.ts | 18 ++++ 18 files changed, 345 insertions(+), 40 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 999f33c2..803ccbca 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1082,7 +1082,7 @@ A Bash call sent to the background (its result is only `Command running in backg 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` (an advisor call that returned an error is drawn as a second opinion that got no answer). 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`). +- `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` (an advisor call that returned an error is drawn as a second opinion that got no answer). `unaskedSave` marks the turn's first save when no prompt of the turn asked to commit, push or save the work (a project rule, or Claude's own idea); the page says so in a small line, never as a fault. 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 helper's report keeps its own words there (`"" reports: …`, its first sentence and key points, absolute paths shortened to their last two parts). 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; 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`). diff --git a/src/story-mode.ts b/src/story-mode.ts index 4ecbb7ad..adfdb857 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -2390,6 +2390,25 @@ function emptyAnswerWhat(e: StoryEvent): string { : 'the last search finds no match'; } +/** Words in a prompt that ask for (or mention) saving the work: then a save is no surprise. */ +const ASKS_TO_SAVE = + /\b(commit|commits|committed|git|push|pushed|pull request|check(ed)? in|ship|release|merge|version control)\b|\bpr\b|\b(save|saving|saved) (the |your |all |this |my |our )?(work|changes|progress|code|project)\b/i; + +/** The branch Claude created for this save (`git checkout -b x`, `git switch -c x`), when it is the first save on it. */ +function newBranchAt(indices: number[], events: StoryEvent[]): string | undefined { + const firstAt = Math.min(...indices); + const lastAt = Math.max(...indices); + for (let i = lastAt; i >= 0; i--) { + const m = + events[i].tool === 'Bash' && + /\bgit (?:checkout|switch)(?:\s+-q)?\s+(?:-b|-c|--create)\s+([\w./-]+)/.exec(events[i].label); + if (!m) continue; + // Nothing committed between the branch and this panel: this is its first save. + return events.slice(i + 1, firstAt).some((e) => (e.commits ?? 0) > 0) ? undefined : m[1]; + } + return undefined; +} + /** 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); @@ -2461,9 +2480,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde const report = origin === 'task' && evs[0]?.helper?.result?.trim(); if (panel.balloon && report) { // A report that is only a path says which file it wrote: its name is enough. - const said = ( - /^\S+$/.test(report) && report.includes('/') ? basename(report) : closingText(report, keyPointsOf(report)) - ) + const pathOnly = /^\S+$/.test(report) && report.includes('/'); + const said = (pathOnly ? basename(report) : closingText(report, keyPointsOf(report))) // An absolute path is mostly room: its last two parts ("tests/test_extra.py") say which file. // Run on the clipped balloon (560 characters at most), so the scan stays short. .replace(/(? n + e.removed, 0); if (draft.kind === 'fix') { const created = unique(edits.filter((e) => e.created).map((e) => e.file)).length; + // Some files written whole (new ones), others patched: "writes README.md and changes CLAUDE.md". + // A file created here is written, even when Claude then edited it in the same panel. + const wholeOf = (f: string) => { + const mine = edits.filter((d) => d.file === f); + return ( + mine.some((d) => d.created) || + mine.every((d) => d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines)) + ); + }; + const wholeList = files.filter(wholeOf); + const mixed = + wholeList.length && wholeList.length < files.length + ? { whole: wholeList, patched: files.filter((f) => !wholeList.includes(f)) } + : undefined; const whole = edits.length > 0 && edits.every((d) => d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines))); @@ -2586,9 +2621,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : `Claude writes ${plural(created, 'new file')}.` : whole && files.length ? `Claude writes ${fileWords(files)}.` - : files.length - ? `Claude changes ${fileWords(files)}.` - : 'Claude changes a file.'; + : mixed + ? `Claude writes ${fileWords(mixed.whole)} and changes ${fileWords(mixed.patched)}.` + : files.length + ? `Claude changes ${fileWords(files)}.` + : 'Claude changes a file.'; const perFile = files.slice(0, 3).map((f) => { const mine = edits.filter((e) => e.file === f); return `${basename(f)} ${fmtCounts( @@ -2728,8 +2765,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde director += ` · ${plural(saved, 'commit')}`; if (ok) story = `${story.slice(0, -1)}, ${startsRepoAt(draft.events, events) ? 'then Claude starts a git repository and' : 'and Claude'} saves the work${ - checks.some((e) => e.ships === 'push') ? ' and sends it up' : '' - }.`; + newBranchAt(draft.events, events) ? ` on a new branch (${newBranchAt(draft.events, events)})` : '' + }${checks.some((e) => e.ships === 'push') ? ' and sends it up' : ''}.`; } break; } @@ -2815,7 +2852,20 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde s ) ); - const stepAfter = looksOnly ? 'a check after that fails' : 'a step after that fails'; + // The prompt named that step and said it may fail ("git describe may fail, that is fine"). + const asked = events + .filter((e) => e.intent === 'prompt') + .map((e) => e.label) + .join('\n'); + const allowed = + after.some((s) => { + const head = s.split(/\s+/).slice(0, 2).join(' '); + return head.length > 3 && asked.includes(head); + }) && + /\b(may|might|can|could|will) (fail|error)\b|\bexpected to fail\b|\b(fine|ok|okay) if it fails\b|\b(that is|that's) fine\b/i.test( + asked + ); + const stepAfter = `${looksOnly ? 'a check after that fails' : 'a step after that fails'}${allowed ? ', as you said it might' : ''}`; story = savedAnyway || hiddenFail ? uploadOnly @@ -2840,18 +2890,27 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : `Claude saves the work${inCommits}${said}.`; const wrote = unique(edits.map((d) => d.file)); const startsRepo = startsRepoAt(draft.events, events); + const wroteWhole = wrote.filter((f) => + edits + .filter((d) => d.file === f) + .every((d) => d.created || (d.hunks.length > 0 && d.hunks.every((h) => !h.oldStart && !h.oldLines))) + ); + const wrotePatched = wrote.filter((f) => !wroteWhole.includes(f)); const lead = [ startsRepo ? 'starts a git repository' : '', - wrote.length ? `writes ${fileWords(wrote)}` : '', + wroteWhole.length ? `writes ${fileWords(wroteWhole)}` : '', + wrotePatched.length ? `changes ${fileWords(wrotePatched)}` : '', ].filter(Boolean); if (lead.length && story.startsWith('Claude ')) { // "starts a git repository, saves the work and uploads it": never "and … and". const rest = story.slice(7); story = - lead.length === 2 + lead.length >= 2 ? `Claude ${lead.join(', ')}, and ${rest}` : `Claude ${lead[0]}${rest.replace(/"[^"]*"/g, '').includes(' and ') ? ',' : ' and'} ${rest}`; } + const branch = newBranchAt(draft.events, events); + if (branch && commits) story = story.replace('saves the work', `saves the work on a new branch (${branch})`); director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${ edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : '' }`; @@ -2888,6 +2947,14 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde case 'complete': break; } + const savesHere = draft.events.some((i) => (events[i].commits ?? 0) > 0); + if ( + savesHere && + turn.origin === 'human' && + !events.slice(0, Math.min(...draft.events)).some((e) => (e.commits ?? 0) > 0) && + !events.some((e) => e.intent === 'prompt' && ASKS_TO_SAVE.test(e.label)) + ) + panel.unaskedSave = true; if (panel.secondOpinion) { // Asked before the step, after it (nothing following), or in the middle of a step's calls. const work = draft.events.filter((i) => events[i].tool !== 'Advisor'); diff --git a/src/types/story.ts b/src/types/story.ts index a3a5a6f4..6c7839ca 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -174,6 +174,8 @@ export interface StoryPanel { removed?: number; ok?: boolean; rounds?: number; + /** The turn's first save, when no prompt of the turn asked for one (a project rule, or Claude's own idea). */ + unaskedSave?: boolean; /** Second opinions (the advisor) Claude asked for right before this step: not a step of their own. */ secondOpinion?: number; /** A loop's test runs in order: whether each passed, `null` for one still running. */ diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 919f79d1..230e8d8c 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2505,16 +2505,20 @@ Object.assign(CodemanApp.prototype, { // A call that came back with an error brought no opinion: say so, never that one was given. const unanswered = asked.length > 0 && asked.every((i) => page.events[i].ok === false); const ask = unanswered ? 'tries to ask' : 'asks'; - rt.capNote.hidden = !p.secondOpinion; - rt.capNote.textContent = !p.secondOpinion - ? '' - : `${ - before - ? `after ${unanswered ? 'trying to ask' : 'asking'}` - : trailing - ? `then ${ask}` - : `along the way, ${ask}` - } a stronger AI model for a second opinion${unanswered ? ' (no answer came)' : ''}`; + const notes = [ + p.unaskedSave ? 'a save your prompt here did not ask for' : '', + p.secondOpinion + ? `${ + before + ? `after ${unanswered ? 'trying to ask' : 'asking'}` + : trailing + ? `then ${ask}` + : `along the way, ${ask}` + } a stronger AI model for a second opinion${unanswered ? ' (no answer came)' : ''}` + : '', + ].filter(Boolean); + rt.capNote.hidden = !notes.length; + rt.capNote.textContent = notes.join('; '); } rt.el.classList.toggle('is-live', !!p.live); // Named by its place only: the caption inside is read as the panel's content, once. @@ -2537,11 +2541,12 @@ Object.assign(CodemanApp.prototype, { ? 'To be continued…' : page.noneWorked ? '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' - : page.openNote === 'catch' - ? 'Done, with a catch' + : page.openNote === 'catch' + ? // A problem left beats work still out: it is what the reader must not miss. + 'Done, with a catch' + : 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' : page.openNote ? 'Done, with a note' : page.asks diff --git a/test/fixtures/story-corpus/background-helpers-reports.expected.json b/test/fixtures/story-corpus/background-helpers-reports.expected.json index f0864094..77962270 100644 --- a/test/fixtures/story-corpus/background-helpers-reports.expected.json +++ b/test/fixtures/story-corpus/background-helpers-reports.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 87 words", "balloon": "Build a tiny Python word-count tool here (no git, no installs). First, use the Agent tool TWICE with run_in_background: true to start two background helper agents in parallel: helper A writes README.md describing wordcount.py (usage: python3 wordcount.py FILE prints lines, words, chars), helper B writes extra pytest tests in tests/test_extra.py for count(text) returning a (lines, words, chars) tu…", "tests": null, @@ -30,6 +31,7 @@ "kind": "backup", "ok": null, "story": "Claude sends 2 helpers off to work in the background.", + "unaskedSave": false, "director": "Agent ×2 · general-purpose · 8 calls inside", "balloon": "Starting both helpers in parallel first.", "tests": null, @@ -41,6 +43,7 @@ "kind": "fix", "ok": null, "story": "Claude writes the new files wordcount.py and test_basic.py.", + "unaskedSave": false, "director": "Write ×2 · wordcount.py +23 · test_basic.py +21", "balloon": null, "tests": null, @@ -52,6 +55,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q 2>&1 → ok · 4 passed", "balloon": null, "tests": { @@ -66,6 +70,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 36 seconds. Its 2 helpers finished later and reported back.", + "unaskedSave": false, "director": "turn_duration · 36 s · 5 tool calls · 2 files changed · 2 helpers", "balloon": "I wrote wordcount.py and tests/test_basic.py, and the one python3 -m pytest -q run passed 4 of 4 tests. Only 4 tests ran, so tests/test_extra.py from helper B wasn't there yet and those tests haven't been run.", "tests": null, @@ -95,8 +100,9 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 6 words · task", - "balloon": "\"Helper A: write README.md\" reports: README.md", + "balloon": "\"Helper A: write README.md\" reports back with the file it wrote: README.md.", "tests": null, "roundsOk": null, "printed": null, @@ -106,6 +112,7 @@ "kind": "investigate", "ok": null, "story": "Claude checks README.md.", + "unaskedSave": false, "director": "Bash · cat /work/cases/story-r6-b/README.md", "balloon": null, "tests": null, @@ -117,6 +124,7 @@ "kind": "complete", "ok": null, "story": "Done in 14 seconds.", + "unaskedSave": false, "director": "turn_duration · 14 s · 1 tool call", "balloon": "Helper A has finished writing README.md, and I checked it against the code. It doesn't mention that running the tool without exactly one file prints a usage message and exits with code 2; I can add that line if you want it.", "tests": null, @@ -146,6 +154,7 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 8 words · task", "balloon": "\"Helper B: write extra pytest tests\" reports: I wrote the file: tests/test_extra.py It has 18 test cases and I worked out each expected value by hand. I haven't run it against the real wordcount.py.", "tests": null, @@ -157,6 +166,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q 2>&1 → ok · 22 passed", "balloon": "Helper B finished; running the full suite against the real wordcount.py now.", "tests": { @@ -171,6 +181,7 @@ "kind": "complete", "ok": null, "story": "Done in 11 seconds.", + "unaskedSave": false, "director": "turn_duration · 11 s · 1 tool call", "balloon": "Both helpers have finished. Against the real wordcount.py, all 22 tests pass: my 4 in tests/test_basic.py and helper B's 18 in tests/test_extra.py.", "tests": null, @@ -200,6 +211,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 93 words", "balloon": "Follow-up, no git: first start ONE background helper with the Agent tool (run_in_background: true) that writes CHANGELOG.md slowly: it must run sleep 20 with Bash, then Write CHANGELOG.md with one entry, then run sleep 20 again, then add a second entry with the Edit tool. Next, start the full test suite in the background with the Bash tool's run_in_background: true parameter, as: sleep 30; python…", "tests": null, @@ -211,6 +223,7 @@ "kind": "backup", "ok": null, "story": "Claude sends 1 helper off to work in the background.", + "unaskedSave": false, "director": "Agent · general-purpose · 4 calls inside", "balloon": null, "tests": null, @@ -222,6 +235,7 @@ "kind": "check", "ok": true, "story": "Claude starts the tests in the background; later they pass.", + "unaskedSave": false, "director": "python3 -m pytest -q → ok", "balloon": null, "tests": null, @@ -233,6 +247,7 @@ "kind": "fix", "ok": null, "story": "Claude changes 3 files.", + "unaskedSave": false, "director": "Write ×2 · Bash · wordcount.py +11 −4 · test_basic.py +7 · README.md ±0", "balloon": null, "tests": null, @@ -244,6 +259,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 52 seconds. Its helper finished later and reported back. Its background command finished later.", + "unaskedSave": false, "director": "turn_duration · 52 s · 5 tool calls · 3 files changed · 1 helper", "balloon": "I added the --json flag and started the helper and the test run as you asked. The helper and the tests are still running in the background; I haven't waited for either, so I don't have results yet. No git, as you asked.", "tests": null, @@ -273,6 +289,7 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 14 words · task", "balloon": "Background command \"Run full test suite after a 30s delay\" completed (exit code 0)", "tests": null, @@ -284,6 +301,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "tail -5 /tmp/claude/-work-cases-story-r6-b/a1a47282-e3ca-45… → ok · 23 passed", "balloon": null, "tests": { @@ -298,6 +316,7 @@ "kind": "complete", "ok": null, "story": "Done in 7 seconds.", + "unaskedSave": false, "director": "turn_duration · 7 s · 1 tool call", "balloon": "The background test run finished and all 23 tests passed, including the new --json test.", "tests": null, @@ -327,8 +346,9 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 6 words · task", - "balloon": "\"Helper: write CHANGELOG.md slowly\" reports: CHANGELOG.md", + "balloon": "\"Helper: write CHANGELOG.md slowly\" reports back with the file it wrote: CHANGELOG.md.", "tests": null, "roundsOk": null, "printed": null, @@ -338,6 +358,7 @@ "kind": "investigate", "ok": null, "story": "Claude checks CHANGELOG.md.", + "unaskedSave": false, "director": "Bash · cat /work/cases/story-r6-b/CHANGELOG.md", "balloon": null, "tests": null, @@ -349,6 +370,7 @@ "kind": "complete", "ok": null, "story": "Done in 8 seconds.", + "unaskedSave": false, "director": "turn_duration · 8 s · 1 tool call", "balloon": "Everything you asked for this round is done. The helper used four tools, one for each step you asked for, so it looks like it followed the sleep, write, sleep, edit sequence.", "tests": null, @@ -378,6 +400,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 26 words", "balloon": "Small follow-up, no git: run python3 -m pytest -q once, then show the first 5 lines of CHANGELOG.md with head -5, then reply with one sentence.", "tests": null, @@ -389,6 +412,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q 2>&1 → ok · 23 passed", "balloon": null, "tests": { @@ -403,6 +427,7 @@ "kind": "investigate", "ok": null, "story": "Claude reads CHANGELOG.md.", + "unaskedSave": false, "director": "Bash · head -5 /work/cases/story-r6-b/CHANGELOG.md", "balloon": null, "tests": null, @@ -414,6 +439,7 @@ "kind": "complete", "ok": null, "story": "Done in 13 seconds.", + "unaskedSave": false, "director": "turn_duration · 13 s · 2 tool calls", "balloon": "All 23 tests passed, and the first five lines of CHANGELOG.md are the # Changelog title, the ## 2026-10-18 heading and the wordcount.py entry, with the --json entry on line 6, just past what head -5 shows.", "tests": null, @@ -443,6 +469,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 8 words", "balloon": "Reply with just the word OK, no tools.", "tests": null, @@ -454,6 +481,7 @@ "kind": "complete", "ok": null, "story": "Done in 3 seconds.", + "unaskedSave": false, "director": "turn_duration · 3 s · 0 tool calls", "balloon": "OK", "tests": null, @@ -483,6 +511,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 8 words", "balloon": "Reply with just the word DONE, no tools.", "tests": null, @@ -494,6 +523,7 @@ "kind": "complete", "ok": null, "story": "Done in 5 seconds.", + "unaskedSave": false, "director": "turn_duration · 5 s · 0 tool calls", "balloon": "DONE", "tests": null, 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 405fba8d..601f81d0 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 65 words", "balloon": "First write a short todo list for this job, then build a bash script rename_by_date.sh in this folder that renames *.jpg files to their modification time as YYYY-MM-DD_HHMMSS.jpg with a --dry-run flag, and while you do that use the Agent tool to have one helper agent write README.md for the script; finally test it on a few sample files you create in a samples folder.", "tests": null, @@ -30,6 +31,7 @@ "kind": "plan", "ok": null, "story": "Claude writes a plan: 5 steps.", + "unaskedSave": false, "director": "written as text · 5 steps", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git log --oneline | head; which shellcheck; bash…", "balloon": null, "tests": null, @@ -52,6 +55,7 @@ "kind": "backup", "ok": null, "story": "Claude sends 1 helper off to work in the background.", + "unaskedSave": false, "director": "Agent · general-purpose · 6 calls inside · after 1 second opinion (no answer)", "balloon": null, "tests": null, @@ -63,6 +67,7 @@ "kind": "fix", "ok": null, "story": "Claude changes rename_by_date.sh and -dash.jpg.", + "unaskedSave": false, "director": "Write · Edit ×3 · Bash · rename_by_date.sh +95 −3 · -dash.jpg +1", "balloon": "Now I'll create the samples and run the dry run.", "tests": null, @@ -74,6 +79,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash ×5 · rm -- ./-- && S=/tmp/claude/-work-cases-story-review-c/740e…", "balloon": "My mk -- ... helper call left a stray file named --. I'll remove it, then run the dry run and save a listing to confirm nothing changed.", "tests": null, @@ -89,6 +95,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 2 minutes. Its helper finished later and reported back.", + "unaskedSave": false, "director": "turn_duration · 2 min 1 s · 13 tool calls · 2 files changed · 1 helper", "balloon": "All the script tests pass.", "tests": null, @@ -118,6 +125,7 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 7 words · task", "balloon": "\"Write README for rename script\" reports: I wrote the README at story-review-c/README.md. It covers the description, requirements, usage with dry-run, real-run and re-run sample output, name clashes, notes and limitations, and exit codes. The script wasn't there when I started, but it appeared while I was writing, so I read it (without running or editing it) and matched the README to it: it agrees with the spec, and I added the exact summary line (Done: N renamed, M skipped.), the -- option for a directory name starting with -, the…", "tests": null, @@ -129,6 +137,7 @@ "kind": "investigate", "ok": null, "story": "Claude checks README.md.", + "unaskedSave": false, "director": "Read", "balloon": null, "tests": null, @@ -140,6 +149,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash · mkdir -p \"$S/ro\" && printf x > \"$S/ro/q.jpg\" && chmod a-w \"…", "balloon": "The README matches the script.", "tests": null, @@ -155,6 +165,7 @@ "kind": "complete", "ok": null, "story": "Done in 21 seconds.", + "unaskedSave": false, "director": "turn_duration · 21 s · 2 tool calls", "balloon": "I built rename_by_date.sh, the helper agent wrote README.md, and the script passed every test I ran on sample files in samples/. Nothing is committed because this folder isn't a git repository, even though the session context said it was. Say if you want me to run git init and commit. Only lowercase .jpg is matched, as you asked.", "tests": null, @@ -184,6 +195,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 26 words", "balloon": "Add a --prefix option to rename_by_date.sh that puts a given text in front of the new names, and show me a dry run on the samples.", "tests": null, @@ -195,6 +207,7 @@ "kind": "investigate", "ok": null, "story": "Claude reads rename_by_date.sh.", + "unaskedSave": false, "director": "Read", "balloon": null, "tests": null, @@ -206,6 +219,7 @@ "kind": "fix", "ok": null, "story": "Claude changes rename_by_date.sh.", + "unaskedSave": false, "director": "Edit ×3 · rename_by_date.sh +21 −6", "balloon": null, "tests": null, @@ -217,6 +231,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash ×2 · bash -n \"$P/rename_by_date.sh\" && echo \"syntax ok\"", "balloon": "Here's the dry run you asked for on the samples, plus a check that nothing on disk changed:", "tests": null, @@ -232,6 +247,7 @@ "kind": "fix", "ok": null, "story": "Claude changes README.md.", + "unaskedSave": false, "director": "Edit ×3 · README.md +18 −2", "balloon": "All passing.", "tests": null, @@ -243,6 +259,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", + "unaskedSave": false, "director": "turn_duration · 1 min 14 s · 9 tool calls · 2 files changed", "balloon": "I added a --prefix option to rename_by_date.sh. Suffix swap: the two files with the same time (2024-05-01_093000*) would trade their _1 suffix. … Making the script sort file names the plain byte-by-byte way would fix it for up to 10 files with the same time. Want me to add that?", "tests": 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 3c5e7f24..ad95b141 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 41 words", "balloon": "Write mathx.py with a function clamp(value, low, high) that returns value limited to the range low..high and raises ValueError when low > high, then write test_mathx.py with exactly 5 pytest tests for it, and run them with python3 -m pytest -q.", "tests": null, @@ -30,6 +31,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git status && git log --oneline -5 2>&1; python3… · 1 error", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "fix", "ok": null, "story": "Claude writes the new files mathx.py and test_mathx.py.", + "unaskedSave": false, "director": "Write ×2 · mathx.py +11 · test_mathx.py +26 · after 1 second opinion", "balloon": null, "tests": null, @@ -52,6 +55,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q → ok · 5 passed", "balloon": null, "tests": { @@ -66,6 +70,7 @@ "kind": "save", "ok": true, "story": "Claude starts a git repository and saves the work: \"add clamp() with pytest coverage\".", + "unaskedSave": true, "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -77,6 +82,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", + "unaskedSave": false, "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, @@ -106,6 +112,7 @@ "kind": "mission", "ok": null, "story": "You run /compact.", + "unaskedSave": false, "director": "Command · /compact", "balloon": "/compact", "tests": null, @@ -117,6 +124,7 @@ "kind": "compact", "ok": null, "story": "Claude packs up its notes to free memory.", + "unaskedSave": false, "director": "manual · 46.9k → 3.6k tokens · 24 s", "balloon": null, "tests": null, @@ -128,6 +136,7 @@ "kind": "complete", "ok": null, "story": "Done in 24 seconds.", + "unaskedSave": false, "director": "local command · 24 s · 0 tool calls", "balloon": "Compacted", "tests": null, @@ -157,6 +166,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 50 words", "balloon": "Call your EnterPlanMode tool to enter plan mode, then write a short 3-step plan for adding a wrap(value, low, high) function (modular wrap into the range) to mathx.py with 3 more pytest tests, and present that plan with your ExitPlanMode tool. After I approve, implement it and run the tests.", "tests": null, @@ -168,6 +178,7 @@ "kind": "investigate", "ok": null, "story": "Claude gets ready to plan.", + "unaskedSave": false, "director": "ToolSearch", "balloon": null, "tests": null, @@ -179,6 +190,7 @@ "kind": "plan", "ok": null, "story": "Claude writes a plan: 3 steps.", + "unaskedSave": false, "director": "EnterPlanMode · Write · plan file · 3 steps · 1 second opinion along the way", "balloon": null, "tests": null, @@ -190,6 +202,7 @@ "kind": "ask", "ok": null, "story": "Claude shows you its plan, and you approve it.", + "unaskedSave": false, "director": "ExitPlanMode · answer: Approved", "balloon": null, "tests": null, @@ -201,6 +214,7 @@ "kind": "fix", "ok": null, "story": "Claude changes mathx.py and test_mathx.py.", + "unaskedSave": false, "director": "Edit ×3 · mathx.py +11 · test_mathx.py +20 −1", "balloon": null, "tests": null, @@ -212,6 +226,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q → ok · 8 passed", "balloon": null, "tests": { @@ -226,6 +241,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"add wrap() for modular wrapping into a range\".", + "unaskedSave": true, "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -237,6 +253,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", + "unaskedSave": false, "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, diff --git a/test/fixtures/story-corpus/errors-push-append.expected.json b/test/fixtures/story-corpus/errors-push-append.expected.json index 86674b6a..cba63484 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 55 words", "balloon": "Build a tiny Python module calc_stats.py with mean(xs) and median(xs), strictly test-first: FIRST write tests/test_calc_stats.py that imports calc_stats, THEN run python3 -m pytest -q once while calc_stats.py does not exist yet (it must error at collection), THEN write calc_stats.py and run python3 -m pytest -q again until it passes. Install nothing, stay in this folder.", "tests": null, @@ -30,6 +31,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git log --oneline -5 2>&1; git status; python3 --…", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash ×2 · python3 -c \"import calc_stats\" 2>&1 | tail -1; ls pytest.in… · 1 second opinion along the way", "balloon": null, "tests": null, @@ -52,6 +55,7 @@ "kind": "fix", "ok": null, "story": "Claude writes the new file test_calc_stats.py.", + "unaskedSave": false, "director": "Write · test_calc_stats.py +55", "balloon": null, "tests": null, @@ -63,6 +67,7 @@ "kind": "check", "ok": false, "story": "The tests don't even load, as you asked.", + "unaskedSave": false, "director": "python3 -m pytest -q → error · 0 passed, 1 error", "balloon": "Now the red run, once, with calc_stats.py confirmed absent:", "tests": { @@ -78,6 +83,7 @@ "kind": "save", "ok": true, "story": "Claude writes .gitignore and saves the work: \"add failing tests for calc_stats mean/median\".", + "unaskedSave": true, "director": "git commit -q -F - · writes 1 file", "balloon": "Red as expected: collection error, exit code 2.", "tests": null, @@ -89,6 +95,7 @@ "kind": "fix", "ok": null, "story": "Claude writes the new file calc_stats.py.", + "unaskedSave": false, "director": "Write · calc_stats.py +24", "balloon": null, "tests": null, @@ -100,6 +107,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q → ok · 12 passed", "balloon": "Green on the first implementation pass.", "tests": { @@ -114,6 +122,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"implement calc_stats mean and median\".", + "unaskedSave": false, "director": "git commit -q -F - · then 1 second opinion", "balloon": null, "tests": null, @@ -125,6 +134,7 @@ "kind": "complete", "ok": null, "story": "Done in 2 minutes.", + "unaskedSave": false, "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, @@ -154,6 +164,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 95 words", "balloon": "Add mode(xs) to calc_stats.py (ValueError on empty input, ties return the smallest value) in two separate steps without committing in between and without consulting the advisor: first add mode tests to tests/test_calc_stats.py and run python3 -m pytest -q (it should fail because mode is missing), then implement mode in calc_stats.py and run python3 -m pytest -q again until green; only then git co…", "tests": null, @@ -165,6 +176,7 @@ "kind": "loop", "ok": true, "story": "The first run fails on purpose, as you asked; the second passes.", + "unaskedSave": false, "director": "2 checks · 3 edits · +40 −1 · last: ok", "balloon": "Green: 18 passed and ruff is clean.", "tests": { @@ -181,7 +193,8 @@ { "kind": "save", "ok": true, - "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.", + "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, as you said it might.", + "unaskedSave": false, "director": "git commit -q -F - · git push -u origin HEAD", "balloon": null, "tests": null, @@ -193,6 +206,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash · git --git-dir=origin.git log --oneline master; git status -…", "balloon": null, "tests": null, @@ -204,6 +218,7 @@ "kind": "complete", "ok": null, "story": "Done in 37 seconds.", + "unaskedSave": false, "director": "turn_duration · 37 s · 8 tool calls · 2 files changed", "balloon": "mode(xs) is in calc_stats.py, committed once after the tests went green, and pushed. git describe --tags failed because the repo has no tags.", "tests": null, @@ -233,6 +248,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 66 words", "balloon": "In ONE Bash command, append exactly two lines to NOTES.md with cat >> NOTES.md <<'EOF' (two short lines about mode) EOF and then cat calc_stats.py; next run python3 -m pytest -q; then git add NOTES.md and commit it, and push with exactly this command, run as written with nothing appended: git push && git describe --tags (it is expected to fail after pushing; just report it).", "tests": null, @@ -244,6 +260,7 @@ "kind": "fix", "ok": null, "story": "Claude writes NOTES.md.", + "unaskedSave": false, "director": "Bash · NOTES.md +2", "balloon": null, "tests": null, @@ -255,6 +272,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q → ok · 18 passed", "balloon": null, "tests": { @@ -268,7 +286,8 @@ { "kind": "save", "ok": true, - "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.", + "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, as you said it might.", + "unaskedSave": false, "director": "git commit -q -F - · git push · 1 error", "balloon": null, "tests": null, @@ -280,6 +299,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash · git --git-dir=origin.git log --oneline -1 master; git statu…", "balloon": null, "tests": null, @@ -291,6 +311,7 @@ "kind": "complete", "ok": null, "story": "Done in 27 seconds.", + "unaskedSave": false, "director": "turn_duration · 27 s · 5 tool calls · 1 file changed", "balloon": "NOTES.md has the two lines and is committed and pushed. As you expected, git describe --tags then failed because the repo has no tags.", "tests": null, @@ -320,6 +341,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 9 words", "balloon": "Reply with just the word ok, use no tools.", "tests": null, @@ -331,6 +353,7 @@ "kind": "complete", "ok": null, "story": "Done in 3 seconds.", + "unaskedSave": false, "director": "turn_duration · 3 s · 0 tool calls", "balloon": "ok", "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 40391aeb..6722616c 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 67 words", "balloon": "Create a small JavaScript ES module slugify.mjs exporting slugify(str) (lowercase, trim, spaces and punctuation to single hyphens, no leading or trailing hyphens) plus slugify.test.mjs with 4 node:test tests; on purpose make ONE test expectation wrong so the first run of node --test fails, run it, then fix that expectation, rerun until all pass, then git init if needed and git commit both files w…", "tests": null, @@ -30,6 +31,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git status && git log --oneline -5 2>&1; node --v…", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "loop", "ok": true, "story": "The first run fails on purpose, as you asked; the second passes.", + "unaskedSave": false, "director": "2 checks · 3 edits · +35 −1 · last: ok · after 1 second opinion", "balloon": "All 4 pass.", "tests": { @@ -58,6 +61,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash · git init -b main && echo \"name=$(git config user.name) emai…", "balloon": null, "tests": null, @@ -72,6 +76,7 @@ "kind": "save", "ok": true, "story": "Claude starts a git repository and saves the work: \"add slugify module with node:test suite\".", + "unaskedSave": false, "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -83,6 +88,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", + "unaskedSave": false, "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, @@ -112,6 +118,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 38 words", "balloon": "Add a fifth node:test test for slugify('') to slugify.test.mjs that on purpose expects 'x' so the first run fails, run node --test, then fix that expectation to the empty string, rerun until all pass, and commit the change.", "tests": null, @@ -123,6 +130,7 @@ "kind": "loop", "ok": true, "story": "The first run fails on purpose, as you asked; the second passes.", + "unaskedSave": false, "director": "2 checks · 2 edits · +5 −1 · last: ok", "balloon": "All 5 pass.", "tests": { @@ -140,6 +148,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"cover empty-string input for slugify\".", + "unaskedSave": false, "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -151,6 +160,7 @@ "kind": "complete", "ok": null, "story": "Done in 21 seconds.", + "unaskedSave": false, "director": "turn_duration · 21 s · 5 tool calls · 1 file changed", "balloon": "I added a fifth test for slugify(''). All 5 tests now pass, and the change is committed.", "tests": 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 9b470e42..d9787ed0 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 96 words", "balloon": "Build a small Python package inventory/ (a CLI to add, list and remove items kept in a JSON file) split into at least 4 modules (for example storage.py, models.py, commands.py, cli.py), with a pytest suite of at least 25 tests in total; build it one module at a time, and after EACH module write its tests and run pytest, fixing any failures before moving on to the next module; then git init the fo…", "tests": null, @@ -30,6 +31,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git status 2>&1 | head; git log --oneline 2>&1 |… · 1 error", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash ×2 · for t in ruff flake8 pyflakes pycodestyle pylint mypy black…", "balloon": null, "tests": null, @@ -56,6 +59,7 @@ "kind": "fix", "ok": null, "story": "Claude writes 3 files.", + "unaskedSave": false, "director": "Bash · pyproject.toml +11 · .gitignore +11 · __init__.py +3 · after 1 second opinion", "balloon": null, "tests": null, @@ -67,6 +71,7 @@ "kind": "mission", "ok": null, "story": "You add something while Claude works.", + "unaskedSave": false, "director": "Prompt · 11 words", "balloon": "Also add a --csv option to the list command, with tests.", "tests": null, @@ -78,6 +83,7 @@ "kind": "loop", "ok": true, "story": "Build and test, 4 rounds. The tests pass every time; a formatting check fails once, then passes.", + "unaskedSave": false, "director": "5 checks · 10 edits · +694 · last: ok", "balloon": "All 4 modules are done: 63 tests pass and ruff is clean.", "tests": { @@ -98,6 +104,7 @@ "kind": "fix", "ok": null, "story": "Claude writes the new file README.md.", + "unaskedSave": false, "director": "Write · README.md +79", "balloon": null, "tests": null, @@ -109,6 +116,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash ×2 · D=/tmp/claude/-work-cases-story-r2-a/94066c8e-0855-4c6b-a14…", "balloon": "README examples match the real output.", "tests": null, @@ -124,6 +132,7 @@ "kind": "save", "ok": true, "story": "Claude starts a git repository and saves the work in 6 commits.", + "unaskedSave": false, "director": "git commit -q -m \"chore: scaffold inventory package\" -m \"Ad…", "balloon": "Commits are in.", "tests": null, @@ -135,6 +144,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q -p no:cacheprovider 2>&1 → ok · 63 passed · then 1 second opinion", "balloon": null, "tests": { @@ -149,6 +159,7 @@ "kind": "complete", "ok": null, "story": "Done in 6 minutes.", + "unaskedSave": false, "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, @@ -178,6 +189,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 188 words", "balloon": "Now do these 15 small steps strictly one at a time, each as its own separate tool call, in exactly this order, never combining two steps into one call and without extra steps in between: 1) Read inventory/models.py with the Read tool; 2) with the Edit tool add the line 'Version 0.1.0.' at the end of README.md; 3) run 'python3 -m pytest -q' in Bash; 4) run 'uname -s' in Bash; 5) run 'git add -A &&…", "tests": null, @@ -189,6 +201,7 @@ "kind": "montage", "ok": null, "story": "Claude gets started: reads 1 file, runs 2 commands, changes files, runs checks, saves the work.", + "unaskedSave": false, "director": "Read · Edit · Bash ×4", "balloon": "Step 1:", "tests": null, @@ -200,6 +213,7 @@ "kind": "mission", "ok": null, "story": "You add something while Claude works.", + "unaskedSave": false, "director": "Prompt · 15 words", "balloon": "Also, as a 16th step at the very end, run git log --oneline in Bash.", "tests": null, @@ -211,6 +225,7 @@ "kind": "montage", "ok": null, "story": "Claude keeps going: runs 1 command, changes files, runs checks.", + "unaskedSave": false, "director": "Write · Bash ×2", "balloon": "Noted, I'll add step 16 at the end.", "tests": null, @@ -222,6 +237,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"changelog\".", + "unaskedSave": false, "director": "git commit -m \"docs: changelog\" -m \"Co-Authored-By: Claude…", "balloon": "Step 10:", "tests": null, @@ -233,6 +249,7 @@ "kind": "investigate", "ok": null, "story": "Claude reads cli.py.", + "unaskedSave": false, "director": "Read", "balloon": "Step 11:", "tests": null, @@ -244,6 +261,7 @@ "kind": "fix", "ok": null, "story": "Claude changes CHANGELOG.md.", + "unaskedSave": false, "director": "Edit · CHANGELOG.md +2", "balloon": "Step 12:", "tests": null, @@ -255,6 +273,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "python3 -m pytest -q → ok · 63 passed", "balloon": "Step 13:", "tests": { @@ -269,6 +288,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash · uname -m", "balloon": "Step 14:", "tests": null, @@ -282,6 +302,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"changelog entry\".", + "unaskedSave": false, "director": "git commit -am \"docs: changelog entry\" -m \"Co-Authored-By:…", "balloon": "Step 15:", "tests": null, @@ -293,6 +314,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash · git log --oneline", "balloon": "Step 16:", "tests": null, @@ -304,6 +326,7 @@ "kind": "complete", "ok": null, "story": "Done in 1 minute.", + "unaskedSave": false, "director": "turn_duration · 1 min 16 s · 16 tool calls · 2 files changed", "balloon": "I ran all 16 steps in order, one tool call each. Two places where I didn't follow your wording exactly: Step 6: this session has no Grep tool, so I ran grep -rn 'def ' inventory/ through Bash instead. It found the same matches. Steps 5, 10 and 15: I added a second -m with the Co-Authored-By: Claude Opus 5.5 line to each commit, as the earlier commits have. The commit subjects are exactly as you wrote them.", "tests": 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 32056d62..a7b73e8c 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 52 words", "balloon": "Build a small Python command-line tool wcount.py in this folder that prints the line, word and character counts of the files given as arguments, like wc, with a --json flag that prints the counts as JSON, plus pytest tests in test_wcount.py; run pytest and fix anything that fails until all tests pass.", "tests": null, @@ -30,6 +31,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git log --oneline -5 2>&1; git branch -a; python3… · 1 error", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "loop", "ok": true, "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.", + "unaskedSave": true, "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": { @@ -59,6 +62,7 @@ "kind": "save", "ok": true, "story": "Claude writes .gitignore and saves the work: \"ignore Python and pytest caches\".", + "unaskedSave": false, "director": "git commit -q -m \"chore: ignore Python and pytest caches · writes 1 file", "balloon": null, "tests": null, @@ -70,6 +74,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash · python3 wcount.py nope nope2; echo \"exit=$?\"; wc nope nope2… · after 1 second opinion", "balloon": null, "tests": null, @@ -85,6 +90,7 @@ "kind": "complete", "ok": null, "story": "Done in 3 minutes.", + "unaskedSave": false, "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, 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 4740f4f5..96da27d1 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 121 words", "balloon": "Build a tiny Python command-line tool in this folder that turns text into URL slugs. Before doing anything else, ask me with your AskUserQuestion tool whether the tool should be named slugify or kebab (offer exactly those two options). After my answer, first write me your plan as a short numbered list, then write the pytest tests FIRST (at least 8 tests in tests/), run them with python3 -m pytest…", "tests": null, @@ -30,6 +31,7 @@ "kind": "ask", "ok": null, "story": "Claude asks you, and you answer.", + "unaskedSave": false, "director": "AskUserQuestion · answer: kebab", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git status 2>&1 | head -5; git rev-parse --show-t…", "balloon": null, "tests": null, @@ -52,6 +55,7 @@ "kind": "loop", "ok": true, "story": "The first run fails on purpose, as you asked; the second passes.", + "unaskedSave": false, "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": { @@ -68,7 +72,8 @@ { "kind": "fix", "ok": null, - "story": "Claude changes 3 files.", + "story": "Claude writes README.md and .gitignore and changes CLAUDE.md.", + "unaskedSave": false, "director": "Write ×2 · Edit · README.md +54 · .gitignore +11 · CLAUDE.md +5 −1", "balloon": null, "tests": null, @@ -80,6 +85,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash ×3 · python3 src/kebab.py \"Hello, World!\" && python3 src/kebab.p…", "balloon": "Now I'll run each documented README command by hand and check the git identity:", "tests": null, @@ -95,6 +101,7 @@ "kind": "save", "ok": true, "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.", + "unaskedSave": false, "director": "git commit -q -F - · git push -u origin main · 1 error", "balloon": null, "tests": null, @@ -106,6 +113,7 @@ "kind": "check", "ok": true, "story": "The tests pass in a fresh copy made from remote.git.", + "unaskedSave": false, "director": "python3 -m pytest -q -p no:cacheprovider) → ok · 19 passed · then 1 second opinion", "balloon": null, "tests": { @@ -120,6 +128,7 @@ "kind": "complete", "ok": null, "story": "Done in 5 minutes.", + "unaskedSave": false, "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, diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index 5c852f19..cf273a4b 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 77 words", "balloon": "Create a tiny node project here: package.json (type module) and index.js exporting two functions add(a,b) and slugify(s). Then start TWO helpers with the Agent tool IN THE BACKGROUND (run_in_background: true): one writes README.md describing the project, the other writes CONTRIBUTING.md. While they work, write index.test.mjs using node:test with a few tests for both functions and run it with node…", "tests": null, @@ -30,6 +31,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git log --oneline -3 2>&1; node --version · 1 error", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "save", "ok": true, "story": "Claude starts a git repository, writes package.json and index.js, and saves the work: \"add package.json and index.js with add and slugify\".", + "unaskedSave": true, "director": "git commit -q -m \"feat: add package.json and index.js with… · writes 2 files · after 1 second opinion", "balloon": null, "tests": null, @@ -52,6 +55,7 @@ "kind": "backup", "ok": null, "story": "Claude sends 2 helpers off to work in the background.", + "unaskedSave": false, "director": "Agent ×2 · general-purpose", "balloon": null, "tests": null, @@ -63,6 +67,7 @@ "kind": "check", "ok": true, "story": "Claude writes index.test.mjs; the tests pass.", + "unaskedSave": false, "director": "node --test index.test.mjs → ok · 8 passed · writes 1 file", "balloon": null, "tests": { @@ -77,6 +82,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"add node:test suite for add and slugify\".", + "unaskedSave": false, "director": "git commit -q -m \"test: add node:test suite for add and slu…", "balloon": null, "tests": null, @@ -88,6 +94,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 2 minutes. Its 2 helpers finished later and reported back.", + "unaskedSave": false, "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, @@ -117,6 +124,7 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 26 words · task", "balloon": "contributing-writer: I wrote /work/cases/story-r2-b/CONTRIBUTING.md covering setup, tests, code style, commits, README sync and PRs; it isn't committed because git was off-limits, and index.test.mjs and README.md, which…", "tests": null, @@ -128,6 +136,7 @@ "kind": "investigate", "ok": null, "story": "Claude checks CONTRIBUTING.md.", + "unaskedSave": false, "director": "Bash · ls -la && git status --short && cat CONTRIBUTING.md", "balloon": null, "tests": null, @@ -139,6 +148,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"add CONTRIBUTING.md\".", + "unaskedSave": false, "director": "git commit -q -m \"docs: add CONTRIBUTING.md", "balloon": null, "tests": null, @@ -150,6 +160,7 @@ "kind": "complete", "ok": null, "story": "Done in 15 seconds.", + "unaskedSave": false, "director": "stop_hook_summary · 15 s · 2 tool calls", "balloon": "The CONTRIBUTING.md helper has finished, and I've committed its file (docs: add CONTRIBUTING.md). The helper reported that index.test.mjs and README.md didn't exist yet. That's out of date: it must have looked before I wrote the tests, and both files are there now. README.md is already on disk, but its helper hasn't reported back yet, so I haven't committed it in case it's still being written.", "tests": null, @@ -179,6 +190,7 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 6 words · task", "balloon": "readme-writer: README done, test file missing", "tests": null, @@ -190,6 +202,7 @@ "kind": "investigate", "ok": null, "story": "Claude checks README.md.", + "unaskedSave": false, "director": "Bash · cat README.md && ls index.test.mjs", "balloon": null, "tests": null, @@ -201,6 +214,7 @@ "kind": "check", "ok": true, "story": "The tests pass, and Claude saves the work.", + "unaskedSave": false, "director": "npm test 2>&1 → ok · 8 passed · 1 commit", "balloon": null, "tests": { @@ -215,6 +229,7 @@ "kind": "complete", "ok": null, "story": "Done in 16 seconds.", + "unaskedSave": false, "director": "stop_hook_summary · 16 s · 2 tool calls", "balloon": "The project is done: both helpers finished, and I checked their files and committed them. Both helpers warned that index.test.mjs didn't exist, but they had looked before I created it, so the README's Testing section is correct. Say if you want CLAUDE.md committed too.", "tests": null, @@ -244,6 +259,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 56 words", "balloon": "Big refactor: split index.js into three modules under lib/ (lib/add.js, lib/slugify.js, lib/util.js with shared helpers), keep index.js as a re-export barrel, add full JSDoc to every function, add at least 15 more node:test tests in separate files under test/, update README and CONTRIBUTING to match, run the tests after each step and commit each step separately.", "tests": null, @@ -255,6 +271,7 @@ "kind": "check", "ok": true, "story": "Claude writes 4 files; the tests pass.", + "unaskedSave": false, "director": "npm test 2>&1 → ok · 8 passed · writes 4 files · after 1 second opinion", "balloon": "All 8 tests pass, the README examples give the same outputs, and the barrel exports only add and slugify.", "tests": { @@ -269,6 +286,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"split index.js into lib/ modules behind an index.js barrel\".", + "unaskedSave": false, "director": "git commit -q -m \"refactor: split index.js into lib/ module…", "balloon": null, "tests": null, @@ -280,6 +298,7 @@ "kind": "check", "ok": true, "story": "Claude writes 4 files; the tests pass.", + "unaskedSave": false, "director": "npm test 2>&1 → ok · 8 passed · writes 4 files", "balloon": "Every @example output matches what the code actually returns, and 8/8 tests pass.", "tests": { @@ -294,6 +313,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"add full JSDoc to every function and module\".", + "unaskedSave": false, "director": "git commit -q -m \"docs: add full JSDoc to every function an…", "balloon": null, "tests": null, @@ -305,6 +325,7 @@ "kind": "complete", "ok": null, "story": "You stop Claude here, during its step 3 (new tests under test/). Saved before the stop: 2 commits.", + "unaskedSave": false, "director": "interrupted · 2 min 41 s · 4 tool calls · 4 files changed", "balloon": "Step 3: new tests under test/.", "tests": null, @@ -334,6 +355,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 23 words", "balloon": "Probe turn, no other work: run the shell command sleep 65 with Bash (foreground, not background), then reply with just the word done.", "tests": null, @@ -345,6 +367,7 @@ "kind": "run", "ok": null, "story": "Claude tries a command, and it is blocked.", + "unaskedSave": false, "director": "Bash · sleep 65 · 1 error", "balloon": null, "tests": null, @@ -358,6 +381,7 @@ "kind": "complete", "ok": null, "story": "Nothing Claude tried worked, so the job is not done.", + "unaskedSave": false, "director": "stop_hook_summary · 6 s · 1 tool call", "balloon": "Not done. The harness blocked sleep 65: a sleep on its own in the foreground isn't allowed. I didn't try to get around the block, for example by chaining shorter sleeps or running it in the background.", "tests": 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 31bc3f35..cce05009 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 @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 81 words", "balloon": "First reply with a numbered plan of exactly 4 steps (as text, not a tool). Then build csvstat.py, a Python CLI that prints row count and per-column min/max/mean of a CSV, with pytest tests. Do a dry run on a sample CSV and save its log with `| tee /tmp/csvstat-dryrun.log`. Start ONE helper with the Agent tool, run_in_background: true and WITHOUT a name parameter, to write README.md; do not wait f…", "tests": null, @@ -30,6 +31,7 @@ "kind": "plan", "ok": null, "story": "Claude writes a plan: 4 steps.", + "unaskedSave": false, "director": "written as text · 4 steps", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git status && git branch -a && git log --oneline… · 1 error", "balloon": null, "tests": null, @@ -52,6 +55,7 @@ "kind": "fix", "ok": null, "story": "Claude writes 4 new files.", + "unaskedSave": false, "director": "Write ×4 · csvstat.py +115 · sample.csv +6 · test_csvstat.py +129 · after 1 second opinion", "balloon": null, "tests": null, @@ -62,7 +66,8 @@ { "kind": "check", "ok": true, - "story": "Claude writes pyproject.toml and changes test_csvstat.py; the tests pass, then Claude starts a git repository and saves the work.", + "story": "Claude writes pyproject.toml and changes test_csvstat.py; the tests pass, then Claude starts a git repository and saves the work on a new branch (feat/csvstat).", + "unaskedSave": true, "director": "python3 -m pytest -q 2>&1 → ok ×2 · 17 passed · writes 2 files · 1 commit", "balloon": "Code and tests are committed.", "tests": { @@ -77,6 +82,7 @@ "kind": "backup", "ok": null, "story": "Claude sends 1 helper off to work in the background.", + "unaskedSave": false, "director": "Agent · general-purpose · 7 calls inside", "balloon": null, "tests": null, @@ -88,6 +94,7 @@ "kind": "run", "ok": null, "story": "Claude works in the terminal.", + "unaskedSave": false, "director": "Bash · set -o pipefail && python3 csvstat.py sample.csv | tee /tmp…", "balloon": null, "tests": null, @@ -103,6 +110,7 @@ "kind": "complete", "ok": null, "story": "Claude's part is done in 3 minutes. Its helper finished later and reported back.", + "unaskedSave": false, "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, @@ -132,6 +140,7 @@ "kind": "mission", "ok": null, "story": "A background job reports back.", + "unaskedSave": false, "director": "Prompt · 6 words · task", "balloon": "\"Write README for csvstat\" reports: I wrote README.md and committed it on its own on feat/csvstat. The README has every section you asked for and no badges.", "tests": null, @@ -143,6 +152,7 @@ "kind": "investigate", "ok": null, "story": "Claude looks around the project.", + "unaskedSave": false, "director": "Bash · git log --oneline && git status --short && git show --stat…", "balloon": null, "tests": null, @@ -154,6 +164,7 @@ "kind": "complete", "ok": null, "story": "Done in 9 seconds.", + "unaskedSave": false, "director": "turn_duration · 9 s · 1 tool call", "balloon": "The background helper has finished README.md, and I've checked its work. Rounding: the README now explains that numbers are shown to 6 significant digits, so the population mean of 214574.2 appears as 214574. That note makes the current behaviour clear, but it doesn't fix it. Now that the helper is done, nothing else is writing to the repo, so I can fix the number format and update the README example whenever you want.", "tests": 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 0161095c..c8c799dc 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -19,6 +19,7 @@ "kind": "mission", "ok": null, "story": "You give Claude a job.", + "unaskedSave": false, "director": "Prompt · 56 words", "balloon": "Build a tiny single-page pomodoro timer in this folder: index.html with inline CSS, timer.js exporting the timer logic as plain functions, and timer.test.mjs using node:test that covers start, pause and reset; run the tests with node --test until they pass, then git init and commit everything with a short message, with no remote and no push.", "tests": null, @@ -30,6 +31,7 @@ "kind": "investigate", "ok": null, "story": "Claude reads CLAUDE.md.", + "unaskedSave": false, "director": "Bash ×2 · ls -la && git status 2>&1 | head; git log --oneline 2>&1 |…", "balloon": null, "tests": null, @@ -41,6 +43,7 @@ "kind": "run", "ok": null, "story": "Claude looks a few things up; the last one finds a git setting that is not set, which is no error.", + "unaskedSave": false, "director": "Bash · grep -oE '\"(permissions|env|allow|deny)\"|[A-Za-z_]*(TOKEN|K… · 1 error", "balloon": null, "tests": null, @@ -55,6 +58,7 @@ "kind": "fix", "ok": null, "story": "Claude writes 3 new files.", + "unaskedSave": false, "director": "Write ×3 · timer.js +42 · timer.test.mjs +74 · index.html +120 · after 1 second opinion", "balloon": null, "tests": null, @@ -66,6 +70,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", + "unaskedSave": false, "director": "node --test 2>&1 → ok · 7 passed", "balloon": "All 7 tests pass, with no module-type warning.", "tests": { @@ -80,6 +85,7 @@ "kind": "save", "ok": true, "story": "Claude starts a git repository and saves the work: \"add pomodoro timer with tests\".", + "unaskedSave": false, "director": "git commit -q -m \"feat: add pomodoro timer with tests\" -m \"… · then 1 second opinion", "balloon": null, "tests": null, @@ -91,6 +97,7 @@ "kind": "complete", "ok": null, "story": "Done in 3 minutes.", + "unaskedSave": false, "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, diff --git a/test/story-corpus.test.ts b/test/story-corpus.test.ts index 173807cb..bbe3d387 100644 --- a/test/story-corpus.test.ts +++ b/test/story-corpus.test.ts @@ -46,6 +46,7 @@ function pagesOf(name: string) { kind: x.kind, ok: x.ok ?? null, story: x.caption.story, + unaskedSave: x.unaskedSave ?? false, director: x.caption.director, balloon: x.balloon?.text ?? null, tests: x.tests ?? null, @@ -108,7 +109,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( // test_csvstat.py was written a panel before and only patched here. - 'Claude writes pyproject.toml and changes test_csvstat.py; the tests pass, then Claude starts a git repository and saves the work.' + 'Claude writes pyproject.toml and changes test_csvstat.py; the tests pass, then Claude starts a git repository and saves the work on a new branch (feat/csvstat).' ); 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 }); @@ -142,10 +143,10 @@ describe('Story Mode on real sessions', () => { }); expect(second.panels.find((p) => p.kind === 'save')?.story).toBe( // `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.' + '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, as you said it might.' ); expect(third.panels.find((p) => p.kind === 'save')?.story).toBe( - '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.' + '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, as you said it might.' ); }); @@ -202,9 +203,21 @@ describe('Story Mode on real sessions', () => { expect(exact.panels.at(-1)!.balloon).toMatch(/Steps 5, 10 and 15: I added a second -m/); }); + it('the saves no prompt asked for are marked on their first panel (round 11)', () => { + const marked = (name: string) => + pagesOf(name).flatMap((p, t) => p.panels.filter((x) => x.unaskedSave).map((x) => `T${t + 1} ${x.kind}`)); + expect(marked('compact-then-plan-mode')).toEqual(['T1 save', 'T3 save']); + expect(marked('python-cli-fix-loop')).toEqual(['T1 loop']); + expect(marked('text-plan-dry-run-helper')).toEqual(['T1 check']); + // The prompt asked to commit and push: nothing to mark. + expect(marked('question-tests-first-push')).toEqual([]); + }); + it('a helper reporting back is heard in its own words, not only "finished" (round 9)', () => { const [, readme, extra] = pagesOf('background-helpers-reports'); - expect(readme.panels[0].balloon).toBe('"Helper A: write README.md" reports: README.md'); + expect(readme.panels[0].balloon).toBe( + '"Helper A: write README.md" reports back with the file it wrote: README.md.' + ); // What the helper did not do is on the page, and a long path does not crowd it out. expect(extra.panels[0].balloon).toMatch( /^"Helper B: write extra pytest tests" reports: I wrote the file: tests\/test_extra\.py / diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 7ae8f60b..809a220c 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -925,6 +925,29 @@ describe('story-mode.js overlay', () => { expect(drawnTexts).not.toContain('+5'); }); + it("the first save no prompt asked for says so in a small line; a catch beats 'Done for now'", async () => { + boot( + storyOf([ + prompt('Add a.'), + ...bash('b1', 'git commit -qm "add a"', 'Commit', ''), + // Still running when the turn ends: the page would read "Done for now" without the catch. + ...bash('b2', 'sleep 60 && pytest -q', 'Slow tests', 'Command running in background with ID: b2x'), + said( + 'I added a and saved it. ' + + 'It reads the file and prints the counts for each column, one line per column. '.repeat(3) + + 'One known issue: big numbers are shown rounded.' + ), + end(1000), + ]) + ); + app.openStoryMode(SID); + await settle(); + expect($('.story-panel[data-kind="save"] .story-cap-note')!.textContent).toBe( + 'a save your prompt here did not ask for' + ); + expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done, with a catch'); + }); + it('a second opinion that came back with an error says no answer came', async () => { const failed = { type: 'assistant', diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 311ace64..48d31ae6 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -1059,6 +1059,24 @@ describe('buildStoryPage', () => { expect(keyKindOf('Anything unknown is left out rather than guessed.')).toBeUndefined(); }); + it('the first save no prompt asked for is marked, once; a new branch is named', () => { + const saves = (ask: string) => + pageOf([ + prompt(ask), + ...bash('b0', 'git checkout -q -b feat/x', 'Branch', ''), + ...bash('b1', 'git commit -qm "add a"', 'Commit', ''), + ...edit('e1', '/w/a.py', ['+a = 1']), + ...bash('b2', 'git commit -qam "add b"', 'Commit', ''), + end(1000), + ]).panels.filter((p) => p.kind === 'save'); + const unasked = saves('Add a.'); + expect(unasked.map((p) => !!p.unaskedSave)).toEqual([true, false]); + expect(unasked[0].caption.story).toBe('Claude saves the work on a new branch (feat/x): "add a".'); + expect(saves('Add a and commit it.').some((p) => p.unaskedSave)).toBe(false); + // "save its log" is no request to save the work. + expect(saves('Add a and save its log with tee.')[0].unaskedSave).toBe(true); + }); + 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