@hustle-together/api-dev-tools 3.12.3 → 4.5.1

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 (159) hide show
  1. package/.claude/adr-requests/.gitkeep +10 -0
  2. package/.claude/agents/adr-researcher.md +109 -0
  3. package/.claude/agents/visual-analyzer.md +183 -0
  4. package/.claude/api-dev-state.json +7 -463
  5. package/.claude/documentation-audit.json +114 -0
  6. package/.claude/registry.json +289 -0
  7. package/.claude/settings.json +45 -1
  8. package/.claude/workflow-logs/None.json +49 -0
  9. package/.claude/workflow-logs/session-20251230-143727.json +106 -0
  10. package/.skills/adr-deep-research/SKILL.md +351 -0
  11. package/.skills/api-create/SKILL.md +116 -17
  12. package/.skills/api-research/SKILL.md +130 -0
  13. package/.skills/docs-sync/SKILL.md +260 -0
  14. package/.skills/docs-update/SKILL.md +205 -0
  15. package/.skills/hustle-brand/SKILL.md +368 -0
  16. package/.skills/hustle-build/SKILL.md +786 -0
  17. package/.skills/hustle-build-review/SKILL.md +518 -0
  18. package/.skills/parallel-spawn/SKILL.md +212 -0
  19. package/.skills/ralph-continue/SKILL.md +151 -0
  20. package/.skills/ralph-loop/SKILL.md +341 -0
  21. package/.skills/ralph-status/SKILL.md +87 -0
  22. package/.skills/refactor/SKILL.md +59 -0
  23. package/.skills/shadcn/SKILL.md +522 -0
  24. package/.skills/test-all/SKILL.md +210 -0
  25. package/.skills/test-builds/SKILL.md +208 -0
  26. package/.skills/test-debug/SKILL.md +212 -0
  27. package/.skills/test-e2e/SKILL.md +168 -0
  28. package/.skills/test-review/SKILL.md +707 -0
  29. package/.skills/test-unit/SKILL.md +143 -0
  30. package/.skills/test-visual/SKILL.md +301 -0
  31. package/.skills/token-report/SKILL.md +132 -0
  32. package/CHANGELOG.md +575 -0
  33. package/README.md +426 -56
  34. package/bin/cli.js +1538 -88
  35. package/commands/hustle-api-create.md +22 -0
  36. package/commands/hustle-build.md +259 -0
  37. package/commands/hustle-combine.md +81 -2
  38. package/commands/hustle-ui-create-page.md +84 -2
  39. package/commands/hustle-ui-create.md +82 -2
  40. package/hooks/__pycache__/api-workflow-check.cpython-314.pyc +0 -0
  41. package/hooks/__pycache__/auto-answer.cpython-314.pyc +0 -0
  42. package/hooks/__pycache__/cache-research.cpython-314.pyc +0 -0
  43. package/hooks/__pycache__/check-api-routes.cpython-314.pyc +0 -0
  44. package/hooks/__pycache__/check-playwright-setup.cpython-314.pyc +0 -0
  45. package/hooks/__pycache__/check-storybook-setup.cpython-314.pyc +0 -0
  46. package/hooks/__pycache__/check-update.cpython-314.pyc +0 -0
  47. package/hooks/__pycache__/completion-promise-detector.cpython-314.pyc +0 -0
  48. package/hooks/__pycache__/context-capacity-warning.cpython-314.pyc +0 -0
  49. package/hooks/__pycache__/detect-interruption.cpython-314.pyc +0 -0
  50. package/hooks/__pycache__/docs-update-check.cpython-314.pyc +0 -0
  51. package/hooks/__pycache__/enforce-a11y-audit.cpython-314.pyc +0 -0
  52. package/hooks/__pycache__/enforce-brand-guide.cpython-314.pyc +0 -0
  53. package/hooks/__pycache__/enforce-component-type-confirm.cpython-314.pyc +0 -0
  54. package/hooks/__pycache__/enforce-deep-research.cpython-314.pyc +0 -0
  55. package/hooks/__pycache__/enforce-disambiguation.cpython-314.pyc +0 -0
  56. package/hooks/__pycache__/enforce-documentation.cpython-314.pyc +0 -0
  57. package/hooks/__pycache__/enforce-dry-run.cpython-314.pyc +0 -0
  58. package/hooks/__pycache__/enforce-environment.cpython-314.pyc +0 -0
  59. package/hooks/__pycache__/enforce-external-research.cpython-314.pyc +0 -0
  60. package/hooks/__pycache__/enforce-freshness.cpython-314.pyc +0 -0
  61. package/hooks/__pycache__/enforce-interview.cpython-314.pyc +0 -0
  62. package/hooks/__pycache__/enforce-page-components.cpython-314.pyc +0 -0
  63. package/hooks/__pycache__/enforce-page-data-schema.cpython-314.pyc +0 -0
  64. package/hooks/__pycache__/enforce-questions-sourced.cpython-314.pyc +0 -0
  65. package/hooks/__pycache__/enforce-refactor.cpython-314.pyc +0 -0
  66. package/hooks/__pycache__/enforce-research.cpython-314.pyc +0 -0
  67. package/hooks/__pycache__/enforce-schema-from-interview.cpython-314.pyc +0 -0
  68. package/hooks/__pycache__/enforce-schema.cpython-314.pyc +0 -0
  69. package/hooks/__pycache__/enforce-scope.cpython-314.pyc +0 -0
  70. package/hooks/__pycache__/enforce-tdd-red.cpython-314.pyc +0 -0
  71. package/hooks/__pycache__/enforce-ui-disambiguation.cpython-314.pyc +0 -0
  72. package/hooks/__pycache__/enforce-ui-interview.cpython-314.pyc +0 -0
  73. package/hooks/__pycache__/enforce-verify.cpython-314.pyc +0 -0
  74. package/hooks/__pycache__/generate-adr-options.cpython-314.pyc +0 -0
  75. package/hooks/__pycache__/generate-manifest-entry.cpython-314.pyc +0 -0
  76. package/hooks/__pycache__/hook_utils.cpython-314.pyc +0 -0
  77. package/hooks/__pycache__/notify-input-needed.cpython-314.pyc +0 -0
  78. package/hooks/__pycache__/notify-phase-complete.cpython-314.pyc +0 -0
  79. package/hooks/__pycache__/ntfy-on-question.cpython-314.pyc +0 -0
  80. package/hooks/__pycache__/orchestrator-completion.cpython-314.pyc +0 -0
  81. package/hooks/__pycache__/orchestrator-handoff.cpython-314.pyc +0 -0
  82. package/hooks/__pycache__/orchestrator-session-startup.cpython-314.pyc +0 -0
  83. package/hooks/__pycache__/parallel-orchestrator.cpython-314.pyc +0 -0
  84. package/hooks/__pycache__/periodic-reground.cpython-314.pyc +0 -0
  85. package/hooks/__pycache__/project-document-prompt.cpython-314.pyc +0 -0
  86. package/hooks/__pycache__/remote-question-proxy.cpython-314.pyc +0 -0
  87. package/hooks/__pycache__/remote-question-server.cpython-314.pyc +0 -0
  88. package/hooks/__pycache__/run-code-review.cpython-314.pyc +0 -0
  89. package/hooks/__pycache__/run-visual-qa.cpython-314.pyc +0 -0
  90. package/hooks/__pycache__/session-logger.cpython-314.pyc +0 -0
  91. package/hooks/__pycache__/session-startup.cpython-314.pyc +0 -0
  92. package/hooks/__pycache__/track-scope-coverage.cpython-314.pyc +0 -0
  93. package/hooks/__pycache__/track-token-usage.cpython-314.pyc +0 -0
  94. package/hooks/__pycache__/track-tool-use.cpython-314.pyc +0 -0
  95. package/hooks/__pycache__/update-adr-decision.cpython-314.pyc +0 -0
  96. package/hooks/__pycache__/update-api-showcase.cpython-314.pyc +0 -0
  97. package/hooks/__pycache__/update-registry.cpython-314.pyc +0 -0
  98. package/hooks/__pycache__/update-ui-showcase.cpython-314.pyc +0 -0
  99. package/hooks/__pycache__/verify-after-green.cpython-314.pyc +0 -0
  100. package/hooks/__pycache__/verify-implementation.cpython-314.pyc +0 -0
  101. package/hooks/api-workflow-check.py +34 -0
  102. package/hooks/auto-answer.py +305 -0
  103. package/hooks/check-update.py +132 -0
  104. package/hooks/completion-promise-detector.py +293 -0
  105. package/hooks/context-capacity-warning.py +171 -0
  106. package/hooks/docs-update-check.py +120 -0
  107. package/hooks/enforce-dry-run.py +134 -0
  108. package/hooks/enforce-external-research.py +25 -0
  109. package/hooks/enforce-interview.py +20 -0
  110. package/hooks/generate-adr-options.py +282 -0
  111. package/hooks/hook_utils.py +609 -0
  112. package/hooks/lib/__pycache__/__init__.cpython-314.pyc +0 -0
  113. package/hooks/lib/__pycache__/greptile.cpython-314.pyc +0 -0
  114. package/hooks/lib/__pycache__/ntfy.cpython-314.pyc +0 -0
  115. package/hooks/ntfy-on-question.py +240 -0
  116. package/hooks/orchestrator-completion.py +313 -0
  117. package/hooks/orchestrator-handoff.py +267 -0
  118. package/hooks/orchestrator-session-startup.py +146 -0
  119. package/hooks/parallel-orchestrator.py +451 -0
  120. package/hooks/periodic-reground.py +270 -67
  121. package/hooks/project-document-prompt.py +302 -0
  122. package/hooks/remote-question-proxy.py +284 -0
  123. package/hooks/remote-question-server.py +1224 -0
  124. package/hooks/run-code-review.py +176 -29
  125. package/hooks/run-visual-qa.py +338 -0
  126. package/hooks/session-logger.py +27 -1
  127. package/hooks/session-startup.py +113 -0
  128. package/hooks/update-adr-decision.py +236 -0
  129. package/hooks/update-api-showcase.py +13 -1
  130. package/hooks/update-testing-checklist.py +195 -0
  131. package/hooks/update-ui-showcase.py +13 -1
  132. package/package.json +7 -3
  133. package/scripts/extract-schema-docs.cjs +322 -0
  134. package/templates/.skills/hustle-interview/SKILL.md +174 -0
  135. package/templates/CLAUDE-SECTION.md +89 -64
  136. package/templates/adr-viewer/_components/ADRViewer.tsx +326 -0
  137. package/templates/api-dev-state.json +33 -1
  138. package/templates/api-showcase/_components/APIModal.tsx +100 -8
  139. package/templates/api-showcase/_components/APIShowcase.tsx +36 -4
  140. package/templates/api-showcase/_components/APITester.tsx +367 -58
  141. package/templates/brand-page/page.tsx +645 -0
  142. package/templates/component/Component.visual.spec.ts +30 -24
  143. package/templates/docs/page.tsx +230 -0
  144. package/templates/eslint-plugin-zod-schema/index.js +446 -0
  145. package/templates/eslint-plugin-zod-schema/package.json +26 -0
  146. package/templates/github-workflows/security.yml +274 -0
  147. package/templates/hustle-build-defaults.json +136 -0
  148. package/templates/hustle-dev-dashboard/page.tsx +365 -0
  149. package/templates/page/page.e2e.test.ts +30 -26
  150. package/templates/performance-budgets.json +63 -5
  151. package/templates/playwright-report/page.tsx +258 -0
  152. package/templates/registry.json +279 -3
  153. package/templates/review-dashboard/page.tsx +510 -0
  154. package/templates/settings.json +155 -7
  155. package/templates/test-results/page.tsx +237 -0
  156. package/templates/typedoc.json +19 -0
  157. package/templates/ui-showcase/_components/UIShowcase.tsx +48 -1
  158. package/templates/ui-showcase/_components/VisualTestingDashboard.tsx +579 -0
  159. package/templates/ui-showcase/page.tsx +1 -1
