# Hooks Guide

Hooks are shell scripts that run automatically at specific points in Claude Code's workflow. Unlike CLAUDE.md instructions (which are advisory), hooks are **deterministic** — they always execute.

Philosophy: **Use prompts for guidance. Use hooks for behavior that should run every time.** When a rule contains "always", "never", "block", "record", "run", or "verify", it belongs in a hook.

---

## Included Hooks

### SessionStart (runs at session start — and again after each compaction)

SessionStart fires with a `source`: `startup` / `resume` / `clear` for a fresh session, and `compact` mid-session right after a context compaction.

| Hook | File | What it does |
|------|------|-------------|
| **session-start** | `.claude/hooks/session-start.sh` | **On a new session** (`startup`/`resume`/`clear`): auto-injects Tier 1 context — confirms CODEBASE_MAP/CLAUDE.project presence, top rules from `tasks/lessons/_index.md`, active task from `tasks/todo.md`, current git branch, and **working-tree status** (modified/untracked counts + branch-ahead distance when dirty, with a "plan check" nudge). Resets the transient per-session state files (quality-gate results are kept — they carry their session — and pruned after 7 days). Silent on clean trees. **After a compaction** (`source=compact`): re-injects the working anchors the summary may have blurred — active task, top rules, any active `tasks/*_CONTRACT.md`, and the session journal — and does **not** reset session state (counters and the session clock must survive the compaction). This is the deterministic half of CLAUDE.md → After Compaction; `SessionStart(compact)` is the only compaction-time event whose `additionalContext` reaches the model (`PreCompact`/`PostCompact` cannot inject context). |

### UserPromptSubmit (runs before the model sees each user prompt)

| Hook | File | What it does |
|------|------|-------------|
| **prompt-router** | `.claude/hooks/prompt-router.sh` | Keyword-based context injection. If the prompt mentions auth, billing, migrations, deploy, or dependencies, it injects a one-line reminder for that domain. |
### PreToolUse (runs BEFORE a tool executes)

| Hook | File | What it does |
|------|------|-------------|
| **protect-files** | `.claude/hooks/protect-files.sh` | Blocks edits to `.env`, credentials, private keys, lock files. **Secret protection.** |
| **protect-changes** | `.claude/hooks/protect-changes.sh` | Blocks edits to dependency manifests, migrations, auth/security paths, and core build configs unless `CLAUDE_APPROVED=1`. **Architectural protection.** Enforces CLAUDE.md → Protected Changes. |
| **branch-protect** | `.claude/hooks/branch-protect.sh` | Blocks direct push to `main`/`master` and force pushes |
| **block-dangerous-commands** | `.claude/hooks/block-dangerous-commands.sh` | Blocks `rm -rf /`, `git reset --hard`, `DROP TABLE`, etc. |
| **conventional-commit** | `.claude/hooks/conventional-commit.sh` | Enforces conventional commit message format |
| **commit-gate** | `.claude/hooks/commit-gate.sh` | Matcher `Bash`. Meets a `git commit` with the verdict the gate already reached: a failed or unrecorded check (`lib/gate-state.sh`, the same classifier `stop-gate.sh` reads) or a test still red (`lib/test-run-log.sh`). **Warns by default** through `additionalContext` and **blocks (exit 2) under the strict profile** (`CCK_TEST_FIRST=strict`) — Clean Check-in: a commit never carries work whose checks did not pass. Reads and never re-verifies: a *stale* record (the file changed since its last check) is mentioned and never blocks, because re-verifying belongs to `stop-gate.sh`, which has a budget for it. Answers for the payload's `session_id`, fail-closed like the other gates. Bypass with `SKIP_TEST_FIRST=1` or `SKIP_QUALITY_GATE=1` — which is also how a scripted commit (a release script, a rebase helper) gets through. |
| **glob-guidance** | `.claude/hooks/glob-guidance.sh` | Matcher `Edit\|Write\|NotebookEdit`. **Non-blocking** path-scoped nudge for cross-cutting file patterns (test files, migrations) that don't map to one directory where a subdir `CLAUDE.md` would suffice. One-shot per pattern per session via `.hook-state/glob-guidance-fired`; emits to stderr (the PreToolUse feedback channel) and always exits 0. Customise the case table in the script. |
| **mcp-gate** | `.claude/hooks/mcp-gate.sh` | Matcher `mcp__.*`. MCP supply-chain / prompt-injection governance. Blocks (`exit 2`) any `mcp__<server>__<tool>` call whose `<server>` is absent from `.claude/mcp-allowlist.txt`. **Inert until you create that allowlist** — with no file it never blocks, only reminds once per session that MCP results are untrusted input. Copy `.claude/mcp-allowlist.txt.example` to turn enforcement on; audit with `/mcp-audit`. |

