mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix(story-mode): one turn per prompt written twice, and plan mode as planning
From review round 3 (compaction, then plan mode):
- A typed `/compact` and its <command-name> 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
db9eb5cea3
commit
dae6a52daa
3 files changed
+116
-14
No files matched your search
@@ -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 `<task-notification>` 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 `<teammate-message>` 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 (`<local-command-stdout>`, 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 `<task-notification>` 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 `<teammate-message>` 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 (`<local-command-stdout>`, 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
|
||||
|
||||
|
||||
+67
-13
@@ -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<StoryEntry, { k: 'tool' }>, 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<StoryEntry, { k: 'result' }>): 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.';
|
||||
|
||||
@@ -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('<command-name>/compact</command-name><command-args></command-args>', { promptId: 'p2' }),
|
||||
prompt('<local-command-stdout>Compacted</local-command-stdout>', { 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]')])
|
||||
|
||||
Reference in new issue
Block a user