@hustle-together/api-dev-tools 3.6.4 → 3.9.2
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 +5307 -258
- package/bin/cli.js +348 -20
- package/commands/README.md +459 -71
- package/commands/hustle-api-continue.md +158 -0
- package/commands/{api-create.md → hustle-api-create.md} +22 -2
- package/commands/{api-env.md → hustle-api-env.md} +4 -4
- package/commands/{api-interview.md → hustle-api-interview.md} +1 -1
- package/commands/{api-research.md → hustle-api-research.md} +3 -3
- package/commands/hustle-api-sessions.md +149 -0
- package/commands/{api-status.md → hustle-api-status.md} +16 -16
- package/commands/{api-verify.md → hustle-api-verify.md} +2 -2
- package/commands/hustle-combine.md +763 -0
- package/commands/hustle-ui-create.md +825 -0
- package/hooks/api-workflow-check.py +385 -19
- package/hooks/cache-research.py +337 -0
- package/hooks/check-playwright-setup.py +103 -0
- package/hooks/check-storybook-setup.py +81 -0
- package/hooks/detect-interruption.py +165 -0
- package/hooks/enforce-brand-guide.py +131 -0
- package/hooks/enforce-documentation.py +60 -8
- package/hooks/enforce-freshness.py +184 -0
- package/hooks/enforce-questions-sourced.py +146 -0
- package/hooks/enforce-schema-from-interview.py +248 -0
- package/hooks/enforce-ui-disambiguation.py +108 -0
- package/hooks/enforce-ui-interview.py +130 -0
- package/hooks/generate-manifest-entry.py +981 -0
- package/hooks/session-logger.py +297 -0
- package/hooks/session-startup.py +65 -10
- package/hooks/track-scope-coverage.py +220 -0
- package/hooks/track-tool-use.py +81 -1
- package/hooks/update-api-showcase.py +149 -0
- package/hooks/update-registry.py +352 -0
- package/hooks/update-ui-showcase.py +148 -0
- package/package.json +8 -2
- package/templates/BRAND_GUIDE.md +299 -0
- package/templates/CLAUDE-SECTION.md +56 -24
- package/templates/SPEC.json +640 -0
- package/templates/api-dev-state.json +179 -161
- package/templates/api-showcase/APICard.tsx +153 -0
- package/templates/api-showcase/APIModal.tsx +375 -0
- package/templates/api-showcase/APIShowcase.tsx +231 -0
- package/templates/api-showcase/APITester.tsx +522 -0
- package/templates/api-showcase/page.tsx +41 -0
- package/templates/component/Component.stories.tsx +172 -0
- package/templates/component/Component.test.tsx +237 -0
- package/templates/component/Component.tsx +86 -0
- package/templates/component/Component.types.ts +55 -0
- package/templates/component/index.ts +15 -0
- package/templates/dev-tools/_components/DevToolsLanding.tsx +320 -0
- package/templates/dev-tools/page.tsx +10 -0
- package/templates/page/page.e2e.test.ts +218 -0
- package/templates/page/page.tsx +42 -0
- package/templates/performance-budgets.json +58 -0
- package/templates/registry.json +13 -0
- package/templates/settings.json +74 -0
- package/templates/shared/HeroHeader.tsx +261 -0
- package/templates/shared/index.ts +1 -0
- package/templates/ui-showcase/PreviewCard.tsx +315 -0
- package/templates/ui-showcase/PreviewModal.tsx +676 -0
- package/templates/ui-showcase/UIShowcase.tsx +262 -0
- package/templates/ui-showcase/page.tsx +26 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PreToolUse for Write/Edit
|
|
4
|
+
Purpose: Inject brand guide content during UI implementation
|
|
5
|
+
|
|
6
|
+
This hook runs before writing component/page files. When use_brand_guide=true
|
|
7
|
+
in the state, it logs the brand guide summary to remind Claude to apply
|
|
8
|
+
consistent branding.
|
|
9
|
+
|
|
10
|
+
Version: 3.9.0
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
- {"continue": true} - Always continues
|
|
14
|
+
- May include "notify" with brand guide summary
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
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
|
+
BRAND_GUIDE_FILE = Path(__file__).parent.parent / "BRAND_GUIDE.md"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_brand_summary(content):
|
|
26
|
+
"""Extract key brand values from brand guide markdown."""
|
|
27
|
+
summary = []
|
|
28
|
+
|
|
29
|
+
lines = content.split("\n")
|
|
30
|
+
current_section = ""
|
|
31
|
+
|
|
32
|
+
for line in lines:
|
|
33
|
+
line = line.strip()
|
|
34
|
+
|
|
35
|
+
# Track section
|
|
36
|
+
if line.startswith("## "):
|
|
37
|
+
current_section = line[3:].lower()
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
# Extract key values
|
|
41
|
+
if line.startswith("- **") and ":" in line:
|
|
42
|
+
# Parse "- **Key:** Value" format
|
|
43
|
+
try:
|
|
44
|
+
key_part = line.split(":**")[0].replace("- **", "")
|
|
45
|
+
value_part = line.split(":**")[1].strip()
|
|
46
|
+
|
|
47
|
+
# Only include primary brand values
|
|
48
|
+
if current_section == "colors" and key_part in ["Primary", "Accent", "Background"]:
|
|
49
|
+
summary.append(f"{key_part}: {value_part}")
|
|
50
|
+
elif current_section == "typography" and key_part in ["Headings", "Body"]:
|
|
51
|
+
summary.append(f"{key_part}: {value_part}")
|
|
52
|
+
elif current_section == "component styling" and key_part in ["Border Radius", "Focus Ring"]:
|
|
53
|
+
summary.append(f"{key_part}: {value_part}")
|
|
54
|
+
except IndexError:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
return summary
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main():
|
|
61
|
+
# Read hook input from stdin
|
|
62
|
+
try:
|
|
63
|
+
input_data = json.load(sys.stdin)
|
|
64
|
+
except json.JSONDecodeError:
|
|
65
|
+
print(json.dumps({"continue": True}))
|
|
66
|
+
sys.exit(0)
|
|
67
|
+
|
|
68
|
+
tool_name = input_data.get("tool_name", "")
|
|
69
|
+
tool_input = input_data.get("tool_input", {})
|
|
70
|
+
|
|
71
|
+
# Only check Write/Edit operations
|
|
72
|
+
if tool_name not in ["Write", "Edit"]:
|
|
73
|
+
print(json.dumps({"continue": True}))
|
|
74
|
+
sys.exit(0)
|
|
75
|
+
|
|
76
|
+
# Check if targeting component or page files
|
|
77
|
+
file_path = tool_input.get("file_path", "")
|
|
78
|
+
is_component = "/components/" in file_path and file_path.endswith(".tsx")
|
|
79
|
+
is_page = "/app/" in file_path and "page.tsx" in file_path
|
|
80
|
+
|
|
81
|
+
if not is_component and not is_page:
|
|
82
|
+
print(json.dumps({"continue": True}))
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
|
|
85
|
+
# Check if state file exists
|
|
86
|
+
if not STATE_FILE.exists():
|
|
87
|
+
print(json.dumps({"continue": True}))
|
|
88
|
+
sys.exit(0)
|
|
89
|
+
|
|
90
|
+
# Load state
|
|
91
|
+
try:
|
|
92
|
+
state = json.loads(STATE_FILE.read_text())
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
print(json.dumps({"continue": True}))
|
|
95
|
+
sys.exit(0)
|
|
96
|
+
|
|
97
|
+
workflow = state.get("workflow", "")
|
|
98
|
+
|
|
99
|
+
# Only apply for UI workflows
|
|
100
|
+
if workflow not in ["ui-create-component", "ui-create-page"]:
|
|
101
|
+
print(json.dumps({"continue": True}))
|
|
102
|
+
sys.exit(0)
|
|
103
|
+
|
|
104
|
+
# Check if brand guide is enabled
|
|
105
|
+
ui_config = state.get("ui_config", {})
|
|
106
|
+
use_brand_guide = ui_config.get("use_brand_guide", False)
|
|
107
|
+
|
|
108
|
+
if not use_brand_guide:
|
|
109
|
+
print(json.dumps({"continue": True}))
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
# Check if brand guide file exists
|
|
113
|
+
if not BRAND_GUIDE_FILE.exists():
|
|
114
|
+
print(json.dumps({"continue": True}))
|
|
115
|
+
sys.exit(0)
|
|
116
|
+
|
|
117
|
+
# Extract brand summary
|
|
118
|
+
brand_content = BRAND_GUIDE_FILE.read_text()
|
|
119
|
+
summary = extract_brand_summary(brand_content)
|
|
120
|
+
|
|
121
|
+
if summary:
|
|
122
|
+
notify_msg = "Applying brand guide: " + " | ".join(summary[:5])
|
|
123
|
+
print(json.dumps({"continue": True, "notify": notify_msg}))
|
|
124
|
+
else:
|
|
125
|
+
print(json.dumps({"continue": True}))
|
|
126
|
+
|
|
127
|
+
sys.exit(0)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
Hook: PreToolUse for Write/Edit (and Stop)
|
|
4
4
|
Purpose: Block completion until documentation confirmed WITH USER REVIEW
|
|
5
5
|
|
|
6
|
-
Phase
|
|
6
|
+
Phase 12 (Documentation) requires:
|
|
7
7
|
1. Update api-tests-manifest.json
|
|
8
8
|
2. Cache research to .claude/research/
|
|
9
9
|
3. Update OpenAPI spec if applicable
|
|
@@ -14,12 +14,50 @@ Phase 21 (Documentation) requires:
|
|
|
14
14
|
Returns:
|
|
15
15
|
- {"permissionDecision": "allow"} - Let the tool run
|
|
16
16
|
- {"permissionDecision": "deny", "reason": "..."} - Block with explanation
|
|
17
|
+
|
|
18
|
+
Updated in v3.6.7:
|
|
19
|
+
- Support multi-API state structure
|
|
20
|
+
- Don't block on missing cache files (cache-research.py creates them)
|
|
21
|
+
- Check actual file existence, not just state flags
|
|
17
22
|
"""
|
|
18
23
|
import json
|
|
19
24
|
import sys
|
|
20
25
|
from pathlib import Path
|
|
21
26
|
|
|
22
27
|
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
28
|
+
RESEARCH_DIR = Path(__file__).parent.parent / "research"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_active_endpoint(state):
|
|
32
|
+
"""Get active endpoint - supports both old and new state formats."""
|
|
33
|
+
# New format (v3.6.7+): endpoints object with active_endpoint pointer
|
|
34
|
+
if "endpoints" in state and "active_endpoint" in state:
|
|
35
|
+
active = state.get("active_endpoint")
|
|
36
|
+
if active and active in state["endpoints"]:
|
|
37
|
+
return active, state["endpoints"][active]
|
|
38
|
+
return None, None
|
|
39
|
+
|
|
40
|
+
# Old format: single endpoint field
|
|
41
|
+
endpoint = state.get("endpoint")
|
|
42
|
+
if endpoint:
|
|
43
|
+
return endpoint, state
|
|
44
|
+
|
|
45
|
+
return None, None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_research_cache_exists(endpoint):
|
|
49
|
+
"""Check if research cache files actually exist."""
|
|
50
|
+
cache_dir = RESEARCH_DIR / endpoint
|
|
51
|
+
if not cache_dir.exists():
|
|
52
|
+
return False, []
|
|
53
|
+
|
|
54
|
+
expected_files = ["sources.json", "interview.json", "schema.json"]
|
|
55
|
+
existing = []
|
|
56
|
+
for f in expected_files:
|
|
57
|
+
if (cache_dir / f).exists():
|
|
58
|
+
existing.append(f)
|
|
59
|
+
|
|
60
|
+
return len(existing) >= 2, existing # At least 2 of 3 files should exist
|
|
23
61
|
|
|
24
62
|
|
|
25
63
|
def main():
|
|
@@ -54,8 +92,13 @@ def main():
|
|
|
54
92
|
print(json.dumps({"permissionDecision": "allow"}))
|
|
55
93
|
sys.exit(0)
|
|
56
94
|
|
|
57
|
-
|
|
58
|
-
|
|
95
|
+
# Get active endpoint (supports both old and new formats)
|
|
96
|
+
endpoint, endpoint_data = get_active_endpoint(state)
|
|
97
|
+
if not endpoint or not endpoint_data:
|
|
98
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
99
|
+
sys.exit(0)
|
|
100
|
+
|
|
101
|
+
phases = endpoint_data.get("phases", {})
|
|
59
102
|
tdd_refactor = phases.get("tdd_refactor", {})
|
|
60
103
|
documentation = phases.get("documentation", {})
|
|
61
104
|
|
|
@@ -74,14 +117,19 @@ def main():
|
|
|
74
117
|
user_confirmed = documentation.get("user_confirmed", False)
|
|
75
118
|
checklist_shown = documentation.get("checklist_shown", False)
|
|
76
119
|
manifest_updated = documentation.get("manifest_updated", False)
|
|
77
|
-
research_cached = documentation.get("research_cached", False)
|
|
78
120
|
openapi_updated = documentation.get("openapi_updated", False)
|
|
79
121
|
|
|
122
|
+
# v3.6.7: Check actual file existence for research cache
|
|
123
|
+
# (cache-research.py PostToolUse hook creates these files automatically)
|
|
124
|
+
cache_exists, cache_files = check_research_cache_exists(endpoint)
|
|
125
|
+
research_cached = cache_exists or documentation.get("research_cached", False)
|
|
126
|
+
|
|
80
127
|
missing = []
|
|
81
128
|
if not manifest_updated:
|
|
82
129
|
missing.append("api-tests-manifest.json not updated")
|
|
83
130
|
if not research_cached:
|
|
84
|
-
|
|
131
|
+
# Don't block - cache-research.py will create files when docs are written
|
|
132
|
+
missing.append(f"Research cache pending (will be created automatically)")
|
|
85
133
|
if not checklist_shown:
|
|
86
134
|
missing.append("Documentation checklist not shown to user")
|
|
87
135
|
if not user_question_asked:
|
|
@@ -93,7 +141,7 @@ def main():
|
|
|
93
141
|
|
|
94
142
|
print(json.dumps({
|
|
95
143
|
"permissionDecision": "deny",
|
|
96
|
-
"reason": f"""❌ BLOCKED: Documentation (Phase
|
|
144
|
+
"reason": f"""❌ BLOCKED: Documentation (Phase 12) not complete.
|
|
97
145
|
|
|
98
146
|
Status: {status}
|
|
99
147
|
Manifest updated: {manifest_updated}
|
|
@@ -176,12 +224,16 @@ WHY: Documentation ensures next developer (or future Claude) has context."""
|
|
|
176
224
|
}))
|
|
177
225
|
sys.exit(0)
|
|
178
226
|
|
|
179
|
-
# Documentation complete
|
|
227
|
+
# Documentation complete - check actual file existence for status
|
|
228
|
+
cache_exists, cache_files = check_research_cache_exists(endpoint)
|
|
229
|
+
manifest_updated = documentation.get("manifest_updated", False)
|
|
230
|
+
openapi_updated = documentation.get("openapi_updated", False)
|
|
231
|
+
|
|
180
232
|
print(json.dumps({
|
|
181
233
|
"permissionDecision": "allow",
|
|
182
234
|
"message": f"""✅ Documentation complete for {endpoint}.
|
|
183
235
|
Manifest updated: {manifest_updated}
|
|
184
|
-
Research cached: {
|
|
236
|
+
Research cached: {cache_exists} ({', '.join(cache_files) if cache_files else 'no files'})
|
|
185
237
|
OpenAPI updated: {openapi_updated}
|
|
186
238
|
User confirmed documentation is complete."""
|
|
187
239
|
}))
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PreToolUse (Write|Edit)
|
|
4
|
+
Purpose: Enforce research freshness for the active endpoint
|
|
5
|
+
|
|
6
|
+
This hook blocks Write/Edit operations if:
|
|
7
|
+
1. There is an active endpoint in api-dev-state.json
|
|
8
|
+
2. Research exists for that endpoint
|
|
9
|
+
3. Research is older than 7 days (configurable)
|
|
10
|
+
|
|
11
|
+
The user can:
|
|
12
|
+
- Run /hustle-api-research to refresh the research
|
|
13
|
+
- Set "enforce_freshness": false in the endpoint config to disable
|
|
14
|
+
- Research is only enforced for the ACTIVE endpoint
|
|
15
|
+
|
|
16
|
+
Exit Codes:
|
|
17
|
+
- 0: Continue (no active endpoint, research is fresh, or enforcement disabled)
|
|
18
|
+
- 2: Block with message (research is stale, requires re-research)
|
|
19
|
+
|
|
20
|
+
Added in v3.7.0:
|
|
21
|
+
- User requested enforcement (not just warning) for stale research
|
|
22
|
+
- Only enforces for the active endpoint being worked on
|
|
23
|
+
"""
|
|
24
|
+
import json
|
|
25
|
+
import sys
|
|
26
|
+
import os
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
# State file is in .claude/ directory (sibling to hooks/)
|
|
31
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
32
|
+
RESEARCH_INDEX = Path(__file__).parent.parent / "research" / "index.json"
|
|
33
|
+
|
|
34
|
+
# Default freshness threshold (days)
|
|
35
|
+
FRESHNESS_THRESHOLD_DAYS = 7
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_active_endpoint(state):
|
|
39
|
+
"""Get active endpoint - supports both old and new state formats."""
|
|
40
|
+
if "endpoints" in state and "active_endpoint" in state:
|
|
41
|
+
active = state.get("active_endpoint")
|
|
42
|
+
if active and active in state["endpoints"]:
|
|
43
|
+
return active, state["endpoints"][active]
|
|
44
|
+
return None, None
|
|
45
|
+
|
|
46
|
+
# Old format
|
|
47
|
+
endpoint = state.get("endpoint")
|
|
48
|
+
if endpoint:
|
|
49
|
+
return endpoint, state
|
|
50
|
+
|
|
51
|
+
return None, None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_research_index():
|
|
55
|
+
"""Load research index from .claude/research/index.json file."""
|
|
56
|
+
if not RESEARCH_INDEX.exists():
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
index = json.loads(RESEARCH_INDEX.read_text())
|
|
60
|
+
return index.get("apis", {})
|
|
61
|
+
except (json.JSONDecodeError, IOError):
|
|
62
|
+
return {}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def calculate_days_old(timestamp_str):
|
|
66
|
+
"""Calculate how many days old a timestamp is."""
|
|
67
|
+
if not timestamp_str:
|
|
68
|
+
return 0
|
|
69
|
+
try:
|
|
70
|
+
last_updated = datetime.fromisoformat(timestamp_str.replace('Z', '+00:00'))
|
|
71
|
+
now = datetime.now(last_updated.tzinfo) if last_updated.tzinfo else datetime.now()
|
|
72
|
+
return (now - last_updated).days
|
|
73
|
+
except (ValueError, TypeError):
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def is_api_related_file(file_path):
|
|
78
|
+
"""Check if the file being written is API-related."""
|
|
79
|
+
if not file_path:
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
file_path = file_path.lower()
|
|
83
|
+
|
|
84
|
+
# Files that indicate API development
|
|
85
|
+
api_indicators = [
|
|
86
|
+
'/api/',
|
|
87
|
+
'/route.ts',
|
|
88
|
+
'/route.js',
|
|
89
|
+
'.api.test.',
|
|
90
|
+
'/schemas/',
|
|
91
|
+
'api-tests-manifest',
|
|
92
|
+
'/v2/'
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
return any(indicator in file_path for indicator in api_indicators)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def main():
|
|
99
|
+
# Read hook input from stdin
|
|
100
|
+
try:
|
|
101
|
+
input_data = json.load(sys.stdin)
|
|
102
|
+
except json.JSONDecodeError:
|
|
103
|
+
input_data = {}
|
|
104
|
+
|
|
105
|
+
# Get the file being written (if applicable)
|
|
106
|
+
tool_input = input_data.get("toolInput", {})
|
|
107
|
+
file_path = tool_input.get("file_path", "")
|
|
108
|
+
|
|
109
|
+
# Only enforce for API-related files
|
|
110
|
+
if not is_api_related_file(file_path):
|
|
111
|
+
print(json.dumps({"continue": True}))
|
|
112
|
+
sys.exit(0)
|
|
113
|
+
|
|
114
|
+
# Check if state file exists
|
|
115
|
+
if not STATE_FILE.exists():
|
|
116
|
+
print(json.dumps({"continue": True}))
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
state = json.loads(STATE_FILE.read_text())
|
|
121
|
+
except json.JSONDecodeError:
|
|
122
|
+
print(json.dumps({"continue": True}))
|
|
123
|
+
sys.exit(0)
|
|
124
|
+
|
|
125
|
+
# Get active endpoint
|
|
126
|
+
endpoint, endpoint_data = get_active_endpoint(state)
|
|
127
|
+
if not endpoint or not endpoint_data:
|
|
128
|
+
# No active endpoint - allow
|
|
129
|
+
print(json.dumps({"continue": True}))
|
|
130
|
+
sys.exit(0)
|
|
131
|
+
|
|
132
|
+
# Check if freshness enforcement is disabled for this endpoint
|
|
133
|
+
if endpoint_data.get("enforce_freshness") is False:
|
|
134
|
+
print(json.dumps({"continue": True}))
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
# Check research freshness
|
|
138
|
+
research_index = load_research_index()
|
|
139
|
+
|
|
140
|
+
if endpoint not in research_index:
|
|
141
|
+
# No research indexed yet - allow but note this is caught by enforce-research.py
|
|
142
|
+
print(json.dumps({"continue": True}))
|
|
143
|
+
sys.exit(0)
|
|
144
|
+
|
|
145
|
+
entry = research_index[endpoint]
|
|
146
|
+
last_updated = entry.get("last_updated", "")
|
|
147
|
+
days_old = calculate_days_old(last_updated)
|
|
148
|
+
|
|
149
|
+
# Get custom threshold if set
|
|
150
|
+
threshold = endpoint_data.get("freshness_threshold_days", FRESHNESS_THRESHOLD_DAYS)
|
|
151
|
+
|
|
152
|
+
if days_old > threshold:
|
|
153
|
+
# Research is stale - block and require re-research
|
|
154
|
+
output = {
|
|
155
|
+
"decision": "block",
|
|
156
|
+
"reason": f"""🔄 STALE RESEARCH DETECTED
|
|
157
|
+
|
|
158
|
+
Research for '{endpoint}' is {days_old} days old (threshold: {threshold} days).
|
|
159
|
+
|
|
160
|
+
**Action Required:**
|
|
161
|
+
Run `/hustle-api-research {endpoint}` to refresh the research before continuing.
|
|
162
|
+
|
|
163
|
+
**Why This Matters:**
|
|
164
|
+
- API documentation may have changed
|
|
165
|
+
- New parameters or features may be available
|
|
166
|
+
- Breaking changes may have been introduced
|
|
167
|
+
- Your implementation may not match current docs
|
|
168
|
+
|
|
169
|
+
**To Skip (Not Recommended):**
|
|
170
|
+
Set `"enforce_freshness": false` in api-dev-state.json for this endpoint.
|
|
171
|
+
|
|
172
|
+
Last researched: {last_updated or 'Unknown'}
|
|
173
|
+
Research location: .claude/research/{endpoint}/CURRENT.md"""
|
|
174
|
+
}
|
|
175
|
+
print(json.dumps(output))
|
|
176
|
+
sys.exit(2) # Exit code 2 = block with message
|
|
177
|
+
|
|
178
|
+
# Research is fresh - continue
|
|
179
|
+
print(json.dumps({"continue": True}))
|
|
180
|
+
sys.exit(0)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PreToolUse for AskUserQuestion
|
|
4
|
+
Purpose: Validate interview questions come from research, not templates
|
|
5
|
+
|
|
6
|
+
This hook ensures that questions asked during the interview phase are
|
|
7
|
+
generated from actual research findings, not generic template questions.
|
|
8
|
+
|
|
9
|
+
Added in v3.6.7 for question quality enforcement.
|
|
10
|
+
|
|
11
|
+
Returns:
|
|
12
|
+
- {"permissionDecision": "allow"} - Question is properly sourced
|
|
13
|
+
- {"permissionDecision": "allow", "message": "..."} - Allow with reminder
|
|
14
|
+
"""
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_active_endpoint(state):
|
|
23
|
+
"""Get active endpoint - supports both old and new state formats."""
|
|
24
|
+
if "endpoints" in state and "active_endpoint" in state:
|
|
25
|
+
active = state.get("active_endpoint")
|
|
26
|
+
if active and active in state["endpoints"]:
|
|
27
|
+
return active, state["endpoints"][active]
|
|
28
|
+
return None, None
|
|
29
|
+
|
|
30
|
+
endpoint = state.get("endpoint")
|
|
31
|
+
if endpoint:
|
|
32
|
+
return endpoint, state
|
|
33
|
+
|
|
34
|
+
return None, None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_research_keywords(state, endpoint_data):
|
|
38
|
+
"""Extract keywords from research that should appear in questions."""
|
|
39
|
+
keywords = set()
|
|
40
|
+
|
|
41
|
+
# From research queries
|
|
42
|
+
for query in state.get("research_queries", []):
|
|
43
|
+
q = query.get("query", "")
|
|
44
|
+
# Extract meaningful words (length > 3)
|
|
45
|
+
words = [w.lower() for w in q.split() if len(w) > 3]
|
|
46
|
+
keywords.update(words)
|
|
47
|
+
|
|
48
|
+
# From initial research sources
|
|
49
|
+
initial = endpoint_data.get("phases", {}).get("research_initial", {})
|
|
50
|
+
for src in initial.get("sources", []):
|
|
51
|
+
if isinstance(src, dict):
|
|
52
|
+
summary = src.get("summary", "")
|
|
53
|
+
words = [w.lower() for w in summary.split() if len(w) > 3]
|
|
54
|
+
keywords.update(words)
|
|
55
|
+
|
|
56
|
+
# From deep research sources
|
|
57
|
+
deep = endpoint_data.get("phases", {}).get("research_deep", {})
|
|
58
|
+
for src in deep.get("sources", []):
|
|
59
|
+
if isinstance(src, dict):
|
|
60
|
+
summary = src.get("summary", "")
|
|
61
|
+
words = [w.lower() for w in summary.split() if len(w) > 3]
|
|
62
|
+
keywords.update(words)
|
|
63
|
+
|
|
64
|
+
return keywords
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main():
|
|
68
|
+
try:
|
|
69
|
+
input_data = json.load(sys.stdin)
|
|
70
|
+
except json.JSONDecodeError:
|
|
71
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
72
|
+
sys.exit(0)
|
|
73
|
+
|
|
74
|
+
tool_name = input_data.get("tool_name", "")
|
|
75
|
+
tool_input = input_data.get("tool_input", {})
|
|
76
|
+
|
|
77
|
+
if tool_name != "AskUserQuestion":
|
|
78
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
79
|
+
sys.exit(0)
|
|
80
|
+
|
|
81
|
+
if not STATE_FILE.exists():
|
|
82
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
83
|
+
sys.exit(0)
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
state = json.loads(STATE_FILE.read_text())
|
|
87
|
+
except json.JSONDecodeError:
|
|
88
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
89
|
+
sys.exit(0)
|
|
90
|
+
|
|
91
|
+
endpoint, endpoint_data = get_active_endpoint(state)
|
|
92
|
+
if not endpoint or not endpoint_data:
|
|
93
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
94
|
+
sys.exit(0)
|
|
95
|
+
|
|
96
|
+
# Only enforce during interview phase
|
|
97
|
+
interview = endpoint_data.get("phases", {}).get("interview", {})
|
|
98
|
+
if interview.get("status") != "in_progress":
|
|
99
|
+
print(json.dumps({"permissionDecision": "allow"}))
|
|
100
|
+
sys.exit(0)
|
|
101
|
+
|
|
102
|
+
# Check if research has been done
|
|
103
|
+
initial = endpoint_data.get("phases", {}).get("research_initial", {})
|
|
104
|
+
if initial.get("status") != "complete":
|
|
105
|
+
# Allow question but remind to do research first
|
|
106
|
+
print(json.dumps({
|
|
107
|
+
"permissionDecision": "allow",
|
|
108
|
+
"message": "REMINDER: Initial research (Phase 3) should be complete before interview. Questions should be generated FROM research findings."
|
|
109
|
+
}))
|
|
110
|
+
sys.exit(0)
|
|
111
|
+
|
|
112
|
+
# Get the question being asked
|
|
113
|
+
question = tool_input.get("question", "")
|
|
114
|
+
|
|
115
|
+
# Get research keywords
|
|
116
|
+
keywords = get_research_keywords(state, endpoint_data)
|
|
117
|
+
|
|
118
|
+
# Check if question contains any research-derived terms
|
|
119
|
+
question_lower = question.lower()
|
|
120
|
+
found_keywords = [k for k in keywords if k in question_lower]
|
|
121
|
+
|
|
122
|
+
if not found_keywords and len(keywords) > 5:
|
|
123
|
+
# No research keywords found - this might be a generic question
|
|
124
|
+
print(json.dumps({
|
|
125
|
+
"permissionDecision": "allow",
|
|
126
|
+
"message": f"""NOTE: This question doesn't appear to reference terms discovered in research.
|
|
127
|
+
|
|
128
|
+
Research-derived terms include: {', '.join(list(keywords)[:10])}...
|
|
129
|
+
|
|
130
|
+
BEST PRACTICE: Interview questions should be generated FROM research findings.
|
|
131
|
+
Example: "I discovered the API supports [feature]. Do you want to implement this?"
|
|
132
|
+
|
|
133
|
+
Proceeding anyway, but consider revising the question."""
|
|
134
|
+
}))
|
|
135
|
+
sys.exit(0)
|
|
136
|
+
|
|
137
|
+
# Question looks good
|
|
138
|
+
print(json.dumps({
|
|
139
|
+
"permissionDecision": "allow",
|
|
140
|
+
"message": f"Question references research terms: {', '.join(found_keywords[:5])}"
|
|
141
|
+
}))
|
|
142
|
+
sys.exit(0)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|