Keeps a per-turn tally of edits, runs, curls and destructive commands and shows it on the turn footer; a claim with no run flags the spinner, a destructive…

Mods that keep an agent honest — plus a few that make the terminal fun.
claude-code · mod · function-hooks · typescript · macos
usage max volty opus-5 5h 34% 7d 12% ctx 41% 82k $1.23
scope: 3/4 files
▸
<sub>Thirteen mods, each drawing or guarding its own slice of the session. Above: usage-band and scope-guard.</sub>
<sub>usage-band, wod-band and wod-timer in a live session.</sub>
Claude Code will tell you a deploy worked because git push exited 0. It will turn a one-line fix into a nine-file refactor and never mention it. Written rules in CLAUDE.md help until the model forgets them, and you find out on the deploy that breaks.
These are the same rules, moved out of prose and into the engine — where they hold whether or not the model remembers.
A mod is a Claude Code plugin whose behaviour lives in a TypeScript hooks module — register(on, options) wiring handlers onto engine events (tool.call, ui.render, turn.complete) rather than markdown the model reads. A mod can deny a tool call, rewrite it in flight, draw above the prompt, or put evidence in front of the model that it cannot argue with.
Every mod here is source you can read in one sitting. None of them phone home: there is no $.http.fetch anywhere in this repo.
Function hooks are behind a flag. Set it first, in your shell profile or settings.json env:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Then, in Claude Code:
/plugin marketplace add yash-gadodia/claude-mods
/plugin install scope-guard@claude-mods
Install only what you want — each mod is independent. Update with claude plugin update <name>@claude-mods.
| Mod | What it does |
|---|---|
| scope-guard | Counts the distinct files one turn edits. At the threshold it stops and makes the goal get restated, so a small ask cannot quietly become a refactor. /scope sets it. |
| deploy-verify | After a deploy command succeeds, waits for the GitHub Actions run it started, then curls the live URL with cache-busting and puts the verdict in the model's context. A deploy cannot be claimed without evidence. |
| receipt | The turn footer becomes a receipt: edits, runs and curls, with a warning when edits ran nothing. Destructive commands are never folded into a tool group, a claim of "fixed" with no run puts "unverified claim pending" in the spinner, and Tab suggests running the tests. |
| diff-review | A docked pane with each edited file's hunk and keep or revert buttons. Reverting runs git directly; no model turn. |
| merge-gate | Denies gh pr merge, a git merge on trunk, or a push to main unless the latest human message contains the word merge. Ship, push and deploy do not count. /merge-gate toggles it. |
| mini-offload | Rewrites heavy Bash commands (test suites, builds, Docker) to run on a second machine over ssh — syncing the commit there first, because the remote checkout is the real hazard. /mini sets always, ask, or off. |
| Mod | What it does |
|---|---|
| usage-band | The 5-hour and 7-day limit windows, this session's context fill and cost, above the prompt. Nudges you to /clear when the window gets expensive. |
| money-band | Liquid assets, CPF, debt and month-to-date spend, read from a pair of SQLite databases over ssh. Every figure is the database's own; nothing is estimated. |
| copy-band | Click-to-copy buttons above the prompt for every code block and quoted draft in the last answer, plus a durable stash of older ones. Copying runs pbcopy directly — no model turn. |
| done-blink | When a turn lands, the iTerm2 tab blinks orange every half second until you send the next prompt or three minutes pass, so a finished session is obvious from any other tab. Works inside tmux with no passthrough config: the escape goes to the tmux client's tty. /done-blink 60 sets the ceiling. |
| chrome-switch | Switches the Claude in Chrome extension between named browser profiles using select_browser, which needs no approval click. /chromep maps them. |
| Mod | What it does |
|---|---|
| wod-band | A pixel-art athlete above the prompt who does a rep every turn. The session is an AMRAP of thrusters, burpees and pull-ups. |
| wod-timer | 3, 2, 1, GO in the spinner when you submit, a running gym clock while Claude works, and a whiteboard split in the footer when the turn lands: turn, time, AMRAP total, PR. /wod-timer voice on reads long splits aloud. |
Every mod checks one environment variable before doing anything:
CLAUDE_MODS_DISABLE=all # every mod in this repo becomes a pass-through
CLAUDE_MODS_DISABLE=scope-guard # just that one
CLAUDE_MODS_DISABLE=wod-band,wod-timer
A disabled mod registers no command and every hook falls straight through to next(e).
Mods that touch your machine declare their settings in plugin.json userConfig, so they are editable through /config rather than by hand:
/scope <n> sets the file threshold. /scope judge on|off (default on) lets a one-shot Haiku call decide at the threshold whether the next edit is still inside the goal you stated first; a yes raises the ceiling by one for that turn, a no or a failed call falls back to asking. /scope off disables the guard./merge-gate on|off. "merge x3" or "merge after each" in your message grants that many merges.host (ssh alias, default mini), remotePath (the PATH export prefixed to every offloaded command). Per-repo overrides live at <repo>/.claude/mini-offload.json.host, networthDb, financeDb. Expects SQLite databases with accounts/balances and transactions tables. efAccount (default UOB One) and efTarget (default 30000) feed the EF 41% footer label.sgdRate (default 1.30) for the S$ footer label; /usage-band sgd off hides it./receipt on|off|status./done-blink on|off|status|<seconds> (default 180, max 900)./diff-review open|close|on|off.<repo>/.claude/deploy-verify.json: ``json { "url": "https://example.com", "matchFile": "VERSION" } ``~/.claude/chrome-browsers.json, mapping labels to deviceIds.scope-guard and deploy-verify also write a block into the model's own context (prompt.context), replacing their previous copy rather than accumulating:
# deployVerify
Last live deploy check, 2 minutes ago:
VERIFIED live: https://example.com served "v3.10.10"
This is the only evidence about the live site in this session. Do not describe the deploy as
verified unless a line above starts with VERIFIED, and do not re-state an older claim over it.
A band above the prompt is for you. A context block is for the model — and it cannot be talked around. Repeated advisories are hashed and suppressed for a cooldown so this costs context once, not once per tool call; verdicts themselves are never throttled, because a verdict is evidence.
npm install
npm test
npm test typechecks every mod, runs its suite under claude plugin test (the official kit, claude-code/testing, with a mocked clock, store and process table), and checks each mod's footprint: the hooks, $ calls and env reads that claude plugin validate reports, pinned in <mod>/FOOTPRINT. A mod that starts calling $.http.fetch fails the build instead of a README sentence going stale. scripts/footprint.sh --write re-pins after a deliberate change.
The interesting half of deploy-verify's suite is the clean baseline: commands that mention a deploy without being one — echo "git push", grep -r "wrangler deploy", git push --dry-run, a commit message quoting make deploy, a heredoc containing one. A false positive curls a live URL nothing was pushed to and then reports a verdict about it, which is worse than not checking at all.
The ones that survived contact with real sessions:
try/catch and falls back to what was there.next(e) and $ calls are free; $.clock.sleep is not. Past the budget, or on a throw, the engine skips the hook silently unless it declares .catch — so every guard here catches and denies, and slow work belongs on a timer.e.props.hasSurvey means the engine wants that slot; give it back.Claude Code 2.1.271+ with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. macOS — copy-band shells out to pbcopy, and mini-offload/money-band assume ssh and a Homebrew path on the remote.
MIT
hooks/register.ts 171 lines1import type { Register, EngineInterface, ToolGroupCall } from 'claude-code'
2
3// "Verify before claiming." The transcript footer says what the turn actually did (edits, runs,
4// curls), a claim made without a run keeps "unverified claim pending" on the next spinner until
5// something runs, a destructive Bash never folds into a count line, and an edit-without-run turn
6// leaves "run the tests and show the output" as the prompt's ghost text.
7
8const MOD = 'receipt'
9const ENABLED_KEY = 'receipt:enabled'
10const SUGGESTION = 'run the tests and show the output'
11const PENDING = 'unverified claim pending'
12
13const EDIT_TOOLS = ['Edit', 'Write', 'NotebookEdit', 'MultiEdit']
14const READ_TOOLS = ['Read', 'Grep', 'Glob', 'LS']
15const RUNNERS = ['vitest', 'jest', 'pytest', 'mocha', 'tsc']
16const PACKAGE = /^(npm|pnpm|yarn|bun|make)$/
17const WRAPPERS = ['npx', 'bunx', 'sudo']
18const WRITERS = ['tee', 'cp', 'mv', 'touch']
19const QUOTED = '\u0001'
20const REDIRECT = /(?<![<>&\w])\d?&?>{1,2}\s*(?!\/dev\/null)[^\s&|;<>]/
21const DROP_TABLE = /\bdrop\s+table\b/i
22const CLAIM = /\b(deployed|fixed|works now|tests pass|live|verified)\b/i
23
24type Tally = { edits: number; runs: number; curls: number; reads: number; destructive: number }
25
26const fresh = (): Tally => ({ edits: 0, runs: 0, curls: 0, reads: 0, destructive: 0 })
27const ran = (t: Tally) => t.runs + t.curls > 0
28const nag = (t: Tally | undefined) => t !== undefined && t.edits > 0 && !ran(t)
29
30let tally = fresh()
31let last: Tally | undefined
32let pending: Tally | undefined
33const rows = new Map<string, Tally>()
34let claimPending = false
35let enabled = true
36
37const commandOf = (input: unknown) => {
38 const c = typeof input === 'object' && input !== null ? Reflect.get(input, 'command') : undefined
39 return typeof c === 'string' ? c : ''
40}
41
42type Kind = { run: boolean; curl: boolean; edit: boolean; destructive: boolean }
43
44// Heredoc bodies and quoted strings are blanked, then each segment is judged by its first word,
45// so `echo "npm test"` and `grep curl README.md` are neither a run nor a curl.
46const classify = (command: string): Kind => {
47 const kind: Kind = { run: false, curl: false, edit: false, destructive: DROP_TABLE.test(command) }
48 let text = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2(?=\n|$)/g, m => m.slice(0, m.indexOf('\n')))
49 text = text.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, QUOTED)
50 for (const segment of text.split(/\n|&&|\|\||[;|]/)) {
51 if (REDIRECT.test(segment)) kind.edit = true
52 const tokens = segment.replace(/\d?&?>{1,2}\s*[^\s&|;<>]+/g, ' ').trim().split(/\s+/).filter(t => !/^\w+=/.test(t))
53 while (tokens.length > 0 && WRAPPERS.includes(tokens[0] ?? '')) tokens.shift()
54 const [cmd = '', ...args] = tokens.map(t => t.replace(/^.*\//, ''))
55 const sub = args.filter(a => !a.startsWith('-'))[0] ?? ''
56 if (RUNNERS.includes(cmd) || ((cmd === 'go' || cmd === 'cargo') && sub === 'test') || (PACKAGE.test(cmd) && /^(test|build)/.test(args[0] === 'run' ? args[1] ?? '' : args[0] ?? '')) || (cmd === 'claude' && args[0] === 'plugin' && args[1] === 'test')) kind.run = true
57 if (cmd === 'curl' || cmd === 'wget') kind.curl = true
58 if (WRITERS.includes(cmd) || (cmd === 'sed' && args.some(a => a === '-i' || a.startsWith('-i') || a.startsWith('--in-place')))) kind.edit = true
59 if ((cmd === 'rm' && args.some(a => /^-\w*r/i.test(a))) || (cmd === 'git' && sub === 'push' && args.some(a => a === '-f' || a === '--force' || a === '--force-with-lease')) || (cmd === 'git' && sub === 'reset' && args.includes('--hard')) || (cmd === 'kubectl' && sub === 'delete')) kind.destructive = true
60 }
61 return kind
62}
63
64const isDestructive = (call: ToolGroupCall) => call.tool === 'Bash' && classify(commandOf(call.input)).destructive
65
66const receipt = (t: Tally) => `${t.edits} edits · ${t.runs} runs${nag(t) ? ' ⚠ no run' : ''}${t.curls > 0 ? ` · ${t.curls} curl` : ''}`
67
68const summary = (t: Tally) => `${t.edits} edits · ${t.runs} runs · ${t.curls} curl · ${t.reads} reads · ${t.destructive} destructive`
69
70let disabled = false
71const readDisabled = async ($: EngineInterface): Promise<boolean> => {
72 const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
73 disabled = raw
74 .split(',')
75 .map(v => v.trim())
76 .some(v => v === 'all' || v === MOD)
77 return disabled
78}
79
80export const register: Register = on => {
81 on('session.start', async ($, e, next) => {
82 const r = await next(e)
83 if (await readDisabled($)) return r
84 enabled = (await $.store.get(ENABLED_KEY).catch(() => undefined)) !== false
85 await $.command
86 .register({
87 name: 'receipt',
88 description: 'Show what each turn did on its footer and flag unverified claims (receipt)',
89 argumentHint: '[on | off | status]',
90 immediate: true,
91 })
92 .catch(err => $.ui.log(`receipt: /receipt not registered: ${err}`))
93 return r
94 })
95
96 on('command.run', { command: 'receipt' }, async ($, e) => {
97 const arg = e.args.trim().toLowerCase()
98 if (arg === 'on' || arg === 'off') {
99 enabled = arg === 'on'
100 await $.store.set(ENABLED_KEY, enabled).catch(err => $.ui.log(`receipt: store write failed: ${err}`))
101 $.ui.invalidate('ui.render')
102 return { text: `receipt ${arg}` }
103 }
104 if (arg === '' || arg === 'status') {
105 return { text: `receipt is ${enabled ? 'on' : 'off'}; this turn: ${summary(tally)}; last turn: ${last ? summary(last) : 'none'}; ${claimPending ? PENDING : 'no claim pending'}` }
106 }
107 return { text: `receipt: "${arg}" is not on, off, or status` }
108 })
109
110 on('turn.start', ($, e, next) => {
111 if (disabled || !enabled) return next(e)
112 tally = fresh()
113 return next(e)
114 })
115
116 on('tool.call', async ($, e, next) => {
117 if (disabled || !enabled) return next(e)
118 const r = await next(e)
119 if (r.deny !== undefined || r.isError) return r
120 if (EDIT_TOOLS.includes(e.tool)) tally.edits++
121 else if (READ_TOOLS.includes(e.tool)) tally.reads++
122 else if (e.tool === 'Bash') {
123 const k = classify(e.command)
124 if (k.run) tally.runs++
125 if (k.curl) tally.curls++
126 if (k.edit) tally.edits++
127 if (k.destructive) tally.destructive++
128 if (k.run || k.curl) claimPending = false
129 }
130 return r
131 })
132
133 on('turn.complete', async ($, e, next) => {
134 const r = await next(e)
135 if (disabled || !enabled || e.agentId !== undefined) return r
136 last = { ...tally }
137 pending = last
138 if (!ran(last) && CLAIM.test(e.answer)) claimPending = true
139 if (nag(last)) void $.prompt.suggest({ text: SUGGESTION }).catch(err => $.ui.log(`receipt: suggestion not shown: ${err}`))
140 return r
141 })
142
143 on('prompt.suggest', ($, e, next) => {
144 if (disabled || !enabled || e.origin.kind !== 'suggestion' || !nag(last)) return next(e)
145 return next({ ...e, text: SUGGESTION })
146 })
147
148 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
149 if (disabled || !enabled || !claimPending || e.surface !== 'terminal') return next(e)
150 const props = e.props.message === null ? { ...e.props, word: `${PENDING} · ${e.props.word}` } : { ...e.props, message: `${PENDING} · ${e.props.message}` }
151 return next({ ...e, props })
152 })
153
154 on('ui.render', { component: 'TurnDuration' }, ($, e, next) => {
155 if (disabled || !enabled || e.surface !== 'terminal') return next(e)
156 let row = rows.get(e.requestId)
157 if (row === undefined && pending !== undefined) {
158 row = pending
159 pending = undefined
160 rows.set(e.requestId, row)
161 }
162 if (row === undefined) return next(e)
163 return next({ ...e, props: { ...e.props, word: `${receipt(row)} · ${e.props.word}` } })
164 })
165
166 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
167 if (disabled || !enabled || e.props.isExpanded || !e.props.calls.some(isDestructive)) return next(e)
168 return next({ ...e, props: { ...e.props, isExpanded: true } })
169 })
170}
171