     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→
   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
   250→    worked on after all other pending features. Also clears the in_progress
   251→    flag so the feature returns to "pending" status.
   252→
   253→    Args:
   254→        feature_id: The ID of the feature to skip
   255→
   256→    Returns:
   257→        JSON with skip details: id, name, old_priority, new_priority, message
   258→    """
   259→    session = get_session()
   260→    try:
   261→        feature = session.query(Feature).filter(Feature.id == feature_id).first()
   262→
   263→        if feature is None:
   264→            return json.dumps({"error": f"Feature with ID {feature_id} not found"})
   265→
   266→        if feature.passes:
   267→            return json.dumps({"error": "Cannot skip a feature that is already passing"})
   268→
   269→        old_priority = feature.priority
   270→
   271→        # Get max priority and set this feature to max + 1
   272→        max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
   273→        new_priority = (max_priority_result[0] + 1) if max_priority_result else 1
   274→
   275→        feature.priority = new_priority
   276→        feature.in_progress = False
   277→        session.commit()
   278→        session.refresh(feature)
   279→
   280→        return json.dumps({
   281→            "id": feature.id,
   282→            "name": feature.name,
   283→            "old_priority": old_priority,
   284→            "new_priority": new_priority,
   285→            "message": f"Feature '{feature.name}' moved to end of queue"
   286→        }, indent=2)
   287→    finally:
   288→        session.close()
   289→
   290→
   291→@mcp.tool()
   292→def feature_mark_in_progress(
   293→    feature_id: Annotated[int, Field(description="The ID of the feature to mark as in-progress", ge=1)]
   294→) -> str:
   295→    """Mark a feature as in-progress. Call immediately after feature_get_next().
   296→
   297→    This prevents other agent sessions from working on the same feature.
   298→    Use this as soon as you retrieve a feature to work on.
   299→
   300→    Args:
   301→        feature_id: The ID of the feature to mark as in-progress
   302→
   303→    Returns:
   304→        JSON with the updated feature details, or error if not found or already in-progress.
   305→    """
   306→    session = get_session()
   307→    try:
   308→        feature = session.query(Feature).filter(Feature.id == feature_id).first()
   309→
   310→        if feature is None:
   311→            return json.dumps({"error": f"Feature with ID {feature_id} not found"})
   312→
   313→        if feature.passes:
   314→            return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})
   315→
   316→        if feature.in_progress:
   317→            return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})
   318→
   319→        feature.in_progress = True
   320→        session.commit()
   321→        session.refresh(feature)
   322→
   323→        return json.dumps(feature.to_dict(), indent=2)
   324→    finally:
   325→        session.close()
   326→
   327→
   328→@mcp.tool()
   329→def feature_clear_in_progress(
   330→    feature_id: Annotated[int, Field(description="The ID of the feature to clear in-progress status", ge=1)]
   331→) -> str:
   332→    """Clear in-progress status from a feature.
   333→
   334→    Use this when abandoning a feature or manually unsticking a stuck feature.
   335→    The feature will return to the pending queue.
   336→
   337→    Args:
   338→        feature_id: The ID of the feature to clear in-progress status
   339→
   340→    Returns:
   341→        JSON with the updated feature details, or error if not found.
   342→    """
   343→    session = get_session()
   344→    try:
   345→        feature = session.query(Feature).filter(Feature.id == feature_id).first()
   346→
   347→        if feature is None:
   348→            return json.dumps({"error": f"Feature with ID {feature_id} not found"})
   349→
   350→        feature.in_progress = False
   351→        session.commit()
   352→        session.refresh(feature)
   353→
   354→        return json.dumps(feature.to_dict(), indent=2)
   355→    finally:
   356→        session.close()
   357→
   358→
   359→@mcp.tool()
   360→def feature_create_bulk(
   361→    features: Annotated[list[dict], Field(description="List of features to create, each with category, name, description, and steps")]
   362→) -> str:
   363→    """Create multiple features in a single operation.
   364→
   365→    Features are assigned sequential priorities based on their order.
   366→    All features start with passes=false.
   367→
   368→    This is typically used by the initializer agent to set up the initial
   369→    feature list from the app specification.
   370→
   371→    Args:
   372→        features: List of features to create, each with:
   373→            - category (str): Feature category
   374→            - name (str): Feature name
   375→            - description (str): Detailed description
   376→            - steps (list[str]): Implementation/test steps
   377→
   378→    Returns:
   379→        JSON with: created (int) - number of features created
   380→    """
   381→    session = get_session()
   382→    try:
   383→        # Get the starting priority
   384→        max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
   385→        start_priority = (max_priority_result[0] + 1) if max_priority_result else 1
   386→
   387→        created_count = 0
   388→        for i, feature_data in enumerate(features):
   389→            # Validate required fields
   390→            if not all(key in feature_data for key in ["category", "name", "description", "steps"]):
   391→                return json.dumps({
   392→                    "error": f"Feature at index {i} missing required fields (category, name, description, steps)"
   393→                })
   394→
   395→            db_feature = Feature(
   396→                priority=start_priority + i,
   397→                category=feature_data["category"],
   398→                name=feature_data["name"],
   399→                description=feature_data["description"],
   400→                steps=feature_data["steps"],
   401→                passes=False,
   402→            )
   403→            session.add(db_feature)
   404→            created_count += 1
   405→
   406→        session.commit()
   407→
   408→        return json.dumps({"created": created_count}, indent=2)
   409→    except Exception as e:
   410→        session.rollback()
   411→        return json.dumps({"error": str(e)})
   412→    finally:
   413→        session.close()
   414→
   415→
   416→if __name__ == "__main__":
   417→    mcp.run()
   418→

<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

    # 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
    worked on after all other pending features. Also clears the in_progress
    flag so the feature returns to "pending" status.

    Args:
        feature_id: The ID of the feature to skip

    Returns:
        JSON with skip details: id, name, old_priority, new_priority, message
    """
    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"})

        if feature.passes:
            return json.dumps({"error": "Cannot skip a feature that is already passing"})

        old_priority = feature.priority

        # Get max priority and set this feature to max + 1
        max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
        new_priority = (max_priority_result[0] + 1) if max_priority_result else 1

        feature.priority = new_priority
        feature.in_progress = False
        session.commit()
        session.refresh(feature)

        return json.dumps({
            "id": feature.id,
            "name": feature.name,
            "old_priority": old_priority,
            "new_priority": new_priority,
            "message": f"Feature '{feature.name}' moved to end of queue"
        }, indent=2)
    finally:
        session.close()

