@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
@@ -1,31 +1,35 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Phase 11: AI Code Review Hook
3
+ Phase 11: AI Code Review Hook (Ralph Wiggum Loop Pattern)
4
4
 
5
- Triggers Greptile AI code review after Phase 10 (Verify) and BEFORE Phase 12 (Refactor).
6
- This ensures issues are caught early and can be fixed during the refactor phase,
7
- rather than after PR creation when it's too late.
5
+ Triggers Greptile AI code review and LOOPS until all issues are fixed.
6
+ This ensures code quality before proceeding to refactor phase.
8
7
 
9
- Hook Type: PostToolUse (triggers after tests pass in Phase 9/10)
8
+ Hook Type: PostToolUse (triggers after tests pass)
10
9
 
11
- Greptile API:
12
- POST https://api.greptile.com/v2/query
13
- - Analyzes code changes against entire codebase context
14
- - Returns issues with file:line references
15
- - Provides actionable fix suggestions
10
+ Ralph Wiggum Pattern:
11
+ 1. Run Greptile review
12
+ 2. If issues found inject context for agent to fix
13
+ 3. Agent fixes issues
14
+ 4. Tests re-run hook triggers again
15
+ 5. Re-review with Greptile
16
+ 6. Loop until clean OR max iterations
17
+ 7. Emit <promise>REVIEW_CLEAN</promise>
16
18
 
17
19
  Environment Variables:
