Counts the distinct files a turn edits and stops at a threshold to make the goal get restated, so a small ask cannot quietly become a refactor.

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 325 lines1import type { Register, EngineInterface, SessionMessage, ToolCheckInput, ToolCheckResult } from 'claude-code'
2
3// Silent scope drift is the expensive failure: the one-line fix that touched nine files.
4// This mod counts the distinct files a single turn writes to, and at the threshold it stops
5// and asks for the goal to be restated. Answering "Continue" raises the bar for that turn only.
6
7const THRESHOLD_KEY = 'scope-guard:threshold'
8const JUDGE_KEY = 'scope-guard:judge'
9const DEFAULT_THRESHOLD = 4
10// tool.check's matcher takes an any-of list, so the scanner records these three, not every tool.
11const EDITING_TOOLS = ['Edit', 'Write', 'NotebookEdit']
12const HUMAN_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
13
14let threshold = DEFAULT_THRESHOLD
15let interactive = false
16let touched = new Set<string>()
17let ceiling = DEFAULT_THRESHOLD
18let paused = false
19let goal = ''
20let turnId: string | undefined
21let aborting: string | undefined
22
23const pathOf = (tool: string, input: unknown): string | undefined => {
24 const i = (input ?? {}) as Record<string, unknown>
25 const v = tool === 'NotebookEdit' ? i.notebook_path : i.file_path
26 return typeof v === 'string' ? v : undefined
27}
28
29const editOf = (input: unknown): string => {
30 const i = (input ?? {}) as Record<string, unknown>
31 const v = i.new_string ?? i.content ?? i.new_source
32 return typeof v === 'string' ? v.slice(0, 1500) : ''
33}
34
35const short = (path: string, root: string) => (path.startsWith(root) ? path.slice(root.length + 1) : path)
36
37// In bypass mode the harness edits through Bash, so a Bash call is read for the files it writes:
38// sed -i, a redirection, tee, cp/mv's destination, touch. Heredoc bodies and quoted strings are
39// blanked first (a quoted string becomes one opaque token) so `echo "> file"` names nothing.
40const QUOTED = '\u0001'
41const SCRATCH = ['/tmp/', '/private/tmp/', '/dev/']
42const FLAG = (t: string) => t.startsWith('-') && t !== '-'
43const WRITERS: Record<string, (args: string[]) => string[]> = {
44 sed: args => {
45 if (!args.some(a => a === '-i' || a.startsWith('-i') || a.startsWith('--in-place'))) return []
46 const rest = args.filter(a => !FLAG(a))
47 const scripted = args.some(a => a === '-e' || a === '--expression' || a.startsWith('--expression=')) || rest.includes(QUOTED)
48 return (scripted ? rest : rest.slice(1)).filter(a => a !== QUOTED)
49 },
50 tee: args => args.filter(a => !FLAG(a)),
51 touch: args => args.filter(a => !FLAG(a)),
52 cp: args => args.filter(a => !FLAG(a)).slice(-1),
53 mv: args => args.filter(a => !FLAG(a)).slice(-1),
54}
55
56export const bashTargets = (command: string, root: string): string[] => {
57 let text = command.replace(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2(?=\n|$)/g, m => m.slice(0, m.indexOf('\n')))
58 text = text.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, QUOTED)
59 const found: string[] = []
60 for (const segment of text.split(/\n|&&|\|\||[;|]/)) {
61 for (const m of segment.matchAll(/(?<![<>&\w])\d?&?>{1,2}\s*([^\s&|;<>]+)/g)) found.push(m[1] ?? '')
62 const tokens = segment.replace(/\d?&?>{1,2}\s*[^\s&|;<>]+/g, ' ').trim().split(/\s+/)
63 const at = tokens.findIndex(t => !/^\w+=/.test(t) && t !== 'sudo')
64 const cmd = tokens[at]?.replace(/^.*\//, '')
65 if (cmd && WRITERS[cmd]) found.push(...WRITERS[cmd](tokens.slice(at + 1)))
66 }
67 const paths = found
68 .filter(t => t !== '' && t !== '-' && !t.includes(QUOTED) && !t.includes('$') && !t.includes('*'))
69 .map(t => (t.startsWith('/') ? t : `${root}/${t.replace(/^\.\//, '')}`))
70 .filter(p => !SCRATCH.some(s => p.startsWith(s)))
71 return [...new Set(paths)]
72}
73
74// The threshold is a rule the model should know before it plans, not a surprise it meets at the
75// fourth edit. It goes into the first user message's context under this name, replaced in place
76// rather than accumulated, and refreshed whenever the threshold or a stop changes it.
77const CONTEXT_BLOCK = 'scopeGuard'
78let stops = 0
79
80// At the threshold, Haiku reads the goal, the files so far and the edit itself: an edit plainly
81// inside the goal is let through, and the turn's bar rises by one, once. Anything else (a no,
82// a malformed reply, a failed call) falls through to the ask or the deny, so the judge can only
83// ever soften the guard by one file.
84const JUDGE_MODEL = 'claude-haiku-4-5-20251001'
85const JUDGE_SYSTEM =
86 'You judge whether one file edit stays within the goal a user stated. Reply with one JSON object and nothing else: {"ok": true} or {"ok": false, "reason": "..."}.'
87let judge = true
88let judged = false
89let judgeNote = ''
90
91const verdictOf = (reply: string): { ok: boolean; reason: string } | undefined => {
92 const json = reply.match(/\{[\s\S]*\}/)
93 if (!json) return undefined
94 let parsed: unknown
95 try {
96 parsed = JSON.parse(json[0])
97 } catch {
98 return undefined
99 }
100 if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean') return undefined
101 return { ok: parsed.ok, reason: 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : '' }
102}
103
104const askJudge = async ($: EngineInterface, files: string[], nextFile: string, edit: string) => {
105 const prompt = `Is this edit within the stated goal?\n${JSON.stringify({ goal, filesTouchedSoFar: files, nextFile, edit })}`
106 const reply = await $.model.complete({ model: JUDGE_MODEL, system: JUDGE_SYSTEM, prompt, maxTokens: 200 })
107 return verdictOf(reply)
108}
109
110// After a resume or a reload the module's memory is empty but the transcript is not: the goal is
111// the first human prompt in it, and the files of the turn in flight are the editing calls since
112// the last human prompt.
113const human = (m: SessionMessage) => m.role === 'user' && !m.toolResults?.length && m.text.trim() !== ''
114
115const rebuild = async ($: EngineInterface) => {
116 const messages = await $.session.messages()
117 const prompts = messages.filter(human)
118 if (!goal) goal = prompts[0]?.text ?? ''
119 const last = prompts.at(-1)
120 const since = last ? messages.slice(messages.indexOf(last) + 1) : []
121 touched = new Set(
122 since
123 .flatMap(m => m.toolUses)
124 .filter(u => EDITING_TOOLS.includes(u.tool))
125 .map(u => pathOf(u.tool, u.input))
126 .filter((p): p is string => p !== undefined),
127 )
128}
129
130const RESTATE =
131 'Do not edit anything else. Restate: (1) the goal in one sentence, (2) the branch or environment, (3) the files you expect to touch, (4) what done looks like. Then wait.'
132
133// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
134// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
135const MOD = 'scope-guard'
136let disabled = false
137const readDisabled = async ($: EngineInterface): Promise<boolean> => {
138 const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
139 disabled = raw
140 .split(',')
141 .map(v => v.trim())
142 .some(v => v === 'all' || v === MOD)
143 return disabled
144}
145
146const decide = async ($: EngineInterface, e: ToolCheckInput, next: (e: ToolCheckInput) => Promise<ToolCheckResult>, paths: string[], edit: string) => {
147 if (disabled || paused || e.tool_use_id === undefined) return next(e)
148 const fresh = paths.filter(p => !touched.has(p))
149 if (fresh.length === 0) return next(e)
150
151 const below = next(e)
152 if (touched.size + fresh.length <= ceiling) {
153 const r = await below
154 if (r.decision !== 'deny') for (const p of fresh) touched.add(p)
155 $.ui.status(touched.size >= ceiling ? `scope: ${touched.size}/${ceiling} files` : undefined)
156 return r
157 }
158
159 const root = await $.session.cwd()
160 const files = [...touched].map(p => short(p, root))
161 const file = fresh.map(p => short(p, root)).join(', ')
162 const admit = () => {
163 for (const p of fresh) touched.add(p)
164 return below
165 }
166
167 if (judge && !judged) {
168 judged = true
169 $.ui.status(`scope: judging ${file}`)
170 const verdict = await askJudge($, files, file, edit).catch(err => {
171 $.ui.log(`scope-guard: judge failed: ${err}`)
172 return undefined
173 })
174 if (verdict?.ok) {
175 ceiling = touched.size + fresh.length
176 judgeNote = `A Haiku judge found the edit to ${file} within the goal and raised this turn's threshold to ${ceiling}, once.`
177 $.ui.invalidate('prompt.context')
178 $.ui.status(`scope: ${touched.size + fresh.length}/${ceiling} files (judge passed ${file})`)
179 return admit()
180 }
181 $.ui.status(undefined)
182 if (verdict && !verdict.ok) $.ui.log(`scope-guard: judge on ${file}: ${verdict.reason || 'not within the goal'}`)
183 }
184
185 const listing = files.map(f => ` ${f}`).join('\n')
186 const stop = (): ToolCheckResult => {
187 stops++
188 $.ui.invalidate('prompt.context')
189 return {
190 decision: 'deny',
191 reason: [
192 `scope-guard stopped this edit: the turn has already touched ${touched.size} files (${files.join(', ')}) and ${file} would take it past the threshold of ${ceiling}.`,
193 RESTATE,
194 ].join('\n'),
195 }
196 }
197
198 if (!interactive) {
199 // A refusal alone is answered with another tool; ending the turn is what makes the model
200 // stop and restate. The stop waits so the refusal lands in the transcript first.
201 const running = turnId
202 if (running !== undefined && aborting !== running) {
203 aborting = running
204 $.clock.after(250, () => void $.turn.abort({ turnId: running }).catch(err => $.ui.log(`scope-guard: abort failed: ${err}`)))
205 }
206 return stop()
207 }
208
209 const question = `This turn has already edited ${touched.size} files and is about to edit ${file}.\n${listing}\n\nRestate the goal before going further?`
210 const answer = await $.ui
211 .ask(question, {
212 header: 'scope-guard',
213 options: [`Stop — I'll restate the goal`, 'Continue, this is in scope', 'Off for this session'],
214 })
215 .catch(() => 'Continue, this is in scope')
216
217 if (answer === 'Off for this session') {
218 paused = true
219 return admit()
220 }
221 if (answer.startsWith('Stop')) return stop()
222 // in scope: let this turn run to double the threshold before asking again
223 ceiling = ceiling * 2
224 return admit()
225}
226
227const failClosed = ($: EngineInterface, e: ToolCheckInput, next: { error: { kind: string; message?: string } }): ToolCheckResult => ({
228 decision: 'deny',
229 reason: [
230 `scope-guard errored (${next.error.kind}${next.error.message ? `: ${next.error.message}` : ''}) and denied this edit rather than let it through unchecked.`,
231 'To disable the guard: /scope off for this session, or CLAUDE_MODS_DISABLE=scope-guard in the environment.',
232 ].join('\n'),
233})
234
235export const register: Register = on => {
236 on('session.start', async ($, e, next) => {
237 const r = await next(e)
238 if (await readDisabled($)) return r
239 interactive = e.isInteractive
240 const saved = Number(await $.store.get(THRESHOLD_KEY).catch(() => undefined))
241 if (Number.isFinite(saved) && saved > 0) threshold = saved
242 ceiling = threshold
243 judge = (await $.store.get(JUDGE_KEY).catch(() => undefined)) !== false
244 await rebuild($).catch(err => $.ui.log(`scope-guard: transcript not read: ${err}`))
245 await $.command
246 .register({
247 name: 'scope',
248 description: 'Files this turn has edited, and the threshold that stops for a restated goal (scope-guard)',
249 argumentHint: '[<number> | off | judge on|off | status]',
250 immediate: true,
251 })
252 .catch(err => $.ui.log(`scope-guard: /scope not registered: ${err}`))
253 return r
254 })
255
256 on('prompt.submit', ($, e, next) => {
257 if (!disabled && !goal && HUMAN_ORIGINS.includes(e.origin.kind) && !e.text.startsWith('/')) goal = e.text
258 return next(e)
259 })
260
261 on('command.run', { command: 'scope' }, async ($, e) => {
262 const arg = e.args.trim().toLowerCase()
263 const root = await $.session.cwd()
264 const listing = touched.size
265 ? `\nthis turn has edited ${touched.size} file(s):\n${[...touched].map(p => ` ${short(p, root)}`).join('\n')}`
266 : '\nthis turn has edited nothing yet'
267 if (arg === '' || arg === 'status') {
268 const line = `scope-guard stops at ${paused ? 'never (off)' : `${threshold} files`}; judge ${judge ? 'on' : 'off'}`
269 return { text: `${line}${goal ? `\ngoal: ${goal}` : ''}${listing}` }
270 }
271 if (arg === 'off') {
272 paused = true
273 return { text: 'scope-guard off for this session' }
274 }
275 if (arg === 'judge on' || arg === 'judge off') {
276 judge = arg === 'judge on'
277 await $.store.set(JUDGE_KEY, judge).catch(err => $.ui.log(`scope-guard: store write failed: ${err}`))
278 return { text: `scope-guard judge ${judge ? 'on: Haiku may let one in-scope edit past the threshold per turn' : 'off'}` }
279 }
280 const n = Number(arg)
281 if (!Number.isFinite(n) || n < 1) return { text: `scope: "${arg}" is not a file count, off, judge on|off, or status` }
282 threshold = Math.floor(n)
283 ceiling = threshold
284 paused = false
285 await $.store.set(THRESHOLD_KEY, threshold).catch(err => $.ui.log(`scope-guard: store write failed: ${err}`))
286 $.ui.invalidate('prompt.context')
287 return { text: `scope-guard will stop at ${threshold} files in one turn` }
288 })
289
290 on('prompt.context', async ($, e, next) => {
291 const below = await next(e)
292 if (disabled || paused) return below
293 const text = [
294 `scope-guard stops a turn that edits more than ${threshold} distinct files.`,
295 'Before a change that will touch more than that, state in one sentence: the goal, the branch or',
296 'environment, the files you expect to touch, and what done looks like — then get agreement.',
297 ...(stops ? [`This session has already been stopped ${stops === 1 ? 'once' : `${stops} times`} for scope.`] : []),
298 ...(judgeNote ? [judgeNote] : []),
299 ].join('\n')
300 return { ...below, blocks: [...below.blocks.filter(b => b.name !== CONTEXT_BLOCK), { name: CONTEXT_BLOCK, text }] }
301 })
302
303 on('turn.start', async ($, e, next) => {
304 if (disabled) return next(e)
305 touched = new Set()
306 ceiling = threshold
307 judged = false
308 judgeNote = ''
309 turnId = e.turnId
310 return next(e)
311 })
312
313 on('tool.check', { tool: EDITING_TOOLS }, ($, e, next) => {
314 const path = pathOf(e.tool, e.input)
315 return decide($, e, next, path ? [path] : [], editOf(e.input))
316 }).catch(failClosed)
317
318 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
319 const command = ((e.input ?? {}) as Record<string, unknown>).command
320 if (typeof command !== 'string' || disabled || paused || e.tool_use_id === undefined) return next(e)
321 const paths = bashTargets(command, await $.session.cwd())
322 return decide($, e, next, paths, command.slice(0, 1500))
323 }).catch(failClosed)
324}
325