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,502 @@
1
+ """
2
+ Spec Creation Chat Session
3
+ ==========================
4
+
5
+ Manages interactive spec creation conversation with Claude.
6
+ Uses the create-spec.md skill to guide users through app spec creation.
7
+ """
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import shutil
13
+ import threading
14
+ from datetime import datetime
15
+ from pathlib import Path
16
+ from typing import Any, AsyncGenerator, Optional
17
+
18
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
19
+ from dotenv import load_dotenv
20
+
21
+ from ..schemas import ImageAttachment
22
+ from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
23
+
24
+ # Load environment variables from .env file if present
25
+ load_dotenv()
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ class SpecChatSession:
31
+ """
32
+ Manages a spec creation conversation for one project.
33
+
34
+ Uses the create-spec skill to guide users through:
35
+ - Phase 1: Project Overview (name, description, audience)
36
+ - Phase 2: Involvement Level (Quick vs Detailed mode)
37
+ - Phase 3: Technology Preferences
38
+ - Phase 4: Features (main exploration phase)
39
+ - Phase 5: Technical Details (derived or discussed)
40
+ - Phase 6-7: Success Criteria & Approval
41
+ """
42
+
43
+ def __init__(self, project_name: str, project_dir: Path):
44
+ """
45
+ Initialize the session.
46
+
47
+ Args:
48
+ project_name: Name of the project being created
49
+ project_dir: Absolute path to the project directory
50
+ """
51
+ self.project_name = project_name
52
+ self.project_dir = project_dir
53
+ self.client: Optional[ClaudeSDKClient] = None
54
+ self.messages: list[dict] = []
55
+ self.complete: bool = False
56
+ self.created_at = datetime.now()
57
+ self._conversation_id: Optional[str] = None
58
+ self._client_entered: bool = False # Track if context manager is active
59
+
60
+ async def close(self) -> None:
61
+ """Clean up resources and close the Claude client."""
62
+ if self.client and self._client_entered:
63
+ try:
64
+ await self.client.__aexit__(None, None, None)
65
+ except Exception as e:
66
+ logger.warning(f"Error closing Claude client: {e}")
67
+ finally:
68
+ self._client_entered = False
69
+ self.client = None
70
+
71
+ async def start(self) -> AsyncGenerator[dict, None]:
72
+ """
73
+ Initialize session and get initial greeting from Claude.
74
+
75
+ Yields message chunks as they stream in.
76
+ """
77
+ # Load the create-spec skill
78
+ skill_path = ROOT_DIR / ".claude" / "commands" / "create-spec.md"
79
+
80
+ if not skill_path.exists():
81
+ yield {
82
+ "type": "error",
83
+ "content": f"Spec creation skill not found at {skill_path}"
84
+ }
85
+ return
86
+
87
+ try:
88
+ skill_content = skill_path.read_text(encoding="utf-8")
89
+ except UnicodeDecodeError:
90
+ skill_content = skill_path.read_text(encoding="utf-8", errors="replace")
91
+
92
+ # Ensure project directory exists (like CLI does in start.py)
93
+ self.project_dir.mkdir(parents=True, exist_ok=True)
94
+
95
+ # Delete app_spec.txt so Claude can create it fresh
96
+ # The SDK requires reading existing files before writing, but app_spec.txt is created new
97
+ # Note: We keep initializer_prompt.md so Claude can read and update the template
98
+ from autoforge_paths import get_prompts_dir
99
+ prompts_dir = get_prompts_dir(self.project_dir)
100
+ app_spec_path = prompts_dir / "app_spec.txt"
101
+ if app_spec_path.exists():
102
+ app_spec_path.unlink()
103
+ logger.info("Deleted scaffolded app_spec.txt for fresh spec creation")
104
+
105
+ # Create security settings file (like client.py does)
106
+ # This grants permissions for file operations in the project directory
107
+ security_settings = {
108
+ "sandbox": {"enabled": False}, # Disable sandbox for spec creation
109
+ "permissions": {
110
+ "defaultMode": "acceptEdits",
111
+ "allow": [
112
+ "Read(./**)",
113
+ "Write(./**)",
114
+ "Edit(./**)",
115
+ "Glob(./**)",
116
+ ],
117
+ },
118
+ }
119
+ from autoforge_paths import get_claude_settings_path
120
+ settings_file = get_claude_settings_path(self.project_dir)
121
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
122
+ with open(settings_file, "w") as f:
123
+ json.dump(security_settings, f, indent=2)
124
+
125
+ # Replace $ARGUMENTS with absolute project path (like CLI does in start.py:184)
126
+ # Using absolute path avoids confusion when project folder name differs from app name
127
+ project_path = str(self.project_dir.resolve())
128
+ system_prompt = skill_content.replace("$ARGUMENTS", project_path)
129
+
130
+ # Write system prompt to CLAUDE.md file to avoid Windows command line length limit
131
+ # The SDK will read this via setting_sources=["project"]
132
+ claude_md_path = self.project_dir / "CLAUDE.md"
133
+ with open(claude_md_path, "w", encoding="utf-8") as f:
134
+ f.write(system_prompt)
135
+ logger.info(f"Wrote system prompt to {claude_md_path}")
136
+
137
+ # Create Claude SDK client with limited tools for spec creation
138
+ # Use Opus for best quality spec generation
139
+ # Use system Claude CLI to avoid bundled Bun runtime crash (exit code 3) on Windows
140
+ system_cli = shutil.which("claude")
141
+
142
+ # Build environment overrides for API configuration
143
+ # Filter to only include vars that are actually set (non-None)
144
+ sdk_env: dict[str, str] = {}
145
+ for var in API_ENV_VARS:
146
+ value = os.getenv(var)
147
+ if value:
148
+ sdk_env[var] = value
149
+
150
+ # Determine model from environment or use default
151
+ # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
152
+ model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
153
+
154
+ try:
155
+ self.client = ClaudeSDKClient(
156
+ options=ClaudeAgentOptions(
157
+ model=model,
158
+ cli_path=system_cli,
159
+ # System prompt loaded from CLAUDE.md via setting_sources
160
+ # Include "user" for global skills and subagents from ~/.claude/
161
+ setting_sources=["project", "user"],
162
+ allowed_tools=[
163
+ "Read",
164
+ "Write",
165
+ "Edit",
166
+ "Glob",
167
+ "WebFetch",
168
+ "WebSearch",
169
+ ],
170
+ permission_mode="acceptEdits", # Auto-approve file writes for spec creation
171
+ max_turns=100,
172
+ cwd=str(self.project_dir.resolve()),
173
+ settings=str(settings_file.resolve()),
174
+ env=sdk_env,
175
+ )
176
+ )
177
+ # Enter the async context and track it
178
+ await self.client.__aenter__()
179
+ self._client_entered = True
180
+ except Exception as e:
181
+ logger.exception("Failed to create Claude client")
182
+ yield {
183
+ "type": "error",
184
+ "content": f"Failed to initialize Claude: {str(e)}"
185
+ }
186
+ return
187
+
188
+ # Start the conversation - Claude will send the Phase 1 greeting
189
+ try:
190
+ async for chunk in self._query_claude("Begin the spec creation process."):
191
+ yield chunk
192
+ # Signal that the response is complete (for UI to hide loading indicator)
193
+ yield {"type": "response_done"}
194
+ except Exception as e:
195
+ logger.exception("Failed to start spec chat")
196
+ yield {
197
+ "type": "error",
198
+ "content": f"Failed to start conversation: {str(e)}"
199
+ }
200
+
201
+ async def send_message(
202
+ self,
203
+ user_message: str,
204
+ attachments: list[ImageAttachment] | None = None
205
+ ) -> AsyncGenerator[dict, None]:
206
+ """
207
+ Send user message and stream Claude's response.
208
+
209
+ Args:
210
+ user_message: The user's response
211
+ attachments: Optional list of image attachments
212
+
213
+ Yields:
214
+ Message chunks of various types:
215
+ - {"type": "text", "content": str}
216
+ - {"type": "question", "questions": list}
217
+ - {"type": "spec_complete", "path": str}
218
+ - {"type": "error", "content": str}
219
+ """
220
+ if not self.client:
221
+ yield {
222
+ "type": "error",
223
+ "content": "Session not initialized. Call start() first."
224
+ }
225
+ return
226
+
227
+ # Store the user message
228
+ self.messages.append({
229
+ "role": "user",
230
+ "content": user_message,
231
+ "has_attachments": bool(attachments),
232
+ "timestamp": datetime.now().isoformat()
233
+ })
234
+
235
+ try:
236
+ async for chunk in self._query_claude(user_message, attachments):
237
+ yield chunk
238
+ # Signal that the response is complete (for UI to hide loading indicator)
239
+ yield {"type": "response_done"}
240
+ except Exception as e:
241
+ logger.exception("Error during Claude query")
242
+ yield {
243
+ "type": "error",
244
+ "content": f"Error: {str(e)}"
245
+ }
246
+
247
+ async def _query_claude(
248
+ self,
249
+ message: str,
250
+ attachments: list[ImageAttachment] | None = None
251
+ ) -> AsyncGenerator[dict, None]:
252
+ """
253
+ Internal method to query Claude and stream responses.
254
+
255
+ Handles tool calls (Write) and text responses.
256
+ Supports multimodal content with image attachments.
257
+
258
+ IMPORTANT: Spec creation requires BOTH files to be written:
259
+ 1. app_spec.txt - the main specification
260
+ 2. initializer_prompt.md - tells the agent how many features to create
261
+
262
+ We only signal spec_complete when BOTH files are verified on disk.
263
+ """
264
+ if not self.client:
265
+ return
266
+
267
+ # Build the message content
268
+ if attachments and len(attachments) > 0:
269
+ # Multimodal message: build content blocks array
270
+ content_blocks: list[dict[str, Any]] = []
271
+
272
+ # Add text block if there's text
273
+ if message:
274
+ content_blocks.append({"type": "text", "text": message})
275
+
276
+ # Add image blocks
277
+ for att in attachments:
278
+ content_blocks.append({
279
+ "type": "image",
280
+ "source": {
281
+ "type": "base64",
282
+ "media_type": att.mimeType,
283
+ "data": att.base64Data,
284
+ }
285
+ })
286
+
287
+ # Send multimodal content to Claude using async generator format
288
+ # The SDK's query() accepts AsyncIterable[dict] for custom message formats
289
+ await self.client.query(make_multimodal_message(content_blocks))
290
+ logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
291
+ else:
292
+ # Text-only message: use string format
293
+ await self.client.query(message)
294
+
295
+ current_text = ""
296
+
297
+ # Track pending writes for BOTH required files
298
+ pending_writes: dict[str, dict[str, Any] | None] = {
299
+ "app_spec": None, # {"tool_id": ..., "path": ...}
300
+ "initializer": None, # {"tool_id": ..., "path": ...}
301
+ }
302
+
303
+ # Track which files have been successfully written
304
+ files_written = {
305
+ "app_spec": False,
306
+ "initializer": False,
307
+ }
308
+
309
+ # Store paths for the completion message
310
+ spec_path = None
311
+
312
+ # Stream the response using receive_response
313
+ async for msg in self.client.receive_response():
314
+ msg_type = type(msg).__name__
315
+
316
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
317
+ # Process content blocks in the assistant message
318
+ for block in msg.content:
319
+ block_type = type(block).__name__
320
+
321
+ if block_type == "TextBlock" and hasattr(block, "text"):
322
+ # Accumulate text and yield it
323
+ text = block.text
324
+ if text:
325
+ current_text += text
326
+ yield {"type": "text", "content": text}
327
+
328
+ # Store in message history
329
+ self.messages.append({
330
+ "role": "assistant",
331
+ "content": text,
332
+ "timestamp": datetime.now().isoformat()
333
+ })
334
+
335
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
336
+ tool_name = block.name
337
+ tool_input = getattr(block, "input", {})
338
+ tool_id = getattr(block, "id", "")
339
+
340
+ if tool_name in ("Write", "Edit"):
341
+ # File being written or edited - track for verification
342
+ file_path = tool_input.get("file_path", "")
343
+
344
+ # Track app_spec.txt
345
+ if "app_spec.txt" in str(file_path):
346
+ pending_writes["app_spec"] = {
347
+ "tool_id": tool_id,
348
+ "path": file_path
349
+ }
350
+ logger.info(f"{tool_name} tool called for app_spec.txt: {file_path}")
351
+
352
+ # Track initializer_prompt.md
353
+ elif "initializer_prompt.md" in str(file_path):
354
+ pending_writes["initializer"] = {
355
+ "tool_id": tool_id,
356
+ "path": file_path
357
+ }
358
+ logger.info(f"{tool_name} tool called for initializer_prompt.md: {file_path}")
359
+
360
+ elif msg_type == "UserMessage" and hasattr(msg, "content"):
361
+ # Tool results - check for write confirmations and errors
362
+ for block in msg.content:
363
+ block_type = type(block).__name__
364
+ if block_type == "ToolResultBlock":
365
+ is_error = getattr(block, "is_error", False)
366
+ tool_use_id = getattr(block, "tool_use_id", "")
367
+
368
+ if is_error:
369
+ content = getattr(block, "content", "Unknown error")
370
+ logger.warning(f"Tool error: {content}")
371
+ # Clear any pending writes that failed
372
+ for key in pending_writes:
373
+ pending_write = pending_writes[key]
374
+ if pending_write is not None and tool_use_id == pending_write.get("tool_id"):
375
+ logger.error(f"{key} write failed: {content}")
376
+ pending_writes[key] = None
377
+ else:
378
+ # Tool succeeded - check which file was written
379
+
380
+ # Check app_spec.txt
381
+ if pending_writes["app_spec"] and tool_use_id == pending_writes["app_spec"].get("tool_id"):
382
+ file_path = pending_writes["app_spec"]["path"]
383
+ full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
384
+ if full_path.exists():
385
+ logger.info(f"app_spec.txt verified at: {full_path}")
386
+ files_written["app_spec"] = True
387
+ spec_path = file_path
388
+
389
+ # Notify about file write (but NOT completion yet)
390
+ yield {
391
+ "type": "file_written",
392
+ "path": str(file_path)
393
+ }
394
+ else:
395
+ logger.error(f"app_spec.txt not found after write: {full_path}")
396
+ pending_writes["app_spec"] = None
397
+
398
+ # Check initializer_prompt.md
399
+ if pending_writes["initializer"] and tool_use_id == pending_writes["initializer"].get("tool_id"):
400
+ file_path = pending_writes["initializer"]["path"]
401
+ full_path = Path(file_path) if Path(file_path).is_absolute() else self.project_dir / file_path
402
+ if full_path.exists():
403
+ logger.info(f"initializer_prompt.md verified at: {full_path}")
404
+ files_written["initializer"] = True
405
+
406
+ # Notify about file write
407
+ yield {
408
+ "type": "file_written",
409
+ "path": str(file_path)
410
+ }
411
+ else:
412
+ logger.error(f"initializer_prompt.md not found after write: {full_path}")
413
+ pending_writes["initializer"] = None
414
+
415
+ # Check if BOTH files are now written - only then signal completion
416
+ if files_written["app_spec"] and files_written["initializer"]:
417
+ logger.info("Both app_spec.txt and initializer_prompt.md verified - signaling completion")
418
+ self.complete = True
419
+ yield {
420
+ "type": "spec_complete",
421
+ "path": str(spec_path)
422
+ }
423
+
424
+ def is_complete(self) -> bool:
425
+ """Check if spec creation is complete."""
426
+ return self.complete
427
+
428
+ def get_messages(self) -> list[dict]:
429
+ """Get all messages in the conversation."""
430
+ return self.messages.copy()
431
+
432
+
433
+ # Session registry with thread safety
434
+ _sessions: dict[str, SpecChatSession] = {}
435
+ _sessions_lock = threading.Lock()
436
+
437
+
438
+ def get_session(project_name: str) -> Optional[SpecChatSession]:
439
+ """Get an existing session for a project."""
440
+ with _sessions_lock:
441
+ return _sessions.get(project_name)
442
+
443
+
444
+ async def create_session(project_name: str, project_dir: Path) -> SpecChatSession:
445
+ """Create a new session for a project, closing any existing one.
446
+
447
+ Args:
448
+ project_name: Name of the project
449
+ project_dir: Absolute path to the project directory
450
+ """
451
+ old_session: Optional[SpecChatSession] = None
452
+
453
+ with _sessions_lock:
454
+ # Get existing session to close later (outside the lock)
455
+ old_session = _sessions.pop(project_name, None)
456
+ session = SpecChatSession(project_name, project_dir)
457
+ _sessions[project_name] = session
458
+
459
+ # Close old session outside the lock to avoid blocking
460
+ if old_session:
461
+ try:
462
+ await old_session.close()
463
+ except Exception as e:
464
+ logger.warning(f"Error closing old session for {project_name}: {e}")
465
+
466
+ return session
467
+
468
+
469
+ async def remove_session(project_name: str) -> None:
470
+ """Remove and close a session."""
471
+ session: Optional[SpecChatSession] = None
472
+
473
+ with _sessions_lock:
474
+ session = _sessions.pop(project_name, None)
475
+
476
+ # Close session outside the lock
477
+ if session:
478
+ try:
479
+ await session.close()
480
+ except Exception as e:
481
+ logger.warning(f"Error closing session for {project_name}: {e}")
482
+
483
+
484
+ def list_sessions() -> list[str]:
485
+ """List all active session project names."""
486
+ with _sessions_lock:
487
+ return list(_sessions.keys())
488
+
489
+
490
+ async def cleanup_all_sessions() -> None:
491
+ """Close all active sessions. Called on server shutdown."""
492
+ sessions_to_close: list[SpecChatSession] = []
493
+
494
+ with _sessions_lock:
495
+ sessions_to_close = list(_sessions.values())
496
+ _sessions.clear()
497
+
498
+ for session in sessions_to_close:
499
+ try:
500
+ await session.close()
501
+ except Exception as e:
502
+ logger.warning(f"Error closing session {session.project_name}: {e}")