@@ -3,14 +3,22 @@
3
3
  Hook: PostToolUse (for periodic re-grounding)
4
4
  Purpose: Inject context reminders every N turns to prevent context dilution
5
5
 
6
- This hook tracks turn count and periodically injects a summary of:
6
+ This hook tracks turn count and periodically injects a comprehensive summary of:
7
7
  - Current endpoint and phase
8
8
  - Key decisions from interview
9
+ - Existing registry elements (APIs, components, pages)
10
+ - Deferred features (don't re-suggest)
11
+ - Last test status
12
+ - Brand guide status
9
13
  - Research cache status
10
- - Important file locations
14
+ - Orchestrator context (if in /hustle-build)
11
15
 
12
16
  The goal is to keep Claude grounded during long sessions where
13
- the original CLAUDE.md context may get diluted.
17
+ the original CLAUDE.md context may get diluted ("lost in the middle").
18
+
19
+ Based on best practices from:
20
+ - Manus: "Manipulate Attention Through Recitation"
21
+ - Sankalp: "Context as limited attention budget"
14
22
 
15
23
  Configuration:
16
24
  - REGROUND_INTERVAL: Number of turns between re-grounding (default: 7)
@@ -27,8 +35,261 @@ from pathlib import Path
27
35
  # Configuration
28
36
  REGROUND_INTERVAL = 7 # Re-ground every N turns
29
37
 
30
- # State file is in .claude/ directory (sibling to hooks/)
31
- STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
38
+ # State files (in .claude/ directory)
39
+ PROJECT_DIR = Path(os.environ.get("CLAUDE_PROJECT_DIR", "."))
40
+ STATE_FILE = PROJECT_DIR / ".claude" / "api-dev-state.json"
41
+ REGISTRY_FILE = PROJECT_DIR / ".claude" / "registry.json"
42
+ BUILD_STATE_FILE = PROJECT_DIR / ".claude" / "hustle-build-state.json"
43
+ BRAND_GUIDE_FILE = PROJECT_DIR / ".claude" / "BRAND_GUIDE.md"
44
+
45
+
46
+ def load_json_file(filepath):
47
+ """Safely load a JSON file"""
48
+ if filepath.exists():
49
+ try:
50
+ return json.loads(filepath.read_text())
51
+ except (json.JSONDecodeError, Exception):
52
+ pass
53
+ return None
54
+
55
+
56
+ def format_list(items, max_items=5, max_chars=80):
57
+ """Format a list of items with truncation"""
58
+ if not items:
59
+ return "None"
60
+ truncated = list(items)[:max_items]
61
+ result = ", ".join(str(item)[:20] for item in truncated)
62
+ if len(items) > max_items:
63
+ result += f" (+{len(items) - max_items} more)"
64
+ return result[:max_chars]
65
+
66
+
67
+ def get_registry_summary(registry):
68
+ """Get summary of existing registry elements"""
69
+ if not registry:
70
+ return None
71
+
72
+ summary = {}
73
+ # Core elements
74
+ for category in ["apis", "components", "pages", "combined"]:
75
+ items = registry.get(category, {})
76
+ if items:
77
+ summary[category] = list(items.keys())
78
+
79
+ # Infrastructure tracking (v1.3.0+)
80
+ routes = registry.get("routes", {})
81
+ if routes and not routes.get("_description"):
82
+ # Has actual routes, not just template
83
+ actual_routes = [k for k in routes.keys() if not k.startswith("_")]
84
+ if actual_routes:
85
+ summary["routes"] = actual_routes
86
+
87
+ env_vars = registry.get("env_vars", {})
88
+ if env_vars:
89
+ actual_vars = [k for k in env_vars.keys() if not k.startswith("_")]
90
+ if actual_vars:
91
+ summary["env_vars"] = actual_vars
92
+
93
+ services = registry.get("services", {})
94
+ if services:
95
+ actual_services = [k for k in services.keys() if not k.startswith("_")]
96
+ if actual_services:
97
+ summary["services"] = actual_services
98
+
99
+ webhooks = registry.get("webhooks", {})
100
+ if webhooks:
101
+ actual_webhooks = [k for k in webhooks.keys() if not k.startswith("_")]
102
+ if actual_webhooks:
103
+ summary["webhooks"] = actual_webhooks
104
+
105
+ return summary if summary else None
106
+
107
+
108
+ def get_test_status(state):
109
+ """Get last test run status"""
110
+ test_run = state.get("last_test_run", {})
111
+ if not test_run:
112
+ return None
113
+
114
+ passed = test_run.get("passed", 0)
115
+ failed = test_run.get("failed", 0)
116
+ timestamp = test_run.get("timestamp", "")
117
+
118
+ if passed or failed:
119
+ return {
120
+ "passed": passed,
121
+ "failed": failed,
122
+ "total": passed + failed,
123
+ "status": "GREEN" if failed == 0 else "RED",
124
+ "timestamp": timestamp
125
+ }
126
+ return None
127
+
128
+
129
+ def get_brand_guide_status():
130
+ """Check if brand guide exists and get key info"""
131
+ if not BRAND_GUIDE_FILE.exists():
132
+ return None
133
+
134
+ try:
135
+ content = BRAND_GUIDE_FILE.read_text()
136
+ # Extract key colors if present
137
+ colors = []
138
+ for line in content.split("\n"):
139
+ if "primary" in line.lower() and "#" in line:
140
+ colors.append("primary found")
141
+ break
142
+ return {"exists": True, "has_colors": len(colors) > 0}
143
+ except Exception:
144
+ return {"exists": True}
145
+
146
+
147
+ def get_orchestrator_status(build_state):
148
+ """Get orchestrator build status if active"""
149
+ if not build_state:
150
+ return None
151
+
152
+ status = build_state.get("status")
153
+ if status not in ["in_progress", "paused"]:
154
+ return None
155
+
156
+ build_id = build_state.get("build_id", "unknown")
157
+ decomposition = build_state.get("decomposition", {})
158
+
159
+ total = 0
160
+ completed = 0
161
+ for wf_type in ["apis", "components", "combined_apis", "pages"]:
162
+ workflows = decomposition.get(wf_type, [])
163
+ total += len(workflows)
164
+ completed += len([w for w in workflows if w.get("status") == "complete"])
165
+
166
+ active = build_state.get("active_sub_workflow", {})
167
+
168
+ return {
169
+ "build_id": build_id,
170
+ "progress": f"{completed}/{total}",
171
+ "active_type": active.get("type", "none"),
172
+ "active_name": active.get("name", "none")
173
+ }
174
+
175
+
176
+ def build_reground_context(state, turn_count):
177
+ """Build comprehensive re-grounding context"""
178
+ parts = []
179
+ parts.append(f"## Re-Grounding Reminder (Turn {turn_count})")
180
+ parts.append("")
181
+
182
+ # === Current Workflow ===
183
+ endpoint = state.get("endpoint", "unknown")
184
+ parts.append(f"**Active Endpoint:** `{endpoint}`")
185
+
186
+ # Get current phase
187
+ phases = state.get("phases", {})
188
+ phase_order = [
189
+ "disambiguation", "scope", "research_initial", "interview",
190
+ "research_deep", "schema_creation", "environment_check",
191
+ "tdd_red", "tdd_green", "verify", "code_review", "tdd_refactor",
192
+ "documentation", "completion"
193
+ ]
194
+
195
+ current_phase = None
196
+ completed_phases = []
197
+ for phase_name in phase_order:
198
+ phase = phases.get(phase_name, {})
199
+ status = phase.get("status", "not_started")
200
+ if status == "complete":
201
+ completed_phases.append(phase_name)
202
+ elif status == "in_progress" and not current_phase:
203
+ current_phase = phase_name
204
+
205
+ if not current_phase:
206
+ for phase_name in phase_order:
207
+ phase = phases.get(phase_name, {})
208
+ if phase.get("status", "not_started") == "not_started":
209
+ current_phase = phase_name
210
+ break
211
+
212
+ parts.append(f"**Current Phase:** {current_phase or 'completion'}")
213
+ parts.append(f"**Completed:** {len(completed_phases)}/{len(phase_order)} phases")
214
+
215
+ # === Key Decisions ===
216
+ interview = phases.get("interview", {})
217
+ decisions = interview.get("decisions", {})
218
+ if decisions:
219
+ parts.append("")
220
+ parts.append("**Key Decisions:**")
221
+ for key, value in list(decisions.items())[:5]:
222
+ response = value.get("value", value.get("response", "N/A"))
223
+ if response:
224
+ parts.append(f" - {key}: {str(response)[:40]}")
225
+
226
+ # === Registry Summary ===
227
+ registry = load_json_file(REGISTRY_FILE)
228
+ registry_summary = get_registry_summary(registry)
229
+ if registry_summary:
230
+ parts.append("")
231
+ parts.append("**Existing Elements (don't recreate):**")
232
+ if registry_summary.get("apis"):
233
+ parts.append(f" - APIs: {format_list(registry_summary['apis'])}")
234
+ if registry_summary.get("components"):
235
+ parts.append(f" - Components: {format_list(registry_summary['components'])}")
236
+ if registry_summary.get("pages"):
237
+ parts.append(f" - Pages: {format_list(registry_summary['pages'])}")
238
+ if registry_summary.get("routes"):
239
+ parts.append(f" - Routes: {format_list(registry_summary['routes'])}")
240
+
241
+ # === Infrastructure Awareness ===
242
+ if registry_summary:
243
+ if registry_summary.get("services"):
244
+ parts.append("")
245
+ parts.append(f"**External Services:** {format_list(registry_summary['services'])}")
246
+ if registry_summary.get("webhooks"):
247
+ parts.append(f"**Webhooks:** {format_list(registry_summary['webhooks'])}")
248
+ if registry_summary.get("env_vars"):
249
+ parts.append(f"**Env Vars Tracked:** {len(registry_summary['env_vars'])} variables")
250
+
251
+ # === Deferred Features ===
252
+ deferred = state.get("deferred_features", [])
253
+ if deferred:
254
+ parts.append("")
255
+ parts.append(f"**Deferred (don't re-suggest):** {format_list(deferred, max_items=3)}")
256
+
257
+ # === Test Status ===
258
+ test_status = get_test_status(state)
259
+ if test_status:
260
+ parts.append("")
261
+ status_emoji = "GREEN" if test_status["status"] == "GREEN" else "RED"
262
+ parts.append(f"**Last Tests:** {status_emoji} ({test_status['passed']} passed, {test_status['failed']} failed)")
263
+
264
+ # === Brand Guide ===
265
+ brand_status = get_brand_guide_status()
266
+ if brand_status and brand_status.get("exists"):
267
+ parts.append("")
268
+ parts.append("**Brand Guide:** Active - use `.claude/BRAND_GUIDE.md` for styling")
269
+
270
+ # === Research Freshness ===
271
+ research_index = state.get("research_index", {})
272
+ if endpoint in research_index:
273
+ entry = research_index[endpoint]
274
+ days_old = entry.get("days_old", 0)
275
+ if days_old > 7:
276
+ parts.append("")
277
+ parts.append(f"**WARNING:** Research is {days_old} days old. Consider `/api-research`.")
278
+
279
+ # === Orchestrator Context ===
280
+ build_state = load_json_file(BUILD_STATE_FILE)
281
+ orchestrator = get_orchestrator_status(build_state)
282
+ if orchestrator:
283
+ parts.append("")
284
+ parts.append(f"**Orchestrated Build:** {orchestrator['build_id']}")
285
+ parts.append(f" - Progress: {orchestrator['progress']} workflows")
286
+ parts.append(f" - Active: [{orchestrator['active_type']}] {orchestrator['active_name']}")
287
+
288
+ # === Quick Reminders ===
289
+ parts.append("")
290
+ parts.append("**Remember:** Research-first | Questions FROM findings | Verify after green")
291
+
292
+ return "\n".join(parts)
32
293
 
33
294
 
34
295
  def main():
@@ -59,73 +320,15 @@ def main():
59
320
  should_reground = turn_count % REGROUND_INTERVAL == 0
60
321
 
61
322
  if should_reground and state.get("endpoint"):
62
- # Build re-grounding context
63
- context_parts = []
64
- context_parts.append(f"## Re-Grounding Reminder (Turn {turn_count})")
65
- context_parts.append("")
66
-
67
- endpoint = state.get("endpoint", "unknown")
68
- context_parts.append(f"**Active Endpoint:** {endpoint}")
69
-
70
- # Get current phase
71
- phases = state.get("phases", {})
72
- phase_order = [
73
- "disambiguation", "scope", "research_initial", "interview",
74
- "research_deep", "schema_creation", "environment_check",
75
- "tdd_red", "tdd_green", "verify", "tdd_refactor", "documentation"
76
- ]
77
-
78
- current_phase = None
79
- completed_phases = []
80
- for phase_name in phase_order:
81
- phase = phases.get(phase_name, {})
82
- status = phase.get("status", "not_started")
83
- if status == "complete":
84
- completed_phases.append(phase_name)
85
- elif status == "in_progress" and not current_phase:
86
- current_phase = phase_name
87
-
88
- if not current_phase:
89
- # Find first not_started phase
90
- for phase_name in phase_order:
91
- phase = phases.get(phase_name, {})
92
- if phase.get("status", "not_started") == "not_started":
93
- current_phase = phase_name
94
- break
95
-
96
- context_parts.append(f"**Current Phase:** {current_phase or 'documentation'}")
97
- context_parts.append(f"**Completed:** {', '.join(completed_phases) if completed_phases else 'None'}")
98
-
99
- # Key decisions summary
100
- interview = phases.get("interview", {})
101
- decisions = interview.get("decisions", {})
102
- if decisions:
103
- context_parts.append("")
104
- context_parts.append("**Key Decisions:**")
105
- for key, value in list(decisions.items())[:5]: # Limit to 5 key decisions
106
- response = value.get("value", value.get("response", "N/A"))
107
- if response:
108
- context_parts.append(f" - {key}: {str(response)[:50]}")
109
-
110
- # Research freshness warning
111
- research_index = state.get("research_index", {})
112
- if endpoint in research_index:
113
- entry = research_index[endpoint]
114
- days_old = entry.get("days_old", 0)
115
- if days_old > 7:
116
- context_parts.append("")
117
- context_parts.append(f"**WARNING:** Research is {days_old} days old. Consider re-researching.")
118
-
119
- # File reminders
120
- context_parts.append("")
121
- context_parts.append("**Key Files:** .claude/api-dev-state.json, .claude/research/")
323
+ # Build comprehensive re-grounding context
324
+ context = build_reground_context(state, turn_count)
122
325
 
123
326
  # Add to reground history
124
327
  reground_history = state.setdefault("reground_history", [])
125
328
  reground_history.append({
126
329
  "turn": turn_count,
127
330
  "timestamp": datetime.now().isoformat(),
128
- "phase": current_phase
331
+ "phase": state.get("phases", {}).get("current_phase", "unknown")
129
332
  })
130
333
  # Keep only last 10 reground events
131
334
  state["reground_history"] = reground_history[-10:]
@@ -138,7 +341,7 @@ def main():
138
341
  "continue": True,
139
342
  "hookSpecificOutput": {
140
343
  "hookEventName": "PostToolUse",
141
- "additionalContext": "\n".join(context_parts)
344
+ "additionalContext": context
142
345
  }
143
346
  }
