diff --git a/docs/api-reference.md b/docs/api-reference.md index 8bd0479b..6a4895b6 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. 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`. 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 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), `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 0e251356..95f52f52 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -2082,7 +2082,19 @@ function groupPanels(events: StoryEvent[]): { const panels: DraftPanel[] = []; let pendingSay: string | undefined; let lastSaid: StoryEvent | undefined; + // A second opinion (the advisor) is not a step of its own (its answer is not readable anyway): it + // rides on the panel of the step after it, or the last one when nothing follows. + let opinions: number[] = []; + const takeOpinions = (panel: DraftPanel) => { + if (!opinions.length) return; + panel.events = [...panel.events, ...opinions].sort((a, b) => a - b); + opinions = []; + }; events.forEach((ev, idx) => { + if (ev.tool === 'Advisor') { + opinions.push(idx); + return; + } if (ev.intent === 'say' && !ev.todos) { pendingSay ??= balloonText(ev.label); lastSaid ??= ev; @@ -2098,6 +2110,7 @@ function groupPanels(events: StoryEvent[]): { (events[last.events[0]].tool === 'Advisor') !== (ev.tool === 'Advisor'); if (last && last.kind === kind && MERGEABLE.has(kind) && !advisorMix) { last.events.push(idx); + takeOpinions(last); if (pendingSay && !last.balloon) last.balloon = { who: 'claude', text: pendingSay }; } else { const panel: DraftPanel = { kind, events: [idx] }; @@ -2119,10 +2132,16 @@ function groupPanels(events: StoryEvent[]): { if (kind === 'mission') panel.balloon = { who: 'you', text: clip(ev.label, 400) }; else if (reportsBack && last && pendingSay) last.balloon = { who: 'claude', text: pendingSay }; else if (pendingSay) panel.balloon = { who: 'claude', text: pendingSay }; + takeOpinions(panel); panels.push(panel); } pendingSay = undefined; }); + if (opinions.length) { + const last = panels[panels.length - 1]; + if (last) takeOpinions(last); + else panels.push({ kind: 'backup', events: opinions }); + } // The closing words keep a caveat that follows them ("Nothing is committed: this is no git repo."). return { panels, @@ -2282,8 +2301,12 @@ function plannedFailure(turn: StoryTurn, events: StoryEvent[]): false | 'asked' } function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, index: number): StoryPanel { - const evs = draft.events.map((i) => events[i]); + const all = draft.events.map((i) => events[i]); + // Second opinions riding on this step are counted, never part of its story. + const opinions = all.filter((e) => e.tool === 'Advisor').length; + const evs = opinions && opinions < all.length ? all.filter((e) => e.tool !== 'Advisor') : all; const panel: StoryPanel = { kind: draft.kind, events: draft.events, caption: { story: '', director: '' } }; + if (evs !== all) panel.secondOpinion = opinions; if (draft.balloon) panel.balloon = draft.balloon; const edits = evs.flatMap(editsOf); const checks = evs.filter((e) => e.intent === 'test' || e.intent === 'build'); @@ -2666,6 +2689,12 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde case 'complete': break; } + if (panel.secondOpinion) { + // Asked before the step, or (nothing following) after the last one. + const lastWork = Math.max(...draft.events.filter((i) => events[i].tool !== 'Advisor')); + const trailing = draft.events.some((i) => events[i].tool === 'Advisor' && i > lastWork); + director += ` · ${trailing ? 'then' : 'after'} ${plural(panel.secondOpinion, 'second opinion')}`; + } panel.caption = { story, director }; return panel; } diff --git a/src/types/story.ts b/src/types/story.ts index e2de8b99..c7fe04d1 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -174,6 +174,8 @@ export interface StoryPanel { removed?: number; ok?: boolean; rounds?: number; + /** 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. */ roundsOk?: (boolean | null)[]; tests?: StoryTests; diff --git a/src/web/public/story-mode.css b/src/web/public/story-mode.css index 80084669..30958bbe 100644 --- a/src/web/public/story-mode.css +++ b/src/web/public/story-mode.css @@ -340,6 +340,19 @@ body.story-open { border-radius: 6px; background: transparent; } +.story-cap-note { + display: block; + margin-top: 3px; + font: 600 10.5px/1.25 var(--story-mono); + letter-spacing: 0.01em; + text-transform: none; + color: var(--story-cap-ink); + opacity: 0.78; +} +.story-cap-note[hidden], +.story-overlay.is-director .story-cap-note { + display: none; +} .story-hit:hover, .story-hit:focus-visible { outline: 3px dashed var(--story-magenta) !important; diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 2be1df55..370c9dcc 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2375,7 +2375,11 @@ Object.assign(CodemanApp.prototype, { capStory.className = 'story-cap-story'; const capDir = document.createElement('span'); capDir.className = 'story-cap-dir'; - cap.append(capStory, capDir); + // A second opinion Claude asked for before this step: a line, not a panel of its own. + const capNote = document.createElement('span'); + capNote.className = 'story-cap-note'; + capNote.hidden = true; + cap.append(capStory, capDir, capNote); const balloon = document.createElement('p'); balloon.className = 'story-balloon'; balloon.hidden = true; @@ -2415,6 +2419,7 @@ Object.assign(CodemanApp.prototype, { panel: null, capStory, capDir, + capNote, balloon, liveTag, splash, @@ -2484,6 +2489,15 @@ Object.assign(CodemanApp.prototype, { : 'story-balloon--claude' }`; rt.liveTag.hidden = !p.live; + if (rt.capNote) { + // Asked before this step, or after it when nothing followed. + const work = p.events.filter((i) => page.events[i]?.tool !== 'Advisor'); + const trailing = p.events.some((i) => page.events[i]?.tool === 'Advisor' && i > Math.max(...work)); + rt.capNote.hidden = !p.secondOpinion; + rt.capNote.textContent = p.secondOpinion + ? `${trailing ? 'then asks' : 'after asking'} a stronger AI model for a second opinion` + : ''; + } rt.el.classList.toggle('is-live', !!p.live); // Named by its place only: the caption inside is read as the panel's content, once. rt.el.setAttribute('aria-label', `Panel ${index + 1} of ${page.panels.length}`); 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 9f314dd9..fab40182 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -51,24 +51,13 @@ { "kind": "backup", "ok": null, - "story": "Claude asks a stronger AI model for a second opinion, but gets no answer.", - "director": "Advisor · a second opinion", + "story": "Claude calls in 2 helpers.", + "director": "Agent · general-purpose · 6 calls inside · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 1 - }, - { - "kind": "backup", - "ok": null, - "story": "Claude sends 1 helper off to work in the background.", - "director": "Agent · general-purpose · 6 calls inside", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 + "events": 2 }, { "kind": "fix", 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 754fd322..7319b208 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -37,27 +37,16 @@ "printed": null, "events": 2 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "fix", "ok": null, "story": "Claude writes 2 new files.", - "director": "Write ×2 · mathx.py +11 · test_mathx.py +26", + "director": "Write ×2 · mathx.py +11 · test_mathx.py +26 · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 2 + "events": 3 }, { "kind": "check", @@ -186,38 +175,16 @@ "printed": null, "events": 1 }, - { - "kind": "plan", - "ok": null, - "story": "Claude switches to plan mode to think it through.", - "director": "EnterPlanMode", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "plan", "ok": null, "story": "Claude writes a plan: 3 steps.", - "director": "Write · plan file · 3 steps", + "director": "EnterPlanMode · Write · plan file · 3 steps · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 1 + "events": 3 }, { "kind": "ask", diff --git a/test/fixtures/story-corpus/errors-push-append.expected.json b/test/fixtures/story-corpus/errors-push-append.expected.json index 10416180..890d66ec 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -27,37 +27,26 @@ "events": 1 }, { - "kind": "montage", + "kind": "investigate", "ok": null, - "story": "Claude gets started: runs 3 commands.", - "director": "Bash ×3", + "story": "Claude looks around the project.", + "director": "Bash ×2 · ls -la && git log --oneline -5 2>&1; git status; python3 --…", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 3 - }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 + "events": 2 }, { "kind": "run", "ok": null, "story": "Claude works in the terminal.", - "director": "Bash · mkdir -p /work/cases/story-r6-a/tests", + "director": "Bash ×2 · python3 -c \"import calc_stats\" 2>&1 | tail -1; ls pytest.in… · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 1 + "events": 3 }, { "kind": "fix", @@ -125,23 +114,12 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"implement calc_stats mean and median\".", - "director": "git commit -q -F -", + "director": "git commit -q -F - · then 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 1 - }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 + "events": 2 }, { "kind": "complete", 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 51836c16..74795ebf 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -37,22 +37,11 @@ "printed": null, "events": 2 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "loop", "ok": true, "story": "The first run fails on purpose, as you asked; the second passes.", - "director": "2 checks · 3 edits · +35 −1 · last: ok", + "director": "2 checks · 3 edits · +35 −1 · last: ok · after 1 second opinion", "balloon": "All 4 pass.", "tests": { "passed": 4, @@ -63,7 +52,7 @@ true ], "printed": null, - "events": 5 + "events": 6 }, { "kind": "run", 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 b9d797a7..d0beaf59 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -27,37 +27,41 @@ "events": 1 }, { - "kind": "montage", + "kind": "investigate", "ok": null, - "story": "Claude gets started: runs 4 commands.", - "director": "Bash ×4", + "story": "Claude looks around the project.", + "director": "Bash ×2 · ls -la && git status 2>&1 | head; git log --oneline 2>&1 |… · 1 error", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 4 + "events": 2 }, { - "kind": "backup", + "kind": "run", "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", + "story": "Claude works in the terminal.", + "director": "Bash ×2 · for t in ruff flake8 pyflakes pycodestyle pylint mypy black…", "balloon": null, "tests": null, "roundsOk": null, - "printed": null, - "events": 1 + "printed": [ + "name rc=1", + "…@…", + "email rc=0" + ], + "events": 2 }, { "kind": "fix", "ok": null, "story": "Claude writes 3 files.", - "director": "Bash · pyproject.toml +11 · .gitignore +11 · __init__.py +3", + "director": "Bash · pyproject.toml +11 · .gitignore +11 · __init__.py +3 · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 1 + "events": 2 }, { "kind": "mission", @@ -131,7 +135,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", - "director": "python3 -m pytest -q -p no:cacheprovider 2>&1 → ok · 63 passed", + "director": "python3 -m pytest -q -p no:cacheprovider 2>&1 → ok · 63 passed · then 1 second opinion", "balloon": null, "tests": { "passed": 63, @@ -139,18 +143,7 @@ }, "roundsOk": null, "printed": null, - "events": 1 - }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 + "events": 2 }, { "kind": "complete", 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 d07c1270..1d6c9e26 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -37,22 +37,11 @@ "printed": null, "events": 2 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "loop", "ok": true, "story": "Fix and test, 2 rounds: a test fails and Claude fixes it, then a layout check fails and Claude fixes that; everything passes. Claude saves the work in 4 commits along the way.", - "director": "3 checks · 5 edits · +218 −1 · last: ok", + "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": { "passed": 19, @@ -64,7 +53,7 @@ true ], "printed": null, - "events": 7 + "events": 8 }, { "kind": "save", @@ -77,22 +66,11 @@ "printed": null, "events": 1 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "run", "ok": null, "story": "Claude works in the terminal.", - "director": "Bash · python3 wcount.py nope nope2; echo \"exit=$?\"; wc nope nope2…", + "director": "Bash · python3 wcount.py nope nope2; echo \"exit=$?\"; wc nope nope2… · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, @@ -101,7 +79,7 @@ "wcount: nope2: No such file or directory", "0 0 0 total" ], - "events": 1 + "events": 2 }, { "kind": "complete", 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 d6677be6..0b55ee14 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -48,22 +48,11 @@ "printed": null, "events": 2 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "loop", "ok": true, "story": "Test first: the first run fails as planned, the second passes.", - "director": "2 checks · 4 edits · +151 −1 · last: ok", + "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": { "passed": 19, @@ -74,7 +63,7 @@ true ], "printed": null, - "events": 6 + "events": 7 }, { "kind": "fix", @@ -117,7 +106,7 @@ "kind": "check", "ok": true, "story": "The tests pass.", - "director": "python3 -m pytest -q -p no:cacheprovider) → ok · 19 passed", + "director": "python3 -m pytest -q -p no:cacheprovider) → ok · 19 passed · then 1 second opinion", "balloon": null, "tests": { "passed": 19, @@ -125,18 +114,7 @@ }, "roundsOk": null, "printed": null, - "events": 1 - }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 + "events": 2 }, { "kind": "complete", diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index bdff44e0..beaa4eaf 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -37,27 +37,16 @@ "printed": null, "events": 2 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "save", "ok": true, "story": "Claude writes 2 files and saves the work: \"add package.json and index.js with add and slugify\".", - "director": "git commit -q -m \"feat: add package.json and index.js with… · writes 2 files", + "director": "git commit -q -m \"feat: add package.json and index.js with… · writes 2 files · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 1 + "events": 2 }, { "kind": "backup", @@ -262,22 +251,11 @@ "printed": null, "events": 1 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "check", "ok": true, "story": "Claude writes 4 files; the tests pass.", - "director": "npm test 2>&1 → ok · 8 passed · writes 4 files", + "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": { "passed": 8, @@ -285,7 +263,7 @@ }, "roundsOk": null, "printed": null, - "events": 1 + "events": 2 }, { "kind": "save", 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 37de28f1..55e960fb 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 @@ -48,27 +48,16 @@ "printed": null, "events": 2 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "fix", "ok": null, "story": "Claude writes 4 new files.", - "director": "Write ×4 · csvstat.py +115 · sample.csv +6 · test_csvstat.py +129", + "director": "Write ×4 · csvstat.py +115 · sample.csv +6 · test_csvstat.py +129 · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 4 + "events": 5 }, { "kind": "check", 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 f5fd9e29..8723f268 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -51,27 +51,16 @@ ], "events": 1 }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 - }, { "kind": "fix", "ok": null, "story": "Claude writes 3 new files.", - "director": "Write ×3 · timer.js +42 · timer.test.mjs +74 · index.html +120", + "director": "Write ×3 · timer.js +42 · timer.test.mjs +74 · index.html +120 · after 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 3 + "events": 4 }, { "kind": "check", @@ -91,23 +80,12 @@ "kind": "save", "ok": true, "story": "Claude saves the work: \"add pomodoro timer with tests\".", - "director": "git commit -q -m \"feat: add pomodoro timer with tests\" -m \"…", + "director": "git commit -q -m \"feat: add pomodoro timer with tests\" -m \"… · then 1 second opinion", "balloon": null, "tests": null, "roundsOk": null, "printed": null, - "events": 1 - }, - { - "kind": "backup", - "ok": null, - "story": "Claude asks a stronger AI model for a second opinion.", - "director": "Advisor · a second opinion", - "balloon": null, - "tests": null, - "roundsOk": null, - "printed": null, - "events": 1 + "events": 2 }, { "kind": "complete", diff --git a/test/story-mode-ui.test.ts b/test/story-mode-ui.test.ts index c20df25b..8005ecbb 100644 --- a/test/story-mode-ui.test.ts +++ b/test/story-mode-ui.test.ts @@ -866,6 +866,26 @@ describe('story-mode.js overlay', () => { ); }); + it("a second opinion is a line in the next panel's caption, not a panel of its own", async () => { + const advisor = { + type: 'assistant', + timestamp: '2026-10-18T09:41:20.000Z', + message: { + content: [ + { type: 'server_tool_use', id: 'srv1', name: 'advisor', input: {} }, + { type: 'advisor_tool_result', tool_use_id: 'srv1', content: { type: 'advisor_redacted_result' } }, + ], + }, + }; + boot(storyOf([prompt('fix it'), advisor, ...edit('e1', '/work/demo-shop/a.py', ['+a = 1']), end(1000)])); + app.openStoryMode(SID); + await settle(); + expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual(['mission', 'fix', 'complete']); + const note = $('.story-panel[data-kind="fix"] .story-cap-note')!; + expect(note.hidden).toBe(false); + expect(note.textContent).toBe('after asking a stronger AI model for a second opinion'); + }); + it('the final panel counts the commits of the whole turn, and says when the work was uploaded', async () => { boot( storyOf([ diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 8025c0b1..5c33f1a2 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -1256,8 +1256,10 @@ describe('buildStoryPage', () => { }, end(1000), ]); - const backup = page.panels.find((p) => p.kind === 'backup')!; - expect(backup.caption.story).toBe('Claude asks a stronger AI model for a second opinion.'); + // Not a step of its own: it rides on the panel before it, here the mission (nothing follows). + expect(page.panels.map((p) => p.kind)).toEqual(['mission', 'complete']); + expect(page.panels[0]).toMatchObject({ secondOpinion: 1 }); + expect(page.panels[0].caption.director).toMatch(/then 1 second opinion$/); expect(page.events[1]).toMatchObject({ tool: 'Advisor', ok: true, helper: { name: 'advisor' } }); expect(page.events[1].helper!.result).toMatch(/encrypted/); expect(page.helpers).toBe(0);