@mcp.tool()
def feature_mark_in_progress(
    feature_id: Annotated[int, Field(description="The ID of the feature to mark as in-progress", ge=1)]
) -> str:
    """Mark a feature as in-progress. Call immediately after feature_get_next().

    This prevents other agent sessions from working on the same feature.
    Use this as soon as you retrieve a feature to work on.

    Args:
        feature_id: The ID of the feature to mark as in-progress

    Returns:
        JSON with the updated feature details, or error if not found or already in-progress.
    """
    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"})

        if feature.passes:
            return json.dumps({"error": f"Feature with ID {feature_id} is already passing"})

        if feature.in_progress:
            return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"})

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

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

@mcp.tool()
def feature_clear_in_progress(
    feature_id: Annotated[int, Field(description="The ID of the feature to clear in-progress status", ge=1)]
) -> str:
    """Clear in-progress status from a feature.

    Use this when abandoning a feature or manually unsticking a stuck feature.
    The feature will return to the pending queue.

    Args:
        feature_id: The ID of the feature to clear in-progress status

    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.in_progress = False
        session.commit()
        session.refresh(feature)

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

@mcp.tool()
def feature_create_bulk(
    features: Annotated[list[dict], Field(description="List of features to create, each with category, name, description, and steps")]
) -> str:
    """Create multiple features in a single operation.

    Features are assigned sequential priorities based on their order.
    All features start with passes=false.

    This is typically used by the initializer agent to set up the initial
    feature list from the app specification.

    Args:
        features: List of features to create, each with:
            - category (str): Feature category
            - name (str): Feature name
            - description (str): Detailed description
            - steps (list[str]): Implementation/test steps

    Returns:
        JSON with: created (int) - number of features created
    """
    session = get_session()
    try:
        # Get the starting priority
        max_priority_result = session.query(Feature.priority).order_by(Feature.priority.desc()).first()
        start_priority = (max_priority_result[0] + 1) if max_priority_result else 1

        created_count = 0
        for i, feature_data in enumerate(features):
            # Validate required fields
            if not all(key in feature_data for key in ["category", "name", "description", "steps"]):
                return json.dumps({
                    "error": f"Feature at index {i} missing required fields (category, name, description, steps)"
                })

            db_feature = Feature(
                priority=start_priority + i,
                category=feature_data["category"],
                name=feature_data["name"],
                description=feature_data["description"],
                steps=feature_data["steps"],
                passes=False,
            )
            session.add(db_feature)
            created_count += 1

        session.commit()

        return json.dumps({"created": created_count}, indent=2)
    except Exception as e:
        session.rollback()
        return json.dumps({"error": str(e)})
    finally:
        session.close()

if __name__ == "__main__":
    mcp.run()


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


Now let me check the client.py to see how the SDK client is created and what state it manages:


     1→"""
     2→Claude SDK Client Configuration
     3→===============================
     4→
     5→Functions for creating and configuring the Claude Agent SDK client.
     6→"""
     7→
     8→import json
     9→import os
    10→import shutil
    11→import sys
    12→from pathlib import Path
    13→
    14→from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
    15→from claude_agent_sdk.types import HookMatcher
    16→
    17→from security import bash_security_hook
    18→
    19→# Feature MCP tools for feature/test management
    20→FEATURE_MCP_TOOLS = [
    21→    "mcp__features__feature_get_stats",
    22→    "mcp__features__feature_get_next",
    23→    "mcp__features__feature_get_for_regression",
    24→    "mcp__features__feature_mark_in_progress",
    25→    "mcp__features__feature_mark_passing",
    26→    "mcp__features__feature_skip",
    27→    "mcp__features__feature_create_bulk",
    28→]
    29→
    30→# Playwright MCP tools for browser automation
    31→PLAYWRIGHT_TOOLS = [
    32→    # Core navigation & screenshots
    33→    "mcp__playwright__browser_navigate",
    34→    "mcp__playwright__browser_navigate_back",
    35→    "mcp__playwright__browser_take_screenshot",
    36→    "mcp__playwright__browser_snapshot",
    37→
    38→    # Element interaction
    39→    "mcp__playwright__browser_click",
    40→    "mcp__playwright__browser_type",
    41→    "mcp__playwright__browser_fill_form",
    42→    "mcp__playwright__browser_select_option",
    43→    "mcp__playwright__browser_hover",
    44→    "mcp__playwright__browser_drag",
    45→    "mcp__playwright__browser_press_key",
    46→
    47→    # JavaScript & debugging
    48→    "mcp__playwright__browser_evaluate",
    49→    # "mcp__playwright__browser_run_code",  # REMOVED - causes Playwright MCP server crash
    50→    "mcp__playwright__browser_console_messages",
    51→    "mcp__playwright__browser_network_requests",
    52→
    53→    # Browser management
    54→    "mcp__playwright__browser_close",
    55→    "mcp__playwright__browser_resize",
    56→    "mcp__playwright__browser_tabs",
    57→    "mcp__playwright__browser_wait_for",
    58→    "mcp__playwright__browser_handle_dialog",
    59→    "mcp__playwright__browser_file_upload",
    60→    "mcp__playwright__browser_install",
    61→]
    62→
    63→# Built-in tools
    64→BUILTIN_TOOLS = [
    65→    "Read",
    66→    "Write",
    67→    "Edit",
    68→    "Glob",
    69→    "Grep",
    70→    "Bash",
    71→    "WebFetch",
    72→    "WebSearch",
    73→]
    74→
    75→
    76→def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
    77→    """
    78→    Create a Claude Agent SDK client with multi-layered security.
    79→
    80→    Args:
    81→        project_dir: Directory for the project
    82→        model: Claude model to use
    83→        yolo_mode: If True, skip Playwright MCP server for rapid prototyping
    84→
    85→    Returns:
    86→        Configured ClaudeSDKClient (from claude_agent_sdk)
    87→
    88→    Security layers (defense in depth):
    89→    1. Sandbox - OS-level bash command isolation prevents filesystem escape
    90→    2. Permissions - File operations restricted to project_dir only
    91→    3. Security hooks - Bash commands validated against an allowlist
    92→       (see security.py for ALLOWED_COMMANDS)
    93→
    94→    Note: Authentication is handled by start.bat/start.sh before this runs.
    95→    The Claude SDK auto-detects credentials from ~/.claude/.credentials.json
    96→    """
    97→    # Build allowed tools list based on mode
    98→    # In YOLO mode, exclude Playwright tools for faster prototyping
    99→    allowed_tools = [*BUILTIN_TOOLS, *FEATURE_MCP_TOOLS]
   100→    if not yolo_mode:
   101→        allowed_tools.extend(PLAYWRIGHT_TOOLS)
   102→
   103→    # Build permissions list
   104→    permissions_list = [
   105→        # Allow all file operations within the project directory
   106→        "Read(./**)",
   107→        "Write(./**)",
   108→        "Edit(./**)",
   109→        "Glob(./**)",
   110→        "Grep(./**)",
   111→        # Bash permission granted here, but actual commands are validated
   112→        # by the bash_security_hook (see security.py for allowed commands)
   113→        "Bash(*)",
   114→        # Allow web tools for documentation lookup
   115→        "WebFetch",
   116→        "WebSearch",
   117→        # Allow Feature MCP tools for feature management
   118→        *FEATURE_MCP_TOOLS,
   119→    ]
   120→    if not yolo_mode:
   121→        # Allow Playwright MCP tools for browser automation (standard mode only)
   122→        permissions_list.extend(PLAYWRIGHT_TOOLS)
   123→
   124→    # Create comprehensive security settings
   125→    # Note: Using relative paths ("./**") restricts access to project directory
   126→    # since cwd is set to project_dir
   127→    security_settings = {
   128→        # Sandbox disabled for full system access (SSH, infrastructure, etc.)
   129→        "sandbox": {"enabled": False},
   130→        "permissions": {
   131→            "defaultMode": "acceptEdits",  # Auto-approve edits within allowed directories
   132→            "allow": permissions_list,
   133→        },
   134→    }
   135→
   136→    # Ensure project directory exists before creating settings file
   137→    project_dir.mkdir(parents=True, exist_ok=True)
   138→
   139→    # Write settings to a file in the project directory
   140→    settings_file = project_dir / ".claude_settings.json"
   141→    with open(settings_file, "w") as f:
   142→        json.dump(security_settings, f, indent=2)
   143→
   144→    print(f"Created security settings at {settings_file}")
   145→    print("   - Sandbox DISABLED (full system access enabled)")
   146→    print(f"   - Working directory: {project_dir.resolve()}")
   147→    print("   - Bash commands restricted to allowlist (see security.py)")
   148→    if yolo_mode:
   149→        print("   - MCP servers: features (database) - YOLO MODE (no Playwright)")
   150→    else:

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

"""
Claude SDK Client Configuration
===============================

