SLOPSHOPPER

tracegraph

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

newpaneguardcommandprompttool
v0.1.0MITupdated 2026-10-03Sangpaa/tracegraph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tracegraph
│ ┃ TraceGraph ✕ › fix the failing auth test and add an audit log call │ ┃ combined · 1 agents · 0 tasks · 3 files · 0 │ ┃ active ⏺ Read(src/auth.ts) │ ┃ ◌ agent main →4 ⎿ Read 6 lines │ ┃ · file src/auth.ts ⏺ Update(src/auth.ts) │ ┃ · file src/audit.ts ⎿ Added 2 lines, removed 1 line │ ┃ · file src/cache.ts ⏺ Bash(bun test) │ ┃ k: prev j: next d: detail p: plan t: tasks c ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /graph │ ⎿ tracegraph: TraceGraph opened (combined view). │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · TraceGraph
combined · 1 agents · 0 tasks · 3 files · 0 active ◌ agent main →4 · file src/auth.ts · file src/audit.ts · file src/cache.ts k: prev j: next d: detail p: plan t: tasks c: code g: all
README

TraceGraph

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:

  • agents: the main loop and every subagent it spawns, with the tool each one runs now
  • tasks: TaskCreate / TaskUpdate / TodoWrite items, their status and depends_on edges
  • files: every file an agent reads or writes, with +added -removed line counts per write
  • plan: before multi-step work, Claude declares the plan as a control block diagram (steps, flow, feedforward, feedback) and reports each step live
         ┌───────────────────────────────┐ 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.

Install

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

Use

/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:

KeyAction
j / knext / previous node
dtoggle the detail panel (edges, metadata, file path)
p / t / c / gplan / tasks / code / combined view

Edge glyphs: ━ observed, ┄ interpreted (reserved for v0.2+).

Plan tools

TraceGraph gives Claude two tools and a short system prompt note asking it to use them:

ToolPurpose
mcp__tracegraph__plandeclare title, steps, links (flow / feedforward / feedback, optional label)
mcp__tracegraph__stepset a step active / done / failed; failed fires its feedback loops (counter ×n) and reactivates the target step

Storage

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.

Develop

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)

Roadmap

  • v0.1 live execution graph (this release)
  • v0.2 decision / requirement nodes, a model-callable tool to record why a change exists
  • v0.3 static code graph (imports, calls)
  • v0.4 semantic change analysis

License

MIT

Source 8 files
hooks/register.tsx 276 lines
1import { 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}
276
src/graph/store.ts 91 lines
1// 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' : '')
91
src/observers/tools.ts 191 lines
1// 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}
191
src/observers/plan.ts 155 lines
1// 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}
155
src/ui/diagram.ts 245 lines
1// 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}
245
src/ui/views.ts 96 lines
1// 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}
96
src/graph/model.ts 21 lines
1// 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}`
21
types/index.d.ts 104 lines
1// 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