SLOPSHOPPER

delivery-run-view

A read-only view of delivery runs inside Claude Code, drawn from the run files the surfaces already write: a /flows pane with one row per phase — how it ran…

newpanebandrowscommandtoast
v1.22.0MITupdated 2026-10-08JSdotNet/devbook/plugins/delivery-run-view
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · delivery-run-view
│ ┃ Delivery flows ✕ › fix the failing auth test and add an audit log call │ ┃ Demo · Undo (Ctrl+Z) for backlog tasks │ ┃ flow-code · feature · 0/12 · 9% context · t… ⏺ Read(src/auth.ts) │ ┃ inline delegate fork gate ? = inferred ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ● Update Base inline ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ┌────────────────────────────────────────┐ ⎿ 3 pass, 1 fail │ ┃ │ Fast-forwarded to origin/main │ │ ┃ │ (bfcf9b8b). │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ skill delivery:phase-update-base │ │ ┃ │ before devbook:validate │ ✻ Worked for 42s · done 4:20 PM │ ┃ └────────────────────────────────────────┘ │ ┃ › /flows │ ┃ ○ Scope delegate ⎿ delivery-run-view: Delivery flows pane opened. │ ┃ ○ Implement delegate │ ┃ ○ Review fork │ ┃ │ ┃ ○ Build & Test delegate │ ┃ ○ Verify delegate │ ┃ ○ Spec Check delegate │ ┃ ○ Ready inline │ ┃ │ ┃ ○ Personal Validation gate │ ┃ │ ┃ ○ Create Pull Request inline flow-code ●○○○○○○○◇○○○ Update Base inline details ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ delivery-run-view: flow-code · Update Base

Draws

Band
flow-code ●○○○○○○○◇○○○ Update Base inline details
Pane · Delivery flows
Demo · Undo (Ctrl+Z) for backlog tasks flow-code · feature · 0/12 · 9% context · this session inline delegate fork gate ? = inferred ≠ = not as confi ● Update Base inline ┌────────────────────────────────────────────────────────┐ │ Fast-forwarded to origin/main (bfcf9b8b). │ │ skill delivery:phase-update-base │ │ before devbook:validate │ └────────────────────────────────────────────────────────┘ ○ Scope delegate ○ Implement delegate ○ Review fork ○ Build & Test delegate ○ Verify delegate ○ Spec Check delegate ○ Ready inline ○ Personal Validation gate ○ Create Pull Request inline ○ Report Back delegate ○ Summary inline
README

delivery-run-view

A delivery run drawn inside Claude Code itself — its phases top to bottom, how each one ran, and the phase this session is in — from the run files the surfaces already write.

A viewer, not a surface. It answers none of the surface contract's operations, writes no run file, declares no dependency, and names no engine. The one thing it sends anywhere is a gate reply the person presses, submitted to this session as their own prompt. Whichever surfaces record a run, this plugin reads what they left on disk and draws it; uninstalling it costs a view, never a capability.

That is also why it is not called delivery-surface-*. Per plugins/delivery/resources/surface-contract.md, that prefix means an MCP server answering the contract, and the engine resolves every installed delivery-surface-* server as a place to record a run. A plugin that only reads would be mistaken for one. It keeps the subsystem's stem, delivery, and is named for what it shows: a run.

Installation

claude plugin marketplace add JSdotNet/devbook

Then enable delivery-run-view with /plugin. Nothing else is required: the hook module is loaded by Claude Code itself, and the pane opens on its own once a run of the session appears.

Claude Code only

The plugin is function-hook modules — hooks/hooks.json names ./register.tsx under modules — and Copilot has no equivalent. So it carries the Claude manifest alone: a plugin ships the manifest of every host that can load something in it, per .devbook/arc42/05-building-block-view.md under Plugin Folder, and here that is one host. It is the mirror of delivery-surface-canvas, which is Copilot's alone.

What it draws

WhereShows
Pane — /flows, opened on its own the first time a run of this session appearsWhile this session's run is at Personal Validation, a review card first: the stage's review links, the Ready stage's open items, and Approve (the primary button), Revise, and Reject, each behind a coloured ✓, ↺, or ✗ and each submitting the gate's reply to this session as the person's prompt — Personal Validation: approve., … revise., … decline.. Then the run top to bottom, one row per phase: its status, how it ran (inline, delegate, fork, gate), the agent, model and effort, ≠ when it did not run as configured, the duration, and ↺N when the phase ran more than once. Sub-agents the phase delegated to hang under it, those a revise round ran under the gate marked as such. The focused phase opens in place with its output, its skill, MCP servers, and chores, what was configured against what ran where they differ, QA scenarios, links, output tokens, and tool calls. ‹ › steps between runs, and a phase name focuses or unfocuses it
Band above the promptWhile this session's run is open: the flow, the stage rail, and the current phase with its mode
Inline tool rowsA mcp__*delivery-surface-*__start_run or __update_stage call in the transcript drawn as one line — the flow and its phases, or the phase and its new status — instead of raw JSON
Status line◆ PV: <repo> <run title> while any checkout's run waits at a gate — +N when more do, this session's first — else <skillId> · <current stage> while this session's run is open
Toast and soundOnce per transition, when a stage of any checkout's run turns waiting or blocked: one short toast line — ◆ PV Budget · Monthly totals, ✗ Ready Backlog · TaskCard for the Board — and sounds/gate.wav
/flows-demoA simulated flow-code run played in the pane, one phase every four seconds, held in memory only — no run file is written

Where it reads

