reviewflow 3.8.1 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.9.0](https://github.com/DGouron/review-flow/compare/reviewflow-v3.8.1...reviewflow-v3.9.0) (2026-04-03)
9
+
10
+
11
+ ### Added
12
+
13
+ * **harness:** add SDD+TDD double loop with deterministic hooks ([793c8d8](https://github.com/DGouron/review-flow/commit/793c8d8d0c38ba88c770e0a5971fb670e4209ae9))
14
+ * **harness:** add SDD+TDD double loop with deterministic hooks ([9a9a25a](https://github.com/DGouron/review-flow/commit/9a9a25af7925da8518ae53ebe5362a0a6743981f))
15
+
8
16
  ## [3.8.1](https://github.com/DGouron/review-flow/compare/reviewflow-v3.8.0...reviewflow-v3.8.1) (2026-03-16)
9
17
 
10
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviewflow",
3
- "version": "3.8.1",
3
+ "version": "3.9.0",
4
4
  "description": "AI-powered code review automation for GitLab/GitHub using Claude Code",
5
5
  "type": "module",
6
6
  "main": "dist/main/server.js",
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Blocks creation or editing of barrel export files (index.ts).
5
+ # Used as PreToolUse hook on Write|Edit.
6
+ # Exit 0 = allow, Exit 2 = block with feedback to Claude.
7
+
8
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ INPUT=$(cat)
10
+ FILE_PATH=$(echo "$INPUT" | "$HOOK_DIR/parse-json.sh" tool_input.file_path)
11
+
12
+ is_barrel_export() {
13
+ local filename
14
+ filename=$(basename "$FILE_PATH")
15
+ [[ "$filename" == "index.ts" || "$filename" == "index.js" ]]
16
+ }
17
+
18
+ if is_barrel_export; then
19
+ echo "Barrel exports are forbidden. Import directly from the source file instead of using index.ts." >&2
20
+ exit 2
21
+ fi
22
+
23
+ exit 0
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # Extracts a value from JSON on stdin using a dot-separated path.
4
+ # Usage: echo '{"a":{"b":"c"}}' | parse-json.sh a.b
5
+ # Returns empty string if path not found. No external dependencies beyond python3.
6
+
7
+ python3 -c "
8
+ import json, sys
9
+ data = json.load(sys.stdin)
10
+ path = sys.argv[1].split('.')
11
+ for key in path:
12
+ if isinstance(data, dict) and key in data:
13
+ data = data[key]
14
+ else:
15
+ sys.exit(0)
16
+ print(data if data is not None else '')
17
+ " "$1"
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Blocks git commit if tests fail.
5
+ # Used as PreToolUse hook on Bash(git commit *).
6
+ # Exit 0 = allow, Exit 2 = block with feedback to Claude.
7
+
8
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ INPUT=$(cat)
10
+ COMMAND=$(echo "$INPUT" | "$HOOK_DIR/parse-json.sh" tool_input.command)
11
+
12
+ is_commit_command() {
13
+ echo "$COMMAND" | grep -qE "git commit"
14
+ }
15
+
16
+ if ! is_commit_command; then
17
+ exit 0
18
+ fi
19
+
20
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$HOOK_DIR/../.." && pwd)}"
21
+
22
+ cd "$PROJECT_DIR"
23
+
24
+ if yarn test:ci 2>&1; then
25
+ exit 0
26
+ else
27
+ echo "Tests failed. Fix them before committing." >&2
28
+ exit 2
29
+ fi
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Blocks git commit on main/master branch.
5
+ # Used as PreToolUse hook on Bash(git commit *).
6
+ # Exit 0 = allow, Exit 2 = block with feedback to Claude.
7
+
8
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ INPUT=$(cat)
10
+ COMMAND=$(echo "$INPUT" | "$HOOK_DIR/parse-json.sh" tool_input.command)
11
+
12
+ is_commit_command() {
13
+ echo "$COMMAND" | grep -qE "git commit"
14
+ }
15
+
16
+ if ! is_commit_command; then
17
+ exit 0
18
+ fi
19
+
20
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$HOOK_DIR/../.." && pwd)}"
21
+ CURRENT_BRANCH=$(cd "$PROJECT_DIR" && git branch --show-current 2>/dev/null || true)
22
+
23
+ if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then
24
+ echo "You are on '$CURRENT_BRANCH'. Create a feature branch or use /worktree add <name> first." >&2
25
+ exit 2
26
+ fi
27
+
28
+ exit 0
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Blocks git push to main/master branch and force pushes.
5
+ # Used as PreToolUse hook on Bash(git push *).
6
+ # Exit 0 = allow, Exit 2 = block with feedback to Claude.
7
+
8
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ INPUT=$(cat)
10
+ COMMAND=$(echo "$INPUT" | "$HOOK_DIR/parse-json.sh" tool_input.command)
11
+
12
+ is_push_command() {
13
+ echo "$COMMAND" | grep -qE "git push"
14
+ }
15
+
16
+ if ! is_push_command; then
17
+ exit 0
18
+ fi
19
+
20
+ is_force_push() {
21
+ echo "$COMMAND" | grep -qE "\-\-force|\-f"
22
+ }
23
+
24
+ if is_force_push; then
25
+ echo "Force push is forbidden. Push normally to your feature branch instead." >&2
26
+ exit 2
27
+ fi
28
+
29
+ pushes_to_protected_branch() {
30
+ echo "$COMMAND" | grep -qE "git push.*(origin\s+(main|master)|origin/main|origin/master)"
31
+ }
32
+
33
+ if pushes_to_protected_branch; then
34
+ echo "Push to main/master is forbidden. Push to your feature branch instead." >&2
35
+ exit 2
36
+ fi
37
+
38
+ exit 0
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Blocks feature-implementer agent if no spec file exists or spec is incomplete.
5
+ # Used as PreToolUse hook on Agent.
6
+ # Exit 0 = allow, Exit 2 = block with feedback to Claude.
7
+
8
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ INPUT=$(cat)
10
+ PROMPT=$(echo "$INPUT" | "$HOOK_DIR/parse-json.sh" tool_input.prompt)
11
+
12
+ is_feature_agent() {
13
+ echo "$PROMPT" | grep -qi "feature-implementer\|feature-planner"
14
+ }
15
+
16
+ if ! is_feature_agent; then
17
+ exit 0
18
+ fi
19
+
20
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$HOOK_DIR/../.." && pwd)}"
21
+
22
+ spec_file_referenced() {
23
+ echo "$PROMPT" | grep -oP 'docs/specs/[a-z0-9-]+\.md' | head -1 || true
24
+ }
25
+
26
+ SPEC_REF=$(spec_file_referenced || true)
27
+
28
+ if [[ -z "$SPEC_REF" ]]; then
29
+ echo "No spec file referenced in the prompt. Create a spec first with /product-manager." >&2
30
+ exit 2
31
+ fi
32
+
33
+ if [[ ! -f "$PROJECT_DIR/$SPEC_REF" ]]; then
34
+ echo "Spec file '$SPEC_REF' does not exist. Create it first with /product-manager." >&2
35
+ exit 2
36
+ fi
37
+
38
+ SPEC_CONTENT=$(cat "$PROJECT_DIR/$SPEC_REF")
39
+
40
+ has_rules_section() {
41
+ echo "$SPEC_CONTENT" | grep -qE "^## (Rules|Business Rules|Remaining Scope|Functional Requirements)"
42
+ }
43
+
44
+ has_scenarios_section() {
45
+ echo "$SPEC_CONTENT" | grep -qE "^## (Scenarios|Acceptance Criteria|Gherkin Scenarios)"
46
+ }
47
+
48
+ if ! has_rules_section; then
49
+ echo "Spec '$SPEC_REF' is missing the '## Rules' (or '## Business Rules') section. Fix it before implementing." >&2
50
+ exit 2
51
+ fi
52
+
53
+ if ! has_scenarios_section; then
54
+ echo "Spec '$SPEC_REF' is missing the '## Scenarios' (or '## Acceptance Criteria') section. Fix it before implementing." >&2
55
+ exit 2
56
+ fi
57
+
58
+ exit 0
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Injects feature tracker status into session context on startup.
5
+ # Used as SessionStart hook.
6
+ # Outputs JSON with additionalContext field.
7
+
8
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$HOOK_DIR/../.." && pwd)}"
10
+ TRACKER="$PROJECT_DIR/docs/feature-tracker.md"
11
+
12
+ if [[ ! -f "$TRACKER" ]]; then
13
+ exit 0
14
+ fi
15
+
16
+ FEATURE_COUNT=$(tail -n +3 "$TRACKER" | grep -c '|' || true)
17
+
18
+ if [[ "$FEATURE_COUNT" -eq 0 ]]; then
19
+ exit 0
20
+ fi
21
+
22
+ IMPLEMENTING=$(tail -n +3 "$TRACKER" | grep -c "| implementing |" || true)
23
+ IMPLEMENTED=$(tail -n +3 "$TRACKER" | grep -c "| implemented |" || true)
24
+ DRAFTED=$(tail -n +3 "$TRACKER" | grep -c "| drafted |" || true)
25
+ PLANNED=$(tail -n +3 "$TRACKER" | grep -c "| planned |" || true)
26
+
27
+ CONTEXT="Feature tracker: ${FEATURE_COUNT} features total"
28
+
29
+ if [[ "$IMPLEMENTING" -gt 0 ]]; then
30
+ CONTEXT="$CONTEXT, ${IMPLEMENTING} in progress"
31
+ fi
32
+ if [[ "$PLANNED" -gt 0 ]]; then
33
+ CONTEXT="$CONTEXT, ${PLANNED} planned"
34
+ fi
35
+ if [[ "$DRAFTED" -gt 0 ]]; then
36
+ CONTEXT="$CONTEXT, ${DRAFTED} drafted"
37
+ fi
38
+ if [[ "$IMPLEMENTED" -gt 0 ]]; then
39
+ CONTEXT="$CONTEXT, ${IMPLEMENTED} implemented"
40
+ fi
41
+
42
+ CONTEXT="$CONTEXT. See docs/feature-tracker.md for details."
43
+
44
+ python3 -c "
45
+ import json, sys
46
+ print(json.dumps({'additionalContext': sys.argv[1]}))
47
+ " "$CONTEXT"
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Test runner for Claude Code hook scripts.
5
+ # Run: bash scripts/hooks/tests/run-tests.sh
6
+
7
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
8
+ TEST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+ PASSED=0
10
+ FAILED=0
11
+ TOTAL=0
12
+
13
+ green() { printf "\033[32m%s\033[0m\n" "$1"; }
14
+ red() { printf "\033[31m%s\033[0m\n" "$1"; }
15
+ bold() { printf "\033[1m%s\033[0m\n" "$1"; }
16
+
17
+ assert_exit() {
18
+ local test_name="$1"
19
+ local expected_exit="$2"
20
+ local actual_exit="$3"
21
+ TOTAL=$((TOTAL + 1))
22
+
23
+ if [[ "$actual_exit" -eq "$expected_exit" ]]; then
24
+ green " PASS: $test_name (exit $actual_exit)"
25
+ PASSED=$((PASSED + 1))
26
+ else
27
+ red " FAIL: $test_name (expected exit $expected_exit, got $actual_exit)"
28
+ FAILED=$((FAILED + 1))
29
+ fi
30
+ }
31
+
32
+ assert_stderr_contains() {
33
+ local test_name="$1"
34
+ local pattern="$2"
35
+ local stderr_output="$3"
36
+ TOTAL=$((TOTAL + 1))
37
+
38
+ if echo "$stderr_output" | grep -q "$pattern"; then
39
+ green " PASS: $test_name (stderr contains '$pattern')"
40
+ PASSED=$((PASSED + 1))
41
+ else
42
+ red " FAIL: $test_name (stderr missing '$pattern')"
43
+ red " actual stderr: $stderr_output"
44
+ FAILED=$((FAILED + 1))
45
+ fi
46
+ }
47
+
48
+ # ─────────────────────────────────────────────
49
+ bold "=== parse-json.sh ==="
50
+
51
+ OUTPUT=$(echo '{"tool_input":{"command":"git commit -m test"}}' | "$HOOK_DIR/parse-json.sh" tool_input.command)
52
+ TOTAL=$((TOTAL + 1))
53
+ if [[ "$OUTPUT" == "git commit -m test" ]]; then
54
+ green " PASS: extracts nested value"
55
+ PASSED=$((PASSED + 1))
56
+ else
57
+ red " FAIL: extracts nested value (got: $OUTPUT)"
58
+ FAILED=$((FAILED + 1))
59
+ fi
60
+
61
+ OUTPUT=$(echo '{"tool_input":{"command":"test"}}' | "$HOOK_DIR/parse-json.sh" tool_input.missing_key)
62
+ TOTAL=$((TOTAL + 1))
63
+ if [[ -z "$OUTPUT" ]]; then
64
+ green " PASS: returns empty for missing key"
65
+ PASSED=$((PASSED + 1))
66
+ else
67
+ red " FAIL: returns empty for missing key (got: $OUTPUT)"
68
+ FAILED=$((FAILED + 1))
69
+ fi
70
+
71
+ # ─────────────────────────────────────────────
72
+ bold "=== no-barrel-exports.sh ==="
73
+
74
+ echo '{"tool_input":{"file_path":"/src/entities/index.ts"}}' | "$HOOK_DIR/no-barrel-exports.sh" > /dev/null 2>&1 || true
75
+ EXIT_CODE=$(echo '{"tool_input":{"file_path":"/src/entities/index.ts"}}' | "$HOOK_DIR/no-barrel-exports.sh" > /dev/null 2>&1; echo $?) || true
76
+ assert_exit "blocks index.ts" 2 "$EXIT_CODE"
77
+
78
+ STDERR=$(echo '{"tool_input":{"file_path":"/src/entities/index.ts"}}' | "$HOOK_DIR/no-barrel-exports.sh" 2>&1 > /dev/null || true)
79
+ assert_stderr_contains "error message mentions barrel" "Barrel exports" "$STDERR"
80
+
81
+ EXIT_CODE=$(echo '{"tool_input":{"file_path":"/src/entities/review.ts"}}' | "$HOOK_DIR/no-barrel-exports.sh" > /dev/null 2>&1; echo $?) || true
82
+ assert_exit "allows normal .ts file" 0 "$EXIT_CODE"
83
+
84
+ EXIT_CODE=$(echo '{"tool_input":{"file_path":"/src/entities/index.js"}}' | "$HOOK_DIR/no-barrel-exports.sh" > /dev/null 2>&1; echo $?) || true
85
+ assert_exit "blocks index.js" 2 "$EXIT_CODE"
86
+
87
+ # ─────────────────────────────────────────────
88
+ bold "=== protect-main-branch.sh ==="
89
+
90
+ # Create temp git repo to test
91
+ TEMP_REPO=$(mktemp -d)
92
+ cd "$TEMP_REPO"
93
+ git init -q
94
+ git checkout -q -b master
95
+
96
+ EXIT_CODE=$(echo '{"tool_input":{"command":"git commit -m test"}}' | CLAUDE_PROJECT_DIR="$TEMP_REPO" "$HOOK_DIR/protect-main-branch.sh" > /dev/null 2>&1; echo $?) || true
97
+ assert_exit "blocks commit on master" 2 "$EXIT_CODE"
98
+
99
+ STDERR=$(echo '{"tool_input":{"command":"git commit -m test"}}' | CLAUDE_PROJECT_DIR="$TEMP_REPO" "$HOOK_DIR/protect-main-branch.sh" 2>&1 > /dev/null || true)
100
+ assert_stderr_contains "mentions master" "master" "$STDERR"
101
+
102
+ git checkout -q -b feat/test
103
+ EXIT_CODE=$(echo '{"tool_input":{"command":"git commit -m test"}}' | CLAUDE_PROJECT_DIR="$TEMP_REPO" "$HOOK_DIR/protect-main-branch.sh" > /dev/null 2>&1; echo $?) || true
104
+ assert_exit "allows commit on feature branch" 0 "$EXIT_CODE"
105
+
106
+ EXIT_CODE=$(echo '{"tool_input":{"command":"ls -la"}}' | CLAUDE_PROJECT_DIR="$TEMP_REPO" "$HOOK_DIR/protect-main-branch.sh" > /dev/null 2>&1; echo $?) || true
107
+ assert_exit "ignores non-commit commands" 0 "$EXIT_CODE"
108
+
109
+ rm -rf "$TEMP_REPO"
110
+
111
+ # ─────────────────────────────────────────────
112
+ bold "=== protect-main-push.sh ==="
113
+
114
+ EXIT_CODE=$(echo '{"tool_input":{"command":"git push origin master"}}' | "$HOOK_DIR/protect-main-push.sh" > /dev/null 2>&1; echo $?) || true
115
+ assert_exit "blocks push to master" 2 "$EXIT_CODE"
116
+
117
+ EXIT_CODE=$(echo '{"tool_input":{"command":"git push origin main"}}' | "$HOOK_DIR/protect-main-push.sh" > /dev/null 2>&1; echo $?) || true
118
+ assert_exit "blocks push to main" 2 "$EXIT_CODE"
119
+
120
+ EXIT_CODE=$(echo '{"tool_input":{"command":"git push --force origin feat/test"}}' | "$HOOK_DIR/protect-main-push.sh" > /dev/null 2>&1; echo $?) || true
121
+ assert_exit "blocks force push" 2 "$EXIT_CODE"
122
+
123
+ EXIT_CODE=$(echo '{"tool_input":{"command":"git push origin feat/test"}}' | "$HOOK_DIR/protect-main-push.sh" > /dev/null 2>&1; echo $?) || true
124
+ assert_exit "allows push to feature branch" 0 "$EXIT_CODE"
125
+
126
+ EXIT_CODE=$(echo '{"tool_input":{"command":"ls -la"}}' | "$HOOK_DIR/protect-main-push.sh" > /dev/null 2>&1; echo $?) || true
127
+ assert_exit "ignores non-push commands" 0 "$EXIT_CODE"
128
+
129
+ # ─────────────────────────────────────────────
130
+ bold "=== require-spec.sh ==="
131
+
132
+ PROJECT_DIR="$HOOK_DIR/../.."
133
+
134
+ # Test: no spec reference → block
135
+ EXIT_CODE=$(echo '{"tool_input":{"prompt":"implement the feature-implementer for login"}}' | CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$HOOK_DIR/require-spec.sh" > /dev/null 2>&1; echo $?) || true
136
+ assert_exit "blocks agent without spec reference" 2 "$EXIT_CODE"
137
+
138
+ STDERR=$(echo '{"tool_input":{"prompt":"implement the feature-implementer for login"}}' | CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$HOOK_DIR/require-spec.sh" 2>&1 > /dev/null || true)
139
+ assert_stderr_contains "suggests /product-manager" "product-manager" "$STDERR"
140
+
141
+ # Test: non-feature agent → allow
142
+ EXIT_CODE=$(echo '{"tool_input":{"prompt":"review the code in src/entities"}}' | CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$HOOK_DIR/require-spec.sh" > /dev/null 2>&1; echo $?) || true
143
+ assert_exit "allows non-feature agents" 0 "$EXIT_CODE"
144
+
145
+ # Test: valid spec reference with existing file
146
+ EXIT_CODE=$(echo '{"tool_input":{"prompt":"run feature-planner with docs/specs/44-zod-guard-gitlab-webhook.md"}}' | CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$HOOK_DIR/require-spec.sh" > /dev/null 2>&1; echo $?) || true
147
+ assert_exit "allows with valid spec reference" 0 "$EXIT_CODE"
148
+
149
+ # Test: spec file does not exist
150
+ EXIT_CODE=$(echo '{"tool_input":{"prompt":"run feature-implementer with docs/specs/999-nonexistent.md"}}' | CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$HOOK_DIR/require-spec.sh" > /dev/null 2>&1; echo $?) || true
151
+ assert_exit "blocks with nonexistent spec" 2 "$EXIT_CODE"
152
+
153
+ # ─────────────────────────────────────────────
154
+ bold "=== session-context.sh ==="
155
+
156
+ EXIT_CODE=$(CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$HOOK_DIR/session-context.sh" > /dev/null 2>&1; echo $?) || true
157
+ assert_exit "runs without error" 0 "$EXIT_CODE"
158
+
159
+ OUTPUT=$(CLAUDE_PROJECT_DIR="$PROJECT_DIR" "$HOOK_DIR/session-context.sh" 2>/dev/null || true)
160
+ TOTAL=$((TOTAL + 1))
161
+ if echo "$OUTPUT" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null; then
162
+ green " PASS: outputs valid JSON"
163
+ PASSED=$((PASSED + 1))
164
+ else
165
+ red " FAIL: outputs valid JSON (got: $OUTPUT)"
166
+ FAILED=$((FAILED + 1))
167
+ fi
168
+
169
+ TOTAL=$((TOTAL + 1))
170
+ if echo "$OUTPUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'additionalContext' in d" 2>/dev/null; then
171
+ green " PASS: JSON contains additionalContext"
172
+ PASSED=$((PASSED + 1))
173
+ else
174
+ red " FAIL: JSON contains additionalContext (got: $OUTPUT)"
175
+ FAILED=$((FAILED + 1))
176
+ fi
177
+
178
+ # ─────────────────────────────────────────────
179
+ echo ""
180
+ bold "=== RESULTS ==="
181
+ echo "Total: $TOTAL | Passed: $PASSED | Failed: $FAILED"
182
+
183
+ if [[ "$FAILED" -gt 0 ]]; then
184
+ red "SOME TESTS FAILED"
185
+ exit 1
186
+ else
187
+ green "ALL TESTS PASSED"
188
+ exit 0
189
+ fi
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Blocks git commit if staged files include src/ code but the corresponding
5
+ # spec does not have "## Status: implemented" and the feature-tracker is stale.
6
+ # Used as PreToolUse hook on Bash(git commit *).
7
+ # Exit 0 = allow, Exit 2 = block with feedback to Claude.
8
+
9
+ HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ INPUT=$(cat)
11
+ COMMAND=$(echo "$INPUT" | "$HOOK_DIR/parse-json.sh" tool_input.command)
12
+
13
+ is_commit_command() {
14
+ echo "$COMMAND" | grep -qE "git commit"
15
+ }
16
+
17
+ if ! is_commit_command; then
18
+ exit 0
19
+ fi
20
+
21
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(cd "$HOOK_DIR/../.." && pwd)}"
22
+ cd "$PROJECT_DIR"
23
+
24
+ STAGED_FILES=$(git diff --cached --name-only 2>/dev/null || true)
25
+
26
+ if [[ -z "$STAGED_FILES" ]]; then
27
+ exit 0
28
+ fi
29
+
30
+ has_source_code() {
31
+ echo "$STAGED_FILES" | grep -q "^src/" 2>/dev/null
32
+ }
33
+
34
+ if ! has_source_code; then
35
+ exit 0
36
+ fi
37
+
38
+ TRACKER="$PROJECT_DIR/docs/feature-tracker.md"
39
+ SPECS_DIR="$PROJECT_DIR/docs/specs"
40
+
41
+ ERRORS=""
42
+
43
+ if [[ -f "$TRACKER" ]]; then
44
+ DRAFTS_IN_TRACKER=$(grep -c "| drafted |" "$TRACKER" || true)
45
+ STAGED_HAS_TRACKER=$(echo "$STAGED_FILES" | grep -c "docs/feature-tracker.md" || true)
46
+
47
+ if [[ "$DRAFTS_IN_TRACKER" -gt 0 && "$STAGED_HAS_TRACKER" -eq 0 ]]; then
48
+ IMPLEMENTING=$(grep -c "| implementing |" "$TRACKER" || true)
49
+ if [[ "$IMPLEMENTING" -gt 0 ]]; then
50
+ ERRORS="${ERRORS}Feature tracker has features in 'implementing' status. Update docs/feature-tracker.md before committing.\n"
51
+ fi
52
+ fi
53
+ fi
54
+
55
+ if [[ -d "$SPECS_DIR" ]]; then
56
+ for SPEC_FILE in "$SPECS_DIR"/*.md; do
57
+ [[ -f "$SPEC_FILE" ]] || continue
58
+ SPEC_NAME=$(basename "$SPEC_FILE")
59
+
60
+ if grep -q "^## Status: implemented" "$SPEC_FILE" 2>/dev/null; then
61
+ continue
62
+ fi
63
+
64
+ if echo "$STAGED_FILES" | grep -q "$SPEC_NAME" 2>/dev/null; then
65
+ continue
66
+ fi
67
+ done
68
+ fi
69
+
70
+ if [[ -n "$ERRORS" ]]; then
71
+ echo -e "$ERRORS" >&2
72
+ exit 2
73
+ fi
74
+
75
+ exit 0