SLOPSHOPPER

rulebook

Hard rules on tool calls, kept in ~/.claude/rulebook.json; Claude adds a rule when you correct it. /rules lists or removes them.

newguardcommandtoastprompttool
v0.1.0no licenseupdated 2026-10-14aidan-nunn-sys/claude-config/mods/rulebook
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · rulebook
› 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(rm -rf build && git push --force origin main) ⎿ Denied by rulebook: rulebook (confirm-commit-push): the user did not allow this. Commits and pushes need a ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /rules ⎿ rulebook: 📏 3 rules in /Users/dev/.claude/rulebook.json ⎿ rulebook: • no-ai-attribution [deny] No AI attribution in commits or PRs: drop the Co-Authored-By, "Generated with Claud ⎿ rulebook: • simple-branch-commands [deny] Make the branch with `git branch <name> <start>` and then `git checkout <name> ⎿ rulebook: • confirm-commit-push [confirm] Commits and pushes need a clear yes from the user. ⎿ rulebook: /rules remove <id> · /rules reload ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-config

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.

New machine

git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh

Then:

  • Fill in any <SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).
  • optimaizr CLI (audited 0.11.0; re-audit before bumping), in ~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }
  • Restart Claude Code.

After changing settings

Mods and skills are symlinked from this repo, so edits land here directly. Settings changed through Claude Code live in ~/.claude; run ./capture.sh and review git diff to bring them back.

Layout

PathWhat
mods/Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder)
plugins/plugins.jsonMarketplaces and which marketplace plugins are enabled
skills/Personal skills, symlinked into ~/.claude/skills
settings/settings.json~/.claude/settings.json minus plugin dirs, plugins and hooks; ${HOME} for paths
settings/mcp.jsonUser MCP servers, secrets blanked
settings/rulebook.jsonGeneric example rules (copied only when the machine has none; not captured)
docs/specs/Design specs

Mods

ModSourceLocal changes
hudownprompt hint, context weather bar, cache countdown, kaomoji mascot
rulebookownhard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule
xagentowndispatch/route tools for Codex, Copilot, Antigravity
handoffown/handoff [next] writes a handoff doc to ~/.claude/handoffs/
agent-radarhamzafer/claude-code-mods @ 87f108479bbf/radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents
mission-controlhamzafer/claude-code-mods @ 87f108479bbfshows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped
blast-radiushamzafer/claude-code-mods @ 87f108479bbf60 s auto-cancel
review-watchhamzafer/claude-code-mods @ 87f108479bbfnone
where-am-ihamzafer/claude-code-mods @ 87f108479bbfshows only "next"
secret-boxray-amjad/claude-code-mods @ 467b20300d8enone
task-boardMarcoCarnevali/claude-code-mods @ a3e528dd00ce/board (not /tasks), no auto-open, mid-turn + toggle

Upstream commits are the repos' HEAD on 2026-10-14, when this repo was made; installs were from around then. Read upstream changes before pulling them in.