Every three seconds it reads the newest run files under the profile, at ~/.claude/delivery-surface-dashboard/<slug>-<hash>/runs/*.json and ~/.claude/backlog/<slug>-<hash>/runs/*.json — CLAUDE_CONFIG_DIR in place of ~/.claude when set — where <slug> is the checkout's folder name. A worktree with no runs of its own falls back to the main checkout's, so the pane is never empty in a fresh worktree. The same run found in both places is shown once, the newer copy winning. This session's runs sort first, by the sessionIds a run records.

For the gate alert it also reads every other checkout's folders under both surfaces — every fifteen seconds, since a profile holds hundreds of them, and at once on /flows — only the run files modified in the last day and only runs not yet done, so a run parked a week ago does not keep ringing. Each file is parsed once per modification time.

The run file shape is the dashboard's, which the Backlog app writes too. It is not part of the surface contract, so a surface that changes its file shape can break this view without breaking the contract; a run file that does not parse is skipped and read again on the next poll.

How a phase ran

The surface contract records it twice, and this view reads both: the run's runContext.phases.<flow>.<phase-skill> — what the flow-runner resolved from the config — and each stage's execution, what actually ran, which wins where both speak. A stage joins its entry by its title's slug, Build & Test to phase-build-test, Create Pull Request to phase-create-pr, and the stage names of an engine before 1.18.0 to the phases that replaced them.

The agent is the recorded one, else the sub-agent that did the phase's work: the configured agent when it ran or a delivery:runner-<effort> carried it, else the longest-running, so a helper such as qa:qa-monitor beside the agent never names the phase. ≠ marks a configured agent that never ran, a model or effort other than the configured one, and a fallback — a configured id that did not resolve.

A run that records neither — every run started before the engine recorded them — is inferred and drawn dimmed with a ?: a phase with a delegated sub-agent in the run's insights is delegate, Personal Validation is the gate, and every other phase is inline. A fork is never detected that way, and nothing can be compared with the config.

Gates

No surface records awaiting_approval: a gate stage — Personal Validation, or one whose execution.mode is gate — that is in_progress is read as waiting on its person. Every poll compares each stage's tone with the last poll's, kept per <run id>#<stage index> in $.state, and a stage that turned waiting or blocked raises one toast and one sound. The first poll of a session takes the baseline and alerts nothing; a gate already waiting shows on the status line instead. A reload keeps the baseline, because $.state outlives the module.

The sound plays where the surface has a player — the desktop app, a macOS terminal; a Windows or Linux terminal plays nothing, and the toast and status line say it alone.

The card's links are clickable where the host allows a link — https:, or http://localhost. Any other address, http://127.0.0.1 or a demo's file:, is drawn as text beside its label. Open items are the Ready stage's output after Open items, split on (1) numbering or on list lines. Revise sends no notes: the flow-runner asks for them, as it does for a typed revise. In /flows-demo the buttons only say what they would send.

The state contract

types/index.d.ts declares the plugin's $.state — runs, selected, focus, and seen under delivery-run-view — and the manifest's types key points at it, which is what claude plugin validate checks the module's state reads and writes against. The .claude-plugin/types/ folder tsconfig.json extends is written by the host for type-checking and is ignored.

Source 2 files
hooks/register.tsx 925 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import type { FlowLink, FlowMode, FlowRun, FlowStage, FlowWorker } from '../types'
4
5const PANE = 'delivery-run-view'
6const POLL_MS = 3000
7const MAX_RUNS = 8
8/** Another checkout's run alerts only while it moved in the last day, so a parked run does not ring forever. */
9const FRESH_MS = 24 * 60 * 60 * 1000
10const MAX_OTHER_RUNS = 40
11/** The short clip a gate plays, shipped beside the module. */
12const GATE_SOUND = 'sounds/gate.wav'
13
14/** The surfaces that write run files under the profile, in the shape the dashboard defines. */
15const SURFACES = ['delivery-surface-dashboard', 'backlog']
16
17const RUNS = { plugin: 'delivery-run-view', key: 'runs' } as const
18const SELECTED = { plugin: 'delivery-run-view', key: 'selected' } as const
19/** The stage whose detail is open; -1 follows the run (the active stage, else the last one reached). */
20const FOCUS = { plugin: 'delivery-run-view', key: 'focus' } as const
21const SEEN = { plugin: 'delivery-run-view', key: 'seen' } as const
22
23// ── Vocabulary ───────────────────────────────────────────────────────────────
24
25type Tone = 'done' | 'active' | 'waiting' | 'blocked' | 'skipped' | 'pending'
26
27const tone = (status: string): Tone => {
28  const s = (status || '').toLowerCase().replace(/[\s_-]/g, '')
29  if (['done', 'completed', 'complete', 'passed', 'approved', 'success'].includes(s)) return 'done'
30  if (['awaitingapproval', 'waiting', 'pendingapproval'].includes(s)) return 'waiting'
31  if (['inprogress', 'running', 'active', 'started'].includes(s)) return 'active'
32  if (['blocked', 'failed', 'error', 'rejected', 'aborted'].includes(s)) return 'blocked'
33  if (s === 'skipped') return 'skipped'
34  return 'pending'
35}
36
37const COLOR: Record<Tone, string> = {
38  done: 'green',
39  active: 'yellow',
40  waiting: 'magenta',
41  blocked: 'red',
42  skipped: 'gray',
43  pending: 'gray',
44}
45
46const MARK: Record<Tone, string> = { done: '✓', active: '●', waiting: '◆', blocked: '✗', skipped: '–', pending: '○' }
47
48/** One word per way a phase runs; colour repeats it, never replaces it. */
49const MODE_LABEL: Record<FlowMode, string> = { inline: 'inline', delegate: 'delegate', fork: 'fork', gate: 'gate' }
50const MODE_COLOR: Record<FlowMode, string> = { inline: 'gray', delegate: 'cyan', fork: 'blue', gate: 'magenta' }
51
52const isQuiet = (t: Tone) => t === 'pending' || t === 'skipped'
53
54/**
55 * A stage's tone with the gate read in: no surface records `awaiting_approval`, so a gate stage
56 * in progress is the run waiting on its person.
57 */
58const stageTone = (s: FlowStage): Tone => {
59  const t = tone(s.status)
60  return s.mode === 'gate' && t === 'active' ? 'waiting' : t
61}
62
63const isPersonalValidation = (s: FlowStage) => /personal validation/i.test(s.name)
64
65const duration = (ms: number | null | undefined) => {
66  if (!ms || ms <= 0) return ''
67  const seconds = Math.round(ms / 1000)
68  if (seconds < 60) return `${seconds}s`
69  const minutes = Math.round(seconds / 60)
70  return minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}`
71}
72
73const tokens = (n: number | null | undefined) =>
74  !n ? '' : n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`
75
76const shortModel = (model: string | null | undefined) =>
77  (model || '').replace(/^claude-/, '').replace(/-\d{8}$/, '').replace(/-(\d+)-(\d+)$/, ' $1.$2')
78
79// ── Reading the run files ────────────────────────────────────────────────────
80
81const slugOf = (root: string) =>
82  (root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() || 'project').replace(/[^a-zA-Z0-9._-]/g, '-')
83
84/** The state folder the surfaces keep for a checkout: `<slug>-<8 hex>`, the hash of the path. */
85const HASHED = /^(.+)-[0-9a-f]{8}$/
86
87type StateDir = { runs: string; slug: string }
88
89/** Every checkout's state folders, under every surface that writes run files. */
90async function stateDirs($: EngineInterface): Promise<StateDir[]> {
91  const config = await $.env.get('CLAUDE_CONFIG_DIR')
92  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? ''
93  const profile = (config || `${home}/.claude`).replace(/\\/g, '/')
94  const dirs: StateDir[] = []
95
96  for (const surface of SURFACES) {
97    const base = `${profile}/${surface}`
98    if (!(await $.fs.exists(base))) continue
99    for (const entry of await $.fs.list(base)) {
100      const slug = entry.kind === 'dir' ? HASHED.exec(entry.name)?.[1] : undefined
101      if (slug) dirs.push({ runs: `${base}/${entry.name}/runs`, slug })
102    }
103  }
104  return dirs
105}
106
107/**
108 * The runs the pane shows — this worktree's own, or the main checkout's when it has none, so
109 * the pane is never empty in a fresh worktree — and every other checkout's open runs that
110 * moved in the last day, which only the gate alert reads.
111 */
112async function loadRuns($: EngineInterface, isForced: boolean): Promise<{ shown: FlowRun[]; others: FlowRun[] }> {
113  const root = (await $.session.root()).replace(/\\/g, '/')
114  const dirs = await stateDirs($)
115  const of = (slug: string) => dirs.filter(d => d.slug === slug)
116  const [main = root] = root.split('/.claude/worktrees/')
117
118  let mine = of(slugOf(root))
119  let shown = await readRuns($, mine)
120  if (shown.length === 0 && main !== root) {
121    mine = of(slugOf(main))
122    shown = await readRuns($, mine)
123  }
124  const now = await $.clock.now()
125  if (isForced || now - othersAt >= OTHERS_MS) {
126    othersAt = now
127    const rest = dirs.filter(d => !mine.includes(d))
128    others = (await readRuns($, rest, { since: now - FRESH_MS, limit: MAX_OTHER_RUNS })).filter(r => tone(r.status) !== 'done')
129  }
130  return { shown, others }
131}
132
133/** A profile holds hundreds of checkout folders, so the other checkouts are listed on every fifth poll, or on /flows. */
134const OTHERS_MS = 5 * POLL_MS
135let othersAt = -Infinity
136let others: FlowRun[] = []
137
138/** Stage titles whose phase skill is not their slug, and the stage names an engine before 1.18.0 used. */
139const PHASE_ALIASES: Record<string, string> = {
140  'create-pull-request': 'create-pr',
141  'scope-discovery': 'scope',
142  implementation: 'implement',
143  validation: 'verify',
144  verification: 'spec-check',
145  'work-item-update': 'report-back',
146}
147
148const phaseKeys = (name: string) => {
149  const slug = name.toLowerCase().replace(/&/g, ' ').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
150  const phase = PHASE_ALIASES[slug] ?? slug
151  return [name, slug, phase, `phase-${phase}`]
152}
153
154const names = (value: unknown): string[] =>
155  Array.isArray(value) ? value.map(v => (typeof v === 'string' ? v : String((v as any)?.skill ?? (v as any)?.id ?? ''))).filter(Boolean) : []
156
157/**
158 * The `runContext.phases` entry for a stage: the qualifier it ran under first, then the bare
159 * key, then a qualified `phase-<id>:<q>` entry when it is the only one.
160 */
161function resolvedEntry(raw: any, name: string, qualifier: unknown): any {
162  const flowMap = raw.runContext?.phases?.[raw.skillId] ?? raw.runContext?.phases ?? {}
163  const keys = phaseKeys(name)
164  const ranUnder = typeof qualifier === 'string' ? flowMap?.[`${keys[3]}:${qualifier}`] : undefined
165  if (ranUnder) return ranUnder
166  const exact = keys.map(k => flowMap?.[k]).find(Boolean)
167  if (exact) return exact
168  const qualified = Object.keys(flowMap ?? {}).filter(k => k.startsWith(`${keys[3]}:`))
169  const [only] = qualified
170  return qualified.length === 1 && only ? flowMap[only] : null
171}
172
173/** An effort runner carries the bound agent's body at one effort: `delivery:runner-<effort>`. */
174const RUNNER = /^delivery:runner-([a-z]+)$/
175
176/**
177 * The worker that did the phase's work: the bound agent when it ran, or an effort runner
178 * carrying it, else the longest-running, so a log monitor beside the agent does not name the phase.
179 */
180const boundWorker = (workers: FlowWorker[], agent: string | null | undefined) =>
181  workers.find(w => agent && w.name === agent) ??
182  workers.find(w => RUNNER.test(w.name)) ??
183  [...workers].sort((a, b) => (b.durationMs ?? 0) - (a.durationMs ?? 0))[0]
184
185/** The model family, so an alias (`opus`) and the id telemetry records (`opus 5.5`) compare equal. */
186const family = (model: string) => model.split(/[\s[]/)[0]
187
188/**
189 * How a stage ran. Read from the run when it says — a stage's `execution`, what actually ran,
190 * over the resolved `runContext.phases` entry, what the config asked for — and otherwise
191 * inferred: a stage a sub-agent worked in was delegated, Personal Validation is the gate, the
192 * rest ran inline. Sub-agents that ran under the gate are a revise round's, and say so.
193 */
194function stageOf(raw: any, s: any, index: number): FlowStage {
195  const name = String(s.name ?? '?')
196  const ran = s.execution && typeof s.execution === 'object' ? s.execution : {}
197  const resolved = resolvedEntry(raw, name, ran.qualifier)
198  const execution = { ...(resolved ?? {}), ...ran }
199  const isGate = /personal validation/i.test(name) || execution.mode === 'gate'
200
201  const insights: any[] = Array.isArray(raw.insights) ? raw.insights : []
202  const workers: FlowWorker[] = insights
203    .filter(i => i.kind === 'agent' && i.stageIndex === index)
204    .map(i => ({
205      name: String(i.agentDisplayName || i.agentName || 'agent'),
206      model: shortModel(i.model),
207      durationMs: typeof i.durationMs === 'number' ? i.durationMs : null,
208      tokens: typeof i.totalTokens === 'number' ? i.totalTokens : null,
209      toolCalls: typeof i.totalToolCalls === 'number' ? i.totalToolCalls : null,
210      isFailed: i.status === 'failed',
211      isRevise: isGate,
212    }))
213  const declared: string[] = Array.isArray(s.agents) ? s.agents.map(String) : []
214  for (const agent of declared) {
215    if (!workers.some(w => w.name === agent)) {
216      workers.push({ name: agent, model: '', durationMs: null, tokens: null, toolCalls: null, isFailed: false, isRevise: isGate })
217    }
218  }
219
220  const recorded = execution.runs && !Array.isArray(execution.runs) ? execution.runs : execution.mode
221  const mode: FlowMode = isGate
222    ? 'gate'
223    : recorded === 'fork' || recorded === 'delegate' || recorded === 'inline'
224      ? recorded
225      : workers.length > 0
226        ? 'delegate'
227        : 'inline'
228
229  // What ran comes from the stage's own record, then from the sub-agents observed, and only
230  // with neither from what was resolved — so a configured agent that never ran is not shown as if it had.
231  const worker = mode === 'delegate' || mode === 'fork' ? boundWorker(workers, ran.agent ?? resolved?.agent) : undefined
232  const runnerEffort = worker ? RUNNER.exec(worker.name)?.[1] : undefined
233  const workerAgent = runnerEffort ? (resolved?.agent ?? 'general-purpose') : worker?.name
234  const agent = ran.agent ?? workerAgent ?? resolved?.agent ?? null
235  const model = shortModel(ran.model) || worker?.model || shortModel(resolved?.model) || null
236  const effort = ran.effort ?? runnerEffort ?? resolved?.effort ?? null
237  const configured = resolved
238    ? { agent: resolved.agent ?? null, model: shortModel(resolved.model) || null, effort: resolved.effort ?? null }
239    : null
240  const mismatch: string[] = []
241  if (configured && !isGate && tone(String(s.status ?? '')) !== 'pending') {
242    if (configured.agent && agent !== configured.agent) mismatch.push('agent')
243    if (configured.model && model && family(model) !== family(configured.model)) mismatch.push('model')
244    if (configured.effort && (ran.effort ?? runnerEffort) && effort !== configured.effort) mismatch.push('effort')
245  }
246
247  return {
248    name,
249    status: String(s.status ?? 'pending'),
250    mode,
251    isModeRecorded: recorded !== undefined || isGate,
252    agent,
253    model,
254    effort,
255    skill: execution.skill ?? null,
256    mcp: execution.mcp === null ? [] : execution.mcp === undefined ? null : names(execution.mcp),
257    before: names(execution.before),
258    after: names(execution.after),
259    fallback: execution.fallback ?? null,
260    configured,
261    mismatch,
262    passes: typeof s.doneCount === 'number' ? s.doneCount : 0,
263    durationMs: typeof s.durationMs === 'number' ? s.durationMs : null,
264    outputTokens: raw.tokenUsage?.byStage?.[index]?.total?.outputTokens ?? null,
265    toolCalls: insights.filter(i => i.kind === 'tool' && i.stageIndex === index).length,
266    workers,
267    scenarios: Array.isArray(s.scenarios) ? s.scenarios.map((c: any) => ({ name: String(c.name), status: String(c.status) })) : [],
268    links: Array.isArray(s.links) ? s.links.map(linkOf).filter((l: FlowLink) => l.label || l.url) : [],
269    output: String(s.output ?? '').slice(0, 600),
270  }
271}
272
273const linkOf = (l: any): FlowLink =>
274  typeof l === 'string'
275    ? { label: l, url: /^[a-z][a-z0-9+.-]*:/i.test(l) ? l : '' }
276    : { label: String(l?.label ?? l?.url ?? ''), url: String(l?.url ?? '') }
277
278/** What a `Link` takes: `https:`, or `http://localhost`. Any other URL — `127.0.0.1`, a demo's `file:` — is drawn as text. */
279const isLinkable = (url: string) => /^(https:|http:\/\/localhost(?=[:/]|$))/i.test(url)
280
281const LIST_LINE =/^\s*(?:[-*•]|\d+[.)])\s+/
282
283/**
284 * The open items the Ready stage handed to the gate: the text after `Open items`, split on
285 * `(1)` numbering or on list lines, else taken whole; with no such marker, its list lines.
286 */
287function openItemsOf(output: unknown): string[] {
288  const text = String(output ?? '')
289  const listed = (body: string) => body.split('\n').filter(l => LIST_LINE.test(l)).map(l => l.replace(LIST_LINE, ''))
290  const marker = /open items?[^:]*:/i.exec(text)
291  if (!marker) return listed(text)
292  const rest = text.slice(marker.index + marker[0].length).trim()
293  const items = rest.includes('(1)')
294    ? rest.split(/\s*\(\d+\)\s*/)
295    : listed(rest).length > 0
296      ? listed(rest)
297      : [rest]
298  return items.map(item => item.trim().replace(/[;.]$/, '')).filter(Boolean)
299}
300
301/** Each run file parsed, by path, kept while its modification time holds: every checkout is polled. */
302const parsed = new Map<string, { mtimeMs: number; run: FlowRun }>()
303
304type ReadOptions = { since?: number; limit?: number }
305
306async function readRuns($: EngineInterface, dirs: StateDir[], options: ReadOptions = {}): Promise<FlowRun[]> {
307  const sessionId = await $.session.id()
308  const byId = new Map<string, FlowRun>()
309
310  for (const { runs: dir, slug } of dirs) {
311    if (!(await $.fs.exists(dir))) continue
312    const files = (await $.fs.list(dir))
313      .filter(f => f.kind === 'file' && f.name.endsWith('.json') && f.mtimeMs >= (options.since ?? 0))
314      .sort((a, b) => b.mtimeMs - a.mtimeMs)
315      .slice(0, 20)
316
317    for (const file of files) {
318      const path = `${dir}/${file.name}`
319      const cached = parsed.get(path)
320      const run = cached?.mtimeMs === file.mtimeMs ? cached.run : await parseRun($, path, slug, sessionId)
321      if (!run) continue
322      parsed.set(path, { mtimeMs: file.mtimeMs, run })
323      const known = byId.get(run.id)
324      if (!known || known.updatedAt < run.updatedAt) byId.set(run.id, run)
325    }
326  }
327
328  return [...byId.values()]
329    .sort((a, b) =>
330      a.isThisSession !== b.isThisSession ? (a.isThisSession ? -1 : 1) : b.updatedAt.localeCompare(a.updatedAt),
331    )
332    .slice(0, options.limit ?? MAX_RUNS)
333}
334
335/** One run file as the view draws it; undefined for a file that is not a run, or is mid-write and read again next poll. */
336async function parseRun($: EngineInterface, path: string, slug: string, sessionId: string): Promise<FlowRun | undefined> {
337  try {
338    const raw = JSON.parse(String(await $.fs.read(path)))
339    if (!raw?.id || !Array.isArray(raw.stages)) return undefined
340    const stages: any[] = raw.stages
341    return {
342      id: raw.id,
343      skillId: raw.skillId ?? 'flow',
344      title: raw.title || raw.originalPrompt || raw.id,
345      repo: String(raw.repo ?? '').split('/').pop() || slug,
346      status: raw.status ?? '',
347      changeKind: raw.changeKind ?? '',
348      approval: raw.approval?.state ?? '',
349      updatedAt: raw.updatedAt ?? '',
350      startedAt: raw.startedAt ?? '',
351      isThisSession: Array.isArray(raw.sessionIds) && raw.sessionIds.includes(sessionId),
352      contextPeak: raw.context?.peakTokens ?? null,
353      contextLimit: raw.context?.tokenLimit ?? null,
354      openItems: openItemsOf(stages.find(s => /^ready$/i.test(String(s?.name ?? '')))?.output),
355      stages: stages.map((s, i) => stageOf(raw, s, i)),
356    }
357  } catch {
358    return undefined
359  }
360}
361
362// ── /flows-demo: a flow-code run in memory only, in the shape the surface contract records ──
363// It is built as a run file and read through stageOf, so the demo draws what a real run would.
364// Scope shows a fallback: the configured architect is not installed, so general-purpose ran.
365
366const DEMO_STEP_MS = 4000
367type DemoStage = [name: string, phase: string, output: string]
368const DEMO_STAGES: DemoStage[] = [
369  ['Update Base', 'phase-update-base', 'Fast-forwarded to origin/main (bfcf9b8b).'],
370  ['Scope', 'phase-scope', 'Kind: feature. 4 acceptance criteria, 3 seams, 5 devbook chapters selected.'],
371  ['Implement', 'phase-implement', 'Red-green at 3 seams; backend first, then the UI against its interface summary.'],
372  ['Review', 'phase-review', 'Pass 1: 1 blocker (rule: ui-components.md, Tasks/Editor.razor:88) → back to implement. Pass 2: clean, 2 advisories.'],
373  ['Build & Test', 'phase-build-test', 'Build green, 4,312 tests passed.'],
374  ['Verify', 'phase-verify', 'Full depth with capture through Aspire; logs monitored, no errors.'],
375  ['Spec Check', 'phase-spec-check', '5 chapters: 4 aligned, 1 code-ahead (reported).'],
376  ['Ready', 'phase-ready', 'Budget spent on round 2. Open items: (1) S3 Redo after reload is flaky; (2) features.md is code-ahead on undo history.'],
377  ['Personal Validation', 'phase-personal-validation', 'Waiting for your approval. Review links published.'],
378  ['Create Pull Request', 'phase-create-pr', 'Pushed and opened the pull request.'],
379  ['Report Back', 'phase-report-back', 'Commented on the origin entry and ticked its steps.'],
380  ['Summary', 'phase-summary', 'Run summary written.'],
381]
382const DEMO_PHASES: Record<string, { mode: FlowMode; [field: string]: unknown }> = {
383  'phase-update-base': { mode: 'inline', skill: 'delivery:phase-update-base', before: ['devbook:validate'] },
384  'phase-scope': { mode: 'delegate', agent: 'architecture:architect', skill: 'delivery:phase-scope', model: 'opus', mcp: ['backlog'] },
385  'phase-implement': { mode: 'delegate', agent: 'csharp-coding:coding', skill: 'delivery:phase-implement', model: 'opus', effort: 'high' },
386  'phase-review': { mode: 'fork', skill: 'delivery:phase-review' },
387  'phase-build-test': { mode: 'delegate', agent: 'general-purpose', runner: 'delivery:runner-low', skill: 'delivery:phase-build-test', model: 'sonnet', effort: 'low', mcp: [] },
388  'phase-verify': { mode: 'delegate', agent: 'qa:qa', skill: 'delivery:phase-verify', mcp: ['aspire', 'playwright'] },
389  'phase-spec-check': { mode: 'delegate', agent: 'general-purpose', runner: 'delivery:runner-xhigh', skill: 'devbook:verify-change', model: 'opus', effort: 'xhigh' },
390  'phase-ready': { mode: 'inline', skill: 'delivery:phase-ready' },
391  'phase-personal-validation': { mode: 'gate', skill: 'delivery:phase-personal-validation' },
392  'phase-create-pr': { mode: 'inline', skill: 'delivery:phase-create-pr', mcp: ['backlog'] },
393  'phase-report-back': { mode: 'delegate', agent: 'general-purpose', skill: 'delivery:phase-report-back', model: 'haiku' },
394  'phase-summary': { mode: 'inline', skill: 'delivery:phase-summary', after: ['devbook:update'] },
395}
396const DEMO_EXECUTION: Record<string, object> = {
397  Scope: { agent: 'general-purpose', fallback: 'architecture:architect' },
398}
399type DemoWorker = [name: string, model: string, durationMs: number, tokens: number, toolCalls: number]
400const DEMO_WORKERS: Record<string, DemoWorker[]> = {
401  Scope: [['general-purpose', 'claude-opus-5-5', 141000, 52000, 33]],
402  Implement: [
403    ['csharp-coding:coding', 'claude-opus-5-5', 192000, 81000, 41],
404    ['csharp-coding:coding', 'claude-opus-5-5', 236000, 64000, 37],
405  ],
406  Review: [['general-purpose', 'claude-opus-5-5', 88000, 30000, 19]],
407  'Build & Test': [['delivery:runner-low', 'claude-sonnet-5-5', 263000, 21000, 12]],
408  Verify: [
409    ['qa:qa-monitor', 'claude-haiku-4-5', 398000, 18000, 22],
410    ['qa:qa', 'claude-sonnet-5-5', 411000, 122000, 96],
411  ],
412  'Spec Check': [['delivery:runner-xhigh', 'claude-opus-5-5', 154000, 40000, 28]],
413  'Report Back': [['general-purpose', 'claude-haiku-4-5', 31000, 6000, 5]],
414}
415let demoStartedAt: number | undefined
416
417function demoRun(now: number): FlowRun {
418  const at = Math.floor((now - (demoStartedAt ?? now)) / DEMO_STEP_MS)
419  const reviewAt = DEMO_STAGES.findIndex(s => s[0] === 'Review')
420  const raw = {
421    skillId: 'flow-code',
422    runContext: { phases: { 'flow-code': DEMO_PHASES } },
423    insights: DEMO_STAGES.flatMap(([name], i) =>
424      i > at
425        ? []
426        : (DEMO_WORKERS[name] ?? []).map(([agentName, model, durationMs, totalTokens, totalToolCalls]) => ({
427            kind: 'agent',
428            status: 'completed',
429            stageIndex: i,
430            agentName,
431            model,
432            durationMs,
433            totalTokens,
434            totalToolCalls,
435          })),
436    ),
437    tokenUsage: {
438      byStage: DEMO_STAGES.map(([, phase], i) => ({
439        total: { outputTokens: i < at && DEMO_PHASES[phase]?.mode !== 'inline' ? 12_000 + i * 9_000 : null },
440      })),
441    },
442  }
443
444  return {
445    id: 'demo-run',
446    skillId: 'flow-code',
447    title: 'Demo · Undo (Ctrl+Z) for backlog tasks',
448    repo: 'demo',
449    status: at >= DEMO_STAGES.length ? 'done' : 'in_progress',
450    changeKind: 'feature',
451    approval: at > 8 ? 'approved' : at === 8 ? 'pending' : '',
452    updatedAt: `demo-${Math.min(at, DEMO_STAGES.length)}`,
453    startedAt: new Date(demoStartedAt ?? now).toISOString(),
454    isThisSession: true,
455    contextPeak: 90_000 + Math.min(at, 12) * 21_000,
456    contextLimit: 1_000_000,
457    openItems: at > DEMO_STAGES.findIndex(s => s[0] === 'Ready') ? openItemsOf(DEMO_STAGES.find(s => s[0] === 'Ready')?.[2]) : [],
458    stages: DEMO_STAGES.map(([name, phase, output], i) => {
459      const isGate = DEMO_PHASES[phase]?.mode === 'gate'
460      const isReached = i <= at
461      const stage = {
462        name,
463        status: i < at ? 'done' : i === at ? (isGate ? 'awaiting_approval' : 'in_progress') : 'pending',
464        doneCount: (i < at ? 1 : 0) + (name === 'Implement' && at > reviewAt ? 1 : 0) + (name === 'Review' && at > reviewAt ? 1 : 0),
465        durationMs: i < at ? DEMO_STEP_MS * (4 + ((i * 37) % 60)) : null,
466        execution: isReached ? DEMO_EXECUTION[name] : undefined,
467        scenarios:
468          name === 'Verify' && i < at
469            ? [
470                { name: 'S1 Undo a status change', status: 'pass' },
471                { name: 'S2 Undo a reorder', status: 'pass' },
472                { name: 'S3 Redo after reload', status: 'flaky' },
473              ]
474            : [],
475        links: name === 'Personal Validation' && isReached ? [
476                { label: 'Backlog board, undo', url: 'http://localhost:5173/board?undo' },
477                { label: 'Aspire dashboard', url: 'http://localhost:18888/' },
478              ] : [],
479        output: isReached ? output : '',
480      }
481      return { ...stageOf(raw, stage, i), toolCalls: i < at ? 3 + ((i * 13) % 40) : 0 }
482    }),
483  }
484}
485
486// ── What the detail says about how a phase ran ───────────────────────────────
487
488type HowLine = { text: string; isDrift: boolean }
489type Field = 'agent' | 'model' | 'effort'
490
491/** The phase's procedure, servers, and chores, then — where they differ — what the run resolved against what ran. */
492function how(s: FlowStage): HowLine[] {
493  const lines: HowLine[] = []
494  const mcp = s.mcp === null ? '' : s.mcp.length === 0 ? 'mcp none' : `mcp ${s.mcp.join(', ')}`
495  const facts = [s.skill ? `skill ${s.skill}` : '', mcp].filter(Boolean)
496  if (facts.length > 0) lines.push({ text: facts.join(' · '), isDrift: false })
497  const chores = [s.before.length ? `before ${s.before.join(', ')}` : '', s.after.length ? `after ${s.after.join(', ')}` : ''].filter(Boolean)
498  if (chores.length > 0) lines.push({ text: chores.join(' · '), isDrift: false })
499  if (s.fallback) lines.push({ text: `≠ ${s.fallback} did not resolve; ran ${s.agent ?? 'the built-in procedure'}`, isDrift: true })
500  const configured = s.configured
501  if (configured && s.mismatch.length > 0) {
502    const field = (f: Field) => `${f} ${configured[f] ?? 'session'} → ${s[f] ?? 'session'}`
503    lines.push({ text: `≠ configured vs ran: ${(s.mismatch as Field[]).map(field).join(' · ')}`, isDrift: true })
504  }
505  if (!configured && !s.isModeRecorded) {
506    lines.push({ text: 'This run records no resolved phases: mode and agent are inferred.', isDrift: false })
507  }
508  return lines
509}
510
511// ── Polling ──────────────────────────────────────────────────────────────────
512
513const signature = (list: FlowRun[]) => list.map(r => `${r.id}@${r.updatedAt}`).join('|')
514
515const currentStage = (run: FlowRun) =>
516  run.stages.find(s => ['active', 'waiting', 'blocked'].includes(tone(s.status)))
517
518type Gate = { run: FlowRun; stage: FlowStage; tone: Tone }
519
520const gateLabel = (s: FlowStage) => (isPersonalValidation(s) ? 'PV' : s.name)
521
522/**
523 * Every run's stages against the tones seen on the last poll: a stage that turned `waiting` or
524 * `blocked` since raises one toast and one sound, and the first poll of a session only takes
525 * the baseline. Returns the gates waiting now.
526 */
527async function alertGates($: EngineInterface, runs: FlowRun[]): Promise<Gate[]> {
528  const { value: before } = await $.state.get(SEEN)
529  const seen: Record<string, string> = {}
530  const waiting: Gate[] = []
531  const turned: Gate[] = []
532
533  for (const run of runs) {
534    run.stages.forEach((stage, i) => {
535      const key = `${run.id}#${i}`
536      const t = stageTone(stage)
537      seen[key] = t
538      if (t === 'waiting') waiting.push({ run, stage, tone: t })
539      if ((t === 'waiting' || t === 'blocked') && before && before[key] !== t) turned.push({ run, stage, tone: t })
540    })
541  }
542  if (JSON.stringify(seen) !== JSON.stringify(before ?? {})) await $.state.set(SEEN, seen)
543
544  for (const { run, stage, tone: t } of turned) {
545    // One short line: the mark says waiting or blocked, so the words do not repeat it.
546    $.ui.toast(`${MARK[t]} ${gateLabel(stage)} ${run.repo} · ${run.title}`, { timeoutMs: 8000 })
547  }
548  // A surface with no player plays nothing; the toast and the status line still say it.
549  if (turned.length > 0) void $.audio.play({ asset: GATE_SOUND }).catch(() => undefined)
550  return waiting
551}
552
553async function refresh($: EngineInterface, isForced = false) {
554  const { shown, others } = await loadRuns($, isForced)
555  const fresh = demoStartedAt === undefined ? shown : [demoRun(await $.clock.now()), ...shown].slice(0, MAX_RUNS)
556  const { value: current = [] } = await $.state.get(RUNS)
557  if (signature(fresh) !== signature(current)) await $.state.set(RUNS, fresh)
558
559  const watched = new Map([...others, ...fresh.filter(r => tone(r.status) !== 'done')].map(r => [r.id, r]))
560  const waiting = (await alertGates($, [...watched.values()])).sort((a, b) => Number(b.run.isThisSession) - Number(a.run.isThisSession))
561  const [gate] = waiting
562  const mine = fresh.find(r => r.isThisSession && tone(r.status) !== 'done')
563  const stage = mine ? currentStage(mine) : undefined
564  $.ui.status(
565    gate
566      ? `${MARK.waiting} ${gateLabel(gate.stage)}: ${gate.run.repo} ${gate.run.title}${waiting.length > 1 ? ` +${waiting.length - 1}` : ''}`
567      : mine
568        ? `${mine.skillId} · ${stage?.name ?? mine.status}`
569        : undefined,
570  )
571
572  // The pane opens itself once per run of this session, the first time that run appears; closed
573  // after that, it stays closed. Another session's run, or the main checkout's, never opens it.
574  const unseen = fresh.find(r => r.isThisSession && !openedFor.has(r.id))
575  if (unseen) {
576    openedFor.add(unseen.id)
577    void openPane($)
578  }
579}
580
581const openedFor = new Set<string>()
582
583const openPane = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Delivery flows' })
584
585/** The gate's three answers, in the flow-runner's words: approve, revise, decline. */
586type GateReply = { key: 'approve' | 'revise' | 'reject'; label: string; text: string; glyph: string; color: string }
587const GATE_REPLIES: GateReply[] = [
588  { key: 'approve', label: 'Approve', text: 'Personal Validation: approve.', glyph: MARK.done, color: COLOR.done },
589  { key: 'revise', label: 'Revise', text: 'Personal Validation: revise.', glyph: '↺', color: 'yellow' },
590  { key: 'reject', label: 'Reject', text: 'Personal Validation: decline.', glyph: MARK.blocked, color: COLOR.blocked },
591]
592
593/** A press submits the reply as the person's own prompt; the demo's has no session to answer, so it says what it would send. */
594async function answerGate($: EngineInterface, run: FlowRun, reply: GateReply) {
595  if (run.id === 'demo-run') {
596    $.ui.toast(`Demo: would send "${reply.text}"`)
597    return
598  }
599  await $.prompt.submit({ text: reply.text, asUser: true })
600}
601
602const isSurfaceTool = (tool: string, op: string) => /delivery-surface-/.test(tool) && tool.endsWith(`__${op}`)
603
604// ── Hooks ────────────────────────────────────────────────────────────────────
605
606export const register: Register = on => {
607  on('session.start', async ($, e, next) => {
608    await $.command.register({
609      name: 'flows',
610      description: "Show this worktree's delivery flow runs as a live phase timeline",
611    })
612    await $.command.register({
613      name: 'flows-demo',
614      description: 'Play a simulated flow-code run in the delivery flows pane (in memory only)',
615    })
616    await refresh($)
617    $.clock.every(POLL_MS, () => refresh($))
618
619    return next(e)
620  })
621
622  on('command.run', { command: 'flows' }, async $ => {
623    await refresh($, true)
624    await openPane($)
625
626    return { text: 'Delivery flows pane opened.' }
627  })
628
629  on('command.run', { command: 'flows-demo' }, async $ => {
630    demoStartedAt = await $.clock.now()
631    await $.state.set(SELECTED, 0)
632    await $.state.set(FOCUS, -1)
633    await refresh($)
634    await openPane($)
635
636    return { text: `Simulating a flow-code run: one phase every ${DEMO_STEP_MS / 1000}s, about 50s in all.` }
637  })
638
639  // The pane: the whole run, top to bottom — one row per phase, its workers hung under it,
640  // and the focused phase opened in place.
641  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
642    const { Box, Text, Button, Link } = $.ui.resolve(e)
643    const { value: list = [] } = await $.state.get(RUNS)
644    const index =Math.min((await $.state.get(SELECTED)).value ?? 0, Math.max(0, list.length - 1))
645    const pinned = (await $.state.get(FOCUS)).value ?? -1
646    const run = list[index]
647    const columns = (e.props as any)?.bodyColumns ?? e.viewport?.columns ?? 60
648    const isWide = columns >= 72
649
650    if (!run) {
651      return (
652        <Box flexDirection="column">
653          <Text dimColor>No delivery runs for this worktree yet.</Text>
654          <Text dimColor>A flow-* skill reports its phases here as it runs. /flows-demo plays one.</Text>
655        </Box>
656      )
657    }
658
659    const done = run.stages.filter(s => tone(s.status) === 'done').length
660    const live = currentStage(run)
661    const focused =
662      pinned >= 0 && pinned < run.stages.length
663        ? pinned
664        : live
665          ? run.stages.indexOf(live)
666          : run.stages.reduce((last, s, i) => (tone(s.status) === 'pending' ? last : i), 0)
667    const gate = run.isThisSession && live && isPersonalValidation(live) && stageTone(live) === 'waiting' ? live : undefined
668    const elapsed = run.startedAt && !run.id.startsWith('demo') ? Date.now() - Date.parse(run.startedAt) : null
669    const context = run.contextPeak && run.contextLimit ? `${Math.round((run.contextPeak / run.contextLimit) * 100)}% context` : ''
670    const facts = [run.skillId, run.changeKind, `${done}/${run.stages.length}`, duration(elapsed), context]
671      .filter(Boolean)
672      .join(' · ')
673
674    const badge = (s: FlowStage) => (
675      <Text color={MODE_COLOR[s.mode]} dimColor={!s.isModeRecorded}>
676        {MODE_LABEL[s.mode]}
677        {s.isModeRecorded ? '' : '?'}
678      </Text>
679    )
680    const who = (s: FlowStage) =>
681      [s.agent, [s.model, s.effort].filter(Boolean).join('/')].filter(Boolean).join(' · ')
682
683    return (
684      <Box flexDirection="column">
685        <Box flexDirection="row" justifyContent="space-between">
686          <Text bold wrap="truncate-end">
687            {run.title}
688          </Text>
689          {list.length > 1 && (
690            <Box flexDirection="row" gap={1} flexShrink={0}>
691              <Button key="prev" plain onPress={() => $.state.set(SELECTED, (index - 1 + list.length) % list.length)}>
692                ‹
693              </Button>
694              <Text dimColor>
695                {index + 1}/{list.length}
696              </Text>
697              <Button key="next" plain onPress={() => $.state.set(SELECTED, (index + 1) % list.length)}>
698                ›
699              </Button>
700            </Box>
701          )}
702        </Box>
703        <Text dimColor wrap="truncate-end">
704          {facts}
705          {run.isThisSession ? ' · this session' : ''}
706        </Text>
707        <Box flexDirection="row" gap={2} marginBottom={1}>
708          <Text color={MODE_COLOR.inline}>inline</Text>
709          <Text color={MODE_COLOR.delegate}>delegate</Text>
710          <Text color={MODE_COLOR.fork}>fork</Text>
711          <Text color={MODE_COLOR.gate}>gate</Text>
712          <Text dimColor>? = inferred</Text>
713          <Text color="yellow">≠ = not as configured</Text>
714        </Box>
715
716        {gate && (
717          <Box key="pv" flexDirection="column" marginBottom={1} borderStyle="round" borderColor={COLOR.waiting} paddingX={1}>
718            <Text bold color={COLOR.waiting}>
719              {MARK.waiting} {gate.name}: your review
720            </Text>
721            {gate.links.map(l =>
722              isLinkable(l.url) ? (
723                <Text wrap="truncate-end">
724                  ↗ <Link href={l.url} label={l.label || l.url} />
725                </Text>
726              ) : (
727                <Text wrap="truncate-end">
728                  ↗ {l.label}
729                  {l.label && l.url ? '  ' : ''}
730                  <Text dimColor>{l.url}</Text>
731                </Text>
732              ),
733            )}
734            {gate.links.length === 0 && <Text dimColor>No review links on the stage yet.</Text>}
735            <Text dimColor>Open items from Ready</Text>
736            {run.openItems.length > 0 ? (
737              run.openItems.map(item => (
738                <Text color="yellow" wrap="wrap">
739                  • {item}
740                </Text>
741              ))
742            ) : (
743              <Text dimColor>None recorded.</Text>
744            )}
745            <Box flexDirection="row" gap={2} marginTop={1}>
746              {GATE_REPLIES.map(reply => (
747                // Button takes no colour, so a coloured glyph in front of it carries the tone.
748                <Box key={`pv-${reply.key}-row`} flexDirection="row" gap={1}>
749                  <Text color={reply.color}>{reply.glyph}</Text>
750                  <Button
751                    key={`pv-${reply.key}`}
752                    variant={reply.key === 'approve' ? 'primary' : 'secondary'}
753                    onPress={() => answerGate($, run, reply)}
754                  >
755                    {reply.label}
756                  </Button>
757                </Box>
758              ))}
759            </Box>
760          </Box>
761        )}
762
763        {run.stages.map((s, i) => {
764          const t = tone(s.status)
765          const isFocused = i === focused
766          const rail = i === run.stages.length - 1 ? ' ' : '│'
767          const right = [s.passes > 1 ? `↺${s.passes}` : '', duration(s.durationMs)].filter(Boolean).join(' ')
768          return (
769            <Box key={`p${i}`} flexDirection="column">
770              <Box flexDirection="row" gap={1}>
771                <Text color={COLOR[t]}>{MARK[t]}</Text>
772                <Box flexGrow={1} flexShrink={1}>
773                  <Button key={`f${i}`} plain onPress={() => $.state.set(FOCUS, isFocused ? -1 : i)}>
774                    {s.name}
775                  </Button>
776                </Box>
777                {badge(s)}
778                {(s.mismatch.length > 0 || s.fallback) && <Text color="yellow">≠</Text>}
779                {isWide && who(s) && (
780                  <Text dimColor wrap="truncate-end">
781                    {who(s)}
782                  </Text>
783                )}
784                {right && (
785                  <Text dimColor={!isQuiet(t)} color={s.passes > 1 ? 'yellow' : undefined}>
786                    {right}
787                  </Text>
788                )}
789              </Box>
790              {!isWide && who(s) && !isQuiet(t) && (
791                <Text dimColor wrap="truncate-end">
792                  {rail} {who(s)}
793                </Text>
794              )}
795              {s.workers.map((w, k) => (
796                <Text color={w.isFailed ? 'red' : 'cyan'} dimColor={t === 'done' && !isFocused} wrap="truncate-end">
797                  {k === s.workers.length - 1 ? '╰─▶' : '├─▶'} {w.name}
798                  {[w.isRevise ? 'revise round' : '', w.model, duration(w.durationMs), w.toolCalls ? `${w.toolCalls} tools` : '', tokens(w.tokens)]
799                    .filter(Boolean)
800                    .map(x => ` · ${x}`)
801                    .join('')}
802                </Text>
803              ))}
804              {isFocused && !isQuiet(t) && (
805                <Box flexDirection="column" marginLeft={2} marginBottom={1} borderStyle="single" borderColor={COLOR[t]} paddingX={1}>
806                  {s.output ? <Text wrap="wrap">{s.output}</Text> : <Text dimColor>No output yet.</Text>}
807                  {how(s).map(line => (
808                    <Text dimColor={!line.isDrift} color={line.isDrift ? 'yellow' : undefined} wrap="truncate-end">
809                      {line.text}
810                    </Text>
811                  ))}
812                  {s.scenarios.map(c => (
813                    <Text color={c.status === 'pass' ? 'green' : c.status === 'fail' ? 'red' : 'yellow'} wrap="truncate-end">
814                      {c.status === 'pass' ? '✓' : c.status === 'fail' ? '✗' : '~'} {c.name}
815                    </Text>
816                  ))}
817                  {s.links.length > 0 && <Text color="blue">↗ {s.links.map(l => l.label || l.url).join('  ↗ ')}</Text>}
818                  {(s.outputTokens || s.toolCalls > 0) && (
819                    <Text dimColor>
820                      {[s.outputTokens ? `${tokens(s.outputTokens)} output tokens` : '', s.toolCalls ? `${s.toolCalls} tool calls` : '']
821                        .filter(Boolean)
822                        .join(' · ')}
823                    </Text>
824                  )}
825                </Box>
826              )}
827            </Box>
828          )
829        })}
830      </Box>
831    )
832  })
833
834  // The band: one row above the prompt while this session's run is open — the rail and the phase it is in.
835  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
836    if ((e.props as any)?.hasSurvey) return next(e)
837    const { value: list = [] } = await $.state.get(RUNS)
838    const run = list.find(r => r.isThisSession && tone(r.status) !== 'done')
839    if (!run) return next(e)
840
841    const { Box, Text, Button } = $.ui.resolve(e)
842    const live = currentStage(run)
843    return (
844      <Box flexDirection="row" gap={1}>
845        <Text bold>{run.skillId}</Text>
846        <Box flexDirection="row">
847          {run.stages.map(s => (
848            <Text color={COLOR[tone(s.status)]}>{s.mode === 'gate' && tone(s.status) === 'pending' ? '◇' : MARK[tone(s.status)]}</Text>
849          ))}
850        </Box>
851        {live && (
852          <Text wrap="truncate-end">
853            <Text color={COLOR[tone(live.status)]}>{live.name}</Text>
854            <Text color={MODE_COLOR[live.mode]}> {MODE_LABEL[live.mode]}</Text>
855          </Text>
856        )}
857        <Button key="open" plain onPress={() => $.ui.open({ id: PANE, title: 'Delivery flows' })}>
858          details
859        </Button>
860      </Box>
861    )
862  })
863
864  // Inline: a surface call in the transcript reads as the phase change it records, not as JSON.
865  on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
866    const props = e.props as any
867    const tool = String(props?.tool ?? '')
868    const input = (props?.input ?? {}) as any
869    const isStage = isSurfaceTool(tool, 'update_stage')
870    const isStart = isSurfaceTool(tool, 'start_run')
871    if (!isStage && !isStart) return next(e)
872
873    const { Box, Text } = $.ui.resolve(e)
874    const surface = tool.includes('backlog') ? 'Backlog' : 'dashboard'
875
876    if (isStart) {
877      const names: string[] = Array.isArray(input.stages) ? input.stages.map((s: any) => String(s?.name ?? s)) : []
878      return (
879        <Box flexDirection="column">
880          <Text>
881            <Text bold>▶ {input.skillId ?? 'flow'}</Text>
882            <Text dimColor> started on the {surface} · {names.length} phases</Text>
883          </Text>
884          {names.length > 0 && (
885            <Text dimColor wrap="truncate-end">
886              {'  '}
887              {names.join(' → ')}
888            </Text>
889          )}
890        </Box>
891      )
892    }
893
894    const t = tone(String(input.status ?? ''))
895    const { value: list = [] } = await $.state.get(RUNS)
896    const run = list.find(r => r.isThisSession) ?? list[0]
897    const stage =
898      run?.stages.find(s => s.name === input.stageName) ??
899      (typeof input.stageIndex === 'number' ? run?.stages[input.stageIndex] : undefined)
900    const name = input.stageName ?? stage?.name ?? `stage ${input.stageIndex ?? '?'}`
901    const scenarios: any[] = Array.isArray(input.scenarios) ? input.scenarios : []
902    const passed = scenarios.filter(c => c.status === 'pass').length
903
904    return (
905      <Box flexDirection="column">
906        <Text wrap="truncate-end">
907          <Text color={COLOR[t]}>{MARK[t]} </Text>
908          <Text bold>{name}</Text>
909          <Text color={COLOR[t]}> {String(input.status ?? '').replace(/_/g, ' ')}</Text>
910          {stage && <Text color={MODE_COLOR[stage.mode]}> · {MODE_LABEL[stage.mode]}</Text>}
911          {stage?.agent && <Text dimColor> · {stage.agent}</Text>}
912          {scenarios.length > 0 && <Text dimColor> · {passed}/{scenarios.length} scenarios pass</Text>}
913          <Text dimColor> · {surface}</Text>
914        </Text>
915        {t !== 'active' && input.output && (
916          <Text dimColor wrap="truncate-end">
917            {'  '}
918            {String(input.output).split('\n')[0]}
919          </Text>
920        )}
921      </Box>
922    )
923  })
924}
925
types/index.d.ts 79 lines
1/** How a phase runs: in the main thread, handed to a sub-agent, or as a `context: fork` skill. */
2export type FlowMode = 'inline' | 'delegate' | 'fork' | 'gate'
3
4export type FlowWorker = {
5  name: string
6  model: string
7  durationMs: number | null
8  tokens: number | null
9  toolCalls: number | null
10  isFailed: boolean
11  /** It ran under the gate: work a revise round reopened, reported on the gate's stage. */
12  isRevise: boolean
13}
14
15export type FlowScenario = { name: string; status: string }
16
17export type FlowLink = { label: string; url: string }
18
19export type FlowStage = {
20  name: string
21  status: string
22  mode: FlowMode
23  /** True when the mode was read from the run, false when this pane inferred it. */
24  isModeRecorded: boolean
25  agent: string | null
26  model: string | null
27  effort: string | null
28  /** The procedure the phase followed, its MCP servers (`[]` for none, null when unrecorded), and its chores. */
29  skill: string | null
30  mcp: string[] | null
31  before: string[]
32  after: string[]
33  /** The configured id that did not resolve, so the phase fell back to its built-in procedure. */
34  fallback: string | null
35  /** What the run's resolved `runContext.phases` entry asked for; null when the run records none. */
36  configured: { agent: string | null; model: string | null; effort: string | null } | null
37  /** The fields where what ran differs from what was configured. */
38  mismatch: string[]
39  passes: number
40  durationMs: number | null
41  outputTokens: number | null
42  toolCalls: number
43  workers: FlowWorker[]
44  scenarios: FlowScenario[]
45  links: FlowLink[]
46  output: string
47}
48
49export type FlowRun = {
50  id: string
51  skillId: string
52  title: string
53  /** The repository the run is in: the run's `repo`, else the checkout folder it was found under. */
54  repo: string
55  status: string
56  changeKind: string
57  approval: string
58  updatedAt: string
59  startedAt: string
60  isThisSession: boolean
61  contextPeak: number | null
62  contextLimit: number | null
63  /** The Ready stage's open items, which Personal Validation presents first. */
64  openItems: string[]
65  stages: FlowStage[]
66}
67
68declare module 'claude-code' {
69  interface PluginState {
70    'delivery-run-view': {
71      runs: FlowRun[]
72      selected: number
73      focus: number
74      /** The last tone seen per `<run id>#<stage index>` in every checkout, so a gate alerts once per transition. */
75      seen: Record<string, string>
76    }
77  }
78}
79