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 <noreply@anthropic.com>
This commit is contained in:
Auto
2026-02-01 11:32:06 +02:00
parent c4d0c6c9b2
commit dc5bcc4ae9
24 changed files with 532 additions and 86 deletions

View File

@@ -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!
>

View File

@@ -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.

View File

@@ -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

View File

@@ -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'
<project_specification>
<project_name>{from package.json or directory}</project_name>
@@ -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

View File

@@ -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

View File

@@ -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)

290
autocoder_paths.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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()

View File

@@ -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 = [

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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")

View File

@@ -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,

View File

@@ -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(

View File

@@ -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)

View File

@@ -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):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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)