SLOPSHOPPER

session-viz

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…

newpaneguardcommandtoaststatus
★ 1v0.1.0no licenseupdated 2026-10-21Joaovsales/jplugin-agentic-development/mods/session-viz
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-viz
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ PHASE │ ┃ idle · no skill running ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ PLAN ⏺ Update(src/auth.ts) │ ┃ no plan block ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ AGENTS ⎿ 3 pass, 1 fail │ ┃ no sub-agents yet │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ GATES │ ┃ no receipt yet ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ AGENT › /session-viz │ ┃ no agent selected ⎿ session-viz: Session pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ session-viz: viz · idle · ctx 49% · $0.42

Draws

Pane · Session
PHASE idle · no skill running PLAN no plan block AGENTS no sub-agents yet GATES no receipt yet AGENT no agent selected
README

session-viz — a Claude Code mod

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
  • Phase — 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 — the active ## 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.
  • Agents — every sub-agent as a tree indented by parent: a glyph (◐ 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.
  • Gates — the quality receipt, the last Verdict: line, finding counts by severity, every [AMBIGUITY] and [SURFACE] line, and the dispatch checks (off-tier, missing contract items).
  • Drill-down — the pressed agent's brief and footprint: description, type, models, parent, the spawn prompt, files read, edited and grepped, commands, tool-call, step and message counts, outcome and duration. Pressing the row again closes it; pressing also scrolls the transcript to the spawning Agent tool row. No transcript is read or shown.

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.

Layout

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

Run it

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-viz
Source 8 files
hooks/register.tsx 97 lines
1import { 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}
97
hooks/agents.tsx 369 lines
1import { 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}
369
hooks/dispatch.tsx 112 lines
1import { 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}
112
hooks/gates.tsx 250 lines
1import { 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}
250
hooks/phase.tsx 84 lines
1import { 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}
84
hooks/plan.tsx 147 lines
1import { 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}
147
hooks/state.ts 145 lines
1import { 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}
145
types/index.d.ts 146 lines
1/** 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