     1→"""
     2→Project Registry Module
     3→=======================
     4→
     5→Cross-platform project registry for storing project name to path mappings.
     6→Uses SQLite database stored at ~/.autocoder/registry.db.
     7→"""
     8→
     9→import logging
    10→import os
    11→import re
    12→from contextlib import contextmanager
    13→from datetime import datetime
    14→from pathlib import Path
    15→from typing import Any
    16→
    17→from sqlalchemy import Column, DateTime, String, create_engine
    18→from sqlalchemy.ext.declarative import declarative_base
    19→from sqlalchemy.orm import sessionmaker
    20→
    21→# Module logger
    22→logger = logging.getLogger(__name__)
    23→
    24→
    25→# =============================================================================
    26→# Exceptions
    27→# =============================================================================
    28→
    29→class RegistryError(Exception):
    30→    """Base registry exception."""
    31→    pass
    32→
    33→
    34→class RegistryNotFound(RegistryError):
    35→    """Registry file doesn't exist."""
    36→    pass
    37→
    38→
    39→class RegistryCorrupted(RegistryError):
    40→    """Registry database is corrupted."""
    41→    pass
    42→
    43→
    44→class RegistryPermissionDenied(RegistryError):
    45→    """Can't read/write registry file."""
    46→    pass
    47→
    48→
    49→# =============================================================================
    50→# SQLAlchemy Model
    51→# =============================================================================
    52→
    53→Base = declarative_base()
    54→
    55→
    56→class Project(Base):
    57→    """SQLAlchemy model for registered projects."""
    58→    __tablename__ = "projects"
    59→
    60→    name = Column(String(50), primary_key=True, index=True)
    61→    path = Column(String, nullable=False)  # POSIX format for cross-platform
    62→    created_at = Column(DateTime, nullable=False)
    63→
    64→
    65→# =============================================================================
    66→# Database Connection
    67→# =============================================================================
    68→
    69→# Module-level singleton for database engine
    70→_engine = None
    71→_SessionLocal = None
    72→
    73→
    74→def get_config_dir() -> Path:
    75→    """
    76→    Get the config directory: ~/.autocoder/
    77→
    78→    Returns:
    79→        Path to ~/.autocoder/ (created if it doesn't exist)
    80→    """
    81→    config_dir = Path.home() / ".autocoder"
    82→    config_dir.mkdir(parents=True, exist_ok=True)
    83→    return config_dir
    84→
    85→
    86→def get_registry_path() -> Path:
    87→    """Get the path to the registry database."""
    88→    return get_config_dir() / "registry.db"
    89→
    90→
    91→def _get_engine():
    92→    """
    93→    Get or create the database engine (singleton pattern).
    94→
    95→    Returns:
    96→        Tuple of (engine, SessionLocal)
    97→    """
    98→    global _engine, _SessionLocal
    99→
   100→    if _engine is None:
   101→        db_path = get_registry_path()
   102→        db_url = f"sqlite:///{db_path.as_posix()}"
   103→        _engine = create_engine(db_url, connect_args={"check_same_thread": False})
   104→        Base.metadata.create_all(bind=_engine)
   105→        _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
   106→        logger.debug("Initialized registry database at: %s", db_path)
   107→
   108→    return _engine, _SessionLocal
   109→
   110→
   111→@contextmanager
   112→def _get_session():
   113→    """
   114→    Context manager for database sessions with automatic commit/rollback.
   115→
   116→    Yields:
   117→        SQLAlchemy session
   118→    """
   119→    _, SessionLocal = _get_engine()
   120→    session = SessionLocal()
   121→    try:
   122→        yield session
   123→        session.commit()
   124→    except Exception:
   125→        session.rollback()
   126→        raise
   127→    finally:
   128→        session.close()
   129→
   130→
   131→# =============================================================================
   132→# Project CRUD Functions
   133→# =============================================================================
   134→
   135→def register_project(name: str, path: Path) -> None:
   136→    """
   137→    Register a new project in the registry.
   138→
   139→    Args:
   140→        name: The project name (unique identifier).
   141→        path: The absolute path to the project directory.
   142→
   143→    Raises:
   144→        ValueError: If project name is invalid or path is not absolute.
   145→        RegistryError: If a project with that name already exists.
   146→    """
   147→    # Validate name
   148→    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
   149→        raise ValueError(
   150→            "Invalid project name. Use only letters, numbers, hyphens, "
   151→            "and underscores (1-50 chars)."
   152→        )
   153→
   154→    # Ensure path is absolute
   155→    path = Path(path).resolve()
   156→
   157→    with _get_session() as session:
   158→        existing = session.query(Project).filter(Project.name == name).first()
   159→        if existing:
   160→            logger.warning("Attempted to register duplicate project: %s", name)
   161→            raise RegistryError(f"Project '{name}' already exists in registry")
   162→
   163→        project = Project(
   164→            name=name,
   165→            path=path.as_posix(),
   166→            created_at=datetime.now()
   167→        )
   168→        session.add(project)
   169→
   170→    logger.info("Registered project '%s' at path: %s", name, path)
   171→
   172→
   173→def unregister_project(name: str) -> bool:
   174→    """
   175→    Remove a project from the registry.
   176→
   177→    Args:
   178→        name: The project name to remove.
   179→
   180→    Returns:
   181→        True if removed, False if project wasn't found.
   182→    """
   183→    with _get_session() as session:
   184→        project = session.query(Project).filter(Project.name == name).first()
   185→        if not project:
   186→            logger.debug("Attempted to unregister non-existent project: %s", name)
   187→            return False
   188→
   189→        session.delete(project)
   190→
   191→    logger.info("Unregistered project: %s", name)
   192→    return True
   193→
   194→
   195→def get_project_path(name: str) -> Path | None:
   196→    """
   197→    Look up a project's path by name.
   198→
   199→    Args:
   200→        name: The project name.
   201→
   202→    Returns:
   203→        The project Path, or None if not found.
   204→    """
   205→    _, SessionLocal = _get_engine()
   206→    session = SessionLocal()
   207→    try:
   208→        project = session.query(Project).filter(Project.name == name).first()
   209→        if project is None:
   210→            return None
   211→        return Path(project.path)
   212→    finally:
   213→        session.close()
   214→
   215→
   216→def list_registered_projects() -> dict[str, dict[str, Any]]:
   217→    """
   218→    Get all registered projects.
   219→
   220→    Returns:
   221→        Dictionary mapping project names to their info dictionaries.
   222→    """
   223→    _, SessionLocal = _get_engine()
   224→    session = SessionLocal()
   225→    try:
   226→        projects = session.query(Project).all()
   227→        return {
   228→            p.name: {
   229→                "path": p.path,
   230→                "created_at": p.created_at.isoformat() if p.created_at else None
   231→            }
   232→            for p in projects
   233→        }
   234→    finally:
   235→        session.close()
   236→
   237→
   238→def get_project_info(name: str) -> dict[str, Any] | None:
   239→    """
   240→    Get full info about a project.
   241→
   242→    Args:
   243→        name: The project name.
   244→
   245→    Returns:
   246→        Project info dictionary, or None if not found.
   247→    """
   248→    _, SessionLocal = _get_engine()
   249→    session = SessionLocal()
   250→    try:
   251→        project = session.query(Project).filter(Project.name == name).first()
   252→        if project is None:
   253→            return None
   254→        return {
   255→            "path": project.path,
   256→            "created_at": project.created_at.isoformat() if project.created_at else None
   257→        }
   258→    finally:
   259→        session.close()
   260→
   261→
   262→def update_project_path(name: str, new_path: Path) -> bool:
   263→    """
   264→    Update a project's path (for relocating projects).
   265→
   266→    Args:
   267→        name: The project name.
   268→        new_path: The new absolute path.
   269→
   270→    Returns:
   271→        True if updated, False if project wasn't found.
   272→    """
   273→    new_path = Path(new_path).resolve()
   274→
   275→    with _get_session() as session:
   276→        project = session.query(Project).filter(Project.name == name).first()
   277→        if not project:
   278→            return False
   279→
   280→        project.path = new_path.as_posix()
   281→
   282→    return True
   283→
   284→
   285→# =============================================================================
   286→# Validation Functions
   287→# =============================================================================
   288→
   289→def validate_project_path(path: Path) -> tuple[bool, str]:
   290→    """
   291→    Validate that a project path is accessible and writable.
   292→
   293→    Args:
   294→        path: The path to validate.
   295→
   296→    Returns:
   297→        Tuple of (is_valid, error_message).
   298→    """
   299→    path = Path(path).resolve()
   300→
   301→    # Check if path exists
   302→    if not path.exists():
   303→        return False, f"Path does not exist: {path}"
   304→
   305→    # Check if it's a directory
   306→    if not path.is_dir():
   307→        return False, f"Path is not a directory: {path}"
   308→
   309→    # Check read permissions
   310→    if not os.access(path, os.R_OK):
   311→        return False, f"No read permission: {path}"
   312→
   313→    # Check write permissions
   314→    if not os.access(path, os.W_OK):
   315→        return False, f"No write permission: {path}"
   316→
   317→    return True, ""
   318→
   319→
   320→def cleanup_stale_projects() -> list[str]:
   321→    """
   322→    Remove projects from registry whose paths no longer exist.
   323→
   324→    Returns:
   325→        List of removed project names.
   326→    """
   327→    removed = []
   328→
   329→    with _get_session() as session:
   330→        projects = session.query(Project).all()
   331→        for project in projects:
   332→            path = Path(project.path)
   333→            if not path.exists():
   334→                session.delete(project)
   335→                removed.append(project.name)
   336→
   337→    if removed:
   338→        logger.info("Cleaned up stale projects: %s", removed)
   339→
   340→    return removed
   341→
   342→
   343→def list_valid_projects() -> list[dict[str, Any]]:
   344→    """
   345→    List all projects that have valid, accessible paths.
   346→
   347→    Returns:
   348→        List of project info dicts with additional 'name' field.
   349→    """
   350→    _, SessionLocal = _get_engine()
   351→    session = SessionLocal()
   352→    try:
   353→        projects = session.query(Project).all()
   354→        valid = []
   355→        for p in projects:
   356→            path = Path(p.path)
   357→            is_valid, _ = validate_project_path(path)
   358→            if is_valid:
   359→                valid.append({
   360→                    "name": p.name,
   361→                    "path": p.path,
   362→                    "created_at": p.created_at.isoformat() if p.created_at else None
   363→                })
   364→        return valid
   365→    finally:
   366→        session.close()
   367→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Project Registry Module