### PostToolUse (runs AFTER a tool executes)

| Hook | File | Matcher | What it does |
|------|------|---------|-------------|
| **secret-scan** | `.claude/hooks/secret-scan.sh` | `Edit\|Write\|NotebookEdit` | Scans edited files for API keys, tokens, passwords |
| **unicode-scan** | `.claude/hooks/unicode-scan.sh` | `Edit\|Write\|NotebookEdit` | Detects invisible Unicode (Glassworm vector) |
| **loop-detect** | `.claude/hooks/loop-detect.sh` | `Edit\|Write\|NotebookEdit` | Warns at 4 edits, blocks at 6 edits to the same file |
| **quality-gate** | `.claude/hooks/quality-gate.sh` | `Edit\|Write\|NotebookEdit` | Runs a fast typecheck/lint (or `bash -n`; for C#/.NET, `dotnet build` of the nearest project — 120s limit unless `CCK_QUALITY_GATE_TIMEOUT` is set) after Edit/Write and records the result per file in `.hook-state/quality-gate-state.json`, with `.hook-state/last_quality_gate.json` as the summary. Statuses: `passed`, `failed`, `timeout`, `error` (command not found or not executable, invalid `commands.json`), and `skipped` with a reason (no check for the file type, tool not installed) — a skipped code file is reported as NOT verified, never as passed. Docs, config and markup files aren't gated. Does NOT block — `stop-gate.sh` does the blocking based on the persisted result. Failures and unverified files reach Claude as PostToolUse `additionalContext`; stderr from a hook that exits 0 only reaches the debug log. If `.claude/commands.json` declares `typecheck`/`lint`, runs the declared command instead of guessing (single source of truth). A key set to `""` turns that check off (the edit is recorded as `skipped`, NOT verified); `timeout` sets the per-check limit; an unknown key or a non-string command is a config `error`. The check runs in the file's package root (nearest `package.json` / `pyproject.toml` / `go.mod` / `Cargo.toml` / `*.csproj` / `*.sln` …, stopping at the git worktree); the result is stored in the project's `.hook-state/` — or, for a file in another git worktree of the same repo, in that worktree's (`lib/roots.sh`). A check that runs past `CCK_QUALITY_GATE_TIMEOUT` (30s) is killed with its whole process group and recorded as `timeout`; what a finished check leaves running is left alone (build servers stay warm), and its output goes to a file, so a leftover can't hold the hook. `commands.json` applies only to files under the project root — a file outside it is auto-detected. Each result carries the payload's `session_id`. Without a usable python3 results go to a plain per-file log, and a `.py` edit with neither ruff nor a working python3 is `skipped`. If a result can't be recorded (unreadable or unwritable state, a full disk), the hook exits 2 — its stderr reaches Claude — and the file blocks stop until a result for it is recorded. |
| **test-first** | `.claude/hooks/test-first.sh` | `Edit\|Write\|NotebookEdit` | Records a source file edited with no test file behind it in `.hook-state/test-first-pending.tsv` and says so once per file through `additionalContext`. Never blocks. Candidate test paths come from language convention only (`lib/test-paths.sh`) — it never reads file content: sibling `*.test.*` / `*.spec.*` / `*_test.go` / `test_*.py` / `*Tests.cs` … and `__tests__` / `tests` / `spec` directories up to four levels up. Deliberately narrow: test files themselves, type declarations, config, stories, barrel `index.*`, generated and minified output, `migrations/`, `node_modules`-style vendor paths, and every type the quality gate treats as non-code are not asked about — nor are Rust (`#[cfg(test)]` lives inline) and shell (no convention to ask against). A record is dropped as soon as a test for it exists on disk, however it got there (Edit, Bash, codegen), so the list is a reminder of what to re-check, never the verdict. Lines carry the payload's `session_id`; records older than 7 days are pruned at session start. |
| **test-run** | `.claude/hooks/test-run.sh` | `Edit\|Write\|NotebookEdit` | Runs the ONE test covering the edited file and says what it did. Off unless the project declares `test-file` in `.claude/commands.json` with a `{file}` placeholder — nothing is guessed, because a guessed command is either the whole suite or the wrong test. The related test comes from the same convention `test-first.sh` uses (`lib/test-paths.sh`); an edit to a test file runs that test itself, so its first run can be red. Appends one line per run to `.hook-state/test-run-log.tsv` and never blocks: a failure, a timeout or a command error is reported through `additionalContext`, and the verdict at Stop belongs to `test-first-gate.sh`. A test whose FIRST run this session was green is called out too — a test you never saw fail is a test you have no reason to trust. Timeout `CCK_TEST_RUN_TIMEOUT`, else `commands.json → timeout`, else 60s; the process group is killed and the run logged as `timeout`. |
| **bash-budget** | `.claude/hooks/bash-budget.sh` | `Bash` | Estimates cumulative Bash output token cost per session (chars / 4). One-shot stderr warning when `$BASH_BUDGET_THRESHOLD` (default 50000) is first crossed. Does NOT block — observability only. Writes `.hook-state/bash-budget.json`. |
| **read-budget** | `.claude/hooks/read-budget.sh` | `Read` | Estimates cumulative file-read token cost per session (chars / 4). One-shot stderr warning when `$READ_BUDGET_THRESHOLD` (default 100000) is first crossed — nudges tiered/on-demand loading. Does NOT block. Writes `.hook-state/read-budget.json`. |

### PostToolUseFailure (runs AFTER a tool call fails)

| Hook | File | What it does |
|------|------|-------------|
| **tool-failure-observe** | `.claude/hooks/tool-failure-observe.sh` | Fires when a tool call errors (Bash non-zero exit, failed Edit, …). **Pure observability** — it cannot prevent the failure. Counts failures per session by tool in `.hook-state/tool-failures.json`; `session-end.sh` folds the total (`metrics.tool_failures`) into the scorecard so a thrashing session is visible. Always exits 0. |

### StopFailure (runs when a turn ends on an API error)

| Hook | File | What it does |
|------|------|-------------|
| **stop-failure-observe** | `.claude/hooks/stop-failure-observe.sh` | Fires only when a turn ends on an API-level error (rate limit, auth, server) — **not** on a deliberate `stop-gate` block. Its stdout/exit are ignored by Claude Code (notification/logging only), so it just records the API-error count + last message in `.hook-state/stop-failures.json`. The scorecard (`metrics.api_errors`) uses it to tell "died on infra" from "skipped work". |

### Stop (runs when Claude tries to finish a turn)

| Hook | File | What it does |
|------|------|-------------|
| **stop-gate** | `.claude/hooks/stop-gate.sh` | Blocks completion (exit 2) while any file edited this session lacks a passing check: failed, timed out, errored, or changed since its check (stale files are re-verified at stop, at most 3 check scopes). Answers for the Stop payload's `session_id` (a record or a stop without one counts everywhere), across every checkout the session touched: the payload's `cwd`, `CLAUDE_PROJECT_DIR`, and worktrees it stored results in (`.hook-state/quality-gate-roots`). Fails closed: a gate state that can't be read (a `python3` that doesn't run included), a `.hook-state` that isn't writable, a result that couldn't be recorded, a scope-wide failure whose files were renamed or deleted, and an error in the hook itself all block. Whether an unreadable state holds this session's records is decided per record — with jq, and without it in bash — so a record written before session scoping, or one whose session is unknown, still counts. Re-verification stops at `CCK_STOP_REVERIFY_BUDGET` (300s). Files no check covers are listed but don't block. Bypass with `SKIP_QUALITY_GATE=1` env var. Enforces CLAUDE.md → Verification (Mandatory Order). |
| **test-first-gate** | `.claude/hooks/test-first-gate.sh` | Blocks completion (exit 2) while a source file this session edited still has no test, or while a test this session ran is not green (`test-run-log.tsv`: the latest line for a test is its verdict, so red-then-green is a finished loop and does not block; an unreadable log is not an empty one and blocks) — **only under the strict profile** (`CCK_TEST_FIRST=strict`). In the standard profile it exits 0 without a word: the discipline is advisory there, and `test-first.sh` has already said it once. Re-asks the disk for every pending record before deciding, so a test written by any means clears it. Answers for the Stop payload's `session_id`, with the same fail-closed rule as `stop-gate.sh` (a record or a stop without one counts everywhere). Its failure trap is armed only in strict mode, so a bug in the hook can never block a standard-profile stop. Bypass with `SKIP_TEST_FIRST=1` — or `SKIP_QUALITY_GATE=1`, since a session that has turned the quality gate off isn't being held to the stricter rule. Enforces `agent_docs/testing.md` → Test-First. |
| **task-complete-notify** | `.claude/hooks/task-complete-notify.sh` | Desktop notification + sound on macOS/Linux. Runs AFTER stop-gate so failed gates don't trigger the success ping. |

