diff --git a/docs/api-reference.md b/docs/api-reference.md index f2630c74..8bd0479b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1074,7 +1074,7 @@ A turn is one prompt and everything Claude did for it. `origin` says who sent th ### Background work -A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `` (matched by tool call) or a `` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out, and `page.finishedLater` the helpers and background commands that finished only after their turn ended (each such event carries `finishedLater: true`); the final panel then says so instead of a clean finish. `page.openNote` says Claude's last words name an open problem or something not done or checked (one of its `keyPoints`), so that panel is not a celebration either. +A helper started in the background (`run_in_background`, or as a teammate: `status: teammate_spawned`) has `helper.background` and `helper.running: true` until it reports back. A `` (matched by tool call) or a `` (matched by name) joins the answer, tool count and duration back onto the helper in the turn that started it. A report that arrives after that turn ended starts a turn of its own (origin `task`) whose prompt event carries the report as `output` and the helper as `helper`; one that lands mid-turn is an event with intent `notice` instead of a new mission. `page.helpersRunning` counts the helpers still out, and `page.finishedLater` the helpers and background commands that finished only after their turn ended (each such event carries `finishedLater: true`); the final panel then says so instead of a clean finish. `page.openNote` says Claude's last words leave something open, from its `keyPoints`: `catch` (a problem left) or `note` (something not done, checked or confirmed yet); that panel is not a celebration either. `page.asks` says they ask you something or offer more: the next move is yours. A Bash call sent to the background (its result is only `Command running in background with ID: …`) has `running: true` and no `ok`: it started, nothing more is known, and its `output` says so instead of carrying the launch note meant for Claude. Its `` settles `ok` from the exit code in the summary (the `output` then names the exit code, and `background: true` stays, so the page says the run happened in the background), and reading the job's output file later is a test run when that output reports test counts. diff --git a/src/story-mode.ts b/src/story-mode.ts index 0d8abb3d..48b2e880 100644 --- a/src/story-mode.ts +++ b/src/story-mode.ts @@ -275,60 +275,124 @@ export function balloonText(text: string): string { * expected, a correction of an earlier warning. WEAK: a bare negation ("I didn't push"), kept only when * nothing strong is there. */ -/** The open-problem part of a key point: something wrong, not done, not checked, or still out. */ -const OPEN_PROBLEM = new RegExp( - String.raw`\b(problem|known (issue|quirk)s?|quirks?|caveat|limitation|heads up|warning|not yet|haven't|have not|never|unfixed|left (open|unfixed|out)|still (fails?|failing|broken|missing|open|running|working)|must be (served|run)|(won't|doesn't|does not|will not) (work|fix)|(isn't|is not|not) fixed|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened))\b`, - 'i' -); -const KEY_POINT_STRONG = new RegExp( +/** + * What a sentence in Claude's last words is, for a reader who must not lose it (round 8 and 9 of the + * cold-reader reviews): kinds in the order they are kept when there is no room for all. + * - `problem`: something wrong or left broken ("it won't work if you double-click", "doesn't fix it"). + * - `offer`: Claude asks you or offers more ("Want me to add that?", "I can fix it whenever you want"). + * - `howto`: how to use what was built ("serve the folder with python3 -m http.server"). + * - `pending`: not done, checked or confirmed yet ("I haven't opened it in a browser"). + * - `failed`: a step that failed, reported ("git describe --tags failed: no tags"). + * - `asked`: a word on what you asked for or expected ("--csv is part of the CLI commit"). + * - `correction`: an earlier warning that turned out wrong ("That's out of date"). + * A bare negation ("I didn't push") is kept only when nothing else is there. + */ +const KEY_KINDS: Array<[StoryKeyKind, RegExp]> = [ [ - OPEN_PROBLEM.source, - String.raw`\b(as you (asked|expected|said|wanted|suggested)|you asked|your (--?[\w-]+|request|option|flag|change))\b`, - String.raw`\b(out of date|looked too early|turned out|was wrong)\b`, - ].join('|'), - 'i' -); -const KEY_POINT_WEAK = /\b(didn't|did not|couldn't|could not|wasn't able|won't|doesn't)\b/i; + 'problem', + /\b(problem|bug|broken|known (issue|quirk)s?|quirks?|caveat|limitation|heads up|warning|unfixed|left (open|unfixed|out)|still (fails?|failing|broken)|(won't|doesn't|does not|will not) (work|fix)|(isn't|is not|not) fixed|never (moves|runs|loads|works|starts|shows))\b/i, + ], + [ + 'offer', + /\b(want me to|should i|shall i|do you want|if you want|whenever you want|let me know|say if you want|i can (add|fix|change|do|update|make|run|commit))\b/i, + ], + [ + 'howto', + /\b(to (use|run|open|try) it|serve (the|this) folder|http\.server|open https?:\/\/\S+|you('ll)? need to (run|open|serve|install|set)|instead,? (run|open|serve|use))\b/i, + ], + [ + 'pending', + /\b(not yet|haven't|have not|can't tell|isn't (confirmed|committed|pushed|tested|done|checked)|not (checked|tested|verified|confirmed|committed|pushed|opened|run)|still (running|working|open|missing)|didn't (open|try|check|test|run|verify) it)\b/i, + ], + // A step that failed, not a description of failure ("1 for a failed operation"). + ['failed', /(? re.test(point))?.[0]; +} /** - * Sentences from further into a long message that carry a key point, at most two strong ones (or one - * weak one when none is strong), in order. Read on the whole message (the event's label is clipped), - * its first 8,000 characters. Table rows, list intros ending in `:` and questions are skipped: none of - * them reads on its own in a balloon. + * Sentences from further into a long message that a reader must not lose, at most three, picked by + * kind (one of each, in KEY_KINDS order) and shown in the order Claude wrote them; a bare negation + * only when none of those is there. Read on the whole message (the event's label is clipped), its + * first 8,000 characters. A table row is skipped; a list intro ending in `:` brings its first item; a + * sentence starting with "that"/"this"/"it" brings the one before it; a problem brings the fix that + * follows it. */ function keyPointsOf(text: string): string[] { if (text.length <= 160) return []; const spans: string[] = []; const plain = plainMarkdown(text.slice(0, 8000), spans); - const strong: string[] = []; - let weak: string | undefined; - let first = true; - let previous = ''; + // Every sentence in order, with the line it came from (list items are lines). + const sentences: string[] = []; for (const line of plain.split('\n')) { - if (strong.length >= 2) break; const trimmed = line.trim(); if (trimmed.startsWith('|') || (trimmed.match(/\|/g)?.length ?? 0) >= 2) continue; // a table row - let rest = trimmed.replace(/\s+/g, ' '); - while (rest && strong.length < 2) { + let rest = trimmed.replace(/^[-*•]\s+/, '').replace(/\s+/g, ' '); + while (rest) { const m = SENTENCE_END.exec(rest); - const sentence = (m ? m[1] : rest).trim(); + sentences.push((m ? m[1] : rest).trim()); rest = m ? rest.slice(m[1].length).trim() : ''; - // The first sentence is the balloon's anyway. - if (first) { - first = false; - continue; - } - const before = previous; - previous = sentence; - if (sentence.length < 12 || /[:?]$/.test(sentence)) continue; - // "That's out of date" needs what "that" was: the sentence before comes along. - const context = /^(that|this|it)\b/i.test(sentence) && before && !strong.includes(before) ? `${before} ` : ''; - const shown = clip(restoreSpans(context + sentence, spans), context ? 300 : 180); - if (KEY_POINT_STRONG.test(sentence)) strong.push(shown); - else if (!weak && KEY_POINT_WEAK.test(sentence)) weak = shown; } } - return strong.length ? strong : weak ? [weak] : []; + type Pick = { at: number; text: string; kind?: StoryKeyKind }; + const candidates: Pick[] = []; + let weak: Pick | undefined; + for (let i = 1; i < sentences.length && candidates.length < 24; i++) { + let sentence = sentences[i]; + if (sentence.length < 12 || POINTS_ELSEWHERE.test(sentence)) continue; + // Judged on its own words (a list intro too: "Known quirks:", "Two places where I didn't…:"). + const kind = KEY_KINDS.find(([, re]) => re.test(sentence))?.[0]; + if (!kind && (weak || !KEY_WEAK.test(sentence))) continue; + // A list intro brings its first items, while short, never one that only points elsewhere. + if (/:$/.test(sentence)) + for (let k = i + 1; k <= i + 2 && k < sentences.length && sentence.length + sentences[k].length < 260; k++) + if (!POINTS_ELSEWHERE.test(sentences[k])) sentence = `${sentence} ${sentences[k]}`; + // "That's out of date" needs what "that" was, and so does "Want me to add that?". + // ("It doesn't mention…" is a sentence of its own: only "that"/"this" point back.) + const refersBack = + /^(that|this)\b/i.test(sentence) || + (kind === 'offer' && sentence.length < 80 && /\b(that|this|it|them)\b/i.test(sentence)); + const before = refersBack && i > 1 ? `${sentences[i - 1]} ` : ''; + // A problem reported with its fix right after it: both, so it never reads as still open. + const fix = + kind === 'problem' && sentences[i + 1] && FIX_FOLLOWS.test(sentences[i + 1]) ? ` ${sentences[i + 1]}` : ''; + const shown = clip(restoreSpans(before + sentence + fix, spans), before || fix ? 360 : 300); + if (kind) candidates.push({ at: i, text: shown, kind }); + else weak = { at: i, text: shown }; + } + const picked: Pick[] = []; + for (const [kind] of KEY_KINDS) { + const c = candidates.find((p) => p.kind === kind); + if (c && picked.length < 3) picked.push(c); + } + if (!picked.length && weak) picked.push(weak); + return picked.sort((a, b) => a.at - b.at).map((p) => p.text); } /** @@ -342,7 +406,7 @@ export function closingText(text: string, keyPoints: string[] = []): string { const parts = [one]; if (two && (CAVEAT.test(two) || one.length < 24) && two.length <= 200) parts.push(two); for (const point of keyPoints) if (!parts.some((p) => p.includes(point) || point.includes(p))) parts.push(point); - return clip(parts.join(' '), parts.length > 2 ? 420 : 300); + return clip(parts.join(' '), parts.length > 2 ? 560 : 300); } /** The first sentence of a message, without markdown, for a speech balloon. */ @@ -1996,7 +2060,25 @@ const REPORT_TALK: Partial> = { }; const ANNOUNCES = /\b(I'll|I will|let me|let's|I'm going to|next,? I|now I)\b/i; -function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay?: string; openNote?: boolean } { +/** What Claude's last words leave open: a catch (a problem), a note (something pending), a question. */ +function closingKinds(points: string[] = []): { openNote?: 'catch' | 'note'; asks?: boolean } { + const kinds = new Set(points.map(keyKindOf)); + return { + ...(kinds.has('problem') + ? { openNote: 'catch' as const } + : kinds.has('pending') + ? { openNote: 'note' as const } + : {}), + ...(points.some((p) => keyKindOf(p) === 'offer' && ASKS_YOU.test(p)) ? { asks: true } : {}), + }; +} + +function groupPanels(events: StoryEvent[]): { + panels: DraftPanel[]; + trailingSay?: string; + openNote?: 'catch' | 'note'; + asks?: boolean; +} { const panels: DraftPanel[] = []; let pendingSay: string | undefined; let lastSaid: StoryEvent | undefined; @@ -2045,8 +2127,8 @@ function groupPanels(events: StoryEvent[]): { panels: DraftPanel[]; trailingSay? return { panels, trailingSay: lastSaid !== undefined ? closingText(lastSaid.label, lastSaid.keyPoints) : pendingSay, - // Claude's last words name an open problem: the final panel is not a celebration. - openNote: !!lastSaid?.keyPoints?.some((k) => OPEN_PROBLEM.test(k)), + // Claude's last words name an open problem, or something not done or checked: no celebration. + ...closingKinds(lastSaid?.keyPoints), }; } @@ -2651,7 +2733,7 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { if (todos) e.todos = todos; } - const { panels: grouped, trailingSay, openNote } = groupPanels(events); + const { panels: grouped, trailingSay, openNote, asks } = groupPanels(events); const room = STORY_LIMITS.panelsPerPage - (turn.ended ? 1 : 0); const drafts = capPanels(collapseLoops(grouped), room); const panels = drafts.map((d, i) => decorate(d, events, turn, i)); @@ -2668,7 +2750,8 @@ export function buildStoryPage(turn: StoryTurn): StoryPage { // The advisor is a second opinion, not a helper that does work. helpers: events.filter((e) => e.helper && e.tool !== 'Advisor' && e.intent !== 'prompt').length, helpersRunning: events.filter((e) => e.helper?.running).length, - ...(openNote && turn.ended && !turn.interrupted ? { openNote: true } : {}), + ...(openNote && turn.ended && !turn.interrupted ? { openNote } : {}), + ...(asks && turn.ended && !turn.interrupted ? { asks: true } : {}), ...(events.some((e) => e.finishedLater && e.intent !== 'prompt') ? { finishedLater: events.filter((e) => e.finishedLater && e.intent !== 'prompt').length } : {}), diff --git a/src/types/story.ts b/src/types/story.ts index 0021d6cf..e2de8b99 100644 --- a/src/types/story.ts +++ b/src/types/story.ts @@ -212,8 +212,10 @@ export interface StoryPage extends StoryTurnSummary { helpersRunning: number; /** Helpers and background commands of this turn that finished only after it ended. */ finishedLater?: number; - /** Claude's last words name an open problem or something not done or checked: no clean finish. */ - openNote?: boolean; + /** Claude's last words leave something open: `catch` a problem, `note` something not done or checked yet. */ + openNote?: 'catch' | 'note'; + /** Claude's last words ask you something or offer more: the next move is yours. */ + asks?: boolean; /** Every tool call of the turn failed (a blocked command, nothing that worked): no celebration. */ noneWorked?: boolean; } diff --git a/src/web/public/story-mode.js b/src/web/public/story-mode.js index 5e3db9bb..02201356 100644 --- a/src/web/public/story-mode.js +++ b/src/web/public/story-mode.js @@ -2503,9 +2503,13 @@ Object.assign(CodemanApp.prototype, { : page.helpersRunning || page.finishedLater || page.events.some((e) => e.running && !e.helper) ? // Work was still out when the turn ended (and maybe still is): not a clean finish. 'Done for now' - : page.openNote + : page.openNote === 'catch' ? 'Done, with a catch' - : 'Mission complete!'; + : page.openNote + ? 'Done, with a note' + : page.asks + ? 'Done, with a question' + : 'Mission complete!'; const chips = rt.splash.querySelector('.story-chips'); const items = []; if (page.durationMs !== undefined) items.push({ text: this._storyDuration(page.durationMs) }); diff --git a/test/fixtures/story-corpus/background-helpers-reports.expected.json b/test/fixtures/story-corpus/background-helpers-reports.expected.json index 124ce445..9855594f 100644 --- a/test/fixtures/story-corpus/background-helpers-reports.expected.json +++ b/test/fixtures/story-corpus/background-helpers-reports.expected.json @@ -12,7 +12,8 @@ "helpers": 2, "helpersRunning": 0, "finishedLater": 2, - "openNote": true, + "openNote": "note", + "asks": false, "panels": [ { "kind": "mission", @@ -88,6 +89,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": true, "panels": [ { "kind": "mission", @@ -116,7 +118,7 @@ "ok": null, "story": "Done in 14 seconds.", "director": "turn_duration · 14 s · 1 tool call", - "balloon": "Helper A has finished writing README.md, and I checked it against the code. It describes the command-line usage, count(text) and how to run the tests correctly, and its example ((2, 4, 24)) is right. It doesn't mention that running the tool without exactly one file prints a usage message and exits…", + "balloon": "Helper A has finished writing README.md, and I checked it against the code. It doesn't mention that running the tool without exactly one file prints a usage message and exits with code 2; I can add that line if you want it.", "tests": null, "roundsOk": null, "printed": null, @@ -138,6 +140,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -190,7 +193,8 @@ "helpers": 1, "helpersRunning": 0, "finishedLater": 2, - "openNote": true, + "openNote": "note", + "asks": false, "panels": [ { "kind": "mission", @@ -263,6 +267,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -316,6 +321,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -366,6 +372,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -430,6 +437,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -469,6 +477,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", 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 c1a0b064..9f314dd9 100644 --- a/test/fixtures/story-corpus/bash-script-background-helper.expected.json +++ b/test/fixtures/story-corpus/bash-script-background-helper.expected.json @@ -13,6 +13,7 @@ "helpersRunning": 0, "finishedLater": 1, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -122,6 +123,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": true, "panels": [ { "kind": "mission", @@ -165,7 +167,7 @@ "ok": null, "story": "Done in 21 seconds.", "director": "turn_duration · 21 s · 2 tool calls", - "balloon": "I built rename_by_date.sh, the helper agent wrote README.md, and the script passed every test I ran on sample files in samples/. Nothing is committed because this folder isn't a git repository, even though the session context said it was. Only lowercase .jpg is matched, as you asked.", + "balloon": "I built rename_by_date.sh, the helper agent wrote README.md, and the script passed every test I ran on sample files in samples/. Nothing is committed because this folder isn't a git repository, even though the session context said it was. Say if you want me to run git init and commit. Only lowercase .jpg is matched, as you asked.", "tests": null, "roundsOk": null, "printed": null, @@ -187,6 +189,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": true, "panels": [ { "kind": "mission", @@ -252,7 +255,7 @@ "ok": null, "story": "Done in 1 minute.", "director": "turn_duration · 1 min 14 s · 9 tool calls · 2 files changed", - "balloon": "I added a --prefix option to rename_by_date.sh. The samples folder didn't change.", + "balloon": "I added a --prefix option to rename_by_date.sh. Making the script sort file names the plain byte-by-byte way would fix it for up to 10 files with the same time. Want me to add that?", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json index c109d28c..754fd322 100644 --- a/test/fixtures/story-corpus/compact-then-plan-mode.expected.json +++ b/test/fixtures/story-corpus/compact-then-plan-mode.expected.json @@ -13,6 +13,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -110,6 +111,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -160,6 +162,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", diff --git a/test/fixtures/story-corpus/errors-push-append.expected.json b/test/fixtures/story-corpus/errors-push-append.expected.json index 46ad5133..5344956c 100644 --- a/test/fixtures/story-corpus/errors-push-append.expected.json +++ b/test/fixtures/story-corpus/errors-push-append.expected.json @@ -13,6 +13,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -169,6 +170,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -225,7 +227,7 @@ "ok": null, "story": "Done in 37 seconds.", "director": "turn_duration · 37 s · 8 tool calls · 2 files changed", - "balloon": "mode(xs) is in calc_stats.py, committed once after the tests went green, and pushed. I didn't use the advisor, gh or the network.", + "balloon": "mode(xs) is in calc_stats.py, committed once after the tests went green, and pushed. git describe --tags failed because the repo has no tags.", "tests": null, "roundsOk": null, "printed": null, @@ -247,6 +249,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -333,6 +336,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", 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 547783ae..6c8c3dda 100644 --- a/test/fixtures/story-corpus/failing-test-on-purpose.expected.json +++ b/test/fixtures/story-corpus/failing-test-on-purpose.expected.json @@ -13,6 +13,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -94,7 +95,7 @@ "ok": null, "story": "Done in 1 minute.", "director": "turn_duration · 1 min 24 s · 10 tool calls · 2 files changed", - "balloon": "slugify.mjs and its test file are written, all 4 tests pass, and both files are committed.", + "balloon": "slugify.mjs and its test file are written, all 4 tests pass, and both files are committed. First run (deliberately failing): the \"strips leading and trailing hyphens\" test expected '-hello-world'. That's wrong against the spec, and the run ended with 3 passed, 1 failed, exit code 1. Set git config user.name if you want a different name on future commits.", "tests": null, "roundsOk": null, "printed": null, @@ -116,6 +117,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -161,7 +163,7 @@ "ok": null, "story": "Done in 21 seconds.", "director": "turn_duration · 21 s · 5 tool calls · 1 file changed", - "balloon": "I added a fifth test for slugify('').", + "balloon": "I added a fifth test for slugify(''). The result was 4 passed, 1 failed, exit code 1.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json index 2bfbf74d..98718526 100644 --- a/test/fixtures/story-corpus/long-turn-prompt-added.expected.json +++ b/test/fixtures/story-corpus/long-turn-prompt-added.expected.json @@ -12,7 +12,8 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, - "openNote": false, + "openNote": "catch", + "asks": false, "panels": [ { "kind": "mission", @@ -156,7 +157,7 @@ "ok": null, "story": "Done in 6 minutes.", "director": "turn_duration · 6 min 25 s · 26 tool calls · 13 files changed", - "balloon": "The inventory/ package is built and committed. --csv is part of the CLI commit rather than its own commit, because you asked for it before that module existed.", + "balloon": "The inventory/ package is built and committed. --csv is part of the CLI commit rather than its own commit, because you asked for it before that module existed. Known quirks: remove pear prints the name as you typed it, not as it was stored (\"Pear\"). Because of how the temporary file is created, the saved JSON file is readable only by its owner (permissions 0600).", "tests": null, "roundsOk": null, "printed": null, @@ -178,6 +179,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -310,7 +312,7 @@ "ok": null, "story": "Done in 1 minute.", "director": "turn_duration · 1 min 16 s · 16 tool calls · 2 files changed", - "balloon": "I ran all 16 steps in order, one tool call each. I didn't re-run ruff, because only README.md and CHANGELOG.md changed, and ruff doesn't check Markdown.", + "balloon": "I ran all 16 steps in order, one tool call each. Two places where I didn't follow your wording exactly: Step 6: this session has no Grep tool, so I ran grep -rn 'def ' inventory/ through Bash instead. It found the same matches.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json index 3488f19c..33b198df 100644 --- a/test/fixtures/story-corpus/python-cli-fix-loop.expected.json +++ b/test/fixtures/story-corpus/python-cli-fix-loop.expected.json @@ -13,6 +13,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -107,7 +108,7 @@ "ok": null, "story": "Done in 3 minutes.", "director": "turn_duration · 3 min 13 s · 13 tool calls · 3 files changed", - "balloon": "I built wcount.py and test_wcount.py. With a missing file plus a readable one, the total row didn't appear because the code counted files that could be read rather than files that were named.", + "balloon": "I built wcount.py and test_wcount.py. What failed and how I fixed it: one test failed on the first run.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/fixtures/story-corpus/question-tests-first-push.expected.json b/test/fixtures/story-corpus/question-tests-first-push.expected.json index 57863f34..d6677be6 100644 --- a/test/fixtures/story-corpus/question-tests-first-push.expected.json +++ b/test/fixtures/story-corpus/question-tests-first-push.expected.json @@ -12,7 +12,8 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, - "openNote": true, + "openNote": "note", + "asks": false, "panels": [ { "kind": "mission", @@ -142,7 +143,7 @@ "ok": null, "story": "Done in 5 minutes.", "director": "turn_duration · 4 min 57 s · 20 tool calls · 6 files changed", - "balloon": "I've built the kebab slug tool in this folder, committed it, and pushed it to the new bare repo remote.git. Not tested: the README says pip install . gives you a kebab command.", + "balloon": "I've built the kebab slug tool in this folder, committed it, and pushed it to the new bare repo remote.git. If you want a different author, run git commit --amend --reset-author and push again. Not tested: the README says pip install . gives you a kebab command.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json index ec95e682..bdff44e0 100644 --- a/test/fixtures/story-corpus/teammates-then-interrupt.expected.json +++ b/test/fixtures/story-corpus/teammates-then-interrupt.expected.json @@ -13,6 +13,7 @@ "helpersRunning": 0, "finishedLater": 2, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -120,7 +121,8 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, - "openNote": true, + "openNote": "note", + "asks": false, "panels": [ { "kind": "mission", @@ -182,6 +184,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": true, "panels": [ { "kind": "mission", @@ -224,7 +227,7 @@ "ok": null, "story": "Done in 16 seconds.", "director": "stop_hook_summary · 16 s · 2 tool calls", - "balloon": "The project is done: both helpers finished, and I checked their files and committed them. Both helpers warned that index.test.mjs didn't exist, but they had looked before I created it, so the README's Testing section is correct.", + "balloon": "The project is done: both helpers finished, and I checked their files and committed them. Both helpers warned that index.test.mjs didn't exist, but they had looked before I created it, so the README's Testing section is correct. Say if you want CLAUDE.md committed too.", "tests": null, "roundsOk": null, "printed": null, @@ -246,6 +249,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", @@ -346,6 +350,7 @@ "helpersRunning": 0, "finishedLater": 0, "openNote": false, + "asks": false, "panels": [ { "kind": "mission", 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 13f6eeb8..37de28f1 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 @@ -12,7 +12,8 @@ "helpers": 1, "helpersRunning": 0, "finishedLater": 1, - "openNote": true, + "openNote": "catch", + "asks": true, "panels": [ { "kind": "mission", @@ -114,7 +115,7 @@ "ok": null, "story": "Claude's part is done in 3 minutes. Its helper finished later and reported back.", "director": "turn_duration · 2 min 44 s · 11 tool calls · 5 files changed · 1 helper", - "balloon": "I've built csvstat.py, a command-line tool that prints a CSV's row count and the min, max and mean of each column. A background helper is writing README.md; I didn't wait for it, so it isn't confirmed yet. The population mean shows a problem, described below.", + "balloon": "I've built csvstat.py, a command-line tool that prints a CSV's row count and the min, max and mean of each column. A background helper is writing README.md; I didn't wait for it, so it isn't confirmed yet. Problem: numbers are shown to only 6 significant digits. Once it has finished, the fix is a one-line formatting change and an update to the README example; say if you want it.", "tests": null, "roundsOk": null, "printed": null, @@ -135,7 +136,8 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, - "openNote": true, + "openNote": "catch", + "asks": true, "panels": [ { "kind": "mission", @@ -164,7 +166,7 @@ "ok": null, "story": "Done in 9 seconds.", "director": "turn_duration · 9 s · 1 tool call", - "balloon": "The background helper has finished README.md, and I've checked its work. Rounding: the README now explains that numbers are shown to 6 significant digits, so the population mean of 214574.2 appears as 214574. That note makes the current behaviour clear, but it doesn't fix it.", + "balloon": "The background helper has finished README.md, and I've checked its work. Rounding: the README now explains that numbers are shown to 6 significant digits, so the population mean of 214574.2 appears as 214574. That note makes the current behaviour clear, but it doesn't fix it. Now that the helper is done, nothing else is writing to the repo, so I can fix the number format and update the README example whenever you want.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/fixtures/story-corpus/web-page-node-test.expected.json b/test/fixtures/story-corpus/web-page-node-test.expected.json index 77770f01..f5fd9e29 100644 --- a/test/fixtures/story-corpus/web-page-node-test.expected.json +++ b/test/fixtures/story-corpus/web-page-node-test.expected.json @@ -12,7 +12,8 @@ "helpers": 0, "helpersRunning": 0, "finishedLater": 0, - "openNote": true, + "openNote": "catch", + "asks": false, "panels": [ { "kind": "mission", @@ -113,7 +114,7 @@ "ok": null, "story": "Done in 3 minutes.", "director": "turn_duration · 2 min 39 s · 10 tool calls · 3 files changed", - "balloon": "I built the pomodoro timer. Opening the page: it won't work if you double-click index.html. The page loads timer.js as a module, and Chrome and Firefox block that for local files, so you'd get a timer that never moves.", + "balloon": "I built the pomodoro timer. Opening the page: it won't work if you double-click index.html. Serve the folder instead, for example with python3 -m http.server in the folder, then open http://localhost:8000. I didn't open it in a browser; I only checked that its script is valid.", "tests": null, "roundsOk": null, "printed": null, diff --git a/test/story-corpus.test.ts b/test/story-corpus.test.ts index 86ab813e..4157ad30 100644 --- a/test/story-corpus.test.ts +++ b/test/story-corpus.test.ts @@ -41,6 +41,7 @@ function pagesOf(name: string) { helpersRunning: p.helpersRunning, finishedLater: p.finishedLater ?? 0, openNote: p.openNote ?? false, + asks: p.asks ?? false, panels: p.panels.map((x) => ({ kind: x.kind, ok: x.ok ?? null, @@ -164,7 +165,7 @@ describe('Story Mode on real sessions', () => { // An open problem, said further into the final message than its first sentence. expect(last('web-page-node-test', 0).balloon).toMatch(/won't work if you double-click index\.html/); // ...and the turn is not drawn as a clean finish. - expect(pagesOf('web-page-node-test')[0].openNote).toBe(true); + expect(pagesOf('web-page-node-test')[0].openNote).toBe('catch'); expect(last('text-plan-dry-run-helper', 1).balloon).toMatch(/doesn't fix it/); // What you expected, and what you asked for mid-turn. expect(last('errors-push-append', 2).balloon).toMatch(/As you expected, git describe --tags then failed/); diff --git a/test/story-mode.test.ts b/test/story-mode.test.ts index 589c9481..b8333835 100644 --- a/test/story-mode.test.ts +++ b/test/story-mode.test.ts @@ -1019,7 +1019,7 @@ describe('buildStoryPage', () => { expect(page.panels.at(-1)!.kind).toBe('complete'); }); - it('the closing balloon keeps an open problem from far into the message, never a table row or a question', () => { + it('the closing balloon keeps an open problem and a question to you from far into the message, never a table row', () => { const long = [ 'I built the csvstat tool for your CSV files.', 'It reads the file and prints the counts for each column, one line per column. '.repeat(4), @@ -1028,10 +1028,12 @@ describe('buildStoryPage', () => { 'Want me to add a --json flag?', 'One known issue: big numbers are shown rounded.', ].join('\n'); - const balloon = pageOf([prompt('build it'), said(long), end(1000)]).panels.at(-1)!.balloon!.text; - expect(balloon).toBe( - 'I built the csvstat tool for your CSV files. One known issue: big numbers are shown rounded.' + const page = pageOf([prompt('build it'), said(long), end(1000)]); + // In the order Claude wrote them; the question is the sentence aimed at you, so it stays. + expect(page.panels.at(-1)!.balloon!.text).toBe( + 'I built the csvstat tool for your CSV files. Want me to add a --json flag? One known issue: big numbers are shown rounded.' ); + expect(page).toMatchObject({ openNote: 'catch', asks: true }); }); it("a run panel's terminal card shows what the command printed, never what looks like a secret", () => {