=======================

Cross-platform project registry for storing project name to path mappings.
Uses SQLite database stored at ~/.autocoder/registry.db.
"""

import logging
import os
import re
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any

from sqlalchemy import Column, DateTime, String, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

# Module logger
logger = logging.getLogger(__name__)

# =============================================================================
# Exceptions
# =============================================================================

class RegistryError(Exception):
    """Base registry exception."""
    pass

class RegistryNotFound(RegistryError):
    """Registry file doesn't exist."""
    pass

class RegistryCorrupted(RegistryError):
    """Registry database is corrupted."""
    pass

class RegistryPermissionDenied(RegistryError):
    """Can't read/write registry file."""
    pass

# =============================================================================
# SQLAlchemy Model
# =============================================================================

Base = declarative_base()

class Project(Base):
    """SQLAlchemy model for registered projects."""
    __tablename__ = "projects"

    name = Column(String(50), primary_key=True, index=True)
    path = Column(String, nullable=False)  # POSIX format for cross-platform
    created_at = Column(DateTime, nullable=False)

# =============================================================================
# Database Connection
# =============================================================================

# Module-level singleton for database engine
_engine = None
_SessionLocal = None

def get_config_dir() -> Path:
    """
    Get the config directory: ~/.autocoder/

    Returns:
        Path to ~/.autocoder/ (created if it doesn't exist)
    """
    config_dir = Path.home() / ".autocoder"
    config_dir.mkdir(parents=True, exist_ok=True)
    return config_dir

def get_registry_path() -> Path:
    """Get the path to the registry database."""
    return get_config_dir() / "registry.db"

def _get_engine():
    """
    Get or create the database engine (singleton pattern).

    Returns:
        Tuple of (engine, SessionLocal)
    """
    global _engine, _SessionLocal

    if _engine is None:
        db_path = get_registry_path()
        db_url = f"sqlite:///{db_path.as_posix()}"
        _engine = create_engine(db_url, connect_args={"check_same_thread": False})
        Base.metadata.create_all(bind=_engine)
        _SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
        logger.debug("Initialized registry database at: %s", db_path)

    return _engine, _SessionLocal

@contextmanager
def _get_session():
    """
    Context manager for database sessions with automatic commit/rollback.

    Yields:
        SQLAlchemy session
    """
    _, SessionLocal = _get_engine()
    session = SessionLocal()
    try:
        yield session
        session.commit()
    except Exception:
        session.rollback()
        raise
    finally:
        session.close()

# =============================================================================
# Project CRUD Functions
# =============================================================================

def register_project(name: str, path: Path) -> None:
    """
    Register a new project in the registry.

    Args:
        name: The project name (unique identifier).
        path: The absolute path to the project directory.

    Raises:
        ValueError: If project name is invalid or path is not absolute.
        RegistryError: If a project with that name already exists.
    """
    # Validate name
    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
        raise ValueError(
            "Invalid project name. Use only letters, numbers, hyphens, "
            "and underscores (1-50 chars)."
        )

    # Ensure path is absolute
    path = Path(path).resolve()

    with _get_session() as session:
        existing = session.query(Project).filter(Project.name == name).first()
        if existing:
            logger.warning("Attempted to register duplicate project: %s", name)
            raise RegistryError(f"Project '{name}' already exists in registry")

        project = Project(
            name=name,
            path=path.as_posix(),
            created_at=datetime.now()
        )
        session.add(project)

    logger.info("Registered project '%s' at path: %s", name, path)

def unregister_project(name: str) -> bool:
    """
    Remove a project from the registry.

    Args:
        name: The project name to remove.

    Returns:
        True if removed, False if project wasn't found.
    """
    with _get_session() as session:
        project = session.query(Project).filter(Project.name == name).first()
        if not project:
            logger.debug("Attempted to unregister non-existent project: %s", name)
            return False

        session.delete(project)

    logger.info("Unregistered project: %s", name)
    return True

def get_project_path(name: str) -> Path | None:
    """
    Look up a project's path by name.

    Args:
        name: The project name.

    Returns:
        The project Path, or None if not found.
    """
    _, SessionLocal = _get_engine()
    session = SessionLocal()
    try:
        project = session.query(Project).filter(Project.name == name).first()
        if project is None:
            return None
        return Path(project.path)
    finally:
        session.close()

def list_registered_projects() -> dict[str, dict[str, Any]]:
    """
    Get all registered projects.

    Returns:
        Dictionary mapping project names to their info dictionaries.
    """
    _, SessionLocal = _get_engine()
    session = SessionLocal()
    try:
        projects = session.query(Project).all()
        return {
            p.name: {
                "path": p.path,
                "created_at": p.created_at.isoformat() if p.created_at else None
            }
            for p in projects
        }
    finally:
        session.close()

def get_project_info(name: str) -> dict[str, Any] | None:
    """
    Get full info about a project.

    Args:
        name: The project name.

    Returns:
        Project info dictionary, or None if not found.
    """
    _, SessionLocal = _get_engine()
    session = SessionLocal()
    try:
        project = session.query(Project).filter(Project.name == name).first()
        if project is None:
            return None
        return {
            "path": project.path,
            "created_at": project.created_at.isoformat() if project.created_at else None
        }
    finally:
        session.close()

def update_project_path(name: str, new_path: Path) -> bool:
    """
    Update a project's path (for relocating projects).

    Args:
        name: The project name.
        new_path: The new absolute path.

    Returns:
        True if updated, False if project wasn't found.
    """
    new_path = Path(new_path).resolve()

    with _get_session() as session:
        project = session.query(Project).filter(Project.name == name).first()
        if not project:
            return False

        project.path = new_path.as_posix()

    return True

# =============================================================================
# Validation Functions
# =============================================================================

def validate_project_path(path: Path) -> tuple[bool, str]:
    """
    Validate that a project path is accessible and writable.

    Args:
        path: The path to validate.

    Returns:
        Tuple of (is_valid, error_message).
    """
    path = Path(path).resolve()

    # Check if path exists
    if not path.exists():
        return False, f"Path does not exist: {path}"

    # Check if it's a directory
    if not path.is_dir():
        return False, f"Path is not a directory: {path}"

    # Check read permissions
    if not os.access(path, os.R_OK):
        return False, f"No read permission: {path}"

    # Check write permissions
    if not os.access(path, os.W_OK):
        return False, f"No write permission: {path}"

    return True, ""

def cleanup_stale_projects() -> list[str]:
    """
    Remove projects from registry whose paths no longer exist.

    Returns:
        List of removed project names.
    """
    removed = []

    with _get_session() as session:
        projects = session.query(Project).all()
        for project in projects:
            path = Path(project.path)
            if not path.exists():
                session.delete(project)
                removed.append(project.name)

    if removed:
        logger.info("Cleaned up stale projects: %s", removed)

    return removed

