SLOPSHOPPER

prompt-shaper

Expands your shorthand and attaches git state to each prompt; adds a stable project-toolchain note to the system prompt

newcommandtoastpromptprocess
v0.3.1no licenseupdated 2026-10-12KZ-HQ/god-mods/prompt-shaper
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · prompt-shaper
› 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 › /shorthand ⎿ prompt-shaper: No shorthand yet. Add one: /shorthand add app = the my-app project ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? 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 1 files
hooks/register.tsx 235 lines
1import type { EngineInterface, On, Register } from 'claude-code'
2
3const STORE_KEY = 'shorthand'
4const SECTION_ID = 'prompt-shaper:toolchain'
5const MAX_GIT_FILES = 8
6
7type Shorthand = Record<string, string>
8
9const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
10
11async function loadShorthand($: EngineInterface): Promise<Shorthand> {
12  const v = await $.store.get(STORE_KEY)
13
14  return v && typeof v === 'object' ? (v as Shorthand) : {}
15}
16
17// Which of the person's shorthand terms the prompt uses, as whole words.
18function expansionsIn(text: string, map: Shorthand): string[] {
19  return Object.entries(map)
20    .filter(([term]) => new RegExp(`(^|[^\\w-])${escape(term)}([^\\w-]|$)`, 'i').test(text))
21    .map(([term, meaning]) => `"${term}" = ${meaning}`)
22}
23
24async function gitSummary($: EngineInterface): Promise<string | undefined> {
25  try {
26    const r = await $.process.run(['git', 'status', '--porcelain=v1', '--branch'], { timeoutMs: 3000 })
27    if (r.exitCode !== 0) return undefined
28    const [branchLine = '', ...files] = r.stdout.split('\n').filter(Boolean)
29    const branch = branchLine.replace(/^## /, '')
30    const shown = files.slice(0, MAX_GIT_FILES).map(f => f.trim()).join(', ')
31    const more = files.length > MAX_GIT_FILES ? `, +${files.length - MAX_GIT_FILES} more` : ''
32
33    return files.length === 0
34      ? `Git now: ${branch}; working tree clean.`
35      : `Git now: ${branch}; ${files.length} changed: ${shown}${more}.`
36  } catch {
37    return undefined
38  }
39}
40
41// Facts about how to run things here, read once so the system prompt stays cache-stable.
42async function detectToolchain($: EngineInterface): Promise<string> {
43  const has = (p: string) => $.fs.exists(p).catch(() => false)
44  const facts: string[] = []
45
46  if (await has('uv.lock')) facts.push('Python via uv: run tools with `uv run <tool>` (e.g. `uv run pytest`), add deps with `uv add`.')
47  else if (await has('.venv/bin/python')) facts.push('Python venv at .venv: use `.venv/bin/python` and `.venv/bin/<tool>`.')
48
49  if (await has('Makefile')) {
50    const make = await $.fs.read('Makefile').catch(() => '')
51    const targets = [...make.matchAll(/^([A-Za-z0-9][\w.-]*):(?!=)/gm)].map(m => m[1]).slice(0, 12)
52    if (targets.length) facts.push(`Makefile targets: ${targets.join(', ')}.`)
53  }
54
55  if (await has('package.json')) {
56    const pm = (await has('pnpm-lock.yaml')) ? 'pnpm' : (await has('yarn.lock')) ? 'yarn' : (await has('bun.lockb')) || (await has('bun.lock')) ? 'bun' : 'npm'
57    try {
58      const pkg = JSON.parse(await $.fs.read('package.json')) as { scripts?: Record<string, string> }
59      const scripts = Object.keys(pkg.scripts ?? {}).slice(0, 12)
60      facts.push(`Node via ${pm}${scripts.length ? `; scripts: ${scripts.join(', ')}` : ''}.`)
61    } catch {
62      facts.push(`Node via ${pm}.`)
63    }
64  }
65
66  for (const f of ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc']) {
67    if (await has(f)) {
68      facts.push('Cloudflare Workers project: wrangler commands act locally unless given `--remote`, which hits production.')
69      break
70    }
71  }
72  if (await has('alembic.ini')) facts.push('Schema migrations via alembic.')
73
74  return facts.length ? `Project toolchain (auto-detected at session start):\n- ${facts.join('\n- ')}` : ''
75}
76
77// <metrics: generated from shared/metrics.ts by shared/sync.sh>
78// Shared mod metrics (schema v2). Source of truth: ~/.claude/mods/shared/metrics.ts.
79// shared/sync.sh inlines it between the metrics markers in each mod's register.tsx (the engine only lets $
80// into functions declared in the same file). Edit here, run sync.sh, never edit an inlined copy.
81//
82// Rows appended to ~/.claude/mods/metrics.jsonl, all with { v: 2, t, mod, event, ver, session, project }:
83//   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
84//   turn     once per turn with activity (logTurns, or count() in the mod's turn.complete hook): { hooks: { <event>: calls } }
85//   <other>  outcome rows from track(), e.g. merge-gate 'verdict'
86// Privacy: never put prompt, command, tool-input or matched-secret text in a row; lengths, kinds and counts only.
87
88const APPEND = 'mkdir -p "$HOME/.claude/mods" && printf "%s\\n" "$1" >> "$HOME/.claude/mods/metrics.jsonl"'
89
90
91const ctx = { ver: '', session: '', project: '' }
92const stats = new Map<string, number>()
93let writeFailed = false
94
95const message = (err: unknown) => (err instanceof Error ? err.message : String(err)).slice(0, 300)
96
97function write($: EngineInterface, mod: string, event: string, data: Record<string, unknown>): void {
98  void (async () => {
99    const t = new Date(await $.clock.now()).toISOString()
100    if (!ctx.project) ctx.project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? ''
101    const line = JSON.stringify({ v: 2, t, mod, event, ver: ctx.ver, session: ctx.session, project: ctx.project, ...data })
102    const { exitCode, stderr } = await $.process.run(['/bin/sh', '-c', APPEND, 'mod-metrics', line], { timeoutMs: 3000 })
103    if (exitCode !== 0) throw new Error(stderr.trim() || `exit ${exitCode}`)
104  })().catch((err: unknown) => {
105    if (writeFailed) return
106    writeFailed = true
107    $.ui.toast(`${mod}: metrics write failed (${message(err)})`)
108  })
109}
110
111// An outcome row: something the mod decided or did. Fire-and-forget.
112function track($: EngineInterface, mod: string, event: string, data: Record<string, unknown> = {}): void {
113  write($, mod, event, data)
114}
115
116async function loaded($: EngineInterface): Promise<void> {
117  const mod = $.plugin.name
118  ctx.session = await $.session.id().catch(() => '')
119  ctx.project = ''
120  try {
121    const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`)) as { version?: string }
122    ctx.ver = manifest.version ?? ''
123  } catch {
124    ctx.ver = '?'
125  }
126  write($, mod, 'loaded', {})
127}
128
129function flush($: EngineInterface): void {
130  const mod = $.plugin.name
131  if (!stats.size) return
132  const hooks = Object.fromEntries(stats)
133  stats.clear()
134  write($, mod, 'turn', { hooks })
135}
136
137// The engine allows one hook per event per plugin, and counts them by spelling. A mod that hooks
138// session.start or turn.complete itself calls count() in that hook, which writes the row; otherwise
139// it calls the matching helper once in register.
140function logLoaded(on: On): void {
141  on('session.start', async ($, e, next) => {
142    await loaded($)
143
144    return next(e)
145  })
146}
147
148function logTurns(on: On): void {
149  on('turn.complete', async ($, e, next) => {
150    flush($)
151
152    return next(e)
153  })
154}
155
156// First statement of every hook: counts the call into this turn's row. The engine only accepts
157// function literals as hooks, so calls are counted from inside rather than by wrapping.
158// In a session.start hook it also writes the loaded row; in a turn.complete hook it writes the turn row.
159function count($: EngineInterface, event: string): void {
160  stats.set(event, (stats.get(event) ?? 0) + 1)
161  if (event === 'session.start') void loaded($)
162  if (event === 'turn.complete') flush($)
163}
164// </metrics>
165
166export const register: Register = on => {
167  logTurns(on)
168
169  let toolchain = ''
170  let lastGit: string | undefined
171
172  on('session.start', async ($, e, next) => {
173    count($, 'session.start')
174    await $.command.register({
175      name: 'shorthand',
176      description: 'List shorthand, or: /shorthand add <term> = <meaning>, /shorthand rm <term>',
177    })
178    toolchain = await detectToolchain($)
179    if (toolchain) track($, 'prompt-shaper', 'toolchain', { chars: toolchain.length })
180
181    return next(e)
182  })
183
184  on('command.run', { command: 'shorthand' }, async ($, e) => {
185    count($, 'command.run{shorthand}')
186    const map = await loadShorthand($)
187    const add = e.args.match(/^\s*add\s+(.+?)\s*=\s*(.+)$/)
188    const rm = e.args.match(/^\s*(rm|remove)\s+(.+?)\s*$/)
189
190    if (add) {
191      map[add[1]!] = add[2]!.trim()
192      await $.store.set(STORE_KEY, map)
193
194      return { text: `Shorthand saved: ${add[1]} = ${add[2]}` }
195    }
196    if (rm) {
197      delete map[rm[2]!]
198      await $.store.set(STORE_KEY, map)
199
200      return { text: `Shorthand removed: ${rm[2]}` }
201    }
202    const lines = Object.entries(map).map(([k, v]) => `  ${k} = ${v}`)
203
204    return { text: lines.length ? `Shorthand:\n${lines.join('\n')}` : 'No shorthand yet. Add one: /shorthand add app = the my-app project' }
205  })
206
207  on('prompt.submit', async ($, e, next) => {
208    count($, 'prompt.submit')
209    if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
210
211    const notes: string[] = []
212    const used = expansionsIn(e.text, await loadShorthand($))
213    if (used.length) notes.push(`The user's shorthand in this prompt: ${used.join('; ')}.`)
214
215    // Only when it changed, so repeated prompts don't repeat it
216    const git = await gitSummary($)
217    if (git !== undefined && git !== lastGit) {
218      notes.push(git)
219      lastGit = git
220    }
221
222    if (notes.length) track($, 'prompt-shaper', 'inject', { chars: notes.join('\n').length, shorthand: used.length, git: notes.some(n => n.startsWith('Git now')) })
223
224    return notes.length ? next({ ...e, context: [...(e.context ?? []), notes.join('\n')] }) : next(e)
225  })
226
227  on('prompt.compose', async ($, e, next) => {
228    count($, 'prompt.compose')
229    const composed = await next(e)
230    if (!toolchain) return composed
231
232    return { sections: [...composed.sections, { id: SECTION_ID, text: toolchain, scope: 'session' as const }] }
233  })
234}
235