SLOPSHOPPER

wod-timer

3, 2, 1, GO above the prompt when you submit, a running gym clock while Claude works, and your split when the turn lands.

newbandspinnercommandprompttimer
★ 1v0.4.1MITupdated 2026-09-21yash-gadodia/claude-mods/wod-timer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · wod-timer
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ 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. ✻ turn 1 · 0:00 · AMRAP 0:00 · Worked for 42s · done 4:20 PM › /wod-timer ⎿ wod-timer: wod timer on ✻ 3… timer split 0:00 · avg 0:00 · longest 0:00 · 1 turns ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
timer split 0:00 · avg 0:00 · longest 0:00 · 1 turns ⟨Claude Code's own drawing⟩
README

claude-mods

Mods that keep an agent honest — plus a few that make the terminal fun.

claude-code · mod · function-hooks · typescript · macos

License: MIT Claude Code — mod test

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>

usage-band, wod-band and wod-timer above the prompt <sub>usage-band, wod-band and wod-timer in a live session.</sub>

Why

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.

What a mod is

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.

Install

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.

The mods

Discipline

ModWhat it does
scope-guardCounts 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-verifyAfter 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.
receiptThe 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-reviewA docked pane with each edited file's hunk and keep or revert buttons. Reverting runs git directly; no model turn.
merge-gateDenies 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-offloadRewrites 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.

Instruments

ModWhat it does
usage-bandThe 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-bandLiquid 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-bandClick-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-blinkWhen 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-switchSwitches the Claude in Chrome extension between named browser profiles using select_browser, which needs no approval click. /chromep maps them.

Fun

ModWhat it does
wod-bandA pixel-art athlete above the prompt who does a rep every turn. The session is an AMRAP of thrusters, burpees and pull-ups.
wod-timer3, 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.

Turning them off

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).

Configuration

Mods that touch your machine declare their settings in plugin.json userConfig, so they are editable through /config rather than by hand:

  • scope-guard — /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 — /merge-gate on|off. "merge x3" or "merge after each" in your message grants that many merges.
  • mini-offload — host (ssh alias, default mini), remotePath (the PATH export prefixed to every offloaded command). Per-repo overrides live at <repo>/.claude/mini-offload.json.
  • money-band — 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.
  • usage-band — sgdRate (default 1.30) for the S$ footer label; /usage-band sgd off hides it.
  • receipt — /receipt on|off|status.
  • done-blink — /done-blink on|off|status|<seconds> (default 180, max 900).
  • diff-review — /diff-review open|close|on|off.
  • deploy-verify — per-repo, at <repo>/.claude/deploy-verify.json: ``json { "url": "https://example.com", "matchFile": "VERSION" } ``
  • chrome-switch — ~/.claude/chrome-browsers.json, mapping labels to deviceIds.

Evidence, not decoration

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.

Tests

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.

Design rules

The ones that survived contact with real sessions:

  1. A deny always carries the fix. Blocking without saying what to do instead strands the model in a retry loop. Every refusal here names the next action.
  2. Never block when there is no way through. If the only outcomes are "denied" and "denied again", let it run and say something instead.
  3. A render hook that throws takes the whole mod down with it. Every band wraps its frame in try/catch and falls back to what was there.
  4. Hooks have ten seconds of their own time. 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.
  5. Bands yield. e.props.hasSurvey means the engine wants that slot; give it back.

Requirements

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.

License

MIT

