@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.
Files changed (96) hide show
  1. package/.claude/commands/hustle-build.md +259 -0
  2. package/.claude/commands/hustle-combine.md +1089 -0
  3. package/.claude/commands/hustle-ui-create-page.md +1078 -0
  4. package/.claude/commands/hustle-ui-create.md +1058 -0
  5. package/.claude/hooks/auto-answer.py +305 -0
  6. package/.claude/hooks/cache-research.py +337 -0
  7. package/.claude/hooks/check-api-routes.py +168 -0
  8. package/.claude/hooks/check-playwright-setup.py +103 -0
  9. package/.claude/hooks/check-storybook-setup.py +81 -0
  10. package/.claude/hooks/check-update.py +132 -0
  11. package/.claude/hooks/completion-promise-detector.py +293 -0
  12. package/.claude/hooks/context-capacity-warning.py +171 -0
  13. package/.claude/hooks/detect-interruption.py +165 -0
  14. package/.claude/hooks/docs-update-check.py +120 -0
  15. package/.claude/hooks/enforce-a11y-audit.py +202 -0
  16. package/.claude/hooks/enforce-brand-guide.py +241 -0
  17. package/.claude/hooks/enforce-component-type-confirm.py +97 -0
  18. package/.claude/hooks/enforce-dry-run.py +134 -0
  19. package/.claude/hooks/enforce-freshness.py +184 -0
  20. package/.claude/hooks/enforce-page-components.py +186 -0
  21. package/.claude/hooks/enforce-page-data-schema.py +155 -0
  22. package/.claude/hooks/enforce-questions-sourced.py +146 -0
  23. package/.claude/hooks/enforce-schema-from-interview.py +248 -0
  24. package/.claude/hooks/enforce-ui-disambiguation.py +108 -0
  25. package/.claude/hooks/enforce-ui-interview.py +130 -0
  26. package/.claude/hooks/generate-adr-options.py +282 -0
  27. package/.claude/hooks/generate-manifest-entry.py +1161 -0
  28. package/.claude/hooks/hook_utils.py +609 -0
  29. package/.claude/hooks/lib/__init__.py +1 -0
  30. package/.claude/hooks/lib/__pycache__/__init__.cpython-314.pyc +0 -0
  31. package/.claude/hooks/lib/__pycache__/greptile.cpython-314.pyc +0 -0
  32. package/.claude/hooks/lib/__pycache__/ntfy.cpython-314.pyc +0 -0
  33. package/.claude/hooks/lib/greptile.py +355 -0
  34. package/.claude/hooks/lib/ntfy.py +209 -0
  35. package/.claude/hooks/notify-input-needed.py +73 -0
  36. package/.claude/hooks/notify-phase-complete.py +90 -0
  37. package/.claude/hooks/ntfy-on-question.py +240 -0
  38. package/.claude/hooks/orchestrator-completion.py +313 -0
  39. package/.claude/hooks/orchestrator-handoff.py +267 -0
  40. package/.claude/hooks/orchestrator-session-startup.py +146 -0
  41. package/.claude/hooks/parallel-orchestrator.py +451 -0
  42. package/.claude/hooks/project-document-prompt.py +302 -0
  43. package/.claude/hooks/remote-question-proxy.py +284 -0
  44. package/.claude/hooks/remote-question-server.py +1224 -0
  45. package/.claude/hooks/run-code-review.py +393 -0
  46. package/.claude/hooks/run-visual-qa.py +338 -0
  47. package/.claude/hooks/session-logger.py +323 -0
  48. package/.claude/hooks/test-orchestrator-reground.py +248 -0
  49. package/.claude/hooks/track-scope-coverage.py +220 -0
  50. package/.claude/hooks/track-token-usage.py +121 -0
  51. package/.claude/hooks/update-adr-decision.py +236 -0
  52. package/.claude/hooks/update-api-showcase.py +161 -0
  53. package/.claude/hooks/update-registry.py +352 -0
  54. package/.claude/hooks/update-testing-checklist.py +195 -0
  55. package/.claude/hooks/update-ui-showcase.py +224 -0
  56. package/.claude/settings.local.json +7 -1
  57. package/.claude/test-auto-answer-bot.py +183 -0
  58. package/.claude/test-completion-detector.py +263 -0
  59. package/.claude/test-orchestrator-state.json +20 -0
  60. package/.claude/test-orchestrator.sh +271 -0
  61. package/.skills/api-create/SKILL.md +88 -3
  62. package/.skills/docs-sync/SKILL.md +260 -0
  63. package/.skills/hustle-build/SKILL.md +459 -0
  64. package/.skills/hustle-build-review/SKILL.md +518 -0
  65. package/CHANGELOG.md +87 -0
  66. package/README.md +86 -9
  67. package/bin/cli.js +1302 -88
  68. package/commands/hustle-api-create.md +22 -0
  69. package/commands/hustle-combine.md +81 -2
  70. package/commands/hustle-ui-create-page.md +84 -2
  71. package/commands/hustle-ui-create.md +82 -2
  72. package/hooks/auto-answer.py +228 -0
  73. package/hooks/check-update.py +132 -0
  74. package/hooks/ntfy-on-question.py +227 -0
  75. package/hooks/orchestrator-completion.py +313 -0
  76. package/hooks/orchestrator-handoff.py +189 -0
  77. package/hooks/orchestrator-session-startup.py +146 -0
  78. package/hooks/periodic-reground.py +230 -67
  79. package/hooks/update-api-showcase.py +13 -1
  80. package/hooks/update-ui-showcase.py +13 -1
  81. package/package.json +7 -3
  82. package/scripts/extract-schema-docs.cjs +322 -0
  83. package/templates/CLAUDE-SECTION.md +89 -64
  84. package/templates/api-showcase/_components/APIModal.tsx +100 -8
  85. package/templates/api-showcase/_components/APIShowcase.tsx +36 -4
  86. package/templates/api-showcase/_components/APITester.tsx +367 -58
  87. package/templates/docs/page.tsx +230 -0
  88. package/templates/hustle-build-defaults.json +84 -0
  89. package/templates/hustle-dev-dashboard/page.tsx +365 -0
  90. package/templates/playwright-report/page.tsx +258 -0
  91. package/templates/settings.json +88 -7
  92. package/templates/test-results/page.tsx +237 -0
  93. package/templates/typedoc.json +19 -0
  94. package/templates/ui-showcase/_components/UIShowcase.tsx +1 -1
  95. package/templates/ui-showcase/page.tsx +1 -1
  96. 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()