From dc5bcc4ae933c079320c3df51406955b74bf3b5e Mon Sep 17 00:00:00 2001 From: Auto Date: Sun, 1 Feb 2026 11:32:06 +0200 Subject: [PATCH] feat: move autocoder runtime files into .autocoder/ subdirectory Add centralized path resolution module (autocoder_paths.py) that consolidates all autocoder-generated file paths behind a dual-path strategy: check .autocoder/X first, fall back to root-level X for backward compatibility, default to .autocoder/X for new projects. Key changes: - New autocoder_paths.py with dual-path resolution for features.db, assistant.db, lock files, settings, prompts dir, and progress cache - migrate_project_layout() safely moves old-layout projects to new layout with SQLite WAL flush and integrity verification - Updated 22 files to delegate path construction to autocoder_paths - Reset/delete logic cleans both old and new file locations - Orphan lock cleanup checks both locations per project - Migration called automatically at agent start in autonomous_agent_demo.py - Updated markdown commands/skills to reference .autocoder/prompts/ - CLAUDE.md documentation updated with new project structure Files at project root that remain unchanged: - CLAUDE.md (Claude SDK reads from cwd via setting_sources=["project"]) - app_spec.txt root copy (agent templates reference it via cat) Co-Authored-By: Claude Opus 4.5 --- .claude/commands/create-spec.md | 26 +- .claude/commands/expand-project.md | 4 +- .claude/commands/gsd-to-autocoder-spec.md | 2 +- .claude/skills/gsd-to-autocoder-spec/SKILL.md | 12 +- CLAUDE.md | 20 +- api/database.py | 7 +- autocoder_paths.py | 290 ++++++++++++++++++ autonomous_agent_demo.py | 6 + client.py | 4 +- progress.py | 12 +- prompts.py | 9 +- server/main.py | 9 +- server/routers/devserver.py | 51 ++- server/routers/expand_project.py | 3 +- server/routers/features.py | 9 +- server/routers/projects.py | 72 +++-- server/routers/spec_creation.py | 3 +- server/services/assistant_chat_session.py | 7 +- server/services/assistant_database.py | 3 +- server/services/dev_server_manager.py | 32 +- server/services/expand_chat_session.py | 7 +- server/services/process_manager.py | 17 +- server/services/scheduler_service.py | 6 +- server/services/spec_chat_session.py | 7 +- 24 files changed, 532 insertions(+), 86 deletions(-) create mode 100644 autocoder_paths.py diff --git a/.claude/commands/create-spec.md b/.claude/commands/create-spec.md index f8a1b96..9c23abe 100644 --- a/.claude/commands/create-spec.md +++ b/.claude/commands/create-spec.md @@ -8,7 +8,7 @@ This command **requires** the project directory as an argument via `$ARGUMENTS`. **Example:** `/create-spec generations/my-app` -**Output location:** `$ARGUMENTS/prompts/app_spec.txt` and `$ARGUMENTS/prompts/initializer_prompt.md` +**Output location:** `$ARGUMENTS/.autocoder/prompts/app_spec.txt` and `$ARGUMENTS/.autocoder/prompts/initializer_prompt.md` If `$ARGUMENTS` is empty, inform the user they must provide a project path and exit. @@ -347,13 +347,13 @@ First ask in conversation if they want to make changes. ## Output Directory -The output directory is: `$ARGUMENTS/prompts/` +The output directory is: `$ARGUMENTS/.autocoder/prompts/` Once the user approves, generate these files: ## 1. Generate `app_spec.txt` -**Output path:** `$ARGUMENTS/prompts/app_spec.txt` +**Output path:** `$ARGUMENTS/.autocoder/prompts/app_spec.txt` Create a new file using this XML structure: @@ -489,7 +489,7 @@ Create a new file using this XML structure: ## 2. Update `initializer_prompt.md` -**Output path:** `$ARGUMENTS/prompts/initializer_prompt.md` +**Output path:** `$ARGUMENTS/.autocoder/prompts/initializer_prompt.md` If the output directory has an existing `initializer_prompt.md`, read it and update the feature count. If not, copy from `.claude/templates/initializer_prompt.template.md` first, then update. @@ -512,7 +512,7 @@ After: **CRITICAL:** You must create exactly **25** features using the `feature ## 3. Write Status File (REQUIRED - Do This Last) -**Output path:** `$ARGUMENTS/prompts/.spec_status.json` +**Output path:** `$ARGUMENTS/.autocoder/prompts/.spec_status.json` **CRITICAL:** After you have completed ALL requested file changes, write this status file to signal completion to the UI. This is required for the "Continue to Project" button to appear. @@ -524,8 +524,8 @@ Write this JSON file: "version": 1, "timestamp": "[current ISO 8601 timestamp, e.g., 2025-01-15T14:30:00.000Z]", "files_written": [ - "prompts/app_spec.txt", - "prompts/initializer_prompt.md" + ".autocoder/prompts/app_spec.txt", + ".autocoder/prompts/initializer_prompt.md" ], "feature_count": [the feature count from Phase 4L] } @@ -539,9 +539,9 @@ Write this JSON file: "version": 1, "timestamp": "2025-01-15T14:30:00.000Z", "files_written": [ - "prompts/app_spec.txt", - "prompts/initializer_prompt.md", - "prompts/coding_prompt.md" + ".autocoder/prompts/app_spec.txt", + ".autocoder/prompts/initializer_prompt.md", + ".autocoder/prompts/coding_prompt.md" ], "feature_count": 35 } @@ -559,11 +559,11 @@ Write this JSON file: Once files are generated, tell the user what to do next: -> "Your specification files have been created in `$ARGUMENTS/prompts/`! +> "Your specification files have been created in `$ARGUMENTS/.autocoder/prompts/`! > > **Files created:** -> - `$ARGUMENTS/prompts/app_spec.txt` -> - `$ARGUMENTS/prompts/initializer_prompt.md` +> - `$ARGUMENTS/.autocoder/prompts/app_spec.txt` +> - `$ARGUMENTS/.autocoder/prompts/initializer_prompt.md` > > The **Continue to Project** button should now appear. Click it to start the autonomous coding agent! > diff --git a/.claude/commands/expand-project.md b/.claude/commands/expand-project.md index e8005b2..0ddf027 100644 --- a/.claude/commands/expand-project.md +++ b/.claude/commands/expand-project.md @@ -42,7 +42,7 @@ You are the **Project Expansion Assistant** - an expert at understanding existin # FIRST: Read and Understand Existing Project **Step 1:** Read the existing specification: -- Read `$ARGUMENTS/prompts/app_spec.txt` +- Read `$ARGUMENTS/.autocoder/prompts/app_spec.txt` **Step 2:** Present a summary to the user: @@ -231,4 +231,4 @@ If they want to add more, go back to Phase 1. # BEGIN -Start by reading the app specification file at `$ARGUMENTS/prompts/app_spec.txt`, then greet the user with a summary of their existing project and ask what they want to add. +Start by reading the app specification file at `$ARGUMENTS/.autocoder/prompts/app_spec.txt`, then greet the user with a summary of their existing project and ask what they want to add. diff --git a/.claude/commands/gsd-to-autocoder-spec.md b/.claude/commands/gsd-to-autocoder-spec.md index fc41cee..dbaeff6 100644 --- a/.claude/commands/gsd-to-autocoder-spec.md +++ b/.claude/commands/gsd-to-autocoder-spec.md @@ -5,6 +5,6 @@ description: Convert GSD codebase mapping to Autocoder app_spec.txt # GSD to Autocoder Spec -Convert `.planning/codebase/*.md` (from `/gsd:map-codebase`) to Autocoder's `prompts/app_spec.txt`. +Convert `.planning/codebase/*.md` (from `/gsd:map-codebase`) to Autocoder's `.autocoder/prompts/app_spec.txt`. @.claude/skills/gsd-to-autocoder-spec/SKILL.md diff --git a/.claude/skills/gsd-to-autocoder-spec/SKILL.md b/.claude/skills/gsd-to-autocoder-spec/SKILL.md index d4fba24..167caf0 100644 --- a/.claude/skills/gsd-to-autocoder-spec/SKILL.md +++ b/.claude/skills/gsd-to-autocoder-spec/SKILL.md @@ -9,7 +9,7 @@ description: | # GSD to Autocoder Spec Converter -Converts `.planning/codebase/*.md` (GSD mapping output) to `prompts/app_spec.txt` (Autocoder format). +Converts `.planning/codebase/*.md` (GSD mapping output) to `.autocoder/prompts/app_spec.txt` (Autocoder format). ## When to Use @@ -84,7 +84,7 @@ Extract: Create `prompts/` directory: ```bash -mkdir -p prompts +mkdir -p .autocoder/prompts ``` **Mapping GSD Documents to Autocoder Spec:** @@ -114,7 +114,7 @@ mkdir -p prompts **Write the spec file** using the XML format from [references/app-spec-format.md](references/app-spec-format.md): ```bash -cat > prompts/app_spec.txt << 'EOF' +cat > .autocoder/prompts/app_spec.txt << 'EOF' {from package.json or directory} @@ -173,9 +173,9 @@ EOF ### Step 5: Verify Generated Spec ```bash -head -100 prompts/app_spec.txt +head -100 .autocoder/prompts/app_spec.txt echo "---" -grep -c "User can\|System\|API\|Feature" prompts/app_spec.txt || echo "0" +grep -c "User can\|System\|API\|Feature" .autocoder/prompts/app_spec.txt || echo "0" ``` **Validation checklist:** @@ -194,7 +194,7 @@ Output: app_spec.txt generated from GSD codebase mapping. Source: .planning/codebase/*.md -Output: prompts/app_spec.txt +Output: .autocoder/prompts/app_spec.txt Next: Start Autocoder diff --git a/CLAUDE.md b/CLAUDE.md index d92db4e..91a3f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,6 +125,7 @@ Configuration in `pyproject.toml`: - `start.py` - CLI launcher with project creation/selection menu - `autonomous_agent_demo.py` - Entry point for running the agent +- `autocoder_paths.py` - Central path resolution with dual-path backward compatibility and migration - `agent.py` - Agent session loop using Claude Agent SDK - `client.py` - ClaudeSDKClient configuration with security hooks and MCP servers - `security.py` - Bash command allowlist validation (ALLOWED_COMMANDS whitelist) @@ -197,12 +198,17 @@ Keyboard shortcuts (press `?` for help): ### Project Structure for Generated Apps Projects can be stored in any directory (registered in `~/.autocoder/registry.db`). Each project contains: -- `prompts/app_spec.txt` - Application specification (XML format) -- `prompts/initializer_prompt.md` - First session prompt -- `prompts/coding_prompt.md` - Continuation session prompt -- `features.db` - SQLite database with feature test cases -- `.agent.lock` - Lock file to prevent multiple agent instances +- `.autocoder/prompts/app_spec.txt` - Application specification (XML format) +- `.autocoder/prompts/initializer_prompt.md` - First session prompt +- `.autocoder/prompts/coding_prompt.md` - Continuation session prompt +- `.autocoder/features.db` - SQLite database with feature test cases +- `.autocoder/.agent.lock` - Lock file to prevent multiple agent instances - `.autocoder/allowed_commands.yaml` - Project-specific bash command allowlist (optional) +- `.autocoder/.gitignore` - Ignores runtime files +- `CLAUDE.md` - Stays at project root (SDK convention) +- `app_spec.txt` - Root copy for agent template compatibility + +Legacy projects with files at root level (e.g., `features.db`, `prompts/`) are auto-migrated to `.autocoder/` on next agent start. Dual-path resolution ensures old and new layouts work transparently. ### Security Model @@ -364,12 +370,12 @@ Run coding agents using local models via Ollama v0.14.0+: ### Prompt Loading Fallback Chain -1. Project-specific: `{project_dir}/prompts/{name}.md` +1. Project-specific: `{project_dir}/.autocoder/prompts/{name}.md` (or legacy `{project_dir}/prompts/{name}.md`) 2. Base template: `.claude/templates/{name}.template.md` ### Agent Session Flow -1. Check if `features.db` has features (determines initializer vs coding agent) +1. Check if `.autocoder/features.db` has features (determines initializer vs coding agent) 2. Create ClaudeSDKClient with security settings 3. Send prompt and stream response 4. Auto-continue with 3-second delay between sessions diff --git a/api/database.py b/api/database.py index 2a732fe..4c5ef42 100644 --- a/api/database.py +++ b/api/database.py @@ -183,7 +183,8 @@ class ScheduleOverride(Base): def get_database_path(project_dir: Path) -> Path: """Return the path to the SQLite database for a project.""" - return project_dir / "features.db" + from autocoder_paths import get_features_db_path + return get_features_db_path(project_dir) def get_database_url(project_dir: Path) -> str: @@ -384,6 +385,10 @@ def create_database(project_dir: Path) -> tuple: db_url = get_database_url(project_dir) + # Ensure parent directory exists (for .autocoder/ layout) + db_path = get_database_path(project_dir) + db_path.parent.mkdir(parents=True, exist_ok=True) + # Choose journal mode based on filesystem type # WAL mode doesn't work reliably on network filesystems and can cause corruption is_network = _is_network_path(project_dir) diff --git a/autocoder_paths.py b/autocoder_paths.py new file mode 100644 index 0000000..7d1db6f --- /dev/null +++ b/autocoder_paths.py @@ -0,0 +1,290 @@ +""" +Autocoder Path Resolution +========================= + +Central module for resolving paths to autocoder-generated files within a project. + +Implements a dual-path resolution strategy for backward compatibility: + + 1. Check ``project_dir / ".autocoder" / X`` (new layout) + 2. Check ``project_dir / X`` (legacy root-level layout) + 3. Default to the new location for fresh projects + +This allows existing projects with root-level ``features.db``, ``.agent.lock``, +etc. to keep working while new projects store everything under ``.autocoder/``. + +The ``migrate_project_layout`` function can move an old-layout project to the +new layout safely, with full integrity checks for SQLite databases. +""" + +import logging +import shutil +import sqlite3 +from pathlib import Path + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# .gitignore content written into every .autocoder/ directory +# --------------------------------------------------------------------------- +_GITIGNORE_CONTENT = """\ +# Autocoder runtime files +features.db +features.db-wal +features.db-shm +assistant.db +assistant.db-wal +assistant.db-shm +.agent.lock +.devserver.lock +.claude_settings.json +.claude_assistant_settings.json +.claude_settings.expand.*.json +.progress_cache +""" + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _resolve_path(project_dir: Path, filename: str) -> Path: + """Resolve a file path using dual-path strategy. + + Checks the new ``.autocoder/`` location first, then falls back to the + legacy root-level location. If neither exists, returns the new location + so that newly-created files land in ``.autocoder/``. + """ + new = project_dir / ".autocoder" / filename + if new.exists(): + return new + old = project_dir / filename + if old.exists(): + return old + return new # default for new projects + + +def _resolve_dir(project_dir: Path, dirname: str) -> Path: + """Resolve a directory path using dual-path strategy. + + Same logic as ``_resolve_path`` but intended for directories such as + ``prompts/``. + """ + new = project_dir / ".autocoder" / dirname + if new.exists(): + return new + old = project_dir / dirname + if old.exists(): + return old + return new + + +# --------------------------------------------------------------------------- +# .autocoder directory management +# --------------------------------------------------------------------------- + +def get_autocoder_dir(project_dir: Path) -> Path: + """Return the ``.autocoder`` directory path. Does NOT create it.""" + return project_dir / ".autocoder" + + +def ensure_autocoder_dir(project_dir: Path) -> Path: + """Create the ``.autocoder/`` directory (if needed) and write its ``.gitignore``. + + Returns: + The path to the ``.autocoder`` directory. + """ + autocoder_dir = get_autocoder_dir(project_dir) + autocoder_dir.mkdir(parents=True, exist_ok=True) + + gitignore_path = autocoder_dir / ".gitignore" + gitignore_path.write_text(_GITIGNORE_CONTENT, encoding="utf-8") + + return autocoder_dir + + +# --------------------------------------------------------------------------- +# Dual-path file helpers +# --------------------------------------------------------------------------- + +def get_features_db_path(project_dir: Path) -> Path: + """Resolve the path to ``features.db``.""" + return _resolve_path(project_dir, "features.db") + + +def get_assistant_db_path(project_dir: Path) -> Path: + """Resolve the path to ``assistant.db``.""" + return _resolve_path(project_dir, "assistant.db") + + +def get_agent_lock_path(project_dir: Path) -> Path: + """Resolve the path to ``.agent.lock``.""" + return _resolve_path(project_dir, ".agent.lock") + + +def get_devserver_lock_path(project_dir: Path) -> Path: + """Resolve the path to ``.devserver.lock``.""" + return _resolve_path(project_dir, ".devserver.lock") + + +def get_claude_settings_path(project_dir: Path) -> Path: + """Resolve the path to ``.claude_settings.json``.""" + return _resolve_path(project_dir, ".claude_settings.json") + + +def get_claude_assistant_settings_path(project_dir: Path) -> Path: + """Resolve the path to ``.claude_assistant_settings.json``.""" + return _resolve_path(project_dir, ".claude_assistant_settings.json") + + +def get_progress_cache_path(project_dir: Path) -> Path: + """Resolve the path to ``.progress_cache``.""" + return _resolve_path(project_dir, ".progress_cache") + + +def get_prompts_dir(project_dir: Path) -> Path: + """Resolve the path to the ``prompts/`` directory.""" + return _resolve_dir(project_dir, "prompts") + + +# --------------------------------------------------------------------------- +# Non-dual-path helpers (always use new location) +# --------------------------------------------------------------------------- + +def get_expand_settings_path(project_dir: Path, uuid_hex: str) -> Path: + """Return the path for an ephemeral expand-session settings file. + + These files are short-lived and always stored in ``.autocoder/``. + """ + return project_dir / ".autocoder" / f".claude_settings.expand.{uuid_hex}.json" + + +# --------------------------------------------------------------------------- +# Lock-file safety check +# --------------------------------------------------------------------------- + +def has_agent_running(project_dir: Path) -> bool: + """Check whether any agent or dev-server lock file exists at either location. + + Inspects both the legacy root-level paths and the new ``.autocoder/`` + paths so that a running agent is detected regardless of project layout. + + Returns: + ``True`` if any ``.agent.lock`` or ``.devserver.lock`` exists. + """ + lock_names = (".agent.lock", ".devserver.lock") + for name in lock_names: + if (project_dir / name).exists(): + return True + if (project_dir / ".autocoder" / name).exists(): + return True + return False + + +# --------------------------------------------------------------------------- +# Migration +# --------------------------------------------------------------------------- + +def migrate_project_layout(project_dir: Path) -> list[str]: + """Migrate a project from the legacy root-level layout to ``.autocoder/``. + + The migration is incremental and safe: + + * If the agent is running (lock files present) the migration is skipped + entirely to avoid corrupting in-use databases. + * Each file/directory is migrated independently. If any single step + fails the error is logged and migration continues with the remaining + items. Partial migration is safe because the dual-path resolution + strategy will find files at whichever location they ended up in. + + Returns: + A list of human-readable descriptions of what was migrated, e.g. + ``["prompts/ -> .autocoder/prompts/", "features.db -> .autocoder/features.db"]``. + An empty list means nothing was migrated (either everything is + already migrated, or the agent is running). + """ + # Safety: refuse to migrate while an agent is running + if has_agent_running(project_dir): + logger.warning("Migration skipped: agent or dev-server is running for %s", project_dir) + return [] + + autocoder_dir = ensure_autocoder_dir(project_dir) + migrated: list[str] = [] + + # --- 1. Migrate prompts/ directory ----------------------------------- + try: + old_prompts = project_dir / "prompts" + new_prompts = autocoder_dir / "prompts" + if old_prompts.exists() and old_prompts.is_dir() and not new_prompts.exists(): + shutil.copytree(str(old_prompts), str(new_prompts)) + shutil.rmtree(str(old_prompts)) + migrated.append("prompts/ -> .autocoder/prompts/") + logger.info("Migrated prompts/ -> .autocoder/prompts/") + except Exception: + logger.warning("Failed to migrate prompts/ directory", exc_info=True) + + # --- 2. Migrate SQLite databases (features.db, assistant.db) --------- + db_names = ("features.db", "assistant.db") + for db_name in db_names: + try: + old_db = project_dir / db_name + new_db = autocoder_dir / db_name + if old_db.exists() and not new_db.exists(): + # Flush WAL to ensure all data is in the main database file + conn = sqlite3.connect(str(old_db)) + try: + cursor = conn.cursor() + cursor.execute("PRAGMA wal_checkpoint(TRUNCATE)") + finally: + conn.close() + + # Copy the main database file (WAL is now flushed) + shutil.copy2(str(old_db), str(new_db)) + + # Verify the copy is intact + verify_conn = sqlite3.connect(str(new_db)) + try: + verify_cursor = verify_conn.cursor() + result = verify_cursor.execute("PRAGMA integrity_check").fetchone() + if result is None or result[0] != "ok": + logger.error( + "Integrity check failed for migrated %s: %s", + db_name, result, + ) + # Remove the broken copy; old file stays in place + new_db.unlink(missing_ok=True) + continue + finally: + verify_conn.close() + + # Remove old database files (.db, .db-wal, .db-shm) + old_db.unlink(missing_ok=True) + for suffix in ("-wal", "-shm"): + wal_file = project_dir / f"{db_name}{suffix}" + wal_file.unlink(missing_ok=True) + + migrated.append(f"{db_name} -> .autocoder/{db_name}") + logger.info("Migrated %s -> .autocoder/%s", db_name, db_name) + except Exception: + logger.warning("Failed to migrate %s", db_name, exc_info=True) + + # --- 3. Migrate simple files ----------------------------------------- + simple_files = ( + ".agent.lock", + ".devserver.lock", + ".claude_settings.json", + ".claude_assistant_settings.json", + ".progress_cache", + ) + for filename in simple_files: + try: + old_file = project_dir / filename + new_file = autocoder_dir / filename + if old_file.exists() and not new_file.exists(): + shutil.move(str(old_file), str(new_file)) + migrated.append(f"{filename} -> .autocoder/{filename}") + logger.info("Migrated %s -> .autocoder/%s", filename, filename) + except Exception: + logger.warning("Failed to migrate %s", filename, exc_info=True) + + return migrated diff --git a/autonomous_agent_demo.py b/autonomous_agent_demo.py index 16702f5..03ceb7f 100644 --- a/autonomous_agent_demo.py +++ b/autonomous_agent_demo.py @@ -193,6 +193,12 @@ def main() -> None: print("Use an absolute path or register the project first.") return + # Migrate project layout to .autocoder/ if needed (idempotent, safe) + from autocoder_paths import migrate_project_layout + migrated = migrate_project_layout(project_dir) + if migrated: + print(f"Migrated project files to .autocoder/: {', '.join(migrated)}", flush=True) + try: if args.agent_type: # Subprocess mode - spawned by orchestrator for a specific role diff --git a/client.py b/client.py index f394ebb..0b55295 100644 --- a/client.py +++ b/client.py @@ -360,7 +360,9 @@ def create_client( project_dir.mkdir(parents=True, exist_ok=True) # Write settings to a file in the project directory - settings_file = project_dir / ".claude_settings.json" + from autocoder_paths import get_claude_settings_path + settings_file = get_claude_settings_path(project_dir) + settings_file.parent.mkdir(parents=True, exist_ok=True) with open(settings_file, "w") as f: json.dump(security_settings, f, indent=2) diff --git a/progress.py b/progress.py index 1f17ae6..f0795b6 100644 --- a/progress.py +++ b/progress.py @@ -46,7 +46,8 @@ def has_features(project_dir: Path) -> bool: return True # Check SQLite database - db_file = project_dir / "features.db" + from autocoder_paths import get_features_db_path + db_file = get_features_db_path(project_dir) if not db_file.exists(): return False @@ -71,7 +72,8 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: Returns: (passing_count, in_progress_count, total_count) """ - db_file = project_dir / "features.db" + from autocoder_paths import get_features_db_path + db_file = get_features_db_path(project_dir) if not db_file.exists(): return 0, 0, 0 @@ -120,7 +122,8 @@ def get_all_passing_features(project_dir: Path) -> list[dict]: Returns: List of dicts with id, category, name for each passing feature """ - db_file = project_dir / "features.db" + from autocoder_paths import get_features_db_path + db_file = get_features_db_path(project_dir) if not db_file.exists(): return [] @@ -144,7 +147,8 @@ def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None: if not WEBHOOK_URL: return # Webhook not configured - cache_file = project_dir / PROGRESS_CACHE_FILE + from autocoder_paths import get_progress_cache_path + cache_file = get_progress_cache_path(project_dir) previous = 0 previous_passing_ids = set() diff --git a/prompts.py b/prompts.py index 137928c..b2ab11b 100644 --- a/prompts.py +++ b/prompts.py @@ -18,7 +18,8 @@ TEMPLATES_DIR = Path(__file__).parent / ".claude" / "templates" def get_project_prompts_dir(project_dir: Path) -> Path: """Get the prompts directory for a specific project.""" - return project_dir / "prompts" + from autocoder_paths import get_prompts_dir + return get_prompts_dir(project_dir) def load_prompt(name: str, project_dir: Path | None = None) -> str: @@ -190,9 +191,9 @@ def scaffold_project_prompts(project_dir: Path) -> Path: project_prompts = get_project_prompts_dir(project_dir) project_prompts.mkdir(parents=True, exist_ok=True) - # Create .autocoder directory for configuration files - autocoder_dir = project_dir / ".autocoder" - autocoder_dir.mkdir(parents=True, exist_ok=True) + # Create .autocoder directory with .gitignore for runtime files + from autocoder_paths import ensure_autocoder_dir + autocoder_dir = ensure_autocoder_dir(project_dir) # Define template mappings: (source_template, destination_name) templates = [ diff --git a/server/main.py b/server/main.py index 1b01f79..e46f436 100644 --- a/server/main.py +++ b/server/main.py @@ -222,7 +222,14 @@ if UI_DIST_DIR.exists(): raise HTTPException(status_code=404) # Try to serve the file directly - file_path = UI_DIST_DIR / path + file_path = (UI_DIST_DIR / path).resolve() + + # Ensure resolved path is within UI_DIST_DIR (prevent path traversal) + try: + file_path.relative_to(UI_DIST_DIR.resolve()) + except ValueError: + raise HTTPException(status_code=404) + if file_path.exists() and file_path.is_file(): return FileResponse(file_path) diff --git a/server/routers/devserver.py b/server/routers/devserver.py index 18f91ec..9892e3a 100644 --- a/server/routers/devserver.py +++ b/server/routers/devserver.py @@ -6,6 +6,7 @@ API endpoints for dev server control (start/stop) and configuration. Uses project registry for path lookups and project_config for command detection. """ +import logging import re import sys from pathlib import Path @@ -33,6 +34,9 @@ if str(_root) not in sys.path: sys.path.insert(0, str(_root)) from registry import get_project_path as registry_get_project_path +from security import extract_commands, get_effective_commands, is_command_allowed + +logger = logging.getLogger(__name__) def _get_project_path(project_name: str) -> Path | None: @@ -106,6 +110,45 @@ def get_project_devserver_manager(project_name: str): return get_devserver_manager(project_name, project_dir) +def validate_dev_command(command: str, project_dir: Path) -> None: + """ + Validate a dev server command against the security allowlist. + + Extracts all commands from the shell string and checks each against + the effective allowlist (global + org + project). Raises HTTPException + if any command is blocked or not allowed. + + Args: + command: The shell command string to validate + project_dir: Project directory for loading project-level allowlists + + Raises: + HTTPException 400: If the command fails validation + """ + commands = extract_commands(command) + if not commands: + raise HTTPException( + status_code=400, + detail="Could not parse command for security validation" + ) + + allowed_commands, blocked_commands = get_effective_commands(project_dir) + + for cmd in commands: + if cmd in blocked_commands: + logger.warning("Blocked dev server command '%s' (in blocklist) for project dir %s", cmd, project_dir) + raise HTTPException( + status_code=400, + detail=f"Command '{cmd}' is blocked and cannot be used as a dev server command" + ) + if not is_command_allowed(cmd, allowed_commands): + logger.warning("Rejected dev server command '%s' (not in allowlist) for project dir %s", cmd, project_dir) + raise HTTPException( + status_code=400, + detail=f"Command '{cmd}' is not in the allowed commands list" + ) + + # ============================================================================ # Endpoints # ============================================================================ @@ -167,7 +210,10 @@ async def start_devserver( detail="No dev command available. Configure a custom command or ensure project type can be detected." ) - # Now command is definitely str + # Validate command against security allowlist before execution + validate_dev_command(command, project_dir) + + # Now command is definitely str and validated success, message = await manager.start(command) return DevServerActionResponse( @@ -258,6 +304,9 @@ async def update_devserver_config( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) else: + # Validate command against security allowlist before persisting + validate_dev_command(update.custom_command, project_dir) + # Set the custom command try: set_dev_command(project_dir, update.custom_command) diff --git a/server/routers/expand_project.py b/server/routers/expand_project.py index 50bf196..7f6c985 100644 --- a/server/routers/expand_project.py +++ b/server/routers/expand_project.py @@ -136,7 +136,8 @@ async def expand_project_websocket(websocket: WebSocket, project_name: str): return # Verify project has app_spec.txt - spec_path = project_dir / "prompts" / "app_spec.txt" + from autocoder_paths import get_prompts_dir + spec_path = get_prompts_dir(project_dir) / "app_spec.txt" if not spec_path.exists(): await websocket.close(code=4004, reason="Project has no spec. Create spec first.") return diff --git a/server/routers/features.py b/server/routers/features.py index a0e1664..ab95843 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -134,7 +134,8 @@ async def list_features(project_name: str): if not project_dir.exists(): raise HTTPException(status_code=404, detail="Project directory not found") - db_file = project_dir / "features.db" + from autocoder_paths import get_features_db_path + db_file = get_features_db_path(project_dir) if not db_file.exists(): return FeatureListResponse(pending=[], in_progress=[], done=[]) @@ -329,7 +330,8 @@ async def get_dependency_graph(project_name: str): if not project_dir.exists(): raise HTTPException(status_code=404, detail="Project directory not found") - db_file = project_dir / "features.db" + from autocoder_paths import get_features_db_path + db_file = get_features_db_path(project_dir) if not db_file.exists(): return DependencyGraphResponse(nodes=[], edges=[]) @@ -393,7 +395,8 @@ async def get_feature(project_name: str, feature_id: int): if not project_dir.exists(): raise HTTPException(status_code=404, detail="Project directory not found") - db_file = project_dir / "features.db" + from autocoder_paths import get_features_db_path + db_file = get_features_db_path(project_dir) if not db_file.exists(): raise HTTPException(status_code=404, detail="No features database found") diff --git a/server/routers/projects.py b/server/routers/projects.py index 0f76ff9..7ecfe08 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -269,8 +269,8 @@ async def delete_project(name: str, delete_files: bool = False): raise HTTPException(status_code=404, detail=f"Project '{name}' not found") # Check if agent is running - lock_file = project_dir / ".agent.lock" - if lock_file.exists(): + from autocoder_paths import has_agent_running + if has_agent_running(project_dir): raise HTTPException( status_code=409, detail="Cannot delete project while agent is running. Stop the agent first." @@ -398,8 +398,8 @@ async def reset_project(name: str, full_reset: bool = False): raise HTTPException(status_code=404, detail="Project directory not found") # Check if agent is running - lock_file = project_dir / ".agent.lock" - if lock_file.exists(): + from autocoder_paths import has_agent_running + if has_agent_running(project_dir): raise HTTPException( status_code=409, detail="Cannot reset project while agent is running. Stop the agent first." @@ -415,36 +415,58 @@ async def reset_project(name: str, full_reset: bool = False): deleted_files: list[str] = [] - # Files to delete in quick reset - quick_reset_files = [ - "features.db", - "features.db-wal", # WAL mode journal file - "features.db-shm", # WAL mode shared memory file - "assistant.db", - "assistant.db-wal", - "assistant.db-shm", - ".claude_settings.json", - ".claude_assistant_settings.json", + from autocoder_paths import ( + get_assistant_db_path, + get_claude_assistant_settings_path, + get_claude_settings_path, + get_features_db_path, + ) + + # Build list of files to delete using path helpers (finds files at current location) + # Plus explicit old-location fallbacks for backward compatibility + db_path = get_features_db_path(project_dir) + asst_path = get_assistant_db_path(project_dir) + reset_files: list[Path] = [ + db_path, + db_path.with_suffix(".db-wal"), + db_path.with_suffix(".db-shm"), + asst_path, + asst_path.with_suffix(".db-wal"), + asst_path.with_suffix(".db-shm"), + get_claude_settings_path(project_dir), + get_claude_assistant_settings_path(project_dir), + # Also clean old root-level locations if they exist + project_dir / "features.db", + project_dir / "features.db-wal", + project_dir / "features.db-shm", + project_dir / "assistant.db", + project_dir / "assistant.db-wal", + project_dir / "assistant.db-shm", + project_dir / ".claude_settings.json", + project_dir / ".claude_assistant_settings.json", ] - for filename in quick_reset_files: - file_path = project_dir / filename + for file_path in reset_files: if file_path.exists(): try: + relative = file_path.relative_to(project_dir) file_path.unlink() - deleted_files.append(filename) + deleted_files.append(str(relative)) except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to delete {filename}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete {file_path.name}: {e}") # Full reset: also delete prompts directory if full_reset: - prompts_dir = project_dir / "prompts" - if prompts_dir.exists(): - try: - shutil.rmtree(prompts_dir) - deleted_files.append("prompts/") - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to delete prompts/: {e}") + from autocoder_paths import get_prompts_dir + # Delete prompts from both possible locations + for prompts_dir in [get_prompts_dir(project_dir), project_dir / "prompts"]: + if prompts_dir.exists(): + try: + relative = prompts_dir.relative_to(project_dir) + shutil.rmtree(prompts_dir) + deleted_files.append(f"{relative}/") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete prompts: {e}") return { "success": True, diff --git a/server/routers/spec_creation.py b/server/routers/spec_creation.py index 87f79a6..c29da6b 100644 --- a/server/routers/spec_creation.py +++ b/server/routers/spec_creation.py @@ -124,7 +124,8 @@ async def get_spec_file_status(project_name: str): if not project_dir.exists(): raise HTTPException(status_code=404, detail="Project directory not found") - status_file = project_dir / "prompts" / ".spec_status.json" + from autocoder_paths import get_prompts_dir + status_file = get_prompts_dir(project_dir) / ".spec_status.json" if not status_file.exists(): return SpecFileStatus( diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index 1fb26e1..2ac41fc 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -76,7 +76,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: """Generate the system prompt for the assistant with project context.""" # Try to load app_spec.txt for context app_spec_content = "" - app_spec_path = project_dir / "prompts" / "app_spec.txt" + from autocoder_paths import get_prompts_dir + app_spec_path = get_prompts_dir(project_dir) / "app_spec.txt" if app_spec_path.exists(): try: app_spec_content = app_spec_path.read_text(encoding="utf-8") @@ -235,7 +236,9 @@ class AssistantChatSession: "allow": permissions_list, }, } - settings_file = self.project_dir / ".claude_assistant_settings.json" + from autocoder_paths import get_claude_assistant_settings_path + settings_file = get_claude_assistant_settings_path(self.project_dir) + settings_file.parent.mkdir(parents=True, exist_ok=True) with open(settings_file, "w") as f: json.dump(security_settings, f, indent=2) diff --git a/server/services/assistant_database.py b/server/services/assistant_database.py index 0dbfdd3..b91a388 100644 --- a/server/services/assistant_database.py +++ b/server/services/assistant_database.py @@ -63,7 +63,8 @@ class ConversationMessage(Base): def get_db_path(project_dir: Path) -> Path: """Get the path to the assistant database for a project.""" - return project_dir / "assistant.db" + from autocoder_paths import get_assistant_db_path + return get_assistant_db_path(project_dir) def get_engine(project_dir: Path): diff --git a/server/services/dev_server_manager.py b/server/services/dev_server_manager.py index 5acfbc8..41dac02 100644 --- a/server/services/dev_server_manager.py +++ b/server/services/dev_server_manager.py @@ -24,6 +24,7 @@ from typing import Awaitable, Callable, Literal, Set import psutil from registry import list_registered_projects +from security import extract_commands, get_effective_commands, is_command_allowed from server.utils.process_utils import kill_process_tree logger = logging.getLogger(__name__) @@ -114,7 +115,8 @@ class DevServerProcessManager: self._callbacks_lock = threading.Lock() # Lock file to prevent multiple instances (stored in project directory) - self.lock_file = self.project_dir / ".devserver.lock" + from autocoder_paths import get_devserver_lock_path + self.lock_file = get_devserver_lock_path(self.project_dir) @property def status(self) -> Literal["stopped", "running", "crashed"]: @@ -304,6 +306,20 @@ class DevServerProcessManager: if not self.project_dir.exists(): return False, f"Project directory does not exist: {self.project_dir}" + # Defense-in-depth: validate command against security allowlist + commands = extract_commands(command) + if not commands: + return False, "Could not parse command for security validation" + + allowed_commands, blocked_commands = get_effective_commands(self.project_dir) + for cmd in commands: + if cmd in blocked_commands: + logger.warning("Blocked dev server command '%s' (in blocklist) for %s", cmd, self.project_name) + return False, f"Command '{cmd}' is blocked and cannot be used as a dev server command" + if not is_command_allowed(cmd, allowed_commands): + logger.warning("Rejected dev server command '%s' (not in allowlist) for %s", cmd, self.project_name) + return False, f"Command '{cmd}' is not in the allowed commands list" + self._command = command self._detected_url = None # Reset URL detection @@ -487,8 +503,18 @@ def cleanup_orphaned_devserver_locks() -> int: if not project_path.exists(): continue - lock_file = project_path / ".devserver.lock" - if not lock_file.exists(): + # Check both legacy and new locations for lock files + from autocoder_paths import get_autocoder_dir + lock_locations = [ + project_path / ".devserver.lock", + get_autocoder_dir(project_path) / ".devserver.lock", + ] + lock_file = None + for candidate in lock_locations: + if candidate.exists(): + lock_file = candidate + break + if lock_file is None: continue try: diff --git a/server/services/expand_chat_session.py b/server/services/expand_chat_session.py index 6829372..2960e2e 100644 --- a/server/services/expand_chat_session.py +++ b/server/services/expand_chat_session.py @@ -128,7 +128,8 @@ class ExpandChatSession: return # Verify project has existing spec - spec_path = self.project_dir / "prompts" / "app_spec.txt" + from autocoder_paths import get_prompts_dir + spec_path = get_prompts_dir(self.project_dir) / "app_spec.txt" if not spec_path.exists(): yield { "type": "error", @@ -166,7 +167,9 @@ class ExpandChatSession: ], }, } - settings_file = self.project_dir / f".claude_settings.expand.{uuid.uuid4().hex}.json" + from autocoder_paths import get_expand_settings_path + settings_file = get_expand_settings_path(self.project_dir, uuid.uuid4().hex) + settings_file.parent.mkdir(parents=True, exist_ok=True) self._settings_file = settings_file with open(settings_file, "w", encoding="utf-8") as f: json.dump(security_settings, f, indent=2) diff --git a/server/services/process_manager.py b/server/services/process_manager.py index fd1a192..7f461c5 100644 --- a/server/services/process_manager.py +++ b/server/services/process_manager.py @@ -92,7 +92,8 @@ class AgentProcessManager: self._callbacks_lock = threading.Lock() # Lock file to prevent multiple instances (stored in project directory) - self.lock_file = self.project_dir / ".agent.lock" + from autocoder_paths import get_agent_lock_path + self.lock_file = get_agent_lock_path(self.project_dir) @property def status(self) -> Literal["stopped", "running", "paused", "crashed"]: @@ -579,8 +580,18 @@ def cleanup_orphaned_locks() -> int: if not project_path.exists(): continue - lock_file = project_path / ".agent.lock" - if not lock_file.exists(): + # Check both legacy and new locations for lock files + from autocoder_paths import get_autocoder_dir + lock_locations = [ + project_path / ".agent.lock", + get_autocoder_dir(project_path) / ".agent.lock", + ] + lock_file = None + for candidate in lock_locations: + if candidate.exists(): + lock_file = candidate + break + if lock_file is None: continue try: diff --git a/server/services/scheduler_service.py b/server/services/scheduler_service.py index eb22a3a..578aed2 100644 --- a/server/services/scheduler_service.py +++ b/server/services/scheduler_service.py @@ -92,8 +92,9 @@ class SchedulerService: async def _load_project_schedules(self, project_name: str, project_dir: Path) -> int: """Load schedules for a single project. Returns count of schedules loaded.""" from api.database import Schedule, create_database + from autocoder_paths import get_features_db_path - db_path = project_dir / "features.db" + db_path = get_features_db_path(project_dir) if not db_path.exists(): return 0 @@ -567,8 +568,9 @@ class SchedulerService: ): """Check if a project should be started on server startup.""" from api.database import Schedule, ScheduleOverride, create_database + from autocoder_paths import get_features_db_path - db_path = project_dir / "features.db" + db_path = get_features_db_path(project_dir) if not db_path.exists(): return diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index c86bda2..ce49ea4 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -125,7 +125,8 @@ class SpecChatSession: # Delete app_spec.txt so Claude can create it fresh # The SDK requires reading existing files before writing, but app_spec.txt is created new # Note: We keep initializer_prompt.md so Claude can read and update the template - prompts_dir = self.project_dir / "prompts" + from autocoder_paths import get_prompts_dir + prompts_dir = get_prompts_dir(self.project_dir) app_spec_path = prompts_dir / "app_spec.txt" if app_spec_path.exists(): app_spec_path.unlink() @@ -145,7 +146,9 @@ class SpecChatSession: ], }, } - settings_file = self.project_dir / ".claude_settings.json" + from autocoder_paths import get_claude_settings_path + settings_file = get_claude_settings_path(self.project_dir) + settings_file.parent.mkdir(parents=True, exist_ok=True) with open(settings_file, "w") as f: json.dump(security_settings, f, indent=2)