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 | Yes — exit 2 shows a message | Capture session state before memory loss |
SessionStart |
When a new session begins (including after compaction) | No — advisory only | Restore context from prior session |
Exit Code Semantics
Every hook communicates its result through its exit code:
- Exit 0 — pass. The operation proceeds (or the advisory message is silent).
- Exit 2 — block or message. For
PreToolUse, this blocks the tool call and shows the hook’s stderr as an error message. ForPreCompact, this shows the hook’s stdout as a visible message in the transcript but does not prevent compaction.
Any other exit code is treated as a hook failure and ignored.
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, andtimeout(in seconds).
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
"timeout": 5
}
]
}
]
}
}The environment variable $CLAUDE_PROJECT_DIR resolves to the project root at runtime. Always use it in hook commands so paths stay portable.
The Seven Hooks
1. protect-files.sh
Event: PreToolUse (matcher: Edit|Write) | Timeout: 5s | Blocking: Yes
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 scores 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
- Extracts the
file_pathfrom the tool’s input parameters - Compares the basename against each protected pattern using bash glob matching
- If a pattern matches, prints an error message to stderr and exits with code 2 (blocking the edit)
- If no pattern matches, exits with code 0 (allowing the edit)
Exit behavior
- Exit 0 — file is not protected, edit proceeds
- Exit 2 — file is protected, edit is blocked with the message:
"Protected file: <filename>. Edit manually or remove protection in .claude/hooks/protect-files.sh"
Customizing
Add or remove entries in the PROTECTED_PATTERNS array. The matching uses bash glob syntax on the basename only (not the full path), so "*.bib" would protect all .bib files regardless of directory.
For path-specific protection, replace the basename check with a full-path match:
# Protect a specific file by full path
if [[ "$FILE" == */paper/main.tex ]]; then
echo "Protected: main.tex. Edit manually." >&2
exit 2
fi2. session-guard.py
Event: PreToolUse (matcher: Edit|Write|Bash) | Timeout: 5s | Blocking: Yes
What it does
Two guards in one hook, both session-scoped and 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
The hook always exits 0. Blocking is communicated through stdout JSON ({"decision": "block", "reason": "..."}) rather than exit codes — this is the newer Claude Code hook protocol for Python hooks.
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 are session-scoped. They reset when the conversation ends.
3. post-edit-lint.sh
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
CLAUDE_TOOL_ARG_FILE_PATHenvironment variable (set by Claude Code) - 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
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.sh 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 — scans the most recent session log for decision markers (lines containing “Decision:”, “Decided:”, “Chose:”, or bullet points) and captures the last 3
- 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
- Appends a compaction note to the most recent session log in
quality_reports/session_logs/, documenting when compaction happened - 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 2, which makes the message visible in the transcript. This is intentional — the checklist serves as 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
- Checks the session type — only runs when the session type is
"compact"(not on fresh starts) - Reads and deletes the
pre-compact-state.jsonfile (one-time consumption) - Independently finds the most recent plan and session log (as a fallback if the state file is missing)
- Prints a restoration message with:
- Pre-compaction state (plan path, current task, recent decisions)
- Active plan file and status
- Session log location
- Recovery action steps
Recovery actions printed
Recovery Actions:
1. Read the active plan to understand current objectives
2. Check git status/diff for uncommitted changes
3. Continue from where you left off
Exit behavior
Always exits 0. The restoration message appears in Claude’s context as informational output.
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. post-merge.sh
Event: Git post-merge (not a Claude Code hook) | Timeout: N/A | Blocking: No
What it does
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."How it works
Git hooks live in .git/hooks/ by default, but this script is stored in .claude/hooks/ alongside the Claude Code hooks for organizational convenience. To activate it, symlink or copy it to .git/hooks/post-merge.
Why it exists
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": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh",
"timeout": 5
}
]
},
{
"matcher": "Edit|Write|Bash",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-guard.py",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-edit-lint.sh",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-compact.py",
"timeout": 10
}
]
}
],
"SessionStart": [
{
"matcher": "compact|resume",
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/post-compact-restore.py",
"timeout": 5
}
]
}
]
}
}Structure breakdown
- Top-level keys are event names:
PreToolUse,PostToolUse,PreCompact,SessionStart. - Each event maps to an array of hook groups. Multiple groups on the same event fire sequentially.
- 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 this
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). For PostToolUse, the edited file path is also available in $CLAUDE_TOOL_ARG_FILE_PATH.
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="${CLAUDE_TOOL_ARG_FILE_PATH:-}"
[[ -z "$FILE" ]] && exit 0
# Only fire on .tex files
case "$FILE" in
*.tex) ;;
*) exit 0 ;;
esac
echo "LaTeX file edited: $FILE"
echo "Remember to compile with: cd paper && latexmk main.tex"
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.sh 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:
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: Pre-commit quality check
A hook that runs the linter before every git commit command:
#!/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
}
]
}