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

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