def list_valid_projects() -> list[dict[str, Any]]:
    """
    List all projects that have valid, accessible paths.

    Returns:
        List of project info dicts with additional 'name' field.
    """
    _, SessionLocal = _get_engine()
    session = SessionLocal()
    try:
        projects = session.query(Project).all()
        valid = []
        for p in projects:
            path = Path(p.path)
            is_valid, _ = validate_project_path(path)
            if is_valid:
                valid.append({
                    "name": p.name,
                    "path": p.path,
                    "created_at": p.created_at.isoformat() if p.created_at else None
                })
        return valid
    finally:
        session.close()


     1→"""
     2→Prompt Loading Utilities
     3→========================
     4→
     5→Functions for loading prompt templates with project-specific support.
     6→
     7→Fallback chain:
     8→1. Project-specific: {project_dir}/prompts/{name}.md
     9→2. Base template: .claude/templates/{name}.template.md
    10→"""
    11→
    12→import shutil
    13→from pathlib import Path
    14→
    15→# Base templates location (generic templates)
    16→TEMPLATES_DIR = Path(__file__).parent / ".claude" / "templates"
    17→
    18→
    19→def get_project_prompts_dir(project_dir: Path) -> Path:
    20→    """Get the prompts directory for a specific project."""
    21→    return project_dir / "prompts"
    22→
    23→
    24→def load_prompt(name: str, project_dir: Path | None = None) -> str:
    25→    """
    26→    Load a prompt template with fallback chain.
    27→
    28→    Fallback order:
    29→    1. Project-specific: {project_dir}/prompts/{name}.md
    30→    2. Base template: .claude/templates/{name}.template.md
    31→
    32→    Args:
    33→        name: The prompt name (without extension), e.g., "initializer_prompt"
    34→        project_dir: Optional project directory for project-specific prompts
    35→
    36→    Returns:
    37→        The prompt content as a string
    38→
    39→    Raises:
    40→        FileNotFoundError: If prompt not found in any location
    41→    """
    42→    # 1. Try project-specific first
    43→    if project_dir:
    44→        project_prompts = get_project_prompts_dir(project_dir)
    45→        project_path = project_prompts / f"{name}.md"
    46→        if project_path.exists():
    47→            try:
    48→                return project_path.read_text(encoding="utf-8")
    49→            except (OSError, PermissionError) as e:
    50→                print(f"Warning: Could not read {project_path}: {e}")
    51→
    52→    # 2. Try base template
    53→    template_path = TEMPLATES_DIR / f"{name}.template.md"
    54→    if template_path.exists():
    55→        try:
    56→            return template_path.read_text(encoding="utf-8")
    57→        except (OSError, PermissionError) as e:
    58→            print(f"Warning: Could not read {template_path}: {e}")
    59→
    60→    raise FileNotFoundError(
    61→        f"Prompt '{name}' not found in:\n"
    62→        f"  - Project: {project_dir / 'prompts' if project_dir else 'N/A'}\n"
    63→        f"  - Templates: {TEMPLATES_DIR}"
    64→    )
    65→
    66→
    67→def get_initializer_prompt(project_dir: Path | None = None) -> str:
    68→    """Load the initializer prompt (project-specific if available)."""
    69→    return load_prompt("initializer_prompt", project_dir)
    70→
    71→
    72→def get_coding_prompt(project_dir: Path | None = None) -> str:
    73→    """Load the coding agent prompt (project-specific if available)."""
    74→    return load_prompt("coding_prompt", project_dir)
    75→
    76→
    77→def get_coding_prompt_yolo(project_dir: Path | None = None) -> str:
    78→    """Load the YOLO mode coding agent prompt (project-specific if available)."""
    79→    return load_prompt("coding_prompt_yolo", project_dir)
    80→
    81→
    82→def get_app_spec(project_dir: Path) -> str:
    83→    """
    84→    Load the app spec from the project.
    85→
    86→    Checks in order:
    87→    1. Project prompts directory: {project_dir}/prompts/app_spec.txt
    88→    2. Project root (legacy): {project_dir}/app_spec.txt
    89→
    90→    Args:
    91→        project_dir: The project directory
    92→
    93→    Returns:
    94→        The app spec content
    95→
    96→    Raises:
    97→        FileNotFoundError: If no app_spec.txt found
    98→    """
    99→    # Try project prompts directory first
   100→    project_prompts = get_project_prompts_dir(project_dir)
   101→    spec_path = project_prompts / "app_spec.txt"
   102→    if spec_path.exists():
   103→        try:
   104→            return spec_path.read_text(encoding="utf-8")
   105→        except (OSError, PermissionError) as e:
   106→            raise FileNotFoundError(f"Could not read {spec_path}: {e}") from e
   107→
   108→    # Fallback to legacy location in project root
   109→    legacy_spec = project_dir / "app_spec.txt"
   110→    if legacy_spec.exists():
   111→        try:
   112→            return legacy_spec.read_text(encoding="utf-8")
   113→        except (OSError, PermissionError) as e:
   114→            raise FileNotFoundError(f"Could not read {legacy_spec}: {e}") from e
   115→
   116→    raise FileNotFoundError(f"No app_spec.txt found for project: {project_dir}")
   117→
   118→
   119→def scaffold_project_prompts(project_dir: Path) -> Path:
   120→    """
   121→    Create the project prompts directory and copy base templates.
   122→
   123→    This sets up a new project with template files that can be customized.
   124→
   125→    Args:
   126→        project_dir: The absolute path to the project directory
   127→
   128→    Returns:
   129→        The path to the project prompts directory
   130→    """
   131→    project_prompts = get_project_prompts_dir(project_dir)
   132→    project_prompts.mkdir(parents=True, exist_ok=True)
   133→
   134→    # Define template mappings: (source_template, destination_name)
   135→    templates = [
   136→        ("app_spec.template.txt", "app_spec.txt"),
   137→        ("coding_prompt.template.md", "coding_prompt.md"),
   138→        ("coding_prompt_yolo.template.md", "coding_prompt_yolo.md"),
   139→        ("initializer_prompt.template.md", "initializer_prompt.md"),
   140→    ]
   141→
   142→    copied_files = []
   143→    for template_name, dest_name in templates:
   144→        template_path = TEMPLATES_DIR / template_name
   145→        dest_path = project_prompts / dest_name
   146→
   147→        # Only copy if template exists and destination doesn't
   148→        if template_path.exists() and not dest_path.exists():
   149→            try:
   150→                shutil.copy(template_path, dest_path)
   151→                copied_files.append(dest_name)
   152→            except (OSError, PermissionError) as e:
   153→                print(f"  Warning: Could not copy {dest_name}: {e}")
   154→
   155→    if copied_files:
   156→        print(f"  Created prompt files: {', '.join(copied_files)}")
   157→
   158→    return project_prompts
   159→
   160→
   161→def has_project_prompts(project_dir: Path) -> bool:
   162→    """
   163→    Check if a project has valid prompts set up.
   164→
   165→    A project has valid prompts if:
   166→    1. The prompts directory exists, AND
   167→    2. app_spec.txt exists within it, AND
   168→    3. app_spec.txt contains the <project_specification> tag
   169→
   170→    Args:
   171→        project_dir: The project directory to check
   172→
   173→    Returns:
   174→        True if valid project prompts exist, False otherwise
   175→    """
   176→    project_prompts = get_project_prompts_dir(project_dir)
   177→    app_spec = project_prompts / "app_spec.txt"
   178→
   179→    if not app_spec.exists():
   180→        # Also check legacy location in project root
   181→        legacy_spec = project_dir / "app_spec.txt"
   182→        if legacy_spec.exists():
   183→            try:
   184→                content = legacy_spec.read_text(encoding="utf-8")
   185→                return "<project_specification>" in content
   186→            except (OSError, PermissionError):
   187→                return False
   188→        return False
   189→
   190→    # Check for valid spec content
   191→    try:
   192→        content = app_spec.read_text(encoding="utf-8")
   193→        return "<project_specification>" in content
   194→    except (OSError, PermissionError):
   195→        return False
   196→
   197→
   198→def copy_spec_to_project(project_dir: Path) -> None:
   199→    """
   200→    Copy the app spec file into the project root directory for the agent to read.
   201→
   202→    This maintains backwards compatibility - the agent expects app_spec.txt
   203→    in the project root directory.
   204→
   205→    The spec is sourced from: {project_dir}/prompts/app_spec.txt
   206→
   207→    Args:
   208→        project_dir: The project directory
   209→    """
   210→    spec_dest = project_dir / "app_spec.txt"
   211→
   212→    # Don't overwrite if already exists
   213→    if spec_dest.exists():
   214→        return
   215→
   216→    # Copy from project prompts directory
   217→    project_prompts = get_project_prompts_dir(project_dir)
   218→    project_spec = project_prompts / "app_spec.txt"
   219→    if project_spec.exists():
   220→        try:
   221→            shutil.copy(project_spec, spec_dest)
   222→            print("Copied app_spec.txt to project directory")
   223→            return
   224→        except (OSError, PermissionError) as e:
   225→            print(f"Warning: Could not copy app_spec.txt: {e}")
   226→            return
   227→
   228→    print("Warning: No app_spec.txt found to copy to project directory")
   229→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Prompt Loading Utilities
========================

Functions for loading prompt templates with project-specific support.

