@hustle-together/api-dev-tools 3.12.3 → 3.12.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/hustle-build.md +259 -0
- package/.claude/commands/hustle-combine.md +1089 -0
- package/.claude/commands/hustle-ui-create-page.md +1078 -0
- package/.claude/commands/hustle-ui-create.md +1058 -0
- package/.claude/hooks/auto-answer.py +305 -0
- package/.claude/hooks/cache-research.py +337 -0
- package/.claude/hooks/check-api-routes.py +168 -0
- package/.claude/hooks/check-playwright-setup.py +103 -0
- package/.claude/hooks/check-storybook-setup.py +81 -0
- package/.claude/hooks/check-update.py +132 -0
- package/.claude/hooks/completion-promise-detector.py +293 -0
- package/.claude/hooks/context-capacity-warning.py +171 -0
- package/.claude/hooks/detect-interruption.py +165 -0
- package/.claude/hooks/docs-update-check.py +120 -0
- package/.claude/hooks/enforce-a11y-audit.py +202 -0
- package/.claude/hooks/enforce-brand-guide.py +241 -0
- package/.claude/hooks/enforce-component-type-confirm.py +97 -0
- package/.claude/hooks/enforce-dry-run.py +134 -0
- package/.claude/hooks/enforce-freshness.py +184 -0
- package/.claude/hooks/enforce-page-components.py +186 -0
- package/.claude/hooks/enforce-page-data-schema.py +155 -0
- package/.claude/hooks/enforce-questions-sourced.py +146 -0
- package/.claude/hooks/enforce-schema-from-interview.py +248 -0
- package/.claude/hooks/enforce-ui-disambiguation.py +108 -0
- package/.claude/hooks/enforce-ui-interview.py +130 -0
- package/.claude/hooks/generate-adr-options.py +282 -0
- package/.claude/hooks/generate-manifest-entry.py +1161 -0
- package/.claude/hooks/hook_utils.py +609 -0
- package/.claude/hooks/lib/__init__.py +1 -0
- package/.claude/hooks/lib/__pycache__/__init__.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/__pycache__/greptile.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/__pycache__/ntfy.cpython-314.pyc +0 -0
- package/.claude/hooks/lib/greptile.py +355 -0
- package/.claude/hooks/lib/ntfy.py +209 -0
- package/.claude/hooks/notify-input-needed.py +73 -0
- package/.claude/hooks/notify-phase-complete.py +90 -0
- package/.claude/hooks/ntfy-on-question.py +240 -0
- package/.claude/hooks/orchestrator-completion.py +313 -0
- package/.claude/hooks/orchestrator-handoff.py +267 -0
- package/.claude/hooks/orchestrator-session-startup.py +146 -0
- package/.claude/hooks/parallel-orchestrator.py +451 -0
- package/.claude/hooks/project-document-prompt.py +302 -0
- package/.claude/hooks/remote-question-proxy.py +284 -0
- package/.claude/hooks/remote-question-server.py +1224 -0
- package/.claude/hooks/run-code-review.py +393 -0
- package/.claude/hooks/run-visual-qa.py +338 -0
- package/.claude/hooks/session-logger.py +323 -0
- package/.claude/hooks/test-orchestrator-reground.py +248 -0
- package/.claude/hooks/track-scope-coverage.py +220 -0
- package/.claude/hooks/track-token-usage.py +121 -0
- package/.claude/hooks/update-adr-decision.py +236 -0
- package/.claude/hooks/update-api-showcase.py +161 -0
- package/.claude/hooks/update-registry.py +352 -0
- package/.claude/hooks/update-testing-checklist.py +195 -0
- package/.claude/hooks/update-ui-showcase.py +224 -0
- package/.claude/settings.local.json +7 -1
- package/.claude/test-auto-answer-bot.py +183 -0
- package/.claude/test-completion-detector.py +263 -0
- package/.claude/test-orchestrator-state.json +20 -0
- package/.claude/test-orchestrator.sh +271 -0
- package/.skills/api-create/SKILL.md +88 -3
- package/.skills/docs-sync/SKILL.md +260 -0
- package/.skills/hustle-build/SKILL.md +459 -0
- package/.skills/hustle-build-review/SKILL.md +518 -0
- package/CHANGELOG.md +87 -0
- package/README.md +86 -9
- package/bin/cli.js +1302 -88
- package/commands/hustle-api-create.md +22 -0
- package/commands/hustle-combine.md +81 -2
- package/commands/hustle-ui-create-page.md +84 -2
- package/commands/hustle-ui-create.md +82 -2
- package/hooks/auto-answer.py +228 -0
- package/hooks/check-update.py +132 -0
- package/hooks/ntfy-on-question.py +227 -0
- package/hooks/orchestrator-completion.py +313 -0
- package/hooks/orchestrator-handoff.py +189 -0
- package/hooks/orchestrator-session-startup.py +146 -0
- package/hooks/periodic-reground.py +230 -67
- package/hooks/update-api-showcase.py +13 -1
- package/hooks/update-ui-showcase.py +13 -1
- package/package.json +7 -3
- package/scripts/extract-schema-docs.cjs +322 -0
- package/templates/CLAUDE-SECTION.md +89 -64
- package/templates/api-showcase/_components/APIModal.tsx +100 -8
- package/templates/api-showcase/_components/APIShowcase.tsx +36 -4
- package/templates/api-showcase/_components/APITester.tsx +367 -58
- package/templates/docs/page.tsx +230 -0
- package/templates/hustle-build-defaults.json +84 -0
- package/templates/hustle-dev-dashboard/page.tsx +365 -0
- package/templates/playwright-report/page.tsx +258 -0
- package/templates/settings.json +88 -7
- package/templates/test-results/page.tsx +237 -0
- package/templates/typedoc.json +19 -0
- package/templates/ui-showcase/_components/UIShowcase.tsx +1 -1
- package/templates/ui-showcase/page.tsx +1 -1
- package/.claude/api-dev-state.json +0 -466
|
@@ -0,0 +1,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()
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Remote Question Proxy Hook
|
|
4
|
+
|
|
5
|
+
When REMOTE_QUESTIONS_ENABLED=true, this hook:
|
|
6
|
+
1. Writes the current question to .claude/current-question.json
|
|
7
|
+
2. Sends NTFY notification with link to the web UI
|
|
8
|
+
3. Optionally waits for remote answer
|
|
9
|
+
|
|
10
|
+
Hook Type: PreToolUse (matcher: AskUserQuestion)
|
|
11
|
+
Version: 4.6.0
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import subprocess
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
|
|
22
|
+
# Configuration
|
|
23
|
+
DEFAULT_PORT = 8765
|
|
24
|
+
POLL_INTERVAL = 2 # seconds
|
|
25
|
+
MAX_WAIT_TIME = 300 # 5 minutes
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_project_dir():
|
|
29
|
+
"""Get project directory from environment."""
|
|
30
|
+
return Path(os.environ.get("CLAUDE_PROJECT_DIR", "."))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_remote_questions_enabled():
|
|
34
|
+
"""Check if remote questions feature is enabled."""
|
|
35
|
+
return os.environ.get("REMOTE_QUESTIONS_ENABLED", "").lower() == "true"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_remote_url():
|
|
39
|
+
"""Get the remote URL (Cloudflare tunnel or localhost)."""
|
|
40
|
+
url = os.environ.get("REMOTE_QUESTIONS_URL", "")
|
|
41
|
+
if url:
|
|
42
|
+
return url.rstrip("/")
|
|
43
|
+
|
|
44
|
+
port = os.environ.get("REMOTE_QUESTIONS_PORT", DEFAULT_PORT)
|
|
45
|
+
return f"http://localhost:{port}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_ntfy_topic():
|
|
49
|
+
"""Get NTFY topic from environment."""
|
|
50
|
+
return os.environ.get("NTFY_TOPIC", "layers-mf-08ebf1d1")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def parse_question_input(tool_input_raw):
|
|
54
|
+
"""Parse the AskUserQuestion tool input."""
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(tool_input_raw)
|
|
57
|
+
questions = data.get("questions", [])
|
|
58
|
+
return questions
|
|
59
|
+
except Exception:
|
|
60
|
+
return []
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_question_file(questions, phase="unknown"):
|
|
64
|
+
"""Write question to .claude/current-question.json for the server."""
|
|
65
|
+
project_dir = get_project_dir()
|
|
66
|
+
question_file = project_dir / ".claude" / "current-question.json"
|
|
67
|
+
question_file.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
|
|
69
|
+
# Format questions for the web UI
|
|
70
|
+
formatted_questions = []
|
|
71
|
+
for q in questions:
|
|
72
|
+
formatted_q = {
|
|
73
|
+
"id": q.get("header", "question").lower().replace(" ", "-"),
|
|
74
|
+
"question": q.get("question", ""),
|
|
75
|
+
"header": q.get("header", "Question"),
|
|
76
|
+
"options": [],
|
|
77
|
+
"multiSelect": q.get("multiSelect", False),
|
|
78
|
+
"timestamp": datetime.now().isoformat()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for opt in q.get("options", []):
|
|
82
|
+
formatted_q["options"].append({
|
|
83
|
+
"label": opt.get("label", ""),
|
|
84
|
+
"description": opt.get("description", "")
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
formatted_questions.append(formatted_q)
|
|
88
|
+
|
|
89
|
+
question_data = {
|
|
90
|
+
"questions": formatted_questions,
|
|
91
|
+
"phase": phase,
|
|
92
|
+
"created_at": datetime.now().isoformat(),
|
|
93
|
+
"status": "pending"
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
question_file.write_text(json.dumps(question_data, indent=2))
|
|
97
|
+
return question_file
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def clear_answer_file():
|
|
101
|
+
"""Clear any existing answer file."""
|
|
102
|
+
project_dir = get_project_dir()
|
|
103
|
+
answer_file = project_dir / ".claude" / "pending-answer.json"
|
|
104
|
+
if answer_file.exists():
|
|
105
|
+
answer_file.unlink()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def send_ntfy_notification(url):
|
|
109
|
+
"""Send NTFY notification with link to question UI."""
|
|
110
|
+
topic = get_ntfy_topic()
|
|
111
|
+
message = f"[INPUT NEEDED] Answer question at: {url}"
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
subprocess.run(
|
|
115
|
+
["curl", "-s", "-d", message, f"ntfy.sh/{topic}"],
|
|
116
|
+
capture_output=True,
|
|
117
|
+
timeout=10
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass # Don't fail if notification fails
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def wait_for_answer(timeout=MAX_WAIT_TIME):
|
|
124
|
+
"""Wait for answer to appear in pending-answer.json."""
|
|
125
|
+
project_dir = get_project_dir()
|
|
126
|
+
answer_file = project_dir / ".claude" / "pending-answer.json"
|
|
127
|
+
|
|
128
|
+
start_time = time.time()
|
|
129
|
+
|
|
130
|
+
while time.time() - start_time < timeout:
|
|
131
|
+
if answer_file.exists():
|
|
132
|
+
try:
|
|
133
|
+
answer_data = json.loads(answer_file.read_text())
|
|
134
|
+
if answer_data.get("status") == "submitted":
|
|
135
|
+
return answer_data
|
|
136
|
+
except Exception:
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
time.sleep(POLL_INTERVAL)
|
|
140
|
+
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_current_phase():
|
|
145
|
+
"""Try to determine current workflow phase from state."""
|
|
146
|
+
project_dir = get_project_dir()
|
|
147
|
+
|
|
148
|
+
# Check hustle-build state
|
|
149
|
+
build_state_file = project_dir / ".claude" / "hustle-build-state.json"
|
|
150
|
+
if build_state_file.exists():
|
|
151
|
+
try:
|
|
152
|
+
state = json.loads(build_state_file.read_text())
|
|
153
|
+
phase = state.get("current_phase", "")
|
|
154
|
+
if phase:
|
|
155
|
+
return phase
|
|
156
|
+
except Exception:
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
# Check api-dev state
|
|
160
|
+
api_state_file = project_dir / ".claude" / "api-dev-state.json"
|
|
161
|
+
if api_state_file.exists():
|
|
162
|
+
try:
|
|
163
|
+
state = json.loads(api_state_file.read_text())
|
|
164
|
+
phases = state.get("phases", {})
|
|
165
|
+
for phase_name, phase_data in phases.items():
|
|
166
|
+
if phase_data.get("status") == "in_progress":
|
|
167
|
+
return phase_name
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
return "workflow"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main():
|
|
175
|
+
# Check if remote questions is enabled
|
|
176
|
+
if not is_remote_questions_enabled():
|
|
177
|
+
# Not enabled, let the question proceed normally
|
|
178
|
+
print(json.dumps({"continue": True}))
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
# Read tool input
|
|
182
|
+
tool_input_raw = os.environ.get("CLAUDE_TOOL_INPUT", "{}")
|
|
183
|
+
|
|
184
|
+
# Also check stdin for hook input
|
|
185
|
+
try:
|
|
186
|
+
if not sys.stdin.isatty():
|
|
187
|
+
stdin_data = sys.stdin.read()
|
|
188
|
+
if stdin_data:
|
|
189
|
+
try:
|
|
190
|
+
hook_input = json.loads(stdin_data)
|
|
191
|
+
tool_input_raw = json.dumps(hook_input.get("tool_input", {}))
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
pass
|
|
194
|
+
except Exception:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
# Parse questions
|
|
198
|
+
questions = parse_question_input(tool_input_raw)
|
|
199
|
+
|
|
200
|
+
if not questions:
|
|
201
|
+
print(json.dumps({"continue": True}))
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
# Get current phase for context
|
|
205
|
+
phase = get_current_phase()
|
|
206
|
+
|
|
207
|
+
# Clear any existing answer
|
|
208
|
+
clear_answer_file()
|
|
209
|
+
|
|
210
|
+
# Write question to file for server
|
|
211
|
+
write_question_file(questions, phase)
|
|
212
|
+
|
|
213
|
+
# Get remote URL
|
|
214
|
+
remote_url = get_remote_url()
|
|
215
|
+
|
|
216
|
+
# Send NTFY notification
|
|
217
|
+
send_ntfy_notification(remote_url)
|
|
218
|
+
|
|
219
|
+
# Check if we should wait for remote answer
|
|
220
|
+
wait_mode = os.environ.get("REMOTE_QUESTIONS_WAIT", "false").lower() == "true"
|
|
221
|
+
|
|
222
|
+
if wait_mode:
|
|
223
|
+
# Wait for remote answer
|
|
224
|
+
answer = wait_for_answer()
|
|
225
|
+
|
|
226
|
+
if answer:
|
|
227
|
+
# Inject the answer as context
|
|
228
|
+
answers = answer.get("answers", {})
|
|
229
|
+
|
|
230
|
+
context = f"""
|
|
231
|
+
## Remote Answer Received
|
|
232
|
+
|
|
233
|
+
The user answered remotely via the question interface:
|
|
234
|
+
|
|
235
|
+
```json
|
|
236
|
+
{json.dumps(answers, indent=2)}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Use these answers to proceed with the workflow.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
# Clear the question file
|
|
243
|
+
project_dir = get_project_dir()
|
|
244
|
+
question_file = project_dir / ".claude" / "current-question.json"
|
|
245
|
+
if question_file.exists():
|
|
246
|
+
question_file.unlink()
|
|
247
|
+
|
|
248
|
+
print(json.dumps({
|
|
249
|
+
"continue": True,
|
|
250
|
+
"additionalContext": context
|
|
251
|
+
}))
|
|
252
|
+
return
|
|
253
|
+
else:
|
|
254
|
+
# Timeout - let the local question proceed
|
|
255
|
+
context = """
|
|
256
|
+
## Remote Question Timeout
|
|
257
|
+
|
|
258
|
+
The remote question interface timed out waiting for an answer.
|
|
259
|
+
The question will be displayed locally instead.
|
|
260
|
+
"""
|
|
261
|
+
print(json.dumps({
|
|
262
|
+
"continue": True,
|
|
263
|
+
"additionalContext": context
|
|
264
|
+
}))
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
# Non-blocking mode - just notify and continue with local question
|
|
268
|
+
context = f"""
|
|
269
|
+
## Remote Question Notification Sent
|
|
270
|
+
|
|
271
|
+
A notification was sent to answer this question remotely at:
|
|
272
|
+
{remote_url}
|
|
273
|
+
|
|
274
|
+
The question will also be displayed here. Answer either locally or remotely.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
print(json.dumps({
|
|
278
|
+
"continue": True,
|
|
279
|
+
"additionalContext": context
|
|
280
|
+
}))
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
main()
|