@hustle-together/api-dev-tools 3.12.3 ā 3.12.16
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/.claude/commands/hustle-build.md +259 -0
- package/.claude/commands/hustle-combine.md +1089 -0
- package/.claude/commands/hustle-ui-create-page.md +1078 -0
- package/.claude/commands/hustle-ui-create.md +1058 -0
- package/.claude/hooks/auto-answer.py +305 -0
- package/.claude/hooks/cache-research.py +337 -0
- package/.claude/hooks/check-api-routes.py +168 -0
- package/.claude/hooks/check-playwright-setup.py +103 -0
- package/.claude/hooks/check-storybook-setup.py +81 -0
- package/.claude/hooks/check-update.py +132 -0
- package/.claude/hooks/completion-promise-detector.py +293 -0
- package/.claude/hooks/context-capacity-warning.py +171 -0
- package/.claude/hooks/detect-interruption.py +165 -0
- package/.claude/hooks/docs-update-check.py +120 -0
- package/.claude/hooks/enforce-a11y-audit.py +202 -0
- package/.claude/hooks/enforce-brand-guide.py +241 -0
- package/.claude/hooks/enforce-component-type-confirm.py +97 -0
- package/.claude/hooks/enforce-dry-run.py +134 -0
- package/.claude/hooks/enforce-freshness.py +184 -0
- package/.claude/hooks/enforce-page-components.py +186 -0
- package/.claude/hooks/enforce-page-data-schema.py +155 -0
- package/.claude/hooks/enforce-questions-sourced.py +146 -0
- package/.claude/hooks/enforce-schema-from-interview.py +248 -0
- package/.claude/hooks/enforce-ui-disambiguation.py +108 -0
- package/.claude/hooks/enforce-ui-interview.py +130 -0
- package/.claude/hooks/generate-adr-options.py +282 -0
- package/.claude/hooks/generate-manifest-entry.py +1161 -0
- package/.claude/hooks/hook_utils.py +609 -0
- package/.claude/hooks/lib/__init__.py +1 -0
- package/.claude/hooks/lib/__pycache__/__init__.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/__pycache__/greptile.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/__pycache__/ntfy.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/greptile.py +355 -0
- package/.claude/hooks/lib/ntfy.py +209 -0
- package/.claude/hooks/notify-input-needed.py +73 -0
- package/.claude/hooks/notify-phase-complete.py +90 -0
- package/.claude/hooks/ntfy-on-question.py +240 -0
- package/.claude/hooks/orchestrator-completion.py +313 -0
- package/.claude/hooks/orchestrator-handoff.py +267 -0
- package/.claude/hooks/orchestrator-session-startup.py +146 -0
- package/.claude/hooks/parallel-orchestrator.py +451 -0
- package/.claude/hooks/project-document-prompt.py +302 -0
- package/.claude/hooks/remote-question-proxy.py +284 -0
- package/.claude/hooks/remote-question-server.py +1224 -0
- package/.claude/hooks/run-code-review.py +393 -0
- package/.claude/hooks/run-visual-qa.py +338 -0
- package/.claude/hooks/session-logger.py +323 -0
- package/.claude/hooks/test-orchestrator-reground.py +248 -0
- package/.claude/hooks/track-scope-coverage.py +220 -0
- package/.claude/hooks/track-token-usage.py +121 -0
- package/.claude/hooks/update-adr-decision.py +236 -0
- package/.claude/hooks/update-api-showcase.py +161 -0
- package/.claude/hooks/update-registry.py +352 -0
- package/.claude/hooks/update-testing-checklist.py +195 -0
- package/.claude/hooks/update-ui-showcase.py +224 -0
- package/.claude/settings.local.json +7 -1
- package/.claude/test-auto-answer-bot.py +183 -0
- package/.claude/test-completion-detector.py +263 -0
- package/.claude/test-orchestrator-state.json +20 -0
- package/.claude/test-orchestrator.sh +271 -0
- package/.skills/api-create/SKILL.md +88 -3
- package/.skills/docs-sync/SKILL.md +260 -0
- package/.skills/hustle-build/SKILL.md +459 -0
- package/.skills/hustle-build-review/SKILL.md +518 -0
- package/CHANGELOG.md +87 -0
- package/README.md +86 -9
- package/bin/cli.js +1302 -88
- package/commands/hustle-api-create.md +22 -0
- package/commands/hustle-combine.md +81 -2
- package/commands/hustle-ui-create-page.md +84 -2
- package/commands/hustle-ui-create.md +82 -2
- package/hooks/auto-answer.py +228 -0
- package/hooks/check-update.py +132 -0
- package/hooks/ntfy-on-question.py +227 -0
- package/hooks/orchestrator-completion.py +313 -0
- package/hooks/orchestrator-handoff.py +189 -0
- package/hooks/orchestrator-session-startup.py +146 -0
- package/hooks/periodic-reground.py +230 -67
- package/hooks/update-api-showcase.py +13 -1
- package/hooks/update-ui-showcase.py +13 -1
- package/package.json +7 -3
- package/scripts/extract-schema-docs.cjs +322 -0
- package/templates/CLAUDE-SECTION.md +89 -64
- package/templates/api-showcase/_components/APIModal.tsx +100 -8
- package/templates/api-showcase/_components/APIShowcase.tsx +36 -4
- package/templates/api-showcase/_components/APITester.tsx +367 -58
- package/templates/docs/page.tsx +230 -0
- package/templates/hustle-build-defaults.json +84 -0
- package/templates/hustle-dev-dashboard/page.tsx +365 -0
- package/templates/playwright-report/page.tsx +258 -0
- package/templates/settings.json +88 -7
- package/templates/test-results/page.tsx +237 -0
- package/templates/typedoc.json +19 -0
- package/templates/ui-showcase/_components/UIShowcase.tsx +1 -1
- package/templates/ui-showcase/page.tsx +1 -1
- package/.claude/api-dev-state.json +0 -466
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Test Orchestrator Re-grounding Hook
|
|
4
|
+
|
|
5
|
+
Runs every 5 turns during test orchestration to:
|
|
6
|
+
1. Re-inject testing goals and current progress
|
|
7
|
+
2. Send NTFY notification with status update
|
|
8
|
+
3. Prevent context dilution during long test sessions
|
|
9
|
+
|
|
10
|
+
Hook Type: PostToolUse
|
|
11
|
+
Trigger: Every 5 turns
|
|
12
|
+
NTFY Topic: test_api_devtools_alerts
|
|
13
|
+
|
|
14
|
+
Version: 1.0.0
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import subprocess
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
# Configuration
|
|
25
|
+
REGROUND_INTERVAL = 5 # Re-ground every 5 turns
|
|
26
|
+
NTFY_TOPIC = "test_api_devtools_alerts"
|
|
27
|
+
STATE_FILE = Path(__file__).parent.parent / "test-orchestrator-state.json"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def send_ntfy(message, title="Test Orchestrator", priority=3, tags=None):
|
|
31
|
+
"""Send NTFY notification."""
|
|
32
|
+
try:
|
|
33
|
+
headers = [
|
|
34
|
+
f"Title: {title}",
|
|
35
|
+
f"Priority: {priority}",
|
|
36
|
+
]
|
|
37
|
+
if tags:
|
|
38
|
+
headers.append(f"Tags: {','.join(tags)}")
|
|
39
|
+
|
|
40
|
+
header_args = []
|
|
41
|
+
for h in headers:
|
|
42
|
+
header_args.extend(["-H", h])
|
|
43
|
+
|
|
44
|
+
subprocess.run(
|
|
45
|
+
["curl", "-s"] + header_args + ["-d", message, f"https://ntfy.sh/{NTFY_TOPIC}"],
|
|
46
|
+
capture_output=True,
|
|
47
|
+
timeout=10
|
|
48
|
+
)
|
|
49
|
+
except Exception as e:
|
|
50
|
+
# Don't fail if notification fails
|
|
51
|
+
print(f"NTFY failed: {e}", file=sys.stderr)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_test_state():
|
|
55
|
+
"""Load test orchestrator state."""
|
|
56
|
+
if not STATE_FILE.exists():
|
|
57
|
+
return {
|
|
58
|
+
"turn_count": 0,
|
|
59
|
+
"started_at": datetime.now().isoformat(),
|
|
60
|
+
"commands_tested": {},
|
|
61
|
+
"current_command": None,
|
|
62
|
+
"current_phase": None,
|
|
63
|
+
"total_retries": 0,
|
|
64
|
+
"reground_history": []
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
return json.loads(STATE_FILE.read_text())
|
|
69
|
+
except Exception:
|
|
70
|
+
return {
|
|
71
|
+
"turn_count": 0,
|
|
72
|
+
"started_at": datetime.now().isoformat(),
|
|
73
|
+
"commands_tested": {},
|
|
74
|
+
"current_command": None,
|
|
75
|
+
"current_phase": None,
|
|
76
|
+
"total_retries": 0,
|
|
77
|
+
"reground_history": []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def save_test_state(state):
|
|
82
|
+
"""Save test orchestrator state."""
|
|
83
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
84
|
+
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_progress_summary(state):
|
|
88
|
+
"""Format a progress summary for re-grounding."""
|
|
89
|
+
commands = state.get("commands_tested", {})
|
|
90
|
+
|
|
91
|
+
summary_lines = []
|
|
92
|
+
summary_lines.append("## Test Orchestrator Progress")
|
|
93
|
+
summary_lines.append("")
|
|
94
|
+
|
|
95
|
+
# Overall stats
|
|
96
|
+
turn = state.get("turn_count", 0)
|
|
97
|
+
started = state.get("started_at", "unknown")
|
|
98
|
+
summary_lines.append(f"**Turn:** {turn}")
|
|
99
|
+
summary_lines.append(f"**Started:** {started}")
|
|
100
|
+
summary_lines.append(f"**Total Retries:** {state.get('total_retries', 0)}")
|
|
101
|
+
summary_lines.append("")
|
|
102
|
+
|
|
103
|
+
# Command progress
|
|
104
|
+
summary_lines.append("**Command Progress:**")
|
|
105
|
+
all_commands = [
|
|
106
|
+
"/api-create",
|
|
107
|
+
"/hustle-ui-create",
|
|
108
|
+
"/hustle-ui-create-page",
|
|
109
|
+
"/hustle-combine",
|
|
110
|
+
"/hustle-build"
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
for cmd in all_commands:
|
|
114
|
+
cmd_state = commands.get(cmd, {})
|
|
115
|
+
status = cmd_state.get("status", "NOT STARTED")
|
|
116
|
+
|
|
117
|
+
icon = {
|
|
118
|
+
"PASSED": "ā
",
|
|
119
|
+
"FAILED": "ā",
|
|
120
|
+
"IN PROGRESS": "š",
|
|
121
|
+
"NOT STARTED": "ā³"
|
|
122
|
+
}.get(status, "ā")
|
|
123
|
+
|
|
124
|
+
phases = cmd_state.get("phases_complete", 0)
|
|
125
|
+
retries = cmd_state.get("retries", 0)
|
|
126
|
+
|
|
127
|
+
if status == "PASSED":
|
|
128
|
+
summary_lines.append(f"- {icon} {cmd}: PASSED ({phases}/14 phases)")
|
|
129
|
+
elif status == "FAILED":
|
|
130
|
+
summary_lines.append(f"- {icon} {cmd}: FAILED at phase {phases}/14 (retry {retries}/ā)")
|
|
131
|
+
elif status == "IN PROGRESS":
|
|
132
|
+
summary_lines.append(f"- {icon} {cmd}: IN PROGRESS (phase {phases}/14)")
|
|
133
|
+
else:
|
|
134
|
+
summary_lines.append(f"- {icon} {cmd}: NOT STARTED")
|
|
135
|
+
|
|
136
|
+
summary_lines.append("")
|
|
137
|
+
|
|
138
|
+
# Current task
|
|
139
|
+
current_cmd = state.get("current_command")
|
|
140
|
+
current_phase = state.get("current_phase")
|
|
141
|
+
if current_cmd:
|
|
142
|
+
summary_lines.append(f"**Current Task:** {current_cmd} - Phase {current_phase}")
|
|
143
|
+
else:
|
|
144
|
+
summary_lines.append("**Current Task:** Initializing test harness")
|
|
145
|
+
|
|
146
|
+
summary_lines.append("")
|
|
147
|
+
summary_lines.append("## Primary Goal")
|
|
148
|
+
summary_lines.append("")
|
|
149
|
+
summary_lines.append("Test ALL 5 commands until they work perfectly:")
|
|
150
|
+
summary_lines.append("1. Run each command in isolated test directory")
|
|
151
|
+
summary_lines.append("2. Auto-answer questions via pending-answer.json")
|
|
152
|
+
summary_lines.append("3. Verify ALL 14 phases complete")
|
|
153
|
+
summary_lines.append("4. Verify ALL hooks fire correctly")
|
|
154
|
+
summary_lines.append("5. If tests fail: research, fix code, rebuild, retry")
|
|
155
|
+
summary_lines.append("6. NEVER STOP until all 5 commands pass")
|
|
156
|
+
summary_lines.append("")
|
|
157
|
+
summary_lines.append("## Key Resources")
|
|
158
|
+
summary_lines.append("")
|
|
159
|
+
summary_lines.append("- Test directory: ~/test-api-dev-tools-auto/")
|
|
160
|
+
summary_lines.append("- .env file: Copy from /Users/alfonso/Documents/GitHub/api-dev-tools/.env.example")
|
|
161
|
+
summary_lines.append("- WORKFLOW_CHECKLIST.md: Track results")
|
|
162
|
+
summary_lines.append("- NTFY topic: test_api_devtools_alerts")
|
|
163
|
+
summary_lines.append("")
|
|
164
|
+
summary_lines.append("## Failure Strategy")
|
|
165
|
+
summary_lines.append("")
|
|
166
|
+
summary_lines.append("If stuck after 5 retries:")
|
|
167
|
+
summary_lines.append("1. Use WebSearch to research the error")
|
|
168
|
+
summary_lines.append("2. Find similar issues and solutions")
|
|
169
|
+
summary_lines.append("3. Try new approaches")
|
|
170
|
+
summary_lines.append("4. Use git commits as savepoints")
|
|
171
|
+
summary_lines.append("5. NEVER give up - keep iterating")
|
|
172
|
+
|
|
173
|
+
return "\n".join(summary_lines)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main():
|
|
177
|
+
# Load state
|
|
178
|
+
state = load_test_state()
|
|
179
|
+
|
|
180
|
+
# Increment turn count
|
|
181
|
+
turn_count = state.get("turn_count", 0) + 1
|
|
182
|
+
state["turn_count"] = turn_count
|
|
183
|
+
state["last_turn_timestamp"] = datetime.now().isoformat()
|
|
184
|
+
|
|
185
|
+
# Check if we should re-ground
|
|
186
|
+
should_reground = turn_count % REGROUND_INTERVAL == 0
|
|
187
|
+
|
|
188
|
+
if should_reground:
|
|
189
|
+
# Generate progress summary
|
|
190
|
+
summary = format_progress_summary(state)
|
|
191
|
+
|
|
192
|
+
# Send NTFY notification
|
|
193
|
+
commands = state.get("commands_tested", {})
|
|
194
|
+
passed = sum(1 for c in commands.values() if c.get("status") == "PASSED")
|
|
195
|
+
in_progress = sum(1 for c in commands.values() if c.get("status") == "IN PROGRESS")
|
|
196
|
+
failed = sum(1 for c in commands.values() if c.get("status") == "FAILED")
|
|
197
|
+
|
|
198
|
+
ntfy_msg = f"""Turn {turn_count} Update:
|
|
199
|
+
ā
Passed: {passed}/5
|
|
200
|
+
š In Progress: {in_progress}/5
|
|
201
|
+
ā Failed: {failed}/5
|
|
202
|
+
|
|
203
|
+
Current: {state.get('current_command', 'Initializing')}
|
|
204
|
+
Retries: {state.get('total_retries', 0)}
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
send_ntfy(
|
|
208
|
+
ntfy_msg,
|
|
209
|
+
title=f"š Turn {turn_count} - Test Orchestrator",
|
|
210
|
+
priority=3,
|
|
211
|
+
tags=["robot", "test"]
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
# Add to reground history
|
|
215
|
+
reground_history = state.setdefault("reground_history", [])
|
|
216
|
+
reground_history.append({
|
|
217
|
+
"turn": turn_count,
|
|
218
|
+
"timestamp": datetime.now().isoformat(),
|
|
219
|
+
"current_command": state.get("current_command"),
|
|
220
|
+
"current_phase": state.get("current_phase"),
|
|
221
|
+
"passed": passed,
|
|
222
|
+
"failed": failed
|
|
223
|
+
})
|
|
224
|
+
# Keep only last 20 reground events
|
|
225
|
+
state["reground_history"] = reground_history[-20:]
|
|
226
|
+
|
|
227
|
+
# Save state
|
|
228
|
+
save_test_state(state)
|
|
229
|
+
|
|
230
|
+
# Output with context injection
|
|
231
|
+
output = {
|
|
232
|
+
"continue": True,
|
|
233
|
+
"hookSpecificOutput": {
|
|
234
|
+
"hookEventName": "PostToolUse",
|
|
235
|
+
"additionalContext": summary
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
print(json.dumps(output))
|
|
239
|
+
else:
|
|
240
|
+
# Just update turn count
|
|
241
|
+
save_test_state(state)
|
|
242
|
+
print(json.dumps({"continue": True}))
|
|
243
|
+
|
|
244
|
+
sys.exit(0)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PostToolUse for AskUserQuestion
|
|
4
|
+
Purpose: Track implemented vs deferred features for scope coverage
|
|
5
|
+
|
|
6
|
+
This hook tracks which features discovered during research are:
|
|
7
|
+
- Implemented (user chose to include)
|
|
8
|
+
- Deferred (user chose to skip for later)
|
|
9
|
+
- Discovered (found in docs but not yet decided)
|
|
10
|
+
|
|
11
|
+
Added in v3.6.7 for feature scope tracking.
|
|
12
|
+
|
|
13
|
+
Returns:
|
|
14
|
+
- JSON with scope coverage update info
|
|
15
|
+
"""
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_active_endpoint(state):
|
|
25
|
+
"""Get active endpoint - supports both old and new state formats."""
|
|
26
|
+
if "endpoints" in state and "active_endpoint" in state:
|
|
27
|
+
active = state.get("active_endpoint")
|
|
28
|
+
if active and active in state["endpoints"]:
|
|
29
|
+
return active, state["endpoints"][active]
|
|
30
|
+
return None, None
|
|
31
|
+
|
|
32
|
+
endpoint = state.get("endpoint")
|
|
33
|
+
if endpoint:
|
|
34
|
+
return endpoint, state
|
|
35
|
+
|
|
36
|
+
return None, None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def extract_feature_from_question(question, options):
|
|
40
|
+
"""Try to extract a feature name from the question."""
|
|
41
|
+
# Look for common patterns
|
|
42
|
+
patterns = [
|
|
43
|
+
"implement",
|
|
44
|
+
"include",
|
|
45
|
+
"support",
|
|
46
|
+
"enable",
|
|
47
|
+
"add"
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
question_lower = question.lower()
|
|
51
|
+
for pattern in patterns:
|
|
52
|
+
if pattern in question_lower:
|
|
53
|
+
# Extract the words after the pattern
|
|
54
|
+
idx = question_lower.find(pattern)
|
|
55
|
+
after = question_lower[idx:].split("?")[0]
|
|
56
|
+
# Clean up
|
|
57
|
+
words = after.split()[1:4] # Get 1-3 words after pattern
|
|
58
|
+
if words:
|
|
59
|
+
return " ".join(words).strip(",.?")
|
|
60
|
+
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_feature_decision(question, answer, options):
|
|
65
|
+
"""Determine if this was a feature implementation decision."""
|
|
66
|
+
question_lower = question.lower()
|
|
67
|
+
|
|
68
|
+
# Keywords suggesting feature decision
|
|
69
|
+
feature_keywords = [
|
|
70
|
+
"implement", "include", "support", "enable", "add",
|
|
71
|
+
"feature", "functionality", "capability"
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
has_keyword = any(k in question_lower for k in feature_keywords)
|
|
75
|
+
|
|
76
|
+
# Check if answer indicates yes/no/defer decision
|
|
77
|
+
answer_lower = str(answer).lower() if answer else ""
|
|
78
|
+
is_decision = any(word in answer_lower for word in [
|
|
79
|
+
"yes", "no", "skip", "defer", "later", "include", "exclude",
|
|
80
|
+
"implement", "confirm", "reject"
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
return has_keyword and is_decision
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def categorize_decision(answer):
|
|
87
|
+
"""Categorize the decision as implement/defer/skip."""
|
|
88
|
+
answer_lower = str(answer).lower() if answer else ""
|
|
89
|
+
|
|
90
|
+
if any(word in answer_lower for word in ["yes", "include", "implement", "confirm"]):
|
|
91
|
+
return "implement"
|
|
92
|
+
elif any(word in answer_lower for word in ["defer", "later", "phase 2", "future"]):
|
|
93
|
+
return "defer"
|
|
94
|
+
elif any(word in answer_lower for word in ["no", "skip", "exclude", "reject"]):
|
|
95
|
+
return "skip"
|
|
96
|
+
|
|
97
|
+
return "unknown"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def main():
|
|
101
|
+
try:
|
|
102
|
+
input_data = json.load(sys.stdin)
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
print(json.dumps({"continue": True}))
|
|
105
|
+
sys.exit(0)
|
|
106
|
+
|
|
107
|
+
tool_name = input_data.get("tool_name", "")
|
|
108
|
+
tool_input = input_data.get("tool_input", {})
|
|
109
|
+
tool_result = input_data.get("tool_result", {})
|
|
110
|
+
|
|
111
|
+
if tool_name != "AskUserQuestion":
|
|
112
|
+
print(json.dumps({"continue": True}))
|
|
113
|
+
sys.exit(0)
|
|
114
|
+
|
|
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
|
+
endpoint, endpoint_data = get_active_endpoint(state)
|
|
126
|
+
if not endpoint or not endpoint_data:
|
|
127
|
+
print(json.dumps({"continue": True}))
|
|
128
|
+
sys.exit(0)
|
|
129
|
+
|
|
130
|
+
# Get question and answer
|
|
131
|
+
question = tool_input.get("question", "")
|
|
132
|
+
options = tool_input.get("options", [])
|
|
133
|
+
|
|
134
|
+
# Get user's answer from result
|
|
135
|
+
answer = None
|
|
136
|
+
if isinstance(tool_result, dict):
|
|
137
|
+
answer = tool_result.get("answer", tool_result.get("value", ""))
|
|
138
|
+
elif isinstance(tool_result, str):
|
|
139
|
+
answer = tool_result
|
|
140
|
+
|
|
141
|
+
# Check if this is a feature decision
|
|
142
|
+
if not is_feature_decision(question, answer, options):
|
|
143
|
+
print(json.dumps({"continue": True}))
|
|
144
|
+
sys.exit(0)
|
|
145
|
+
|
|
146
|
+
# Extract feature name
|
|
147
|
+
feature = extract_feature_from_question(question, options)
|
|
148
|
+
if not feature:
|
|
149
|
+
feature = f"feature_{datetime.now().strftime('%H%M%S')}"
|
|
150
|
+
|
|
151
|
+
# Categorize decision
|
|
152
|
+
category = categorize_decision(answer)
|
|
153
|
+
|
|
154
|
+
# Ensure scope object exists
|
|
155
|
+
if "endpoints" in state:
|
|
156
|
+
if "scope" not in state["endpoints"][endpoint]:
|
|
157
|
+
state["endpoints"][endpoint]["scope"] = {
|
|
158
|
+
"discovered_features": [],
|
|
159
|
+
"implemented_features": [],
|
|
160
|
+
"deferred_features": [],
|
|
161
|
+
"coverage_percent": 0
|
|
162
|
+
}
|
|
163
|
+
scope = state["endpoints"][endpoint]["scope"]
|
|
164
|
+
else:
|
|
165
|
+
if "scope" not in state:
|
|
166
|
+
state["scope"] = {
|
|
167
|
+
"discovered_features": [],
|
|
168
|
+
"implemented_features": [],
|
|
169
|
+
"deferred_features": [],
|
|
170
|
+
"coverage_percent": 0
|
|
171
|
+
}
|
|
172
|
+
scope = state["scope"]
|
|
173
|
+
|
|
174
|
+
# Add to discovered if not already there
|
|
175
|
+
feature_entry = {
|
|
176
|
+
"name": feature,
|
|
177
|
+
"discovered_at": datetime.now().isoformat(),
|
|
178
|
+
"question": question[:100],
|
|
179
|
+
"decision": category
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if feature not in [f.get("name") if isinstance(f, dict) else f for f in scope["discovered_features"]]:
|
|
183
|
+
scope["discovered_features"].append(feature_entry)
|
|
184
|
+
|
|
185
|
+
# Add to appropriate category
|
|
186
|
+
if category == "implement":
|
|
187
|
+
if feature not in scope["implemented_features"]:
|
|
188
|
+
scope["implemented_features"].append(feature)
|
|
189
|
+
elif category == "defer":
|
|
190
|
+
defer_entry = {
|
|
191
|
+
"name": feature,
|
|
192
|
+
"reason": f"User chose to defer: {str(answer)[:50]}",
|
|
193
|
+
"deferred_at": datetime.now().isoformat()
|
|
194
|
+
}
|
|
195
|
+
if feature not in [f.get("name") if isinstance(f, dict) else f for f in scope["deferred_features"]]:
|
|
196
|
+
scope["deferred_features"].append(defer_entry)
|
|
197
|
+
|
|
198
|
+
# Calculate coverage
|
|
199
|
+
total = len(scope["discovered_features"])
|
|
200
|
+
implemented = len(scope["implemented_features"])
|
|
201
|
+
if total > 0:
|
|
202
|
+
scope["coverage_percent"] = round((implemented / total) * 100, 1)
|
|
203
|
+
|
|
204
|
+
# Save state
|
|
205
|
+
STATE_FILE.write_text(json.dumps(state, indent=2))
|
|
206
|
+
|
|
207
|
+
output = {
|
|
208
|
+
"hookSpecificOutput": {
|
|
209
|
+
"featureTracked": feature,
|
|
210
|
+
"decision": category,
|
|
211
|
+
"coveragePercent": scope["coverage_percent"]
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
print(json.dumps(output))
|
|
216
|
+
sys.exit(0)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == "__main__":
|
|
220
|
+
main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Hook: PostToolUse
|
|
4
|
+
Purpose: Track token usage per phase and display after phase completion
|
|
5
|
+
|
|
6
|
+
Logs token usage to state file and outputs summary after each phase.
|
|
7
|
+
Integrates with ccusage if available.
|
|
8
|
+
|
|
9
|
+
Version: 3.10.0
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_token_usage() -> dict:
|
|
19
|
+
"""Get current token usage from ccusage."""
|
|
20
|
+
try:
|
|
21
|
+
result = subprocess.run(
|
|
22
|
+
["ccusage", "--json"],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
timeout=5
|
|
26
|
+
)
|
|
27
|
+
if result.returncode == 0:
|
|
28
|
+
return json.loads(result.stdout)
|
|
29
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError):
|
|
30
|
+
pass
|
|
31
|
+
return {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
# Read hook input from stdin
|
|
36
|
+
try:
|
|
37
|
+
input_data = json.load(sys.stdin)
|
|
38
|
+
except json.JSONDecodeError:
|
|
39
|
+
sys.exit(0)
|
|
40
|
+
|
|
41
|
+
tool_name = input_data.get("tool_name", "")
|
|
42
|
+
tool_input = input_data.get("tool_input", {})
|
|
43
|
+
|
|
44
|
+
# Only trigger on Write/Edit to state file
|
|
45
|
+
if tool_name not in ["Write", "Edit"]:
|
|
46
|
+
sys.exit(0)
|
|
47
|
+
|
|
48
|
+
file_path = tool_input.get("file_path", "")
|
|
49
|
+
if "api-dev-state.json" not in file_path:
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
|
|
52
|
+
# Get current token usage
|
|
53
|
+
usage = get_token_usage()
|
|
54
|
+
if not usage:
|
|
55
|
+
sys.exit(0)
|
|
56
|
+
|
|
57
|
+
# Read state file
|
|
58
|
+
cwd = Path.cwd()
|
|
59
|
+
state_file = cwd / ".claude" / "api-dev-state.json"
|
|
60
|
+
|
|
61
|
+
if not state_file.exists():
|
|
62
|
+
sys.exit(0)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
state = json.loads(state_file.read_text())
|
|
66
|
+
except (json.JSONDecodeError, IOError):
|
|
67
|
+
sys.exit(0)
|
|
68
|
+
|
|
69
|
+
# Check for phase completion and log usage
|
|
70
|
+
phases = state.get("phases", {})
|
|
71
|
+
current_phase = None
|
|
72
|
+
|
|
73
|
+
for phase_key, phase_data in phases.items():
|
|
74
|
+
if isinstance(phase_data, dict):
|
|
75
|
+
status = phase_data.get("status", "")
|
|
76
|
+
if status == "complete":
|
|
77
|
+
current_phase = phase_key
|
|
78
|
+
|
|
79
|
+
if current_phase:
|
|
80
|
+
# Initialize token tracking in state if needed
|
|
81
|
+
if "token_usage" not in state:
|
|
82
|
+
state["token_usage"] = {
|
|
83
|
+
"by_phase": {},
|
|
84
|
+
"total_at_start": usage.get("total_tokens", 0),
|
|
85
|
+
"started_at": datetime.now().isoformat()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Record phase completion tokens
|
|
89
|
+
state["token_usage"]["by_phase"][current_phase] = {
|
|
90
|
+
"total_tokens": usage.get("total_tokens", 0),
|
|
91
|
+
"total_cost": usage.get("total_cost", 0),
|
|
92
|
+
"timestamp": datetime.now().isoformat()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
# Calculate phase delta if we have previous data
|
|
96
|
+
by_phase = state["token_usage"]["by_phase"]
|
|
97
|
+
phase_keys = list(by_phase.keys())
|
|
98
|
+
|
|
99
|
+
if len(phase_keys) >= 2:
|
|
100
|
+
prev_phase = phase_keys[-2]
|
|
101
|
+
prev_tokens = by_phase[prev_phase].get("total_tokens", 0)
|
|
102
|
+
current_tokens = usage.get("total_tokens", 0)
|
|
103
|
+
delta = current_tokens - prev_tokens
|
|
104
|
+
|
|
105
|
+
# Output phase token summary
|
|
106
|
+
print(f"\nš Phase '{current_phase}' Token Usage:", file=sys.stderr)
|
|
107
|
+
print(f" Phase tokens: {delta:,}", file=sys.stderr)
|
|
108
|
+
print(f" Total tokens: {current_tokens:,}", file=sys.stderr)
|
|
109
|
+
print(f" Total cost: ${usage.get('total_cost', 0):.2f}", file=sys.stderr)
|
|
110
|
+
|
|
111
|
+
# Update state file with token tracking
|
|
112
|
+
try:
|
|
113
|
+
state_file.write_text(json.dumps(state, indent=2))
|
|
114
|
+
except IOError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
sys.exit(0)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
main()
|