Not in this repo

  • ~/.claude/.credentials.json and MCP secrets.
  • Managed by other tools: omarchy skills (diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook.
  • Per-machine state: memory, history, sessions, plans.
  • .claude-plugin/types/ in each mod: SDK types Claude Code writes itself.
  • Hiding Claude Code's native subagent area: the mod API exposes no component for it (checked 2026-10-14; Spinner props are word, message, suffix, mode).
Source 2 files
hooks/register.ts 187 lines
1// Rulebook: hard rules on tool calls, read from ~/.claude/rulebook.json.
2// Claude adds one through mcp__rulebook__add_rule when the user corrects it or states a
3// lasting rule; /rules lists them and /rules remove <id> takes one out.
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Rule } from '../types'
8
9const TOOL = 'add_rule'
10const ALLOW = 'Allow'
11
12const SEED: Rule[] = [
13  {
14    id: 'no-ai-attribution',
15    tool: '^Bash$',
16    field: 'command',
17    pattern: 'Co-Authored-By:.*(claude|anthropic)|generated with \\[?claude code|claude\\.ai/code|noreply@anthropic\\.com',
18    flags: 'i',
19    action: 'deny',
20    message: 'No AI attribution in commits or PRs: drop the Co-Authored-By, "Generated with Claude Code" and session-link lines.',
21    added: '2026-10-12',
22  },
23  {
24    id: 'simple-branch-commands',
25    tool: '^Bash$',
26    field: 'command',
27    pattern: '\\bgit\\s+(checkout\\s+-[bB]|switch\\s+(-c|-C|--create))\\s+\\S+\\s+[^\\s;&|]+',
28    action: 'deny',
29    message: 'Make the branch with `git branch <name> <start>` and then `git checkout <name>`: checkout -b with a start point once set an upstream that pushed to development.',
30    added: '2026-10-12',
31  },
32  {
33    id: 'confirm-commit-push',
34    tool: '^Bash$',
35    field: 'command',
36    pattern: '\\bgit\\s+(commit|push)\\b',
37    action: 'confirm',
38    message: 'Commits and pushes need a clear yes from the user.',
39    added: '2026-10-12',
40  },
41]
42
43const rules = atom({ plugin: 'rulebook', key: 'rules' } as const, [] as Rule[])
44
45const TOOL_DESCRIPTION =
46  "Add a hard rule to the user's rulebook, enforced on every later tool call in every session. " +
47  'Call it when the user corrects how you acted or states a lasting rule about commands, git, files or tools ' +
48  '("never X", "always ask before Y", "don\'t touch Z"), in addition to saving a memory. ' +
49  'Make the pattern as narrow as the rule: a JavaScript regex on one input field of the tool. ' +
50  'Use action "confirm" when the user wants to be asked first, "deny" when the thing should never happen.'
51
52const TOOL_SCHEMA = {
53  type: 'object',
54  properties: {
55    id: { type: 'string', description: 'short-kebab-case name' },
56    tool: { type: 'string', description: 'regex on the tool name, e.g. ^Bash$ or ^(Edit|Write)$' },
57    field: { type: 'string', description: 'input field to match: command, file_path, content, ... or * for all input' },
58    pattern: { type: 'string', description: 'JavaScript regex source' },
59    flags: { type: 'string', description: 'regex flags, e.g. i' },
60    action: { type: 'string', enum: ['deny', 'confirm'] },
61    message: { type: 'string', description: 'what Claude is told when the rule fires, with what to do instead' },
62    why: { type: 'string', description: 'what the user said that led to it' },
63  },
64  required: ['id', 'tool', 'field', 'pattern', 'action', 'message'],
65}
66
67export const register: Register = on => {
68  on('session.start', async ($, e, next) => {
69    const result = await next(e)
70    await load($).catch(() => {})
71    await $.command.register({ name: 'rules', description: 'List the rulebook, or /rules remove <id>' }).catch(() => {})
72    await $.tool.register({ name: TOOL, description: TOOL_DESCRIPTION, inputSchema: TOOL_SCHEMA }).catch(() => {})
73    return result
74  })
75
76  on('prompt.compose', async ($, e, next) => {
77    const result = await next(e)
78    const list = await read($, rules).catch(() => [] as Rule[])
79    const text =
80      "# Rulebook\nThe user's hard rules are enforced on tool calls by the rulebook mod. " +
81      `When the user corrects you or states a lasting rule about how to act, call mcp__rulebook__${TOOL} as well as saving a memory.\n` +
82      list.map(r => `- ${r.id} (${r.action}): ${r.message}`).join('\n')
83    return { ...result, sections: [...result.sections, { id: 'rulebook', text, scope: 'session' }] }
84  })
85
86  on('tool.call', { tool: `mcp__rulebook__${TOOL}` }, async ($, e) => {
87    const added = await addRule($, inputOf(e) as Partial<Rule>)
88    return typeof added === 'string' ? { deny: added } : { result: `Added rule ${added.id}.`, text: `Added rule ${added.id}: ${added.message}` }
89  })
90
91  on('tool.call', async ($, e, next) => {
92    if (e.tool === `mcp__rulebook__${TOOL}`) return next(e)
93    const list = await read($, rules).catch(() => [] as Rule[])
94    const hit = firstMatch(list, e.tool, inputOf(e))
95    if (!hit) return next(e)
96    if (hit.action === 'deny') return { deny: `rulebook (${hit.id}): ${hit.message}` }
97    const answer = await $.ui
98      .ask(`Rulebook: ${hit.message} Claude wants to run ${preview(e)} Allow it?`, [ALLOW, 'Deny'])
99      .catch(() => 'Deny')
100    if (answer !== ALLOW) return { deny: `rulebook (${hit.id}): the user did not allow this. ${hit.message}` }
101    return next(e)
102  })
103
104  on('command.run', { command: 'rules' }, async ($, e) => {
105    const [verb, id] = e.args.trim().split(/\s+/)
106    if (verb === 'remove' && id) {
107      const before = await read($, rules)
108      if (!before.some(r => r.id === id)) return { text: `No rule named ${id}.` }
109      await save($, before.filter(r => r.id !== id))
110      return { text: `Removed ${id}.` }
111    }
112    if (verb === 'reload') await load($)
113    const list = await read($, rules)
114    return { text: [`📏 ${list.length} rules in ${await rulesPath($)}`, ...list.map(r => `• ${r.id} [${r.action}] ${r.message}`), '/rules remove <id> · /rules reload'].join('\n') }
115  })
116}
117
118// The call's input fields as the hook sees them (the envelope carries them at its top level).
119function inputOf(e: object): Record<string, unknown> {
120  return e as Record<string, unknown>
121}
122
123function preview(e: object) {
124  const command = (e as { command?: unknown }).command
125  return typeof command === 'string' ? `\`${command.slice(0, 200)}\`.` : 'this tool call.'
126}
127
128export function firstMatch(list: Rule[], tool: string, input: Record<string, unknown>): Rule | undefined {
129  return list.find(r => {
130    try {
131      if (!new RegExp(r.tool).test(tool)) return false
132      const text = r.field === '*' ? JSON.stringify(input) : String(input[r.field] ?? '')
133      return text !== '' && new RegExp(r.pattern, r.flags).test(text)
134    } catch {
135      return false // a broken pattern never blocks
136    }
137  })
138}
139
140// A rule as Claude asked for it, or why it was refused.
141export function checkRule(input: Partial<Rule>, existing: Rule[], today: string): Rule | string {
142  const { id, tool, field, pattern, flags, action, message, why } = input
143  if (!id || !tool || !field || !pattern || !message) return 'id, tool, field, pattern and message are required.'
144  if (action !== 'deny' && action !== 'confirm') return 'action must be deny or confirm.'
145  if (existing.some(r => r.id === id)) return `A rule named ${id} already exists.`
146  try {
147    new RegExp(tool)
148    const re = new RegExp(pattern, flags)
149    if (re.test('') || re.test(' ')) return 'That pattern matches everything; make it narrower.'
150  } catch (err) {
151    return `Bad regex: ${String(err)}`
152  }
153  return { id, tool, field, pattern, ...(flags ? { flags } : {}), action, message, ...(why ? { why } : {}), added: today }
154}
155
156async function addRule($: EngineInterface, input: Partial<Rule>): Promise<Rule | string> {
157  // Re-read the file first: an empty in-memory list (a reload that skipped session.start) would overwrite it.
158  await load($)
159  const list = await read($, rules)
160  const today = new Date(await $.clock.now()).toISOString().slice(0, 10)
161  const rule = checkRule(input, list, today)
162  if (typeof rule === 'string') return rule
163  await save($, [...list, rule])
164  $.ui.toast(`📏 New rule: ${rule.message}`)
165  return rule
166}
167
168async function rulesPath($: EngineInterface) {
169  const home = (await $.env.get('HOME')) ?? ''
170  return `${home}/.claude/rulebook.json`
171}
172
173async function load($: EngineInterface) {
174  const path = await rulesPath($)
175  if (!(await $.fs.exists(path))) {
176    await save($, SEED)
177    return
178  }
179  const parsed = JSON.parse(await $.fs.read(path)) as { rules?: Rule[] }
180  await update($, rules, () => parsed.rules ?? [])
181}
182
183async function save($: EngineInterface, list: Rule[]) {
184  await update($, rules, () => list)
185  await $.fs.write(await rulesPath($), `${JSON.stringify({ rules: list }, null, 2)}\n`)
186}
187
types/index.d.ts 21 lines
1export type Rule = {
2  id: string
3  // Regex on the tool name, e.g. ^Bash$ or ^(Edit|Write)$
4  tool: string
5  // Which input field the pattern reads: a field name like command or file_path, or * for the whole input
6  field: string
7  pattern: string
8  flags?: string
9  // deny refuses the call; confirm asks the user first
10  action: 'deny' | 'confirm'
11  message: string
12  why?: string
13  added: string
14}
15
16declare module 'claude-code' {
17  interface PluginState {
18    rulebook: { rules: Rule[] }
19  }
20}
21