One docked pane showing how a jplugin session is going: the workflow phase and /go route, slice progress from tasks/todo.md, the sub-agent tree with a…

One docked pane, titled Session, that shows how a jplugin session is going. It observes and never intervenes: no hook denies, rewrites or answers for the engine. It supersedes mods/factory-board/, whose sub-agent Kanban it absorbs into its Agents section.
PHASE AGENT
BUILD · build code-reviewer · review slice 3
route feature · plan, build requested sonnet · resolved claude-sonnet-5-5
spec specs/session-viz-mod.md parent claude-opus-5-5 · under agent-7
✓ answer · 12s · 14 tool calls · 3 steps · 9 messages
PLAN read [a.md, b.ts] · edited [c.ts] · grepped [foo in src]
session-viz-mod · slice 1/5 · rows 3/11 commands [npm test]
1/5 Shell, phase and status line · rows 3/3 · ✓ handover
2/5 Agents tree and drill-down · rows 0/3
…
AGENTS
◐ build general-purpose · slice 2 agents
✓ review code-reviewer · review slice 1 · 12s
GATES
receipt GO 3f9a1c2e · tests exit 0 · unresolved 0
MUST-FIX 0 · SHOULD-FIX 1 · NITPICK 2
PLAN, BUILD, GATE, WRAP-UP or idle from the skill running now (skill.prompt), plus the /go route when a [ROUTE] line was printed and the spec the build prompt named.## Plan: block of tasks/todo.md: slices closed over total (a slice is closed once its > Handover: exists), rows done over total, one line per slice.◐ running, ✓ answered, ✗ failed or killed, ⚑ waiting on a dialog or with [AMBIGUITY] lines), the factory stage, the type, the description, the status or duration. Rows are buttons.Verdict: line, finding counts by severity, every [AMBIGUITY] and [SURFACE] line, and the dispatch checks (off-tier, missing contract items).From 120 body columns the drill-down is a right column beside the four sections; below that it is the last section. Every section shows one dim line when it has nothing.
The status line reads viz · <PHASE> · <skill> · slice k/N · ctx NN% · $X.XX · agents R/T, every unknown field left out.
Three toasts, and no others: session-viz: quality gate STOP (<fp8>), session-viz: <type> dispatched on <model> (<tier>); model-routing says <tier> and session-viz: <type> dispatch missing contract item(s) <n, …>. HOLD never toasts.
.claude-plugin/plugin.json manifest (name, version, the state contract)
hooks/hooks.json names the one hooks module
hooks/register.tsx the shell: session.start, /session-viz, the Pane render composing the sections
hooks/state.ts every $.state atom, the status-line builder and shared helpers
hooks/phase.tsx the skill-to-phase map, the [ROUTE] parser, the recorded spec
hooks/plan.tsx the tasks/todo.md plan-block parser and re-read triggers
hooks/agents.tsx the sub-agent tree, footprints and drill-down
hooks/gates.tsx findings, ambiguities, surfaces, the receipt reader, the STOP toast
hooks/dispatch.tsx the off-tier and contract-item checks on agent.spawn
hooks/*.test.ts claude plugin test, one file per section
types/index.d.ts the $.state contract
Everything a drawing or a toast decision reads lives in $.state, so a hot reload keeps the pane and repeats no toast; nothing is kept in $.store.
Needs Claude Code 2.1.299 or later (claude --version).
Installed from the repository's marketplace, the pane opens in every session:
claude plugin marketplace update jplugin-agentic-development
claude plugin install session-viz@jplugin-agentic-development
For one session from a checkout, without installing:
claude --plugin-dir "$(pwd)/mods/session-viz"
Or copy the folder into the session's ~/.claude/dev-mods/<session>/ folder while the plugin-authoring skill is loaded and accept "Enable hot reloading"; the repository folder stays the source of truth.
claude plugin validate mods/session-viz
claude plugin test mods/session-vizhooks/register.tsx 97 lines1import { atom, read } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { AgentsSection, DrillDown, registerAgents } from './agents'
5import { DispatchIssues, registerDispatch } from './dispatch'
6import { GatesSection, registerGates } from './gates'
7import { PhaseSection, registerPhase } from './phase'
8import { PlanSection, registerPlan } from './plan'
9import {
10 INITIAL_AGENTS,
11 INITIAL_ASKING,
12 INITIAL_PHASE,
13 INITIAL_PLAN,
14 INITIAL_SELECTED,
15 PANE,
16 TITLE,
17 WIDE_COLUMNS,
18 emptyGates,
19 refreshStatus,
20 updateStatus,
21} from './state'
22import type { Draw } from './state'
23
24const phase = atom({ plugin: 'session-viz', key: 'phase' } as const, INITIAL_PHASE)
25const agents = atom({ plugin: 'session-viz', key: 'agents' } as const, INITIAL_AGENTS)
26const asking = atom({ plugin: 'session-viz', key: 'asking' } as const, INITIAL_ASKING)
27const selected = atom({ plugin: 'session-viz', key: 'selected' } as const, INITIAL_SELECTED)
28const plan = atom({ plugin: 'session-viz', key: 'plan' } as const, INITIAL_PLAN)
29const gates = atom({ plugin: 'session-viz', key: 'gates' } as const, emptyGates())
30
31export const register: Register = on => {
32 on('session.start', async ($, e, next) => {
33 await $.command.register({
34 name: 'session-viz',
35 description: 'Show how this jplugin session is going: phase, plan, sub-agents and quality gates',
36 })
37 void $.ui.open({ id: PANE, title: TITLE })
38 await refreshStatus($)
39
40 return next(e)
41 })
42
43 on('command.run', { command: 'session-viz' }, async $ => {
44 await $.ui.open({ id: PANE, title: TITLE, focus: true })
45
46 return { text: 'Session pane opened.' }
47 })
48
49 on('session.measure', async ($, e, next) => {
50 await updateStatus($, 'measure', current => ({
51 percent: e.context.percent ?? current.percent,
52 usd: e.cost?.usd ?? current.usd,
53 }))
54
55 return next(e)
56 })
57
58 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
59 const t = $.ui.resolve(e)
60 const { Box } = t
61 const [ph, pl, ag, open, picked, ga] = await Promise.all([
62 read($, phase),
63 read($, plan),
64 read($, agents),
65 read($, asking),
66 read($, selected),
67 read($, gates),
68 ])
69 const width = e.props.bodyColumns
70 const isWide = width >= WIDE_COLUMNS
71 const leftWidth = isWide ? Math.floor(width / 2) : width
72 const left: Draw = { t, width: Math.max(1, leftWidth - 1), view: e.props.view.agentId }
73 const right: Draw = { ...left, width: Math.max(1, isWide ? width - leftWidth : width) }
74
75 return (
76 <Box flexDirection={isWide ? 'row' : 'column'}>
77 <Box flexDirection="column" width={leftWidth} paddingRight={isWide ? 1 : 0}>
78 {PhaseSection(left, ph)}
79 {PlanSection(left, pl)}
80 {AgentsSection($, left, ag, open, picked)}
81 {GatesSection(left, ga)}
82 {DispatchIssues(left, ga.dispatch)}
83 </Box>
84 <Box flexDirection="column" width={right.width}>
85 {DrillDown(right, ag, picked)}
86 </Box>
87 </Box>
88 )
89 })
90
91 registerPhase(on)
92 registerPlan(on)
93 registerAgents(on)
94 registerGates(on)
95 registerDispatch(on)
96}
97hooks/agents.tsx 369 lines1import { atom, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, On, RenderElement } from 'claude-code'
3
4import type { AgentRow, Footprint, FootprintList } from '../types'
5import { EMPTY_FINDINGS, EVERY, INITIAL_AGENTS, INITIAL_ASKING, INITIAL_SELECTED, MAIN, fit, seconds, updateStatus } from './state'
6import type { Draw } from './state'
7
8const agents = atom({ plugin: 'session-viz', key: 'agents' } as const, INITIAL_AGENTS)
9const asking = atom({ plugin: 'session-viz', key: 'asking' } as const, INITIAL_ASKING)
10const selected = atom({ plugin: 'session-viz', key: 'selected' } as const, INITIAL_SELECTED)
11
12const SYNC_MS = 5000
13const MAX_AGENTS = 200
14const MAX_ENTRIES = 40
15const MAX_COMMAND = 80
16const MAX_PROMPT = 2000
17const UNKNOWN_PROMPT = '(spawned before session-viz loaded)'
18
19const LISTS: readonly FootprintList[] = ['read', 'edited', 'grepped', 'commands']
20
21/** Which factory stage an agent definition works in, by its name (factory-board's derivation). */
22function stageOfType(type: string): 'plan' | 'build' | 'review' {
23 const name = type.toLowerCase()
24 if (/review|critic|security|validator/.test(name)) return 'review'
25 if (/plan|explore|scout|grill/.test(name)) return 'plan'
26 return 'build'
27}
28
29function statusOf(engineStatus: AgentInfo['status']): AgentRow['status'] {
30 switch (engineStatus) {
31 case 'pending':
32 case 'running':
33 case 'waiting':
34 case 'idle':
35 return 'running'
36 case 'completed':
37 return 'done'
38 case 'failed':
39 case 'killed':
40 return 'failed'
41 default:
42 return engineStatus satisfies never
43 }
44}
45
46function emptyFootprint(): Footprint {
47 return { read: [], edited: [], grepped: [], commands: [], toolCalls: 0, steps: 0, messages: 0 }
48}
49
50/** A string field of a tool call, read by name; undefined when absent or not a string. */
51function field(call: object, name: string): string | undefined {
52 const value = (call as Record<string, unknown>)[name]
53 return typeof value === 'string' ? value : undefined
54}
55
56/** Which footprint list a tool call lands in, and what it records there. */
57function footprintEntry(call: object, tool: string): { list: FootprintList; entry: string } | undefined {
58 switch (tool) {
59 case 'Read':
60 case 'Glob': {
61 const entry = field(call, 'file_path') ?? field(call, 'pattern')
62 return entry === undefined ? undefined : { list: 'read', entry }
63 }
64 case 'Edit':
65 case 'Write':
66 case 'MultiEdit':
67 case 'NotebookEdit': {
68 const entry = field(call, 'file_path') ?? field(call, 'notebook_path')
69 return entry === undefined ? undefined : { list: 'edited', entry }
70 }
71 case 'Grep': {
72 const pattern = field(call, 'pattern')
73 if (pattern === undefined) return undefined
74 const path = field(call, 'path')
75 return { list: 'grepped', entry: path === undefined ? pattern : `${pattern} in ${path}` }
76 }
77 case 'Bash': {
78 const command = field(call, 'command')
79 return command === undefined ? undefined : { list: 'commands', entry: command.slice(0, MAX_COMMAND) }
80 }
81 default:
82 return undefined
83 }
84}
85
86/** Counts one tool call and adds its entry, keeping the newest MAX_ENTRIES and counting what fell off. */
87function withEntry(row: AgentRow, found: { list: FootprintList; entry: string } | undefined): AgentRow {
88 const footprint: Footprint = { ...row.footprint, toolCalls: row.footprint.toolCalls + 1 }
89 if (found === undefined) return { ...row, footprint }
90 const grown = [...footprint[found.list], found.entry]
91 const overflow = Math.max(0, grown.length - MAX_ENTRIES)
92 footprint[found.list] = grown.slice(overflow)
93 if (overflow > 0) footprint.dropped = { ...footprint.dropped, [found.list]: (footprint.dropped?.[found.list] ?? 0) + overflow }
94 return { ...row, footprint }
95}
96
97/** Keeps at most MAX_AGENTS rows: the oldest finished rows leave first, then the oldest running. */
98function capped(rows: AgentRow[]): AgentRow[] {
99 const excess = rows.length - MAX_AGENTS
100 if (excess <= 0) return rows
101 const order = [...rows.filter(row => row.status !== 'running'), ...rows.filter(row => row.status === 'running')]
102 const leaving = new Set(order.slice(0, excess))
103 return rows.filter(row => !leaving.has(row))
104}
105
106/**
107 * Folds the engine's own agent list into the rows: new agents join with an empty footprint and a
108 * running row follows the engine. A row already done or failed keeps what turn.complete recorded.
109 * Returns `known` itself when nothing changed.
110 */
111function merge(known: AgentRow[], live: AgentInfo[]): AgentRow[] {
112 let changed = false
113 const next = known.map(row => {
114 const agent = live.find(one => one.id === row.agentId)
115 if (agent === undefined || row.status !== 'running') return row
116 const status = statusOf(agent.status)
117 if (status === row.status) return row
118 changed = true
119 return { ...row, status, outcome: agent.status }
120 })
121 for (const agent of live) {
122 if (next.some(row => row.agentId === agent.id)) continue
123 changed = true
124 const status = statusOf(agent.status)
125 next.push({
126 agentId: agent.id,
127 type: agent.type,
128 description: agent.description,
129 prompt: UNKNOWN_PROMPT,
130 parentAgentId: agent.parentId,
131 status,
132 outcome: status === 'running' ? undefined : agent.status,
133 ambiguities: [],
134 surfaces: [],
135 findings: { ...EMPTY_FINDINGS },
136 footprint: emptyFootprint(),
137 })
138 }
139 return changed ? capped(next) : known
140}
141
142/** Applies `change` to the row of `agentId`, if it is tracked. */
143function updateRow(agentId: string, change: (row: AgentRow) => AgentRow): (list: AgentRow[]) => AgentRow[] {
144 return list => list.map(row => (row.agentId === agentId ? change(row) : row))
145}
146
147export function registerAgents(on: On): void {
148 on('session.start', { cwd: EVERY }, async ($, e, next) => {
149 const sync = async () => {
150 const live = await $.agent.list()
151 await updateStatus($, 'agents', known => merge(known, live))
152 }
153 await sync()
154 $.clock.every(SYNC_MS, () => {
155 sync().catch((reason: unknown) => $.ui.log(`agent sync failed: ${String(reason)}`, { to: 'debug' }))
156 })
157
158 return next(e)
159 })
160
161 on('agent.spawn', async ($, e, next) => {
162 const started = await next(e)
163 if (started.agentId === undefined) return started
164 const row: AgentRow = {
165 agentId: started.agentId,
166 toolUseId: e.tool_use_id,
167 type: e.subagentType,
168 description: e.description,
169 prompt: e.prompt,
170 model: e.model,
171 resolvedModel: started.model,
172 parentModel: e.parentModel,
173 parentAgentId: e.parentAgentId,
174 status: 'running',
175 ambiguities: [],
176 surfaces: [],
177 findings: { ...EMPTY_FINDINGS },
178 footprint: emptyFootprint(),
179 }
180 await updateStatus($, 'agents', list => capped([...list.filter(one => one.agentId !== row.agentId), row]))
181
182 return started
183 })
184
185 // A streaming event: the hook is the generator, `yield* next(e)` forwards the response.
186 on('turn.step', { agentId: /./ }, async function* ($, e, next) {
187 if (e.agentId !== undefined) {
188 const { index, messageCount } = e
189 await update(
190 $,
191 agents,
192 updateRow(e.agentId, row => ({
193 ...row,
194 footprint: { ...row.footprint, steps: Math.max(row.footprint.steps, index + 1), messages: messageCount },
195 })),
196 )
197 }
198
199 return yield* next(e)
200 })
201
202 on('turn.complete', { agentId: /./ }, async ($, e, next) => {
203 if (e.agentId !== undefined) {
204 const status: AgentRow['status'] = e.reason === 'answer' ? 'done' : 'failed'
205 await updateStatus(
206 $,
207 'agents',
208 updateRow(e.agentId, row => ({ ...row, status, outcome: e.reason, durationMs: e.durationMs })),
209 )
210 }
211
212 return next(e)
213 })
214
215 on('tool.call', { agentId: /./ }, async ($, e, next) => {
216 if (e.agentId !== undefined) {
217 const found = footprintEntry(e, String(e.tool))
218 await update($, agents, updateRow(e.agentId, row => withEntry(row, found)))
219 }
220
221 return next(e)
222 })
223
224 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
225 const who = e.agentId ?? MAIN
226 await update($, asking, list => (list.includes(who) ? list : [...list, who]))
227 try {
228 return await next(e)
229 } finally {
230 await update($, asking, list => list.filter(one => one !== who))
231 }
232 })
233}
234
235function glyph(row: AgentRow, isAsking: boolean): string {
236 if (isAsking) return '⚑'
237 if (row.status === 'running') return '◐'
238 if (row.status === 'failed') return '✗'
239 return row.ambiguities.length > 0 ? '⚑' : '✓'
240}
241
242/** `running`, `12s`, `killed 3s`: what the row's tail says. */
243function tail(row: AgentRow): string {
244 if (row.status === 'running') return 'running'
245 const took = seconds(row.durationMs)
246 if (row.status === 'done') return took === '' ? 'done' : took
247 return [row.outcome ?? 'failed', took].filter(part => part !== '').join(' ')
248}
249
250/** The rows depth-first: roots in spawn order, each followed by its children; an unknown parent makes a root. */
251function inTreeOrder(rows: AgentRow[]): Array<{ row: AgentRow; depth: number }> {
252 const known = new Set(rows.map(row => row.agentId))
253 const ordered: Array<{ row: AgentRow; depth: number }> = []
254 const seen = new Set<string>()
255 const visit = (row: AgentRow, depth: number): void => {
256 if (seen.has(row.agentId)) return
257 seen.add(row.agentId)
258 ordered.push({ row, depth })
259 for (const child of rows) if (child.parentAgentId === row.agentId) visit(child, depth + 1)
260 }
261 for (const row of rows) {
262 if (row.parentAgentId === undefined || row.parentAgentId === row.agentId || !known.has(row.parentAgentId)) visit(row, 0)
263 }
264 for (const row of rows) visit(row, 0)
265 return ordered
266}
267
268export function AgentsSection($: EngineInterface, d: Draw, rows: AgentRow[], open: string[], picked: string | null): RenderElement {
269 const { Box, Text, Button } = d.t
270 if (rows.length === 0) {
271 return (
272 <Box flexDirection="column" marginBottom={1}>
273 <Text bold>AGENTS</Text>
274 <Text dimColor>no sub-agents yet</Text>
275 </Box>
276 )
277 }
278 return (
279 <Box flexDirection="column" marginBottom={1}>
280 <Text bold>AGENTS</Text>
281 {inTreeOrder(rows).map(({ row, depth }) => {
282 const indent = ' '.repeat(depth)
283 const mark = row.agentId === d.view ? '▶ ' : ''
284 const text = `${glyph(row, open.includes(row.agentId))} ${mark}${stageOfType(row.type)} ${row.type} · ${row.description} · ${tail(row)}`
285 return (
286 <Button
287 key={`agent:${row.agentId}`}
288 plain
289 variant={row.agentId === picked ? 'primary' : undefined}
290 label={indent + fit(text, d.width - indent.length)}
291 onPress={() => {
292 // Select it (again to clear) and bring its spawning row into view.
293 void update($, selected, current => (current === row.agentId ? null : row.agentId))
294 if (row.toolUseId !== undefined) {
295 $.ui
296 .scroll({ to: { requestId: row.toolUseId }, block: 'center' })
297 .catch((reason: unknown) => $.ui.log(`scroll to ${row.toolUseId} failed: ${String(reason)}`, { to: 'debug' }))
298 }
299 }}
300 />
301 )
302 })}
303 </Box>
304 )
305}
306
307/** `read [a.md, b.md] +2 more`, cut to `width` before the `+n more` so the count stays visible; undefined for an empty list. */
308function listLine(footprint: Footprint, list: FootprintList, width: number): string | undefined {
309 const entries = footprint[list]
310 if (entries.length === 0) return undefined
311 const more = footprint.dropped?.[list] ?? 0
312 const suffix = more > 0 ? ` +${more} more` : ''
313 return fit(`${list} [${entries.join(', ')}]`, width - suffix.length) + suffix
314}
315
316/** The spawn prompt's first MAX_PROMPT characters as lines, then `… +n chars` when it was longer. */
317function promptLines(prompt: string): string[] {
318 const lines = prompt.slice(0, MAX_PROMPT).split('\n').filter(line => line.trim() !== '')
319 return prompt.length > MAX_PROMPT ? [...lines, `… +${prompt.length - MAX_PROMPT} chars`] : lines
320}
321
322function detailLines(row: AgentRow, width: number): string[] {
323 const { footprint } = row
324 const counts = Object.entries(row.findings)
325 .filter(([, count]) => count > 0)
326 .map(([severity, count]) => `${severity} ${count}`)
327 return [
328 `${row.type} · ${row.description}`,
329 `requested ${row.model ?? 'inherit'} · resolved ${row.resolvedModel ?? '?'}`,
330 `parent ${row.parentModel ?? '?'} · under ${row.parentAgentId ?? 'main'}`,
331 [
332 row.status,
333 row.outcome,
334 seconds(row.durationMs),
335 `${footprint.toolCalls} tool calls`,
336 `${footprint.steps} steps`,
337 `${footprint.messages} messages`,
338 ]
339 .filter(part => part !== undefined && part !== '')
340 .join(' · '),
341 ...promptLines(row.prompt),
342 ...LISTS.map(list => listLine(footprint, list, width)).filter((line): line is string => line !== undefined),
343 ...row.ambiguities,
344 ...row.surfaces,
345 ...(counts.length === 0 ? [] : [`findings ${counts.join(' · ')}`]),
346 ]
347}
348
349export function DrillDown(d: Draw, rows: AgentRow[], picked: string | null): RenderElement {
350 const { Box, Text } = d.t
351 const row = picked === null ? undefined : rows.find(one => one.agentId === picked)
352 if (row === undefined) {
353 return (
354 <Box flexDirection="column">
355 <Text bold>AGENT</Text>
356 <Text dimColor>no agent selected</Text>
357 </Box>
358 )
359 }
360 return (
361 <Box flexDirection="column">
362 <Text bold>AGENT</Text>
363 {detailLines(row, d.width).map(line => (
364 <Text wrap="truncate-end">{fit(line, d.width)}</Text>
365 ))}
366 </Box>
367 )
368}
369hooks/dispatch.tsx 112 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement } from 'claude-code'
3
4import type { DispatchIssue } from '../types'
5import { GATES_KEPT, SPEC_PATH, emptyGates, fit, lastSegment } from './state'
6import type { Draw } from './state'
7
8const gates = atom({ plugin: 'session-viz', key: 'gates' } as const, emptyGates())
9
10type Tier = 'planner' | 'builder' | 'scout'
11
12/** How each persona is routed (`.agents/references/model-routing.md` § Agents, § Floors): a pinned tier, `inherit` the session model, or `critic`'s planner floor. */
13const ROUTING: Readonly<Record<string, Tier | 'inherit' | 'planner-floor'>> = {
14 planner: 'planner',
15 'backend-developer': 'builder',
16 'frontend-developer': 'builder',
17 'frontend-design-validator': 'builder',
18 'code-debugger': 'builder',
19 'context-document-optimizer': 'builder',
20 'code-reviewer': 'inherit',
21 'security-reviewer': 'inherit',
22 'software-design-expert-review': 'inherit',
23 critic: 'planner-floor',
24}
25
26/** The personas whose dispatch must carry the seven contract items (review-dispatch-contract § The seven items). */
27const REVIEWERS: ReadonlySet<string> = new Set(['code-reviewer', 'critic', 'security-reviewer', 'software-design-expert-review'])
28
29/** Each contract item as the test over a lower-cased prompt that says it is present, in item order 1..7. */
30const ITEM_PRESENT: ReadonlyArray<(text: string) => boolean> = [
31 text => ['diff --git', '...head', 'git diff'].some(marker => text.includes(marker)),
32 text => SPEC_PATH.test(text) || text.includes('no spec —') || text.includes('no spec -'),
33 text => text.includes('tasks/todo.md'),
34 text => text.includes('ambiguit'),
35 text => text.includes('todo(shortcut)') || text.includes('deferrals: none'),
36 text => ['introduced', 'pre-existing', 'boundary'].some(marker => text.includes(marker)),
37 text => (text.includes('confidence') && text.includes('autofix_class')) || text.includes('failure:'),
38]
39
40/** The tier a model string names (an alias or a full id), undefined when it names none. */
41export function tierOf(model: string): Tier | undefined {
42 const name = model.toLowerCase()
43 if (name.includes('opus')) return 'planner'
44 if (name.includes('sonnet')) return 'builder'
45 if (name.includes('haiku')) return 'scout'
46 return undefined
47}
48
49/** The off-tier issue text for one spawn (toasted with the mod's prefix), undefined when the dispatch follows model-routing (spec D11). */
50export function offTier(e: { subagentType: string; model?: string; parentModel: string }): string | undefined {
51 const type = lastSegment(e.subagentType)
52 const routing = ROUTING[type]
53 if (routing === undefined || e.model === undefined) return undefined
54 const tier = tierOf(e.model)
55 const says = (expected: string): string => `${type} dispatched on ${e.model} (${tier ?? 'unknown'}); model-routing says ${expected}`
56 if (routing === 'inherit') return says('inherit')
57 if (routing === 'planner-floor') {
58 const parent = tierOf(e.parentModel)
59 return tier === 'planner' && parent !== 'planner' ? undefined : says('inherit')
60 }
61 return tier === undefined || tier === routing ? undefined : says(routing)
62}
63
64/** The numbers (1..7) of the contract items a reviewer dispatch prompt lacks; none for any other type. */
65export function missingContractItems(subagentType: string, prompt: string): number[] {
66 if (!REVIEWERS.has(lastSegment(subagentType))) return []
67 const text = prompt.toLowerCase()
68 return ITEM_PRESENT.flatMap((present, index) => (present(text) ? [] : [index + 1]))
69}
70
71/** Toasts each dispatch issue of one spawn and records it as a row under Gates. */
72async function raise($: EngineInterface, issues: DispatchIssue[]): Promise<void> {
73 for (const issue of issues) {
74 $.ui.toast(`session-viz: ${issue.text}`)
75 await update($, gates, current => ({ ...current, dispatch: [...current.dispatch, issue].slice(-GATES_KEPT) }))
76 }
77}
78
79export function registerDispatch(on: On): void {
80 // Every spawn names a type; the matcher keeps this registration distinct from the agents section's.
81 on('agent.spawn', { subagentType: /./ }, async ($, e, next) => {
82 const started = await next(e)
83 const type = lastSegment(e.subagentType)
84 const issues: DispatchIssue[] = []
85 const tierText = offTier(e)
86 if (tierText !== undefined) issues.push({ kind: 'off-tier', type, text: tierText })
87 const missing = missingContractItems(e.subagentType, e.prompt)
88 if (missing.length > 0) {
89 issues.push({ kind: 'contract', type, text: `${type} dispatch missing contract item(s) ${missing.join(', ')}` })
90 }
91 await raise($, issues)
92
93 return started
94 })
95}
96
97/** The dispatch issues drawn under the Gates section; nothing while there are none. */
98export function DispatchIssues(d: Draw, issues: DispatchIssue[]): RenderElement | null {
99 if (issues.length === 0) return null
100 const { Box, Text } = d.t
101
102 return (
103 <Box flexDirection="column" marginBottom={1}>
104 {issues.map((issue, index) => (
105 <Text key={String(index)} color="yellow">
106 {fit(`⚠ ${issue.text}`, d.width)}
107 </Text>
108 ))}
109 </Box>
110 )
111}
112hooks/gates.tsx 250 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement } from 'claude-code'
3
4import type { FindingCounts, Gates, ReceiptState, Severity } from '../types'
5import { EMPTY_FINDINGS, EVERY, GATES_KEPT, INITIAL_AGENTS, INITIAL_TOASTED, SEVERITIES, emptyGates, findingCounts, fit } from './state'
6import type { Draw } from './state'
7
8const gates = atom({ plugin: 'session-viz', key: 'gates' } as const, emptyGates())
9const toasted = atom({ plugin: 'session-viz', key: 'toasted' } as const, INITIAL_TOASTED)
10const agents = atom({ plugin: 'session-viz', key: 'agents' } as const, INITIAL_AGENTS)
11
12
13/** `1. [MUST-FIX | confidence: 100 | …] file:line — text`, the number prefix optional. */
14const FINDING = new RegExp(`^\\s*(?:\\d+\\.\\s*)?\\[(${SEVERITIES.join('|')})\\s*\\|`)
15/** `Verdict: ✅ GO`, the glyph optional. */
16const VERDICT = /^\s*Verdict:\s*(?:\S+\s+)?(GO|HOLD|STOP)\b/
17/** `Receipt: STOP <fp8> policy qg1-00000000`. */
18const RECEIPT_LINE = /^\s*Receipt:\s*(GO|HOLD|STOP)\s+([0-9a-f]{8})/
19/** `ref: refs/heads/<branch>` in a HEAD file. */
20const HEAD_REF = /^ref:\s*refs\/heads\/(\S+)/
21/** A receipt fingerprint as the pointer names it. */
22const FINGERPRINT = /^[0-9a-f]{64}$/
23
24function lines(text: string): string[] {
25 return text.split(/\r?\n/)
26}
27
28function startingWith(text: string, tag: string): string[] {
29 return lines(text)
30 .map(line => line.trim())
31 .filter(line => line.startsWith(tag))
32}
33
34/** The finding count per severity of a reviewer's answer. */
35export function parseFindings(text: string): FindingCounts {
36 const counts: FindingCounts = { ...EMPTY_FINDINGS }
37 for (const line of lines(text)) {
38 const found = FINDING.exec(line)
39 if (found !== null) counts[found[1] as Severity] += 1
40 }
41 return counts
42}
43
44/** Every `[AMBIGUITY] …` line, whole. */
45export function parseAmbiguities(text: string): string[] {
46 return startingWith(text, '[AMBIGUITY]')
47}
48
49/** Every `[SURFACE] +<path> | reason: …` line, whole. */
50export function parseSurfaces(text: string): string[] {
51 return startingWith(text, '[SURFACE]')
52}
53
54/** The verdict of the last `Verdict:` line. */
55export function parseVerdict(text: string): string | undefined {
56 const found = lines(text)
57 .map(line => VERDICT.exec(line))
58 .filter(match => match !== null)
59 return found.at(-1)?.[1]
60}
61
62/** The verdict and fingerprint of the last `Receipt:` line. */
63export function parseReceiptLine(text: string): { verdict: string; fp8: string } | undefined {
64 const found = lines(text)
65 .map(line => RECEIPT_LINE.exec(line))
66 .filter(match => match !== null)
67 .at(-1)
68 return found === undefined ? undefined : { verdict: found[1]!, fp8: found[2]! }
69}
70
71function sumFindings(a: FindingCounts, b: FindingCounts): FindingCounts {
72 return findingCounts(severity => a[severity] + b[severity])
73}
74
75async function readText($: EngineInterface, path: string): Promise<string | undefined> {
76 try {
77 return await $.fs.read(path)
78 } catch {
79 return undefined
80 }
81}
82
83function parseObject(text: string): Record<string, unknown> | undefined {
84 try {
85 const value: unknown = JSON.parse(text)
86 return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : undefined
87 } catch {
88 return undefined
89 }
90}
91
92/** The branch HEAD names, through the `gitdir:` file a worktree has; undefined when detached or unreadable. */
93async function currentBranch($: EngineInterface, cwd: string): Promise<string | undefined> {
94 const dotGit = await readText($, `${cwd}/.git`)
95 const gitDir = dotGit?.startsWith('gitdir:') ? dotGit.slice('gitdir:'.length).trim() : `${cwd}/.git`
96 const head = await readText($, `${gitDir}/HEAD`)
97 return head === undefined ? undefined : HEAD_REF.exec(head)?.[1]
98}
99
100/**
101 * What the current branch's pointer location holds (spec D12): no pointer, a pointer or receipt that cannot be read
102 * (a dangling pointer included), or the receipt; undefined when no repo or branch resolves, so no location could be read.
103 */
104async function readReceipt($: EngineInterface): Promise<ReceiptState | undefined> {
105 const repo = await $.session.repo()
106 if (repo === null) return undefined
107 const branch = await currentBranch($, await $.session.cwd())
108 if (branch === undefined) return undefined
109
110 const dir = `${repo.root}/.git/quality-receipts`
111 const pointerText = await readText($, `${dir}/branch-${branch.replace(/[^A-Za-z0-9_.-]/g, '-')}.json`)
112 if (pointerText === undefined) return { state: 'none' }
113 const fingerprint = parseObject(pointerText)?.fingerprint
114 if (typeof fingerprint !== 'string' || !FINGERPRINT.test(fingerprint)) return { state: 'unreadable' }
115
116 const receiptText = await readText($, `${dir}/${fingerprint}.json`)
117 if (receiptText === undefined) return { state: 'unreadable' }
118 const receipt = parseObject(receiptText)
119 if (receipt === undefined || typeof receipt.verdict !== 'string') return { state: 'unreadable' }
120
121 const tests = receipt.tests
122 const testsExit = typeof tests === 'object' && tests !== null ? (tests as Record<string, unknown>).exit : undefined
123 return {
124 state: 'ok',
125 verdict: receipt.verdict,
126 fp8: fingerprint.slice(0, 8),
127 testsExit: typeof testsExit === 'number' ? testsExit : undefined,
128 unresolved: Array.isArray(receipt.unresolved) ? receipt.unresolved.length : undefined,
129 designVerdict: typeof receipt.design_verdict === 'string' ? receipt.design_verdict : undefined,
130 }
131}
132
133/** Toasts a STOP once per `id` of its kind (a receipt fingerprint, or a main-loop turn); HOLD and GO never reach here. */
134async function toastStop($: EngineInterface, kind: keyof typeof INITIAL_TOASTED, id: string, fp8: string | undefined): Promise<void> {
135 // Decided inside the updater, so two overlapping refreshes of one STOP cannot both toast.
136 let fresh = false
137 await update($, toasted, current => {
138 if (current[kind].includes(id)) return current
139 fresh = true
140 return { ...current, [kind]: [...current[kind], id] }
141 })
142 if (fresh) $.ui.toast(`session-viz: quality gate STOP${fp8 === undefined ? '' : ` (${fp8})`}`)
143}
144
145/**
146 * Re-reads the receipt files into the section; a STOP toasts once per fingerprint. Whatever the pointer
147 * location holds replaces what is shown; only when no repo or branch resolves does a main-loop line's receipt stay.
148 */
149async function refreshReceipt($: EngineInterface): Promise<void> {
150 const found = await readReceipt($)
151 if (found === undefined) return
152 await update($, gates, current => ({ ...current, receipt: found }))
153 if (found.state === 'ok' && found.verdict === 'STOP') await toastStop($, 'fingerprints', found.fp8, found.fp8)
154}
155
156/** What a sub-agent's answer adds: its parsed lines go on its row and into the section. */
157async function recordAgentAnswer($: EngineInterface, agentId: string, answer: string): Promise<void> {
158 const parsed = { findings: parseFindings(answer), ambiguities: parseAmbiguities(answer), surfaces: parseSurfaces(answer) }
159 const row = (await read($, agents)).find(one => one.agentId === agentId)
160 const agent = row?.type ?? agentId
161 const tagged = (texts: string[]) => texts.map(text => ({ agent, text }))
162 await update($, agents, rows => rows.map(one => (one.agentId === agentId ? { ...one, ...parsed } : one)))
163 await update($, gates, current => ({
164 ...current,
165 findings: sumFindings(current.findings, parsed.findings),
166 ambiguities: [...current.ambiguities, ...tagged(parsed.ambiguities)].slice(-GATES_KEPT),
167 surfaces: [...current.surfaces, ...tagged(parsed.surfaces)].slice(-GATES_KEPT),
168 }))
169}
170
171/** A main-loop `Receipt:` line's verdict and fingerprint laid over the receipt the files last gave, when they gave one. */
172function fromLine(current: ReceiptState, line: { verdict: string; fp8: string }): ReceiptState {
173 return current.state === 'ok' ? { ...current, ...line } : { state: 'ok', ...line }
174}
175
176/** What the main loop's answer adds: its Verdict and Receipt lines, a STOP toast once per turn, then the files re-read. */
177async function recordMainAnswer($: EngineInterface, turnId: string, answer: string): Promise<void> {
178 const verdict = parseVerdict(answer)
179 const line = parseReceiptLine(answer)
180 await update($, gates, current => ({
181 ...current,
182 verdict: verdict ?? current.verdict,
183 receipt: line === undefined ? current.receipt : fromLine(current.receipt, line),
184 }))
185 if (line?.verdict === 'STOP' || verdict === 'STOP') {
186 const fp8 = line?.verdict === 'STOP' ? line.fp8 : undefined
187 await toastStop($, 'turns', turnId, fp8)
188 // The same fingerprint's file read must not toast it a second time.
189 if (fp8 !== undefined) await update($, toasted, current => ({ ...current, fingerprints: [...new Set([...current.fingerprints, fp8])] }))
190 }
191 await refreshReceipt($)
192}
193
194export function registerGates(on: On): void {
195 on('session.start', { cwd: EVERY }, async ($, e, next) => {
196 const result = await next(e)
197 await refreshReceipt($)
198
199 return result
200 })
201
202 on('turn.complete', { answer: EVERY }, async ($, e, next) => {
203 const result = await next(e)
204 if (e.agentId === undefined) await recordMainAnswer($, e.turnId, e.answer)
205 else await recordAgentAnswer($, e.agentId, e.answer)
206
207 return result
208 })
209
210 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
211 const result = await next(e)
212 if (e.command.includes('receipt.py') && e.command.includes('write')) await refreshReceipt($)
213
214 return result
215 })
216}
217
218function receiptLine(receipt: ReceiptState): string {
219 if (receipt.state === 'unreadable') return 'receipt unreadable'
220 if (receipt.state === 'none') return 'no receipt yet'
221 const parts = [
222 `receipt ${receipt.verdict} ${receipt.fp8}`,
223 receipt.testsExit === undefined ? undefined : `tests exit ${receipt.testsExit}`,
224 receipt.unresolved === undefined ? undefined : `unresolved ${receipt.unresolved}`,
225 ]
226 return parts.filter((part): part is string => part !== undefined).join(' · ')
227}
228
229export function GatesSection(d: Draw, state: Gates): RenderElement {
230 const { Box, Text } = d.t
231 const isDim = state.receipt.state !== 'ok'
232 const counts = SEVERITIES.filter(severity => state.findings[severity] > 0)
233 .map(severity => `${severity} ${state.findings[severity]}`)
234 .join(' · ')
235 return (
236 <Box flexDirection="column" marginBottom={1}>
237 <Text bold>GATES</Text>
238 <Text dimColor={isDim}>{fit(receiptLine(state.receipt), d.width)}</Text>
239 {state.verdict !== undefined && <Text>{fit(`verdict ${state.verdict}`, d.width)}</Text>}
240 {counts !== '' && <Text>{fit(counts, d.width)}</Text>}
241 {state.ambiguities.map((one, index) => (
242 <Text key={`a${index}`}>{fit(`⚑ ${one.agent}: ${one.text}`, d.width)}</Text>
243 ))}
244 {state.surfaces.map((one, index) => (
245 <Text key={`s${index}`}>{fit(`${one.agent}: ${one.text}`, d.width)}</Text>
246 ))}
247 </Box>
248 )
249}
250hooks/phase.tsx 84 lines1import { atom, update } from 'claude-code'
2import type { On, RenderElement } from 'claude-code'
3
4import type { Phase, PhaseState } from '../types'
5import { INITIAL_PHASE, fit, lastSegment, updateStatus } from './state'
6import type { Draw } from './state'
7
8const phase = atom({ plugin: 'session-viz', key: 'phase' } as const, INITIAL_PHASE)
9
10/** Which phase each jplugin skill puts the session in (spec D8); any other skill shows as running and leaves the phase. */
11const PHASE_OF: Readonly<Record<string, Phase>> = {
12 prd: 'PLAN',
13 'grill-me': 'PLAN',
14 grilling: 'PLAN',
15 brainstorm: 'PLAN',
16 plan: 'PLAN',
17 'system-design-planning': 'PLAN',
18 slice: 'PLAN',
19 'visual-plan': 'PLAN',
20 build: 'BUILD',
21 yolo: 'BUILD',
22 'auto-push': 'BUILD',
23 'make-it-simpler': 'BUILD',
24 'quality-gate': 'GATE',
25 'software-design-expert-review': 'GATE',
26 'security-scan': 'GATE',
27 'verify-evidence': 'GATE',
28 'wrap-up-session': 'WRAP-UP',
29}
30
31/** `[ROUTE] lane=<lane> | chain=<skill, …> | reason: …`, as /go prints it. */
32const ROUTE = /^\s*\[ROUTE\]\s*lane=([^|\r\n]+?)\s*\|\s*chain=([^|\r\n]+?)\s*(?:\||$)/m
33
34export function phaseOfSkill(skill: string): Phase | undefined {
35 return PHASE_OF[lastSegment(skill)]
36}
37
38export function parseRoute(text: string): { lane: string; chain: string } | undefined {
39 const found = ROUTE.exec(text)
40 return found === null ? undefined : { lane: found[1]!.trim(), chain: found[2]!.trim() }
41}
42
43export function registerPhase(on: On): void {
44 on('skill.prompt', async ($, e, next) => {
45 const result = await next(e)
46 const skill = lastSegment(e.skill)
47 const nextPhase = phaseOfSkill(e.skill)
48 await updateStatus($, 'phase', current => ({ ...current, skill, phase: nextPhase ?? current.phase }))
49
50 return result
51 })
52
53 // A streaming event: the hook is the generator, `yield* next(e)` forwards the response and
54 // evaluates to the step's result. The route is read off every main-loop step, not only the
55 // turn's final answer.
56 on('turn.step', async function* ($, e, next) {
57 const result = yield* next(e)
58 if (e.agentId === undefined) {
59 const route = parseRoute(result.answer)
60 if (route !== undefined) await update($, phase, current => ({ ...current, ...route }))
61 }
62
63 return result
64 })
65}
66
67export function PhaseSection(d: Draw, state: PhaseState): RenderElement {
68 const { Box, Text } = d.t
69 const head = state.skill === undefined ? undefined : `${state.phase} · ${state.skill}`
70 const route = state.lane === undefined ? undefined : `route ${state.lane}${state.chain === undefined ? '' : ` · ${state.chain}`}`
71 return (
72 <Box flexDirection="column" marginBottom={1}>
73 <Text bold>PHASE</Text>
74 {head === undefined ? (
75 <Text dimColor>{fit(`${state.phase} · no skill running`, d.width)}</Text>
76 ) : (
77 <Text>{fit(head, d.width)}</Text>
78 )}
79 {route !== undefined && <Text dimColor>{fit(route, d.width)}</Text>}
80 {state.spec !== undefined && <Text dimColor>{fit(`spec ${state.spec}`, d.width)}</Text>}
81 </Box>
82 )
83}
84hooks/plan.tsx 147 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, RenderElement } from 'claude-code'
3
4import type { PlanProgress, SliceRow } from '../types'
5import { EVERY, INITIAL_PHASE, SPEC_PATH, fit, updateStatus } from './state'
6import type { Draw } from './state'
7
8const phase = atom({ plugin: 'session-viz', key: 'phase' } as const, INITIAL_PHASE)
9
10const TODO = 'tasks/todo.md'
11
12/** The tools that change the file their `file_path` names. */
13const WRITERS = /^(?:Edit|Write|MultiEdit)$/
14/** A path ending `tasks/todo.md`, either slash. */
15const TODO_PATH = /tasks[\\/]todo\.md$/
16
17/** `## Plan: <feature>` — opens a block; any later line starting `## ` ends it. */
18const PLAN_HEAD = /^## Plan:\s*(.*)$/
19/** `> Spec: specs/<x>.md` — the spec the block was planned from. */
20const SPEC_LINE = /^>\s*Spec:\s*(\S+)/
21/** `### Slice 2/5 — <name>` (the dash is U+2014; a hyphen is accepted too). */
22const SLICE_HEAD = /^### Slice\s+(\d+)\/\d+\s*[—-]\s*(.*)$/
23/** `- [ ] <name> …` — the slice header row: open or done, never counted as a row. */
24const HEADER_ROW = /^- \[([ xX])\]/
25/** ` [x] TDD: …` / ` [ ] Verify: …` — the rows that are counted. */
26const COUNTED_ROW = /^\s+\[([ xX])\]\s+(?:TDD|Verify):/
27/** `> Handover: …` — closes the slice it sits under. */
28const HANDOVER = /^>\s*Handover:/
29
30type Block = { feature: string; spec?: string; slices: SliceRow[]; implicit: SliceRow; open: boolean }
31
32const newSlice = (n: number, name: string): SliceRow => ({ n, total: 0, name, rowsDone: 0, rowsTotal: 0, handover: false })
33
34const slashed = (path: string): string => path.replace(/\\/g, '/')
35
36function toProgress(block: Block): PlanProgress {
37 const found = block.slices.length === 0 ? [block.implicit] : block.slices
38 const slices = found.map(row => ({ ...row, total: found.length }))
39 return {
40 feature: block.feature,
41 ...(block.spec === undefined ? {} : { spec: block.spec }),
42 slices,
43 rowsDone: slices.reduce((sum, row) => sum + row.rowsDone, 0),
44 rowsTotal: slices.reduce((sum, row) => sum + row.rowsTotal, 0),
45 closed: slices.filter(row => row.handover).length,
46 total: slices.length,
47 }
48}
49
50/** The active `## Plan:` block of a todo file (D9): the recorded spec's, else the last with an open row, else the last; null with none. */
51export function parsePlan(text: string, spec?: string): PlanProgress | null {
52 const blocks: Block[] = []
53 let block: Block | undefined
54 let slice: SliceRow | undefined
55 for (const line of text.replace(/\r/g, '').split('\n')) {
56 const opened = PLAN_HEAD.exec(line)
57 if (opened !== null) {
58 const feature = opened[1]!.trim()
59 block = { feature, slices: [], implicit: newSlice(1, feature), open: false }
60 blocks.push(block)
61 slice = block.implicit
62 continue
63 }
64 if (block === undefined) continue
65 if (line.startsWith('## ')) {
66 block = undefined
67 continue
68 }
69 const named = SPEC_LINE.exec(line)
70 const heading = SLICE_HEAD.exec(line)
71 const header = HEADER_ROW.exec(line)
72 const row = COUNTED_ROW.exec(line)
73 if (named !== null && block.spec === undefined) block.spec = named[1]
74 else if (heading !== null) {
75 slice = newSlice(Number(heading[1]), heading[2]!.trim())
76 block.slices.push(slice)
77 } else if (header !== null) block.open ||= header[1] === ' '
78 else if (row !== null && slice !== undefined) {
79 slice.rowsTotal += 1
80 if (row[1] === ' ') block.open = true
81 else slice.rowsDone += 1
82 } else if (HANDOVER.test(line) && slice !== undefined) slice.handover = true
83 }
84
85 const recorded = spec === undefined ? undefined : slashed(spec)
86 const chosen =
87 blocks.find(b => recorded !== undefined && b.spec !== undefined && slashed(b.spec) === recorded) ??
88 blocks.findLast(b => b.open) ??
89 blocks.at(-1)
90 return chosen === undefined ? null : toProgress(chosen)
91}
92
93/** Reads tasks/todo.md, parses it against the recorded spec and stores the result; a missing file stores null. */
94async function reload($: EngineInterface): Promise<void> {
95 const { spec } = await read($, phase)
96 const parsed = await $.fs.read(TODO).then(
97 text => parsePlan(text, spec),
98 () => null,
99 )
100 await updateStatus($, 'plan', () => parsed)
101}
102
103export function registerPlan(on: On): void {
104 on('session.start', { cwd: EVERY }, async ($, e, next) => {
105 const result = await next(e)
106 await reload($)
107
108 return result
109 })
110
111 on('tool.call', { tool: WRITERS, file_path: TODO_PATH }, async ($, e, next) => {
112 const ran = await next(e)
113 await reload($)
114
115 return ran
116 })
117
118 // The one writer of the recorded spec (spec D9): the Phase section draws it, and the plan is re-read against it here, in order.
119 on('prompt.submit', { text: SPEC_PATH }, async ($, e, next) => {
120 const result = await next(e)
121 const spec = SPEC_PATH.exec(e.text)?.[0]
122 if (spec !== undefined) await update($, phase, current => ({ ...current, spec }))
123 await reload($)
124
125 return result
126 })
127}
128
129export function PlanSection(d: Draw, progress: PlanProgress | null): RenderElement {
130 const { Box, Text } = d.t
131 return (
132 <Box flexDirection="column" marginBottom={1}>
133 <Text bold>PLAN</Text>
134 {progress === null ? (
135 <Text dimColor>no plan block</Text>
136 ) : (
137 <Text>{fit(`${progress.feature} · slice ${progress.closed}/${progress.total} · rows ${progress.rowsDone}/${progress.rowsTotal}`, d.width)}</Text>
138 )}
139 {progress?.slices.map(row => (
140 <Text key={String(row.n)} dimColor={row.handover}>
141 {fit(`${row.n}/${row.total} ${row.name} · rows ${row.rowsDone}/${row.rowsTotal}${row.handover ? ' · ✓ handover' : ''}`, d.width)}
142 </Text>
143 ))}
144 </Box>
145 )
146}
147hooks/state.ts 145 lines1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface } from 'claude-code'
3
4import type { AgentRow, FindingCounts, Gates, Measure, PhaseState, PlanProgress, Severity, Toasted } from '../types'
5
6export const PANE = 'session-viz'
7export const TITLE = 'Session'
8export const MAIN = 'main'
9/** From this many body columns the drill-down is a right column beside the sections (spec D6). */
10export const WIDE_COLUMNS = 120
11
12/** The severities of a four-axis finding, in the order the Gates section lists them. */
13export const SEVERITIES: readonly Severity[] = ['MUST-FIX', 'SHOULD-FIX', 'NITPICK']
14
15/** One count per severity, each from `count`. */
16export function findingCounts(count: (severity: Severity) => number): FindingCounts {
17 return Object.fromEntries(SEVERITIES.map(severity => [severity, count(severity)])) as FindingCounts
18}
19
20export const EMPTY_FINDINGS = findingCounts(() => 0)
21
22/** Entries kept per Gates list (ambiguities, surfaces, dispatch issues); the oldest leave first. */
23export const GATES_KEPT = 50
24
25export function emptyGates(): Gates {
26 return { receipt: { state: 'none' }, findings: { ...EMPTY_FINDINGS }, ambiguities: [], surfaces: [], dispatch: [] }
27}
28
29// Every value a drawing or a toast decision reads lives in $.state (spec D4): a hot reload
30// starts the module's own variables over and keeps these. The engine's validator reads each
31// `atom(...)` reference in the file that calls `read` or `update` on it, so every module
32// declares the atoms it touches with `{ plugin: 'session-viz', key }` written out and one of
33// these initials; the contract is `types/index.d.ts`.
34export const INITIAL_PHASE: PhaseState = { phase: 'idle' }
35export const INITIAL_AGENTS: AgentRow[] = []
36export const INITIAL_ASKING: string[] = []
37export const INITIAL_SELECTED: string | null = null
38export const INITIAL_PLAN: PlanProgress | null = null
39export const INITIAL_MEASURE: Measure = {}
40export const INITIAL_TOASTED: Toasted = { fingerprints: [], turns: [] }
41
42const phase = atom({ plugin: 'session-viz', key: 'phase' } as const, INITIAL_PHASE)
43const agents = atom({ plugin: 'session-viz', key: 'agents' } as const, INITIAL_AGENTS)
44const plan = atom({ plugin: 'session-viz', key: 'plan' } as const, INITIAL_PLAN)
45const measure = atom({ plugin: 'session-viz', key: 'measure' } as const, INITIAL_MEASURE)
46
47/** The elements every surface's table has; what a section draws with. */
48export type Table = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
49
50/** What a section's render receives: the surface's elements, the columns it may fill, the agent in view. */
51export type Draw = {
52 t: Table
53 width: number
54 view?: string
55}
56
57/** One line of at most `width` cells, ending in `…` when cut. */
58export function fit(text: string, width: number): string {
59 const line = text.replace(/\s+/g, ' ').trim()
60 if (width <= 1) return line.slice(0, Math.max(0, width))
61 return line.length <= width ? line : `${line.slice(0, width - 1)}…`
62}
63
64/** `12s` for 12000 ms; '' for none. */
65export function seconds(ms: number | undefined): string {
66 return ms === undefined ? '' : `${Math.round(ms / 1000)}s`
67}
68
69/** A spec path as a prompt, a plan block or a dispatch names it: `specs/<x>.md`. */
70export const SPEC_PATH = /specs\/[^\s`'"<>|()]+\.md/
71
72/**
73 * A matcher that selects every event. The engine refuses a second matcher-less hook on one
74 * event in a plugin, and register.tsx holds the matcher-less session.start, so every other
75 * section's hook on a shared event carries one.
76 */
77export const EVERY = /^/
78
79/** The segment after the last `:`: `jplugin:build` → `build`, `build` → `build`. */
80export function lastSegment(name: string): string {
81 return name.slice(name.lastIndexOf(':') + 1)
82}
83
84/**
85 * The status line: `viz · <PHASE> · <skill> · slice k/N · ctx NN% · $X.XX · agents R/T`,
86 * every unknown field left out.
87 */
88export function statusLine(ph: PhaseState, pl: PlanProgress | null, me: Measure, ag: AgentRow[]): string {
89 const running = ag.filter(row => row.status === 'running').length
90 const parts: Array<string | undefined> = [
91 'viz',
92 ph.phase,
93 ph.skill,
94 pl === null ? undefined : `slice ${pl.closed}/${pl.total}`,
95 me.percent === undefined ? undefined : `ctx ${me.percent}%`,
96 me.usd === undefined ? undefined : `$${me.usd.toFixed(2)}`,
97 ag.length === 0 ? undefined : `agents ${running}/${ag.length}`,
98 ]
99 return parts.filter((part): part is string => part !== undefined).join(' · ')
100}
101
102/** Rebuilds the status line from the atoms: the shell's first draw at session.start; every later redraw comes through `updateStatus`. */
103export async function refreshStatus($: EngineInterface): Promise<void> {
104 const [ph, pl, me, ag] = await Promise.all([read($, phase), read($, plan), read($, measure), read($, agents)])
105 $.ui.status(statusLine(ph, pl, me, ag))
106}
107
108/** The atoms the status line draws from, by key. */
109type StatusKey = 'phase' | 'agents' | 'plan' | 'measure'
110
111/**
112 * Updates one of the status line's atoms and redraws the line when the value changed (an updater
113 * returning `current` draws nothing): the one way a section writes a status-line field. Each atom is
114 * named at its own `update` call because the engine's scan lists what the module writes from them.
115 */
116export async function updateStatus($: EngineInterface, key: 'phase', change: (current: PhaseState) => PhaseState): Promise<void>
117export async function updateStatus($: EngineInterface, key: 'agents', change: (current: AgentRow[]) => AgentRow[]): Promise<void>
118export async function updateStatus($: EngineInterface, key: 'plan', change: (current: PlanProgress | null) => PlanProgress | null): Promise<void>
119export async function updateStatus($: EngineInterface, key: 'measure', change: (current: Measure) => Measure): Promise<void>
120export async function updateStatus($: EngineInterface, key: StatusKey, change: (current: never) => unknown): Promise<void> {
121 let changed = false
122 const tracked =
123 <T>(apply: (current: T) => T) =>
124 (current: T): T => {
125 const next = apply(current)
126 changed = next !== current
127 return next
128 }
129 switch (key) {
130 case 'phase':
131 await update($, phase, tracked(change as (current: PhaseState) => PhaseState))
132 break
133 case 'agents':
134 await update($, agents, tracked(change as (current: AgentRow[]) => AgentRow[]))
135 break
136 case 'plan':
137 await update($, plan, tracked(change as (current: PlanProgress | null) => PlanProgress | null))
138 break
139 case 'measure':
140 await update($, measure, tracked(change as (current: Measure) => Measure))
141 break
142 }
143 if (changed) await refreshStatus($)
144}
145types/index.d.ts 146 lines1/** The workflow phase the running skill puts the session in (spec D8). */
2export type Phase = 'PLAN' | 'BUILD' | 'GATE' | 'WRAP-UP' | 'idle'
3
4/** What the Phase section and the status line draw. */
5export type PhaseState = {
6 phase: Phase
7 /** The skill running now, by the segment after its last `:`. */
8 skill?: string
9 /** The `/go` lane the last main-loop `[ROUTE]` line named. */
10 lane?: string
11 /** The `/go` chain the last main-loop `[ROUTE]` line named. */
12 chain?: string
13 /** The `specs/<x>.md` path the most recent prompt named (spec D9). */
14 spec?: string
15}
16
17/** The three severities of a four-axis finding. */
18export type Severity = 'MUST-FIX' | 'SHOULD-FIX' | 'NITPICK'
19
20export type FindingCounts = Record<Severity, number>
21
22/** The four capped lists of a footprint. */
23export type FootprintList = 'read' | 'edited' | 'grepped' | 'commands'
24
25/** What one sub-agent touched, from its `tool.call`s and `turn.step`s (spec D16). */
26export type Footprint = {
27 /** Files Read or Globbed, newest last, at most 40. */
28 read: string[]
29 /** Files Edited, Written or notebook-edited, at most 40. */
30 edited: string[]
31 /** `<pattern> in <path>` per Grep, at most 40. */
32 grepped: string[]
33 /** The first 80 characters of each Bash command, at most 40. */
34 commands: string[]
35 /** How many entries each list has dropped past its cap, the oldest first; a list that dropped none is absent. */
36 dropped?: Partial<Record<FootprintList, number>>
37 toolCalls: number
38 steps: number
39 messages: number
40}
41
42/** One sub-agent of the session, as the Agents section tracks it. */
43export type AgentRow = {
44 /** `$.agent.list()`'s `id`, `turn.complete`'s `agentId`. */
45 agentId: string
46 /** The spawning Agent tool call a press scrolls to; absent for an agent seen only through `$.agent.list()`. */
47 toolUseId?: string
48 type: string
49 description: string
50 /** The spawn prompt, or `(spawned before session-viz loaded)`. */
51 prompt: string
52 /** The Agent tool's `model` as requested, when one was. */
53 model?: string
54 /** What the engine resolved the agent to run on. */
55 resolvedModel?: string
56 parentModel?: string
57 parentAgentId?: string
58 status: 'running' | 'done' | 'failed'
59 /** `turn.complete`'s reason, or the engine's status for an agent the hooks never saw end. */
60 outcome?: string
61 durationMs?: number
62 /** Every `[AMBIGUITY]` line of its answer. */
63 ambiguities: string[]
64 /** Every `[SURFACE]` line of its answer. */
65 surfaces: string[]
66 findings: FindingCounts
67 footprint: Footprint
68}
69
70/** One `### Slice n/N` of the active plan block. */
71export type SliceRow = {
72 n: number
73 total: number
74 name: string
75 rowsDone: number
76 rowsTotal: number
77 /** True once the slice's `> Handover:` blockquote exists. */
78 handover: boolean
79}
80
81/** The active `## Plan:` block of `tasks/todo.md`. */
82export type PlanProgress = {
83 feature: string
84 spec?: string
85 slices: SliceRow[]
86 rowsDone: number
87 rowsTotal: number
88 /** Slices whose handover exists. */
89 closed: number
90 total: number
91}
92
93/** The quality receipt the branch pointer names, as the Gates section reads it. */
94export type Receipt = {
95 verdict: string
96 /** The first eight characters of the fingerprint. */
97 fp8: string
98 testsExit?: number
99 unresolved?: number
100 designVerdict?: string
101}
102
103/** What the Gates section knows of the branch's receipt: none yet, a pointer or receipt it could not read, or the receipt itself. */
104export type ReceiptState = { state: 'none' } | { state: 'unreadable' } | ({ state: 'ok' } & Receipt)
105
106/** One dispatch check that toasted: off-tier (spec D11) or a missing contract item (spec D10). */
107export type DispatchIssue = {
108 kind: 'off-tier' | 'contract'
109 type: string
110 text: string
111}
112
113/** Everything the Gates section draws. */
114export type Gates = {
115 receipt: ReceiptState
116 /** The last `Verdict:` line seen on a main-loop answer. */
117 verdict?: string
118 findings: FindingCounts
119 ambiguities: { agent: string; text: string }[]
120 surfaces: { agent: string; text: string }[]
121 dispatch: DispatchIssue[]
122}
123
124/** The last `session.measure` figures the status line draws. */
125export type Measure = { percent?: number; usd?: number }
126
127/** What has already toasted, so a hot reload repeats nothing (spec D7). */
128export type Toasted = { fingerprints: string[]; turns: string[] }
129
130declare module 'claude-code' {
131 interface PluginState {
132 'session-viz': {
133 phase: PhaseState
134 agents: AgentRow[]
135 /** Agent ids (or `main`) with an AskUserQuestion dialog open right now. */
136 asking: string[]
137 /** The agent whose drill-down is open, by id; null for none. */
138 selected: string | null
139 plan: PlanProgress | null
140 gates: Gates
141 measure: Measure
142 toasted: Toasted
143 }
144 }
145}
146