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,399 @@
1
+ """
2
+ Expand Chat Session
3
+ ===================
4
+
5
+ Manages interactive project expansion conversation with Claude.
6
+ Uses the expand-project.md skill to help users add features to existing projects.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import sys
15
+ import threading
16
+ import uuid
17
+ from datetime import datetime
18
+ from pathlib import Path
19
+ from typing import Any, AsyncGenerator, Optional
20
+
21
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
22
+ from dotenv import load_dotenv
23
+
24
+ from ..schemas import ImageAttachment
25
+ from .chat_constants import API_ENV_VARS, ROOT_DIR, make_multimodal_message
26
+
27
+ # Load environment variables from .env file if present
28
+ load_dotenv()
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Feature MCP tools needed for expand session
33
+ EXPAND_FEATURE_TOOLS = [
34
+ "mcp__features__feature_create",
35
+ "mcp__features__feature_create_bulk",
36
+ "mcp__features__feature_get_stats",
37
+ ]
38
+
39
+
40
+ class ExpandChatSession:
41
+ """
42
+ Manages a project expansion conversation.
43
+
44
+ Unlike SpecChatSession which writes spec files, this session:
45
+ 1. Reads existing app_spec.txt for context
46
+ 2. Chats with the user to define new features
47
+ 3. Claude creates features via the feature_create_bulk MCP tool
48
+ """
49
+
50
+ def __init__(self, project_name: str, project_dir: Path):
51
+ """
52
+ Initialize the session.
53
+
54
+ Args:
55
+ project_name: Name of the project being expanded
56
+ project_dir: Absolute path to the project directory
57
+ """
58
+ self.project_name = project_name
59
+ self.project_dir = project_dir
60
+ self.client: Optional[ClaudeSDKClient] = None
61
+ self.messages: list[dict] = []
62
+ self.complete: bool = False
63
+ self.created_at = datetime.now()
64
+ self._conversation_id: Optional[str] = None
65
+ self._client_entered: bool = False
66
+ self.features_created: int = 0
67
+ self.created_feature_ids: list[int] = []
68
+ self._settings_file: Optional[Path] = None
69
+ self._query_lock = asyncio.Lock()
70
+
71
+ async def close(self) -> None:
72
+ """Clean up resources and close the Claude client."""
73
+ if self.client and self._client_entered:
74
+ try:
75
+ await self.client.__aexit__(None, None, None)
76
+ except Exception as e:
77
+ logger.warning(f"Error closing Claude client: {e}")
78
+ finally:
79
+ self._client_entered = False
80
+ self.client = None
81
+
82
+ # Clean up temporary settings file
83
+ if self._settings_file and self._settings_file.exists():
84
+ try:
85
+ self._settings_file.unlink()
86
+ except Exception as e:
87
+ logger.warning(f"Error removing settings file: {e}")
88
+
89
+ async def start(self) -> AsyncGenerator[dict, None]:
90
+ """
91
+ Initialize session and get initial greeting from Claude.
92
+
93
+ Yields message chunks as they stream in.
94
+ """
95
+ # Load the expand-project skill
96
+ skill_path = ROOT_DIR / ".claude" / "commands" / "expand-project.md"
97
+
98
+ if not skill_path.exists():
99
+ yield {
100
+ "type": "error",
101
+ "content": f"Expand project skill not found at {skill_path}"
102
+ }
103
+ return
104
+
105
+ # Verify project has existing spec
106
+ from autoforge_paths import get_prompts_dir
107
+ spec_path = get_prompts_dir(self.project_dir) / "app_spec.txt"
108
+ if not spec_path.exists():
109
+ yield {
110
+ "type": "error",
111
+ "content": "Project has no app_spec.txt. Please create it first using spec creation."
112
+ }
113
+ return
114
+
115
+ try:
116
+ skill_content = skill_path.read_text(encoding="utf-8")
117
+ except UnicodeDecodeError:
118
+ skill_content = skill_path.read_text(encoding="utf-8", errors="replace")
119
+
120
+ # Find and validate Claude CLI before creating temp files
121
+ system_cli = shutil.which("claude")
122
+ if not system_cli:
123
+ yield {
124
+ "type": "error",
125
+ "content": "Claude CLI not found. Please install it: npm install -g @anthropic-ai/claude-code"
126
+ }
127
+ return
128
+
129
+ # Create temporary security settings file (unique per session to avoid conflicts)
130
+ # Note: permission_mode="bypassPermissions" is safe here because:
131
+ # 1. Only Read/Glob file tools are allowed (no Write/Edit)
132
+ # 2. MCP tools are restricted to feature creation only
133
+ # 3. No Bash access - cannot execute arbitrary commands
134
+ security_settings = {
135
+ "sandbox": {"enabled": True},
136
+ "permissions": {
137
+ "defaultMode": "bypassPermissions",
138
+ "allow": [
139
+ "Read(./**)",
140
+ "Glob(./**)",
141
+ *EXPAND_FEATURE_TOOLS,
142
+ ],
143
+ },
144
+ }
145
+ from autoforge_paths import get_expand_settings_path
146
+ settings_file = get_expand_settings_path(self.project_dir, uuid.uuid4().hex)
147
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
148
+ self._settings_file = settings_file
149
+ with open(settings_file, "w", encoding="utf-8") as f:
150
+ json.dump(security_settings, f, indent=2)
151
+
152
+ # Replace $ARGUMENTS with absolute project path
153
+ project_path = str(self.project_dir.resolve())
154
+ system_prompt = skill_content.replace("$ARGUMENTS", project_path)
155
+
156
+ # Build environment overrides for API configuration
157
+ # Filter to only include vars that are actually set (non-None)
158
+ sdk_env: dict[str, str] = {}
159
+ for var in API_ENV_VARS:
160
+ value = os.getenv(var)
161
+ if value:
162
+ sdk_env[var] = value
163
+
164
+ # Determine model from environment or use default
165
+ # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
166
+ model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
167
+
168
+ # Build MCP servers config for feature creation
169
+ mcp_servers = {
170
+ "features": {
171
+ "command": sys.executable,
172
+ "args": ["-m", "mcp_server.feature_mcp"],
173
+ "env": {
174
+ "PROJECT_DIR": str(self.project_dir.resolve()),
175
+ "PYTHONPATH": str(ROOT_DIR.resolve()),
176
+ },
177
+ },
178
+ }
179
+
180
+ # Create Claude SDK client
181
+ try:
182
+ self.client = ClaudeSDKClient(
183
+ options=ClaudeAgentOptions(
184
+ model=model,
185
+ cli_path=system_cli,
186
+ system_prompt=system_prompt,
187
+ allowed_tools=[
188
+ "Read",
189
+ "Glob",
190
+ "Grep",
191
+ "WebFetch",
192
+ "WebSearch",
193
+ *EXPAND_FEATURE_TOOLS,
194
+ ],
195
+ mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
196
+ permission_mode="bypassPermissions",
197
+ max_turns=100,
198
+ cwd=str(self.project_dir.resolve()),
199
+ settings=str(settings_file.resolve()),
200
+ env=sdk_env,
201
+ )
202
+ )
203
+ await self.client.__aenter__()
204
+ self._client_entered = True
205
+ except Exception:
206
+ logger.exception("Failed to create Claude client")
207
+ yield {
208
+ "type": "error",
209
+ "content": "Failed to initialize Claude"
210
+ }
211
+ return
212
+
213
+ # Start the conversation
214
+ try:
215
+ async with self._query_lock:
216
+ async for chunk in self._query_claude("Begin the project expansion process."):
217
+ yield chunk
218
+ yield {"type": "response_done"}
219
+ except Exception:
220
+ logger.exception("Failed to start expand chat")
221
+ yield {
222
+ "type": "error",
223
+ "content": "Failed to start conversation"
224
+ }
225
+
226
+ async def send_message(
227
+ self,
228
+ user_message: str,
229
+ attachments: list[ImageAttachment] | None = None
230
+ ) -> AsyncGenerator[dict, None]:
231
+ """
232
+ Send user message and stream Claude's response.
233
+
234
+ Args:
235
+ user_message: The user's response
236
+ attachments: Optional list of image attachments
237
+
238
+ Yields:
239
+ Message chunks of various types:
240
+ - {"type": "text", "content": str}
241
+ - {"type": "features_created", "count": N, "features": [...]}
242
+ - {"type": "expansion_complete", "total_added": N}
243
+ - {"type": "error", "content": str}
244
+ """
245
+ if not self.client:
246
+ yield {
247
+ "type": "error",
248
+ "content": "Session not initialized. Call start() first."
249
+ }
250
+ return
251
+
252
+ # Store the user message
253
+ self.messages.append({
254
+ "role": "user",
255
+ "content": user_message,
256
+ "has_attachments": bool(attachments),
257
+ "timestamp": datetime.now().isoformat()
258
+ })
259
+
260
+ try:
261
+ # Use lock to prevent concurrent queries from corrupting the response stream
262
+ async with self._query_lock:
263
+ async for chunk in self._query_claude(user_message, attachments):
264
+ yield chunk
265
+ yield {"type": "response_done"}
266
+ except Exception:
267
+ logger.exception("Error during Claude query")
268
+ yield {
269
+ "type": "error",
270
+ "content": "Error while processing message"
271
+ }
272
+
273
+ async def _query_claude(
274
+ self,
275
+ message: str,
276
+ attachments: list[ImageAttachment] | None = None
277
+ ) -> AsyncGenerator[dict, None]:
278
+ """
279
+ Internal method to query Claude and stream responses.
280
+
281
+ Feature creation is handled by Claude calling the feature_create_bulk
282
+ MCP tool directly -- no text parsing needed.
283
+ """
284
+ if not self.client:
285
+ return
286
+
287
+ # Build the message content
288
+ if attachments and len(attachments) > 0:
289
+ content_blocks: list[dict[str, Any]] = []
290
+ if message:
291
+ content_blocks.append({"type": "text", "text": message})
292
+ for att in attachments:
293
+ content_blocks.append({
294
+ "type": "image",
295
+ "source": {
296
+ "type": "base64",
297
+ "media_type": att.mimeType,
298
+ "data": att.base64Data,
299
+ }
300
+ })
301
+ await self.client.query(make_multimodal_message(content_blocks))
302
+ logger.info(f"Sent multimodal message with {len(attachments)} image(s)")
303
+ else:
304
+ await self.client.query(message)
305
+
306
+ # Stream the response
307
+ async for msg in self.client.receive_response():
308
+ msg_type = type(msg).__name__
309
+
310
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
311
+ for block in msg.content:
312
+ block_type = type(block).__name__
313
+
314
+ if block_type == "TextBlock" and hasattr(block, "text"):
315
+ text = block.text
316
+ if text:
317
+ yield {"type": "text", "content": text}
318
+
319
+ self.messages.append({
320
+ "role": "assistant",
321
+ "content": text,
322
+ "timestamp": datetime.now().isoformat()
323
+ })
324
+
325
+ def get_features_created(self) -> int:
326
+ """Get the total number of features created in this session."""
327
+ return self.features_created
328
+
329
+ def is_complete(self) -> bool:
330
+ """Check if expansion session is complete."""
331
+ return self.complete
332
+
333
+ def get_messages(self) -> list[dict]:
334
+ """Get all messages in the conversation."""
335
+ return self.messages.copy()
336
+
337
+
338
+ # Session registry with thread safety
339
+ _expand_sessions: dict[str, ExpandChatSession] = {}
340
+ _expand_sessions_lock = threading.Lock()
341
+
342
+
343
+ def get_expand_session(project_name: str) -> Optional[ExpandChatSession]:
344
+ """Get an existing expansion session for a project."""
345
+ with _expand_sessions_lock:
346
+ return _expand_sessions.get(project_name)
347
+
348
+
349
+ async def create_expand_session(project_name: str, project_dir: Path) -> ExpandChatSession:
350
+ """Create a new expansion session for a project, closing any existing one."""
351
+ old_session: Optional[ExpandChatSession] = None
352
+
353
+ with _expand_sessions_lock:
354
+ old_session = _expand_sessions.pop(project_name, None)
355
+ session = ExpandChatSession(project_name, project_dir)
356
+ _expand_sessions[project_name] = session
357
+
358
+ if old_session:
359
+ try:
360
+ await old_session.close()
361
+ except Exception as e:
362
+ logger.warning(f"Error closing old expand session for {project_name}: {e}")
363
+
364
+ return session
365
+
366
+
367
+ async def remove_expand_session(project_name: str) -> None:
368
+ """Remove and close an expansion session."""
369
+ session: Optional[ExpandChatSession] = None
370
+
371
+ with _expand_sessions_lock:
372
+ session = _expand_sessions.pop(project_name, None)
373
+
374
+ if session:
375
+ try:
376
+ await session.close()
377
+ except Exception as e:
378
+ logger.warning(f"Error closing expand session for {project_name}: {e}")
379
+
380
+
381
+ def list_expand_sessions() -> list[str]:
382
+ """List all active expansion session project names."""
383
+ with _expand_sessions_lock:
384
+ return list(_expand_sessions.keys())
385
+
386
+
387
+ async def cleanup_all_expand_sessions() -> None:
388
+ """Close all active expansion sessions. Called on server shutdown."""
389
+ sessions_to_close: list[ExpandChatSession] = []
390
+
391
+ with _expand_sessions_lock:
392
+ sessions_to_close = list(_expand_sessions.values())
393
+ _expand_sessions.clear()
394
+
395
+ for session in sessions_to_close:
396
+ try:
397
+ await session.close()
398
+ except Exception as e:
399
+ logger.warning(f"Error closing expand session {session.project_name}: {e}")