@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.
@@ -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
- reason_parts = [" API workflow has REQUIRED phases incomplete:\n"]
78
- reason_parts.extend(incomplete_required)
79
-
80
- if incomplete_recommended:
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(reason_parts)
189
+ "reason": "\n".join(all_issues)
92
190
  }))
93
191
  sys.exit(0)
94
192
 
95
- # Warn about recommended phases but allow
96
- if incomplete_recommended:
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
- Consider running /api-status to review what was skipped."""
104
- }))
105
- sys.exit(0)
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": "✅ API workflow completed successfully!"
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()
@@ -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
- # Only track research-related tools
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 or create research phase
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()