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. For PreCompact, 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. For PreToolUse and PostToolUse, it matches tool names (e.g., "Edit|Write"). For SessionStart, 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 with type, command, and timeout (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

  1. Reads the tool input JSON from stdin
  2. Extracts the file_path from the tool’s input parameters
  3. Compares the basename against each protected pattern using bash glob matching
  4. If a pattern matches, prints an error message to stderr and exits with code 2 (blocking the edit)
  5. 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
fi

2. 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:

  1. Extract the target file_path from tool input
  2. If the path is inside .claude/, allow (always editable)
  3. Check against each entry in allowed_paths (resolved relative to project root)
  4. If the file matches an allowed path prefix, allow
  5. 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:

  1. Extract the command string from tool input
  2. 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
  1. If any pattern matches, block with: "CAREFUL MODE: Blocked '<description>'. Run /careful off to deactivate, or rephrase the command."
  2. 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

  1. Reads the edited file path from the CLAUDE_TOOL_ARG_FILE_PATH environment variable (set by Claude Code)
  2. Filters by extension — only runs on .R, .py, and .jl files
  3. Skips files inside .claude/ (hooks, agents, and other configuration scripts should not be linted as analysis code)
  4. Delegates to lint-scripts.sh for 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 project

What 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

  1. 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
  2. Recent decisions — scans the most recent session log for decision markers (lines containing “Decision:”, “Decided:”, “Chose:”, or bullet points) and captures the last 3
  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

  1. Checks the session type — only runs when the session type is "compact" (not on fresh starts)
  2. Reads and deletes the pre-compact-state.json file (one-time consumption)
  3. Independently finds the most recent plan and session log (as a fallback if the state file is missing)
  4. 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 required hooks array.
  • Each hook in the array specifies:
    • type — always "command" for shell/Python scripts
    • command — the script to execute, quoted to handle spaces in paths
    • timeout — 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.sh

Step 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 0

Step 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
        }
      ]
    }
  ]
}
Important

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 failures

Register it under PreToolUse with matcher "Bash":

{
  "matcher": "Bash",
  "hooks": [
    {
      "type": "command",
      "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/pre-commit-lint.sh",
      "timeout": 15
    }
  ]
}