Fallback chain:
1. Project-specific: {project_dir}/prompts/{name}.md
2. Base template: .claude/templates/{name}.template.md
"""

import shutil
from pathlib import Path

# Base templates location (generic templates)
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"

def load_prompt(name: str, project_dir: Path | None = None) -> str:
    """
    Load a prompt template with fallback chain.

    Fallback order:
    1. Project-specific: {project_dir}/prompts/{name}.md
    2. Base template: .claude/templates/{name}.template.md

    Args:
        name: The prompt name (without extension), e.g., "initializer_prompt"
        project_dir: Optional project directory for project-specific prompts

    Returns:
        The prompt content as a string

    Raises:
        FileNotFoundError: If prompt not found in any location
    """
    # 1. Try project-specific first
    if project_dir:
        project_prompts = get_project_prompts_dir(project_dir)
        project_path = project_prompts / f"{name}.md"
        if project_path.exists():
            try:
                return project_path.read_text(encoding="utf-8")
            except (OSError, PermissionError) as e:
                print(f"Warning: Could not read {project_path}: {e}")

    # 2. Try base template
    template_path = TEMPLATES_DIR / f"{name}.template.md"
    if template_path.exists():
        try:
            return template_path.read_text(encoding="utf-8")
        except (OSError, PermissionError) as e:
            print(f"Warning: Could not read {template_path}: {e}")

    raise FileNotFoundError(
        f"Prompt '{name}' not found in:\n"
        f"  - Project: {project_dir / 'prompts' if project_dir else 'N/A'}\n"
        f"  - Templates: {TEMPLATES_DIR}"
    )

def get_initializer_prompt(project_dir: Path | None = None) -> str:
    """Load the initializer prompt (project-specific if available)."""
    return load_prompt("initializer_prompt", project_dir)

def get_coding_prompt(project_dir: Path | None = None) -> str:
    """Load the coding agent prompt (project-specific if available)."""
    return load_prompt("coding_prompt", project_dir)

def get_coding_prompt_yolo(project_dir: Path | None = None) -> str:
    """Load the YOLO mode coding agent prompt (project-specific if available)."""
    return load_prompt("coding_prompt_yolo", project_dir)

def get_app_spec(project_dir: Path) -> str:
    """
    Load the app spec from the project.

    Checks in order:
    1. Project prompts directory: {project_dir}/prompts/app_spec.txt
    2. Project root (legacy): {project_dir}/app_spec.txt

    Args:
        project_dir: The project directory

    Returns:
        The app spec content

    Raises:
        FileNotFoundError: If no app_spec.txt found
    """
    # Try project prompts directory first
    project_prompts = get_project_prompts_dir(project_dir)
    spec_path = project_prompts / "app_spec.txt"
    if spec_path.exists():
        try:
            return spec_path.read_text(encoding="utf-8")
        except (OSError, PermissionError) as e:
            raise FileNotFoundError(f"Could not read {spec_path}: {e}") from e

    # Fallback to legacy location in project root
    legacy_spec = project_dir / "app_spec.txt"
    if legacy_spec.exists():
        try:
            return legacy_spec.read_text(encoding="utf-8")
        except (OSError, PermissionError) as e:
            raise FileNotFoundError(f"Could not read {legacy_spec}: {e}") from e

    raise FileNotFoundError(f"No app_spec.txt found for project: {project_dir}")

def scaffold_project_prompts(project_dir: Path) -> Path:
    """
    Create the project prompts directory and copy base templates.

    This sets up a new project with template files that can be customized.

    Args:
        project_dir: The absolute path to the project directory

    Returns:
        The path to the project prompts directory
    """
    project_prompts = get_project_prompts_dir(project_dir)
    project_prompts.mkdir(parents=True, exist_ok=True)

    # Define template mappings: (source_template, destination_name)
    templates = [
        ("app_spec.template.txt", "app_spec.txt"),
        ("coding_prompt.template.md", "coding_prompt.md"),
        ("coding_prompt_yolo.template.md", "coding_prompt_yolo.md"),
        ("initializer_prompt.template.md", "initializer_prompt.md"),
    ]

    copied_files = []
    for template_name, dest_name in templates:
        template_path = TEMPLATES_DIR / template_name
        dest_path = project_prompts / dest_name

        # Only copy if template exists and destination doesn't
        if template_path.exists() and not dest_path.exists():
            try:
                shutil.copy(template_path, dest_path)
                copied_files.append(dest_name)
            except (OSError, PermissionError) as e:
                print(f"  Warning: Could not copy {dest_name}: {e}")

    if copied_files:
        print(f"  Created prompt files: {', '.join(copied_files)}")

    return project_prompts

def has_project_prompts(project_dir: Path) -> bool:
    """
    Check if a project has valid prompts set up.

    A project has valid prompts if:
    1. The prompts directory exists, AND
    2. app_spec.txt exists within it, AND
    3. app_spec.txt contains the <project_specification> tag

    Args:
        project_dir: The project directory to check

    Returns:
        True if valid project prompts exist, False otherwise
    """
    project_prompts = get_project_prompts_dir(project_dir)
    app_spec = project_prompts / "app_spec.txt"

    if not app_spec.exists():
        # Also check legacy location in project root
        legacy_spec = project_dir / "app_spec.txt"
        if legacy_spec.exists():
            try:
                content = legacy_spec.read_text(encoding="utf-8")
                return "<project_specification>" in content
            except (OSError, PermissionError):
                return False
        return False

    # Check for valid spec content
    try:
        content = app_spec.read_text(encoding="utf-8")
        return "<project_specification>" in content
    except (OSError, PermissionError):
        return False

def copy_spec_to_project(project_dir: Path) -> None:
    """
    Copy the app spec file into the project root directory for the agent to read.

    This maintains backwards compatibility - the agent expects app_spec.txt
    in the project root directory.

    The spec is sourced from: {project_dir}/prompts/app_spec.txt

    Args:
        project_dir: The project directory
    """
    spec_dest = project_dir / "app_spec.txt"

    # Don't overwrite if already exists
    if spec_dest.exists():
        return

    # Copy from project prompts directory
    project_prompts = get_project_prompts_dir(project_dir)
    project_spec = project_prompts / "app_spec.txt"
    if project_spec.exists():
        try:
            shutil.copy(project_spec, spec_dest)
            print("Copied app_spec.txt to project directory")
            return
        except (OSError, PermissionError) as e:
            print(f"Warning: Could not copy app_spec.txt: {e}")
            return

    print("Warning: No app_spec.txt found to copy to project directory")


Now let me read the MCP server and progress tracking files:


Bash

find "C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocode-fork\autocoder-master" -type f -name "*.py" | wc -l


     1→#!/usr/bin/env python3
     2→"""
     3→MCP Server for Feature Management
     4→==================================
     5→
     6→Provides tools to manage features in the autonomous coding system,
     7→replacing the previous FastAPI-based REST API.
     8→
     9→Tools:
    10→- feature_get_stats: Get progress statistics
    11→- feature_get_next: Get next feature to implement
    12→- feature_get_for_regression: Get random passing features for testing
    13→- feature_mark_passing: Mark a feature as passing
    14→- feature_skip: Skip a feature (move to end of queue)
    15→- feature_mark_in_progress: Mark a feature as in-progress
    16→- feature_clear_in_progress: Clear in-progress status
    17→- feature_create_bulk: Create multiple features at once
    18→"""
    19→
    20→import json
    21→import os
    22→import sys
    23→from contextlib import asynccontextmanager
    24→from pathlib import Path
    25→from typing import Annotated
    26→
    27→from mcp.server.fastmcp import FastMCP
    28→from pydantic import BaseModel, Field
    29→from sqlalchemy.sql.expression import func
    30→
    31→# Add parent directory to path so we can import from api module
    32→sys.path.insert(0, str(Path(__file__).parent.parent))
    33→
    34→from api.database import Feature, create_database
    35→from api.migration import migrate_json_to_sqlite
    36→
    37→# Configuration from environment
    38→PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve()
    39→
    40→
    41→# Pydantic models for input validation
    42→class MarkPassingInput(BaseModel):
    43→    """Input for marking a feature as passing."""
    44→    feature_id: int = Field(..., description="The ID of the feature to mark as passing", ge=1)
    45→
    46→
    47→class SkipFeatureInput(BaseModel):
    48→    """Input for skipping a feature."""
    49→    feature_id: int = Field(..., description="The ID of the feature to skip", ge=1)
    50→
    51→
    52→class MarkInProgressInput(BaseModel):
    53→    """Input for marking a feature as in-progress."""
    54→    feature_id: int = Field(..., description="The ID of the feature to mark as in-progress", ge=1)
    55→
    56→
    57→class ClearInProgressInput(BaseModel):
    58→    """Input for clearing in-progress status."""
    59→    feature_id: int = Field(..., description="The ID of the feature to clear in-progress status", ge=1)
    60→
    61→
    62→class RegressionInput(BaseModel):
    63→    """Input for getting regression features."""
    64→    limit: int = Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")
    65→
    66→
    67→class FeatureCreateItem(BaseModel):
    68→    """Schema for creating a single feature."""
    69→    category: str = Field(..., min_length=1, max_length=100, description="Feature category")
    70→    name: str = Field(..., min_length=1, max_length=255, description="Feature name")
    71→    description: str = Field(..., min_length=1, description="Detailed description")
    72→    steps: list[str] = Field(..., min_length=1, description="Implementation/test steps")
    73→
    74→
    75→class BulkCreateInput(BaseModel):
    76→    """Input for bulk creating features."""
    77→    features: list[FeatureCreateItem] = Field(..., min_length=1, description="List of features to create")
    78→
    79→
    80→# Global database session maker (initialized on startup)
    81→_session_maker = None
    82→_engine = None
    83→
    84→
    85→@asynccontextmanager
    86→async def server_lifespan(server: FastMCP):
    87→    """Initialize database on startup, cleanup on shutdown."""
    88→    global _session_maker, _engine
    89→
    90→    # Create project directory if it doesn't exist
    91→    PROJECT_DIR.mkdir(parents=True, exist_ok=True)
    92→
    93→    # Initialize database
    94→    _engine, _session_maker = create_database(PROJECT_DIR)
    95→
    96→    # Run migration if needed (converts legacy JSON to SQLite)
    97→    migrate_json_to_sqlite(PROJECT_DIR, _session_maker)
    98→
    99→    yield
   100→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

#!/usr/bin/env python3
"""
MCP Server for Feature Management
==================================

Provides tools to manage features in the autonomous coding system,
replacing the previous FastAPI-based REST API.

