Live provenance graph of tasks, agents and files inside a Claude Code pane

See not only what Claude is doing, but why each change exists and where it lives in the code.
TraceGraph is a Claude Code mod. It draws a live graph of the current session in a Claude Code pane:
TaskCreate / TaskUpdate / TodoWrite items, their status and depends_on edges+added -removed line counts per write ┌───────────────────────────────┐ test cases
┌────────┴────────┐ ┌────────┐ ┌─────▼────┐ ┌────────┐
│ ✓ requirements │──▶│ ● impl │──▶│ ✕ test │──▶│ ○ ship │
└─────────────────┘ └────▲───┘ └─────┬────┘ └────────┘
└─────────────┘ tests fail ×1
Agent, task and file edges are observed facts from Claude Code tool calls. Plan blocks and arrows are Claude's own statements (source: agent) and are drawn as such. TraceGraph does not invent dependencies. See DESIGN.md for the full design and roadmap.
Early access. Claude Code function hooks (mods) are an early-access API. They can change between Claude Code releases.
From GitHub:
claude plugin marketplace add Sangpaa/tracegraph
claude plugin install tracegraph@tracegraph
From a local clone:
git clone https://github.com/Sangpaa/tracegraph
claude --plugin-dir ./tracegraph
/graph plan view if a plan exists, else combined
/graph plan control block diagram of the current plan
/graph combined everything
/graph demo draw a sample plan
/graph tasks agents + tasks
/graph code agents + files
Keys in the pane:
| Key | Action |
|---|---|
j / k | next / previous node |
d | toggle the detail panel (edges, metadata, file path) |
p / t / c / g | plan / tasks / code / combined view |
Edge glyphs: ━ observed, ┄ interpreted (reserved for v0.2+).
TraceGraph gives Claude two tools and a short system prompt note asking it to use them:
| Tool | Purpose |
|---|---|
mcp__tracegraph__plan | declare title, steps, links (flow / feedforward / feedback, optional label) |
mcp__tracegraph__step | set a step active / done / failed; failed fires its feedback loops (counter ×n) and reactivates the target step |
TraceGraph writes an event-sourced log for each session:
<project>/.claude/graph/<session-id>.jsonl
Each line is one event (node.add, node.update, edge.add, node.alias). Replay the file to rebuild the graph. Add .claude/graph/ to your project's .gitignore if you do not want to commit it.
claude plugin validate . # manifest, hooks, state contract
claude plugin test . # tests/*.test.ts
tsc -p . # after Claude Code has loaded the mod once
Layout:
hooks/register.tsx wiring: /graph command, tool.call observer, pane renderer
src/graph/ model + pure event reducer (replay, JSONL)
src/observers/tools.ts tool call → graph events (pure)
src/observers/plan.ts plan / step tools → graph events (pure)
src/ui/diagram.ts plan → monospace block diagram (pure)
src/ui/views.ts view filters and row text (pure)
types/index.d.ts type contract ($.state + graph model)
decision / requirement nodes, a model-callable tool to record why a change existsMIT
hooks/register.tsx 276 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GraphEvent, PaneUi } from '../types'
5import { replay, toJsonl } from '../src/graph/store'
6import { onEnd, onStart } from '../src/observers/tools'
7import type { ObservedCall } from '../src/observers/tools'
8import { PLAN_SCHEMA, STEP_SCHEMA, currentPlan, parsePlan, parseStep, planEvents, stepEvents } from '../src/observers/plan'
9import { drawPlan } from '../src/ui/diagram'
10import {
11 VIEWS,
12 clampIndex,
13 displayPath,
14 edgeLabel,
15 mark,
16 neighbors,
17 parseView,
18 summary,
19 visibleNodes,
20} from '../src/ui/views'
21
22const PANE = 'tracegraph'
23const MAX_EVENTS = 5000
24const PLAN_TOOL = 'mcp__tracegraph__plan'
25const STEP_TOOL = 'mcp__tracegraph__step'
26
27// A sample plan for /graph demo: a chain with two feedback loops and a feedforward.
28const DEMO_PLAN = {
29 title: 'TraceGraph v0.2 배포 (demo)',
30 steps: [
31 { id: 'design', label: '설계 검토' },
32 { id: 'impl', label: '구현' },
33 { id: 'test', label: '테스트' },
34 { id: 'review', label: '코드 리뷰' },
35 { id: 'ship', label: '배포' },
36 ],
37 links: [
38 { from: 'test', to: 'impl', kind: 'feedback' as const, label: '테스트 실패' },
39 { from: 'review', to: 'impl', kind: 'feedback' as const, label: '리뷰 지적' },
40 { from: 'design', to: 'test', kind: 'feedforward' as const, label: '테스트 기준' },
41 ],
42}
43const DEMO_STEPS: [string, 'active' | 'done' | 'failed'][] = [
44 ['design', 'done'], ['impl', 'done'], ['test', 'failed'], ['impl', 'done'], ['test', 'done'], ['review', 'active'],
45]
46
47const GUIDANCE = [
48 'TraceGraph is active. Before multi-step work (3 or more steps), first call',
49 `${PLAN_TOOL} to declare the plan as a control block diagram: the steps, the`,
50 'forward flow, feedforward arrows (a step that feeds a later one directly) and',
51 'feedback loops (where a failed check sends work back, e.g. "tests fail" from',
52 'test to implement). While working, call',
53 `${STEP_TOOL} with status active when a step starts, done when it ends, and failed`,
54 'when its check fails (this fires its feedback loop). Keep labels 2-4 words.',
55].join(' ')
56
57const events = atom({ plugin: 'tracegraph', key: 'events' } as const, [])
58const ui = atom({ plugin: 'tracegraph', key: 'ui' } as const, {
59 view: 'combined',
60 selected: 0,
61 detail: false,
62})
63
64// Serializes JSONL writes so a slow write never overtakes a newer one.
65let writing: Promise<void> = Promise.resolve()
66
67function persist($: EngineInterface, all: readonly GraphEvent[]): void {
68 writing = writing
69 .then(async () => {
70 const sid = await $.session.id()
71 await $.fs.write(`.claude/graph/${sid}.jsonl`, toJsonl(all))
72 })
73 .catch(() => undefined)
74}
75
76async function record($: EngineInterface, evs: GraphEvent[]): Promise<void> {
77 if (evs.length === 0) return
78 const all = await update($, events, list => [...list, ...evs].slice(-MAX_EVENTS))
79 persist($, all)
80}
81
82function setUi($: EngineInterface, fn: (u: PaneUi) => PaneUi): Promise<PaneUi> {
83 return update($, ui, fn)
84}
85
86export const register: Register = on => {
87 on('session.start', async ($, e, next) => {
88 await $.command.register({
89 name: 'graph',
90 description: 'Open the TraceGraph pane: /graph [plan|combined|tasks|code|demo]',
91 })
92 await $.tool.register({
93 name: 'plan',
94 description:
95 'Declare the plan for the work you are about to do, as a control block diagram (steps, flow, feedforward, feedback). Shown live to the user in the TraceGraph pane. Call again to replace the plan.',
96 inputSchema: PLAN_SCHEMA,
97 })
98 await $.tool.register({
99 name: 'step',
100 description:
101 'Report a plan step\'s state: active, done, or failed (failed fires its feedback loops). Call when a step starts and when it ends.',
102 inputSchema: STEP_SCHEMA,
103 })
104 return next(e)
105 })
106
107 on('prompt.compose', async ($, e, next) => {
108 const composed = await next(e)
109 return { sections: [...composed.sections, { id: 'tracegraph:plan', text: GUIDANCE, scope: 'session' as const }] }
110 })
111
112 on('tool.call', { tool: /^mcp__tracegraph__plan$/ }, async ($, e) => {
113 const plan = parsePlan(e)
114 if (typeof plan === 'string') return { result: `Plan rejected: ${plan}`, isError: true }
115 const { planId, events: evs } = planEvents(plan, await $.clock.now())
116 await record($, evs)
117 await setUi($, u => ({ ...u, view: 'plan', selected: 0, detail: false }))
118 void $.ui.open({ id: PANE, title: 'TraceGraph' })
119 return { result: `Plan "${planId}" drawn with ${plan.steps.length} steps. Report progress with ${STEP_TOOL}.` }
120 })
121
122 on('tool.call', { tool: /^mcp__tracegraph__step$/ }, async ($, e) => {
123 const step = parseStep(e)
124 if (typeof step === 'string') return { result: `Step rejected: ${step}`, isError: true }
125 const graph = replay(await read($, events))
126 const planId = currentPlan(graph)
127 if (!planId) return { result: `No plan yet. Call ${PLAN_TOOL} first.`, isError: true }
128 const evs = stepEvents(graph, planId, step, await $.clock.now())
129 if (typeof evs === 'string') return { result: evs, isError: true }
130 await record($, evs)
131 const loops = evs.filter(x => x.type === 'edge.add').length
132 return { result: `Step ${step.id}: ${step.status}${loops ? ` (feedback fired ${loops}x)` : ''}.` }
133 })
134
135 on('classic.SubagentStop', async ($, e, next) => {
136 await record($, [{ type: 'node.update', at: await $.clock.now(), id: `agent:${e.agent_id}`, patch: { status: 'completed' } }])
137 return next(e)
138 })
139
140 on('command.run', { command: 'graph' }, async ($, e) => {
141 if (e.args.trim() === 'demo') {
142 const now = await $.clock.now()
143 const { planId, events: evs } = planEvents(DEMO_PLAN, now)
144 const steps = DEMO_STEPS.reduce<GraphEvent[]>((acc, [id, status], i) => {
145 const more = stepEvents(replay([...evs, ...acc]), planId, { id, status }, now + i + 1)
146 return typeof more === 'string' ? acc : [...acc, ...more]
147 }, [])
148 await record($, [...evs, ...steps])
149 await setUi($, u => ({ ...u, view: 'plan', selected: 0, detail: false }))
150 await $.ui.open({ id: PANE, title: 'TraceGraph' })
151 return { text: 'TraceGraph demo plan drawn.' }
152 }
153 const parsed = parseView(e.args)
154 const hasPlan = currentPlan(replay(await read($, events))) !== undefined
155 const view = e.args.trim() === '' && hasPlan ? 'plan' : parsed
156 if (!view) return { text: `Unknown view "${e.args}". Use one of: ${VIEWS.join(', ')}.` }
157 await setUi($, u => ({ ...u, view, selected: 0, detail: false }))
158 await $.ui.open({ id: PANE, title: 'TraceGraph' })
159 return { text: `TraceGraph opened (${view} view).` }
160 })
161
162 on('tool.call', async ($, e, next) => {
163 const { tool, tool_use_id, agentId, ...input } = e as typeof e & Record<string, unknown>
164 const call: ObservedCall = {
165 tool,
166 id: tool_use_id,
167 agentId,
168 input,
169 cwd: await $.session.cwd(),
170 at: await $.clock.now(),
171 }
172 await record($, onStart(call))
173 const ran = await next(e)
174 await record(
175 $,
176 onEnd(
177 { ...call, at: await $.clock.now() },
178 { denied: ran.deny !== undefined, isError: ran.isError === true, result: ran.result },
179 ),
180 )
181 return ran
182 })
183
184 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
185 const { Box, Text, Button } = $.ui.resolve(e)
186 const state = await read($, ui)
187 const graph = replay(await read($, events))
188 const nodes = visibleNodes(graph, state.view)
189 const sel = clampIndex(state.selected, nodes.length)
190 const current = nodes[sel]
191 const rows = Math.max(3, (e.viewport?.rows ?? 24) - 8)
192 const start = Math.max(0, Math.min(sel - Math.floor(rows / 2), nodes.length - rows))
193
194 const move = (d: number) => () => setUi($, u => ({ ...u, selected: clampIndex(u.selected + d, nodes.length) }))
195 const show = (view: PaneUi['view']) => () => setUi($, u => ({ ...u, view, selected: 0, detail: false }))
196
197 const toolbar = (
198 <Box key="bar" flexDirection="row" gap={1}>
199 <Button key="up" hotkey="k" plain label="prev" onPress={move(-1)} />
200 <Button key="down" hotkey="j" plain label="next" onPress={move(1)} />
201 <Button key="detail" hotkey="d" plain label={state.detail ? 'list' : 'detail'} onPress={() => setUi($, u => ({ ...u, detail: !u.detail }))} />
202 <Button key="p" hotkey="p" plain label="plan" onPress={show('plan')} />
203 <Button key="t" hotkey="t" plain label="tasks" onPress={show('tasks')} />
204 <Button key="c" hotkey="c" plain label="code" onPress={show('code')} />
205 <Button key="g" hotkey="g" plain label="all" onPress={show('combined')} />
206 </Box>
207 )
208
209 if (state.view === 'plan' && !state.detail) {
210 const planId = currentPlan(graph)
211 const title = planId ? graph.nodes[`plan:${planId}`]?.label : undefined
212 const lines = planId ? drawPlan(graph, planId, Math.max(20, (e.viewport?.columns ?? 80) - 2)) : []
213 return (
214 <Box flexDirection="column">
215 <Text bold>{title ?? 'No plan yet'}</Text>
216 {!planId && <Text dimColor>Claude draws a plan here before multi-step work (tool: plan).</Text>}
217 {lines.map((line, i) => (
218 <Text key={`l${i}`} wrap="truncate">{line || ' '}</Text>
219 ))}
220 <Text dimColor>○ planned ● active ✓ done ✕ failed · ┄ stated by Claude, not observed</Text>
221 {toolbar}
222 </Box>
223 )
224 }
225
226 if (state.detail && current) {
227 const links = neighbors(graph, current.id)
228 return (
229 <Box flexDirection="column">
230 <Text bold>
231 {mark(current)} {current.label} [{current.kind.toUpperCase()}]
232 </Text>
233 <Text dimColor>
234 id {current.id} · {current.status ?? 'no status'} · {current.source}
235 </Text>
236 {current.file && <Text>{displayPath(current.file)}{current.line ? `:${current.line}` : ''}</Text>}
237 {Object.entries(current.metadata ?? {})
238 .filter(([, v]) => v !== null && v !== '')
239 .map(([k, v]) => (
240 <Text key={`m-${k}`} dimColor>
241 {k}: {String(v)}
242 </Text>
243 ))}
244 <Text bold>Edges ({links.length})</Text>
245 {links.length === 0 && <Text dimColor>none</Text>}
246 {links.slice(0, rows).map(r => (
247 <Text key={r.edge.id}>{edgeLabel(r)}</Text>
248 ))}
249 {toolbar}
250 </Box>
251 )
252 }
253
254 return (
255 <Box flexDirection="column">
256 <Text dimColor>
257 {state.view} · {summary(graph)}
258 </Text>
259 {nodes.length === 0 && <Text dimColor>No activity yet. Graph fills as Claude reads, edits and spawns agents.</Text>}
260 {nodes.slice(start, start + rows).map((n, i) => {
261 const out = neighbors(graph, n.id).filter(r => r.dir === 'out').length
262 const isSel = start + i === sel
263 return (
264 <Text key={n.id} bold={isSel} inverse={isSel}>
265 {mark(n)} {n.kind.padEnd(5)} {n.file ? displayPath(n.file) : n.label}
266 {n.metadata?.tool ? ` ⟨${n.metadata.tool}⟩` : ''}
267 {out ? ` →${out}` : ''}
268 </Text>
269 )
270 })}
271 {toolbar}
272 </Box>
273 )
274 })
275}
276src/graph/store.ts 91 lines1// Pure, immutable reducer: replays GraphEvents into a Graph.
2import type { Graph, GraphEdge, GraphEvent, GraphNode, Meta } from './model'
3import { EMPTY_GRAPH, edgeId } from './model'
4
5const mergeMeta = (a?: Meta, b?: Meta): Meta | undefined =>
6 a === undefined && b === undefined ? undefined : { ...a, ...b }
7
8const addNode = (g: Graph, ev: Extract<GraphEvent, { type: 'node.add' }>): Graph => {
9 const old = g.nodes[ev.node.id]
10 const node: GraphNode = old
11 ? {
12 ...old,
13 ...ev.node,
14 createdAt: old.createdAt,
15 updatedAt: ev.at,
16 metadata: mergeMeta(old.metadata, ev.node.metadata),
17 }
18 : { ...ev.node, createdAt: ev.at, updatedAt: ev.at }
19 return { ...g, nodes: { ...g.nodes, [node.id]: node } }
20}
21
22const updateNode = (g: Graph, ev: Extract<GraphEvent, { type: 'node.update' }>): Graph => {
23 const old = g.nodes[ev.id]
24 if (!old) return g
25 const node: GraphNode = {
26 ...old,
27 ...ev.patch,
28 updatedAt: ev.at,
29 metadata: mergeMeta(old.metadata, ev.patch.metadata),
30 }
31 return { ...g, nodes: { ...g.nodes, [node.id]: node } }
32}
33
34const addEdge = (g: Graph, ev: Extract<GraphEvent, { type: 'edge.add' }>): Graph => {
35 // Edges only connect known nodes: no invented endpoints.
36 if (!g.nodes[ev.from] || !g.nodes[ev.to]) return g
37 const id = edgeId(ev.from, ev.kind, ev.to)
38 const old = g.edges[id]
39 const edge: GraphEdge = old
40 ? { ...old, count: old.count + 1, metadata: mergeMeta(old.metadata, ev.metadata) }
41 : {
42 id,
43 from: ev.from,
44 to: ev.to,
45 kind: ev.kind,
46 source: ev.source,
47 createdAt: ev.at,
48 count: 1,
49 metadata: ev.metadata,
50 }
51 return { ...g, edges: { ...g.edges, [id]: edge } }
52}
53
54const alias = (g: Graph, ev: Extract<GraphEvent, { type: 'node.alias' }>): Graph => {
55 const src = g.nodes[ev.from]
56 if (!src || ev.from === ev.to) return g
57 const dst = g.nodes[ev.to]
58 const merged: GraphNode = dst
59 ? { ...dst, ...src, id: ev.to, createdAt: Math.min(src.createdAt, dst.createdAt), metadata: mergeMeta(src.metadata, dst.metadata) }
60 : { ...src, id: ev.to }
61 const { [ev.from]: _gone, ...rest } = g.nodes
62 const nodes = { ...rest, [ev.to]: merged }
63 const edges = Object.values(g.edges).reduce<Record<string, GraphEdge>>((acc, e) => {
64 const from = e.from === ev.from ? ev.to : e.from
65 const to = e.to === ev.from ? ev.to : e.to
66 const id = edgeId(from, e.kind, to)
67 const prev = acc[id]
68 acc[id] = prev ? { ...prev, count: prev.count + e.count } : { ...e, id, from, to }
69 return acc
70 }, {})
71 return { nodes, edges }
72}
73
74export const apply = (g: Graph, ev: GraphEvent): Graph => {
75 switch (ev.type) {
76 case 'node.add':
77 return addNode(g, ev)
78 case 'node.update':
79 return updateNode(g, ev)
80 case 'edge.add':
81 return addEdge(g, ev)
82 case 'node.alias':
83 return alias(g, ev)
84 }
85}
86
87export const replay = (events: readonly GraphEvent[]): Graph => events.reduce(apply, EMPTY_GRAPH)
88
89export const toJsonl = (events: readonly GraphEvent[]): string =>
90 events.map(ev => JSON.stringify(ev)).join('\n') + (events.length ? '\n' : '')
91src/observers/tools.ts 191 lines1// Turns one observed tool call into GraphEvents. Pure: no engine access.
2// Only facts Claude Code reported are recorded (source "observed").
3import type { GraphEvent, Meta } from '../graph/model'
4import { MAIN_AGENT } from '../graph/model'
5
6export interface ObservedCall {
7 tool: string
8 id: string
9 agentId?: string
10 input: Record<string, unknown>
11 cwd: string
12 at: number
13}
14
15export interface ObservedResult {
16 isError: boolean
17 denied: boolean
18 result: unknown
19}
20
21const READS = new Set(['Read', 'NotebookRead'])
22const WRITES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
23
24const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
25
26export const agentNodeId = (agentId?: string): string =>
27 agentId ? `agent:${agentId}` : MAIN_AGENT
28
29export const relPath = (path: string, cwd: string): string => {
30 const base = cwd.endsWith('/') ? cwd : `${cwd}/`
31 return path.startsWith(base) ? path.slice(base.length) : path
32}
33
34const basename = (p: string): string => p.split('/').filter(Boolean).pop() ?? p
35
36const lineCount = (s: string | undefined): number => (s ? s.split('\n').length : 0)
37
38const writeStats = (tool: string, input: Record<string, unknown>): Meta => {
39 if (tool === 'Write') return { added: lineCount(str(input.content)), removed: 0 }
40 if (tool === 'Edit')
41 return { added: lineCount(str(input.new_string)), removed: lineCount(str(input.old_string)) }
42 return {}
43}
44
45const ensureAgent = (id: string, at: number): GraphEvent => ({
46 type: 'node.add',
47 at,
48 node: {
49 id,
50 kind: 'agent',
51 label: id === MAIN_AGENT ? 'main' : id.slice('agent:'.length),
52 source: 'observed',
53 },
54})
55
56/** Events emitted when a call starts (before the tool runs). */
57export const onStart = (c: ObservedCall): GraphEvent[] => {
58 const actor = agentNodeId(c.agentId)
59 const base: GraphEvent[] = [
60 ensureAgent(actor, c.at),
61 { type: 'node.update', at: c.at, id: actor, patch: { status: 'running', metadata: { tool: c.tool } } },
62 ]
63 if (c.tool === 'Agent') {
64 const id = `agent:call:${c.id}`
65 return [
66 ...base,
67 {
68 type: 'node.add',
69 at: c.at,
70 node: {
71 id,
72 kind: 'agent',
73 label: str(c.input.description) ?? 'subagent',
74 status: 'running',
75 source: 'observed',
76 metadata: { type: str(c.input.subagent_type) ?? 'general-purpose' },
77 },
78 },
79 { type: 'edge.add', at: c.at, from: actor, to: id, kind: 'spawned', source: 'observed' },
80 ]
81 }
82 return base
83}
84
85const fileEvents = (c: ObservedCall, kind: 'reads' | 'writes'): GraphEvent[] => {
86 const path = str(c.input.file_path) ?? str(c.input.notebook_path)
87 if (!path) return []
88 const rel = relPath(path, c.cwd)
89 const id = `file:${rel}`
90 return [
91 {
92 type: 'node.add',
93 at: c.at,
94 node: { id, kind: 'file', label: basename(rel), file: rel, source: 'observed' },
95 },
96 {
97 type: 'edge.add',
98 at: c.at,
99 from: agentNodeId(c.agentId),
100 to: id,
101 kind,
102 source: 'observed',
103 metadata: kind === 'writes' ? writeStats(c.tool, c.input) : undefined,
104 },
105 ]
106}
107
108const taskId = (result: unknown): string | undefined => {
109 const r = result as { task?: { id?: unknown }; id?: unknown } | undefined
110 const raw = r?.task?.id ?? r?.id
111 return typeof raw === 'string' || typeof raw === 'number' ? String(raw) : undefined
112}
113
114const taskEvents = (c: ObservedCall, r: ObservedResult): GraphEvent[] => {
115 const owner = agentNodeId(c.agentId)
116 if (c.tool === 'TaskCreate') {
117 const tid = taskId(r.result)
118 if (!tid) return []
119 const id = `task:${tid}`
120 return [
121 {
122 type: 'node.add',
123 at: c.at,
124 node: {
125 id,
126 kind: 'task',
127 label: str(c.input.subject) ?? `task ${tid}`,
128 status: 'pending',
129 source: 'observed',
130 metadata: { description: str(c.input.description) ?? '' },
131 },
132 },
133 { type: 'edge.add', at: c.at, from: owner, to: id, kind: 'spawned', source: 'observed' },
134 ]
135 }
136 if (c.tool === 'TaskUpdate') {
137 const tid = str(c.input.taskId)
138 if (!tid) return []
139 const id = `task:${tid}`
140 const status = str(c.input.status)
141 const subject = str(c.input.subject)
142 const deps = Array.isArray(c.input.addBlockedBy) ? c.input.addBlockedBy.map(String) : []
143 const blocks = Array.isArray(c.input.addBlocks) ? c.input.addBlocks.map(String) : []
144 return [
145 { type: 'node.update', at: c.at, id, patch: { ...(status && { status }), ...(subject && { label: subject }) } },
146 ...deps.map((d): GraphEvent => ({ type: 'edge.add', at: c.at, from: id, to: `task:${d}`, kind: 'depends_on', source: 'observed' })),
147 ...blocks.map((b): GraphEvent => ({ type: 'edge.add', at: c.at, from: `task:${b}`, to: id, kind: 'depends_on', source: 'observed' })),
148 ]
149 }
150 if (c.tool === 'TodoWrite' && Array.isArray(c.input.todos)) {
151 return (c.input.todos as Array<{ content?: unknown; status?: unknown }>).flatMap((t, i): GraphEvent[] => {
152 const content = str(t.content)
153 if (!content) return []
154 const id = `todo:${content}`
155 return [
156 {
157 type: 'node.add',
158 at: c.at,
159 node: { id, kind: 'task', label: content, status: str(t.status) ?? 'pending', source: 'observed', metadata: { order: i } },
160 },
161 { type: 'edge.add', at: c.at, from: owner, to: id, kind: 'spawned', source: 'observed' },
162 ]
163 })
164 }
165 return []
166}
167
168/** Events emitted when a call finishes. */
169export const onEnd = (c: ObservedCall, r: ObservedResult): GraphEvent[] => {
170 const actor = agentNodeId(c.agentId)
171 const idle: GraphEvent = { type: 'node.update', at: c.at, id: actor, patch: { status: 'idle', metadata: { tool: null } } }
172 if (r.denied) return [idle]
173
174 if (c.tool === 'Agent') {
175 const placeholder = `agent:call:${c.id}`
176 const rec = r.result as { agentId?: unknown; status?: unknown } | undefined
177 const real = typeof rec?.agentId === 'string' ? `agent:${rec.agentId}` : undefined
178 const background = rec?.status === 'async_launched'
179 const status = r.isError ? 'failed' : background ? 'running' : 'completed'
180 return [
181 { type: 'node.update', at: c.at, id: placeholder, patch: { status } },
182 ...(real ? [{ type: 'node.alias', at: c.at, from: placeholder, to: real } as GraphEvent] : []),
183 idle,
184 ]
185 }
186 if (r.isError) return [idle]
187 if (READS.has(c.tool)) return [...fileEvents(c, 'reads'), idle]
188 if (WRITES.has(c.tool)) return [...fileEvents(c, 'writes'), idle]
189 return [...taskEvents(c, r), idle]
190}
191src/observers/plan.ts 155 lines1// The plan graph: Claude declares the work as a control block diagram before
2// doing it, then reports each step's state. Every node and edge here is the
3// agent's own statement (source "agent"), never an observed fact.
4import type { EdgeKind, Graph, GraphEvent } from '../graph/model'
5
6export type StepStatus = 'planned' | 'active' | 'done' | 'failed'
7
8export interface PlanInput {
9 title: string
10 steps: { id: string; label: string }[]
11 links?: { from: string; to: string; kind?: 'flow' | 'feedforward' | 'feedback'; label?: string }[]
12}
13
14export interface StepInput {
15 id: string
16 status: StepStatus
17 note?: string
18}
19
20const STATUSES: readonly StepStatus[] = ['planned', 'active', 'done', 'failed']
21
22export const PLAN_SCHEMA = {
23 type: 'object',
24 properties: {
25 title: { type: 'string', description: 'What the plan achieves' },
26 steps: {
27 type: 'array',
28 description: 'Blocks of the diagram, in execution order',
29 items: {
30 type: 'object',
31 properties: {
32 id: { type: 'string', description: 'Short id, e.g. "impl"' },
33 label: { type: 'string', description: 'Block text, 2-4 words' },
34 },
35 required: ['id', 'label'],
36 },
37 },
38 links: {
39 type: 'array',
40 description:
41 'Arrows. flow: normal forward path (default: consecutive steps). feedforward: a step that feeds a later step directly. feedback: loop back when a check fails, e.g. tests → impl.',
42 items: {
43 type: 'object',
44 properties: {
45 from: { type: 'string' },
46 to: { type: 'string' },
47 kind: { type: 'string', enum: ['flow', 'feedforward', 'feedback'] },
48 label: { type: 'string', description: 'Condition, e.g. "tests fail"' },
49 },
50 required: ['from', 'to'],
51 },
52 },
53 },
54 required: ['title', 'steps'],
55} as const
56
57export const STEP_SCHEMA = {
58 type: 'object',
59 properties: {
60 id: { type: 'string', description: 'Step id from the plan' },
61 status: { type: 'string', enum: STATUSES },
62 note: { type: 'string', description: 'Short result or reason' },
63 },
64 required: ['id', 'status'],
65} as const
66
67export const slug = (s: string): string =>
68 s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'plan'
69
70export const stepNodeId = (planId: string, stepId: string): string => `plan:${planId}:${stepId}`
71
72const isObj = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null
73
74/** Validates the model's input. Returns an error message or the parsed plan. */
75export const parsePlan = (raw: unknown): PlanInput | string => {
76 if (!isObj(raw) || typeof raw.title !== 'string' || !raw.title.trim()) return 'title is required'
77 if (!Array.isArray(raw.steps) || raw.steps.length === 0) return 'steps must be a non-empty array'
78 const steps = raw.steps.filter(isObj).map(s => ({ id: String(s.id ?? ''), label: String(s.label ?? '') }))
79 if (steps.some(s => !s.id || !s.label)) return 'every step needs id and label'
80 const ids = new Set(steps.map(s => s.id))
81 if (ids.size !== steps.length) return 'step ids must be unique'
82 const links = (Array.isArray(raw.links) ? raw.links : []).filter(isObj).map(l => ({
83 from: String(l.from ?? ''),
84 to: String(l.to ?? ''),
85 kind: (['flow', 'feedforward', 'feedback'].includes(String(l.kind)) ? l.kind : 'flow') as 'flow' | 'feedforward' | 'feedback',
86 label: typeof l.label === 'string' ? l.label : undefined,
87 }))
88 const bad = links.find(l => !ids.has(l.from) || !ids.has(l.to))
89 if (bad) return `link ${bad.from} → ${bad.to} names an unknown step`
90 return { title: raw.title.trim(), steps, links }
91}
92
93export const parseStep = (raw: unknown): StepInput | string => {
94 if (!isObj(raw) || typeof raw.id !== 'string') return 'id is required'
95 if (!STATUSES.includes(raw.status as StepStatus)) return `status must be one of ${STATUSES.join(', ')}`
96 return { id: raw.id, status: raw.status as StepStatus, note: typeof raw.note === 'string' ? raw.note : undefined }
97}
98
99export const planEvents = (plan: PlanInput, at: number): { planId: string; events: GraphEvent[] } => {
100 const planId = slug(plan.title)
101 const root = `plan:${planId}`
102 const explicitFlow = (plan.links ?? []).some(l => (l.kind ?? 'flow') === 'flow')
103 // Without explicit flow arrows, consecutive steps form the forward path.
104 const implicit = explicitFlow
105 ? []
106 : plan.steps.slice(1).map((s, i) => ({ from: plan.steps[i]!.id, to: s.id, kind: 'flow' as const, label: undefined }))
107 const links = [...implicit, ...(plan.links ?? [])]
108 const events: GraphEvent[] = [
109 { type: 'node.add', at, node: { id: root, kind: 'requirement', label: plan.title, source: 'agent', status: 'active', metadata: { plan: planId } } },
110 ...plan.steps.map((s, i): GraphEvent => ({
111 type: 'node.add',
112 at: at + i / 1000,
113 node: { id: stepNodeId(planId, s.id), kind: 'task', label: s.label, source: 'agent', status: 'planned', metadata: { plan: planId, step: s.id, order: i } },
114 })),
115 ...plan.steps.map((s): GraphEvent => ({ type: 'edge.add', at, from: root, to: stepNodeId(planId, s.id), kind: 'implements', source: 'agent' })),
116 ...links.map((l): GraphEvent => ({
117 type: 'edge.add',
118 at,
119 from: stepNodeId(planId, l.from),
120 to: stepNodeId(planId, l.to),
121 kind: (l.kind ?? 'flow') as EdgeKind,
122 source: 'agent',
123 metadata: l.label ? { label: l.label, fired: 0 } : { fired: 0 },
124 })),
125 ]
126 return { planId, events }
127}
128
129/** Latest plan id in the graph, or undefined. */
130export const currentPlan = (g: Graph): string | undefined =>
131 Object.values(g.nodes)
132 .filter(n => n.kind === 'requirement' && n.id.startsWith('plan:') && typeof n.metadata?.plan === 'string')
133 .sort((a, b) => b.createdAt - a.createdAt)[0]?.metadata?.plan as string | undefined
134
135/**
136 * A failed step fires its feedback arrows: the loop counter goes up and the
137 * target step is set active again.
138 */
139export const stepEvents = (g: Graph, planId: string, step: StepInput, at: number): GraphEvent[] | string => {
140 const id = stepNodeId(planId, step.id)
141 if (!g.nodes[id]) return `unknown step "${step.id}" in plan ${planId}`
142 const base: GraphEvent[] = [
143 { type: 'node.update', at, id, patch: { status: step.status, metadata: step.note ? { note: step.note } : {} } },
144 ]
145 if (step.status !== 'failed') return base
146 const loops = Object.values(g.edges).filter(e => e.from === id && e.kind === 'feedback')
147 return [
148 ...base,
149 ...loops.flatMap((e): GraphEvent[] => [
150 { type: 'edge.add', at, from: e.from, to: e.to, kind: 'feedback', source: 'agent', metadata: { fired: Number(e.metadata?.fired ?? 0) + 1 } },
151 { type: 'node.update', at, id: e.to, patch: { status: 'active' } },
152 ]),
153 ]
154}
155src/ui/diagram.ts 245 lines1// Draws a plan as a control block diagram in monospace text. Pure.
2//
3// ┌──────── feedforward ────────┐
4// ┌─────┴┐ ┌───────┐ ┌───────┐ ┌▼──────┐
5// │✓ spec│──▶│● impl │──▶│✕ test │──▶│○ ship │
6// └──────┘ └───▲───┘ └───┬───┘ └───────┘
7// └───────────┘ tests fail ×2
8//
9// Feedforward arrows run above the blocks, feedback loops below. The loops are
10// drawn only for a chain (one block per column); otherwise they are listed.
11import type { Graph, GraphEdge, GraphNode } from '../graph/model'
12
13const GAP = '──▶'
14
15// What a cell becomes when a loop drawn further out passes down through it.
16const JOIN: Record<string, string> = { ' ': '│', '─': '┼', '└': '├', '┘': '┤', '┌': '├', '┐': '┤' }
17
18const isWide = (cp: number): boolean =>
19 (cp >= 0x1100 && cp <= 0x115f) ||
20 (cp >= 0x2e80 && cp <= 0xa4cf) ||
21 (cp >= 0xac00 && cp <= 0xd7a3) ||
22 (cp >= 0xf900 && cp <= 0xfaff) ||
23 (cp >= 0xfe30 && cp <= 0xfe4f) ||
24 (cp >= 0xff00 && cp <= 0xff60) ||
25 (cp >= 0xffe0 && cp <= 0xffe6) ||
26 (cp >= 0x1f300 && cp <= 0x1faff)
27
28/** Terminal cell width: CJK and Hangul take two cells. */
29export const cellWidth = (s: string): number => [...s].reduce((n, ch) => n + (isWide(ch.codePointAt(0)!) ? 2 : 1), 0)
30
31
32const clip = (s: string, w: number): string => {
33 if (cellWidth(s) <= w) return s
34 let out = ''
35 for (const ch of s) {
36 if (cellWidth(out + ch) > w - 1) break
37 out += ch
38 }
39 return `${out}…`
40}
41
42const STATUS: Record<string, string> = { planned: '○', active: '●', done: '✓', failed: '✕' }
43const MAX_LABEL = 18
44
45export interface Block {
46 id: string
47 text: string
48 col: number
49 row: number
50}
51
52export interface Layout {
53 blocks: Block[]
54 columns: Block[][]
55 forward: GraphEdge[]
56 feedforward: GraphEdge[]
57 feedback: GraphEdge[]
58}
59
60export const planSteps = (g: Graph, planId: string): GraphNode[] =>
61 Object.values(g.nodes)
62 .filter(n => n.kind === 'task' && n.metadata?.plan === planId)
63 .sort((a, b) => Number(a.metadata?.order ?? 0) - Number(b.metadata?.order ?? 0))
64
65/** Columns by longest forward path (flow + feedforward); feedback is ignored for layering. */
66export const layout = (g: Graph, planId: string): Layout => {
67 const steps = planSteps(g, planId)
68 const ids = new Set(steps.map(s => s.id))
69 const edges = Object.values(g.edges).filter(e => ids.has(e.from) && ids.has(e.to))
70 const forward = edges.filter(e => e.kind === 'flow')
71 const feedforward = edges.filter(e => e.kind === 'feedforward')
72 const feedback = edges.filter(e => e.kind === 'feedback')
73 const col = new Map(steps.map(s => [s.id, 0]))
74 // Bellman-style relaxation, bounded by the step count so a cycle cannot hang.
75 for (let i = 0; i < steps.length; i++) {
76 for (const e of [...forward, ...feedforward]) {
77 const next = (col.get(e.from) ?? 0) + 1
78 if (next > (col.get(e.to) ?? 0) && next < steps.length) col.set(e.to, next)
79 }
80 }
81 const columns: Block[][] = []
82 const blocks = steps.map(s => {
83 const c = col.get(s.id) ?? 0
84 const column = (columns[c] ??= [])
85 const block: Block = { id: s.id, text: `${STATUS[s.status ?? ''] ?? '○'} ${clip(s.label, MAX_LABEL)}`, col: c, row: column.length }
86 column.push(block)
87 return block
88 })
89 return { blocks, columns: columns.filter(c => c.length > 0), forward, feedforward, feedback }
90}
91
92const fired = (e: GraphEdge): string => {
93 const n = Number(e.metadata?.fired ?? 0)
94 return n > 0 ? ` ×${n}` : ''
95}
96
97const label = (e: GraphEdge): string => (typeof e.metadata?.label === 'string' ? ` ${e.metadata.label}` : '')
98
99const quoted = (e: GraphEdge): string => (typeof e.metadata?.label === 'string' ? ` "${e.metadata.label}"` : '')
100
101const put = (line: string[], x: number, ch: string) => {
102 if (x >= 0 && x < line.length) line[x] = ch
103}
104
105/** Draws loops between block centres on lines above (feedforward) or below (feedback). */
106const loopLines = (
107 loops: { a: number; b: number; text: string }[],
108 width: number,
109 above: boolean,
110): string[] => {
111 const sorted = [...loops].sort((p, q) => Math.abs(p.b - p.a) - Math.abs(q.b - q.a))
112 const lines = sorted.map((loop, k) => {
113 const row = Array.from({ length: width }, () => ' ')
114 const lo = Math.min(loop.a, loop.b)
115 const hi = Math.max(loop.a, loop.b)
116 for (let x = lo; x <= hi; x++) row[x] = '─'
117 put(row, lo, above ? '┌' : '└')
118 put(row, hi, above ? '┐' : '┘')
119 // Loops drawn further out pass through this line.
120 for (const outer of sorted.slice(k + 1)) {
121 for (const x of [outer.a, outer.b]) row[x] = JOIN[row[x] ?? ' '] ?? row[x]!
122 }
123 return `${row.join('').trimEnd()}${loop.text}`
124 })
125 return above ? lines.reverse() : lines
126}
127
128/** Monospace lines of the diagram, or a vertical list when it does not fit. */
129export const drawPlan = (g: Graph, planId: string, cols: number): string[] => {
130 const lay = layout(g, planId)
131 if (lay.blocks.length === 0) return []
132 const widths = lay.columns.map(c => Math.max(...c.map(b => cellWidth(b.text))) + 2)
133 const total = widths.reduce((a, w) => a + w + 2, 0) + GAP.length * (widths.length - 1)
134 if (total > cols) return drawVertical(lay)
135
136 const x0 = widths.map((_, i) => widths.slice(0, i).reduce((a, w) => a + w + 2 + GAP.length, 0))
137 const centre = (id: string): number => {
138 const b = lay.blocks.find(x => x.id === id)!
139 return x0[b.col]! + Math.floor((widths[b.col]! + 2) / 2)
140 }
141 const isChain = lay.columns.every(c => c.length === 1)
142 const backLoops = isChain ? lay.feedback.filter(e => centre(e.to) < centre(e.from)) : []
143 const ffLoops = isChain ? lay.feedforward : []
144 const below = loopLines(backLoops.map(e => ({ a: centre(e.to), b: centre(e.from), text: `${label(e)}${fired(e)}` })), total, false)
145 const above = loopLines(ffLoops.map(e => ({ a: centre(e.from), b: centre(e.to), text: label(e) })), total, true)
146
147 const rows = Math.max(...lay.columns.map(c => c.length))
148 const grid = Array.from({ length: rows * 3 }, () => Array.from({ length: total }, () => ' '))
149 lay.blocks.forEach(b => {
150 const w = widths[b.col]!
151 const x = x0[b.col]!
152 const y = b.row * 3
153 for (let i = 0; i < w + 2; i++) {
154 grid[y]![x + i] = i === 0 ? '┌' : i === w + 1 ? '┐' : '─'
155 grid[y + 2]![x + i] = i === 0 ? '└' : i === w + 1 ? '┘' : '─'
156 }
157 grid[y + 1]![x] = '│'
158 grid[y + 1]![x + w + 1] = '│'
159 // A wide character fills two cells: the second holds '' so join() keeps the width.
160 let cx = x + 2
161 for (const ch of b.text) {
162 grid[y + 1]![cx] = ch
163 if (cellWidth(ch) === 2) grid[y + 1]![cx + 1] = ''
164 cx += cellWidth(ch)
165 }
166 })
167 const drawn = drawBuses(lay, grid, x0.map((x, i) => x + widths[i]! + 2))
168 for (const e of ffLoops) {
169 put(grid[0]!, centre(e.from), '┴')
170 put(grid[0]!, centre(e.to), '▼')
171 }
172 for (const e of backLoops) {
173 put(grid[2]!, centre(e.to), '▲')
174 put(grid[2]!, centre(e.from), '┬')
175 }
176 const out: string[] = [...above, ...grid.map(row => row.join('').trimEnd())]
177 out.push(...below)
178 return [...out, ...listed(lay, isChain ? [] : lay.feedforward, isChain ? lay.feedback.filter(e => !backLoops.includes(e)) : lay.feedback, drawn)]
179}
180
181const nameOf = (lay: Layout, id: string): string => lay.blocks.find(b => b.id === id)?.text.slice(2) ?? id
182
183/** Links that the boxes cannot show are listed under the diagram. */
184const listed = (lay: Layout, ff: GraphEdge[], fb: GraphEdge[], drawn: ReadonlySet<GraphEdge>): string[] => {
185 const skipped = lay.forward.filter(e => !drawn.has(e))
186 const lines = [
187 ...skipped.map(e => ` → ${nameOf(lay, e.from)} ──▶ ${nameOf(lay, e.to)}${quoted(e)}`),
188 ...ff.map(e => ` ⇢ feedforward ${nameOf(lay, e.from)} ──▶ ${nameOf(lay, e.to)}${quoted(e)}`),
189 ...fb.map(e => ` ↺ feedback ${nameOf(lay, e.from)} ──▶ ${nameOf(lay, e.to)}${quoted(e)}${fired(e)}`),
190 ]
191 return lines.length ? ['', ...lines] : []
192}
193
194const drawVertical = (lay: Layout): string[] => {
195 const order = [...lay.blocks].sort((a, b) => a.col - b.col || a.row - b.row)
196 const lines = order.flatMap((b, i) => [`[${b.text}]`, ...(i < order.length - 1 ? [' │', ' ▼'] : [])])
197 return [...lines, ...listed(lay, lay.feedforward, lay.feedback, new Set(lay.forward))]
198}
199
200// Box-drawing glyph for a bus cell, keyed by up, down, left, right.
201const BUS: Record<string, string> = {
202 '0011': '─', '0001': '─', '0010': '─', '1100': '│', '0101': '┌', '1001': '└', '0110': '┐', '1010': '┘',
203 '1101': '├', '1110': '┤', '0111': '┬', '1011': '┴', '1111': '┼', '1000': '│', '0100': '│',
204}
205
206/**
207 * Draws forward arrows between adjacent columns in the three-cell gap: sources
208 * enter from the left, a vertical bus joins them, targets leave with ▶.
209 * Edges that share a block form one bus; buses that would overlap are left
210 * for the list. Returns the edges it drew.
211 */
212const drawBuses = (lay: Layout, grid: string[][], gapStart: number[]): Set<GraphEdge> => {
213 const at = new Map(lay.blocks.map(b => [b.id, b]))
214 const drawn = new Set<GraphEdge>()
215 for (let c = 0; c < lay.columns.length - 1; c++) {
216 const edges = lay.forward.filter(e => at.get(e.from)?.col === c && at.get(e.to)?.col === c + 1)
217 // Group edges that share a source or a target.
218 const groups: GraphEdge[][] = []
219 for (const e of edges) {
220 const hit = groups.filter(g => g.some(o => o.from === e.from || o.to === e.to))
221 const merged = [...hit.flat(), e]
222 groups.splice(0, groups.length, ...groups.filter(g => !hit.includes(g)), merged)
223 }
224 const used: [number, number][] = []
225 const x = gapStart[c]!
226 for (const group of groups) {
227 const src = new Set(group.map(e => at.get(e.from)!.row * 3 + 1))
228 const dst = new Set(group.map(e => at.get(e.to)!.row * 3 + 1))
229 const ys = [...src, ...dst]
230 const lo = Math.min(...ys)
231 const hi = Math.max(...ys)
232 if (used.some(([a, b]) => lo <= b && a <= hi)) continue
233 used.push([lo, hi])
234 for (let y = lo; y <= hi; y++) {
235 const key = `${y > lo ? 1 : 0}${y < hi ? 1 : 0}${src.has(y) ? 1 : 0}${dst.has(y) ? 1 : 0}`
236 grid[y]![x] = src.has(y) ? '─' : ' '
237 grid[y]![x + 1] = BUS[key] ?? '│'
238 grid[y]![x + 2] = dst.has(y) ? '▶' : ' '
239 }
240 group.forEach(e => drawn.add(e))
241 }
242 }
243 return drawn
244}
245src/ui/views.ts 96 lines1// View filters over the one graph (DESIGN.md §7) and the text rows a pane draws.
2import type { EdgeKind, Graph, GraphEdge, GraphNode, NodeKind } from '../graph/model'
3
4import type { ViewName } from '../../types'
5
6export type { ViewName }
7
8export const VIEWS: readonly ViewName[] = ['plan', 'combined', 'tasks', 'code']
9
10const VIEW_KINDS: Record<ViewName, ReadonlySet<NodeKind>> = {
11 plan: new Set<NodeKind>(['requirement', 'task']),
12 combined: new Set<NodeKind>(['agent', 'task', 'file', 'decision', 'requirement']),
13 tasks: new Set<NodeKind>(['agent', 'task']),
14 code: new Set<NodeKind>(['agent', 'file', 'symbol']),
15}
16
17const KIND_ORDER: NodeKind[] = ['requirement', 'decision', 'agent', 'task', 'file', 'symbol', 'test', 'tool']
18
19export const parseView = (s: string): ViewName | undefined => {
20 const v = s.trim().toLowerCase()
21 if (v === '' || v === 'all') return 'combined'
22 if (v === 'task') return 'tasks'
23 if (v === 'files' || v === 'changes') return 'code'
24 return (VIEWS as readonly string[]).includes(v) ? (v as ViewName) : undefined
25}
26
27export const STATUS_MARK: Record<string, string> = {
28 running: '●',
29 in_progress: '●',
30 pending: '○',
31 planned: '○',
32 active: '●',
33 done: '✓',
34 idle: '◌',
35 completed: '✓',
36 failed: '✕',
37 deleted: '–',
38}
39
40export const mark = (n: GraphNode): string => (n.status ? STATUS_MARK[n.status] ?? '·' : '·')
41
42/** Nodes visible in a view, ordered by kind then creation time. */
43export const visibleNodes = (g: Graph, view: ViewName): GraphNode[] =>
44 Object.values(g.nodes)
45 .filter(n => VIEW_KINDS[view].has(n.kind))
46 .sort(
47 (a, b) =>
48 KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind) ||
49 a.createdAt - b.createdAt ||
50 a.id.localeCompare(b.id),
51 )
52
53export interface EdgeRow {
54 dir: 'out' | 'in'
55 kind: EdgeKind
56 other: GraphNode
57 edge: GraphEdge
58}
59
60export const neighbors = (g: Graph, id: string): EdgeRow[] =>
61 Object.values(g.edges).flatMap((edge): EdgeRow[] => {
62 const to = g.nodes[edge.to]
63 const from = g.nodes[edge.from]
64 if (edge.from === id && to) return [{ dir: 'out', kind: edge.kind, other: to, edge }]
65 if (edge.to === id && from) return [{ dir: 'in', kind: edge.kind, other: from, edge }]
66 return []
67 })
68
69// "━" observed, "┄" anything interpreted (DESIGN.md §6).
70export const edgeGlyph = (e: GraphEdge): string => (e.source === 'observed' ? '━' : '┄')
71
72export const edgeLabel = (r: EdgeRow): string => {
73 const stats =
74 r.kind === 'writes' && typeof r.edge.metadata?.added === 'number'
75 ? ` +${r.edge.metadata.added} -${r.edge.metadata.removed ?? 0}`
76 : ''
77 const times = r.edge.count > 1 ? ` ×${r.edge.count}` : ''
78 const arrow = r.dir === 'out' ? `${edgeGlyph(r.edge)}${r.kind}▶` : `◀${r.kind}${edgeGlyph(r.edge)}`
79 return `${arrow} ${r.other.kind}:${r.other.file ? displayPath(r.other.file) : r.other.label}${stats}${times}`
80}
81
82export const summary = (g: Graph): string => {
83 const count = (k: NodeKind) => Object.values(g.nodes).filter(n => n.kind === k).length
84 const running = Object.values(g.nodes).filter(n => n.status === 'running' || n.status === 'in_progress').length
85 return `${count('agent')} agents · ${count('task')} tasks · ${count('file')} files · ${running} active`
86}
87
88export const clampIndex = (i: number, len: number): number => (len === 0 ? 0 : Math.max(0, Math.min(len - 1, i)))
89
90/** Project files stay relative; a path outside the project shows its last two segments. */
91export const displayPath = (file: string): string => {
92 if (!file.startsWith('/')) return file
93 const parts = file.split('/').filter(Boolean)
94 return parts.length <= 2 ? file : `…/${parts.slice(-2).join('/')}`
95}
96src/graph/model.ts 21 lines1// Unified graph model (DESIGN.md §5, §16). The types live in the contract.
2import type { EdgeKind, Graph } from '../../types'
3
4export type {
5 EdgeKind,
6 Graph,
7 GraphEdge,
8 GraphEvent,
9 GraphNode,
10 GraphSource,
11 Meta,
12 NodeKind,
13} from '../../types'
14
15export const EMPTY_GRAPH: Graph = { nodes: {}, edges: {} }
16
17export const MAIN_AGENT = 'agent:main'
18
19export const edgeId = (from: string, kind: EdgeKind, to: string): string =>
20 `${from}|${kind}|${to}`
21types/index.d.ts 104 lines1// TraceGraph's type contract: the graph model (DESIGN.md §5, §12, §16) and pane state.
2// Self-contained by rule: no imports.
3
4export type NodeKind =
5 | 'task'
6 | 'agent'
7 | 'file'
8 | 'symbol'
9 | 'decision'
10 | 'requirement'
11 | 'tool'
12 | 'test'
13
14export type EdgeKind =
15 | 'depends_on'
16 | 'spawned'
17 | 'reads'
18 | 'writes'
19 | 'implements'
20 | 'tests'
21 | 'motivated_by'
22 | 'decided'
23 | 'calls'
24 // Plan graph (control block diagram): forward path, skip-ahead path, loop back.
25 | 'flow'
26 | 'feedforward'
27 | 'feedback'
28
29// Observed facts and interpretations must never look the same (DESIGN.md §6).
30export type GraphSource = 'observed' | 'agent' | 'user' | 'static-analysis'
31
32export type Meta = Record<string, string | number | boolean | null>
33
34export interface GraphNode {
35 id: string
36 kind: NodeKind
37 label: string
38 source: GraphSource
39 status?: string
40 createdAt: number
41 updatedAt: number
42 file?: string
43 line?: number
44 metadata?: Meta
45}
46
47export interface GraphEdge {
48 id: string
49 from: string
50 to: string
51 kind: EdgeKind
52 source: GraphSource
53 createdAt: number
54 count: number
55 metadata?: Meta
56}
57
58export interface Graph {
59 nodes: Record<string, GraphNode>
60 edges: Record<string, GraphEdge>
61}
62
63// Event-sourced log entries (DESIGN.md §12). Each line of the JSONL file is one.
64export type GraphEvent =
65 | {
66 type: 'node.add'
67 at: number
68 node: Omit<GraphNode, 'createdAt' | 'updatedAt'>
69 }
70 | {
71 type: 'node.update'
72 at: number
73 id: string
74 patch: Partial<Pick<GraphNode, 'label' | 'status' | 'line'>> & { metadata?: Meta }
75 }
76 | {
77 type: 'edge.add'
78 at: number
79 from: string
80 to: string
81 kind: EdgeKind
82 source: GraphSource
83 metadata?: Meta
84 }
85 // Merges node `from` into node `to`: used when a placeholder (a running
86 // Agent call) learns the spawned agent's real id.
87 | { type: 'node.alias'; at: number; from: string; to: string }
88
89export type ViewName = 'plan' | 'combined' | 'tasks' | 'code'
90
91export type PaneUi = {
92 view: ViewName
93 /** Index of the selected node within the current view. */
94 selected: number
95 /** True when the detail panel of the selected node is shown. */
96 detail: boolean
97}
98
99declare module 'claude-code' {
100 interface PluginState {
101 tracegraph: { events: GraphEvent[]; ui: PaneUi }
102 }
103}
104