fix(story-mode): commits in montages, lint verdicts, and a page that holds still

From review round 2 (a long turn, and a prompt added mid-turn):

- Commits fold last and a montage names them ("saves the work in 3
  commits"); a save counts its commits ("saves the work in 6 commits").
  Before, all three commits of a 16-step turn vanished into montages that
  read "runs 2 commands".
- Folding a full page starts at the oldest pair and grows a montage already
  there, and never takes the newest panel while another choice exists: a
  live page no longer reshuffles panels the reader is watching.
- A check whose output reports problems ("1 file would be reformatted",
  "Found 3 errors", "✖ 5 problems", `error TS…`) failed, though a trailing
  `; ls` hid the exit code: "The tests pass, but another check fails."
- Shell structure is not a command (`for x in`, `do`, `done`, `if`), so a
  `--version` probe in a loop reads as looking; `command -v` and
  `git config --get` too.
- The montage draws only the kinds of steps it folded, with a box for
  saves; the loop's arrow has a head and rests on a finished page; the
  "MISSION COMPLETE!" title has room for its tilt; drawer commands keep
  their line breaks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 11:10:14 +02:00
1 parent ca035dd41b
commit a2db03cda5
5 files changed
+162 -30

No files matched your search

+76 -22
View File
@@ -78,6 +78,8 @@ interface ReducedInput {
ships?: 'pr' | 'push';
/** The segment of a Bash one-liner that decided its intent. */
headline?: string;
/** How many commits a save makes, when more than one. */
commits?: number;
/** Files a Bash call writes through here-documents. */
writes?: StoryEdit[];
description?: string;
@@ -127,6 +129,8 @@ export type StoryEntry =
helper?: { result?: string; toolUses?: number; durationMs?: number; agentId?: string; background?: boolean };
answer?: string;
tests?: StoryTests;
/** A check whose output reports problems (lint, format, types). */
problems?: boolean;
}
| { k: 'end'; at: string; durationMs?: number }
| { k: 'compact'; at: string }
@@ -301,8 +305,12 @@ function commandSegments(command: string): string[] {
/** A segment's command with what only wraps it removed: env assignments, `sudo`, `timeout 60`, `( ...`. */
function segmentHead(segment: string): string {
let s = segment.replace(/^[({\s]+/, '');
// Shell structure is not a command: `for x in a b`, `done`, `fi`; `do`/`then`/`if` only wrap one.
if (/^(for\s+\w+(\s+in\b.*)?|done|fi|esac|else|[{}])\s*$/.test(s)) return '';
if (/^command\s+-[vV]\b/.test(s)) return 'which';
for (let k = 0; k < 6; k++) {
const next = s
.replace(/^(do|then|else|if|elif|while|until|!)\s+/, '')
.replace(/^[A-Za-z_][A-Za-z0-9_]*=("[^"]*"|'[^']*'|\S*)\s+/, '')
.replace(/^(sudo|command|exec|time|nice|nohup|env|xargs)\s+/, '')
.replace(/^timeout\s+(-\S+\s+)*\S+\s+/, '');
@@ -323,7 +331,7 @@ const EDIT_HEAD = /^(sed (-i|--in-place)\b|perl -p?i\b|patch\b|git apply\b)/;
/** Where a redirect writes scratch, not the project: temp dirs, devices, and paths held in variables. */
const SCRATCH_TARGET = /^(\/tmp\/|\/var\/tmp\/|\/dev\/|\$|~\/\.cache\/)/;
const LOOK_HEAD =
/^(cd|cat|head|tail|sed -n|grep|egrep|rg|find|ls|wc|awk|jq|tree|stat|file|du|df|diff|less|more|xxd|od|which|type|pwd|echo|printf|sort|uniq|cut|basename|dirname|realpath|readlink|git (status|diff|log|show|branch|blame|ls-files|rev-parse|remote)|gh (pr|issue|run|api) (view|list|diff|checks))\b/;
/^(cd|cat|head|tail|sed -n|grep|egrep|rg|find|ls|wc|awk|jq|tree|stat|file|du|df|diff|less|more|xxd|od|which|type|pwd|echo|printf|sort|uniq|cut|basename|dirname|realpath|readlink|git (status|diff|log|show|branch|blame|ls-files|rev-parse|remote|config (--get|-l|--list))|gh (pr|issue|run|api) (view|list|diff|checks))\b/;
/** A probe that asks a tool about itself without running anything: `pytest --version`, `npm test --help`. */
const PROBE = /\s--(version|help|collect-only)\b|\s-[Vh]\s*$/;
@@ -502,6 +510,21 @@ export function heredocWrites(command: string, cwd?: string): StoryEdit[] {
return out;
}
/**
* Whether a linter, formatter or type checker reported problems (`Found 3 errors.`, `1 file would be
* reformatted`, `✖ 5 problems`, `error TS2322:`). Read when a trailing command (`; ls`) hid the exit code.
*/
export function reportsProblems(output: string): boolean {
return [
/\bFound [1-9]\d* errors?\b/,
/\b[1-9]\d* files? would be reformatted\b/,
/^would reformat /m,
/✖ [1-9]\d* problems?\b/,
/\[warn\] Code style issues found/,
/\berror TS\d+:/,
].some((re) => re.test(output));
}
/**
* Passed/failed counts from a test runner's summary line (vitest, jest, pytest, mocha, cargo...).
* Scans the last lines only; returns null when no summary is recognisable.
@@ -660,9 +683,11 @@ function reduceInput(name: string, input: unknown, cwd?: string): ReducedInput {
if (reads.length) input.reads = reads;
}
if (intent === 'save') {
const ships = commandSegments(full.slice(0, 4000)).map(segmentHead);
if (ships.some((h) => /^gh pr create\b/.test(h))) input.ships = 'pr';
else if (ships.some((h) => /^git push\b/.test(h))) input.ships = 'push';
const heads = commandSegments(withoutHeredocBodies(full.slice(0, 4000))).map(segmentHead);
if (heads.some((h) => /^gh pr create\b/.test(h))) input.ships = 'pr';
else if (heads.some((h) => /^git push\b/.test(h))) input.ships = 'push';
const commits = heads.filter((h) => /^git commit\b/.test(h)).length;
if (commits > 1) input.commits = commits;
}
return input;
}
@@ -852,6 +877,8 @@ export function createStoryReducer({ sidechain = false }: { sidechain?: boolean
if (name === 'Bash') {
const tests = parseTestCounts(tail.text);
if (tests) result.tests = tests;
const intent = call?.input.intent;
if ((intent === 'build' || intent === 'test') && reportsProblems(tail.text)) result.problems = true;
}
}
out.push(result);
@@ -1087,6 +1114,7 @@ function toolEvent(e: Extract<StoryEntry, { k: 'tool' }>, actor: StoryActor): St
if (i.reads) ev.files = i.reads;
if (i.ships) ev.ships = i.ships;
if (i.headline) ev.headline = i.headline;
if (i.commits) ev.commits = i.commits;
if (i.writes) ev.edits = i.writes;
if (i.offset) ev.line = i.offset;
if (e.name === 'Bash' && i.description) ev.description = i.description;
@@ -1121,6 +1149,7 @@ function applyResult(ev: StoryEvent, r: Extract<StoryEntry, { k: 'result' }>): v
ev.edit = r.edit;
ev.file = r.edit.file;
}
if (r.problems) ev.ok = false;
if (r.tests) {
ev.tests = r.tests;
// A run that reports failed tests failed, whatever its exit code (`pytest | tail` exits 0).
@@ -1369,7 +1398,7 @@ const MERGEABLE = new Set<StoryPanelKind>([
'compact',
]);
/** Kinds that may be folded into a montage when a page has too many panels. */
const MINOR = new Set<StoryPanelKind>(['investigate', 'research', 'plan', 'run', 'compact', 'save', 'montage']);
const MINOR = new Set<StoryPanelKind>(['investigate', 'research', 'plan', 'run', 'compact', 'montage']);
interface DraftPanel {
kind: StoryPanelKind;
@@ -1483,27 +1512,38 @@ function collapseLoops(panels: DraftPanel[]): DraftPanel[] {
return out;
}
/** Folds the smallest neighbouring pair into a montage until the page fits. */
/**
* Folds neighbouring panels into montages until the page fits. Quiet kinds fold first, and the
* oldest pair first, growing a montage already there when it can: so on a live page, the panels
* the reader is watching keep their place, and the newest panel is never the one folded while
* another choice exists.
*/
function capPanels(panels: DraftPanel[], max: number): DraftPanel[] {
const out = [...panels];
const firstMergeable = out[0]?.kind === 'mission' ? 1 : 0;
while (out.length > max) {
let best = -1;
let bestSize = Infinity;
for (const strict of [true, false]) {
for (let k = firstMergeable; k < out.length - 1; k++) {
const a = out[k];
const b = out[k + 1];
const ok = strict
? MINOR.has(a.kind) && MINOR.has(b.kind)
: ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask');
const size = a.events.length + b.events.length;
if (ok && size < bestSize) {
best = k;
bestSize = size;
search: for (const strict of [true, false]) {
for (const lastStart of [out.length - 3, out.length - 2]) {
let oldest = -1;
for (let k = firstMergeable; k <= lastStart; k++) {
const a = out[k];
const b = out[k + 1];
const ok = strict
? MINOR.has(a.kind) && MINOR.has(b.kind)
: ![a.kind, b.kind].some((kind) => kind === 'mission' || kind === 'ask');
if (!ok) continue;
if (a.kind === 'montage' || b.kind === 'montage') {
best = k;
break search;
}
if (oldest < 0) oldest = k;
}
if (oldest >= 0) {
best = oldest;
break search;
}
}
if (best >= 0) break;
}
if (best < 0) break;
const a = out[best];
@@ -1538,6 +1578,8 @@ function montageSummary(evs: StoryEvent[]): string {
if (evs.some((e) => e.intent === 'web')) parts.push('checks the web');
if (evs.some((e) => e.intent === 'edit')) parts.push('changes files');
if (evs.some((e) => e.intent === 'test' || e.intent === 'build')) parts.push('runs checks');
const commits = evs.reduce((n, e) => n + (e.commits ?? (e.intent === 'save' ? 1 : 0)), 0);
if (commits) parts.push(commits > 1 ? `saves the work in ${commits} commits` : 'saves the work');
return parts.length ? parts.join(', ') : 'a few more steps';
}
@@ -1682,7 +1724,15 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
story =
neverFailed && panel.ok === true
? `Build and test, ${plural(panel.rounds, 'round')}. Every check passes.`
: `Fix and test, ${plural(panel.rounds, 'round')}.${panel.ok === true ? ' It passes in the end.' : panel.ok === false ? ' Still failing.' : ''}`;
: `Fix and test, ${plural(panel.rounds, 'round')}.${
panel.ok === true
? ' It passes in the end.'
: panel.ok === false && tests && tests.passed && !tests.failed && !tests.errors
? ' The tests pass, but another check fails.'
: panel.ok === false
? ' Still failing.'
: ''
}`;
director = `${plural(checks.length, 'check')} · ${plural(edits.length, 'edit')} · ${fmtCounts(panel.added, panel.removed)} · last: ${
lastCheck ? outcome(lastCheck) : 'running'
}`;
@@ -1699,6 +1749,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
const failedFirst = checks.slice(0, -1).some((e) => passed(e) === false);
if (ok === undefined) story = isTest ? 'Claude runs the tests.' : 'Claude runs the checks.';
else if (!ok && tests && !tests.passed && !tests.failed && tests.errors) story = "The tests don't even load.";
else if (!ok && tests && tests.passed && !tests.failed && !tests.errors)
story = 'The tests pass, but another check fails.';
else if (ok)
story = failedFirst ? 'It fails at first, then passes.' : isTest ? 'The tests pass.' : 'The checks pass.';
else
@@ -1728,6 +1780,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
const last = evs[evs.length - 1];
if (last?.ok !== undefined) panel.ok = last.ok;
const ships = evs.some((e) => e.ships === 'pr') ? 'pr' : evs.some((e) => e.ships === 'push') ? 'push' : '';
const commits = evs.reduce((n, e) => n + (e.commits ?? (e.intent === 'save' ? 1 : 0)), 0);
const inCommits = commits > 1 ? ` in ${commits} commits` : '';
story =
panel.ok === false
? ships === 'pr'
@@ -1738,8 +1792,8 @@ function decorate(draft: DraftPanel, events: StoryEvent[], turn: StoryTurn, inde
: ships === 'pr'
? 'Claude opens a pull request.'
: ships === 'push'
? 'Claude saves the work and sends it up.'
: 'Claude saves the work.';
? `Claude saves the work${inCommits} and sends it up.`
: `Claude saves the work${inCommits}.`;
director = `${evs.map(shortCommand).slice(0, 2).join(' · ')}${errors ? ` · ${plural(errors, 'error')}` : ''}${
edits.length ? ` · writes ${plural(unique(edits.map((d) => d.file)).length, 'file')}` : ''
}`;
+2
View File
@@ -126,6 +126,8 @@ export interface StoryEvent {
description?: string;
/** A save that ships the work: `gh pr create` (`pr`) or `git push` (`push`). */
ships?: 'pr' | 'push';
/** How many commits a Bash call makes (`git commit` segments). */
commits?: number;
/** Who a prompt came from (a prompt added mid-turn can come from someone other than the turn's starter). */
origin?: StoryTurnOrigin;
output?: string;
+3 -1
View File
@@ -483,7 +483,8 @@ body.story-open {
display: none;
}
.story-splash-title {
margin: 0;
/* Room for the tilt: rotated about its bottom-left corner, its right end rises. */
margin: 0.4em 0 0;
font: 400 clamp(28px, 4.4cqi, 44px) / 0.92 var(--story-display);
color: var(--story-yellow);
-webkit-text-stroke: 2.5px var(--story-line);
@@ -791,6 +792,7 @@ body.story-open {
.story-df-path,
.story-out-cmd {
flex: 1 1 220px;
white-space: pre-wrap;
font: 500 12.5px/1.35 var(--story-mono);
font-variant-ligatures: none;
overflow-wrap: anywhere;
+29 -7
View File
@@ -1116,7 +1116,8 @@ const StoryArt = (() => {
}
// The cycle arrow between the page and the board.
const ax = 12 + pw + 17;
const spin = (f / 20) % TAU;
// It turns while the rounds are still going; on a finished page it rests.
const spin = p.panel.live || f < HERO ? (f / 20) % TAU : 0.4;
ctx.save();
ctx.translate(ax, 164);
ctx.rotate(spin);
@@ -1128,6 +1129,16 @@ const StoryArt = (() => {
ctx.strokeStyle = C.yellow;
ctx.lineWidth = 3;
ctx.stroke();
// The arrowhead at the arc's end.
const end = TAU - 0.6;
const hx = Math.cos(end) * 14;
const hy = Math.sin(end) * 14;
ctx.beginPath();
ctx.moveTo(hx + 7, hy - 3);
ctx.lineTo(hx - 1, hy + 7);
ctx.lineTo(hx - 6, hy - 6);
ctx.closePath();
inked(ctx, C.yellow, 2);
ctx.restore();
// Rounds and verdict stack under the top corner, which the balloon may take.
sfx(
@@ -1274,12 +1285,18 @@ const StoryArt = (() => {
halftone(ctx, W, H, C.yellow, W / 2, H / 2, { rmax: 3.4 });
},
draw(ctx, W, H, f, p, hit) {
// One cell per kind of step the montage folded, in this order, at most four.
const evs = p.panel.events.map((i) => p.page.events[i]).filter(Boolean);
const has = (...intents) => evs.some((e) => intents.includes(e.intent));
const cells = [
{ icon: 'look', label: 'reads' },
{ icon: 'run', label: 'runs' },
{ icon: 'edit', label: 'edits' },
{ icon: 'check', label: 'checks' },
];
has('read', 'search', 'look', 'web') && { icon: 'look' },
has('run', 'tool', 'message', 'helper', 'plan') && { icon: 'run' },
has('edit') && { icon: 'edit' },
has('test', 'build') && { icon: 'check' },
has('save') && { icon: 'save' },
]
.filter(Boolean)
.slice(0, 4);
const size = Math.min(70, (W - 120) / 2);
const x0 = 16;
const y0 = 92;
@@ -1298,7 +1315,12 @@ const StoryArt = (() => {
else if (c.icon === 'run') terminal(ctx, x + 8, y + 12, size - 16, size - 24, ['…'], HERO);
else if (c.icon === 'edit')
pencil(ctx, [cx + size * 0.3, cy - size * 0.3], [cx - size * 0.2, cy + size * 0.2]);
else star4(ctx, cx, cy, size * 0.3, C.go);
else if (c.icon === 'save') {
rr(ctx, cx - size * 0.3, cy - size * 0.12, size * 0.6, size * 0.38, 3);
inked(ctx, C.yellow, 2.5);
rr(ctx, cx - size * 0.34, cy - size * 0.26, size * 0.68, size * 0.14, 3);
inked(ctx, C.yellow, 2.5);
} else star4(ctx, cx, cy, size * 0.3, C.go);
ctx.restore();
});
hit('list', x0, y0, size * 2 + 10, size * 2 + 10, 'See every step');