Claude Code · Hook Cookbook
18 Hooks, End-to-End
Every script, every settings.json entry, every catch — copy and wire.
1 How Hooks Work
The three-line mental model before you write a single script.
Claude Code hooks are shell scripts executed by the harness on tool events. The harness passes JSON on stdin; the script reads it, inspects the action, and decides what to do. Three event types, three different powers:
- PreToolUse + exit 2 = BLOCK. The action never runs. Use this for safety gates: destructive commands, force pushes, commits with broken builds. Exit 0 means "allow." Any non-zero exit other than 2 is treated as an error but still allows the action.
- PostToolUse = INFORM. The write is already on disk. Exit 2 has no blocking effect — the harness ignores it. Use PostToolUse for feedback loops: build checks, lint output, validation results.
- UserPromptSubmit = INJECT. Fires at the start of every turn before Claude sees the message. Use it to prepend situational context: git branch, dirty file count, drift status.
The settings.json structure that wires a hook:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "/path/to/hook.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "/path/to/hook.sh" }]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [{ "type": "command", "command": "/path/to/hook.sh" }]
}
]
}
}
The matcher field is a regex applied to the tool name. "Bash" fires on every Bash call. "Edit|Write" fires on file edits and file writes. "" (empty string) matches everything — use it for UserPromptSubmit and Stop hooks that should always fire. "Agent" fires when sub-agents are dispatched.
PostToolUse exit 2 does nothing. A common mistake: writing a PostToolUse hook that exits 2 expecting to undo a write. The write is already on disk. The harness ignores exit 2 in PostToolUse. If you want to prevent a write, use PreToolUse on the Write or Edit tool instead.
Reading stdin. Every hook receives a JSON blob on stdin. The reliable parse pattern: INPUT=$(cat) then python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))". The outer key is always tool_input; the inner keys vary by tool (command for Bash, file_path for Edit/Write).
2 The Hook Lifecycle
Where each hook fires in a typical edit-commit-tag session.
.go file*_test.go file.svelte fileSKILL.mdgit commitgit tag3 Full Hook Catalog
18 hooks grouped by event type. Each card shows the full script, exact settings.json wiring, and what it catches.
PreToolUse — Blocking
BLOCKS on exit 2 exit 2 stops the action; exit 0 allows itdestructive-guard.sh
Guards against the four most common irreversible commands: rm -rf on root or home paths, git reset --hard with no ref, git checkout -- ., and git clean -f with no path scope.
Script — ~/.claude/hooks/destructive-guard.sh
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" \
2>/dev/null || echo "")
# Block rm -rf on root-level or home-directory paths
if echo "$CMD" | grep -qE 'rm\s+-[rf]+\s+(/[^/[:space:]]*[[:space:]]*$|~/[[:space:]]*$|~[[:space:]]*$)'; then
echo "BLOCKED: rm -rf on a root-level or home path. Use a more specific path." >&2
exit 2
fi
# Block git reset --hard with no ref (nukes working tree silently)
if echo "$CMD" | grep -qE 'git\s+reset\s+--hard\s*$'; then
echo "BLOCKED: git reset --hard with no ref. Specify a commit ref or use git stash." >&2
exit 2
fi
# Block git checkout -- . (discards all unstaged changes)
if echo "$CMD" | grep -qE 'git\s+checkout\s+--\s+\.'; then
echo "BLOCKED: git checkout -- . discards all unstaged changes. Use git stash instead." >&2
exit 2
fi
# Block git clean -f with no path scope
if echo "$CMD" | grep -qE 'git\s+clean\s+-[ffdx]+\s*$'; then
echo "BLOCKED: git clean -f with no path scope. Add a target directory." >&2
exit 2
fi
exit 0
settings.json wiring
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/destructive-guard.sh" }]
}
]
}
}
What it catches:
rm -rf /,rm -rf ~,rm -rf ~/git reset --hard(no ref — resets to HEAD silently discarding all changes)git checkout -- .(wipes unstaged edits across entire repo)git clean -ffdwith no path (deletes all untracked files)
force-push-guard.sh
A force push to main or master is an immediate exit 2. Force push to any other branch prints a warning but allows it through (exit 0).
Script — ~/.claude/hooks/force-push-guard.sh
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" \
2>/dev/null || echo "")
# Check if this is a force push
if ! echo "$CMD" | grep -qE 'git\s+push.*(--force|-f)(\s|$)'; then
exit 0
fi
# Extract target branch if present
BRANCH=$(echo "$CMD" | grep -oE '(origin|upstream)\s+[a-zA-Z0-9/_-]+' | awk '{print $2}' | head -1)
# Hard block on main/master
if echo "$BRANCH" | grep -qE '^(main|master)$'; then
echo "BLOCKED: force push to ${BRANCH} is not allowed." >&2
echo "If you truly need this, run the git push command manually in a terminal." >&2
exit 2
fi
# Warn on any other branch
if [ -n "$BRANCH" ]; then
echo "WARNING: force pushing to branch '${BRANCH}'. This rewrites remote history." >&2
echo "Continuing — but verify with the team before doing this on shared branches." >&2
else
echo "WARNING: force push detected without clear target branch. Allowing — verify intent." >&2
fi
exit 0
settings.json wiring
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/force-push-guard.sh" }]
}
]
}
}
git push origin main --force— hard blockedgit push --force-with-lease origin master— hard blockedgit push origin feature/x -f— warned, allowed
pre-commit-build-gate.sh
Intercepts every git commit call. Walks staged Go files, deduplicates by go.mod root, runs go build ./... in each module, and blocks if any fail. Only triggers when Go files are actually staged — zero cost on TypeScript-only commits.
Script — ~/.claude/hooks/pre-commit-build-gate.sh
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" \
2>/dev/null || echo "")
# Only run on git commit commands
echo "$CMD" | grep -qE 'git\s+commit' || exit 0
# Get staged Go files
STAGED_GO=$(git diff --cached --name-only 2>/dev/null | grep '\.go$' || true)
[[ -n "$STAGED_GO" ]] || exit 0
CHECKED_ROOTS=""
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
FAILED=""
while IFS= read -r gofile; do
DIR="$REPO_ROOT/$(dirname "$gofile")"
MOD_ROOT="$DIR"
# Walk up to find go.mod
while [[ "$MOD_ROOT" != "/" && ! -f "$MOD_ROOT/go.mod" ]]; do
MOD_ROOT=$(dirname "$MOD_ROOT")
done
[[ -f "$MOD_ROOT/go.mod" ]] || continue
# Skip already-checked module roots
echo "$CHECKED_ROOTS" | grep -qF "$MOD_ROOT" && continue
CHECKED_ROOTS="$CHECKED_ROOTS
$MOD_ROOT"
RESULT=$(cd "$MOD_ROOT" && go build ./... 2>&1) || true
if [[ -n "$RESULT" ]]; then
FAILED="$FAILED\n[$MOD_ROOT]\n$RESULT"
fi
done <<< "$STAGED_GO"
if [[ -n "$FAILED" ]]; then
echo "BLOCKED: go build ./... failed — fix before committing:" >&2
echo -e "$FAILED" >&2
exit 2
fi
exit 0
settings.json wiring
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/pre-commit-build-gate.sh" }]
}
]
}
}
- Type mismatch in staged Go file caught before commit lands
- Undefined function reference from a parallel edit in another file
- Import cycle introduced in a refactor
- Multi-module monorepo: each module checked independently
pre-commit-multi-lang-gate.sh
Same pattern as the Go gate but covers TypeScript (tsc --noEmit) and Rust (cargo check). Runs only when the relevant file types are staged. Complements the Go gate — wire both in the same matcher.
Script — ~/.claude/hooks/pre-commit-multi-lang-gate.sh
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" \
2>/dev/null || echo "")
echo "$CMD" | grep -qE 'git\s+commit' || exit 0
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
FAILED=""
# ── TypeScript ──
STAGED_TS=$(git diff --cached --name-only 2>/dev/null | grep -E '\.(ts|tsx)$' || true)
if [[ -n "$STAGED_TS" ]]; then
# Find tsconfig.json searching up from the first changed file
FIRST_DIR="$REPO_ROOT/$(dirname "$(echo "$STAGED_TS" | head -1)")"
TSC_ROOT="$FIRST_DIR"
while [[ "$TSC_ROOT" != "/" && ! -f "$TSC_ROOT/tsconfig.json" ]]; do
TSC_ROOT=$(dirname "$TSC_ROOT")
done
if [[ -f "$TSC_ROOT/tsconfig.json" ]]; then
TS_RESULT=$(cd "$TSC_ROOT" && npx tsc --noEmit 2>&1) || true
if [[ -n "$TS_RESULT" ]]; then
FAILED="$FAILED\n[TypeScript: $TSC_ROOT]\n$TS_RESULT"
fi
fi
fi
# ── Rust ──
STAGED_RS=$(git diff --cached --name-only 2>/dev/null | grep '\.rs$' || true)
if [[ -n "$STAGED_RS" ]]; then
FIRST_RS_DIR="$REPO_ROOT/$(dirname "$(echo "$STAGED_RS" | head -1)")"
CARGO_ROOT="$FIRST_RS_DIR"
while [[ "$CARGO_ROOT" != "/" && ! -f "$CARGO_ROOT/Cargo.toml" ]]; do
CARGO_ROOT=$(dirname "$CARGO_ROOT")
done
if [[ -f "$CARGO_ROOT/Cargo.toml" ]]; then
RS_RESULT=$(cd "$CARGO_ROOT" && cargo check 2>&1) || true
if echo "$RS_RESULT" | grep -q "^error"; then
FAILED="$FAILED\n[Rust: $CARGO_ROOT]\n$RS_RESULT"
fi
fi
fi
if [[ -n "$FAILED" ]]; then
echo "BLOCKED: compilation errors in staged files — fix before committing:" >&2
echo -e "$FAILED" >&2
exit 2
fi
exit 0
settings.json wiring
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/pre-commit-build-gate.sh" },
{ "type": "command", "command": "~/.claude/hooks/pre-commit-multi-lang-gate.sh" }
]
}
]
}
}
- TypeScript type error in a staged
.tsfile - Rust borrow checker error in staged
.rsfile - Missing Cargo.toml dependency caught before commit
milestone-gate.sh
The most comprehensive gate. Intercepts git tag and runs the full stack: Go build → vet → golangci-lint → test → e2e, TypeScript tsc → ESLint, Rust check → clippy → test, Python ruff → pytest. Missing lint tools print install instructions as warnings rather than hard-blocking, so the gate degrades gracefully in minimal environments.
Script — ~/.claude/hooks/milestone-gate.sh
#!/bin/bash
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" \
2>/dev/null || echo "")
echo "$CMD" | grep -qE 'git\s+tag' || exit 0
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
FAILED=""
WARN=""
echo "--- milestone-gate: running full quality suite ---" >&2
# ── Go ──
if find "$REPO_ROOT" -name "go.mod" -not -path "*/vendor/*" | head -1 | grep -q .; then
while IFS= read -r modfile; do
MOD_DIR=$(dirname "$modfile")
BUILD=$(cd "$MOD_DIR" && go build ./... 2>&1) || true
[[ -n "$BUILD" ]] && FAILED="$FAILED\n[go build: $MOD_DIR]\n$BUILD"
VET=$(cd "$MOD_DIR" && go vet ./... 2>&1) || true
[[ -n "$VET" ]] && FAILED="$FAILED\n[go vet: $MOD_DIR]\n$VET"
if command -v golangci-lint >/dev/null 2>&1; then
LINT=$(cd "$MOD_DIR" && golangci-lint run ./... 2>&1) || true
[[ -n "$LINT" ]] && FAILED="$FAILED\n[golangci-lint: $MOD_DIR]\n$LINT"
else
WARN="$WARN\n[WARN] golangci-lint not found. Install: brew install golangci-lint"
fi
TEST=$(cd "$MOD_DIR" && go test ./... 2>&1) || true
if echo "$TEST" | grep -qE '^(FAIL|---\s+FAIL)'; then
FAILED="$FAILED\n[go test: $MOD_DIR]\n$TEST"
fi
done < <(find "$REPO_ROOT" -name "go.mod" -not -path "*/vendor/*")
fi
# ── TypeScript ──
if find "$REPO_ROOT" -name "tsconfig.json" -not -path "*/node_modules/*" | head -1 | grep -q .; then
while IFS= read -r tscfg; do
TSC_DIR=$(dirname "$tscfg")
TSC=$(cd "$TSC_DIR" && npx tsc --noEmit 2>&1) || true
[[ -n "$TSC" ]] && FAILED="$FAILED\n[tsc: $TSC_DIR]\n$TSC"
if [ -f "$TSC_DIR/.eslintrc.json" ] || [ -f "$TSC_DIR/.eslintrc.js" ] || [ -f "$TSC_DIR/eslint.config.js" ]; then
ESL=$(cd "$TSC_DIR" && npx eslint . 2>&1) || true
if echo "$ESL" | grep -q "error"; then
FAILED="$FAILED\n[eslint: $TSC_DIR]\n$ESL"
fi
fi
done < <(find "$REPO_ROOT" -name "tsconfig.json" -not -path "*/node_modules/*")
fi
# ── Rust ──
if find "$REPO_ROOT" -name "Cargo.toml" -not -path "*/target/*" | head -1 | grep -q .; then
CARGO_ROOT=$(find "$REPO_ROOT" -name "Cargo.toml" -not -path "*/target/*" | head -1 | xargs dirname)
RS_CHECK=$(cd "$CARGO_ROOT" && cargo check 2>&1) || true
echo "$RS_CHECK" | grep -q "^error" && FAILED="$FAILED\n[cargo check]\n$RS_CHECK"
if command -v cargo-clippy >/dev/null 2>&1; then
CLIPPY=$(cd "$CARGO_ROOT" && cargo clippy -- -D warnings 2>&1) || true
echo "$CLIPPY" | grep -q "^error" && FAILED="$FAILED\n[clippy]\n$CLIPPY"
else
WARN="$WARN\n[WARN] cargo-clippy not found. Install: rustup component add clippy"
fi
RS_TEST=$(cd "$CARGO_ROOT" && cargo test 2>&1) || true
echo "$RS_TEST" | grep -q "^FAILED" && FAILED="$FAILED\n[cargo test]\n$RS_TEST"
fi
# ── Python ──
if find "$REPO_ROOT" -name "*.py" -not -path "*/.venv/*" | head -1 | grep -q .; then
if command -v ruff >/dev/null 2>&1; then
RUFF=$(cd "$REPO_ROOT" && ruff check . 2>&1) || true
echo "$RUFF" | grep -q "error" && FAILED="$FAILED\n[ruff]\n$RUFF"
else
WARN="$WARN\n[WARN] ruff not found. Install: pip install ruff"
fi
if command -v pytest >/dev/null 2>&1; then
PYTEST=$(cd "$REPO_ROOT" && pytest --tb=short -q 2>&1) || true
echo "$PYTEST" | grep -q "failed" && FAILED="$FAILED\n[pytest]\n$PYTEST"
fi
fi
# ── Results ──
[[ -n "$WARN" ]] && echo -e "$WARN" >&2
if [[ -n "$FAILED" ]]; then
echo "" >&2
echo "BLOCKED: milestone-gate failed — cannot tag until all checks pass:" >&2
echo -e "$FAILED" >&2
exit 2
fi
echo "milestone-gate: all checks passed." >&2
exit 0
settings.json wiring
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/milestone-gate.sh" }]
}
]
}
}
- Go test regression introduced before the release tag
- TypeScript type errors missed during rapid iteration
- Rust clippy warning that was suppressed locally but fails in CI
- Python ruff lint error in a data pipeline script
PostToolUse — Informational
INFORMS after write exit 2 has no effect; use for feedback loopsgo-build-check.sh
Fires on every Edit or Write to a .go file (excluding *_test.go). Walks up to find the module root, runs go build ./..., and prints errors inline. The turnaround is seconds — errors surface before the next tool call.
Script — ~/.claude/hooks/go-build-check.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
# Only .go files, skip test files
[[ "$FILE" == *.go && "$FILE" != *_test.go ]] || exit 0
DIR=$(dirname "$FILE")
MOD_ROOT="$DIR"
while [[ "$MOD_ROOT" != "/" && ! -f "$MOD_ROOT/go.mod" ]]; do
MOD_ROOT=$(dirname "$MOD_ROOT")
done
[[ -f "$MOD_ROOT/go.mod" ]] || exit 0
cd "$MOD_ROOT"
RESULT=$(go build ./... 2>&1) || true
if [[ -n "$RESULT" ]]; then
echo "--- go build ./... ($MOD_ROOT) ---"
echo "$RESULT"
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/go-build-check.sh" }]
}
]
}
}
- Type mismatch surfaced after the edit, before the next read
- Undefined variable from a refactor visible immediately
- Silent in green state — only prints when there are errors
go-vet-check.sh
go vet catches correctness issues that compile successfully: unreachable code after return, mismatched fmt.Printf format strings, struct tag errors, and more. Same file-matching and module-walk pattern as go-build-check.sh.
Script — ~/.claude/hooks/go-vet-check.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
[[ "$FILE" == *.go ]] || exit 0
DIR=$(dirname "$FILE")
MOD_ROOT="$DIR"
while [[ "$MOD_ROOT" != "/" && ! -f "$MOD_ROOT/go.mod" ]]; do
MOD_ROOT=$(dirname "$MOD_ROOT")
done
[[ -f "$MOD_ROOT/go.mod" ]] || exit 0
cd "$MOD_ROOT"
RESULT=$(go vet ./... 2>&1) || true
if [[ -n "$RESULT" ]]; then
echo "--- go vet ./... ($MOD_ROOT) ---"
echo "$RESULT"
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/go-build-check.sh" },
{ "type": "command", "command": "~/.claude/hooks/go-vet-check.sh" }
]
}
]
}
}
fmt.Printf("%s", intValue)— format string mismatch- Unreachable code after an early return
- Incorrect struct tags that would silently skip JSON fields
go-test-check.sh
Triggered only on *_test.go edits. Runs go test ./... from the module root and prints failures. Complements go-build-check.sh — together they cover both production code and test code without overlap.
Script — ~/.claude/hooks/go-test-check.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
# Only trigger on test files
[[ "$FILE" == *_test.go ]] || exit 0
DIR=$(dirname "$FILE")
MOD_ROOT="$DIR"
while [[ "$MOD_ROOT" != "/" && ! -f "$MOD_ROOT/go.mod" ]]; do
MOD_ROOT=$(dirname "$MOD_ROOT")
done
[[ -f "$MOD_ROOT/go.mod" ]] || exit 0
cd "$MOD_ROOT"
RESULT=$(go test ./... 2>&1) || true
if echo "$RESULT" | grep -qE '^(FAIL|---\s+FAIL)'; then
echo "--- go test ./... ($MOD_ROOT) ---"
echo "$RESULT"
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/go-test-check.sh" }]
}
]
}
}
- Test assertion updated but production code not yet matching
- Table-driven test case added that exposes a regression
svelte5-lint.sh
Svelte 5 removed the on: event directive entirely. The compiler rejects it. This hook greps for on:[a-zA-Z] patterns in any .svelte edit and prints the line numbers. The fix is always to convert to onclick={handler} syntax.
Script — ~/.claude/hooks/svelte5-lint.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
[[ "$FILE" == *.svelte ]] || exit 0
[[ -f "$FILE" ]] || exit 0
MATCHES=$(grep -n 'on:[a-zA-Z]' "$FILE" 2>/dev/null || true)
if [[ -n "$MATCHES" ]]; then
echo "--- svelte5-lint: Svelte 4 event directives found in $(basename "$FILE") ---"
echo "$MATCHES"
echo ""
echo "Fix: replace 'on:click={handler}' with 'onclick={handler}'"
echo " replace 'on:input|e.stopPropagation()' with 'oninput={(e) => { e.stopPropagation(); handler(e) }}'"
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/svelte5-lint.sh" }]
}
]
}
}
on:click={handler}— Svelte 4, rejected by Svelte 5 compileron:input|preventDefault— modifier syntax, also removedon:keydownin a component that was copy-pasted from old code
rust-check.sh
Finds the Cargo.toml root above the edited file, runs cargo check, and prints the first 20 error lines. cargo check is faster than cargo build because it skips code generation — ideal for tight feedback loops.
Script — ~/.claude/hooks/rust-check.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
[[ "$FILE" == *.rs ]] || exit 0
DIR=$(dirname "$FILE")
CARGO_ROOT="$DIR"
while [[ "$CARGO_ROOT" != "/" && ! -f "$CARGO_ROOT/Cargo.toml" ]]; do
CARGO_ROOT=$(dirname "$CARGO_ROOT")
done
[[ -f "$CARGO_ROOT/Cargo.toml" ]] || exit 0
cd "$CARGO_ROOT"
RESULT=$(cargo check 2>&1) || true
if echo "$RESULT" | grep -q "^error"; then
echo "--- cargo check ($CARGO_ROOT) ---"
echo "$RESULT" | head -20
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/rust-check.sh" }]
}
]
}
}
- Borrow checker violation after a struct field change
- Lifetime annotation error introduced by a refactor
- Trait bound missing on a new generic parameter
ts-check.sh
Walks up from the edited file to find tsconfig.json, runs npx tsc --noEmit, and prints errors. --noEmit means type-check only — no output files generated, fast turnaround.
Script — ~/.claude/hooks/ts-check.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
echo "$FILE" | grep -qE '\.(ts|tsx)$' || exit 0
DIR=$(dirname "$FILE")
TSC_ROOT="$DIR"
while [[ "$TSC_ROOT" != "/" && ! -f "$TSC_ROOT/tsconfig.json" ]]; do
TSC_ROOT=$(dirname "$TSC_ROOT")
done
[[ -f "$TSC_ROOT/tsconfig.json" ]] || exit 0
cd "$TSC_ROOT"
RESULT=$(npx tsc --noEmit 2>&1) || true
if [[ -n "$RESULT" ]]; then
echo "--- tsc --noEmit ($TSC_ROOT) ---"
echo "$RESULT"
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/ts-check.sh" }]
}
]
}
}
- Property does not exist on a type after an interface change
- Missing
awaiton an async call that returnsPromise<T> - Incorrect argument types in a function call
skill-validate.sh
Fires only on edits to files matching the CoworkPlugins skill path pattern. Checks: YAML frontmatter present, all required fields (name, description, model, category), non-stub description with a trigger cue, line count under 500, model value is sonnet or opus, required sections present, no dead /home/ubuntu paths.
Script — ~/.claude/hooks/skill-validate.sh
#!/bin/bash
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
case "$FILE" in
*/CoworkPluginsByDojoGenesis/plugins/*/skills/*/SKILL.md) ;;
*) exit 0 ;;
esac
[ ! -f "$FILE" ] && exit 0
ERRORS=""
if ! head -1 "$FILE" | grep -q "^---"; then
ERRORS="${ERRORS}\n - Missing YAML frontmatter"
fi
for field in name description model category; do
if ! head -25 "$FILE" | grep -q "^${field}:"; then
ERRORS="${ERRORS}\n - Missing required field: ${field}"
fi
done
DESC=$(head -25 "$FILE" | grep "^description:" | sed 's/^description:[[:space:]]*//')
if [ -z "$DESC" ] || [ "$DESC" = ">" ] || [ "$DESC" = '""' ]; then
ERRORS="${ERRORS}\n - Description is empty or stub"
fi
LINES=$(wc -l < "$FILE" | tr -d ' ')
if [ "$LINES" -gt 500 ]; then
ERRORS="${ERRORS}\n - ${LINES} lines exceeds 500-line limit"
fi
MODEL=$(head -25 "$FILE" | grep "^model:" | sed 's/^model:[[:space:]]*//')
if [ -n "$MODEL" ] && [ "$MODEL" != "sonnet" ] && [ "$MODEL" != "opus" ]; then
ERRORS="${ERRORS}\n - model: '${MODEL}' must be 'sonnet' or 'opus'"
fi
for section in "## Output" "## Examples" "## Edge Cases" "## Anti-Patterns"; do
if ! grep -qF "$section" "$FILE" 2>/dev/null; then
ERRORS="${ERRORS}\n - Missing section: '${section}'"
fi
done
if grep -q "/home/ubuntu" "$FILE" 2>/dev/null; then
COUNT=$(grep -c "/home/ubuntu" "$FILE")
ERRORS="${ERRORS}\n - ${COUNT} dead path(s) referencing /home/ubuntu/"
fi
DESC_LOWER=$(echo "$DESC" | tr '[:upper:]' '[:lower:]')
if [ -n "$DESC" ] && ! echo "$DESC_LOWER" | grep -qE "use when|trigger|produces|generates|creates|writes|returns|outputs"; then
ERRORS="${ERRORS}\n - Description has no trigger cue"
fi
if [ -n "$ERRORS" ]; then
echo "--- skill-validate: $(basename "$(dirname "$FILE")")/SKILL.md ---"
echo -e "$ERRORS"
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/skill-validate.sh" }]
}
]
}
}
- SKILL.md written without required
## Examplessection - Model field set to
claude-sonnet-4instead ofsonnet - Description is a placeholder with no trigger cue
- Dead
/home/ubuntu/path from an agent that ran on a remote machine
agent-log.sh
Appends a timestamped record to ~/.claude/agent-spawns.log for every agent dispatch. On macOS, fires a notification via osascript when the agent completes — useful for context-switching during long runs. On Linux, replace the osascript call with notify-send.
Script — ~/.claude/hooks/agent-log.sh
#!/bin/bash
INPUT=$(cat)
MODEL=$(echo "$INPUT" | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('model','unknown'))" \
2>/dev/null || echo "unknown")
DESC=$(echo "$INPUT" | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('description','(no description)')[:80])" \
2>/dev/null || echo "(no description)")
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
LOG="$HOME/.claude/agent-spawns.log"
mkdir -p "$(dirname "$LOG")"
echo "${TIMESTAMP} model=${MODEL} desc=${DESC}" >> "$LOG"
# macOS notification (replace with notify-send on Linux)
if command -v osascript >/dev/null 2>&1; then
osascript -e "display notification \"Agent finished: ${DESC}\" with title \"Claude Code\" sound name \"Submarine\"" 2>/dev/null || true
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Agent",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/agent-log.sh" }]
}
]
}
}
- Audit trail of every agent dispatch with model and description
- macOS notification lets you context-switch while agents run
- Log file useful for debugging false-completion reports
agent-model-enforce.sh
Checks the model field in the Agent tool input. If absent or empty, prints a routing guidance reminder. This is informational — it cannot block the dispatch (PostToolUse). But the consistent nudge prevents model selection from drifting to implicit defaults over time.
Script — ~/.claude/hooks/agent-model-enforce.sh
#!/bin/bash
INPUT=$(cat)
MODEL=$(echo "$INPUT" | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('model',''))" \
2>/dev/null || echo "")
DESC=$(echo "$INPUT" | python3 -c \
"import json,sys; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('description','(no description)')[:60])" \
2>/dev/null || echo "(no description)")
if [ -z "$MODEL" ]; then
echo "--- agent-model-enforce ---"
echo "WARNING: Agent dispatched without explicit model: \"${DESC}\""
echo ""
echo "Routing guidance:"
echo " model: sonnet — parsing, bulk work, templates, audits, file writes (~80% of dispatches)"
echo " model: opus — architecture decisions, synthesis, strategic reasoning (~20%)"
echo ""
echo "Add 'model: sonnet' or 'model: opus' to the Agent call."
fi
settings.json wiring
{
"hooks": {
"PostToolUse": [
{
"matcher": "Agent",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/agent-log.sh" },
{ "type": "command", "command": "~/.claude/hooks/agent-model-enforce.sh" }
]
}
]
}
}
- Agent dispatch missing the
modelparameter entirely - Reminds the 80/20 Sonnet/Opus split rule at the moment of dispatch
UserPromptSubmit — Context Injection
INJECTS context fires before Claude sees the message; echo output is prependedcontext-inject.sh
Every turn starts with current working directory, the active git branch, how many files are uncommitted, and how many commits are ahead of origin. This eliminates the common failure mode where Claude acts on stale branch assumptions from earlier in the session.
Script — ~/.claude/hooks/context-inject.sh
#!/bin/bash
CWD=$(pwd)
BRANCH=$(git branch --show-current 2>/dev/null || echo "not a git repo")
DIRTY=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')
AHEAD=$(git log @{u}.. --oneline 2>/dev/null | wc -l | tr -d ' ')
STASH=$(git stash list 2>/dev/null | wc -l | tr -d ' ')
echo "[Session context]"
echo " CWD: $CWD"
echo " Branch: $BRANCH"
echo " Dirty: $DIRTY files uncommitted"
[ "$AHEAD" -gt 0 ] 2>/dev/null && echo " Ahead: $AHEAD commits unpushed"
[ "$STASH" -gt 0 ] 2>/dev/null && echo " Stash: $STASH entries"
echo ""
settings.json wiring
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/context-inject.sh" }]
}
]
}
}
- Claude assumes wrong branch mid-session after a manual checkout
- Dirty file count reveals parallel session work before it causes confusion
- Unpushed commits visible before Claude suggests pushing
drift-detector.sh
Checks dirty file counts across all watched repos, reads the convergence ledger for sessions-since-last-convergence, counts open items in MEMORY.md. Silent when GREEN. Outputs a drift warning with repo breakdown when YELLOW or RED. RED includes a recommendation to run /converge before starting new feature work.
Script — ~/.claude/hooks/drift-detector.sh
#!/bin/bash
# ── Configure these paths for your workspace ──
REPOS=(
"$HOME/ZenflowProjects/DojoGenesis/cli"
"$HOME/ZenflowProjects/AgenticGatewayByDojoGenesis"
"$HOME/ZenflowProjects/CoworkPluginsByDojoGenesis"
"$HOME/ZenflowProjects/LogwatcherByDojoGenesis"
)
LEDGER="$HOME/.claude/convergence-ledger.md"
MEMORY_DIR="$HOME/.claude/projects/-Users-$(whoami)-ZenflowProjects/memory"
TOTAL_DIRTY=0
DIRTY_REPOS=""
for repo in "${REPOS[@]}"; do
if [ -d "$repo/.git" ]; then
count=$(git -C "$repo" status --porcelain 2>/dev/null | wc -l | tr -d ' ')
if [ "$count" -gt 0 ]; then
name=$(basename "$repo")
DIRTY_REPOS="${DIRTY_REPOS} ${name}: ${count} files\n"
TOTAL_DIRTY=$((TOTAL_DIRTY + count))
fi
fi
done
SESSIONS_SINCE=0
LAST_CONVERGE=""
if [ -f "$LEDGER" ]; then
SESSIONS_SINCE=$(grep -c "^session:" "$LEDGER" 2>/dev/null || echo 0)
LAST_CONVERGE=$(grep "^converged:" "$LEDGER" | tail -1 | awk '{print $2}')
fi
OPEN_ITEMS=0
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
OPEN_ITEMS=$(awk '/^## Open Items/{flag=1;next}/^## /{flag=0}flag && /^- /' \
"$MEMORY_DIR/MEMORY.md" | wc -l)
fi
# Append to ledger
mkdir -p "$(dirname "$LEDGER")"
echo "session: $(date '+%Y-%m-%d %H:%M') dirty=${TOTAL_DIRTY} open=${OPEN_ITEMS}" >> "$LEDGER"
# Determine level
LEVEL="GREEN"
if [ "$TOTAL_DIRTY" -ge 25 ] || [ "$SESSIONS_SINCE" -ge 6 ]; then
LEVEL="RED"
elif [ "$TOTAL_DIRTY" -ge 10 ] || [ "$SESSIONS_SINCE" -ge 4 ]; then
LEVEL="YELLOW"
fi
[ "$LEVEL" = "GREEN" ] && exit 0
echo "[Drift Detection: ${LEVEL}]"
echo "Sessions since convergence: ${SESSIONS_SINCE}"
echo "Uncommitted files: ${TOTAL_DIRTY}"
[ -n "$DIRTY_REPOS" ] && printf "Dirty repos:\n${DIRTY_REPOS}"
echo "Open items: ${OPEN_ITEMS}"
[ -n "$LAST_CONVERGE" ] && echo "Last convergence: ${LAST_CONVERGE}"
[ "$LEVEL" = "RED" ] && echo "RECOMMENDATION: Run /converge before starting new feature work."
echo ""
settings.json wiring
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/context-inject.sh" },
{ "type": "command", "command": "~/.claude/hooks/drift-detector.sh" }
]
}
]
}
}
- 4 sessions of parallel work accumulate without a convergence commit
- 25+ dirty files across repos signal unintegrated work before starting another feature
- Ledger persists across sessions — drift accumulates correctly even after restarts
Stop — Session End
FIRES on session end matcher "" fires on every stop eventdone-notification.sh
The simplest hook. When the session ends, fires a macOS notification so you can context-switch during long runs and come back when Claude is actually done. On Linux, replace osascript with notify-send.
Script — ~/.claude/hooks/done-notification.sh
#!/bin/bash
if command -v osascript >/dev/null 2>&1; then
osascript -e 'display notification "Claude finished." with title "Claude Code" sound name "Glass"' \
2>/dev/null || true
elif command -v notify-send >/dev/null 2>&1; then
notify-send "Claude Code" "Claude finished." 2>/dev/null || true
fi
settings.json wiring
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [{ "type": "command", "command": "~/.claude/hooks/done-notification.sh" }]
}
]
}
}
- You tab away during a long multi-file refactor and get pinged when it finishes
- Paired with agent-log.sh: notification fires both when agent completes and when main session ends
External — Cron / Scheduled
NOT in settings.json called externally via cron or a schedulernightly-build-sweep.sh
Not wired in settings.json — called by cron or a task scheduler. Discovers every go.mod under the workspace root, runs go build ./... and go test ./... on each, logs results to ~/.claude/nightly-build.log, and fires a macOS notification summarizing pass/fail counts. Run it nightly to catch regressions before the next session.
Script — ~/.claude/hooks/nightly-build-sweep.sh
#!/bin/bash
WORKSPACE="${1:-$HOME/ZenflowProjects}"
LOG="$HOME/.claude/nightly-build.log"
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
PASS=0
FAIL=0
FAILURES=""
mkdir -p "$(dirname "$LOG")"
echo "" >> "$LOG"
echo "=== nightly-build-sweep: $TIMESTAMP ===" >> "$LOG"
while IFS= read -r modfile; do
MOD_DIR=$(dirname "$modfile")
# Skip vendor directories
echo "$MOD_DIR" | grep -q "/vendor/" && continue
BUILD_OUT=$(cd "$MOD_DIR" && go build ./... 2>&1)
BUILD_OK=$?
TEST_OUT=$(cd "$MOD_DIR" && go test ./... -timeout 120s 2>&1)
TEST_OK=$?
REL_DIR="${MOD_DIR#$WORKSPACE/}"
if [ "$BUILD_OK" -eq 0 ] && [ "$TEST_OK" -eq 0 ]; then
echo " PASS $REL_DIR" >> "$LOG"
PASS=$((PASS + 1))
else
echo " FAIL $REL_DIR" >> "$LOG"
[ -n "$BUILD_OUT" ] && echo " build: $BUILD_OUT" >> "$LOG"
echo "$TEST_OUT" | grep -E '^(FAIL|---\s+FAIL)' | while IFS= read -r line; do
echo " test: $line" >> "$LOG"
done
FAIL=$((FAIL + 1))
FAILURES="$FAILURES\n - $REL_DIR"
fi
done < <(find "$WORKSPACE" -name "go.mod" \
-not -path "*/vendor/*" \
-not -path "*/.git/*" \
2>/dev/null)
SUMMARY="${PASS} passed, ${FAIL} failed"
echo "Summary: $SUMMARY" >> "$LOG"
# macOS notification
if command -v osascript >/dev/null 2>&1; then
if [ "$FAIL" -gt 0 ]; then
osascript -e "display notification \"${FAIL} modules failed build/test\" with title \"Nightly Build Sweep\" sound name \"Basso\"" 2>/dev/null || true
else
osascript -e "display notification \"All ${PASS} modules passing\" with title \"Nightly Build Sweep\" sound name \"Glass\"" 2>/dev/null || true
fi
fi
Cron setup (run nightly at 2am)
# Add to crontab: crontab -e
0 2 * * * /bin/bash ~/.claude/hooks/nightly-build-sweep.sh "$HOME/ZenflowProjects" >> ~/.claude/nightly-build-sweep.stderr 2>&1
- Regression introduced during an afternoon session shows up in morning log
- Catches multi-module breakage that per-file hooks miss (hook only fires for the edited file's module)
- Log at
~/.claude/nightly-build.loggives historical record
4 Build Your Own Hook
Six steps from blank script to wired hook, with three starter templates.
- 1Create the script at
~/.claude/hooks/your-hook.sh. Keeping all hooks in one directory makes the settings.json paths predictable. - 2Make it executable:
chmod +x ~/.claude/hooks/your-hook.sh. The harness will silently skip non-executable files. - 3Read stdin as JSON:
INPUT=$(cat). Parse fields with python3:python3 -c "import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))". For Edit/Write tools, the relevant field isfile_path. For Bash, it iscommand. For Agent, it ismodelanddescription. - 4For PreToolUse:
exit 2to block,exit 0to allow. Print a human-readable explanation to stderr before exiting 2 — the harness surfaces it to the user. - 5For PostToolUse: just echo output, exit 0 or exit 1. Exit 2 has no effect. The harness will display your stdout in the conversation.
- 6Wire in
~/.claude/settings.jsonunder the right event + matcher. Multiple hooks on the same event run in array order — later hooks still run even if an earlier one exits 2 (for PreToolUse, the action is blocked, but remaining hooks in the array still execute).
Starter Template A: PreToolUse Blocker
The simplest blocking hook — one grep, one block. Copy this and replace the pattern.
#!/bin/bash
# Starter: PreToolUse blocker
INPUT=$(cat)
CMD=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" \
2>/dev/null || echo "")
# Replace this pattern with whatever you want to block
if echo "$CMD" | grep -qE 'YOUR_DANGEROUS_PATTERN'; then
echo "BLOCKED: reason why this is dangerous" >&2
exit 2
fi
exit 0
Starter Template B: PostToolUse Feedback Loop
Runs a check after every edit to a matching file type, prints results if non-empty.
#!/bin/bash
# Starter: PostToolUse feedback loop
INPUT=$(cat)
FILE=$(echo "$INPUT" | python3 -c \
"import json,sys; print(json.load(sys.stdin).get('tool_input',{}).get('file_path',''))" \
2>/dev/null || echo "")
# Gate on file extension
[[ "$FILE" == *.YOUR_EXT ]] || exit 0
[[ -f "$FILE" ]] || exit 0
# Run your check — capture output
RESULT=$(your-check-command "$FILE" 2>&1) || true
# Print if non-empty
if [[ -n "$RESULT" ]]; then
echo "--- your-check-name: $(basename "$FILE") ---"
echo "$RESULT"
fi
Starter Template C: UserPromptSubmit Context Injector
Gathers state at the start of every turn and prepends it to the prompt as context.
#!/bin/bash
# Starter: UserPromptSubmit context injector
# Anything echoed here is prepended to the user's prompt before Claude sees it.
STATE_A=$(your-command-a 2>/dev/null || echo "unavailable")
STATE_B=$(your-command-b 2>/dev/null || echo "unavailable")
# Only output when there is something meaningful to say
if [[ "$STATE_A" != "unavailable" || "$STATE_B" != "unavailable" ]]; then
echo "[Context: your-label]"
echo " State A: $STATE_A"
echo " State B: $STATE_B"
echo ""
fi
5 Complete settings.json
All 18 hooks wired. Copy this, replace the paths, remove the hooks you don't want.
settings.json lives at ~/.claude/settings.json for global hooks, or .claude/settings.json inside a project directory for project-scoped hooks. Project settings merge with global settings — project hooks run in addition to global hooks, not instead of them.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
// Block catastrophic rm -rf, git reset --hard, git checkout --, git clean -f
{ "type": "command", "command": "~/.claude/hooks/destructive-guard.sh" },
// Hard block force push to main/master; warn on any other branch
{ "type": "command", "command": "~/.claude/hooks/force-push-guard.sh" },
// Block git commit if staged .go files fail go build ./...
{ "type": "command", "command": "~/.claude/hooks/pre-commit-build-gate.sh" },
// Block git commit if staged .ts/.rs files have compilation errors
{ "type": "command", "command": "~/.claude/hooks/pre-commit-multi-lang-gate.sh" },
// Block git tag unless full build + lint + test + e2e pass
{ "type": "command", "command": "~/.claude/hooks/milestone-gate.sh" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
// Run go build after editing non-test .go files
{ "type": "command", "command": "~/.claude/hooks/go-build-check.sh" },
// Run go vet after editing .go files (catches correctness issues build misses)
{ "type": "command", "command": "~/.claude/hooks/go-vet-check.sh" },
// Run go test after editing *_test.go files
{ "type": "command", "command": "~/.claude/hooks/go-test-check.sh" },
// Flag Svelte 4 on: event directives in .svelte files
{ "type": "command", "command": "~/.claude/hooks/svelte5-lint.sh" },
// Run cargo check after editing .rs files
{ "type": "command", "command": "~/.claude/hooks/rust-check.sh" },
// Run tsc --noEmit after editing .ts/.tsx files
{ "type": "command", "command": "~/.claude/hooks/ts-check.sh" },
// Validate SKILL.md files against A+ quality standards
{ "type": "command", "command": "~/.claude/hooks/skill-validate.sh" }
]
},
{
"matcher": "Agent",
"hooks": [
// Log every agent dispatch to ~/.claude/agent-spawns.log + macOS notification
{ "type": "command", "command": "~/.claude/hooks/agent-log.sh" },
// Warn when agent dispatched without explicit model parameter
{ "type": "command", "command": "~/.claude/hooks/agent-model-enforce.sh" }
]
}
],
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
// Inject CWD, branch, dirty count, commits ahead at every turn
{ "type": "command", "command": "~/.claude/hooks/context-inject.sh" },
// Detect drift: YELLOW at 10+ dirty/4+ sessions, RED at 25+/6+
{ "type": "command", "command": "~/.claude/hooks/drift-detector.sh" }
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
// macOS notification when Claude finishes
{ "type": "command", "command": "~/.claude/hooks/done-notification.sh" }
]
}
]
}
}
JSON does not allow comments. The block above uses // comments for readability — remove them before saving as actual settings.json. A JSON parse error silently disables all hooks.
Minimal starter (just the three safety gates)
If you want only the blocking guards with no feedback noise, start here and add hooks incrementally:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/destructive-guard.sh" },
{ "type": "command", "command": "~/.claude/hooks/force-push-guard.sh" },
{ "type": "command", "command": "~/.claude/hooks/pre-commit-build-gate.sh" }
]
}
],
"Stop": [
{
"matcher": "",
"hooks": [
{ "type": "command", "command": "~/.claude/hooks/done-notification.sh" }
]
}
]
}
}