TLDR; A Claude Code hook rule turns a mistake Claude keeps repeating into a check that runs before each matching tool call. CLAUDE.md asks; a
PreToolUsehook enforces. Log failed calls, find the mistake that repeats, and write it as a pattern plus an action: block or rewrite. Each rule carries a keep-test: an input to catch and one to let through. Delete rules that never fire, except safety rules. The steps include hook examples to copy: a logging hook, a guard script and two rules.
When to use a Claude Code hook rule
- Claude ignores an instruction your CLAUDE.md already gives, and repeats the mistake.
- The mistake has a shape you can match: a flag, a path, a command name.
- The fix is mechanical, or the action must never run. A missing quote gets rewritten; a force-push to
maingets blocked. - Skip it for judgement calls such as design or naming; leave those to a code review or another quality gate.
- Skip it for a one-off. Each rule adds a check to every matching action Claude takes.
How Claude Code hook rules work
A hook is a command Claude Code runs automatically on an event. A tool call is one action Claude takes: a command, a file edit. The PreToolUse event fires just before each one, so a hook there can block or rewrite it.
CLAUDE.md and auto memory are text the model weighs against everything else in the session. In my sessions, Claude still sometimes repeats a mistake that one line among 200 already warns about. A hook runs outside the model’s judgement and costs no tokens until it fires.
For months my global CLAUDE.md has warned about globs, wildcards like the *.md in --include=*.md. In the zsh shell, one that matches no file aborts the whole command with no matches found. Claude kept writing them unquoted. On 15 October I turned the lesson into a hook rule, and it caught 229 commands in its first four days.
How to turn a Claude Code mistake into a hook rule
1. Log failed tool calls
Claude Code fires PostToolUseFailure when a tool call fails. Add this hook to Claude Code’s settings file, settings.json. It logs each failure as one line of JSON (structured text) and needs jq, a small command-line tool for JSON:
{
"hooks": {
"PostToolUseFailure": [
{ "hooks": [{ "type": "command", "command": "jq -c . >> ~/.claude/tool-failures.jsonl" }] }
]
}
}
Check: ask Claude to run ls /no-such-dir. The log gains a line.
2. Pick the mistake that repeats
Ask Claude for the lesson right after a session goes wrong; asking after every session gives you noise. Look for an error that recurs across projects: that is a habit worth a rule.
Read the last 100 lines of ~/.claude/tool-failures.jsonl. Name the
mistake that repeats and the command shape behind it. Give one input
a rule must catch and one near miss it must let through.
Check: you can state the mistake as a shape in the command, not as advice.
3. Write a hook rule and its keep-test
A rule names the tool, the field to read, a pattern, an action and the message Claude sees. The pattern is a regular expression, a notation for matching text. The tests array is the keep-test: an input to catch and a near miss to let through, so a broad pattern can’t quietly block good commands. Rules go in a list in rules.json:
{
"id": "no-cd-into-worktree",
"tool": "Bash",
"field": "command",
"pattern": "(^|[;&|]\\s*)cd\\s+\\S*\\.worktrees/",
"action": "block",
"message": "Use absolute paths; cd into a linked worktree drifts cwd for every later call.",
"tests": [
{"input": "cd .worktrees/x && ls", "expect": "block"},
{"input": "ls .worktrees/x", "expect": "pass"}
]
}
This rule blocks cd into a worktree, a second copy of the code.
A rule on the Edit tool reads new_string; on Write, it reads content.
Check: the two test inputs differ only in the part the rule targets: here, the cd.
4. Run the rules from a PreToolUse hook
Save this as ~/.claude/hooks/guard.py, beside rules.json. It reads the call from stdin (its input) and skips any rule whose tests are missing or fail. To block, it prints the message to stderr (its error output) and exits with code 2. Each time a rule fires, it adds the rule’s id to fires.log:
#!/usr/bin/env python3
import json, os, re, sys
HERE = os.path.dirname(os.path.abspath(__file__))
def apply(rule, text):
if not re.search(rule["pattern"], text):
return "pass", text
if rule["action"] == "rewrite":
return "rewrite", re.sub(rule["pattern"], rule["replace"], text)
return "block", text
def proven(rule): # the keep-test: a catch and a near miss, both must hold
tests = rule.get("tests") or []
kinds = {t["expect"] for t in tests}
return "pass" in kinds and len(kinds) > 1 and all(
apply(rule, t["input"]) == (t["expect"], t.get("output", t["input"]))
for t in tests)
call = json.load(sys.stdin)
args, notes = call.get("tool_input") or {}, []
for rule in json.load(open(os.path.join(HERE, "rules.json"))):
text = args.get(rule["field"])
if rule["tool"] != call.get("tool_name") or not isinstance(text, str):
continue
action, new = apply(rule, text)
if action == "pass" or not proven(rule):
continue
open(os.path.join(HERE, "fires.log"), "a").write(rule["id"] + "\n")
if action == "block":
print(rule["message"], file=sys.stderr)
sys.exit(2)
args[rule["field"]] = new
notes.append(f"{rule['message']} Rewritten to: {new}")
if notes:
print(json.dumps({"hookSpecificOutput": {"hookEventName": "PreToolUse",
"updatedInput": args, "additionalContext": "\n".join(notes)}}))
Register it:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash|Edit|Write",
"hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/guard.py" }] }
]
}
}
For a team, commit both files to the repo’s .claude/hooks/ and point the command at "$CLAUDE_PROJECT_DIR"/.claude/hooks/guard.py.
Check: echo '{"tool_name":"Bash","tool_input":{"command":"cd .worktrees/x && ls"}}' | python3 ~/.claude/hooks/guard.py; echo $? prints the message and 2.
5. Rewrite when the fix is mechanical
Each block costs a turn: Claude reads the message, fixes the command and tries again. When the fix needs no judgement, rewrite instead. The guard hands Claude Code the corrected call as updatedInput and tells Claude what it changed. Claude Code still runs its usual permission checks on the rewritten call. A rewrite rule looks like this:
{
"id": "unquoted-include-glob",
"tool": "Bash", "field": "command",
"pattern": "((?:^|\\s)--(?:include|exclude|exclude-dir|glob)=)(?!['\"])([^\\s'\"\\\\{};&|<>()]*[*?\\[][^\\s'\"\\\\{};&|<>()]*)",
"action": "rewrite",
"replace": "\\1\"\\2\"",
"message": "zsh globs an unquoted --include=*.md; quoted for you.",
"tests": [
{"input": "grep -rn --include=*.md foo .", "expect": "rewrite", "output": "grep -rn --include=\"*.md\" foo ."},
{"input": "grep -rn --include=*.md; ls", "expect": "rewrite", "output": "grep -rn --include=\"*.md\"; ls"},
{"input": "grep -rn --include='*.md' foo .", "expect": "pass"}
]
}
Check: run the step 4 check with grep -rn --include=*.md foo . as the command. It prints the quoted command and exits 0.
6. Count fires and prune
Once a month, count the guard’s fires:
sort ~/.claude/hooks/fires.log | uniq -c | sort -rn
A rule missing from the count has never fired but still costs a check on every call. Delete it after 60 days unless it guards against something destructive: a hard-safety rule that never fires is doing its job.
Check: every rule appears in the count or is a safety rule you chose to keep.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| A rewrite changes text inside quotes or a heredoc | The pattern reads the raw command | Narrow the pattern, or block instead |
| Another hook’s rewrite of the same call is lost | Matching hooks run in parallel; the hooks reference gives no order for competing rewrites | Let one hook own each rewrite |
| A destructive command slips past the rules | A regular expression can’t catch every way to write a shell command | Make the sandbox and deny rules the boundary (data privacy guide) |
In practice
hamster, my MIT-licensed plugin, automates these steps. A PostToolUseFailure hook logs each failed call; after six weeks of work my machine held 375 of those session log files, one per session. After eight errors, a Stop hook blocks Claude’s first attempt to stop and asks for the one durable lesson. /hamster:promote drafts a rule with tests and installs it only when they pass. A PreToolUse guard applies every rule and counts each fire.
- Run fails
- CaptureOne log line per failed tool call
- RetroAfter eight errors, a Stop hook asks for the lesson
- LessonClaude names the one lesson worth keeping
- PromoteDrafts a rule with tests; installs only when they pass
- Rule + keep-testThe guard blocks or rewrites the next repeat
- Prune
I run RTK, which rewrites shell commands to filter their output and save tokens. Its find rejects searches that combine conditions, so those calls need rtk proxy find. Three of my rules started as blocks, and the note on the RTK rule reads “325 blocks in three weeks, every one re-issued as rtk proxy find”. On 18 October I switched all three to rewrites. Before the switch they blocked 563 calls; each cost a turn. Since then, all 127 rewrites have run first time.
rtk proxy find for find searches that combine conditions; quote --include and --glob patterns; add the --actor flag to writes to my task tracker.hamster also ships the /handoff workflow from the session craft guide. To install hamster:
git clone https://github.com/kam/hamster.git && cd hamster
./install.sh
It needs python3 and git. Restart Claude Code after the install. A clone install names the commands with a hyphen (/hamster-test); a marketplace install uses a colon (/hamster:test).
The Applied AI agents I build for clients use the same idea: their hard limits live in code the model can’t skip.
Related reading
- Claude Code skills, hooks, subagents and commands: when to use each, where hooks fit among Claude Code’s primitives
- Session craft: when to start fresh, resume, or compact, the handoff workflow hamster bundles
- Quality gates: session reviews, fact-checking, and code review, hooks that review work rather than block it
- Local audio transcription with speaker labels in Claude Code, my other open-source Claude Code tool
- Claude Code for the full stack: marketing, DevOps, and beyond code, a hook that guards Terraform and AWS commands
Sources
- Hooks reference, Claude Code Docs, hook events, exit codes and
updatedInput. Documentation - hamster on GitHub, the plugin’s source, rule format and tests. Repository
- RTK, the command-rewriting token filter referred to above. Tool
Common questions
What is a Claude Code hook rule?
Hooks are commands Claude Code runs automatically on events. A hook rule is a pattern plus an action, checked by a PreToolUse hook before each tool call. When a call matches the pattern, the hook blocks it or hands Claude Code a corrected version to run.
Why does Claude Code ignore CLAUDE.md?
CLAUDE.md is text the model weighs against everything else in the session. In my sessions, Claude sometimes repeats a mistake that one line among 200 in CLAUDE.md already warns about. A rule that must hold every time belongs in a PreToolUse hook, which Claude Code runs before each matching tool call, whatever the model remembers.
How do you stop Claude Code repeating the same mistake?
Turn the lesson into a PreToolUse hook rule rather than another line in CLAUDE.md. A rule is a pattern over the tool call plus an action: block the call or rewrite it.
What is a keep-test for a Claude Code hook rule?
A pair of examples stored with the rule: one input the rule must catch and one it must let through. A blocking rule should not run until both pass; hamster, my plugin, refuses to install one that fails and runs a rule without tests as a warning only. The test stops a broad pattern from quietly blocking good commands.
Should a hook block a mistake or fix it?
Rewrite it when the correction is mechanical, such as quoting a glob or adding a missing flag; Claude Code gets the corrected call and Claude sees what changed. Block it when a person or the model has to change course, such as a force-push to main.
How do you keep hook rules from piling up?
Count every fire and retire the rules that never fire. hamster lists a user or project rule for deletion once it is 60 days old with no fires. Bundled safety rules and rules marked keep are exempt: a hard-safety rule that never fires is doing its job.