Ships a feature only when a test that actually ran proves every requirement in its spec. Free-text requirement -> spec (you sign it off) -> plan -> review ->…

A feature ships only when a test that actually ran proves every requirement in its spec. It closes the quiet failure: a requirement everyone believes is covered, with no test that proves it.
spec-flow is a spec-driven, multi-agent pipeline for Claude Code, for Node projects. A free-text requirement becomes a spec you sign off, a plan in milestones, a review, and an implementation loop gated by lint, tests and requirement traceability — checks that run outside the model, in a hook, on the commands your repo declares.
What the machine enforces: a test whose reported name carries the requirement's id executed — checked in both directions, from the report your runner writes, so a skipped or todo test does not count. What it cannot check is whether that test asserts anything: the engine reads no source code (ADR-001). A model reads each new test once per change and reports weak ones; the sign-off and your code review do the rest (ADR-020).
A requirement lives in specs/, with a permanent id:
### REQ-USER-001 — the user can reset their password by email
The system sends a single-use link, valid for one hour.
A test carries that id in its name:
it('REQ-USER-001 — sends a single-use link valid for one hour', ...)
Green is the two agreeing, read from the report your suite wrote:
spec-trace: OK — 12 requirement(s) across 3 capability spec(s), every one
proven by a test; 4 archived change(s), every one with a status.
OK — lint, tests and the unscoped checks pass.
Skip that test — it.skip, it.todo, a runtime skip — and it disappears from the report:
spec-trace: the spec layer and the code disagree.
- REQ-USER-001 (specs/user.md) has no test that RAN. Add a test whose name
contains REQ-USER-001, or delete the requirement — an unproven requirement
is a wish, not a spec.
A test naming an id no spec declares fails the same way.
claude plugin marketplace add IgnacioMarin402/spec-flow-plugin
claude plugin install spec-flow@spec-flow-marketplace
Then, from your repo's root:
node <the plugin's path>/scripts/init.mjs # writes .spec-flow/config.json
node <the plugin's path>/scripts/check-changed.mjs
init reads your test, lint, build and typecheck commands from package.json, adds your runner's reporter flag so the suite writes a report of what ran, and marks anything it inferred REVIEW and anything it could not read MISSING. If it leaves MISSING lines, ask Claude Code to set up spec-flow: the plugin's setup skill fills them and proves the result with check-changed. Green there means green at the gate. Every field is in REFERENCE.
Before the first run, write down how a module is built in this repo — in CLAUDE.md, or as a skill — or every agent learns it from the source, on every run (REFERENCE → Project skills).
The plugin is the whole install. For a terminal without Claude Code, or CI, the same engine installs as a pinned devDependency straight from this repository — nothing is published to npm, and spec-flow on npm is someone else's package (REFERENCE → CLI). To stay current: claude plugin update spec-flow@spec-flow-marketplace, then /reload-plugins (REFERENCE).
/spec-flow users can reset their password by email
.claude/spec-flow.config.json holds a setting the hooks cannot apply.specs/, stamped SHIPPED and archived with the run's telemetry.Beyond answering its questions, it stops for you only at the sign-off, a /spec-fix defect that turns out to be a wrong spec or a feature, an exhausted escalation budget, or five gate failures — then read .claude/state/gate-failure.log.
For a defect, /spec-fix <what is broken> triages it against specs/ and runs one implementer pass through the same gate.
| agent | does |
|---|---|
spec-writer | Writes the spec, triages defects, folds shipped changes into specs/ |
planner | Turns the spec into milestones; escalation consultant |
reviewer | Reads the plan against the spec before an implementer is spent |
implementer | Implements one milestone |
architect | Consulted when the implementer hits a design decision |
Each ships on a model tier, not a version, named in its own description. /spec-flow:models shows what each will run on, at which effort, and who decided. A project can re-route any agent and cap escalations (REFERENCE).
Nothing coordinates a run but one file, .claude/state/phase. The orchestrator routes and never writes code; each subagent does one job; when the orchestrator's turn ends, a Stop hook runs your lint, your whole suite and the traceability check, and either allows the stop or blocks with the next instruction. The three flowcharts are in REFERENCE.
npm install
npm run lint && npm run typecheck
Every other check is a *:check script in package.json, none needs the network, and CI runs all of them on Linux and Windows. cold:check is the one that fails when adoption breaks: it takes a Node repo from nothing to a green check through the route above.
MIT — see LICENSE.
hooks/live-view.tsx 806 lines1/**
2 * The live view: one box per spec-flow agent, the orchestrator, the phase and
3 * the last gate, in a pane and the status line (ADR-037).
4 *
5 * A mod, not a command hook: it runs inside Claude Code with no Node, and a
6 * Claude Code without function hooks skips it while the engine runs as before.
7 *
8 * It shows; it never decides. Every hook hands on what `next` resolved,
9 * untouched, its own bookkeeping may fail without reaching the spawn, the tool
10 * call or the turn, and it writes nothing but its own `$.state`. Nothing in the
11 * engine reads what it shows: `token-trace` stays the record.
12 *
13 * Per-agent activity comes from the session's events, joined by `agentId`.
14 * Phase, milestone, gate, budgets, send-backs and the checks that stood down
15 * come from `.claude/state/`, and the caps from the file `opus-budget` reads
16 * them from (ADR-047). It never reads a transcript (ADR-036). The one cost in
17 * dollars it shows is the session's, as Claude Code totals it: a share per
18 * role would be a guess (ADR-037).
19 */
20import { atom, read, update } from 'claude-code'
21import type { EngineInterface, Register, TurnStepInput, TurnStepResult } from 'claude-code'
22
23// The parser the agent gate and run-trace read a return with, and the gate's cap: one copy.
24import { MAX_BLOCKS, parseReturn } from '../scripts/agent-blocks.mjs'
25// The caps opus-budget denies at: one copy.
26import { BUDGETS, budgetCap } from './lib/budgets.mjs'
27// Which log lines count, read here or by node past 4 MiB: one filter.
28import { LINES, linesOf } from './lib/log-lines.mjs'
29// The milestone an implementer's spawn names, read the way current-milestone is: one reading.
30import { milestoneRef } from './lib/milestone-ref.mjs'
31// The settings file, read the one way its hooks read it.
32import { projectConfig } from './lib/project-config.mjs'
33import type { LiveAgent, LiveOrchestrator, LiveRun, LiveTokens, LiveTurn } from './live-view-state'
34
35const PANE = 'spec-flow-live'
36const PREFIX = 'spec-flow:'
37// The phase vocabulary `hooks/lib/io.mjs` arms on.
38const ARMED = ['spec', 'plan', 'review', 'implement', 'blocked']
39const BAR = ['spec', 'plan', 'review', 'implement', 'done']
40const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
41const CARD = 38 // 34 columns of text, its padding and its border
42
43const ZERO: LiveTokens = { in: 0, cacheRead: 0, cacheWrite: 0, out: 0 }
44const ORCH0: LiveOrchestrator = { steps: 0, calls: 0, byTool: {}, advisor: 0, advisorMs: 0, lastTool: '', lastAt: 0, ctx: 0, model: '', effort: '', tokens: ZERO }
45const TURN0: LiveTurn = { steps: 0, tokens: ZERO, advisor: 0, advisorMs: 0 }
46const RUN0: LiveRun = { phase: '', milestone: '', gate: '', gateAt: 0, attempts: 0, usd: null, failed: [], budgets: [], stoodDown: [], sentBack: {}, unread: [] }
47
48// The checks whose own log says they stood down, each line stamped when it did.
49const STOOD_DOWN = [
50 { file: 'orchestrator-scope-unmatched.log', check: 'orchestrator-scope', what: 'stood down: no tool call attributed to an agent yet' },
51 { file: 'human-turn-unmatched.log', check: 'the case 3 check', what: 'stood down: no answer ever recorded' },
52]
53
54const agents = atom({ plugin: 'spec-flow', key: 'liveAgents' } as const, [])
55const orchestrator = atom({ plugin: 'spec-flow', key: 'liveOrchestrator' } as const, ORCH0)
56const turn = atom({ plugin: 'spec-flow', key: 'liveTurn' } as const, TURN0)
57// A reload keeps `$.state`. A key of its own, because an untagged earlier
58// version reads `liveRun` and would draw a tagged run as its own; the tag, so a
59// later version that changes the run declines this one's.
60const run = atom({ plugin: 'spec-flow', key: 'liveRun2' } as const, RUN0, { shape: 'live-run-2' })
61const clock = atom({ plugin: 'spec-flow', key: 'liveClock' } as const, 0)
62
63// ---- what the engine wrote -------------------------------------------------
64
65async function stateText($: EngineInterface, root: string, file: string): Promise<string> {
66 try {
67 const text = await $.fs.read(`${root}/.claude/state/${file}`)
68 return typeof text === 'string' ? text.trim() : ''
69 } catch {
70 return ''
71 }
72}
73
74let root = ''
75let session = ''
76let phaseSeen = ''
77let phaseTracked = false
78// Each log is read again only when its stat moved; what it said, and whether it
79// was read, until then. Polls may overlap and share these: each records the
80// stat it read last, so an older read that lands late is read again, and drawn
81// right, at the next tick.
82let gateSeen = ''
83let gateLine = ''
84let gateUnread = false
85let stoodSeen = ''
86let stood: string[] = []
87let stoodUnread: string[] = []
88let traceSeen = ''
89let backs: LiveRun['sentBack'] = {}
90let traceUnread = false
91// This session's `closed` lines in run-trace.log, which run-trace writes when
92// this session writes `done`, as counted at the last turn's end; null until a
93// count has been read.
94let closedSeen: number | null = null
95
96/**
97 * The checks a failing gate line names with a non-zero code: the fields gate.mjs
98 * writes after `result=` and ahead of `files=` — `lint`, `test`, and each
99 * unscoped check's own, whatever the contract named it.
100 */
101function failedChecks(line: string): string[] {
102 const fields = line.split(' ')
103 const at = fields.findIndex(f => f.startsWith('result='))
104 if (!/^result=fail:(behaviour|lint\/trace)$/.test(fields[at] ?? '')) return []
105 const checks = fields.slice(at + 1)
106 if (checks.at(-1)?.startsWith('files=')) checks.pop()
107 return checks
108 .map(f => f.split('='))
109 .filter(([, v]) => v !== '0' && v !== '-')
110 .map(([key]) => key)
111}
112
113/** The agent gate's send-backs per agent, from its own `agent-check` lines (ADR-031). */
114function sendBacks(lines: string[], ids: Set<string>): LiveRun['sentBack'] {
115 const out: LiveRun['sentBack'] = {}
116 for (const line of lines) {
117 const [, result, why, id] = / agent-check agent=\S+ result=(block|cap) why=(\S+) agent_id=([\w-]+)$/.exec(line) ?? []
118 if (!id || !ids.has(id)) continue
119 const was = out[id] ?? { n: 0, why: '', cap: false }
120 out[id] = { n: was.n + (result === 'block' ? 1 : 0), why, cap: was.cap || result === 'cap' }
121 }
122 return out
123}
124
125async function statKey($: EngineInterface, path: string): Promise<string> {
126 try {
127 const st = await $.fs.stat(path)
128 return `${st.size}:${st.mtimeMs}`
129 } catch {
130 return ''
131 }
132}
133
134/**
135 * A log's lines that count (lib/log-lines.mjs): none when there is no log, and
136 * null when it is there and could not be read. Past what one read takes, node
137 * streams it.
138 */
139async function logLines($: EngineInterface, file: string, needle: string, since: number): Promise<{ n: number; last: string[] } | null> {
140 const path = `${root}/.claude/state/${file}`
141 try {
142 return linesOf(await $.fs.read(path), needle, since)
143 } catch {
144 /* over what one read takes, or not there */
145 }
146 if ((await statKey($, path)) === '') return { n: 0, last: [] }
147 try {
148 const out = await $.process.run(['node', '-e', LINES, '--', path, String(since), needle], { cwd: root, timeoutMs: 5_000 })
149 const [head = '', ...last] = out.stdout.split('\n')
150 return out.exitCode === 0 && !out.isStdoutTruncated && /^\d+$/.test(head) ? { n: Number(head), last: last.filter(Boolean) } : null
151 } catch {
152 return null
153 }
154}
155
156/**
157 * This session's run, as `readOwnedPhase` reads it (ADR-017): a phase another
158 * session owns, or one git tracks, is not a run here and shows as none. The
159 * milestone and the gate are this run's only: the milestone an implementer
160 * this session spawned wrote, and the gate lines after the run's telemetry mark.
161 *
162 * A log it cannot read is said, never drawn as nothing in it (ADR-047).
163 */
164async function poll($: EngineInterface): Promise<void> {
165 const before = await read($, run)
166 const raw = await stateText($, root, 'phase')
167 if (!raw && !before.phase) return // a repository that never ran the engine: nothing more to read
168
169 if (raw !== phaseSeen) {
170 let tracked = false
171 if (ARMED.includes(raw)) {
172 try {
173 const git = await $.process.run(['git', 'ls-files', '--error-unmatch', '--', '.claude/state/phase'], { cwd: root, timeoutMs: 5_000 })
174 tracked = git.exitCode === 0
175 } catch {
176 /* no git to ask: the engine's own read counts the phase too */
177 }
178 }
179 phaseTracked = tracked
180 phaseSeen = raw
181 }
182 const owner = ARMED.includes(raw) ? await stateText($, root, 'phase.session') : ''
183 const phase = phaseTracked || (owner && session && owner !== session) ? '' : raw
184 if (closedSeen === null) closedSeen = await closedCount($)
185
186 // Ours when this session spawned its implementer: by id, or, where the spawn's
187 // response named none, an implementer this session put on that change's milestone.
188 const [slug = '', mk = '', implementer = ''] = (await stateText($, root, 'current-milestone')).split(/\s+/)
189 const ours = (await read($, agents)).some(a =>
190 // A card whose spawn named no change matches by milestone alone.
191 implementer ? a.agentId === implementer : a.role === 'implementer' && a.milestone === mk && (!a.change || a.change === slug),
192 )
193
194 // The gate line after the run's mark, which counts the lines already there,
195 // stamped or not: `-Infinity` counts them so.
196 let mark = 0
197 try {
198 mark = Number(JSON.parse(await stateText($, root, 'run-offset'))['gate-history.log']) || 0
199 } catch {
200 /* no mark: every line counts, as the snapshot reads it */
201 }
202 const gateKey = `${await statKey($, `${root}/.claude/state/gate-history.log`)}:${mark}`
203 if (phase && gateKey !== gateSeen) {
204 const got = await logLines($, 'gate-history.log', '', -Infinity)
205 gateLine = got && got.n > mark ? (got.last.at(-1) ?? '') : ''
206 gateUnread = got === null
207 gateSeen = gateKey
208 }
209 const result = /\bresult=(\S+)/.exec(gateLine)?.[1] ?? ''
210 let usd: number | null = null
211 try {
212 usd = (await $.session.usage()).cost?.usd ?? null
213 } catch {
214 /* no ledger to read: shown as none */
215 }
216
217 // The run starts where its telemetry mark was set; no mark, every line counts, as above.
218 const offsetKey = await statKey($, `${root}/.claude/state/run-offset`)
219 const stoodKey = [offsetKey, ...(await Promise.all(STOOD_DOWN.map(d => statKey($, `${root}/.claude/state/${d.file}`))))].join('|')
220 if (phase && stoodKey !== stoodSeen) {
221 const startedAt = Number(offsetKey.split(':')[1]) || 0
222 const lines: string[] = []
223 const unread: string[] = []
224 for (const d of STOOD_DOWN) {
225 const got = await logLines($, d.file, '', startedAt)
226 if (got === null) unread.push(`${d.file} could not be read: whether ${d.check} stood down is not shown`)
227 else if (got.n > 0) lines.push(`${d.check} ${d.what} (${got.n}×)`)
228 }
229 stood = lines
230 stoodUnread = unread
231 stoodSeen = stoodKey
232 }
233
234 const ids = (await read($, agents)).map(a => a.agentId)
235 const traceKey = `${await statKey($, `${root}/.claude/state/run-trace.log`)}|${ids.join(',')}`
236 if (phase && traceKey !== traceSeen) {
237 const got = await logLines($, 'run-trace.log', ' agent-check ', 0)
238 backs = got ? sendBacks(got.last, new Set(ids)) : {}
239 traceUnread = got === null
240 traceSeen = traceKey
241 }
242
243 // While a run can still spend them: at `done` or `idle` no spawn is charged.
244 let budgets: LiveRun['budgets'] = []
245 let settings = ''
246 if (ARMED.includes(phase)) {
247 const path = `${root}/.claude/spec-flow.config.json`
248 let config = ''
249 let unreadable = false
250 try {
251 config = await $.fs.read(path)
252 } catch {
253 // None: each cap is its default, as opus-budget reads it. There and not read: the same, said.
254 unreadable = (await statKey($, path)) !== ''
255 }
256 budgets = await Promise.all(
257 Object.entries(BUDGETS).map(async ([role, b]) => ({ role, used: Number(await stateText($, root, b.counter)) || 0, cap: budgetCap(config, role) })),
258 )
259 // Mid-run its hooks fall back to the defaults over it, and say nothing.
260 const { problem } = projectConfig(config)
261 if (problem || unreadable) settings = `.claude/spec-flow.config.json ${problem || 'could not be read'}: the caps shown are the defaults`
262 }
263
264 const after: LiveRun = {
265 phase,
266 milestone: phase && ours && mk ? `${mk} · ${slug}` : '',
267 gate: phase && result ? `${result} ${gateLine.split(' ')[1] ?? ''}` : '',
268 gateAt: Date.parse(gateLine.split(' ')[0] ?? '') || 0,
269 attempts: phase ? Number(await stateText($, root, 'gate_attempts')) || 0 : 0,
270 usd,
271 failed: phase ? failedChecks(gateLine) : [],
272 budgets,
273 stoodDown: phase ? stood : [],
274 sentBack: phase ? backs : {},
275 unread: phase
276 ? [
277 ...(gateUnread ? ['gate-history.log could not be read: the last gate is not shown'] : []),
278 ...stoodUnread,
279 ...(traceUnread ? ["run-trace.log could not be read: the agent gate's send-backs are not shown"] : []),
280 ...(settings ? [settings] : []),
281 ]
282 : [],
283 }
284 if (JSON.stringify(after) !== JSON.stringify(before)) await update($, run, () => after)
285}
286
287// ---- what the session's own events say ------------------------------------
288
289const label = (e: { tool: string } & Record<string, unknown>): string => {
290 const pick = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
291 const path = pick('file_path') || pick('notebook_path')
292 const target = path
293 ? (path.split(/[\\/]/).at(-1) ?? path)
294 : pick('subagent_type').replace(PREFIX, '') || pick('pattern') || pick('command').split('\n')[0] || pick('description')
295 return `${e.tool} ${target ?? ''}`.trim()
296}
297
298const plus = (t: LiveTokens, u: TurnStepResult['usage']): LiveTokens =>
299 u
300 ? {
301 in: t.in + u.input_tokens,
302 cacheRead: t.cacheRead + u.cache_read_input_tokens,
303 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
304 out: t.out + u.output_tokens,
305 }
306 : t
307
308const sum = (a: LiveTokens, b: LiveTokens): LiveTokens => ({
309 in: a.in + b.in,
310 cacheRead: a.cacheRead + b.cacheRead,
311 cacheWrite: a.cacheWrite + b.cacheWrite,
312 out: a.out + b.out,
313})
314
315const sent = (u: TurnStepResult['usage']) =>
316 u ? u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens : 0
317
318// The advisor runs inside the request, as a server tool, so no tool call is
319// made for it: its consultations are read off the step's result. Their tokens
320// are not in the step's usage; token-trace reads them from the transcript.
321const consulted = (res: TurnStepResult) => {
322 const uses = (res.serverToolUses ?? []).filter(u => u.name === 'advisor')
323 return { n: uses.length, ms: uses.reduce((sum, u) => sum + Math.max(0, (u.endedAt ?? u.startedAt) - u.startedAt), 0) }
324}
325
326const armed = async ($: EngineInterface) => ARMED.includes((await read($, run)).phase)
327
328async function noteCall($: EngineInterface, agentId: string | undefined, what: string, tool: string) {
329 if (agentId !== undefined) {
330 if (!(await read($, agents)).some(a => a.agentId === agentId)) return
331 await update($, agents, all =>
332 all.map(a =>
333 a.agentId === agentId
334 ? { ...a, calls: a.calls + 1, byTool: { ...a.byTool, [tool]: (a.byTool[tool] ?? 0) + 1 }, lastTool: what }
335 : a,
336 ),
337 )
338 return
339 }
340 if (!(await armed($))) return
341 const at = await $.clock.now()
342 await update($, orchestrator, m => ({
343 ...m,
344 calls: m.calls + 1,
345 byTool: { ...m.byTool, [tool]: (m.byTool?.[tool] ?? 0) + 1 },
346 lastTool: what,
347 lastAt: at,
348 }))
349}
350
351// A SendMessage resumes an agent under the same id: its first request is its new run.
352async function noteResume($: EngineInterface, agentId: string | undefined) {
353 if (agentId === undefined) return
354 const agent = (await read($, agents)).find(a => a.agentId === agentId)
355 if (!agent || agent.endedAt === null) return
356 const at = await $.clock.now()
357 await update($, agents, all =>
358 all.map(a => (a.agentId === agentId ? { ...a, endedAt: null, turnAt: at, status: '', resumes: a.resumes + 1 } : a)),
359 )
360 await update($, clock, () => at)
361}
362
363async function noteStep($: EngineInterface, e: TurnStepInput, res: TurnStepResult) {
364 const u = res.usage
365 const effort = typeof e.effort === 'string' ? e.effort : ''
366 const adv = consulted(res)
367 if (e.agentId !== undefined) {
368 const id = e.agentId
369 if (!(await read($, agents)).some(a => a.agentId === id)) return
370 await update($, agents, all =>
371 all.map(a =>
372 a.agentId === id
373 ? {
374 ...a,
375 steps: a.steps + 1,
376 tokens: plus(a.tokens, u),
377 advisor: (a.advisor ?? 0) + adv.n,
378 advisorMs: (a.advisorMs ?? 0) + adv.ms,
379 ctx: u ? sent(u) : a.ctx,
380 model: u?.model ?? a.model,
381 effort: effort || a.effort,
382 }
383 : a,
384 ),
385 )
386 return
387 }
388 // The orchestrator's request goes on its open turn; the turn's end decides whether it counts.
389 const at = await $.clock.now()
390 await update($, turn, t => ({
391 steps: t.steps + 1,
392 tokens: plus(t.tokens, u),
393 advisor: (t.advisor ?? 0) + adv.n,
394 advisorMs: (t.advisorMs ?? 0) + adv.ms,
395 }))
396 await update($, orchestrator, m => ({ ...m, ctx: u ? sent(u) : m.ctx, model: u?.model ?? m.model, effort: effort || m.effort, lastAt: at }))
397}
398
399/** This session's `closed` lines in run-trace.log; null when it cannot be read, or whose they are cannot be told. */
400async function closedCount($: EngineInterface): Promise<number | null> {
401 if (!session) return null
402 return (await logLines($, 'run-trace.log', ` session=${session} closed tool=`, 0))?.n ?? null
403}
404
405/**
406 * The orchestrator is charged per turn, as `token-trace` charges it at Stop: a
407 * turn that ends inside this session's run counts whole, the turn that starts
408 * the run included, and so does the one that writes `done` (ADR-043) — told, as
409 * token-trace tells it, by a `closed` line of this session's written during the
410 * turn, so neither a tick that saw `done` first nor another session's close
411 * moves it. With no count to compare, a run the turn began in is the sign. One
412 * that ends outside a run counts nothing.
413 */
414async function closeTurn($: EngineInterface) {
415 const before = (await read($, run)).phase
416 await poll($)
417 const open = await read($, turn)
418 const closed = await closedCount($)
419 const closing =
420 (await read($, run)).phase === 'done' && (closed !== null && closedSeen !== null ? closed > closedSeen : ARMED.includes(before))
421 if (closed !== null) closedSeen = closed
422 if (open.steps > 0 && ((await armed($)) || closing)) {
423 await update($, orchestrator, m => ({
424 ...m,
425 steps: m.steps + open.steps,
426 tokens: sum(m.tokens, open.tokens),
427 advisor: (m.advisor ?? 0) + (open.advisor ?? 0),
428 advisorMs: (m.advisorMs ?? 0) + (open.advisorMs ?? 0),
429 }))
430 }
431 await update($, turn, () => TURN0)
432}
433
434// ---- drawing ---------------------------------------------------------------
435
436const k = (n: number) => (n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : `${n}`)
437const dur = (ms: number) => {
438 const s = Math.max(0, Math.round(ms / 1000))
439 if (s >= 3600) return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
440 return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, '0')}s` : `${s}s`
441}
442const shortModel = (m: string) =>
443 m.replace(/^claude-/, '').replace(/-(\d+)-(\d+)(-\d{8})?$/, ' $1.$2').replace(/-(\d+)$/, ' $1') || '?'
444// Read from cache, sent fresh (cache writes included), and generated. Never one
445// sum: a cache read is billed at a fraction of a fresh token, and out at a multiple.
446const split = (t: LiveTokens) => `cache ${k(t.cacheRead)} · new ${k(t.in + t.cacheWrite)} · out ${k(t.out)}`
447// What it invoked, counted: tool calls, the skills among them, and the advisor's consultations.
448type Invoked = { calls: number; byTool: Record<string, number>; advisor: number; advisorMs: number }
449const counted = (x: Invoked) => `${x.calls - (x.byTool?.Skill ?? 0)} tools · ${x.byTool?.Skill ?? 0} skills`
450// '' when it consulted none: a card gives it a line of its own only then.
451const advised = (x: Invoked) => (x.advisor ? `advisor ${x.advisor} · ${dur(x.advisorMs ?? 0)}` : '')
452const invoked = (x: Invoked) => [counted(x), advised(x)].filter(Boolean).join(' · ')
453const usdLine = (usd: number | null) => (usd === null ? 'session cost: none reported' : `session cost US$${usd.toFixed(2)} (/cost)`)
454const tone = (status: string) =>
455 /BLOCK|FAIL|ERROR|ABORTED/.test(status) ? 'error' : /ESCALATE|CHANGES|NEEDS|NO_STATUS/.test(status) ? 'warning' : 'success'
456const mark = (status: string) => (tone(status) === 'error' ? '✗' : tone(status) === 'warning' ? '↺' : '✓')
457const elapsed = (a: LiveAgent, at: number) => a.activeMs + (a.endedAt === null ? Math.max(0, at - a.turnAt) : 0)
458// Exact, in token-trace's field names, so the two can be compared line by line
459// (ADR-037), and priced with no join: `?` is a model or effort no request named.
460const countLine = (id: string, role: string, a: { model: string; effort: string; tokens: LiveTokens }, msgs: number) =>
461 `agent_id=${id} role=${role} model=${a.model || '?'} effort=${a.effort || '?'} in=${a.tokens.in} cache_read=${a.tokens.cacheRead} cache_write=${a.tokens.cacheWrite} out=${a.tokens.out} msgs=${msgs}`
462
463export const register: Register = (on, options) => {
464 // The plugin setting that turns the view off: nothing is hooked at all.
465 if (options.live_view === false) return
466
467 on('session.start', async ($, e, next) => {
468 root = e.cwd
469 gateSeen = traceSeen = stoodSeen = ''
470 try {
471 session = await $.session.id()
472 } catch {
473 /* unknown: the phase owner then cannot be compared, as in readOwnedPhase */
474 }
475 // Counted from here: a session's first turn may write `done` before any poll.
476 closedSeen = await closedCount($).catch(() => null)
477 $.clock.every(2000, () => poll($).catch(() => undefined))
478 // A running agent's clock ticks once a second; nothing redraws while none runs.
479 let shown: string | undefined
480 $.clock.every(1000, async () => {
481 try {
482 const live = (await read($, agents)).filter(a => a.endedAt === null)
483 if (live.length > 0) {
484 const t = await $.clock.now()
485 await update($, clock, () => t)
486 }
487 const { phase } = await read($, run)
488 // Claude Code puts the plugin's name ahead of it.
489 const text = !ARMED.includes(phase) ? undefined : `${phase}${live.length ? ` · ${live.map(a => a.role).join(', ')}` : ''}`
490 if (text !== shown) $.ui.status((shown = text))
491 } catch {}
492 })
493 try {
494 await $.command.register({ name: 'spec-flow-live', description: 'Show the live spec-flow pane', immediate: true })
495 } catch {}
496 return next(e)
497 })
498
499 on('command.run', { command: 'spec-flow-live' }, async $ => {
500 await $.ui.open({ id: PANE, title: 'spec-flow' })
501 return { text: 'spec-flow live pane opened.' }
502 })
503
504 on('agent.spawn', async ($, e, next) => {
505 const spawned = await next(e)
506 if (!e.subagentType.startsWith(PREFIX) || spawned.deny !== undefined || spawned.agentId === undefined) return spawned
507 try {
508 const at = await $.clock.now()
509 const role = e.subagentType.slice(PREFIX.length)
510 // Only an implementer works ON a milestone; the others merely name one.
511 const { change, milestone } = (role === 'implementer' && milestoneRef(e.prompt)) || { change: '', milestone: '' }
512 const agent: LiveAgent = {
513 agentId: spawned.agentId,
514 role,
515 parentId: e.parentAgentId ?? '',
516 change,
517 milestone,
518 model: spawned.model,
519 effort: '',
520 startedAt: at,
521 turnAt: at,
522 activeMs: 0,
523 endedAt: null,
524 status: '',
525 resumes: 0,
526 steps: 0,
527 calls: 0,
528 byTool: {},
529 advisor: 0,
530 advisorMs: 0,
531 lastTool: '',
532 ctx: 0,
533 tokens: ZERO,
534 }
535 await update($, agents, list => [...list.filter(a => a.agentId !== agent.agentId), agent].slice(-60))
536 await update($, clock, () => at)
537 // Unasked, a pane is placed only where it would sit beside the transcript.
538 void $.ui.open({ id: PANE, title: 'spec-flow' }).catch(() => undefined)
539 } catch {}
540 return spawned
541 }).catch(($, e, next) => next(e))
542
543 on('tool.call', async ($, e, next) => {
544 try {
545 await noteCall($, e.agentId, label(e as unknown as { tool: string } & Record<string, unknown>), String(e.tool))
546 } catch {}
547 return next(e)
548 }).catch(($, e, next) => next(e))
549
550 on('turn.step', async function* ($, e, next) {
551 try {
552 await noteResume($, e.agentId)
553 } catch {}
554 const res = yield* next(e)
555 try {
556 await noteStep($, e, res)
557 } catch {}
558 return res
559 })
560
561 on('turn.complete', async ($, e, next) => {
562 const done = await next(e)
563 const id = e.agentId
564 try {
565 if (id === undefined) {
566 await closeTurn($)
567 return done
568 }
569 if (!(await read($, agents)).some(a => a.agentId === id)) return done
570 const at = await $.clock.now()
571 const status = parseReturn(e.answer)?.status ?? (e.reason === 'answer' ? 'NO_STATUS' : e.reason.toUpperCase())
572 await update($, agents, list =>
573 list.map(a => (a.agentId === id ? { ...a, endedAt: at, activeMs: a.activeMs + e.durationMs, status } : a)),
574 )
575 await update($, clock, () => at)
576 } catch {}
577 return done
578 }).catch(($, e, next) => next(e))
579
580 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
581 const { Box, Button, Text } = $.ui.resolve(e)
582 const list = await read($, agents)
583 const committed = await read($, orchestrator)
584 const open = await read($, turn)
585 const r = await read($, run)
586 const at = Math.max(await read($, clock), ...list.map(a => a.endedAt ?? a.turnAt), committed.lastAt)
587 // What the orchestrator has spent so far: its charged turns and the one it is in.
588 const m = {
589 ...committed,
590 steps: committed.steps + open.steps,
591 tokens: sum(committed.tokens, open.tokens),
592 advisor: (committed.advisor ?? 0) + (open.advisor ?? 0),
593 advisorMs: (committed.advisorMs ?? 0) + (open.advisorMs ?? 0),
594 }
595
596 const cols = e.props.bodyColumns
597 const live = list.filter(a => a.endedAt === null)
598 const spin = SPIN[Math.floor(at / 1000) % SPIN.length]
599
600 const bar = (
601 <Box flexWrap="wrap">
602 {BAR.map((p, i) => {
603 const here = BAR.indexOf(r.phase)
604 return (
605 <Text
606 bold={p === r.phase}
607 color={p === r.phase ? 'warning' : i < here ? 'success' : undefined}
608 dimColor={here !== -1 && i > here}
609 >
610 {i === 0 ? '' : ' ─ '}
611 {p === r.phase ? `[${p}]` : p}
612 </Text>
613 )
614 })}
615 {r.phase === 'blocked' && (
616 <Text bold color="error">
617 {' [blocked]'}
618 </Text>
619 )}
620 {r.milestone !== '' && <Text color="suggestion">{` ${r.milestone}`}</Text>}
621 </Box>
622 )
623
624 const gate =
625 r.gate === '' ? (
626 <Text dimColor>gate: not run in this run</Text>
627 ) : (
628 <Text wrap="truncate-end">
629 gate:{' '}
630 <Text color={r.gate.startsWith('pass') ? 'success' : r.gate.startsWith('running') ? 'warning' : 'error'}>
631 {r.gate}
632 </Text>
633 <Text dimColor>{`${r.gateAt ? ` · ${dur(at - r.gateAt)} ago` : ''} · failures in a row ${r.attempts}`}</Text>
634 </Text>
635 )
636 // Named by the checks the gate line records; their output stays in gate-failure.log.
637 const failed = r.failed.length > 0 && <Text color="error" wrap="wrap">{` failed: ${r.failed.join(' · ')}`}</Text>
638
639 // A cap that is not a number of calls is one opus-budget denies every call at.
640 // Wrapped, never cut: what is cut off here is what says why.
641 const budget = r.budgets.length > 0 && (
642 <Text wrap="wrap">
643 <Text dimColor>budget:</Text>
644 {r.budgets.map(b =>
645 Number.isFinite(b.cap) ? (
646 <Text color={b.used >= b.cap ? 'error' : b.used === b.cap - 1 ? 'warning' : undefined}>{` ${b.role} ${b.used}/${b.cap}`}</Text>
647 ) : (
648 <Text color="error">{` ${b.role} ${b.used}, cap unreadable: every call denied`}</Text>
649 ),
650 )}
651 </Text>
652 )
653
654 const orchestratorBox = (
655 <Box flexDirection="column" borderStyle="round" borderColor="suggestion" paddingX={1} width={Math.min(cols, CARD * 2 + 3)}>
656 <Box justifyContent="space-between">
657 <Text bold>orchestrator</Text>
658 <Text dimColor>{shortModel(m.model)}</Text>
659 </Box>
660 {live.length > 0 ? (
661 <Text color="warning" wrap="truncate-end">{`waiting on ${live.map(a => a.role).join(', ')}`}</Text>
662 ) : at - m.lastAt < 20_000 && m.lastTool !== '' ? (
663 <Text color="suggestion" wrap="truncate-end">{`▸ ${m.lastTool}`}</Text>
664 ) : (
665 <Text dimColor>idle: your turn, or the gate's</Text>
666 )}
667 <Text wrap="truncate-end">{`${m.steps} requests · ctx ${k(m.ctx)}`}</Text>
668 <Text wrap="truncate-end">{invoked(m)}</Text>
669 <Text wrap="truncate-end">{split(m.tokens)}</Text>
670 </Box>
671 )
672
673 const sentBack = (a: LiveAgent) => {
674 const b = r.sentBack[a.agentId]
675 if (!b) return null
676 return (
677 <Text color={b.cap ? 'error' : 'warning'} wrap="wrap">
678 {b.cap ? `sent back ${b.n}/${MAX_BLOCKS}, then let through · ${b.why}` : `sent back ${b.n}/${MAX_BLOCKS} · ${b.why}`}
679 </Text>
680 )
681 }
682
683 // A running agent is drawn whole and lit; a finished one keeps its box, dimmed and shorter.
684 const card = (a: LiveAgent) => {
685 const running = a.endedAt === null
686 const color = running ? 'warning' : tone(a.status)
687 const parent = a.parentId ? list.find(p => p.agentId === a.parentId)?.role : undefined
688 const title = (
689 <Box justifyContent="space-between">
690 <Text bold={running} dimColor={!running} wrap="truncate-end">
691 {a.role}
692 {a.milestone ? ` · ${a.milestone}` : ''}
693 {parent ? ` ← ${parent}` : ''}
694 </Text>
695 <Text color={color}>{running ? `${spin} running` : `${mark(a.status)} ${a.status}`}</Text>
696 </Box>
697 )
698 const pace = `${dur(elapsed(a, at))} · ${a.steps} requests`
699 if (!running) {
700 return (
701 <Box key={`agent-${a.agentId}`} flexDirection="column" borderStyle="round" borderColor={color} borderDimColor paddingX={1} width={CARD}>
702 {title}
703 {sentBack(a)}
704 <Text dimColor wrap="truncate-end">{`${pace} · ${shortModel(a.model)}`}</Text>
705 <Text dimColor wrap="truncate-end">{counted(a)}</Text>
706 {advised(a) !== '' && <Text dimColor wrap="truncate-end">{advised(a)}</Text>}
707 <Text dimColor wrap="truncate-end">{split(a.tokens)}</Text>
708 </Box>
709 )
710 }
711 return (
712 <Box key={`agent-${a.agentId}`} flexDirection="column" borderStyle="bold" borderColor={color} paddingX={1} width={CARD}>
713 {title}
714 {sentBack(a)}
715 <Text dimColor wrap="truncate-end">
716 {`${shortModel(a.model)}${a.effort ? ` · ${a.effort}` : ''}${a.resumes ? ` · resumed ${a.resumes}` : ''}`}
717 </Text>
718 <Text wrap="truncate-end">{`${pace} · ctx ${k(a.ctx)}`}</Text>
719 <Text wrap="truncate-end">{counted(a)}</Text>
720 {advised(a) !== '' && <Text wrap="truncate-end">{advised(a)}</Text>}
721 <Text wrap="truncate-end">{split(a.tokens)}</Text>
722 <Text color="suggestion" wrap="truncate-end">{`▸ ${a.lastTool || 'thinking…'}`}</Text>
723 </Box>
724 )
725 }
726
727 // Chronological; the arrow is "then": every hand-off goes back through the orchestrator.
728 const perRow = Math.max(1, Math.floor((cols + 3) / (CARD + 3)))
729 const rows: LiveAgent[][] = []
730 for (let i = 0; i < list.length; i += perRow) rows.push(list.slice(i, i + perRow))
731
732 const total = list.reduce(
733 (acc, a) => ({
734 calls: acc.calls + a.calls,
735 byTool: { ...acc.byTool, Skill: (acc.byTool.Skill ?? 0) + (a.byTool?.Skill ?? 0) },
736 advisor: acc.advisor + (a.advisor ?? 0),
737 advisorMs: acc.advisorMs + (a.advisorMs ?? 0),
738 tokens: sum(acc.tokens, a.tokens),
739 }),
740 { calls: m.calls, byTool: { Skill: m.byTool?.Skill ?? 0 }, advisor: m.advisor, advisorMs: m.advisorMs, tokens: m.tokens },
741 )
742
743 return (
744 <Box flexDirection="column">
745 {bar}
746 {gate}
747 {failed}
748 {budget}
749 {r.stoodDown.map(line => (
750 <Text color="warning" wrap="wrap">{`⚠ ${line}`}</Text>
751 ))}
752 {r.unread.map(line => (
753 <Text dimColor wrap="wrap">{line}</Text>
754 ))}
755 {orchestratorBox}
756 {list.length === 0 ? (
757 <Text dimColor>No spec-flow agent has been spawned in this session yet.</Text>
758 ) : (
759 <Box flexDirection="column">
760 {rows.map(row => (
761 <Box flexDirection="row">
762 {row.map((a, i) =>
763 i === 0 ? (
764 card(a)
765 ) : (
766 <Box flexDirection="row">
767 <Box width={3} alignItems="center" justifyContent="center">
768 <Text dimColor>→</Text>
769 </Box>
770 {card(a)}
771 </Box>
772 ),
773 )}
774 </Box>
775 ))}
776 </Box>
777 )}
778 <Text wrap="wrap">
779 <Text bold>run total </Text>
780 {`${list.length} agents · ${invoked(total)}`}
781 </Text>
782 <Text wrap="wrap">{split(total.tokens)}</Text>
783 <Text wrap="wrap">{usdLine(r.usd)}</Text>
784 {list.length > 0 && (
785 <Box>
786 <Button
787 key="copy"
788 label="Copy exact counts"
789 onPress={async () => {
790 // Read at the press, not from the drawing: a count may have moved since.
791 const now = await read($, agents)
792 const charged = await read($, orchestrator)
793 const text = [
794 ...now.map(a => countLine(a.agentId, a.role, a, a.steps)),
795 countLine('-', 'orchestrator', charged, charged.steps),
796 ].join('\n')
797 await $.ui.copy({ text, surface: e.surface })
798 }}
799 />
800 </Box>
801 )}
802 </Box>
803 )
804 })
805}
806scripts/agent-blocks.mjs 128 lines1#!/usr/bin/env node
2/**
3 * Reading the fenced return blocks an agent contract declares.
4 *
5 * A pure function over a string, in its own module so it can be tested as
6 * one: `agent-contracts.mjs` exits the process on a finding, so importing it
7 * from a test would end the test run rather than answer a question.
8 *
9 * **Every line break here is `\r?\n`, and that is the invariant.** These files
10 * are read from wherever the plugin was installed, and git hands a Windows
11 * checkout CRLF — so an anchor spelled `\n` finds nothing there while finding
12 * everything on the machine it was written on. The failure is not subtle once
13 * seen and is invisible before: the caller's own "I found no blocks" guard
14 * fires, which reads as a moved anchor rather than as a line ending.
15 */
16
17/**
18 * The field names declared across every fenced block that opens with
19 * `STATUS:`, in declaration order, deduplicated.
20 *
21 * @param {string} text An agent contract's markdown.
22 * @returns {{ blocks: number, fields: string[] }} `blocks` is how many were
23 * found, so a caller can tell "this agent declares none" from "the anchor
24 * stopped matching" — they are the same empty field list otherwise.
25 */
26export function returnBlocks(text) {
27 const found = [...String(text ?? '').matchAll(/```\r?\n(STATUS:[\s\S]*?)```/g)];
28 const fields = [];
29 for (const block of found) {
30 for (const line of block[1].split(/\r?\n/)) {
31 const key = /^([A-Z][A-Z_]+):/.exec(line);
32 if (key && !fields.includes(key[1])) fields.push(key[1]);
33 }
34 }
35 return { blocks: found.length, fields };
36}
37
38/**
39 * The fields no command names, matched as whole field names: `QUESTIONS` is
40 * not named by a command that only says `OPEN_QUESTIONS`, and `READ` is not
41 * named by `SPEC_READY`. A substring match let a field pass on a neighbour's
42 * name, which reads exactly like a field that is routed.
43 *
44 * @param {string[]} fields Declared return fields.
45 * @param {string} commandsText The commands' text, concatenated.
46 * @param {Record<string, string>} [exempt] Fields deliberately not named, by reason.
47 * @returns {string[]} The fields that are neither named nor exempt.
48 */
49export function unroutedFields(fields, commandsText, exempt = {}) {
50 return fields.filter((f) => !(f in exempt) && !new RegExp(`(?<![A-Za-z0-9_])${f}(?![A-Za-z0-9_])`).test(commandsText));
51}
52
53/** Blocks per agent before the agent gate lets its return through as it is. The live view shows a send-back against the same number. */
54export const MAX_BLOCKS = 2;
55
56/** How many questions one `NEEDS_INPUT` round may ask. The spec-writer's contract must state the same number, and the agent gate holds a NEEDS_INPUT to it (ADR-030). */
57export const MAX_OPEN_QUESTIONS = 3;
58
59/**
60 * The statuses an agent contract declares: the `STATUS:` line of each fenced
61 * return block.
62 *
63 * @param {string} text An agent contract's markdown, modes included.
64 * @returns {string[]}
65 */
66export function declaredStatuses(text) {
67 return [...new Set([...String(text ?? '').matchAll(/```\r?\nSTATUS:[ \t]*([A-Z_]+)/g)].map((m) => m[1]))];
68}
69
70/**
71 * The return block in an agent's reply: its status and the `KEY: value` lines
72 * after it, up to a closing fence. The first LINE opening with `STATUS:` wins
73 * — markdown emphasis, a heading mark or a quote mark around it allowed; a
74 * status named mid-sentence ("the last round ended with STATUS: X") is prose.
75 * run-trace records this status and the agent gate judges it, so both read it
76 * here. A key that repeats keeps its first value; a list field's items are
77 * not read.
78 *
79 * @param {string} text What the agent handed back.
80 * @returns {{ status: string, fields: Record<string, string> } | null}
81 */
82export function parseReturn(text) {
83 const s = String(text ?? '');
84 const m = /^[ \t>#*]*STATUS:[ \t*]*([A-Z_]+)/m.exec(s);
85 if (!m) return null;
86 /** @type {Record<string, string>} */
87 const fields = {};
88 for (const line of s.slice(m.index).split(/\r?\n/).slice(1)) {
89 if (/^\s*```/.test(line)) break;
90 const kv = /^[ \t>*]*([A-Z][A-Z_]+):[ \t*]*(.*)$/.exec(line);
91 if (kv && !(kv[1] in fields)) fields[kv[1]] = kv[2].trim();
92 }
93 return { status: m[1], fields };
94}
95
96/**
97 * How many questions a `NEEDS_INPUT` reply asks: the top-level items of the
98 * list under `OPEN_QUESTIONS:`. Options nested under a question are part of
99 * it, and so are bullets flush with numbered questions. The list ends at a
100 * blank line, a flush line that is not an item, a fence or the next field:
101 * bullets after it are context, not questions. Questions separated by blank
102 * lines therefore undercount, which lets a round through rather than send a
103 * good one back.
104 *
105 * @param {string} text What the agent handed back.
106 * @returns {number}
107 */
108export function openQuestions(text) {
109 const lines = String(text ?? '').split(/\r?\n/);
110 const at = lines.findIndex((l) => /^[ \t>*]*OPEN_QUESTIONS:/.test(l));
111 if (at === -1) return 0;
112 const items = [];
113 for (const line of lines.slice(at + 1)) {
114 if (/^\s*```/.test(line) || /^[ \t>*]*[A-Z][A-Z_]+:/.test(line)) break;
115 if (!line.trim()) {
116 if (items.length > 0) break;
117 continue;
118 }
119 const item = /^(\s*)([-*+]|\d+[.)])\s+\S/.exec(line);
120 if (item) items.push({ indent: item[1].length, numbered: /\d/.test(item[2]) });
121 else if (items.length > 0 && !/^\s/.test(line)) break;
122 }
123 if (items.length === 0) return 0;
124 const top = Math.min(...items.map((i) => i.indent));
125 const level = items.filter((i) => i.indent === top);
126 return level.some((i) => i.numbered) ? level.filter((i) => i.numbered).length : level.length;
127}
128hooks/lib/budgets.mjs 35 lines1/**
2 * The escalation budgets: what `opus-budget` denies at and what the live view
3 * shows. One copy, so the pane never shows a cap the hook does not deny at.
4 *
5 * Pure — no Node import — because the live view loads it, and a mod runs with
6 * no Node (ADR-037).
7 */
8
9import { projectConfig } from './project-config.mjs';
10
11/** Each escalation role's cap, its default and its counter (ADR-038). */
12export const BUDGETS = {
13 planner: { key: 'max_opus_calls', fallback: 6, counter: 'opus_calls' },
14 architect: { key: 'max_architect_calls', fallback: 3, counter: 'architect_calls' },
15};
16
17/**
18 * A role's cap, from the text of `.claude/spec-flow.config.json`: its default
19 * when the file is missing or malformed, or does not set it. NaN when it sets
20 * something that is not a whole number of calls, `null` included: no count
21 * compares true against NaN, so a caller that compared would never deny.
22 *
23 * @param {string} configText
24 * @param {string} role `planner` or `architect`
25 * @returns {number}
26 */
27export function budgetCap(configText, role) {
28 const budget = BUDGETS[role];
29 const value = projectConfig(configText).settings[budget.key];
30 if (value === undefined) return budget.fallback;
31 // Digits only, in a string: `Number()` also reads "0x10" as 16 and "1e1" as 10.
32 const cap = typeof value === 'number' ? value : typeof value === 'string' && /^\s*\d+\s*$/.test(value) ? Number(value) : NaN;
33 return Number.isInteger(cap) && cap >= 0 ? cap : NaN;
34}
35hooks/lib/log-lines.mjs 58 lines1/**
2 * The lines of a `.claude/state/` log the live view counts, read whole by the
3 * view or, past what one `$.fs.read` takes (4 MiB), streamed by node. One
4 * filter for both: a count must not change when a log crosses 4 MiB.
5 *
6 * Pure — no Node import — because the live view loads it, and a mod runs with
7 * no Node (ADR-037). node runs the engine's hooks, so it is there wherever the
8 * view has a run to show.
9 */
10
11/**
12 * Whether a line counts: it holds `needle` and is stamped at or after `since`,
13 * in milliseconds; with `since` -Infinity, any line that is not blank, stamped
14 * or not, as the run's telemetry mark counts them. A line with no stamp never
15 * counts otherwise.
16 *
17 * Self-contained, because `LINES` carries its source to node.
18 *
19 * @param {string} line
20 * @param {string} needle
21 * @param {number} since
22 * @returns {boolean}
23 */
24export const counts = (line, needle, since) =>
25 line.includes(needle) && (since === -Infinity ? line.trim() !== '' : Date.parse(line.split(' ')[0]) >= since);
26
27/** How many of the lines that count a reading hands back: the last, a run's own. */
28export const KEEP = 10000;
29
30/**
31 * The lines of `text` that count: how many, and the last `KEEP` of them.
32 *
33 * @param {string} text
34 * @param {string} needle
35 * @param {number} since
36 * @returns {{ n: number, last: string[] }}
37 */
38export function linesOf(text, needle, since) {
39 const all = text.split(/\r?\n/).filter((l) => counts(l, needle, since));
40 return { n: all.length, last: all.slice(-KEEP) };
41}
42
43/**
44 * `linesOf` for a file one read cannot take, as `node -e LINES -- <path>
45 * <since> <needle>`: `n` on the first line of its output, then the last `KEEP`
46 * lines, oldest first. A line at a time, so the file's size costs no memory.
47 */
48export const LINES = `
49const counts = ${counts};
50const [path, since, needle] = process.argv.slice(1);
51const last = [];
52let n = 0;
53require('readline')
54 .createInterface({ input: require('fs').createReadStream(path), crlfDelay: Infinity })
55 .on('line', (l) => { if (counts(l, needle, Number(since))) last[n++ % ${KEEP}] = l; })
56 .on('close', () => process.stdout.write([n, ...last.slice(n % ${KEEP}), ...last.slice(0, n % ${KEEP})].join('\\n')));
57`;
58hooks/lib/milestone-ref.mjs 31 lines1/**
2 * The milestone an implementer's spawn works on, read from its prompt.
3 *
4 * One reading for every reader of it: the live view finds the agent a
5 * `current-milestone` record names by the milestone it labelled that agent's
6 * card with, and a record and a label read two ways can name two milestones.
7 *
8 * Pure — no Node import — because the live view loads it (ADR-037).
9 */
10
11// Its work order, in either separator: Windows names `specflow\x\milestones\M2.md`.
12const WORK_ORDER = /specflow[\\/]([^\\/\s"'`]+)[\\/]milestones[\\/](M\d+)\.md/;
13// `milestones/<Mk>.md` starting a path, as a fresh session on a milestone under
14// way is handed it; `docs/milestones/M1.md` is some other file.
15const BARE = /(?<![\w.-][\\/]?)milestones[\\/](M\d+)\.md/;
16
17/**
18 * The first work order the prompt names, wherever it stands; else a bare
19 * `milestones/<Mk>.md`, with no change. Null when it names neither.
20 *
21 * @param {unknown} prompt
22 * @returns {{ change: string, milestone: string } | null}
23 */
24export function milestoneRef(prompt) {
25 const text = typeof prompt === 'string' ? prompt : '';
26 const order = WORK_ORDER.exec(text);
27 if (order) return { change: order[1], milestone: order[2] };
28 const bare = BARE.exec(text);
29 return bare ? { change: '', milestone: bare[1] } : null;
30}
31hooks/lib/project-config.mjs 34 lines1/**
2 * `.claude/spec-flow.config.json`, read the one way every reader reads it: the
3 * caps (`budgets.mjs`), the routing (`routing.mjs`), the run-start check
4 * (`can-run.mjs`) and the live view. Two readings of one file can disagree,
5 * and then the pane shows a cap the hook does not deny at (ADR-047).
6 *
7 * Pure — no Node import — because the live view loads it, and a mod runs with
8 * no Node (ADR-037).
9 */
10
11/**
12 * The file's settings, and why it has none when it should: `{}` and no problem
13 * for an empty file, `{}` and the problem for one that does not parse or is not
14 * an object. A byte-order mark, which some editors write, is not part of the
15 * JSON.
16 *
17 * @param {string} text
18 * @returns {{ settings: Record<string, unknown>, problem: string }}
19 */
20export function projectConfig(text) {
21 const json = text.replace(/^\uFEFF/, '');
22 if (json.trim() === '') return { settings: {}, problem: '' };
23 let value;
24 try {
25 value = JSON.parse(json);
26 } catch (err) {
27 return { settings: {}, problem: `does not parse as JSON (${err.message}), so none of its settings apply` };
28 }
29 if (value === null || typeof value !== 'object' || Array.isArray(value)) {
30 return { settings: {}, problem: 'is not a JSON object, so none of its settings apply' };
31 }
32 return { settings: value, problem: '' };
33}
34hooks/live-view-state.d.ts 85 lines1export type LiveTokens = { in: number; cacheRead: number; cacheWrite: number; out: number }
2
3export type LiveAgent = {
4 agentId: string
5 role: string
6 // the agent that spawned it ('' for the orchestrator): the architect runs inside the implementer
7 parentId: string
8 // an implementer's work order: its change and milestone ('' for any other role)
9 change: string
10 milestone: string
11 model: string
12 effort: string
13 startedAt: number
14 // when its current run began: the spawn, or its first request after a SendMessage resumed it
15 turnAt: number
16 // wall time of its finished runs
17 activeMs: number
18 // null while it runs
19 endedAt: number | null
20 // the STATUS: its report handed back; '' while it runs
21 status: string
22 resumes: number
23 steps: number
24 calls: number
25 byTool: Record<string, number>
26 // the advisor consultations its requests made, and the time they took
27 advisor: number
28 advisorMs: number
29 lastTool: string
30 // the last request's prompt: what the agent carries now
31 ctx: number
32 tokens: LiveTokens
33}
34
35// The orchestrator's turns that ended inside this session's run, plus what it is doing now.
36export type LiveOrchestrator = {
37 steps: number
38 calls: number
39 byTool: Record<string, number>
40 advisor: number
41 advisorMs: number
42 lastTool: string
43 lastAt: number
44 ctx: number
45 model: string
46 effort: string
47 tokens: LiveTokens
48}
49
50// The orchestrator's current turn, charged or dropped when it ends.
51export type LiveTurn = { steps: number; tokens: LiveTokens; advisor: number; advisorMs: number }
52
53// What .claude/state/ said at the last poll, as this session's run.
54export type LiveRun = {
55 phase: string
56 milestone: string
57 gate: string
58 gateAt: number
59 attempts: number
60 // what the session has cost, as /cost totals it; null where the host keeps no ledger
61 usd: number | null
62 // the checks the last gate line records as failed: `lint`, `test`, an unscoped check's field
63 failed: string[]
64 // planner and architect calls this run, against the cap opus-budget denies at
65 budgets: { role: string; used: number; cap: number }[]
66 // the checks whose own log says they stood down during this run
67 stoodDown: string[]
68 // by agent id: the agent gate's send-backs, the last one's reasons, and whether it then let the return through
69 sentBack: Record<string, { n: number; why: string; cap: boolean }>
70 // a log that is there and could not be read, and what that leaves unshown; a settings file none of whose settings apply
71 unread: string[]
72}
73
74declare module 'claude-code' {
75 interface PluginState {
76 'spec-flow': {
77 liveAgents: LiveAgent[]
78 liveOrchestrator: LiveOrchestrator
79 liveTurn: LiveTurn
80 liveRun2: Shaped<LiveRun>
81 liveClock: number
82 }
83 }
84}
85