Tools:
- feature_get_stats: Get progress statistics
- feature_get_next: Get next feature to implement
- feature_get_for_regression: Get random passing features for testing
- feature_mark_passing: Mark a feature as passing
- feature_skip: Skip a feature (move to end of queue)
- feature_mark_in_progress: Mark a feature as in-progress
- feature_clear_in_progress: Clear in-progress status
- feature_create_bulk: Create multiple features at once
"""

import json
import os
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated

from mcp.server.fastmcp import FastMCP
from pydantic import BaseModel, Field
from sqlalchemy.sql.expression import func

# Add parent directory to path so we can import from api module
sys.path.insert(0, str(Path(__file__).parent.parent))

from api.database import Feature, create_database
from api.migration import migrate_json_to_sqlite

# Configuration from environment
PROJECT_DIR = Path(os.environ.get("PROJECT_DIR", ".")).resolve()

# Pydantic models for input validation
class MarkPassingInput(BaseModel):
    """Input for marking a feature as passing."""
    feature_id: int = Field(..., description="The ID of the feature to mark as passing", ge=1)

class SkipFeatureInput(BaseModel):
    """Input for skipping a feature."""
    feature_id: int = Field(..., description="The ID of the feature to skip", ge=1)

class MarkInProgressInput(BaseModel):
    """Input for marking a feature as in-progress."""
    feature_id: int = Field(..., description="The ID of the feature to mark as in-progress", ge=1)

class ClearInProgressInput(BaseModel):
    """Input for clearing in-progress status."""
    feature_id: int = Field(..., description="The ID of the feature to clear in-progress status", ge=1)

class RegressionInput(BaseModel):
    """Input for getting regression features."""
    limit: int = Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")

class FeatureCreateItem(BaseModel):
    """Schema for creating a single feature."""
    category: str = Field(..., min_length=1, max_length=100, description="Feature category")
    name: str = Field(..., min_length=1, max_length=255, description="Feature name")
    description: str = Field(..., min_length=1, description="Detailed description")
    steps: list[str] = Field(..., min_length=1, description="Implementation/test steps")

class BulkCreateInput(BaseModel):
    """Input for bulk creating features."""
    features: list[FeatureCreateItem] = Field(..., min_length=1, description="List of features to create")

# Global database session maker (initialized on startup)
_session_maker = None
_engine = None

@asynccontextmanager
async def server_lifespan(server: FastMCP):
    """Initialize database on startup, cleanup on shutdown."""
    global _session_maker, _engine

    # Create project directory if it doesn't exist
    PROJECT_DIR.mkdir(parents=True, exist_ok=True)

    # Initialize database
    _engine, _session_maker = create_database(PROJECT_DIR)

    # Run migration if needed (converts legacy JSON to SQLite)
    migrate_json_to_sqlite(PROJECT_DIR, _session_maker)

    yield


     1→"""
     2→Progress Tracking Utilities
     3→===========================
     4→
     5→Functions for tracking and displaying progress of the autonomous coding agent.
     6→Uses direct SQLite access for database queries.
     7→"""
     8→
     9→import json
    10→import os
    11→import sqlite3
    12→import urllib.request
    13→from datetime import datetime
    14→from pathlib import Path
    15→
    16→WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL")
    17→PROGRESS_CACHE_FILE = ".progress_cache"
    18→
    19→
    20→def has_features(project_dir: Path) -> bool:
    21→    """
    22→    Check if the project has features in the database.
    23→
    24→    This is used to determine if the initializer agent needs to run.
    25→    We check the database directly (not via API) since the API server
    26→    may not be running yet when this check is performed.
    27→
    28→    Returns True if:
    29→    - features.db exists AND has at least 1 feature, OR
    30→    - feature_list.json exists (legacy format)
    31→
    32→    Returns False if no features exist (initializer needs to run).
    33→    """
    34→    import sqlite3
    35→
    36→    # Check legacy JSON file first
    37→    json_file = project_dir / "feature_list.json"
    38→    if json_file.exists():
    39→        return True
    40→
    41→    # Check SQLite database
    42→    db_file = project_dir / "features.db"
    43→    if not db_file.exists():
    44→        return False
    45→
    46→    try:
    47→        conn = sqlite3.connect(db_file)
    48→        cursor = conn.cursor()
    49→        cursor.execute("SELECT COUNT(*) FROM features")
    50→        count = cursor.fetchone()[0]
    51→        conn.close()
    52→        return count > 0
    53→    except Exception:
    54→        # Database exists but can't be read or has no features table
    55→        return False
    56→
    57→
    58→def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
    59→    """
    60→    Count passing, in_progress, and total tests via direct database access.
    61→
    62→    Args:
    63→        project_dir: Directory containing the project
    64→
    65→    Returns:
    66→        (passing_count, in_progress_count, total_count)
    67→    """
    68→    db_file = project_dir / "features.db"
    69→    if not db_file.exists():
    70→        return 0, 0, 0
    71→
    72→    try:
    73→        conn = sqlite3.connect(db_file)
    74→        cursor = conn.cursor()
    75→        cursor.execute("SELECT COUNT(*) FROM features")
    76→        total = cursor.fetchone()[0]
    77→        cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1")
    78→        passing = cursor.fetchone()[0]
    79→        # Handle case where in_progress column doesn't exist yet
    80→        try:
    81→            cursor.execute("SELECT COUNT(*) FROM features WHERE in_progress = 1")
    82→            in_progress = cursor.fetchone()[0]
    83→        except sqlite3.OperationalError:
    84→            in_progress = 0
    85→        conn.close()
    86→        return passing, in_progress, total
    87→    except Exception as e:
    88→        print(f"[Database error in count_passing_tests: {e}]")
    89→        return 0, 0, 0
    90→
    91→
    92→def get_all_passing_features(project_dir: Path) -> list[dict]:
    93→    """
    94→    Get all passing features for webhook notifications.
    95→
    96→    Args:
    97→        project_dir: Directory containing the project
    98→
    99→    Returns:
   100→        List of dicts with id, category, name for each passing feature
   101→    """
   102→    db_file = project_dir / "features.db"
   103→    if not db_file.exists():
   104→        return []
   105→
   106→    try:
   107→        conn = sqlite3.connect(db_file)
   108→        cursor = conn.cursor()
   109→        cursor.execute(
   110→            "SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC"
   111→        )
   112→        features = [
   113→            {"id": row[0], "category": row[1], "name": row[2]}
   114→            for row in cursor.fetchall()
   115→        ]
   116→        conn.close()
   117→        return features
   118→    except Exception:
   119→        return []
   120→
   121→
   122→def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None:
   123→    """Send webhook notification when progress increases."""
   124→    if not WEBHOOK_URL:
   125→        return  # Webhook not configured
   126→
   127→    cache_file = project_dir / PROGRESS_CACHE_FILE
   128→    previous = 0
   129→    previous_passing_ids = set()
   130→
   131→    # Read previous progress and passing feature IDs
   132→    if cache_file.exists():
   133→        try:
   134→            cache_data = json.loads(cache_file.read_text())
   135→            previous = cache_data.get("count", 0)
   136→            previous_passing_ids = set(cache_data.get("passing_ids", []))
   137→        except Exception:
   138→            previous = 0
   139→
   140→    # Only notify if progress increased
   141→    if passing > previous:
   142→        # Find which features are now passing via API
   143→        completed_tests = []
   144→        current_passing_ids = []
   145→
   146→        # Detect transition from old cache format (had count but no passing_ids)
   147→        # In this case, we can't reliably identify which specific tests are new
   148→        is_old_cache_format = len(previous_passing_ids) == 0 and previous > 0
   149→
   150→        # Get all passing features via direct database access
   151→        all_passing = get_all_passing_features(project_dir)
   152→        for feature in all_passing:
   153→            feature_id = feature.get("id")
   154→            current_passing_ids.append(feature_id)
   155→            # Only identify individual new tests if we have previous IDs to compare
   156→            if not is_old_cache_format and feature_id not in previous_passing_ids:
   157→                # This feature is newly passing
   158→                name = feature.get("name", f"Feature #{feature_id}")
   159→                category = feature.get("category", "")
   160→                if category:
   161→                    completed_tests.append(f"{category} {name}")
   162→                else:
   163→                    completed_tests.append(name)
   164→
   165→        payload = {
   166→            "event": "test_progress",
   167→            "passing": passing,
   168→            "total": total,
   169→            "percentage": round((passing / total) * 100, 1) if total > 0 else 0,
   170→            "previous_passing": previous,
   171→            "tests_completed_this_session": passing - previous,
   172→            "completed_tests": completed_tests,
   173→            "project": project_dir.name,
   174→            "timestamp": datetime.utcnow().isoformat() + "Z",
   175→        }
   176→
   177→        try:
   178→            req = urllib.request.Request(
   179→                WEBHOOK_URL,
   180→                data=json.dumps([payload]).encode("utf-8"),  # n8n expects array
   181→                headers={"Content-Type": "application/json"},
   182→            )
   183→            urllib.request.urlopen(req, timeout=5)
   184→        except Exception as e:
   185→            print(f"[Webhook notification failed: {e}]")
   186→
   187→        # Update cache with count and passing IDs
   188→        cache_file.write_text(
   189→            json.dumps({"count": passing, "passing_ids": current_passing_ids})
   190→        )
   191→    else:
   192→        # Update cache even if no change (for initial state)
   193→        if not cache_file.exists():
   194→            all_passing = get_all_passing_features(project_dir)
   195→            current_passing_ids = [f.get("id") for f in all_passing]
   196→            cache_file.write_text(
   197→                json.dumps({"count": passing, "passing_ids": current_passing_ids})
   198→            )
   199→
   200→
   201→def print_session_header(session_num: int, is_initializer: bool) -> None:
   202→    """Print a formatted header for the session."""
   203→    session_type = "INITIALIZER" if is_initializer else "CODING AGENT"
   204→
   205→    print("\n" + "=" * 70)
   206→    print(f"  SESSION {session_num}: {session_type}")
   207→    print("=" * 70)
   208→    print()
   209→
   210→
   211→def print_progress_summary(project_dir: Path) -> None:
   212→    """Print a summary of current progress."""
   213→    passing, in_progress, total = count_passing_tests(project_dir)
   214→
   215→    if total > 0:
   216→        percentage = (passing / total) * 100
   217→        status_parts = [f"{passing}/{total} tests passing ({percentage:.1f}%)"]
   218→        if in_progress > 0:
   219→            status_parts.append(f"{in_progress} in progress")
   220→        print(f"\nProgress: {', '.join(status_parts)}")
   221→        send_progress_webhook(passing, total, project_dir)
   222→    else:
   223→        print("\nProgress: No features in database yet")
   224→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Progress Tracking Utilities
===========================

Functions for tracking and displaying progress of the autonomous coding agent.
Uses direct SQLite access for database queries.
"""

import json
import os
import sqlite3
import urllib.request
from datetime import datetime
from pathlib import Path

WEBHOOK_URL = os.environ.get("PROGRESS_N8N_WEBHOOK_URL")
PROGRESS_CACHE_FILE = ".progress_cache"

def has_features(project_dir: Path) -> bool:
    """
    Check if the project has features in the database.

    This is used to determine if the initializer agent needs to run.
    We check the database directly (not via API) since the API server
    may not be running yet when this check is performed.

    Returns True if:
    - features.db exists AND has at least 1 feature, OR
    - feature_list.json exists (legacy format)

    Returns False if no features exist (initializer needs to run).
    """
    import sqlite3

    # Check legacy JSON file first
    json_file = project_dir / "feature_list.json"
    if json_file.exists():
        return True

    # Check SQLite database
    db_file = project_dir / "features.db"
    if not db_file.exists():
        return False

    try:
        conn = sqlite3.connect(db_file)
        cursor = conn.cursor()
        cursor.execute("SELECT COUNT(*) FROM features")
        count = cursor.fetchone()[0]
        conn.close()
        return count > 0
    except Exception:
        # Database exists but can't be read or has no features table
        return False

def count_passing_tests(project_dir: Path) -> tuple[int, int, int]:
    """
    Count passing, in_progress, and total tests via direct database access.

    Args:
        project_dir: Directory containing the project

    Returns:
        (passing_count, in_progress_count, total_count)
    """
    db_file = project_dir / "features.db"
    if not db_file.exists():
        return 0, 0, 0

    try:
        conn = sqlite3.connect(db_file)
        cursor = conn.cursor()
        cursor.execute("SELECT COUNT(*) FROM features")
        total = cursor.fetchone()[0]
        cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1")
        passing = cursor.fetchone()[0]
        # Handle case where in_progress column doesn't exist yet
        try:
            cursor.execute("SELECT COUNT(*) FROM features WHERE in_progress = 1")
            in_progress = cursor.fetchone()[0]
        except sqlite3.OperationalError:
            in_progress = 0
        conn.close()
        return passing, in_progress, total
    except Exception as e:
        print(f"[Database error in count_passing_tests: {e}]")
        return 0, 0, 0