144
347
  print(json.dumps(output))
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Project Document Prompt Hook
4
+
5
+ Prompts users for a project document (PRD, spec, deep research output) at the
6
+ start of /hustle-build. Stores the document in state for AI-powered decomposition.
7
+
8
+ Hook Type: PreToolUse (matcher: Skill)
9
+ Trigger: When /hustle-build is invoked
10
+ Version: 4.6.0
11
+
12
+ Flags:
13
+ --skip-document Skip the project document prompt
14
+ --from-document PATH Use specified file as project document
15
+ --no-document Alias for --skip-document
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from datetime import datetime
23
+
24
+ try:
25
+ from hook_utils import load_state, save_state, get_project_dir, log_workflow_event
26
+ UTILS_AVAILABLE = True
27
+ except ImportError:
28
+ UTILS_AVAILABLE = False
29
+
30
+
31
+ def get_project_dir_fallback():
32
+ """Get project directory from environment or current directory."""
33
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR", "."))
34
+
35
+
36
+ def load_hustle_build_state():
37
+ """Load hustle-build orchestration state."""
38
+ project_dir = get_project_dir_fallback()
39
+ state_file = project_dir / ".claude" / "hustle-build-state.json"
40
+ if state_file.exists():
41
+ try:
42
+ return json.loads(state_file.read_text())
43
+ except Exception:
44
+ pass
45
+ return None
46
+
47
+
48
+ def save_hustle_build_state(state):
49
+ """Save hustle-build orchestration state."""
50
+ project_dir = get_project_dir_fallback()
51
+ state_file = project_dir / ".claude" / "hustle-build-state.json"
52
+ state_file.parent.mkdir(parents=True, exist_ok=True)
53
+ state_file.write_text(json.dumps(state, indent=2))
54
+
55
+
56
+ def parse_flags(args):
57
+ """Parse command-line style flags from arguments string."""
58
+ flags = {
59
+ "skip_document": False,
60
+ "from_document": None,
61
+ }
62
+
63
+ if not args:
64
+ return flags
65
+
66
+ # Check for skip flags
67
+ if "--skip-document" in args or "--no-document" in args:
68
+ flags["skip_document"] = True
69
+
70
+ # Check for --from-document PATH
71
+ if "--from-document" in args:
72
+ # Extract path after --from-document
73
+ parts = args.split("--from-document")
74
+ if len(parts) > 1:
75
+ path_part = parts[1].strip().split()[0] if parts[1].strip() else None
76
+ if path_part and not path_part.startswith("--"):
77
+ flags["from_document"] = path_part
78
+
79
+ return flags
80
+
81
+
82
+ def read_document_file(file_path):
83
+ """Read a document file and detect its format."""
84
+ path = Path(file_path)
85
+
86
+ if not path.exists():
87
+ # Try relative to project dir
88
+ project_dir = get_project_dir_fallback()
89
+ path = project_dir / file_path
90
+
91
+ if not path.exists():
92
+ return None, None, f"File not found: {file_path}"
93
+
94
+ try:
95
+ content = path.read_text()
96
+
97
+ # Detect format
98
+ suffix = path.suffix.lower()
99
+ if suffix in [".md", ".markdown"]:
100
+ fmt = "markdown"
101
+ elif suffix == ".json":
102
+ fmt = "json"
103
+ elif suffix in [".txt", ".text"]:
104
+ fmt = "text"
105
+ else:
106
+ # Guess based on content
107
+ if content.strip().startswith("{") or content.strip().startswith("["):
108
+ fmt = "json"
109
+ elif content.startswith("#") or "##" in content[:500]:
110
+ fmt = "markdown"
111
+ else:
112
+ fmt = "text"
113
+
114
+ return content, fmt, None
115
+ except Exception as e:
116
+ return None, None, f"Error reading file: {e}"
117
+
118
+
119
+ def main():
120
+ # Read tool input from stdin or environment
121
+ tool_input_raw = os.environ.get("CLAUDE_TOOL_INPUT", "")
122
+
123
+ # Also check stdin for hook input
124
+ try:
125
+ if not sys.stdin.isatty():
126
+ stdin_data = sys.stdin.read()
127
+ if stdin_data:
128
+ try:
129
+ hook_input = json.loads(stdin_data)
130
+ tool_input_raw = json.dumps(hook_input.get("tool_input", {}))
131
+ except json.JSONDecodeError:
132
+ pass
133
+ except Exception:
134
+ pass
135
+
136
+ try:
137
+ data = json.loads(tool_input_raw) if tool_input_raw else {}
138
+ skill_name = data.get("skill", "")
139
+ args = data.get("args", "")
140
+ except Exception:
141
+ # Not a skill invocation or invalid JSON
142
+ print(json.dumps({"continue": True}))
143
+ return
144
+
145
+ # Only trigger for hustle-build skill
146
+ if skill_name != "hustle-build":
147
+ print(json.dumps({"continue": True}))
148
+ return
149
+
150
+ # Parse flags from arguments
151
+ flags = parse_flags(args)
152
+
153
+ # Check for skip flag
154
+ if flags["skip_document"]:
155
+ print(json.dumps({"continue": True}))
156
+ return
157
+
158
+ # Check if project_spec already exists with content
159
+ state = load_hustle_build_state()
160
+ if state and state.get("project_spec", {}).get("raw_content"):
161
+ print(json.dumps({"continue": True}))
162
+ return
163
+
164
+ # Handle --from-document flag
165
+ if flags["from_document"]:
166
+ content, fmt, error = read_document_file(flags["from_document"])
167
+
168
+ if error:
169
+ # Inject error message
170
+ result = {
171
+ "continue": True,
172
+ "additionalContext": f"""
173
+ ## Project Document Error
174
+
175
+ Could not load document: {error}
176
+
177
+ Please provide the document path again or use `--skip-document` to proceed without a document.
178
+ """
179
+ }
180
+ print(json.dumps(result))
181
+ return
182
+
183
+ # Initialize or update state with document
184
+ if not state:
185
+ state = {
186
+ "version": "4.6.0",
187
+ "build_id": f"build-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
188
+ "status": "initializing"
189
+ }
190
+
191
+ state["project_spec"] = {
192
+ "source": "file",
193
+ "file_path": flags["from_document"],
194
+ "raw_content": content,
195
+ "format": fmt,
196
+ "loaded_at": datetime.now().isoformat(),
197
+ "word_count": len(content.split()),
198
+ "extracted": None, # Will be filled by Phase 0.5
199
+ "user_modifications": {
200
+ "added": [],
201
+ "removed": [],
202
+ "modified": []
203
+ }
204
+ }
205
+
206
+ save_hustle_build_state(state)
207
+
208
+ # Log the event
209
+ if UTILS_AVAILABLE:
210
+ log_workflow_event("project_document_loaded", {
211
+ "source": "file",
212
+ "file_path": flags["from_document"],
213
+ "format": fmt,
214
+ "word_count": len(content.split())
215
+ })
216
+
217
+ # Inject confirmation
218
+ result = {
219
+ "continue": True,
220
+ "additionalContext": f"""
221
+ ## Project Document Loaded
222
+
223
+ Successfully loaded project document:
224
+ - **Source:** `{flags["from_document"]}`
225
+ - **Format:** {fmt}
226
+ - **Size:** {len(content.split())} words
227
+
228
+ The document will be analyzed in Phase 0.5 to extract:
229
+ - Pages/routes
230
+ - Components
231
+ - APIs
232
+ - Data models
233
+ - External integrations
234
+
235
+ Proceeding to parse your build request...
236
+ """
237
+ }
238
+ print(json.dumps(result))
239
+ return
240
+
241
+ # No document provided - inject prompt asking for one
242
+ context = """
243
+ ## Project Document Intake
244
+
245
+ Before decomposing this build request, I need to check if you have a comprehensive project document.
246
+
247
+ **Do you have a project document (PRD, spec, deep research output)?**
248
+
249
+ A project document helps me:
250
+ - Identify ALL pages, components, and APIs upfront
251
+ - Build accurate dependency graphs
252
+ - Reference the spec throughout each sub-workflow
253
+ - Ensure nothing is missed
254
+
255
+ ### How to Provide a Document
256
+
257
+ **Option 1: File Path**
258
+ ```
259
+ I have a document at ./docs/my-prd.md
260
+ ```
261
+
262
+ **Option 2: Paste Content**
263
+ Just paste the document content directly in your next message.
264
+
265
+ **Option 3: URL**
266
+ ```
267
+ Fetch the document from https://example.com/my-spec.md
268
+ ```
269
+
270
+ **Option 4: No Document**
271
+ ```
272
+ No document, proceed with parsing my description
273
+ ```
274
+
275
+ ### Supported Formats
276
+ - Markdown (`.md`) - PRDs, specs, research outputs
277
+ - Plain text (`.txt`) - Notes, outlines
278
+ - JSON (`.json`) - Structured specs, API definitions
279
+
280
+ ---
281
+
282
+ _To skip this prompt in the future, use:_
283
+ ```
284
+ /hustle-build --skip-document [description]
285
+ ```
286
+
287
+ _Or provide a document directly:_
288
+ ```
289
+ /hustle-build --from-document ./docs/spec.md [description]
290
+ ```
291
+ """
292
+
293
+ result = {
294
+ "continue": True,
295
+ "additionalContext": context
296
+ }
297
+
298
+ print(json.dumps(result))
299
+
300
+
301
+ if __name__ == "__main__":
302
+ main()