Functions for creating and configuring the Claude Agent SDK client.
"""

import json
import os
import shutil
import sys
from pathlib import Path

from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from claude_agent_sdk.types import HookMatcher

from security import bash_security_hook

# Feature MCP tools for feature/test management
FEATURE_MCP_TOOLS = [
    "mcp__features__feature_get_stats",
    "mcp__features__feature_get_next",
    "mcp__features__feature_get_for_regression",
    "mcp__features__feature_mark_in_progress",
    "mcp__features__feature_mark_passing",
    "mcp__features__feature_skip",
    "mcp__features__feature_create_bulk",
]

# Playwright MCP tools for browser automation
PLAYWRIGHT_TOOLS = [
    # Core navigation & screenshots
    "mcp__playwright__browser_navigate",
    "mcp__playwright__browser_navigate_back",
    "mcp__playwright__browser_take_screenshot",
    "mcp__playwright__browser_snapshot",

    # Element interaction
    "mcp__playwright__browser_click",
    "mcp__playwright__browser_type",
    "mcp__playwright__browser_fill_form",
    "mcp__playwright__browser_select_option",
    "mcp__playwright__browser_hover",
    "mcp__playwright__browser_drag",
    "mcp__playwright__browser_press_key",

    # JavaScript & debugging
    "mcp__playwright__browser_evaluate",
    # "mcp__playwright__browser_run_code",  # REMOVED - causes Playwright MCP server crash
    "mcp__playwright__browser_console_messages",
    "mcp__playwright__browser_network_requests",

    # Browser management
    "mcp__playwright__browser_close",
    "mcp__playwright__browser_resize",
    "mcp__playwright__browser_tabs",
    "mcp__playwright__browser_wait_for",
    "mcp__playwright__browser_handle_dialog",
    "mcp__playwright__browser_file_upload",
    "mcp__playwright__browser_install",
]

# Built-in tools
BUILTIN_TOOLS = [
    "Read",
    "Write",
    "Edit",
    "Glob",
    "Grep",
    "Bash",
    "WebFetch",
    "WebSearch",
]

def create_client(project_dir: Path, model: str, yolo_mode: bool = False):
    """
    Create a Claude Agent SDK client with multi-layered security.

    Args:
        project_dir: Directory for the project
        model: Claude model to use
        yolo_mode: If True, skip Playwright MCP server for rapid prototyping

    Returns:
        Configured ClaudeSDKClient (from claude_agent_sdk)

    Security layers (defense in depth):
    1. Sandbox - OS-level bash command isolation prevents filesystem escape
    2. Permissions - File operations restricted to project_dir only
    3. Security hooks - Bash commands validated against an allowlist
       (see security.py for ALLOWED_COMMANDS)

    Note: Authentication is handled by start.bat/start.sh before this runs.
    The Claude SDK auto-detects credentials from ~/.claude/.credentials.json
    """
    # Build allowed tools list based on mode
    # In YOLO mode, exclude Playwright tools for faster prototyping
    allowed_tools = [*BUILTIN_TOOLS, *FEATURE_MCP_TOOLS]
    if not yolo_mode:
        allowed_tools.extend(PLAYWRIGHT_TOOLS)

    # Build permissions list
    permissions_list = [
        # Allow all file operations within the project directory
        "Read(./**)",
        "Write(./**)",
        "Edit(./**)",
        "Glob(./**)",
        "Grep(./**)",
        # Bash permission granted here, but actual commands are validated
        # by the bash_security_hook (see security.py for allowed commands)
        "Bash(*)",
        # Allow web tools for documentation lookup
        "WebFetch",
        "WebSearch",
        # Allow Feature MCP tools for feature management
        *FEATURE_MCP_TOOLS,
    ]
    if not yolo_mode:
        # Allow Playwright MCP tools for browser automation (standard mode only)
        permissions_list.extend(PLAYWRIGHT_TOOLS)

    # Create comprehensive security settings
    # Note: Using relative paths ("./**") restricts access to project directory
    # since cwd is set to project_dir
    security_settings = {
        # Sandbox disabled for full system access (SSH, infrastructure, etc.)
        "sandbox": {"enabled": False},
        "permissions": {
            "defaultMode": "acceptEdits",  # Auto-approve edits within allowed directories
            "allow": permissions_list,
        },
    }

    # Ensure project directory exists before creating settings file
    project_dir.mkdir(parents=True, exist_ok=True)

    # Write settings to a file in the project directory
    settings_file = project_dir / ".claude_settings.json"
    with open(settings_file, "w") as f:
        json.dump(security_settings, f, indent=2)

    print(f"Created security settings at {settings_file}")
    print("   - Sandbox DISABLED (full system access enabled)")
    print(f"   - Working directory: {project_dir.resolve()}")
    print("   - Bash commands restricted to allowlist (see security.py)")
    if yolo_mode:
        print("   - MCP servers: features (database) - YOLO MODE (no Playwright)")
    else:


Let me look at some of the files mentioned in the grep results to understand existing recovery patterns:


     1→"""
     2→File Lock Manager
     3→=================
     4→
     5→Async-safe file locking to prevent concurrent modifications
     6→to the same files by parallel coding agents.
     7→"""
     8→
     9→import asyncio
    10→import logging
    11→from dataclasses import dataclass
    12→from datetime import datetime, timedelta
    13→from pathlib import Path
    14→from typing import Optional
    15→
    16→logger = logging.getLogger(__name__)
    17→
    18→
    19→@dataclass
    20→class LockInfo:
    21→    """Information about a file lock."""
    22→    file_path: str
    23→    feature_id: int
    24→    agent_id: str
    25→    locked_at: datetime
    26→    expires_at: Optional[datetime] = None
    27→
    28→
    29→@dataclass
    30→class LockResult:
    31→    """Result of a lock operation."""
    32→    success: bool
    33→    message: str
    34→    lock_info: Optional[LockInfo] = None
    35→    holder_feature_id: Optional[int] = None
    36→
    37→
    38→class FileLockManager:
    39→    """
    40→    Manage file locks for parallel coding agents.
    41→
    42→    This class provides async-safe file locking to prevent multiple
    43→    agents from modifying the same files simultaneously. It supports:
    44→
    45→    - Acquiring locks on individual files or directories
    46→    - Lock timeouts to prevent deadlocks
    47→    - Force release for recovery scenarios
    48→    - Pattern-based locking (e.g., lock all files in a directory)
    49→
    50→    Thread Safety:
    51→        All operations use asyncio.Lock to ensure thread safety
    52→        in async contexts.
    53→    """
    54→
    55→    # Default lock timeout (1 hour)
    56→    DEFAULT_TIMEOUT_SECONDS = 3600
    57→
    58→    def __init__(self, project_dir: Optional[Path] = None):
    59→        """
    60→        Initialize the file lock manager.
    61→
    62→        Args:
    63→            project_dir: Optional project directory for relative path resolution
    64→        """
    65→        self.project_dir = project_dir
    66→        self._locks: dict[str, LockInfo] = {}
    67→        self._lock = asyncio.Lock()
    68→
    69→    def _normalize_path(self, file_path: str) -> str:
    70→        """Normalize a file path for consistent locking."""
    71→        # Convert to forward slashes and lowercase for consistency
    72→        normalized = file_path.replace("\\", "/").lower()
    73→
    74→        # Remove leading/trailing slashes
    75→        normalized = normalized.strip("/")
    76→
    77→        return normalized
    78→
    79→    async def acquire(
    80→        self,
    81→        file_path: str,
    82→        feature_id: int,
    83→        agent_id: str = "default",
    84→        timeout_seconds: Optional[int] = None,
    85→        wait: bool = False,
    86→        wait_timeout: float = 30.0,
    87→    ) -> LockResult:
    88→        """
    89→        Acquire a lock on a file.
    90→
    91→        Args:
    92→            file_path: Path to the file to lock (relative to project)
    93→            feature_id: ID of the feature requesting the lock
    94→            agent_id: ID of the agent requesting the lock
    95→            timeout_seconds: Lock timeout in seconds (None for default)
    96→            wait: If True, wait for the lock to become available
    97→            wait_timeout: Maximum time to wait for the lock (if wait=True)
    98→
    99→        Returns:
   100→            LockResult indicating success or failure
   101→        """
   102→        normalized = self._normalize_path(file_path)
   103→        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS
   104→
   105→        start_time = datetime.now()
   106→
   107→        while True:
   108→            async with self._lock:
   109→                # Check if already locked
   110→                existing = self._locks.get(normalized)
   111→
   112→                if existing:
   113→                    # Check if lock has expired
   114→                    if existing.expires_at and datetime.now() > existing.expires_at:
   115→                        logger.info(
   116→                            f"Lock on {normalized} expired, releasing "
   117→                            f"(was held by feature {existing.feature_id})"
   118→                        )
   119→                        del self._locks[normalized]
   120→                        existing = None
   121→
   122→                if existing:
   123→                    # Already locked by same feature - extend the lock
   124→                    if existing.feature_id == feature_id:
   125→                        existing.expires_at = datetime.now() + timedelta(seconds=timeout)
   126→                        logger.debug(f"Extended lock on {normalized} for feature {feature_id}")
   127→                        return LockResult(
   128→                            success=True,
   129→                            message="Lock extended",
   130→                            lock_info=existing,
   131→                        )
   132→
   133→                    # Locked by different feature
   134→                    if not wait:
   135→                        return LockResult(
   136→                            success=False,
   137→                            message=f"File locked by feature {existing.feature_id}",
   138→                            holder_feature_id=existing.feature_id,
   139→                        )
   140→                else:
   141→                    # Lock is available - acquire it
   142→                    lock_info = LockInfo(
   143→                        file_path=normalized,
   144→                        feature_id=feature_id,
   145→                        agent_id=agent_id,
   146→                        locked_at=datetime.now(),
   147→                        expires_at=datetime.now() + timedelta(seconds=timeout),
   148→                    )
   149→                    self._locks[normalized] = lock_info
   150→
   151→                    logger.debug(f"Acquired lock on {normalized} for feature {feature_id}")
   152→                    return LockResult(
   153→                        success=True,
   154→                        message="Lock acquired",
   155→                        lock_info=lock_info,
   156→                    )
   157→
   158→            # If waiting, check timeout and retry
   159→            if wait:
   160→                elapsed = (datetime.now() - start_time).total_seconds()
   161→                if elapsed >= wait_timeout:
   162→                    return LockResult(
   163→                        success=False,
   164→                        message=f"Timeout waiting for lock on {normalized}",
   165→                        holder_feature_id=existing.feature_id if existing else None,
   166→                    )
   167→
   168→                # Wait a bit before retrying
   169→                await asyncio.sleep(0.5)
   170→            else:
   171→                break
   172→
   173→        # Should not reach here
   174→        return LockResult(success=False, message="Unexpected error")
   175→
   176→    async def acquire_multiple(
   177→        self,
   178→        file_paths: list[str],
   179→        feature_id: int,
   180→        agent_id: str = "default",
   181→        timeout_seconds: Optional[int] = None,
   182→    ) -> LockResult:
   183→        """
   184→        Acquire locks on multiple files atomically.
   185→
   186→        Either all locks are acquired or none are. This prevents
   187→        partial lock scenarios that could lead to deadlocks.
   188→
   189→        Args:
   190→            file_paths: List of file paths to lock
   191→            feature_id: ID of the feature requesting the locks
   192→            agent_id: ID of the agent requesting the locks
   193→            timeout_seconds: Lock timeout in seconds
   194→
   195→        Returns:
   196→            LockResult indicating success or failure
   197→        """
   198→        if not file_paths:
   199→            return LockResult(success=True, message="No files to lock")
   200→
   201→        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS
   202→        normalized_paths = [self._normalize_path(p) for p in file_paths]
   203→
   204→        async with self._lock:
   205→            # First, check if all locks are available
   206→            conflicts = []
   207→            for path in normalized_paths:
   208→                existing = self._locks.get(path)
   209→                if existing:
   210→                    # Check expiration
   211→                    if existing.expires_at and datetime.now() > existing.expires_at:
   212→                        continue  # Expired, will be released
   213→
   214→                    if existing.feature_id != feature_id:
   215→                        conflicts.append((path, existing.feature_id))
   216→
   217→            if conflicts:
   218→                conflict_msg = ", ".join(
   219→                    f"{p} (feature {fid})" for p, fid in conflicts
   220→                )
   221→                return LockResult(
   222→                    success=False,
   223→                    message=f"Conflicts: {conflict_msg}",
   224→                    holder_feature_id=conflicts[0][1],
   225→                )
   226→
   227→            # All locks available - acquire them
   228→            lock_time = datetime.now()
   229→            expires_at = lock_time + timedelta(seconds=timeout)
   230→
   231→            for path in normalized_paths:
   232→                # Clean up expired locks
   233→                existing = self._locks.get(path)
   234→                if existing and existing.expires_at and datetime.now() > existing.expires_at:
   235→                    del self._locks[path]
   236→
   237→                # Acquire lock
   238→                self._locks[path] = LockInfo(
   239→                    file_path=path,
   240→                    feature_id=feature_id,
   241→                    agent_id=agent_id,
   242→                    locked_at=lock_time,
   243→                    expires_at=expires_at,
   244→                )
   245→
   246→            logger.debug(
   247→                f"Acquired {len(normalized_paths)} locks for feature {feature_id}"
   248→            )
   249→            return LockResult(
   250→                success=True,
   251→                message=f"Acquired {len(normalized_paths)} locks",
   252→            )
   253→
   254→    async def release(
   255→        self,
   256→        file_path: str,
   257→        feature_id: int,
   258→    ) -> LockResult:
   259→        """
   260→        Release a lock on a file.
   261→
   262→        Args:
   263→            file_path: Path to the file to unlock
   264→            feature_id: ID of the feature releasing the lock
   265→
   266→        Returns:
   267→            LockResult indicating success or failure
   268→        """
   269→        normalized = self._normalize_path(file_path)
   270→
   271→        async with self._lock:
   272→            existing = self._locks.get(normalized)
   273→
   274→            if not existing:
   275→                return LockResult(
   276→                    success=True,
   277→                    message="Lock not held (already released)",
   278→                )
   279→
   280→            if existing.feature_id != feature_id:
   281→                return LockResult(
   282→                    success=False,
   283→                    message=f"Lock held by different feature ({existing.feature_id})",
   284→                    holder_feature_id=existing.feature_id,
   285→                )
   286→
   287→            del self._locks[normalized]
   288→            logger.debug(f"Released lock on {normalized} by feature {feature_id}")
   289→
   290→            return LockResult(
   291→                success=True,
   292→                message="Lock released",
   293→            )
   294→
   295→    async def release_all(
   296→        self,
   297→        feature_id: int,
   298→    ) -> int:
   299→        """
   300→        Release all locks held by a feature.
   301→
   302→        Args:
   303→            feature_id: ID of the feature
   304→
   305→        Returns:
   306→            Number of locks released
   307→        """
   308→        async with self._lock:
   309→            to_release = [
   310→                path for path, info in self._locks.items()
   311→                if info.feature_id == feature_id
   312→            ]
   313→
   314→            for path in to_release:
   315→                del self._locks[path]
   316→
   317→            if to_release:
   318→                logger.debug(
   319→                    f"Released {len(to_release)} locks for feature {feature_id}"
   320→                )
   321→
   322→            return len(to_release)
   323→
   324→    async def force_release(
   325→        self,
   326→        file_path: str,
   327→    ) -> LockResult:
   328→        """
   329→        Force release a lock regardless of owner.
   330→
   331→        Use with caution - this is for recovery scenarios only.
   332→
   333→        Args:
   334→            file_path: Path to the file to unlock
   335→
   336→        Returns:
   337→            LockResult indicating success
   338→        """
   339→        normalized = self._normalize_path(file_path)
   340→
   341→        async with self._lock:
   342→            existing = self._locks.get(normalized)
   343→
   344→            if existing:
   345→                logger.warning(
   346→                    f"Force releasing lock on {normalized} "
   347→                    f"(was held by feature {existing.feature_id})"
   348→                )
   349→                del self._locks[normalized]
   350→                return LockResult(
   351→                    success=True,
   352→                    message=f"Force released (was held by feature {existing.feature_id})",
   353→                )
   354→
   355→            return LockResult(
   356→                success=True,
   357→                message="Lock not held",
   358→            )
   359→
   360→    async def is_locked(
   361→        self,
   362→        file_path: str,
   363→    ) -> tuple[bool, Optional[int]]:
   364→        """
   365→        Check if a file is locked.
   366→
   367→        Args:
   368→            file_path: Path to check
   369→
   370→        Returns:
   371→            Tuple of (is_locked, feature_id or None)
   372→        """
   373→        normalized = self._normalize_path(file_path)
   374→
   375→        async with self._lock:
   376→            existing = self._locks.get(normalized)
   377→
   378→            if existing:
   379→                # Check expiration
   380→                if existing.expires_at and datetime.now() > existing.expires_at:
   381→                    del self._locks[normalized]
   382→                    return False, None
   383→
   384→                return True, existing.feature_id
   385→
   386→            return False, None
   387→
   388→    async def get_locks_for_feature(
   389→        self,
   390→        feature_id: int,
   391→    ) -> list[LockInfo]:
   392→        """
   393→        Get all locks held by a feature.
   394→
   395→        Args:
   396→            feature_id: ID of the feature
   397→
   398→        Returns:
   399→            List of lock information
   400→        """
   401→        async with self._lock:
   402→            return [
   403→                info for info in self._locks.values()
   404→                if info.feature_id == feature_id
   405→            ]
   406→
   407→    async def get_all_locks(self) -> dict[str, LockInfo]:
   408→        """
   409→        Get all current locks.
   410→
   411→        Returns:
   412→            Dictionary mapping file paths to lock information
   413→        """
   414→        async with self._lock:
   415→            # Clean up expired locks first
   416→            now = datetime.now()
   417→            expired = [
   418→                path for path, info in self._locks.items()
   419→                if info.expires_at and now > info.expires_at
   420→            ]
   421→            for path in expired:
   422→                del self._locks[path]
   423→
   424→            return dict(self._locks)
   425→
   426→    async def cleanup_expired(self) -> int:
   427→        """
   428→        Clean up all expired locks.
   429→
   430→        Returns:
   431→            Number of locks cleaned up
   432→        """
   433→        async with self._lock:
   434→            now = datetime.now()
   435→            expired = [
   436→                path for path, info in self._locks.items()
   437→                if info.expires_at and now > info.expires_at
   438→            ]
   439→
   440→            for path in expired:
   441→                logger.debug(f"Cleaning up expired lock: {path}")
   442→                del self._locks[path]
   443→
   444→            return len(expired)
   445→
   446→    def get_stats(self) -> dict:
   447→        """
   448→        Get lock manager statistics (sync version for quick checks).
   449→
   450→        Returns:
   451→            Dictionary with lock statistics
   452→        """
   453→        return {
   454→            "total_locks": len(self._locks),
   455→            "locked_files": list(self._locks.keys()),
   456→            "features_with_locks": list(set(
   457→                info.feature_id for info in self._locks.values()
   458→            )),
   459→        }
   460→
   461→
   462→# Global lock manager instance
   463→_global_lock_manager: Optional[FileLockManager] = None
   464→
   465→
   466→def get_lock_manager(project_dir: Optional[Path] = None) -> FileLockManager:
   467→    """
   468→    Get or create the global lock manager.
   469→
   470→    Args:
   471→        project_dir: Project directory (used on first call)
   472→
   473→    Returns:
   474→        The global FileLockManager instance
   475→    """
   476→    global _global_lock_manager
   477→
   478→    if _global_lock_manager is None:
   479→        _global_lock_manager = FileLockManager(project_dir)
   480→
   481→    return _global_lock_manager
   482→
   483→
   484→def reset_lock_manager() -> None:
   485→    """Reset the global lock manager (for testing)."""
   486→    global _global_lock_manager
   487→    _global_lock_manager = None
   488→

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

"""
File Lock Manager
=================

