SLOPSHOPPER

atlas

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.

newpanebandrowsguardcommand
★ 4v0.1.0MITupdated 2026-10-15braininahat/brains-in-a-hat/atlas
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · atlas
│ ┃ atlas ✕ › fix the failing auth test and add an audit log call │ ┃ [ Map ] [ Pipeline ] [ Glossary ] [ Trail ] │ ┃ No map for this project yet. Claude starts ⏺ Read(src/auth.ts) │ ┃ one with atlas_update once the work has a ⎿ Read 6 lines │ ┃ goal and steps. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /atlas │ ⎿ atlas: atlas pane opened. │ │ atlas: no map for this project yet. [ Build map ] Not now ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
atlas: no map for this project yet. [ Build map ] Not now
Pane · atlas
[ Map ] [ Pipeline ] [ Glossary ] [ Trail ] No map for this project yet. Claude starts one with atlas_update once the work has a goal and steps.
README

atlas

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.

  • Plan tree with a focus path. Steps can grow sub-steps and detours; finishing one rolls up to its parent. The band above the prompt shows the breadcrumb (▶ 3 Training › 3.2 Learning-rate sweep), pinned facts and parked items.
  • Watchdog. After every turn a small model (Haiku) files the turn into the tree and refreshes a recap (where we are, what was learned, what is next). When work drifts, the band offers Return, Keep going and Park. /atlas recap runs a deeper recap on Sonnet.
  • Keeper. A background Sonnet agent (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.
  • Pane (/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.
  • Cold start. In a project with history, /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.
  • One map per project, many sessions. Every session in the project shares .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.
  • Each project's map lives in that project's repo, as .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.

Source 5 files
hooks/register.tsx 1174 lines
1import { 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
1174
hooks/model.ts 365 lines
1// 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}
365
hooks/route.ts 78 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
78
hooks/watch.ts 102 lines
1// 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}
102
types/index.d.ts 93 lines
1export 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