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, additionalContext puts text in front of Claude, and systemMessage shows the user a warning. Plain stdout reaches Claude only on SessionStart (and UserPromptSubmit); for other events it goes to the debug log. Stderr on exit 0 is never shown.
  • Exit 2 — blocking error. For PreToolUse it 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.

How clo-author Hooks Fail: Loudly

Every hook here runs through .claude/hooks/_hooklib.py, which gives each run one of three outcomes:

Outcome When What you see
acted The hook did its job (linted a file, blocked an edit, restored context) Its output
skipped Nothing to do (a .txt edit, no guard active) Nothing
error The hook could not do its job A one-line notice in the transcript: clo-author hook <name>: <reason>

What an error does to the action depends on the hook:

  • Advisory hooks (lint, restore, health, pre-compact) keep the default. The action proceeds and you see the notice.
  • Guards (protect-files.py, session-guard.py) set "onFailure": "block". A guard that cannot answer blocks the action, because a guard that fails open lets through exactly what it exists to stop. In a test, a corrupted /careful state file let writes through silently under the old setting; with "block" the write stopped and the error named the file.

The cost of failing closed: a broken guard also blocks the edit that would fix it. Fix it by hand in your editor — the error message and hook-health.py name the file. scripts/test_hooks.py and scripts/check_scaffold.py both fail if a PreToolUse hook in settings.json lacks "onFailure": "block".

A payload missing a field the hook depends on is an error, not a skip. Two hooks in this template once sat dead for months because they read a field Claude Code never sends, found it empty, and exited cleanly as if there were nothing to do. require() in _hooklib.py turns that case into a visible failure.

Every outcome is appended to .claude/state/hook-log.jsonl (gitignored). At session start, hook-health.py reads that log and speaks up when something is wrong. python3 scripts/test_hooks.py feeds every hook synthetic payloads and checks all three outcomes.

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, timeout (in seconds), and optionally onFailure ("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

  1. Reads the tool input JSON from stdin and requires tool_input.file_path (a missing path is a visible error)
  2. Compares the basename against each protected pattern with shell-style glob matching (fnmatch)
  3. If a pattern matches, prints a permissionDecision: "deny" with the reason, which blocks the edit
  4. 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:

  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

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

  1. Reads the edited file path from the JSON payload Claude Code sends on stdin (tool_input.file_path)
  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
  5. Returns any findings as hookSpecificOutput.additionalContext, which is how a PostToolUse hook 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 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 — the last three bullets under Decisions: in the newest SESSION_REPORT.md entry
  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

  • 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

  1. Reads the source field of the SessionStart payload and runs only for compact or resume (not on fresh starts)
  2. Reads and deletes the pre-compact-state.json file (one-time consumption)
  3. Finds the active plan, summarizes quality_reports/pipeline_state.json, and reads the newest SESSION_REPORT.md entry’s status
  4. 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 status before 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:

  1. Every hook command in .claude/settings.json and settings.local.json points at a file that exists (and, for shell scripts, is executable).
  2. Errors logged in .claude/state/hook-log.jsonl since the last check.
  3. Liveness: a hook on PreToolUse or PostToolUse that 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.py finds a broken reference, a missing hook file, or a weight that disagrees between permissions.md and quality-gates.json
  • blocks when .claude/hooks/ or settings changed and scripts/test_hooks.py fails, or when enforcement scripts changed and scripts/test_scripts.py fails
  • blocks on HIGH lint findings in staged analysis scripts (setwd(), absolute paths, …)
  • prints paper-coherence findings for staged .tex files 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 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
    • onFailure — "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.sh

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

Note

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