Skip to content

Instantly share code, notes, and snippets.

@vtemian
Last active March 31, 2026 12:44
Show Gist options
  • Select an option

  • Save vtemian/5c8d8b72545ea606aa8949b0267e55b1 to your computer and use it in GitHub Desktop.

Select an option

Save vtemian/5c8d8b72545ea606aa8949b0267e55b1 to your computer and use it in GitHub Desktop.
Claude Code hook: automated CLAUDE.md code review with closed feedback loop

Claude Code: CLAUDE.md Code Review Hook

A PostToolUse hook for Claude Code that automatically reviews every code change against your CLAUDE.md rules, with a closed feedback loop: violations are surfaced to the model, which then auto-fixes them.

How it works

You write code -> Hook finds nearest CLAUDE.md -> claude -p reviews the change
  -> PASS: silent, no interruption
  -> VIOLATION: model receives blocking feedback and auto-fixes
  1. On every Write, Edit, or MultiEdit, the hook fires
  2. It walks up from the changed file to find the nearest CLAUDE.md
  3. It sends the change + rules to claude -p --model haiku (fast, cheap, no tool use, no hook recursion)
  4. If violations are found, it returns "decision": "block" with the violation details
  5. The model receives the feedback as a system message and fixes the code
  6. On the next write, the hook reviews again (clean code passes silently)

Setup

1. Copy the hook script

mkdir -p .claude/hooks
cp code-review.sh .claude/hooks/
chmod +x .claude/hooks/code-review.sh

2. Add to .claude/settings.json

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          {
            "type": "command",
            "command": "bash \"$(git rev-parse --show-toplevel)/.claude/hooks/code-review.sh\"",
            "timeout": 120,
            "statusMessage": "Checking CLAUDE.md rules..."
          }
        ]
      }
    ]
  }
}

Why git rev-parse? Claude Code sometimes changes cwd to subdirectories. Relative paths like .claude/hooks/... break when that happens. git rev-parse --show-toplevel always resolves to the repo root.

3. Have a CLAUDE.md

The hook finds the nearest CLAUDE.md by walking up from the changed file. You can have multiple at different levels:

project/
  CLAUDE.md           <- project-wide rules
  src/
    frontend/
      CLAUDE.md       <- frontend-specific rules (used for files in this dir)
    backend/
      CLAUDE.md       <- backend-specific rules

Configuration

Review model

Default: haiku (fast, ~10s per review). Override with an environment variable:

export CLAUDE_REVIEW_MODEL=sonnet  # deeper review, slower

File extensions

Edit the case "$extension" block in code-review.sh to add/remove languages:

case "$extension" in
  ts|tsx|js|jsx|py|rs|go) ;;  # review these
  *) exit 0 ;;
esac

Skip patterns

Edit the skip case block to exclude paths:

