@inerrata-corporation/errata 2.0.17-dev.3571 → 2.0.17-dev.3577

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,484 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { EngineInterface, Register, Timer } from 'claude-code'
3
+
4
+ import type { Burst, MapGraph, Verdict } from '../types'
5
+ import {
6
+ colorOf,
7
+ DARK_BG,
8
+ FULL,
9
+ LIGHT_BG,
10
+ displayName,
11
+ glyphOf,
12
+ hex,
13
+ JUDGMENT_STYLE,
14
+ layoutBurst,
15
+ parseJudgments,
16
+ resolveJudgmentRef,
17
+ neighborIds,
18
+ parseAgentBurst,
19
+ parseAgentSearch,
20
+ parseFileRecall,
21
+ parsePromptPrime,
22
+ rasterize,
23
+ SCOPE_COLOR,
24
+ SCOPE_ORDER,
25
+ seedNode,
26
+ relPath,
27
+ shortName,
28
+ statusLine,
29
+ textsOf,
30
+ } from './minimap'
31
+ import type { Frame, HandleTable, JudgmentKind, RasterOptions } from './minimap'
32
+
33
+ const PANE = 'errata-minimap'
34
+ const ERRATA = 'errata'
35
+ const ENRICH_MAX = 8
36
+ const TOP_ITEMS = 3
37
+
38
+ const focus = atom({ plugin: 'errata-minimap', key: 'focus' } as const, '')
39
+ const burst = atom({ plugin: 'errata-minimap', key: 'burst' } as const, null as Burst | null)
40
+ const graph = atom({ plugin: 'errata-minimap', key: 'graph' } as const, { title: '', nodes: [], edges: [], at: 0 } as MapGraph)
41
+ const verdicts = atom({ plugin: 'errata-minimap', key: 'verdicts' } as const, [] as Verdict[])
42
+
43
+ const FILE_TOOLS = new Set(['Read', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
44
+ const BURST_FILE = /\.errata\/g\/burst\/([^/]+)/
45
+
46
+ const ORIGIN_LABEL = { agent: 'agent burst', 'daemon-file': 'file recall', 'daemon-prompt': 'prompt prime' } as const
47
+
48
+ // Module-level on purpose: a reload starts by redrawing whatever burst the session holds.
49
+ const pending = { isRefreshing: false, drawnAt: -1 }
50
+
51
+ /**
52
+ * Where the pane sits decides the UI. Docked beside the transcript (a wide
53
+ * fullscreen terminal) it is the full map; anywhere else — waiting undrawn on a
54
+ * narrow terminal, behind another tab, closed — the map collapses into the
55
+ * status line. A pane the surface would only seat inline above the prompt is
56
+ * closed unless the person asked for it with /minimap.
57
+ */
58
+ const view = { placement: undefined as 'dock' | 'inline' | undefined, isAsked: false, isDocked: false, status: undefined as string | undefined }
59
+
60
+ const GROW_MS = 1200
61
+ const TYPE_MS = 600
62
+ const FRAME_MS = 33
63
+ const HOLD_MS = 700
64
+ const PULSE_MS = 180
65
+
66
+ /**
67
+ * The reveal and the highlight run on blits, not render passes: a new map's
68
+ * routes grow out ring by ring, then its names type out; a prior the agent
69
+ * bursts off pulses (its route to the seed lit) until the fresh results replace
70
+ * the map. Only while docked — the status line has nothing to animate.
71
+ */
72
+ const MARK_MS = 4000
73
+ /** How long a verdict stays in the footer and on the status line. */
74
+ const VERDICT_SHOWN_MS = 10 * 60 * 1000
75
+ const VERDICT_STATUS_MS = 60 * 1000
76
+ const VERDICTS_KEPT = 6
77
+
78
+ const motion = {
79
+ /** Priors the agent just judged → their verdict, pulsing until `markUntil`. */
80
+ marks: {} as Record<string, JudgmentKind>,
81
+ markUntil: 0,
82
+ isMarkOn: true,
83
+ graphAt: -1,
84
+ start: 0,
85
+ frame: FULL as Frame,
86
+ highlight: undefined as string | undefined,
87
+ highlightStart: 0,
88
+ isPulseOn: true,
89
+ raster: undefined as { columns: number; rows: number } | undefined,
90
+ bg: DARK_BG,
91
+ timer: undefined as Timer | undefined,
92
+ }
93
+
94
+ function rasterOptions(map: MapGraph): RasterOptions {
95
+ return {
96
+ frame: motion.graphAt === map.at ? motion.frame : FULL,
97
+ bg: motion.bg,
98
+ highlight: motion.highlight,
99
+ isPulseOn: motion.isPulseOn,
100
+ marks: motion.marks,
101
+ isMarkOn: motion.isMarkOn,
102
+ }
103
+ }
104
+
105
+ async function animate($: EngineInterface): Promise<void> {
106
+ const now = await $.clock.now()
107
+ const t = now - motion.start
108
+ const isRevealing = motion.frame.type < 1
109
+ if (isRevealing) {
110
+ motion.frame = { grow: Math.min(1, t / GROW_MS), type: Math.max(0, Math.min(1, (t - GROW_MS) / TYPE_MS)) }
111
+ }
112
+ if (motion.highlight) motion.isPulseOn = Math.floor((now - motion.highlightStart) / PULSE_MS) % 2 === 0
113
+ const isMarking = Object.keys(motion.marks).length > 0 && now < motion.markUntil
114
+ if (isMarking) motion.isMarkOn = Math.floor((now - (motion.markUntil - MARK_MS)) / PULSE_MS) % 2 === 0
115
+ else motion.marks = {}
116
+ const map = await read($, graph)
117
+ if (motion.raster && map.nodes.length > 0) {
118
+ const { columns, rows } = motion.raster
119
+ await $.ui.blit({ requestId: PANE, key: 'map', columns, rows, cells: rasterize(map, columns, rows, rasterOptions(map)) }).catch(() => undefined)
120
+ }
121
+ if (!isRevealing && !motion.highlight && !isMarking) {
122
+ motion.timer?.cancel()
123
+ motion.timer = undefined
124
+ }
125
+ }
126
+
127
+ function startMotion($: EngineInterface): void {
128
+ motion.timer ??= $.clock.every(FRAME_MS, () => void animate($))
129
+ }
130
+
131
+ /** Lights the prior the agent is about to burst off, if the map shows it. */
132
+ async function highlightSeed($: EngineInterface, id: string): Promise<boolean> {
133
+ const map = await read($, graph)
134
+ if (!view.isDocked || !map.nodes.some(n => !n.isSeed && n.id === id)) return false
135
+ motion.highlight = id
136
+ motion.highlightStart = await $.clock.now()
137
+ motion.isPulseOn = true
138
+ startMotion($)
139
+ return true
140
+ }
141
+
142
+ /** Holds the highlight at least HOLD_MS, so the target reads before the map moves on. */
143
+ async function holdHighlight($: EngineInterface): Promise<void> {
144
+ if (!motion.highlight) return
145
+ const left = HOLD_MS - ((await $.clock.now()) - motion.highlightStart)
146
+ if (left > 0) await $.clock.sleep(left)
147
+ }
148
+
149
+ /**
150
+ * The agent's reply judged some priors (cite / confirm / fix / refute tags):
151
+ * record each, and pulse the ones on the map in their verdict's colour. Refs
152
+ * that resolve to no node — `[id]` in prose ABOUT the protocol — are ignored.
153
+ */
154
+ async function recordJudgments($: EngineInterface, text: string): Promise<void> {
155
+ const judgments = parseJudgments(text)
156
+ if (judgments.length === 0) return
157
+ const handles = await primingHandles($)
158
+ const map = await read($, graph)
159
+ const now = await $.clock.now()
160
+ const fresh: Verdict[] = []
161
+ for (const j of judgments) {
162
+ const id = resolveJudgmentRef(j.ref, handles)
163
+ if (!id) continue
164
+ const onMap = map.nodes.find(n => n.id === id)
165
+ fresh.push({ id, kind: j.kind, name: onMap ? displayName(onMap.label, onMap.name) : j.ref, at: now })
166
+ if (onMap) motion.marks[id] = j.kind
167
+ }
168
+ if (fresh.length === 0) return
169
+ await update($, verdicts, list => [...fresh, ...list.filter(v => !fresh.some(f => f.id === v.id))].slice(0, VERDICTS_KEPT))
170
+ if (Object.keys(motion.marks).length > 0 && view.isDocked) {
171
+ motion.markUntil = now + MARK_MS
172
+ motion.isMarkOn = true
173
+ startMotion($)
174
+ }
175
+ }
176
+
177
+ async function themeBackground($: EngineInterface): Promise<number> {
178
+ const row = (await $.config.list().catch(() => [])).find(r => r.key === 'theme')
179
+ return String(row?.value ?? '').includes('light') ? LIGHT_BG : DARK_BG
180
+ }
181
+
182
+ async function tick($: EngineInterface): Promise<void> {
183
+ await refresh($)
184
+ await syncLayout($)
185
+ }
186
+
187
+ async function syncLayout($: EngineInterface): Promise<void> {
188
+ const mine = (await $.ui.panes()).find(p => p.id === PANE)
189
+ if (mine?.isPlaced && view.placement === 'inline' && !view.isAsked) {
190
+ await $.ui.close({ id: PANE })
191
+ view.placement = undefined
192
+ }
193
+ const isDocked = !!mine?.isPlaced && mine.isShown && view.placement === 'dock'
194
+ view.isDocked = isDocked
195
+ const latest = (await read($, verdicts))[0]
196
+ const now = await $.clock.now()
197
+ const verdictNote =
198
+ latest && now - latest.at < VERDICT_STATUS_MS
199
+ ? ` · ${JUDGMENT_STYLE[latest.kind].glyph} ${JUDGMENT_STYLE[latest.kind].verb} ${shortName(latest.name, 32)}`
200
+ : ''
201
+ const base = isDocked ? undefined : statusLine(await read($, graph))
202
+ const text = base === undefined ? undefined : base + verdictNote
203
+ if (text !== view.status) {
204
+ view.status = text
205
+ $.ui.status(text)
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Draws a burst the moment it lands and starts its reveal. Enrichment waits:
211
+ * the first cut walked up to 12 neighbor calls before drawing anything, so an
212
+ * MCP burst showed up seconds after the agent had already read it.
213
+ */
214
+ async function draw($: EngineInterface, b: Burst): Promise<void> {
215
+ const laid = layoutBurst(b)
216
+ // Set the reveal up before the graph lands, so its first render is frame 0, not a flash of the finished map.
217
+ motion.highlight = undefined
218
+ if (view.isDocked) {
219
+ motion.graphAt = laid.at
220
+ motion.start = await $.clock.now()
221
+ motion.frame = { grow: 0, type: 0 }
222
+ startMotion($)
223
+ }
224
+ await update($, graph, () => laid)
225
+ pending.drawnAt = b.at
226
+ }
227
+
228
+ /** Catches up a burst not drawn yet, then fills in the real edges among what it delivered. */
229
+ async function refresh($: EngineInterface): Promise<void> {
230
+ if (pending.isRefreshing) return
231
+ const current = await read($, burst)
232
+ if (!current) return
233
+ pending.isRefreshing = true
234
+ try {
235
+ if (current.at !== pending.drawnAt) await draw($, current)
236
+ if (current.isEnriched) return
237
+ // Background, after the draw: the seed's real name first (cheap, visible), then the cross edges.
238
+ const named = (await resolveSeed($, current)) ?? current
239
+ if (named !== current) {
240
+ if ((await read($, burst))?.at !== current.at) return
241
+ await update($, burst, () => named)
242
+ await update($, graph, () => layoutBurst(named))
243
+ }
244
+ const enriched = { ...named, extra: await crossEdges($, named), isEnriched: true }
245
+ // A newer burst landed while we walked: it is drawn already; drop this one.
246
+ const latest = await read($, burst)
247
+ if (latest && latest.at !== current.at) return
248
+ await update($, burst, () => enriched)
249
+ // Same `at`, same positions: the cross edges appear in place, the reveal keeps running.
250
+ await update($, graph, () => layoutBurst(enriched))
251
+ } catch (err) {
252
+ await update($, graph, g => ({ ...g, note: `minimap: ${String(err).slice(0, 80)}` }))
253
+ } finally {
254
+ pending.isRefreshing = false
255
+ }
256
+ }
257
+
258
+ async function crossEdges($: EngineInterface, b: Burst): Promise<[string, string][]> {
259
+ const ids = new Set([b.seed.id, ...b.nodes.map(n => n.id)])
260
+ const answers = await Promise.all(
261
+ b.nodes.slice(0, ENRICH_MAX).map(n =>
262
+ $.mcp.call(ERRATA, 'errata_neighbors', { nodeId: n.id, limit: 30 }).then(
263
+ res => [n.id, res] as const,
264
+ () => [n.id, undefined] as const,
265
+ ),
266
+ ),
267
+ )
268
+ const out: [string, string][] = []
269
+ for (const [id, res] of answers) {
270
+ if (!res || res.isError) continue
271
+ for (const hop of neighborIds(textsOf(res.content))) {
272
+ if (ids.has(hop.id) && hop.id !== id) out.push([id, hop.id])
273
+ }
274
+ }
275
+ return out
276
+ }
277
+
278
+ /** A burst names its seed by id (`file_fb8c…`): ask the graph what that node is. */
279
+ async function resolveSeed($: EngineInterface, b: Burst | null): Promise<Burst | null> {
280
+ if (!b || b.seed.label !== 'Seed') return b
281
+ const res = await $.mcp.call(ERRATA, 'errata_neighbors', { nodeId: b.seed.id, limit: 1 }).catch(() => undefined)
282
+ const node = res && !res.isError ? seedNode(textsOf(res.content)) : undefined
283
+ return node ? { ...b, seed: { id: b.seed.id, label: node.label, name: node.name } } : b
284
+ }
285
+
286
+ /** Sets the burst the map shows and draws it now; the next tick enriches it. */
287
+ async function show($: EngineInterface, next: Burst | null): Promise<void> {
288
+ if (!next) return
289
+ await update($, burst, () => next)
290
+ await draw($, next)
291
+ }
292
+
293
+ async function primingHandles($: EngineInterface): Promise<HandleTable> {
294
+ const cwd = await $.session.cwd()
295
+ const text = await $.fs.read(`${cwd}/.errata/priming-handles.json`).catch(() => '{}')
296
+ return JSON.parse(String(text)) as HandleTable
297
+ }
298
+
299
+ export const register: Register = on => {
300
+ on('session.start', async ($, e, next) => {
301
+ await $.command.register({ name: 'minimap', description: 'Open the errata minimap: the latest burst neighborhood that entered context' })
302
+ void $.ui.open({ id: PANE, title: 'errata' })
303
+ motion.bg = await themeBackground($)
304
+ $.clock.every(1500, () => void tick($))
305
+ return next(e)
306
+ })
307
+
308
+ on('config.set', { key: 'theme' }, async ($, e, next) => {
309
+ const done = await next(e)
310
+ motion.bg = await themeBackground($)
311
+ return done
312
+ })
313
+
314
+ on('command.run', { command: 'minimap' }, async $ => {
315
+ view.isAsked = true
316
+ const opened = await $.ui.open({ id: PANE, title: 'errata', columns: 56 })
317
+ pending.drawnAt = -1
318
+ void refresh($)
319
+ return { text: opened.isPlaced ? 'errata minimap opened.' : `minimap waits: ${opened.reason}` }
320
+ })
321
+
322
+ // The agent bursts through MCP: the result IS the neighborhood that entered its context.
323
+ on('tool.call', { tool: 'mcp__errata__errata_burst' }, async ($, e, next) => {
324
+ const input = e as unknown as Record<string, unknown>
325
+ const seed = String(input['nodeId'] ?? input['qname'] ?? '')
326
+ // Bursting off a prior the map already shows: light it while the burst runs.
327
+ await highlightSeed($, seed)
328
+ const ran = await next(e)
329
+ if (ran.deny !== undefined || ran.isError) {
330
+ motion.highlight = undefined
331
+ return ran
332
+ }
333
+ // No MCP call before the draw: the seed's name is looked up on the next tick (refresh).
334
+ const fresh = parseAgentBurst(textsOf([ran.text, ran.result]), seed)
335
+ await holdHighlight($)
336
+ await show($, fresh)
337
+ return ran
338
+ })
339
+
340
+ // A search is a neighborhood too: the hits that entered context, ringed around the query.
341
+ on('tool.call', { tool: 'mcp__errata__errata_search' }, async ($, e, next) => {
342
+ const ran = await next(e)
343
+ if (ran.deny !== undefined || ran.isError) return ran
344
+ const query = String((e as unknown as Record<string, unknown>)['query'] ?? '')
345
+ await show($, parseAgentSearch(textsOf([ran.text, ran.result]), query))
346
+ return ran
347
+ })
348
+
349
+ on('tool.call', async ($, e, next) => {
350
+ if (!FILE_TOOLS.has(String(e.tool))) return next(e)
351
+ const input = e as unknown as Record<string, unknown>
352
+ const abs = String(input['file_path'] ?? input['notebook_path'] ?? '')
353
+ const path = abs ? relPath(abs, await $.session.cwd()) : ''
354
+ // The daemon's file recall rides this call's hook context and names no file: remember which one.
355
+ if (path) await update($, focus, () => path)
356
+
357
+ const ran = await next(e)
358
+ if (!path || ran.deny !== undefined || ran.isError) return ran
359
+
360
+ // The agent bursting through the file surface: ask the same burst as structure.
361
+ const viaFile = BURST_FILE.exec(path)
362
+ if (e.tool === 'Read' && viaFile) {
363
+ const seed = decodeURIComponent(viaFile[1]!)
364
+ const args = /^[A-Za-z]+_[0-9a-f]{8,}|^(dprob|dfix|dcause|projn|motif)_/.test(seed) ? { nodeId: seed } : { qname: seed }
365
+ await highlightSeed($, seed)
366
+ const res = await $.mcp.call(ERRATA, 'errata_burst', args).catch(() => undefined)
367
+ const fresh = res && !res.isError ? parseAgentBurst(textsOf(res.content), seed) : null
368
+ await holdHighlight($)
369
+ if (fresh) await show($, fresh)
370
+ else motion.highlight = undefined
371
+ }
372
+ return ran
373
+ })
374
+
375
+ // The daemon's bursts arrive as injected rows: a file recall on a touch, the context block on a prompt.
376
+ on('session.append', async ($, e, next) => {
377
+ // The agent's own reply: its inline tags judge the priors it was shown.
378
+ // Text blocks only — tool inputs (a test fixture, a file being written) can
379
+ // carry tag syntax that judges nothing.
380
+ if (e.door === 'response' && !e.agentId) {
381
+ const said = e.message.content
382
+ .flatMap(b => {
383
+ const text: unknown = (b as { text?: unknown }).text
384
+ return b.type === 'text' && typeof text === 'string' ? [text] : []
385
+ })
386
+ .join('\n')
387
+ if (said) await recordJudgments($, said)
388
+ }
389
+ if (e.door !== 'response' && e.door !== 'prompt' && !e.agentId) {
390
+ const text = textsOf(e.message.content).join('\n')
391
+ if (text.includes('recorded on this file')) {
392
+ await show($, parseFileRecall(text, await read($, focus)))
393
+ } else if (text.includes('## Errata context')) {
394
+ await show($, parsePromptPrime(text, await primingHandles($)))
395
+ }
396
+ }
397
+ return next(e)
398
+ })
399
+
400
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
401
+ const { Box, Text } = $.ui.resolve(e)
402
+ // The surface's word on where it seated us drives the status-line collapse (syncLayout).
403
+ if (e.surface === 'terminal') view.placement = e.props.placement
404
+ const width = Math.max(24, e.props.bodyColumns)
405
+ const height = Math.max(14, (e.viewport?.rows ?? 30) - 4)
406
+ const map = await read($, graph)
407
+
408
+ const delivered = map.nodes.filter(n => !n.isSeed).sort((a, b) => b.score - a.score)
409
+ const counts = new Map<string, number>()
410
+ for (const n of delivered) counts.set(n.label, (counts.get(n.label) ?? 0) + 1)
411
+ const legend = [...counts].sort((a, b) => b[1] - a[1])
412
+ const legendRows = Math.ceil(legend.reduce((w, [l, c]) => w + l.length + String(c).length + 6, 0) / width)
413
+ const tiers = SCOPE_ORDER.filter(s => delivered.some(n => n.scope === s))
414
+ const now = await $.clock.now()
415
+ const recent = (await read($, verdicts)).filter(v => now - v.at < VERDICT_SHOWN_MS).slice(0, 3)
416
+ const footRows =
417
+ legendRows + (tiers.length > 0 ? 1 : 0) + Math.min(TOP_ITEMS, delivered.length) + recent.length
418
+ const mapRows = Math.max(5, height - footRows - 2)
419
+ // Blits must match the mounted size: remember what this pass mounts.
420
+ motion.raster = { columns: width, rows: mapRows }
421
+
422
+ const minimap =
423
+ e.surface === 'terminal' && map.nodes.length > 0
424
+ ? (() => {
425
+ const { Raster } = $.ui.resolve(e)
426
+ return <Raster key="map" columns={width} rows={mapRows} cells={rasterize(map, width, mapRows, rasterOptions(map))} />
427
+ })()
428
+ : (
429
+ <Box height={mapRows}>
430
+ <Text dimColor>
431
+ {map.note ?? (map.nodes.length === 0 ? 'no burst yet — touch a file or burst a seed' : 'map draws in the terminal')}
432
+ </Text>
433
+ </Box>
434
+ )
435
+
436
+ return (
437
+ <Box flexDirection="column" width={width}>
438
+ <Text bold wrap="truncate-end">
439
+ {map.origin ? ORIGIN_LABEL[map.origin] : 'burst'}
440
+ <Text dimColor> · {delivered.length} in context · </Text>
441
+ <Text color="#ffd75f">◉ </Text>
442
+ <Text>{shortName(map.title, Math.max(8, width - 30))}</Text>
443
+ <Text dimColor> {map.seedLabel ?? ''}</Text>
444
+ </Text>
445
+ {minimap}
446
+ {tiers.length > 0 && (
447
+ <Text wrap="truncate-end">
448
+ {tiers.map(s => (
449
+ <Text>
450
+ <Text color={hex(SCOPE_COLOR[s])}>━━</Text>
451
+ <Text dimColor> {s} </Text>
452
+ </Text>
453
+ ))}
454
+ </Text>
455
+ )}
456
+ {legend.length > 0 && (
457
+ <Text wrap="wrap">
458
+ {legend.map(([label, count]) => (
459
+ <Text>
460
+ <Text color={hex(colorOf(label))}>{String.fromCodePoint(glyphOf(label))}</Text>
461
+ <Text dimColor> {count} {label} </Text>
462
+ </Text>
463
+ ))}
464
+ </Text>
465
+ )}
466
+ {recent.map(v => (
467
+ <Text wrap="truncate-end">
468
+ <Text color={hex(JUDGMENT_STYLE[v.kind].color)} bold>
469
+ {JUDGMENT_STYLE[v.kind].glyph} {JUDGMENT_STYLE[v.kind].verb}{' '}
470
+ </Text>
471
+ <Text>{v.name}</Text>
472
+ </Text>
473
+ ))}
474
+ {delivered.slice(0, TOP_ITEMS).map(n => (
475
+ <Text wrap="truncate-end">
476
+ <Text color={hex(colorOf(n.label))}>{String.fromCodePoint(glyphOf(n.label))} </Text>
477
+ <Text dimColor>{displayName(n.label, n.name)}</Text>
478
+ </Text>
479
+ ))}
480
+ </Box>
481
+ )
482
+ })
483
+ }
484
+
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -0,0 +1,74 @@
1
+ /** Who burst: the agent through the MCP tool or the `.errata/g/burst` file, or the daemon priming a file touch / a prompt. */
2
+ export type BurstOrigin = 'agent' | 'daemon-file' | 'daemon-prompt'
3
+
4
+ /** Which membrane a prior lives in: this machine only, or the project / team / org membrane, or the public Errata Network. */
5
+ export type Scope = 'machine' | 'project' | 'team' | 'org' | 'public'
6
+
7
+ export type BurstNode = {
8
+ id: string
9
+ label: string
10
+ name: string
11
+ /** Burst relevance; daemon-delivered nodes get a rank-derived stand-in. */
12
+ score: number
13
+ hops: number
14
+ /** The node the walk reached this one from, and by which edge. */
15
+ parent?: string
16
+ edge?: string
17
+ /** Absent when no client-visible signal says. */
18
+ scope?: Scope
19
+ }
20
+
21
+ /** One neighborhood that entered the agent's context. */
22
+ export type Burst = {
23
+ origin: BurstOrigin
24
+ seed: { id: string; label: string; name: string }
25
+ nodes: BurstNode[]
26
+ /** Real edges among the delivered nodes, found after the fact. */
27
+ extra: [string, string][]
28
+ isEnriched: boolean
29
+ at: number
30
+ }
31
+
32
+ export type MapNode = {
33
+ id: string
34
+ label: string
35
+ name: string
36
+ isSeed: boolean
37
+ score: number
38
+ /** Rings out from the seed (0 for the seed): the order the reveal grows in. */
39
+ hops: number
40
+ scope?: Scope
41
+ x: number
42
+ y: number
43
+ }
44
+
45
+ export type MapGraph = {
46
+ title: string
47
+ /** The seed's node type (File, Function, Prompt, ...). */
48
+ seedLabel?: string
49
+ origin?: BurstOrigin
50
+ nodes: MapNode[]
51
+ /** Index pairs into `nodes`; `true` = an edge of the walk, `false` = a cross edge. */
52
+ edges: [number, number, boolean][]
53
+ at: number
54
+ note?: string
55
+ }
56
+
57
+ /** One judgment the agent made on a prior in its reply: cited, confirmed, resolved or refuted. */
58
+ export type Verdict = {
59
+ id: string
60
+ kind: 'cite' | 'confirm' | 'fix' | 'refute'
61
+ name: string
62
+ at: number
63
+ }
64
+
65
+ declare module 'claude-code' {
66
+ interface PluginState {
67
+ 'errata-minimap': {
68
+ focus: string
69
+ burst: Burst | null
70
+ graph: MapGraph
71
+ verdicts: Verdict[]
72
+ }
73
+ }
74
+ }