SLOPSHOPPER

mod-stats

/mod-stats [days]: what each god-mods mod did, cost and saved, from ~/.claude/mods/metrics.jsonl

newcommandtoastprocess
v0.5.4no licenseupdated 2026-10-15KZ-HQ/god-mods/mod-stats
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-stats
› 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. ✻ Worked for 42s · done 4:20 PM › /mod-stats ⎿ mod-stats: No mod events recorded yet. They collect in ~/.claude/mods/metrics.jsonl as the mods act. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

god-mods

Claude Code mods: hooks modules that change Claude's interface and behaviour, published as the god-mods plugin marketplace.

Install

In Claude Code:

/plugin marketplace add KZ-HQ/god-mods
/plugin install danger-guard@god-mods

Install the mods you want one by one. Restart Claude Code (a full restart; /reload-plugins does not load new mod code) for them to take effect.

Mods

ModWhat it does
agent-spend/spend [days] [here]: pane of subagent spend by model and agent type, with the costliest agents, from subagent transcripts
context-gaugeBand above the prompt: context fill, last turn's cost, session cost and rate limits; /gauge toggles it
danger-guardHolds risky shell commands and writes to protected data, previews what they would change, and asks before they run
long-turn-notifymacOS notification when a long turn finishes or Claude needs your input, in terminal sessions
merge-gateHolds gh pr merge until CI passes on the PR head; /gate runs the project's CI steps locally
mod-stats/mod-stats [days]: what each mod did, cost and saved, and how often it faulted
mod-faultsPassive: records any god-mod hook that fails, times out or is refused, and toasts at a mod's third fault in a session; /mod-faults. It sees only mods beneath it: put mod-faults@god-mods first in enabledPlugins (that order is the chain order)
next-stepsSuggests 2–3 likely next prompts after each turn; press 1/2/3 to draft one; /next off
prompt-shaperExpands your shorthand and attaches git state to each prompt; adds a project-toolchain note to the system prompt
secret-scrubberMasks API keys and tokens in tool output, and refuses to write them into source files
test-on-editWhen Claude's answer ends, runs the tests of the files it edited; failures go to Claude with your next prompt

Plugin order

Installed mods nest in the order of enabledPlugins in ~/.claude/settings.json, first outermost; a plugin can't choose its place. mod-faults only sees faults in mods beneath it, so keep it first:

"enabledPlugins": {
  "mod-faults@god-mods": true,
  "danger-guard@god-mods": true,
  ...
}

Plugins installed later are appended, so they land beneath it. Reinstalling mod-faults moves it to the end again; its start-up toast and /mod-faults name any mods it can't see.

Metrics

Every mod appends local JSON lines to ~/.claude/mods/metrics.jsonl; nothing leaves your machine. Rows (schema v: 2) carry t, mod, event, ver, session and project:

  • loaded: once per session start, with the mod's version
  • turn: once per turn, counting each hook's calls, e.g. {"tool.call": 3, "command.run{goal}": 1}
  • anything else: an outcome the mod recorded, e.g. merge-gate's verdict

Rows hold counts, kinds and lengths only, never prompt, command or tool-input text. /mod-stats summarises them. mod-faults adds a fault row per failed hook, naming the mod and event; it no longer writes ~/.claude/logs/god-mods-faults.jsonl, which can be deleted.

Development

Each mod is a folder with .claude-plugin/plugin.json and hooks/register.tsx, plus tests in tests/.

The metrics code lives once in shared/metrics.ts and is inlined into every register.tsx between // <metrics> markers, because the engine only lets $ into functions declared in the same file. Edit the shared file, then:

shared/sync.sh          # inline into every mod
shared/sync.sh --check  # fail if any copy is stale

Check a mod before committing:

claude plugin validate <mod>
claude plugin test <mod>

Bump the mod's version in its plugin.json and in .claude-plugin/marketplace.json together.