### SessionEnd (runs when the session ends)

| Hook | File | What it does |
|------|------|-------------|
| **session-end** | `.claude/hooks/session-end.sh` | Appends a JSON audit line to `reports/session-audit.log` with session id, exit reason, and last quality-gate status. |
| **journal-fold** | `.claude/hooks/journal-fold.sh` | Consumes `.hook-state/session-journal.md` (populated mid-session by the `/note` skill). If `[finding]` or `[decision]` entries are present, folds them into `tasks/handoff-<session-id>.md`. If only `[summary]` entries, discards. Always removes the journal so the next session starts clean. Silent when no journal exists. |
| **next-red-test** | `.claude/hooks/next-red-test.sh` | Appends the still-red test — and the command that re-runs it (`commands.json → test-file`, `{file}` substituted) — to `tasks/handoff-<session-id>.md`, so the next session opens on the red bar instead of looking for it. Reads `test-run-log.tsv` through `lib/test-run-log.sh` (latest line per test wins, so red-then-green writes nothing). Silent and file-less when the bar is green, which keeps "a handoff exists ⇒ the session was interrupted" true. Derives the session id exactly as `journal-fold.sh` does, so both hooks of one session write one file. Never blocks. |

### Optional (installed but not enabled by default)

These hooks are included in the kit but **not enabled** in the standard profile. They can be slow or conflict with project-specific configs.