Source 1 files
hooks/register.tsx 286 lines
1/* @jsx h */
2import type { EngineInterface, Register } from 'claude-code'
3
4// A gym timer for each turn. Submitting a prompt starts "3 · 2 · 1 · GO" (450ms a beat) in the
5// band and in the spinner's word, then the clock runs a second at a time from the turn's first
6// model request until the turn lands. The split goes on the band beside the session's average and
7// longest, and the "Cooked for 1m 5s" line becomes the whiteboard: turn number, split, AMRAP (the
8// session's running total) and PR when this is the fastest turn over 5s so far. With voice on, a
9// turn over a minute is read aloud from turn.complete. Tickers are started only by prompt.submit
10// and turn.start and stopped by turn.complete and turn.abort, never by a render.
11
12const BEAT_MS = 450
13const MIN_REDRAW_MS = 100
14const SHOWN_KEY = 'wod-timer:shown'
15const VOICE_KEY = 'wod-timer:voice'
16const PR_MIN_MS = 5_000
17const SPEAK_MIN_MS = 60_000
18
19let shown = true
20let voice = false
21let phase: 'idle' | 'count' | 'run' = 'idle'
22let beat = 0
23let startedAt = 0
24let requestAt: number | undefined
25let last: number | undefined
26type Row = { n: number; split: number; total: number; pr: boolean }
27let pending: Row | undefined
28let splits: number[] = []
29let best: number | undefined
30const rows = new Map<string, Row>()
31let lastDraw = -Infinity
32let tick: { cancel: () => void } | undefined
33
34const COUNT = ['3', '2', '1', 'GO']
35
36export const clock = (ms: number) => {
37  const s = Math.max(0, Math.floor(ms / 1000))
38  const m = Math.floor(s / 60)
39  return `${m}:${String(s % 60).padStart(2, '0')}`
40}
41
42const stop = () => {
43  tick?.cancel()
44  tick = undefined
45}
46
47async function redraw($: EngineInterface) {
48  const now = await $.clock.now()
49  if (now - lastDraw < MIN_REDRAW_MS) return
50  lastDraw = now
51  $.ui.invalidate('ui.render')
52}
53
54const origin = () => requestAt ?? startedAt
55
56function startRun($: EngineInterface) {
57  phase = 'run'
58  stop()
59  tick = $.clock.every(1000, () => void redraw($))
60  void redraw($)
61}
62
63async function land($: EngineInterface, counts: boolean) {
64  stop()
65  let split: number | undefined
66  if (phase !== 'idle' && counts) {
67    split = (await $.clock.now()) - origin()
68    last = split
69    splits.push(split)
70    const pr = split > PR_MIN_MS && (best === undefined || split < best)
71    if (pr) best = split
72    pending = { n: splits.length, split, total: splits.reduce((a, b) => a + b, 0), pr }
73  }
74  phase = 'idle'
75  requestAt = undefined
76  await redraw($)
77  return split
78}
79
80const whiteboard = (r: Row) => `turn ${r.n} · ${clock(r.split)} · AMRAP ${clock(r.total)}${r.pr ? ' · PR' : ''}`
81
82const speak = ($: EngineInterface, ms: number) => {
83  const m = Math.floor(ms / 60_000)
84  const s = Math.floor(ms / 1000) % 60
85  const text = `${m} minute${m === 1 ? '' : 's'}${s ? ` ${s}` : ''}, done`
86  Promise.resolve()
87    .then(() => $.audio.speak(text))
88    .catch((err) => $.ui.log(`wod-timer: voice failed: ${err}`))
89}
90
91const tail = () => {
92  const avg = splits.reduce((a, b) => a + b, 0) / splits.length
93  const longest = Math.max(...splits)
94  return [` · avg ${clock(avg)}`, ` · longest ${clock(longest)}`, ` · ${splits.length} turns`]
95}
96
97const summary = () => tail().map((t) => t.slice(3)).join(' · ')
98
99// Sheds parts from the end until head plus the rest fits the width.
100export const fit = (width: number, head: string, parts: string[]) => {
101  const kept = [...parts]
102  while (kept.length > 0 && head.length + kept.join('').length > width) kept.pop()
103  return kept.join('')
104}
105
106// The first thing anyone does when a mod misbehaves is try to turn it off. `CLAUDE_MODS_DISABLE=all`,
107// or a comma list naming this mod, makes every hook here a pass-through and registers no command.
108const MOD = 'wod-timer'
109let disabled = false
110const readDisabled = async ($: EngineInterface): Promise<boolean> => {
111  const raw = (await $.env.get('CLAUDE_MODS_DISABLE').catch(() => undefined)) ?? ''
112  disabled = raw
113    .split(',')
114    .map(v => v.trim())
115    .some(v => v === 'all' || v === MOD)
116  return disabled
117}
118
119export const register: Register = (on) => {
120  on('session.start', async ($, e, next) => {
121    const r = await next(e)
122    if (await readDisabled($)) return r
123    if ((await $.store.get(SHOWN_KEY).catch(() => undefined)) === false) shown = false
124    if ((await $.store.get(VOICE_KEY).catch(() => undefined)) === true) voice = true
125    await $.command
126      .register({
127        name: 'wod-timer',
128        description: '3-2-1-GO on submit, a running clock per turn and a whiteboard split (wod-timer)',
129        argumentHint: '[on | off | voice on | voice off]',
130        immediate: true,
131      })
132      .catch((err) => $.ui.log(`wod-timer: /wod-timer not registered: ${err}`))
133    return r
134  })
135
136  on('command.run', { command: 'wod-timer' }, async ($, e) => {
137    const arg = e.args.trim().toLowerCase().replace(/\s+/g, ' ')
138    if (arg === 'voice on' || arg === 'voice off') {
139      voice = arg === 'voice on'
140      await $.store.set(VOICE_KEY, voice).catch(() => undefined)
141      return { text: `wod timer voice ${voice ? 'on: turns over a minute are read aloud' : 'off'}` }
142    }
143    if (arg === 'off') {
144      shown = false
145      stop()
146      await $.store.set(SHOWN_KEY, false).catch(() => undefined)
147      $.ui.invalidate('ui.render')
148      return { text: 'wod timer off' }
149    }
150    if (arg === 'on' || arg === '') {
151      shown = true
152      await $.store.set(SHOWN_KEY, true).catch(() => undefined)
153      $.ui.invalidate('ui.render')
154      return { text: 'wod timer on' }
155    }
156    return { text: `wod-timer: no such argument "${arg}" — use on, off, voice on or voice off` }
157  })
158
159  on('prompt.submit', async ($, e, next) => {
160    if (disabled || !shown || e.turnId !== undefined || e.text.startsWith('/')) return next(e)
161    startedAt = await $.clock.now()
162    requestAt = undefined
163    phase = 'count'
164    beat = 0
165    stop()
166    tick = $.clock.every(BEAT_MS, () => {
167      beat += 1
168      if (beat >= COUNT.length) {
169        if (phase === 'count') startRun($)
170        return
171      }
172      void redraw($)
173    })
174    await redraw($)
175    const r = await next(e)
176    if (r.drop !== undefined) {
177      stop()
178      phase = 'idle'
179    }
180    return r
181  })
182
183  on('turn.start', async ($, e, next) => {
184    if (disabled) return next(e)
185    // A turn the timer did not see submitted (a plugin's own prompt, a resumed session) still
186    // gets a clock, but never a countdown mid-run.
187    if (shown && phase === 'idle') {
188      startedAt = await $.clock.now()
189      requestAt = undefined
190      startRun($)
191    }
192    return next(e)
193  })
194
195  on('turn.step', async function* ($, e, next) {
196    if (!disabled && e.agentId === undefined && requestAt === undefined) requestAt = await $.clock.now()
197    return yield* next(e)
198  })
199
200  on('turn.complete', async ($, e, next) => {
201    if (disabled) return next(e)
202    const r = await next(e)
203    if (e.agentId === undefined) {
204      const split = await land($, true)
205      if (voice && split !== undefined && split > SPEAK_MIN_MS) speak($, split)
206    }
207    return r
208  })
209
210  on('turn.abort', async ($, e, next) => {
211    if (disabled) return next(e)
212    const r = await next(e)
213    await land($, false)
214    return r
215  })
216
217  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
218    if (disabled || !shown || e.surface !== 'terminal' || phase !== 'count') return next(e)
219    return next({ ...e, props: { ...e.props, word: COUNT[Math.min(beat, COUNT.length - 1)]! } })
220  })
221
222  on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
223    if (disabled || !shown || e.surface !== 'terminal') return next(e)
224    let row = rows.get(e.requestId)
225    if (row === undefined && pending !== undefined) {
226      row = pending
227      pending = undefined
228      rows.set(e.requestId, row)
229    }
230    if (row === undefined) return next(e)
231    return next({ ...e, props: { ...e.props, word: `${whiteboard(row)} · ${e.props.word}` } })
232  })
233
234  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
235    if (disabled) return next(e)
236    if (!shown || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
237    try {
238      const { Box, Text } = await $.ui.resolve(e)
239      const rest = await next(e)
240      const now = await $.clock.now()
241      const width = e.props.bodyColumns ?? 80
242      let line
243      if (phase === 'count') {
244        const word = COUNT[Math.min(beat, COUNT.length - 1)]!
245        const colour = word === 'GO' ? 'green' : word === '1' ? 'yellow' : '#dd3b2a'
246        line = (
247          <Text wrap="truncate-end">
248            <Text dimColor>timer  </Text>
249            <Text dimColor>{COUNT.slice(0, beat).map((w) => `${w} · `).join('')}</Text>
250            <Text bold color={colour}>{word}</Text>
251          </Text>
252        )
253      } else if (phase === 'run') {
254        const t = clock(now - origin())
255        line = (
256          <Text wrap="truncate-end">
257            <Text dimColor>timer  </Text>
258            <Text bold color="green">{t}</Text>
259            <Text dimColor>{fit(width, `timer  ${t}`, [' running'])}</Text>
260          </Text>
261        )
262      } else if (last !== undefined) {
263        const head = `timer  split ${clock(last)}`
264        line = (
265          <Text wrap="truncate-end">
266            <Text dimColor>timer  split </Text>
267            <Text bold>{clock(last)}</Text>
268            <Text dimColor>{fit(width, head, tail())}</Text>
269          </Text>
270        )
271      } else {
272        line = <Text dimColor wrap="truncate-end">timer  ready · 3, 2, 1 on your next prompt</Text>
273      }
274      return (
275        <Box flexDirection="column">
276          {line}
277          {rest}
278        </Box>
279      )
280    } catch (err) {
281      $.ui.log(`wod-timer: render failed: ${err}`)
282      return next(e)
283    }
284  })
285}
286