case "$file_path" in
  *.test.*|*.spec.*|*.config.*|*/gen/*) exit 0 ;;
esac

Debug log

All hook activity is logged to /tmp/claude-code-review-hook.log:

tail -f /tmp/claude-code-review-hook.log

Requirements

  • Claude Code (the claude CLI)
  • jq for JSON parsing
  • A git repository with at least one CLAUDE.md file

Why not use an agent hook?

Claude Code supports "type": "agent" hooks, but as of v2.1.x they consistently fail with hook error. This command hook achieves the same result by calling claude -p directly.

License

MIT

#!/usr/bin/env bash
# Claude Code hook: automated CLAUDE.md code review with closed feedback loop.
#
# On every Write/Edit, this hook:
# 1. Finds the nearest CLAUDE.md to the changed file
# 2. Sends the change + rules to claude -p (print mode, no tools, no recursion)
# 3. If violations found, blocks with "decision: block" so the model sees
# the feedback and auto-fixes
#
# Requirements: claude CLI, jq
# Debug log: /tmp/claude-code-review-hook.log
DEBUG_LOG="/tmp/claude-code-review-hook.log"
echo "[$(date '+%H:%M:%S')] [code-review] STARTED pid=$$ cwd=$(pwd)" >> "$DEBUG_LOG" 2>&1
trap 'echo "[$(date +%H:%M:%S)] [code-review] CRASH line=$LINENO exit=$? cmd=$BASH_COMMAND" >> "$DEBUG_LOG" 2>&1' ERR
set -uo pipefail
log_debug() {
echo "[$(date '+%H:%M:%S')] [code-review] $*" >> "$DEBUG_LOG"
}
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null) || { log_debug "SKIP: jq failed"; exit 0; }
# Extract file path
case "$tool_name" in
Write|Edit|MultiEdit)
file_path=$(echo "$input" | jq -r '.tool_input.file_path // empty' 2>/dev/null) || { log_debug "SKIP: no file_path"; exit 0; }
;;
*)
log_debug "SKIP: unmatched tool=$tool_name"
exit 0
;;
esac
if [[ -z "$file_path" || ! -f "$file_path" ]]; then
log_debug "SKIP: file not found"
exit 0
fi
# Only review files within the project
if [[ "$file_path" != "${PROJECT_ROOT}"/* ]]; then
log_debug "SKIP: file outside project"
exit 0
fi
# --- CONFIGURE: file extensions to review ---
extension="${file_path##*.}"
case "$extension" in
ts|tsx|js|jsx|py|rs|go) ;; # review these
*)
log_debug "SKIP: non-code extension=$extension"
exit 0
;;
esac
# --- CONFIGURE: paths to skip ---
case "$file_path" in
*.test.*|*.spec.*|*.config.*|*/gen/*|*/generated/*)
log_debug "SKIP: test/config/gen file"
exit 0
;;
esac
log_debug "Reviewing $file_path"
# Walk up from the file to find the nearest CLAUDE.md
find_nearest_claude_md() {
local dir="$1"
while [[ "$dir" == "${PROJECT_ROOT}"* ]]; do
if [[ -f "$dir/CLAUDE.md" ]]; then
echo "$dir/CLAUDE.md"
return 0
fi
dir=$(dirname "$dir")
done
return 1
}
rules_file=$(find_nearest_claude_md "$(dirname "$file_path")") || {
log_debug "SKIP: no CLAUDE.md found"
exit 0
}
log_debug "Using rules from: $rules_file"
rules=$(cat "$rules_file")
# Build change context from the tool input
case "$tool_name" in
Write)
change_desc="NEW FILE written"
change_content=$(echo "$input" | jq -r '.tool_input.content // empty' 2>/dev/null)
;;
Edit)
old=$(echo "$input" | jq -r '.tool_input.old_string // empty' 2>/dev/null)
new=$(echo "$input" | jq -r '.tool_input.new_string // empty' 2>/dev/null)
change_desc="EDIT: replaced code"
change_content="--- old
${old}
+++ new
${new}"
;;
MultiEdit)
change_desc="MULTI-EDIT on file"
change_content=$(echo "$input" | jq -r '.tool_input | tostring' 2>/dev/null)
;;
esac
# Truncate large changes to keep the prompt reasonable
if [[ ${#change_content} -gt 3000 ]]; then
change_content="${change_content:0:3000}... (truncated)"
fi
prompt="You are a code review hook. Check the following code change for CLEAR violations of the project rules below. Ignore stylistic issues that linters handle (formatting, import order, whitespace). Focus ONLY on semantic rules: naming conventions, architecture patterns, forbidden patterns, error handling, and structural rules.
RULES:
${rules}
FILE: ${file_path}
CHANGE TYPE: ${change_desc}
CODE CHANGE:
${change_content}
If there are CLEAR violations, respond with ONLY a single line starting with 'VIOLATION:' followed by a brief description.
If the code is clean or the change is too small to meaningfully review, respond with ONLY the word 'PASS'."
# --- CONFIGURE: model to use (haiku is fast and cheap, sonnet for deeper review) ---
REVIEW_MODEL="${CLAUDE_REVIEW_MODEL:-haiku}"
log_debug "Calling claude -p --model $REVIEW_MODEL..."
review_output=$(claude -p "$prompt" --model "$REVIEW_MODEL" 2>/dev/null) || {
log_debug "claude -p failed, exit=$?"
exit 0
}
log_debug "Review output: $review_output"
# Check if review found violations
if echo "$review_output" | grep -q "^VIOLATION:"; then
violation=$(echo "$review_output" | grep "^VIOLATION:" | head -1)
log_debug "FOUND: $violation"
# Escape the violation for JSON embedding
escaped_violation=$(echo "$violation" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g')
# "decision: block" with "reason" is what the model actually sees and acts on
cat <<HOOK_EOF
{
"decision": "block",
"reason": "CLAUDE.md code review (${file_path##*/}): ${escaped_violation}\n\nFix these violations before proceeding."
}
HOOK_EOF
else
log_debug "PASS"
fi
log_debug "--- EXIT 0 ---"
exit 0
#!/usr/bin/env bash
set -uo pipefail
# Integration tests for code-review.sh hook.
# Runs real `claude -p` calls against real CLAUDE.md rules.
# Usage: ./test-code-review.sh [path-to-hook] [project-root]
HOOK="${1:-$(git rev-parse --show-toplevel)/.claude/hooks/code-review.sh}"
PROJECT_ROOT="${2:-$(git rev-parse --show-toplevel)}"
PASS=0
FAIL=0
TOTAL=0
red() { printf "\033[31m%s\033[0m" "$*"; }
green() { printf "\033[32m%s\033[0m" "$*"; }
dim() { printf "\033[2m%s\033[0m" "$*"; }
assert_violation() {
local name="$1" output="$2"
TOTAL=$((TOTAL + 1))
if echo "$output" | grep -q '"decision"'; then
PASS=$((PASS + 1))
echo " $(green PASS) $name"
else
FAIL=$((FAIL + 1))
echo " $(red FAIL) $name (expected violation, got: $(dim "${output:-<empty>}"))"
fi
}
assert_pass() {
local name="$1" output="$2"
TOTAL=$((TOTAL + 1))
if echo "$output" | grep -q '"decision"'; then
FAIL=$((FAIL + 1))
echo " $(red FAIL) $name (expected pass, got violation: $(dim "$output"))"
else
PASS=$((PASS + 1))
echo " $(green PASS) $name"
fi
}
assert_skip() {
local name="$1" output="$2"
TOTAL=$((TOTAL + 1))
if [[ -z "$output" ]]; then
PASS=$((PASS + 1))
echo " $(green PASS) $name $(dim '(skipped, no output)')"
else
FAIL=$((FAIL + 1))
echo " $(red FAIL) $name (expected skip/empty, got: $(dim "$output"))"
fi
}
# Helper: run the hook with a simulated Write input
run_write() {
local file_path="$1" content="$2"
# Escape content for JSON
local json_content
json_content=$(printf '%s' "$content" | jq -Rs '.')
echo "{
\"session_id\": \"test-session\",
\"cwd\": \"${PROJECT_ROOT}\",
\"hook_event_name\": \"PostToolUse\",
\"tool_name\": \"Write\",
\"tool_input\": {
\"file_path\": \"${file_path}\",
\"content\": ${json_content}
}
}" | bash "$HOOK" 2>/dev/null
}
# Helper: run the hook with a simulated Edit input
run_edit() {
local file_path="$1" old_string="$2" new_string="$3"
local json_old json_new
json_old=$(printf '%s' "$old_string" | jq -Rs '.')
json_new=$(printf '%s' "$new_string" | jq -Rs '.')
echo "{
\"session_id\": \"test-session\",
\"cwd\": \"${PROJECT_ROOT}\",
\"hook_event_name\": \"PostToolUse\",
\"tool_name\": \"Edit\",
\"tool_input\": {
\"file_path\": \"${file_path}\",
\"old_string\": ${json_old},
\"new_string\": ${json_new}
}
}" | bash "$HOOK" 2>/dev/null
}
echo ""
echo "Testing code-review.sh hook"
echo "Hook: $HOOK"
echo "Project: $PROJECT_ROOT"
echo ""
# We need a real file on disk for the hook to find (it checks -f)
TEST_DIR="${PROJECT_ROOT}/src/web/lib"
mkdir -p "$TEST_DIR"
cleanup() {
rm -f "$TEST_DIR/test-hook-"*.ts "$TEST_DIR/test-hook-"*.tsx
}
trap cleanup EXIT
# ============================================================
# SKIP tests (no claude -p call, instant)
# ============================================================
echo "--- Skip conditions ---"
# Non-code extension
TEST_FILE="$TEST_DIR/test-hook-skip.json"
echo '{}' > "$TEST_FILE"
output=$(run_write "$TEST_FILE" '{}')
assert_skip "skips .json files" "$output"
rm -f "$TEST_FILE"
# Test file
TEST_FILE="$TEST_DIR/test-hook-skip.test.ts"
echo '' > "$TEST_FILE"
output=$(run_write "$TEST_FILE" 'const x = 1;')
assert_skip "skips .test.ts files" "$output"
rm -f "$TEST_FILE"
# Config file
TEST_FILE="$TEST_DIR/test-hook-skip.config.ts"
echo '' > "$TEST_FILE"
output=$(run_write "$TEST_FILE" 'export default {}')
assert_skip "skips .config.ts files" "$output"
rm -f "$TEST_FILE"
# File outside project
output=$(echo "{
\"tool_name\": \"Write\",
\"tool_input\": {\"file_path\": \"/tmp/random.ts\", \"content\": \"const x: any = 1;\"}
}" | bash "$HOOK" 2>/dev/null)
assert_skip "skips files outside project" "$output"
# Non-existent file
output=$(echo "{
\"tool_name\": \"Write\",
\"tool_input\": {\"file_path\": \"${PROJECT_ROOT}/does-not-exist.ts\", \"content\": \"x\"}
}" | bash "$HOOK" 2>/dev/null)
assert_skip "skips non-existent files" "$output"
# Wrong tool
output=$(echo "{
\"tool_name\": \"Bash\",
\"tool_input\": {\"command\": \"echo hi\"}
}" | bash "$HOOK" 2>/dev/null)
assert_skip "skips non-Write/Edit tools" "$output"
# ============================================================
# VIOLATION tests (calls claude -p, ~10s each)
# ============================================================
echo ""
echo "--- Violation detection (each test calls claude -p, ~10s) ---"
# any type
TEST_FILE="$TEST_DIR/test-hook-review.ts"
echo 'const x: any = "bad";' > "$TEST_FILE"
output=$(run_write "$TEST_FILE" 'const getData = (): any => { return null; };
export { getData };')
assert_violation "catches 'any' type" "$output"
# default export
echo 'export default function bad() {}' > "$TEST_FILE"
output=$(run_write "$TEST_FILE" 'export default function bad() { return null; }')
assert_violation "catches default export" "$output"
# localStorage (forbidden browser API)
echo 'localStorage.getItem("x")' > "$TEST_FILE"
output=$(run_write "$TEST_FILE" 'const save = () => { localStorage.setItem("key", "val"); };
export { save };')
assert_violation "catches localStorage usage" "$output"
# ============================================================
# PASS tests (calls claude -p, ~10s each)
# ============================================================
echo ""
echo "--- Clean code (should pass) ---"
echo 'const DELIMITER = "-";' > "$TEST_FILE"
output=$(run_write "$TEST_FILE" 'const DELIMITER = "-";
const joinParts = (parts: string[]) => parts.join(DELIMITER);
export { joinParts };')
assert_pass "clean code passes" "$output"
# Small edit
echo 'const X = 1;' > "$TEST_FILE"
output=$(run_edit "$TEST_FILE" 'const X = 1;' 'const X = 2;')
assert_pass "trivial constant change passes" "$output"
cleanup
# ============================================================
# Summary
# ============================================================
echo ""
echo "---"
echo "Results: $PASS/$TOTAL passed, $FAIL failed"
if [[ $FAIL -gt 0 ]]; then
echo "$(red 'SOME TESTS FAILED')"
exit 1
else
echo "$(green 'ALL TESTS PASSED')"
exit 0
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment