diff --git a/.claude/settings.json b/.claude/settings.json index 621eaf16..46c844c3 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -34,6 +34,15 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/hooks/tdd-pretool-check.sh" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/hooks/codex-gate-check.sh" + } + ] } ], "InstructionsLoaded": [ diff --git a/cli/init.js b/cli/init.js index ff493221..c95132e2 100644 --- a/cli/init.js +++ b/cli/init.js @@ -26,6 +26,7 @@ const FILES = [ { src: 'hooks/model-effort-check.sh', dest: '.claude/hooks/model-effort-check.sh', executable: true, base: REPO_ROOT }, { src: 'hooks/precompact-seam-check.sh', dest: '.claude/hooks/precompact-seam-check.sh', executable: true, base: REPO_ROOT }, { src: 'hooks/goal-confidence-check.sh', dest: '.claude/hooks/goal-confidence-check.sh', executable: true, base: REPO_ROOT }, + { src: 'hooks/codex-gate-check.sh', dest: '.claude/hooks/codex-gate-check.sh', executable: true, base: REPO_ROOT }, // #254 Bug 1: shared helper sourced by all hooks above. Must ship — without // it, hooks emit "_find-sdlc-root.sh: No such file or directory" + the // SDLC root walk-up logic is silently dead. diff --git a/cli/templates/settings.json b/cli/templates/settings.json index 74061efe..1d69e18e 100644 --- a/cli/templates/settings.json +++ b/cli/templates/settings.json @@ -25,6 +25,15 @@ "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/tdd-pretool-check.sh" } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/codex-gate-check.sh" + } + ] } ], "InstructionsLoaded": [ diff --git a/hooks/codex-gate-check.sh b/hooks/codex-gate-check.sh new file mode 100755 index 00000000..fa774a15 --- /dev/null +++ b/hooks/codex-gate-check.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# PreToolUse hook — blocks git commit without cross-model review artifact +# Fires on Bash tool; only acts when the command contains "git commit" + +set -e + +# Skip gate if explicitly overridden (emergency bypass with logged justification) +[ "${CODEX_GATE_SKIP:-}" = "1" ] && exit 0 + +TOOL_INPUT=$(cat) + +COMMAND=$(printf '%s' "$TOOL_INPUT" \ + | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' \ + | head -1 \ + | sed 's/"command"[[:space:]]*:[[:space:]]*"//; s/"$//') + +case "$COMMAND" in + *"git commit"*) ;; + *) exit 0 ;; +esac + +REVIEW_FILE=".reviews/handoff.json" + +if [ ! -f "$REVIEW_FILE" ]; then + echo "CROSS-MODEL REVIEW REQUIRED: No .reviews/handoff.json found. Run Codex cross-model review before committing. Set CODEX_GATE_SKIP=1 to bypass with justification." + exit 0 +fi + +STATUS=$(grep -o '"status"[[:space:]]*:[[:space:]]*"[^"]*"' "$REVIEW_FILE" \ + | head -1 \ + | sed 's/.*"status"[[:space:]]*:[[:space:]]*"//; s/"$//') + +case "$STATUS" in + CERTIFIED|REVIEWED) exit 0 ;; + *) + echo "CROSS-MODEL REVIEW REQUIRED: .reviews/handoff.json status is '$STATUS' (need REVIEWED or CERTIFIED). Run Codex cross-model review before committing. Set CODEX_GATE_SKIP=1 to bypass with justification." + exit 0 + ;; +esac diff --git a/tests/test-hooks.sh b/tests/test-hooks.sh index c37d9564..e7319eed 100755 --- a/tests/test-hooks.sh +++ b/tests/test-hooks.sh @@ -3541,6 +3541,103 @@ test_goal_confidence_check_silent_when_confidence_present test_goal_confidence_check_dlc_binding_warning test_goal_confidence_check_silent_on_status_and_clear +# ---- codex-gate-check.sh tests ---- +echo "" +echo "--- codex-gate-check.sh ---" + +test_codex_gate_blocks_commit_without_review() { + local tmpdir + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir" + # No .reviews/handoff.json — should block + local out + out=$(printf '%s' '{"tool_input":{"command":"git commit -m \"fix: something\""}}' | (cd "$tmpdir" && "$HOOKS_DIR/codex-gate-check.sh") 2>&1) || true + rm -rf "$tmpdir" + if echo "$out" | grep -qi "cross-model review"; then + pass "codex gate blocks commit without review artifact" + else + fail "codex gate should block commit without review artifact (got: $out)" + fi +} + +test_codex_gate_allows_commit_with_certified_review() { + local tmpdir + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/.reviews" + printf '{"status":"CERTIFIED","score":9}' > "$tmpdir/.reviews/handoff.json" + local out + out=$(printf '%s' '{"tool_input":{"command":"git commit -m \"fix: something\""}}' | (cd "$tmpdir" && "$HOOKS_DIR/codex-gate-check.sh") 2>&1) || true + rm -rf "$tmpdir" + if [ -z "$out" ]; then + pass "codex gate allows commit with CERTIFIED review" + else + fail "codex gate should be silent with CERTIFIED review (got: $out)" + fi +} + +test_codex_gate_allows_commit_with_reviewed_status() { + local tmpdir + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/.reviews" + printf '{"status":"REVIEWED"}' > "$tmpdir/.reviews/handoff.json" + local out + out=$(printf '%s' '{"tool_input":{"command":"git commit -m \"fix: something\""}}' | (cd "$tmpdir" && "$HOOKS_DIR/codex-gate-check.sh") 2>&1) || true + rm -rf "$tmpdir" + if [ -z "$out" ]; then + pass "codex gate allows commit with REVIEWED status" + else + fail "codex gate should be silent with REVIEWED review (got: $out)" + fi +} + +test_codex_gate_silent_on_non_commit_commands() { + local tmpdir + tmpdir=$(mktemp -d) + local out + out=$(printf '%s' '{"tool_input":{"command":"git status"}}' | (cd "$tmpdir" && "$HOOKS_DIR/codex-gate-check.sh") 2>&1) || true + rm -rf "$tmpdir" + if [ -z "$out" ]; then + pass "codex gate silent on non-commit commands" + else + fail "codex gate should be silent on git status (got: $out)" + fi +} + +test_codex_gate_blocks_on_invalid_status() { + local tmpdir + tmpdir=$(mktemp -d) + mkdir -p "$tmpdir/.reviews" + printf '{"status":"PENDING"}' > "$tmpdir/.reviews/handoff.json" + local out + out=$(printf '%s' '{"tool_input":{"command":"git commit -m \"fix: thing\""}}' | (cd "$tmpdir" && "$HOOKS_DIR/codex-gate-check.sh") 2>&1) || true + rm -rf "$tmpdir" + if echo "$out" | grep -qi "cross-model review"; then + pass "codex gate blocks commit with PENDING status" + else + fail "codex gate should block with non-certified status (got: $out)" + fi +} + +test_codex_gate_skip_override() { + local tmpdir + tmpdir=$(mktemp -d) + local out + out=$(printf '%s' '{"tool_input":{"command":"git commit -m \"fix: something\""}}' | (cd "$tmpdir" && CODEX_GATE_SKIP=1 "$HOOKS_DIR/codex-gate-check.sh") 2>&1) || true + rm -rf "$tmpdir" + if [ -z "$out" ]; then + pass "codex gate respects CODEX_GATE_SKIP=1 override" + else + fail "codex gate should be silent with CODEX_GATE_SKIP=1 (got: $out)" + fi +} + +test_codex_gate_blocks_commit_without_review +test_codex_gate_allows_commit_with_certified_review +test_codex_gate_allows_commit_with_reviewed_status +test_codex_gate_silent_on_non_commit_commands +test_codex_gate_blocks_on_invalid_status +test_codex_gate_skip_override + echo "" echo "=== Results ===" echo "Passed: $PASSED"