SLOPSHOPPER

spec-flow

Side pane for the idea-to-ship flow: stage, spec, tickets and what each agent is doing

newpaneguardcommandtoastprompt
v0.3.0MITupdated 2026-10-16bardaxx/modsbook/mods/spec-flow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · spec-flow
│ ┃ Flow ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─ modsbook │ ┃ ───────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ❯ spec-flow ▌ ⎿ Read 6 lines │ ┃ │ ⏺ Update(src/auth.ts) │ ┃ ╰────────────────────────────────── ⎿ Added 2 lines, removed 1 line │ ┃ mattpocock/skills ─╯ ⏺ Bash(bun test) │ ┃ ╭─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ │ ◆ flow no spec 0% │ ┃ │ /flow spec <n> to follow one ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ │ ┃ │ grill ▸ spec ▸ tickets ▸ implement ▸ revi ✻ Worked for 42s · done 4:20 PM │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭──────────────────────────────────────────╮ › /flow │ ┃ │ ◆ agents none implementing │ ⎿ spec-flow: Flow pane opened. │ ┃ │ No agents yet. │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ◆ tickets none yet │ │ ┃ │ │ │ ┃ │ │ │ ┃ ╰──────────────────────────────────────────╯ │ ┃ [ Refresh ] [ Clear ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Flow
╭─ modsbook ───────────────────────────────────────────╮ │ ❯ spec-flow ▌ │ ╰────────────────────────────────── mattpocock/skills ─╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ flow no spec 0% │ │ /flow spec <n> to follow one │ │ │ │ grill ▸ spec ▸ tickets ▸ implement ▸ review ▸ ship │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ agents none implementing │ │ No agents yet. │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ◆ tickets none yet │ │ │ ╰──────────────────────────────────────────────────────────╯ [ Refresh ] [ Clear ]
README

spec-flow

A Claude Code mod that docks a side pane beside the transcript and follows a spec from idea to merge: which stage the work is in, what each subagent is doing right now, and how far the tickets have come.

It is built around the idea-to-ship spine of AI Hero's skills: /grill-me → /to-spec → /to-tickets → /implement-spec → /code-review → PR.

╭─────────────────────────────────────────────╮
│ ◆ flow                         spec #237 40% │
│ Builder, track panel and Episodes            │
│ PR #243 · open · feat/builder-feedback  CI ✓ │
│                                              │
│ grill ▸ spec ▸ tickets ▸ implement ▸ review ▸ │
╰─────────────────────────────────────────────╯
╭─────────────────────────────────────────────╮
│ ◆ agents                   ■ ■ □ 2/3 slots   │
│ ● implementer #241 · Implement #241    ↻ 3m  │
│  Bash mise run lint                          │
│ ✓ merger #239 · Merged cleanly          12m  │
╰─────────────────────────────────────────────╯
╭─────────────────────────────────────────────╮
│ ◆ tickets                       2 of 5 done  │
│ ████████████████▓▓▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░░ │
│ ■ done 2  ■ in progress 1  ■ ready 1  ■ …    │
│                                              │
│ ■ #238 reopen an acknowledged warning merged │
│ ■ #241 Create episode opens…    implementer  │
│ ■ #240 a row preview replaces…   after #239  │
╰─────────────────────────────────────────────╯

What it shows

  • flow: the spec being followed, its integration PR with its CI state, and the stage. A skill that runs sets the stage; a spec followed with /flow spec <n> takes it from its tickets and PR.
  • agents: each subagent this session spawns, with its role (implementer, merger, reviewer, explorer, read from its description), the tickets it names, the tool it is running now and how long it has run. The slot meter counts running implementers against a cap: the slots setting, or else the cap the running implement skill states ("in 3 slots at most"). With neither, it counts them with no cap.
  • steps: press an agent's digit (1–9) and its card lays out what it did, newest last: each tool it ran with how long it took, ✗ and the error's first line where one failed, and the lines it wrote between tools (› The lint passes; now the tests.). o puts that agent's whole transcript in the main view, as the tasks list does, and back; x closes the card.
  • tickets: the spec's sub-issues as one bar split by state, a legend, and one row per ticket: closed, merged into the integration branch, being merged, implemented and waiting for a merge, in progress, ready (on the frontier), or blocked by the tickets it waits for.

Requirements

  • Claude Code with function-hook mods (2.1.292 or later).
  • The GitHub CLI, signed in, run from a checkout of the repository whose issues hold the spec.
  • Specs and tickets laid out the way /to-spec and /to-tickets publish them: tickets are sub-issues of the spec, their blockers either a ## Blocked by section or GitHub's native blocking links.

Install

At the prompt of a Claude Code terminal session:

/plugin install spec-flow --marketplace bardaxx/modsbook

Answer y to add the marketplace, then pick a scope (Enter takes the user scope).

To update it:

claude plugin update spec-flow@modsbook

The new version loads in the next session, or at once with /reload-plugins. To get updates without asking, turn on auto-update for modsbook in /plugin, under Marketplaces; it is off by default.

To run it from a clone while you change it:

claude --plugin-dir /path/to/modsbook/mods/spec-flow

Use

CommandWhat it does
/flowOpens the pane.
/flow spec <n>Follows spec issue #n and loads its tickets.
/flow refreshReads the spec, its tickets and its PR from GitHub again.
/flow clearForgets the spec, the agents and the stage.

Settings

SettingDefaultWhat it does
slots0How many implementers may run at once, drawn as the slot meter. 0 reads the cap from the implement skill's text (3 slots), or draws none when the skill states none.

Change it in /config, where it is one of the mod's rows.

In the pane, r refreshes, c clears, a digit focuses an agent's steps, o opens its transcript and x closes them. The pane opens by itself when a flow skill runs, as a sidebar from 144 terminal columns; below that, open it with /flow. It also refreshes every minute and whenever an implementer or a merger finishes.

The spec is picked up without a command too: from the argument of /implement-spec or /to-tickets, or from the issue /to-spec creates.

How it knows

It showsIt reads
stagethe skills that run (skill.prompt, the Skill tool, a typed /skill), else the tickets and the PR
agentsagent.spawn, each subagent's tool.call, and its turn.complete
stepseach subagent's tool.call and its result, and the text of its turn.steps
ticketsgh api …/issues/<spec>/sub_issues, and …/dependencies/blocked_by where the body names no blockers
mergedthe merge commits of the spec's PR that name a ticket (Merge #239: …, feat: merge … (#238))
PR and CIthe PR whose title or body names #<spec>, and its statusCheckRollup

Limits

  • A mod sees the subagents of its own session only. Run /implement-spec in the session that shows the pane, or the agents card and the slot meter stay empty; ticket progress still shows, since it comes from GitHub.
  • An agent's role and tickets are read from its Agent call: name the role and the #ticket in the description.

Develop

From this folder:

claude plugin validate .
claude plugin test .

Claude Code writes the API's types into .claude-plugin/types/ when it loads the mod, and tsconfig.json extends them, so tsc -p . type-checks it after the first load.

License

MIT, see the repository's license.

Source 3 files
hooks/register.tsx 899 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { AgentRow, Flow, IntegrationPr, Role, Stage, Step, Ticket } from '../types'
5import { header } from './header'
6
7const PANE = 'spec-flow'
8const TITLE = 'Flow'
9const POLL_MS = 60_000
10// The steps each agent keeps, and how many the focused card lays out.
11const STEPS_KEPT = 40
12const STEPS_SHOWN = 14
13
14// The deck palette: muted pills on a dark track, dark ink on the pills.
15const INK = '#151515'
16const TRACK = '#2b2b2b'
17const EDGE = '#4a4a4a'
18const ACCENT = '#d97757'
19const PAST_BG = '#1f3b31'
20type TicketState = 'done' | 'implemented' | 'merging' | 'working' | 'ready' | 'blocked' | 'failed'
21const STATE_COLOR: Record<TicketState, string> = {
22  done: '#5ccf9f',
23  implemented: '#7fb4f5',
24  merging: '#b79cf5',
25  working: ACCENT,
26  ready: '#e2b867',
27  blocked: '#5a5a5a',
28  failed: '#e5737b',
29}
30// Bar order, left to right: what is finished first, what waits last.
31const LEGEND: { state: TicketState; label: string }[] = [
32  { state: 'done', label: 'done' },
33  { state: 'implemented', label: 'to merge' },
34  { state: 'merging', label: 'merging' },
35  { state: 'working', label: 'in progress' },
36  { state: 'failed', label: 'failed' },
37  { state: 'ready', label: 'ready' },
38  { state: 'blocked', label: 'blocked' },
39]
40const CI_LABEL: Record<IntegrationPr['ci'], string> = {
41  pass: 'CI ✓',
42  fail: 'CI ✗',
43  running: 'CI …',
44  none: 'no CI',
45}
46const CI_COLOR: Record<IntegrationPr['ci'], string> = {
47  pass: STATE_COLOR.done,
48  fail: STATE_COLOR.failed,
49  running: STATE_COLOR.ready,
50  none: EDGE,
51}
52
53// The states someone is acting on get a pill; the rest stay plain text.
54const ACTIVE = new Set<TicketState>(['working', 'merging', 'implemented', 'failed'])
55const ROLE_COLOR: Record<Role, string> = {
56  implementer: ACCENT,
57  merger: STATE_COLOR.merging,
58  reviewer: STATE_COLOR.implemented,
59  explorer: STATE_COLOR.ready,
60  agent: '#9a9a9a',
61}
62
63const EMPTY: Flow = {
64  stage: null,
65  lastSkill: null,
66  spec: null,
67  pr: null,
68  integration: null,
69  slotCap: null,
70  tickets: [],
71  agents: [],
72  focus: null,
73  error: null,
74  refreshedAt: null,
75}
76const flow = atom({ plugin: 'spec-flow', key: 'flow' } as const, EMPTY)
77
78// A row an older version kept lacks the step log.
79const upgrade = (a: AgentRow): AgentRow => ({ ...a, steps: a.steps ?? [], tools: a.tools ?? 0, errors: a.errors ?? 0 })
80
81// State a session kept from an older version of the mod lacks the fields added since.
82const current = async ($: EngineInterface): Promise<Flow> => {
83  const f = { ...EMPTY, ...(await read($, flow)) }
84  return { ...f, agents: f.agents.map(upgrade) }
85}
86
87// Changes one agent's row, if the pane knows the agent.
88const withAgent = (f: Flow, id: string, change: (a: AgentRow) => AgentRow): Flow =>
89  f.agents.some(a => a.id === id)
90    ? { ...f, agents: f.agents.map(a => (a.id === id ? change(upgrade(a)) : a)) }
91    : f
92
93const firstLine = (text: string) => text.split('\n').find(line => line.trim())?.trim() ?? ''
94
95// The idea-to-ship spine (aihero.dev/skills) and the skills that move it.
96const STAGES: { stage: Stage; label: string }[] = [
97  { stage: 'grill', label: 'grill' },
98  { stage: 'spec', label: 'spec' },
99  { stage: 'tickets', label: 'tickets' },
100  { stage: 'implement', label: 'implement' },
101  { stage: 'review', label: 'review' },
102  { stage: 'ship', label: 'ship' },
103]
104const STAGE_OF: Record<string, Stage> = {
105  'grill-me': 'grill',
106  'grill-with-docs': 'grill',
107  grilling: 'grill',
108  wayfinder: 'grill',
109  'to-questionnaire': 'grill',
110  'to-spec': 'spec',
111  'to-tickets': 'tickets',
112  'implement-spec': 'implement',
113  implement: 'implement',
114  'code-review': 'review',
115  pr: 'ship',
116}
117// Skills worth naming in the pane though they do not move the spine.
118const SIDE_SKILLS = new Set([
119  'prototype',
120  'research',
121  'domain-modeling',
122  'diagnosing-bugs',
123  'improve-codebase-architecture',
124  'triage',
125  'tdd',
126  'codebase-design',
127])
128
129// A cap the running skill sets on parallel implementers: "in **3 slots** at most".
130const slotCapIn = (text: string): number | null => {
131  const n = Number(/\b(\d+)\**\s+slots?\b/i.exec(text)?.[1])
132  return n > 0 ? n : null
133}
134
135const issueIn = (text: string): number | null => {
136  const m = /issues\/(\d+)/.exec(text) ?? /#(\d+)/.exec(text) ?? /^\s*(\d+)\b/.exec(text)
137  return m ? Number(m[1]) : null
138}
139
140const refsIn = (text: string): number[] => {
141  const found = new Set<number>()
142  for (const m of text.matchAll(/(?:#|issues\/|ticket\s+)(\d+)/gi)) found.add(Number(m[1]))
143  return [...found]
144}
145
146const roleOf = (description: string, type: string, prompt: string): Role => {
147  const head = `${description} ${type}`
148  if (/merg/i.test(head)) return 'merger'
149  if (/review/i.test(head)) return 'reviewer'
150  if (/explor|research/i.test(head) || type === 'Explore') return 'explorer'
151  if (/implement|ticket|fix|#\d+/i.test(head) || /\btdd\b|implement/i.test(prompt.slice(0, 400)))
152    return 'implementer'
153  return 'agent'
154}
155
156const baseName = (path: string) => path.split('/').pop() ?? path
157
158const activityOf = (e: { tool: unknown }): string => {
159  const tool = String(e.tool)
160  const args = e as unknown as Record<string, unknown>
161  const field = (k: string) => (typeof args[k] === 'string' ? (args[k] as string) : '')
162  const detail =
163    tool === 'Bash'
164      ? field('description') || field('command')
165      : tool === 'Skill'
166        ? `/${field('skill')}`
167        : field('file_path')
168          ? baseName(field('file_path'))
169          : field('pattern') || field('description')
170  return `${tool} ${detail}`.trim().split('\n')[0] ?? tool
171}
172
173// "feat(web): Create episode…" reads as "Create episode…" in a narrow pane.
174const shortTitle = (title: string) => title.replace(/^\w+(\([^)]*\))?!?:\s*/, '')
175
176// A spec title's headline: what comes before its first colon or parenthesis.
177const headline = (title: string) => title.split(/:\s|\s\(/)[0]?.trim() || title
178
179const since = (from: number, to = Date.now()) => {
180  const s = Math.max(0, Math.round((to - from) / 1000))
181  if (s < 60) return `${s}s`
182  const m = Math.floor(s / 60)
183  return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60}m`
184}
185
186const gh = async ($: EngineInterface, args: string[]) => {
187  const run = await $.process.run(['gh', ...args], { timeoutMs: 20_000 })
188  if (run.exitCode !== 0) throw new Error(run.stderr.trim().split('\n')[0] || `gh ${args[0]} failed`)
189  return run.stdout
190}
191
192type GhPr = {
193  number: number
194  title: string
195  body: string
196  state: string
197  isDraft: boolean
198  headRefName: string
199}
200type GhCheck = { status: string | null; conclusion: string | null; state: string | null }
201
202// The spec's integration PR: the one whose title or body names the spec, open first.
203const findPr = async ($: EngineInterface, spec: number, known: number | null) => {
204  const prs = JSON.parse(
205    await gh($, [
206      'pr',
207      'list',
208      '--state',
209      'all',
210      '--search',
211      String(spec),
212      '--limit',
213      '20',
214      '--json',
215      'number,title,body,state,isDraft,headRefName',
216    ]),
217  ) as GhPr[]
218  const names = new RegExp(`#${spec}\\b`)
219  const mine = prs.filter(p => p.number === known || names.test(`${p.title}\n${p.body}`))
220  return mine.find(p => p.state === 'OPEN') ?? mine.find(p => p.state === 'MERGED') ?? mine[0]
221}
222
223const ciOf = (checks: GhCheck[]): IntegrationPr['ci'] => {
224  if (!checks.length) return 'none'
225  const outcome = (c: GhCheck) => (c.conclusion ?? c.state ?? '').toUpperCase()
226  if (checks.some(c => ['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED'].includes(outcome(c)))) return 'fail'
227  if (checks.some(c => (c.status && c.status !== 'COMPLETED') || outcome(c) === 'PENDING')) return 'running'
228  return 'pass'
229}
230
231// Tickets a merge commit of the PR names: "Merge #239: ..." or "feat: merge ... (#238)".
232const mergedTickets = async ($: EngineInterface, pr: number, tickets: Set<number>) => {
233  const lines = await gh($, [
234    'api',
235    `repos/{owner}/{repo}/pulls/${pr}/commits?per_page=100`,
236    '--paginate',
237    '--jq',
238    '.[] | [(.commit.message | split("\\n")[0]), (.parents | length)] | @json',
239  ])
240  const merged = new Set<number>()
241  for (const line of lines.split('\n').filter(Boolean)) {
242    const [subject, parents] = JSON.parse(line) as [string, number]
243    if (parents < 2 && !/\bmerge\b/i.test(subject)) continue
244    for (const n of refsIn(subject)) if (tickets.has(n)) merged.add(n)
245  }
246  return merged
247}
248
249type GhIssue = { number: number; title: string; state: string; body: string | null }
250
251// "## Blocked by" (GitHub tickets) or "**Blocked by:**" (local tickets); null when absent.
252const blockersInBody = (body: string): number[] | null => {
253  const m = /blocked by[:*\s]*([\s\S]*?)(?:\n#{1,6}\s|\n\*\*|$)/i.exec(body)
254  return m ? refsIn(m[1] ?? '') : null
255}
256
257const refresh = async ($: EngineInterface) => {
258  const { spec } = await current($)
259  if (!spec) return
260  try {
261    const head = JSON.parse(await gh($, ['issue', 'view', String(spec.number), '--json', 'title'])) as {
262      title: string
263    }
264    const subs = JSON.parse(
265      await gh($, ['api', `repos/{owner}/{repo}/issues/${spec.number}/sub_issues?per_page=100`]),
266    ) as GhIssue[]
267    const tickets: Ticket[] = []
268    for (const sub of subs) {
269      let blockedBy = blockersInBody(sub.body ?? '')
270      if (blockedBy === null) {
271        // to-tickets omits the section when it set native blocking edges.
272        try {
273          const deps = JSON.parse(
274            await gh($, ['api', `repos/{owner}/{repo}/issues/${sub.number}/dependencies/blocked_by`]),
275          ) as { number: number }[]
276          blockedBy = deps.map(d => d.number)
277        } catch {
278          blockedBy = []
279        }
280      }
281      tickets.push({
282        number: sub.number,
283        title: sub.title,
284        isClosed: sub.state === 'closed',
285        isMerged: false,
286        blockedBy,
287      })
288    }
289    tickets.sort((a, b) => a.number - b.number)
290
291    // Progress lives on GitHub, whichever session did the work.
292    let integration: IntegrationPr | null = null
293    const found = await findPr($, spec.number, (await current($)).pr)
294    if (found) {
295      const view = JSON.parse(
296        await gh($, ['pr', 'view', String(found.number), '--json', 'statusCheckRollup']),
297      ) as { statusCheckRollup: GhCheck[] }
298      integration = {
299        number: found.number,
300        branch: found.headRefName,
301        state:
302          found.state === 'MERGED'
303            ? 'merged'
304            : found.state === 'CLOSED'
305              ? 'closed'
306              : found.isDraft
307                ? 'draft'
308                : 'open',
309        ci: ciOf(view.statusCheckRollup),
310      }
311      const merged = await mergedTickets($, found.number, new Set(tickets.map(t => t.number)))
312      for (const t of tickets) t.isMerged = merged.has(t.number) || integration.state === 'merged'
313    }
314
315    await update($, flow, f => ({
316      ...f,
317      spec: f.spec && { ...f.spec, title: head.title },
318      pr: integration?.number ?? f.pr,
319      integration,
320      tickets,
321      error: null,
322      refreshedAt: Date.now(),
323    }))
324  } catch (err) {
325    await update($, flow, f => ({ ...f, error: String(err instanceof Error ? err.message : err) }))
326  }
327}
328
329const setSpec = async ($: EngineInterface, number: number) => {
330  await update($, flow, f =>
331    f.spec?.number === number
332      ? f
333      : { ...f, spec: { number, title: '' }, pr: null, integration: null, tickets: [], error: null },
334  )
335  await refresh($)
336}
337
338// Bookkeeping never fails the call it watches.
339const quietly = async (work: () => Promise<unknown>) => {
340  try {
341    await work()
342  } catch {
343    // the pane misses one update; the session goes on
344  }
345}
346
347// The agent's whole transcript in the main view, as the tasks list switches to it.
348const openView = async ($: EngineInterface, view: { agentId?: string }) => {
349  const moved = await $.ui.view(view)
350  if (moved.deny) $.ui.toast(`Cannot open the transcript: ${moved.deny}`)
351}
352
353const enterSkill = async ($: EngineInterface, skill: string, args: string) => {
354  const name = skill.replace(/^.*:/, '')
355  const stage = STAGE_OF[name]
356  if (!stage && !SIDE_SKILLS.has(name)) return
357  await update($, flow, f => ({ ...f, stage: stage ?? f.stage, lastSkill: name }))
358  if (stage) void $.ui.open({ id: PANE, title: TITLE })
359  const spec = issueIn(args)
360  if (spec !== null && (name === 'implement-spec' || name === 'to-tickets')) await setSpec($, spec)
361}
362
363export const register: Register = (on, options) => {
364  // The `slots` setting wins over a cap read from the skill; 0 leaves it to the skill.
365  const configuredCap = typeof options.slots === 'number' && options.slots > 0 ? options.slots : null
366
367  on('session.start', async ($, e, next) => {
368    await $.command.register({
369      name: 'flow',
370      description: 'Show the spec flow pane (/flow spec <n>, /flow refresh, /flow clear)',
371      argumentHint: '[spec <n> | refresh | clear]',
372    })
373    $.clock.every(POLL_MS, () => void refresh($))
374    const { spec, stage } = await current($)
375    if (spec || stage) void $.ui.open({ id: PANE, title: TITLE })
376
377    return next(e)
378  })
379
380  on('command.run', { command: 'flow' }, async ($, e) => {
381    const [verb = '', arg = ''] = e.args.trim().split(/\s+/)
382    if (verb === 'clear') {
383      await update($, flow, () => EMPTY)
384      return { text: 'Flow pane cleared.' }
385    }
386    if (verb === 'spec') {
387      const n = issueIn(arg)
388      if (n === null) return { text: 'Usage: /flow spec <issue number>' }
389      await $.ui.open({ id: PANE, title: TITLE })
390      await setSpec($, n)
391      return { text: `Following spec #${n}.` }
392    }
393    await $.ui.open({ id: PANE, title: TITLE })
394    if (verb === 'refresh') await refresh($)
395    return { text: 'Flow pane opened.' }
396  })
397
398  // A skill typed as /name args.
399  on('prompt.submit', async ($, e, next) => {
400    const m = /^\/([\w:-]+)\s*([\s\S]*)$/.exec(e.text.trim())
401    if (m) await quietly(() => enterSkill($, m[1] ?? '', m[2] ?? ''))
402    return next(e)
403  })
404
405  // Every skill the model or the person runs, whatever route it came by.
406  on('skill.prompt', async ($, e, next) => {
407    const args = /ARGUMENTS:\s*(.*)$/m.exec(e.text)?.[1] ?? ''
408    await quietly(() => enterSkill($, e.skill, args))
409    const cap = STAGE_OF[e.skill.replace(/^.*:/, '')] === 'implement' ? slotCapIn(e.text) : null
410    if (cap !== null) await quietly(() => update($, flow, f => ({ ...f, slotCap: cap })))
411    return next(e)
412  })
413
414  on('agent.spawn', async ($, e, next) => {
415    const started = await next(e)
416    if ('deny' in started && started.deny) return started
417    await quietly(async () => {
418      const id = started.agentId ?? e.tool_use_id
419      const spec = (await current($)).spec?.number
420      const row: AgentRow = {
421        id,
422        role: roleOf(e.description, e.subagentType, e.prompt),
423        description: e.description,
424        type: e.subagentType,
425        tickets: refsIn(`${e.description}\n${e.prompt}`)
426          .filter(n => n !== spec)
427          .slice(0, 4),
428        status: 'running',
429        activity: 'starting',
430        report: '',
431        startedAt: Date.now(),
432        steps: [],
433        tools: 0,
434        errors: 0,
435      }
436      await update($, flow, f => ({ ...f, agents: [...f.agents.filter(a => a.id !== id), row].slice(-30) }))
437    })
438
439    return started
440  })
441
442  on('turn.complete', async ($, e, next) => {
443    const done = await next(e)
444    const id = e.agentId
445    if (id)
446      await quietly(async () => {
447        const status: AgentRow['status'] = e.reason === 'answer' ? 'done' : 'failed'
448        const report =
449          e.answer
450            .trim()
451            .split('\n')
452            .find(line => line.trim()) ?? ''
453        await update($, flow, f => ({
454          ...f,
455          agents: f.agents.map(a => (a.id === id ? { ...a, status, report, endedAt: Date.now() } : a)),
456        }))
457        const row = (await current($)).agents.find(a => a.id === id)
458        if (row?.role === 'merger' || row?.role === 'implementer') void refresh($)
459      })
460
461    return done
462  })
463
464  // What an agent writes between its tools: why it runs the next one, what it found.
465  on('turn.step', async function* ($, e, next) {
466    const step = yield* next(e)
467    const id = e.agentId
468    const said = firstLine(step.answer)
469    if (id && said) {
470      const row: Step = {
471        kind: 'say',
472        id: `say-${step.turnId}-${step.index}`,
473        text: said.slice(0, 300),
474        status: 'ok',
475        at: Date.now(),
476      }
477      await quietly(() =>
478        update($, flow, f => withAgent(f, id, a => ({ ...a, steps: [...a.steps, row].slice(-STEPS_KEPT) }))),
479      )
480    }
481    return step
482  })
483
484  let calls = 0
485  on('tool.call', async ($, e, next) => {
486    const id = e.agentId
487    const stepId = e.tool_use_id ?? `call-${++calls}`
488    const at = Date.now()
489    if (id) {
490      const activity = activityOf(e)
491      const step: Step = { kind: 'tool', id: stepId, text: activity, status: 'running', at }
492      await quietly(() =>
493        update($, flow, f =>
494          withAgent(f, id, a => ({
495            ...a,
496            activity,
497            tools: a.tools + 1,
498            steps: [...a.steps, step].slice(-STEPS_KEPT),
499          })),
500        ),
501      )
502    }
503    const ran = await next(e)
504    if (id) {
505      const failure = 'deny' in ran && ran.deny ? ran.deny : ran.isError ? (ran.text ?? 'error') : null
506      const error = failure === null ? undefined : firstLine(failure).slice(0, 200)
507      await quietly(() =>
508        update($, flow, f =>
509          withAgent(f, id, a => ({
510            ...a,
511            errors: a.errors + (error === undefined ? 0 : 1),
512            steps: a.steps.map(s =>
513              s.id === stepId
514                ? { ...s, status: error === undefined ? ('ok' as const) : ('error' as const), ms: Date.now() - at, error }
515                : s,
516            ),
517          })),
518        ),
519      )
520    }
521    if (e.tool === 'Skill' && !id) await quietly(() => enterSkill($, e.skill, e.args ?? ''))
522    if (e.tool === 'Bash')
523      await quietly(async () => {
524        const out = `${JSON.stringify(ran.result ?? '')}`
525        const cmd = e.command
526        const pr = /pull\/(\d+)/.exec(out)?.[1] ?? /pr:wait\s+(\d+)/.exec(cmd)?.[1]
527        if (pr) await update($, flow, f => ({ ...f, pr: Number(pr) }))
528        if (/gh pr merge/.test(cmd)) await update($, flow, f => ({ ...f, stage: 'ship' as const }))
529        const { stage, spec } = await current($)
530        if (/gh issue create/.test(cmd)) {
531          const made = issueIn(out)
532          if (stage === 'spec' && made !== null) await setSpec($, made)
533          else if (spec) void refresh($)
534        }
535      })
536
537    return ran
538  })
539
540  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
541    const { Box, Text, Button } = $.ui.resolve(e)
542    const f = await current($)
543    const now = Date.now()
544    const inner = Math.max(20, e.props.bodyColumns - 4)
545    const merged = new Set(
546      f.agents.filter(a => a.role === 'merger' && a.status === 'done').flatMap(a => a.tickets),
547    )
548    const isDone = (n: number) =>
549      merged.has(n) || f.tickets.some(t => t.number === n && (t.isClosed || t.isMerged))
550    const stateOf = (t: Ticket): { state: TicketState; note: string } => {
551      if (t.isClosed) return { state: 'done', note: 'closed' }
552      if (t.isMerged || merged.has(t.number)) return { state: 'done', note: 'merged' }
553      const mine = f.agents.filter(a => a.tickets.includes(t.number))
554      const busy = mine.find(a => a.status === 'running')
555      if (busy?.role === 'merger') return { state: 'merging', note: 'merging' }
556      if (busy) return { state: 'working', note: busy.role }
557      if (mine.some(a => a.role === 'implementer' && a.status === 'done'))
558        return { state: 'implemented', note: 'to merge' }
559      if (mine.some(a => a.status === 'failed')) return { state: 'failed', note: 'failed' }
560      const open = t.blockedBy.filter(n => !isDone(n))
561      if (open.length) return { state: 'blocked', note: `after ${open.map(n => `#${n}`).join(' ')}` }
562      return { state: 'ready', note: 'ready' }
563    }
564    const tickets = f.tickets.map(t => ({ ...t, ...stateOf(t) }))
565    const count = (s: TicketState) => tickets.filter(t => t.state === s).length
566    const done = count('done')
567    const percent = tickets.length ? Math.round((done / tickets.length) * 100) : 0
568    // Followed with /flow spec, no skill set the stage: the tickets say where the work stands.
569    const stage: Stage | null =
570      f.stage ??
571      (f.integration?.state === 'merged'
572        ? 'ship'
573        : tickets.length
574          ? done === tickets.length
575            ? 'review'
576            : 'implement'
577          : f.spec
578            ? 'tickets'
579            : null)
580    const at = stage ? STAGES.findIndex(s => s.stage === stage) : -1
581
582    const running = f.agents.filter(a => a.status === 'running')
583    const finished = f.agents
584      .filter(a => a.status !== 'running')
585      .slice(-4)
586      .reverse()
587    const slots = running.filter(a => a.role === 'implementer').length
588    const cap = configuredCap ?? f.slotCap
589
590    const pill = (text: string, bg: string, fg = INK, bold = false) => (
591      <Text backgroundColor={bg} color={fg} bold={bold}>
592        {` ${text} `}
593      </Text>
594    )
595    const heading = (title: string, right?: RenderChildren) => (
596      <Box flexDirection="row" justifyContent="space-between">
597        <Text>
598          <Text color={ACCENT}>◆ </Text>
599          <Text bold>{title}</Text>
600        </Text>
601        {right}
602      </Box>
603    )
604    const card = (key: string, children: RenderChildren) => (
605      <Box key={key} flexDirection="column" borderStyle="round" borderColor={EDGE} paddingX={1}>
606        {children}
607      </Box>
608    )
609
610    // One cell row split by ticket state, like the context bar.
611    const bar = () => {
612      if (!tickets.length) return <Text backgroundColor={TRACK}>{' '.repeat(inner)}</Text>
613      let left = inner
614      const cells = LEGEND.map(({ state }, i) => {
615        const n = count(state)
616        const width = i === LEGEND.length - 1 ? left : Math.round((n / tickets.length) * inner)
617        left -= width
618        return { state, width: Math.max(0, width) }
619      })
620      return (
621        <Box flexDirection="row">
622          {cells
623            .filter(c => c.width > 0)
624            .map(c => (
625              <Text key={`bar-${c.state}`} backgroundColor={STATE_COLOR[c.state]}>
626                {' '.repeat(c.width)}
627              </Text>
628            ))}
629        </Box>
630      )
631    }
632
633    // A digit focuses an agent: its steps laid out in full below the list.
634    const listed = [...running, ...finished].slice(0, 9)
635    const focused = f.agents.find(a => a.id === f.focus)
636    const toggleFocus = (id: string) => update($, flow, g => ({ ...g, focus: g.focus === id ? null : id }))
637    const counts = (a: AgentRow) => (
638      <Text dimColor>
639        {a.tools} tools{a.errors ? <Text color={STATE_COLOR.failed}> · {a.errors} ✗</Text> : ''}
640      </Text>
641    )
642
643    const agentRow = (a: AgentRow) => {
644      const color = ROLE_COLOR[a.role]
645      const refs = a.tickets.map(n => `#${n}`).join(' ')
646      const age = since(a.startedAt, a.endedAt ?? now)
647      const label = ` ${a.activity.slice(0, inner - 3)}${a.activity.length > inner - 3 ? '…' : ''} `
648      const digit = String(listed.indexOf(a) + 1)
649      const isFocused = a.id === f.focus
650      if (a.status !== 'running') {
651        return (
652          <Box key={`agent-${a.id}`} flexDirection="row" justifyContent="space-between">
653            <Box flexShrink={1}>
654              <Button key={`focus-${a.id}`} plain hotkey={digit} onPress={() => toggleFocus(a.id)}>
655                <Text dimColor={!isFocused} bold={isFocused} wrap="truncate-end">
656                  <Text color={a.status === 'done' ? STATE_COLOR.done : STATE_COLOR.failed}>
657                    {a.status === 'done' ? '✓' : '✗'}{' '}
658                  </Text>
659                  {a.role}
660                  {refs ? ` ${refs}` : ''} · {a.report || a.description}
661                </Text>
662              </Button>
663            </Box>
664            <Text dimColor> {age}</Text>
665          </Box>
666        )
667      }
668      return (
669        <Box key={`agent-${a.id}`} flexDirection="column" marginBottom={1}>
670          <Box flexDirection="row" justifyContent="space-between">
671            <Box flexShrink={1}>
672              <Button key={`focus-${a.id}`} plain hotkey={digit} onPress={() => toggleFocus(a.id)}>
673                <Text wrap="truncate-end">
674                  <Text color={color}>{isFocused ? '◉ ' : '● '}</Text>
675                  <Text bold>{a.role}</Text>
676                  {refs ? <Text color={color}> {refs}</Text> : ''}
677                  <Text dimColor> · {a.description}</Text>
678                </Text>
679              </Button>
680            </Box>
681            <Text>
682              {counts(a)}
683              <Text dimColor> ↻ {age}</Text>
684            </Text>
685          </Box>
686          <Text>
687            <Text backgroundColor={color} color={INK}>
688              {label}
689            </Text>
690            <Text backgroundColor={TRACK}>{' '.repeat(inner - label.length)}</Text>
691          </Text>
692        </Box>
693      )
694    }
695
696    const STEP_MARK: Record<Step['status'], { mark: string; color: string }> = {
697      running: { mark: '●', color: ACCENT },
698      ok: { mark: '✓', color: STATE_COLOR.done },
699      error: { mark: '✗', color: STATE_COLOR.failed },
700    }
701    const stepRow = (s: Step) => {
702      if (s.kind === 'say')
703        return (
704          <Text key={`step-${s.id}`} dimColor italic wrap="truncate-end">
705            › {s.text}
706          </Text>
707        )
708      const { mark, color } = STEP_MARK[s.status]
709      return (
710        <Box key={`step-${s.id}`} flexDirection="column">
711          <Box flexDirection="row" justifyContent="space-between">
712            <Text wrap="truncate-end" bold={s.status === 'running'}>
713              <Text color={color}>{mark} </Text>
714              {s.text}
715            </Text>
716            <Text dimColor> {s.status === 'running' ? `↻ ${since(s.at, now)}` : since(s.at, s.at + (s.ms ?? 0))}</Text>
717          </Box>
718          {s.error && (
719            <Text color={STATE_COLOR.failed} dimColor wrap="truncate-end">
720              {'  '}└ {s.error}
721            </Text>
722          )}
723        </Box>
724      )
725    }
726    const isViewed = (a: AgentRow) => e.props.view.agentId === a.id
727    const stepsCard = (a: AgentRow) => {
728      const shown = a.steps.slice(-STEPS_SHOWN)
729      const hidden = a.steps.length - shown.length
730      return (
731        <Box flexDirection="column">
732          {heading(
733            `${a.role}${a.tickets.length ? ` ${a.tickets.map(n => `#${n}`).join(' ')}` : ''}`,
734            <Text>
735              {counts(a)}
736              <Text dimColor> · {since(a.startedAt, a.endedAt ?? now)}</Text>
737            </Text>,
738          )}
739          <Text dimColor wrap="truncate-end">
740            {a.description} · {a.type}
741          </Text>
742          <Text> </Text>
743          {a.steps.length === 0 && <Text dimColor>Nothing yet.</Text>}
744          {hidden > 0 && <Text dimColor>… {hidden} earlier steps in the transcript</Text>}
745          {shown.map(stepRow)}
746          {a.status !== 'running' && a.report && (
747            <Text wrap="truncate-end">
748              <Text color={a.status === 'done' ? STATE_COLOR.done : STATE_COLOR.failed}>⏎ </Text>
749              {a.report}
750            </Text>
751          )}
752          <Box flexDirection="row" columnGap={1} marginTop={1}>
753            {isViewed(a) ? (
754              <Button key="view" label="Main transcript" hotkey="o" onPress={() => openView($, {})} />
755            ) : (
756              <Button key="view" label="Open transcript" hotkey="o" onPress={() => openView($, { agentId: a.id })} />
757            )}
758            <Button key="unfocus" label="Close" hotkey="x" onPress={() => toggleFocus(a.id)} />
759          </Box>
760        </Box>
761      )
762    }
763
764    return (
765      <Box flexDirection="column">
766        {header({ Box, Text }, PANE, e.props.bodyColumns)}
767        {card(
768          'flow',
769          <Box flexDirection="column">
770            {heading(
771              'flow',
772              <Text>
773                <Text dimColor>{f.spec ? `spec #${f.spec.number}` : 'no spec'} </Text>
774                {pill(
775                  `${percent}%`,
776                  percent === 100 ? STATE_COLOR.done : TRACK,
777                  percent === 100 ? INK : 'white',
778                  true,
779                )}
780              </Text>,
781            )}
782            {f.spec?.title ? (
783              <Text bold wrap="truncate-end">
784                {headline(f.spec.title)}
785              </Text>
786            ) : (
787              !f.spec && <Text dimColor>/flow spec &lt;n&gt; to follow one</Text>
788            )}
789            {f.integration ? (
790              <Box flexDirection="row" justifyContent="space-between">
791                <Text dimColor wrap="truncate-end">
792                  PR #{f.integration.number} · {f.integration.state} · {f.integration.branch}
793                </Text>
794                {pill(CI_LABEL[f.integration.ci], CI_COLOR[f.integration.ci])}
795              </Box>
796            ) : (
797              f.pr !== null && <Text dimColor>PR #{f.pr}</Text>
798            )}
799            <Box flexDirection="row" flexWrap="wrap" marginTop={1}>
800              {STAGES.map((s, i) => (
801                <Text key={`stage-${s.stage}`}>
802                  {i > 0 ? <Text color={EDGE}>▸</Text> : ''}
803                  {i === at
804                    ? pill(s.label, ACCENT, INK, true)
805                    : i < at
806                      ? pill(s.label, PAST_BG, STATE_COLOR.done)
807                      : pill(s.label, TRACK, 'gray')}
808                </Text>
809              ))}
810            </Box>
811            {f.lastSkill && <Text dimColor>last skill /{f.lastSkill}</Text>}
812          </Box>,
813        )}
814
815        {card(
816          'agents',
817          <Box flexDirection="column">
818            {heading(
819              'agents',
820              <Text>
821                {Array.from({ length: cap ?? slots }, (_, i) => (
822                  <Text key={`slot-${i}`} color={i < slots ? ROLE_COLOR.implementer : EDGE}>
823                    {i < slots ? '■' : '□'}{' '}
824                  </Text>
825                ))}
826                <Text dimColor>
827                  {cap !== null
828                    ? `${slots}/${cap} slots`
829                    : slots
830                      ? `${slots} implementing`
831                      : 'none implementing'}
832                </Text>
833              </Text>,
834            )}
835            {running.length === 0 && finished.length === 0 && <Text dimColor>No agents yet.</Text>}
836            {running.length > 0 && <Text> </Text>}
837            {running.map(agentRow)}
838            {finished.map(agentRow)}
839            {listed.length > 0 && !focused && <Text dimColor>1–{listed.length} shows what an agent did</Text>}
840          </Box>,
841        )}
842
843        {focused && card('steps', stepsCard(focused))}
844
845        {card(
846          'tickets',
847          <Box flexDirection="column">
848            {heading(
849              'tickets',
850              <Text dimColor>{tickets.length ? `${done} of ${tickets.length} done` : 'none yet'}</Text>,
851            )}
852            {bar()}
853            <Box flexDirection="row" flexWrap="wrap" columnGap={2}>
854              {LEGEND.filter(l => count(l.state) > 0).map(l => (
855                <Text key={`legend-${l.state}`}>
856                  <Text color={STATE_COLOR[l.state]}>■ </Text>
857                  {l.label} <Text bold>{count(l.state)}</Text>
858                </Text>
859              ))}
860            </Box>
861            {tickets.length > 0 && <Text> </Text>}
862            {tickets.map(t => (
863              <Box key={`ticket-${t.number}`} flexDirection="row" justifyContent="space-between">
864                <Box flexShrink={1}>
865                  <Text wrap="truncate-end" dimColor={t.state === 'done' || t.state === 'blocked'}>
866                    <Text color={STATE_COLOR[t.state]}>■ </Text>
867                    <Text bold>#{t.number}</Text> {shortTitle(t.title)}
868                  </Text>
869                </Box>
870                <Box flexShrink={0} marginLeft={1}>
871                  {ACTIVE.has(t.state) ? (
872                    pill(t.note, STATE_COLOR[t.state])
873                  ) : (
874                    <Text
875                      color={t.state === 'blocked' ? undefined : STATE_COLOR[t.state]}
876                      dimColor={t.state !== 'ready'}
877                    >
878                      {t.note}
879                    </Text>
880                  )}
881                </Box>
882              </Box>
883            ))}
884          </Box>,
885        )}
886
887        {f.error && <Text color={STATE_COLOR.failed}> gh: {f.error}</Text>}
888        <Box flexDirection="row" justifyContent="space-between" paddingX={1}>
889          <Box flexDirection="row" columnGap={1}>
890            <Button key="refresh" label="Refresh" hotkey="r" onPress={() => refresh($)} />
891            <Button key="clear" label="Clear" hotkey="c" onPress={() => update($, flow, () => EMPTY)} />
892          </Box>
893          {f.refreshedAt !== null && <Text dimColor>↻ {since(f.refreshedAt, now)} ago</Text>}
894        </Box>
895      </Box>
896    )
897  })
898}
899
hooks/header.tsx 47 lines
1// The head every modsbook pane opens with: a terminal panel, its titles set in the border as lazygit and tmux
2// set theirs, a prompt naming the mod, and a nod to mattpocock/skills, the skills the mods follow.
3// The same in each mod, only the name changes. One identical copy in each mod; scripts/check.sh fails when they differ.
4
5import type { BoxProps, ElementConstructor, TextProps } from 'claude-code'
6
7export type HeaderElements = {
8  Box: ElementConstructor<BoxProps>
9  Text: ElementConstructor<TextProps>
10}
11
12const EDGE = '#4a4a4a'
13const ACCENT = '#d97757'
14const TOP = 'modsbook'
15const NOD = 'mattpocock/skills'
16
17/** Three rows drawn to the pane's width: `modsbook` in the top border, `❯ <name> ▌`, the nod in the bottom one. */
18export const header = ({ Box, Text }: HeaderElements, name: string, columns: number) => {
19  const width = Math.max(TOP.length + NOD.length + 10, columns)
20  const rule = (n: number) => '─'.repeat(Math.max(0, n))
21  const room = width - 4
22  const prompt = name.length + 4 > room ? `${name.slice(0, Math.max(1, room - 5))}…` : name
23  return (
24    <Box key="header" flexDirection="column">
25      <Text>
26        <Text color={EDGE}>╭─ </Text>
27        <Text dimColor>{TOP}</Text>
28        <Text color={EDGE}>{` ${rule(width - TOP.length - 5)}╮`}</Text>
29      </Text>
30      <Text>
31        <Text color={EDGE}>│ </Text>
32        <Text color={ACCENT}>❯ </Text>
33        <Text bold>{prompt}</Text>
34        <Text color={ACCENT}> ▌</Text>
35        <Text color={EDGE}>{`${' '.repeat(Math.max(0, room - prompt.length - 4))} │`}</Text>
36      </Text>
37      <Text>
38        <Text color={EDGE}>{`╰${rule(width - NOD.length - 5)} `}</Text>
39        <Text dimColor italic>
40          {NOD}
41        </Text>
42        <Text color={EDGE}> ─╯</Text>
43      </Text>
44    </Box>
45  )
46}
47
types/index.d.ts 71 lines
1export type Stage = 'grill' | 'spec' | 'tickets' | 'implement' | 'review' | 'ship'
2
3export type Role = 'implementer' | 'merger' | 'reviewer' | 'explorer' | 'agent'
4
5/** One thing an agent did: a tool it ran, or a line it wrote between tools. */
6export type Step = {
7  kind: 'tool' | 'say'
8  id: string
9  text: string
10  status: 'running' | 'ok' | 'error'
11  at: number
12  ms?: number
13  /** The first line of the error the tool answered. */
14  error?: string
15}
16
17export type AgentRow = {
18  id: string
19  role: Role
20  description: string
21  type: string
22  tickets: number[]
23  status: 'running' | 'done' | 'failed'
24  activity: string
25  report: string
26  startedAt: number
27  endedAt?: number
28  /** The newest steps, oldest first. */
29  steps: Step[]
30  tools: number
31  errors: number
32}
33
34export type Ticket = {
35  number: number
36  title: string
37  isClosed: boolean
38  /** Merged into the integration branch: a merge commit of the spec's PR names it. */
39  isMerged: boolean
40  blockedBy: number[]
41}
42
43export type IntegrationPr = {
44  number: number
45  branch: string
46  state: 'open' | 'draft' | 'merged' | 'closed'
47  ci: 'pass' | 'fail' | 'running' | 'none'
48}
49
50export type Flow = {
51  stage: Stage | null
52  lastSkill: string | null
53  spec: { number: number; title: string } | null
54  pr: number | null
55  integration: IntegrationPr | null
56  /** The cap on parallel implementers the running skill states, if it states one. */
57  slotCap: number | null
58  tickets: Ticket[]
59  agents: AgentRow[]
60  /** The agent whose steps the pane lays out in full. */
61  focus: string | null
62  error: string | null
63  refreshedAt: number | null
64}
65
66declare module 'claude-code' {
67  interface PluginState {
68    'spec-flow': { flow: Flow }
69  }
70}
71