@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.
- package/.claude/adr-requests/.gitkeep +10 -0
- package/.claude/agents/adr-researcher.md +109 -0
- package/.claude/agents/visual-analyzer.md +183 -0
- package/.claude/api-dev-state.json +7 -463
- package/.claude/documentation-audit.json +114 -0
- package/.claude/registry.json +289 -0
- package/.claude/settings.json +45 -1
- package/.claude/workflow-logs/None.json +49 -0
- package/.claude/workflow-logs/session-20251230-143727.json +106 -0
- package/.skills/adr-deep-research/SKILL.md +351 -0
- package/.skills/api-create/SKILL.md +116 -17
- package/.skills/api-research/SKILL.md +130 -0
- package/.skills/docs-sync/SKILL.md +260 -0
- package/.skills/docs-update/SKILL.md +205 -0
- package/.skills/hustle-brand/SKILL.md +368 -0
- package/.skills/hustle-build/SKILL.md +786 -0
- package/.skills/hustle-build-review/SKILL.md +518 -0
- package/.skills/parallel-spawn/SKILL.md +212 -0
- package/.skills/ralph-continue/SKILL.md +151 -0
- package/.skills/ralph-loop/SKILL.md +341 -0
- package/.skills/ralph-status/SKILL.md +87 -0
- package/.skills/refactor/SKILL.md +59 -0
- package/.skills/shadcn/SKILL.md +522 -0
- package/.skills/test-all/SKILL.md +210 -0
- package/.skills/test-builds/SKILL.md +208 -0
- package/.skills/test-debug/SKILL.md +212 -0
- package/.skills/test-e2e/SKILL.md +168 -0
- package/.skills/test-review/SKILL.md +707 -0
- package/.skills/test-unit/SKILL.md +143 -0
- package/.skills/test-visual/SKILL.md +301 -0
- package/.skills/token-report/SKILL.md +132 -0
- package/CHANGELOG.md +575 -0
- package/README.md +426 -56
- package/bin/cli.js +1538 -88
- package/commands/hustle-api-create.md +22 -0
- package/commands/hustle-build.md +259 -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/__pycache__/api-workflow-check.cpython-314.pyc +0 -0
- package/hooks/__pycache__/auto-answer.cpython-314.pyc +0 -0
- package/hooks/__pycache__/cache-research.cpython-314.pyc +0 -0
- package/hooks/__pycache__/check-api-routes.cpython-314.pyc +0 -0
- package/hooks/__pycache__/check-playwright-setup.cpython-314.pyc +0 -0
- package/hooks/__pycache__/check-storybook-setup.cpython-314.pyc +0 -0
- package/hooks/__pycache__/check-update.cpython-314.pyc +0 -0
- package/hooks/__pycache__/completion-promise-detector.cpython-314.pyc +0 -0
- package/hooks/__pycache__/context-capacity-warning.cpython-314.pyc +0 -0
- package/hooks/__pycache__/detect-interruption.cpython-314.pyc +0 -0
- package/hooks/__pycache__/docs-update-check.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-a11y-audit.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-brand-guide.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-component-type-confirm.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-deep-research.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-disambiguation.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-documentation.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-dry-run.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-environment.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-external-research.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-freshness.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-interview.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-page-components.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-page-data-schema.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-questions-sourced.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-refactor.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-research.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-schema-from-interview.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-schema.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-scope.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-tdd-red.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-ui-disambiguation.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-ui-interview.cpython-314.pyc +0 -0
- package/hooks/__pycache__/enforce-verify.cpython-314.pyc +0 -0
- package/hooks/__pycache__/generate-adr-options.cpython-314.pyc +0 -0
- package/hooks/__pycache__/generate-manifest-entry.cpython-314.pyc +0 -0
- package/hooks/__pycache__/hook_utils.cpython-314.pyc +0 -0
- package/hooks/__pycache__/notify-input-needed.cpython-314.pyc +0 -0
- package/hooks/__pycache__/notify-phase-complete.cpython-314.pyc +0 -0
- package/hooks/__pycache__/ntfy-on-question.cpython-314.pyc +0 -0
- package/hooks/__pycache__/orchestrator-completion.cpython-314.pyc +0 -0
- package/hooks/__pycache__/orchestrator-handoff.cpython-314.pyc +0 -0
- package/hooks/__pycache__/orchestrator-session-startup.cpython-314.pyc +0 -0
- package/hooks/__pycache__/parallel-orchestrator.cpython-314.pyc +0 -0
- package/hooks/__pycache__/periodic-reground.cpython-314.pyc +0 -0
- package/hooks/__pycache__/project-document-prompt.cpython-314.pyc +0 -0
- package/hooks/__pycache__/remote-question-proxy.cpython-314.pyc +0 -0
- package/hooks/__pycache__/remote-question-server.cpython-314.pyc +0 -0
- package/hooks/__pycache__/run-code-review.cpython-314.pyc +0 -0
- package/hooks/__pycache__/run-visual-qa.cpython-314.pyc +0 -0
- package/hooks/__pycache__/session-logger.cpython-314.pyc +0 -0
- package/hooks/__pycache__/session-startup.cpython-314.pyc +0 -0
- package/hooks/__pycache__/track-scope-coverage.cpython-314.pyc +0 -0
- package/hooks/__pycache__/track-token-usage.cpython-314.pyc +0 -0
- package/hooks/__pycache__/track-tool-use.cpython-314.pyc +0 -0
- package/hooks/__pycache__/update-adr-decision.cpython-314.pyc +0 -0
- package/hooks/__pycache__/update-api-showcase.cpython-314.pyc +0 -0
- package/hooks/__pycache__/update-registry.cpython-314.pyc +0 -0
- package/hooks/__pycache__/update-ui-showcase.cpython-314.pyc +0 -0
- package/hooks/__pycache__/verify-after-green.cpython-314.pyc +0 -0
- package/hooks/__pycache__/verify-implementation.cpython-314.pyc +0 -0
- package/hooks/api-workflow-check.py +34 -0
- package/hooks/auto-answer.py +305 -0
- package/hooks/check-update.py +132 -0
- package/hooks/completion-promise-detector.py +293 -0
- package/hooks/context-capacity-warning.py +171 -0
- package/hooks/docs-update-check.py +120 -0
- package/hooks/enforce-dry-run.py +134 -0
- package/hooks/enforce-external-research.py +25 -0
- package/hooks/enforce-interview.py +20 -0
- package/hooks/generate-adr-options.py +282 -0
- package/hooks/hook_utils.py +609 -0
- package/hooks/lib/__pycache__/__init__.cpython-314.pyc +0 -0
- package/hooks/lib/__pycache__/greptile.cpython-314.pyc +0 -0
- package/hooks/lib/__pycache__/ntfy.cpython-314.pyc +0 -0
- package/hooks/ntfy-on-question.py +240 -0
- package/hooks/orchestrator-completion.py +313 -0
- package/hooks/orchestrator-handoff.py +267 -0
- package/hooks/orchestrator-session-startup.py +146 -0
- package/hooks/parallel-orchestrator.py +451 -0
- package/hooks/periodic-reground.py +270 -67
- package/hooks/project-document-prompt.py +302 -0
- package/hooks/remote-question-proxy.py +284 -0
- package/hooks/remote-question-server.py +1224 -0
- package/hooks/run-code-review.py +176 -29
- package/hooks/run-visual-qa.py +338 -0
- package/hooks/session-logger.py +27 -1
- package/hooks/session-startup.py +113 -0
- package/hooks/update-adr-decision.py +236 -0
- package/hooks/update-api-showcase.py +13 -1
- package/hooks/update-testing-checklist.py +195 -0
- package/hooks/update-ui-showcase.py +13 -1
- package/package.json +7 -3
- package/scripts/extract-schema-docs.cjs +322 -0
- package/templates/.skills/hustle-interview/SKILL.md +174 -0
- package/templates/CLAUDE-SECTION.md +89 -64
- package/templates/adr-viewer/_components/ADRViewer.tsx +326 -0
- package/templates/api-dev-state.json +33 -1
- 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/brand-page/page.tsx +645 -0
- package/templates/component/Component.visual.spec.ts +30 -24
- package/templates/docs/page.tsx +230 -0
- package/templates/eslint-plugin-zod-schema/index.js +446 -0
- package/templates/eslint-plugin-zod-schema/package.json +26 -0
- package/templates/github-workflows/security.yml +274 -0
- package/templates/hustle-build-defaults.json +136 -0
- package/templates/hustle-dev-dashboard/page.tsx +365 -0
- package/templates/page/page.e2e.test.ts +30 -26
- package/templates/performance-budgets.json +63 -5
- package/templates/playwright-report/page.tsx +258 -0
- package/templates/registry.json +279 -3
- package/templates/review-dashboard/page.tsx +510 -0
- package/templates/settings.json +155 -7
- package/templates/test-results/page.tsx +237 -0
- package/templates/typedoc.json +19 -0
- package/templates/ui-showcase/_components/UIShowcase.tsx +48 -1
- package/templates/ui-showcase/_components/VisualTestingDashboard.tsx +579 -0
- package/templates/ui-showcase/page.tsx +1 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
NTFY notification hook for AskUserQuestion.
|
|
4
|
+
|
|
5
|
+
Sends a push notification via NTFY when a question is asked,
|
|
6
|
+
allowing the user to be notified on their phone/desktop.
|
|
7
|
+
|
|
8
|
+
Hook Type: PostToolUse (matcher: AskUserQuestion)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import requests
|
|
18
|
+
HAS_REQUESTS = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_REQUESTS = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_state():
|
|
24
|
+
"""Load workflow state for context"""
|
|
25
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
26
|
+
|
|
27
|
+
# Check hustle-build state first
|
|
28
|
+
build_state = Path(project_dir) / ".claude" / "hustle-build-state.json"
|
|
29
|
+
if build_state.exists():
|
|
30
|
+
try:
|
|
31
|
+
return json.loads(build_state.read_text())
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# Check api-dev state
|
|
36
|
+
api_state = Path(project_dir) / ".claude" / "api-dev-state.json"
|
|
37
|
+
if api_state.exists():
|
|
38
|
+
try:
|
|
39
|
+
return json.loads(api_state.read_text())
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
return {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_ntfy_config():
|
|
47
|
+
"""Get NTFY configuration from environment, .env file, or hustle-build-defaults.json"""
|
|
48
|
+
topic = os.environ.get("NTFY_TOPIC")
|
|
49
|
+
server = os.environ.get("NTFY_SERVER", "https://ntfy.sh")
|
|
50
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
51
|
+
|
|
52
|
+
if not topic:
|
|
53
|
+
# Try loading from hustle-build-defaults.json first
|
|
54
|
+
defaults_file = Path(project_dir) / ".claude" / "hustle-build-defaults.json"
|
|
55
|
+
if defaults_file.exists():
|
|
56
|
+
try:
|
|
57
|
+
defaults = json.loads(defaults_file.read_text())
|
|
58
|
+
ntfy_config = defaults.get("ntfy", {})
|
|
59
|
+
if ntfy_config.get("enabled", False):
|
|
60
|
+
topic = ntfy_config.get("topic")
|
|
61
|
+
server = ntfy_config.get("server", server)
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
if not topic:
|
|
66
|
+
# Try loading from .env
|
|
67
|
+
env_file = Path(project_dir) / ".env"
|
|
68
|
+
|
|
69
|
+
if env_file.exists():
|
|
70
|
+
try:
|
|
71
|
+
for line in env_file.read_text().splitlines():
|
|
72
|
+
if line.startswith("NTFY_TOPIC="):
|
|
73
|
+
topic = line.split("=", 1)[1].strip().strip('"\'')
|
|
74
|
+
elif line.startswith("NTFY_SERVER="):
|
|
75
|
+
server = line.split("=", 1)[1].strip().strip('"\'')
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
return topic, server
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def send_notification(topic, server, title, message, priority="default", tags=None):
|
|
83
|
+
"""Send notification via NTFY"""
|
|
84
|
+
if not HAS_REQUESTS:
|
|
85
|
+
# Fallback to curl
|
|
86
|
+
import subprocess
|
|
87
|
+
try:
|
|
88
|
+
headers = ["-H", f"Title: {title}", "-H", f"Priority: {priority}"]
|
|
89
|
+
if tags:
|
|
90
|
+
headers.extend(["-H", f"Tags: {','.join(tags)}"])
|
|
91
|
+
|
|
92
|
+
subprocess.run(
|
|
93
|
+
["curl", "-s", "-d", message, *headers, f"{server}/{topic}"],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
timeout=5
|
|
96
|
+
)
|
|
97
|
+
return True
|
|
98
|
+
except Exception:
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
headers = {
|
|
103
|
+
"Title": title,
|
|
104
|
+
"Priority": priority,
|
|
105
|
+
}
|
|
106
|
+
if tags:
|
|
107
|
+
headers["Tags"] = ",".join(tags)
|
|
108
|
+
|
|
109
|
+
response = requests.post(
|
|
110
|
+
f"{server}/{topic}",
|
|
111
|
+
data=message.encode("utf-8"),
|
|
112
|
+
headers=headers,
|
|
113
|
+
timeout=5
|
|
114
|
+
)
|
|
115
|
+
return response.status_code == 200
|
|
116
|
+
except Exception:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def extract_question_summary(tool_input):
|
|
121
|
+
"""Extract question summary from tool input"""
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(tool_input)
|
|
124
|
+
questions = data.get("questions", [])
|
|
125
|
+
|
|
126
|
+
if not questions:
|
|
127
|
+
return None, None
|
|
128
|
+
|
|
129
|
+
# Get first question
|
|
130
|
+
q = questions[0]
|
|
131
|
+
header = q.get("header", "Question")
|
|
132
|
+
question = q.get("question", "")
|
|
133
|
+
options = q.get("options", [])
|
|
134
|
+
|
|
135
|
+
# Truncate question for notification
|
|
136
|
+
if len(question) > 100:
|
|
137
|
+
question = question[:97] + "..."
|
|
138
|
+
|
|
139
|
+
# Add option count
|
|
140
|
+
option_text = f" ({len(options)} options)"
|
|
141
|
+
|
|
142
|
+
return header, question + option_text
|
|
143
|
+
except Exception:
|
|
144
|
+
return None, None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def main():
|
|
148
|
+
tool_input = os.environ.get("CLAUDE_TOOL_INPUT", "{}")
|
|
149
|
+
|
|
150
|
+
# Check if in auto mode (skip notification in auto mode)
|
|
151
|
+
state = load_state()
|
|
152
|
+
if state.get("mode") == "auto":
|
|
153
|
+
# In auto mode, questions are auto-answered
|
|
154
|
+
# Only notify on errors, not questions
|
|
155
|
+
print(json.dumps({"continue": True}))
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Get NTFY config
|
|
159
|
+
topic, server = get_ntfy_config()
|
|
160
|
+
|
|
161
|
+
if not topic:
|
|
162
|
+
# NTFY not configured, skip
|
|
163
|
+
print(json.dumps({"continue": True}))
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# Extract question info
|
|
167
|
+
header, message = extract_question_summary(tool_input)
|
|
168
|
+
|
|
169
|
+
if not header or not message:
|
|
170
|
+
print(json.dumps({"continue": True}))
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Build context for notification
|
|
174
|
+
workflow = state.get("workflow", "")
|
|
175
|
+
endpoint = state.get("active_endpoint") or state.get("active_element") or ""
|
|
176
|
+
phase = ""
|
|
177
|
+
|
|
178
|
+
# Find current phase
|
|
179
|
+
phases = state.get("phases", {})
|
|
180
|
+
for phase_name, phase_data in phases.items():
|
|
181
|
+
if phase_data.get("status") == "in_progress":
|
|
182
|
+
phase = phase_name.replace("_", " ").title()
|
|
183
|
+
break
|
|
184
|
+
|
|
185
|
+
# Build title
|
|
186
|
+
title_parts = ["Hustle Dev"]
|
|
187
|
+
if workflow:
|
|
188
|
+
title_parts.append(workflow)
|
|
189
|
+
if endpoint:
|
|
190
|
+
title_parts.append(endpoint)
|
|
191
|
+
title = " - ".join(title_parts)
|
|
192
|
+
|
|
193
|
+
# Build message
|
|
194
|
+
full_message = f"{header}: {message}"
|
|
195
|
+
if phase:
|
|
196
|
+
full_message = f"[{phase}] {full_message}"
|
|
197
|
+
|
|
198
|
+
# Determine priority
|
|
199
|
+
priority = "default"
|
|
200
|
+
tags = ["question", "hustle"]
|
|
201
|
+
|
|
202
|
+
# Higher priority for blocking questions
|
|
203
|
+
if "required" in message.lower() or "must" in message.lower():
|
|
204
|
+
priority = "high"
|
|
205
|
+
tags.append("warning")
|
|
206
|
+
|
|
207
|
+
# Send notification
|
|
208
|
+
success = send_notification(topic, server, title, full_message, priority, tags)
|
|
209
|
+
|
|
210
|
+
# Log notification
|
|
211
|
+
if success:
|
|
212
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
213
|
+
logs_dir = Path(project_dir) / ".claude" / "workflow-logs"
|
|
214
|
+
logs_dir.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
|
|
216
|
+
log_file = logs_dir / "ntfy-log.json"
|
|
217
|
+
try:
|
|
218
|
+
if log_file.exists():
|
|
219
|
+
log = json.loads(log_file.read_text())
|
|
220
|
+
else:
|
|
221
|
+
log = {"notifications": []}
|
|
222
|
+
|
|
223
|
+
from datetime import datetime
|
|
224
|
+
log["notifications"].append({
|
|
225
|
+
"timestamp": datetime.now().isoformat(),
|
|
226
|
+
"title": title,
|
|
227
|
+
"message": full_message,
|
|
228
|
+
"workflow": workflow,
|
|
229
|
+
"endpoint": endpoint
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
log_file.write_text(json.dumps(log, indent=2))
|
|
233
|
+
except Exception:
|
|
234
|
+
pass
|
|
235
|
+
|
|
236
|
+
print(json.dumps({"continue": True}))
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
if __name__ == "__main__":
|
|
240
|
+
main()
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Orchestrator completion hook.
|
|
4
|
+
|
|
5
|
+
After a Skill completes, this hook updates the orchestration state
|
|
6
|
+
and determines the next workflow to execute.
|
|
7
|
+
|
|
8
|
+
Hook Type: PostToolUse (matcher: Skill)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import requests
|
|
18
|
+
HAS_REQUESTS = True
|
|
19
|
+
except ImportError:
|
|
20
|
+
HAS_REQUESTS = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load_build_state():
|
|
24
|
+
"""Load hustle-build orchestration state"""
|
|
25
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
26
|
+
state_file = Path(project_dir) / ".claude" / "hustle-build-state.json"
|
|
27
|
+
|
|
28
|
+
if state_file.exists():
|
|
29
|
+
try:
|
|
30
|
+
return json.loads(state_file.read_text())
|
|
31
|
+
except Exception:
|
|
32
|
+
pass
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_build_state(state):
|
|
37
|
+
"""Save hustle-build orchestration state"""
|
|
38
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
39
|
+
state_file = Path(project_dir) / ".claude" / "hustle-build-state.json"
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
state_file.write_text(json.dumps(state, indent=2))
|
|
43
|
+
return True
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_api_state():
|
|
49
|
+
"""Load api-dev state to check completion"""
|
|
50
|
+
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", ".")
|
|
51
|
+
state_file = Path(project_dir) / ".claude" / "api-dev-state.json"
|
|
52
|
+
|
|
53
|
+
if state_file.exists():
|
|
54
|
+
try:
|
|
55
|
+
return json.loads(state_file.read_text())
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_skill_name(tool_input):
|
|
62
|
+
"""Extract skill name from tool input"""
|
|
63
|
+
try:
|
|
64
|
+
data = json.loads(tool_input)
|
|
65
|
+
return data.get("skill", "")
|
|
66
|
+
except Exception:
|
|
67
|
+
return ""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def check_workflow_complete(api_state):
|
|
71
|
+
"""Check if the current workflow completed successfully"""
|
|
72
|
+
phases = api_state.get("phases", {})
|
|
73
|
+
|
|
74
|
+
# Check if documentation phase is complete (last phase)
|
|
75
|
+
doc_phase = phases.get("documentation", {})
|
|
76
|
+
if doc_phase.get("status") == "complete":
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
# Alternative: check verification
|
|
80
|
+
verify_phase = phases.get("verify", {})
|
|
81
|
+
if verify_phase.get("status") == "complete":
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def find_next_workflow(build_state):
|
|
88
|
+
"""Find the next pending workflow based on dependencies"""
|
|
89
|
+
decomposition = build_state.get("decomposition", {})
|
|
90
|
+
completed_names = set()
|
|
91
|
+
|
|
92
|
+
# Collect completed workflow names
|
|
93
|
+
for wf_type in ["apis", "components", "combined_apis", "pages"]:
|
|
94
|
+
workflows = decomposition.get(wf_type, [])
|
|
95
|
+
for wf in workflows:
|
|
96
|
+
if wf.get("status") == "complete":
|
|
97
|
+
completed_names.add(wf.get("name"))
|
|
98
|
+
|
|
99
|
+
# Find first pending workflow with satisfied dependencies
|
|
100
|
+
for wf_type in ["apis", "components", "combined_apis", "pages"]:
|
|
101
|
+
workflows = decomposition.get(wf_type, [])
|
|
102
|
+
for wf in workflows:
|
|
103
|
+
if wf.get("status") != "pending":
|
|
104
|
+
continue
|
|
105
|
+
|
|
106
|
+
# Check dependencies
|
|
107
|
+
deps = wf.get("depends_on", [])
|
|
108
|
+
if all(dep in completed_names for dep in deps):
|
|
109
|
+
return wf, wf_type
|
|
110
|
+
|
|
111
|
+
return None, None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def send_ntfy(title, message, priority="default"):
|
|
115
|
+
"""Send NTFY notification"""
|
|
116
|
+
topic = os.environ.get("NTFY_TOPIC")
|
|
117
|
+
server = os.environ.get("NTFY_SERVER", "https://ntfy.sh")
|
|
118
|
+
|
|
119
|
+
if not topic:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
if HAS_REQUESTS:
|
|
124
|
+
requests.post(
|
|
125
|
+
f"{server}/{topic}",
|
|
126
|
+
data=message.encode("utf-8"),
|
|
127
|
+
headers={"Title": title, "Priority": priority, "Tags": "hustle,check"},
|
|
128
|
+
timeout=5
|
|
129
|
+
)
|
|
130
|
+
else:
|
|
131
|
+
import subprocess
|
|
132
|
+
subprocess.run(
|
|
133
|
+
["curl", "-s", "-d", message, "-H", f"Title: {title}", f"{server}/{topic}"],
|
|
134
|
+
capture_output=True,
|
|
135
|
+
timeout=5
|
|
136
|
+
)
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main():
|
|
142
|
+
tool_input = os.environ.get("CLAUDE_TOOL_INPUT", "{}")
|
|
143
|
+
tool_result = os.environ.get("CLAUDE_TOOL_RESULT", "")
|
|
144
|
+
|
|
145
|
+
# Get skill that completed
|
|
146
|
+
skill_name = get_skill_name(tool_input)
|
|
147
|
+
|
|
148
|
+
# Check if this is a workflow skill
|
|
149
|
+
workflow_skills = [
|
|
150
|
+
"api-create", "hustle-ui-create", "hustle-ui-create-page",
|
|
151
|
+
"hustle-combine", "cycle"
|
|
152
|
+
]
|
|
153
|
+
|
|
154
|
+
if skill_name not in workflow_skills:
|
|
155
|
+
print(json.dumps({"continue": True}))
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Check if we're in an orchestrated build
|
|
159
|
+
build_state = load_build_state()
|
|
160
|
+
|
|
161
|
+
if not build_state or build_state.get("status") != "in_progress":
|
|
162
|
+
print(json.dumps({"continue": True}))
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
# Check if the workflow completed successfully
|
|
166
|
+
api_state = load_api_state()
|
|
167
|
+
workflow_complete = check_workflow_complete(api_state)
|
|
168
|
+
|
|
169
|
+
# Get active workflow info
|
|
170
|
+
active = build_state.get("active_sub_workflow", {})
|
|
171
|
+
active_name = active.get("name")
|
|
172
|
+
active_type = active.get("type")
|
|
173
|
+
|
|
174
|
+
# Update workflow status
|
|
175
|
+
decomposition = build_state.get("decomposition", {})
|
|
176
|
+
|
|
177
|
+
if workflow_complete and active_name:
|
|
178
|
+
# Mark workflow as complete
|
|
179
|
+
for wf_type in ["apis", "components", "combined_apis", "pages"]:
|
|
180
|
+
workflows = decomposition.get(wf_type, [])
|
|
181
|
+
for wf in workflows:
|
|
182
|
+
if wf.get("name") == active_name:
|
|
183
|
+
wf["status"] = "complete"
|
|
184
|
+
wf["completed_at"] = datetime.now().isoformat()
|
|
185
|
+
|
|
186
|
+
# Add to completed list
|
|
187
|
+
if "completed_sub_workflows" not in build_state:
|
|
188
|
+
build_state["completed_sub_workflows"] = []
|
|
189
|
+
|
|
190
|
+
build_state["completed_sub_workflows"].append({
|
|
191
|
+
"type": active_type,
|
|
192
|
+
"name": active_name,
|
|
193
|
+
"completed_at": datetime.now().isoformat()
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
# Find next workflow
|
|
197
|
+
next_wf, next_type = find_next_workflow(build_state)
|
|
198
|
+
|
|
199
|
+
if next_wf:
|
|
200
|
+
# Set next as active
|
|
201
|
+
build_state["active_sub_workflow"] = {
|
|
202
|
+
"type": next_type.rstrip("s"),
|
|
203
|
+
"name": next_wf.get("name"),
|
|
204
|
+
"workflow_id": f"wf-{len(build_state.get('completed_sub_workflows', []))+1:03d}"
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# Mark as in progress
|
|
208
|
+
next_wf["status"] = "in_progress"
|
|
209
|
+
next_wf["started_at"] = datetime.now().isoformat()
|
|
210
|
+
|
|
211
|
+
save_build_state(build_state)
|
|
212
|
+
|
|
213
|
+
# Determine which skill to run
|
|
214
|
+
skill_mapping = {
|
|
215
|
+
"api": "/api-create",
|
|
216
|
+
"component": "/hustle-ui-create",
|
|
217
|
+
"combined_api": "/hustle-combine api",
|
|
218
|
+
"page": "/hustle-ui-create-page"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
next_skill = skill_mapping.get(next_type.rstrip("s"), "/api-create")
|
|
222
|
+
next_name = next_wf.get("name")
|
|
223
|
+
|
|
224
|
+
context = f"""
|
|
225
|
+
## Workflow Complete: {active_name}
|
|
226
|
+
|
|
227
|
+
The [{active_type}] **{active_name}** workflow has completed.
|
|
228
|
+
|
|
229
|
+
### Next Workflow: [{next_type}] {next_name}
|
|
230
|
+
|
|
231
|
+
Run: `{next_skill} {next_name}`
|
|
232
|
+
|
|
233
|
+
Progress: {len(build_state.get('completed_sub_workflows', []))}/{sum(len(decomposition.get(t, [])) for t in decomposition)} complete
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
result = {
|
|
237
|
+
"continue": True,
|
|
238
|
+
"additionalContext": context
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
else:
|
|
242
|
+
# All workflows complete!
|
|
243
|
+
build_state["status"] = "complete"
|
|
244
|
+
build_state["completed_at"] = datetime.now().isoformat()
|
|
245
|
+
build_state["active_sub_workflow"] = None
|
|
246
|
+
|
|
247
|
+
save_build_state(build_state)
|
|
248
|
+
|
|
249
|
+
# Send completion notification
|
|
250
|
+
completed_count = len(build_state.get("completed_sub_workflows", []))
|
|
251
|
+
build_id = build_state.get("build_id", "unknown")
|
|
252
|
+
|
|
253
|
+
send_ntfy(
|
|
254
|
+
"Hustle Build Complete!",
|
|
255
|
+
f"All {completed_count} workflows finished.\nReview: /hustle-build-review {build_id}",
|
|
256
|
+
"high"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
context = f"""
|
|
260
|
+
## BUILD COMPLETE
|
|
261
|
+
|
|
262
|
+
All workflows have finished successfully!
|
|
263
|
+
|
|
264
|
+
**Build ID:** {build_id}
|
|
265
|
+
**Total Workflows:** {completed_count}
|
|
266
|
+
**Duration:** {calculate_duration(build_state)}
|
|
267
|
+
|
|
268
|
+
### Created Elements:
|
|
269
|
+
{format_created_elements(build_state)}
|
|
270
|
+
|
|
271
|
+
### Next Steps:
|
|
272
|
+
- `/hustle-build-review {build_id}` - Review all decisions and results
|
|
273
|
+
- `/commit` - Commit all changes
|
|
274
|
+
- `/pr` - Create pull request
|
|
275
|
+
|
|
276
|
+
Visit `/hustle-dev-dashboard` to see all created elements.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
result = {
|
|
280
|
+
"continue": True,
|
|
281
|
+
"additionalContext": context
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
print(json.dumps(result))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def calculate_duration(build_state):
|
|
288
|
+
"""Calculate build duration"""
|
|
289
|
+
try:
|
|
290
|
+
started = datetime.fromisoformat(build_state.get("created_at", ""))
|
|
291
|
+
ended = datetime.fromisoformat(build_state.get("completed_at", datetime.now().isoformat()))
|
|
292
|
+
duration = ended - started
|
|
293
|
+
minutes = int(duration.total_seconds() / 60)
|
|
294
|
+
return f"{minutes} minutes"
|
|
295
|
+
except Exception:
|
|
296
|
+
return "Unknown"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def format_created_elements(build_state):
|
|
300
|
+
"""Format list of created elements"""
|
|
301
|
+
completed = build_state.get("completed_sub_workflows", [])
|
|
302
|
+
lines = []
|
|
303
|
+
|
|
304
|
+
for wf in completed:
|
|
305
|
+
wf_type = wf.get("type", "unknown")
|
|
306
|
+
name = wf.get("name", "unnamed")
|
|
307
|
+
lines.append(f" - [{wf_type}] {name}")
|
|
308
|
+
|
|
309
|
+
return "\n".join(lines) if lines else " None"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
if __name__ == "__main__":
|
|
313
|
+
main()
|