SLOPSHOPPER

mandrel-status

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

newpanebandtoasttimer
★ 7v0.1.0MITupdated 2026-10-09dsj1984/mandrel/.agents/mods/mandrel-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mandrel-status
│ ┃ mandrel-status ✕ › fix the failing auth test and add an audit log call │ ┃ No /mandrel-deliver Story in flight or rece… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · mandrel-status
No /mandrel-deliver Story in flight or recent.
README

Mandrel

CI / CD

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.

Prerequisites

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:

  • Node.js >= 22.22.1 (< 25).
  • git on your PATH.
  • GitHub CLI 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.

Quickstart

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).

Manual equivalent

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/*.js run from your project root and resolve their third-party deps (ajv, js-yaml, …) from your top-level node_modules. npm and yarn hoist transitive deps there automatically; pnpm's default isolated layout does not — it keeps them in the .pnpm virtual store, so the framework scripts (and mandrel doctor's runtime-deps check) cannot see them. Add the following to your .npmrc before installing:

# Lift mandrel's runtime deps to the top-level node_modules so the
# materialized .agents/scripts can resolve them.
shamefully-hoist=true

Prefer a surgical alternative? Replace shamefully-hoist with a scoped public-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. If mandrel doctor reports runtime-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.

Update

Advance mandrel to the newest published version and re-materialize ./.agents/ in one command:

npx mandrel update

mandrel update runs an ordered cycle:

  1. Resolve the newest published version (a npm view mandrel version registry probe) and the currently installed version.
  2. No-op short-circuit — already on the newest version ⇒ nothing to do.
  3. Install the target version with the project's package manager — auto-detected from the lockfile (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.
  4. Sync — re-materialize ./.agents/ from the freshly installed payload.
  5. Migrate — apply version-keyed migration steps for the crossed range.
  6. Doctor — run the check registry to verify the resulting install.
  7. Surface the target changelog section.

Flags

  • --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).

Manual equivalent

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

Benchmarking

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.

Contributors

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.

License

MIT

Source 2 files
hooks/register.tsx 1069 lines
1// 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};
1069
types/index.d.ts 91 lines
1/** 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