Beta, read-only: shows the current /mandrel-deliver Story above the prompt

An opinionated workflow framework for AI coding assistants built on Story-centric GitHub orchestration. Planning, execution, and state all live natively in GitHub Issues, Labels, and Projects V2.
Mandrel is distributed as the mandrel npm package and wires its orchestration into your project's GitHub repository. You do not need a pre-created Git repo or GitHub remote — bootstrap.js provisions both as part of a cold start (git init → gh repo create --push → gh project create). You need:
git on your PATH.gh >= 2.40, authenticated — run gh auth login once so orchestration scripts pick up your token from the OS keychain. A vanilla gh auth login token does not carry the project scope needed to provision the GitHub Projects V2 board; bootstrap degrades to warn-and-skip-board in that case (no hard failure). To enable board provisioning, grant the scope with gh auth refresh -s project (re-auth in the browser when prompted) before running bootstrap.js.The canonical cold-start path is one command, then one slash command:
npx mandrel init # install mandrel → sync → prompt → bootstrap → onboarding tail → /mandrel-plan handoff
# then, inside Claude Code (commands load from .claude/commands/):
/mandrel-plan --seed "…" # interrogate → author one Story (default) → persist
/mandrel-deliver <id> # story-<id> → PR → main
npx mandrel init installs mandrel (when ./.agents/ is absent), materializes it via mandrel sync, then asks whether to configure now (option 1 → runs bootstrap.js, then the onboarding tail: stack detection, docs scaffolding offer, mandrel doctor readiness gate, and a /mandrel-plan handoff) or stop at just the files (option 2 → re-run mandrel init any time to configure). Pass --assume-yes for a non-interactive run that proceeds straight to configure (and forwards the flag to bootstrap). When ./.agents/ is already present (you ran npm install mandrel first), init skips the install/sync and goes straight to the prompt. Once mandrel init completes, you land at the /mandrel-plan handoff — run /mandrel-plan --seed "<idea>" to author your first Story, then deliver it with /mandrel-deliver <storyId> (story-<id> → PR → main).
If you prefer to drive the steps mandrel init wraps by hand, run them from your project root:
npm install mandrel # pin an exact, provenance-signed version
npx mandrel sync # materialize ./.agents/ from the package
node .agents/scripts/bootstrap.js
npm install mandrel pins an exact, provenance-signed version in your lockfile. The package's postinstall hook runs mandrel sync best-effort, so ./.agents/ is usually materialized automatically; the explicit npx mandrel sync above is the belt-and-suspenders step for --ignore-scripts or sandboxed-CI installs. Run npx mandrel doctor any time to confirm the install is healthy.
pnpm users — hoist mandrel's runtime deps. The materialized
./.agents/scripts/*.jsrun from your project root and resolve their third-party deps (ajv, js-yaml, …) from your top-levelnode_modules. npm and yarn hoist transitive deps there automatically; pnpm's default isolated layout does not — it keeps them in the.pnpmvirtual store, so the framework scripts (andmandrel doctor'sruntime-depscheck) cannot see them. Add the following to your.npmrcbefore installing:# Lift mandrel's runtime deps to the top-level node_modules so the # materialized .agents/scripts can resolve them. shamefully-hoist=truePrefer a surgical alternative? Replace
shamefully-hoistwith a scopedpublic-hoist-pattern[]=line per package listed in.agents/runtime-deps.json— read the file rather than copying a list from here, since the complexity kernel's closure is several packages. Ifmandrel doctorreportsruntime-deps missing: …, this is the fix.
bootstrap.js is interactive on a TTY and auto-accepts the owner/repo/base branch/operator handle it can infer from your local git remote and git config user.name — you only get prompted for fields it can't infer (typically the optional Projects V2 number). When the folder is not yet a git repo, or the GitHub repo doesn't exist, it provisions them: git init plus a first commit, then gh repo create --source=. --push (use --visibility private|public|internal, default private, to set the new repo's visibility), then gh project create for the Projects V2 board. Override anything inferred with --owner, --repo, --base-branch, or --operator-handle. For CI / scripted installs pass --assume-yes plus whichever overrides you need. The script is idempotent — safe to re-run anytime.
For the consumer reference and the end-to-end workflow narrative, see .agents/README.md and docs/SDLC.md. Every .agentrc.json key is documented in .agents/docs/configuration.md, and the slash-command index lives in .agents/docs/workflows.md.
Advance mandrel to the newest published version and re-materialize ./.agents/ in one command:
npx mandrel update
mandrel update runs an ordered cycle:
npm view mandrel version registry probe) and the currently installed version.pnpm-lock.yaml ⇒ pnpm, yarn.lock ⇒ yarn, otherwise npm) so the bump lands in your real lockfile. The dependency bump is left staged on disk — mandrel update performs no git add / git commit, so you review and commit the lockfile change yourself../.agents/ from the freshly installed payload.--dry-run — print the resolved target version and the ordered step plan, then exit. No dependency is bumped, no file is written, no seam runs.--install-cmd "<cmd>" — override the auto-detected install command. The package manager is normally detected from your lockfile (pnpm-lock.yaml ⇒ pnpm add -D …, yarn.lock ⇒ yarn add -D …, otherwise npm install …), so an override is rarely needed. When you do pass one, a {target} placeholder is substituted with the resolved newest version — e.g. --install-cmd "pnpm add -D mandrel@{target} -w" — so the override can still consume the auto-probed version. The registry probe always stays on npm view (it is a PM-agnostic registry query).If you prefer to drive the steps by hand:
npm install mandrel@latest # or pnpm add / yarn up
npx mandrel sync # re-materialize ./.agents/
npx mandrel doctor # verify the install
Mandrel's effectiveness is measured by a separate companion repo, mandrel-bench — a consumer of the published mandrel package. It pins a specific framework version, materializes it via mandrel sync, and drives Mandrel's own /mandrel-plan→/mandrel-deliver pipeline (plus a bare-model control) over a scenario corpus. Each run is scored across five dimensions — Quality, Planning fidelity, and Autonomy (what the scaffolding buys) versus Efficiency and Overhead ratio (what it costs) — reported as distributions with a noise-band, tracking the framework's value-add over the bare-model baseline across versions and models.
The benchmark lives in its own repo on purpose: holding the harness fixed while varying the pinned mandrel version cleanly decouples harness-version from framework-version, and running through the published-package + mandrel sync path exercises the real consumer contract. The dependency is one-directional — mandrel-bench depends on mandrel, never the reverse. See the mandrel-bench README for the dimensions, run model, and how to benchmark a new version.
The published mandrel package ships three directories — .agents/, bin/, and lib/ — plus the single file docs/CHANGELOG.md, and excludes the __tests__ subtrees under .agents/ and lib/ (see the files array in package.json). .agents/ is the payload mandrel sync materializes into a consumer's ./.agents/ directory; bin/mandrel.js and its lib/ implementation stay inside node_modules/mandrel/ and back the npx mandrel … CLI used throughout this README. Everything else in this repository — docs/, tests/, .github/, the root tooling configs — is internal development tooling and is not published.
Common commands while developing the framework itself:
npm run lint # biome + markdownlint + repo ratchets + generated-doc drift
npm run format # biome format — JavaScript/JSON only, not markdown
npm test # framework tests
npm run test:coverage # tests with coverage gate
Deeper reference material lives in docs/ rather than inline here:
docs/architecture.md — module map, repo layout, state machine, and tech stack..agents/docs/configuration.md — every .agentrc.json key explained..agents/docs/workflows.md — slash-command index (auto-generated from the workflow set).docs/CHANGELOG.md — release history.AGENTS.md — the repository-level orientation pointer; it links on to docs/onboarding.md for the layout, commands, and development standards.docs/release-operations.md — the Release Checklist, the Install Matrix release gate, the single-package release topology, PAT / npm-token setup, and the major-version policy. Releases are automated by release-please: land Conventional Commits on main and it opens a combined chore: release main PR that squash-merges itself once CI is green, tags main, and publishes mandrel to npm.Install scripts are disabled by default: the committed .npmrc sets ignore-scripts=true, so npm install / npm ci will not execute dependency lifecycle hooks — a defense-in-depth measure against malicious lifecycle scripts in compromised transitive packages (CWE-1357). CI passes --ignore-scripts explicitly. If you knowingly need install scripts for a specific install, run npm install --ignore-scripts=false for that invocation only.
CRAP and Maintainability gates fire at four sites — .husky/pre-commit (quality-preview.js --staged, scored on the staged index), .husky/pre-push (diff-scoped against origin/main), close-validation (story close), and CI (ci.yml, push + PR) — against the same thresholds from delivery.quality.* in .agentrc.json. All four are blocking: the pre-commit hook fires earliest and refuses the commit on a threshold violation, and a green npm run lint / npm test / check-baselines.js run does not predict it.
MIT
hooks/register.tsx 1069 lines1// mandrel-status — a beta, read-only Claude Code mod. It reads the state files
2// /mandrel-deliver already writes and draws a short band above the prompt, plus
3// a Details pane. Nothing in Mandrel depends on it; see
4// .agents/docs/runbooks/mods.md.
5
6import type { EngineInterface, FsEntry, Register } from 'claude-code';
7import { atom, read, update } from 'claude-code';
8
9import type {
10 Focus,
11 Learned,
12 PhaseRow,
13 RunLine,
14 Snapshot,
15 StageName,
16 StageRow,
17} from '../types';
18
19const REFRESH_MS = 15_000;
20const LEARN_MS = 10 * 60_000;
21const STALL_MS = 10 * 60_000;
22const MIN_STALL_MS = 2 * 60_000;
23const RECENT_MS = 2 * 60 * 60_000;
24const MIN_SAMPLES = 3;
25const MAX_SAMPLES = 30;
26/** Under this many body columns the band keeps only its first line. */
27const NARROW = 80;
28/** Cells the Hide and Details buttons take at the end of line 1. */
29const BUTTON_CELLS = 18;
30const PANE = 'mandrel-status';
31
32const STAGES: StageName[] = [
33 'start',
34 'build',
35 'check',
36 'handoff',
37 'close',
38 'merge',
39];
40const CLOSE_PHASES = [
41 'wrong-tree-guard',
42 'base-sync',
43 'close-validation',
44 'push',
45 'pull-request',
46 'code-review',
47 'auto-merge',
48 'confirm-merge',
49 'post-land',
50];
51const MERGE_PHASES = new Set(['auto-merge', 'confirm-merge']);
52const STOPPED = new Set(['blocked', 'failed', 'escalated']);
53const ANNOUNCED = new Set(['landed', 'blocked']);
54
55const WORKTREE = /^story-(\d+)$/;
56const ENVELOPE = /^story-deliver-terminal-(\d+)\.json$/;
57const VERDICT = /^acceptance-verdict-round-(\d+)\.json$/;
58const RUN_DIR = /^run-[\w-]+$/;
59const GITDIR = /^gitdir:\s*(.+?)\s*$/m;
60const MAIN_OF = /^(.+)\/\.git\/worktrees\/[^/]+$/;
61const INIT_MARKER = '--- STORY INIT RESULT ---';
62const TITLE = /"storyTitle"\s*:\s*("(?:[^"\\]|\\.)*")/;
63const GATE = /^\[([^\]]+)\]/;
64const REFLOG_TIME = /\s(\d+)\s[+-]\d{4}$/;
65const BULLET = /^\s*[-*]\s+\S/;
66
67const EMPTY: Snapshot = { focus: null, run: null, scannedAt: 0 };
68const snapshot = atom(
69 { plugin: 'mandrel-status', key: 'snapshot' } as const,
70 EMPTY,
71);
72const seen = atom(
73 { plugin: 'mandrel-status', key: 'seen' } as const,
74 [] as string[],
75);
76const isSeeded = atom(
77 { plugin: 'mandrel-status', key: 'isSeeded' } as const,
78 false,
79);
80const learnedAtom = atom(
81 { plugin: 'mandrel-status', key: 'learned' } as const,
82 { at: 0, phases: {} } as Learned,
83);
84const hiddenFor = atom(
85 { plugin: 'mandrel-status', key: 'hiddenFor' } as const,
86 null as number | null,
87);
88
89type Stamped = { storyId: number; mtimeMs: number };
90
91/** Where a scan reads: the main checkout, its tempRoot, the orchestration listing. */
92type Ctx = {
93 root: string;
94 temp: string;
95 orchestration: string;
96 files: FsEntry[];
97 now: number;
98};
99
100type Envelope = {
101 storyId: number;
102 at: number;
103 status: string;
104 prNumber: number | null;
105 checksStatus: string | null;
106 elapsedSeconds: number | null;
107 phaseDurations: Record<string, number>;
108 problem: string | null;
109 nextCommand: string | null;
110};
111
112type Progress = {
113 stage: string;
114 phase: string | null;
115 stageStartedAt: number | null;
116 phaseStartedAt: number | null;
117 prNumber: number | null;
118 updatedAt: number;
119};
120
121type Commits = { at: number; firstAt: number; count: number; subject: string };
122type Verdict = {
123 at: number;
124 firstAt: number;
125 round: number;
126 met: number;
127 total: number;
128};
129type Handoff = {
130 at: number;
131 firstAt: number;
132 step: string | null;
133 steps: { name: string; at: number }[];
134 doneAt: number | null;
135};
136
137/** Everything on disk about one Story, each piece stamped with its time. */
138type Evidence = {
139 init: { at: number; title: string | null } | null;
140 build: Commits | null;
141 check: Verdict | null;
142 handoff: Handoff | null;
143 close: { at: number; gate: string | null } | null;
144 progress: Progress | null;
145 envelope: Envelope | null;
146 followUps: number | null;
147};
148
149// ---------------------------------------------------------------- reading
150
151const num = (value: unknown): number | null =>
152 typeof value === 'number' && Number.isFinite(value) ? value : null;
153const str = (value: unknown): string | null =>
154 typeof value === 'string' && value.length > 0 ? value : null;
155
156/** An epoch-ms number or an ISO string, as the progress file and ledger write them. */
157function toMs(value: unknown): number | null {
158 if (typeof value === 'number') return num(value);
159 if (typeof value !== 'string') return null;
160 const parsed = Date.parse(value);
161 return Number.isNaN(parsed) ? null : parsed;
162}
163
164const join = (root: string, path: string): string =>
165 root === '.' || path.startsWith('/') ? path : `${root}/${path}`;
166
167async function readText(
168 $: EngineInterface,
169 path: string,
170): Promise<string | null> {
171 try {
172 return await $.fs.read(path);
173 } catch {
174 return null;
175 }
176}
177
178async function readJson($: EngineInterface, path: string): Promise<unknown> {
179 const text = await readText($, path);
180 if (text === null) return undefined;
181 try {
182 return JSON.parse(text);
183 } catch {
184 return undefined;
185 }
186}
187
188/**
189 * The main checkout: a linked worktree's `.git` file names it as
190 * `gitdir: <main>/.git/worktrees/<name>`; anywhere else it is `.`.
191 */
192async function resolveRoot($: EngineInterface): Promise<string> {
193 const gitdir = GITDIR.exec((await readText($, '.git')) ?? '')?.[1];
194 const main = gitdir
195 ? MAIN_OF.exec(gitdir.replace(/\\/g, '/'))?.[1]
196 : undefined;
197 return main ?? '.';
198}
199
200function tempRootOf(config: unknown): string | undefined {
201 const value = (config as { project?: { paths?: { tempRoot?: unknown } } })
202 ?.project?.paths?.tempRoot;
203 return typeof value === 'string' && value.length > 0 ? value : undefined;
204}
205
206/** `.agentrc.local.json` wins over `.agentrc.json`; `temp` when neither sets it. */
207async function resolveTempRoot(
208 $: EngineInterface,
209 root: string,
210): Promise<string> {
211 const temp =
212 tempRootOf(await readJson($, join(root, '.agentrc.local.json'))) ??
213 tempRootOf(await readJson($, join(root, '.agentrc.json'))) ??
214 'temp';
215 return join(root, temp);
216}
217
218async function listMatching(
219 $: EngineInterface,
220 dir: string,
221 pattern: RegExp,
222): Promise<Stamped[]> {
223 const entries = await $.fs.list(dir).catch(() => []);
224 const found: Stamped[] = [];
225 for (const entry of entries) {
226 const match = pattern.exec(entry.name);
227 if (!match || entry.kind !== 'dir') continue;
228 const mtimeMs = await $.fs
229 .stat(`${dir}/${entry.name}`)
230 .then((stat) => stat.mtimeMs)
231 .catch(() => 0);
232 found.push({ storyId: Number(match[1]), mtimeMs });
233 }
234 return found;
235}
236
237function stampFiles(files: FsEntry[], pattern: RegExp): Stamped[] {
238 return files.flatMap((entry) => {
239 const match = pattern.exec(entry.name);
240 return match && entry.kind === 'file'
241 ? [{ storyId: Number(match[1]), mtimeMs: entry.mtimeMs }]
242 : [];
243 });
244}
245
246const fileNamed = (ctx: Ctx, name: string): FsEntry | undefined =>
247 ctx.files.find((entry) => entry.name === name && entry.kind === 'file');
248
249const newest = (list: Stamped[]): Stamped | undefined =>
250 [...list].sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
251
252function toEnvelope(
253 storyId: number,
254 at: number,
255 raw: unknown,
256): Envelope | null {
257 const body = raw as {
258 status?: unknown;
259 elapsedSeconds?: unknown;
260 nextCommand?: unknown;
261 phaseDurations?: unknown;
262 pr?: { number?: unknown; checksStatus?: unknown } | null;
263 blocked?: { blockClass?: unknown } | null;
264 failure?: { reason?: unknown } | null;
265 };
266 const status = str(body?.status);
267 if (status === null) return null;
268 const pr = body.pr ?? {};
269 return {
270 storyId,
271 at,
272 status,
273 prNumber: num(pr.number),
274 checksStatus: str(pr.checksStatus),
275 elapsedSeconds: num(body.elapsedSeconds),
276 phaseDurations: durationsOf(body.phaseDurations),
277 problem: str(body.blocked?.blockClass) ?? str(body.failure?.reason),
278 nextCommand: str(body.nextCommand),
279 };
280}
281
282function durationsOf(raw: unknown): Record<string, number> {
283 if (!raw || typeof raw !== 'object') return {};
284 return Object.fromEntries(
285 Object.entries(raw).filter(
286 (pair): pair is [string, number] => num(pair[1]) !== null && pair[1] >= 0,
287 ),
288 );
289}
290
291async function readEnvelope(
292 $: EngineInterface,
293 ctx: Ctx,
294 stamp: Stamped,
295): Promise<Envelope | null> {
296 const raw = await readJson(
297 $,
298 `${ctx.orchestration}/story-deliver-terminal-${stamp.storyId}.json`,
299 );
300 return toEnvelope(stamp.storyId, stamp.mtimeMs, raw);
301}
302
303async function readInit(
304 $: EngineInterface,
305 ctx: Ctx,
306 id: number,
307): Promise<Evidence['init']> {
308 const entry = fileNamed(ctx, `story-init-result-${id}.log`);
309 if (!entry) return null;
310 const text = (await readText($, `${ctx.orchestration}/${entry.name}`)) ?? '';
311 const body = text.slice(Math.max(0, text.indexOf(INIT_MARKER)));
312 const quoted = TITLE.exec(body)?.[1];
313 let title: string | null = null;
314 try {
315 title = quoted ? str(JSON.parse(quoted)) : null;
316 } catch {
317 title = null;
318 }
319 return { at: entry.mtimeMs, title };
320}
321
322/** The `commit…` lines of a reflog: count, last subject, first and last time. */
323function parseReflog(text: string): Commits | null {
324 const commits = text.split('\n').flatMap((line) => {
325 const tab = line.indexOf('\t');
326 const message = tab < 0 ? '' : line.slice(tab + 1);
327 if (!message.startsWith('commit')) return [];
328 const seconds = Number(REFLOG_TIME.exec(line.slice(0, tab))?.[1]);
329 return [
330 {
331 subject: message.replace(/^commit[^:]*:\s*/, ''),
332 at: Number.isFinite(seconds) ? seconds * 1000 : 0,
333 },
334 ];
335 });
336 const last = commits.at(-1);
337 if (!last) return null;
338 return {
339 at: last.at,
340 firstAt: commits[0].at,
341 count: commits.length,
342 subject: last.subject,
343 };
344}
345
346async function readBuild(
347 $: EngineInterface,
348 ctx: Ctx,
349 id: number,
350): Promise<Commits | null> {
351 const pointer = await readText(
352 $,
353 join(ctx.root, `.worktrees/story-${id}/.git`),
354 );
355 const gitdir =
356 GITDIR.exec(pointer ?? '')?.[1] ??
357 join(ctx.root, `.git/worktrees/story-${id}`);
358 const reflog = await readText($, `${gitdir}/logs/HEAD`);
359 return reflog === null ? null : parseReflog(reflog);
360}
361
362async function readCheck(
363 $: EngineInterface,
364 ctx: Ctx,
365 id: number,
366): Promise<Verdict | null> {
367 const dir = `${ctx.temp}/scratch/story-${id}`;
368 const rounds = (await $.fs.list(dir).catch(() => [])).flatMap((entry) => {
369 const match = VERDICT.exec(entry.name);
370 return match && entry.kind === 'file'
371 ? [{ round: Number(match[1]), entry }]
372 : [];
373 });
374 if (rounds.length === 0) return null;
375 const last = rounds.reduce((a, b) => (b.round > a.round ? b : a));
376 const raw = (await readJson($, `${dir}/${last.entry.name}`)) as {
377 criteria?: { verdict?: unknown }[];
378 };
379 const criteria = Array.isArray(raw?.criteria) ? raw.criteria : [];
380 return {
381 at: last.entry.mtimeMs,
382 firstAt: Math.min(...rounds.map((one) => one.entry.mtimeMs)),
383 round: last.round,
384 met: criteria.filter((one) => one?.verdict === 'met').length,
385 total: criteria.length,
386 };
387}
388
389function readHandoff(ctx: Ctx, id: number): Handoff | null {
390 const prefix = `story-handoff-${id}-`;
391 const steps = ctx.files
392 .filter(
393 (entry) =>
394 entry.kind === 'file' &&
395 entry.name.startsWith(prefix) &&
396 entry.name.endsWith('.log'),
397 )
398 .map((entry) => ({
399 name: entry.name.slice(prefix.length, -'.log'.length),
400 at: entry.mtimeMs,
401 }))
402 .sort((a, b) => a.at - b.at);
403 const doneAt = fileNamed(ctx, `story-handoff-${id}.json`)?.mtimeMs ?? null;
404 const times = [...steps.map((one) => one.at), doneAt ?? 0];
405 if (steps.length === 0 && doneAt === null) return null;
406 return {
407 at: Math.max(...times),
408 firstAt: steps[0]?.at ?? doneAt ?? 0,
409 step: steps.at(-1)?.name ?? null,
410 steps,
411 doneAt,
412 };
413}
414
415async function readClose(
416 $: EngineInterface,
417 ctx: Ctx,
418 id: number,
419): Promise<Evidence['close']> {
420 const entry = fileNamed(ctx, `close-gates-${id}.log`);
421 if (!entry) return null;
422 const text = (await readText($, `${ctx.orchestration}/${entry.name}`)) ?? '';
423 const last = text
424 .split('\n')
425 .filter((line) => line.trim().length > 0)
426 .at(-1);
427 return { at: entry.mtimeMs, gate: GATE.exec(last ?? '')?.[1] ?? null };
428}
429
430/** The sibling Story's progress file; read when present, never required. */
431async function readProgress(
432 $: EngineInterface,
433 ctx: Ctx,
434 id: number,
435): Promise<Progress | null> {
436 if (!fileNamed(ctx, `story-progress-${id}.json`)) return null;
437 const raw = (await readJson(
438 $,
439 `${ctx.orchestration}/story-progress-${id}.json`,
440 )) as Record<string, unknown> | undefined;
441 const updatedAt = toMs(raw?.updatedAt);
442 const stage = str(raw?.stage);
443 if (raw?.kind !== 'story-progress' || updatedAt === null || stage === null)
444 return null;
445 return {
446 stage,
447 phase: str(raw.phase),
448 stageStartedAt: toMs(raw.stageStartedAt),
449 phaseStartedAt: toMs(raw.phaseStartedAt),
450 prNumber: num(raw.prNumber),
451 updatedAt,
452 };
453}
454
455async function readFollowUps(
456 $: EngineInterface,
457 ctx: Ctx,
458 id: number,
459): Promise<number | null> {
460 const name = `follow-ups-rollup-${id}.md`;
461 if (!fileNamed(ctx, name)) return null;
462 const text = await readText($, `${ctx.orchestration}/${name}`);
463 return text === null
464 ? null
465 : text.split('\n').filter((line) => BULLET.test(line)).length;
466}
467
468async function gather(
469 $: EngineInterface,
470 ctx: Ctx,
471 id: number,
472 envelope: Envelope | null,
473): Promise<Evidence> {
474 const [init, build, check, close, progress, followUps] = await Promise.all([
475 readInit($, ctx, id),
476 readBuild($, ctx, id),
477 readCheck($, ctx, id),
478 readClose($, ctx, id),
479 readProgress($, ctx, id),
480 readFollowUps($, ctx, id),
481 ]);
482 return {
483 init,
484 build,
485 check,
486 handoff: readHandoff(ctx, id),
487 close,
488 progress,
489 envelope,
490 followUps,
491 };
492}
493
494// --------------------------------------------------------------- learning
495
496function median(list: number[]): number {
497 const sorted = [...list].sort((a, b) => a - b);
498 const mid = Math.floor(sorted.length / 2);
499 return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
500}
501
502/** Medians of each close phase over the newest 30 envelopes; at most every 10 minutes. */
503async function learn(
504 $: EngineInterface,
505 ctx: Ctx,
506 envelopes: Stamped[],
507): Promise<Learned> {
508 const held = await read($, learnedAtom);
509 if (held.at > 0 && ctx.now - held.at < LEARN_MS) return held;
510 const samples: Record<string, number[]> = {};
511 const recent = [...envelopes]
512 .sort((a, b) => b.mtimeMs - a.mtimeMs)
513 .slice(0, MAX_SAMPLES);
514 for (const stamp of recent) {
515 const envelope = await readEnvelope($, ctx, stamp);
516 for (const [name, seconds] of Object.entries(
517 envelope?.phaseDurations ?? {},
518 )) {
519 samples[name] = [...(samples[name] ?? []), seconds];
520 }
521 }
522 const phases = Object.fromEntries(
523 Object.entries(samples).map(([name, list]) => [
524 name,
525 { medianSeconds: median(list), samples: list.length },
526 ]),
527 );
528 const next: Learned = { at: ctx.now, phases };
529 await update($, learnedAtom, () => next);
530 return next;
531}
532
533// ---------------------------------------------------------------- deriving
534
535function stageTimes(ev: Evidence): Partial<Record<StageName, number>> {
536 const handoff = ev.handoff
537 ? Math.max(ev.handoff.at, ev.handoff.doneAt ?? 0)
538 : undefined;
539 return {
540 start: ev.init?.at,
541 build: ev.build?.at,
542 check: ev.check?.at,
543 handoff,
544 close: ev.close?.at,
545 merge: ev.envelope?.status === 'pending' ? ev.envelope.at : undefined,
546 };
547}
548
549/**
550 * The current stage: the furthest one with evidence, back to build when a
551 * commit follows the last verdict round; a progress file newer than every
552 * other piece of evidence decides instead.
553 */
554function pickStage(ev: Evidence): {
555 stage: StageName;
556 progress: Progress | null;
557} {
558 const times = stageTimes(ev);
559 let stage: StageName = 'start';
560 for (const name of STAGES) if (times[name] !== undefined) stage = name;
561 if (stage === 'check' && (times.build ?? 0) > (times.check ?? 0))
562 stage = 'build';
563 const latest = Math.max(0, ...Object.values(times).map((at) => at ?? 0));
564 const progress =
565 ev.progress && ev.progress.updatedAt >= latest ? ev.progress : null;
566 if (progress?.phase && MERGE_PHASES.has(progress.phase))
567 return { stage: 'merge', progress };
568 if (progress?.stage === 'handoff' || progress?.stage === 'close')
569 return { stage: progress.stage, progress };
570 return { stage, progress: null };
571}
572
573function stageStarts(ev: Evidence): Record<StageName, number | null> {
574 const known = ev.progress;
575 const mergeStart =
576 known?.phase && MERGE_PHASES.has(known.phase) ? known.phaseStartedAt : null;
577 return {
578 start: ev.init?.at ?? null,
579 build: ev.build?.firstAt ?? null,
580 check: ev.check?.firstAt ?? null,
581 handoff: ev.handoff?.firstAt ?? null,
582 close:
583 (known?.stage === 'close' ? known.stageStartedAt : null) ??
584 ev.handoff?.doneAt ??
585 ev.close?.at ??
586 null,
587 merge:
588 mergeStart ?? (ev.envelope?.status === 'pending' ? ev.envelope.at : null),
589 };
590}
591
592function stageRows(ev: Evidence, current: StageName): StageRow[] {
593 const starts = stageStarts(ev);
594 const at = STAGES.indexOf(current);
595 return STAGES.map((name, index) => {
596 const later = STAGES.slice(index + 1, at + 1)
597 .map((next) => starts[next])
598 .find((value) => value !== null);
599 return {
600 name,
601 startedAt: index <= at ? starts[name] : null,
602 endedAt: index < at ? (later ?? null) : null,
603 };
604 });
605}
606
607/** The close phase the band times, when the stage has one. */
608function phaseOf(stage: StageName, progress: Progress | null): string | null {
609 if (stage === 'close') return progress?.phase ?? 'close-validation';
610 if (stage === 'merge') return progress?.phase ?? 'confirm-merge';
611 return null;
612}
613
614const plural = (count: number, word: string): string =>
615 `${count} ${word}${count === 1 ? '' : 's'}`;
616
617function detailOf(
618 stage: StageName,
619 ev: Evidence,
620 progress: Progress | null,
621 prNumber: number | null,
622): string {
623 switch (stage) {
624 case 'start':
625 return 'initialized';
626 case 'build':
627 return ev.build
628 ? `${plural(ev.build.count, 'commit')} · ${ev.build.subject}`
629 : 'building';
630 case 'check':
631 return ev.check
632 ? `round ${ev.check.round} · ${ev.check.met}/${ev.check.total} met`
633 : 'self-eval';
634 case 'handoff':
635 if (progress?.phase) return `step ${progress.phase}`;
636 if (ev.handoff?.doneAt && !progress) return 'handed off';
637 return `step ${ev.handoff?.step ?? 'starting'}`;
638 case 'close': {
639 const phase = progress?.phase ?? 'close-validation';
640 const gate = ev.close?.gate;
641 return phase === 'close-validation' && gate
642 ? `gate ${gate}`
643 : `phase ${phase}`;
644 }
645 case 'merge': {
646 const pr = prNumber === null ? 'PR' : `PR #${prNumber}`;
647 return `${pr} · ${progress?.phase ?? 'waiting on merge'}`;
648 }
649 }
650}
651
652/** The stall verdict: learned medians first, else the 10-minute gate-log rule. */
653function stallOf(
654 stage: StageName,
655 ev: Evidence,
656 elapsedMs: number | null,
657 usualMs: number | null,
658 now: number,
659): { isStalled: boolean; idleMinutes: number | null } {
660 if (usualMs !== null && elapsedMs !== null)
661 return {
662 isStalled: elapsedMs > Math.max(2 * usualMs, MIN_STALL_MS),
663 idleMinutes: null,
664 };
665 const idleMs = stage === 'close' && ev.close ? now - ev.close.at : 0;
666 return idleMs > STALL_MS
667 ? { isStalled: true, idleMinutes: Math.floor(idleMs / 60_000) }
668 : { isStalled: false, idleMinutes: null };
669}
670
671function deriveFocus(
672 storyId: number,
673 ev: Evidence,
674 learned: Learned,
675 now: number,
676): Focus {
677 const isInFlight = ev.envelope === null;
678 const { stage, progress } = pickStage(ev);
679 const phase = phaseOf(stage, progress);
680 const learnedPhase = phase ? learned.phases[phase] : undefined;
681 const usualMs =
682 learnedPhase && learnedPhase.samples >= MIN_SAMPLES
683 ? learnedPhase.medianSeconds * 1000
684 : null;
685 const phaseStart = progress?.phaseStartedAt ?? stageStarts(ev)[stage];
686 const phaseElapsedMs =
687 isInFlight && phaseStart !== null ? Math.max(0, now - phaseStart) : null;
688 const stall = isInFlight
689 ? stallOf(stage, ev, phaseElapsedMs, usualMs, now)
690 : { isStalled: false, idleMinutes: null };
691 const prNumber = ev.progress?.prNumber ?? ev.envelope?.prNumber ?? null;
692 const closePhases: PhaseRow[] = CLOSE_PHASES.map((name) => ({
693 name,
694 seconds: ev.envelope?.phaseDurations[name] ?? null,
695 usualSeconds: learned.phases[name]?.medianSeconds ?? null,
696 }));
697 return {
698 storyId,
699 title: ev.init?.title ?? null,
700 status: ev.envelope?.status ?? 'in-flight',
701 stage,
702 detail: detailOf(stage, ev, progress, prNumber),
703 stages: stageRows(ev, stage),
704 handoffSteps: ev.handoff?.steps ?? [],
705 closePhases,
706 prNumber,
707 checksStatus: ev.envelope?.checksStatus ?? null,
708 elapsedSeconds: ev.envelope?.elapsedSeconds ?? null,
709 phaseElapsedMs,
710 usualMs,
711 isStalled: stall.isStalled,
712 idleMinutes: stall.idleMinutes,
713 problem: ev.envelope?.problem ?? null,
714 nextCommand: ev.envelope?.nextCommand ?? null,
715 followUps: ev.followUps,
716 };
717}
718
719/** A Story whose worktree exists with no newer terminal envelope; the newest. */
720function pickInFlight(
721 worktrees: Stamped[],
722 envelopes: Stamped[],
723): Stamped | undefined {
724 return newest(
725 worktrees.filter((tree) => {
726 const envelope = envelopes.find((one) => one.storyId === tree.storyId);
727 return !envelope || envelope.mtimeMs < tree.mtimeMs;
728 }),
729 );
730}
731
732type Ledger = { stories: number[]; dispatched: number[]; updatedAt: number };
733
734function toLedger(raw: unknown): Ledger | null {
735 const body = raw as Record<string, unknown> | undefined;
736 const ids = (value: unknown): number[] =>
737 Array.isArray(value) ? value.filter((id) => num(id) !== null) : [];
738 const updatedAt = toMs(body?.updatedAt);
739 if (body?.kind !== 'deliver-run-ledger' || updatedAt === null) return null;
740 return {
741 stories: ids(body.stories),
742 dispatched: ids(body.dispatched),
743 updatedAt,
744 };
745}
746
747async function readLedgers($: EngineInterface, ctx: Ctx): Promise<Ledger[]> {
748 const entries = await $.fs.list(ctx.temp).catch(() => []);
749 const ledgers: Ledger[] = [];
750 for (const entry of entries) {
751 if (entry.kind !== 'dir' || !RUN_DIR.test(entry.name)) continue;
752 const ledger = toLedger(
753 await readJson($, `${ctx.temp}/${entry.name}/ledger.json`),
754 );
755 if (ledger && ctx.now - ledger.updatedAt <= RECENT_MS) ledgers.push(ledger);
756 }
757 return ledgers.sort((a, b) => b.updatedAt - a.updatedAt);
758}
759
760/** The newest live run ledger with a Story still to land. */
761async function readRun(
762 $: EngineInterface,
763 ctx: Ctx,
764 envelopes: Stamped[],
765 focus: Focus | null,
766): Promise<RunLine | null> {
767 for (const ledger of await readLedgers($, ctx)) {
768 const statuses = new Map<number, string>();
769 for (const stamp of envelopes.filter((one) =>
770 ledger.stories.includes(one.storyId),
771 )) {
772 const envelope = await readEnvelope($, ctx, stamp);
773 if (envelope) statuses.set(stamp.storyId, envelope.status);
774 }
775 const open = ledger.stories.filter((id) => statuses.get(id) !== 'landed');
776 if (open.length === 0) continue;
777 const currentId =
778 focus && open.includes(focus.storyId)
779 ? focus.storyId
780 : open.find((id) => ledger.dispatched.includes(id));
781 const stageOf = (id: number): string =>
782 id === focus?.storyId
783 ? focus.status === 'in-flight'
784 ? focus.stage
785 : focus.status
786 : (statuses.get(id) ?? 'dispatched');
787 return {
788 landed: ledger.stories.length - open.length,
789 total: ledger.stories.length,
790 current:
791 currentId === undefined
792 ? null
793 : { storyId: currentId, stage: stageOf(currentId) },
794 queued: ledger.stories.filter((id) => !ledger.dispatched.includes(id))
795 .length,
796 };
797 }
798 return null;
799}
800
801async function scan(
802 $: EngineInterface,
803): Promise<{ next: Snapshot; latest: Envelope | null }> {
804 const root = await resolveRoot($);
805 if (!(await $.fs.exists(join(root, '.agents')).catch(() => false)))
806 return { next: EMPTY, latest: null };
807 const temp = await resolveTempRoot($, root);
808 const orchestration = `${temp}/orchestration`;
809 const now = await $.clock.now();
810 const [worktrees, files] = await Promise.all([
811 listMatching($, join(root, '.worktrees'), WORKTREE),
812 $.fs.list(orchestration).catch(() => []),
813 ]);
814 const ctx: Ctx = { root, temp, orchestration, files, now };
815 const envelopes = stampFiles(files, ENVELOPE);
816 const learned = await learn($, ctx, envelopes);
817 const recent = newest(
818 envelopes.filter((one) => now - one.mtimeMs <= RECENT_MS),
819 );
820 const latest = recent ? await readEnvelope($, ctx, recent) : null;
821 const inFlight = pickInFlight(worktrees, envelopes);
822 const focusId = inFlight?.storyId ?? latest?.storyId;
823 const focus =
824 focusId === undefined
825 ? null
826 : deriveFocus(
827 focusId,
828 await gather($, ctx, focusId, inFlight ? null : latest),
829 learned,
830 now,
831 );
832 const run = await readRun($, ctx, envelopes, focus);
833 return { next: { focus, run, scannedAt: now }, latest };
834}
835
836/** Toasts a landed or blocked result once; the first scan only records. */
837async function announce(
838 $: EngineInterface,
839 latest: Envelope | null,
840): Promise<void> {
841 const seeded = await read($, isSeeded);
842 if (!seeded) await update($, isSeeded, () => true);
843 if (!latest) return;
844 const key = `${latest.storyId}:${latest.at}`;
845 if ((await read($, seen)).includes(key)) return;
846 await update($, seen, (list) => [...list, key].slice(-50));
847 if (seeded && ANNOUNCED.has(latest.status)) {
848 const pr = latest.prNumber ? ` (PR #${latest.prNumber})` : '';
849 $.ui.toast(`Story #${latest.storyId} ${latest.status}${pr}`);
850 }
851}
852
853async function refresh($: EngineInterface): Promise<void> {
854 try {
855 const { next, latest } = await scan($);
856 await update($, snapshot, () => next);
857 await announce($, latest);
858 } catch {
859 // Read-only and best effort: a failed scan leaves the last drawing up.
860 }
861}
862
863// --------------------------------------------------------------- drawing
864
865type Line = { text: string; color: string | null };
866
867/** `45s`, `5m39s`, `1h05m`. */
868function fmt(ms: number): string {
869 const seconds = Math.max(0, Math.round(ms / 1000));
870 if (seconds < 60) return `${seconds}s`;
871 const minutes = Math.floor(seconds / 60);
872 if (minutes < 60) return `${minutes}m${seconds % 60}s`;
873 return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
874}
875
876/** Cut short with an ellipsis, never wrapped. */
877function cut(text: string, width: number): string {
878 if (text.length <= width) return text;
879 return width <= 1 ? '…' : `${text.slice(0, width - 1)}…`;
880}
881
882function colorOf(focus: Focus): string | null {
883 if (focus.status === 'landed') return 'green';
884 if (STOPPED.has(focus.status)) return 'red';
885 return focus.isStalled ? 'yellow' : null;
886}
887
888function rightOf(focus: Focus): string {
889 const pr = focus.prNumber === null ? null : `PR #${focus.prNumber}`;
890 if (focus.status === 'landed') {
891 return [
892 `landed in ${focus.elapsedSeconds === null ? '?' : fmt(focus.elapsedSeconds * 1000)}`,
893 pr,
894 focus.followUps === null ? null : plural(focus.followUps, 'follow-up'),
895 ]
896 .filter(Boolean)
897 .join(' · ');
898 }
899 if (STOPPED.has(focus.status))
900 return [`${focus.status}: ${focus.problem ?? 'see the Story'}`, pr]
901 .filter(Boolean)
902 .join(' · ');
903 const checks = focus.checksStatus ? ` checks ${focus.checksStatus}` : '';
904 return pr ? `${pr}${checks}` : '';
905}
906
907/** Line 1: outcome glyph, Story and title on the left, the outcome or PR on the right. */
908function headline(focus: Focus, width: number): string {
909 const glyph =
910 focus.status === 'landed' ? '✓ ' : STOPPED.has(focus.status) ? '✗ ' : '';
911 const left = `${glyph}mandrel · #${focus.storyId}${focus.title ? ` ${focus.title}` : ''}`;
912 const right = cut(rightOf(focus), Math.max(16, Math.floor(width * 0.6)));
913 if (!right) return cut(left, width);
914 const room = Math.max(glyph.length + 14, width - right.length - 3);
915 return cut(`${cut(left, room)} · ${right}`, width);
916}
917
918function timingOf(focus: Focus): string {
919 if (focus.phaseElapsedMs === null) return focus.status;
920 const usual =
921 focus.usualMs === null ? '' : ` of usual ~${fmt(focus.usualMs)}`;
922 const stall = !focus.isStalled
923 ? ''
924 : focus.idleMinutes === null
925 ? ' · stalled?'
926 : ` · stalled? no gate output for ${focus.idleMinutes}m`;
927 return `${fmt(focus.phaseElapsedMs)}${usual}${stall}`;
928}
929
930/** Line 2: the six-stage strip, the step detail and the timing; or the next command. */
931function strip(focus: Focus): string {
932 if (STOPPED.has(focus.status))
933 return `next: ${focus.nextCommand ?? 'see the Story for how to resume'}`;
934 const at = STAGES.indexOf(focus.stage);
935 const marks = STAGES.map((name, index) => {
936 const mark = index < at ? '✓' : index === at ? '●' : '○';
937 return `${mark} ${name}`;
938 }).join(' ─ ');
939 return `${marks} · ${focus.detail} · ${timingOf(focus)}`;
940}
941
942function runText(run: RunLine): string {
943 const current = run.current
944 ? ` · #${run.current.storyId} ${run.current.stage}`
945 : '';
946 return `run ${run.landed}/${run.total} landed${current} · ${run.queued} queued`;
947}
948
949/** The band's lines for a body this wide; null draws nothing. */
950function compose({ focus, run }: Snapshot, columns: number): Line[] | null {
951 if (!focus) return null;
952 const color = colorOf(focus);
953 const lines: Line[] = [
954 { text: headline(focus, Math.max(20, columns - BUTTON_CELLS)), color },
955 ];
956 if (columns < NARROW) return lines;
957 if (focus.status !== 'landed')
958 lines.push({ text: cut(strip(focus), columns), color });
959 if (run) lines.push({ text: cut(runText(run), columns), color: null });
960 return lines;
961}
962
963const clockOf = (ms: number | null): string => {
964 if (ms === null) return '—';
965 const at = new Date(ms);
966 return [at.getHours(), at.getMinutes(), at.getSeconds()]
967 .map((part) => String(part).padStart(2, '0'))
968 .join(':');
969};
970
971/** The Details pane: stages with start and duration, handoff steps, close phases. */
972function details(focus: Focus | null, now: number): string[] {
973 if (!focus) return ['No /mandrel-deliver Story in flight or recent.'];
974 const at = STAGES.indexOf(focus.stage);
975 const stages = focus.stages.map((row, index) => {
976 const mark = index < at ? '✓' : index === at ? '●' : '○';
977 const end = row.endedAt ?? (index === at ? now : null);
978 const took =
979 row.startedAt !== null && end !== null ? fmt(end - row.startedAt) : '—';
980 return ` ${mark} ${row.name.padEnd(8)} ${clockOf(row.startedAt)} ${took}`;
981 });
982 const steps = focus.handoffSteps.length
983 ? focus.handoffSteps.map((step) => ` ${step.name} ${clockOf(step.at)}`)
984 : [' none yet'];
985 const phases = focus.closePhases.map((phase) => {
986 const took = phase.seconds === null ? '—' : fmt(phase.seconds * 1000);
987 const usual =
988 phase.usualSeconds === null
989 ? ''
990 : ` (usual ~${fmt(phase.usualSeconds * 1000)})`;
991 return ` ${phase.name.padEnd(17)} ${took}${usual}`;
992 });
993 return [
994 `Story #${focus.storyId}${focus.title ? ` ${focus.title}` : ''} · ${focus.status}`,
995 'Stages',
996 ...stages,
997 'Handoff steps',
998 ...steps,
999 'Close phases',
1000 ...phases,
1001 ];
1002}
1003
1004export const register: Register = (on) => {
1005 on('session.start', async ($, e, next) => {
1006 await refresh($);
1007 $.clock.every(REFRESH_MS, () => {
1008 void refresh($);
1009 });
1010 return next(e);
1011 });
1012
1013 on('turn.complete', async ($, e, next) => {
1014 await refresh($);
1015 return next(e);
1016 });
1017
1018 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1019 if (e.props.hasSurvey) return next(e);
1020 const current = await read($, snapshot);
1021 const lines = compose(current, e.props.bodyColumns);
1022 const focus = current.focus;
1023 if (!lines || !focus || (await read($, hiddenFor)) === focus.storyId)
1024 return next(e);
1025 const { Box, Button, Text } = $.ui.resolve(e);
1026 const [first, ...rest] = lines;
1027 const style = (line: Line) =>
1028 line.color ? { color: line.color } : { dimColor: true };
1029 return (
1030 <Box flexDirection="column">
1031 <Box>
1032 <Text {...style(first)} wrap="truncate">
1033 {first.text}
1034 </Text>
1035 <Button
1036 key="hide"
1037 label="Hide"
1038 onPress={() => update($, hiddenFor, () => focus.storyId)}
1039 />
1040 <Button
1041 key="details"
1042 label="Details"
1043 onPress={() => $.ui.open({ id: PANE, title: 'mandrel-status' })}
1044 />
1045 </Box>
1046 {rest.map((line, index) => (
1047 <Text key={`line-${index}`} {...style(line)} wrap="truncate">
1048 {line.text}
1049 </Text>
1050 ))}
1051 </Box>
1052 );
1053 });
1054
1055 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1056 const { Box, Text } = $.ui.resolve(e);
1057 const current = await read($, snapshot);
1058 return (
1059 <Box flexDirection="column">
1060 {details(current.focus, current.scannedAt).map((line, index) => (
1061 <Text key={`row-${index}`} wrap="truncate">
1062 {line}
1063 </Text>
1064 ))}
1065 </Box>
1066 );
1067 });
1068};
1069types/index.d.ts 91 lines1/** The six stages of a /mandrel-deliver Story, in order. */
2export type StageName =
3 | 'start'
4 | 'build'
5 | 'check'
6 | 'handoff'
7 | 'close'
8 | 'merge';
9
10/** One stage as the Details pane lists it; times in epoch milliseconds. */
11export type StageRow = {
12 name: StageName;
13 startedAt: number | null;
14 /** The next stage's start, or null while this one is current or pending. */
15 endedAt: number | null;
16};
17
18/** One close phase: its recorded duration and the learned median, in seconds. */
19export type PhaseRow = {
20 name: string;
21 seconds: number | null;
22 usualSeconds: number | null;
23};
24
25/** The Story the band follows: in flight, or the newest recent result. */
26export type Focus = {
27 storyId: number;
28 title: string | null;
29 /** `in-flight`, or the terminal envelope's `status`. */
30 status: string;
31 stage: StageName;
32 /** The current stage's step detail, already worded. */
33 detail: string;
34 stages: StageRow[];
35 handoffSteps: { name: string; at: number }[];
36 closePhases: PhaseRow[];
37 prNumber: number | null;
38 checksStatus: string | null;
39 /** The envelope's `elapsedSeconds`, for a finished result. */
40 elapsedSeconds: number | null;
41 /** Milliseconds into the current phase, while in flight. */
42 phaseElapsedMs: number | null;
43 /** The learned median of the current phase, once it has 3+ samples. */
44 usualMs: number | null;
45 isStalled: boolean;
46 /** Minutes the gate log has been quiet, when the 10-minute rule fired. */
47 idleMinutes: number | null;
48 /** `blocked.blockClass` or `failure.reason`. */
49 problem: string | null;
50 nextCommand: string | null;
51 /** Bullets in `follow-ups-rollup-<id>.md`, when it exists. */
52 followUps: number | null;
53};
54
55/** The newest live multi-Story run ledger. */
56export type RunLine = {
57 landed: number;
58 total: number;
59 current: { storyId: number; stage: string } | null;
60 queued: number;
61};
62
63export type Snapshot = {
64 focus: Focus | null;
65 run: RunLine | null;
66 /** When the scan ran, epoch milliseconds; 0 before the first. */
67 scannedAt: number;
68};
69
70/** Per close phase: the median of the newest terminal envelopes' timings. */
71export type Learned = {
72 /** When the medians were last computed; 0 before the first pass. */
73 at: number;
74 phases: Record<string, { medianSeconds: number; samples: number }>;
75};
76
77declare module 'claude-code' {
78 interface PluginState {
79 'mandrel-status': {
80 snapshot: Snapshot;
81 /** `<storyId>:<mtimeMs>` of every envelope already seen. */
82 seen: string[];
83 /** False until the first scan, which records envelopes without a toast. */
84 isSeeded: boolean;
85 learned: Learned;
86 /** The focus Story the person hid the band for; it shows again on a new one. */
87 hiddenFor: number | null;
88 };
89 }
90}
91