autoforge-ai 0.1.0
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/check-code.md +32 -0
- package/.claude/commands/checkpoint.md +40 -0
- package/.claude/commands/create-spec.md +613 -0
- package/.claude/commands/expand-project.md +234 -0
- package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
- package/.claude/commands/review-pr.md +75 -0
- package/.claude/templates/app_spec.template.txt +331 -0
- package/.claude/templates/coding_prompt.template.md +265 -0
- package/.claude/templates/initializer_prompt.template.md +354 -0
- package/.claude/templates/testing_prompt.template.md +146 -0
- package/.env.example +64 -0
- package/LICENSE.md +676 -0
- package/README.md +423 -0
- package/agent.py +444 -0
- package/api/__init__.py +10 -0
- package/api/database.py +536 -0
- package/api/dependency_resolver.py +449 -0
- package/api/migration.py +156 -0
- package/auth.py +83 -0
- package/autoforge_paths.py +315 -0
- package/autonomous_agent_demo.py +293 -0
- package/bin/autoforge.js +3 -0
- package/client.py +607 -0
- package/env_constants.py +27 -0
- package/examples/OPTIMIZE_CONFIG.md +230 -0
- package/examples/README.md +531 -0
- package/examples/org_config.yaml +172 -0
- package/examples/project_allowed_commands.yaml +139 -0
- package/lib/cli.js +791 -0
- package/mcp_server/__init__.py +1 -0
- package/mcp_server/feature_mcp.py +988 -0
- package/package.json +53 -0
- package/parallel_orchestrator.py +1800 -0
- package/progress.py +247 -0
- package/prompts.py +427 -0
- package/pyproject.toml +17 -0
- package/rate_limit_utils.py +132 -0
- package/registry.py +614 -0
- package/requirements-prod.txt +14 -0
- package/security.py +959 -0
- package/server/__init__.py +17 -0
- package/server/main.py +261 -0
- package/server/routers/__init__.py +32 -0
- package/server/routers/agent.py +177 -0
- package/server/routers/assistant_chat.py +327 -0
- package/server/routers/devserver.py +309 -0
- package/server/routers/expand_project.py +239 -0
- package/server/routers/features.py +746 -0
- package/server/routers/filesystem.py +514 -0
- package/server/routers/projects.py +524 -0
- package/server/routers/schedules.py +356 -0
- package/server/routers/settings.py +127 -0
- package/server/routers/spec_creation.py +357 -0
- package/server/routers/terminal.py +453 -0
- package/server/schemas.py +593 -0
- package/server/services/__init__.py +36 -0
- package/server/services/assistant_chat_session.py +496 -0
- package/server/services/assistant_database.py +304 -0
- package/server/services/chat_constants.py +57 -0
- package/server/services/dev_server_manager.py +557 -0
- package/server/services/expand_chat_session.py +399 -0
- package/server/services/process_manager.py +657 -0
- package/server/services/project_config.py +475 -0
- package/server/services/scheduler_service.py +683 -0
- package/server/services/spec_chat_session.py +502 -0
- package/server/services/terminal_manager.py +756 -0
- package/server/utils/__init__.py +1 -0
- package/server/utils/process_utils.py +134 -0
- package/server/utils/project_helpers.py +32 -0
- package/server/utils/validation.py +54 -0
- package/server/websocket.py +903 -0
- package/start.py +456 -0
- package/ui/dist/assets/index-8W_wmZzz.js +168 -0
- package/ui/dist/assets/index-B47Ubhox.css +1 -0
- package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
- package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
- package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
- package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
- package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
- package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
- package/ui/dist/index.html +23 -0
- package/ui/dist/ollama.png +0 -0
- package/ui/dist/vite.svg +6 -0
- package/ui/package.json +57 -0
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spec Creation Router
|
|
3
|
+
====================
|
|
4
|
+
|
|
5
|
+
WebSocket and REST endpoints for interactive spec creation with Claude.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
|
|
13
|
+
from pydantic import BaseModel, ValidationError
|
|
14
|
+
|
|
15
|
+
from ..schemas import ImageAttachment
|
|
16
|
+
from ..services.spec_chat_session import (
|
|
17
|
+
SpecChatSession,
|
|
18
|
+
create_session,
|
|
19
|
+
get_session,
|
|
20
|
+
list_sessions,
|
|
21
|
+
remove_session,
|
|
22
|
+
)
|
|
23
|
+
from ..utils.project_helpers import get_project_path as _get_project_path
|
|
24
|
+
from ..utils.validation import is_valid_project_name as validate_project_name
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
router = APIRouter(prefix="/api/spec", tags=["spec-creation"])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ============================================================================
|
|
32
|
+
# REST Endpoints
|
|
33
|
+
# ============================================================================
|
|
34
|
+
|
|
35
|
+
class SpecSessionStatus(BaseModel):
|
|
36
|
+
"""Status of a spec creation session."""
|
|
37
|
+
project_name: str
|
|
38
|
+
is_active: bool
|
|
39
|
+
is_complete: bool
|
|
40
|
+
message_count: int
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@router.get("/sessions", response_model=list[str])
|
|
44
|
+
async def list_spec_sessions():
|
|
45
|
+
"""List all active spec creation sessions."""
|
|
46
|
+
return list_sessions()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@router.get("/sessions/{project_name}", response_model=SpecSessionStatus)
|
|
50
|
+
async def get_session_status(project_name: str):
|
|
51
|
+
"""Get status of a spec creation session."""
|
|
52
|
+
if not validate_project_name(project_name):
|
|
53
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
54
|
+
|
|
55
|
+
session = get_session(project_name)
|
|
56
|
+
if not session:
|
|
57
|
+
raise HTTPException(status_code=404, detail="No active session for this project")
|
|
58
|
+
|
|
59
|
+
return SpecSessionStatus(
|
|
60
|
+
project_name=project_name,
|
|
61
|
+
is_active=True,
|
|
62
|
+
is_complete=session.is_complete(),
|
|
63
|
+
message_count=len(session.get_messages()),
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.delete("/sessions/{project_name}")
|
|
68
|
+
async def cancel_session(project_name: str):
|
|
69
|
+
"""Cancel and remove a spec creation session."""
|
|
70
|
+
if not validate_project_name(project_name):
|
|
71
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
72
|
+
|
|
73
|
+
session = get_session(project_name)
|
|
74
|
+
if not session:
|
|
75
|
+
raise HTTPException(status_code=404, detail="No active session for this project")
|
|
76
|
+
|
|
77
|
+
await remove_session(project_name)
|
|
78
|
+
return {"success": True, "message": "Session cancelled"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class SpecFileStatus(BaseModel):
|
|
82
|
+
"""Status of spec files on disk (from .spec_status.json)."""
|
|
83
|
+
exists: bool
|
|
84
|
+
status: str # "complete" | "in_progress" | "not_started"
|
|
85
|
+
feature_count: Optional[int] = None
|
|
86
|
+
timestamp: Optional[str] = None
|
|
87
|
+
files_written: list[str] = []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.get("/status/{project_name}", response_model=SpecFileStatus)
|
|
91
|
+
async def get_spec_file_status(project_name: str):
|
|
92
|
+
"""
|
|
93
|
+
Get spec creation status by reading .spec_status.json from the project.
|
|
94
|
+
|
|
95
|
+
This is used for polling to detect when Claude has finished writing spec files.
|
|
96
|
+
Claude writes this status file as the final step after completing all spec work.
|
|
97
|
+
"""
|
|
98
|
+
if not validate_project_name(project_name):
|
|
99
|
+
raise HTTPException(status_code=400, detail="Invalid project name")
|
|
100
|
+
|
|
101
|
+
project_dir = _get_project_path(project_name)
|
|
102
|
+
if not project_dir:
|
|
103
|
+
raise HTTPException(status_code=404, detail="Project not found in registry")
|
|
104
|
+
|
|
105
|
+
if not project_dir.exists():
|
|
106
|
+
raise HTTPException(status_code=404, detail="Project directory not found")
|
|
107
|
+
|
|
108
|
+
from autoforge_paths import get_prompts_dir
|
|
109
|
+
status_file = get_prompts_dir(project_dir) / ".spec_status.json"
|
|
110
|
+
|
|
111
|
+
if not status_file.exists():
|
|
112
|
+
return SpecFileStatus(
|
|
113
|
+
exists=False,
|
|
114
|
+
status="not_started",
|
|
115
|
+
feature_count=None,
|
|
116
|
+
timestamp=None,
|
|
117
|
+
files_written=[],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
data = json.loads(status_file.read_text(encoding="utf-8"))
|
|
122
|
+
return SpecFileStatus(
|
|
123
|
+
exists=True,
|
|
124
|
+
status=data.get("status", "unknown"),
|
|
125
|
+
feature_count=data.get("feature_count"),
|
|
126
|
+
timestamp=data.get("timestamp"),
|
|
127
|
+
files_written=data.get("files_written", []),
|
|
128
|
+
)
|
|
129
|
+
except json.JSONDecodeError as e:
|
|
130
|
+
logger.warning(f"Invalid JSON in spec status file: {e}")
|
|
131
|
+
return SpecFileStatus(
|
|
132
|
+
exists=True,
|
|
133
|
+
status="error",
|
|
134
|
+
feature_count=None,
|
|
135
|
+
timestamp=None,
|
|
136
|
+
files_written=[],
|
|
137
|
+
)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.error(f"Error reading spec status file: {e}")
|
|
140
|
+
raise HTTPException(status_code=500, detail="Failed to read status file")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ============================================================================
|
|
144
|
+
# WebSocket Endpoint
|
|
145
|
+
# ============================================================================
|
|
146
|
+
|
|
147
|
+
@router.websocket("/ws/{project_name}")
|
|
148
|
+
async def spec_chat_websocket(websocket: WebSocket, project_name: str):
|
|
149
|
+
"""
|
|
150
|
+
WebSocket endpoint for interactive spec creation chat.
|
|
151
|
+
|
|
152
|
+
Message protocol:
|
|
153
|
+
|
|
154
|
+
Client -> Server:
|
|
155
|
+
- {"type": "start"} - Start the spec creation session
|
|
156
|
+
- {"type": "message", "content": "..."} - Send user message
|
|
157
|
+
- {"type": "answer", "answers": {...}, "tool_id": "..."} - Answer structured question
|
|
158
|
+
- {"type": "ping"} - Keep-alive ping
|
|
159
|
+
|
|
160
|
+
Server -> Client:
|
|
161
|
+
- {"type": "text", "content": "..."} - Text chunk from Claude
|
|
162
|
+
- {"type": "question", "questions": [...], "tool_id": "..."} - Structured question
|
|
163
|
+
- {"type": "spec_complete", "path": "..."} - Spec file created
|
|
164
|
+
- {"type": "file_written", "path": "..."} - Other file written
|
|
165
|
+
- {"type": "complete"} - Session complete
|
|
166
|
+
- {"type": "error", "content": "..."} - Error message
|
|
167
|
+
- {"type": "pong"} - Keep-alive pong
|
|
168
|
+
"""
|
|
169
|
+
if not validate_project_name(project_name):
|
|
170
|
+
await websocket.close(code=4000, reason="Invalid project name")
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
# Look up project directory from registry
|
|
174
|
+
project_dir = _get_project_path(project_name)
|
|
175
|
+
if not project_dir:
|
|
176
|
+
await websocket.close(code=4004, reason="Project not found in registry")
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
if not project_dir.exists():
|
|
180
|
+
await websocket.close(code=4004, reason="Project directory not found")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
await websocket.accept()
|
|
184
|
+
|
|
185
|
+
session: Optional[SpecChatSession] = None
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
while True:
|
|
189
|
+
try:
|
|
190
|
+
# Receive message from client
|
|
191
|
+
data = await websocket.receive_text()
|
|
192
|
+
message = json.loads(data)
|
|
193
|
+
msg_type = message.get("type")
|
|
194
|
+
|
|
195
|
+
if msg_type == "ping":
|
|
196
|
+
await websocket.send_json({"type": "pong"})
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
elif msg_type == "start":
|
|
200
|
+
# Create and start a new session
|
|
201
|
+
session = await create_session(project_name, project_dir)
|
|
202
|
+
|
|
203
|
+
# Track spec completion state
|
|
204
|
+
spec_complete_received = False
|
|
205
|
+
spec_path = None
|
|
206
|
+
|
|
207
|
+
# Stream the initial greeting
|
|
208
|
+
async for chunk in session.start():
|
|
209
|
+
# Track spec_complete but don't send complete yet
|
|
210
|
+
if chunk.get("type") == "spec_complete":
|
|
211
|
+
spec_complete_received = True
|
|
212
|
+
spec_path = chunk.get("path")
|
|
213
|
+
await websocket.send_json(chunk)
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
# When response_done arrives, send complete if spec was done
|
|
217
|
+
if chunk.get("type") == "response_done":
|
|
218
|
+
await websocket.send_json(chunk)
|
|
219
|
+
if spec_complete_received:
|
|
220
|
+
await websocket.send_json({"type": "complete", "path": spec_path})
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
await websocket.send_json(chunk)
|
|
224
|
+
|
|
225
|
+
elif msg_type == "message":
|
|
226
|
+
# User sent a message
|
|
227
|
+
if not session:
|
|
228
|
+
session = get_session(project_name)
|
|
229
|
+
if not session:
|
|
230
|
+
await websocket.send_json({
|
|
231
|
+
"type": "error",
|
|
232
|
+
"content": "No active session. Send 'start' first."
|
|
233
|
+
})
|
|
234
|
+
continue
|
|
235
|
+
|
|
236
|
+
user_content = message.get("content", "").strip()
|
|
237
|
+
|
|
238
|
+
# Parse attachments if present
|
|
239
|
+
attachments: list[ImageAttachment] = []
|
|
240
|
+
raw_attachments = message.get("attachments", [])
|
|
241
|
+
if raw_attachments:
|
|
242
|
+
try:
|
|
243
|
+
for raw_att in raw_attachments:
|
|
244
|
+
attachments.append(ImageAttachment(**raw_att))
|
|
245
|
+
except (ValidationError, Exception) as e:
|
|
246
|
+
logger.warning(f"Invalid attachment data: {e}")
|
|
247
|
+
await websocket.send_json({
|
|
248
|
+
"type": "error",
|
|
249
|
+
"content": f"Invalid attachment: {str(e)}"
|
|
250
|
+
})
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
# Allow empty content if attachments are present
|
|
254
|
+
if not user_content and not attachments:
|
|
255
|
+
await websocket.send_json({
|
|
256
|
+
"type": "error",
|
|
257
|
+
"content": "Empty message"
|
|
258
|
+
})
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Track spec completion state
|
|
262
|
+
spec_complete_received = False
|
|
263
|
+
spec_path = None
|
|
264
|
+
|
|
265
|
+
# Stream Claude's response (with attachments if present)
|
|
266
|
+
async for chunk in session.send_message(user_content, attachments if attachments else None):
|
|
267
|
+
# Track spec_complete but don't send complete yet
|
|
268
|
+
if chunk.get("type") == "spec_complete":
|
|
269
|
+
spec_complete_received = True
|
|
270
|
+
spec_path = chunk.get("path")
|
|
271
|
+
await websocket.send_json(chunk)
|
|
272
|
+
continue
|
|
273
|
+
|
|
274
|
+
# When response_done arrives, send complete if spec was done
|
|
275
|
+
if chunk.get("type") == "response_done":
|
|
276
|
+
await websocket.send_json(chunk)
|
|
277
|
+
if spec_complete_received:
|
|
278
|
+
await websocket.send_json({"type": "complete", "path": spec_path})
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
await websocket.send_json(chunk)
|
|
282
|
+
|
|
283
|
+
elif msg_type == "answer":
|
|
284
|
+
# User answered a structured question
|
|
285
|
+
if not session:
|
|
286
|
+
session = get_session(project_name)
|
|
287
|
+
if not session:
|
|
288
|
+
await websocket.send_json({
|
|
289
|
+
"type": "error",
|
|
290
|
+
"content": "No active session"
|
|
291
|
+
})
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
# Format the answers as a natural response
|
|
295
|
+
answers = message.get("answers", {})
|
|
296
|
+
if isinstance(answers, dict):
|
|
297
|
+
# Convert structured answers to a message
|
|
298
|
+
response_parts = []
|
|
299
|
+
for question_idx, answer_value in answers.items():
|
|
300
|
+
if isinstance(answer_value, list):
|
|
301
|
+
response_parts.append(", ".join(answer_value))
|
|
302
|
+
else:
|
|
303
|
+
response_parts.append(str(answer_value))
|
|
304
|
+
user_response = "; ".join(response_parts) if response_parts else "OK"
|
|
305
|
+
else:
|
|
306
|
+
user_response = str(answers)
|
|
307
|
+
|
|
308
|
+
# Track spec completion state
|
|
309
|
+
spec_complete_received = False
|
|
310
|
+
spec_path = None
|
|
311
|
+
|
|
312
|
+
# Stream Claude's response
|
|
313
|
+
async for chunk in session.send_message(user_response):
|
|
314
|
+
# Track spec_complete but don't send complete yet
|
|
315
|
+
if chunk.get("type") == "spec_complete":
|
|
316
|
+
spec_complete_received = True
|
|
317
|
+
spec_path = chunk.get("path")
|
|
318
|
+
await websocket.send_json(chunk)
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
# When response_done arrives, send complete if spec was done
|
|
322
|
+
if chunk.get("type") == "response_done":
|
|
323
|
+
await websocket.send_json(chunk)
|
|
324
|
+
if spec_complete_received:
|
|
325
|
+
await websocket.send_json({"type": "complete", "path": spec_path})
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
await websocket.send_json(chunk)
|
|
329
|
+
|
|
330
|
+
else:
|
|
331
|
+
await websocket.send_json({
|
|
332
|
+
"type": "error",
|
|
333
|
+
"content": f"Unknown message type: {msg_type}"
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
except json.JSONDecodeError:
|
|
337
|
+
await websocket.send_json({
|
|
338
|
+
"type": "error",
|
|
339
|
+
"content": "Invalid JSON"
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
except WebSocketDisconnect:
|
|
343
|
+
logger.info(f"Spec chat WebSocket disconnected for {project_name}")
|
|
344
|
+
|
|
345
|
+
except Exception as e:
|
|
346
|
+
logger.exception(f"Spec chat WebSocket error for {project_name}")
|
|
347
|
+
try:
|
|
348
|
+
await websocket.send_json({
|
|
349
|
+
"type": "error",
|
|
350
|
+
"content": f"Server error: {str(e)}"
|
|
351
|
+
})
|
|
352
|
+
except Exception:
|
|
353
|
+
pass
|
|
354
|
+
|
|
355
|
+
finally:
|
|
356
|
+
# Don't remove the session on disconnect - allow resume
|
|
357
|
+
pass
|