def get_all_passing_features(project_dir: Path) -> list[dict]:
    """
    Get all passing features for webhook notifications.

    Args:
        project_dir: Directory containing the project

    Returns:
        List of dicts with id, category, name for each passing feature
    """
    db_file = project_dir / "features.db"
    if not db_file.exists():
        return []

    try:
        conn = sqlite3.connect(db_file)
        cursor = conn.cursor()
        cursor.execute(
            "SELECT id, category, name FROM features WHERE passes = 1 ORDER BY priority ASC"
        )
        features = [
            {"id": row[0], "category": row[1], "name": row[2]}
            for row in cursor.fetchall()
        ]
        conn.close()
        return features
    except Exception:
        return []

def send_progress_webhook(passing: int, total: int, project_dir: Path) -> None:
    """Send webhook notification when progress increases."""
    if not WEBHOOK_URL:
        return  # Webhook not configured

    cache_file = project_dir / PROGRESS_CACHE_FILE
    previous = 0
    previous_passing_ids = set()

    # Read previous progress and passing feature IDs
    if cache_file.exists():
        try:
            cache_data = json.loads(cache_file.read_text())
            previous = cache_data.get("count", 0)
            previous_passing_ids = set(cache_data.get("passing_ids", []))
        except Exception:
            previous = 0

    # Only notify if progress increased
    if passing > previous:
        # Find which features are now passing via API
        completed_tests = []
        current_passing_ids = []

        # Detect transition from old cache format (had count but no passing_ids)
        # In this case, we can't reliably identify which specific tests are new
        is_old_cache_format = len(previous_passing_ids) == 0 and previous > 0

        # Get all passing features via direct database access
        all_passing = get_all_passing_features(project_dir)
        for feature in all_passing:
            feature_id = feature.get("id")
            current_passing_ids.append(feature_id)
            # Only identify individual new tests if we have previous IDs to compare
            if not is_old_cache_format and feature_id not in previous_passing_ids:
                # This feature is newly passing
                name = feature.get("name", f"Feature #{feature_id}")
                category = feature.get("category", "")
                if category:
                    completed_tests.append(f"{category} {name}")
                else:
                    completed_tests.append(name)

        payload = {
            "event": "test_progress",
            "passing": passing,
            "total": total,
            "percentage": round((passing / total) * 100, 1) if total > 0 else 0,
            "previous_passing": previous,
            "tests_completed_this_session": passing - previous,
            "completed_tests": completed_tests,
            "project": project_dir.name,
            "timestamp": datetime.utcnow().isoformat() + "Z",
        }

        try:
            req = urllib.request.Request(
                WEBHOOK_URL,
                data=json.dumps([payload]).encode("utf-8"),  # n8n expects array
                headers={"Content-Type": "application/json"},
            )
            urllib.request.urlopen(req, timeout=5)
        except Exception as e:
            print(f"[Webhook notification failed: {e}]")

        # Update cache with count and passing IDs
        cache_file.write_text(
            json.dumps({"count": passing, "passing_ids": current_passing_ids})
        )
    else:
        # Update cache even if no change (for initial state)
        if not cache_file.exists():
            all_passing = get_all_passing_features(project_dir)
            current_passing_ids = [f.get("id") for f in all_passing]
            cache_file.write_text(
                json.dumps({"count": passing, "passing_ids": current_passing_ids})
            )

def print_session_header(session_num: int, is_initializer: bool) -> None:
    """Print a formatted header for the session."""
    session_type = "INITIALIZER" if is_initializer else "CODING AGENT"

    print("\n" + "=" * 70)
    print(f"  SESSION {session_num}: {session_type}")
    print("=" * 70)
    print()

def print_progress_summary(project_dir: Path) -> None:
    """Print a summary of current progress."""
    passing, in_progress, total = count_passing_tests(project_dir)

    if total > 0:
        percentage = (passing / total) * 100
        status_parts = [f"{passing}/{total} tests passing ({percentage:.1f}%)"]
        if in_progress > 0:
            status_parts.append(f"{in_progress} in progress")
        print(f"\nProgress: {', '.join(status_parts)}")
        send_progress_webhook(passing, total, project_dir)
    else:
        print("\nProgress: No features in database yet")


Let me read the API database model and autonomous demo files:


     1→"""
     2→Database Models and Connection
     3→==============================
     4→
     5→SQLite database schema for feature storage using SQLAlchemy.
     6→"""
     7→
     8→from pathlib import Path
     9→from typing import Optional
    10→
    11→from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine
    12→from sqlalchemy.ext.declarative import declarative_base
    13→from sqlalchemy.orm import Session, sessionmaker
    14→from sqlalchemy.types import JSON
    15→
    16→Base = declarative_base()
    17→
    18→
    19→class Feature(Base):
    20→    """Feature model representing a test case/feature to implement."""
    21→
    22→    __tablename__ = "features"
    23→
    24→    id = Column(Integer, primary_key=True, index=True)
    25→    priority = Column(Integer, nullable=False, default=999, index=True)
    26→    category = Column(String(100), nullable=False)
    27→    name = Column(String(255), nullable=False)
    28→    description = Column(Text, nullable=False)
    29→    steps = Column(JSON, nullable=False)  # Stored as JSON array
    30→    passes = Column(Boolean, default=False, index=True)
    31→    in_progress = Column(Boolean, default=False, index=True)
    32→
    33→    def to_dict(self) -> dict:
    34→        """Convert feature to dictionary for JSON serialization."""
    35→        return {
    36→            "id": self.id,
    37→            "priority": self.priority,
    38→            "category": self.category,
    39→            "name": self.name,
    40→            "description": self.description,
    41→            "steps": self.steps,
    42→            "passes": self.passes,
    43→            "in_progress": self.in_progress,
    44→        }
    45→
    46→
    47→def get_database_path(project_dir: Path) -> Path:
    48→    """Return the path to the SQLite database for a project."""
    49→    return project_dir / "features.db"
    50→
    51→
    52→def get_database_url(project_dir: Path) -> str:
    53→    """Return the SQLAlchemy database URL for a project.
    54→
    55→    Uses POSIX-style paths (forward slashes) for cross-platform compatibility.
    56→    """
    57→    db_path = get_database_path(project_dir)
    58→    return f"sqlite:///{db_path.as_posix()}"
    59→
    60→
    61→def _migrate_add_in_progress_column(engine) -> None:
    62→    """Add in_progress column to existing databases that don't have it."""
    63→    from sqlalchemy import text
    64→
    65→    with engine.connect() as conn:
    66→        # Check if column exists
    67→        result = conn.execute(text("PRAGMA table_info(features)"))
    68→        columns = [row[1] for row in result.fetchall()]
    69→
    70→        if "in_progress" not in columns:
    71→            # Add the column with default value
    72→            conn.execute(text("ALTER TABLE features ADD COLUMN in_progress BOOLEAN DEFAULT 0"))
    73→            conn.commit()
    74→
    75→
    76→def create_database(project_dir: Path) -> tuple:
    77→    """
    78→    Create database and return engine + session maker.
    79→
    80→    Args:
    81→        project_dir: Directory containing the project
    82→
    83→    Returns:
    84→        Tuple of (engine, SessionLocal)
    85→    """
    86→    db_url = get_database_url(project_dir)
    87→    engine = create_engine(db_url, connect_args={"check_same_thread": False})
    88→    Base.metadata.create_all(bind=engine)
    89→
    90→    # Migrate existing databases to add in_progress column
    91→    _migrate_add_in_progress_column(engine)
    92→
    93→    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    94→    return engine, SessionLocal
    95→
    96→
    97→# Global session maker - will be set when server starts
    98→_session_maker: Optional[sessionmaker] = None
    99→
   100→
   101→def set_session_maker(session_maker: sessionmaker) -> None:
   102→    """Set the global session maker."""
   103→    global _session_maker
   104→    _session_maker = session_maker
   105→
   106→
   107→def get_db() -> Session:
   108→    """
   109→    Dependency for FastAPI to get database session.
   110→
   111→    Yields a database session and ensures it's closed after use.
   112→    """
   113→    if _session_maker is None:
   114→        raise RuntimeError("Database not initialized. Call set_session_maker first.")
   115→
   116→    db = _session_maker()
   117→    try:
   118→        yield db
   119→    finally:
   120→        db.close()
   121→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

"""
Database Models and Connection
==============================

SQLite database schema for feature storage using SQLAlchemy.
"""

from pathlib import Path
from typing import Optional

from sqlalchemy import Boolean, Column, Integer, String, Text, create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.types import JSON

Base = declarative_base()

class Feature(Base):
    """Feature model representing a test case/feature to implement."""

    __tablename__ = "features"

    id = Column(Integer, primary_key=True, index=True)
    priority = Column(Integer, nullable=False, default=999, index=True)
    category = Column(String(100), nullable=False)
    name = Column(String(255), nullable=False)
    description = Column(Text, nullable=False)
    steps = Column(JSON, nullable=False)  # Stored as JSON array
    passes = Column(Boolean, default=False, index=True)
    in_progress = Column(Boolean, default=False, index=True)

    def to_dict(self) -> dict:
        """Convert feature to dictionary for JSON serialization."""
        return {
            "id": self.id,
            "priority": self.priority,
            "category": self.category,
            "name": self.name,
            "description": self.description,
            "steps": self.steps,
            "passes": self.passes,
            "in_progress": self.in_progress,
        }

def get_database_path(project_dir: Path) -> Path:
    """Return the path to the SQLite database for a project."""
    return project_dir / "features.db"

