Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 126 additions & 31 deletions .claude/hooks/pre-commit-verification.sh
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
#!/bin/bash
# Hook: pre-commit-verification
# Event: PreToolUse (Bash)
# Purpose: Ensure tests and linting pass before git commits
# Purpose: Run the project's detected quality gates before git commits and
# block the commit on failure.
#
# Enforcement rewrite (artifacts/plan_framework_hardening.md, unit U5b): this
# hook used to only ever print advisory text and trust a time-only stamp the
# AGENT was instructed to write by hand — self-attestation, not verification
# (see the plan's Design Principles). It now runs the gates itself. A stamp
# is trusted only when BOTH fresh (<=5 min) AND content-bound: its recorded
# tree-hash must equal the current `git write-tree` output, so a change
# staged seconds ago forces a re-run even if the last stamp is a minute old
# (review F8 — a time-only stamp rides post-edit changes). The stamp file is
# hook-authored only; nothing in this file's own output ever instructs the
# agent to write it.

INPUT=$(cat)

# jq is required to parse tool input; fail open if unavailable (hooks are
# guardrails, not a security boundary)
# guardrails, not a security boundary). This path is byte-for-byte the same
# as before this rewrite — no gates ever ran on the jq-absent path, and none
# do now either.
command -v jq >/dev/null 2>&1 || exit 0

PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
Expand All @@ -18,48 +32,60 @@ if [ "$TOOL_NAME" != "Bash" ]; then
exit 0
fi

# Check if this is a git commit command
if ! echo "$COMMAND" | grep -qE '\bgit\s+commit\b'; then
exit 0
fi

# Check for state file indicating verification already completed
STATE_DIR="$PROJECT_DIR/.claude/hooks/.state"
mkdir -p "$STATE_DIR"
VERIFICATION_FILE="$STATE_DIR/commit-verified"
STAMP_FILE="$STATE_DIR/commit-verified"

# If verification was completed recently (within last 5 minutes), allow commit
if [ -f "$VERIFICATION_FILE" ]; then
VERIFIED_TIME=$(cat "$VERIFICATION_FILE" 2>/dev/null || echo 0)
[[ "$VERIFIED_TIME" =~ ^[0-9]+$ ]] || VERIFIED_TIME=0
CURRENT_TIME=$(date +%s)
TIME_DIFF=$((CURRENT_TIME - VERIFIED_TIME))

if [ "$TIME_DIFF" -lt 300 ]; then
# Verification is recent, allow commit
exit 0
fi
# Escape hatch: unconditional, checked before the stamp or any gate ever
# runs, and always disclosed — never a silent skip.
if [ "${CLAUDE_SKIP_GATE_HOOK:-}" = "1" ]; then
cat << EOF
{
"hookSpecificOutput": {
"additionalContext": "[GATE HOOK SKIPPED] CLAUDE_SKIP_GATE_HOOK=1 is set — quality gates were NOT run for this commit. Unset it to restore enforcement."
}
}
EOF
exit 0
fi

# Detect project type and available gates — detection lives in gate-lib.sh
# (artifacts/plan_framework_hardening.md, unit U5a): one shared function
# emits invocable commands per gate; this hook only needs the human labels
# for its advisory text below, reconstructed here in the same order and
# format ("<label>" tokens space-joined) as before the extraction, so the
# advisory output stays byte-identical.
# (unit U5a): one shared function emits invocable commands plus the human
# label the advisory has always shown.
# shellcheck source=gate-lib.sh
# shellcheck disable=SC1091 # dynamic path (BASH_SOURCE-relative); the
# above source= directive documents it for anyone re-running with `-x`
source "$(dirname "${BASH_SOURCE[0]}")/gate-lib.sh"
gate_lib_detect "$PROJECT_DIR"

DETECTED_TOOLS=""
for _gate_label in "${GATE_LABELS[@]}"; do
DETECTED_TOOLS="$DETECTED_TOOLS $_gate_label"
done
# A stamp is trusted only if BOTH fresh (<5 min) AND content-bound to the
# CURRENT index — computed once, up front, before we even know whether there
# are gates to trust it for.
CURRENT_TREE=$(git -C "$PROJECT_DIR" write-tree 2>/dev/null || true)

if [ -f "$STAMP_FILE" ] && [ -n "$CURRENT_TREE" ]; then
STAMP_EPOCH=""
STAMP_TREE=""
read -r STAMP_EPOCH STAMP_TREE < "$STAMP_FILE" 2>/dev/null || true
[[ "$STAMP_EPOCH" =~ ^[0-9]+$ ]] || STAMP_EPOCH=0
NOW=$(date +%s)
AGE=$((NOW - STAMP_EPOCH))
if [ "$AGE" -lt 300 ] && [ "$STAMP_TREE" = "$CURRENT_TREE" ]; then
exit 0
fi
fi

gate_lib_detect "$PROJECT_DIR"

# Build verification context message
cat << EOF
# No gates detected: nothing for the hook to run automatically — keep
# today's advisory guidance (manual verification is the only check that
# happens here; the stamp stays hook-authored-only in every branch, so this
# text no longer tells the agent to write it by hand).
if [ "${#GATE_LABELS[@]}" -eq 0 ]; then
cat << EOF
{
"hookSpecificOutput": {
"additionalContext": "
Expand All @@ -73,7 +99,7 @@ Before committing, you MUST complete these steps:
- Run linting/formatting checks and fix any issues
- Run type checking if available

Detected tools in this project: ${DETECTED_TOOLS:-none detected - check manually}
Detected tools in this project: none detected - check manually

2. FIX ALL FAILURES:
- If tests fail, fix the code until they pass
Expand All @@ -87,11 +113,80 @@ Before committing, you MUST complete these steps:
- Fix the actual code issues instead

4. AFTER VERIFICATION SUCCEEDS:
- Mark verification complete: echo \$(date +%s) > $STATE_DIR/commit-verified
- Then proceed with the git commit
- Proceed with the git commit. No gates were detected for this project,
so this manual pass is the only verification that happens — nothing
here is written or re-checked automatically.

If you cannot fix a test legitimately, STOP and ask the user for guidance.
---"
}
}
EOF
exit 0
fi

# Gates detected: run each one under its own timeout, from PROJECT_DIR,
# logging to its own file. Fail-fast on the first red or timed-out gate —
# the reason names exactly one gate, matching the plan's fixtures.
# `timeout` is GNU coreutils: present on Linux/CI, absent on stock macOS
# (Homebrew installs it as `gtimeout`). Without either, gates run unbounded
# inside the hook's own settings.json ceiling — bounded worse, never broken.
GATE_TIMEOUT="${CLAUDE_GATE_TIMEOUT_SECS:-120}"
if command -v timeout >/dev/null 2>&1; then TIMEOUT_BIN="timeout"
elif command -v gtimeout >/dev/null 2>&1; then TIMEOUT_BIN="gtimeout"
else TIMEOUT_BIN=""; fi
FAILED_LABEL=""
FAILED_LOG=""
TIMED_OUT_LABEL=""

for _gate_i in "${!GATE_LABELS[@]}"; do
_gate_label="${GATE_LABELS[$_gate_i]}"
_gate_cmd="${GATE_COMMANDS[$_gate_i]}"
_gate_log="$STATE_DIR/gate-${_gate_label//\//_}.log"
if [ -n "$TIMEOUT_BIN" ]; then
( cd "$PROJECT_DIR" && "$TIMEOUT_BIN" "$GATE_TIMEOUT" bash -c "$_gate_cmd" </dev/null ) >"$_gate_log" 2>&1
else
( cd "$PROJECT_DIR" && bash -c "$_gate_cmd" </dev/null ) >"$_gate_log" 2>&1
fi
_gate_rc=$?
if [ "$_gate_rc" -eq 124 ]; then
TIMED_OUT_LABEL="$_gate_label"
break
elif [ "$_gate_rc" -ne 0 ]; then
FAILED_LABEL="$_gate_label"
FAILED_LOG="$_gate_log"
break
fi
done

if [ -n "$TIMED_OUT_LABEL" ]; then
cat << EOF
{
"hookSpecificOutput": {
"permissionDecision": "ask",
"permissionDecisionReason": "Quality gate '$TIMED_OUT_LABEL' exceeded its ${GATE_TIMEOUT}s budget and was stopped — gates did not finish, so nothing was verified either way. Run them manually before committing; no evidence stamp was written."
}
}
EOF
exit 0
fi

if [ -n "$FAILED_LABEL" ]; then
cat << EOF
{
"hookSpecificOutput": {
"permissionDecision": "deny",
"permissionDecisionReason": "Quality gate '$FAILED_LABEL' failed. See $FAILED_LOG for details. Do not delete or weaken tests to force a pass — fix the issue or ask the user."
}
}
EOF
exit 0
fi

# All gates green: hook-authored stamp only, content-bound to the index we
# actually checked — no instruction anywhere tells the agent to write this
# file itself.
if [ -n "$CURRENT_TREE" ]; then
echo "$(date +%s) $CURRENT_TREE" > "$STAMP_FILE"
fi
exit 0
2 changes: 1 addition & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-verification.sh",
"timeout": 5
"timeout": 300
},
{
"type": "command",
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Correction-capture loop (O14): `.claude/rules/core-directives.md` gains a compact "Correction Capture" subsection — when a user correction contradicts a standing rule/skill/instruction, append one line to `scratchpad/corrections.log` (`YYYY-MM-DD | correction | surface`); `land-the-plane`'s Handoff section gains a Retro step that maps each logged entry to the strongest enforcement rung it can support (rule/skill/hook/CI — the same discipline `postmortem`'s Prevention step already applies), promotes it via a small PR or filed issue, then removes the line; `stop-validator.sh` now emits a one-line session-end reminder naming the pending count when the log is non-empty. New `scripts/hook-tests.d/40-self-improvement.sh` harness cases cover the 2-line, absent, and zero-byte log states (unit U16)
- `docs/customization.md`'s new "Session Learning: Auto-Memory vs. Repo Rules" section draws the line between Claude Code's personal, unreviewed auto-memory and this repo's team-shared, reviewed `.claude/rules/`/`.claude/skills/`/`.claude/hooks/` layer, cross-linking the Correction Capture convention; `CONTRIBUTING.md`'s new "Standing self-improvement loop" section names the full capture → escalate → verify → re-audit cycle the eval-first and retirement policies feed into, including re-running `artifacts/research_ai_coding_frustrations.md`'s failure-taxonomy coverage audit at each model-generation bump (unit U16)
- `branch-pr-discipline.sh`'s no-jq sed fallback switched to `sed -E` — the old BRE alternation silently never matched on BSD/macOS sed, disabling the hook's warnings whenever jq was absent (same bug class fixed in `pre-push-main-blocker.sh` by U1); regression cases added in `scripts/hook-tests.d/50-discipline.sh`
- `pre-commit-verification.sh` rewritten from advisory-only to enforcing (jq present): on `git commit`, runs every gate `gate-lib.sh` detects, each under `timeout "${CLAUDE_GATE_TIMEOUT_SECS:-120}"` from the project dir, logging to `.claude/hooks/.state/gate-<label>.log`. All green writes a hook-authored evidence stamp (`.claude/hooks/.state/commit-verified`, `{epoch, tree-hash}`) trusted on a later commit only when BOTH ≤5 minutes old AND its tree-hash matches the current `git write-tree` output — content-bound, not time-only (review F8), so an edit staged seconds ago forces a re-run despite an otherwise-fresh stamp. A red gate denies the commit, naming the gate, its log, and the existing anti-test-deletion sentence; a gate that exceeds its budget asks instead of silently killing or hanging, and writes no stamp. No gates detected → falls back to the original advisory text, unchanged. `CLAUDE_SKIP_GATE_HOOK=1` escape hatch allows unconditionally with the skip always disclosed in context; the jq-absent path is unchanged (silent fail-open). `settings.json`'s registration for this hook is raised from 5s to 300s to fit real gate runs (per-gate defaults sum safely under it). New `scripts/hook-tests.d/30-gates-lane.sh` covers the red/green/timeout/escape-hatch/cache-invalidation matrix against three fixtures — `scripts/fixtures/failing-project/` and `.../slow-gate/` each gain an (empty) `package-lock.json` so `gate-lib.sh` picks npm over pnpm (GitHub-hosted ubuntu-latest ships the former, not the latter, keeping the fixtures CI-executable); new sibling fixture `scripts/fixtures/passing-project/` covers the green/stamp path. `docs/hooks.md` gains a "Quality Gates" subsection documenting the stamp, `CLAUDE_GATE_TIMEOUT_SECS`, and the escape hatch (unit U5b)

