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 +8 -0
- package/package.json +1 -1
- package/scripts/hooks/no-barrel-exports.sh +23 -0
- package/scripts/hooks/parse-json.sh +17 -0
- package/scripts/hooks/pre-commit-gate.sh +29 -0
- package/scripts/hooks/protect-main-branch.sh +28 -0
- package/scripts/hooks/protect-main-push.sh +38 -0
- package/scripts/hooks/require-spec.sh +58 -0
- package/scripts/hooks/session-context.sh +47 -0
- package/scripts/hooks/tests/run-tests.sh +189 -0
- package/scripts/hooks/verify-spec-updated.sh +75 -0
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
|
@@ -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
|