18
20
  GREPTILE_API_KEY: Your Greptile API key (get from https://app.greptile.com)
19
21
  GITHUB_TOKEN: GitHub Personal Access Token with repo access
20
22
  CODE_REVIEW_ENABLED: Set to 'true' to enable (default: true)
23
+ CODE_REVIEW_MAX_ITERATIONS: Max review cycles (default: 5)
21
24
 
22
- Version: 1.1.0
25
+ Version: 2.0.0
23
26
  """
24
27
  import os
25
28
  import sys
26
29
  import json
27
30
  import subprocess
28
31
  from pathlib import Path
32
+ from datetime import datetime
29
33
 
30
34
  # Add lib directory to path for imports
31
35
  HOOK_DIR = Path(__file__).parent
@@ -44,6 +48,10 @@ try:
44
48
  except ImportError:
45
49
  GREPTILE_AVAILABLE = False
46
50
 
51
+ # State file for tracking review loops
52
+ REVIEW_STATE_FILE = ".claude/code-review-state.json"
53
+ MAX_ITERATIONS = int(os.environ.get("CODE_REVIEW_MAX_ITERATIONS", "5"))
54
+
47
55
 
48
56
  def get_git_diff() -> tuple:
49
57
  """Get the current git diff and changed files."""
@@ -98,7 +106,44 @@ def get_repo_info() -> tuple:
98
106
  return None, None
99
107
 
100
108
 
101
- def load_state() -> dict:
109
+ def load_review_state() -> dict:
110
+ """Load code review loop state."""
111
+ state_file = Path.cwd() / REVIEW_STATE_FILE
112
+ if state_file.exists():
113
+ try:
114
+ return json.loads(state_file.read_text())
115
+ except (json.JSONDecodeError, IOError):
116
+ pass
117
+ return {
118
+ "iteration": 0,
119
+ "issues_found": [],
120
+ "status": "pending",
121
+ "started_at": None,
122
+ "last_review_at": None
123
+ }
124
+
125
+
126
+ def save_review_state(state: dict):
127
+ """Save code review loop state."""
128
+ state_file = Path.cwd() / REVIEW_STATE_FILE
129
+ state_file.parent.mkdir(parents=True, exist_ok=True)
130
+ try:
131
+ state_file.write_text(json.dumps(state, indent=2))
132
+ except IOError:
133
+ pass
134
+
135
+
136
+ def clear_review_state():
137
+ """Clear review state after successful completion."""
138
+ state_file = Path.cwd() / REVIEW_STATE_FILE
139
+ if state_file.exists():
140
+ try:
141
+ state_file.unlink()
142
+ except IOError:
143
+ pass
144
+
145
+
146
+ def load_workflow_state() -> dict:
102
147
  """Load current workflow state."""
103
148
  state_file = Path.cwd() / ".claude" / "api-dev-state.json"
104
149
  if state_file.exists():
@@ -109,21 +154,22 @@ def load_state() -> dict:
109
154
  return {}
110
155
 
111
156
 
112
- def update_state_with_review(review_summary: dict):
113
- """Update state file with code review results."""
157
+ def update_workflow_state_with_review(review_summary: dict, iteration: int):
158
+ """Update workflow state file with code review results."""
114
159
  state_file = Path.cwd() / ".claude" / "api-dev-state.json"
115
- state = load_state()
160
+ state = load_workflow_state()
116
161
 
117
162
  # Add or update code_review phase
118
163
  if "phases" not in state:
119
164
  state["phases"] = {}
120
165
 
121
166
  state["phases"]["code_review"] = {
122
- "status": "complete",
167
+ "status": "in_progress" if review_summary.get("issue_count", 0) > 0 else "complete",
168
+ "iteration": iteration,
123
169
  "score": review_summary.get("score", 0),
124
170
  "issues_found": review_summary.get("issue_count", 0),
125
171
  "suggestions": review_summary.get("suggestion_count", 0),
126
- "reviewed_at": __import__("datetime").datetime.now().isoformat()
172
+ "reviewed_at": datetime.now().isoformat()
127
173
  }
128
174
 
129
175
  try:
@@ -140,7 +186,7 @@ def should_run_review(hook_input: dict) -> bool:
140
186
 
141
187
  tool_name = hook_input.get("tool_name", "")
142
188
 
143
- # Run after tests pass (Phase 9/10) - triggers before refactoring
189
+ # Run after tests pass (Phase 9/10)
144
190
  if tool_name == "Bash":
145
191
  tool_input = hook_input.get("tool_input", {})
146
192
  command = tool_input.get("command", "")
@@ -160,8 +206,40 @@ def should_run_review(hook_input: dict) -> bool:
160
206
  return False
161
207
 
162
208
 
209
+ def format_issues_for_context(issues: list) -> str:
210
+ """Format issues as context for the agent to fix."""
211
+ if not issues:
212
+ return ""
213
+
214
+ lines = [
215
+ "",
216
+ "=" * 60,
217
+ "CODE REVIEW ISSUES TO FIX (Ralph Wiggum Loop)",
218
+ "=" * 60,
219
+ "",
220
+ "The following issues were found by Greptile code review.",
221
+ "Please fix ALL issues, then run tests again.",
222
+ "The review will re-run automatically after tests pass.",
223
+ "",
224
+ "ISSUES:",
225
+ ]
226
+
227
+ for i, issue in enumerate(issues, 1):
228
+ lines.append(f" {i}. {issue}")
229
+
230
+ lines.extend([
231
+ "",
232
+ "After fixing all issues, run: pnpm test",
233
+ "Review will loop until all issues are resolved.",
234
+ "=" * 60,
235
+ ""
236
+ ])
237
+
238
+ return "\n".join(lines)
239
+
240
+
163
241
  def main():
164
- """Main hook entry point."""
242
+ """Main hook entry point with Ralph Wiggum loop pattern."""
165
243
  # Read hook input
166
244
  try:
167
245
  hook_input = json.loads(sys.stdin.read())
@@ -190,6 +268,28 @@ def main():
190
268
  }))
191
269
  return
192
270
 
271
+ # Load current review loop state
272
+ review_state = load_review_state()
273
+
274
+ # Increment iteration
275
+ review_state["iteration"] += 1
276
+ iteration = review_state["iteration"]
277
+
278
+ # Check max iterations
279
+ if iteration > MAX_ITERATIONS:
280
+ print(json.dumps({
281
+ "continue": True,
282
+ "message": f"Code review max iterations ({MAX_ITERATIONS}) reached. Proceeding with warnings.\n\n<promise>REVIEW_CLEAN</promise>"
283
+ }))
284
+ clear_review_state()
285
+ return
286
+
287
+ # Track start time on first iteration
288
+ if iteration == 1:
289
+ review_state["started_at"] = datetime.now().isoformat()
290
+
291
+ review_state["last_review_at"] = datetime.now().isoformat()
292
+
193
293
  # Get repository info
194
294
  repo_owner, repo_name = get_repo_info()
195
295
  if not repo_owner or not repo_name:
@@ -204,11 +304,12 @@ def main():
204
304
  if not diff_content:
205
305
  print(json.dumps({
206
306
  "continue": True,
207
- "message": "No changes detected - skipping code review"
307
+ "message": "No changes detected - code review complete.\n\n<promise>REVIEW_CLEAN</promise>"
208
308
  }))
309
+ clear_review_state()
209
310
  return
210
311
 
211
- # Run the review
312
+ # Run the Greptile review
212
313
  result = review_changes(
213
314
  repo_owner=repo_owner,
214
315
  repo_name=repo_name,
@@ -223,22 +324,68 @@ def main():
223
324
  }))
224
325
  return
225
326
 
226
- # Parse and display results
327
+ # Parse results
227
328
  summary = get_review_summary(result)
228
- update_state_with_review(summary)
329
+ update_workflow_state_with_review(summary, iteration)
229
330
 
230
331
  # Format for display
231
332
  display_output = format_review_for_display(summary)
232
333
 
233
- # Determine if we should block based on critical issues
234
- has_critical = summary.get("score", 10) < 5
334
+ # Check if issues found
335
+ issue_count = summary.get("issue_count", 0)
336
+ issues = summary.get("issues", [])
337
+
338
+ if issue_count == 0:
339
+ # All clean! Emit promise and proceed
340
+ review_state["status"] = "complete"
341
+ save_review_state(review_state)
342
+
343
+ output = f"""
344
+ {display_output}
345
+
346
+ ================================================================================
347
+ REVIEW LOOP COMPLETE (Iteration {iteration}/{MAX_ITERATIONS})
348
+ ================================================================================
349
+ All code review checks passed!
350
+ Proceeding to next phase.
351
+
352
+ <promise>REVIEW_CLEAN</promise>
353
+ """
354
+ print(json.dumps({
355
+ "continue": True,
356
+ "message": output
357
+ }))
358
+ clear_review_state()
359
+ return
360
+
361
+ # Issues found - save state and block for fixes
362
+ review_state["status"] = "needs_fixing"
363
+ review_state["issues_found"] = issues
364
+ save_review_state(review_state)
365
+
366
+ # Format issues as context for agent
367
+ issues_context = format_issues_for_context(issues)
368
+
369
+ output = f"""
370
+ {display_output}
371
+
372
+ ================================================================================
373
+ REVIEW LOOP - ITERATION {iteration}/{MAX_ITERATIONS}
374
+ ================================================================================
375
+ {issue_count} issue(s) found. Fix them and run tests again.
376
+ Review will re-run automatically after tests pass.
377
+ {issues_context}
378
+ """
235
379
 
380
+ # Block workflow - agent needs to fix issues
236
381
  print(json.dumps({
237
- "continue": not has_critical,
238
- "message": display_output,
382
+ "continue": False, # Block until fixed
383
+ "message": output,
239
384
  "review_score": summary.get("score", 0),
240
- "issues_count": summary.get("issue_count", 0),
241
- "action_required": has_critical
385
+ "issues_count": issue_count,
386
+ "iteration": iteration,
387
+ "action_required": True,
388
+ "next_action": "Fix the issues above, then run tests again"
242
389
  }))
243
390
 
244
391
 
@@ -0,0 +1,338 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Visual QA Hook (Ralph Wiggum Loop Pattern)
4
+
5
+ Runs visual analysis with AI (Haiku) and LOOPS until all issues are fixed.
6
+ This ensures visual quality before proceeding to next phase.
7
+
8
+ Hook Type: PostToolUse (triggers after Storybook/visual tests)
9
+
10
+ Ralph Wiggum Pattern:
11
+ 1. Run visual tests / capture screenshots
12
+ 2. Analyze with AI (Haiku subagent)
13
+ 3. If issues found → inject context for agent to fix
14
+ 4. Agent fixes CSS/layout
15
+ 5. Re-run visual tests → hook triggers again
16
+ 6. Re-analyze with Haiku
17
+ 7. Loop until clean OR max iterations
18
+ 8. Emit <promise>VISUAL_CLEAN</promise>
19
+
20
+ Environment Variables:
21
+ VISUAL_QA_ENABLED: Set to 'true' to enable (default: true)
22
+ VISUAL_QA_MAX_ITERATIONS: Max QA cycles (default: 5)
23
+
24
+ Version: 1.0.0
25
+ """
26
+ import os
27
+ import sys
28
+ import json
29
+ from pathlib import Path
30
+ from datetime import datetime
31
+
32
+ # State file for tracking visual QA loops
33
+ VISUAL_STATE_FILE = ".claude/visual-qa-state.json"
34
+ MAX_ITERATIONS = int(os.environ.get("VISUAL_QA_MAX_ITERATIONS", "5"))
35
+
36
+
37
+ def load_visual_state() -> dict:
38
+ """Load visual QA loop state."""
39
+ state_file = Path.cwd() / VISUAL_STATE_FILE
40
+ if state_file.exists():
41
+ try:
42
+ return json.loads(state_file.read_text())
43
+ except (json.JSONDecodeError, IOError):
44
+ pass
45
+ return {
46
+ "iteration": 0,
47
+ "issues_found": [],
48
+ "components_checked": [],
49
+ "viewports_passed": [],
50
+ "status": "pending",
51
+ "started_at": None,
52
+ "last_check_at": None
53
+ }
54
+
55
+
56
+ def save_visual_state(state: dict):
57
+ """Save visual QA loop state."""
58
+ state_file = Path.cwd() / VISUAL_STATE_FILE
59
+ state_file.parent.mkdir(parents=True, exist_ok=True)
60
+ try:
61
+ state_file.write_text(json.dumps(state, indent=2))
62
+ except IOError:
63
+ pass
64
+
65
+
66
+ def clear_visual_state():
67
+ """Clear visual state after successful completion."""
68
+ state_file = Path.cwd() / VISUAL_STATE_FILE
69
+ if state_file.exists():
70
+ try:
71
+ state_file.unlink()
72
+ except IOError:
73
+ pass
74
+
75
+
76
+ def load_workflow_state() -> dict:
77
+ """Load current workflow state."""
78
+ state_file = Path.cwd() / ".claude" / "api-dev-state.json"
79
+ if state_file.exists():
80
+ try:
81
+ return json.loads(state_file.read_text())
82
+ except (json.JSONDecodeError, IOError):
83
+ pass
84
+
85
+ # Also check hustle-build state
86
+ hustle_state = Path.cwd() / ".claude" / "hustle-build-state.json"
87
+ if hustle_state.exists():
88
+ try:
89
+ return json.loads(hustle_state.read_text())
90
+ except (json.JSONDecodeError, IOError):
91
+ pass
92
+
93
+ return {}
94
+
95
+
96
+ def update_workflow_state(issues_count: int, iteration: int):
97
+ """Update workflow state with visual QA results."""
98
+ state_file = Path.cwd() / ".claude" / "api-dev-state.json"
99
+ state = load_workflow_state()
100
+
101
+ if "phases" not in state:
102
+ state["phases"] = {}
103
+
104
+ state["phases"]["visual_qa"] = {
105
+ "status": "in_progress" if issues_count > 0 else "complete",
106
+ "iteration": iteration,
107
+ "issues_found": issues_count,
108
+ "checked_at": datetime.now().isoformat()
109
+ }
110
+
111
+ try:
112
+ state_file.write_text(json.dumps(state, indent=2))
113
+ except IOError:
114
+ pass
115
+
116
+
117
+ def should_run_visual_qa(hook_input: dict) -> bool:
118
+ """Determine if visual QA should run based on hook context."""
119
+ # Check if visual QA is enabled
120
+ if os.environ.get("VISUAL_QA_ENABLED", "true").lower() == "false":
121
+ return False
122
+
123
+ tool_name = hook_input.get("tool_name", "")
124
+ tool_input = hook_input.get("tool_input", {})
125
+
126
+ # Run after Storybook tests
127
+ if tool_name == "Bash":
128
+ command = tool_input.get("command", "")
129
+ tool_result = hook_input.get("tool_result", {})
130
+ stdout = tool_result.get("stdout", "")
131
+
132
+ # Check if visual/storybook tests ran
133
+ visual_triggers = [
134
+ "storybook",
135
+ "test-storybook",
136
+ "chromatic",
137
+ "playwright test --project=visual",
138
+ "visual",
139
+ "screenshot"
140
+ ]
141
+
142
+ if any(trigger in command.lower() for trigger in visual_triggers):
143
+ return True
144
+
145
+ # Run after Task with visual-analyzer
146
+ if tool_name == "Task":
147
+ subagent_type = tool_input.get("subagent_type", "")
148
+ if subagent_type == "visual-analyzer":
149
+ return True
150
+
151
+ return False
152
+
153
+
154
+ def parse_visual_issues(hook_input: dict) -> list:
155
+ """Parse visual issues from tool output."""
156
+ issues = []
157
+
158
+ tool_result = hook_input.get("tool_result", {})
159
+ stdout = tool_result.get("stdout", "")
160
+ message = tool_result.get("message", "")
161
+
162
+ output = stdout + "\n" + message
163
+
164
+ # Look for common issue patterns
165
+ issue_keywords = [
166
+ "touch target",
167
+ "contrast",
168
+ "overflow",
169
+ "clipping",
170
+ "alignment",
171
+ "spacing",
172
+ "typography",
173
+ "safe area",
174
+ "layout issue",
175
+ "responsive",
176
+ "accessibility",
177
+ "wcag"
178
+ ]
179
+
180
+ lines = output.split("\n")
181
+ for line in lines:
182
+ line_lower = line.lower()
183
+ if any(keyword in line_lower for keyword in issue_keywords):
184
+ if "issue" in line_lower or "warning" in line_lower or "error" in line_lower or "fail" in line_lower:
185
+ issues.append(line.strip())
186
+
187
+ # Also look for severity markers
188
+ for line in lines:
189
+ if "⚠️" in line or "❌" in line or "warning" in line.lower():
190
+ if line.strip() and line.strip() not in issues:
191
+ issues.append(line.strip())
192
+
193
+ return issues[:10] # Limit to 10 issues
194
+
195
+
196
+ def format_issues_for_context(issues: list, iteration: int) -> str:
197
+ """Format issues as context for the agent to fix."""
198
+ if not issues:
199
+ return ""
200
+
201
+ lines = [
202
+ "",
203
+ "=" * 60,
204
+ "VISUAL QA ISSUES TO FIX (Ralph Wiggum Loop)",
205
+ "=" * 60,
206
+ "",
207
+ f"Iteration {iteration}/{MAX_ITERATIONS}",
208
+ "",
209
+ "The following visual issues were found by AI analysis.",
210
+ "Please fix ALL issues, then re-run visual tests.",
211
+ "The QA will re-run automatically.",
212
+ "",
213
+ "ISSUES:",
214
+ ]
215
+
216
+ for i, issue in enumerate(issues, 1):
217
+ lines.append(f" {i}. {issue}")
218
+
219
+ lines.extend([
220
+ "",
221
+ "Common fixes:",
222
+ " - Touch targets: Add min-h-[44px] min-w-[44px]",
223
+ " - Contrast: Check text color against background",
224
+ " - Safe areas: Use safe-area-inset-* CSS",
225
+ " - Overflow: Add overflow-hidden or adjust sizing",
226
+ "",
227
+ "After fixing, run: /test-visual",
228
+ "=" * 60,
229
+ ""
230
+ ])
231
+
232
+ return "\n".join(lines)
233
+
234
+
235
+ def main():
236
+ """Main hook entry point with Ralph Wiggum loop pattern."""
237
+ # Read hook input
238
+ try:
239
+ hook_input = json.loads(sys.stdin.read())
240
+ except json.JSONDecodeError:
241
+ hook_input = {}
242
+
243
+ # Check if we should run
244
+ if not should_run_visual_qa(hook_input):
245
+ print(json.dumps({"continue": True}))
246
+ return
247
+
248
+ # Load current visual QA state
249
+ visual_state = load_visual_state()
250
+
251
+ # Increment iteration
252
+ visual_state["iteration"] += 1
253
+ iteration = visual_state["iteration"]
254
+
255
+ # Check max iterations
256
+ if iteration > MAX_ITERATIONS:
257
+ output = f"""
258
+ ================================================================================
259
+ VISUAL QA - MAX ITERATIONS REACHED ({MAX_ITERATIONS})
260
+ ================================================================================
261
+ Proceeding with remaining warnings. Consider reviewing manually.
262
+
263
+ <promise>VISUAL_CLEAN</promise>
264
+ """
265
+ print(json.dumps({
266
+ "continue": True,
267
+ "message": output
268
+ }))
269
+ clear_visual_state()
270
+ return
271
+
272
+ # Track timing
273
+ if iteration == 1:
274
+ visual_state["started_at"] = datetime.now().isoformat()
275
+ visual_state["last_check_at"] = datetime.now().isoformat()
276
+
277
+ # Parse issues from the visual test output
278
+ issues = parse_visual_issues(hook_input)
279
+ issue_count = len(issues)
280
+
281
+ update_workflow_state(issue_count, iteration)
282
+
283
+ if issue_count == 0:
284
+ # All clean! Emit promise and proceed
285
+ visual_state["status"] = "complete"
286
+ save_visual_state(visual_state)
287
+
288
+ output = f"""
289
+ ================================================================================
290
+ VISUAL QA LOOP COMPLETE (Iteration {iteration}/{MAX_ITERATIONS})
291
+ ================================================================================
292
+ All visual checks passed!
293
+ - Layout: ✅
294
+ - Typography: ✅
295
+ - Touch Targets: ✅
296
+ - Safe Areas: ✅
297
+ - Brand Consistency: ✅
298
+
299
+ Proceeding to next phase.
300
+
301
+ <promise>VISUAL_CLEAN</promise>
302
+ """
303
+ print(json.dumps({
304
+ "continue": True,
305
+ "message": output
306
+ }))
307
+ clear_visual_state()
308
+ return
309
+
310
+ # Issues found - save state and provide context for fixes
311
+ visual_state["status"] = "needs_fixing"
312
+ visual_state["issues_found"] = issues
313
+ save_visual_state(visual_state)
314
+
315
+ # Format issues as context
316
+ issues_context = format_issues_for_context(issues, iteration)
317
+
318
+ output = f"""
319
+ ================================================================================
320
+ VISUAL QA LOOP - ITERATION {iteration}/{MAX_ITERATIONS}
321
+ ================================================================================
322
+ {issue_count} visual issue(s) found. Fix them and re-run visual tests.
323
+ {issues_context}
324
+ """
325
+
326
+ # Block workflow - agent needs to fix issues
327
+ print(json.dumps({
328
+ "continue": False, # Block until fixed
329
+ "message": output,
330
+ "issues_count": issue_count,
331
+ "iteration": iteration,
332
+ "action_required": True,
333
+ "next_action": "Fix the visual issues above, then run /test-visual"
334
+ }))
335
+
336
+
337
+ if __name__ == "__main__":
338
+ main()
@@ -23,6 +23,13 @@ from datetime import datetime
23
23
  from pathlib import Path
24
24
  import shutil
25
25
 
26
+ # Import shared utilities for NTFY
27
+ try:
28
+ from hook_utils import send_ntfy_notification
29
+ HAS_NTFY = True
30
+ except ImportError:
31
+ HAS_NTFY = False
32
+
26
33
  STATE_FILE = Path(__file__).parent.parent / "api-dev-state.json"
27
34
  SESSIONS_DIR = Path(__file__).parent.parent / "api-sessions"
28
35
  RESEARCH_DIR = Path(__file__).parent.parent / "research"
@@ -270,12 +277,31 @@ def main():
270
277
  try:
271
278
  session_dir = save_session(endpoint, endpoint_data, state)
272
279
 
280
+ # Send NTFY notification on session end
281
+ if HAS_NTFY:
282
+ status = endpoint_data.get("status", "unknown")
283
+ if status == "complete":
284
+ send_ntfy_notification(
285
+ title=f"✅ Session Complete: {endpoint}",
286
+ message=f"Completed {len(completed)}/13 phases. Session saved.",
287
+ priority="default",
288
+ tags=["white_check_mark", "robot"]
289
+ )
290
+ else:
291
+ send_ntfy_notification(
292
+ title=f"📋 Session Ended: {endpoint}",
293
+ message=f"Completed {len(completed)}/13 phases. Status: {status}",
294
+ priority="low",
295
+ tags=["clipboard", "robot"]
296
+ )
297
+
273
298
  output = {
274
299
  "hookSpecificOutput": {
275
300
  "sessionSaved": True,
276
301
  "endpoint": endpoint,
277
302
  "sessionDir": str(session_dir),
278
- "phasesCompleted": len(completed)
303
+ "phasesCompleted": len(completed),
304
+ "notificationSent": HAS_NTFY
279
305
  }
280
306
  }
281
307