## [4.0.0] - 2026-07-23

Expand Down
20 changes: 20 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,3 +287,23 @@ Correction Capture convention; adopters who aren't will never see this reminder,
empty log stays exactly as silent as before.

<!-- Future v3 migration notes appended here. -->

### Commits are newly blocking when a quality gate is detected

**Who is affected:** any project with a detectable stack — a `package.json` with a `test`/`lint`/`typecheck`/`biome` script, a `pyproject.toml` with `pytest`/`ruff`/`mypy`, a `go.mod`, or a `Cargo.toml`. Projects with none of these see no behavior change.

**What breaks:** `pre-commit-verification.sh` used to only print advisory text before a `git commit` — it never ran anything itself, and trusted a stamp the agent was instructed to write by hand after manually verifying (self-attestation). It now runs the project's detected quality gates itself (detection: `.claude/hooks/gate-lib.sh`) and blocks the commit if one fails:

- A failing gate denies the commit, naming the gate and its log (`.claude/hooks/.state/gate-<label>.log`), plus a reminder never to delete or weaken a test to force a pass.
- A passing run writes a hook-authored evidence stamp (`.claude/hooks/.state/commit-verified`) so an immediately-following commit doesn't always re-run the same gates — but the stamp is trusted only when BOTH ≤5 minutes old AND content-bound (its recorded tree-hash matches the current `git write-tree` output), so staging any change invalidates it even seconds after a fresh stamp.
- A gate that runs longer than its timeout produces an `ask` (an honest "exceeded budget, run manually" reason), never a silent kill or an indefinite hang, and writes no stamp.
- The hook's own registration in `.claude/settings.json` is raised from 5s to 300s to fit real gate runs; each individual gate still defaults to a 120s budget.

**Action required:**

- If a gate legitimately takes longer than the 120s default, set `CLAUDE_GATE_TIMEOUT_SECS` (seconds) in your environment before committing.
- To skip gate enforcement for one commit (e.g. mid-refactor, or a known-slow environment), set `CLAUDE_SKIP_GATE_HOOK=1`. The skip is always disclosed in the hook's own output, never silent.
- If you don't want this hook running gates at all, remove its entry from `.claude/settings.json`'s `hooks.PreToolUse` (`Bash` matcher) — see `docs/hooks.md`'s "How to disable a hook."
- If `jq` is unavailable in your environment, this hook's behavior is unchanged from before this release (silent no-op; no gates run, no advisory shown).

<!-- Future migration notes appended here. -->
14 changes: 13 additions & 1 deletion docs/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Hooks run automatically at key points in Claude Code's lifecycle.
| `pre-tool-use-validator.sh` | PreToolUse | File locking, secret detection (Write/Edit + Bash redirects/heredocs), protected file enforcement, config-write ask-gate |
| `dangerous-command-guard.sh` | PreToolUse (Bash) | Guard against dangerous shell commands (force push, rm -rf, etc.) |
| `pre-push-main-blocker.sh` | PreToolUse (Bash) | Block direct pushes to main/master branch |
| `pre-commit-verification.sh` | PreToolUse (Bash) | Pre-commit quality checks |
| `pre-commit-verification.sh` | PreToolUse (Bash) | Runs detected quality gates before `git commit`; blocks on failure, asks on timeout |
| `post-tool-use-tracker.sh` | PostToolUse | Track file changes |
| `stop-validator.sh` | Stop | Release file locks, cleanup session state, warn about uncommitted/unpushed work and unprocessed `scratchpad/corrections.log` entries |
| `subagent-stop-validator.sh` | SubagentStop | Log swarm worker completion |
Expand Down Expand Up @@ -59,6 +59,18 @@ Asks for confirmation (not a hard block) before a direct Write/Edit to:

