Side-effects ledger (/ledger) with a hold on production SQL writes, and a waiting-on tracker for background work

Five Claude Code mods built from how I actually use Claude Code: four to six sessions at a time, mostly in one repo, a lot of PRs going to production, and a pile of rules I keep forgetting to check.
Claude built all five. The full story, with screenshots, videos and the bugs a real run caught, is on my site: I asked Claude to read 30 of my Claude Code sessions and build the mods I needed

This is what I gave Claude Code. It read my last 30 sessions, came back with ten ideas, and built all ten as these five mods.
look at the last 30 sessions and see how we use claude to work - then look at https://code.claude.com/docs/en/plugins/mods/overview and suggest 10 possible mods for our workflow that would help me be better informed as to what each session is doing.
Run it on your own sessions. Your ten ideas will probably come out different from mine, and I'd like to see them.
| Mod | What it gives you | Commands |
|---|---|---|
| session-fleet | A board of every live session, a "needs you" row when another session is blocked on you, and a guard when two sessions write to the same worktree | /fleet |
| ship-tracker | A row per PR: CI → merged → production deployment → live, a warning when a merge never deploys, and a receipt line after every answer | /ship-track <pr> |
| session-hygiene | Configurable tripwires (typecheck before push, review before push, mutation-check new tests and more), plus a handoff card of loose ends from earlier sessions | /handoff, /handoff clear |
| session-activity | A ledger of everything that left the machine, a hold on database writes, and what each session is waiting on | /ledger, /waiting |
| context-gauge | Context fill in the status line, warnings before compaction, and a snapshot of in-flight work that survives compaction | /context-log |
They work on their own. Install any one, or all five.
You need a Claude Code version with mods (see the mods docs), git, and for ship-tracker and the handoff card, the GitHub CLI signed in.
A mod is code that runs with your permissions inside Claude Code. Read it before you install it. Each one is a single hooks/register.tsx file, and claude plugin validate <folder> lists exactly which events it hooks and what it calls.
In a Claude Code session:
/plugin marketplace add bennewton999/claude-code-mods
/plugin install session-fleet@bennewton-mods
/plugin install ship-tracker@bennewton-mods
/plugin install session-hygiene@bennewton-mods
/plugin install session-activity@bennewton-mods
/plugin install context-gauge@bennewton-mods
Then /reload-plugins, or start a new session.
git clone https://github.com/bennewton999/claude-code-mods ~/claude-code-mods
Load them in every session by adding the folders to env in ~/.claude/settings.json (colon-separated):
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-code-mods/session-fleet:/Users/you/claude-code-mods/ship-tracker:/Users/you/claude-code-mods/session-hygiene:/Users/you/claude-code-mods/session-activity:/Users/you/claude-code-mods/context-gauge",
"CLAUDE_CODE_PLUGIN_DIR_WATCH": "1"
}
}
Or try one for a single session:
claude --plugin-dir ~/claude-code-mods/session-fleet
CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop app sessions hot-reload a mod when its files change (an interactive terminal session already does). A session reads it at startup, so sessions that were already open pick up the mods, and the watch, after one restart.
Every session writes one record to a key-value store that all sessions on the machine share ($.store), with a heartbeat every 30 seconds. Three minutes without a heartbeat and the session counts as gone.
/fleet opens a pane with every live session: a status dot, its first prompt (there's no session title API), the worktrees it's touching, branch, PR, last action and how long ago. Waiting sessions sort to the top, and a worktree two live sessions share gets a ⚠.projectPills to Label:regex:color entries separated by ;, the regex tested against the repo folder name: "pluginConfigs": {
"session-fleet": {
"options": { "projectPills": "Work:acme|billing:#2563eb; Blog:blog:#059669" }
}
}

gh pr output, or add one with /ship-track 1107 or /ship-track owner/repo#1107.no prod deploy!. That's the silent Vercel git-trigger failure that got me before.
Tripwires for rules you keep forgetting. When one trips you get a transcript line and a toast, and Claude gets a reminder with the tool result so it can act on it.
Every rule is a plugin option. You set them on the install screen, or later in /config. Out of the box only the two generic rules are on; the rest stay off until you point them at your repos.
| Option | Default | What it does |
|---|---|---|
holdEnvSourcing | on | Asks Run it or Stop before a command sources a .env file into the shell |
mutationCheckTests | on | When Claude writes a new test file, reminds it to prove the test fails without the code it covers |
reviewBeforePush | off | Flags a git push with edits made since the last local /code-review |
uncheckedRepos | none | Path fragments (/my-app/) of repos whose CI doesn't typecheck or build. There it flags a push after TypeScript edits with no typecheck, and a merge after package.json or next.config changes with no local build |
middlewareRouteRepos | none | Next.js repos whose src/middleware.ts allowlists routes. A new top-level src/app/<route>/page.tsx gets a reminder to add it |
bannedStrings | none | Case-insensitive text that should never be written into a file |
supabase | off | Asks before a command uses a service-role key; after apply_migration, reminds Claude to run NOTIFY pgrst, 'reload schema' and to revoke default grants on new tables |
If you load the mod from a clone instead of the marketplace, set the same options in ~/.claude/settings.json:
{
"pluginConfigs": {
"session-hygiene": {
"options": {
"uncheckedRepos": ["/my-app/"],
"bannedStrings": ["lorem ipsum"],
"supabase": true
}
}
}
}
The handoff card: after each turn a session saves its open loops (open PRs, uncommitted or unpushed worktrees, a question it left you). A new session lists loops from sessions that ended or went quiet, re-checked live first. /handoff shows them again, /handoff clear dismisses them.

/ledger lists everything that left the machine: MCP write tools, git push, gh pr create and merge, gh api writes, Vercel deploys, curl writes, publishes, SQL writes. A button switches to all sessions in the last 24 hours. Reads stay out of it.execute_sql that writes (INSERT, UPDATE, DELETE, DDL, GRANT…) stops for a Run it or Stop question. SELECTs and NOTIFY pass. Words inside comments and quoted strings don't count.Thinking · 2 bg · agent 3m…), and a row above the prompt lists background commands, agents, workflows and monitors still running after Claude stops. /waiting lists them, /waiting clear resets.
ctx 62% · 124k/200k · compacted 1×./context-log lists every compaction this session with its snapshot.
Everything these mods record stays on your machine in Claude Code's per-plugin store. The only network calls are gh requests to GitHub for PR and deployment state (ship-tracker, and the handoff card's re-check).
/ledger reported the pane as opened but it never drew. It works in fresh sessions; the built-in diff pane seemed to be in front of it.I'm Ben Newton. I write about building with AI at benenewton.com, and I'm building BlackOps Center, the platform that runs my site, my posts and the notes these Claude sessions write into. If you want to see it, start at blackopscenter.com/start.
Claude Code wrote the mods with its built-in plugin-authoring skill. Issues and PRs are welcome.
MIT licensed.
hooks/register.tsx 331 lines1import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
2
3// session-activity:
4// 4. side-effects ledger: every action that leaves the machine (MCP writes,
5// pushes, PRs, deploys, posts, emails, SQL writes) is logged with its
6// result and link; /ledger opens it as a pane (this session or all
7// sessions in the last 24h). Writes to a database through execute_sql are
8// held with a question first.
9// 5. waiting-on tracker: background commands, subagents, workflows and
10// monitors, plus foreground calls running long, shown beside the spinner
11// and in the band above the prompt with elapsed time.
12
13const PANE = 'ledger'
14const LEDGER = 'l:'
15const DAY = 24 * 60 * 60_000
16const LONG_MS = 15_000 // a foreground call running this long counts as waiting
17const STALE_MS = 3 * 60 * 60_000
18
19type Entry = { ts: number; kind: string; detail: string; ok: boolean; link: string | null }
20type Ledger = { sessionId: string; task: string; entries: Entry[] }
21type Wait = { id: string; label: string; kind: string; since: number; background: boolean }
22
23let sessionId = ''
24let ledger: Ledger = { sessionId: '', task: '', entries: [] }
25let showAll = false
26let timers: Timer[] = []
27const waits = new Map<string, Wait>() // keyed by tool_use_id
28
29const HUMAN = new Set(['composer', 'bridge', 'sdk'])
30const OUTWARD =
31 /^(post|patch|put|create|update|delete|send|publish|apply|upload|trash|merge|fire|schedule|bind|set|reply|forward|share|deploy|log|calibrate|promote|rollback|add|remove|buy|cancel|respond|label|unlabel|mark|archive)_/
32const LOCAL_SERVERS =
33 /^mcp__(Claude_Browser|computer-use|claude-in-chrome|visualize|ccd_session|ccd_view|ccd_window|ccd_sidebar|ccd_directory|ccd_connectors|terminal|filesystem|puppeteer|config-registry|mcp-registry)/
34const SQL_WRITE =
35 /\b(insert\s+into|update\s+[\w."]+\s+set|delete\s+from|alter\s+(table|type|function|policy|view)|drop\s+\w|truncate\s|create\s+(or\s+replace\s+)?\w|grant\s|revoke\s|comment\s+on|copy\s+\w)/i
36
37const short = (s: string, n: number) => {
38 const one = s.replace(/\s+/g, ' ').trim()
39 return one.length > n ? one.slice(0, n - 1) + '…' : one
40}
41const elapsed = (ms: number) => {
42 const s = Math.max(0, Math.round(ms / 1000))
43 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m` : `${Math.floor(s / 3600)}h${Math.round((s % 3600) / 60)}m`
44}
45const hhmm = (ts: number) => new Date(ts).toTimeString().slice(0, 5)
46const firstUrl = (text: string) => text.match(/https:\/\/[^\s"'<>)\]]+/)?.[0] ?? null
47
48type Call = { tool: string } & Record<string, unknown>
49
50// What a call does to the outside world, or null when it stays local.
51function classify(call: Call): { kind: string; detail: string } | null {
52 const t = call.tool
53 if (t === 'Bash' && typeof call.command === 'string') {
54 const c = call.command
55 const rules: Array<[RegExp, string]> = [
56 [/\bgit\s+push\b/, 'git push'],
57 [/\bgh\s+pr\s+(create|merge|close|comment|review|edit|ready)\b/, 'gh pr'],
58 [/\bgh\s+(issue|release)\s+(create|close|comment|edit)\b/, 'gh'],
59 [/\bgh\s+api\b.*(-X|--method)\s*(POST|PUT|PATCH|DELETE)/i, 'gh api write'],
60 [/\bvercel\b.*(--prod|\bdeploy\b|\balias\b|\benv\s+(add|rm)\b|\bpromote\b|\brollback\b)/, 'vercel'],
61 [/\bcurl\b.*(-X\s*(POST|PUT|PATCH|DELETE)|--data|\s-d\s)/i, 'http write'],
62 [/\b(npm|bun|pnpm)\s+publish\b/, 'publish'],
63 [/\bsupabase\s+(db\s+push|migration\s+up|functions\s+deploy)\b/, 'supabase cli'],
64 [/\bfastlane\b|\baltool\b|\bxcrun\s+notarytool\b/, 'app store'],
65 ]
66 for (const [re, kind] of rules) if (re.test(c)) return { kind, detail: short(c, 90) }
67 return null
68 }
69 if (t === 'Artifact' && (call.action === undefined || call.action === 'publish' || call.action === 'delete'))
70 return { kind: `artifact ${String(call.action ?? 'publish')}`, detail: short(String(call.file_path ?? call.url ?? ''), 90) }
71 if (!t.startsWith('mcp__') || LOCAL_SERVERS.test(t)) return null
72
73 const server = t.split('__')[1] ?? ''
74 let name = t.split('__').pop() ?? t
75 let args: Record<string, unknown> = call
76 if (name === 'use_tool' && typeof call.name === 'string') {
77 name = call.name
78 args = (call.args as Record<string, unknown>) ?? {}
79 }
80 if (name === 'execute_sql') {
81 const q = String(call.query ?? '')
82 if (!SQL_WRITE.test(stripSql(q))) return null
83 return { kind: 'sql write', detail: short(q, 90) }
84 }
85 if (!OUTWARD.test(name)) return null
86 const hint = ['text', 'title', 'subject', 'to', 'name', 'domain', 'id', 'note_id', 'post_id', 'query']
87 .map(k => args[k])
88 .find(v => typeof v === 'string' && v.length > 0) as string | undefined
89 const where = /supabase/.test(server) ? 'supabase ' : /vercel/.test(server) ? 'vercel ' : ''
90 return { kind: `${where}${name}`, detail: hint ? short(hint, 80) : '' }
91}
92
93// Drop comments and quoted strings so words inside them don't count as SQL.
94const stripSql = (q: string) =>
95 q.replace(/--[^\n]*/g, ' ').replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/'(?:[^']|'')*'/g, "''").replace(/\$\$[\s\S]*?\$\$/g, ' ')
96
97async function persist($: Engine) {
98 if (sessionId) await $.store.set(LEDGER + sessionId, ledger)
99}
100
101async function allToday($: Engine): Promise<Array<Entry & { task: string; mine: boolean }>> {
102 const now = await $.clock.now()
103 const rows: Array<Entry & { task: string; mine: boolean }> = []
104 for (const k of await $.store.keys()) {
105 if (!k.startsWith(LEDGER)) continue
106 const l = (await $.store.get(k)) as Ledger | undefined
107 if (!l) continue
108 const recent = l.entries.filter(e => now - e.ts < DAY)
109 if (recent.length === 0 && l.sessionId !== sessionId) {
110 await $.store.delete(k)
111 continue
112 }
113 for (const e of recent) rows.push({ ...e, task: l.task, mine: l.sessionId === sessionId })
114 }
115 return rows.sort((a, b) => b.ts - a.ts)
116}
117
118function waitLabel(call: Call): { label: string; kind: string } {
119 if (call.tool === 'Bash') return { label: String(call.description ?? short(String(call.command ?? ''), 40)), kind: 'bg' }
120 if (call.tool === 'Agent' || call.tool === 'Task')
121 return { label: String(call.description ?? call.subagent_type ?? 'agent'), kind: 'agent' }
122 if (call.tool === 'Workflow') return { label: 'workflow', kind: 'workflow' }
123 if (call.tool === 'Monitor') return { label: String(call.description ?? 'monitor'), kind: 'monitor' }
124 const name = call.tool.split('__').pop() ?? call.tool
125 return { label: name, kind: 'call' }
126}
127
128async function prune($: Engine) {
129 const now = await $.clock.now()
130 for (const [k, w] of waits) if (now - w.since > STALE_MS) waits.delete(k)
131}
132
133function waitingSummary(now: number) {
134 const list = [...waits.values()].filter(w => w.background || now - w.since > LONG_MS)
135 return list.sort((a, b) => a.since - b.since)
136}
137
138export const register: Register = on => {
139 on('session.start', async ($, e, next) => {
140 sessionId = await $.session.id()
141 ledger = ((await $.store.get(LEDGER + sessionId)) as Ledger | undefined) ?? { sessionId, task: '', entries: [] }
142 await $.command.register({ name: 'ledger', description: 'Everything this session sent outside the machine, as a pane' })
143 await $.command.register({
144 name: 'waiting',
145 description: 'What this session is waiting on (background commands, agents, workflows). /waiting clear forgets them.',
146 argumentHint: '[clear]',
147 })
148 for (const t of timers) t.cancel()
149 timers = [$.clock.every(5000, () => void (waits.size > 0 ? $.ui.invalidate('ui.render') : undefined))]
150 return next(e)
151 })
152
153 on('command.run', { command: 'ledger' }, async $ => {
154 await $.ui.open({ id: PANE, title: 'Side-effects ledger' })
155 return { text: 'Ledger pane opened.' }
156 })
157
158 on('command.run', { command: 'waiting' }, async ($, e) => {
159 if ((e.args ?? '').trim() === 'clear') {
160 waits.clear()
161 $.ui.invalidate('ui.render')
162 return { text: 'Cleared.' }
163 }
164 const now = await $.clock.now()
165 const list = waitingSummary(now)
166 return {
167 text: list.length
168 ? list.map(w => `${w.kind.padEnd(8)} ${elapsed(now - w.since).padStart(5)} ${w.label}`).join('\n')
169 : 'Not waiting on anything.',
170 }
171 })
172
173 on('prompt.submit', async ($, e, next) => {
174 const kind = e.origin?.kind
175 if ((!kind || HUMAN.has(kind)) && !ledger.task && !e.text.startsWith('<')) {
176 ledger.task = short(e.text, 70)
177 await persist($)
178 }
179 // A background task finished: <task-notification> names its tool-use id
180 if (kind === 'task-notification' || e.text.includes('<task-notification>')) {
181 for (const m of e.text.matchAll(/<tool-use-id>([^<]+)<\/tool-use-id>[\s\S]*?<status>([^<]+)<\/status>/g)) {
182 if (m[2] !== 'running') waits.delete(m[1]!.trim())
183 }
184 $.ui.invalidate('ui.render')
185 }
186 return next(e)
187 })
188
189 on('tool.call', async ($, e, next) => {
190 const call = e as unknown as Call
191 const isMain = e.agentId === undefined
192 const effect = isMain ? classify(call) : null
193
194 // 4. hold database writes until the user says go
195 if (effect?.kind === 'sql write') {
196 let answer = 'Stop'
197 try {
198 answer = await $.ui.ask(`Write to the database with execute_sql? ${short(String(call.query ?? ''), 220)}`, {
199 header: 'SQL write',
200 options: ['Run it', 'Stop'],
201 })
202 } catch {
203 answer = 'Stop'
204 }
205 if (answer !== 'Run it') {
206 ledger.entries.push({ ts: await $.clock.now(), kind: 'sql write', detail: `STOPPED: ${effect.detail}`, ok: false, link: null })
207 await persist($)
208 return { deny: 'session-activity: the user stopped this database write. Ask them before writing to the database.' }
209 }
210 }
211
212 // 5. track what we are waiting on
213 const background = call.run_in_background === true || ((call.tool === 'Agent' || call.tool === 'Workflow') && call.run_in_background !== false) || call.tool === 'Monitor'
214 if (isMain) {
215 const { label, kind } = waitLabel(call)
216 waits.set(e.tool_use_id, { id: e.tool_use_id, label: short(label, 50), kind, since: await $.clock.now(), background: false })
217 }
218
219 let ran
220 try {
221 ran = await next(e)
222 } finally {
223 const w = waits.get(e.tool_use_id)
224 if (w) {
225 // A background launch stays on the list until its notification arrives
226 if (background && !w.background) waits.set(e.tool_use_id, { ...w, background: true })
227 else waits.delete(e.tool_use_id)
228 }
229 void prune($)
230 }
231
232 if (effect && !('deny' in ran && ran.deny !== undefined)) {
233 ledger.entries.push({
234 ts: await $.clock.now(),
235 kind: effect.kind,
236 detail: effect.detail,
237 ok: !ran.isError,
238 link: firstUrl(ran.text ?? ''),
239 })
240 ledger.entries = ledger.entries.slice(-300)
241 await persist($)
242 $.ui.invalidate('ui.render')
243 }
244 return ran
245 })
246
247 on('turn.complete', async ($, e, next) => {
248 // Foreground calls can't outlive a main-loop turn
249 if (e.agentId === undefined) for (const [k, w] of waits) if (!w.background) waits.delete(k)
250 return next(e)
251 })
252
253 // 5. spinner suffix while working
254 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
255 const now = await $.clock.now()
256 const list = waitingSummary(now)
257 if (list.length === 0) return next(e)
258 const bg = list.filter(w => w.background).length
259 const oldest = list[0]!
260 const bits = [bg ? `${bg} bg` : '', `${oldest.kind} ${elapsed(now - oldest.since)}`].filter(Boolean)
261 return next({ ...e, props: { ...e.props, suffix: ` · ${bits.join(' · ')}…` } })
262 })
263
264 // 5. band: background work still running
265 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
266 const below = await next(e)
267 if (e.props.hasSurvey) return below
268 const now = await $.clock.now()
269 const bg = waitingSummary(now).filter(w => w.background)
270 if (bg.length === 0) return below
271 const { Box, Text } = $.ui.resolve(e)
272 const cols = e.props.bodyColumns ?? 80
273 return (
274 <Box flexDirection="column">
275 <Text>
276 <Text color="cyan">⧗ Waiting on </Text>
277 <Text>
278 {short(bg.map(w => `${w.kind} "${w.label}" ${elapsed(now - w.since)}`).join(' · '), Math.max(30, cols - 14))}
279 </Text>
280 </Text>
281 {below}
282 </Box>
283 )
284 })
285
286 // 4. ledger pane
287 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
288 const { Box, Text, Button, Link } = $.ui.resolve(e)
289 const cols = Math.max(30, e.props.bodyColumns ?? 60)
290 const rows = showAll
291 ? await allToday($)
292 : [...ledger.entries].reverse().map(x => ({ ...x, task: ledger.task, mine: true }))
293 return (
294 <Box flexDirection="column" gap={1}>
295 <Box gap={2}>
296 <Text dimColor>
297 {rows.length} action{rows.length === 1 ? '' : 's'} · {showAll ? 'all sessions, last 24h' : 'this session'}
298 </Text>
299 <Button
300 key="scope"
301 label={showAll ? 'This session' : 'All sessions'}
302 plain
303 onPress={() => {
304 showAll = !showAll
305 $.ui.invalidate('ui.render')
306 }}
307 />
308 </Box>
309 {rows.length === 0 && <Text dimColor>Nothing has left the machine yet.</Text>}
310 {rows.slice(0, 80).map((r, i) => (
311 <Box key={`${r.ts}-${i}`} flexDirection="column">
312 <Text>
313 <Text dimColor>{hhmm(r.ts)} </Text>
314 <Text color={r.ok ? 'green' : 'red'}>{r.ok ? '✓' : '✗'} </Text>
315 <Text bold>{r.kind}</Text>
316 {showAll && !r.mine && <Text dimColor> · {short(r.task || 'other session', 30)}</Text>}
317 </Text>
318 {r.detail && <Text dimColor> {short(r.detail, cols - 2)}</Text>}
319 {r.link && (
320 <Box>
321 <Text> </Text>
322 <Link href={r.link} label={short(r.link.replace(/^https:\/\//, ''), cols - 4)} />
323 </Box>
324 )}
325 </Box>
326 ))}
327 </Box>
328 )
329 })
330}
331