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…

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.
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.
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.
| Where | Shows |
|---|---|
Pane — /flows, opened on its own the first time a run of this session appears | While 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 prompt | While this session's run is open: the flow, the stage rail, and the current phase with its mode |
| Inline tool rows | A 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 sound | Once 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-demo | A simulated flow-code run played in the pane, one phase every four seconds, held in memory only — no run file is written |
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.
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.
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.
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.
hooks/register.tsx 925 lines1import 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}
925types/index.d.ts 79 lines1/** 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