Exploration map: the repository as a tree under fog, cleared where Claude read, marked where it edited, made a file or hit an error, with what the edits may…

English | 日本語
Personal Claude Code mods (function-hook plugins), published as one marketplace.
| Mod | What it does |
|---|---|
agent-pane | Live pane of the session's subagents: running ones with elapsed time, finished ones with result and duration |
done-gate | Flags unverified edits, stops repeated failing commands, and blocks git --no-verify |
sandbox-lens | Tallies sandbox denials seen in Bash output and reports them with /sandbox-report |
skill-hint | Suggests up to three matching skills above the prompt from what was typed; never invokes them. |
todo-agent | Collects #TODO(agent) follow-ups from Claude's replies into a per-project list with a /todos pane. |
context-meter | Band above the prompt with a Gruvbox sparkline of context use per turn, plus tokens and cost |
tool-row | Redraws finished tool-call rows in the terminal as one compact line with a Nerd Font icon, Gruvbox colors and a short stat |
image-preview | Thumbnails of the images a session writes, reads or generates: under the tool's row in the terminal, and in an /images pane |
self-audit | After each turn, a cached fork of the conversation audits Claude's own reply against the CLAUDE.md reporting rules and flags violations above the prompt |
context-diet | Trims the skill listing to what the project kind needs (off by default); /context-diet shows what was dropped and the tokens saved |
browser-cam | Live pane of the browser Claude drives: the latest screenshot from Claude in Chrome, the page URL and the recent actions |
prompt-coach | Holds back a typed prompt that looks ambiguous (no done-condition, unclear target) and offers guesses at what it meant, sending it as is, or adding one; on by default |
loopback | Feeds what self-audit, done-gate and sandbox-lens found back to Claude beside the next prompt, instead of only showing it to the person, skipping rules hit-rate found mostly wrong; off by default |
zukan | Error book: catches the errors Bash commands hit across sessions and repos, records what got past each one, and when one comes back tells Claude and the person how it was fixed last time |
pr-lamp | The current branch's pull request as a lamp: checks, review, unresolved threads and what is left before a merge, in the status line and a /pr pane; red checks and new review comments go to Claude beside the next prompt |
fog-map | Exploration map: the repository as a tree under fog, cleared where Claude read and marked where it edited, made a file or hit an error, with the importers an edit may have broken unseen; /map |
assertion-guard | Catches edits that weaken tests to pass: assertions removed, expectations loosened or rewritten, tests skipped, errors swallowed; asks Claude for the reason beside the edit |
negative-space | When an edit adds authorization, validation or error handling, lists the refusal and boundary-value tests no test touched this session covers; /negative-space |
mutation-tap | /mutate runs a mutation test limited to the lines changed since HEAD and lists the mutants the tests did not catch |
perf-delta | /perf compares a benchmark before and after (a saved baseline, or a git ref in a temporary worktree): median time, peak memory, spread, and whether the change beats the noise |
handoff-capsule | Before a compaction and at session end, keeps the goal, decisions, open items, commands to reproduce and files touched; the next session picks which parts to bring back |
port-lantern | The dev servers the session started: URL, port, whether each still listens, and their latest log lines, in the status line and /ports |
artifact-shelf | A shelf of what sessions made (CSV, PDF, HTML, video, patches…), tied to the call and request that made each; /shelf searches by kind, word and time |
focus-hud | One status line: goal, target, what is running and for how long, and what the session waits on; /focus |
side-effect-ledger | A ledger of what a git diff never shows (push, publish, deploy, PRs, API writes, messages) with outcome, how to undo, and whether the undo was checked; /ledger |
trust-label | Labels external text (web, issues, logs, browser pages, services) with its source beside the result and flags candidate injected instructions; a heuristic, never a safety verdict; /trust |
visual-diff | Compares browser screenshots of the same URL and size before and after an edit: side by side, a map of changed cells, regions, and the edits in between; /vdiff |
journey-trace | Records Claude's browser actions and the expected states as a replayable recipe; /journey md for a bug report, /journey e2e for a Playwright draft |
hit-rate | Gives each finding of done-gate, assertion-guard, self-audit and zukan a later verdict from what the session did next, keeps each rule's precision across sessions, and names the rules loopback stops feeding; /hit-rate; off by default |
token-lens | Checks an element's actual colours, spacing and type against the project's design tokens and lists off-token values with the nearest token; /token-lens <selector> |
At the prompt of a terminal session:
/plugin install <mod> --marketplace simota/claude-mods
From a shell:
claude plugin install <mod> --marketplace simota/claude-mods
This repository is private: installing needs read access on GitHub and a git credential for github.com on the machine (an SSH key, or gh auth login then gh auth setup-git).
Options are set with /plugin configure <mod>@simota-mods in a session, or from a shell:
echo '{"enabled":"true"}' | claude plugin configure <mod>@simota-mods --values-stdin
A change made with /plugin configure reloads the mod; one made from a shell applies after a restart. Bands above the prompt stack: each mod draws its row above what the others draw.
/agent-pane opens the pane: running subagents first with elapsed time, then finished ones with ✓/✗ and duration; a background run that was stopped shows ■ 停止. A background run that left the roster without a result shows ? 結果不明; a teammate or remote launch shows ↗ 起動済み(追跡外).agents: N running while any run.Works on its own, no command:
ls build, npm ci are not checks), and only when its exit status is the command's: the check is last, or only && follows it (npm test | tail does not count).--no-verify (or a prefix of it from --no-ver), and git commit -n / -nm, is denied, also inside subshells, $(…), backticks, bash -c "…", eval and wrappers such as sudo/timeout. The value of an option (-m "…"), a comment or a heredoc never triggers it. So is a git command that moves its hooks for the run (-c core.hooksPath=…, --config-env core.hooksPath=…) or carries a hook manager's off switch in its environment (HUSKY=0, LEFTHOOK=0, pre-commit's SKIP=…), or moves the hooks through config in its environment (GIT_CONFIG_KEY_n=core.hooksPath, GIT_CONFIG_PARAMETERS). ANSI-C quoting ($'--no-\x76erify'), env -S "…", bash -c -- '…' and a shell reading its script from stdin (bash <<< '…', a heredoc, echo '…' | sh) are read through. It is a best-effort guard: git aliases (git ci -n), a hook path written into config beforehand and an environment exported by an earlier command are not detected./sandbox-report lists the sandbox denials seen in Bash output, for this session and all time: denied hosts and paths, counts, last seen, an example command, and which setting would allow each./sandbox-report reset clears the record.127.0.0.1:3000), since an entry without a port opens every port.<sandbox_violations> block, and Operation not permitted lines a program printed about itself, are read. For a command that succeeded or was interrupted, the block is taken from where the engine puts it, apart from the command's own output, so one inside a shown file or page is ignored even when it ends the output. A failed command keeps only its error text: the block that ends it (before an agent-proxy note) is read, and there a block the command printed itself cannot be told from the harness's. Credentials in the example command (tokens, passwords, Authorization/Cookie and other credential headers such as X-API-Key, URL userinfo, keys in env assignments including run-together names such as PGPASSWORD, known key shapes) are masked. Each section shows the top 10; when the record is full (200), the least recently seen entry gives way.After a prompt is sent, up to three skills matching it show above the prompt (💡 skill候補: /coding-debug …), only ones that exist in the session. Nothing is invoked; 閉じる or the next prompt (a slash command included) clears it. A prompt that already names a skill (/name anywhere, or a hyphenated skill name such as coding-debug) gets no hint; built-in commands such as /clear and path segments such as src/review/ do not count as naming one. Plugin skills (plugin:name) are suggested by their full name.
#TODO(agent): <action> (after an optional quote or list bullet) in Claude's main-loop replies are collected per project (repo root or cwd). A mention inside a sentence, a table or inline code is not a TODO; subagent replies are ignored./todos opens the pane of this project's open items, each with a 完了 button./todos all lists every project's open items as text, marked as stored data.TODO(agent): N while the project has open items.A band with a sparkline of context use per turn and ctx 63% ▲4 · 126k · turn in 120k / out 8k · $1.23: the context size of the last request, then the whole turn's input/output (shown when the turn reported usage). The percentage turns yellow from 50% and red from 80%.
| Option | Default | |
|---|---|---|
band | true | Draw the band (samples are recorded either way) |
Finished tool calls are drawn as one compact line with a Nerd Font icon: Edit/Write path +A −D, Read with line count, Bash (red on error), Agent, web and MCP calls. The result block under a compacted row is hidden, so a call is one line; an errored result stays. A row another mod decorated (an image-preview thumbnail) is left as it is, result included. Runs Claude Code folds into one count line (Read 3 files, ran 2 shell commands) stay folded. Terminal only.
| Option | Default | |
|---|---|---|
compact | true | Off leaves every row and result as Claude Code draws them |
/images opens a pane, newest first; h/l step through them.sips after the tool result has gone back, so tool calls never wait on it. Converted files live in a private cache ($TMPDIR or ~/.cache), newest 50 kept. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps./tmp/a b/x.jpg) joins at most 8 preceding pieces and 4096 characters; the first 64 paths named are read, and of those the first 4 that exist are shown.| Option | Default | |
|---|---|---|
inline | true | Thumbnail under the tool row |
maxRows | 12 | Rows a thumbnail may take, 1 to 60 |
After each reply of at least minChars characters, one tool-less fork of the conversation (served mostly from the prompt cache), given the reply itself as quoted data, checks it against the CLAUDE.md rules: claims done without evidence, first line not the result, unverified assertions, bypassed checks, scope inflation. Only violations of the five rules whose quote is found in the reply are kept (a made-up quote or rule is dropped); the quote is compared past markdown marks, bullets, curly quotes, case, spacing and a closing full stop, and one shortened with … is found piece by piece. Violations show in red above the prompt; 詳細 or /self-audit opens the pane with quotes and the fork's token use. Results belong to the latest reply only: when it was not audited, the pane says why.
| Option | Default | |
|---|---|---|
enabled | true | |
minChars | 200 | Shorter replies are not audited |
Cost: one extra request per turn on the session's model, plus the reply text (up to 12,000 characters) as uncached input; a cold cache (after 5 minutes idle or a model switch) resends the whole conversation.
Off by default. When on, the skill listing is trimmed to what the project kind needs (Swift, Android, Rust, web, Python, Go, detected from the repo root and one level below). Coding, quality, planning and research skills are always kept; red-team, dynamic-*, persona and operation skills are dropped; image, design, ux-scan and brand-scan are dropped for Rust, Python and Go. Only the main loop's listing is reported. A dropped skill named in a prompt is still described for that prompt.
/context-diet shows the project kind, what was kept and dropped in the latest listing, and the tokens saved (estimate). A listing it cannot read reliably goes out unchanged./context-diet on|off|reset overrides the option for this session. Switching rebuilds the listing, so the prompt cache misses once.| Option | Default | |
|---|---|---|
enabled | false | |
keep | Comma list of names or prefix* never dropped; wins over drop | |
drop | Comma list of names or prefix* always dropped |
type ‹7文字›), since the field may be a password; a URL's secrets (token parameters, userinfo, key-shaped parts) are masked./cam toggles the pane; in a fullscreen terminal it opens by itself once per session, on the first browser call. Screenshots are decoded only while the pane is open, and written to a private per-session folder that is removed at session end. A killed session's folder is removed by a later session's start once a day untouched; a running session touches its own every hour (again after a plugin reload), so it is kept while idle, though not across a day the machine sleeps.| Option | Default | |
|---|---|---|
autoOpen | true | Open on the first browser call (fullscreen terminal) |
On by default (enabled: false turns it off). A typed prompt that looks ambiguous (no done-condition and an unclear target, or only evaluative words like いい感じ) is held back, and a band shows what is missing with up to three guesses at what it meant (judged along the lines of the clarify skill: the likeliest reading first, each a different deliverable with a checkable done-condition):
a / b / d: a guess goes into the prompt to edit and send (not held again)s: このまま送る sends it unchangede: 補足して送る puts it back with 完了条件: to fill in (not held again)q: 閉じる puts it back in the promptTyping another prompt instead sends that one and puts the held text back into the prompt. The held text is never lost: when a send fails or is dropped beneath, or putting words back fails, it stays held with its band (unless another prompt was held since) and its words are shown in a toast; a prompt still held at /clear goes straight back into the box, and one held when the session quits comes back at this project's next interactive session start. Words go into an empty box as they are, and are appended after a draft typed meanwhile on a line of their own, never over it.
Slash commands, prompts under 12 or over 1500 characters, prompts with any attachment or an @ mention, prompts sent by a plugin, and prompts typed while a turn runs are not judged. A timeout or error sends the prompt unchanged; the same text is not held twice (within the last 50 judged). Sending can wait up to timeoutMs.
| Option | Default | |
|---|---|---|
enabled | true | |
model | claude-haiku-5-5 | Judging model (alias or id) |
timeoutMs | 3000 | 500 to 3000 |
Off by default. When on, the new findings of the detector mods are attached to the next prompt typed at an idle prompt, as context only the model reads, so the correction does not wait for the person to retype it:
self-audit: the violations of the latest audited replydone-gate: edits with no passing check after them (again after a check passed and new edits were left unverified; 閉じる on its band takes the flag down, and nothing is fed)sandbox-lens: denials seen this session, each onceEach finding goes back once per session, marked as data that may be wrong for Claude to check before acting. A toast says how many went back and from where. Slash commands, hidden prompts, prompts sent by a plugin and prompts typed while a turn runs carry nothing. A detector that is not installed contributes nothing.
With hit-rate on, the rules it muted are not fed (sandbox-lens denials never are muted).
/loopback shows what was fed last and the self-audit repeat rate: of the rules fed back, how many self-audit flagged again on the next audited reply (all sessions). The rate is what the design is judged by (.genius/decisions/loopback.md). It also names the rules hit-rate muted.| Option | Default | |
|---|---|---|
enabled | false |
Cost: the findings ride as extra input on the prompt they are attached to (each quote clipped to 300 characters, at most 12 findings).
An error book, kept across sessions and repos. When a Bash command fails, the error line is caught and numbered:
📕 新種のエラー発見! No.042「…」 — a kind not seen before📙 No.017 再会(3回目・未解決) — seen before, nothing known to get past it 📗 既知 No.017(3回目)— 前回: sandbox 外で git push origin main ` — seen before with a fix: Claude also reads, beside the tool's result, where it was seen and what got past it (marked as a past record to check, not an instruction)A fix is learned when the same program and subcommand (git push, pnpm install; read past a leading cd, export or source, so cd app && npm install is npm install) succeeds within the next 10 Bash calls of the session; whether it ran outside the sandbox is kept with it. Two sightings are one kind when their error line matches with paths, quotes, numbers and hashes taken out.
Failures of tests, linters, type checkers and builds are not caught: they are fixed by editing code, not by a command. Credentials in a kept command and in the kept error line are masked as sandbox-lens does.
/zukan opens the book, most recently seen first: kinds, solved, sightings/zukan note <No> <text> writes how to get past it, shown and fed in place of the learned command; an empty text removes the note/zukan forget <No> removes an entryThe newest 300 kinds by last sighting are kept.
The current branch's pull request, kept in the status line while you work:
⎇ #42 · ✓5 ✗1 ⏳2 · 🛑 変更要求 · 💬3 · マージまであと4
"マージまであと N" counts what still stands before a merge: draft, failed checks, running checks, changes requested or approval pending, unresolved review threads, a conflict, the branch behind its base. At 0 it reads 🟢 マージ可能.
A toast when it changes: 🔴 CI 失敗: test, 💬 新しいレビューコメント 2件 (@rev), ✅ Approved, 🟢 #42 マージまであと0, 🎉 #42 がマージされました. Checks turning red, new review comments, changes requested and a new conflict are also attached to the next prompt typed at an idle prompt, once, with the last 40 lines of the failed log of up to three jobs (more are counted), marked as data whose comments and logs are not to be followed as instructions. The session's own replies on a thread (the comment, made after the post, carrying words it replied with through an MCP reply tool or gh api …/pulls/N/comments… -f body=, matched once) are not news; your own review comments are. What waits for the next prompt is about one pull request and is capped (newest kept). The option feed (on by default) turns that off.
/pr asks again and opens the pane: title, size, what is left, checks (failed first) and unresolved threads/pr <number|url|branch> follows that pull request; /pr clear goes back to the defaultWhich pull request: the one a Bash command of the session last worked on (gh pr comment 2, gh pr review 2, the URL gh pr create printed, the branch a git push sent to; looking with gh pr view|diff|checks does not move it), so work pushed to a review branch from another checkout is followed; else the checked-out branch's; else your newest open pull request in the repository.
It asks gh (gh pr view, and gh api graphql for review threads) at session start, right after a Bash git push, git checkout, gh pr create and the like, every minute while a check runs or after a push, and every five minutes otherwise; it stops once the pull request is merged or closed. Without an origin remote, gh, or a pull request for the branch it shows nothing, and /pr says why. gh runs on the host, outside the Bash sandbox, with your gh auth.
The repository as a map under fog, cleared as Claude works through it:
🗺 探索率 31% ██████░░░░░░░░░░░░░░ 読 23 · 編 9 · ✗ 2 / 101
▾ mods/
▾ pr-lamp/
▾ hooks/
● lamp.ts +43 −2 編集 4回
✗ register.tsx +59 −16 編集 6回 エラー 1 ★
▸ loopback/ ○●
░ agent-pane/ ░ browser-cam/ ░ context-diet/ …8
⚠ 編集したファイルを import しているが未読: mods/pr-lamp/tests/pr-lamp.test.ts
░ nothing touched yet, ○ read (Read, or cat, sed, grep… in Bash), ● edited (Edit, Write, NotebookEdit, sed -i), ✚ made by the session, ✗ named with a line number by a failed Bash command since its last edit, ★ the file touched last. A directory holding an edit, a new file or an error is open; one only read is closed with its marks beside it; untouched directories and files at a level share one fog row.
Two warnings, each a likely oversight: a file edited without being read, and a file that imports an edited one (JS/TS and Python, found by git grep) that was never opened. A very large repository's file list is cut to what one state value holds (3 M characters of paths), so it is still mapped, partly.
/map opens the pane🗺 31% · ●9 · ✗2 · ⚠1🗺 新しい区域を開拓: mods/zukan/)The map is the session's, subagents' calls included; files outside the repository ar
hooks/register.tsx 214 lines1import { atom, derive, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FogFile, FogRepo } from '../types'
5import {
6 areaOf,
7 bar,
8 bashTouches,
9 capFiles,
10 countsOf,
11 editChange,
12 errorPaths,
13 importPattern,
14 inRepo,
15 milestoneOf,
16 rowsOf,
17 statusOf,
18 treeOf,
19 warningsOf,
20 withEdit,
21 withError,
22 withRead,
23} from './fog'
24
25const PANE = 'fog-map'
26const TITLE = '探索マップ'
27
28const repo = atom({ plugin: 'fog-map', key: 'repo' } as const, null as FogRepo | null)
29const touched = atom({ plugin: 'fog-map', key: 'touched' } as const, {} as Record<string, FogFile>)
30const focus = atom({ plugin: 'fog-map', key: 'focus' } as const, null as string | null)
31const importers = atom({ plugin: 'fog-map', key: 'importers' } as const, {} as Record<string, string[]>)
32const areas = atom({ plugin: 'fog-map', key: 'areas' } as const, [] as string[])
33const milestone = atom({ plugin: 'fog-map', key: 'milestone' } as const, 0)
34// Built from the file list once each time it changes, not on every call or every draw.
35const tracked = derive([repo], known => new Set(known?.files ?? []))
36const tree = derive([repo], known => treeOf(known?.files ?? []))
37
38const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
39// Commands after which the repository's file list is read again.
40const RESHAPES = /\bgit\s+(?:checkout|switch|pull|merge|rebase|mv|rm|stash)\b|\b(?:mv|rm|cp)\s/
41
42async function git($: EngineInterface, cwd: string, argv: string[]): Promise<string | undefined> {
43 const ran = await $.process.run(['git', ...argv], { cwd, timeoutMs: 15_000 }).catch(() => undefined)
44 return ran?.exitCode === 0 ? ran.stdout : undefined
45}
46
47/** The repository the session works in and its files; null outside a git repository. */
48async function loadRepo($: EngineInterface): Promise<void> {
49 const root = (await $.session.repo())?.root
50 if (root === undefined) {
51 await update($, repo, () => null)
52 return
53 }
54 const [listed, branch] = await Promise.all([git($, root, ['ls-files', '-z']), git($, root, ['branch', '--show-current'])])
55 const files = capFiles((listed ?? '').split('\0').filter(Boolean).slice(0, 50_000))
56 const name = root.split('/').filter(Boolean).pop() ?? root
57 await update($, repo, () => ({ root, name, branch: branch?.trim() || 'detached', files }))
58}
59
60/** The repository's root, else the session's directory: paths outside it are not mapped. */
61async function placeOf($: EngineInterface): Promise<{ root: string; cwd: string; files: Set<string> }> {
62 const known = await read($, repo)
63 const cwd = await $.session.cwd()
64 return { root: known?.root ?? cwd, cwd, files: new Set(known?.files ?? []) }
65}
66
67/** Finds, once per edited file, the files importing it (JS/TS and Python, by git grep). */
68async function findImporters($: EngineInterface, path: string): Promise<void> {
69 if ((await read($, importers))[path] !== undefined) return
70 const known = await read($, repo)
71 const pattern = importPattern(path)
72 if (known === null || pattern === undefined) return
73 const out = await git($, known.root, ['grep', '-l', '-E', pattern])
74 const list = (out ?? '').split('\n').filter(line => line !== '' && line !== path).slice(0, 50)
75 await update($, importers, all => ({ ...all, [path]: list }))
76}
77
78/** Marks what a call did, then redraws the status line and announces what is worth a toast. */
79async function mark($: EngineInterface, change: (all: Record<string, FogFile>) => Record<string, FogFile>, last: string | undefined): Promise<void> {
80 const before = await read($, touched)
81 const after = await update($, touched, change)
82 if (last !== undefined) await update($, focus, () => last)
83
84 const known = await read($, repo)
85 const counts = countsOf(after, known?.files ?? [], await read($, tracked))
86 const warnings = warningsOf(after, await read($, importers))
87 $.ui.status(statusOf(counts, warnings.length, known !== null))
88
89 const notes: string[] = []
90 const reached = milestoneOf(await read($, milestone), counts.percent)
91 if (known !== null && reached !== undefined) {
92 await update($, milestone, () => reached)
93 notes.push(`🗺 探索率 ${reached}% 到達`)
94 }
95 const newlyEdited = Object.keys(after).filter(path => (after[path]?.edits ?? 0) > 0 && (before[path]?.edits ?? 0) === 0)
96 const seen = await read($, areas)
97 const fresh = [...new Set(newlyEdited.map(areaOf))].filter(area => !seen.includes(area))
98 if (fresh.length > 0) {
99 await update($, areas, list => [...list, ...fresh])
100 notes.push(`🗺 新しい区域を開拓: ${fresh.join(' ')}`)
101 }
102 if (notes.length > 0) $.ui.toast(notes.join(' · '))
103}
104
105export const register: Register = on => {
106 on('session.start', async ($, e, next) => {
107 await $.command.register({ name: 'map', description: '探索マップを開く: Claude が読んだ・編集したファイルを、未踏の場所を霧にして表示' })
108 const started = await next(e)
109 await loadRepo($).catch(() => undefined)
110 await mark($, all => all, undefined)
111 return started
112 })
113
114 on('tool.call', async ($, e, next) => {
115 const tool = String(e.tool)
116 const input = e as unknown as Record<string, unknown>
117 const isEdit = EDIT_TOOLS.has(tool)
118 if (tool !== 'Read' && tool !== 'Bash' && !isEdit) return next(e)
119
120 const place = await placeOf($)
121 const target = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
122 const path = target === undefined ? undefined : inRepo(target, place.root, place.cwd)
123 // Asked before a Write runs: whether it makes a new file, and what it writes over, so its
124 // lines count as a diff stat would rather than as the whole file added.
125 const before = tool === 'Write' && path !== undefined ? await $.fs.read(`${place.root}/${path}`).catch(() => undefined) : undefined
126 const isNew = tool === 'Write' && path !== undefined && before === undefined
127 ? (await $.fs.stat(`${place.root}/${path}`).catch(() => undefined)) === undefined
128 : false
129
130 const ran = await next(e)
131 if (ran.deny !== undefined) return ran
132 const at = await $.clock.now()
133
134 if (tool === 'Read' && path !== undefined && ran.isError !== true) {
135 await mark($, all => ({ ...all, [path]: withRead(all[path], at) }), path)
136 } else if (isEdit && path !== undefined && ran.isError !== true) {
137 const change =
138 tool === 'Edit'
139 ? editChange(String(input.old_string ?? ''), String(input.new_string ?? ''))
140 : tool === 'Write'
141 ? editChange(typeof before === 'string' ? before : '', String(input.content ?? ''))
142 : editChange('', String(input.new_source ?? ''))
143 await mark($, all => ({ ...all, [path]: withEdit(all[path], { ...change, isNew }, at) }), path)
144 if (isNew) await update($, repo, old => (old === null ? old : { ...old, files: [...old.files, path] }))
145 void findImporters($, path).catch(() => undefined)
146 } else if (tool === 'Bash') {
147 const command = String(input.command ?? '')
148 const known = (word: string, cwd: string) => {
149 const one = inRepo(word, place.root, cwd)
150 return one !== undefined && place.files.has(one) ? one : undefined
151 }
152 const { reads, edits } = bashTouches(command, place.cwd, known)
153 const failed = ran.isError === true ? errorPaths(ran.text ?? '').flatMap(word => known(word, place.cwd) ?? []) : []
154 if (reads.length + edits.length + failed.length > 0) {
155 await mark(
156 $,
157 all => {
158 const marked = { ...all }
159 for (const one of reads) marked[one] = withRead(marked[one], at)
160 for (const one of edits) marked[one] = withEdit(marked[one], { added: 0, removed: 0 }, at)
161 for (const one of failed) marked[one] = withError(marked[one], at)
162 return marked
163 },
164 [...edits, ...reads, ...failed].at(-1),
165 )
166 }
167 if (ran.isError !== true && RESHAPES.test(command)) await loadRepo($).catch(() => undefined)
168 }
169 return ran
170 }).catch(($, e, next) => next(e)) // an observer: a failure here never changes the call
171
172 on('command.run', { command: 'map' }, async $ => {
173 await $.ui.open({ id: PANE, title: TITLE })
174 const known = await read($, repo)
175 const counts = countsOf(await read($, touched), known?.files ?? [], await read($, tracked))
176 return { text: known === null ? `🗺 git リポジトリの外です(編集 ${counts.edited})` : `🗺 探索率 ${counts.percent}% · ${counts.touched}/${counts.total}` }
177 })
178
179 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
180 const { Box, Text } = $.ui.resolve(e)
181 const known = await read($, repo)
182 const all = await read($, touched)
183 const counts = countsOf(all, known?.files ?? [], await read($, tracked))
184 const warnings = warningsOf(all, await read($, importers))
185 const width = Math.max(30, e.props.bodyColumns - 2)
186 const rows = rowsOf(known?.files ?? [], all, await read($, focus), width, await read($, tree))
187 const room = Math.max(3, (e.viewport?.rows ?? e.props.scroll.bodyRows ?? 24) - 5 - Math.min(3, warnings.length))
188 const color = { error: 'red', new: 'green', edit: 'yellow', 'focus-dir': 'cyan' } as Record<string, string>
189
190 return (
191 <Box flexDirection="column">
192 <Text bold>
193 {known === null
194 ? `🗺 リポジトリの外 · 編 ${counts.edited}`
195 : `🗺 探索率 ${counts.percent}% ${bar(counts.percent, 20)} 読 ${counts.read} · 編 ${counts.edited} · ✗ ${counts.errors} / ${counts.total}`}
196 </Text>
197 {known !== null && <Text dimColor>{`${known.name} · ${known.branch}`}</Text>}
198 {rows.length === 0 && <Text dimColor>まだどこにも踏み込んでいません。Claude がファイルを読むと霧が晴れていきます。</Text>}
199 {rows.slice(0, room).map((row, index) => (
200 <Text key={`row-${index}`} wrap="truncate-end" color={color[row.kind]} dimColor={row.kind === 'fog'} bold={row.kind === 'dir' || row.kind === 'focus-dir'}>
201 {`${' '.repeat(row.depth)}${row.text}`}
202 </Text>
203 ))}
204 {rows.length > room && <Text dimColor>{`…ほか ${rows.length - room} 行`}</Text>}
205 {warnings.slice(0, 3).map((note, index) => (
206 <Text key={`warn-${index}`} color="yellow" wrap="truncate-end">{`⚠ ${note}`}</Text>
207 ))}
208 {warnings.length > 3 && <Text dimColor>{`⚠ ほか ${warnings.length - 3} 件`}</Text>}
209 <Text dimColor>░ 未踏 ○ 読んだ ● 編集 ✚ 新規 ✗ エラー ★ 直前</Text>
210 </Box>
211 )
212 })
213}
214hooks/fog.ts 374 lines1// The map, pure: where a tool call landed in the repository, what it did there, and the
2// tree drawn from it — fog where nothing was touched, marks where something was.
3
4import type { FogFile } from '../types'
5
6export const MILESTONES = [25, 50, 75, 100]
7/** Programs whose file arguments a Bash command reads. */
8const READERS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'nl', 'grep', 'rg', 'awk', 'wc', 'diff', 'cmp', 'jq'])
9
10export const blank = (at: number): FogFile => ({ reads: 0, edits: 0, added: 0, removed: 0, errors: 0, isNew: false, at })
11
12/** A path as the repository names it, or undefined when it lies outside. */
13export function inRepo(path: string, root: string, cwd: string): string | undefined {
14 const full = path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`
15 const parts: string[] = []
16 for (const part of full.split('/')) {
17 if (part === '' || part === '.') continue
18 if (part === '..') parts.pop()
19 else parts.push(part)
20 }
21 const clean = `/${parts.join('/')}`
22 const base = root.replace(/\/+$/, '')
23 return clean.startsWith(`${base}/`) ? clean.slice(base.length + 1) : undefined
24}
25
26export function withRead(file: FogFile | undefined, at: number): FogFile {
27 const one = file ?? blank(at)
28 return { ...one, reads: one.reads + 1, at }
29}
30
31/** An edit's mark: its lines added and removed, and the errors it answers cleared. */
32export function withEdit(file: FogFile | undefined, change: { added: number; removed: number; isNew?: boolean }, at: number): FogFile {
33 const one = file ?? blank(at)
34 return {
35 ...one,
36 edits: one.edits + 1,
37 added: one.added + change.added,
38 removed: one.removed + change.removed,
39 errors: 0,
40 isNew: one.isNew || change.isNew === true,
41 at,
42 }
43}
44
45export function withError(file: FogFile | undefined, at: number): FogFile {
46 const one = file ?? blank(at)
47 return { ...one, errors: one.errors + 1, at }
48}
49
50/**
51 * The lines a change adds and removes, as a diff stat counts them: lines of the new text
52 * not matched by an equal line of the old, and the old ones left unmatched.
53 */
54export function editChange(oldText: string, newText: string): { added: number; removed: number } {
55 const left = new Map<string, number>()
56 for (const line of oldText === '' ? [] : oldText.split('\n')) left.set(line, (left.get(line) ?? 0) + 1)
57 let added = 0
58 for (const line of newText === '' ? [] : newText.split('\n')) {
59 const count = left.get(line) ?? 0
60 if (count > 0) left.set(line, count - 1)
61 else added += 1
62 }
63 const removed = [...left.values()].reduce((sum, n) => sum + n, 0)
64 return { added, removed }
65}
66
67/** The words of a Bash command, quotes taken off; a best-effort split, not a shell. */
68const words = (part: string): string[] => part.trim().split(/\s+/).map(word => word.replace(/^['"]|['"]$/g, ''))
69
70/**
71 * What a Bash command reads and edits among the given paths: `cat a`, `sed -n 1,9p a`,
72 * `sed -i … a`. A `cd` moves where the parts after it resolve (`cd mods && cat a.ts`);
73 * one into a place it cannot follow (`cd -`, `cd $DIR`) stops it reading further parts.
74 */
75export function bashTouches(
76 command: string,
77 cwd: string,
78 isKnown: (word: string, cwd: string) => string | undefined,
79): { reads: string[]; edits: string[] } {
80 const reads = new Set<string>()
81 const edits = new Set<string>()
82 let here: string | undefined = cwd
83 for (const part of command.split(/&&|\|\||;|\||\n/)) {
84 const [program = '', ...rest] = words(part)
85 if (program === 'cd') {
86 const to = rest[0]
87 here = to === undefined || to === '-' || to.startsWith('~') || to.includes('$') || here === undefined
88 ? undefined
89 : to.startsWith('/') ? to : `${here.replace(/\/+$/, '')}/${to}`
90 continue
91 }
92 const name = program.slice(program.lastIndexOf('/') + 1)
93 if (here === undefined || !READERS.has(name)) continue
94 const isInPlace = name === 'sed' && rest.some(word => /^-[a-zA-Z]*i/.test(word) || word === '--in-place')
95 for (const word of rest) {
96 if (word.startsWith('-')) continue
97 const path = isKnown(word, here)
98 if (path !== undefined) (isInPlace ? edits : reads).add(path)
99 }
100 }
101 return { reads: [...reads], edits: [...edits] }
102}
103
104const PATH_WORD = /[\w.@~+/-]+/g
105const LINE_MARK = /:\d+|\(\d+,\d+\)/y
106const EXTENSION = /^[A-Za-z]\w*$/
107
108/**
109 * Paths a failed command's output names with a line (`a.ts:12`, `a.ts(12,5)`), as candidates.
110 * Linear: each word is read once and only its last dot is tried, the dot a path's extension
111 * must follow; a regex trying every dot of a long word took seconds on a minified line.
112 */
113export function errorPaths(output: string): string[] {
114 const found = new Set<string>()
115 let taken = 0 // where the last mark ended: a path never starts inside one (`a.ts:12.b.ts:3`)
116 for (const word of output.matchAll(PATH_WORD)) {
117 const end = word.index + word[0].length
118 LINE_MARK.lastIndex = end
119 const mark = LINE_MARK.exec(output)
120 if (mark === null) continue
121 const path = output.slice(Math.max(word.index, taken), end)
122 const dot = path.lastIndexOf('.')
123 if (dot < 1 || !EXTENSION.test(path.slice(dot + 1))) continue
124 found.add(path)
125 taken = end + mark[0].length
126 }
127 return [...found]
128}
129
130/**
131 * The `git grep -E` pattern for a file's importers, or undefined for a language it does not
132 * read. POSIX classes only: the git macOS ships matches nothing for `\s`, `\w` or `\b` there.
133 */
134export function importPattern(path: string): string | undefined {
135 const name = path.slice(path.lastIndexOf('/') + 1)
136 const dot = name.lastIndexOf('.')
137 const ext = dot > 0 ? name.slice(dot + 1) : ''
138 const stem = (dot > 0 ? name.slice(0, dot) : name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
139 if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) {
140 return String.raw`(from|import|require\()[[:space:]]*\(?['"][^'"]*[/.]${stem}(\.[a-z]+)?['"]`
141 }
142 if (ext === 'py') {
143 // `from pkg.x import y`, `from .x import y`, `import pkg.x`, `import x as y`, `import a, x`.
144 const tail = String.raw`([[:space:],]|$)`
145 return String.raw`^[[:space:]]*(from[[:space:]]+([[:alnum:]_.]*\.)?${stem}${tail}|import[[:space:]]+([[:alnum:]_.,[:space:]]*[[:space:],.])?${stem}${tail})`
146 }
147 return undefined
148}
149
150export type Counts = { read: number; edited: number; errors: number; touched: number; total: number; percent: number }
151
152/** `tracked` is `new Set(files)` from a cache, when there is one: the list is not read again. */
153export function countsOf(touched: Readonly<Record<string, FogFile>>, files: readonly string[], tracked: ReadonlySet<string> = new Set(files)): Counts {
154 const all = Object.entries(touched)
155 const inside = all.filter(([path]) => tracked.has(path) || touched[path]?.isNew === true)
156 const total = tracked.size + all.filter(([path, one]) => one.isNew && !tracked.has(path)).length
157 return {
158 read: all.filter(([, one]) => one.reads > 0 && one.edits === 0).length,
159 edited: all.filter(([, one]) => one.edits > 0).length,
160 errors: all.filter(([, one]) => one.errors > 0).length,
161 touched: inside.length,
162 total,
163 percent: total === 0 ? 0 : Math.floor((inside.length / total) * 100),
164 }
165}
166
167/** The milestone a new percentage crosses, if any. */
168export function milestoneOf(previous: number, percent: number): number | undefined {
169 return MILESTONES.filter(one => one > previous && percent >= one).at(-1)
170}
171
172/** The area a path belongs to, announced the first time it is edited: `mods/zukan/`. */
173export function areaOf(path: string): string {
174 const parts = path.split('/')
175 return parts.length >= 3 ? `${parts[0]}/${parts[1]}/` : parts.length === 2 ? `${parts[0]}/` : './'
176}
177
178/** Files that import an edited one and were never opened: what an edit may have broken unseen. */
179export function warningsOf(touched: Readonly<Record<string, FogFile>>, importers: Readonly<Record<string, string[]>>): string[] {
180 const notes: string[] = []
181 for (const [path, one] of Object.entries(touched)) {
182 if (one.edits > 0 && one.reads === 0 && !one.isNew) notes.push(`編集したのに一度も読んでいない: ${path}`)
183 }
184 const unseen = new Set<string>()
185 for (const [edited, list] of Object.entries(importers)) {
186 if ((touched[edited]?.edits ?? 0) === 0) continue
187 for (const importer of list) {
188 const one = touched[importer]
189 if (one === undefined || (one.reads === 0 && one.edits === 0)) unseen.add(importer)
190 }
191 }
192 for (const path of [...unseen].sort()) notes.push(`編集したファイルを import しているが未読: ${path}`)
193 return notes
194}
195
196export const markOf = (one: FogFile): string => (one.errors > 0 ? '✗' : one.isNew ? '✚' : one.edits > 0 ? '●' : '○')
197
198export function statusOf(counts: Counts, warnings: number, hasRepo: boolean): string {
199 const parts = [hasRepo ? `🗺 ${counts.percent}%` : '🗺', `●${counts.edited}`]
200 if (counts.errors > 0) parts.push(`✗${counts.errors}`)
201 if (warnings > 0) parts.push(`⚠${warnings}`)
202 return parts.join(' · ')
203}
204
205export function bar(percent: number, width = 24): string {
206 const full = Math.round((Math.min(100, Math.max(0, percent)) / 100) * width)
207 return `${'█'.repeat(full)}${'░'.repeat(width - full)}`
208}
209
210/** One row of the drawn tree. */
211export type Row = { depth: number; text: string; kind: 'fog' | 'read' | 'edit' | 'new' | 'error' | 'dir' | 'focus-dir' }
212
213export type Node = { name: string; dirs: Map<string, Node>; files: string[] }
214
215/** The paths as a tree of folders, each name once. */
216export function treeOf(paths: Iterable<string>): Node {
217 const root: Node = { name: '', dirs: new Map(), files: [] }
218 for (const path of new Set(paths)) {
219 const parts = path.split('/')
220 let node = root
221 for (const part of parts.slice(0, -1)) {
222 let next = node.dirs.get(part)
223 if (next === undefined) {
224 next = { name: part, dirs: new Map(), files: [] }
225 node.dirs.set(part, next)
226 }
227 node = next
228 }
229 node.files.push(parts.at(-1) ?? path)
230 }
231 return root
232}
233
234function hasPath(tree: Node, path: string): boolean {
235 const parts = path.split('/')
236 let node: Node | undefined = tree
237 for (const part of parts.slice(0, -1)) node = node?.dirs.get(part)
238 return node !== undefined && node.files.includes(parts.at(-1) ?? path)
239}
240
241/**
242 * `base` with `paths` it lacks added, as treeOf would have placed them after its own. The
243 * folders on their way are copied, so `base`, kept in a cache, is never changed.
244 */
245function withPaths(base: Node, paths: readonly string[]): Node {
246 const root: Node = { ...base, dirs: new Map(base.dirs), files: [...base.files] }
247 const copied = new Set<Node>([root])
248 for (const path of paths) {
249 const parts = path.split('/')
250 let node = root
251 for (const part of parts.slice(0, -1)) {
252 const found = node.dirs.get(part)
253 let next: Node
254 if (found === undefined) next = { name: part, dirs: new Map(), files: [] }
255 else if (copied.has(found)) next = found
256 else next = { ...found, dirs: new Map(found.dirs), files: [...found.files] }
257 copied.add(next)
258 node.dirs.set(part, next)
259 node = next
260 }
261 const name = parts.at(-1) ?? path
262 if (!node.files.includes(name)) node.files.push(name)
263 }
264 return root
265}
266
267const signed = (one: FogFile): string => (one.edits > 0 ? `+${one.added} −${one.removed}` : '')
268
269/**
270 * The tree as rows: a directory holding an edit, a new file or an error is open; one only
271 * read is closed with its marks beside it; untouched directories and files at a level are
272 * one fog row. The ★ goes on the file touched last.
273 */
274export function rowsOf(
275 files: readonly string[],
276 touched: Readonly<Record<string, FogFile>>,
277 focus: string | null,
278 width: number,
279 tree?: Node,
280): Row[] {
281 const rows: Row[] = []
282 // `tree` is treeOf(files) from a cache: only the touched paths it lacks are added, on a copy.
283 const root = tree === undefined ? treeOf([...files, ...Object.keys(touched)]) : withPaths(tree, Object.keys(touched).filter(path => !hasPath(tree, path)))
284
285 // Each folder's marks are gathered once: an open folder's are not gathered again for each level
286 // above it, and a folder holding no touched path is never read through.
287 const holding = new Set<string>()
288 for (const path of Object.keys(touched)) {
289 for (let at = path.indexOf('/'); at >= 0; at = path.indexOf('/', at + 1)) holding.add(path.slice(0, at + 1))
290 }
291 const gathered = new Map<Node, FogFile[]>()
292 const marksUnder = (node: Node, prefix: string): FogFile[] => {
293 if (prefix !== '' && !holding.has(prefix)) return []
294 const known = gathered.get(node)
295 if (known !== undefined) return known
296 const marks = [
297 ...node.files.flatMap(name => (touched[`${prefix}${name}`] ? [touched[`${prefix}${name}`] as FogFile] : [])),
298 ...[...node.dirs.values()].flatMap(dir => marksUnder(dir, `${prefix}${dir.name}/`)),
299 ]
300 gathered.set(node, marks)
301 return marks
302 }
303
304 const walk = (node: Node, prefix: string, depth: number) => {
305 const fog: string[] = []
306 for (const dir of [...node.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
307 const path = `${prefix}${dir.name}/`
308 const marks = marksUnder(dir, path)
309 if (marks.length === 0) {
310 fog.push(`${dir.name}/`)
311 continue
312 }
313 const isOpen = marks.some(one => one.edits > 0 || one.errors > 0 || one.isNew)
314 const hasFocus = focus !== null && focus.startsWith(path)
315 if (!isOpen) {
316 rows.push({ depth, text: `▸ ${dir.name}/ ${marks.map(markOf).join('')}`, kind: hasFocus ? 'focus-dir' : 'dir' })
317 continue
318 }
319 rows.push({ depth, text: `▾ ${dir.name}/`, kind: hasFocus ? 'focus-dir' : 'dir' })
320 walk(dir, path, depth + 1)
321 }
322 for (const name of [...node.files].sort()) {
323 const path = `${prefix}${name}`
324 const one = touched[path]
325 if (one === undefined) {
326 fog.push(name)
327 continue
328 }
329 const details = [
330 signed(one),
331 one.edits > 1 ? `編集 ${one.edits}回` : '',
332 one.isNew ? '新規' : '',
333 one.errors > 0 ? `エラー ${one.errors}` : '',
334 path === focus ? '★' : '',
335 ].filter(Boolean)
336 const kind = one.errors > 0 ? 'error' : one.isNew ? 'new' : one.edits > 0 ? 'edit' : 'read'
337 rows.push({ depth, text: `${markOf(one)} ${name}${details.length > 0 ? ` ${details.join(' ')}` : ''}`, kind })
338 }
339 if (fog.length > 0) {
340 const room = Math.max(10, width - depth * 2)
341 let line = ''
342 let shown = 0
343 for (const name of fog) {
344 const next = `${line}${line === '' ? '' : ' '}░ ${name}`
345 if (next.length > room - 6 && line !== '') break
346 line = next
347 shown += 1
348 }
349 rows.push({ depth, text: shown < fog.length ? `${line} …${fog.length - shown}` : line, kind: 'fog' })
350 }
351 }
352
353 walk(root, '', 0)
354 return rows
355}
356
357// The repository's files are kept in one state value, which the engine holds to 4,194,304
358// characters of JSON: past this the list is cut, so the write is never refused and the
359// repository never taken for none.
360export const MAX_FILES_CHARS = 3_000_000
361
362/** The files a state value can hold, in the order git lists them. */
363export function capFiles(files: readonly string[]): string[] {
364 const kept: string[] = []
365 let size = 2
366 for (const file of files) {
367 const next = JSON.stringify(file).length + 1
368 if (size + next > MAX_FILES_CHARS) break
369 size += next
370 kept.push(file)
371 }
372 return kept
373}
374types/index.d.ts 43 lines1/** What the session did to one file of the repository. */
2export type FogFile = {
3 /** Times it was read (Read, or a reader such as cat or sed in Bash). */
4 reads: number
5 /** Times it was edited (Edit, Write, NotebookEdit, sed -i). */
6 edits: number
7 /** Lines added and removed by those edits, as the edits' own text counts them. */
8 added: number
9 removed: number
10 /** Errors a failed Bash command named it in since it was last edited. */
11 errors: number
12 /** Made by this session: it did not exist before the first write. */
13 isNew: boolean
14 /** When it was last touched. */
15 at: number
16}
17
18/** The repository the map is of. */
19export type FogRepo = {
20 root: string
21 name: string
22 branch: string
23 /** Its files, repository-relative, as `git ls-files` lists them. */
24 files: string[]
25}
26
27declare module 'claude-code' {
28 interface PluginState {
29 'fog-map': {
30 repo: FogRepo | null
31 /** Repository-relative path to what the session did to it. */
32 touched: Record<string, FogFile>
33 /** The file touched last: the map's ★. */
34 focus: string | null
35 /** Edited file to the files that import it, as git grep found them. */
36 importers: Record<string, string[]>
37 /** Areas already announced, and the highest milestone reached (percent). */
38 areas: string[]
39 milestone: number
40 }
41 }
42}
43