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,496 @@
1
+ """
2
+ Assistant Chat Session
3
+ ======================
4
+
5
+ Manages read-only conversational assistant sessions for projects.
6
+ The assistant can answer questions about the codebase and features
7
+ but cannot modify any files.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import sys
15
+ import threading
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import AsyncGenerator, Optional
19
+
20
+ from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
21
+ from dotenv import load_dotenv
22
+
23
+ from .assistant_database import (
24
+ add_message,
25
+ create_conversation,
26
+ get_messages,
27
+ )
28
+ from .chat_constants import API_ENV_VARS, ROOT_DIR
29
+
30
+ # Load environment variables from .env file if present
31
+ load_dotenv()
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Read-only feature MCP tools
36
+ READONLY_FEATURE_MCP_TOOLS = [
37
+ "mcp__features__feature_get_stats",
38
+ "mcp__features__feature_get_by_id",
39
+ "mcp__features__feature_get_ready",
40
+ "mcp__features__feature_get_blocked",
41
+ ]
42
+
43
+ # Feature management tools (create/skip but not mark_passing)
44
+ FEATURE_MANAGEMENT_TOOLS = [
45
+ "mcp__features__feature_create",
46
+ "mcp__features__feature_create_bulk",
47
+ "mcp__features__feature_skip",
48
+ ]
49
+
50
+ # Combined list for assistant
51
+ ASSISTANT_FEATURE_TOOLS = READONLY_FEATURE_MCP_TOOLS + FEATURE_MANAGEMENT_TOOLS
52
+
53
+ # Read-only built-in tools (no Write, Edit, Bash)
54
+ READONLY_BUILTIN_TOOLS = [
55
+ "Read",
56
+ "Glob",
57
+ "Grep",
58
+ "WebFetch",
59
+ "WebSearch",
60
+ ]
61
+
62
+
63
+ def get_system_prompt(project_name: str, project_dir: Path) -> str:
64
+ """Generate the system prompt for the assistant with project context."""
65
+ # Try to load app_spec.txt for context
66
+ app_spec_content = ""
67
+ from autoforge_paths import get_prompts_dir
68
+ app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt"
69
+ if app_spec_path.exists():
70
+ try:
71
+ app_spec_content = app_spec_path.read_text(encoding="utf-8")
72
+ # Truncate if too long
73
+ if len(app_spec_content) > 5000:
74
+ app_spec_content = app_spec_content[:5000] + "\n... (truncated)"
75
+ except Exception as e:
76
+ logger.warning(f"Failed to read app_spec.txt: {e}")
77
+
78
+ return f"""You are a helpful project assistant and backlog manager for the "{project_name}" project.
79
+
80
+ Your role is to help users understand the codebase, answer questions about features, and manage the project backlog. You can READ files and CREATE/MANAGE features, but you cannot modify source code.
81
+
82
+ You have MCP tools available for feature management. Use them directly by calling the tool -- do not suggest CLI commands, bash commands, or curl commands to the user. You can create features yourself using the feature_create and feature_create_bulk tools.
83
+
84
+ ## What You CAN Do
85
+
86
+ **Codebase Analysis (Read-Only):**
87
+ - Read and analyze source code files
88
+ - Search for patterns in the codebase
89
+ - Look up documentation online
90
+ - Check feature progress and status
91
+
92
+ **Feature Management:**
93
+ - Create new features/test cases in the backlog
94
+ - Skip features to deprioritize them (move to end of queue)
95
+ - View feature statistics and progress
96
+
97
+ ## What You CANNOT Do
98
+
99
+ - Modify, create, or delete source code files
100
+ - Mark features as passing (that requires actual implementation by the coding agent)
101
+ - Run bash commands or execute code
102
+
103
+ If the user asks you to modify code, explain that you're a project assistant and they should use the main coding agent for implementation.
104
+
105
+ ## Project Specification
106
+
107
+ {app_spec_content if app_spec_content else "(No app specification found)"}
108
+
109
+ ## Available Tools
110
+
111
+ **Code Analysis:**
112
+ - **Read**: Read file contents
113
+ - **Glob**: Find files by pattern (e.g., "**/*.tsx")
114
+ - **Grep**: Search file contents with regex
115
+ - **WebFetch/WebSearch**: Look up documentation online
116
+
117
+ **Feature Management:**
118
+ - **feature_get_stats**: Get feature completion progress
119
+ - **feature_get_by_id**: Get details for a specific feature
120
+ - **feature_get_ready**: See features ready for implementation
121
+ - **feature_get_blocked**: See features blocked by dependencies
122
+ - **feature_create**: Create a single feature in the backlog
123
+ - **feature_create_bulk**: Create multiple features at once
124
+ - **feature_skip**: Move a feature to the end of the queue
125
+
126
+ ## Creating Features
127
+
128
+ When a user asks to add a feature, use the `feature_create` or `feature_create_bulk` MCP tools directly:
129
+
130
+ For a **single feature**, call `feature_create` with:
131
+ - category: A grouping like "Authentication", "API", "UI", "Database"
132
+ - name: A concise, descriptive name
133
+ - description: What the feature should do
134
+ - steps: List of verification/implementation steps
135
+
136
+ For **multiple features**, call `feature_create_bulk` with an array of feature objects.
137
+
138
+ You can ask clarifying questions if the user's request is vague, or make reasonable assumptions for simple requests.
139
+
140
+ **Example interaction:**
141
+ User: "Add a feature for S3 sync"
142
+ You: I'll create that feature now.
143
+ [calls feature_create with appropriate parameters]
144
+ You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board.
145
+
146
+ ## Guidelines
147
+
148
+ 1. Be concise and helpful
149
+ 2. When explaining code, reference specific file paths and line numbers
150
+ 3. Use the feature tools to answer questions about project progress
151
+ 4. Search the codebase to find relevant information before answering
152
+ 5. When creating features, confirm what was created
153
+ 6. If you're unsure about details, ask for clarification"""
154
+
155
+
156
+ class AssistantChatSession:
157
+ """
158
+ Manages a read-only assistant conversation for a project.
159
+
160
+ Uses Claude Opus 4.5 with only read-only tools enabled.
161
+ Persists conversation history to SQLite.
162
+ """
163
+
164
+ def __init__(self, project_name: str, project_dir: Path, conversation_id: Optional[int] = None):
165
+ """
166
+ Initialize the session.
167
+
168
+ Args:
169
+ project_name: Name of the project
170
+ project_dir: Absolute path to the project directory
171
+ conversation_id: Optional existing conversation ID to resume
172
+ """
173
+ self.project_name = project_name
174
+ self.project_dir = project_dir
175
+ self.conversation_id = conversation_id
176
+ self.client: Optional[ClaudeSDKClient] = None
177
+ self._client_entered: bool = False
178
+ self.created_at = datetime.now()
179
+ self._history_loaded: bool = False # Track if we've loaded history for resumed conversations
180
+
181
+ async def close(self) -> None:
182
+ """Clean up resources and close the Claude client."""
183
+ if self.client and self._client_entered:
184
+ try:
185
+ await self.client.__aexit__(None, None, None)
186
+ except Exception as e:
187
+ logger.warning(f"Error closing Claude client: {e}")
188
+ finally:
189
+ self._client_entered = False
190
+ self.client = None
191
+
192
+ async def start(self) -> AsyncGenerator[dict, None]:
193
+ """
194
+ Initialize session with the Claude client.
195
+
196
+ Creates a new conversation if none exists, then sends an initial greeting.
197
+ For resumed conversations, skips the greeting since history is loaded from DB.
198
+ Yields message chunks as they stream in.
199
+ """
200
+ # Track if this is a new conversation (for greeting decision)
201
+ is_new_conversation = self.conversation_id is None
202
+
203
+ # Create a new conversation if we don't have one
204
+ if is_new_conversation:
205
+ conv = create_conversation(self.project_dir, self.project_name)
206
+ self.conversation_id = int(conv.id) # type coercion: Column[int] -> int
207
+ yield {"type": "conversation_created", "conversation_id": self.conversation_id}
208
+
209
+ # Build permissions list for assistant access (read + feature management)
210
+ permissions_list = [
211
+ "Read(./**)",
212
+ "Glob(./**)",
213
+ "Grep(./**)",
214
+ "WebFetch",
215
+ "WebSearch",
216
+ *ASSISTANT_FEATURE_TOOLS,
217
+ ]
218
+
219
+ # Create security settings file
220
+ security_settings = {
221
+ "sandbox": {"enabled": False}, # No bash, so sandbox not needed
222
+ "permissions": {
223
+ "defaultMode": "bypassPermissions", # Read-only, no dangerous ops
224
+ "allow": permissions_list,
225
+ },
226
+ }
227
+ from autoforge_paths import get_claude_assistant_settings_path
228
+ settings_file = get_claude_assistant_settings_path(self.project_dir)
229
+ settings_file.parent.mkdir(parents=True, exist_ok=True)
230
+ with open(settings_file, "w") as f:
231
+ json.dump(security_settings, f, indent=2)
232
+
233
+ # Build MCP servers config - only features MCP for read-only access
234
+ mcp_servers = {
235
+ "features": {
236
+ "command": sys.executable,
237
+ "args": ["-m", "mcp_server.feature_mcp"],
238
+ "env": {
239
+ # Only specify variables the MCP server needs
240
+ # (subprocess inherits parent environment automatically)
241
+ "PROJECT_DIR": str(self.project_dir.resolve()),
242
+ "PYTHONPATH": str(ROOT_DIR.resolve()),
243
+ },
244
+ },
245
+ }
246
+
247
+ # Get system prompt with project context
248
+ system_prompt = get_system_prompt(self.project_name, self.project_dir)
249
+
250
+ # Write system prompt to CLAUDE.md file to avoid Windows command line length limit
251
+ # The SDK will read this via setting_sources=["project"]
252
+ claude_md_path = self.project_dir / "CLAUDE.md"
253
+ with open(claude_md_path, "w", encoding="utf-8") as f:
254
+ f.write(system_prompt)
255
+ logger.info(f"Wrote assistant system prompt to {claude_md_path}")
256
+
257
+ # Use system Claude CLI
258
+ system_cli = shutil.which("claude")
259
+
260
+ # Build environment overrides for API configuration
261
+ sdk_env: dict[str, str] = {}
262
+ for var in API_ENV_VARS:
263
+ value = os.getenv(var)
264
+ if value:
265
+ sdk_env[var] = value
266
+
267
+ # Determine model from environment or use default
268
+ # This allows using alternative APIs (e.g., GLM via z.ai) that may not support Claude model names
269
+ model = os.getenv("ANTHROPIC_DEFAULT_OPUS_MODEL", "claude-opus-4-5-20251101")
270
+
271
+ try:
272
+ logger.info("Creating ClaudeSDKClient...")
273
+ self.client = ClaudeSDKClient(
274
+ options=ClaudeAgentOptions(
275
+ model=model,
276
+ cli_path=system_cli,
277
+ # System prompt loaded from CLAUDE.md via setting_sources
278
+ # This avoids Windows command line length limit (~8191 chars)
279
+ setting_sources=["project"],
280
+ allowed_tools=[*READONLY_BUILTIN_TOOLS, *ASSISTANT_FEATURE_TOOLS],
281
+ mcp_servers=mcp_servers, # type: ignore[arg-type] # SDK accepts dict config at runtime
282
+ permission_mode="bypassPermissions",
283
+ max_turns=100,
284
+ cwd=str(self.project_dir.resolve()),
285
+ settings=str(settings_file.resolve()),
286
+ env=sdk_env,
287
+ )
288
+ )
289
+ logger.info("Entering Claude client context...")
290
+ await self.client.__aenter__()
291
+ self._client_entered = True
292
+ logger.info("Claude client ready")
293
+ except Exception as e:
294
+ logger.exception("Failed to create Claude client")
295
+ yield {"type": "error", "content": f"Failed to initialize assistant: {str(e)}"}
296
+ return
297
+
298
+ # Send initial greeting only for NEW conversations
299
+ # Resumed conversations already have history loaded from the database
300
+ if is_new_conversation:
301
+ # New conversations don't need history loading
302
+ self._history_loaded = True
303
+ try:
304
+ greeting = f"Hello! I'm your project assistant for **{self.project_name}**. I can help you understand the codebase, explain features, and answer questions about the project. What would you like to know?"
305
+
306
+ # Store the greeting in the database
307
+ # conversation_id is guaranteed non-None here (set on line 206 above)
308
+ assert self.conversation_id is not None
309
+ add_message(self.project_dir, self.conversation_id, "assistant", greeting)
310
+
311
+ yield {"type": "text", "content": greeting}
312
+ yield {"type": "response_done"}
313
+ except Exception as e:
314
+ logger.exception("Failed to send greeting")
315
+ yield {"type": "error", "content": f"Failed to start conversation: {str(e)}"}
316
+ else:
317
+ # For resumed conversations, history will be loaded on first message
318
+ # _history_loaded stays False so send_message() will include history
319
+ yield {"type": "response_done"}
320
+
321
+ async def send_message(self, user_message: str) -> AsyncGenerator[dict, None]:
322
+ """
323
+ Send user message and stream Claude's response.
324
+
325
+ Args:
326
+ user_message: The user's message
327
+
328
+ Yields:
329
+ Message chunks:
330
+ - {"type": "text", "content": str}
331
+ - {"type": "tool_call", "tool": str, "input": dict}
332
+ - {"type": "response_done"}
333
+ - {"type": "error", "content": str}
334
+ """
335
+ if not self.client:
336
+ yield {"type": "error", "content": "Session not initialized. Call start() first."}
337
+ return
338
+
339
+ if self.conversation_id is None:
340
+ yield {"type": "error", "content": "No conversation ID set."}
341
+ return
342
+
343
+ # Store user message in database
344
+ add_message(self.project_dir, self.conversation_id, "user", user_message)
345
+
346
+ # For resumed conversations, include history context in first message
347
+ message_to_send = user_message
348
+ if not self._history_loaded:
349
+ self._history_loaded = True
350
+ history = get_messages(self.project_dir, self.conversation_id)
351
+ # Exclude the message we just added (last one)
352
+ history = history[:-1] if history else []
353
+ # Cap history to last 35 messages to prevent context overload
354
+ history = history[-35:] if len(history) > 35 else history
355
+ if history:
356
+ # Format history as context for Claude
357
+ history_lines = ["[Previous conversation history for context:]"]
358
+ for msg in history:
359
+ role = "User" if msg["role"] == "user" else "Assistant"
360
+ content = msg["content"]
361
+ # Truncate very long messages
362
+ if len(content) > 500:
363
+ content = content[:500] + "..."
364
+ history_lines.append(f"{role}: {content}")
365
+ history_lines.append("[End of history. Continue the conversation:]")
366
+ history_lines.append(f"User: {user_message}")
367
+ message_to_send = "\n".join(history_lines)
368
+ logger.info(f"Loaded {len(history)} messages from conversation history")
369
+
370
+ try:
371
+ async for chunk in self._query_claude(message_to_send):
372
+ yield chunk
373
+ yield {"type": "response_done"}
374
+ except Exception as e:
375
+ logger.exception("Error during Claude query")
376
+ yield {"type": "error", "content": f"Error: {str(e)}"}
377
+
378
+ async def _query_claude(self, message: str) -> AsyncGenerator[dict, None]:
379
+ """
380
+ Internal method to query Claude and stream responses.
381
+
382
+ Handles tool calls and text responses.
383
+ """
384
+ if not self.client:
385
+ return
386
+
387
+ # Send message to Claude
388
+ await self.client.query(message)
389
+
390
+ full_response = ""
391
+
392
+ # Stream the response
393
+ async for msg in self.client.receive_response():
394
+ msg_type = type(msg).__name__
395
+
396
+ if msg_type == "AssistantMessage" and hasattr(msg, "content"):
397
+ for block in msg.content:
398
+ block_type = type(block).__name__
399
+
400
+ if block_type == "TextBlock" and hasattr(block, "text"):
401
+ text = block.text
402
+ if text:
403
+ full_response += text
404
+ yield {"type": "text", "content": text}
405
+
406
+ elif block_type == "ToolUseBlock" and hasattr(block, "name"):
407
+ tool_name = block.name
408
+ tool_input = getattr(block, "input", {})
409
+ yield {
410
+ "type": "tool_call",
411
+ "tool": tool_name,
412
+ "input": tool_input,
413
+ }
414
+
415
+ # Store the complete response in the database
416
+ if full_response and self.conversation_id:
417
+ add_message(self.project_dir, self.conversation_id, "assistant", full_response)
418
+
419
+ def get_conversation_id(self) -> Optional[int]:
420
+ """Get the current conversation ID."""
421
+ return self.conversation_id
422
+
423
+
424
+ # Session registry with thread safety
425
+ _sessions: dict[str, AssistantChatSession] = {}
426
+ _sessions_lock = threading.Lock()
427
+
428
+
429
+ def get_session(project_name: str) -> Optional[AssistantChatSession]:
430
+ """Get an existing session for a project."""
431
+ with _sessions_lock:
432
+ return _sessions.get(project_name)
433
+
434
+
435
+ async def create_session(
436
+ project_name: str,
437
+ project_dir: Path,
438
+ conversation_id: Optional[int] = None
439
+ ) -> AssistantChatSession:
440
+ """
441
+ Create a new session for a project, closing any existing one.
442
+
443
+ Args:
444
+ project_name: Name of the project
445
+ project_dir: Absolute path to the project directory
446
+ conversation_id: Optional conversation ID to resume
447
+ """
448
+ old_session: Optional[AssistantChatSession] = None
449
+
450
+ with _sessions_lock:
451
+ old_session = _sessions.pop(project_name, None)
452
+ session = AssistantChatSession(project_name, project_dir, conversation_id)
453
+ _sessions[project_name] = session
454
+
455
+ if old_session:
456
+ try:
457
+ await old_session.close()
458
+ except Exception as e:
459
+ logger.warning(f"Error closing old session for {project_name}: {e}")
460
+
461
+ return session
462
+
463
+
464
+ async def remove_session(project_name: str) -> None:
465
+ """Remove and close a session."""
466
+ session: Optional[AssistantChatSession] = None
467
+
468
+ with _sessions_lock:
469
+ session = _sessions.pop(project_name, None)
470
+
471
+ if session:
472
+ try:
473
+ await session.close()
474
+ except Exception as e:
475
+ logger.warning(f"Error closing session for {project_name}: {e}")
476
+
477
+
478
+ def list_sessions() -> list[str]:
479
+ """List all active session project names."""
480
+ with _sessions_lock:
481
+ return list(_sessions.keys())
482
+
483
+
484
+ async def cleanup_all_sessions() -> None:
485
+ """Close all active sessions. Called on server shutdown."""
486
+ sessions_to_close: list[AssistantChatSession] = []
487
+
488
+ with _sessions_lock:
489
+ sessions_to_close = list(_sessions.values())
490
+ _sessions.clear()
491
+
492
+ for session in sessions_to_close:
493
+ try:
494
+ await session.close()
495
+ except Exception as e:
496
+ logger.warning(f"Error closing session {session.project_name}: {e}")