reviewflow 3.8.0 → 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,21 @@ 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
+
16
+ ## [3.8.1](https://github.com/DGouron/review-flow/compare/reviewflow-v3.8.0...reviewflow-v3.8.1) (2026-03-16)
17
+
18
+
19
+ ### Fixed
20
+
21
+ * **docs:** escape angle bracket placeholders in spec files ([e82ff68](https://github.com/DGouron/review-flow/commit/e82ff688a5ce9e556d01a94369ac14b4598bfa2c))
22
+
8
23
  ## [3.8.0](https://github.com/DGouron/review-flow/compare/reviewflow-v3.7.0...reviewflow-v3.8.0) (2026-03-16)
9
24
 
10
25
 
package/README.md CHANGED
@@ -92,9 +92,32 @@ A WebSocket-powered dashboard shows live review progress:
92
92
  - Phase and agent-level progress bars
93
93
  - Running / queued / completed review counts
94
94
  - Review history with duration, scores, and error details
95
+ - **Team tab** with developer cards, insights, and AI analysis
96
+ - **Stats section** with canvas charts, score trends, and animated counters
95
97
  - Log stream for debugging
96
98
  - Auto-reconnection with exponential backoff
97
99
 
100
+ ### Developer & Team Insights
101
+
102
+ The dashboard computes performance insights from your review history — no configuration needed.
103
+
104
+ **Per-developer analysis** across 4 categories:
105
+
106
+ | Category | What it measures |
107
+ |----------|-----------------|
108
+ | Quality | Average score, blocking issues ratio |
109
+ | Responsiveness | Review turnaround time vs team average |
110
+ | Code Volume | Additions/deletions per review |
111
+ | Iteration | First-pass quality rate (reviews without blocking issues) |
112
+
113
+ Each developer gets a **level** (beginner → expert), a **trend** (improving / stable / declining), identified **strengths and weaknesses**, and a title based on their strongest category (Architect, Firefighter, Workhorse, Sentinel, or Balanced).
114
+
115
+ **Team-level analysis** shows top performer, most improved developer, and actionable tips.
116
+
117
+ **AI-powered narrative** (optional): click "Generate AI Insights" to have Claude produce a written analysis with per-developer and team recommendations.
118
+
119
+ Insights are computed from the first 5 reviews onward and persist across sessions.
120
+
98
121
  ### Follow-Up Reviews
99
122
 
100
123
  When a developer pushes fixes after a review, Claude automatically:
@@ -210,6 +233,11 @@ For detailed setup, see the **[Quick Start Guide](https://dgouron.github.io/revi
210
233
  | `/webhooks/github` | POST | GitHub webhook receiver |
211
234
  | `/api/reviews` | GET | List reviews |
212
235
  | `/api/reviews/cancel/:jobId` | POST | Cancel a running review |
236
+ | `/api/insights?path=` | GET | Developer & team insights |
237
+ | `/api/insights/generate` | POST | Generate AI-powered insights via Claude |
238
+ | `/api/stats/recalculate` | POST | Recalculate stats with optional diff backfill |
239
+ | `/api/version/check` | GET | Check for updates |
240
+ | `/api/version/update` | POST | Trigger self-update |
213
241
  | `/ws` | WS | Real-time progress updates |
214
242
 
215
243
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviewflow",
3
- "version": "3.8.0",
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