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,524 @@
1
+ """
2
+ Projects Router
3
+ ===============
4
+
5
+ API endpoints for project management.
6
+ Uses project registry for path lookups instead of fixed generations/ directory.
7
+ """
8
+
9
+ import re
10
+ import shutil
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+ from fastapi import APIRouter, HTTPException
16
+
17
+ from ..schemas import (
18
+ ProjectCreate,
19
+ ProjectDetail,
20
+ ProjectPrompts,
21
+ ProjectPromptsUpdate,
22
+ ProjectSettingsUpdate,
23
+ ProjectStats,
24
+ ProjectSummary,
25
+ )
26
+
27
+ # Lazy imports to avoid circular dependencies
28
+ # These are initialized by _init_imports() before first use.
29
+ _imports_initialized = False
30
+ _check_spec_exists: Callable[..., Any] | None = None
31
+ _scaffold_project_prompts: Callable[..., Any] | None = None
32
+ _get_project_prompts_dir: Callable[..., Any] | None = None
33
+ _count_passing_tests: Callable[..., Any] | None = None
34
+
35
+
36
+ def _init_imports():
37
+ """Lazy import of project-level modules."""
38
+ global _imports_initialized, _check_spec_exists
39
+ global _scaffold_project_prompts, _get_project_prompts_dir
40
+ global _count_passing_tests
41
+
42
+ if _imports_initialized:
43
+ return
44
+
45
+ import sys
46
+ root = Path(__file__).parent.parent.parent
47
+ if str(root) not in sys.path:
48
+ sys.path.insert(0, str(root))
49
+
50
+ from progress import count_passing_tests
51
+ from prompts import get_project_prompts_dir, scaffold_project_prompts
52
+ from start import check_spec_exists
53
+
54
+ _check_spec_exists = check_spec_exists
55
+ _scaffold_project_prompts = scaffold_project_prompts
56
+ _get_project_prompts_dir = get_project_prompts_dir
57
+ _count_passing_tests = count_passing_tests
58
+ _imports_initialized = True
59
+
60
+
61
+ def _get_registry_functions():
62
+ """Get registry functions with lazy import."""
63
+ import sys
64
+ root = Path(__file__).parent.parent.parent
65
+ if str(root) not in sys.path:
66
+ sys.path.insert(0, str(root))
67
+
68
+ from registry import (
69
+ get_project_concurrency,
70
+ get_project_path,
71
+ list_registered_projects,
72
+ register_project,
73
+ set_project_concurrency,
74
+ unregister_project,
75
+ validate_project_path,
76
+ )
77
+ return (
78
+ register_project,
79
+ unregister_project,
80
+ get_project_path,
81
+ list_registered_projects,
82
+ validate_project_path,
83
+ get_project_concurrency,
84
+ set_project_concurrency,
85
+ )
86
+
87
+
88
+ router = APIRouter(prefix="/api/projects", tags=["projects"])
89
+
90
+
91
+ def validate_project_name(name: str) -> str:
92
+ """Validate and sanitize project name to prevent path traversal."""
93
+ if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
94
+ raise HTTPException(
95
+ status_code=400,
96
+ detail="Invalid project name. Use only letters, numbers, hyphens, and underscores (1-50 chars)."
97
+ )
98
+ return name
99
+
100
+
101
+ def get_project_stats(project_dir: Path) -> ProjectStats:
102
+ """Get statistics for a project."""
103
+ _init_imports()
104
+ assert _count_passing_tests is not None # guaranteed by _init_imports()
105
+ passing, in_progress, total = _count_passing_tests(project_dir)
106
+ percentage = (passing / total * 100) if total > 0 else 0.0
107
+ return ProjectStats(
108
+ passing=passing,
109
+ in_progress=in_progress,
110
+ total=total,
111
+ percentage=round(percentage, 1)
112
+ )
113
+
114
+
115
+ @router.get("", response_model=list[ProjectSummary])
116
+ async def list_projects():
117
+ """List all registered projects."""
118
+ _init_imports()
119
+ assert _check_spec_exists is not None # guaranteed by _init_imports()
120
+ (_, _, _, list_registered_projects, validate_project_path,
121
+ get_project_concurrency, _) = _get_registry_functions()
122
+
123
+ projects = list_registered_projects()
124
+ result = []
125
+
126
+ for name, info in projects.items():
127
+ project_dir = Path(info["path"])
128
+
129
+ # Skip if path no longer exists
130
+ is_valid, _ = validate_project_path(project_dir)
131
+ if not is_valid:
132
+ continue
133
+
134
+ has_spec = _check_spec_exists(project_dir)
135
+ stats = get_project_stats(project_dir)
136
+
137
+ result.append(ProjectSummary(
138
+ name=name,
139
+ path=info["path"],
140
+ has_spec=has_spec,
141
+ stats=stats,
142
+ default_concurrency=info.get("default_concurrency", 3),
143
+ ))
144
+
145
+ return result
146
+
147
+
148
+ @router.post("", response_model=ProjectSummary)
149
+ async def create_project(project: ProjectCreate):
150
+ """Create a new project at the specified path."""
151
+ _init_imports()
152
+ assert _scaffold_project_prompts is not None # guaranteed by _init_imports()
153
+ (register_project, _, get_project_path, list_registered_projects,
154
+ _, _, _) = _get_registry_functions()
155
+
156
+ name = validate_project_name(project.name)
157
+ project_path = Path(project.path).resolve()
158
+
159
+ # Check if project name already registered
160
+ existing = get_project_path(name)
161
+ if existing:
162
+ raise HTTPException(
163
+ status_code=409,
164
+ detail=f"Project '{name}' already exists at {existing}"
165
+ )
166
+
167
+ # Check if path already registered under a different name
168
+ all_projects = list_registered_projects()
169
+ for existing_name, info in all_projects.items():
170
+ existing_path = Path(info["path"]).resolve()
171
+ # Case-insensitive comparison on Windows
172
+ if sys.platform == "win32":
173
+ paths_match = str(existing_path).lower() == str(project_path).lower()
174
+ else:
175
+ paths_match = existing_path == project_path
176
+
177
+ if paths_match:
178
+ raise HTTPException(
179
+ status_code=409,
180
+ detail=f"Path '{project_path}' is already registered as project '{existing_name}'"
181
+ )
182
+
183
+ # Security: Check if path is in a blocked location
184
+ from .filesystem import is_path_blocked
185
+ if is_path_blocked(project_path):
186
+ raise HTTPException(
187
+ status_code=403,
188
+ detail="Cannot create project in system or sensitive directory"
189
+ )
190
+
191
+ # Validate the path is usable
192
+ if project_path.exists():
193
+ if not project_path.is_dir():
194
+ raise HTTPException(
195
+ status_code=400,
196
+ detail="Path exists but is not a directory"
197
+ )
198
+ else:
199
+ # Create the directory
200
+ try:
201
+ project_path.mkdir(parents=True, exist_ok=True)
202
+ except OSError as e:
203
+ raise HTTPException(
204
+ status_code=500,
205
+ detail=f"Failed to create directory: {e}"
206
+ )
207
+
208
+ # Scaffold prompts
209
+ _scaffold_project_prompts(project_path)
210
+
211
+ # Register in registry
212
+ try:
213
+ register_project(name, project_path)
214
+ except Exception as e:
215
+ raise HTTPException(
216
+ status_code=500,
217
+ detail=f"Failed to register project: {e}"
218
+ )
219
+
220
+ return ProjectSummary(
221
+ name=name,
222
+ path=project_path.as_posix(),
223
+ has_spec=False, # Just created, no spec yet
224
+ stats=ProjectStats(passing=0, total=0, percentage=0.0),
225
+ default_concurrency=3,
226
+ )
227
+
228
+
229
+ @router.get("/{name}", response_model=ProjectDetail)
230
+ async def get_project(name: str):
231
+ """Get detailed information about a project."""
232
+ _init_imports()
233
+ assert _check_spec_exists is not None # guaranteed by _init_imports()
234
+ assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
235
+ (_, _, get_project_path, _, _, get_project_concurrency, _) = _get_registry_functions()
236
+
237
+ name = validate_project_name(name)
238
+ project_dir = get_project_path(name)
239
+
240
+ if not project_dir:
241
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found in registry")
242
+
243
+ if not project_dir.exists():
244
+ raise HTTPException(status_code=404, detail=f"Project directory no longer exists: {project_dir}")
245
+
246
+ has_spec = _check_spec_exists(project_dir)
247
+ stats = get_project_stats(project_dir)
248
+ prompts_dir = _get_project_prompts_dir(project_dir)
249
+
250
+ return ProjectDetail(
251
+ name=name,
252
+ path=project_dir.as_posix(),
253
+ has_spec=has_spec,
254
+ stats=stats,
255
+ prompts_dir=str(prompts_dir),
256
+ default_concurrency=get_project_concurrency(name),
257
+ )
258
+
259
+
260
+ @router.delete("/{name}")
261
+ async def delete_project(name: str, delete_files: bool = False):
262
+ """
263
+ Delete a project from the registry.
264
+
265
+ Args:
266
+ name: Project name to delete
267
+ delete_files: If True, also delete the project directory and files
268
+ """
269
+ _init_imports()
270
+ (_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions()
271
+
272
+ name = validate_project_name(name)
273
+ project_dir = get_project_path(name)
274
+
275
+ if not project_dir:
276
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
277
+
278
+ # Check if agent is running
279
+ from autoforge_paths import has_agent_running
280
+ if has_agent_running(project_dir):
281
+ raise HTTPException(
282
+ status_code=409,
283
+ detail="Cannot delete project while agent is running. Stop the agent first."
284
+ )
285
+
286
+ # Optionally delete files
287
+ if delete_files and project_dir.exists():
288
+ try:
289
+ shutil.rmtree(project_dir)
290
+ except Exception as e:
291
+ raise HTTPException(status_code=500, detail=f"Failed to delete project files: {e}")
292
+
293
+ # Unregister from registry
294
+ unregister_project(name)
295
+
296
+ return {
297
+ "success": True,
298
+ "message": f"Project '{name}' deleted" + (" (files removed)" if delete_files else " (files preserved)")
299
+ }
300
+
301
+
302
+ @router.get("/{name}/prompts", response_model=ProjectPrompts)
303
+ async def get_project_prompts(name: str):
304
+ """Get the content of project prompt files."""
305
+ _init_imports()
306
+ assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
307
+ (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
308
+
309
+ name = validate_project_name(name)
310
+ project_dir = get_project_path(name)
311
+
312
+ if not project_dir:
313
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
314
+
315
+ if not project_dir.exists():
316
+ raise HTTPException(status_code=404, detail="Project directory not found")
317
+
318
+ prompts_dir: Path = _get_project_prompts_dir(project_dir)
319
+
320
+ def read_file(filename: str) -> str:
321
+ filepath = prompts_dir / filename
322
+ if filepath.exists():
323
+ try:
324
+ return filepath.read_text(encoding="utf-8")
325
+ except Exception:
326
+ return ""
327
+ return ""
328
+
329
+ return ProjectPrompts(
330
+ app_spec=read_file("app_spec.txt"),
331
+ initializer_prompt=read_file("initializer_prompt.md"),
332
+ coding_prompt=read_file("coding_prompt.md"),
333
+ )
334
+
335
+
336
+ @router.put("/{name}/prompts")
337
+ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
338
+ """Update project prompt files."""
339
+ _init_imports()
340
+ assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
341
+ (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
342
+
343
+ name = validate_project_name(name)
344
+ project_dir = get_project_path(name)
345
+
346
+ if not project_dir:
347
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
348
+
349
+ if not project_dir.exists():
350
+ raise HTTPException(status_code=404, detail="Project directory not found")
351
+
352
+ prompts_dir = _get_project_prompts_dir(project_dir)
353
+ prompts_dir.mkdir(parents=True, exist_ok=True)
354
+
355
+ def write_file(filename: str, content: str | None):
356
+ if content is not None:
357
+ filepath = prompts_dir / filename
358
+ filepath.write_text(content, encoding="utf-8")
359
+
360
+ write_file("app_spec.txt", prompts.app_spec)
361
+ write_file("initializer_prompt.md", prompts.initializer_prompt)
362
+ write_file("coding_prompt.md", prompts.coding_prompt)
363
+
364
+ return {"success": True, "message": "Prompts updated"}
365
+
366
+
367
+ @router.get("/{name}/stats", response_model=ProjectStats)
368
+ async def get_project_stats_endpoint(name: str):
369
+ """Get current progress statistics for a project."""
370
+ _init_imports()
371
+ (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
372
+
373
+ name = validate_project_name(name)
374
+ project_dir = get_project_path(name)
375
+
376
+ if not project_dir:
377
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
378
+
379
+ if not project_dir.exists():
380
+ raise HTTPException(status_code=404, detail="Project directory not found")
381
+
382
+ return get_project_stats(project_dir)
383
+
384
+
385
+ @router.post("/{name}/reset")
386
+ async def reset_project(name: str, full_reset: bool = False):
387
+ """
388
+ Reset a project to its initial state.
389
+
390
+ Args:
391
+ name: Project name to reset
392
+ full_reset: If True, also delete prompts/ directory (triggers setup wizard)
393
+
394
+ Returns:
395
+ Dictionary with list of deleted files and reset type
396
+ """
397
+ _init_imports()
398
+ (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
399
+
400
+ name = validate_project_name(name)
401
+ project_dir = get_project_path(name)
402
+
403
+ if not project_dir:
404
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
405
+
406
+ if not project_dir.exists():
407
+ raise HTTPException(status_code=404, detail="Project directory not found")
408
+
409
+ # Check if agent is running
410
+ from autoforge_paths import has_agent_running
411
+ if has_agent_running(project_dir):
412
+ raise HTTPException(
413
+ status_code=409,
414
+ detail="Cannot reset project while agent is running. Stop the agent first."
415
+ )
416
+
417
+ # Dispose of database engines to release file locks (required on Windows)
418
+ # Import here to avoid circular imports
419
+ from api.database import dispose_engine as dispose_features_engine
420
+ from server.services.assistant_database import dispose_engine as dispose_assistant_engine
421
+
422
+ dispose_features_engine(project_dir)
423
+ dispose_assistant_engine(project_dir)
424
+
425
+ deleted_files: list[str] = []
426
+
427
+ from autoforge_paths import (
428
+ get_assistant_db_path,
429
+ get_claude_assistant_settings_path,
430
+ get_claude_settings_path,
431
+ get_features_db_path,
432
+ )
433
+
434
+ # Build list of files to delete using path helpers (finds files at current location)
435
+ # Plus explicit old-location fallbacks for backward compatibility
436
+ db_path = get_features_db_path(project_dir)
437
+ asst_path = get_assistant_db_path(project_dir)
438
+ reset_files: list[Path] = [
439
+ db_path,
440
+ db_path.with_suffix(".db-wal"),
441
+ db_path.with_suffix(".db-shm"),
442
+ asst_path,
443
+ asst_path.with_suffix(".db-wal"),
444
+ asst_path.with_suffix(".db-shm"),
445
+ get_claude_settings_path(project_dir),
446
+ get_claude_assistant_settings_path(project_dir),
447
+ # Also clean old root-level locations if they exist
448
+ project_dir / "features.db",
449
+ project_dir / "features.db-wal",
450
+ project_dir / "features.db-shm",
451
+ project_dir / "assistant.db",
452
+ project_dir / "assistant.db-wal",
453
+ project_dir / "assistant.db-shm",
454
+ project_dir / ".claude_settings.json",
455
+ project_dir / ".claude_assistant_settings.json",
456
+ ]
457
+
458
+ for file_path in reset_files:
459
+ if file_path.exists():
460
+ try:
461
+ relative = file_path.relative_to(project_dir)
462
+ file_path.unlink()
463
+ deleted_files.append(str(relative))
464
+ except Exception as e:
465
+ raise HTTPException(status_code=500, detail=f"Failed to delete {file_path.name}: {e}")
466
+
467
+ # Full reset: also delete prompts directory
468
+ if full_reset:
469
+ from autoforge_paths import get_prompts_dir
470
+ # Delete prompts from both possible locations
471
+ for prompts_dir in [get_prompts_dir(project_dir), project_dir / "prompts"]:
472
+ if prompts_dir.exists():
473
+ try:
474
+ relative = prompts_dir.relative_to(project_dir)
475
+ shutil.rmtree(prompts_dir)
476
+ deleted_files.append(f"{relative}/")
477
+ except Exception as e:
478
+ raise HTTPException(status_code=500, detail=f"Failed to delete prompts: {e}")
479
+
480
+ return {
481
+ "success": True,
482
+ "reset_type": "full" if full_reset else "quick",
483
+ "deleted_files": deleted_files,
484
+ "message": f"Project '{name}' has been reset" + (" (full reset)" if full_reset else " (quick reset)")
485
+ }
486
+
487
+
488
+ @router.patch("/{name}/settings", response_model=ProjectDetail)
489
+ async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
490
+ """Update project-level settings (concurrency, etc.)."""
491
+ _init_imports()
492
+ assert _check_spec_exists is not None # guaranteed by _init_imports()
493
+ assert _get_project_prompts_dir is not None # guaranteed by _init_imports()
494
+ (_, _, get_project_path, _, _, get_project_concurrency,
495
+ set_project_concurrency) = _get_registry_functions()
496
+
497
+ name = validate_project_name(name)
498
+ project_dir = get_project_path(name)
499
+
500
+ if not project_dir:
501
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
502
+
503
+ if not project_dir.exists():
504
+ raise HTTPException(status_code=404, detail="Project directory not found")
505
+
506
+ # Update concurrency if provided
507
+ if settings.default_concurrency is not None:
508
+ success = set_project_concurrency(name, settings.default_concurrency)
509
+ if not success:
510
+ raise HTTPException(status_code=500, detail="Failed to update concurrency")
511
+
512
+ # Return updated project details
513
+ has_spec = _check_spec_exists(project_dir)
514
+ stats = get_project_stats(project_dir)
515
+ prompts_dir = _get_project_prompts_dir(project_dir)
516
+
517
+ return ProjectDetail(
518
+ name=name,
519
+ path=project_dir.as_posix(),
520
+ has_spec=has_spec,
521
+ stats=stats,
522
+ prompts_dir=str(prompts_dir),
523
+ default_concurrency=get_project_concurrency(name),
524
+ )