SLOPSHOPPER

codebase-atlas

Understand a codebase visually: a live architecture map that lights up as Claude reads and edits, a call graph explorer, the session's changes by layer with…

newpaneguardcommandtoastmodel
v0.2.0no licenseupdated 2026-10-03harshitmywork17/claude-mods/plugins/codebase-atlas
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codebase-atlas
│ ┃ Codebase Atlas ✕ › fix the failing auth test and add an audit log call │ ┃ [ Map ] [ Changes ] [ Components ] [ Calls ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ Starting the first scan… ⎿ Read 6 lines │ ⏺ 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 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Codebase Atlas
[ Map ] [ Changes ] [ Components ] [ Calls ] [ Decisions ] [ Starting the first scan…
README

harshit-mods

A Claude Code plugin marketplace, and the single home for Claude customizations that stay the same on every device: ten mods, the Claritymaxx skill, the sync-home skill, and the karpathy-guidelines and python-standards coding skills. To add something, ask Claude to put it "on all my devices". The sync-home skill and CLAUDE.md tell it how.

PluginCommandWhat it does
usage-forecast/forecast, /forecast hide, /forecast showA band above the prompt. 5-hour and Weekly plan limits as coloured bars (green, then yellow from 70%, red from 90%) with the percent used, a reset countdown and the reset time (↻ resets in 1h 12m · 15:40), and ⚠ at this pace, out in 40m when you would run out before the reset. Below it, the context window as weather with a 12-turn sparkline and turns left. Replaces token-weather.
blast-radius/blast-radius, /blast-radius <command> (dry run)Catches rm -rf, git reset --hard, force pushes, git clean -f, git checkout ., git branch -D, git stash drop/clear, DROP/TRUNCATE, mkfs, dd of=/dev/.... It shows what the command would touch in a pane and forces a permission prompt. A deny from your settings is never loosened.
replay-theater/replay, /replay 2Records each turn's Edit and Write calls. In the pane, n/p step through the diffs, o/w change turn, and Esc closes it.
changed-files/changed-filesA live sidebar of every file touched this session, with +/- counts, edit counts and a new tag. It opens by itself on the first edit.
session-meter/meterThe status line shows elapsed time, tool calls and files edited. A toast appears when a turn takes 60 s or more; change it with /plugin configure session-meter@harshit-mods.
claritymaxx/claritymaxx:explain <topic>, or ask "explain..."Third-party skill from v60samurai/claritymaxx. Builds a mental model first, then explains it as text, a diagram or a small HTML page.
fork-explorer/fork <what if...>, or type in the paneRuns a side question over the current session and shows 2-3 alternative approaches as cards side by side, with pros, cons and effort. "use this" puts the choice in your prompt box to edit and send. Nothing is added to your conversation.
mission-control/missionA live pane of what Claude is executing: each tool call with its input, status badge (✓ ✗ ⊘ ●), duration and output; subagents in their own lanes with tool and token counts; a network-tab style waterfall; time spent in the model between tools. v switches between the timeline and the outputs. Observe-only; it never changes a call.
diff-minimapautomaticA thin strip beside each Edit and Write row in the transcript. Green, red and yellow ticks show where in the file the change sits, with the line range and the file length.
codebase-atlas/atlas, /atlas changes, /atlas components <file>, /atlas calls <name>, /atlas decisions, /atlas rescanOne pane with six tabs. Map: folders as boxes in dependency layers (entry points on top, foundations at the bottom), lit yellow when Claude edits and cyan when it reads; select one for what it imports and what uses it. Changes: every uncommitted change mapped to the functions and classes it added, modified or removed, with call-site counts, the layers crossed and the files that import what changed. Components: a file's classes, functions and methods with size bars, what each calls, and which changed. Calls: callers and callees of any function; type a name or select one in the transcript, then walk the graph. Decisions: a timeline of decisions a small model pulls from each turn (turn off with extractDecisions). Explain: side-question explanations of a folder, a function or the session's changes that never enter your conversation. Works with or without git: in a git repo, changes are measured against the last commit; in a plain folder, against the files as Atlas first read them that session (dependencies, virtual environments and build output are skipped). Reads Python and JS/TS.
workbench/wb, /wb changes [file], /wb preview <file>, /wb artifacts, /wb map, /wb usage, /wb calls <name>, /wb forecast, /wb workbench, /wb hud offOne workspace. A one-line band above the prompt with two slides, switched with ◀ ▶: Workbench (session time, tool calls, files changed with +/−, new files, plan limits and context on wide terminals, then the running tool or the last edit and its functions) and Forecast (5-hour and weekly limits with bars, reset times and pace, plus the context forecast; it shows the usage-forecast mod's band when that mod is on). A pane with six tabs on one row: Now, the turn's tool calls with status and timing, failures with their error, and subagents nested under the call that started them; Changes, every file with a change bar and the functions it touched, then a file's changed functions and highlighted diff since Claude first touched it, then each edit (replay with n/p); Preview, a live render of the file being edited (Markdown, CSV table, JSON tree, HTML screenshot, PNG, code) with recent files one press away; Artifacts, files created this session by Claude ✦ or its commands, with preview and copy path; Map, architecture layers with folder sizes and imports, a file's components, and the call graph; Usage, limits with reset times and pace, context, and cost per turn. Works with or without git. Claude Code's own diff panel sits above mod panes: run /diff to hide it if it covers the workbench.
crew/crew, /crew auto off, /crew closeAn Agents side pane, like a mission board for subagents. Three cards on top total the session's cost, tokens and time. Each subagent is a little pixel crew member whose hat shows its effort (heavy, careful, medium, light), with its model and effort level, context used with a bar, tokens, its share of the cost and how long it ran; it walks while it works and shows the tool it is running. Sections: Running, Completed (✓ done, ✗ failed, ⊘ stopped; folds) and Planned, the tasks from Claude's task or todo list with what each still waits for ("after 1, 3"). Click an agent for its task, tool count and answer. Opens by itself when the first subagent starts; /crew auto off stops that.
explainer-page/explainer-page:explainer-page, or ask for a visual or interactive explainer pageA skill for textbook-style HTML pages that teach one concept: each idea in words, maths, a figure and code, with colour-coded notation (inputs blue, outputs orange, parameters green, functions violet, probabilities pink) kept identical everywhere; tables and figures (3D bars, node diagrams) drawn from one data source and highlighted together on hover; sliders where a parameter is the point; code cells with their real output; margin notes, callouts and check-yourself questions; light and dark themes and a contents sidebar. Publishes as a claude.ai artifact when that tool is available, otherwise writes an .html file. Triggers only on an explicit request for such a page.
savvy-flow/savvy-flow:savvy-flow <task>A copy of savvy-flow from johnnyvizz/claude-kit with one change: the top tier, savvy-fable, runs on Opus at max effort instead of the Fable model, so it works without Fable access. The session model plans the task, delegates pieces to five tiered worker agents (savvy-fable, savvy-heavy, savvy-careful, savvy-medium, savvy-light) and reviews what they return. tools/sync-savvy-flow.sh refreshes the copy from upstream and reapplies the change; the Sync savvy-flow GitHub Action runs it daily and commits when upstream changed.
savvy-progress/agents-infoFrom the same repo, following its main branch the same way. A progress bar above the prompt driven by savvy-flow, and an agents panel with model, effort, step progress, context, estimated cost and time. Animated crabs draw in the desktop app; the terminal shows text rows (crew draws its sprites in both).
sync-home(automatic) or ask "add X to all my devices"A skill that tells Claude this repository is where plugins, mods, skills and other customizations go so they sync to every device. Claude adds them under plugins/, validates, pushes and tells you what to turn on.
karpathy-guidelines/karpathy-guidelines:clean-code, or automatic on any coding taskGuidelines from Andrej Karpathy's notes on LLM coding mistakes: think before coding, simplicity first, surgical changes, goal-driven execution, with worked examples in EXAMPLES.md.
python-standards/python-standards:python-standards, or automatic on Python workPython standards: SOLID, DRY, KISS and YAGNI; FastAPI project layout; naming; OWASP-aligned secure coding; linting and testing setup.

Use them on every device

Add this marketplace to your claude.ai account once:

  1. On claude.ai, open Customize, then Plugins, then Add, then Add marketplace.
  2. Enter harshitmywork17/claude-mods. If the repository is private, your GitHub account must be connected to Claude.
  3. Turn on each plugin. Turn on sync-home too, so every session knows to put new synced customizations here.

Plugins on your account sync at session start, on every machine where you sign in to Claude Code with that account. Mods need Claude Code v2.1.287 or later (claude --version, then claude update). After you push a change here, press Check for updates on the marketplace in claude.ai, and turn on any new plugin. Then start a new session.

If a command such as /wb is missing on a machine:

  1. claude --version must be 2.1.287 or later.
  2. claude plugin list should show workbench@synced. If it doesn't, the sync hasn't picked it up: check for updates in claude.ai, or install on that machine as below.
  3. claude plugin test run in an empty folder should say no hooks module to load. Any other message means mods are turned off there.

Mods run in Claude Code only: the terminal, the desktop app's Code tab, IDE extensions and cloud sessions set up as below. Claude chat on the web, desktop and mobile shows no mod UI. Claritymaxx is a skill, so it also works in Claude chat and Cowork.

Use them in cloud sessions

Cloud sessions at claude.ai/code don't load account plugins or the plugins a repository lists in .claude/settings.json. They do load plugin folders named in CLAUDE_CODE_PLUGIN_DIRS. Set it once on the cloud environment:

  1. Open the environment menu in a session's title bar, then Edit.
  2. Under Environment variables, add: `` CLAUDE_CODE_PLUGIN_DIRS=/home/user/claude-mods/plugins ``
  3. When you start a cloud session, select harshitmywork17/claude-mods alongside the repository you're working on. It is cloned to /home/user/claude-mods, and every plugin in plugins/ loads, including new ones.

A cloud session can only clone the repositories selected for it, because this repository is private. In a session without it, the variable points at a missing folder, which Claude Code skips.

Install on one machine

claude plugin marketplace add harshitmywork17/claude-mods
claude plugin install workbench@harshit-mods   # repeat for each plugin

Or run every plugin from a local clone, picking up changes with git pull: add "env": { "CLAUDE_CODE_PLUGIN_DIRS": "<path to clone>/plugins" } to ~/.claude/settings.json. Don't combine this with account sync on the same machine: when two copies share a name, only the first one loads.

Develop

Each mod has tests: claude plugin test plugins/<mod>. Check a mod with claude plugin validate plugins/<mod>. Increase version in a mod's plugin.json when you change it, because installed copies are cached by version.

Source 6 files
hooks/register.tsx 1031 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, RenderNode, Register } from 'claude-code'
3
4import type {
5  Activity,
6  CallView,
7  ChangeMap,
8  Decision,
9  FileChange,
10  Graph,
11  Insight,
12  Outline,
13  Site,
14  SymbolKind,
15  Tab,
16} from '../types'
17import { bodyOf, calleesIn, enclosingSymbol, isDefinitionLine, symbolFromSelection } from './calls'
18import { countCallers, escapeRegex, lineDiff, markChanged, outlineOf, parseDiff, symbolChanges } from './changes'
19import type { DiffFile, Range } from './changes'
20import {
21  DECISION_MODEL,
22  DECISION_SYSTEM,
23  decisionPrompt,
24  explainChangesPrompt,
25  explainFunctionPrompt,
26  explainModulePrompt,
27  parseDecisions,
28} from './prompts'
29import {
30  IMPORT_PATTERN,
31  IMPORT_RE,
32  SKIPPED_DIRS,
33  SOURCE_GLOBS,
34  SYMBOL_PATTERN,
35  SYMBOL_RE,
36  buildScan,
37  countTexts,
38  dependentsOf,
39  grepLine,
40  grepMatches,
41  grepTexts,
42  groupOf,
43  isSource,
44  shortCount,
45} from './scan'
46import type { Scan } from './scan'
47
48const graphAtom = atom({ plugin: 'codebase-atlas', key: 'graph' } as const, null)
49const scanStatusAtom = atom({ plugin: 'codebase-atlas', key: 'scanStatus' } as const, 'idle')
50const scanErrorAtom = atom({ plugin: 'codebase-atlas', key: 'scanError' } as const, '')
51const activityAtom = atom({ plugin: 'codebase-atlas', key: 'activity' } as const, [])
52const tabAtom = atom({ plugin: 'codebase-atlas', key: 'tab' } as const, 'map')
53const focusAtom = atom({ plugin: 'codebase-atlas', key: 'focus' } as const, null)
54const changesAtom = atom({ plugin: 'codebase-atlas', key: 'changes' } as const, { status: 'idle', files: [], layers: [], dependents: [] })
55const outlineAtom = atom({ plugin: 'codebase-atlas', key: 'outline' } as const, null)
56const callAtom = atom({ plugin: 'codebase-atlas', key: 'call' } as const, null)
57const trailAtom = atom({ plugin: 'codebase-atlas', key: 'trail' } as const, [])
58const decisionsAtom = atom({ plugin: 'codebase-atlas', key: 'decisions' } as const, [])
59const isExtractingAtom = atom({ plugin: 'codebase-atlas', key: 'isExtracting' } as const, false)
60const insightAtom = atom({ plugin: 'codebase-atlas', key: 'insight' } as const, null)
61
62const MAX_CHANGED_FILES = 30
63const MAX_CALLER_NAMES = 25
64const MAX_CALLERS = 40
65const MAX_DECISIONS = 60
66const GIT_TIMEOUT_MS = 20_000
67const MAX_FOLDER_FILES = 1500
68const MAX_FOLDER_DIRS = 2000
69const MAX_FILE_BYTES = 512 * 1024
70
71type Source = { root: string; isGit: boolean }
72
73let cwd = ''
74let cache: Scan | null = null
75let isScanning = false
76let source: Source | null = null
77
78/** Folder mode: each source file's text and modification time as last read. */
79const current = new Map<string, { text: string; mtimeMs: number }>()
80/** Folder mode: each file as the first scan found it; null for a file that appeared after that scan. */
81const baseline = new Map<string, string | null>()
82let hasBaseline = false
83
84function setCwd(value: string): void {
85  cwd = value
86}
87
88type GitResult = { isOk: boolean; out: string; err: string }
89
90/** Runs git in the repo; `git grep` finding nothing (exit 1 with no error text) counts as success. */
91async function git($: EngineInterface, root: string, args: readonly string[]): Promise<GitResult> {
92  const run = await $.process.run(['git', '-C', root, ...args], { timeoutMs: GIT_TIMEOUT_MS })
93  const isEmptyGrep = args[0] === 'grep' && run.exitCode === 1 && run.stderr.trim() === ''
94  return { isOk: run.exitCode === 0 || isEmptyGrep, out: run.stdout, err: run.stderr.trim() }
95}
96
97/** The folder Atlas reads: the git repository around the session, or else the session's own folder. */
98async function findSource($: EngineInterface): Promise<Source | null> {
99  if (source !== null) return source
100  try {
101    const run = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: cwd || undefined, timeoutMs: 5000 })
102    if (run.exitCode === 0 && run.stdout.trim() !== '') {
103      source = { root: run.stdout.trim(), isGit: true }
104      return source
105    }
106  } catch {
107    // No git on this machine: read the folder directly.
108  }
109  if (cwd === '') return null
110  source = { root: cwd.replace(/\/+$/, ''), isGit: false }
111  return source
112}
113
114/** A path as the repo names it: relative to the root when inside it. */
115function relative(path: string, root: string): string {
116  return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
117}
118
119/** Lines in a text, not counting the empty piece after a final newline. */
120function lineTotal(text: string): number {
121  if (text === '') return 0
122  const lines = text.split('\n')
123  return lines[lines.length - 1] === '' ? lines.length - 1 : lines.length
124}
125
126async function readText($: EngineInterface, path: string): Promise<string> {
127  try {
128    return await $.fs.read(path)
129  } catch {
130    return ''
131  }
132}
133
134/** Folder mode: the source files under the root, leaving out dependencies, environments, caches and build output. */
135async function walkFolder($: EngineInterface, root: string): Promise<{ path: string; mtimeMs: number }[]> {
136  const found: { path: string; mtimeMs: number }[] = []
137  const queue = ['']
138  let dirs = 0
139  while (queue.length > 0 && found.length < MAX_FOLDER_FILES && dirs < MAX_FOLDER_DIRS) {
140    const dir = queue.shift() ?? ''
141    dirs += 1
142    let entries: Awaited<ReturnType<EngineInterface['fs']['list']>>
143    try {
144      entries = await $.fs.list(dir === '' ? root : `${root}/${dir}`)
145    } catch {
146      continue
147    }
148    if (dir !== '' && entries.some(entry => entry.name === 'pyvenv.cfg')) continue
149    for (const entry of entries) {
150      const path = dir === '' ? entry.name : `${dir}/${entry.name}`
151      if (entry.kind === 'dir' && !entry.isLink && !entry.name.startsWith('.') && !SKIPPED_DIRS.has(entry.name)) queue.push(path)
152      else if (entry.kind === 'file' && isSource(entry.name) && entry.size <= MAX_FILE_BYTES) found.push({ path, mtimeMs: entry.mtimeMs })
153    }
154  }
155  return found.sort((a, b) => a.path.localeCompare(b.path)).slice(0, MAX_FOLDER_FILES)
156}
157
158/** Folder mode: re-reads only files whose time changed; the first scan's texts stay as the baseline. */
159async function syncFolder($: EngineInterface, root: string): Promise<void> {
160  const listed = await walkFolder($, root)
161  const seen = new Set<string>()
162  for (const file of listed) {
163    seen.add(file.path)
164    if (current.get(file.path)?.mtimeMs === file.mtimeMs) continue
165    const text = await readText($, `${root}/${file.path}`)
166    current.set(file.path, { text, mtimeMs: file.mtimeMs })
167    if (!baseline.has(file.path)) baseline.set(file.path, hasBaseline ? null : text)
168  }
169  for (const path of [...current.keys()]) if (!seen.has(path)) current.delete(path)
170  hasBaseline = true
171}
172
173function currentTexts(): Map<string, string> {
174  return new Map([...current].map(([path, file]) => [path, file.text]))
175}
176
177/** Folder mode: the same scan git mode builds, from the texts in memory. */
178function folderScan(root: string): Scan {
179  const texts = currentTexts()
180  return buildScan(root, [...texts.keys()].join('\n'), countTexts(texts), grepTexts(texts, IMPORT_RE), grepTexts(texts, SYMBOL_RE), false)
181}
182
183/** Lines matching a pattern across the sources, as `git grep -n` prints them (`-o` with `isOnlyMatch`). */
184async function searchSources($: EngineInterface, src: Source, gitPattern: string, jsPattern: RegExp, isOnlyMatch: boolean): Promise<string> {
185  if (src.isGit) {
186    const flags = isOnlyMatch ? ['-n', '-o', '-I', '-E'] : ['-n', '-I', '-E']
187    return (await git($, src.root, ['grep', ...flags, gitPattern, '--', ...SOURCE_GLOBS])).out
188  }
189  return isOnlyMatch ? grepMatches(currentTexts(), jsPattern) : grepTexts(currentTexts(), jsPattern)
190}
191
192async function sourceText($: EngineInterface, src: Source, path: string): Promise<string> {
193  return src.isGit ? readText($, `${src.root}/${path}`) : (current.get(path)?.text ?? readText($, `${src.root}/${path}`))
194}
195
196/** Scans the code for files, imports and definitions, and draws the map from them. */
197async function scan($: EngineInterface): Promise<void> {
198  if (isScanning) return
199  isScanning = true
200  await update($, scanStatusAtom, () => 'scanning')
201  try {
202    cache = null
203    const src = await findSource($)
204    if (src === null) {
205      await update($, scanStatusAtom, () => 'no-folder')
206      return
207    }
208    if (src.isGit) {
209      const files = await git($, src.root, ['ls-files', '--', ...SOURCE_GLOBS])
210      if (!files.isOk) throw new Error(files.err || 'git ls-files failed')
211      const counts = await git($, src.root, ['grep', '-c', '-I', '', '--', ...SOURCE_GLOBS])
212      const imports = await git($, src.root, ['grep', '-n', '-I', '-E', IMPORT_PATTERN, '--', ...SOURCE_GLOBS])
213      const symbols = await git($, src.root, ['grep', '-n', '-I', '-E', SYMBOL_PATTERN, '--', ...SOURCE_GLOBS])
214      cache = buildScan(src.root, files.out, counts.out, imports.out, symbols.out, true)
215    } else {
216      await syncFolder($, src.root)
217      cache = folderScan(src.root)
218    }
219    const graph = cache.graph
220    await update($, graphAtom, () => graph)
221    await update($, scanStatusAtom, () => 'ready')
222  } catch (error) {
223    await update($, scanErrorAtom, () => String(error))
224    await update($, scanStatusAtom, () => 'error')
225  } finally {
226    isScanning = false
227  }
228}
229
230async function ensureScan($: EngineInterface): Promise<Scan | null> {
231  if (cache === null) await scan($)
232  return cache
233}
234
235async function recordActivity($: EngineInterface, path: string, kind: 'read' | 'edit'): Promise<void> {
236  await update($, activityAtom, list => {
237    const found = list.find(one => one.path === path) ?? { path, reads: 0, edits: 0 }
238    const next = kind === 'read' ? { ...found, reads: found.reads + 1 } : { ...found, edits: found.edits + 1 }
239    return [next, ...list.filter(one => one.path !== path)].slice(0, 300)
240  })
241}
242
243function toFileChange(diff: DiffFile, after: string, before: string): FileChange {
244  const symbols = symbolChanges(outlineOf(after, diff.path), outlineOf(before, diff.path), diff.ranges)
245  return { path: diff.path, group: groupOf(diff.path), status: diff.status, added: diff.added, removed: diff.removed, symbols }
246}
247
248/** Git mode: uncommitted and untracked changes against HEAD. */
249async function gitChanges($: EngineInterface, root: string): Promise<FileChange[]> {
250  const diff = await git($, root, ['diff', '-U0', 'HEAD', '--', ...SOURCE_GLOBS])
251  const untracked = await git($, root, ['ls-files', '--others', '--exclude-standard', '--', ...SOURCE_GLOBS])
252  const diffs = parseDiff(diff.isOk ? diff.out : '')
253  for (const path of untracked.out.split('\n').filter(Boolean)) {
254    const lines = lineTotal(await readText($, `${root}/${path}`))
255    diffs.push({ path, status: 'added', added: lines, removed: 0, ranges: [[1, lines]] })
256  }
257  const files: FileChange[] = []
258  for (const one of diffs.slice(0, MAX_CHANGED_FILES)) {
259    const after = one.status === 'deleted' ? '' : await readText($, `${root}/${one.path}`)
260    const before = one.status === 'added' ? '' : (await git($, root, ['show', `HEAD:${one.path}`])).out
261    files.push(toFileChange(one, after, before))
262  }
263  return files
264}
265
266/** Folder mode: every file whose text differs from the baseline snapshot. */
267function folderDiffs(): (DiffFile & { text: string; before: string; after: string })[] {
268  const out: (DiffFile & { text: string; before: string; after: string })[] = []
269  for (const path of [...new Set([...baseline.keys(), ...current.keys()])].sort()) {
270    const before = baseline.get(path) ?? null
271    const after = current.get(path)?.text ?? null
272    if (before === after || (before === null && after === null)) continue
273    const diff = lineDiff(path, before, after)
274    if (diff.ranges.length === 0) continue
275    out.push({ ...diff, before: before ?? '', after: after ?? '' })
276    if (out.length >= MAX_CHANGED_FILES) break
277  }
278  return out
279}
280
281/** Maps every change to the functions and classes it touched, with call sites, layers and impact. */
282async function refreshChanges($: EngineInterface): Promise<void> {
283  await update($, changesAtom, (current): ChangeMap => ({ ...current, status: 'loading' }))
284  try {
285    const src = await findSource($)
286    if (src === null) throw new Error('Codebase Atlas could not tell which folder to read.')
287    let scanned = await ensureScan($)
288    let files: FileChange[]
289    if (src.isGit) {
290      files = await gitChanges($, src.root)
291    } else {
292      await syncFolder($, src.root)
293      scanned = folderScan(src.root)
294      cache = scanned
295      const graph = scanned.graph
296      await update($, graphAtom, () => graph)
297      files = folderDiffs().map(diff => toFileChange(diff, diff.after, diff.before))
298    }
299
300    const names = [...new Set(files.flatMap(file => file.symbols.filter(s => s.change !== 'removed').map(s => s.name)))]
301      .filter(name => /^[A-Za-z_$][\w$]*$/.test(name))
302      .slice(0, MAX_CALLER_NAMES)
303    if (names.length > 0) {
304      const alternatives = names.map(escapeRegex).join('|')
305      const hits = await searchSources($, src, `[A-Za-z0-9_$]*(${alternatives})[[:space:]]*\\(`, new RegExp(`[A-Za-z0-9_$]*(${alternatives})\\s*\\(`), true)
306      const definitions = new Set<string>()
307      for (const [name, sites] of scanned?.symbols ?? []) for (const site of sites) definitions.add(`${site.file}:${site.line}:${name}`)
308      for (const file of files) for (const s of file.symbols) definitions.add(`${file.path}:${s.line}:${s.name}`)
309      const counts = countCallers(hits, definitions, new Set(names))
310      for (const file of files) for (const s of file.symbols) s.callers = counts.get(s.name) ?? 0
311    }
312
313    const levelOf = new Map((await read($, graphAtom))?.groups.map(group => [group.id, group.level]) ?? [])
314    const layers = [...new Set(files.map(file => file.group))].sort((a, b) => (levelOf.get(b) ?? 0) - (levelOf.get(a) ?? 0))
315    const dependents = scanned === null ? [] : dependentsOf(new Set(files.map(file => file.path)), scanned.imports).slice(0, 30)
316    const map: ChangeMap = { status: 'ready', files, layers, dependents }
317    await update($, changesAtom, () => map)
318  } catch (error) {
319    await update($, changesAtom, (current): ChangeMap => ({ ...current, status: 'error', error: String(error) }))
320  }
321}
322
323/** Builds the component outline of one file: its classes, functions and methods, what each calls, and what changed. */
324async function loadOutline($: EngineInterface, path: string): Promise<void> {
325  await update($, outlineAtom, (): Outline => ({ file: path, status: 'loading', lines: 0, entries: [] }))
326  await update($, tabAtom, () => 'components')
327  try {
328    const src = await findSource($)
329    if (src === null) throw new Error('Codebase Atlas could not tell which folder to read.')
330    const scanned = await ensureScan($)
331    const text = await sourceText($, src, path)
332    if (text === '') throw new Error(`Could not read ${path}.`)
333    let ranges: Range[]
334    if (src.isGit) {
335      const diff = parseDiff((await git($, src.root, ['diff', '-U0', 'HEAD', '--', path])).out)[0]
336      const isUntracked = (await git($, src.root, ['ls-files', '--', path])).out.trim() === ''
337      ranges = isUntracked ? [[1, lineTotal(text)]] : (diff?.ranges ?? [])
338    } else {
339      const before = baseline.get(path)
340      ranges = before === undefined ? [] : lineDiff(path, before, text).ranges
341    }
342    const entries = markChanged(outlineOf(text, path), ranges).map(entry => ({
343      ...entry,
344      calls: entry.kind === 'class' ? [] : calleesIn(bodyOf(text, entry.line, path), entry.name, scanned?.symbols ?? new Map())
345        .filter(site => site.isKnown)
346        .map(site => site.symbol)
347        .slice(0, 8),
348    }))
349    await update($, outlineAtom, (): Outline => ({ file: path, status: 'ready', lines: lineTotal(text), entries }))
350  } catch (error) {
351    await update($, outlineAtom, (): Outline => ({ file: path, status: 'error', lines: 0, entries: [], error: String(error) }))
352  }
353}
354
355/** Finds a function's definition, who calls it and what it calls. */
356async function loadCalls($: EngineInterface, symbol: string, isFromTrail = false): Promise<void> {
357  const name = symbol.trim()
358  if (!/^[A-Za-z_$][\w$]*$/.test(name)) {
359    await update($, callAtom, (): CallView => ({ symbol: name, status: 'error', callers: [], callees: [], error: 'Type one function or class name.' }))
360    await update($, tabAtom, () => 'calls')
361    return
362  }
363  await update($, callAtom, (): CallView => ({ symbol: name, status: 'loading', callers: [], callees: [] }))
364  await update($, tabAtom, () => 'calls')
365  if (!isFromTrail) await update($, trailAtom, trail => [...trail.filter(one => one !== name), name].slice(-20))
366  try {
367    const src = await findSource($)
368    if (src === null) throw new Error('Codebase Atlas could not tell which folder to read.')
369    const scanned = await ensureScan($)
370    const index = scanned?.symbols ?? new Map<string, Site[]>()
371    const definition = index.get(name)?.[0]
372
373    const texts = new Map<string, string>()
374    const textOf = async (file: string): Promise<string> => {
375      if (!texts.has(file)) texts.set(file, await sourceText($, src, file))
376      return texts.get(file) ?? ''
377    }
378
379    const escaped = escapeRegex(name)
380    const hits = await searchSources($, src, `(^|[^A-Za-z0-9_$])${escaped}[[:space:]]*\\(`, new RegExp(`(^|[^A-Za-z0-9_$])${escaped}\\s*\\(`), false)
381    const callers: Site[] = []
382    const seen = new Set<string>()
383    for (const row of hits.split('\n')) {
384      const hit = grepLine(row)
385      if (hit === null || isDefinitionLine(hit.text, name)) continue
386      const caller = enclosingSymbol(await textOf(hit.file), hit.line, hit.file)
387      const key = `${caller}@${hit.file}`
388      if (seen.has(key)) continue
389      seen.add(key)
390      callers.push({ symbol: caller, file: hit.file, line: hit.line, isKnown: index.has(caller) })
391      if (callers.length >= MAX_CALLERS) break
392    }
393
394    const callees = definition === undefined ? [] : calleesIn(bodyOf(await textOf(definition.file), definition.line, definition.file), name, index)
395    const view: CallView = {
396      symbol: name,
397      status: definition === undefined && callers.length === 0 ? 'missing' : 'ready',
398      definition,
399      callers,
400      callees,
401    }
402    await update($, callAtom, () => view)
403  } catch (error) {
404    await update($, callAtom, (): CallView => ({ symbol: name, status: 'error', callers: [], callees: [], error: String(error) }))
405  }
406}
407
408async function showInsight($: EngineInterface, title: string, prompt: () => Promise<string>): Promise<void> {
409  const loading: Insight = { title, status: 'loading', text: '' }
410  await update($, insightAtom, () => loading)
411  await update($, tabAtom, (): Tab => 'explain')
412  try {
413    const reply = await $.model.fork({ prompt: await prompt() })
414    const text = reply.isAnswered ? reply.text : `No answer (${reply.reason}). Ask Claude something first, then try again.`
415    await update($, insightAtom, () => ({ title, status: reply.isAnswered ? 'ready' : 'error', text }))
416  } catch (error) {
417    await update($, insightAtom, () => ({ title, status: 'error', text: String(error) }))
418  }
419}
420
421async function explainModule($: EngineInterface, id: string): Promise<void> {
422  await showInsight($, `Folder ${id}`, async () => {
423    const scanned = await ensureScan($)
424    const files = [...new Set([...(scanned?.symbols.values() ?? [])].flat().map(site => site.file))].filter(file => groupOf(file) === id)
425    const symbols = [...(scanned?.symbols ?? new Map<string, Site[]>())]
426      .filter(([, sites]) => sites.some(site => groupOf(site.file) === id))
427      .map(([name]) => name)
428    const edges = (await read($, graphAtom))?.edges ?? []
429    return explainModulePrompt(
430      id,
431      files,
432      symbols,
433      edges.filter(edge => edge.from === id).map(edge => edge.to),
434      edges.filter(edge => edge.to === id).map(edge => edge.from),
435    )
436  })
437}
438
439async function explainFunction($: EngineInterface, view: CallView): Promise<void> {
440  await showInsight($, `Function ${view.symbol}`, async () => {
441    const src = await findSource($)
442    const definition = view.definition
443    const body = definition === undefined || src === null ? '' : bodyOf(await sourceText($, src, definition.file), definition.line, definition.file).join('\n')
444    return explainFunctionPrompt(
445      view.symbol,
446      definition?.file ?? 'an unknown file',
447      body,
448      view.callers.map(site => site.symbol),
449      view.callees.filter(site => site.isKnown).map(site => site.symbol),
450    )
451  })
452}
453
454async function explainChanges($: EngineInterface): Promise<void> {
455  await showInsight($, 'This session’s changes', async () => {
456    const changes = await read($, changesAtom)
457    const summary = changes.files
458      .map(file => `${file.path} (+${file.added} −${file.removed}): ${file.symbols.map(s => `${s.change} ${s.name}`).join(', ') || 'no function-level change'}`)
459      .join('\n')
460    const src = await findSource($)
461    const diff = src === null ? '' : src.isGit ? (await git($, src.root, ['diff', 'HEAD', '--', ...SOURCE_GLOBS])).out : folderDiffs().map(one => one.text).join('\n')
462    return explainChangesPrompt(summary || 'No changes found.', diff)
463  })
464}
465
466/** Pulls the decisions out of one finished turn with a small model, in the background. */
467async function extractDecisions($: EngineInterface, request: string, answer: string, edited: readonly string[]): Promise<void> {
468  await update($, isExtractingAtom, () => true)
469  try {
470    const reply = await $.model.complete({
471      model: DECISION_MODEL,
472      system: DECISION_SYSTEM,
473      prompt: decisionPrompt(request, answer, edited),
474      maxTokens: 700,
475    })
476    if (reply.isAnswered) {
477      const found = parseDecisions(reply.text, request.slice(0, 120), `d${await $.clock.now()}`)
478      if (found.length > 0) await update($, decisionsAtom, list => [...list, ...found].slice(-MAX_DECISIONS))
479    }
480  } finally {
481    await update($, isExtractingAtom, () => false)
482  }
483}
484
485const PANE = 'codebase-atlas'
486const TITLE = 'Codebase Atlas'
487const READ_TOOLS = new Set(['Read'])
488const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
489const LONG_ANSWER = 400
490
491type T = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button' | 'Markdown'>
492
493const TABS: { tab: Tab; label: string; key: string }[] = [
494  { tab: 'map', label: 'Map', key: '1' },
495  { tab: 'changes', label: 'Changes', key: '2' },
496  { tab: 'components', label: 'Components', key: '3' },
497  { tab: 'calls', label: 'Calls', key: '4' },
498  { tab: 'decisions', label: 'Decisions', key: '5' },
499  { tab: 'explain', label: 'Explain', key: '6' },
500]
501
502const KIND_ICON: Record<SymbolKind, string> = { class: '◆', function: 'ƒ', method: '·' }
503const CHANGE_STYLE = {
504  added: { mark: '+', color: 'green' },
505  modified: { mark: '~', color: 'yellow' },
506  removed: { mark: '−', color: 'red' },
507} as const
508
509function filePath(path: string, line?: number): string {
510  return line === undefined || line === 0 ? path : `${path}:${line}`
511}
512
513type GroupActivity = { reads: number; edits: number }
514
515function activityByGroup(activity: readonly Activity[], root: string): Map<string, GroupActivity> {
516  const out = new Map<string, GroupActivity>()
517  for (const one of activity) {
518    const id = groupOf(relative(one.path, root))
519    const sum = out.get(id) ?? { reads: 0, edits: 0 }
520    out.set(id, { reads: sum.reads + one.reads, edits: sum.edits + one.edits })
521  }
522  return out
523}
524
525function mapView($: EngineInterface, t: T, graph: Graph, activity: readonly Activity[], focus: string | null): RenderNode {
526  const { Box, Text, Button } = t
527  const touched = activityByGroup(activity, graph.root)
528  const levels = [...new Set(graph.groups.map(group => group.level))].sort((a, b) => b - a)
529  const focused = graph.groups.find(group => group.id === focus)
530  const files = activity.map(one => relative(one.path, graph.root)).filter(path => focused !== undefined && groupOf(path) === focused.id)
531
532  return (
533    <Box flexDirection="column">
534      <Box flexDirection="row" gap={2}>
535        <Text dimColor>
536          {graph.files} files · {graph.groups.length} folders{graph.isTruncated ? ' (largest shown)' : ''}
537        </Text>
538        {!graph.isGit && <Text color="magenta">folder mode (no git)</Text>}
539        <Text color="yellow">■ edited</Text>
540        <Text color="cyan">■ read</Text>
541        <Text dimColor>■ untouched</Text>
542      </Box>
543      {levels.map((level, index) => (
544        <Box key={`level-${level}`} flexDirection="column">
545          {index > 0 && <Text dimColor>{'   ▼ imports'}</Text>}
546          <Box flexDirection="row" flexWrap="wrap" gap={1}>
547            <Box width={4} flexShrink={0}>
548              <Text dimColor>L{level}</Text>
549            </Box>
550            {graph.groups
551              .filter(group => group.level === level)
552              .map(group => {
553                const hit = touched.get(group.id)
554                const color = hit === undefined ? 'gray' : hit.edits > 0 ? 'yellow' : 'cyan'
555                return (
556                  <Box
557                    key={`box-${group.id}`}
558                    flexDirection="column"
559                    borderStyle={group.id === focus ? 'double' : 'round'}
560                    borderColor={color}
561                    paddingX={1}
562                  >
563                    <Button key={`g:${group.id}`} label={group.id} plain onPress={() => update($, focusAtom, now => (now === group.id ? null : group.id))} />
564                    <Text dimColor>
565                      {group.files} files · {shortCount(group.lines)} lines
566                    </Text>
567                    {hit !== undefined && (
568                      <Text color={color}>
569                        {hit.edits > 0 ? `✎ ${hit.edits} ` : ''}
570                        {hit.reads > 0 ? `◉ ${hit.reads}` : ''}
571                      </Text>
572                    )}
573                  </Box>
574                )
575              })}
576          </Box>
577        </Box>
578      ))}
579      {focused !== undefined && (
580        <Box flexDirection="column" borderStyle="round" borderColor="magenta" paddingX={1} marginTop={1}>
581          <Text bold color="magenta">
582            {focused.id} · {focused.symbols} functions and classes · layer L{focused.level}
583          </Text>
584          <Text>
585            imports → {graph.edges.filter(edge => edge.from === focused.id).map(edge => `${edge.to} (${edge.count})`).join(', ') || 'nothing in the repo'}
586          </Text>
587          <Text>
588            used by ← {graph.edges.filter(edge => edge.to === focused.id).map(edge => `${edge.from} (${edge.count})`).join(', ') || 'nothing in the repo'}
589          </Text>
590          {files.length > 0 && <Text dimColor>Touched this session:</Text>}
591          {files.slice(0, 8).map((path, index) => (
592            <Button key={`mf:${index}`} label={`▸ ${path}`} plain onPress={() => void loadOutline($, path)} />
593          ))}
594          <Box flexDirection="row" gap={1}>
595            <Button key="explain-folder" label="explain this folder" hotkey="e" onPress={() => void explainModule($, focused.id)} />
596          </Box>
597        </Box>
598      )}
599      {focused === undefined && <Text dimColor>Select a folder to see what it imports, what uses it, and to explain it.</Text>}
600    </Box>
601  )
602}
603
604function changesView($: EngineInterface, t: T, changes: ChangeMap, isGit: boolean): RenderNode {
605  const { Box, Text, Button } = t
606  const added = changes.files.reduce((sum, file) => sum + file.added, 0)
607  const removed = changes.files.reduce((sum, file) => sum + file.removed, 0)
608
609  return (
610    <Box flexDirection="column">
611      {changes.status === 'loading' && <Text color="cyan">Mapping changes to functions…</Text>}
612      {changes.status === 'error' && <Text color="red">{changes.error}</Text>}
613      {changes.status === 'idle' && <Text dimColor>Press refresh to map the changes to the functions they touch.</Text>}
614      {!isGit && <Text dimColor>No git here: changes are compared with the files as Codebase Atlas first read them this session.</Text>}
615      {changes.status === 'ready' && changes.files.length === 0 && (
616        <Text dimColor>{isGit ? 'No uncommitted changes in source files.' : 'No changes since Codebase Atlas first read this folder.'}</Text>
617      )}
618      {changes.files.length > 0 && (
619        <Box flexDirection="column">
620          <Box flexDirection="row" gap={1}>
621            <Text bold>
622              {changes.files.length} file{changes.files.length === 1 ? '' : 's'}
623            </Text>
624            <Text color="green">+{added}</Text>
625            <Text color="red">−{removed}</Text>
626          </Box>
627          <Text color="magenta" wrap="truncate-end">
628            Layers crossed: {changes.layers.join('  →  ')}
629          </Text>
630        </Box>
631      )}
632      {changes.files.map((file, fileIndex) => (
633        <Box key={`cf-${fileIndex}`} flexDirection="column" marginTop={1}>
634          <Box flexDirection="row" gap={1}>
635            <Button key={`cfile:${fileIndex}`} label={file.path} plain onPress={() => void loadOutline($, file.path)} />
636            <Text color={file.status === 'added' ? 'green' : file.status === 'deleted' ? 'red' : 'yellow'}>{file.status}</Text>
637            <Text color="green">+{file.added}</Text>
638            <Text color="red">−{file.removed}</Text>
639          </Box>
640          {file.symbols.length === 0 && <Text dimColor>{'   no function-level change (imports, constants or top-level code)'}</Text>}
641          {file.symbols.map((change, index) => (
642            <Box key={`cs-${fileIndex}-${index}`} flexDirection="row" gap={1}>
643              <Text color={CHANGE_STYLE[change.change].color}>
644                {'   '}
645                {CHANGE_STYLE[change.change].mark} {KIND_ICON[change.kind]}
646              </Text>
647              {change.change === 'removed' ? (
648                <Text strikethrough dimColor>
649                  {change.name}
650                </Text>
651              ) : (
652                <Button key={`csym:${fileIndex}:${index}`} label={change.name} plain onPress={() => void loadCalls($, change.name)} />
653              )}
654              <Text dimColor>
655                {change.change} · L{change.line}
656                {change.change === 'removed' ? '' : ` · ${change.callers} call site${change.callers === 1 ? '' : 's'}`}
657              </Text>
658            </Box>
659          ))}
660        </Box>
661      ))}
662      {changes.dependents.length > 0 && (
663        <Box flexDirection="column" marginTop={1}>
664          <Text bold color="yellow">
665            Affected elsewhere: {changes.dependents.length} file{changes.dependents.length === 1 ? '' : 's'} import what changed
666          </Text>
667          {changes.dependents.slice(0, 10).map((path, index) => (
668            <Button key={`dep:${index}`} label={`▸ ${path}`} plain onPress={() => void loadOutline($, path)} />
669          ))}
670        </Box>
671      )}
672      <Box flexDirection="row" gap={1} marginTop={1}>
673        <Button key="refresh-changes" label="refresh" hotkey="r" onPress={() => void refreshChanges($)} />
674        {changes.files.length > 0 && <Button key="explain-changes" label="explain these changes" hotkey="e" onPress={() => void explainChanges($)} />}
675      </Box>
676    </Box>
677  )
678}
679
680function componentsView($: EngineInterface, t: T, outline: Outline | null, recent: readonly string[], columns: number): RenderNode {
681  const { Box, Text, Button } = t
682  const longest = Math.max(1, ...(outline?.entries ?? []).map(entry => entry.endLine - entry.line + 1))
683  const barWidth = Math.max(6, Math.min(24, Math.floor(columns * 0.2)))
684
685  return (
686    <Box flexDirection="column">
687      {recent.length > 0 && (
688        <Box flexDirection="row" flexWrap="wrap" gap={1}>
689          <Text dimColor>Files:</Text>
690          {recent.slice(0, 8).map((path, index) => (
691            <Button key={`rf:${index}`} label={path.split('/').pop() ?? path} plain onPress={() => void loadOutline($, path)} />
692          ))}
693        </Box>
694      )}
695      {outline === null && <Text dimColor>Pick a file above, or a file in Map or Changes, to see its classes, functions and what each calls.</Text>}
696      {outline?.status === 'loading' && <Text color="cyan">Reading {outline.file}…</Text>}
697      {outline?.status === 'error' && <Text color="red">{outline.error}</Text>}
698      {outline?.status === 'ready' && (
699        <Box flexDirection="column" marginTop={1}>
700          <Text bold>
701            {outline.file} · {outline.lines} lines · {outline.entries.length} components ·{' '}
702            <Text color="yellow">{outline.entries.filter(entry => entry.isChanged).length} changed</Text>
703          </Text>
704          {outline.entries.length === 0 && <Text dimColor>No functions or classes found.</Text>}
705          {outline.entries.map((entry, index) => {
706            const size = entry.endLine - entry.line + 1
707            const fill = Math.max(1, Math.round((size / longest) * barWidth))
708            return (
709              <Box key={`oe-${index}`} flexDirection="column">
710                <Box flexDirection="row" gap={1}>
711                  <Text>{'  '.repeat(entry.depth)}</Text>
712                  <Text color={entry.isChanged ? 'yellow' : entry.kind === 'class' ? 'magenta' : 'cyan'}>{KIND_ICON[entry.kind]}</Text>
713                  {entry.kind === 'class' ? (
714                    <Text bold color={entry.isChanged ? 'yellow' : undefined}>
715                      {entry.name}
716                    </Text>
717                  ) : (
718                    <Button key={`oc:${index}`} label={entry.name} plain onPress={() => void loadCalls($, entry.name)} />
719                  )}
720                  <Text dimColor>
721                    L{entry.line}-{entry.endLine}
722                  </Text>
723                  <Text color={entry.isChanged ? 'yellow' : 'gray'}>{'▇'.repeat(fill)}</Text>
724                  <Text dimColor>{size}</Text>
725                  {entry.isChanged && <Text color="yellow">● changed</Text>}
726                </Box>
727                {entry.calls.length > 0 && (
728                  <Text dimColor wrap="truncate-end">
729                    {'  '.repeat(entry.depth + 2)}→ {entry.calls.join(', ')}
730                  </Text>
731                )}
732              </Box>
733            )
734          })}
735        </Box>
736      )}
737    </Box>
738  )
739}
740
741function siteRow($: EngineInterface, t: T, site: Site, key: string, arrow: string): RenderNode {
742  const { Box, Text, Button } = t
743  return (
744    <Box key={`row-${key}`} flexDirection="row" gap={1}>
745      <Text dimColor>{arrow}</Text>
746      {site.isKnown ? (
747        <Button key={key} label={site.symbol} plain onPress={() => void loadCalls($, site.symbol)} />
748      ) : (
749        <Text dimColor>{site.symbol}</Text>
750      )}
751      <Text dimColor wrap="truncate-end">
752        {site.file === '' ? '(outside the repo)' : filePath(site.file, site.line)}
753      </Text>
754    </Box>
755  )
756}
757
758function callsView($: EngineInterface, t: T, call: CallView | null, trail: readonly string[], ask: RenderNode | null): RenderNode {
759  const { Box, Text, Button } = t
760  const previous = call === null ? undefined : trail[trail.indexOf(call.symbol) - 1]
761
762  return (
763    <Box flexDirection="column">
764      {ask}
765      <Box flexDirection="row" gap={1}>
766        <Button
767          key="from-selection"
768          label="use my selection"
769          hotkey="s"
770          onPress={async () => {
771            const selected = await $.ui.selection()
772            const symbol = selected === undefined ? null : symbolFromSelection(selected.text)
773            if (symbol === null) $.ui.toast('Codebase Atlas: select a function name in the transcript first.')
774            else await loadCalls($, symbol)
775          }}
776        />
777        {previous !== undefined && <Button key="back" label={`back to ${previous}`} hotkey="b" onPress={() => void loadCalls($, previous, true)} />}
778      </Box>
779      {call === null && <Text dimColor>Type a function name, or select one in the transcript and press "use my selection".</Text>}
780      {call?.status === 'loading' && <Text color="cyan">Tracing {call.symbol}…</Text>}
781      {(call?.status === 'error' || call?.status === 'missing') && (
782        <Text color="red">{call.status === 'missing' ? `No definition or calls of ${call.symbol} found in source files.` : call.error}</Text>
783      )}
784      {call?.status === 'ready' && (
785        <Box flexDirection="column" marginTop={1}>
786          {trail.length > 1 && (
787            <Text dimColor wrap="truncate-start">
788              {trail.join(' › ')}
789            </Text>
790          )}
791          <Box flexDirection="row" gap={1}>
792            <Text bold color="cyan">
793              ƒ {call.symbol}
794            </Text>
795            <Text dimColor>{call.definition === undefined ? 'definition not found' : filePath(call.definition.file, call.definition.line)}</Text>
796          </Box>
797          <Text bold color="magenta">
798            ▲ Called by ({call.callers.length})
799          </Text>
800          {call.callers.length === 0 && <Text dimColor>{'   no callers found: an entry point, or only called dynamically'}</Text>}
801          {call.callers.map((site, index) => siteRow($, t, site, `caller:${index}`, '   ├'))}
802          <Text bold color="green">
803            ▼ Calls ({call.callees.filter(site => site.isKnown).length} in the repo)
804          </Text>
805          {call.callees.length === 0 && <Text dimColor>{'   calls nothing'}</Text>}
806          {call.callees.map((site, index) => siteRow($, t, site, `callee:${index}`, '   ├'))}
807          <Box marginTop={1}>
808            <Button key="explain-function" label="explain this function" hotkey="e" onPress={() => void explainFunction($, call)} />
809          </Box>
810        </Box>
811      )}
812    </Box>
813  )
814}
815
816function decisionsView($: EngineInterface, t: T, decisions: readonly Decision[], isExtracting: boolean, isAuto: boolean): RenderNode {
817  const { Box, Text } = t
818  return (
819    <Box flexDirection="column">
820      <Text dimColor>
821        {isAuto ? 'Decisions are pulled out after each turn that edits code or answers at length.' : 'Automatic extraction is off (extractDecisions in /config).'}
822        {isExtracting ? '  Reading the last turn…' : ''}
823      </Text>
824      {decisions.length === 0 && <Text dimColor>No decisions recorded yet.</Text>}
825      {[...decisions].reverse().map((decision, index) => (
826        <Box key={`dec-${decision.id}`} flexDirection="row" marginTop={1}>
827          <Box flexDirection="column" width={2} flexShrink={0}>
828            <Text color="magenta">●</Text>
829            {index < decisions.length - 1 && <Text color="magenta">│</Text>}
830          </Box>
831          <Box flexDirection="column" flexShrink={1}>
832            <Text bold>{decision.decision}</Text>
833            {decision.because !== '' && <Text>because {decision.because}</Text>}
834            {decision.alternatives.length > 0 && <Text dimColor>instead of: {decision.alternatives.join(' · ')}</Text>}
835            <Text dimColor wrap="truncate-end">
836              from “{decision.prompt}”
837            </Text>
838          </Box>
839        </Box>
840      ))}
841    </Box>
842  )
843}
844
845function explainView(t: T, insight: Insight | null): RenderNode {
846  const { Box, Text, Markdown } = t
847  if (insight === null) {
848    return <Text dimColor>Use an "explain" button in Map, Changes or Calls. Answers appear here and never enter your conversation.</Text>
849  }
850  return (
851    <Box flexDirection="column">
852      <Text bold color="cyan">
853        {insight.title}
854      </Text>
855      {insight.status === 'loading' && <Text color="cyan">Thinking it through…</Text>}
856      {insight.status === 'error' && <Text color="red">{insight.text}</Text>}
857      {insight.status === 'ready' && <Markdown key="insight" text={insight.text} />}
858    </Box>
859  )
860}
861
862async function openTab($: EngineInterface, args: string): Promise<void> {
863  const [word = '', ...rest] = args.trim().split(/\s+/)
864  const value = rest.join(' ')
865  if (word === 'rescan') {
866    await update($, tabAtom, () => 'map')
867    await scan($)
868  } else if (word === 'changes') {
869    await update($, tabAtom, () => 'changes')
870    await refreshChanges($)
871  } else if ((word === 'components' || word === 'file') && value !== '') {
872    await loadOutline($, value)
873  } else if (word === 'calls' && value !== '') {
874    await loadCalls($, value)
875  } else if (TABS.some(one => one.tab === word)) {
876    await update($, tabAtom, () => word as Tab)
877  }
878}
879
880export const register: Register = (on, options) => {
881  const isAutoDecisions = options.extractDecisions !== false
882  let request = ''
883  const editedThisTurn = new Set<string>()
884  let hasNewFile = false
885
886  on('session.start', async ($, e, next) => {
887    setCwd(e.cwd)
888    await $.command.register({
889      name: 'atlas',
890      description: 'Codebase Atlas: architecture map, changes by function, components, call graph, decisions',
891      argumentHint: '[map|changes|components <file>|calls <name>|decisions|explain|rescan]',
892      immediate: true,
893    })
894    $.clock.after(1500, () => void scan($).catch(() => undefined))
895
896    return next(e)
897  })
898
899  on('tool.call', async ($, e, next) => {
900    const ran = await next(e)
901    try {
902      const tool = String(e.tool)
903      if (ran.deny !== undefined || ran.isError === true || (!READ_TOOLS.has(tool) && !EDIT_TOOLS.has(tool))) return ran
904      const input = e as unknown as { file_path?: unknown; notebook_path?: unknown; type?: unknown }
905      const path = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : null
906      if (path === null) return ran
907      const isEdit = EDIT_TOOLS.has(tool)
908      await recordActivity($, path, isEdit ? 'edit' : 'read')
909      if (isEdit) {
910        editedThisTurn.add(path)
911        if (tool === 'Write' && (ran.result as { type?: unknown } | undefined)?.type === 'create') hasNewFile = true
912      }
913    } catch {
914      // Tracking is best effort: the tool's own result always goes back unchanged.
915    }
916    return ran
917  })
918
919  on('turn.start', async ($, e, next) => {
920    request = e.text
921    editedThisTurn.clear()
922    hasNewFile = false
923
924    return next(e)
925  })
926
927  on('turn.complete', async ($, e, next) => {
928    const ran = await next(e)
929    if (e.agentId !== undefined || e.isAborted) return ran
930
931    const edited = [...editedThisTurn]
932    const isNewFile = hasNewFile
933    if (edited.length > 0) {
934      $.clock.after(300, () => {
935        void (async () => {
936          if (isNewFile) await scan($)
937          await refreshChanges($)
938        })().catch(() => undefined)
939      })
940    }
941    if (isAutoDecisions && (edited.length > 0 || e.answer.length > LONG_ANSWER)) {
942      const turnRequest = request
943      const answer = e.answer
944      $.clock.after(0, () => void extractDecisions($, turnRequest, answer, edited).catch(() => undefined))
945    }
946    return ran
947  })
948
949  on('session.end', async ($, e, next) => {
950    if (e.reason === 'clear') {
951      await update($, activityAtom, () => [])
952      await update($, decisionsAtom, () => [])
953      await update($, changesAtom, (): ChangeMap => ({ status: 'idle', files: [], layers: [], dependents: [] }))
954    }
955    return next(e)
956  })
957
958  on('command.run', { command: 'atlas' }, async ($, e) => {
959    await $.ui.open({ id: PANE, title: TITLE })
960    await openTab($, e.args)
961
962    return {}
963  })
964
965  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
966    const table = $.ui.resolve(e)
967    const t: T = table
968    const { Box, Text, Button } = t
969    const tab = await read($, tabAtom)
970    const status = await read($, scanStatusAtom)
971    const graph = await read($, graphAtom)
972    const activity = await read($, activityAtom)
973    const root = graph?.root ?? ''
974
975    const ask =
976      'Input' in table ? (
977        <table.Input
978          key="symbol"
979          label="Function"
980          placeholder="name of a function or class"
981          submitLabel="trace"
982          onSubmit={value => void loadCalls($, value)}
983        />
984      ) : null
985
986    let body: RenderNode
987    if (tab === 'map') {
988      body =
989        status === 'scanning' ? <Text color="cyan">Scanning the repository…</Text>
990        : status === 'no-folder' ? <Text color="red">Codebase Atlas could not tell which folder to read. Start Claude Code inside your project.</Text>
991        : status === 'error' ? <Text color="red">Scan failed: {await read($, scanErrorAtom)}</Text>
992        : graph === null ? <Text dimColor>Starting the first scan…</Text>
993        : mapView($, t, graph, activity, await read($, focusAtom))
994    } else if (tab === 'changes') {
995      body = changesView($, t, await read($, changesAtom), graph?.isGit ?? true)
996    } else if (tab === 'components') {
997      const recent = [...new Set([...(await read($, changesAtom)).files.map(file => file.path), ...activity.map(one => relative(one.path, root))])]
998      body = componentsView($, t, await read($, outlineAtom), recent, e.props.bodyColumns)
999    } else if (tab === 'calls') {
1000      body = callsView($, t, await read($, callAtom), await read($, trailAtom), ask)
1001    } else if (tab === 'decisions') {
1002      body = decisionsView($, t, await read($, decisionsAtom), await read($, isExtractingAtom), isAutoDecisions)
1003    } else {
1004      body = explainView(t, await read($, insightAtom))
1005    }
1006
1007    return (
1008      <Box flexDirection="column">
1009        <Box flexDirection="row" flexWrap="wrap" gap={1}>
1010          {TABS.map(one => (
1011            <Button
1012              key={`tab:${one.tab}`}
1013              label={one.label}
1014              hotkey={one.key}
1015              variant={one.tab === tab ? 'primary' : 'secondary'}
1016              onPress={async () => {
1017                await update($, tabAtom, () => one.tab)
1018                if (one.tab === 'changes' && (await read($, changesAtom)).status === 'idle') await refreshChanges($)
1019              }}
1020            />
1021          ))}
1022          <Button key="rescan" label="rescan" hotkey="x" onPress={() => void scan($)} />
1023        </Box>
1024        <Box marginTop={1} flexDirection="column">
1025          {body}
1026        </Box>
1027      </Box>
1028    )
1029  })
1030}
1031
hooks/calls.ts 94 lines
1import type { Site } from '../types'
2import { isPython, symbolName } from './scan'
3import type { SymbolIndex } from './scan'
4
5const MAX_BODY_LINES = 400
6const MAX_CALLEES = 40
7const KEYWORDS = new Set([
8  'if', 'for', 'while', 'switch', 'return', 'catch', 'function', 'typeof', 'await', 'new', 'print', 'super',
9  'elif', 'with', 'assert', 'lambda', 'yield', 'not', 'and', 'or', 'in', 'is', 'def', 'class',
10])
11
12function indent(line: string): number {
13  return (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, '    ').length
14}
15
16/** The lines of the definition starting at 1-based `line`: by indentation for Python, by braces otherwise. */
17export function bodyOf(text: string, line: number, path: string): string[] {
18  const lines = text.split('\n')
19  const start = line - 1
20  const head = lines[start]
21  if (head === undefined) return []
22
23  const body = [head]
24  if (isPython(path)) {
25    const base = indent(head)
26    for (let index = start + 1; index < lines.length && body.length < MAX_BODY_LINES; index += 1) {
27      const current = lines[index] ?? ''
28      if (current.trim() !== '' && indent(current) <= base) break
29      body.push(current)
30    }
31    while (body.length > 1 && (body[body.length - 1] ?? '').trim() === '') body.pop()
32    return body
33  }
34
35  let depth = (head.match(/\{/g) ?? []).length - (head.match(/\}/g) ?? []).length
36  let hasOpened = depth > 0
37  for (let index = start + 1; index < lines.length && body.length < MAX_BODY_LINES; index += 1) {
38    if (hasOpened && depth <= 0) break
39    const current = lines[index] ?? ''
40    body.push(current)
41    depth += (current.match(/\{/g) ?? []).length - (current.match(/\}/g) ?? []).length
42    if (depth > 0) hasOpened = true
43  }
44  return body
45}
46
47/** The function or class around 1-based `line`, found by walking up to the nearest definition that encloses it. */
48export function enclosingSymbol(text: string, line: number, path: string): string {
49  const lines = text.split('\n')
50  const target = lines[line - 1] ?? ''
51  for (let index = line - 2; index >= 0; index -= 1) {
52    const current = lines[index] ?? ''
53    const name = symbolName(current)
54    if (name === null) continue
55    if (isPython(path) ? indent(current) < indent(target) : bodyOf(text, index + 1, path).length >= line - index) {
56      return name
57    }
58  }
59  return '(module level)'
60}
61
62/** Functions a body calls, each marked known when the repo defines it. */
63export function calleesIn(body: readonly string[], self: string, index: SymbolIndex): Site[] {
64  const seen = new Set<string>()
65  const out: Site[] = []
66  for (const line of body.slice(1)) {
67    const code = line.replace(/(#|\/\/).*$/, '')
68    for (const match of code.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) {
69      const name = match[1] ?? ''
70      if (name === self || KEYWORDS.has(name) || seen.has(name)) continue
71      seen.add(name)
72      const definition = index.get(name)?.[0]
73      out.push(definition ?? { symbol: name, file: '', line: 0, isKnown: false })
74      if (out.length >= MAX_CALLEES) return sortKnownFirst(out)
75    }
76  }
77  return sortKnownFirst(out)
78}
79
80function sortKnownFirst(sites: Site[]): Site[] {
81  return sites.sort((a, b) => Number(b.isKnown) - Number(a.isKnown) || a.symbol.localeCompare(b.symbol))
82}
83
84/** True when a grep hit for `name(` is the definition itself rather than a call. */
85export function isDefinitionLine(text: string, name: string): boolean {
86  return symbolName(text) === name
87}
88
89/** The identifier under or around a selection: the last word that looks like a name. */
90export function symbolFromSelection(selection: string): string | null {
91  const words = selection.match(/[A-Za-z_$][\w$]*/g)
92  return words === null ? null : (words[words.length - 1] ?? null)
93}
94
hooks/changes.ts 223 lines
1import type { OutlineEntry, SymbolChange, SymbolKind } from '../types'
2import { bodyOf } from './calls'
3import { isPython, symbolName } from './scan'
4
5export type Range = readonly [number, number]
6
7export type DiffFile = {
8  path: string
9  status: 'modified' | 'added' | 'deleted'
10  added: number
11  removed: number
12  /** Changed lines in the current file, 1-based and inclusive. */
13  ranges: Range[]
14}
15
16/** Reads `git diff -U0` output into one entry per file. */
17export function parseDiff(diff: string): DiffFile[] {
18  const files: DiffFile[] = []
19  let current: DiffFile | null = null
20  for (const line of diff.split('\n')) {
21    const header = /^diff --git a\/(.+?) b\/(.+)$/.exec(line)
22    if (header !== null) {
23      current = { path: header[2] ?? '', status: 'modified', added: 0, removed: 0, ranges: [] }
24      files.push(current)
25      continue
26    }
27    if (current === null) continue
28    if (line.startsWith('new file mode')) current.status = 'added'
29    else if (line.startsWith('deleted file mode')) current.status = 'deleted'
30    else if (line.startsWith('+++') || line.startsWith('---')) continue
31    else if (line.startsWith('@@')) {
32      const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line)
33      if (hunk !== null) {
34        const start = Number(hunk[1])
35        const count = hunk[2] === undefined ? 1 : Number(hunk[2])
36        current.ranges.push([Math.max(1, start), Math.max(1, start + Math.max(count, 1) - 1)])
37      }
38    } else if (line.startsWith('+')) current.added += 1
39    else if (line.startsWith('-')) current.removed += 1
40  }
41  return files
42}
43
44/** The LCS table above which a diff gives up line matching and marks the whole middle as replaced. */
45const MAX_DIFF_CELLS = 4_000_000
46
47/**
48 * Compares two versions of a file line by line, as `git diff -U0` would: changed ranges in the new
49 * version, added and removed counts, and unified-diff text for the explanation prompt.
50 */
51export function lineDiff(path: string, before: string | null, after: string | null): DiffFile & { text: string } {
52  const split = (text: string): string[] => (text === '' ? [] : text.replace(/\n$/, '').split('\n'))
53  const a = split(before ?? '')
54  const b = split(after ?? '')
55  const status = before === null ? 'added' : after === null ? 'deleted' : 'modified'
56
57  let head = 0
58  while (head < a.length && head < b.length && a[head] === b[head]) head += 1
59  let tail = 0
60  while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail]) tail += 1
61  const midA = a.slice(head, a.length - tail)
62  const midB = b.slice(head, b.length - tail)
63
64  type Op = { kind: ' ' | '-' | '+'; text: string }
65  const ops: Op[] = []
66  if ((midA.length + 1) * (midB.length + 1) > MAX_DIFF_CELLS) {
67    ops.push(...midA.map(text => ({ kind: '-' as const, text })), ...midB.map(text => ({ kind: '+' as const, text })))
68  } else {
69    const width = midB.length + 1
70    const table = new Uint32Array((midA.length + 1) * width)
71    for (let i = midA.length - 1; i >= 0; i -= 1) {
72      for (let j = midB.length - 1; j >= 0; j -= 1) {
73        table[i * width + j] =
74          midA[i] === midB[j]
75            ? (table[(i + 1) * width + j + 1] ?? 0) + 1
76            : Math.max(table[(i + 1) * width + j] ?? 0, table[i * width + j + 1] ?? 0)
77      }
78    }
79    let i = 0
80    let j = 0
81    while (i < midA.length || j < midB.length) {
82      if (i < midA.length && j < midB.length && midA[i] === midB[j]) {
83        ops.push({ kind: ' ', text: midA[i] ?? '' })
84        i += 1
85        j += 1
86      } else if (j >= midB.length || (i < midA.length && (table[(i + 1) * width + j] ?? 0) >= (table[i * width + j + 1] ?? 0))) {
87        ops.push({ kind: '-', text: midA[i] ?? '' })
88        i += 1
89      } else {
90        ops.push({ kind: '+', text: midB[j] ?? '' })
91        j += 1
92      }
93    }
94  }
95
96  const ranges: Range[] = []
97  const text: string[] = [`--- a/${path}`, `+++ b/${path}`]
98  let oldLine = head + 1
99  let newLine = head + 1
100  let added = 0
101  let removed = 0
102  let index = 0
103  while (index < ops.length) {
104    if (ops[index]?.kind === ' ') {
105      oldLine += 1
106      newLine += 1
107      index += 1
108      continue
109    }
110    const hunk: Op[] = []
111    while (index < ops.length && ops[index]?.kind !== ' ') {
112      hunk.push(ops[index] as Op)
113      index += 1
114    }
115    const minus = hunk.filter(op => op.kind === '-').length
116    const plus = hunk.filter(op => op.kind === '+').length
117    text.push(`@@ -${oldLine},${minus} +${newLine},${plus} @@`, ...hunk.map(op => `${op.kind}${op.text}`))
118    ranges.push(plus > 0 ? [newLine, newLine + plus - 1] : [Math.max(1, newLine), Math.max(1, newLine)])
119    oldLine += minus
120    newLine += plus
121    added += plus
122    removed += minus
123  }
124  return { path, status, added, removed, ranges, text: ranges.length === 0 ? '' : text.join('\n') }
125}
126
127function indentOf(line: string): number {
128  return (/^\s*/.exec(line)?.[0] ?? '').replace(/\t/g, '    ').length
129}
130
131const JS_METHOD = /^\s+(?:static\s+)?(?:async\s+)?(?:get\s+|set\s+)?([A-Za-z_$][\w$]*)\s*\([^)]*\)\s*(?::[^{]*)?\{\s*$/
132const NOT_METHODS = new Set(['if', 'for', 'while', 'switch', 'catch', 'with', 'function', 'return'])
133
134/** The classes, functions and methods of a file, each with its line span and depth. */
135export function outlineOf(text: string, path: string): OutlineEntry[] {
136  const lines = text.split('\n')
137  const entries: OutlineEntry[] = []
138  const classes: { endLine: number; indent: number }[] = []
139
140  lines.forEach((line, index) => {
141    const number = index + 1
142    while (classes.length > 0 && (classes[classes.length - 1]?.endLine ?? 0) < number) classes.pop()
143
144    let name = symbolName(line)
145    let kind: SymbolKind | null = null
146    if (name !== null) {
147      kind = /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s/.test(line) ? 'class' : 'function'
148    } else if (!isPython(path) && classes.length > 0) {
149      const method = JS_METHOD.exec(line)
150      if (method?.[1] !== undefined && !NOT_METHODS.has(method[1])) {
151        name = method[1]
152        kind = 'method'
153      }
154    }
155    if (name === null || kind === null) return
156
157    const insideClass = classes.length > 0 && (!isPython(path) || indentOf(line) > (classes[classes.length - 1]?.indent ?? 0))
158    if (kind === 'function' && insideClass) kind = 'method'
159    const endLine = number + bodyOf(text, number, path).length - 1
160    entries.push({ name, kind, line: number, endLine, depth: insideClass ? classes.length : 0, isChanged: false, calls: [] })
161    if (kind === 'class') classes.push({ endLine, indent: indentOf(line) })
162  })
163  return entries
164}
165
166function overlaps(entry: OutlineEntry, ranges: readonly Range[]): boolean {
167  return ranges.some(([start, end]) => start <= entry.endLine && end >= entry.line)
168}
169
170/** Marks entries whose span a change touches; a class counts only for changes outside its methods. */
171export function markChanged(entries: readonly OutlineEntry[], ranges: readonly Range[]): OutlineEntry[] {
172  return entries.map(entry => {
173    if (!overlaps(entry, ranges)) return entry
174    if (entry.kind !== 'class') return { ...entry, isChanged: true }
175    const members = entries.filter(other => other !== entry && other.line > entry.line && other.endLine <= entry.endLine)
176    const outsideMembers = ranges.some(
177      ([start, end]) =>
178        start <= entry.endLine &&
179        end >= entry.line &&
180        !members.some(member => start >= member.line && end <= member.endLine),
181    )
182    return { ...entry, isChanged: outsideMembers }
183  })
184}
185
186/** What changed in one file at the level of its functions and classes. */
187export function symbolChanges(current: readonly OutlineEntry[], before: readonly OutlineEntry[], ranges: readonly Range[]): SymbolChange[] {
188  const beforeNames = new Set(before.map(entry => `${entry.kind}:${entry.name}`))
189  const currentNames = new Set(current.map(entry => `${entry.kind}:${entry.name}`))
190  const changes: SymbolChange[] = []
191  for (const entry of markChanged(current, ranges)) {
192    const key = `${entry.kind}:${entry.name}`
193    if (!beforeNames.has(key)) changes.push({ name: entry.name, kind: entry.kind, change: 'added', line: entry.line, callers: 0 })
194    else if (entry.isChanged) changes.push({ name: entry.name, kind: entry.kind, change: 'modified', line: entry.line, callers: 0 })
195  }
196  for (const entry of before) {
197    if (!currentNames.has(`${entry.kind}:${entry.name}`)) {
198      changes.push({ name: entry.name, kind: entry.kind, change: 'removed', line: entry.line, callers: 0 })
199    }
200  }
201  return changes
202}
203
204/**
205 * Counts call sites per name from `git grep -o` output, leaving out each name's own definition lines
206 * and longer identifiers that only end in a name (`refetch(` is not a call of `fetch`).
207 */
208export function countCallers(grepOutput: string, definitions: ReadonlySet<string>, names: ReadonlySet<string>): Map<string, number> {
209  const counts = new Map<string, number>()
210  for (const line of grepOutput.split('\n')) {
211    const match = /^(.+?):(\d+):([A-Za-z_$][\w$]*)/.exec(line)
212    if (match === null) continue
213    const [, file = '', number = '', name = ''] = match
214    if (!names.has(name) || definitions.has(`${file}:${number}:${name}`)) continue
215    counts.set(name, (counts.get(name) ?? 0) + 1)
216  }
217  return counts
218}
219
220export function escapeRegex(name: string): string {
221  return name.replace(/[$]/g, '\\$')
222}
223
hooks/prompts.ts 88 lines
1import type { Decision } from '../types'
2
3export const DECISION_MODEL = 'claude-haiku-4-5-20251001'
4const MAX_DECISIONS = 3
5const MAX_ANSWER = 6000
6export const MAX_CONTEXT = 12000
7
8export const DECISION_SYSTEM =
9  'You extract engineering decisions from one turn of a coding session. A decision is a choice between real options that shapes the code or design, such as a library, a data model, an algorithm, a layout or a trade-off. Ignore routine steps such as reading files or running tests. Reply with JSON only.'
10
11export function decisionPrompt(request: string, answer: string, editedFiles: readonly string[]): string {
12  return [
13    `User request: ${request}`,
14    `Files edited this turn: ${editedFiles.length === 0 ? 'none' : editedFiles.join(', ')}`,
15    `Assistant's final answer:\n${answer.slice(0, MAX_ANSWER)}`,
16    '',
17    `List at most ${MAX_DECISIONS} decisions made in this turn, in this exact shape, or an empty list when there were none:`,
18    '{"decisions":[{"decision":"what was chosen, one line","because":"why, one line","alternatives":["option not taken"]}]}',
19  ].join('\n')
20}
21
22function text(value: unknown, limit: number): string {
23  return typeof value === 'string' ? value.trim().slice(0, limit) : ''
24}
25
26export function parseDecisions(reply: string, prompt: string, idPrefix: string): Decision[] {
27  const start = reply.indexOf('{')
28  const end = reply.lastIndexOf('}')
29  if (start < 0 || end <= start) return []
30  let parsed: unknown
31  try {
32    parsed = JSON.parse(reply.slice(start, end + 1))
33  } catch {
34    return []
35  }
36  const raw = (parsed as { decisions?: unknown }).decisions
37  if (!Array.isArray(raw)) return []
38  return raw
39    .map((item: Record<string, unknown>, index) => ({
40      id: `${idPrefix}-${index}`,
41      prompt,
42      decision: text(item.decision, 200),
43      because: text(item.because, 240),
44      alternatives: Array.isArray(item.alternatives)
45        ? item.alternatives.map(option => text(option, 80)).filter(Boolean).slice(0, 4)
46        : [],
47    }))
48    .filter(decision => decision.decision !== '')
49    .slice(0, MAX_DECISIONS)
50}
51
52const SIDE_QUESTION =
53  'Side question from the user. Answer it on its own in Markdown: do not use tools and do not continue or change the current task.'
54
55export function explainModulePrompt(id: string, files: readonly string[], symbols: readonly string[], dependsOn: readonly string[], usedBy: readonly string[]): string {
56  return [
57    SIDE_QUESTION,
58    `Explain the "${id}" folder of this codebase to someone new to it: what it is responsible for, how its main pieces fit together, and how it relates to the folders around it.`,
59    `Files: ${files.slice(0, 40).join(', ')}`,
60    `Functions and classes defined there: ${symbols.slice(0, 80).join(', ') || 'unknown'}`,
61    `It imports from: ${dependsOn.join(', ') || 'nothing in the repo'}`,
62    `It is imported by: ${usedBy.join(', ') || 'nothing in the repo'}`,
63    'Use a one-sentence summary, then short sections with bullets. Say when something is inferred from names rather than seen.',
64  ].join('\n')
65}
66
67export function explainFunctionPrompt(symbol: string, file: string, body: string, callers: readonly string[], callees: readonly string[]): string {
68  return [
69    SIDE_QUESTION,
70    `Explain the function "${symbol}" in ${file}: what it does, step by step, why it is shaped this way, and its edge cases.`,
71    `Called by: ${callers.join(', ') || 'no callers found'}`,
72    `It calls: ${callees.join(', ') || 'nothing in the repo'}`,
73    'Its code:',
74    '```',
75    body.slice(0, MAX_CONTEXT),
76    '```',
77  ].join('\n')
78}
79
80export function explainChangesPrompt(summary: string, diff: string): string {
81  return [
82    SIDE_QUESTION,
83    'Explain the changes made in this session: the approach, how the pieces fit together, which layers of the architecture they touch, and anything risky or worth testing.',
84    `Changed files:\n${summary}`,
85    diff === '' ? '' : `The uncommitted diff (may be cut):\n\`\`\`diff\n${diff.slice(0, MAX_CONTEXT)}\n\`\`\``,
86  ].join('\n')
87}
88
hooks/scan.ts 298 lines
1import type { Edge, Graph, Group, Site } from '../types'
2
3export const SOURCE_GLOBS = ['*.py', '*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs']
4
5/** Lines `git grep -E` matches as possible imports, in POSIX classes so every git build reads them. */
6export const IMPORT_PATTERN =
7  '^[[:space:]]*(import|from)[[:space:]]|require\\(|^[[:space:]]*export[[:space:]].*[[:space:]]from[[:space:]]'
8
9/** Lines `git grep -E` matches as possible definitions. */
10export const SYMBOL_PATTERN =
11  '^[[:space:]]*(async[[:space:]]+)?def[[:space:]]+[A-Za-z_]|^[[:space:]]*class[[:space:]]+[A-Za-z_]|function[[:space:]]*\\*?[[:space:]]+[A-Za-z_$]|^[[:space:]]*(export[[:space:]]+)?(const|let)[[:space:]]+[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=[[:space:]]*(async[[:space:]]*)?(\\(|function|[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*=>)'
12
13/** The same two patterns as JavaScript expressions, for folders searched without git. */
14export const IMPORT_RE = /^\s*(import|from)\s|require\(|^\s*export\s.*\sfrom\s/
15export const SYMBOL_RE =
16  /^\s*(async\s+)?def\s+[A-Za-z_]|^\s*class\s+[A-Za-z_]|function\s*\*?\s+[A-Za-z_$]|^\s*(export\s+)?(const|let)\s+[A-Za-z_$][\w$]*\s*=\s*(async\s*)?(\(|function|[A-Za-z_$][\w$]*\s*=>)/
17
18export const SOURCE_EXTENSIONS = ['.py', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
19
20/** Folders a walk without git never enters: dependencies, environments, caches and build output. */
21export const SKIPPED_DIRS = new Set([
22  'node_modules', 'venv', '.venv', 'env', '.env', '__pycache__', 'dist', 'build', 'out', 'target', 'coverage',
23  'site-packages', '.git', '.hg', '.svn', '.next', '.nuxt', '.tox', '.mypy_cache', '.pytest_cache', '.ruff_cache',
24  '.idea', '.vscode', '.cache', '.turbo', 'vendor',
25])
26
27export function isSource(name: string): boolean {
28  return SOURCE_EXTENSIONS.some(ext => name.endsWith(ext)) && !name.endsWith('.d.ts') && !name.endsWith('.min.js')
29}
30
31/** What `git grep -n -E` prints, produced from texts held in memory. */
32export function grepTexts(texts: ReadonlyMap<string, string>, pattern: RegExp): string {
33  const out: string[] = []
34  for (const [path, text] of texts) {
35    text.split('\n').forEach((line, index) => {
36      if (pattern.test(line)) out.push(`${path}:${index + 1}:${line}`)
37    })
38  }
39  return out.join('\n')
40}
41
42/** What `git grep -n -o -E` prints: each match on its own line. */
43export function grepMatches(texts: ReadonlyMap<string, string>, pattern: RegExp): string {
44  const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`)
45  const out: string[] = []
46  for (const [path, text] of texts) {
47    text.split('\n').forEach((line, index) => {
48      for (const match of line.matchAll(global)) out.push(`${path}:${index + 1}:${match[0]}`)
49    })
50  }
51  return out.join('\n')
52}
53
54/** What `git grep -c ''` prints: the line count of each file. */
55export function countTexts(texts: ReadonlyMap<string, string>): string {
56  return [...texts].map(([path, text]) => `${path}:${text.split('\n').length - (text.endsWith('\n') ? 1 : 0)}`).join('\n')
57}
58
59export const MAX_FILES = 4000
60const MAX_GROUPS = 80
61const MAX_EDGES = 1000
62const GROUP_DEPTH = 3
63const JS_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs']
64
65export type FileImports = Map<string, Set<string>>
66export type SymbolIndex = Map<string, Site[]>
67
68/** Everything a scan learned: the map for the pane, and the indexes later lookups use. */
69export type Scan = { graph: Graph; imports: FileImports; symbols: SymbolIndex }
70
71export function isPython(path: string): boolean {
72  return path.endsWith('.py')
73}
74
75/** The folder a file is drawn under: its directory, cut to GROUP_DEPTH levels. */
76export function groupOf(path: string): string {
77  const parts = path.split('/').slice(0, -1)
78  return parts.length === 0 ? '(root)' : parts.slice(0, GROUP_DEPTH).join('/')
79}
80
81/** Splits one `git grep -n` line into file, line number and text. */
82export function grepLine(line: string): { file: string; line: number; text: string } | null {
83  const match = /^(.+?):(\d+):(.*)$/.exec(line)
84  if (match === null) return null
85  return { file: match[1] ?? '', line: Number(match[2]), text: match[3] ?? '' }
86}
87
88function normalize(parts: readonly string[]): string {
89  const out: string[] = []
90  for (const part of parts) {
91    if (part === '' || part === '.') continue
92    if (part === '..') out.pop()
93    else out.push(part)
94  }
95  return out.join('/')
96}
97
98function dirOf(path: string): string[] {
99  return path.split('/').slice(0, -1)
100}
101
102/** Finds the repo file a dotted Python module names, also under a prefix such as src/. */
103function pythonFile(dotted: string, base: readonly string[], files: ReadonlySet<string>, bySuffix: Map<string, string>): string | null {
104  const stem = normalize([...base, ...dotted.split('.')])
105  for (const candidate of [`${stem}.py`, `${stem}/__init__.py`]) {
106    if (files.has(candidate)) return candidate
107    if (base.length === 0) {
108      const found = bySuffix.get(candidate)
109      if (found !== undefined) return found
110    }
111  }
112  return null
113}
114
115/** The repo files one Python import line brings in. */
116export function resolvePython(from: string, text: string, files: ReadonlySet<string>, bySuffix: Map<string, string>): string[] {
117  const out: string[] = []
118  const fromImport = /^\s*from\s+(\.*)([\w.]*)\s+import\s+(.+)$/.exec(text)
119  if (fromImport !== null) {
120    const dots = (fromImport[1] ?? '').length
121    const module = fromImport[2] ?? ''
122    const base = dots === 0 ? [] : dirOf(from).slice(0, Math.max(0, dirOf(from).length - (dots - 1)))
123    const whole = module === '' ? null : pythonFile(module, base, files, bySuffix)
124    if (whole !== null) out.push(whole)
125    const names = (fromImport[3] ?? '').replace(/[()]/g, '').split(',')
126    for (const name of names) {
127      const bare = name.trim().split(/\s+/)[0] ?? ''
128      if (!/^\w+$/.test(bare)) continue
129      const sub = pythonFile(module === '' ? bare : `${module}.${bare}`, base, files, bySuffix)
130      if (sub !== null) out.push(sub)
131    }
132    return out
133  }
134  const plain = /^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/.exec(text)
135  if (plain !== null) {
136    for (const module of (plain[1] ?? '').split(',')) {
137      const found = pythonFile(module.trim(), [], files, bySuffix)
138      if (found !== null) out.push(found)
139    }
140  }
141  return out
142}
143
144/** The repo files one JS or TS import line brings in; package imports are left out. */
145export function resolveJs(from: string, text: string, files: ReadonlySet<string>): string[] {
146  const out: string[] = []
147  const pattern = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\(\s*)['"]([^'"]+)['"]/g
148  for (const match of text.matchAll(pattern)) {
149    const spec = match[1] ?? ''
150    if (!spec.startsWith('.')) continue
151    const stem = normalize([...dirOf(from), ...spec.split('/')])
152    const candidates = [
153      stem,
154      ...JS_EXTENSIONS.map(ext => `${stem}${ext}`),
155      ...JS_EXTENSIONS.map(ext => `${stem}/index${ext}`),
156      stem.replace(/\.js$/, '.ts'),
157      stem.replace(/\.js$/, '.tsx'),
158    ]
159    const found = candidates.find(candidate => files.has(candidate))
160    if (found !== undefined) out.push(found)
161  }
162  return out
163}
164
165/** The name a definition line declares, or null. */
166export function symbolName(text: string): string | null {
167  const patterns = [
168    /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)/,
169    /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/,
170    /function\s*\*?\s+([A-Za-z_$][\w$]*)/,
171    /^\s*(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*=/,
172  ]
173  for (const pattern of patterns) {
174    const match = pattern.exec(text)
175    if (match?.[1] !== undefined) return match[1]
176  }
177  return null
178}
179
180/** Height of each folder in the dependency order, cycles broken where they close. */
181export function levels(ids: readonly string[], edges: readonly Edge[]): Map<string, number> {
182  const out = new Map<string, string[]>()
183  for (const edge of edges) out.set(edge.from, [...(out.get(edge.from) ?? []), edge.to])
184  const memo = new Map<string, number>()
185  const visiting = new Set<string>()
186  const level = (id: string): number => {
187    const known = memo.get(id)
188    if (known !== undefined) return known
189    if (visiting.has(id)) return 0
190    visiting.add(id)
191    const deps = out.get(id) ?? []
192    const value = deps.length === 0 ? 0 : 1 + Math.max(...deps.map(level))
193    visiting.delete(id)
194    memo.set(id, value)
195    return value
196  }
197  return new Map(ids.map(id => [id, level(id)]))
198}
199
200/** Builds the map and indexes from the four git outputs a scan collects, or their in-memory equivalents. */
201export function buildScan(root: string, fileList: string, lineCounts: string, importLines: string, symbolLines: string, isGit = true): Scan {
202  const fileNames = fileList.split('\n').filter(Boolean)
203  const isTruncated = fileNames.length > MAX_FILES
204  const files = new Set(fileNames.slice(0, MAX_FILES))
205  const bySuffix = new Map<string, string>()
206  for (const file of files) {
207    const parts = file.split('/')
208    for (let start = 1; start < parts.length; start += 1) {
209      const suffix = parts.slice(start).join('/')
210      if (!bySuffix.has(suffix)) bySuffix.set(suffix, file)
211    }
212  }
213
214  const lines = new Map<string, number>()
215  for (const row of lineCounts.split('\n')) {
216    const match = /^(.+):(\d+)$/.exec(row)
217    if (match !== null && files.has(match[1] ?? '')) lines.set(match[1] ?? '', Number(match[2]))
218  }
219
220  const imports: FileImports = new Map()
221  for (const row of importLines.split('\n')) {
222    const hit = grepLine(row)
223    if (hit === null || !files.has(hit.file)) continue
224    const targets = isPython(hit.file) ? resolvePython(hit.file, hit.text, files, bySuffix) : resolveJs(hit.file, hit.text, files)
225    for (const target of targets) {
226      if (target === hit.file) continue
227      const set = imports.get(hit.file) ?? new Set<string>()
228      set.add(target)
229      imports.set(hit.file, set)
230    }
231  }
232
233  const symbols: SymbolIndex = new Map()
234  const symbolCount = new Map<string, number>()
235  for (const row of symbolLines.split('\n')) {
236    const hit = grepLine(row)
237    if (hit === null || !files.has(hit.file)) continue
238    const name = symbolName(hit.text)
239    if (name === null) continue
240    symbols.set(name, [...(symbols.get(name) ?? []), { symbol: name, file: hit.file, line: hit.line, isKnown: true }])
241    symbolCount.set(groupOf(hit.file), (symbolCount.get(groupOf(hit.file)) ?? 0) + 1)
242  }
243
244  const groupFiles = new Map<string, string[]>()
245  for (const file of files) groupFiles.set(groupOf(file), [...(groupFiles.get(groupOf(file)) ?? []), file])
246
247  const edgeCount = new Map<string, number>()
248  for (const [from, targets] of imports) {
249    for (const to of targets) {
250      const key = `${groupOf(from)}\u0000${groupOf(to)}`
251      if (groupOf(from) !== groupOf(to)) edgeCount.set(key, (edgeCount.get(key) ?? 0) + 1)
252    }
253  }
254  const edges: Edge[] = [...edgeCount]
255    .map(([key, count]) => {
256      const [from = '', to = ''] = key.split('\u0000')
257      return { from, to, count }
258    })
259    .sort((a, b) => b.count - a.count)
260    .slice(0, MAX_EDGES)
261
262  const byLines = [...groupFiles.keys()].sort(
263    (a, b) => (groupFiles.get(b)?.length ?? 0) - (groupFiles.get(a)?.length ?? 0),
264  )
265  const kept = new Set(byLines.slice(0, MAX_GROUPS))
266  const keptEdges = edges.filter(edge => kept.has(edge.from) && kept.has(edge.to))
267  const heights = levels([...kept], keptEdges)
268  const groups: Group[] = [...kept]
269    .map(id => ({
270      id,
271      files: groupFiles.get(id)?.length ?? 0,
272      lines: (groupFiles.get(id) ?? []).reduce((sum, file) => sum + (lines.get(file) ?? 0), 0),
273      symbols: symbolCount.get(id) ?? 0,
274      level: heights.get(id) ?? 0,
275    }))
276    .sort((a, b) => b.level - a.level || a.id.localeCompare(b.id))
277
278  return {
279    graph: { root, isGit, files: files.size, groups, edges: keptEdges, isTruncated: isTruncated || kept.size < groupFiles.size },
280    imports,
281    symbols,
282  }
283}
284
285/** Files outside `changed` that import any file in it. */
286export function dependentsOf(changed: ReadonlySet<string>, imports: FileImports): string[] {
287  const out: string[] = []
288  for (const [from, targets] of imports) {
289    if (changed.has(from)) continue
290    if ([...targets].some(target => changed.has(target))) out.push(from)
291  }
292  return out.sort()
293}
294
295export function shortCount(value: number): string {
296  return value >= 1000 ? `${(value / 1000).toFixed(1)}k` : String(value)
297}
298
types/index.d.ts 120 lines
1/** A folder of source files: one box on the map. */
2export type Group = {
3  id: string
4  files: number
5  lines: number
6  symbols: number
7  /** 0 for folders that import nothing in the repo; higher for folders built on top of others. */
8  level: number
9}
10
11/** `from` imports `to`, `count` times across their files. */
12export type Edge = { from: string; to: string; count: number }
13
14export type Graph = {
15  root: string
16  /** False when the folder has no git: changes are then measured against the first scan's snapshot. */
17  isGit: boolean
18  files: number
19  groups: Group[]
20  edges: Edge[]
21  isTruncated: boolean
22}
23
24export type ScanStatus = 'idle' | 'scanning' | 'ready' | 'no-folder' | 'error'
25
26/** What Claude did to one file this session; `path` is absolute as the tools name it. */
27export type Activity = { path: string; reads: number; edits: number }
28
29/** A place in the code: a definition, a caller or a callee. */
30export type Site = { symbol: string; file: string; line: number; isKnown: boolean }
31
32export type CallView = {
33  symbol: string
34  status: 'loading' | 'ready' | 'missing' | 'error'
35  definition?: Site
36  callers: Site[]
37  callees: Site[]
38  error?: string
39}
40
41export type SymbolKind = 'class' | 'function' | 'method'
42
43/** One function, method or class of a file, with what it calls. */
44export type OutlineEntry = {
45  name: string
46  kind: SymbolKind
47  line: number
48  endLine: number
49  depth: number
50  isChanged: boolean
51  calls: string[]
52}
53
54export type Outline = {
55  file: string
56  status: 'loading' | 'ready' | 'error'
57  lines: number
58  entries: OutlineEntry[]
59  error?: string
60}
61
62export type SymbolChange = {
63  name: string
64  kind: SymbolKind
65  change: 'added' | 'modified' | 'removed'
66  line: number
67  callers: number
68}
69
70export type FileChange = {
71  path: string
72  group: string
73  status: 'modified' | 'added' | 'deleted'
74  added: number
75  removed: number
76  symbols: SymbolChange[]
77}
78
79export type ChangeMap = {
80  status: 'idle' | 'loading' | 'ready' | 'error'
81  files: FileChange[]
82  /** Folders touched, highest layer first. */
83  layers: string[]
84  /** Unchanged files that import a changed file. */
85  dependents: string[]
86  error?: string
87}
88
89export type Decision = {
90  id: string
91  prompt: string
92  decision: string
93  because: string
94  alternatives: string[]
95}
96
97export type Insight = { title: string; status: 'loading' | 'ready' | 'error'; text: string }
98
99export type Tab = 'map' | 'changes' | 'components' | 'calls' | 'decisions' | 'explain'
100
101declare module 'claude-code' {
102  interface PluginState {
103    'codebase-atlas': {
104      graph: Graph | null
105      scanStatus: ScanStatus
106      scanError: string
107      activity: Activity[]
108      tab: Tab
109      focus: string | null
110      changes: ChangeMap
111      outline: Outline | null
112      call: CallView | null
113      trail: string[]
114      decisions: Decision[]
115      isExtracting: boolean
116      insight: Insight | null
117    }
118  }
119}
120