| Hook | File | Event | What it does |
|------|------|-------|-------------|
| **auto-lint** | `.claude/hooks/auto-lint.sh` | PostToolUse | Runs linter with --fix after file edits (eslint, ruff, gofmt, clippy, rubocop) |
| **auto-format** | `.claude/hooks/auto-format.sh` | PostToolUse | Runs formatter after file edits (prettier, black, gofmt, rustfmt) |
| **skill-compliance** | `.claude/hooks/skill-compliance.sh` | PostToolUse | Checks edited files against active skills and surfaces relevant checklists |
| **skill-extract-reminder** | `.claude/hooks/skill-extract-reminder.sh` | UserPromptSubmit | Reminds to extract reusable skills from session discoveries |
| **notify-waiting** | `.claude/hooks/notify-waiting.sh` | Notification | Pushes an out-of-terminal ping the moment Claude is **waiting** for you (input or a permission prompt) — so long autonomous runs (`/loop`, auto-mode, `/ship`) don't go dark. Local desktop notification (terminal-notifier / osascript / notify-send), silent if none present. Optional remote push (ntfy / Pushover) is **off by default**, opt-in via env. Strictly advisory — never blocks. Complements `task-complete-notify` (Stop = "done"; this = "waiting"). |

---

## State Files

Several hooks share state through transient files at the project root. Quality-gate results for files in another git worktree of the same repository go to that worktree's own `.hook-state/` instead, so parallel worktrees never block or clear each other (see `agent_docs/worktrees.md`). These are **self-gitignored** (the hook writes a local `.gitignore` inside the directory the first time it creates state). You don't need to add them to your project's root `.gitignore`.