These are the same paths `/tailor` proposes changes to rather than writing directly (see `tailor/SKILL.md`'s Output Contract) — this hook backs that contract mechanically instead of leaving it as convention only.

### Quality Gates (pre-commit-verification.sh)

Runs the project's detected quality gates before every `git commit` and blocks the commit if one fails (unit U5b of `artifacts/plan_framework_hardening.md`):

- **Detection**: gate labels/commands come from `.claude/hooks/gate-lib.sh` (unit U5a) — per-stack: TS/JS via the detected package manager, Python, Go, Rust. No gates detected → the hook falls back to its original advisory text (manual verification guidance), unchanged.
- **Evidence stamp**: a passing run writes `{epoch, tree-hash}` to `.claude/hooks/.state/commit-verified` — hook-authored only; nothing in this hook's own output ever instructs the agent to write it by hand. A later commit trusts the stamp only if it's BOTH ≤5 minutes old AND its recorded tree-hash matches the current `git write-tree` output — content-bound, not just time-bound, so staging an edit a second ago invalidates a minute-old stamp.
- **On failure**: denies the commit, naming the failing gate and its log (`.claude/hooks/.state/gate-<label>.log`), plus a fixed reminder never to delete or weaken a test to force a pass.
- **On timeout**: each gate runs under `timeout "${CLAUDE_GATE_TIMEOUT_SECS:-120}"` (default 120s per gate; set the env var to override). The hook's own registration in `settings.json` is 300s, leaving headroom above the per-gate default so ordinary (non-commit) Bash calls still return instantly via the hook's early exits. A gate that exceeds its budget produces an `ask`, never a silent kill or an indefinite hang — and no stamp is written.
On stock macOS, GNU `timeout` is absent — the hook falls back to `gtimeout` (Homebrew coreutils) or, failing both, runs gates unbounded within its own settings.json timeout ceiling; install coreutils to restore per-gate bounding.
- **Escape hatch**: `CLAUDE_SKIP_GATE_HOOK=1` allows the commit unconditionally; the skip is always disclosed in the hook's own `additionalContext`, never silent.
- **jq-absent**: identical to every other hook in this repo — silent exit 0, no gates run, no advisory shown (the repo's fail-open convention, see Security model below). Broader jq-availability degradation visibility is a session-level concern (`session-start-loader.sh`), not something this specific hook re-announces per commit.

### Push Blocking (pre-push-main-blocker.sh)

Enforces trunk-based development by blocking pushes to main/master:
Expand Down
Empty file.
Empty file.
9 changes: 9 additions & 0 deletions scripts/fixtures/passing-project/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "passing-project-fixture",
"version": "0.0.0",
"private": true,
"description": "Dependency-free fixture for hook-behavior tests: a project whose test script always succeeds (no npm install needed) — exercises the pre-commit gate's green path and hook-authored evidence-stamp write (unit U5b). Sibling of failing-project (red path) and slow-gate (timeout path).",
"scripts": {
"test": "node -e \"process.exit(0)\""
}
}
Empty file.
9 changes: 9 additions & 0 deletions scripts/fixtures/slow-gate/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "slow-gate-fixture",
"version": "0.0.0",
"private": true,
"description": "Fixture whose test script shells out to gate.sh, which sleeps past a deliberately short per-gate timeout — exercises the pre-commit gate's honest 'ask' path on timeout, never a silent kill or hang (unit U5b). See gate.sh for the sleep itself.",
"scripts": {
"test": "bash gate.sh"
}
}
Loading
Loading