@hustle-together/api-dev-tools 1.2.1 → 1.6.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/README.md +48 -5
- package/bin/cli.js +27 -27
- package/demo/workflow-demo.html +1945 -0
- package/hooks/api-workflow-check.py +135 -26
- package/hooks/enforce-interview.py +183 -0
- package/hooks/track-tool-use.py +38 -3
- package/hooks/verify-implementation.py +225 -0
- package/package.json +2 -1
- package/templates/settings.json +23 -1
|
@@ -6,33 +6,104 @@ Purpose: Check if all required phases are complete before allowing stop
|
|
|
6
6
|
This hook runs when Claude tries to stop/end the conversation.
|
|
7
7
|
It checks api-dev-state.json to ensure critical workflow phases completed.
|
|
8
8
|
|
|
9
|
+
Gap Fixes Applied:
|
|
10
|
+
- Gap 2: Shows files_created vs files_modified to verify all claimed changes
|
|
11
|
+
- Gap 3: Warns if there are verification_warnings that weren't addressed
|
|
12
|
+
- Gap 4: Requires explicit verification that implementation matches interview
|
|
13
|
+
|
|
9
14
|
Returns:
|
|
10
15
|
- {"decision": "approve"} - Allow stopping
|
|
11
16
|
- {"decision": "block", "reason": "..."} - Prevent stopping with explanation
|
|
12
17
|
"""
|
|
13
18
|
import json
|
|
14
19
|
import sys
|
|
20
|
+
import subprocess
|
|
15
21
|
from pathlib import Path
|
|
16
22
|
|
|
17
23
|
# State file is in .claude/ directory (sibling to hooks/)
|
|
18
24
|
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
19
25
|
|
|
20
26
|
# Phases that MUST be complete before stopping
|
|
21
|
-
# These are the critical phases - others are optional
|
|
22
27
|
REQUIRED_PHASES = [
|
|
23
28
|
("research_initial", "Initial research (Context7/WebSearch)"),
|
|
29
|
+
("interview", "User interview"),
|
|
24
30
|
("tdd_red", "TDD Red phase (failing tests written)"),
|
|
25
31
|
("tdd_green", "TDD Green phase (tests passing)"),
|
|
26
32
|
]
|
|
27
33
|
|
|
28
34
|
# Phases that SHOULD be complete (warning but don't block)
|
|
29
35
|
RECOMMENDED_PHASES = [
|
|
30
|
-
("interview", "User interview"),
|
|
31
36
|
("schema_creation", "Schema creation"),
|
|
37
|
+
("tdd_refactor", "TDD Refactor phase"),
|
|
32
38
|
("documentation", "Documentation updates"),
|
|
33
39
|
]
|
|
34
40
|
|
|
35
41
|
|
|
42
|
+
def get_git_modified_files() -> list[str]:
|
|
43
|
+
"""Get list of modified files from git.
|
|
44
|
+
|
|
45
|
+
Gap 2 Fix: Verify which files actually changed.
|
|
46
|
+
"""
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["git", "diff", "--name-only", "HEAD"],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
cwd=STATE_FILE.parent.parent # Project root
|
|
53
|
+
)
|
|
54
|
+
if result.returncode == 0:
|
|
55
|
+
return [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check_verification_warnings(state: dict) -> list[str]:
|
|
62
|
+
"""Check for unaddressed verification warnings.
|
|
63
|
+
|
|
64
|
+
Gap 3 Fix: Don't accept "skipped" or warnings without explanation.
|
|
65
|
+
"""
|
|
66
|
+
warnings = state.get("verification_warnings", [])
|
|
67
|
+
if warnings:
|
|
68
|
+
return [
|
|
69
|
+
"⚠️ Unaddressed verification warnings:",
|
|
70
|
+
*[f" - {w}" for w in warnings[-5:]], # Show last 5
|
|
71
|
+
"",
|
|
72
|
+
"Please review and address these warnings before completing."
|
|
73
|
+
]
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def check_interview_implementation_match(state: dict) -> list[str]:
|
|
78
|
+
"""Verify implementation matches interview requirements.
|
|
79
|
+
|
|
80
|
+
Gap 4 Fix: Define specific "done" criteria based on interview.
|
|
81
|
+
"""
|
|
82
|
+
issues = []
|
|
83
|
+
|
|
84
|
+
interview = state.get("phases", {}).get("interview", {})
|
|
85
|
+
questions = interview.get("questions", [])
|
|
86
|
+
|
|
87
|
+
# Extract key requirements from interview
|
|
88
|
+
all_text = " ".join(str(q) for q in questions)
|
|
89
|
+
|
|
90
|
+
# Check files_created includes expected patterns
|
|
91
|
+
files_created = state.get("files_created", [])
|
|
92
|
+
|
|
93
|
+
# Look for route files if interview mentioned endpoints
|
|
94
|
+
if "endpoint" in all_text.lower() or "/api/" in all_text.lower():
|
|
95
|
+
route_files = [f for f in files_created if "route.ts" in f]
|
|
96
|
+
if not route_files:
|
|
97
|
+
issues.append("⚠️ Interview mentioned endpoints but no route.ts files were created")
|
|
98
|
+
|
|
99
|
+
# Look for test files
|
|
100
|
+
test_files = [f for f in files_created if ".test." in f or "__tests__" in f]
|
|
101
|
+
if not test_files:
|
|
102
|
+
issues.append("⚠️ No test files tracked in files_created")
|
|
103
|
+
|
|
104
|
+
return issues
|
|
105
|
+
|
|
106
|
+
|
|
36
107
|
def main():
|
|
37
108
|
# If no state file, we're not in an API workflow - allow stop
|
|
38
109
|
if not STATE_FILE.exists():
|
|
@@ -56,6 +127,9 @@ def main():
|
|
|
56
127
|
print(json.dumps({"decision": "approve"}))
|
|
57
128
|
sys.exit(0)
|
|
58
129
|
|
|
130
|
+
# Collect all issues
|
|
131
|
+
all_issues = []
|
|
132
|
+
|
|
59
133
|
# Check required phases
|
|
60
134
|
incomplete_required = []
|
|
61
135
|
for phase_key, phase_name in REQUIRED_PHASES:
|
|
@@ -64,6 +138,10 @@ def main():
|
|
|
64
138
|
if status != "complete":
|
|
65
139
|
incomplete_required.append(f" - {phase_name} ({status})")
|
|
66
140
|
|
|
141
|
+
if incomplete_required:
|
|
142
|
+
all_issues.append("❌ REQUIRED phases incomplete:")
|
|
143
|
+
all_issues.extend(incomplete_required)
|
|
144
|
+
|
|
67
145
|
# Check recommended phases
|
|
68
146
|
incomplete_recommended = []
|
|
69
147
|
for phase_key, phase_name in RECOMMENDED_PHASES:
|
|
@@ -72,42 +150,73 @@ def main():
|
|
|
72
150
|
if status != "complete":
|
|
73
151
|
incomplete_recommended.append(f" - {phase_name} ({status})")
|
|
74
152
|
|
|
153
|
+
# Gap 2: Check git diff vs tracked files
|
|
154
|
+
git_files = get_git_modified_files()
|
|
155
|
+
tracked_files = state.get("files_created", []) + state.get("files_modified", [])
|
|
156
|
+
|
|
157
|
+
if git_files and tracked_files:
|
|
158
|
+
# Find files in git but not tracked
|
|
159
|
+
untracked_changes = []
|
|
160
|
+
for gf in git_files:
|
|
161
|
+
if not any(gf.endswith(tf) or tf in gf for tf in tracked_files):
|
|
162
|
+
if gf.endswith(".ts") and ("/api/" in gf or "/lib/" in gf):
|
|
163
|
+
untracked_changes.append(gf)
|
|
164
|
+
|
|
165
|
+
if untracked_changes:
|
|
166
|
+
all_issues.append("\n⚠️ Gap 2: Files changed but not tracked:")
|
|
167
|
+
all_issues.extend([f" - {f}" for f in untracked_changes[:5]])
|
|
168
|
+
|
|
169
|
+
# Gap 3: Check for unaddressed warnings
|
|
170
|
+
warning_issues = check_verification_warnings(state)
|
|
171
|
+
if warning_issues:
|
|
172
|
+
all_issues.append("\n" + "\n".join(warning_issues))
|
|
173
|
+
|
|
174
|
+
# Gap 4: Check interview-implementation match
|
|
175
|
+
match_issues = check_interview_implementation_match(state)
|
|
176
|
+
if match_issues:
|
|
177
|
+
all_issues.append("\n⚠️ Gap 4: Implementation verification:")
|
|
178
|
+
all_issues.extend([f" {i}" for i in match_issues])
|
|
179
|
+
|
|
75
180
|
# Block if required phases incomplete
|
|
76
181
|
if incomplete_required:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
reason_parts.append("\n\n⚠️ Also recommended but not complete:")
|
|
82
|
-
reason_parts.extend(incomplete_recommended)
|
|
83
|
-
|
|
84
|
-
reason_parts.append("\n\nTo continue:")
|
|
85
|
-
reason_parts.append(" 1. Complete required phases above")
|
|
86
|
-
reason_parts.append(" 2. Use /api-status to see detailed progress")
|
|
87
|
-
reason_parts.append(" 3. Or manually mark phases complete in .claude/api-dev-state.json")
|
|
182
|
+
all_issues.append("\n\nTo continue:")
|
|
183
|
+
all_issues.append(" 1. Complete required phases above")
|
|
184
|
+
all_issues.append(" 2. Use /api-status to see detailed progress")
|
|
185
|
+
all_issues.append(" 3. Run `git diff --name-only` to verify changes")
|
|
88
186
|
|
|
89
187
|
print(json.dumps({
|
|
90
188
|
"decision": "block",
|
|
91
|
-
"reason": "\n".join(
|
|
189
|
+
"reason": "\n".join(all_issues)
|
|
92
190
|
}))
|
|
93
191
|
sys.exit(0)
|
|
94
192
|
|
|
95
|
-
#
|
|
96
|
-
|
|
97
|
-
# Allow but the reason will be shown to user
|
|
98
|
-
print(json.dumps({
|
|
99
|
-
"decision": "approve",
|
|
100
|
-
"message": f"""⚠️ API workflow completing with optional phases pending:
|
|
101
|
-
{chr(10).join(incomplete_recommended)}
|
|
193
|
+
# Build completion message
|
|
194
|
+
message_parts = ["✅ API workflow completing"]
|
|
102
195
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
196
|
+
if incomplete_recommended:
|
|
197
|
+
message_parts.append("\n⚠️ Optional phases skipped:")
|
|
198
|
+
message_parts.extend(incomplete_recommended)
|
|
199
|
+
|
|
200
|
+
# Show summary of tracked files
|
|
201
|
+
files_created = state.get("files_created", [])
|
|
202
|
+
if files_created:
|
|
203
|
+
message_parts.append(f"\n📁 Files created: {len(files_created)}")
|
|
204
|
+
for f in files_created[:5]:
|
|
205
|
+
message_parts.append(f" - {f}")
|
|
206
|
+
if len(files_created) > 5:
|
|
207
|
+
message_parts.append(f" ... and {len(files_created) - 5} more")
|
|
208
|
+
|
|
209
|
+
# Show any remaining warnings
|
|
210
|
+
if warning_issues or match_issues:
|
|
211
|
+
message_parts.append("\n⚠️ Review suggested:")
|
|
212
|
+
if warning_issues:
|
|
213
|
+
message_parts.extend(warning_issues[:3])
|
|
214
|
+
if match_issues:
|
|
215
|
+
message_parts.extend(match_issues[:3])
|
|
106
216
|
|
|
107
|
-
# All phases complete
|
|
108
217
|
print(json.dumps({
|
|
109
218
|
"decision": "approve",
|
|
110
|
-
"message": "
|
|
219
|
+
"message": "\n".join(message_parts)
|
|
111
220
|
}))
|
|
112
221
|
sys.exit(0)
|
|
113
222
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PreToolUse for Write/Edit
|
|
4
|
+
Purpose: Block proceeding to schema/TDD if interview has no USER answers
|
|
5
|
+
|
|
6
|
+
This hook ensures Claude actually asks the user questions and records
|
|
7
|
+
their answers, rather than self-answering the interview.
|
|
8
|
+
|
|
9
|
+
It checks:
|
|
10
|
+
1. Interview status is "complete"
|
|
11
|
+
2. There are actual questions with answers
|
|
12
|
+
3. Answers don't look auto-generated (contain user-specific details)
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
- {"permissionDecision": "allow"} - Let the tool run
|
|
16
|
+
- {"permissionDecision": "deny", "reason": "..."} - Block with explanation
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
# State file is in .claude/ directory (sibling to hooks/)
|
|
23
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
24
|
+
|
|
25
|
+
# Minimum questions required for a valid interview
|
|
26
|
+
MIN_QUESTIONS = 3
|
|
27
|
+
|
|
28
|
+
# Phrases that indicate self-answered (not real user input)
|
|
29
|
+
SELF_ANSWER_INDICATORS = [
|
|
30
|
+
"based on common",
|
|
31
|
+
"self-answered",
|
|
32
|
+
"assumed",
|
|
33
|
+
"typical use case",
|
|
34
|
+
"standard implementation",
|
|
35
|
+
"common pattern",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def main():
|
|
40
|
+
# Read hook input from stdin
|
|
41
|
+
try:
|
|
42
|
+
input_data = json.load(sys.stdin)
|
|
43
|
+
except json.JSONDecodeError:
|
|
44
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
45
|
+
sys.exit(0)
|
|
46
|
+
|
|
47
|
+
tool_input = input_data.get("tool_input", {})
|
|
48
|
+
file_path = tool_input.get("file_path", "")
|
|
49
|
+
|
|
50
|
+
# Enforce for ANY file in /api/ directory (not just route.ts)
|
|
51
|
+
# This forces Claude to stop and interview before ANY API work
|
|
52
|
+
is_api_file = "/api/" in file_path and file_path.endswith(".ts")
|
|
53
|
+
is_schema_file = "/schemas/" in file_path and file_path.endswith(".ts")
|
|
54
|
+
|
|
55
|
+
# Skip test files - those are allowed during TDD
|
|
56
|
+
is_test_file = ".test." in file_path or "/__tests__/" in file_path or ".spec." in file_path
|
|
57
|
+
|
|
58
|
+
if is_test_file:
|
|
59
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
60
|
+
sys.exit(0)
|
|
61
|
+
|
|
62
|
+
if not is_schema_file and not is_api_file:
|
|
63
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
64
|
+
sys.exit(0)
|
|
65
|
+
|
|
66
|
+
# Check if state file exists
|
|
67
|
+
if not STATE_FILE.exists():
|
|
68
|
+
print(json.dumps({
|
|
69
|
+
"permissionDecision": "deny",
|
|
70
|
+
"reason": """❌ API workflow not started.
|
|
71
|
+
|
|
72
|
+
Run /api-create [endpoint-name] to begin the interview-driven workflow."""
|
|
73
|
+
}))
|
|
74
|
+
sys.exit(0)
|
|
75
|
+
|
|
76
|
+
# Load state
|
|
77
|
+
try:
|
|
78
|
+
state = json.loads(STATE_FILE.read_text())
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
81
|
+
sys.exit(0)
|
|
82
|
+
|
|
83
|
+
phases = state.get("phases", {})
|
|
84
|
+
interview = phases.get("interview", {})
|
|
85
|
+
interview_status = interview.get("status", "not_started")
|
|
86
|
+
interview_desc = interview.get("description", "").lower()
|
|
87
|
+
questions = interview.get("questions", [])
|
|
88
|
+
|
|
89
|
+
# Check 1: Interview must be complete
|
|
90
|
+
if interview_status != "complete":
|
|
91
|
+
print(json.dumps({
|
|
92
|
+
"permissionDecision": "deny",
|
|
93
|
+
"reason": f"""❌ BLOCKED: Interview phase not complete.
|
|
94
|
+
|
|
95
|
+
Current status: {interview_status}
|
|
96
|
+
AskUserQuestion calls: {interview.get('user_question_count', 0)}
|
|
97
|
+
|
|
98
|
+
═══════════════════════════════════════════════════════════
|
|
99
|
+
⚠️ YOU MUST STOP AND ASK THE USER QUESTIONS NOW
|
|
100
|
+
═══════════════════════════════════════════════════════════
|
|
101
|
+
|
|
102
|
+
Use the AskUserQuestion tool to ask EACH of these questions ONE AT A TIME:
|
|
103
|
+
|
|
104
|
+
1. "What is the primary purpose of this endpoint?"
|
|
105
|
+
2. "Who will use it and how?"
|
|
106
|
+
3. "What parameters are essential vs optional?"
|
|
107
|
+
|
|
108
|
+
WAIT for the user's response after EACH question before continuing.
|
|
109
|
+
|
|
110
|
+
DO NOT:
|
|
111
|
+
❌ Make up answers yourself
|
|
112
|
+
❌ Assume what the user wants
|
|
113
|
+
❌ Mark the interview as complete without asking
|
|
114
|
+
❌ Try to write any code until you have real answers
|
|
115
|
+
|
|
116
|
+
The system is tracking your AskUserQuestion calls. You need at least 3
|
|
117
|
+
actual calls with user responses to proceed."""
|
|
118
|
+
}))
|
|
119
|
+
sys.exit(0)
|
|
120
|
+
|
|
121
|
+
# Check 2: Must have minimum questions
|
|
122
|
+
if len(questions) < MIN_QUESTIONS:
|
|
123
|
+
print(json.dumps({
|
|
124
|
+
"permissionDecision": "deny",
|
|
125
|
+
"reason": f"""❌ Interview incomplete - not enough questions asked.
|
|
126
|
+
|
|
127
|
+
Questions recorded: {len(questions)}
|
|
128
|
+
Minimum required: {MIN_QUESTIONS}
|
|
129
|
+
|
|
130
|
+
You must ask the user more questions about their requirements.
|
|
131
|
+
DO NOT proceed without understanding the user's actual needs."""
|
|
132
|
+
}))
|
|
133
|
+
sys.exit(0)
|
|
134
|
+
|
|
135
|
+
# Check 2.5: Verify AskUserQuestion tool was actually used
|
|
136
|
+
user_question_count = interview.get("user_question_count", 0)
|
|
137
|
+
tool_used_count = sum(1 for q in questions if q.get("tool_used", False))
|
|
138
|
+
|
|
139
|
+
if tool_used_count < MIN_QUESTIONS:
|
|
140
|
+
print(json.dumps({
|
|
141
|
+
"permissionDecision": "deny",
|
|
142
|
+
"reason": f"""❌ Interview not conducted properly.
|
|
143
|
+
|
|
144
|
+
AskUserQuestion tool uses tracked: {tool_used_count}
|
|
145
|
+
Minimum required: {MIN_QUESTIONS}
|
|
146
|
+
|
|
147
|
+
You MUST use the AskUserQuestion tool to ask the user directly.
|
|
148
|
+
Do NOT make up answers or mark the interview as complete without
|
|
149
|
+
actually asking the user and receiving their responses.
|
|
150
|
+
|
|
151
|
+
The system tracks when AskUserQuestion is used. Self-answering
|
|
152
|
+
will be detected and blocked."""
|
|
153
|
+
}))
|
|
154
|
+
sys.exit(0)
|
|
155
|
+
|
|
156
|
+
# Check 3: Look for self-answer indicators
|
|
157
|
+
for indicator in SELF_ANSWER_INDICATORS:
|
|
158
|
+
if indicator in interview_desc:
|
|
159
|
+
print(json.dumps({
|
|
160
|
+
"permissionDecision": "deny",
|
|
161
|
+
"reason": f"""❌ Interview appears to be self-answered.
|
|
162
|
+
|
|
163
|
+
Detected: "{indicator}" in interview description.
|
|
164
|
+
|
|
165
|
+
You MUST actually ask the user questions using AskUserQuestion.
|
|
166
|
+
Self-answering the interview defeats its purpose.
|
|
167
|
+
|
|
168
|
+
Reset the interview phase and ask the user directly:
|
|
169
|
+
1. What do you want this endpoint to do?
|
|
170
|
+
2. Which providers/models should it support?
|
|
171
|
+
3. What parameters matter most to you?
|
|
172
|
+
|
|
173
|
+
Wait for their real answers before proceeding."""
|
|
174
|
+
}))
|
|
175
|
+
sys.exit(0)
|
|
176
|
+
|
|
177
|
+
# All checks passed
|
|
178
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
179
|
+
sys.exit(0)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
if __name__ == "__main__":
|
|
183
|
+
main()
|
package/hooks/track-tool-use.py
CHANGED
|
@@ -34,11 +34,12 @@ def main():
|
|
|
34
34
|
tool_input = input_data.get("tool_input", {})
|
|
35
35
|
tool_output = input_data.get("tool_output", {})
|
|
36
36
|
|
|
37
|
-
#
|
|
37
|
+
# Track research tools AND user questions
|
|
38
38
|
research_tools = ["WebSearch", "WebFetch", "mcp__context7"]
|
|
39
39
|
is_research_tool = any(t in tool_name for t in research_tools)
|
|
40
|
+
is_user_question = tool_name == "AskUserQuestion"
|
|
40
41
|
|
|
41
|
-
if not is_research_tool:
|
|
42
|
+
if not is_research_tool and not is_user_question:
|
|
42
43
|
print(json.dumps({"continue": True}))
|
|
43
44
|
sys.exit(0)
|
|
44
45
|
|
|
@@ -51,8 +52,42 @@ def main():
|
|
|
51
52
|
else:
|
|
52
53
|
state = create_initial_state()
|
|
53
54
|
|
|
54
|
-
# Get
|
|
55
|
+
# Get phases
|
|
55
56
|
phases = state.setdefault("phases", {})
|
|
57
|
+
|
|
58
|
+
# Handle AskUserQuestion separately - track in interview phase
|
|
59
|
+
if is_user_question:
|
|
60
|
+
interview = phases.setdefault("interview", {
|
|
61
|
+
"status": "not_started",
|
|
62
|
+
"questions": [],
|
|
63
|
+
"user_question_count": 0
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
# Track the question
|
|
67
|
+
questions = interview.setdefault("questions", [])
|
|
68
|
+
user_count = interview.get("user_question_count", 0) + 1
|
|
69
|
+
interview["user_question_count"] = user_count
|
|
70
|
+
|
|
71
|
+
question_entry = {
|
|
72
|
+
"question": tool_input.get("question", ""),
|
|
73
|
+
"timestamp": datetime.now().isoformat(),
|
|
74
|
+
"tool_used": True # Proves AskUserQuestion was actually called
|
|
75
|
+
}
|
|
76
|
+
questions.append(question_entry)
|
|
77
|
+
|
|
78
|
+
# Update interview status
|
|
79
|
+
if interview.get("status") == "not_started":
|
|
80
|
+
interview["status"] = "in_progress"
|
|
81
|
+
interview["started_at"] = datetime.now().isoformat()
|
|
82
|
+
|
|
83
|
+
interview["last_activity"] = datetime.now().isoformat()
|
|
84
|
+
|
|
85
|
+
# Save and exit
|
|
86
|
+
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
87
|
+
print(json.dumps({"continue": True}))
|
|
88
|
+
sys.exit(0)
|
|
89
|
+
|
|
90
|
+
# Get or create research phase (for research tools)
|
|
56
91
|
research = phases.setdefault("research_initial", {
|
|
57
92
|
"status": "in_progress",
|
|
58
93
|
"sources": [],
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PreToolUse for Write/Edit (runs AFTER enforce-research and enforce-interview)
|
|
4
|
+
Purpose: Verify implementation matches interview requirements
|
|
5
|
+
|
|
6
|
+
This hook addresses these gaps:
|
|
7
|
+
1. AI uses exact user terminology when researching (not paraphrasing)
|
|
8
|
+
2. All changed files are tracked and verified
|
|
9
|
+
3. Test files use same patterns as production code
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
- {"permissionDecision": "allow"} - Let the tool run
|
|
13
|
+
- {"permissionDecision": "deny", "reason": "..."} - Block with explanation
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import re
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# State file is in .claude/ directory (sibling to hooks/)
|
|
21
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_key_terms(text: str) -> list[str]:
|
|
25
|
+
"""Extract likely important terms from interview answers.
|
|
26
|
+
|
|
27
|
+
These are terms that should appear in research and implementation:
|
|
28
|
+
- Proper nouns (capitalized multi-word phrases)
|
|
29
|
+
- Technical terms (SDK names, API names, etc.)
|
|
30
|
+
- Specific patterns (e.g., "via X", "using X", "with X")
|
|
31
|
+
"""
|
|
32
|
+
terms = []
|
|
33
|
+
|
|
34
|
+
# Look for "via X", "using X", "with X" patterns
|
|
35
|
+
via_patterns = re.findall(r'(?:via|using|with|through)\s+([A-Z][A-Za-z0-9\s]+?)(?:[,.\n]|$)', text)
|
|
36
|
+
terms.extend(via_patterns)
|
|
37
|
+
|
|
38
|
+
# Look for capitalized phrases (likely proper nouns/product names)
|
|
39
|
+
# e.g., "Vercel AI Gateway", "OpenAI API"
|
|
40
|
+
proper_nouns = re.findall(r'[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+', text)
|
|
41
|
+
terms.extend(proper_nouns)
|
|
42
|
+
|
|
43
|
+
# Clean up and dedupe
|
|
44
|
+
terms = [t.strip() for t in terms if len(t.strip()) > 3]
|
|
45
|
+
return list(set(terms))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_research_used_exact_terms(state: dict) -> list[str]:
|
|
49
|
+
"""Verify research sources used the exact terms from interview.
|
|
50
|
+
|
|
51
|
+
Gap 1 Fix: When user provides a term, use THAT EXACT TERM to search.
|
|
52
|
+
"""
|
|
53
|
+
issues = []
|
|
54
|
+
|
|
55
|
+
interview = state.get("phases", {}).get("interview", {})
|
|
56
|
+
research = state.get("phases", {}).get("research_initial", {})
|
|
57
|
+
deep_research = state.get("phases", {}).get("research_deep", {})
|
|
58
|
+
|
|
59
|
+
questions = interview.get("questions", [])
|
|
60
|
+
if isinstance(questions, list) and len(questions) > 0:
|
|
61
|
+
# Extract key terms from all interview answers
|
|
62
|
+
all_text = " ".join(str(q) for q in questions)
|
|
63
|
+
key_terms = extract_key_terms(all_text)
|
|
64
|
+
|
|
65
|
+
# Check if these terms appear in research sources
|
|
66
|
+
research_sources = research.get("sources", []) + deep_research.get("sources", [])
|
|
67
|
+
research_text = " ".join(str(s) for s in research_sources).lower()
|
|
68
|
+
|
|
69
|
+
missing_terms = []
|
|
70
|
+
for term in key_terms:
|
|
71
|
+
# Check if term or close variant appears in research
|
|
72
|
+
term_lower = term.lower()
|
|
73
|
+
if term_lower not in research_text:
|
|
74
|
+
# Check for partial matches (e.g., "AI Gateway" in "Vercel AI Gateway")
|
|
75
|
+
words = term_lower.split()
|
|
76
|
+
if not any(all(w in research_text for w in words) for _ in [1]):
|
|
77
|
+
missing_terms.append(term)
|
|
78
|
+
|
|
79
|
+
if missing_terms:
|
|
80
|
+
issues.append(
|
|
81
|
+
f"⚠️ Gap 1 Warning: User-specified terms not found in research:\n"
|
|
82
|
+
f" Terms from interview: {missing_terms}\n"
|
|
83
|
+
f" These EXACT terms should have been searched."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return issues
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check_files_tracked(state: dict, file_path: str) -> list[str]:
|
|
90
|
+
"""Verify we're tracking all files being modified.
|
|
91
|
+
|
|
92
|
+
Gap 2 Fix: Track files as they're modified, not after claiming completion.
|
|
93
|
+
"""
|
|
94
|
+
issues = []
|
|
95
|
+
|
|
96
|
+
files_created = state.get("files_created", [])
|
|
97
|
+
files_modified = state.get("files_modified", [])
|
|
98
|
+
all_tracked = files_created + files_modified
|
|
99
|
+
|
|
100
|
+
# Normalize paths for comparison
|
|
101
|
+
normalized_path = file_path.replace("\\", "/")
|
|
102
|
+
|
|
103
|
+
# Check if this file is a test file
|
|
104
|
+
is_test = ".test." in file_path or "/__tests__/" in file_path or ".spec." in file_path
|
|
105
|
+
|
|
106
|
+
# For non-test files in api/ or lib/, they should be tracked
|
|
107
|
+
is_trackable = ("/api/" in file_path or "/lib/" in file_path) and file_path.endswith(".ts")
|
|
108
|
+
|
|
109
|
+
if is_trackable and not is_test:
|
|
110
|
+
# Check if any tracked file matches this one
|
|
111
|
+
found = False
|
|
112
|
+
for tracked in all_tracked:
|
|
113
|
+
if normalized_path.endswith(tracked) or tracked in normalized_path:
|
|
114
|
+
found = True
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
# Don't block, but log that this file should be tracked
|
|
118
|
+
if not found:
|
|
119
|
+
state.setdefault("files_modified", []).append(normalized_path.split("/src/")[-1] if "/src/" in normalized_path else normalized_path)
|
|
120
|
+
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
121
|
+
|
|
122
|
+
return issues
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_test_production_alignment(state: dict, file_path: str, content: str = "") -> list[str]:
|
|
126
|
+
"""Verify test files use same patterns as production code.
|
|
127
|
+
|
|
128
|
+
Gap 5 Fix: Test files must use the same patterns as production code.
|
|
129
|
+
"""
|
|
130
|
+
issues = []
|
|
131
|
+
|
|
132
|
+
is_test = ".test." in file_path or "/__tests__/" in file_path or ".spec." in file_path
|
|
133
|
+
|
|
134
|
+
if not is_test:
|
|
135
|
+
return issues
|
|
136
|
+
|
|
137
|
+
# Check interview for key configuration patterns
|
|
138
|
+
interview = state.get("phases", {}).get("interview", {})
|
|
139
|
+
questions = interview.get("questions", [])
|
|
140
|
+
all_text = " ".join(str(q) for q in questions)
|
|
141
|
+
|
|
142
|
+
# Look for environment variable patterns mentioned in interview
|
|
143
|
+
env_patterns = re.findall(r'[A-Z_]+_(?:KEY|API_KEY|TOKEN|SECRET)', all_text)
|
|
144
|
+
|
|
145
|
+
if env_patterns and content:
|
|
146
|
+
# If interview mentions specific env vars, test should check those
|
|
147
|
+
for pattern in env_patterns:
|
|
148
|
+
if pattern in content:
|
|
149
|
+
# Good - test is checking the right env var
|
|
150
|
+
pass
|
|
151
|
+
|
|
152
|
+
# Look for mismatches - e.g., checking OPENAI_API_KEY when we said "single gateway key"
|
|
153
|
+
if "gateway" in all_text.lower() or "single key" in all_text.lower():
|
|
154
|
+
# Interview mentioned gateway/single key - tests shouldn't check individual provider keys
|
|
155
|
+
old_patterns = ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "PERPLEXITY_API_KEY"]
|
|
156
|
+
found_old = [p for p in old_patterns if p in content]
|
|
157
|
+
|
|
158
|
+
if found_old and "AI_GATEWAY" not in content:
|
|
159
|
+
issues.append(
|
|
160
|
+
f"⚠️ Gap 5 Warning: Test may be checking wrong environment variables.\n"
|
|
161
|
+
f" Interview mentioned: gateway/single key pattern\n"
|
|
162
|
+
f" Test checks: {found_old}\n"
|
|
163
|
+
f" Consider: Should test check AI_GATEWAY_API_KEY instead?"
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return issues
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def main():
|
|
170
|
+
# Read hook input from stdin
|
|
171
|
+
try:
|
|
172
|
+
input_data = json.load(sys.stdin)
|
|
173
|
+
except json.JSONDecodeError:
|
|
174
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
175
|
+
sys.exit(0)
|
|
176
|
+
|
|
177
|
+
tool_input = input_data.get("tool_input", {})
|
|
178
|
+
file_path = tool_input.get("file_path", "")
|
|
179
|
+
new_content = tool_input.get("content", "") or tool_input.get("new_string", "")
|
|
180
|
+
|
|
181
|
+
# Only check for API/schema/lib files
|
|
182
|
+
is_api_file = "/api/" in file_path and file_path.endswith(".ts")
|
|
183
|
+
is_lib_file = "/lib/" in file_path and file_path.endswith(".ts")
|
|
184
|
+
|
|
185
|
+
if not is_api_file and not is_lib_file:
|
|
186
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
187
|
+
sys.exit(0)
|
|
188
|
+
|
|
189
|
+
# Load state
|
|
190
|
+
if not STATE_FILE.exists():
|
|
191
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
192
|
+
sys.exit(0)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
state = json.loads(STATE_FILE.read_text())
|
|
196
|
+
except json.JSONDecodeError:
|
|
197
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
198
|
+
sys.exit(0)
|
|
199
|
+
|
|
200
|
+
# Run verification checks
|
|
201
|
+
all_issues = []
|
|
202
|
+
|
|
203
|
+
# Check 1: Research used exact terms from interview
|
|
204
|
+
all_issues.extend(check_research_used_exact_terms(state))
|
|
205
|
+
|
|
206
|
+
# Check 2: Track this file
|
|
207
|
+
all_issues.extend(check_files_tracked(state, file_path))
|
|
208
|
+
|
|
209
|
+
# Check 5: Test/production alignment
|
|
210
|
+
all_issues.extend(check_test_production_alignment(state, file_path, new_content))
|
|
211
|
+
|
|
212
|
+
# If there are issues, warn but don't block (these are warnings)
|
|
213
|
+
# The user can review these in the state file
|
|
214
|
+
if all_issues:
|
|
215
|
+
# Store warnings in state for later review
|
|
216
|
+
state.setdefault("verification_warnings", []).extend(all_issues)
|
|
217
|
+
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
218
|
+
|
|
219
|
+
# Allow the operation - these are warnings, not blockers
|
|
220
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
221
|
+
sys.exit(0)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
main()
|