From f6c540276cf1cbf40f561b5df744869e29c680fd Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 18:19:01 +0200 Subject: [PATCH] fix(story-mode): plans you approved, project rules, tag lookups and dry runs (review round 12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a reader who has never written code: - "a save your prompt here did not ask for" on a save that was step 3 of the plan you approved ("Verify and commit"): a commit step in an approved plan asks for the save. When Claude says a project rule asks for its commits (read on its whole message), the line says "…; your project's rules do". - "a check after that fails" read as a failed test where it was `git describe` finding no tags: "the version-tag lookup after that fails (there are no tags yet)". - The dry run you asked for never showed: the terminal card took the last command's edge-case errors. A dry run in the panel is now what the card shows, its "a -> b" lines, under "Claude does the dry run you asked for: it shows what would happen, changing nothing." Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- src/story-mode.ts | 56 ++++++++++++++++--- src/types/story.ts | 9 ++- src/web/public/story-mode.js | 6 +- ...ash-script-background-helper.expected.json | 16 +++--- .../compact-then-plan-mode.expected.json | 4 +- .../errors-push-append.expected.json | 4 +- .../teammates-then-interrupt.expected.json | 2 +- .../text-plan-dry-run-helper.expected.json | 2 +- test/story-corpus.test.ts | 9 ++- test/story-mode-ui.test.ts | 16 ++++++ test/story-mode.test.ts | 25 ++++++++- 12 files changed, 120 insertions(+), 31 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 803ccbca..4a667dc3 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). `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`). +- `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 (nor a plan you approved) asked to commit, push or save the work: `rule` when Claude said a project rule asks for it, else `true`; 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 86694dab..786782b9 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -127,7 +127,7 @@ export type StoryEntry = /** Teammates (helpers started as a team) reporting back, matched to their helper by name. */ teammates?: StoryTeammateReport[]; } - | { k: 'text'; at: string; text: string; keyPoints?: string[] } + | { k: 'text'; at: string; text: string; keyPoints?: string[]; citesSaveRule?: boolean } | { k: 'tool'; at: string; id: string; name: string; input: ReducedInput } | { k: 'result'; @@ -1493,7 +1493,15 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean const content = message.content; const textEntry = (text: string): StoryEntry => { const keyPoints = keyPointsOf(text); - return { k: 'text', at, text: clip(text, STORY_LIMITS.textChars), ...(keyPoints.length ? { keyPoints } : {}) }; + // Read on the whole message: the label keeps only its start. + const rule = RULE_SAVE.test(text.slice(0, 8000)); + return { + k: 'text', + at, + text: clip(text, STORY_LIMITS.textChars), + ...(keyPoints.length ? { keyPoints } : {}), + ...(rule ? { citesSaveRule: true } : {}), + }; }; if (typeof content === 'string') return content.trim() ? [textEntry(content)] : []; if (!Array.isArray(content)) return []; @@ -1933,6 +1941,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { label: e.text, intent: 'say', ...(e.keyPoints ? { keyPoints: e.keyPoints } : {}), + ...(e.citesSaveRule ? { citesSaveRule: true } : {}), }); break; case 'tool': { @@ -2411,6 +2420,10 @@ function emptyAnswerWhat(e: StoryEvent): string { 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; +/** Claude saying a project rule asks for the commits ("CLAUDE.md says to commit after every change"). */ +const RULE_SAVE = + /\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\b[^.\n]{0,120}\bcommit|\bcommit[^.\n]{0,120}\b(CLAUDE\.md|project('s)? (rules|notes|instructions)|house rules)\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); @@ -2455,6 +2468,9 @@ function touchedBefore(file: string, before: StoryEvent[]): boolean { const GIT_LOOK = /^(?:(?:git (?:-C \S+ |--git-dir=\S+ )?(?:log|status|show|ls-remote|branch|remote|describe|rev-parse|diff)\b[^;&|\n]*|(?:echo|head|tail|wc|sort)\b[^;&|\n]*)\s*(?:&&|\|\||;|\||\n|$)\s*)+$/; +/** A dry run: a command that shows what it would do without doing it. */ +const DRY_RUN = /\bdry[- ]?run\b|--dry-run\b|--what-if\b/i; + /** 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); @@ -2831,13 +2847,19 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde case 'run': { panel.notes = notesOf(evs); // What it printed, so a dry run or a listing you asked to see is on the page, not only in the drawer. - const lastOut = [...evs].reverse().find((e) => e.output?.trim() && !e.running); + // A dry run in the panel is what the card shows, whatever ran after it (edge cases, a clean-up). + const dryRun = evs.find( + (e) => e.output?.trim() && !e.running && e.ok !== false && DRY_RUN.test(`${e.description ?? ''} ${e.label}`) + ); + const lastOut = dryRun ?? [...evs].reverse().find((e) => e.output?.trim() && !e.running); if (lastOut?.output) { const lines = lastOut.output .split('\n') .map(printableLine) .filter((l): l is string => !!l); - const picked = lastOut.ok === false ? lines.slice(-2) : lines.slice(0, 3); + // Of a dry run, the lines that show what it would do ("a.jpg -> holiday_a.jpg"). + const shows = dryRun ? lines.filter((l) => /->|→|\bwould\b|dry[- ]?run\]/i.test(l)) : []; + const picked = shows.length ? shows.slice(0, 3) : lastOut.ok === false ? lines.slice(-2) : lines.slice(0, 3); if (picked.length) panel.printed = picked; } story = evs.every((e) => e.running) @@ -2854,7 +2876,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde }.` : evs.every((e) => e.tool !== 'Bash') ? 'Claude uses a tool.' - : 'Claude works in the terminal.'; + : dryRun + ? events.some((e) => e.intent === 'prompt' && DRY_RUN.test(e.label)) + ? 'Claude does the dry run you asked for: it shows what would happen, changing nothing.' + : 'Claude does a dry run: it shows what would happen, changing nothing.' + : 'Claude works in the terminal.'; director = `${tools} · ${shortCommand(evs[0])}${errors ? ` · ${plural(errors, 'error')}` : ''}`; break; } @@ -2923,7 +2949,15 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde /\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' : ''}`; + // `git describe` with no tags: a lookup of a version name, not a check of the work. + const noTags = /No names found, cannot describe anything|No tags can describe/.test(out); + const stepAfter = `${ + noTags + ? 'the version-tag lookup after that fails (there are no tags yet)' + : looksOnly + ? 'a check after that fails' + : 'a step after that fails' + }${allowed ? ', as you said it might' : ''}`; story = savedAnyway || hiddenFail ? uploadOnly @@ -3009,13 +3043,21 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde break; } const savesHere = draft.events.some((i) => (events[i].commits ?? 0) > 0); + // A plan you approved that has a commit step asked for the save too ("Verify and commit"). + const planAsked = events.some( + (e) => + e.tool === 'ExitPlanMode' && + e.question?.answer === 'Approved' && + (ASKS_TO_SAVE.test(e.label) || !!e.todos?.some((t) => ASKS_TO_SAVE.test(t.text))) + ); if ( savesHere && turn.origin === 'human' && + !planAsked && !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; + panel.unaskedSave = events.some((e) => e.intent === 'say' && e.citesSaveRule) ? 'rule' : 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 6c7839ca..fbf368c3 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -137,6 +137,8 @@ export interface StoryEvent { * not taken, what you asked for. From the whole message, past the clip of `label`. */ keyPoints?: string[]; + /** Claude says a project rule asks for its commits ("CLAUDE.md's commit-after-each-change rule"). */ + citesSaveRule?: boolean; /** A save that ships the work: `gh pr create` (`pr`) or `git push` (`push`). */ ships?: 'pr' | 'push'; /** How many commits a Bash call makes (`git commit` segments). */ @@ -174,8 +176,11 @@ 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; + /** + * The turn's first save, when no prompt of the turn (nor a plan you approved) asked for one: `rule` + * when Claude said a project rule asks for it, `true` otherwise. + */ + unaskedSave?: true | 'rule'; /** 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 9b1f4012..f0533cc7 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2514,7 +2514,11 @@ Object.assign(CodemanApp.prototype, { const unanswered = asked.length > 0 && asked.every((i) => page.events[i].ok === false); const ask = unanswered ? 'tries to ask' : 'asks'; const notes = [ - p.unaskedSave ? 'a save your prompt here did not ask for' : '', + p.unaskedSave === 'rule' + ? "a save your prompt here did not ask for; your project's rules do" + : p.unaskedSave + ? 'a save your prompt here did not ask for' + : '', p.secondOpinion ? `${ before 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 a5b26ca0..5a552955 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -78,16 +78,16 @@ { "kind": "run", "ok": null, - "story": "Claude works in the terminal.", + "story": "Claude does the dry run you asked for: it shows what would happen, changing nothing.", "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, "roundsOk": null, "printed": [ - "--bogus exit=2", - "two dirs exit=2", - "-- two dirs exit=2" + "[dry-run] 2022-07-04_120000.jpg -> 2020-01-01_000000.jpg", + "[dry-run] a.jpg -> 2024-05-01_093000.jpg", + "[dry-run] b.jpg -> 2024-05-01_093000_1.jpg" ], "events": 5 }, @@ -230,16 +230,16 @@ { "kind": "run", "ok": null, - "story": "Claude works in the terminal.", + "story": "Claude does the dry run you asked for: it shows what would happen, changing nothing.", "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, "roundsOk": null, "printed": [ - "--- missing value", - "exit=2 error: --prefix needs a value", - "--- slash" + "[dry-run] 2020-01-01_000000.jpg -> holiday_2020-01-01_000000.jpg", + "[dry-run] 2021-02-03_040506.jpg -> holiday_2021-02-03_040506.jpg", + "[dry-run] 2022-07-04_120000.jpg -> holiday_2022-07-04_120000.jpg" ], "events": 2 }, 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 ad95b141..26975593 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -70,7 +70,7 @@ "kind": "save", "ok": true, "story": "Claude starts a git repository and saves the work: \"add clamp() with pytest coverage\".", - "unaskedSave": true, + "unaskedSave": "rule", "director": "git commit -q -F -", "balloon": null, "tests": null, @@ -241,7 +241,7 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"add wrap() for modular wrapping into a range\".", - "unaskedSave": true, + "unaskedSave": false, "director": "git commit -q -F -", "balloon": null, "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 eb7a42b7..1022b992 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -193,7 +193,7 @@ { "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, as you said it might.", + "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 the version-tag lookup after that fails (there are no tags yet), as you said it might.", "unaskedSave": false, "director": "git commit -q -F - · git push -u origin HEAD", "balloon": null, @@ -286,7 +286,7 @@ { "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, as you said it might.", + "story": "Claude saves the work: \"add NOTES.md describing mode behavior\" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet), as you said it might.", "unaskedSave": false, "director": "git commit -q -F - · git push · 1 error", "balloon": null, diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index 3b661af0..08a8317b 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -43,7 +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, + "unaskedSave": "rule", "director": "git commit -q -m \"feat: add package.json and index.js with… · writes 2 files · after 1 second opinion", "balloon": null, "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 6a35b674..249de36a 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 @@ -93,7 +93,7 @@ { "kind": "run", "ok": null, - "story": "Claude works in the terminal.", + "story": "Claude does the dry run you asked for: it shows what would happen, changing nothing.", "unaskedSave": false, "director": "Bash · set -o pipefail && python3 csvstat.py sample.csv | tee /tmp…", "balloon": null, diff --git a/test/story-corpus.test.ts b/test/story-corpus.test.ts index bbe3d387..15eb9302 100644 --- a/test/story-corpus.test.ts +++ b/test/story-corpus.test.ts @@ -143,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, as you said it might.' + '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 the version-tag lookup after that fails (there are no tags yet), 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, as you said it might.' + 'Claude saves the work: "add NOTES.md describing mode behavior" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet), as you said it might.' ); }); @@ -206,7 +206,10 @@ describe('Story Mode on real sessions', () => { 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']); + // T3's save was a step of the plan you approved ("Verify and commit"): asked for. + expect(marked('compact-then-plan-mode')).toEqual(['T1 save']); + // Claude said why: the project's rules ask for a commit after each change. + expect(pagesOf('teammates-then-interrupt')[0].panels.find((x) => x.unaskedSave)?.unaskedSave).toBe('rule'); 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. diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index 809a220c..8b458ee7 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -948,6 +948,22 @@ describe('story-mode.js overlay', () => { expect($('.story-panel[data-kind="complete"] .story-splash-title')!.textContent).toBe('Done, with a catch'); }); + it("an unasked save that Claude says the project's rules ask for says so", async () => { + boot( + storyOf([ + prompt('Add a.'), + ...bash('b1', 'git commit -qm "add a"', 'Commit', ''), + said("I added a. I committed it to follow CLAUDE.md's commit-after-each-change rule."), + 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; your project's rules do" + ); + }); + 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 28248e40..482636eb 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -1127,6 +1127,25 @@ describe('buildStoryPage', () => { ); }); + it('the dry run you asked for is what the terminal card shows, whatever ran after it', () => { + const page = pageOf([ + prompt('add --prefix and show me a dry run on the samples'), + ...bash( + 'b1', + './rename.sh --dry-run --prefix x_', + 'Dry run', + 'syntax ok\n[dry-run] a.jpg -> x_a.jpg\n[dry-run] b.jpg -> x_b.jpg' + ), + ...bash('b2', './rename.sh --prefix', 'Edge cases', '--- missing value\nexit=2 error: --prefix needs a value'), + end(1000), + ]); + const run = page.panels.find((p) => p.kind === 'run')!; + expect(run.printed).toEqual(['[dry-run] a.jpg -> x_a.jpg', '[dry-run] b.jpg -> x_b.jpg']); + expect(run.caption.story).toBe( + 'Claude does the dry run you asked for: it shows what would happen, changing nothing.' + ); + }); + it('a short ending keeps the sentence that leaves something open', () => { const page = pageOf([ prompt('run them'), @@ -1331,7 +1350,7 @@ describe('buildStoryPage', () => { 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.' + 'Claude saves the work: "add a" and copies it to origin.git, a folder on this computer, but the version-tag lookup after that fails (there are no tags yet).' ); expect(page.toolCalls).toBe(2); }); @@ -1384,8 +1403,8 @@ describe('buildStoryPage', () => { prompt('dry run please'), ...bash( 'b1', - './rename.sh --dry-run samples/', - 'Dry run', + './rename.sh --list samples/', + 'List the renames', 'a.jpg -> holiday_2024-05-01.jpg\nAPI_TOKEN=abc123\nauthor: me@example.com\nb.jpg -> holiday_2024-05-02.jpg' ), end(1000),