def get_database_url(project_dir: Path) -> str:
    """Return the SQLAlchemy database URL for a project.

    Uses POSIX-style paths (forward slashes) for cross-platform compatibility.
    """
    db_path = get_database_path(project_dir)
    return f"sqlite:///{db_path.as_posix()}"

def _migrate_add_in_progress_column(engine) -> None:
    """Add in_progress column to existing databases that don't have it."""
    from sqlalchemy import text

    with engine.connect() as conn:
        # Check if column exists
        result = conn.execute(text("PRAGMA table_info(features)"))
        columns = [row[1] for row in result.fetchall()]

        if "in_progress" not in columns:
            # Add the column with default value
            conn.execute(text("ALTER TABLE features ADD COLUMN in_progress BOOLEAN DEFAULT 0"))
            conn.commit()

def create_database(project_dir: Path) -> tuple:
    """
    Create database and return engine + session maker.

    Args:
        project_dir: Directory containing the project

    Returns:
        Tuple of (engine, SessionLocal)
    """
    db_url = get_database_url(project_dir)
    engine = create_engine(db_url, connect_args={"check_same_thread": False})
    Base.metadata.create_all(bind=engine)

    # Migrate existing databases to add in_progress column
    _migrate_add_in_progress_column(engine)

    SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
    return engine, SessionLocal

# Global session maker - will be set when server starts
_session_maker: Optional[sessionmaker] = None

def set_session_maker(session_maker: sessionmaker) -> None:
    """Set the global session maker."""
    global _session_maker
    _session_maker = session_maker

def get_db() -> Session:
    """
    Dependency for FastAPI to get database session.

    Yields a database session and ensures it's closed after use.
    """
    if _session_maker is None:
        raise RuntimeError("Database not initialized. Call set_session_maker first.")

    db = _session_maker()
    try:
        yield db
    finally:
        db.close()


     1→#!/usr/bin/env python3
     2→"""
     3→Autonomous Coding Agent Demo
     4→============================
     5→
     6→A minimal harness demonstrating long-running autonomous coding with Claude.
     7→This script implements the two-agent pattern (initializer + coding agent) and
     8→incorporates all the strategies from the long-running agents guide.
     9→
    10→Example Usage:
    11→    # Using absolute path directly
    12→    python autonomous_agent_demo.py --project-dir C:/Projects/my-app
    13→
    14→    # Using registered project name (looked up from registry)
    15→    python autonomous_agent_demo.py --project-dir my-app
    16→
    17→    # Limit iterations for testing
    18→    python autonomous_agent_demo.py --project-dir my-app --max-iterations 5
    19→
    20→    # YOLO mode: rapid prototyping without browser testing
    21→    python autonomous_agent_demo.py --project-dir my-app --yolo
    22→"""
    23→
    24→import argparse
    25→import asyncio
    26→from pathlib import Path
    27→
    28→from dotenv import load_dotenv
    29→
    30→# Load environment variables from .env file (if it exists)
    31→# IMPORTANT: Must be called BEFORE importing other modules that read env vars at load time
    32→load_dotenv()
    33→
    34→from agent import run_autonomous_agent
    35→from registry import get_project_path
    36→
    37→# Configuration
    38→# DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
    39→DEFAULT_MODEL = "claude-opus-4-5-20251101"
    40→
    41→
    42→def parse_args() -> argparse.Namespace:
    43→    """Parse command line arguments."""
    44→    parser = argparse.ArgumentParser(
    45→        description="Autonomous Coding Agent Demo - Long-running agent harness",
    46→        formatter_class=argparse.RawDescriptionHelpFormatter,
    47→        epilog="""
    48→Examples:
    49→  # Use absolute path directly
    50→  python autonomous_agent_demo.py --project-dir C:/Projects/my-app
    51→
    52→  # Use registered project name (looked up from registry)
    53→  python autonomous_agent_demo.py --project-dir my-app
    54→
    55→  # Use a specific model
    56→  python autonomous_agent_demo.py --project-dir my-app --model claude-sonnet-4-5-20250929
    57→
    58→  # Limit iterations for testing
    59→  python autonomous_agent_demo.py --project-dir my-app --max-iterations 5
    60→
    61→  # YOLO mode: rapid prototyping without browser testing
    62→  python autonomous_agent_demo.py --project-dir my-app --yolo
    63→
    64→Authentication:
    65→  Uses Claude CLI credentials from ~/.claude/.credentials.json
    66→  Run 'claude login' to authenticate (handled by start.bat/start.sh)
    67→        """,
    68→    )
    69→
    70→    parser.add_argument(
    71→        "--project-dir",
    72→        type=str,
    73→        required=True,
    74→        help="Project directory path (absolute) or registered project name",
    75→    )
    76→
    77→    parser.add_argument(
    78→        "--max-iterations",
    79→        type=int,
    80→        default=None,
    81→        help="Maximum number of agent iterations (default: unlimited)",
    82→    )
    83→
    84→    parser.add_argument(
    85→        "--model",
    86→        type=str,
    87→        default=DEFAULT_MODEL,
    88→        help=f"Claude model to use (default: {DEFAULT_MODEL})",
    89→    )
    90→
    91→    parser.add_argument(
    92→        "--yolo",
    93→        action="store_true",
    94→        default=False,
    95→        help="Enable YOLO mode: rapid prototyping without browser testing",
    96→    )
    97→
    98→    return parser.parse_args()
    99→
   100→
   101→def main() -> None:
   102→    """Main entry point."""
   103→    args = parse_args()
   104→
   105→    # Note: Authentication is handled by start.bat/start.sh before this script runs.
   106→    # The Claude SDK auto-detects credentials from ~/.claude/.credentials.json
   107→
   108→    # Resolve project directory:
   109→    # 1. If absolute path, use as-is
   110→    # 2. Otherwise, look up from registry by name
   111→    project_dir_input = args.project_dir
   112→    project_dir = Path(project_dir_input)
   113→
   114→    if project_dir.is_absolute():
   115→        # Absolute path provided - use directly
   116→        if not project_dir.exists():
   117→            print(f"Error: Project directory does not exist: {project_dir}")
   118→            return
   119→    else:
   120→        # Treat as a project name - look up from registry
   121→        registered_path = get_project_path(project_dir_input)
   122→        if registered_path:
   123→            project_dir = registered_path
   124→        else:
   125→            print(f"Error: Project '{project_dir_input}' not found in registry")
   126→            print("Use an absolute path or register the project first.")
   127→            return
   128→
   129→    try:
   130→        # Run the agent (MCP server handles feature database)
   131→        asyncio.run(
   132→            run_autonomous_agent(
   133→                project_dir=project_dir,
   134→                model=args.model,
   135→                max_iterations=args.max_iterations,
   136→                yolo_mode=args.yolo,
   137→            )
   138→        )
   139→    except KeyboardInterrupt:
   140→        print("\n\nInterrupted by user")
   141→        print("To resume, run the same command again")
   142→    except Exception as e:
   143→        print(f"\nFatal error: {e}")
   144→        raise
   145→
   146→
   147→if __name__ == "__main__":
   148→    main()
   149→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

#!/usr/bin/env python3
"""
Autonomous Coding Agent Demo
============================

A minimal harness demonstrating long-running autonomous coding with Claude.
This script implements the two-agent pattern (initializer + coding agent) and
incorporates all the strategies from the long-running agents guide.

Example Usage:
    # Using absolute path directly
    python autonomous_agent_demo.py --project-dir C:/Projects/my-app

    # Using registered project name (looked up from registry)
    python autonomous_agent_demo.py --project-dir my-app

    # Limit iterations for testing
    python autonomous_agent_demo.py --project-dir my-app --max-iterations 5

    # YOLO mode: rapid prototyping without browser testing
    python autonomous_agent_demo.py --project-dir my-app --yolo
"""

import argparse
import asyncio
from pathlib import Path

from dotenv import load_dotenv

# Load environment variables from .env file (if it exists)
# IMPORTANT: Must be called BEFORE importing other modules that read env vars at load time
load_dotenv()

from agent import run_autonomous_agent
from registry import get_project_path

# Configuration
# DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
DEFAULT_MODEL = "claude-opus-4-5-20251101"