Async-safe file locking to prevent concurrent modifications
to the same files by parallel coding agents.
"""

import asyncio
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

@dataclass
class LockInfo:
    """Information about a file lock."""
    file_path: str
    feature_id: int
    agent_id: str
    locked_at: datetime
    expires_at: Optional[datetime] = None

@dataclass
class LockResult:
    """Result of a lock operation."""
    success: bool
    message: str
    lock_info: Optional[LockInfo] = None
    holder_feature_id: Optional[int] = None

class FileLockManager:
    """
    Manage file locks for parallel coding agents.

    This class provides async-safe file locking to prevent multiple
    agents from modifying the same files simultaneously. It supports:

    - Acquiring locks on individual files or directories
    - Lock timeouts to prevent deadlocks
    - Force release for recovery scenarios
    - Pattern-based locking (e.g., lock all files in a directory)

    Thread Safety:
        All operations use asyncio.Lock to ensure thread safety
        in async contexts.
    """

    # Default lock timeout (1 hour)
    DEFAULT_TIMEOUT_SECONDS = 3600

    def __init__(self, project_dir: Optional[Path] = None):
        """
        Initialize the file lock manager.

        Args:
            project_dir: Optional project directory for relative path resolution
        """
        self.project_dir = project_dir
        self._locks: dict[str, LockInfo] = {}
        self._lock = asyncio.Lock()

    def _normalize_path(self, file_path: str) -> str:
        """Normalize a file path for consistent locking."""
        # Convert to forward slashes and lowercase for consistency
        normalized = file_path.replace("\\", "/").lower()

        # Remove leading/trailing slashes
        normalized = normalized.strip("/")

        return normalized

    async def acquire(
        self,
        file_path: str,
        feature_id: int,
        agent_id: str = "default",
        timeout_seconds: Optional[int] = None,
        wait: bool = False,
        wait_timeout: float = 30.0,
    ) -> LockResult:
        """
        Acquire a lock on a file.

        Args:
            file_path: Path to the file to lock (relative to project)
            feature_id: ID of the feature requesting the lock
            agent_id: ID of the agent requesting the lock
            timeout_seconds: Lock timeout in seconds (None for default)
            wait: If True, wait for the lock to become available
            wait_timeout: Maximum time to wait for the lock (if wait=True)

        Returns:
            LockResult indicating success or failure
        """
        normalized = self._normalize_path(file_path)
        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS

        start_time = datetime.now()

        while True:
            async with self._lock:
                # Check if already locked
                existing = self._locks.get(normalized)

                if existing:
                    # Check if lock has expired
                    if existing.expires_at and datetime.now() > existing.expires_at:
                        logger.info(
                            f"Lock on {normalized} expired, releasing "
                            f"(was held by feature {existing.feature_id})"
                        )
                        del self._locks[normalized]
                        existing = None

                if existing:
                    # Already locked by same feature - extend the lock
                    if existing.feature_id == feature_id:
                        existing.expires_at = datetime.now() + timedelta(seconds=timeout)
                        logger.debug(f"Extended lock on {normalized} for feature {feature_id}")
                        return LockResult(
                            success=True,
                            message="Lock extended",
                            lock_info=existing,
                        )

                    # Locked by different feature
                    if not wait:
                        return LockResult(
                            success=False,
                            message=f"File locked by feature {existing.feature_id}",
                            holder_feature_id=existing.feature_id,
                        )
                else:
                    # Lock is available - acquire it
                    lock_info = LockInfo(
                        file_path=normalized,
                        feature_id=feature_id,
                        agent_id=agent_id,
                        locked_at=datetime.now(),
                        expires_at=datetime.now() + timedelta(seconds=timeout),
                    )
                    self._locks[normalized] = lock_info

                    logger.debug(f"Acquired lock on {normalized} for feature {feature_id}")
                    return LockResult(
                        success=True,
                        message="Lock acquired",
                        lock_info=lock_info,
                    )

            # If waiting, check timeout and retry
            if wait:
                elapsed = (datetime.now() - start_time).total_seconds()
                if elapsed >= wait_timeout:
                    return LockResult(
                        success=False,
                        message=f"Timeout waiting for lock on {normalized}",
                        holder_feature_id=existing.feature_id if existing else None,
                    )

                # Wait a bit before retrying
                await asyncio.sleep(0.5)
            else:
                break

        # Should not reach here
        return LockResult(success=False, message="Unexpected error")

    async def acquire_multiple(
        self,
        file_paths: list[str],
        feature_id: int,
        agent_id: str = "default",
        timeout_seconds: Optional[int] = None,
    ) -> LockResult:
        """
        Acquire locks on multiple files atomically.

        Either all locks are acquired or none are. This prevents
        partial lock scenarios that could lead to deadlocks.

        Args:
            file_paths: List of file paths to lock
            feature_id: ID of the feature requesting the locks
            agent_id: ID of the agent requesting the locks
            timeout_seconds: Lock timeout in seconds

        Returns:
            LockResult indicating success or failure
        """
        if not file_paths:
            return LockResult(success=True, message="No files to lock")

        timeout = timeout_seconds or self.DEFAULT_TIMEOUT_SECONDS
        normalized_paths = [self._normalize_path(p) for p in file_paths]

        async with self._lock:
            # First, check if all locks are available
            conflicts = []
            for path in normalized_paths:
                existing = self._locks.get(path)
                if existing:
                    # Check expiration
                    if existing.expires_at and datetime.now() > existing.expires_at:
                        continue  # Expired, will be released

                    if existing.feature_id != feature_id:
                        conflicts.append((path, existing.feature_id))

            if conflicts:
                conflict_msg = ", ".join(
                    f"{p} (feature {fid})" for p, fid in conflicts
                )
                return LockResult(
                    success=False,
                    message=f"Conflicts: {conflict_msg}",
                    holder_feature_id=conflicts[0][1],
                )

            # All locks available - acquire them
            lock_time = datetime.now()
            expires_at = lock_time + timedelta(seconds=timeout)

            for path in normalized_paths:
                # Clean up expired locks
                existing = self._locks.get(path)
                if existing and existing.expires_at and datetime.now() > existing.expires_at:
                    del self._locks[path]

                # Acquire lock
                self._locks[path] = LockInfo(
                    file_path=path,
                    feature_id=feature_id,
                    agent_id=agent_id,
                    locked_at=lock_time,
                    expires_at=expires_at,
                )

            logger.debug(
                f"Acquired {len(normalized_paths)} locks for feature {feature_id}"
            )
            return LockResult(
                success=True,
                message=f"Acquired {len(normalized_paths)} locks",
            )

    async def release(
        self,
        file_path: str,
        feature_id: int,
    ) -> LockResult:
        """
        Release a lock on a file.

        Args:
            file_path: Path to the file to unlock
            feature_id: ID of the feature releasing the lock

        Returns:
            LockResult indicating success or failure
        """
        normalized = self._normalize_path(file_path)

        async with self._lock:
            existing = self._locks.get(normalized)

            if not existing:
                return LockResult(
                    success=True,
                    message="Lock not held (already released)",
                )

            if existing.feature_id != feature_id:
                return LockResult(
                    success=False,
                    message=f"Lock held by different feature ({existing.feature_id})",
                    holder_feature_id=existing.feature_id,
                )

            del self._locks[normalized]
            logger.debug(f"Released lock on {normalized} by feature {feature_id}")

            return LockResult(
                success=True,
                message="Lock released",
            )

    async def release_all(
        self,
        feature_id: int,
    ) -> int:
        """
        Release all locks held by a feature.

        Args:
            feature_id: ID of the feature

        Returns:
            Number of locks released
        """
        async with self._lock:
            to_release = [
                path for path, info in self._locks.items()
                if info.feature_id == feature_id
            ]

            for path in to_release:
                del self._locks[path]

            if to_release:
                logger.debug(
                    f"Released {len(to_release)} locks for feature {feature_id}"
                )

            return len(to_release)

    async def force_release(
        self,
        file_path: str,
    ) -> LockResult:
        """
        Force release a lock regardless of owner.

        Use with caution - this is for recovery scenarios only.

        Args:
            file_path: Path to the file to unlock

        Returns:
            LockResult indicating success
        """
        normalized = self._normalize_path(file_path)

        async with self._lock:
            existing = self._locks.get(normalized)

            if existing:
                logger.warning(
                    f"Force releasing lock on {normalized} "
                    f"(was held by feature {existing.feature_id})"
                )
                del self._locks[normalized]
                return LockResult(
                    success=True,
                    message=f"Force released (was held by feature {existing.feature_id})",
                )

            return LockResult(
                success=True,
                message="Lock not held",
            )

    async def is_locked(
        self,
        file_path: str,
    ) -> tuple[bool, Optional[int]]:
        """
        Check if a file is locked.

        Args:
            file_path: Path to check

        Returns:
            Tuple of (is_locked, feature_id or None)
        """
        normalized = self._normalize_path(file_path)

        async with self._lock:
            existing = self._locks.get(normalized)

            if existing:
                # Check expiration
                if existing.expires_at and datetime.now() > existing.expires_at:
                    del self._locks[normalized]
                    return False, None

                return True, existing.feature_id

            return False, None

    async def get_locks_for_feature(
        self,
        feature_id: int,
    ) -> list[LockInfo]:
        """
        Get all locks held by a feature.

        Args:
            feature_id: ID of the feature

        Returns:
            List of lock information
        """
        async with self._lock:
            return [
                info for info in self._locks.values()
                if info.feature_id == feature_id
            ]

    async def get_all_locks(self) -> dict[str, LockInfo]:
        """
        Get all current locks.

        Returns:
            Dictionary mapping file paths to lock information
        """
        async with self._lock:
            # Clean up expired locks first
            now = datetime.now()
            expired = [
                path for path, info in self._locks.items()
                if info.expires_at and now > info.expires_at
            ]
            for path in expired:
                del self._locks[path]

            return dict(self._locks)

    async def cleanup_expired(self) -> int:
        """
        Clean up all expired locks.

        Returns:
            Number of locks cleaned up
        """
        async with self._lock:
            now = datetime.now()
            expired = [
                path for path, info in self._locks.items()
                if info.expires_at and now > info.expires_at
            ]

            for path in expired:
                logger.debug(f"Cleaning up expired lock: {path}")
                del self._locks[path]

            return len(expired)

    def get_stats(self) -> dict:
        """
        Get lock manager statistics (sync version for quick checks).

        Returns:
            Dictionary with lock statistics
        """
        return {
            "total_locks": len(self._locks),
            "locked_files": list(self._locks.keys()),
            "features_with_locks": list(set(
                info.feature_id for info in self._locks.values()
            )),
        }

# Global lock manager instance
_global_lock_manager: Optional[FileLockManager] = None

def get_lock_manager(project_dir: Optional[Path] = None) -> FileLockManager:
    """
    Get or create the global lock manager.

    Args:
        project_dir: Project directory (used on first call)

    Returns:
        The global FileLockManager instance
    """
    global _global_lock_manager

    if _global_lock_manager is None:
        _global_lock_manager = FileLockManager(project_dir)

    return _global_lock_manager

def reset_lock_manager() -> None:
    """Reset the global lock manager (for testing)."""
    global _global_lock_manager
    _global_lock_manager = None


     1→"""
     2→Agent Router
     3→============
     4→
     5→API endpoints for agent control (start/stop/pause/resume).
     6→Uses project registry for path lookups.
     7→"""
     8→
     9→import re
    10→from pathlib import Path
    11→
    12→from fastapi import APIRouter, HTTPException
    13→
    14→from ..schemas import AgentActionResponse, AgentStartRequest, AgentStatus
    15→from ..services.process_manager import get_manager
    16→
    17→
    18→def _get_project_path(project_name: str) -> Path:
    19→    """Get project path from registry."""
    20→    import sys
    21→    root = Path(__file__).parent.parent.parent
    22→    if str(root) not in sys.path:
    23→        sys.path.insert(0, str(root))
    24→
    25→    from registry import get_project_path
    26→    return get_project_path(project_name)
    27→
    28→
    29→router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])
    30→
    31→# Root directory for process manager
    32→ROOT_DIR = Path(__file__).parent.parent.parent
    33→
    34→
    35→def validate_project_name(name: str) -> str:
    36→    """Validate and sanitize project name to prevent path traversal."""
    37→    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
    38→        raise HTTPException(
    39→            status_code=400,
    40→            detail="Invalid project name"
    41→        )
    42→    return name
    43→
    44→
    45→def get_project_manager(project_name: str):
    46→    """Get the process manager for a project."""
    47→    project_name = validate_project_name(project_name)
    48→    project_dir = _get_project_path(project_name)
    49→
    50→    if not project_dir:
    51→        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")
    52→
    53→    if not project_dir.exists():
    54→        raise HTTPException(status_code=404, detail=f"Project directory not found: {project_dir}")
    55→
    56→    return get_manager(project_name, project_dir, ROOT_DIR)
    57→
    58→
    59→@router.get("/status", response_model=AgentStatus)
    60→async def get_agent_status(project_name: str):
    61→    """Get the current status of the agent for a project."""
    62→    manager = get_project_manager(project_name)
    63→
    64→    # Run healthcheck to detect crashed processes
    65→    await manager.healthcheck()
    66→
    67→    return AgentStatus(
    68→        status=manager.status,
    69→        pid=manager.pid,
    70→        started_at=manager.started_at,
    71→        yolo_mode=manager.yolo_mode,
    72→    )
    73→
    74→
    75→@router.post("/start", response_model=AgentActionResponse)
    76→async def start_agent(
    77→    project_name: str,
    78→    request: AgentStartRequest = AgentStartRequest(),
    79→):
    80→    """Start the agent for a project."""
    81→    manager = get_project_manager(project_name)
    82→
    83→    success, message = await manager.start(yolo_mode=request.yolo_mode)
    84→
    85→    return AgentActionResponse(
    86→        success=success,
    87→        status=manager.status,
    88→        message=message,
    89→    )
    90→
    91→
    92→@router.post("/stop", response_model=AgentActionResponse)
    93→async def stop_agent(project_name: str):
    94→    """Stop the agent for a project."""
    95→    manager = get_project_manager(project_name)
    96→
    97→    success, message = await manager.stop()
    98→
    99→    return AgentActionResponse(
   100→        success=success,
   101→        status=manager.status,
   102→        message=message,
   103→    )
   104→
   105→
   106→@router.post("/pause", response_model=AgentActionResponse)
   107→async def pause_agent(project_name: str):
   108→    """Pause the agent for a project."""
   109→    manager = get_project_manager(project_name)
   110→
   111→    success, message = await manager.pause()
   112→
   113→    return AgentActionResponse(
   114→        success=success,
   115→        status=manager.status,
   116→        message=message,
   117→    )
   118→
   119→
   120→@router.post("/resume", response_model=AgentActionResponse)
   121→async def resume_agent(project_name: str):
   122→    """Resume a paused agent."""
   123→    manager = get_project_manager(project_name)
   124→
   125→    success, message = await manager.resume()
   126→
   127→    return AgentActionResponse(
   128→        success=success,
   129→        status=manager.status,
   130→        message=message,
   131→    )
   132→

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

"""
Agent Router
============

