After each commit this session makes, reads the diff and updates README.md (or the docs you list) with small, safe edits in a follow-up docs commit.

Three function-hook mods for Claude Code:
? button that asks Haiku what you should ask next.Every picture below was rendered from the element trees these mods really return, recorded with the Claude Code plugin test kit. They show what you get, not a mock-up. Try the interactive demo →
Real recording (Claude Code 2.1.295 desktop app, Windows 11): click ?, Haiku writes five questions about this conversation, tick three, and insert them into the prompt box together.
<img src="docs/img/demo-real.gif" alt="Screen recording: clicking ? on the token-ledger row, five Haiku questions appear in a pane, three are ticked and inserted into the prompt box" width="100%">
Rendered from the mod's own draw output:
<img src="docs/img/demo-questions.svg" alt="Animated demo: click ?, tick two of Haiku's questions, and insert them into the prompt box" width="100%">
| Mod | What you get | Commands |
|---|---|---|
| token-ledger | A telemetry row above the prompt (5-hour %, burn rate, reset, time left, weekly %, context %, cache hit %, model, effort). A ? button for five Haiku questions. A /ledger pane. | /ask, /ledger |
| next-steps | 1-3 suggested next prompts after each real turn, in a pane of their own. | /next, /next off, /next on |
| auto-docs | After each commit, a follow-up docs: sync with <sha> commit that updates README.md, or the docs you list. | /docs, /docs <commit> |
Both panes are multi-select. Tick as many rows as you like by clicking or pressing 1-5. Use Select all (a) or Clear (c), then Insert (i). The ticked text goes into the prompt box in the order you ticked it, one blank line apart, and is never sent for you. Anything you had already typed stays.
<img src="docs/img/telemetry-row.svg" alt="token-ledger telemetry row: 34% 5h limit, +1.5/min, 04:30 PM reset, 2h 16m left, 58% weekly, 41% context, 96% cache hit, Opus 5.5, High, and the ? button" width="100%">
From left to right:
Every figure comes from the engine's own readings. A metric with no reading is left out, never faked. On a narrow window, secondary metrics drop out so the row stays on one line. It shows percentages, never money. The row appears after the session's first reply, because that's when the first limit figures arrive.
?: questions worth askingClick ? at the end of the row, or run /ask. Haiku 5.5 reads the project (folder, git branch, status and recent commits, CLAUDE.md), Claude's visible reasoning, and the chat. It writes five specific questions you haven't explored yet, which open in their own pane, so the row above the prompt never moves.
<img src="docs/img/questions-pane.svg" alt="Questions worth asking pane with two questions ticked and the Insert 2 questions button" width="560">
/ledger<img src="docs/img/ledger-pane.svg" alt="Token ledger pane: limits, this session, subagents, last 7 days, cache rewrites after idle, recent turns" width="600">
The pane shows:
Settings (/config, or pluginConfigs in settings.json):
| Setting | Default | What it does |
|---|---|---|
agentAlertPercent | 10 | Shows a toast when one subagent has used about this much of your 5-hour limit. 0 turns it off. |
animations | on | Off draws a still row. |
toastOnCacheMiss | on | Shows a toast when a turn after an idle gap rewrote the prompt cache. |
After a real turn (a long answer, or one that used 3 or more tools), Haiku suggests 1-3 prompts you could send next. They open in their own pane.
<img src="docs/img/demo-next-steps.svg" alt="Animated demo: the next-steps pane, two steps ticked and inserted into the prompt box" width="100%">
<img src="docs/img/next-steps-pane.svg" alt="Next steps pane with two of three steps ticked" width="560">
Before anything is sent to Haiku, these are replaced with stable tokens:
The scrubbing covers file and project names too.
| Setting | Default | What it does |
|---|---|---|
model | haiku | haiku or sonnet. |
minAnswerChars | 300 | Skips suggestions after shorter answers, unless the turn made 3 or more tool calls. |
When the session runs a git commit, auto-docs reads the diff and asks the model for the smallest edits that make your docs true again. It applies them as exact find-and-replace edits, so it never rewrites a whole file, and commits them separately.
<img src="docs/img/demo-auto-docs.svg" alt="Animated demo: a feat commit, the auto-docs status line, its toast, and the follow-up docs commit in git log" width="100%">
[skip docs], docs: commits, and commits that only touch documentation./docs or /docs <commit>.| Setting | Default | What it does |
|---|---|---|
docs | README.md | Comma-separated paths from the repository root. |
mode | commit | commit makes a separate docs commit. stage leaves the changes staged for you to review. |
model | sonnet | The model that reads the diff. |
maxDiffChars | 60000 | Larger diffs are cut down by whole files. |
These are function-hook plugins (a hooks/register.tsx module), so you need a Claude Code build that supports them. They were built and tested on 2.1.29x.
From the marketplace (in a Claude Code session):
/plugin marketplace add bully911210/claude-code-mods
/plugin install token-ledger@claude-code-mods
/plugin install next-steps@claude-code-mods
/plugin install auto-docs@claude-code-mods
Or from a clone. Point CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json at the folders you want (use ; between them on Windows, : elsewhere):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/claude-code-mods/token-ledger:/path/to/claude-code-mods/next-steps:/path/to/claude-code-mods/auto-docs"
}
}
Plugin folders load when a session starts, so fully quit and reopen Claude after installing or changing them.
claude plugin validate token-ledger
claude plugin test token-ledger
Each mod has its own tests/. To redraw the README pictures after changing a view:
docs/capture/<mod>.capture.ts into that mod's tests/ folder.claude plugin test <mod>, and save the lines that start with CAPTURE (without that prefix) to docs/capture/<mod>.jsonl.python docs/tools/render.py.The interactive demo (docs/index.html) reads the same JSONL files.
hooks/register.ts 153 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 applyEdits,
5 buildPrompt,
6 changedDocs,
7 isCommitCommand,
8 parseDocList,
9 parseEdits,
10 skipReason,
11 SYSTEM,
12 trimDiff,
13} from './lib/docs'
14import type { DocFile } from './lib/docs'
15
16/** Lock files and generated bundles say nothing the docs need. */
17const NOISE = [':(exclude)*.lock', ':(exclude)package-lock.json', ':(exclude)pnpm-lock.yaml', ':(exclude)*.min.js', ':(exclude)dist/**']
18
19type Config = {
20 readonly docs: readonly string[]
21 readonly mode: 'commit' | 'stage'
22 readonly model: string
23 readonly maxDiffChars: number
24}
25
26const configOf = (options: Readonly<Record<string, unknown>>): Config => {
27 const docs = parseDocList(typeof options.docs === 'string' ? options.docs : 'README.md')
28 return {
29 docs: docs.length ? docs : ['README.md'],
30 mode: options.mode === 'stage' ? 'stage' : 'commit',
31 model: typeof options.model === 'string' && options.model !== '' ? options.model : 'sonnet',
32 maxDiffChars: typeof options.maxDiffChars === 'number' && options.maxDiffChars > 1000 ? options.maxDiffChars : 60_000,
33 }
34}
35
36/** Runs git and returns its output, or throws with git's own message. */
37async function git($: EngineInterface, args: readonly string[], cwd?: string): Promise<string> {
38 const r = await $.process.run(['git', ...args], { ...(cwd ? { cwd } : {}), timeoutMs: 60_000 })
39 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `git ${args[0]} failed`)
40 return r.stdout
41}
42
43async function readDocs($: EngineInterface, root: string, paths: readonly string[]): Promise<DocFile[]> {
44 const found = await Promise.all(
45 paths.map(async path => {
46 const text = await $.fs.read(`${root}/${path}`).catch(() => null)
47 return typeof text === 'string' ? { path, text } : null
48 }),
49 )
50 return found.filter((d): d is DocFile => d !== null)
51}
52
53/** Brings the docs in line with one commit. Resolves a one-line summary of what it did. */
54async function syncDocs($: EngineInterface, cfg: Config, rev: string): Promise<string> {
55 const root = (await git($, ['rev-parse', '--show-toplevel']).catch(() => '')).trim()
56 if (root === '') return 'auto-docs: not inside a git repository.'
57 const sha = (await git($, ['rev-parse', rev], root)).trim()
58 const short = sha.slice(0, 7)
59 const doneKey = `done:${root.toLowerCase()}`
60 if ((await $.store.get(doneKey)) === sha) return `auto-docs: ${short} is already documented.`
61
62 const message = await git($, ['log', '-1', '--format=%B', sha], root)
63 const changed = (await git($, ['show', '--name-only', '--format=', sha], root)).split('\n').map(s => s.trim()).filter(Boolean)
64 const skip = skipReason({ message, changed, docs: cfg.docs })
65 if (skip) return `auto-docs: skipped ${short} because ${skip}.`
66
67 const docs = await readDocs($, root, cfg.docs)
68 if (docs.length === 0) return `auto-docs: none of ${cfg.docs.join(', ')} exist in this repository.`
69
70 const stat = await git($, ['show', '--stat', '--format=', sha], root)
71 const diff = trimDiff(await git($, ['show', '--format=', '--unified=3', '--no-color', sha, '--', '.', ...NOISE], root), cfg.maxDiffChars)
72 $.ui.status(`auto-docs: reading ${short}…`)
73 const reply = await $.model.complete({
74 model: cfg.model,
75 system: SYSTEM,
76 prompt: buildPrompt({ sha: short, message, stat, diff, docs }),
77 maxTokens: 8_000,
78 timeoutMs: 180_000,
79 })
80 if (!reply.isAnswered) return `auto-docs: the model gave no answer for ${short} (${reply.reason}).`
81
82 const result = applyEdits(docs, parseEdits(reply.text))
83 const touched = changedDocs(docs, result.files)
84 const rejectedNote = result.rejected.length ? ` (${result.rejected.length} edit(s) skipped: text not found exactly once)` : ''
85 await $.store.set(doneKey, sha)
86 if (touched.length === 0) return `auto-docs: ${short} needs no documentation changes${rejectedNote}.`
87
88 for (const doc of touched) await $.fs.write(`${root}/${doc.path}`, doc.text)
89 const names = touched.map(d => d.path)
90 await git($, ['add', '--', ...names], root)
91 if (cfg.mode === 'stage') return `auto-docs: updated ${names.join(', ')} for ${short} and staged it for you to review${rejectedNote}.`
92
93 await git($, ['commit', '-m', `docs: sync with ${short}`, '-m', `Updated by auto-docs from commit ${sha}.`, '--', ...names], root)
94 const docSha = (await git($, ['rev-parse', 'HEAD'], root)).trim()
95 await $.store.set(doneKey, docSha)
96 return `auto-docs: updated ${names.join(', ')} for ${short} in commit ${docSha.slice(0, 7)}${rejectedNote}.`
97}
98
99/** Runs one sync with the status line and a toast around it; never throws. */
100async function runAndReport($: EngineInterface, cfg: Config, rev: string): Promise<string> {
101 try {
102 const summary = await syncDocs($, cfg, rev)
103 $.ui.toast(summary, { timeoutMs: 8_000 })
104 return summary
105 } catch (err) {
106 const summary = `auto-docs: could not update the docs: ${err instanceof Error ? err.message : String(err)}`
107 $.ui.toast(summary, { timeoutMs: 10_000 })
108 return summary
109 } finally {
110 $.ui.status(undefined)
111 }
112}
113
114export const register: Register = (on, options) => {
115 const cfg = configOf(options)
116 // One sync at a time; a commit made while one runs is picked up after.
117 let isRunning = false
118
119 on('session.start', async ($, e, next) => {
120 await $.command.register({
121 name: 'docs',
122 description: 'auto-docs: update the README for the last commit (or a given one)',
123 argumentHint: '[commit]',
124 })
125 return next(e)
126 })
127
128 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
129 const ran = await next(e)
130 const isCommit = ran.deny === undefined && ran.isError !== true && isCommitCommand(e.command)
131 if (isCommit && !isRunning) {
132 isRunning = true
133 // After the commit, off the tool call's path: the commit is never slowed down.
134 $.clock.after(0, () => {
135 void runAndReport($, cfg, 'HEAD').finally(() => {
136 isRunning = false
137 })
138 })
139 }
140 return ran
141 }).catch(($, e, next) => next(e))
142
143 on('command.run', { command: 'docs' }, async ($, e) => {
144 if (isRunning) return { text: 'auto-docs: a sync is already running.' }
145 isRunning = true
146 try {
147 return { text: await runAndReport($, cfg, e.args.trim() || 'HEAD') }
148 } finally {
149 isRunning = false
150 }
151 })
152}
153hooks/lib/docs.ts 132 lines1// Pure logic for auto-docs: spotting a commit, deciding whether to act,
2// building the prompt, and applying the model's edits safely. No engine calls.
3
4export type DocFile = { readonly path: string; readonly text: string }
5export type Edit = { readonly file: string; readonly find: string; readonly replace: string }
6export type Applied = {
7 readonly files: readonly DocFile[]
8 readonly applied: number
9 readonly rejected: readonly { readonly edit: Edit; readonly reason: string }[]
10}
11
12/** A shell command that makes a commit: `git commit`, also inside `git add -A && git commit -m ...`. */
13export const isCommitCommand = (command: string): boolean =>
14 /(^|[;&|(]\s*|\s)git(\s+-[cC]\s+\S+)*\s+commit\b/.test(command) && !/\s--dry-run\b/.test(command)
15
16const norm = (p: string): string => p.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase()
17
18/** Why to leave a commit alone, or null to go ahead. */
19export const skipReason = (args: {
20 readonly message: string
21 readonly changed: readonly string[]
22 readonly docs: readonly string[]
23}): string | null => {
24 if (/\[skip docs\]/i.test(args.message)) return 'the commit says [skip docs]'
25 if (/^docs(\(.*\))?:/i.test(args.message.trim())) return 'it is a docs commit'
26 if (args.changed.length === 0) return 'it changed no files'
27 const docs = new Set(args.docs.map(norm))
28 const isDocOnly = args.changed.every(f => docs.has(norm(f)) || /\.(md|mdx|txt|rst)$/i.test(f))
29 return isDocOnly ? 'it only changed documentation' : null
30}
31
32/** Keeps the diff within budget, cutting whole file sections and saying what was left out. */
33export const trimDiff = (diff: string, maxChars: number): string => {
34 if (diff.length <= maxChars) return diff
35 const sections = diff.split(/(?=^diff --git )/m)
36 const kept: string[] = []
37 let size = 0
38 for (const section of sections) {
39 if (size + section.length > maxChars) break
40 kept.push(section)
41 size += section.length
42 }
43 const dropped = sections.length - kept.length
44 return `${kept.join('')}\n[auto-docs: ${dropped} more file diff(s) left out to stay within budget]\n`
45}
46
47export const SYSTEM = [
48 'You keep a software project\'s documentation in step with its code.',
49 'You are given one commit (message, file list and diff) and the current text of the documentation files.',
50 'Propose the smallest edits that make the documentation true again: fix statements the commit made wrong,',
51 'and add a short mention of new commands, options, endpoints or behaviour users need to know about.',
52 'Do not restyle, reorder or rewrite text the commit did not affect. Match the existing tone and format.',
53 'Never invent features that are not in the diff. If nothing needs to change, return no edits.',
54 'Reply with JSON only: {"edits":[{"file":"README.md","find":"exact text currently in the file","replace":"new text"}]}.',
55 '"find" must be copied exactly from the file and appear in it exactly once; keep it short but unique.',
56 'To add a new section, use the last line of the section it should follow as "find" and repeat that line at the start of "replace".',
57].join(' ')
58
59export const buildPrompt = (args: {
60 readonly sha: string
61 readonly message: string
62 readonly stat: string
63 readonly diff: string
64 readonly docs: readonly DocFile[]
65}): string =>
66 [
67 `Commit ${args.sha}: ${args.message.trim()}`,
68 '',
69 'Files changed:',
70 args.stat.trim(),
71 '',
72 'Diff:',
73 args.diff,
74 '',
75 ...args.docs.flatMap(d => [`=== ${d.path} (current) ===`, d.text, `=== end of ${d.path} ===`, '']),
76 ].join('\n')
77
78/** Reads the model's reply as edits; anything malformed is dropped, never guessed at. */
79export const parseEdits = (reply: string): Edit[] => {
80 const match = reply.match(/\{[\s\S]*\}/)
81 if (!match) return []
82 try {
83 const parsed = JSON.parse(match[0]) as { edits?: unknown }
84 if (!Array.isArray(parsed.edits)) return []
85 return parsed.edits.filter(
86 (x): x is Edit =>
87 typeof x === 'object' &&
88 x !== null &&
89 typeof (x as Edit).file === 'string' &&
90 typeof (x as Edit).find === 'string' &&
91 typeof (x as Edit).replace === 'string' &&
92 (x as Edit).find.length > 0,
93 )
94 } catch {
95 return []
96 }
97}
98
99const countOf = (text: string, part: string): number => text.split(part).length - 1
100
101/**
102 * Applies edits in order, each only when its `find` text appears exactly once
103 * in the file as it stands. Returns new file texts; the inputs are untouched.
104 */
105export const applyEdits = (docs: readonly DocFile[], edits: readonly Edit[]): Applied =>
106 edits.reduce<Applied>(
107 (acc, edit) => {
108 const doc = acc.files.find(d => norm(d.path) === norm(edit.file))
109 if (!doc) return { ...acc, rejected: [...acc.rejected, { edit, reason: 'not one of the documentation files' }] }
110 const n = countOf(doc.text, edit.find)
111 if (n !== 1) return { ...acc, rejected: [...acc.rejected, { edit, reason: n === 0 ? 'text not found' : 'text not unique' }] }
112 if (edit.find === edit.replace) return acc
113 const text = doc.text.replace(edit.find, () => edit.replace)
114 return {
115 files: acc.files.map(d => (d === doc ? { ...d, text } : d)),
116 applied: acc.applied + 1,
117 rejected: acc.rejected,
118 }
119 },
120 { files: [...docs], applied: 0, rejected: [] },
121 )
122
123/** The files whose text changed. */
124export const changedDocs = (before: readonly DocFile[], after: readonly DocFile[]): DocFile[] =>
125 after.filter(d => before.find(b => b.path === d.path)?.text !== d.text)
126
127export const parseDocList = (value: string): string[] =>
128 value
129 .split(',')
130 .map(s => s.trim().replace(/\\/g, '/'))
131 .filter(s => s.length > 0 && !s.includes('..') && !/^([A-Za-z]:|\/)/.test(s))
132