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

My Claude Code setup: mods, plugins, skills and settings, so a new machine is one clone away.
git clone git@github-personal:aidan-nunn-sys/claude-config.git ~/Work/personal/claude-config
~/Work/personal/claude-config/install.sh
Then:
<SET-ME> values install.sh reports in ~/.claude.json (MCP secrets are never committed).~/.config/mise/config.toml: "npm:optimaizr" = { version = "0.11.0", allow_low_downloads = true }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.
| Path | What |
|---|---|
mods/ | Mods loaded through CLAUDE_CODE_PLUGIN_DIRS (built by install.sh from this folder) |
plugins/plugins.json | Marketplaces 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.json | User MCP servers, secrets blanked |
settings/rulebook.json | Generic example rules (copied only when the machine has none; not captured) |
docs/specs/ | Design specs |
| Mod | Source | Local changes |
|---|---|---|
| hud | own | prompt hint, context weather bar, cache countdown, kaomoji mascot |
| rulebook | own | hard rules in ~/.claude/rulebook.json, /rules, mcp__rulebook__add_rule |
| xagent | own | dispatch/route tools for Codex, Copilot, Antigravity |
| handoff | own | /handoff [next] writes a handoff doc to ~/.claude/handoffs/ |
| agent-radar | hamzafer/claude-code-mods @ 87f108479bbf | /radar runs mid-turn (immediate) and toggles closed; shows xagent's external agents |
| mission-control | hamzafer/claude-code-mods @ 87f108479bbf | shows xagent's external agents; code-map view (Haiku diff summaries, headless Chrome) stripped |
| blast-radius | hamzafer/claude-code-mods @ 87f108479bbf | 60 s auto-cancel |
| review-watch | hamzafer/claude-code-mods @ 87f108479bbf | none |
| where-am-i | hamzafer/claude-code-mods @ 87f108479bbf | shows only "next" |
| secret-box | ray-amjad/claude-code-mods @ 467b20300d8e | none |
| task-board | MarcoCarnevali/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.
~/.claude/.credentials.json and MCP secrets.diagnose-crash, omarchy, symlinks into /usr/share/omarchy), themes/omarchy.json, the herdr SessionStart hook..claude-plugin/types/ in each mod: SDK types Claude Code writes itself.hooks/register.ts 187 lines1// 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}
187types/index.d.ts 21 lines1export 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