| File | Written by | Read by | Purpose |
|------|-----------|---------|---------|
| `.hook-state/quality-gate-state.json` | `quality-gate.sh` | `stop-gate.sh` | Per-file results (`schema_version` 2): `runs[scope]` — latest result of each check scope `{command, kind, status, exit_code, reason, at, duration_s}`; `files[path]` — `{scope, hash, at, sessions}`, or `{status: "skipped", reason}` when no check applies. A file is verified while its scope's latest run passed and its sha256 is unchanged. Each run gets a number (`seq`, `runs[scope].started`) and a snapshot of its scope's file hashes at start (`pending`): a run records nothing once a later run of its scope has started, and a scope-wide pass re-covers only files unchanged since it started. Written under an exclusive lock (`quality-gate-state.json.lock`) through a unique temp file and a rename. A file that can't be parsed blocks stop — it is never read as empty. Kept across sessions and compactions (stop-gate reads only its own session's records); records older than 7 days are pruned at session start. |
| `.hook-state/last_quality_gate.json` | `quality-gate.sh` | `stop-gate.sh` (fallback), `session-end.sh` | Summary: the latest run `{session_id, exit_code, tool, edited_file, duration_seconds, stderr_tail, last_run_status, reason}` plus the overall verdict — `status` is `failed` / `timeout` / `error` when any file blocks, else `stale`, `passed` or `skipped` — and `blocking_files`, `stale_files`, `unverified_files`. Consulted only when no v2 state exists; `session-start.sh` clears it on a new session when it carries no `session_id` (a pre-v2 summary would otherwise count as every session's and block a fresh session's first stop). |
| `.hook-state/quality-gate-unrecorded` | `quality-gate.sh` | `stop-gate.sh` | `session<TAB>file<TAB>epoch` per line: files whose gate result could not be written to the state. Each blocks its session's stop until a record for it exists. When not even this can be written, the line goes to `${TMPDIR:-/tmp}/cck-gate-<key of the project>` (`session<TAB>root<TAB>file<TAB>epoch`). |
| `.hook-state/quality-gate-files.tsv` | `quality-gate.sh` (no usable python3) | `stop-gate.sh` | Plain per-file log, one line per run: `session, file, status, stamp, kind, scope, epoch, detail` (tab-separated, `-` for empty). The latest line per file wins; a file whose stamp (`cksum`, else mtime) changed is re-verified. `v2` lines hand a file over to `quality-gate-state.json`. |
| `.hook-state/quality-gate-roots` | `quality-gate.sh` | `stop-gate.sh` | In `CLAUDE_PROJECT_DIR`: `session<TAB>root<TAB>epoch` for every other git worktree this session stored a result in, so its stop checks those too. |
| `.hook-state/test-first-pending.tsv` | `test-first.sh` | `test-first-gate.sh` | `session<TAB>file<TAB>epoch` per line: source files edited with no test behind them. Written at the project root only (`CLAUDE_PROJECT_DIR`, else the hook's `PWD`) — a reminder doesn't need the worktree routing the gate's verdict does. Both readers re-check the disk, so a stale line costs nothing; lines older than 7 days are pruned at session start. |
| `.hook-state/test-run-log.tsv` | `test-run.sh` | `test-first-gate.sh`, `commit-gate.sh`, `next-red-test.sh` (all via `lib/test-run-log.sh`) | `session<TAB>test<TAB>source<TAB>status<TAB>epoch<TAB>detail` per line, paths relative to the project root, `source` = `-` when the edit was to the test itself. Append-only on purpose: the latest line for a test is its verdict and the earlier ones are its history, which is what answers "did this test ever go red?" without a second state file. Kept out of the quality gate's own state because that state holds one check scope per path — recording a test result there would overwrite the gate's verdict for the same test file. Lines older than 7 days are pruned at session start. |
| `.hook-state/bash-budget.json` | `bash-budget.sh` | `session-end.sh` (scorecard) | Cumulative Bash output token estimate for the session: `{schema_version, cumulative_tokens, threshold, warned, since_session_start, by_command_top5}` |
| `.hook-state/read-budget.json` | `read-budget.sh` | `session-end.sh` (scorecard) | Cumulative file-read token estimate for the session: `{schema_version, cumulative_tokens, threshold, warned, since_session_start, by_file_top5}` |
| `.hook-state/quality-gate-history.json` | `quality-gate.sh`, `stop-gate.sh` | `session-end.sh`, `/scorecard` | Per-session cumulative quality-gate metrics: `{runs, failures, last_status, last_tool, skip_gate_used}`. `skip_gate_used` is incremented by `stop-gate.sh` when the agent bypasses the gate. |
| `.hook-state/verification-ledger.json` | `quality-gate.sh` | `stop-gate.sh`, `/verification-status`, `/ship` | Append-only per-task verification evidence: `{schema_version, entries[{at, tool, status, exit_code, file, duration_s, reason?, scope?}], smoke_test, silent_failures, coverage}` (last 50). Edits no check could cover are logged too, as `skipped` with their reason. Auto-gates written by `quality-gate.sh`; manual slots (smoke test, silent-failure tally) filled via `/verification-status`. |
| `.hook-state/hook-firings.json` | every blocking hook (on `exit 2`) | `session-end.sh`, `/scorecard` | Per-session block counters: `{"protect-files": N, "protect-changes": N, "branch-protect": N, "block-dangerous-commands": N, "mcp-gate": N, "stop-gate": N}`. Reset by `session-start.sh` on a new session. |
| `.hook-state/glob-guidance-fired` | `glob-guidance.sh` | (self) | Plain text, one pattern-id per line (`tests`, `migrations`, …). One-shot ledger so each cross-cutting nudge fires once per session. Removed by `session-start.sh` on a new session. |
| `.hook-state/mcp-banner-fired` | `mcp-gate.sh` | (self) | Empty marker; presence means the once-per-session "MCP output is untrusted" reminder has fired. Removed by `session-start.sh` on a new session. |
| `.hook-state/tool-failures.json` | `tool-failure-observe.sh` | `session-end.sh` (scorecard) | Per-session tool-failure tally: `{schema_version, cumulative, by_tool}`. Reset by `session-start.sh`. |
| `.hook-state/stop-failures.json` | `stop-failure-observe.sh` | `session-end.sh` (scorecard) | Per-session API-error tally: `{schema_version, count, last_error}`. Reset by `session-start.sh`. |
| `.hook-state/session-meta.json` | `session-start.sh` | `session-end.sh` | Identity for the in-progress session: `{session_id, started_at, started_at_epoch}`. Used to compute `session_duration_seconds` and the mtime cutoff for `lessons_added` / `decisions_added`. |
| `reports/session-audit.log` | `session-end.sh` | `/scorecard`, operator review | One JSON line per session. **schema_version 2** records contain a `metrics` object (edits, blocks_fired, quality_gate, lessons_added, decisions_added, bash_token_estimate, compactions_observed, session_duration_seconds). v1 records (just identifiers + `last_quality_gate`) remain parseable. |

The transient `.hook-state/*` counters are reset on every new session (`session-start.sh`) so they reflect only the current session; quality-gate results are not — they are per session already, and pruned after 7 days. The audit log is append-only across sessions — `/scorecard` aggregates over the requested window.

Both directories are created on demand. Delete them anytime — the next hook run re-creates them.

**Persistence note**: results are per file. A failed `.py` stays failed until a check of that file — or of its whole scope, for project-wide checks such as `tsc` — passes; a pass on another file or an edit to a Markdown file doesn't clear it. A file that changes after its check (through Bash, a formatter, codegen) is stale, and `stop-gate.sh` re-verifies it instead of trusting the old pass. A file no check covers is recorded as `skipped` with a reason: reported as NOT verified, never counted as passed, and not blocking. Results carry their session and are kept across sessions and compactions; a stop answers only for its own session's files.

---

## Escape Hatches

Some hooks block actions or completion. When they get in the way (broken test infra, intentional hot-fix, etc.) use these environment variables:

| Variable | Effect |
|----------|--------|
| `CLAUDE_APPROVED=1` | `protect-changes.sh` skips its block. Record the rationale in `tasks/decisions.md` (ADR template) — that is the agreed audit trail. |
| `SKIP_QUALITY_GATE=1` | `stop-gate.sh` allows completion even with a failed gate. **For failures unrelated to your change only** (broken infra, intentional WIP) — not to walk past a red gate your own edit caused; that's gaming the gate (see `agent_docs/auto-mode.md → Don't let the loop game the gate`). Use sparingly; the failure is still recorded in `.hook-state/last_quality_gate.json`. |
| `CLAUDE_SKIP_QUALITY_GATE=1` | Alias for the above. |
| `SKIP_TEST_FIRST=1` | `test-first-gate.sh` allows completion, and `commit-gate.sh` a commit, with a test still missing or red. For work that legitimately has none — a refactor, a config edit, a spike — or tests the hook can't see (a separate repo, a path its conventions don't cover). Matters only under `CCK_TEST_FIRST=strict`; `SKIP_QUALITY_GATE=1` covers it too. |
| `CLAUDE_SKIP_TEST_FIRST=1` | Alias for the above. |
| `CCK_TEST_FIRST=strict` | Turns the test-first nudge into a block at both doors out of the loop: a stop (`test-first-gate.sh`) and a `git commit` (`commit-gate.sh`). Set by the strict profile's `settings.json → env`; unset or any other value leaves both advisory. |
| `CCK_TEST_RUN_TIMEOUT=<seconds>` | How long `test-run.sh` may spend on one test before its process group is killed and the run is logged as `timeout`. Falls back to `commands.json → timeout`, then 60s. |
| `CCK_QUALITY_GATE_TIMEOUT=<seconds>` | Per-check time limit for `quality-gate.sh` (default 30). Past it the check's whole process group is killed and the run is recorded as `timeout`, which `stop-gate.sh` blocks on like a failure. Raise it for a legitimately slow check (a large `tsc`, a cold build). |
| `CCK_STOP_REVERIFY_BUDGET=<seconds>` | Total time `stop-gate.sh` may spend re-verifying stale files (default 300, well under Claude Code's 600s Stop-hook timeout — a killed hook doesn't block). Stale files not re-verified in time block. |
| `BASH_BUDGET_THRESHOLD=<n>` | Overrides the default 50000-token threshold used by `bash-budget.sh`. Set to a high number (e.g. 999999999) to suppress the warning entirely; set lower to surface it earlier. |
| `READ_BUDGET_THRESHOLD=<n>` | Overrides the default 100000-token threshold used by `read-budget.sh` (cumulative file-read cost). Same semantics as `BASH_BUDGET_THRESHOLD`. |

Set per-session (`export CLAUDE_APPROVED=1`) or per-command (`CLAUDE_APPROVED=1 claude ...`). Never put these in committed config — they defeat the purpose.

---

## How It Works

Hooks are configured in `.claude/settings.json`:

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Edit|Write|NotebookEdit",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/protect-files.sh" },
          { "type": "command", "command": ".claude/hooks/protect-changes.sh" }
        ]
      }
    ]
  }
}
```

- **matcher**: which tools trigger the hook (regex pattern). Not used for SessionStart/UserPromptSubmit/SessionEnd/Stop.
- **exit 0**: allow the action
- **exit 2**: block the action (PreToolUse only) or block completion (Stop only)
- **stdout**: for SessionStart and UserPromptSubmit, valid JSON of the form `{"additionalContext": "..."}` injects context into the model.
- **stderr**: shown to Claude as feedback regardless of exit code.

The hook receives tool input as JSON via stdin.

---

## Hook Profiles

The installer supports three profiles (`--profile minimal|standard|strict`). Each profile enables a different set of hooks:

| Hook | minimal | standard | strict |
|------|:-------:|:--------:|:------:|
| session-start | ✓ | ✓ | ✓ |
| prompt-router | | ✓ | ✓ |
| protect-files | ✓ | ✓ | ✓ |
| protect-changes | | ✓ | ✓ |
| branch-protect | ✓ | ✓ | ✓ |
| block-dangerous-commands | ✓ | ✓ | ✓ |
| conventional-commit | | ✓ | ✓ |
| glob-guidance | | ✓ | ✓ |
| mcp-gate | | ✓ | ✓ |
| secret-scan | | ✓ | ✓ |
| unicode-scan | | ✓ | ✓ |
| loop-detect | | ✓ | ✓ |
| quality-gate | | ✓ | ✓ |
| bash-budget | | ✓ | ✓ |
| read-budget | | ✓ | ✓ |
| tool-failure-observe | | ✓ | ✓ |
| stop-failure-observe | | ✓ | ✓ |
| stop-gate | | ✓ | ✓ |
| test-first | | ✓ | ✓ |
| test-run | | ✓ | ✓ |
| test-first-gate | | ✓ | ✓ |
| commit-gate | | ✓ | ✓ |
| task-complete-notify | | ✓ | ✓ |
| session-end | | ✓ | ✓ |
| next-red-test | | ✓ | ✓ |
| auto-lint | | | ✓ |
| auto-format | | | ✓ |
| skill-compliance | | | ✓ |
| skill-extract-reminder | | | ✓ |
| notify-waiting | | | ✓ |

The repository's `.claude/settings.json` represents the **standard** profile. The strict profile is generated by `install.sh` at install time.

Three hooks ship in both profiles but only *block* in strict, through `env` flags the strict settings set: `protect-changes.sh` hard-blocks build-config edits under `CCK_PROTECT_BUILD_CONFIGS=1`, and `test-first-gate.sh` (a stop) and `commit-gate.sh` (a `git commit`) block under `CCK_TEST_FIRST=strict`. In the standard profile all three emit a non-blocking heads-up instead.

---

## Enabling / Disabling Hooks

### Disable a specific hook

Remove or comment out its entry in `.claude/settings.json`.

### Enable optional hooks

To enable auto-lint and auto-format, add to the `PostToolUse` section in `.claude/settings.json`:

```json
{
  "matcher": "Edit|Write",
  "hooks": [
    { "type": "command", "command": ".claude/hooks/auto-lint.sh" },
    { "type": "command", "command": ".claude/hooks/auto-format.sh" }
  ]
}
```

### Enable notify-waiting (out-of-terminal "agent is waiting" ping)

Enabled by default in the **strict** profile. To turn it on elsewhere, add a `Notification` section to `.claude/settings.json`:

```json
"Notification": [
  { "hooks": [ { "type": "command", "command": ".claude/hooks/notify-waiting.sh" } ] }
]
```

With no further config it only fires a **local desktop notification** (via `terminal-notifier`/`osascript` on macOS, `notify-send` on Linux) and is silent where none is installed (CI, headless servers). To also get an **out-of-band push** on another device, opt in with env vars — nothing leaves the machine unless one of these is set:

| Variable | Effect |
|----------|--------|
| `CCK_NOTIFY_NTFY_URL` | Full [ntfy](https://ntfy.sh) topic URL (e.g. `https://ntfy.sh/my-secret-topic`). The waiting message is POSTed there. |
| `CCK_NOTIFY_PUSHOVER_TOKEN` + `CCK_NOTIFY_PUSHOVER_USER` | [Pushover](https://pushover.net) app token **and** user key — both required, or the Pushover branch is skipped. |
| `CCK_NOTIFY_DRY_RUN=1` | Suppresses the real desktop popup and, instead of calling `curl`, prints the remote target(s) to stderr. Use it to verify your config without spamming yourself: `CCK_NOTIFY_DRY_RUN=1 CCK_NOTIFY_NTFY_URL=https://ntfy.sh/t echo '{"message":"test"}' \| .claude/hooks/notify-waiting.sh`. |

The hook is strictly advisory: it always exits 0 and never blocks or mutates tool flow, so a missing binary or an unreachable push endpoint can't stall a run.

### Make secret-scan block instead of warn

Edit `secret-scan.sh` and change the final `exit 0` to `exit 2`.

### Loosen protect-changes for a specific project

Add a project-specific override under `.claude/hooks/project/`. Project hooks are configured separately in settings and are never modified by kit upgrades. Example: a hook that exits 0 for `package.json` if the project owner has pre-approved auto-updates.

---

## Writing Your Own Hooks

### Template

```bash
#!/usr/bin/env bash
set -euo pipefail

INPUT=$(cat)
HOOK_LIB="$(cd "$(dirname "$0")/lib" 2>/dev/null && pwd)"
source "$HOOK_LIB/json-parse.sh"

TOOL_NAME=$(parse_json_field "tool_name")
FILE_PATH=$(parse_json_field "file_path")

# Your logic here

exit 0  # allow (or exit 2 to block in PreToolUse / Stop)
```

### Output JSON (SessionStart, UserPromptSubmit)

For context-injecting hooks, write JSON to stdout:

```bash
printf '%s' "$context" | python3 -c \
  'import json,sys; print(json.dumps({"additionalContext": sys.stdin.read()}))'
```

The kit uses `python3` for safe JSON construction, falling back to `jq`, then to manual bash escaping. Match this pattern.

### Tips

- Keep hooks fast — they run on every tool call. Quality-gate runs each check under a 30s limit through `lib/run-with-timeout.sh`, which kills the whole process group on timeout (and when the hook is signalled) — use it in any hook that spawns a tool. Probe python3 with `lib/python3.sh` (`python3_usable`), not `command -v`: a stub that exists but can't run must count as absent. It caches a working python3 in `$TMPDIR` (never a broken one), so call it once at the top of a hook.
- Use `exit 0` for pass, `exit 2` for block
- Output to stderr is shown to Claude as feedback regardless of exit code
- Output to stdout in JSON form (for SessionStart/UserPromptSubmit) is parsed by Claude Code and injected as context
- For Stop hooks: avoid infinite loops. If you block, make sure the condition can become false (e.g., read a state file, don't re-evaluate the same condition forever).
- Test hooks manually: `echo '{"tool_name":"Edit","tool_input":{"file_path":".env"}}' | .claude/hooks/your-hook.sh`
- For hooks that read state, fall back gracefully when the state file doesn't exist (e.g., a fresh checkout).
