From dae6a52daae11c3a0919661830105a65d5688e7c Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 11:26:34 +0200 Subject: [PATCH] fix(story-mode): one turn per prompt written twice, and plan mode as planning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From review round 3 (compaction, then plan mode): - A typed `/compact` and its record share one promptId; they were two turns ("Done in 0 seconds", then a turn left running). A prompt carrying the open turn's promptId now joins it: one "You run /compact." turn of 24 s, the real compaction time. A command typed as such (`/model opus`) is a command too. - Plan mode: writing the plan file under ~/.claude/plans/ is planning (a plan panel with its numbered steps), not a code change ("3 files changed" for a commit of 2). ExitPlanMode's input is empty in current builds; its result carries the plan and your answer: "Claude shows you its plan, and you approve it." - A command's Director's caption names it ("Command · /compact"). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/api-reference.md | 2 +- src/story-mode.ts | 80 ++++++++++++++++++++++++++++++++++------- test/story-mode.test.ts | 48 +++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 14 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index f873001a..b0eeeee0 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1067,7 +1067,7 @@ Responses carry a weak `ETag` with `Cache-Control: no-cache`; a request with a m A `page` carries the turn's summary fields plus `events` (one per prompt, sentence Claude wrote and tool call, with `intent`, `ok` when the call returned, `edit` hunks in Claude Code's own `structuredPatch` shape, the tail of `output`, `helper`, `tests` counts, and `files` for the files a Bash call printed with `cat`/`head`/`tail`, as the command named them), `panels` (each `{ kind, events, caption: { story, director }, balloon?, files?, ... }`, at most 12 per page, indices into `events`), `filesChanged`, `added`, `removed`, `helpers`, `helpersRunning`, `partial` (the turn began before the part that was read) and `truncated` (older outputs were dropped to keep the page small). A transcript that cannot be read is a `500` with `OPERATION_FAILED`. -A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). A slash command is a prompt event with tool `Command`; a local one (`/compact`, `/model`) ends its turn at its own output (``, kept as the event's `output`), and a compaction that lands between turns belongs to the turn after it. A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span. +A helper started in the background (`helper.background`) carries `helper.running: true` until its `` arrives; that notification starts a turn of its own (origin `task`), whose prompt event carries the helper's answer as `output`, and the answer, tool count and duration are joined back onto the helper in the turn that started it. Claude Code times such a turn from the prompt that started the chain; Story Mode caps a turn's `durationMs` at the turn's own span. A helper started as a teammate (`status: teammate_spawned`) is a background helper too, and its `` reports join it by name. A prompt that arrives while Claude works (typed, a background job's report, a message from another session) is written by current Claude Code builds as a `queued_command` attachment, not a user line; it joins the running turn, a prompt event carrying `origin` (`human`, `task`, `peer`, `system`), and a report that lands mid-turn is an event with intent `notice` rather than a new mission panel. A Bash call that writes files through here-documents (`cat > f <<'EOF'`) carries them as `edits` (whole-file edits, counted in `filesChanged`), and its label leaves the bodies out; `headline` is the part of a one-liner that decided its intent. The advisor (a server tool) is an event with tool `Advisor` and intent `helper`; its answer is encrypted in the transcript. `tests` may carry `errors` (pytest's collection errors). A prompt written twice under one `promptId` (a typed `/compact`, then its command record) is one turn. In plan mode the plan file under `~/.claude/plans/` is planning (intent `plan`, its numbered steps as `todos`), not a code change, and `ExitPlanMode`'s result carries the plan (the event's `output`) and your answer (`Approved` / `Sent back`). A slash command is a prompt event with tool `Command`; a local one (`/compact`, `/model`) ends its turn at its own output (``, kept as the event's `output`), and a compaction that lands between turns belongs to the turn after it. A turn ends at `turn_duration`, or at the Stop hook's `stop_hook_summary` (unless it prevented the stop) in sessions that never write `turn_duration`, timed then by its own span. ## Git worktrees of a case diff --git a/src/story-mode.ts b/src/story-mode.ts index a3998296..149e4a8c 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -113,6 +113,8 @@ export type StoryEntry = queued: boolean; /** A slash command (`/compact`), not a prompt to Claude. */ slash?: boolean; + /** Claude Code's id for the prompt; one prompt can be written more than once (typed, then as a command). */ + promptId?: string; /** A background task reporting back: which call it answers, and how it went. */ task?: StoryTaskReport; /** Teammates (helpers started as a team) reporting back, matched to their helper by name. */ @@ -133,6 +135,8 @@ export type StoryEntry = tests?: StoryTests; /** A check whose output reports problems (lint, format, types). */ problems?: boolean; + /** The plan an ExitPlanMode call presented. */ + plan?: string; } | { k: 'end'; at: string; durationMs?: number } | { k: 'compact'; at: string } @@ -652,6 +656,8 @@ function readPrompt(raw: string): { }; return { text: tag('summary') || 'A background task finished.', kind: 'task', task }; } + // A command typed as such (`/compact`, `/model opus`); a path (`/home/x`) is not one. + if (/^\/[a-z][\w:-]*(\s|$)/i.test(text) && !text.includes('\n')) return { text, kind: 'prompt', slash: true }; return { text, kind: 'prompt' }; } @@ -708,8 +714,11 @@ function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput { old: s(i.old_string ?? i.old_str, STORY_LIMITS.inputChars), new: s(i.new_string ?? i.new_str, STORY_LIMITS.inputChars), }; - case 'Write': - return { file: s(i.file_path, 1000) }; + case 'Write': { + const file = s(i.file_path, 1000); + // A plan-mode plan file keeps its text: it becomes the plan panel's list. + return file && PLAN_FILE.test(file) ? { file, plan: s(i.content, 4000) } : { file }; + } case 'Grep': case 'Glob': return { pattern: s(i.pattern, 200), path: s(i.path, 1000) }; @@ -873,6 +882,11 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean ? extra.agentId : undefined, }; + } else if (name === 'ExitPlanMode') { + // The plan rides on the result (the call's input is empty in current builds), with your answer. + if (typeof extra.plan === 'string') result.plan = clip(extra.plan, 4000); + if (!isError && /\bapproved\b/i.test(text)) result.answer = 'Approved'; + else if (text) result.answer = 'Sent back'; } else if (name === 'AskUserQuestion' && isRecord(extra.answers)) { const first = Object.values(extra.answers).find((v): v is string => typeof v === 'string'); if (first) result.answer = clip(first, 300); @@ -903,13 +917,16 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean if (!prompt) return out; if (prompt.kind === 'interrupt') return [{ k: 'interrupt', at }]; return [ - promptEntry( - prompt, - str(entry.uuid) ?? `prompt-${at}`, - at, - originOf(entry.turnOrigin, prompt.kind), - entry.promptSource === 'queued' - ), + { + ...promptEntry( + prompt, + str(entry.uuid) ?? `prompt-${at}`, + at, + originOf(entry.turnOrigin, prompt.kind), + entry.promptSource === 'queued' + ), + promptId: str(entry.promptId), + }, ]; } @@ -1042,8 +1059,13 @@ export interface StoryTurn { interrupted: boolean; endedAt?: string; durationMs?: number; + /** Claude Code's id for the prompt that started the turn. */ + promptId?: string; } +/** Plan mode keeps its plan in a file under `~/.claude/plans/`: writing it is planning, not changing code. */ +const PLAN_FILE = /\/\.claude\/plans\/[^/]+\.md$/; + function intentOf(name: string, input: ReducedInput): StoryIntent { switch (name) { case 'Read': @@ -1075,7 +1097,7 @@ function intentOf(name: string, input: ReducedInput): StoryIntent { case 'MultiEdit': case 'Write': case 'NotebookEdit': - return 'edit'; + return input.file && PLAN_FILE.test(input.file) ? 'plan' : 'edit'; case 'Bash': return input.intent ?? classifyBashCommand(input.command ?? ''); default: @@ -1105,6 +1127,9 @@ function labelOf(name: string, i: ReducedInput): string { return i.question ?? ''; case 'ExitPlanMode': return 'Plan ready for your OK'; + case 'Write': + case 'Edit': + return i.file && PLAN_FILE.test(i.file) ? `the plan, ${basename(i.file)}` : (i.file ?? ''); case 'SendMessage': return `to ${i.to ?? '?'}: ${i.summary ?? ''}`.trim(); default: @@ -1147,6 +1172,10 @@ function toolEvent(e: Extract, actor: StoryActor): St prompt: 'Claude shows a stronger model the whole conversation so far and asks what it thinks.', }; if (i.todos) ev.todos = i.todos; + if (i.plan) { + const todos = planFromText(i.plan); + if (todos) ev.todos = todos; + } if (e.name === 'AskUserQuestion') ev.question = { text: i.question ?? '', options: i.options ?? [] }; if (e.name === 'ExitPlanMode') ev.question = { text: firstSentence(i.plan ?? 'Claude has a plan.', 240), options: [] }; @@ -1158,10 +1187,16 @@ function applyResult(ev: StoryEvent, r: Extract): v ev.ok = !r.isError; if (r.output !== undefined) ev.output = r.output; if (r.outputTruncated) ev.outputTruncated = true; - if (r.edit) { + if (r.edit && ev.intent !== 'plan') { ev.edit = r.edit; ev.file = r.edit.file; } + if (r.plan) { + ev.output = r.plan; + if (ev.question) ev.question = { ...ev.question, text: firstSentence(r.plan, 240) }; + const todos = planFromText(r.plan); + if (todos) ev.todos = todos; + } if (r.problems) ev.ok = false; if (r.tests) { ev.tests = r.tests; @@ -1267,6 +1302,16 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { if (e.task) reportBack(e.task, ev); if (e.teammates) teammatesBack(e.teammates, ev); const open = cur as StoryTurn | null; + if (open && !open.ended && e.promptId && open.promptId === e.promptId) { + // The same prompt written again (`/compact` typed, then recorded as a command): one turn. + const first = open.events[0]; + if (e.slash && first?.intent === 'prompt') { + first.tool = 'Command'; + first.label = e.text; + open.prompt = e.text; + } + break; + } if (open && !open.ended && e.queued) { // A report that lands while Claude works is a line on the wire, not a new mission. if (e.origin === 'task') { @@ -1287,6 +1332,7 @@ export function buildStoryTurns(entries: StoryEntry[]): StoryTurn[] { ended: false, interrupted: false, }; + if (e.promptId) cur.promptId = e.promptId; turns.push(cur); byId.clear(); if (pendingCompact) { @@ -1667,7 +1713,9 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde : origin === 'peer' ? 'Another session chimes in while Claude works.' : lines[origin]; - director = `Prompt · ${plural(words, 'word')}${origin === 'human' ? '' : ` · ${origin}`}`; + director = command + ? `Command · ${clip(evs[0].label, 60)}` + : `Prompt · ${plural(words, 'word')}${origin === 'human' ? '' : ` · ${origin}`}`; break; } case 'investigate': { @@ -1699,6 +1747,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde story = todos ? `Claude writes a plan: ${plural(todos.length, 'step')}.` : 'Claude thinks it through.'; // A plan written as text has no progress to report; a plan tool's list does. if (todos && evs.every((e) => e.tool === 'Text')) director = `written as text · ${plural(todos.length, 'step')}`; + else if (todos && evs.some((e) => e.file && PLAN_FILE.test(e.file))) + director = `${tools} · plan file · ${plural(todos.length, 'step')}`; else if (todos) director = `${tools} · ${todos.filter((t) => t.done).length}/${todos.length} done`; break; } @@ -1846,7 +1896,11 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde if (q) panel.question = { text: q.text, answer: q.answer }; const plan = evs[0]?.tool === 'ExitPlanMode'; story = plan - ? 'Claude shows you its plan and waits for your OK.' + ? q?.answer === 'Approved' + ? 'Claude shows you its plan, and you approve it.' + : q?.answer + ? 'Claude shows you its plan, and you send it back.' + : 'Claude shows you its plan and waits for your OK.' : q?.answer ? 'Claude asks you, and you answer.' : 'Claude asks you something.'; diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index a5c0e3ce..4ff09604 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -538,6 +538,54 @@ describe('buildStoryTurns', () => { expect(model).toMatchObject({ prompt: '/model opus', ended: true }); }); + it('one prompt written twice (typed /compact, then its command record) is one turn', () => { + const turns = buildStoryTurns( + entriesOf([ + prompt('one'), + end(1000), + prompt('/compact', { promptId: 'p2' }), + { type: 'system', subtype: 'compact_boundary', timestamp: '2026-10-18T09:50:00.000Z' }, + prompt('/compact', { promptId: 'p2' }), + prompt('Compacted', { promptId: 'p2' }), + ]) + ); + expect(turns).toHaveLength(2); + const page = buildStoryPage(turns[1]); + expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'compact', 'complete']); + expect(page.panels[0].caption).toEqual({ story: 'You run /compact.', director: 'Command · /compact' }); + expect(page.complete).toBe(true); + }); + + it('plan mode: the plan file is planning, and the plan and your answer come back with ExitPlanMode', () => { + const plan = '# Plan: add wrap\n\n1. Implement wrap.\n2. Add tests.\n3. Commit.'; + const page = pageOf([ + prompt('plan it'), + ...bash('b0', 'ls', 'List', 'a'), + tool('w1', 'Write', { file_path: '/home/me/.claude/plans/radiant.md', content: plan }), + result('w1', 'File created', { + toolUseResult: { + type: 'create', + filePath: '/home/me/.claude/plans/radiant.md', + content: plan, + structuredPatch: [], + }, + }), + tool('x1', 'ExitPlanMode', {}), + result('x1', 'User has approved your plan. You can now start coding.', { toolUseResult: { plan } }), + ...edit('e1', '/w/a.py', ['+def wrap(): ...']), + end(1000), + ]); + expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'investigate', 'plan', 'ask', 'fix', 'complete']); + expect(page.panels[2].todos?.map((t) => t.text)).toEqual(['Implement wrap.', 'Add tests.', 'Commit.']); + expect(page.panels[2].caption.director).toBe('Write · plan file · 3 steps'); + expect(page.panels[3].caption.story).toBe('Claude shows you its plan, and you approve it.'); + expect(page.events.find((e) => e.tool === 'ExitPlanMode')).toMatchObject({ + output: plan, + question: { answer: 'Approved' }, + }); + expect(page.filesChanged).toBe(1); + }); + it('an interrupt ends the turn', () => { const turns = buildStoryTurns( entriesOf([prompt('go'), ...bash('b', 'ls', 'List', 'a'), prompt('[Request interrupted by user]')])