Source 2 files
hooks/register.tsx 144 lines
1import type { EngineInterface, On, Register } from 'claude-code'
2
3import { parse, report } from './stats.ts'
4
5const DAY_MS = 86_400_000
6// The newest lines are enough for any window /mod-stats shows
7const READ = 'tail -n 50000 "$HOME/.claude/mods/metrics.jsonl" 2>/dev/null || true'
8// The log only grows, and nothing reads past those lines: past 10 MB, keep just them. An append landing
9// between tail and mv is lost, which a metrics row can afford.
10export const TRIM =
11  'f="$HOME/.claude/mods/metrics.jsonl"; [ -f "$f" ] && [ "$(wc -c < "$f")" -gt 10000000 ] && tail -n 50000 "$f" > "$f.trim" && mv "$f.trim" "$f"; true'
12
13// <metrics: generated from shared/metrics.ts by shared/sync.sh>
14// Shared mod metrics (schema v2). Source of truth: ~/.claude/mods/shared/metrics.ts.
15// shared/sync.sh inlines it between the metrics markers in each mod's register.tsx (the engine only lets $
16// into functions declared in the same file). Edit here, run sync.sh, never edit an inlined copy.
17//
18// Rows appended to ~/.claude/mods/metrics.jsonl, all with { v: 2, t, mod, event, ver, session, project }:
19//   loaded   once per load (logLoaded, or count() in the mod's session.start hook), at session.start: proves which version of the mod is running
20//   turn     once per turn with activity (logTurns, or count() in the mod's turn.complete hook): { hooks: { <event>: calls } }
21//   <other>  outcome rows from track(), e.g. merge-gate 'verdict'
22// Privacy: never put prompt, command, tool-input or matched-secret text in a row; lengths, kinds and counts only.
23
24const APPEND = 'mkdir -p "$HOME/.claude/mods" && printf "%s\\n" "$1" >> "$HOME/.claude/mods/metrics.jsonl"'
25
26
27const ctx = { ver: '', session: '', project: '' }
28const stats = new Map<string, number>()
29let writeFailed = false
30
31const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300)
32
33function write($: EngineInterface, mod: string, event: string, data: Record<string, unknown>): void {
34  void (async () => {
35    const t = new Date(await $.clock.now()).toISOString()
36    if (!ctx.project) ctx.project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? ''
37    const line = JSON.stringify({ v: 2, t, mod, event, ver: ctx.ver, session: ctx.session, project: ctx.project, ...data })
38    const { exitCode, stderr } = await $.process.run(['/bin/sh', '-c', APPEND, 'mod-metrics', line], { timeoutMs: 3000 })
39    if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
40  })().catch((err: unknown) => {
41    if (writeFailed) return
42    writeFailed = true
43    $.ui.toast(`${mod}: metrics write failed (${message(err)})`)
44  })
45}
46
47// An outcome row: something the mod decided or did. Fire-and-forget.
48function track($: EngineInterface, mod: string, event: string, data: Record<string, unknown> = {}): void {
49  write($, mod, event, data)
50}
51
52async function loaded($: EngineInterface): Promise<void> {
53  const mod = $.plugin.name
54  ctx.session = await $.session.id().catch(() => '')
55  ctx.project = ''
56  try {
57    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
58    ctx.ver = manifest.version ?? ''
59  } catch {
60    ctx.ver = '?'
61  }
62  write($, mod, 'loaded', {})
63}
64
65function flush($: EngineInterface): void {
66  const mod = $.plugin.name
67  if (!stats.size) return
68  const hooks = Object.fromEntries(stats)
69  stats.clear()
70  write($, mod, 'turn', { hooks })
71}
72
73// The engine allows one hook per event per plugin, and counts them by spelling. A mod that hooks
74// session.start or turn.complete itself calls count() in that hook, which writes the row; otherwise
75// it calls the matching helper once in register.
76function logLoaded(on: On): void {
77  on('session.start', async ($, e, next) => {
78    await loaded($)
79
80    return next(e)
81  })
82}
83
84function logTurns(on: On): void {
85  on('turn.complete', async ($, e, next) => {
86    flush($)
87
88    return next(e)
89  })
90}
91
92// First statement of every hook: counts the call into this turn's row. The engine only accepts
93// function literals as hooks, so calls are counted from inside rather than by wrapping.
94// In a session.start hook it also writes the loaded row; in a turn.complete hook it writes the turn row.
95function count($: EngineInterface, event: string): void {
96  stats.set(event, (stats.get(event) ?? 0) + 1)
97  if (event === 'session.start') void loaded($)
98  if (event === 'turn.complete') flush($)
99}
100// </metrics>
101
102// <modlist: generated from shared/modlist.ts by shared/sync.sh>
103async function godModList($: EngineInterface): Promise<{ names: string[]; file: string } | undefined> {
104  try {
105    const home = await $.env.get('HOME')
106    const known = JSON.parse(await $.fs.read(`${home}/.claude/plugins/known_marketplaces.json`))
107    const root = known['god-mods']?.installLocation ?? known['god-mods']?.source?.path
108    if (!root) return undefined
109    const file = `${root}/.claude-plugin/marketplace.json`
110    const names = (JSON.parse(await $.fs.read(file)).plugins as { name: string }[]).map(p => p.name)
111
112    return names.length ? { names, file } : undefined
113  } catch {
114    return undefined
115  }
116}
117// </modlist>
118
119export const register: Register = on => {
120  logTurns(on)
121
122  on('session.start', async ($, e, next) => {
123    count($, 'session.start')
124    await $.command.register({ name: 'mod-stats', description: 'What each mod did, cost and saved: /mod-stats [days] [here]' })
125    void $.process.run(['/bin/sh', '-c', TRIM], { timeoutMs: 10_000 }).catch(() => undefined)
126
127    return next(e)
128  })
129
130  on('command.run', { command: 'mod-stats' }, async ($, e) => {
131    count($, 'command.run{mod-stats}')
132    const args = e.args.trim().split(/\s+/).filter(Boolean)
133    const days = Math.max(1, Number(args.find(a => /^\d+$/.test(a)) ?? 7))
134    const project = args.includes('here') ? (await $.session.cwd()).split('/').filter(Boolean).pop() : undefined
135    const r = await $.process.run(['/bin/sh', '-c', READ], { timeoutMs: 10_000 })
136    const rows = parse(r.stdout)
137    if (rows.length === 0) return { text: 'No mod events recorded yet. They collect in ~/.claude/mods/metrics.jsonl as the mods act.' }
138
139    const mods = (await godModList($))?.names ?? []
140
141    return { text: report(rows, (await $.clock.now()) - days * DAY_MS, days, project, mods) }
142  })
143}
144
hooks/stats.ts 114 lines
1// Pure: turns metrics.jsonl lines into the /mod-stats report.
2
3export type Row = { t: string; mod: string; event: string; project?: string; [k: string]: unknown }
4
5export function parse(text: string): Row[] {
6  const rows: Row[] = []
7  for (const line of text.split('\n')) {
8    if (!line.trim()) continue
9    try {
10      const r = JSON.parse(line) as Row
11      if (typeof r.mod === 'string' && typeof r.event === 'string' && typeof r.t === 'string') rows.push(r)
12    } catch {
13      // A torn line from a crash: skip it
14    }
15  }
16
17  return rows
18}
19
20// <money: generated from shared/money.ts by shared/sync.sh>
21// US dollars: three decimals under $1, where cents hide the cost of a cheap call; two from $1
22function usd(n: number): string {
23  return `$${Math.abs(n) < 1 ? n.toFixed(3) : n.toFixed(2)}`
24}
25// </money>
26
27const num = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
28const k = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(Math.round(n)))
29const pct = (a: number, b: number) => (b ? `${Math.round((a * 100) / b)}%` : '—')
30
31function line(mod: string, rows: Row[]): string {
32  const of = (event: string) => rows.filter(r => r.event === event)
33  const where = (event: string, key: string, value: unknown) => of(event).filter(r => r[key] === value).length
34  const sum = (event: string, key: string) => of(event).reduce((a, r) => a + num(r[key]), 0)
35
36  switch (mod) {
37    case 'next-steps': {
38      const shown = where('shown', 'ok', true)
39
40      return `${shown} suggestion sets, ${of('clicked').length} clicked (${pct(of('clicked').length, shown)}) · Haiku ${usd(sum('shown', 'usd'))}`
41    }
42    case 'prompt-shaper':
43      return `${of('inject').length} prompts got notes (~${k(sum('inject', 'chars') / 4)} tokens added) · toolchain in ${of('toolchain').length} sessions (~${k(sum('toolchain', 'chars') / 4)} tokens each time)`
44    case 'test-on-edit':
45      return `${of('run').length} runs, ${of('run').filter(r => num(r.failed) > 0).length} with failures · ${of('delivered').length} failure notes to Claude (~${k(sum('delivered', 'chars') / 4)} tokens)`
46    case 'context-gauge': {
47      const done = of('compact').filter(r => r.outcome === 'done')
48      const freed = done.reduce((a, r) => a + Math.max(0, num(r.before) - num(r.after)), 0)
49
50      return `${of('nag').length} over-400k warnings · compact: ${done.length} done (${k(freed)} tokens freed), ${where('compact', 'outcome', 'skipped')} skipped, ${where('compact', 'outcome', 'error')} failed`
51    }
52    case 'danger-guard':
53      return `${of('asked').length} risky commands asked about, you cancelled ${where('asked', 'answer', 'cancel')}`
54    case 'merge-gate':
55      return `${of('verdict').length} merges checked: ${['pass', 'fail', 'pending', 'none'].map(v => `${where('verdict', 'kind', v)} ${v}`).join(', ')} · asked ${of('asked').length}, held ${where('asked', 'answer', 'hold')}`
56    case 'secret-scrubber':
57      return `${of('masked').length} outputs masked, ${of('blocked').length} writes blocked`
58    case 'long-turn-notify':
59      return `${of('notified').length} notifications (${where('notified', 'isError', true)} for errors)`
60    case 'mod-faults':
61      return `${of('fault').length} hook faults recorded across mods`
62    default: {
63      const counts = new Map<string, number>()
64      for (const r of rows) counts.set(r.event, (counts.get(r.event) ?? 0) + 1)
65
66      return [...counts].map(([e, n]) => `${n} ${e}`).join(', ')
67    }
68  }
69}
70
71// v2 rows the shared metrics block writes for every mod; everything else is an outcome row
72const TELEMETRY = new Set(['loaded', 'turn', 'blind'])
73
74function telemetry(rows: Row[]): string {
75  const loads = rows.filter(r => r.event === 'loaded')
76  const calls = rows.filter(r => r.event === 'turn').reduce((a, r) => a + Object.values((r.hooks ?? {}) as Record<string, unknown>).reduce((b: number, n) => b + num(n), 0), 0)
77  const vers = [...new Set(loads.map(r => String(r.ver ?? '?')))].sort()
78  if (!loads.length && !calls) return ''
79
80  return ` · loaded ${loads.length}× (v${vers.join(', v') || '?'}), ${calls} hook calls`
81}
82
83// mods: every mod in the marketplace, so ones with no rows show under No events
84export function report(all: Row[], sinceMs: number, days: number, project?: string, mods: string[] = []): string {
85  const rows = all.filter(r => Date.parse(r.t) >= sinceMs && (!project || r.project === project))
86  const outcomes = rows.filter(r => !TELEMETRY.has(r.event))
87  const names = [...new Set([...mods, ...rows.map(r => r.mod)])].sort()
88  const out = [`Mod activity, last ${days} day${days === 1 ? '' : 's'}${project ? ` in ${project}` : ''} (${outcomes.length} events):`]
89  // mod-faults records each hook fault as its own row, naming the mod that faulted
90  const faults = new Map<string, number>()
91  for (const r of rows) {
92    if (r.mod !== 'mod-faults' || r.event !== 'fault' || typeof r.plugin !== 'string') continue
93    const of = r.plugin.split('@')[0]!
94    faults.set(of, (faults.get(of) ?? 0) + 1)
95  }
96  // mod-faults can't see mods above it in the chain; only its latest session's view counts
97  const latest = rows.filter(r => r.mod === 'mod-faults' && r.event === 'loaded').at(-1)
98  const blindRow = latest && rows.filter(r => r.mod === 'mod-faults' && r.event === 'blind' && r.session === latest.session).at(-1)
99  const blindTo = Array.isArray(blindRow?.mods) ? (blindRow.mods as string[]) : []
100  const quiet: string[] = []
101  for (const mod of [...new Set([...names, ...faults.keys()])].sort()) {
102    const mine = rows.filter(r => r.mod === mod)
103    const acts = mine.filter(r => !TELEMETRY.has(r.event))
104    const faulted = faults.get(mod) ? ` · ${faults.get(mod)} fault${faults.get(mod) === 1 ? '' : 's'}` : ''
105    const blindNote = mod === 'mod-faults' && blindTo.length ? ` · can't see ${blindTo.length}: ${blindTo.join(', ')} (/mod-faults)` : ''
106    if (mine.length === 0 && !faulted) quiet.push(mod)
107    else out.push(`  ${mod}: ${acts.length ? line(mod, acts) : 'no outcomes'}${mine.length ? telemetry(mine) : ''}${faulted}${blindNote}`)
108  }
109  if (quiet.length) out.push(`  No events: ${quiet.join(', ')}`)
110  out.push('Token figures for notes are estimates (4 characters per token); $ are list prices.')
111
112  return out.join('\n')
113}
114