API endpoints for agent control (start/stop/pause/resume).
Uses project registry for path lookups.
"""

import re
from pathlib import Path

from fastapi import APIRouter, HTTPException

from ..schemas import AgentActionResponse, AgentStartRequest, AgentStatus
from ..services.process_manager import get_manager

def _get_project_path(project_name: str) -> Path:
    """Get project path from registry."""
    import sys
    root = Path(__file__).parent.parent.parent
    if str(root) not in sys.path:
        sys.path.insert(0, str(root))

    from registry import get_project_path
    return get_project_path(project_name)

router = APIRouter(prefix="/api/projects/{project_name}/agent", tags=["agent"])

# Root directory for process manager
ROOT_DIR = Path(__file__).parent.parent.parent

def validate_project_name(name: str) -> str:
    """Validate and sanitize project name to prevent path traversal."""
    if not re.match(r'^[a-zA-Z0-9_-]{1,50}$', name):
        raise HTTPException(
            status_code=400,
            detail="Invalid project name"
        )
    return name

def get_project_manager(project_name: str):
    """Get the process manager for a project."""
    project_name = validate_project_name(project_name)
    project_dir = _get_project_path(project_name)

    if not project_dir:
        raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry")

    if not project_dir.exists():
        raise HTTPException(status_code=404, detail=f"Project directory not found: {project_dir}")

    return get_manager(project_name, project_dir, ROOT_DIR)

@router.get("/status", response_model=AgentStatus)
async def get_agent_status(project_name: str):
    """Get the current status of the agent for a project."""
    manager = get_project_manager(project_name)

    # Run healthcheck to detect crashed processes
    await manager.healthcheck()

    return AgentStatus(
        status=manager.status,
        pid=manager.pid,
        started_at=manager.started_at,
        yolo_mode=manager.yolo_mode,
    )

@router.post("/start", response_model=AgentActionResponse)
async def start_agent(
    project_name: str,
    request: AgentStartRequest = AgentStartRequest(),
):
    """Start the agent for a project."""
    manager = get_project_manager(project_name)

    success, message = await manager.start(yolo_mode=request.yolo_mode)

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )

@router.post("/stop", response_model=AgentActionResponse)
async def stop_agent(project_name: str):
    """Stop the agent for a project."""
    manager = get_project_manager(project_name)

    success, message = await manager.stop()

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )

@router.post("/pause", response_model=AgentActionResponse)
async def pause_agent(project_name: str):
    """Pause the agent for a project."""
    manager = get_project_manager(project_name)

    success, message = await manager.pause()

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )

@router.post("/resume", response_model=AgentActionResponse)
async def resume_agent(project_name: str):
    """Resume a paused agent."""
    manager = get_project_manager(project_name)

    success, message = await manager.resume()

    return AgentActionResponse(
        success=success,
        status=manager.status,
        message=message,
    )