Hooks & Automation
Event-driven scripts that run automatically
How Hooks Work
Claude Code fires hooks on specific events — before a tool runs, after a file is edited, before context gets compacted. Scripts in .claude/hooks/ register for these events and execute automatically when they fire. You never call them directly.
Hooks serve two purposes: guardrails (blocking operations that would damage the project) and automation (running checks or capturing state without manual intervention).
Event Types
| Event | When It Fires | Blocking? | Use Case |
|---|---|---|---|
PreToolUse |
Before Claude calls a tool (Edit, Write, Bash, etc.) | Yes — exit 2 blocks the tool call | Protect files from accidental edits |
PostToolUse |
After a tool call completes | No — advisory only | Lint code after edits |
PreCompact |
Before context compaction | No — a systemMessage shows the user a reminder |
Capture session state before memory loss |
SessionStart |
When a new session begins (including after compaction) | No — advisory only | Restore context from prior session |
Exit Codes and Output
Claude Code reads a hook’s exit code and any JSON it prints on stdout:
- Exit 0 — success. JSON on stdout is honored:
permissionDecision: "deny"blocks a tool call,additionalContextputs text in front of Claude, andsystemMessageshows the user a warning. Plain stdout reaches Claude only onSessionStart(andUserPromptSubmit); for other events it goes to the debug log. Stderr on exit 0 is never shown. - Exit 2 — blocking error. For
PreToolUseit blocks the tool call and shows stderr. - Any other non-zero exit, a timeout, or a script that can’t start — a hook failure. By default the action proceeds and the transcript shows the hook’s name with the first line of stderr. A hook that sets
"onFailure": "block"(Claude Code 2.1.295+) turns a failure into a block instead.
One trap: when python3 can’t find the script file it exits 2, so a hook whose .py file is missing or renamed already blocks every matching tool call, with or without onFailure.
Configuration
Hooks are registered in .claude/settings.json under the hooks key. Each event type maps to an array of hook groups, where each group has:
matcher— a regex pattern for filtering. ForPreToolUseandPostToolUse, it matches tool names (e.g.,"Edit|Write"). ForSessionStart, it matches session types (e.g.,"compact|resume"). Omit the matcher to fire on every event of that type.hooks— an array of hook definitions, each withtype,command,timeout(in seconds), and optionallyonFailure("continue", the default, or"block").
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.py",
"timeout": 5,
"onFailure": "block"
}
]
}
]
}
}The environment variable $CLAUDE_PROJECT_DIR resolves to the project root at runtime. Always use it in hook commands so paths stay portable.
The Hooks
1. protect-files.py
Event: PreToolUse (matcher: Edit|Write) | Timeout: 5s | Blocking: Yes | On failure: blocks
What it does
Some files should never be touched by an agent. Strategy memos after approval, referee reports from real journals, the hook configuration itself — these are inputs or infrastructure, not outputs. This hook enforces that boundary.
When Claude attempts an Edit or Write operation, the hook extracts the target file path from the tool input, checks the filename against a list of protected patterns, and blocks the operation if there is a match.
Protected patterns (default)
PROTECTED_PATTERNS = [
"settings.json",
"strategy-memo-*.md",
"referee-report-*.md",
"quality-score-*.json",
]These defaults protect:
settings.json— the hook configuration itself. Accidental edits here could disable all hooks.strategy-memo-*.md— approved strategy memos. The strategist-critic reviews these; Claude should not modify them after approval.referee-report-*.md— real referee reports. These are external inputs that Claude classifies and responds to, never rewrites.quality-score-*.json— critic scores. Separation of powers: creators cannot edit their own scores.
How it works
- Reads the tool input JSON from stdin and requires
tool_input.file_path(a missing path is a visible error) - Compares the basename against each protected pattern with shell-style glob matching (
fnmatch) - If a pattern matches, prints a
permissionDecision: "deny"with the reason, which blocks the edit - If no pattern matches, exits silently and the edit proceeds
Exit behavior
Exits 0 in both cases; the deny decision travels as JSON. The block message reads: "Protected file: <filename>. Edit it by hand, or remove the pattern '<pattern>' from .claude/hooks/protect-files.py."
Customizing
Add or remove entries in PROTECTED_PATTERNS. Matching is on the basename only (not the full path), so "*.bib" would protect all .bib files regardless of directory. For path-specific protection, compare hook.require("tool_input.file_path") against a full path in body().
2. session-guard.py
Event: PreToolUse (matcher: Edit|Write|Bash) | Timeout: 5s | Blocking: Yes | On failure: blocks
What it does
Two guards in one hook, both opt-in. When neither guard is active, the hook reads a single JSON file, finds nothing, and exits — zero overhead in the common case.
Freeze locks edits to a set of allowed directories. Invoke /freeze paper/ and every Write or Edit targeting a file outside paper/ gets blocked. The .claude/ directory is always exempt (we cannot freeze ourselves out of configuration changes).
Careful blocks destructive Bash commands. Invoke /careful and patterns like rm -rf, git reset --hard, and git push --force are intercepted before they execute. Non-destructive Bash commands pass through untouched.
Guard state
Both guards read from a single file: .claude/state/session-guards.json. The /freeze and /careful skills write this file; the hook only reads it.
{
"freeze": {
"active": true,
"allowed_paths": ["paper/", "scripts/"],
"activated_at": "2026-05-09T14:30:00",
"reason": "User invoked /freeze"
},
"careful": {
"active": true,
"activated_at": "2026-05-09T14:30:00",
"reason": "User invoked /careful"
}
}Freeze logic
When freeze.active is true and the tool is Edit or Write:
- Extract the target
file_pathfrom tool input - If the path is inside
.claude/, allow (always editable) - Check against each entry in
allowed_paths(resolved relative to project root) - If the file matches an allowed path prefix, allow
- Otherwise, block with:
"FREEZE ACTIVE: Edit blocked. File '<name>' is outside allowed paths: [<paths>]. Run /freeze off to deactivate."
Careful logic
When careful.active is true and the tool is Bash:
- Extract the
commandstring from tool input - Test against a list of destructive regex patterns:
| Pattern | What It Catches |
|---|---|
rm -(r\|f\|rf\|fr) |
Recursive/force delete |
git reset --hard |
Discard all uncommitted changes |
git push --force / git push -f |
Overwrite remote history |
git clean -f |
Delete untracked files |
git checkout -- . |
Discard all working tree changes |
git branch -D |
Force-delete branch |
DROP TABLE / DROP DATABASE |
SQL destruction |
chmod 777 |
Overly permissive permissions |
- If any pattern matches, block with:
"CAREFUL MODE: Blocked '<description>'. Run /careful off to deactivate, or rephrase the command." - If no pattern matches, allow
Exit behavior
When it can answer, the hook exits 0, and blocking is communicated through stdout JSON (hookSpecificOutput.permissionDecision: "deny" with a permissionDecisionReason) rather than exit codes — the current Claude Code protocol for PreToolUse decisions. When it cannot (for example, session-guards.json is not valid JSON), it exits 1, and "onFailure": "block" makes Claude Code block the call. Delete or fix the state file by hand to recover.
Activating and deactivating
| Command | Effect |
|---|---|
/freeze paper/ |
Only allow edits in paper/ |
/freeze scripts/ data/ |
Only allow edits in scripts/ and data/ |
/freeze off |
Deactivate freeze |
/careful |
Block destructive commands |
/careful off |
Deactivate careful mode |
Both guards stay on until you turn them off — across conversations too, since .claude/state/session-guards.json persists and nothing clears it automatically. If edits are blocked and you don’t remember freezing, check that file or run /freeze off.
3. post-edit-lint.py
Event: PostToolUse (matcher: Edit|Write) | Timeout: 10s | Blocking: No (advisory)
What it does
The coder-critic catches prohibited patterns, but it runs later in the pipeline. This hook gives Claude immediate feedback on violations — setwd(), missing set.seed(), sapply() — so it can fix them in the same editing pass rather than waiting for a review cycle.
How it works
- Reads the edited file path from the JSON payload Claude Code sends on stdin (
tool_input.file_path) - Filters by extension — only runs on
.R,.py, and.jlfiles - Skips files inside
.claude/(hooks, agents, and other configuration scripts should not be linted as analysis code) - Delegates to
lint-scripts.shfor the actual analysis - Returns any findings as
hookSpecificOutput.additionalContext, which is how aPostToolUsehook puts text in front of Claude (plain stdout only reaches the transcript)
Exit behavior
Always exits 0. Lint findings appear in Claude’s context as advisory information — they inform the agent but do not block the edit.
4. lint-scripts.sh
Not a hook itself — this is the linter that post-edit-lint.py calls. It can also be run standalone via /tools lint.
Usage
lint-scripts.sh <file> # Lint a single file
lint-scripts.sh <dir> # Lint all scripts in a directory (recursive)
lint-scripts.sh # Lint scripts/ in the current projectWhat it checks
The linter is a grep-based static analyzer organized by language. Every check maps to a specific rule from the coding standards.
Common checks (all languages)
| Severity | Pattern | Rule |
|---|---|---|
| HIGH | Absolute paths (/Users/, /home/, C:\\) |
INV-16: all paths relative via here() or equivalent |
R-specific checks
| Severity | Pattern | Replacement |
|---|---|---|
| HIGH | setwd() |
here() |
| HIGH | install.packages() |
renv |
| HIGH | Stochastic code without set.seed() |
Add set.seed() at top |
| MEDIUM | rm(list = ls()) |
Restart R |
| MEDIUM | T/F literals |
TRUE/FALSE |
| MEDIUM | sapply() |
vapply() or lapply() |
| MEDIUM | attach()/detach() |
Explicit references |
| MEDIUM | <<- global assignment |
Pass through arguments |
| MEDIUM | set.seed() after line 30 |
Move to top of script |
| MEDIUM | library(stargazer) |
modelsummary or fixest::etable |
| MEDIUM | library(plyr) |
data.table |
| LOW | require() |
library() |
| LOW | library() after line 30 |
Load packages at top |
| LOW | print() for status |
message() |
| LOW | 1:length(), 1:nrow() |
seq_len() or seq_along() |
Python-specific checks
| Severity | Pattern | Replacement |
|---|---|---|
| HIGH | os.chdir() |
pathlib.Path |
| HIGH | pip install in scripts |
requirements.txt |
| HIGH | Stochastic code without seed | np.random.default_rng(SEED) |
| MEDIUM | from X import * |
Explicit imports |
| MEDIUM | np.random.seed() |
np.random.default_rng(seed) |
| MEDIUM | Bare except: |
Catch specific exceptions |
| LOW | Import after line 30 | Put imports at top |
Julia-specific checks
| Severity | Pattern | Replacement |
|---|---|---|
| HIGH | cd() |
joinpath(@__DIR__, ...) |
| HIGH | Stochastic code without seed | MersenneTwister(SEED) |
| MEDIUM | eval/@eval at runtime |
Multiple dispatch |
| LOW | using/import after line 30 |
Put at top |
Output format
=== LINT REPORT ===
scripts/R/04_estimation.R (3 issues):
[HIGH] Line 12: setwd() -- use here() instead
[MEDIUM] Line 45: sapply() -- use vapply() or lapply()
[LOW] Line 67: print() -- use message() for status output
--- Summary ---
Files scanned: 1
Total issues: 3 (HIGH: 1, MEDIUM: 1, LOW: 1)
Status: 3 issue(s) found -- review before committing
Exit behavior
Always exits 0, even when issues are found. The linter is advisory — it reports problems but never blocks operations. The coder-critic uses these findings to inform its scoring.
5. pre-compact.py
Event: PreCompact | Timeout: 10s | Blocking: No (message only)
What it does
Compaction is the single biggest source of context loss in long sessions. This hook captures the current state before context compaction destroys it — the active plan, recent decisions, and a timestamp — so post-compact-restore.py can bring it back afterward.
What it captures
- Active plan — finds the most recent non-completed plan in
quality_reports/plans/, extracts its status (draft/approved/in-progress) and the first unchecked task - Recent decisions — the last three bullets under Decisions: in the newest
SESSION_REPORT.mdentry - Compaction timestamp — records when compaction occurred
Where it saves
State is written to a JSON file at ~/.claude/sessions/<project-hash>/pre-compact-state.json, where <project-hash> is an MD5 hash of the project directory. This location is outside the project tree, so it does not clutter the repository.
Side effects
- Prints a context survival checklist to the transcript as a reminder:
Context Survival Checklist:
[ ] Active plan saved to quality_reports/plans/
[ ] SESSION_REPORT.md updated
[ ] Open questions documented
Exit behavior
Always exits 0, so compaction is never blocked. The checklist reaches you as a systemMessage (stderr on exit 0 would go only to the debug log). It is a reminder to run /checkpoint before compaction if you have not already.
6. post-compact-restore.py
Event: SessionStart (matcher: compact|resume) | Timeout: 5s | Blocking: No (advisory)
What it does
Restores context after compaction by reading the state saved by pre-compact.py and printing it into the new session. Claude resumes with immediate awareness of the active plan, the current task, and the last three decisions made before compaction.
How it works
- Reads the
sourcefield of the SessionStart payload and runs only forcompactorresume(not on fresh starts) - Reads and deletes the
pre-compact-state.jsonfile (one-time consumption) - Finds the active plan, summarizes
quality_reports/pipeline_state.json, and reads the newestSESSION_REPORT.mdentry’s status - Prints a plain-text restoration message (no terminal color codes, since SessionStart stdout goes straight into Claude’s context) ending with: read the active plan and check
git statusbefore continuing
Exit behavior
Exits 0 when it restores (or has nothing to restore). An error exits 1 with a one-line notice; session start proceeds either way, since the hook keeps the default onFailure.
Why the pair exists
Without these hooks, Claude resumes after compaction with no memory of the active plan, current task, or recent decisions. The pre-compact/post-compact pair ensures continuity — the first hook captures state, the second restores it.
7. hook-health.py
Event: SessionStart (matcher: startup|resume) | Timeout: 5s | Blocking: No (silent when healthy)
What it does
Catches the failure a hook cannot report about itself: never running at all. At session start it checks three things and stays silent unless one fails:
- Every hook command in
.claude/settings.jsonandsettings.local.jsonpoints at a file that exists (and, for shell scripts, is executable). - Errors logged in
.claude/state/hook-log.jsonlsince the last check. - Liveness: a hook on
PreToolUseorPostToolUsethat has logged nothing across three or more logged sessions is configured but not running.
Exit behavior
Always exits 0. Problems go to you as a systemMessage and to Claude as additionalContext, with an instruction to mention them before other work.
8. Git hooks: pre-commit and post-merge
These are git hooks, not Claude Code hooks. They live in .githooks/; scripts/install-hooks.sh points git at that folder (git config core.hooksPath .githooks) once per clone.
pre-commit runs on every commit, including ones made outside Claude:
- blocks when
scripts/check_scaffold.pyfinds a broken reference, a missing hook file, or a weight that disagrees betweenpermissions.mdandquality-gates.json - blocks when
.claude/hooks/or settings changed andscripts/test_hooks.pyfails, or when enforcement scripts changed andscripts/test_scripts.pyfails - blocks on HIGH lint findings in staged analysis scripts (
setwd(), absolute paths, …) - prints paper-coherence findings for staged
.texfiles as advice; they block at/submit
Bypass with SKIP_GATE=1 git commit ... and say why in the message. It does not check the quality score; /tools commit does that once critics have scored.
post-merge
Event: Git post-merge | Blocking: No
A standard Git hook (not a Claude Code hook) that prints a reminder after any git merge or git pull to update the session report.
#!/bin/bash
echo "=== SESSION MERGED TO MAIN ==="
echo ""
echo "Remember to append a summary to SESSION_REPORT.md if you haven't already."Merges to main are significant project events that should be logged. This hook is a simple reminder — it does not enforce logging, just nudges.
Settings Configuration
The full hooks section of .claude/settings.json for this project:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.py",
"timeout": 5,
"onFailure": "block"
}
]
},
{
"matcher": "Edit|Write|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-guard.py",
"timeout": 5,
"onFailure": "block"
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-compact.py",
"timeout": 10
}
]
}
],
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/hook-health.py",
"timeout": 5
}
]
},
{
"matcher": "compact|resume",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-compact-restore.py",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-lint.py",
"timeout": 10
}
]
}
]
}
}Structure breakdown
- Top-level keys are event names:
PreToolUse,PostToolUse,PreCompact,SessionStart. - Each event maps to an array of hook groups. All hooks that match an event run in parallel, so one hook cannot rely on another having run first.
- Each group has an optional
matcher(regex) and a requiredhooksarray. - Each hook in the array specifies:
type— always"command"for shell/Python scriptscommand— the script to execute, quoted to handle spaces in pathstimeout— maximum execution time in seconds; the hook is killed if it exceeds thisonFailure—"block"on the two guards, so a crash, timeout, or unexpected exit code blocks the call; omitted (default"continue") on advisory hooks
Matcher behavior
PreToolUse/PostToolUse: the matcher tests against the tool name (Edit,Write,Bash, etc.)."Edit|Write"matches either tool."Edit|Write|Bash"matches all three.SessionStart: the matcher tests against the session type."compact|resume"fires only after compaction or session resumption, not on fresh starts.PreCompact: no matcher needed — there is only one compaction event.- Omitting the matcher means the hook fires on every event of that type.
Adding a Custom Hook
Step 1: Write the script
Create a new file in .claude/hooks/. Make it executable.
touch .claude/hooks/my-custom-hook.sh
chmod +x .claude/hooks/my-custom-hook.shStep 2: Write the logic
The script receives the tool input as JSON on stdin (for PreToolUse and PostToolUse); the edited file path is tool_input.file_path. To show Claude a message from a PostToolUse hook, print JSON with hookSpecificOutput.additionalContext.
Here is an example hook that warns when a LaTeX file is edited without compiling:
#!/bin/bash
# warn-latex-compile.sh --- Advisory reminder after LaTeX edits
FILE=$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("file_path",""))')
[[ -z "$FILE" ]] && exit 0
# Only fire on .tex files
case "$FILE" in
*.tex) ;;
*) exit 0 ;;
esac
MSG="LaTeX file edited: $FILE. Compile with: cd paper && latexmk main.tex"
python3 -c 'import json,sys; print(json.dumps({"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":sys.argv[1]}}))' "$MSG"
exit 0Step 3: Register in settings.json
Add an entry under the appropriate event type:
{
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/warn-latex-compile.sh",
"timeout": 5
}
]
}
]
}settings.json is a protected file by default. Edit it manually — the protect-files.py hook will block Claude from modifying it.
Step 4: Test
The simplest test is to trigger the event. For a PostToolUse hook on Edit|Write, just ask Claude to edit a file matching your hook’s filter. Watch for the hook’s output in the transcript.
For standalone testing of the script itself (and add a case to scripts/test_hooks.py so it stays tested):
echo '{"tool_name": "Edit", "tool_input": {"file_path": "paper/main.tex"}}' \
| CLAUDE_PROJECT_DIR="$(pwd)" .claude/hooks/my-custom-hook.sh
echo "Exit code: $?"Example: Lint before Claude commits
You don’t need this for clo-author itself: the git pre-commit gate in .githooks/pre-commit (see Git hooks) already blocks HIGH lint findings on every commit, including commits made outside Claude. The example shows how a Claude-side PreToolUse hook on Bash works.
A hook that runs the linter before every git commit command Claude issues:
#!/bin/bash
# pre-commit-lint.sh --- Lint all staged R/Python/Julia files before commit
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
# Only fire on Bash tool calls that are git commits
[[ "$TOOL" != "Bash" ]] && exit 0
[[ "$COMMAND" != git\ commit* ]] && exit 0
# Find staged script files
STAGED=$(git diff --cached --name-only --diff-filter=ACM \
| grep -E '\.(R|py|jl)$' || true)
if [[ -n "$STAGED" ]]; then
echo "Linting staged scripts before commit..."
for f in $STAGED; do
"$CLAUDE_PROJECT_DIR"/.claude/hooks/lint-scripts.sh "$f"
done
fi
exit 0 # Advisory only --- change to exit 2 to block on lint failuresRegister it under PreToolUse with matcher "Bash":
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-commit-lint.sh",
"timeout": 15
}
]
}