SLOPSHOPPER

auto-docs

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.

newguardcommandtoaststatusmodel
★ 1v0.1.0MITupdated 2026-10-13bully911210/claude-code-mods/auto-docs
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-docs
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ auto-docs │ ⏺ Read(src/auth.ts) │ auto-docs: skipped a1b2c3d because it │ ⎿ Read 6 lines │ changed no files. │ ⏺ 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 › /docs ⎿ auto-docs: auto-docs: skipped a1b2c3d because it changed no files. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Claude Code mods

ci licence: MIT

Three function-hook mods for Claude Code:

  • token-ledger puts your plan limits in one row above the prompt and adds a ? button that asks Haiku what you should ask next.
  • next-steps suggests your next prompts after each turn.
  • auto-docs keeps your README in step with every commit.

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%">

ModWhat you getCommands
token-ledgerA 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-steps1-3 suggested next prompts after each real turn, in a pane of their own./next, /next off, /next on
auto-docsAfter 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.


token-ledger

The telemetry row

<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:

  • 5-hour limit % (orange)
  • burn rate
  • when the 5-hour window resets
  • time left
  • weekly limit % (blue)
  • context window % (purple)
  • cache-hit % (green)
  • model
  • effort

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 asking

Click ? 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:

  • your limits
  • this session's tokens and cache-hit rate
  • each subagent's share of the session
  • the last 7 days
  • cache rewrites after idle: turns where the prompt cache expired and had to be rewritten

Settings (/config, or pluginConfigs in settings.json):

SettingDefaultWhat it does
agentAlertPercent10Shows a toast when one subagent has used about this much of your 5-hour limit. 0 turns it off.
animationsonOff draws a still row.
toastOnCacheMissonShows a toast when a turn after an idle gap rewrote the prompt cache.

next-steps

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:

  • ID numbers
  • phone numbers
  • email addresses
  • card numbers

The scrubbing covers file and project names too.

SettingDefaultWhat it does
modelhaikuhaiku or sonnet.
minAnswerChars300Skips suggestions after shorter answers, unless the turn made 3 or more tool calls.

auto-docs

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%">

  • An edit is applied only when the text it replaces appears exactly once. Any edits it skips are reported.
  • It skips commits marked [skip docs], docs: commits, and commits that only touch documentation.
  • It never pushes.
  • It only sees commits this session makes. For commits made elsewhere, run /docs or /docs <commit>.
SettingDefaultWhat it does
docsREADME.mdComma-separated paths from the repository root.
modecommitcommit makes a separate docs commit. stage leaves the changes staged for you to review.
modelsonnetThe model that reads the diff.
maxDiffChars60000Larger diffs are cut down by whole files.

Install

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.

Develop

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:

  1. Copy docs/capture/<mod>.capture.ts into that mod's tests/ folder.
  2. Run claude plugin test <mod>, and save the lines that start with CAPTURE (without that prefix) to docs/capture/<mod>.jsonl.
  3. Run python docs/tools/render.py.

The interactive demo (docs/index.html) reads the same JSONL files.

More

Licence

MIT

Source 2 files
hooks/register.ts 153 lines
1import 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}
153
hooks/lib/docs.ts 132 lines
1// 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