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.
Files changed (84) hide show
  1. package/.claude/commands/check-code.md +32 -0
  2. package/.claude/commands/checkpoint.md +40 -0
  3. package/.claude/commands/create-spec.md +613 -0
  4. package/.claude/commands/expand-project.md +234 -0
  5. package/.claude/commands/gsd-to-autoforge-spec.md +10 -0
  6. package/.claude/commands/review-pr.md +75 -0
  7. package/.claude/templates/app_spec.template.txt +331 -0
  8. package/.claude/templates/coding_prompt.template.md +265 -0
  9. package/.claude/templates/initializer_prompt.template.md +354 -0
  10. package/.claude/templates/testing_prompt.template.md +146 -0
  11. package/.env.example +64 -0
  12. package/LICENSE.md +676 -0
  13. package/README.md +423 -0
  14. package/agent.py +444 -0
  15. package/api/__init__.py +10 -0
  16. package/api/database.py +536 -0
  17. package/api/dependency_resolver.py +449 -0
  18. package/api/migration.py +156 -0
  19. package/auth.py +83 -0
  20. package/autoforge_paths.py +315 -0
  21. package/autonomous_agent_demo.py +293 -0
  22. package/bin/autoforge.js +3 -0
  23. package/client.py +607 -0
  24. package/env_constants.py +27 -0
  25. package/examples/OPTIMIZE_CONFIG.md +230 -0
  26. package/examples/README.md +531 -0
  27. package/examples/org_config.yaml +172 -0
  28. package/examples/project_allowed_commands.yaml +139 -0
  29. package/lib/cli.js +791 -0
  30. package/mcp_server/__init__.py +1 -0
  31. package/mcp_server/feature_mcp.py +988 -0
  32. package/package.json +53 -0
  33. package/parallel_orchestrator.py +1800 -0
  34. package/progress.py +247 -0
  35. package/prompts.py +427 -0
  36. package/pyproject.toml +17 -0
  37. package/rate_limit_utils.py +132 -0
  38. package/registry.py +614 -0
  39. package/requirements-prod.txt +14 -0
  40. package/security.py +959 -0
  41. package/server/__init__.py +17 -0
  42. package/server/main.py +261 -0
  43. package/server/routers/__init__.py +32 -0
  44. package/server/routers/agent.py +177 -0
  45. package/server/routers/assistant_chat.py +327 -0
  46. package/server/routers/devserver.py +309 -0
  47. package/server/routers/expand_project.py +239 -0
  48. package/server/routers/features.py +746 -0
  49. package/server/routers/filesystem.py +514 -0
  50. package/server/routers/projects.py +524 -0
  51. package/server/routers/schedules.py +356 -0
  52. package/server/routers/settings.py +127 -0
  53. package/server/routers/spec_creation.py +357 -0
  54. package/server/routers/terminal.py +453 -0
  55. package/server/schemas.py +593 -0
  56. package/server/services/__init__.py +36 -0
  57. package/server/services/assistant_chat_session.py +496 -0
  58. package/server/services/assistant_database.py +304 -0
  59. package/server/services/chat_constants.py +57 -0
  60. package/server/services/dev_server_manager.py +557 -0
  61. package/server/services/expand_chat_session.py +399 -0
  62. package/server/services/process_manager.py +657 -0
  63. package/server/services/project_config.py +475 -0
  64. package/server/services/scheduler_service.py +683 -0
  65. package/server/services/spec_chat_session.py +502 -0
  66. package/server/services/terminal_manager.py +756 -0
  67. package/server/utils/__init__.py +1 -0
  68. package/server/utils/process_utils.py +134 -0
  69. package/server/utils/project_helpers.py +32 -0
  70. package/server/utils/validation.py +54 -0
  71. package/server/websocket.py +903 -0
  72. package/start.py +456 -0
  73. package/ui/dist/assets/index-8W_wmZzz.js +168 -0
  74. package/ui/dist/assets/index-B47Ubhox.css +1 -0
  75. package/ui/dist/assets/vendor-flow-CVNK-_lx.js +7 -0
  76. package/ui/dist/assets/vendor-query-BUABzP5o.js +1 -0
  77. package/ui/dist/assets/vendor-radix-DTNNCg2d.js +45 -0
  78. package/ui/dist/assets/vendor-react-qkC6yhPU.js +1 -0
  79. package/ui/dist/assets/vendor-utils-COeKbHgx.js +2 -0
  80. package/ui/dist/assets/vendor-xterm-DP_gxef0.js +16 -0
  81. package/ui/dist/index.html +23 -0
  82. package/ui/dist/ollama.png +0 -0
  83. package/ui/dist/vite.svg +6 -0
  84. 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