def parse_args() -> argparse.Namespace:
    """Parse command line arguments."""
    parser = argparse.ArgumentParser(
        description="Autonomous Coding Agent Demo - Long-running agent harness",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Use absolute path directly
  python autonomous_agent_demo.py --project-dir C:/Projects/my-app

  # Use registered project name (looked up from registry)
  python autonomous_agent_demo.py --project-dir my-app

  # Use a specific model
  python autonomous_agent_demo.py --project-dir my-app --model claude-sonnet-4-5-20250929

  # Limit iterations for testing
  python autonomous_agent_demo.py --project-dir my-app --max-iterations 5

  # YOLO mode: rapid prototyping without browser testing
  python autonomous_agent_demo.py --project-dir my-app --yolo

Authentication:
  Uses Claude CLI credentials from ~/.claude/.credentials.json
  Run 'claude login' to authenticate (handled by start.bat/start.sh)
        """,
    )

    parser.add_argument(
        "--project-dir",
        type=str,
        required=True,
        help="Project directory path (absolute) or registered project name",
    )

    parser.add_argument(
        "--max-iterations",
        type=int,
        default=None,
        help="Maximum number of agent iterations (default: unlimited)",
    )

    parser.add_argument(
        "--model",
        type=str,
        default=DEFAULT_MODEL,
        help=f"Claude model to use (default: {DEFAULT_MODEL})",
    )

    parser.add_argument(
        "--yolo",
        action="store_true",
        default=False,
        help="Enable YOLO mode: rapid prototyping without browser testing",
    )

    return parser.parse_args()

def main() -> None:
    """Main entry point."""
    args = parse_args()

    # Note: Authentication is handled by start.bat/start.sh before this script runs.
    # The Claude SDK auto-detects credentials from ~/.claude/.credentials.json

    # Resolve project directory:
    # 1. If absolute path, use as-is
    # 2. Otherwise, look up from registry by name
    project_dir_input = args.project_dir
    project_dir = Path(project_dir_input)

    if project_dir.is_absolute():
        # Absolute path provided - use directly
        if not project_dir.exists():
            print(f"Error: Project directory does not exist: {project_dir}")
            return
    else:
        # Treat as a project name - look up from registry
        registered_path = get_project_path(project_dir_input)
        if registered_path:
            project_dir = registered_path
        else:
            print(f"Error: Project '{project_dir_input}' not found in registry")
            print("Use an absolute path or register the project first.")
            return

    try:
        # Run the agent (MCP server handles feature database)
        asyncio.run(
            run_autonomous_agent(
                project_dir=project_dir,
                model=args.model,
                max_iterations=args.max_iterations,
                yolo_mode=args.yolo,
            )
        )
    except KeyboardInterrupt:
        print("\n\nInterrupted by user")
        print("To resume, run the same command again")
    except Exception as e:
        print(f"\nFatal error: {e}")
        raise

if __name__ == "__main__":
    main()


Perfect! Now let me check the remaining part of the MCP server and look at the templates:


   100→
   101→    # Cleanup
   102→    if _engine:
   103→        _engine.dispose()
   104→
   105→
   106→# Initialize the MCP server
   107→mcp = FastMCP("features", lifespan=server_lifespan)
   108→
   109→
   110→def get_session():
   111→    """Get a new database session."""
   112→    if _session_maker is None:
   113→        raise RuntimeError("Database not initialized")
   114→    return _session_maker()
   115→
   116→
   117→@mcp.tool()
   118→def feature_get_stats() -> str:
   119→    """Get statistics about feature completion progress.
   120→
   121→    Returns the number of passing features, in-progress features, total features,
   122→    and completion percentage. Use this to track overall progress of the implementation.
   123→
   124→    Returns:
   125→        JSON with: passing (int), in_progress (int), total (int), percentage (float)
   126→    """
   127→    session = get_session()
   128→    try:
   129→        total = session.query(Feature).count()
   130→        passing = session.query(Feature).filter(Feature.passes == True).count()
   131→        in_progress = session.query(Feature).filter(Feature.in_progress == True).count()
   132→        percentage = round((passing / total) * 100, 1) if total > 0 else 0.0
   133→
   134→        return json.dumps({
   135→            "passing": passing,
   136→            "in_progress": in_progress,
   137→            "total": total,
   138→            "percentage": percentage
   139→        }, indent=2)
   140→    finally:
   141→        session.close()
   142→
   143→
   144→@mcp.tool()
   145→def feature_get_next() -> str:
   146→    """Get the highest-priority pending feature to work on.
   147→
   148→    Returns the feature with the lowest priority number that has passes=false.
   149→    Use this at the start of each coding session to determine what to implement next.
   150→
   151→    Returns:
   152→        JSON with feature details (id, priority, category, name, description, steps, passes, in_progress)
   153→        or error message if all features are passing.
   154→    """
   155→    session = get_session()
   156→    try:
   157→        feature = (
   158→            session.query(Feature)
   159→            .filter(Feature.passes == False)
   160→            .order_by(Feature.priority.asc(), Feature.id.asc())
   161→            .first()
   162→        )
   163→
   164→        if feature is None:
   165→            return json.dumps({"error": "All features are passing! No more work to do."})
   166→
   167→        return json.dumps(feature.to_dict(), indent=2)
   168→    finally:
   169→        session.close()
   170→
   171→
   172→@mcp.tool()
   173→def feature_get_for_regression(
   174→    limit: Annotated[int, Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")] = 3
   175→) -> str:
   176→    """Get random passing features for regression testing.
   177→
   178→    Returns a random selection of features that are currently passing.
   179→    Use this to verify that previously implemented features still work
   180→    after making changes.
   181→
   182→    Args:
   183→        limit: Maximum number of features to return (1-10, default 3)
   184→
   185→    Returns:
   186→        JSON with: features (list of feature objects), count (int)
   187→    """
   188→    session = get_session()
   189→    try:
   190→        features = (
   191→            session.query(Feature)
   192→            .filter(Feature.passes == True)
   193→            .order_by(func.random())
   194→            .limit(limit)
   195→            .all()
   196→        )
   197→
   198→        return json.dumps({
   199→            "features": [f.to_dict() for f in features],
   200→            "count": len(features)
   201→        }, indent=2)
   202→    finally:
   203→        session.close()
   204→
   205→
   206→@mcp.tool()
   207→def feature_mark_passing(
   208→    feature_id: Annotated[int, Field(description="The ID of the feature to mark as passing", ge=1)]
   209→) -> str:
   210→    """Mark a feature as passing after successful implementation.
   211→
   212→    Updates the feature's passes field to true and clears the in_progress flag.
   213→    Use this after you have implemented the feature and verified it works correctly.
   214→
   215→    Args:
   216→        feature_id: The ID of the feature to mark as passing
   217→
   218→    Returns:
   219→        JSON with the updated feature details, or error if not found.
   220→    """
   221→    session = get_session()
   222→    try:
   223→        feature = session.query(Feature).filter(Feature.id == feature_id).first()
   224→
   225→        if feature is None:
   226→            return json.dumps({"error": f"Feature with ID {feature_id} not found"})
   227→
   228→        feature.passes = True
   229→        feature.in_progress = False
   230→        session.commit()
   231→        session.refresh(feature)
   232→
   233→        return json.dumps(feature.to_dict(), indent=2)
   234→    finally:
   235→        session.close()
   236→
   237→
   238→@mcp.tool()
   239→def feature_skip(
   240→    feature_id: Annotated[int, Field(description="The ID of the feature to skip", ge=1)]
   241→) -> str:
   242→    """Skip a feature by moving it to the end of the priority queue.
   243→
   244→    Use this when a feature cannot be implemented yet due to:
   245→    - Dependencies on other features that aren't implemented yet
   246→    - External blockers (missing assets, unclear requirements)
   247→    - Technical prerequisites that need to be addressed first
   248→
   249→    The feature's priority is set to max_priority + 1, so it will be

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>

    # Cleanup
    if _engine:
        _engine.dispose()

# Initialize the MCP server
mcp = FastMCP("features", lifespan=server_lifespan)

def get_session():
    """Get a new database session."""
    if _session_maker is None:
        raise RuntimeError("Database not initialized")
    return _session_maker()

@mcp.tool()
def feature_get_stats() -> str:
    """Get statistics about feature completion progress.

    Returns the number of passing features, in-progress features, total features,
    and completion percentage. Use this to track overall progress of the implementation.

    Returns:
        JSON with: passing (int), in_progress (int), total (int), percentage (float)
    """
    session = get_session()
    try:
        total = session.query(Feature).count()
        passing = session.query(Feature).filter(Feature.passes == True).count()
        in_progress = session.query(Feature).filter(Feature.in_progress == True).count()
        percentage = round((passing / total) * 100, 1) if total > 0 else 0.0

        return json.dumps({
            "passing": passing,
            "in_progress": in_progress,
            "total": total,
            "percentage": percentage
        }, indent=2)
    finally:
        session.close()

@mcp.tool()
def feature_get_next() -> str:
    """Get the highest-priority pending feature to work on.

    Returns the feature with the lowest priority number that has passes=false.
    Use this at the start of each coding session to determine what to implement next.

    Returns:
        JSON with feature details (id, priority, category, name, description, steps, passes, in_progress)
        or error message if all features are passing.
    """
    session = get_session()
    try:
        feature = (
            session.query(Feature)
            .filter(Feature.passes == False)
            .order_by(Feature.priority.asc(), Feature.id.asc())
            .first()
        )

        if feature is None:
            return json.dumps({"error": "All features are passing! No more work to do."})

        return json.dumps(feature.to_dict(), indent=2)
    finally:
        session.close()

@mcp.tool()
def feature_get_for_regression(
    limit: Annotated[int, Field(default=3, ge=1, le=10, description="Maximum number of passing features to return")] = 3
) -> str:
    """Get random passing features for regression testing.

    Returns a random selection of features that are currently passing.
    Use this to verify that previously implemented features still work
    after making changes.

    Args:
        limit: Maximum number of features to return (1-10, default 3)

    Returns:
        JSON with: features (list of feature objects), count (int)
    """
    session = get_session()
    try:
        features = (
            session.query(Feature)
            .filter(Feature.passes == True)
            .order_by(func.random())
            .limit(limit)
            .all()
        )

        return json.dumps({
            "features": [f.to_dict() for f in features],
            "count": len(features)
        }, indent=2)
    finally:
        session.close()

@mcp.tool()
def feature_mark_passing(
    feature_id: Annotated[int, Field(description="The ID of the feature to mark as passing", ge=1)]
) -> str:
    """Mark a feature as passing after successful implementation.

    Updates the feature's passes field to true and clears the in_progress flag.
    Use this after you have implemented the feature and verified it works correctly.

    Args:
        feature_id: The ID of the feature to mark as passing

    Returns:
        JSON with the updated feature details, or error if not found.
    """
    session = get_session()
    try:
        feature = session.query(Feature).filter(Feature.id == feature_id).first()

        if feature is None:
            return json.dumps({"error": f"Feature with ID {feature_id} not found"})

        feature.passes = True
        feature.in_progress = False
        session.commit()
        session.refresh(feature)

        return json.dumps(feature.to_dict(), indent=2)
    finally:
        session.close()

@mcp.tool()
def feature_skip(
    feature_id: Annotated[int, Field(description="The ID of the feature to skip", ge=1)]
) -> str:
    """Skip a feature by moving it to the end of the priority queue.

    Use this when a feature cannot be implemented yet due to:
    - Dependencies on other features that aren't implemented yet
    - External blockers (missing assets, unclear requirements)
    - Technical prerequisites that need to be addressed first

    The feature's priority is set to max_priority + 1, so it will be