SLOPSHOPPER

spec-flow

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 ->…

paneguardcommandstatusprocesstimer
★ 2v?MITupdated 2026-10-24IgnacioMarin402/spec-flow-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spec-flow
│ ┃ spec-flow ✕ › fix the failing auth test and add an audit log call │ ┃ spec ─ plan ─ review ─ implement ─ done │ ┃ gate: not run in this run ⏺ Read(src/auth.ts) │ ┃ ╭─────────────────────────────────────────── ⎿ Read 6 lines │ ┃ │ orchestrator ⏺ Update(src/auth.ts) │ ┃ │ idle: your turn, or the gate's ⎿ Added 2 lines, removed 1 line │ ┃ │ 0 requests · ctx 0 ⏺ Bash(bun test) │ ┃ │ 0 tools · 0 skills ⎿ 3 pass, 1 fail │ ┃ │ cache 0 · new 0 · out 0 │ ┃ ╰─────────────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ No spec-flow agent has been spawned in this │ ┃ session yet. ✻ Worked for 42s · done 4:20 PM │ ┃ run total 0 agents · 0 tools · 0 skills │ ┃ cache 0 · new 0 · out 0 › /spec-flow-live │ ┃ session cost: none reported ⎿ spec-flow: spec-flow live pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · spec-flow
spec ─ plan ─ review ─ implement ─ done gate: not run in this run ╭──────────────────────────────────────────────────────╮ │ orchestrator ? │ │ idle: your turn, or the gate's │ │ 0 requests · ctx 0 │ │ 0 tools · 0 skills │ │ cache 0 · new 0 · out 0 │ ╰──────────────────────────────────────────────────────╯ No spec-flow agent has been spawned in this session yet. run total 0 agents · 0 tools · 0 skills cache 0 · new 0 · out 0 session cost: none reported
README

spec-flow

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

What it looks like

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.

Requirements

  • Claude Code, opened at the root of the git repo you are changing, where you work on a branch off your base branch. A workspace of several repositories is opened one repository at a time (ADR-027)
  • A Node project with test and lint commands. Other stacks are not supported (ADR-007)
  • Node 22+ — a run refuses to start below it
  • Linux or Windows; CI runs both on Node 22 and 24 (ADR-019)

Install

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

Your first run

/spec-flow users can reset their password by email
  1. The run refuses to start if the contract does not load, the base branch does not resolve, or .claude/spec-flow.config.json holds a setting the hooks cannot apply.
  2. The spec-writer asks you questions if the requirement is ambiguous.
  3. You sign off on the requirement deltas and the decision. A "no" is archived with its reason.
  4. Plan, review, then one milestone at a time, each in a fresh implementer session that is told to write each requirement's test first.
  5. The gate runs when the turn ends. Red tells the orchestrator what to fix; green tells it to advance — so a run moves on its own.
  6. Fold: the change is verified against 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.

The agents

agentdoes
spec-writerWrites the spec, triages defects, folds shipped changes into specs/
plannerTurns the spec into milestones; escalation consultant
reviewerReads the plan against the spec before an implementer is spent
implementerImplements one milestone
architectConsulted 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).

How it works

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.

Developing the engine

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.

License

MIT — see LICENSE.

Source 7 files
hooks/live-view.tsx 806 lines
1/**
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}
806
scripts/agent-blocks.mjs 128 lines
1#!/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}
128
hooks/lib/budgets.mjs 35 lines
1/**
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}
35
hooks/lib/log-lines.mjs 58 lines
1/**
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`;
58
hooks/lib/milestone-ref.mjs 31 lines
1/**
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}
31
hooks/lib/project-config.mjs 34 lines
1/**
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}
34
hooks/live-view-state.d.ts 85 lines
1export 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