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

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 │
╰─────────────────────────────────────────────╯
/flow spec <n> takes it from its tickets and PR.slots setting, or else the cap the running implement skill states ("in 3 slots at most"). With neither, it counts them with no cap.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./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.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
| Command | What it does |
|---|---|
/flow | Opens the pane. |
/flow spec <n> | Follows spec issue #n and loads its tickets. |
/flow refresh | Reads the spec, its tickets and its PR from GitHub again. |
/flow clear | Forgets the spec, the agents and the stage. |
| Setting | Default | What it does |
|---|---|---|
slots | 0 | How 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.
| It shows | It reads |
|---|---|
| stage | the skills that run (skill.prompt, the Skill tool, a typed /skill), else the tickets and the PR |
| agents | agent.spawn, each subagent's tool.call, and its turn.complete |
| steps | each subagent's tool.call and its result, and the text of its turn.steps |
| tickets | gh api …/issues/<spec>/sub_issues, and …/dependencies/blocked_by where the body names no blockers |
| merged | the merge commits of the spec's PR that name a ticket (Merge #239: …, feat: merge … (#238)) |
| PR and CI | the PR whose title or body names #<spec>, and its statusCheckRollup |
/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.#ticket in the description.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.
MIT, see the repository's license.
hooks/register.tsx 899 lines1import { 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 <n> 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}
899hooks/header.tsx 47 lines1// 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}
47types/index.d.ts 71 lines1export 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