A map of the work beside the transcript: goal, current step, facts, glossary, pipeline stages with iris samples, and a trail of results and decisions.

A Claude Code mod that keeps a map of the work beside the transcript, so long sessions keep their way back to the top-level plan.
▶ 3 Training › 3.2 Learning-rate sweep), pinned facts and parked items./atlas recap runs a deeper recap on Sonnet.atlas:keeper, hidden from the main model) does the upkeep nobody should have to ask for: it renders missing pipeline-stage samples from real data into .claude/atlas/samples/, checks pinned facts against the repository, and fills the glossary. It starts on its own when there is upkeep to do, at most every ten minutes, and writes only inside .claude/atlas/. Renders shown with iris id stage-<name> link to their stage automatically./atlas): a route picture of the plan, Now and Recap cards, the plan tree, parked tangents, facts, pipeline stages with iris thumbnails (marked Seen once Claude has read the render), glossary and trail./atlas init (or Build map in the band) digests every past transcript of the project (scripts/digest.py: what was said plus one line per tool call, in dated chunks), hands each chunk to an atlas:historian agent (Haiku, read-only) in parallel, and has the session's Claude build the map from those summaries, the project's memory notes, the conversation and the repository. Contradictions become trail notes starting "Open question:". Expect about a dollar for a project with weeks of history..claude/atlas/map.json; each change re-reads the file and merges, and each session picks up the others' changes within a few seconds. Writes take a lock (scripts/maplock.py; test: scripts/maplock.test.sh, 20 parallel writers) and replace the file in one rename, so no change is lost and no session reads half a file. Each session keeps its own place in the plan, and the Map tab shows a Sessions card with where the others are..claude/atlas/map.json plus a readable map.md, so it travels with git clone and reads the same on any machine. Commit it like any file (check your .gitignore does not exclude .claude/). /atlas project <path> shows another project's map.Needs: Claude Code ≥ 2.1.287, the iris mod, ImageMagick for the route picture.
hooks/register.tsx 1174 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ImageSource, Register, RenderChildren, Timer, ToolCallResult } from 'claude-code'
3
4import type { AtlasMap, AtlasNode, AtlasStage, AtlasTab, IrisImages } from '../types'
5import {
6 age,
7 applyPatch,
8 bandLine,
9 breadcrumb,
10 EMPTY,
11 focusLabel,
12 focusNodes,
13 isEmpty,
14 migrate,
15 RESEARCH_LIMIT,
16 toMarkdown,
17 type Patch,
18} from './model'
19import { hash, ROUTE_H, ROUTE_W, routeSvg } from './route'
20import { parseWatch, watchPrompt, WATCH_SYSTEM, type Turn } from './watch'
21
22const PANE = 'atlas'
23const TOOL = 'mcp__atlas__atlas_update'
24
25const mapAtom = atom({ plugin: 'atlas', key: 'map' } as const, EMPTY)
26const changes = atom({ plugin: 'atlas', key: 'changes' } as const, 0)
27const isStale = atom({ plugin: 'atlas', key: 'isStale' } as const, false)
28const tab = atom({ plugin: 'atlas', key: 'tab' } as const, 'map')
29const seen = atom({ plugin: 'atlas', key: 'seen' } as const, [])
30const isWatching = atom({ plugin: 'atlas', key: 'isWatching' } as const, false)
31const isInitDismissed = atom({ plugin: 'atlas', key: 'isInitDismissed' } as const, false)
32const isKeeping = atom({ plugin: 'atlas', key: 'isKeeping' } as const, false)
33// iris's state, read only: which figure the iris pane shows, and every figure's renders.
34const irisSel = atom({ plugin: 'iris', key: 'sel' } as const, null)
35const irisFigures = atom({ plugin: 'iris', key: 'figures' } as const, [])
36// How the viewing terminal shows pictures (see IrisImages): iris's answer, or atlas's own when atlas asked first.
37const irisImages = atom({ plugin: 'iris', key: 'images' } as const, 'unknown' as IrisImages)
38const images = atom({ plugin: 'atlas', key: 'images' } as const, 'unknown' as IrisImages)
39
40const RULES = `# atlas: keep the user oriented
41Long sessions drift: a big plan, a couple of steps done, then one step turns into its own search space and the session never returns to the top. The user may be a visual thinker who loses track of where things stand. The atlas map (goal, plan tree, facts, glossary, pipeline stages, parked tangents, trail) is shown beside the transcript and above the prompt. Keep it true with the atlas_update tool. The user is free to go anywhere; your job is to keep the books and the way back.
42- When work has a goal and more than one step, set the goal and steps.
43- When a step turns into its own problem, give it sub-steps (substeps with under: "3"). Steps are addressed by paths like "3.2".
44- When work leaves the step in focus, file it as a detour (with why). When a step or detour is finished, call done with a one-line outcome: atlas moves to the next step or back up to the parent. Then say where that leaves the top-level plan.
45- Park tangents (park) instead of chasing them, and offer the parked list when a branch finishes.
46- Record facts the user would otherwise ask about again: dataset and version, tensor shapes and dtypes, key paths, configs, checkpoints. Pin the two or three that matter most right now.
47- Do not coin abbreviations or nicknames. Write the full name. If a short form is unavoidable, add it to the glossary before its first use.
48- After any step that changes data or a figure, render a sample with the iris show tool, Read the PNG, and say what is visible before claiming success. Link the figure to its pipeline stage.
49- Add a trail item for each result, decision or dead end, in one line.
50- A watchdog also files each turn into the map automatically. If it filed something wrong, correct it.
51- Write visible text (titles, labels, captions) capitalised, never all lower case.`
52
53const MAP_SCHEMA = {
54 type: 'object',
55 properties: {
56 goal: { type: 'string', description: 'The overall goal, one line.' },
57 steps: { type: 'array', items: { type: 'string' }, description: 'Replace the top-level plan with these step titles.' },
58 current: { type: 'integer', minimum: 1, description: 'Top-level step now in progress (same as focus: "n").' },
59 focus: { type: 'string', description: 'Step path to put in focus, e.g. "3" or "3.2".' },
60 substeps: {
61 type: 'object',
62 description: 'Give a step its own sub-plan; the first sub-step takes focus.',
63 properties: { under: { type: 'string', description: 'Path of the parent step, e.g. "3".' }, steps: { type: 'array', items: { type: 'string' } } },
64 required: ['under', 'steps'],
65 },
66 detour: {
67 type: 'object',
68 description: 'File work that left the step in focus as a detour under it, and focus the detour.',
69 properties: { title: { type: 'string' }, why: { type: 'string' } },
70 required: ['title'],
71 },
72 done: {
73 type: 'object',
74 description: 'Finish a step (default: the one in focus); focus moves to its next sibling or back to its parent.',
75 properties: { path: { type: 'string' }, outcome: { type: 'string', description: 'One-line result.' } },
76 },
77 step_updates: {
78 type: 'array',
79 items: {
80 type: 'object',
81 properties: {
82 n: { type: 'integer', minimum: 1, description: 'Top-level step number.' },
83 path: { type: 'string', description: 'Or a path such as "3.2".' },
84 status: { type: 'string', enum: ['todo', 'doing', 'done', 'dropped'] },
85 outcome: { type: 'string', description: 'One-line result of the step.' },
86 },
87 },
88 },
89 facts: {
90 type: 'object',
91 additionalProperties: { type: 'string' },
92 description: 'Facts to set, e.g. {"Dataset": "UXTD v2", "Input shape": "(B, T, 1, 64, 128)"}. Empty string deletes.',
93 },
94 pin: { type: 'array', items: { type: 'string' }, description: 'Fact names to show above the prompt (replaces the list).' },
95 glossary: { type: 'object', additionalProperties: { type: 'string' }, description: 'Term to meaning. Empty string deletes.' },
96 stages: {
97 type: 'array',
98 description: 'Pipeline stages, upserted by name.',
99 items: {
100 type: 'object',
101 properties: {
102 name: { type: 'string' },
103 in: { type: 'string', description: 'Input shape or type.' },
104 out: { type: 'string', description: 'Output shape or type.' },
105 figure: { type: 'integer', description: 'iris figure number showing a sample of this stage.' },
106 sample: { type: 'string', description: 'A sample image already saved in the project, e.g. .claude/atlas/samples/crop.png.' },
107 note: { type: 'string' },
108 },
109 required: ['name'],
110 },
111 },
112 remove_stages: { type: 'array', items: { type: 'string' } },
113 park: { type: 'array', items: { type: 'string' }, description: 'Tangents to come back to later, one line each.' },
114 unpark: { type: 'array', items: { type: 'integer', minimum: 1 }, description: 'Parked items (1-based) picked up or dropped.' },
115 recap: {
116 type: 'object',
117 properties: { where: { type: 'string' }, learned: { type: 'string' }, next: { type: 'string' } },
118 required: ['where', 'learned', 'next'],
119 },
120 trail: {
121 type: 'array',
122 items: {
123 type: 'object',
124 properties: { kind: { type: 'string', enum: ['done', 'decision', 'deadend', 'note'] }, text: { type: 'string' } },
125 required: ['kind', 'text'],
126 },
127 },
128 },
129 additionalProperties: false,
130}
131
132const MAP_DESCRIPTION =
133 'Update the atlas map the user follows: goal, plan tree (sub-steps, detours, done with outcomes), facts, glossary, ' +
134 'pipeline stages with iris figures, parked tangents, recap, and a trail of results, decisions and dead ends. ' +
135 'Call it with no arguments to read the whole map.'
136
137const GREEN = '#59A14F'
138const ORANGE = '#F28E2B'
139const BLUE = '#4E79A7'
140const RED = '#E15759'
141const GREY = '#8C8C8C'
142const BROWN = '#9C755F'
143
144let projectRoot = '.'
145let sessionId = ''
146let sessionName = ''
147let fileMtime = 0
148const SESSION_MAX_AGE_MS = 14 * 86_400_000
149let workDir = '/tmp/atlas'
150let probedAt = 0
151let probedViewport = ''
152const dims = new Map<string, { w: number; h: number }>()
153// The last few main-thread turns, as the watchdog reads them. Rebuilt after a reload.
154const turns: Turn[] = []
155let pending: Turn = { prompt: '', actions: [], answer: '' }
156
157// Each project's map lives in its own repo, so it travels with git and reads the same on any machine.
158const mapFile = (root: string) => `${root}/.claude/atlas/map.json`
159const mapNotes = (root: string) => `${root}/.claude/atlas/map.md`
160
161/** The map as this session sees it: the shared plan, with this session's own position as the focus. */
162function withMyFocus(map: AtlasMap): AtlasMap {
163 const mine = map.sessions?.[sessionId]
164 return mine ? { ...map, focus: mine.focus } : map
165}
166
167const lockDir = (root: string) => `${root}/.claude/atlas/.lock`
168
169/** Runs scripts/maplock.py: the lock several sessions take before writing a project's map. */
170async function maplock($: EngineInterface, ...args: string[]): Promise<boolean> {
171 const out = await $.process.run(['python3', '-I', `${$.plugin.root}/scripts/maplock.py`, ...args], { timeoutMs: 10_000 }).catch(() => undefined)
172 return out?.exitCode === 0
173}
174
175/** Writes a file in one step (a temporary file renamed over it), so another session never reads half of it. */
176async function writeWhole($: EngineInterface, file: string, text: string) {
177 const tmp = `${file}.tmp-${sessionId || 'atlas'}`
178 await $.fs.write(tmp, text)
179 if (!(await maplock($, 'put', tmp, file))) await $.fs.write(file, text)
180}
181
182async function save($: EngineInterface, map: AtlasMap) {
183 const now = await $.clock.now()
184 const sessions = Object.fromEntries(Object.entries(map.sessions ?? {}).filter(([, v]) => now - v.t < SESSION_MAX_AGE_MS))
185 if (sessionId !== '') sessions[sessionId] = { name: sessionName || sessionId.slice(0, 8), focus: map.focus, t: now }
186 map = { ...map, sessions }
187 await update($, mapAtom, () => map)
188 const ignore = `${projectRoot}/.claude/atlas/.gitignore`
189 if (!(await $.fs.exists(ignore))) await $.fs.write(ignore, '# Written by atlas: the lock and half-written files never belong in git.\n.lock/\n*.tmp-*\nmap.unreadable-*.json\n')
190 await writeWhole($, mapFile(projectRoot), `${JSON.stringify(map, null, 2)}\n`)
191 fileMtime = (await $.fs.stat(mapFile(projectRoot)).catch(() => undefined))?.mtimeMs ?? fileMtime
192 const notes = isEmpty(map) ? '' : toMarkdown(map, await read($, seen))
193 await writeWhole($, mapNotes(projectRoot), `<!-- Written by atlas; change it with /atlas or atlas_update. -->\n# atlas map\n\n${notes}\n`)
194}
195
196/** The newest map: re-read from disk when another session wrote it since we last did. */
197async function latest($: EngineInterface): Promise<AtlasMap> {
198 const st = await $.fs.stat(mapFile(projectRoot)).catch(() => undefined)
199 if (st && st.mtimeMs !== fileMtime) {
200 try {
201 const map = withMyFocus(migrate(JSON.parse(await $.fs.read(mapFile(projectRoot)))))
202 fileMtime = st.mtimeMs
203 await update($, mapAtom, () => map)
204 return map
205 } catch {
206 // A half-written file: keep what we have and try again next time.
207 }
208 }
209 return read($, mapAtom)
210}
211
212/**
213 * Every change goes through here, so changes from several sessions merge instead of overwriting:
214 * under the project's lock, read the file as it is now, apply the change, write it back.
215 * Keep `change` quick (no copying or rendering): other sessions wait while it runs.
216 */
217async function mutate($: EngineInterface, change: (map: AtlasMap) => AtlasMap): Promise<AtlasMap> {
218 const dir = lockDir(projectRoot)
219 // Without the lock (another writer stuck for 5 s), write anyway: atlas never blocks the session.
220 const isLocked = await maplock($, 'take', dir)
221 try {
222 fileMtime = -1
223 const next = change(await latest($))
224 await save($, next)
225 return next
226 } finally {
227 if (isLocked) await maplock($, 'drop', dir)
228 }
229}
230
231/** This session's display name, from its transcript (custom title or agent name). */
232async function readSessionName($: EngineInterface, root: string, id: string): Promise<string> {
233 const file = `${(await $.env.get('HOME')) ?? ''}/.claude/projects/${root.replace(/[^A-Za-z0-9]/g, '-')}/${id}.jsonl`
234 const script = `grep -oE '"(customTitle|agentName)":"[^"]*"' "$1" 2>/dev/null | tail -1 | sed -E 's/.*":"//; s/"$//'`
235 const out = await $.process.run(['sh', '-c', script, 'atlas', file], { timeoutMs: 10_000 }).catch(() => undefined)
236 return out?.stdout.trim() ?? ''
237}
238
239/** A project's map: its repo file, else a map left in the old per-machine store (moved into the repo once). */
240async function load($: EngineInterface, root: string): Promise<AtlasMap> {
241 if (await $.fs.exists(mapFile(root))) {
242 for (let attempt = 0; attempt < 3; attempt++) {
243 try {
244 fileMtime = (await $.fs.stat(mapFile(root))).mtimeMs
245 return withMyFocus(migrate(JSON.parse(await $.fs.read(mapFile(root)))))
246 } catch {
247 await $.clock.sleep(300)
248 }
249 }
250 // Still unreadable (a git merge conflict, say): keep a copy so starting over loses nothing.
251 const copy = `${root}/.claude/atlas/map.unreadable-${await $.clock.now()}.json`
252 await $.process.run(['cp', mapFile(root), copy]).catch(() => undefined)
253 $.ui.toast(`atlas: map.json could not be read; kept a copy at ${copy} and started a new map.`)
254 return migrate(undefined)
255 }
256 const legacy = await $.store.get(`map:${root}`)
257 const map = migrate(legacy)
258 if (!isEmpty(map)) {
259 const previous = projectRoot
260 projectRoot = root
261 await save($, map)
262 projectRoot = previous
263 }
264 return map
265}
266
267const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'stage'
268const sampleRel = (name: string) => `.claude/atlas/samples/${slug(name)}.png`
269
270/** Copies each newly linked stage's iris render into the project, so the sample outlives the session. Returns stage name → sample path. */
271async function copySamples($: EngineInterface, linked: readonly { name: string; figure: number }[]): Promise<Map<string, string>> {
272 const copied = new Map<string, string>()
273 for (const { name, figure } of linked) {
274 const png = await irisPng($, figure)
275 if (!png) continue
276 const rel = sampleRel(name)
277 const out = await $.process.run(['sh', '-c', 'mkdir -p "$(dirname "$2")" && cp "$1" "$2"', 'atlas', png, `${projectRoot}/${rel}`])
278 if (out.exitCode === 0) {
279 dims.delete(`${projectRoot}/${rel}`)
280 copied.set(name, rel)
281 }
282 }
283 return copied
284}
285
286/** Points the copied stages at their stored samples. */
287function withSamples(map: AtlasMap, copied: ReadonlyMap<string, string>): AtlasMap {
288 if (copied.size === 0) return map
289 return { ...map, stages: map.stages.map(st => (copied.has(st.name) && st.figure !== undefined ? { ...st, sample: copied.get(st.name) } : st)) }
290}
291
292/** How to draw pictures for this viewer: atlas's own probe if it has one, else iris's; a new viewport asks again. */
293async function imageMode($: EngineInterface, viewport: { columns: number; rows: number } | undefined): Promise<IrisImages> {
294 const vp = `${viewport?.columns}x${viewport?.rows}`
295 if (vp !== probedViewport) {
296 probedViewport = vp
297 await update($, images, () => 'unknown' as IrisImages)
298 }
299 const own = await read($, images)
300 return own !== 'unknown' ? own : read($, irisImages)
301}
302
303/** An Image source for a PNG in the form this viewer reads. */
304async function sourceOf($: EngineInterface, png: string, how: IrisImages): Promise<ImageSource> {
305 return how === 'bytes' ? { png: (await $.fs.read(png, { as: 'bytes' })).base64 } : { file: png, format: 'png' }
306}
307
308/** Asks the renderer how an Image atlas drew came out (the session's environment is not its viewer's). */
309async function probeImages($: EngineInterface, key: string, source: ImageSource, columns: number, rows: number) {
310 const now = await $.clock.now()
311 if (now - probedAt < 2000) return
312 probedAt = now
313 await $.clock.sleep(300)
314 const r = await $.ui.blit({ requestId: PANE, key, source, columns, rows }).catch(() => ({ deny: 'failed' }))
315 const deny = r.deny ?? ''
316 const was = await read($, images)
317 let next = was
318 if (deny === '') next = 'png' in source ? 'bytes' : 'file'
319 else if (/cannot read files/.test(deny)) next = 'bytes'
320 else if (/draws its alt|not drawn under a terminal/.test(deny) && !/in use/.test(deny)) next = 'none'
321 else if (/blit again/.test(deny)) probedAt = 0
322 if (next !== was) {
323 await update($, images, () => next)
324 $.ui.invalidate('ui.render')
325 }
326}
327
328/** The iris PNG for figure `n`'s latest version and current page. */
329async function irisPng($: EngineInterface, n: number): Promise<string | undefined> {
330 const fig = (await read($, irisFigures)).find(f => f.n === n)
331 const version = fig?.versions[fig.versions.length - 1]
332 return fig && version ? version.png[String(fig.page)] : undefined
333}
334
335async function pngDims($: EngineInterface, png: string) {
336 let d = dims.get(png)
337 if (d === undefined) {
338 const out = await $.process.run(['magick', 'identify', '-format', '%w %h', `${png}[0]`], { timeoutMs: 10_000 })
339 const [w, h] = out.stdout.trim().split(/\s+/).map(Number)
340 d = { w: w || 1, h: h || 1 }
341 dims.set(png, d)
342 }
343 return d
344}
345
346async function remember<R extends ToolCallResult>($: EngineInterface, tool: string, query: string, run: () => Promise<R>): Promise<R> {
347 const map = await read($, mapAtom)
348 const before = map.research.find(r => r.query === query)
349 const result = await run()
350 if (result.deny !== undefined || result.isError) return result
351 const now = await $.clock.now()
352 const entry = { t: now, tool, query, summary: (result.text ?? '').replace(/\s+/g, ' ').slice(0, 300) }
353 await mutate($, m => ({ ...m, research: [...m.research.filter(r => r.query !== query), entry].slice(-RESEARCH_LIMIT) }))
354 if (!before) return result
355 return { ...result, context: [...(result.context ?? []), `atlas: the same lookup was made ${age(now, before.t)}: ${before.summary}`] }
356}
357
358/** The tool row's one line: what this call changed. */
359function summarize(p: Patch | undefined): string {
360 if (!p || Object.keys(p).length === 0) return 'Read the map'
361 const out: string[] = []
362 const n = (k: object | undefined) => (k ? Object.keys(k).length : 0)
363 if (p.goal !== undefined) out.push('Goal set')
364 if (p.steps) out.push(`${p.steps.length} steps`)
365 if (p.current !== undefined) out.push(`Step ${p.current} now`)
366 if (p.focus !== undefined) out.push(`Focus ${p.focus}`)
367 if (p.substeps) out.push(`${p.substeps.steps.length} sub-steps under ${p.substeps.under}`)
368 if (p.detour) out.push(`Detour: ${p.detour.title}`)
369 if (p.done) out.push(`Done${p.done.path ? ` ${p.done.path}` : ''}`)
370 if (p.step_updates?.length) out.push(`${p.step_updates.length} step updates`)
371 if (n(p.facts)) out.push(`${n(p.facts)} facts`)
372 if (p.pin) out.push(`${p.pin.length} pinned`)
373 if (n(p.glossary)) out.push(`${n(p.glossary)} terms`)
374 if (p.stages?.length) out.push(`${p.stages.length} stages`)
375 if (p.park?.length) out.push(`${p.park.length} parked`)
376 if (p.recap) out.push('Recap')
377 if (p.trail?.length) out.push(`${p.trail.length} trail items`)
378 return out.join(' · ') || 'Updated the map'
379}
380
381/** A route picture for one level of the plan, rendered once per distinct content. */
382async function routePng($: EngineInterface, steps: AtlasNode[], current: number, prefix: string): Promise<string> {
383 const svg = routeSvg(steps, current, prefix)
384 const png = `${workDir}/route-${hash(svg)}.png`
385 if (!(await $.fs.exists(png))) {
386 const src = png.replace(/\.png$/, '.svg')
387 await $.fs.write(src, svg)
388 const out = await $.process.run(['magick', '-density', '96', src, png], { timeoutMs: 20_000 })
389 if (out.exitCode !== 0) throw new Error(out.stderr)
390 }
391 return png
392}
393
394async function openPane($: EngineInterface) {
395 await $.ui.open({ id: PANE, title: 'atlas' })
396}
397
398/** After a turn: a small model files the turn into the map and refreshes the recap. */
399async function watch($: EngineInterface, model: string, recent: Turn[]) {
400 const map = await read($, mapAtom)
401 if (isEmpty(map) || recent.length === 0) return
402 await update($, isWatching, () => true)
403 try {
404 const reply = await $.model.complete({ model, system: WATCH_SYSTEM, prompt: watchPrompt(map, recent), maxTokens: 900 })
405 if (!reply.isAnswered) return
406 const patch = parseWatch(reply.text)
407 if (!patch) return
408 const now = await $.clock.now()
409 await mutate($, m => {
410 const next = applyPatch(m, patch, now)
411 if (patch.recap && next.recap) next.recap = { ...next.recap, by: model === 'haiku' ? 'Haiku' : 'Sonnet' }
412 return next
413 })
414 } finally {
415 await update($, isWatching, () => false)
416 }
417}
418
419const KEEPER_PROMPT = `You are the atlas keeper. You keep the project's atlas map true in the background so the user never has to ask for upkeep. The map is shown to a user who thinks visually and loses track of long sessions.
420
421Rules:
422- Read anything in the project. Write files only inside .claude/atlas/ (sample images). Never edit project code, configs or data; never run training, long jobs, installs, or git commands that change anything.
423- Finish within a few minutes of work. Do the tasks you are given and nothing else.
424- Change the map only through the atlas_update tool. Use full names; capitalise the first word of every label.
425
426Rendering a stage sample:
427- Find one real example of the stage's output (the code, caches, run folders or result files tell you where). Draw it in the form that fits its shape: an image or frame, a signal trace (a few channels, a short window), a heat map for a matrix, a short table rendered as an image for scalars.
428- Use a short Python script run with: uv run --quiet --with matplotlib --with numpy python -I <script>. Put the script in /tmp, not in the project.
429- Save the PNG at the path given in the task, Read it, and only then call atlas_update with stages: [{name, sample: <path>, note: <one line saying what is visible>}].
430- If you cannot find real data within a few tries, say so in the note and stop; never draw made-up data.
431
432Checking facts: compare pinned facts and the newest facts with the repository (git log, result and registry files). Fix wrong ones with atlas_update facts, and add a trail note saying what changed.
433
434Glossary: add any abbreviation, run-ID prefix or nickname from the recent turns that the glossary lacks.
435
436End with one short line saying what you did.`
437
438const KEEPER_EVERY_MS = 10 * 60_000
439const KEEPER_CHECK_TURNS = 15
440let keeperId: string | undefined
441let refreshTimer: Timer | undefined
442let lastKeeperAt = 0
443let turnsSinceKeeper = 0
444
445/** Upkeep the map needs now: stages without a sample, and a periodic fact and glossary check. */
446async function keeperTasks($: EngineInterface, map: AtlasMap, root: string): Promise<string[]> {
447 const tasks: string[] = []
448 const missing = []
449 // A stage needs a sample when it has none stored and its iris figure is gone (iris numbers do not outlive a session).
450 for (const st of map.stages) {
451 if (st.sample !== undefined && (await $.fs.exists(`${root}/${st.sample}`))) continue
452 if (st.figure !== undefined && (await irisPng($, st.figure))) continue
453 missing.push(st)
454 }
455 for (const st of missing.slice(0, 2)) {
456 tasks.push(`Render a sample for the "${st.name}" stage${st.out ? ` (output ${st.out})` : ''} and save it at ${root}/${sampleRel(st.name)}.`)
457 }
458 if (turnsSinceKeeper >= KEEPER_CHECK_TURNS) tasks.push('Check the pinned facts against the repository, and add missing glossary entries from the recent turns.')
459 return tasks
460}
461
462async function startKeeper($: EngineInterface, tasks: string[]) {
463 if (tasks.length === 0 || (await read($, isKeeping))) return
464 const map = await read($, mapAtom)
465 const recent = turns.slice(-3).map((t, i) => `Turn ${i + 1}. User: ${t.prompt}\nActions: ${t.actions.join('; ')}\nAssistant: ${t.answer}`).join('\n\n')
466 const prompt = [
467 `Project root: ${projectRoot}`,
468 '',
469 'Tasks:',
470 ...tasks.map((t, i) => `${i + 1}. ${t}`),
471 '',
472 'The map now:',
473 toMarkdown(map, await read($, seen)),
474 '',
475 'Recent turns:',
476 recent || '(none)',
477 ].join('\n')
478 await update($, isKeeping, () => true)
479 lastKeeperAt = await $.clock.now()
480 turnsSinceKeeper = 0
481 try {
482 const r = await $.agent.spawn({ subagentType: 'atlas:keeper', prompt, description: 'atlas keeper' })
483 keeperId = r.agentId
484 if (!keeperId) await update($, isKeeping, () => false)
485 } catch {
486 await update($, isKeeping, () => false)
487 }
488}
489
490const HISTORIAN_PROMPT = `You are the atlas historian. You read one chunk of a project's past Claude Code conversations (the user's and Claude's words, plus one line per tool call; tool output is cut) and write down what happened, so the main session can rebuild the project's map without rereading everything.
491
492Read the whole chunk file you are given (in parts if it is long). Do not read anything else. Do not write or change any file.
493
494Reply with only these sections, dated where the chunk gives a time, in plain full words (no new abbreviations; explain any that the chunk uses):
495- Goals: what the user was trying to achieve, and any deadlines or targets.
496- Steps: the work done, in order, each with its result (numbers, scores, shapes, paths, run names exactly as written).
497- Decisions: what was chosen over what, and why.
498- Dead ends: what was tried and dropped, with the numbers that killed it.
499- Facts: datasets and versions, input and output shapes, splits, key paths, best scores and which run produced them.
500- Open threads: what was left unfinished, running, or waiting on the user at the end of the chunk.
501- Terms: abbreviations, run-ID prefixes and nicknames, with their meanings.
502- Conflicts: places where the conversation contradicts itself (say which came later).
503
504Keep it under 900 words. Leave out chat, tooling trouble and anything that did not change the project.`
505
506/** The cold-start instruction: rebuild the map from the conversation, past transcripts, memory and the repository. */
507function bootstrapText(history: { chunks: { file: string; from: string; to: string }[]; memory: string[]; transcripts: number } | undefined): string {
508 const past = history && history.chunks.length > 0
509 ? [
510 `2. Read the project's history. Its ${history.transcripts} past transcripts are digested into ${history.chunks.length} chunks, oldest first:`,
511 ...history.chunks.map((c, i) => ` ${i + 1}. ${c.file} (${c.from} to ${c.to})`),
512 ' Hand each chunk to its own atlas:historian agent (Agent tool, subagent_type "atlas:historian", prompt: the chunk path), several in one message so they run in parallel. Do not read the chunks yourself.',
513 ...(history.memory.length > 0 ? [' Also read the project memory notes:', ...history.memory.map(m => ` - ${m}`)] : []),
514 ]
515 : ['2. There are no past transcripts for this project; skip to the repository.']
516 return [
517 'Build the atlas map for this project now (a cold start: the project already has history).',
518 '1. Start from what you already know in this conversation: the goal, what has been done, what is in progress, what was tried and dropped, and what is waiting on me.',
519 ...past,
520 '3. Check and fill gaps from the repository with a quick read: README or context docs, backlog or notes, recent git log and branches, run or result records, uncommitted changes. Do not read everything; aim for the shape of the project. Where history and the repository disagree, the newer source usually wins, but say so.',
521 '4. Write it with atlas_update, in a few calls. If the map already has content (call atlas_update with no arguments to see it), revise it instead of starting over: keep steps, sub-steps and outcomes that are right, correct the rest, and add only trail lines, facts and glossary entries that are not there yet.',
522 ' - goal (one line, with the target and deadline if there is one)',
523 ' - steps: the top-level plan with done and in-progress marked (step_updates with one-line outcomes for finished steps); then focus on where the work actually is now, with substeps for the branch the work drilled into, and detours for side work that left its step',
524 ' - facts: datasets and versions, input and output shapes and dtypes, splits, key paths, best scores and which run produced them, deadlines; pin the three that matter most right now',
525 ' - stages: the pipeline from raw data to the final artefact, with the shape or format in and out of each stage',
526 ' - glossary: every abbreviation, run-ID prefix and nickname in use, with its meaning',
527 ' - trail: decisions and dead ends, one line each, oldest first',
528 ' - park: open threads and tangents that are not the current focus',
529 ' - recap: where we are, the most useful thing learned, the next concrete step and how it leads back to the top-level plan',
530 '5. Where sources disagree and you cannot tell which is right, do not guess: add a trail note starting "Open question:" and list them for me at the end.',
531 'Then show me the plan, in a few lines, as a tree.',
532 ].join('\n')
533}
534
535/** Digest the project's past transcripts, then ask the main session to build the map from them. */
536async function startInit($: EngineInterface) {
537 const out = `${workDir}/history`
538 let history
539 try {
540 const r = await $.process.run(['python3', '-I', `${$.plugin.root}/scripts/digest.py`, projectRoot, out], { timeoutMs: 300_000 })
541 if (r.exitCode === 0) history = JSON.parse(await $.fs.read(`${out}/index.json`))
542 } catch {
543 // No history to read: build from the conversation and the repository alone.
544 }
545 await $.prompt.submit({ text: bootstrapText(history), asUser: true })
546}
547
548/** What the Look at it button sends. */
549function lookText(stage: AtlasStage): string {
550 const what = stage.sample ? `the sample at ${projectRoot}/${stage.sample}` : `iris figure #${stage.figure}`
551 return `Look at ${what} for the "${stage.name}" stage: Read the PNG and tell me what is actually visible and whether anything looks wrong.`
552}
553
554/** The instructions the drift buttons send, as if typed. */
555function returnText(map: AtlasMap): string {
556 const path = focusNodes(map)
557 const here = path[path.length - 1]
558 const parent = path[path.length - 2]
559 return `Wrap up "${here?.title ?? 'this detour'}": record what came of it with atlas_update (done, with a one-line outcome), then continue with ${parent ? `"${parent.title}"` : 'the top-level plan'} and tell me where that leaves the plan.`
560}
561
562function parkText(map: AtlasMap): string {
563 const here = focusNodes(map).pop()
564 return `Park "${here?.title ?? 'this tangent'}" with atlas_update (park, then done with outcome "Parked"), and return to the step it came from.`
565}
566
567export const register: Register = on => {
568 on('session.start', async ($, e, next) => {
569 projectRoot = await $.session.root()
570 sessionId = await $.session.id()
571 sessionName = await readSessionName($, projectRoot, sessionId)
572 workDir = `${(await $.env.get('TMPDIR')) ?? '/tmp'}/atlas-${sessionId}`
573 const loaded = await load($, projectRoot)
574 await update($, mapAtom, () => loaded)
575 await $.tool.register({ name: 'atlas_update', description: MAP_DESCRIPTION, inputSchema: MAP_SCHEMA, isDeferred: false })
576 await $.agent
577 .register({
578 name: 'keeper',
579 description: 'Keeps the atlas map true in the background: renders missing stage samples, checks facts, fills the glossary.',
580 prompt: KEEPER_PROMPT,
581 tools: ['Read', 'Glob', 'Grep', 'Bash', TOOL],
582 model: 'sonnet',
583 maxTurns: 30,
584 background: true,
585 omitClaudeMd: true,
586 })
587 .catch(() => undefined)
588 await $.agent
589 .register({
590 name: 'historian',
591 description: 'Summarises one chunk of a project\'s past transcripts for /atlas init: goals, steps with results, decisions, dead ends, facts, open threads, terms.',
592 prompt: HISTORIAN_PROMPT,
593 tools: ['Read'],
594 // Haiku kept every checked number on two cerebrov2 chunks and found more contradictions than Sonnet; the main session does the judgement.
595 model: 'haiku',
596 maxTurns: 12,
597 omitClaudeMd: true,
598 })
599 .catch(() => undefined)
600 await update($, isKeeping, () => false)
601 // Other sessions on this project write the same map; pick their changes up within a few seconds.
602 refreshTimer?.cancel()
603 refreshTimer = $.clock.every(3000, () => void latest($).catch(() => undefined))
604 await $.command.register({ name: 'atlas', description: 'Open the atlas map. /atlas init builds one for a project with history; /atlas reset clears it; /atlas recap runs a deep recap; /atlas project <path> switches project.', argumentHint: '[init | reset | recap | project <path>]' })
605
606 return next(e)
607 })
608
609 // The keeper is atlas's own: the main model never delegates to it.
610 on('agent.offer', { agent: 'atlas:keeper' }, () => ({ isOffered: false }))
611
612 // ---- the model's side ----
613
614 on('prompt.compose', async ($, e, next) => {
615 const result = await next(e)
616 return { sections: [...result.sections, { id: 'atlas:rules', text: RULES, scope: 'session' as const }] }
617 }).catch(($, e, next) => next(e))
618
619 on('prompt.submit', async ($, e, next) => {
620 const result = await next(e)
621 if (result.drop !== undefined) return result
622 pending = { prompt: result.text.slice(0, 700), actions: [], answer: '' }
623 const map = await read($, mapAtom)
624 const lines: string[] = []
625 if (isEmpty(map)) {
626 lines.push('atlas: no map yet for this project. When the work has a goal and steps, start one with atlas_update.')
627 } else {
628 const terms = Object.keys(map.glossary)
629 lines.push(`atlas: ${bandLine(map, false)}${terms.length > 0 ? ` · Glossary: ${terms.join(', ')}` : ''} · atlas_update with no arguments returns the full map.`)
630 if (map.recap) lines.push(`atlas recap: ${map.recap.where} Next: ${map.recap.next}`)
631 if (map.drift) lines.push(`atlas: drift note on the step in focus: ${map.drift.note}`)
632 const unsampled = map.stages.filter(st => st.figure === undefined && st.sample === undefined).map(st => st.name)
633 if (unsampled.length > 0) {
634 lines.push(`atlas: pipeline stages with no sample yet: ${unsampled.join(', ')}. When the work touches one of them, render one real example with iris and link it (stages[].figure).`)
635 }
636 if (await read($, isStale)) {
637 lines.push(`atlas: ${await read($, changes)} edits or commands since the map was last updated. Update the steps, facts and trail if they changed.`)
638 }
639 }
640 return { ...result, context: [...(result.context ?? []), ...lines] }
641 }).catch(($, e, next) => next(e))
642
643 on('tool.call', { tool: TOOL }, async ($, e) => {
644 const { tool: _t, tool_use_id: _id, ...patch } = e as unknown as Patch & { tool: string; tool_use_id: string }
645 if (Object.keys(patch).length === 0) return { result: toMarkdown(await latest($), await read($, seen)) }
646 const linked = (patch.stages ?? []).flatMap(st => (st.figure !== undefined ? [{ name: st.name, figure: st.figure }] : []))
647 const copied = await copySamples($, linked)
648 const now = await $.clock.now()
649 const next = await mutate($, m => withSamples(applyPatch(m, patch, now), copied))
650 await update($, changes, () => 0)
651 await update($, isStale, () => false)
652 return { result: `Map updated. Above the prompt: ${bandLine(next, false) || '(nothing pinned yet)'}` }
653 }).catch(() => ({ deny: 'atlas failed while updating the map.' }))
654
655 // Every main-thread call: note it for the watchdog, and count changes since the last map update.
656 on('tool.call', async ($, e, next) => {
657 const result = await next(e)
658 if (e.agentId !== undefined || result.deny !== undefined) return result
659 const args = e as unknown as Record<string, unknown>
660 const detail = [args.file_path, args.command, args.path, args.pattern, args.title, args.query, args.url].find(v => typeof v === 'string') as string | undefined
661 if (pending.actions.length < 30) pending.actions.push(`${e.tool}${detail ? ` ${detail.slice(0, 90)}` : ''}`)
662 if (!result.isReadOnly && (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit' || e.tool === 'Bash')) {
663 await update($, changes, n => n + 1)
664 }
665 return result
666 })
667
668 // A Read of an iris render means Claude looked at that figure.
669 on('tool.call', { tool: 'Read' }, async ($, e, next) => {
670 const result = await next(e)
671 if (result.deny !== undefined) return result
672 const m = /\/iris-[^/]+\/(\d+)\/v\d+-p\d+\.png$/.exec(e.file_path)
673 const n = m ? Number(m[1]) : undefined
674 if (n !== undefined) await update($, seen, list => (list.includes(n) ? list : [...list, n]))
675 // Looking at a stage's render, or at its stored sample, marks that stage seen in the saved map.
676 const map = await latest($)
677 const now = await $.clock.now()
678 let isChanged = false
679 const stages = map.stages.map(st => {
680 const isThis =
681 (n !== undefined && st.figure === n) || e.file_path === `${projectRoot}/${st.sample ?? sampleRel(st.name)}`
682 if (!isThis) return st
683 isChanged = true
684 return { ...st, seenAt: now }
685 })
686 if (isChanged) {
687 const hit = new Set(stages.filter(st => st.seenAt === now).map(st => st.name))
688 await mutate($, m => ({ ...m, stages: m.stages.map(st => (hit.has(st.name) ? { ...st, seenAt: now } : st)) }))
689 }
690 return result
691 })
692
693 // A render shown with id "stage-<name>" (or titled like a stage) becomes that stage's sample at once.
694 on('tool.call', { tool: 'mcp__iris__show' }, async ($, e, next) => {
695 const result = await next(e)
696 if (result.deny !== undefined || result.isError) return result
697 const text = typeof result.result === 'string' ? result.result : (result.text ?? '')
698 const m = /as #(\d+) /.exec(text)
699 const args = e as unknown as { id?: string; title?: string }
700 if (!m) return result
701 const map = await latest($)
702 const stage = map.stages.find(
703 st => args.id === `stage-${st.name}` || args.id === `stage-${slug(st.name)}` || (args.title ?? '').toLowerCase() === st.name.toLowerCase(),
704 )
705 if (!stage) return result
706 const now = await $.clock.now()
707 const figure = Number(m[1])
708 const copied = await copySamples($, [{ name: stage.name, figure }])
709 await mutate($, mm => withSamples(applyPatch(mm, { stages: [{ name: stage.name, figure }] }, now), copied))
710 return result
711 })
712
713 // Research cache: remember lookups, and say so when one repeats.
714 on('tool.call', { tool: 'WebSearch' }, ($, e, next) => remember($, e.tool, e.query, () => next(e)))
715 on('tool.call', { tool: 'WebFetch' }, ($, e, next) => remember($, e.tool, `${e.url} ${e.prompt}`, () => next(e)))
716
717 on('turn.complete', async ($, e, next) => {
718 if (e.agentId !== undefined && e.agentId === keeperId) {
719 keeperId = undefined
720 await update($, isKeeping, () => false)
721 const line = e.answer.trim().split('\n').pop()?.slice(0, 160) ?? ''
722 if (line !== '') $.ui.toast(`atlas keeper: ${line}`)
723 return next(e)
724 }
725 if (e.agentId === undefined && !e.isAborted) {
726 turnsSinceKeeper += 1
727 if ((await read($, changes)) > 0 && !isEmpty(await read($, mapAtom))) await update($, isStale, () => true)
728 turns.push({ ...pending, answer: e.answer.slice(0, 900) })
729 if (turns.length > 12) turns.shift()
730 pending = { prompt: '', actions: [], answer: '' }
731 void watch($, 'haiku', turns.slice(-2)).catch(() => undefined)
732 const map = await read($, mapAtom)
733 if (!isEmpty(map) && (await $.clock.now()) - lastKeeperAt > KEEPER_EVERY_MS) {
734 void keeperTasks($, map, projectRoot).then(tasks => startKeeper($, tasks)).catch(() => undefined)
735 }
736 }
737 return next(e)
738 })
739
740 // ---- the user's side ----
741
742 on('command.run', { command: 'atlas' }, async ($, e) => {
743 const arg = e.args.trim()
744 if (arg === 'reset') {
745 await save($, migrate(undefined))
746 await update($, changes, () => 0)
747 await update($, isStale, () => false)
748 return { text: 'atlas: this project\'s map is cleared.' }
749 }
750 if (arg === 'init') {
751 void startInit($)
752 await openPane($)
753 return { text: 'atlas: building this project\'s map from past transcripts, memory notes, the conversation and the repository.' }
754 }
755 if (arg.startsWith('project')) {
756 const target = arg.slice('project'.length).trim()
757 const root = target === '' ? await $.session.root() : target.replace(/\/+$/, '').replace(/^~(?=\/)/, (await $.env.get('HOME')) ?? '~')
758 const loaded = await load($, root)
759 projectRoot = root
760 await update($, mapAtom, () => loaded)
761 await update($, changes, () => 0)
762 await update($, isStale, () => false)
763 turns.length = 0
764 await openPane($)
765 return { text: `atlas: now showing the map for ${root}.` }
766 }
767 if (arg === 'recap') {
768 void watch($, 'sonnet', turns.slice()).catch(() => undefined)
769 await openPane($)
770 return { text: 'atlas: deep recap running on Sonnet; it appears in the Recap card.' }
771 }
772 await openPane($)
773 return { text: 'atlas pane opened.' }
774 })
775
776 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
777 const map = await read($, mapAtom)
778 if (e.props.hasSurvey) return next(e)
779 if (isEmpty(map)) {
780 if (await read($, isInitDismissed)) return next(e)
781 const { Box, Text, Button } = $.ui.resolve(e)
782 return (
783 <Box flexDirection="row" columnGap={1}>
784 <Text dimColor>atlas: no map for this project yet.</Text>
785 <Button key="atlas-init" label="Build map" onPress={() => void startInit($)} />
786 <Button key="atlas-later" plain dimColor label="Not now" onPress={() => update($, isInitDismissed, () => true)} />
787 </Box>
788 )
789 }
790 const { Box, Text, Button } = $.ui.resolve(e)
791 const sel = await read($, irisSel)
792 const stale = await read($, isStale)
793 const watching = await read($, isWatching)
794 return (
795 <Box flexDirection="column">
796 <Box flexDirection="row" columnGap={1}>
797 <Text wrap="truncate-end">{bandLine(map, stale, sel?.n)}</Text>
798 {watching && <Text dimColor>◷</Text>}
799 {(await read($, isKeeping)) && <Text dimColor>⚙ Keeper</Text>}
800 <Button key="open-atlas" plain dimColor label="Map" onPress={() => openPane($)} />
801 </Box>
802 {map.drift && (
803 <Box flexDirection="row" columnGap={1}>
804 <Text color={RED} wrap="truncate-end">↯ {map.drift.note}</Text>
805 <Button key="drift-return" label="Return" onPress={() => $.prompt.submit({ text: returnText(map), asUser: true })} />
806 <Button
807 key="drift-keep"
808 label="Keep going"
809 onPress={async () => {
810 await mutate($, m => {
811 const nodeId = m.drift?.nodeId
812 return { ...m, drift: null, keepGoing: nodeId ? [...m.keepGoing, nodeId] : m.keepGoing }
813 })
814 }}
815 />
816 <Button key="drift-park" label="Park" onPress={() => $.prompt.submit({ text: parkText(map), asUser: true })} />
817 </Box>
818 )}
819 </Box>
820 )
821 })
822
823 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
824 if (e.props.tool !== TOOL) return next(e)
825 const { Box, Text } = $.ui.resolve(e)
826 return (
827 <Box flexDirection="row">
828 <Text bold color={ORANGE}>atlas</Text>
829 <Text dimColor> · {summarize(e.props.input as Patch)}</Text>
830 </Box>
831 )
832 }).catch(($, e, next) => next(e))
833
834 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
835 const { Box, Text, Button } = $.ui.resolve(e)
836 const map = await read($, mapAtom)
837 const current = await read($, tab)
838 const looked = await read($, seen)
839 const width = Math.max(24, e.props.bodyColumns)
840 const inner = width - 4
841
842 const tabs: [AtlasTab, string, string][] = [
843 ['map', 'm', 'Map'],
844 ['pipeline', 'p', 'Pipeline'],
845 ['glossary', 'g', 'Glossary'],
846 ['trail', 't', 'Trail'],
847 ]
848 const header = (
849 <Box flexDirection="row" columnGap={1}>
850 {tabs.map(([id, key, label]) => (
851 <Button key={`tab-${id}`} hotkey={key} label={label} variant={id === current ? 'primary' : 'secondary'} onPress={() => update($, tab, () => id)} />
852 ))}
853 </Box>
854 )
855
856 if (isEmpty(map)) {
857 return (
858 <Box flexDirection="column">
859 {header}
860 <Text dimColor>No map for this project yet. Claude starts one with atlas_update once the work has a goal and steps.</Text>
861 </Box>
862 )
863 }
864
865 const chip = (label: string, colour: string) => (
866 <Text backgroundColor={colour} color="#FFFFFF" bold>
867 {` ${label} `}
868 </Text>
869 )
870 const card = (key: string, title: string, colour: string, children: RenderChildren) => (
871 <Box key={key} flexDirection="column" borderStyle="round" borderColor={colour} paddingX={1} width={width}>
872 <Text bold color={colour}>
873 {title}
874 </Text>
875 {children}
876 </Box>
877 )
878 const table = (rows: [string, string, boolean?][], keyPrefix: string) => {
879 const kw = Math.min(24, Math.max(...rows.map(([k]) => k.length)) + 2)
880 return rows.map(([k, v, isMarked]) => (
881 <Box key={`${keyPrefix}-${k}`} flexDirection="row">
882 <Box width={kw} flexShrink={0}>
883 <Text bold>{k}</Text>
884 </Box>
885 <Text color={isMarked ? ORANGE : undefined}>{v}</Text>
886 </Box>
887 ))
888 }
889 const how = e.surface === 'terminal' ? await imageMode($, e.viewport) : 'none'
890 const routeImage = async (key: string, steps: AtlasNode[], at: number, prefix: string) => {
891 if (how === 'none' || e.surface !== 'terminal' || steps.length === 0) return null
892 const png = await routePng($, steps, at, prefix).catch(() => undefined)
893 if (!png) return null
894 const { Image } = $.ui.resolve(e)
895 const cols = Math.min(width, 255)
896 const rows = Math.max(3, Math.round((cols * ROUTE_H) / ROUTE_W / 2))
897 const id = `${key}-${png.slice(-14)}-${cols}x${rows}`
898 const source = await sourceOf($, png, how)
899 void probeImages($, id, source, cols, rows).catch(() => undefined)
900 return <Image key={id} source={source} columns={cols} rows={rows} alt="Plan route" />
901 }
902 const now = await $.clock.now()
903
904 let body
905 if (current === 'map') {
906 const path = focusNodes(map)
907 const here = path[path.length - 1]
908 const top = path[0]
909 const topRoute = await routeImage('route-top', map.plan, top ? map.plan.indexOf(top) : -1, '')
910 let branchRoute = null
911 if (path.length >= 2) {
912 const parent = path[path.length - 2]
913 const siblings = parent?.children ?? []
914 const label = focusLabel(map).split('.').slice(0, -1).join('.')
915 branchRoute = (
916 <Box flexDirection="column">
917 <Text dimColor>Inside {label} {parent?.title}</Text>
918 {await routeImage('route-branch', siblings, here ? siblings.indexOf(here) : -1, `${label}.`)}
919 </Box>
920 )
921 }
922 const pinned = map.pinned.filter(k => map.facts[k]).map(k => [k, map.facts[k]?.value ?? ''] as [string, string])
923 const others = Object.entries(map.facts)
924 .filter(([k]) => !map.pinned.includes(k))
925 .map(([k, f]) => {
926 const isRecent = f.was !== undefined && now - f.changedAt < 10 * 60_000
927 return [k, isRecent ? `${f.value} (was ${f.was})` : f.value, isRecent] as [string, string, boolean]
928 })
929 const treeRows: RenderChildren[] = []
930 const walk = (nodes: AtlasNode[], prefix: string, depth: number) => {
931 nodes.forEach((n, i) => {
932 const p = prefix === '' ? String(i + 1) : `${prefix}.${i + 1}`
933 const isHere = n === here
934 treeRows.push(
935 <Box key={`node-${n.id}`} flexDirection="row" columnGap={1}>
936 <Box flexGrow={1}>
937 <Text bold={isHere} dimColor={n.status === 'todo' || n.status === 'dropped'} color={isHere ? ORANGE : undefined}>
938 {' '.repeat(depth)}
939 {n.kind === 'detour' ? '↪ ' : ''}
940 {p}. {n.title}
941 {n.outcome ? <Text dimColor> · {n.outcome}</Text> : ''}
942 </Text>
943 </Box>
944 {n.status === 'done' && chip('Done', GREEN)}
945 {isHere && chip('Now', ORANGE)}
946 {n.status === 'doing' && !isHere && chip('Open', BLUE)}
947 {n.status === 'dropped' && chip('Dropped', GREY)}
948 </Box>,
949 )
950 // Finished branches fold away; open ones stay expanded.
951 if (n.status !== 'done' || path.includes(n)) walk(n.children, p, depth + 1)
952 })
953 }
954 walk(map.plan, '', 0)
955 const recap = map.recap
956 // Other sessions working on this project, and where each one is in the shared plan.
957 const otherSessions = Object.entries(map.sessions ?? {})
958 .filter(([id]) => id !== sessionId)
959 .sort((x, y) => y[1].t - x[1].t)
960 body = (
961 <Box flexDirection="column">
962 {map.goal !== '' && <Text bold>{map.goal}</Text>}
963 {topRoute}
964 {branchRoute}
965 {map.drift &&
966 card(
967 'drift',
968 '↯ Drifting?',
969 RED,
970 <Box flexDirection="column">
971 <Text>{map.drift.note}</Text>
972 <Box flexDirection="row" columnGap={1}>
973 <Button key="pane-return" label="Return" onPress={() => $.prompt.submit({ text: returnText(map), asUser: true })} />
974 <Button
975 key="pane-keep"
976 label="Keep going"
977 onPress={async () => {
978 await mutate($, m => {
979 const nodeId = m.drift?.nodeId
980 return { ...m, drift: null, keepGoing: nodeId ? [...m.keepGoing, nodeId] : m.keepGoing }
981 })
982 }}
983 />
984 <Button key="pane-park" label="Park" onPress={() => $.prompt.submit({ text: parkText(map), asUser: true })} />
985 </Box>
986 </Box>,
987 )}
988 {here &&
989 card(
990 'now',
991 `Now · ${breadcrumb(map)}`,
992 ORANGE,
993 <Box flexDirection="column">
994 <Text bold>{here.title}</Text>
995 {here.why && <Text dimColor>Why: {here.why}</Text>}
996 </Box>,
997 )}
998 {otherSessions.length > 0 &&
999 card(
1000 'sessions',
1001 `Sessions (${otherSessions.length + 1})`,
1002 GREY,
1003 otherSessions.map(([id, ss]) => (
1004 <Text key={`session-${id}`}>
1005 <Text bold>{ss.name}</Text> · {breadcrumb({ ...map, focus: ss.focus }) || 'No step'} <Text dimColor>· {age(now, ss.t)}</Text>
1006 </Text>
1007 )),
1008 )}
1009 {recap &&
1010 card(
1011 'recap',
1012 'Recap',
1013 BLUE,
1014 <Box flexDirection="column">
1015 <Text>
1016 <Text bold>Where we are </Text>
1017 {recap.where}
1018 </Text>
1019 <Text>
1020 <Text bold>Learned </Text>
1021 {recap.learned}
1022 </Text>
1023 <Text>
1024 <Text bold color={ORANGE}>Next </Text>
1025 {recap.next}
1026 </Text>
1027 <Box flexDirection="row" columnGap={1}>
1028 <Text dimColor>
1029 {recap.by}, {age(now, recap.t)}
1030 </Text>
1031 <Button key="deep-recap" plain dimColor label="Deep recap" onPress={() => void watch($, 'sonnet', turns.slice()).catch(() => undefined)} />
1032 </Box>
1033 </Box>,
1034 )}
1035 {map.plan.length > 0 && card('plan', 'Plan', BLUE, treeRows)}
1036 {map.parked.length > 0 &&
1037 card(
1038 'parked',
1039 `Parked (${map.parked.length})`,
1040 BROWN,
1041 map.parked.map((p, i) => (
1042 <Text key={`parked-${i}`}>
1043 {i + 1}. {p.text} <Text dimColor>· from {p.from}, {age(now, p.t)}</Text>
1044 </Text>
1045 )),
1046 )}
1047 {pinned.length > 0 && card('pinned', 'Pinned', ORANGE, table(pinned, 'pin'))}
1048 {others.length > 0 && card('facts', 'Facts', GREY, table(others, 'fact'))}
1049 </Box>
1050 )
1051 } else if (current === 'pipeline') {
1052 const cards = []
1053 for (const [i, s] of map.stages.entries()) {
1054 const hasSample = s.figure !== undefined || s.sample !== undefined
1055 const isSeen = s.seenAt !== undefined || (s.figure !== undefined && looked.includes(s.figure))
1056 const colour = !hasSample ? GREY : isSeen ? GREEN : ORANGE
1057 let thumb = null
1058 if (how === 'none' && hasSample && e.surface === 'terminal') {
1059 thumb = <Text dimColor>This terminal shows no pictures; Look at it describes the sample, or open {s.sample ?? `figure #${s.figure}`}.</Text>
1060 } else if (hasSample && e.surface === 'terminal') {
1061 const stored = s.sample ? `${projectRoot}/${s.sample}` : undefined
1062 const png = stored && (await $.fs.exists(stored)) ? stored : s.figure !== undefined ? await irisPng($, s.figure) : undefined
1063 const d = png ? await pngDims($, png).catch(() => undefined) : undefined
1064 if (png && d) {
1065 const { Image } = $.ui.resolve(e)
1066 let cols = Math.min(inner, 255)
1067 let rows = Math.max(1, Math.round((cols * d.h) / d.w / 2))
1068 if (rows > 14) {
1069 rows = 14
1070 cols = Math.max(1, Math.round((rows * 2 * d.w) / d.h))
1071 }
1072 const id = `thumb-${i}-${cols}x${rows}`
1073 const source = await sourceOf($, png, how)
1074 void probeImages($, id, source, cols, rows).catch(() => undefined)
1075 thumb = <Image key={id} source={source} columns={cols} rows={rows} alt={`${s.name} sample`} />
1076 }
1077 }
1078 if (i > 0) cards.push(<Text key={`arrow-${i}`} color={GREY}>{' ↓'}</Text>)
1079 cards.push(
1080 card(
1081 `stage-${i}`,
1082 `${i + 1}. ${s.name}`,
1083 colour,
1084 <Box flexDirection="column">
1085 <Box flexDirection="row" columnGap={1}>
1086 {!hasSample ? chip('No sample', GREY) : isSeen ? chip('Seen ✓', GREEN) : chip('Not looked at', ORANGE)}
1087 {s.sample !== undefined ? <Text dimColor>{s.sample}</Text> : s.figure !== undefined && <Text dimColor>Figure #{s.figure}</Text>}
1088 </Box>
1089 {(s.in || s.out) && (
1090 <Text>
1091 <Text dimColor>In </Text>
1092 {s.in ?? '?'}
1093 <Text dimColor> → Out </Text>
1094 {s.out ?? '?'}
1095 </Text>
1096 )}
1097 {s.note && <Text dimColor>{s.note}</Text>}
1098 {!hasSample && (
1099 <Button
1100 key={`sample-${i}`}
1101 label="Generate sample"
1102 onPress={() => startKeeper($, [`Render a sample for the "${s.name}" stage${s.out ? ` (output ${s.out})` : ''} and save it at ${projectRoot}/${sampleRel(s.name)}.`])}
1103 />
1104 )}
1105 {hasSample && !isSeen && (
1106 <Button key={`look-${i}`} label="Look at it" onPress={() => $.prompt.submit({ text: lookText(s), asUser: true })} />
1107 )}
1108 {thumb}
1109 </Box>,
1110 ),
1111 )
1112 }
1113 body = cards.length > 0 ? <Box flexDirection="column">{cards}</Box> : <Text dimColor>No pipeline stages yet.</Text>
1114 } else if (current === 'glossary') {
1115 const terms = Object.entries(map.glossary).sort(([a], [b]) => a.localeCompare(b))
1116 body = terms.length > 0 ? card('glossary', 'Glossary', BLUE, table(terms, 'term')) : <Text dimColor>No terms yet.</Text>
1117 } else {
1118 const kinds: Record<string, [string, string]> = {
1119 decision: ['Decision', BLUE],
1120 deadend: ['Dead end', RED],
1121 done: ['Done', GREEN],
1122 note: ['Note', GREY],
1123 checkpoint: ['Checkpoint', BROWN],
1124 }
1125 const items = [...map.trail].reverse().slice(0, 60)
1126 const research = [...map.research].reverse().slice(0, 20)
1127 body = (
1128 <Box flexDirection="column">
1129 {items.length === 0 ? (
1130 <Text dimColor>No trail yet.</Text>
1131 ) : (
1132 card(
1133 'trail',
1134 'Trail',
1135 BLUE,
1136 items.map((t, i) => {
1137 const [label, colour] = kinds[t.kind] ?? ['Note', GREY]
1138 return (
1139 <Box key={`trail-${i}`} flexDirection="row" columnGap={1}>
1140 <Box width={13} flexShrink={0}>{chip(label, colour)}</Box>
1141 <Box flexGrow={1}>
1142 <Text>{t.text}</Text>
1143 </Box>
1144 <Text dimColor>{age(now, t.t)}</Text>
1145 </Box>
1146 )
1147 }),
1148 )
1149 )}
1150 {research.length > 0 &&
1151 card(
1152 'lookups',
1153 'Lookups',
1154 GREY,
1155 research.map((r, i) => (
1156 <Text key={`research-${i}`} dimColor>
1157 {r.tool === 'WebSearch' ? 'Searched' : 'Fetched'}: {r.query.slice(0, inner - 16)} ({age(now, r.t)})
1158 </Text>
1159 )),
1160 )}
1161 </Box>
1162 )
1163 }
1164
1165 return (
1166 <Box flexDirection="column">
1167 {header}
1168 {body}
1169 </Box>
1170 )
1171 })
1172}
1173
1174hooks/model.ts 365 lines1// The map's pure logic: the plan tree and its focus, patches, the band line, and text views.
2
3import type { AtlasMap, AtlasNode, AtlasStage, AtlasStepStatus, AtlasTrailKind } from '../types'
4
5export const EMPTY: AtlasMap = {
6 goal: '',
7 plan: [],
8 focus: [],
9 nextId: 1,
10 facts: {},
11 pinned: [],
12 glossary: {},
13 stages: [],
14 trail: [],
15 research: [],
16 parked: [],
17 recap: null,
18 drift: null,
19 keepGoing: [],
20}
21
22export const TRAIL_LIMIT = 300
23export const RESEARCH_LIMIT = 100
24
25export type Patch = {
26 goal?: string
27 /** Replaces the top-level plan; the first step takes focus. */
28 steps?: string[]
29 /** 1-based top-level step now in progress (shorthand for focus: "n"). */
30 current?: number
31 /** Step path such as "3" or "3.2" to put in focus. */
32 focus?: string
33 /** Replaces the sub-steps of the step at `under` (a path); the first takes focus. */
34 substeps?: { under: string; steps: string[] }
35 /** Files a detour under the step in focus and focuses it. */
36 detour?: { title: string; why?: string }
37 /** Marks a step done (default: the one in focus) and moves on to its next sibling or back to its parent. */
38 done?: { path?: string; outcome?: string }
39 /** Updates by path ("3.2") or top-level number. */
40 step_updates?: { n?: number; path?: string; status?: AtlasStepStatus; outcome?: string }[]
41 facts?: Record<string, string>
42 pin?: string[]
43 glossary?: Record<string, string>
44 stages?: AtlasStage[]
45 remove_stages?: string[]
46 trail?: { kind: AtlasTrailKind; text: string }[]
47 /** Tangents to come back to later. */
48 park?: string[]
49 /** 1-based parked items to remove (picked up or dropped). */
50 unpark?: number[]
51 recap?: { where: string; learned: string; next: string }
52 /** A drift note for the step in focus; null clears it. */
53 drift?: { note: string } | null
54}
55
56// ---------- tree helpers ----------
57
58export function clone(map: AtlasMap): AtlasMap {
59 return JSON.parse(JSON.stringify(map)) as AtlasMap
60}
61
62/** Nodes from the top level down to the node at `path` ("3.2" -> [step 3, its step 2]). */
63export function nodesAtPath(plan: AtlasNode[], path: string): AtlasNode[] | undefined {
64 const parts = path.split('.').map(p => Number(p) - 1)
65 const out: AtlasNode[] = []
66 let level = plan
67 for (const i of parts) {
68 const node = level[i]
69 if (!node || !Number.isInteger(i)) return undefined
70 out.push(node)
71 level = node.children
72 }
73 return out.length > 0 ? out : undefined
74}
75
76/** Nodes along the focus path; stops at the first id that no longer exists. */
77export function focusNodes(map: AtlasMap): AtlasNode[] {
78 const out: AtlasNode[] = []
79 let level = map.plan
80 for (const id of map.focus) {
81 const node = level.find(n => n.id === id)
82 if (!node) break
83 out.push(node)
84 level = node.children
85 }
86 return out
87}
88
89/** "3.2"-style label of the focus path. */
90export function focusLabel(map: AtlasMap): string {
91 const labels: number[] = []
92 let level = map.plan
93 for (const id of map.focus) {
94 const i = level.findIndex(n => n.id === id)
95 if (i < 0) break
96 labels.push(i + 1)
97 level = level[i]?.children ?? []
98 }
99 return labels.join('.')
100}
101
102/** The siblings list that holds the last focus node, and its index there. */
103function focusSlot(map: AtlasMap): { siblings: AtlasNode[]; index: number } | undefined {
104 let level = map.plan
105 let slot: { siblings: AtlasNode[]; index: number } | undefined
106 for (const id of map.focus) {
107 const i = level.findIndex(n => n.id === id)
108 if (i < 0) break
109 slot = { siblings: level, index: i }
110 level = level[i]?.children ?? []
111 }
112 return slot
113}
114
115function newNode(map: AtlasMap, title: string, extra: Partial<AtlasNode> = {}): AtlasNode {
116 return { id: `s${map.nextId++}`, title: title.trim(), status: 'todo', children: [], ...extra }
117}
118
119function setFocusPath(map: AtlasMap, nodes: AtlasNode[]) {
120 map.focus = nodes.map(n => n.id)
121 for (const n of nodes) if (n.status === 'todo') n.status = 'doing'
122}
123
124export function isEmpty(map: AtlasMap): boolean {
125 return map.goal === '' && map.plan.length === 0 && Object.keys(map.facts).length === 0 && map.stages.length === 0
126}
127
128/** Accepts maps saved before the plan became a tree (flat steps + current). */
129export function migrate(raw: unknown): AtlasMap {
130 const m = { ...clone(EMPTY), ...((raw ?? {}) as Partial<AtlasMap>) } as AtlasMap & {
131 steps?: { title: string; status: AtlasStepStatus; outcome?: string }[]
132 current?: number
133 }
134 if (m.steps && (!m.plan || m.plan.length === 0)) {
135 m.plan = m.steps.map(s => newNode(m, s.title, { status: s.status, outcome: s.outcome }))
136 const cur = m.plan[m.current ?? -1]
137 m.focus = cur ? [cur.id] : []
138 }
139 delete m.steps
140 delete m.current
141 return m
142}
143
144// ---------- patches ----------
145
146export function applyPatch(map: AtlasMap, patch: Patch, now: number): AtlasMap {
147 const next = clone(map)
148 const trail = (kind: AtlasTrailKind, text: string) => next.trail.push({ t: now, kind, text })
149
150 if (patch.goal !== undefined) next.goal = patch.goal.trim()
151 if (patch.steps !== undefined) {
152 next.plan = patch.steps.map(t => newNode(next, t))
153 next.focus = []
154 if (next.plan[0]) setFocusPath(next, [next.plan[0]])
155 }
156 if (patch.current !== undefined) patch = { ...patch, focus: String(patch.current) }
157 if (patch.substeps !== undefined) {
158 const nodes = nodesAtPath(next.plan, patch.substeps.under)
159 const parent = nodes?.[nodes.length - 1]
160 if (nodes && parent) {
161 parent.children = patch.substeps.steps.map(t => newNode(next, t))
162 const first = parent.children[0]
163 if (first) setFocusPath(next, [...nodes, first])
164 }
165 }
166 if (patch.focus !== undefined) {
167 const nodes = nodesAtPath(next.plan, patch.focus)
168 if (nodes) {
169 // Moving along one level finishes the step left behind, as moving to the next station does.
170 const before = focusNodes(next)
171 const left = before[nodes.length - 1]
172 const target = nodes[nodes.length - 1]
173 if (left && target && left !== target && left.status === 'doing' && before.length >= nodes.length) {
174 const siblings = nodes.length === 1 ? next.plan : nodes[nodes.length - 2]?.children ?? []
175 if (siblings.indexOf(left) < siblings.indexOf(target)) left.status = 'done'
176 }
177 setFocusPath(next, nodes)
178 }
179 }
180 if (patch.detour !== undefined) {
181 const path = focusNodes(next)
182 const parent = path[path.length - 1]
183 const node = newNode(next, patch.detour.title, { kind: 'detour', why: patch.detour.why })
184 if (parent) parent.children.push(node)
185 else next.plan.push(node)
186 setFocusPath(next, [...path, node])
187 trail('note', `Detour${parent ? ` from "${parent.title}"` : ''}: ${node.title}${node.why ? ` (${node.why})` : ''}`)
188 }
189 if (patch.done !== undefined) {
190 const nodes = patch.done.path ? nodesAtPath(next.plan, patch.done.path) : focusNodes(next)
191 const node = nodes?.[nodes.length - 1]
192 if (nodes && node) {
193 node.status = 'done'
194 if (patch.done.outcome !== undefined) node.outcome = patch.done.outcome
195 trail('done', `${node.title}${node.outcome ? `: ${node.outcome}` : ''}`)
196 const focus = focusNodes(next)
197 if (focus.includes(node)) {
198 const parents = nodes.slice(0, -1)
199 const siblings = parents.length > 0 ? parents[parents.length - 1]?.children ?? [] : next.plan
200 const after = siblings.slice(siblings.indexOf(node) + 1).find(s => s.status === 'todo' || s.status === 'doing')
201 if (after) setFocusPath(next, [...parents, after])
202 else next.focus = parents.map(p => p.id)
203 }
204 if (next.drift && next.drift.nodeId === node.id) next.drift = null
205 }
206 }
207 for (const u of patch.step_updates ?? []) {
208 const nodes = u.path ? nodesAtPath(next.plan, u.path) : u.n !== undefined ? nodesAtPath(next.plan, String(u.n)) : undefined
209 const node = nodes?.[nodes.length - 1]
210 if (!node) continue
211 if (u.status !== undefined) node.status = u.status
212 if (u.outcome !== undefined) node.outcome = u.outcome
213 }
214 for (const [key, value] of Object.entries(patch.facts ?? {})) {
215 if (value === '') {
216 delete next.facts[key]
217 continue
218 }
219 const old = next.facts[key]
220 if (old?.value === value) continue
221 next.facts[key] = { value, changedAt: now, was: old?.value }
222 }
223 if (patch.pin !== undefined) next.pinned = patch.pin
224 next.pinned = next.pinned.filter(k => k in next.facts)
225 for (const [term, meaning] of Object.entries(patch.glossary ?? {})) {
226 if (meaning === '') delete next.glossary[term]
227 else next.glossary[term] = meaning
228 }
229 for (const stage of patch.stages ?? []) {
230 if (stage.name === '') continue
231 const at = next.stages.findIndex(s => s.name === stage.name)
232 const old = next.stages[at]
233 if (at < 0 || !old) next.stages.push({ ...stage })
234 else {
235 // A new sample has not been looked at yet.
236 const isNewSample = stage.figure !== undefined && stage.figure !== old.figure
237 next.stages[at] = { ...old, ...stage, ...(isNewSample ? { seenAt: undefined, sample: stage.sample } : {}) }
238 }
239 }
240 if (patch.remove_stages) next.stages = next.stages.filter(s => !patch.remove_stages?.includes(s.name))
241 for (const item of patch.trail ?? []) trail(item.kind, item.text)
242 const from = focusNodes(next).map(n => n.title).pop() ?? 'the plan'
243 for (const text of patch.park ?? []) next.parked.push({ t: now, text, from })
244 if (patch.unpark) next.parked = next.parked.filter((_, i) => !patch.unpark?.includes(i + 1))
245 if (patch.recap) next.recap = { t: now, by: 'claude', ...patch.recap }
246 if (patch.drift !== undefined) {
247 const at = focusNodes(next).pop()
248 if (patch.drift === null || !at) next.drift = null
249 else if (!next.keepGoing.includes(at.id)) {
250 next.drift = { since: next.drift?.nodeId === at.id ? next.drift.since : now, note: patch.drift.note, nodeId: at.id }
251 }
252 }
253 next.trail = next.trail.slice(-TRAIL_LIMIT)
254 return next
255}
256
257// ---------- views ----------
258
259export const STATUS_MARK: Record<AtlasStepStatus, string> = { done: '✓', doing: '▶', todo: '·', dropped: '✗' }
260export const TRAIL_MARK: Record<AtlasTrailKind, string> = { done: '✓', decision: '◆', deadend: '✗', note: '·', checkpoint: '◷' }
261
262/** A step's short name: the text before a colon, else the first three words. */
263export function shortName(title: string): string {
264 const head = title.includes(':') ? title.slice(0, title.indexOf(':')) : title.split(/\s+/).slice(0, 3).join(' ')
265 return head.trim()
266}
267
268/** "Build tools › 3 atlas › 3.2 Route picture" */
269export function breadcrumb(map: AtlasMap): string {
270 const parts: string[] = []
271 let level = map.plan
272 const nums: number[] = []
273 for (const id of map.focus) {
274 const i = level.findIndex(n => n.id === id)
275 const node = level[i]
276 if (!node) break
277 nums.push(i + 1)
278 parts.push(`${nums.join('.')} ${shortName(node.title)}`)
279 level = node.children
280 }
281 return parts.join(' › ')
282}
283
284/** The one line above the prompt. */
285export function bandLine(map: AtlasMap, isStale: boolean, lastFigure?: number): string {
286 const parts: string[] = []
287 const crumb = breadcrumb(map)
288 if (crumb !== '') parts.push(`▶ ${crumb}`)
289 else if (map.goal !== '') parts.push(map.goal)
290 for (const key of map.pinned) {
291 const fact = map.facts[key]
292 if (fact) parts.push(`${key}: ${fact.value}`)
293 }
294 if (lastFigure !== undefined) parts.push(`Figure #${lastFigure}`)
295 if (map.parked.length > 0) parts.push(`${map.parked.length} parked`)
296 if (isStale) parts.push('⚠ Map may be stale')
297 return parts.join(' · ')
298}
299
300/** The plan tree as indented text with paths, the focus marked. */
301export function treeText(map: AtlasMap): string {
302 const focus = new Set(map.focus)
303 const lines: string[] = []
304 const walk = (nodes: AtlasNode[], prefix: string, depth: number) => {
305 nodes.forEach((n, i) => {
306 const path = prefix === '' ? String(i + 1) : `${prefix}.${i + 1}`
307 const tag = n.kind === 'detour' ? ' [detour]' : ''
308 const here = focus.has(n.id) && map.focus[map.focus.length - 1] === n.id ? ' ← focus' : ''
309 lines.push(`${' '.repeat(depth)}${path} [${n.status}]${tag} ${n.title}${n.outcome ? ` — ${n.outcome}` : ''}${here}`)
310 walk(n.children, path, depth + 1)
311 })
312 }
313 walk(map.plan, '', 0)
314 return lines.join('\n')
315}
316
317/** The whole map as markdown, for the model. */
318export function toMarkdown(map: AtlasMap, seen: readonly number[]): string {
319 if (isEmpty(map)) return 'The atlas map for this project is empty.'
320 const out: string[] = []
321 if (map.goal !== '') out.push(`Goal: ${map.goal}`)
322 if (map.plan.length > 0) out.push('', 'Plan (paths like 3.2 address steps):', treeText(map))
323 if (map.recap) out.push('', `Recap: where ${map.recap.where}; learned ${map.recap.learned}; next ${map.recap.next}`)
324 if (map.drift) out.push('', `Drift note: ${map.drift.note}`)
325 if (map.parked.length > 0) {
326 out.push('', 'Parked:')
327 map.parked.forEach((p, i) => out.push(`${i + 1}. ${p.text} (from ${p.from})`))
328 }
329 const facts = Object.entries(map.facts)
330 if (facts.length > 0) {
331 out.push('', 'Facts:')
332 for (const [k, f] of facts) out.push(`- ${k}: ${f.value}${f.was !== undefined ? ` (was ${f.was})` : ''}`)
333 }
334 if (map.stages.length > 0) {
335 out.push('', 'Pipeline stages:')
336 for (const s of map.stages) {
337 const shape = s.in || s.out ? ` ${s.in ?? '?'} → ${s.out ?? '?'}` : ''
338 const hasSample = s.figure !== undefined || s.sample !== undefined
339 const isSeen = s.seenAt !== undefined || (s.figure !== undefined && seen.includes(s.figure))
340 const where = s.sample ?? `figure #${s.figure}`
341 const look = !hasSample ? 'no sample shown' : isSeen ? `seen (${s.sample ?? `figure #${s.figure}`})` : `${where}, not looked at yet`
342 out.push(`- ${s.name}:${shape} [${look}]${s.note ? ` ${s.note}` : ''}`)
343 }
344 }
345 const terms = Object.entries(map.glossary)
346 if (terms.length > 0) {
347 out.push('', 'Glossary:')
348 for (const [k, v] of terms) out.push(`- ${k}: ${v}`)
349 }
350 if (map.trail.length > 0) {
351 out.push('', 'Recent trail:')
352 for (const item of map.trail.slice(-10)) out.push(`${TRAIL_MARK[item.kind]} ${item.text}`)
353 }
354 return out.join('\n')
355}
356
357/** "3m ago" style ages for the pane. */
358export function age(now: number, t: number): string {
359 const s = Math.max(0, Math.round((now - t) / 1000))
360 if (s < 60) return `${s}s ago`
361 if (s < 3600) return `${Math.round(s / 60)}m ago`
362 if (s < 86400) return `${Math.round(s / 3600)}h ago`
363 return `${Math.round(s / 86400)}d ago`
364}
365hooks/route.ts 78 lines1// The plan as a route line: one station per step, done green, current orange, upcoming grey.
2
3import type { AtlasNode } from '../types'
4
5export const ROUTE_W = 1200
6export const ROUTE_H = 210
7
8const COLOUR = { done: '#59A14F', doing: '#F28E2B', todo: '#BAB0AC', dropped: '#BAB0AC', ink: '#222222', muted: '#6B6B6B' }
9
10/** A station's short name: the text before a colon, else the first two words. */
11export function shortName(title: string): string {
12 const head = title.includes(':') ? title.slice(0, title.indexOf(':')) : title.split(/\s+/).slice(0, 2).join(' ')
13 return head.trim()
14}
15
16/** Splits a label into at most two lines of about `width` characters. */
17export function wrap2(text: string, width = 12): string[] {
18 const words = text.split(/\s+/)
19 const lines: string[] = []
20 let line = ''
21 for (const w of words) {
22 if (line === '' || (line + ' ' + w).length <= width) line = line === '' ? w : `${line} ${w}`
23 else {
24 lines.push(line)
25 line = w
26 }
27 }
28 if (line !== '') lines.push(line)
29 return lines.length <= 2 ? lines : [lines[0] ?? '', `${lines.slice(1).join(' ').slice(0, width - 1)}…`]
30}
31
32const esc = (s: string) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
33
34/** `prefix` numbers a sub-route's stations ("3." gives 3.1, 3.2, ...). */
35export function routeSvg(steps: readonly AtlasNode[], current: number, prefix = ''): string {
36 const n = Math.max(1, steps.length)
37 const margin = 115
38 const y = 78
39 const x = (i: number) => (n === 1 ? ROUTE_W / 2 : margin + (i * (ROUTE_W - 2 * margin)) / (n - 1))
40 const parts: string[] = [
41 `<svg xmlns="http://www.w3.org/2000/svg" width="${ROUTE_W}" height="${ROUTE_H}" viewBox="0 0 ${ROUTE_W} ${ROUTE_H}">`,
42 `<rect width="100%" height="100%" fill="#FFFFFF"/>`,
43 `<style>text{font-family:'TeX Gyre Heros',Helvetica,Arial,sans-serif}</style>`,
44 ]
45 for (let i = 0; i < n - 1; i++) {
46 const isDone = steps[i]?.status === 'done' && steps[i + 1]?.status !== 'todo'
47 parts.push(`<line x1="${x(i)}" y1="${y}" x2="${x(i + 1)}" y2="${y}" stroke="${isDone ? COLOUR.done : COLOUR.todo}" stroke-width="10" stroke-linecap="round"/>`)
48 }
49 steps.forEach((s, i) => {
50 const isNow = i === current
51 const isDetour = s.kind === 'detour'
52 const fill = isNow ? COLOUR.doing : s.status === 'done' ? COLOUR.done : '#FFFFFF'
53 const stroke = isNow ? COLOUR.doing : s.status === 'done' ? COLOUR.done : COLOUR.todo
54 if (isNow) parts.push(`<circle cx="${x(i)}" cy="${y}" r="34" fill="none" stroke="${COLOUR.doing}" stroke-width="4" opacity="0.45"/>`)
55 parts.push(`<circle cx="${x(i)}" cy="${y}" r="${isNow ? 24 : 19}" fill="${fill}" stroke="${stroke}" stroke-width="6"/>`)
56 const numColour = isNow || s.status === 'done' ? '#FFFFFF' : COLOUR.muted
57 const num = `${prefix}${i + 1}`
58 const numSize = (isNow ? 26 : 22) - (num.length > 2 ? 6 : 0)
59 parts.push(`<text x="${x(i)}" y="${y + 8}" text-anchor="middle" font-size="${numSize}" font-weight="bold" fill="${numColour}">${num}</text>`)
60 const lines = wrap2(`${isDetour ? '↪ ' : ''}${shortName(s.title)}`)
61 lines.forEach((line, k) => {
62 const colour = isNow ? COLOUR.ink : s.status === 'done' ? COLOUR.ink : COLOUR.muted
63 parts.push(
64 `<text x="${x(i)}" y="${y + 70 + k * 30}" text-anchor="middle" font-size="25" font-weight="${isNow ? 'bold' : 'normal'}" fill="${colour}">${esc(line)}</text>`,
65 )
66 })
67 })
68 parts.push('</svg>')
69 return parts.join('\n')
70}
71
72/** A short stable hash, to name the rendered file after its content. */
73export function hash(text: string): string {
74 let h = 2166136261
75 for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 16777619)
76 return (h >>> 0).toString(36)
77}
78hooks/watch.ts 102 lines1// The watchdog: after each turn a small model files the turn into the plan tree and refreshes the recap.
2
3import type { AtlasMap } from '../types'
4import { breadcrumb, treeText, type Patch } from './model'
5
6export type Turn = { prompt: string; actions: string[]; answer: string }
7
8export const WATCH_SYSTEM = `You keep a work map for a user who thinks visually and loses track of long sessions. Work often drills from a big plan into one step that becomes its own search space and never returns to the top. Your job is bookkeeping, not judgement: the user may go anywhere. File the latest turns into the plan tree so the way back stays clear, and write a short recap.
9
10Reply with one JSON object and nothing else. Every field is optional; leave out anything that did not change.
11{
12 "focus": "3.2", // the path of the step the latest work belongs to, if it moved
13 "substeps": {"under": "3", "steps": ["...", "..."]}, // only when a step clearly split into a sub-plan that the map lacks
14 "detour": {"title": "...", "why": "..."}, // when the work left the step in focus for something not in the plan
15 "done": {"path": "3.2", "outcome": "..."}, // when a step or detour clearly finished
16 "park": ["..."], // tangents mentioned but deliberately not pursued
17 "recap": {"where": "...", "learned": "...", "next": "..."},
18 "drift": {"note": "..."} or null, // a note only when two or more turns went far from the focus step with no sign it was intended; null clears an old note
19 "trail": [{"kind": "decision" | "deadend" | "done" | "checkpoint", "text": "..."}]
20}
21
22Rules:
23- The assistant may already have updated the map during the turn; the map below is current. Do not repeat what it already says.
24- Prefer fewer structural changes. Never invent steps the work does not show. Never mark something done without evidence.
25- Recap: "where" names the step in focus and what state it is in; "learned" is the one most useful finding; "next" is the next concrete action and, if a branch is nearly done, how it returns to the top-level plan. Each under 25 words.
26- Full names, no abbreviations you invented. Capitalise the first word.`
27
28/** The prompt: the map as a tree with paths, then the latest turns. */
29export function watchPrompt(map: AtlasMap, turns: readonly Turn[]): string {
30 const parts = [
31 `Goal: ${map.goal || '(none)'}`,
32 `Focus: ${breadcrumb(map) || '(none)'}`,
33 '',
34 'Plan:',
35 treeText(map) || '(empty)',
36 ]
37 if (map.parked.length > 0) parts.push('', 'Parked:', ...map.parked.map((p, i) => `${i + 1}. ${p.text}`))
38 if (map.recap) parts.push('', `Previous recap: where ${map.recap.where} | learned ${map.recap.learned} | next ${map.recap.next}`)
39 if (map.drift) parts.push('', `Open drift note: ${map.drift.note}`)
40 parts.push('', 'Latest turns, oldest first:')
41 turns.forEach((t, i) => {
42 parts.push(
43 `--- Turn ${i + 1}`,
44 `User: ${t.prompt || '(no new message; a continuation)'}`,
45 `Actions: ${t.actions.length > 0 ? t.actions.join('; ') : '(none)'}`,
46 `Assistant said: ${t.answer || '(nothing)'}`,
47 )
48 })
49 return parts.join('\n')
50}
51
52const KINDS = new Set(['decision', 'deadend', 'done', 'checkpoint', 'note'])
53const str = (v: unknown, max = 200): string | undefined => (typeof v === 'string' && v.trim() !== '' ? v.trim().slice(0, max) : undefined)
54const isPath = (v: unknown): v is string => typeof v === 'string' && /^\d+(\.\d+)*$/.test(v)
55
56/** Reads the model's JSON reply into a patch, keeping only well-formed fields. */
57export function parseWatch(text: string): Patch | undefined {
58 const start = text.indexOf('{')
59 const end = text.lastIndexOf('}')
60 if (start < 0 || end <= start) return undefined
61 let raw: Record<string, unknown>
62 try {
63 raw = JSON.parse(text.slice(start, end + 1)) as Record<string, unknown>
64 } catch {
65 return undefined
66 }
67 const patch: Patch = {}
68 if (isPath(raw.focus)) patch.focus = raw.focus
69 const sub = raw.substeps as { under?: unknown; steps?: unknown } | undefined
70 if (sub && isPath(sub.under) && Array.isArray(sub.steps)) {
71 const steps = sub.steps.map(s => str(s, 120)).filter((s): s is string => s !== undefined)
72 if (steps.length > 0) patch.substeps = { under: sub.under, steps }
73 }
74 const det = raw.detour as { title?: unknown; why?: unknown } | undefined
75 const title = str(det?.title, 120)
76 if (title) patch.detour = { title, why: str(det?.why) }
77 const done = raw.done as { path?: unknown; outcome?: unknown } | undefined
78 if (done && (done.path === undefined || isPath(done.path))) patch.done = { path: done.path as string | undefined, outcome: str(done.outcome) }
79 if (Array.isArray(raw.park)) {
80 const park = raw.park.map(p => str(p, 160)).filter((p): p is string => p !== undefined)
81 if (park.length > 0) patch.park = park
82 }
83 const rc = raw.recap as { where?: unknown; learned?: unknown; next?: unknown } | undefined
84 const where = str(rc?.where)
85 const learned = str(rc?.learned)
86 const nxt = str(rc?.next)
87 if (where && learned && nxt) patch.recap = { where, learned, next: nxt }
88 if (raw.drift === null) patch.drift = null
89 else {
90 const note = str((raw.drift as { note?: unknown } | undefined)?.note)
91 if (note) patch.drift = { note }
92 }
93 if (Array.isArray(raw.trail)) {
94 const trail = raw.trail
95 .map(t => t as { kind?: unknown; text?: unknown })
96 .filter(t => typeof t.kind === 'string' && KINDS.has(t.kind) && str(t.text))
97 .map(t => ({ kind: t.kind as 'decision' | 'deadend' | 'done' | 'checkpoint' | 'note', text: str(t.text) as string }))
98 if (trail.length > 0) patch.trail = trail.slice(0, 4)
99 }
100 return Object.keys(patch).length > 0 ? patch : undefined
101}
102types/index.d.ts 93 lines1export type AtlasStepStatus = 'todo' | 'doing' | 'done' | 'dropped'
2
3/** One step of the plan; any step can open its own sub-plan. */
4export type AtlasNode = {
5 id: string
6 title: string
7 status: AtlasStepStatus
8 outcome?: string
9 /** A detour is work that left its parent step's path, filed under it. */
10 kind?: 'detour'
11 why?: string
12 children: AtlasNode[]
13}
14
15export type AtlasFact = { value: string; changedAt: number; was?: string }
16
17export type AtlasStage = {
18 name: string
19 in?: string
20 out?: string
21 /** iris figure number the sample came from (session-scoped; the sample file is what lasts). */
22 figure?: number
23 /** The sample image, copied into the project: a path relative to the project root. */
24 sample?: string
25 /** When Claude last read this stage's sample; cleared when the sample changes. */
26 seenAt?: number
27 note?: string
28}
29
30export type AtlasTrailKind = 'done' | 'decision' | 'deadend' | 'note' | 'checkpoint'
31
32export type AtlasTrailItem = { t: number; kind: AtlasTrailKind; text: string }
33
34export type AtlasResearch = { t: number; tool: string; query: string; summary: string }
35
36export type AtlasParked = { t: number; text: string; from: string }
37
38export type AtlasRecap = { t: number; where: string; learned: string; next: string; by: string }
39
40export type AtlasDrift = { since: number; note: string; nodeId: string }
41
42export type AtlasMap = {
43 goal: string
44 /** The top level of the plan. */
45 plan: AtlasNode[]
46 /** Node ids from the top level down to the step in focus; empty when none. */
47 focus: string[]
48 nextId: number
49 facts: Record<string, AtlasFact>
50 /** Fact keys shown in the band above the prompt. */
51 pinned: string[]
52 glossary: Record<string, string>
53 stages: AtlasStage[]
54 trail: AtlasTrailItem[]
55 research: AtlasResearch[]
56 parked: AtlasParked[]
57 recap: AtlasRecap | null
58 drift: AtlasDrift | null
59 /** Node ids the user chose to keep working in despite a drift note. */
60 keepGoing: string[]
61 /** Every session working on this project, by session id: its name and its own position in the plan. */
62 sessions?: Record<string, AtlasSession>
63}
64
65export type AtlasSession = { name: string; focus: string[]; t: number }
66
67export type AtlasTab = 'map' | 'pipeline' | 'glossary' | 'trail'
68
69/** How the viewing terminal shows an Image: from a file path, only as PNG bytes, not at all, or not asked yet. Mirrors iris's type. */
70export type IrisImages = 'unknown' | 'file' | 'bytes' | 'none'
71
72declare module 'claude-code' {
73 interface PluginState {
74 atlas: {
75 map: AtlasMap
76 /** Edits and commands since the last atlas_update. */
77 changes: number
78 isStale: boolean
79 tab: AtlasTab
80 /** iris figure numbers whose PNG Claude has read this session. */
81 seen: number[]
82 /** True while the watchdog is reading the last turn. */
83 isWatching: boolean
84 /** The person chose "Not now" on the empty-map band this session. */
85 isInitDismissed: boolean
86 /** True while the keeper agent is working in the background. */
87 isKeeping: boolean
88 /** How the viewing terminal shows pictures, as atlas's own probe found (same values as iris's). */
89 images: IrisImages
90 }
91 }
92}
93