     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)

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


     1→"""
     2→Parallel Agent Runner
     3→=====================
     4→
     5→Run multiple coding agents in parallel on non-conflicting features.
     6→Uses the FeatureGraph to determine which features can be parallelized
     7→and FileLockManager to prevent concurrent file modifications.
     8→"""
     9→
    10→import asyncio
    11→import io
    12→import sys
    13→import logging
    14→from dataclasses import dataclass, field
    15→from datetime import datetime
    16→from pathlib import Path
    17→from typing import Optional, Callable, Any
    18→
    19→# Fix Windows console encoding
    20→if sys.platform == "win32":
    21→    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
    22→    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')
    23→
    24→from claude_agent_sdk import ClaudeSDKClient
    25→
    26→from client import create_client
    27→from progress import has_features, print_progress_summary, get_feature_stats
    28→from prompts import get_coding_prompt, get_coding_prompt_yolo, copy_spec_to_project, get_initializer_prompt
    29→
    30→# Import server services
    31→import sys as _sys
    32→_root = Path(__file__).parent
    33→if str(_root / "server") not in _sys.path:
    34→    _sys.path.insert(0, str(_root))
    35→
    36→from server.services.feature_graph import FeatureGraph, ParallelFeature
    37→from server.services.file_lock import FileLockManager, get_lock_manager
    38→from server.agents.git_agent import GitAgent, GitAgentConfig
    39→
    40→logger = logging.getLogger(__name__)
    41→
    42→# Configuration
    43→DEFAULT_MAX_PARALLEL = 3
    44→AUTO_CONTINUE_DELAY_SECONDS = 3
    45→
    46→
    47→@dataclass
    48→class AgentResult:
    49→    """Result of a single agent run."""
    50→    feature_id: int
    51→    feature_name: str
    52→    success: bool
    53→    error: Optional[str] = None
    54→    files_touched: list[str] = field(default_factory=list)
    55→    duration_seconds: float = 0.0
    56→
    57→
    58→@dataclass
    59→class ParallelRunResult:
    60→    """Result of a parallel agent run."""
    61→    total_features: int
    62→    completed: int
    63→    failed: int
    64→    results: list[AgentResult] = field(default_factory=list)
    65→    duration_seconds: float = 0.0
    66→
    67→
    68→class ParallelAgentRunner:
    69→    """
    70→    Orchestrate multiple coding agents running in parallel.
    71→
    72→    This class:
    73→    1. Uses FeatureGraph to find parallelizable features
    74→    2. Manages file locks to prevent conflicts
    75→    3. Coordinates with GitAgent for commits
    76→    4. Tracks progress and handles failures
    77→    """
    78→
    79→    def __init__(
    80→        self,
    81→        project_dir: Path,
    82→        model: str = "claude-sonnet-4-20250514",
    83→        max_parallel: int = DEFAULT_MAX_PARALLEL,
    84→        yolo_mode: bool = False,
    85→        on_feature_start: Optional[Callable[[int, str], Any]] = None,
    86→        on_feature_complete: Optional[Callable[[AgentResult], Any]] = None,
    87→        on_progress: Optional[Callable[[int, int, int], Any]] = None,
    88→    ):
    89→        """
    90→        Initialize the parallel agent runner.
    91→
    92→        Args:
    93→            project_dir: Path to the project directory
    94→            model: Claude model to use
    95→            max_parallel: Maximum number of parallel agents
    96→            yolo_mode: If True, skip browser testing
    97→            on_feature_start: Callback when a feature starts (id, name)
    98→            on_feature_complete: Callback when a feature completes
    99→            on_progress: Callback for progress updates (completed, failed, total)
   100→        """
   101→        self.project_dir = project_dir
   102→        self.model = model
   103→        self.max_parallel = max_parallel
   104→        self.yolo_mode = yolo_mode
   105→
   106→        # Callbacks
   107→        self._on_feature_start = on_feature_start
   108→        self._on_feature_complete = on_feature_complete
   109→        self._on_progress = on_progress
   110→
   111→        # Services
   112→        self.feature_graph = FeatureGraph(project_dir)
   113→        self.lock_manager = FileLockManager(project_dir)
   114→        self.git_agent = GitAgent(
   115→            project_dir,
   116→            GitAgentConfig(
   117→                auto_commit=True,
   118→                auto_push=False,  # Don't auto-push, let user control
   119→                commit_style="conventional",
   120→            ),
   121→        )
   122→
   123→        # State
   124→        self._running = False
   125→        self._active_agents: dict[int, asyncio.Task] = {}
   126→        self._results: list[AgentResult] = []
   127→
   128→    async def run_single_feature(
   129→        self,
   130→        feature: ParallelFeature,
   131→        agent_id: str,
   132→    ) -> AgentResult:
   133→        """
   134→        Run a single feature with its own agent instance.
   135→
   136→        Args:
   137→            feature: The feature to implement
   138→            agent_id: Unique identifier for this agent instance
   139→
   140→        Returns:
   141→            AgentResult with success/failure info
   142→        """
   143→        start_time = datetime.now()
   144→        files_touched: list[str] = []
   145→
   146→        logger.info(f"[Agent {agent_id}] Starting feature {feature.id}: {feature.name}")
   147→
   148→        if self._on_feature_start:
   149→            self._on_feature_start(feature.id, feature.name)
   150→
   151→        try:
   152→            # Predict and lock files this feature might touch
   153→            predicted_files = self.feature_graph.predict_files_for_feature(
   154→                feature.id,
   155→                feature.category,
   156→                feature.name,
   157→            )
   158→
   159→            if predicted_files:
   160→                lock_result = await self.lock_manager.acquire_multiple(
   161→                    predicted_files,
   162→                    feature.id,
   163→                    agent_id,
   164→                )
   165→                if not lock_result.success:
   166→                    logger.warning(
   167→                        f"[Agent {agent_id}] Could not acquire locks: {lock_result.message}"
   168→                    )
   169→                    # Continue anyway - locks are advisory
   170→
   171→            # Create client for this agent
   172→            client = create_client(
   173→                self.project_dir,
   174→                self.model,
   175→                yolo_mode=self.yolo_mode,
   176→            )
   177→
   178→            # Generate prompt for this specific feature
   179→            prompt = self._generate_feature_prompt(feature)
   180→
   181→            # Run the agent session
   182→            async with client:
   183→                await client.query(prompt)
   184→
   185→                async for msg in client.receive_response():
   186→                    msg_type = type(msg).__name__
   187→
   188→                    if msg_type == "AssistantMessage" and hasattr(msg, "content"):
   189→                        for block in msg.content:
   190→                            block_type = type(block).__name__
   191→
   192→                            if block_type == "TextBlock" and hasattr(block, "text"):
   193→                                # Log output (truncated)
   194→                                text = block.text[:200] + "..." if len(block.text) > 200 else block.text
   195→                                logger.debug(f"[Agent {agent_id}] {text}")
   196→
   197→                            elif block_type == "ToolUseBlock" and hasattr(block, "name"):
   198→                                tool_name = block.name
   199→                                logger.info(f"[Agent {agent_id}] [Tool: {tool_name}]")
   200→
   201→                                # Track file operations
   202→                                if tool_name in ("Write", "Edit", "Read") and hasattr(block, "input"):
   203→                                    file_path = block.input.get("file_path", "")
   204→                                    if file_path:
   205→                                        files_touched.append(file_path)
   206→
   207→            # Record files touched
   208→            if files_touched:
   209→                self.feature_graph.record_files_touched(feature.id, files_touched)
   210→
   211→                # Track changes for git
   212→                for file in files_touched:
   213→                    await self.git_agent.track_change(
   214→                        file=file,
   215→                        agent_id=agent_id,
   216→                        feature_id=feature.id,
   217→                        change_type="modified",
   218→                    )
   219→
   220→            duration = (datetime.now() - start_time).total_seconds()
   221→
   222→            result = AgentResult(
   223→                feature_id=feature.id,
   224→                feature_name=feature.name,
   225→                success=True,
   226→                files_touched=files_touched,
   227→                duration_seconds=duration,
   228→            )
   229→
   230→            logger.info(
   231→                f"[Agent {agent_id}] Completed feature {feature.id} "
   232→                f"in {duration:.1f}s ({len(files_touched)} files)"
   233→            )
   234→
   235→            return result
   236→
   237→        except Exception as e:
   238→            duration = (datetime.now() - start_time).total_seconds()
   239→            logger.error(f"[Agent {agent_id}] Error on feature {feature.id}: {e}")
   240→
   241→            return AgentResult(
   242→                feature_id=feature.id,
   243→                feature_name=feature.name,
   244→                success=False,
   245→                error=str(e),
   246→                files_touched=files_touched,
   247→                duration_seconds=duration,
   248→            )
   249→
   250→        finally:
   251→            # Release locks
   252→            await self.lock_manager.release_all(feature.id)
   253→
   254→            # Notify git agent of feature completion
   255→            await self.git_agent.on_feature_complete(feature.id, feature.name)
   256→
   257→    def _generate_feature_prompt(self, feature: ParallelFeature) -> str:
   258→        """
   259→        Generate a prompt for implementing a specific feature.
   260→
   261→        Args:
   262→            feature: The feature to implement
   263→
   264→        Returns:
   265→            Prompt string
   266→        """
   267→        # Start with the base coding prompt
   268→        if self.yolo_mode:
   269→            base_prompt = get_coding_prompt_yolo(self.project_dir)
   270→        else:
   271→            base_prompt = get_coding_prompt(self.project_dir)
   272→
   273→        # Add feature-specific context
   274→        feature_context = f"""
   275→## Current Feature Assignment
   276→
   277→You are specifically working on this feature:
   278→
   279→**Feature #{feature.id}**: {feature.name}
   280→**Category**: {feature.category}
   281→**Priority**: {feature.priority}
   282→
   283→**Description**:
   284→{feature.description}
   285→
   286→**Steps**:
   287→{chr(10).join(f"- {step}" for step in feature.steps)}
   288→
   289→Focus on implementing this feature completely before moving on.
   290→Use the feature MCP tools to mark this feature as passing when done.
   291→"""
   292→
   293→        return base_prompt + "\n\n" + feature_context
   294→
   295→    async def run_parallel(
   296→        self,
   297→        max_iterations: Optional[int] = None,
   298→    ) -> ParallelRunResult:
   299→        """
   300→        Run the parallel agent loop.

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

"""
Parallel Agent Runner
=====================

Run multiple coding agents in parallel on non-conflicting features.
Uses the FeatureGraph to determine which features can be parallelized
and FileLockManager to prevent concurrent file modifications.
"""

import asyncio
import io
import sys
import logging
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Optional, Callable, Any

# Fix Windows console encoding
if sys.platform == "win32":
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding='utf-8', errors='replace')

from claude_agent_sdk import ClaudeSDKClient

from client import create_client
from progress import has_features, print_progress_summary, get_feature_stats
from prompts import get_coding_prompt, get_coding_prompt_yolo, copy_spec_to_project, get_initializer_prompt

# Import server services
import sys as _sys
_root = Path(__file__).parent
if str(_root / "server") not in _sys.path:
    _sys.path.insert(0, str(_root))

from server.services.feature_graph import FeatureGraph, ParallelFeature
from server.services.file_lock import FileLockManager, get_lock_manager
from server.agents.git_agent import GitAgent, GitAgentConfig

logger = logging.getLogger(__name__)

# Configuration
DEFAULT_MAX_PARALLEL = 3
AUTO_CONTINUE_DELAY_SECONDS = 3

@dataclass
class AgentResult:
    """Result of a single agent run."""
    feature_id: int
    feature_name: str
    success: bool
    error: Optional[str] = None
    files_touched: list[str] = field(default_factory=list)
    duration_seconds: float = 0.0

@dataclass
class ParallelRunResult:
    """Result of a parallel agent run."""
    total_features: int
    completed: int
    failed: int
    results: list[AgentResult] = field(default_factory=list)
    duration_seconds: float = 0.0

class ParallelAgentRunner:
    """
    Orchestrate multiple coding agents running in parallel.

    This class:
    1. Uses FeatureGraph to find parallelizable features
    2. Manages file locks to prevent conflicts
    3. Coordinates with GitAgent for commits
    4. Tracks progress and handles failures
    """

    def __init__(
        self,
        project_dir: Path,
        model: str = "claude-sonnet-4-20250514",
        max_parallel: int = DEFAULT_MAX_PARALLEL,
        yolo_mode: bool = False,
        on_feature_start: Optional[Callable[[int, str], Any]] = None,
        on_feature_complete: Optional[Callable[[AgentResult], Any]] = None,
        on_progress: Optional[Callable[[int, int, int], Any]] = None,
    ):
        """
        Initialize the parallel agent runner.

        Args:
            project_dir: Path to the project directory
            model: Claude model to use
            max_parallel: Maximum number of parallel agents
            yolo_mode: If True, skip browser testing
            on_feature_start: Callback when a feature starts (id, name)
            on_feature_complete: Callback when a feature completes
            on_progress: Callback for progress updates (completed, failed, total)
        """
        self.project_dir = project_dir
        self.model = model
        self.max_parallel = max_parallel
        self.yolo_mode = yolo_mode

        # Callbacks
        self._on_feature_start = on_feature_start
        self._on_feature_complete = on_feature_complete
        self._on_progress = on_progress

        # Services
        self.feature_graph = FeatureGraph(project_dir)
        self.lock_manager = FileLockManager(project_dir)
        self.git_agent = GitAgent(
            project_dir,
            GitAgentConfig(
                auto_commit=True,
                auto_push=False,  # Don't auto-push, let user control
                commit_style="conventional",
            ),
        )

        # State
        self._running = False
        self._active_agents: dict[int, asyncio.Task] = {}
        self._results: list[AgentResult] = []

    async def run_single_feature(
        self,
        feature: ParallelFeature,
        agent_id: str,
    ) -> AgentResult:
        """
        Run a single feature with its own agent instance.

        Args:
            feature: The feature to implement
            agent_id: Unique identifier for this agent instance

        Returns:
            AgentResult with success/failure info
        """
        start_time = datetime.now()
        files_touched: list[str] = []

        logger.info(f"[Agent {agent_id}] Starting feature {feature.id}: {feature.name}")

        if self._on_feature_start:
            self._on_feature_start(feature.id, feature.name)

        try:
            # Predict and lock files this feature might touch
            predicted_files = self.feature_graph.predict_files_for_feature(
                feature.id,
                feature.category,
                feature.name,
            )

            if predicted_files:
                lock_result = await self.lock_manager.acquire_multiple(
                    predicted_files,
                    feature.id,
                    agent_id,
                )
                if not lock_result.success:
                    logger.warning(
                        f"[Agent {agent_id}] Could not acquire locks: {lock_result.message}"
                    )
                    # Continue anyway - locks are advisory

            # Create client for this agent
            client = create_client(
                self.project_dir,
                self.model,
                yolo_mode=self.yolo_mode,
            )

            # Generate prompt for this specific feature
            prompt = self._generate_feature_prompt(feature)

            # Run the agent session
            async with client:
                await client.query(prompt)

                async for msg in client.receive_response():
                    msg_type = type(msg).__name__

                    if msg_type == "AssistantMessage" and hasattr(msg, "content"):
                        for block in msg.content:
                            block_type = type(block).__name__

                            if block_type == "TextBlock" and hasattr(block, "text"):
                                # Log output (truncated)
                                text = block.text[:200] + "..." if len(block.text) > 200 else block.text
                                logger.debug(f"[Agent {agent_id}] {text}")

                            elif block_type == "ToolUseBlock" and hasattr(block, "name"):
                                tool_name = block.name
                                logger.info(f"[Agent {agent_id}] [Tool: {tool_name}]")

                                # Track file operations
                                if tool_name in ("Write", "Edit", "Read") and hasattr(block, "input"):
                                    file_path = block.input.get("file_path", "")
                                    if file_path:
                                        files_touched.append(file_path)

            # Record files touched
            if files_touched:
                self.feature_graph.record_files_touched(feature.id, files_touched)

                # Track changes for git
                for file in files_touched:
                    await self.git_agent.track_change(
                        file=file,
                        agent_id=agent_id,
                        feature_id=feature.id,
                        change_type="modified",
                    )

            duration = (datetime.now() - start_time).total_seconds()

            result = AgentResult(
                feature_id=feature.id,
                feature_name=feature.name,
                success=True,
                files_touched=files_touched,
                duration_seconds=duration,
            )

            logger.info(
                f"[Agent {agent_id}] Completed feature {feature.id} "
                f"in {duration:.1f}s ({len(files_touched)} files)"
            )

            return result

        except Exception as e:
            duration = (datetime.now() - start_time).total_seconds()
            logger.error(f"[Agent {agent_id}] Error on feature {feature.id}: {e}")

            return AgentResult(
                feature_id=feature.id,
                feature_name=feature.name,
                success=False,
                error=str(e),
                files_touched=files_touched,
                duration_seconds=duration,
            )

        finally:
            # Release locks
            await self.lock_manager.release_all(feature.id)

            # Notify git agent of feature completion
            await self.git_agent.on_feature_complete(feature.id, feature.name)

    def _generate_feature_prompt(self, feature: ParallelFeature) -> str:
        """
        Generate a prompt for implementing a specific feature.

        Args:
            feature: The feature to implement

        Returns:
            Prompt string
        """
        # Start with the base coding prompt
        if self.yolo_mode:
            base_prompt = get_coding_prompt_yolo(self.project_dir)
        else:
            base_prompt = get_coding_prompt(self.project_dir)

        # Add feature-specific context
        feature_context = f"""
## Current Feature Assignment

You are specifically working on this feature:

**Feature #{feature.id}**: {feature.name}
**Category**: {feature.category}
**Priority**: {feature.priority}

**Description**:
{feature.description}

**Steps**:
{chr(10).join(f"- {step}" for step in feature.steps)}

Focus on implementing this feature completely before moving on.
Use the feature MCP tools to mark this feature as passing when done.
"""

        return base_prompt + "\n\n" + feature_context

    async def run_parallel(
        self,
        max_iterations: Optional[int] = None,
    ) -> ParallelRunResult:
        """
        Run the parallel agent loop.


Let me also check the config file to see git integration settings:


     1→"""
     2→Autocoder Configuration
     3→=======================
     4→
     5→Centralized configuration management for Autocoder 2.0.
     6→Loads settings from ~/.autocoder/config.json with sensible defaults.
     7→"""
     8→
     9→import json
    10→import logging
    11→from dataclasses import dataclass, field, asdict
    12→from pathlib import Path
    13→from typing import Optional
    14→
    15→logger = logging.getLogger(__name__)
    16→
    17→# Default config location
    18→CONFIG_DIR = Path.home() / ".autocoder"
    19→CONFIG_FILE = CONFIG_DIR / "config.json"
    20→
    21→
    22→@dataclass
    23→class GitAgentConfig:
    24→    """Git agent configuration."""
    25→    auto_commit: bool = True
    26→    auto_push: bool = False
    27→    commit_style: str = "conventional"  # "conventional", "simple", "detailed"
    28→    push_on: list[str] = field(default_factory=lambda: ["feature_complete", "session_end"])
    29→    pull_before: list[str] = field(default_factory=lambda: ["code_review", "feature_start"])
    30→
    31→
    32→@dataclass
    33→class ParallelConfig:
    34→    """Parallel agent configuration."""
    35→    enabled: bool = True
    36→    max_agents: int = 3
    37→    lock_timeout_seconds: int = 3600
    38→
    39→
    40→@dataclass
    41→class SecurityConfig:
    42→    """Security configuration."""
    43→    sandbox_enabled: bool = False  # Disabled for full system access
    44→    allowed_hosts: list[str] = field(default_factory=list)  # Empty = all allowed
    45→
    46→
    47→@dataclass
    48→class PathsConfig:
    49→    """Path configuration."""
    50→    credentials_path: str = "~/claude-projects/shared-data/credentials.md"
    51→    global_resources_path: str = "~/.autocoder/global_resources.json"
    52→    session_logs_path: str = "~/claude-projects/session-logs"
    53→
    54→
    55→@dataclass
    56→class UIConfig:
    57→    """UI configuration."""
    58→    theme: str = "neobrutalism"
    59→    show_agent_thoughts: bool = True
    60→    sound_enabled: bool = True
    61→    celebration_enabled: bool = True
    62→
    63→
    64→@dataclass
    65→class AutocoderConfig:
    66→    """Main Autocoder configuration."""
    67→    version: str = "2.0.0"
    68→    default_model: str = "claude-sonnet-4-20250514"
    69→    git_agent: GitAgentConfig = field(default_factory=GitAgentConfig)
    70→    parallel: ParallelConfig = field(default_factory=ParallelConfig)
    71→    security: SecurityConfig = field(default_factory=SecurityConfig)
    72→    paths: PathsConfig = field(default_factory=PathsConfig)
    73→    ui: UIConfig = field(default_factory=UIConfig)
    74→
    75→    def to_dict(self) -> dict:
    76→        """Convert to dictionary for JSON serialization."""
    77→        return {
    78→            "version": self.version,
    79→            "default_model": self.default_model,
    80→            "git_agent": asdict(self.git_agent),
    81→            "parallel": asdict(self.parallel),
    82→            "security": asdict(self.security),
    83→            "paths": asdict(self.paths),
    84→            "ui": asdict(self.ui),
    85→        }
    86→
    87→    @classmethod
    88→    def from_dict(cls, data: dict) -> "AutocoderConfig":
    89→        """Create from dictionary."""
    90→        return cls(
    91→            version=data.get("version", "2.0.0"),
    92→            default_model=data.get("default_model", "claude-sonnet-4-20250514"),
    93→            git_agent=GitAgentConfig(**data.get("git_agent", {})),
    94→            parallel=ParallelConfig(**data.get("parallel", {})),
    95→            security=SecurityConfig(**data.get("security", {})),
    96→            paths=PathsConfig(**data.get("paths", {})),
    97→            ui=UIConfig(**data.get("ui", {})),
    98→        )
    99→
   100→
   101→# Global config instance
   102→_config: Optional[AutocoderConfig] = None
   103→
   104→
   105→def get_config() -> AutocoderConfig:
   106→    """
   107→    Get the global configuration instance.
   108→
   109→    Loads from ~/.autocoder/config.json if it exists,
   110→    otherwise returns defaults.
   111→
   112→    Returns:
   113→        AutocoderConfig instance
   114→    """
   115→    global _config
   116→
   117→    if _config is not None:
   118→        return _config
   119→
   120→    _config = load_config()
   121→    return _config
   122→
   123→
   124→def load_config(path: Optional[Path] = None) -> AutocoderConfig:
   125→    """
   126→    Load configuration from file.
   127→
   128→    Args:
   129→        path: Optional path to config file (defaults to ~/.autocoder/config.json)
   130→
   131→    Returns:
   132→        AutocoderConfig instance
   133→    """
   134→    config_path = path or CONFIG_FILE
   135→
   136→    if config_path.exists():
   137→        try:
   138→            with open(config_path, "r", encoding="utf-8") as f:
   139→                data = json.load(f)
   140→            logger.info(f"Loaded config from {config_path}")
   141→            return AutocoderConfig.from_dict(data)
   142→        except Exception as e:
   143→            logger.warning(f"Failed to load config from {config_path}: {e}")
   144→            logger.info("Using default configuration")
   145→
   146→    return AutocoderConfig()
   147→
   148→
   149→def save_config(config: Optional[AutocoderConfig] = None, path: Optional[Path] = None) -> None:
   150→    """
   151→    Save configuration to file.
   152→
   153→    Args:
   154→        config: Config to save (defaults to global config)
   155→        path: Path to save to (defaults to ~/.autocoder/config.json)
   156→    """
   157→    config = config or get_config()
   158→    config_path = path or CONFIG_FILE
   159→
   160→    # Ensure directory exists
   161→    config_path.parent.mkdir(parents=True, exist_ok=True)
   162→
   163→    with open(config_path, "w", encoding="utf-8") as f:
   164→        json.dump(config.to_dict(), f, indent=2)
   165→
   166→    logger.info(f"Saved config to {config_path}")
   167→
   168→
   169→def reset_config() -> AutocoderConfig:
   170→    """
   171→    Reset configuration to defaults.
   172→
   173→    Returns:
   174→        New default AutocoderConfig
   175→    """
   176→    global _config
   177→    _config = AutocoderConfig()
   178→    return _config
   179→
   180→
   181→def update_config(**kwargs) -> AutocoderConfig:
   182→    """
   183→    Update configuration with new values.
   184→
   185→    Args:
   186→        **kwargs: Configuration values to update
   187→
   188→    Returns:
   189→        Updated AutocoderConfig
   190→    """
   191→    config = get_config()
   192→
   193→    for key, value in kwargs.items():
   194→        if hasattr(config, key):
   195→            setattr(config, key, value)
   196→
   197→    return config
   198→
   199→
   200→def ensure_config_dir() -> Path:
   201→    """
   202→    Ensure the config directory exists.
   203→
   204→    Returns:
   205→        Path to config directory
   206→    """
   207→    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
   208→    return CONFIG_DIR
   209→
   210→
   211→def create_default_config() -> None:
   212→    """Create default configuration file if it doesn't exist."""
   213→    if not CONFIG_FILE.exists():
   214→        ensure_config_dir()
   215→        save_config(AutocoderConfig())
   216→        logger.info(f"Created default config at {CONFIG_FILE}")
   217→
   218→
   219→# Initialize config directory on import
   220→ensure_config_dir()
   221→

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

"""
Autocoder Configuration
=======================

Centralized configuration management for Autocoder 2.0.
Loads settings from ~/.autocoder/config.json with sensible defaults.
"""

import json
import logging
from dataclasses import dataclass, field, asdict
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

# Default config location
CONFIG_DIR = Path.home() / ".autocoder"
CONFIG_FILE = CONFIG_DIR / "config.json"

@dataclass
class GitAgentConfig:
    """Git agent configuration."""
    auto_commit: bool = True
    auto_push: bool = False
    commit_style: str = "conventional"  # "conventional", "simple", "detailed"
    push_on: list[str] = field(default_factory=lambda: ["feature_complete", "session_end"])
    pull_before: list[str] = field(default_factory=lambda: ["code_review", "feature_start"])

@dataclass
class ParallelConfig:
    """Parallel agent configuration."""
    enabled: bool = True
    max_agents: int = 3
    lock_timeout_seconds: int = 3600

@dataclass
class SecurityConfig:
    """Security configuration."""
    sandbox_enabled: bool = False  # Disabled for full system access
    allowed_hosts: list[str] = field(default_factory=list)  # Empty = all allowed

@dataclass
class PathsConfig:
    """Path configuration."""
    credentials_path: str = "~/claude-projects/shared-data/credentials.md"
    global_resources_path: str = "~/.autocoder/global_resources.json"
    session_logs_path: str = "~/claude-projects/session-logs"

@dataclass
class UIConfig:
    """UI configuration."""
    theme: str = "neobrutalism"
    show_agent_thoughts: bool = True
    sound_enabled: bool = True
    celebration_enabled: bool = True

@dataclass
class AutocoderConfig:
    """Main Autocoder configuration."""
    version: str = "2.0.0"
    default_model: str = "claude-sonnet-4-20250514"
    git_agent: GitAgentConfig = field(default_factory=GitAgentConfig)
    parallel: ParallelConfig = field(default_factory=ParallelConfig)
    security: SecurityConfig = field(default_factory=SecurityConfig)
    paths: PathsConfig = field(default_factory=PathsConfig)
    ui: UIConfig = field(default_factory=UIConfig)

    def to_dict(self) -> dict:
        """Convert to dictionary for JSON serialization."""
        return {
            "version": self.version,
            "default_model": self.default_model,
            "git_agent": asdict(self.git_agent),
            "parallel": asdict(self.parallel),
            "security": asdict(self.security),
            "paths": asdict(self.paths),
            "ui": asdict(self.ui),
        }

    @classmethod
    def from_dict(cls, data: dict) -> "AutocoderConfig":
        """Create from dictionary."""
        return cls(
            version=data.get("version", "2.0.0"),
            default_model=data.get("default_model", "claude-sonnet-4-20250514"),
            git_agent=GitAgentConfig(**data.get("git_agent", {})),
            parallel=ParallelConfig(**data.get("parallel", {})),
            security=SecurityConfig(**data.get("security", {})),
            paths=PathsConfig(**data.get("paths", {})),
            ui=UIConfig(**data.get("ui", {})),
        )

# Global config instance
_config: Optional[AutocoderConfig] = None

def get_config() -> AutocoderConfig:
    """
    Get the global configuration instance.

    Loads from ~/.autocoder/config.json if it exists,
    otherwise returns defaults.

    Returns:
        AutocoderConfig instance
    """
    global _config

    if _config is not None:
        return _config

    _config = load_config()
    return _config

def load_config(path: Optional[Path] = None) -> AutocoderConfig:
    """
    Load configuration from file.

    Args:
        path: Optional path to config file (defaults to ~/.autocoder/config.json)

    Returns:
        AutocoderConfig instance
    """
    config_path = path or CONFIG_FILE

    if config_path.exists():
        try:
            with open(config_path, "r", encoding="utf-8") as f:
                data = json.load(f)
            logger.info(f"Loaded config from {config_path}")
            return AutocoderConfig.from_dict(data)
        except Exception as e:
            logger.warning(f"Failed to load config from {config_path}: {e}")
            logger.info("Using default configuration")

    return AutocoderConfig()

def save_config(config: Optional[AutocoderConfig] = None, path: Optional[Path] = None) -> None:
    """
    Save configuration to file.

    Args:
        config: Config to save (defaults to global config)
        path: Path to save to (defaults to ~/.autocoder/config.json)
    """
    config = config or get_config()
    config_path = path or CONFIG_FILE

    # Ensure directory exists
    config_path.parent.mkdir(parents=True, exist_ok=True)

    with open(config_path, "w", encoding="utf-8") as f:
        json.dump(config.to_dict(), f, indent=2)

    logger.info(f"Saved config to {config_path}")

def reset_config() -> AutocoderConfig:
    """
    Reset configuration to defaults.

    Returns:
        New default AutocoderConfig
    """
    global _config
    _config = AutocoderConfig()
    return _config

def update_config(**kwargs) -> AutocoderConfig:
    """
    Update configuration with new values.

    Args:
        **kwargs: Configuration values to update

    Returns:
        Updated AutocoderConfig
    """
    config = get_config()

    for key, value in kwargs.items():
        if hasattr(config, key):
            setattr(config, key, value)

    return config

def ensure_config_dir() -> Path:
    """
    Ensure the config directory exists.

    Returns:
        Path to config directory
    """
    CONFIG_DIR.mkdir(parents=True, exist_ok=True)
    return CONFIG_DIR

def create_default_config() -> None:
    """Create default configuration file if it doesn't exist."""
    if not CONFIG_FILE.exists():
        ensure_config_dir()
        save_config(AutocoderConfig())
        logger.info(f"Created default config at {CONFIG_FILE}")

# Initialize config directory on import
ensure_config_dir()


Perfect! Now let me check the server's main architecture file:


Bash

ls -la "/c/Users/MikeSwanson/claude-projects/Autocode-remix/Autocoder-2.0/server/routers/" 2>&1 | head -20


     1→"""
     2→FastAPI Main Application
     3→========================
     4→
     5→Main entry point for the Autonomous Coding UI server.
     6→Provides REST API, WebSocket, and static file serving.
     7→"""
     8→
     9→import shutil
    10→from contextlib import asynccontextmanager
    11→from pathlib import Path
    12→
    13→from fastapi import FastAPI, HTTPException, Request, WebSocket
    14→from fastapi.middleware.cors import CORSMiddleware
    15→from fastapi.responses import FileResponse
    16→from fastapi.staticfiles import StaticFiles
    17→
    18→from .routers import (
    19→    agent_router,
    20→    assistant_chat_router,
    21→    features_router,
    22→    filesystem_router,
    23→    projects_router,
    24→    spec_creation_router,
    25→    terminal_router,
    26→    git_router,
    27→    scheduler_router,
    28→    system_router,
    29→)
    30→from .schemas import SetupStatus
    31→from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
    32→from .services.process_manager import cleanup_all_managers
    33→from .websocket import project_websocket
    34→
    35→# Paths
    36→ROOT_DIR = Path(__file__).parent.parent
    37→UI_DIST_DIR = ROOT_DIR / "ui" / "dist"
    38→
    39→
    40→@asynccontextmanager
    41→async def lifespan(app: FastAPI):
    42→    """Lifespan context manager for startup and shutdown."""
    43→    # Startup
    44→    yield
    45→    # Shutdown - cleanup all running agents and assistant sessions
    46→    await cleanup_all_managers()
    47→    await cleanup_assistant_sessions()
    48→
    49→
    50→# Create FastAPI app
    51→app = FastAPI(
    52→    title="Autonomous Coding UI",
    53→    description="Web UI for the Autonomous Coding Agent",
    54→    version="1.0.0",
    55→    lifespan=lifespan,
    56→)
    57→
    58→# CORS - allow only localhost origins for security
    59→app.add_middleware(
    60→    CORSMiddleware,
    61→    allow_origins=[
    62→        "http://localhost:5173",      # Vite dev server
    63→        "http://127.0.0.1:5173",
    64→        "http://localhost:8888",      # Production
    65→        "http://127.0.0.1:8888",
    66→    ],
    67→    allow_credentials=True,
    68→    allow_methods=["*"],
    69→    allow_headers=["*"],
    70→)
    71→
    72→
    73→# ============================================================================
    74→# Security Middleware
    75→# ============================================================================
    76→
    77→@app.middleware("http")
    78→async def require_localhost(request: Request, call_next):
    79→    """Only allow requests from localhost."""
    80→    client_host = request.client.host if request.client else None
    81→
    82→    # Allow localhost connections
    83→    if client_host not in ("127.0.0.1", "::1", "localhost", None):
    84→        raise HTTPException(status_code=403, detail="Localhost access only")
    85→
    86→    return await call_next(request)
    87→
    88→
    89→# ============================================================================
    90→# Include Routers
    91→# ============================================================================
    92→
    93→app.include_router(projects_router)
    94→app.include_router(features_router)
    95→app.include_router(agent_router)
    96→app.include_router(spec_creation_router)
    97→app.include_router(filesystem_router)
    98→app.include_router(assistant_chat_router)
    99→app.include_router(terminal_router)
   100→app.include_router(git_router)
   101→app.include_router(scheduler_router)
   102→app.include_router(system_router)
   103→
   104→
   105→# ============================================================================
   106→# WebSocket Endpoint
   107→# ============================================================================
   108→
   109→@app.websocket("/ws/projects/{project_name}")
   110→async def websocket_endpoint(websocket: WebSocket, project_name: str):
   111→    """WebSocket endpoint for real-time project updates."""
   112→    await project_websocket(websocket, project_name)
   113→
   114→
   115→# ============================================================================
   116→# Setup & Health Endpoints
   117→# ============================================================================
   118→
   119→@app.get("/api/health")
   120→async def health_check():
   121→    """Health check endpoint."""
   122→    return {"status": "healthy"}
   123→
   124→
   125→@app.get("/api/setup/status", response_model=SetupStatus)
   126→async def setup_status():
   127→    """Check system setup status."""
   128→    # Check for Claude CLI
   129→    claude_cli = shutil.which("claude") is not None
   130→
   131→    # Check for credentials file
   132→    credentials_path = Path.home() / ".claude" / ".credentials.json"
   133→    credentials = credentials_path.exists()
   134→
   135→    # Check for Node.js and npm
   136→    node = shutil.which("node") is not None
   137→    npm = shutil.which("npm") is not None
   138→
   139→    return SetupStatus(
   140→        claude_cli=claude_cli,
   141→        credentials=credentials,
   142→        node=node,
   143→        npm=npm,
   144→    )
   145→
   146→
   147→# ============================================================================
   148→# Static File Serving (Production)
   149→# ============================================================================
   150→

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

"""
FastAPI Main Application
========================

Main entry point for the Autonomous Coding UI server.
Provides REST API, WebSocket, and static file serving.
"""

import shutil
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException, Request, WebSocket
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles

from .routers import (
    agent_router,
    assistant_chat_router,
    features_router,
    filesystem_router,
    projects_router,
    spec_creation_router,
    terminal_router,
    git_router,
    scheduler_router,
    system_router,
)
from .schemas import SetupStatus
from .services.assistant_chat_session import cleanup_all_sessions as cleanup_assistant_sessions
from .services.process_manager import cleanup_all_managers
from .websocket import project_websocket

# Paths
ROOT_DIR = Path(__file__).parent.parent
UI_DIST_DIR = ROOT_DIR / "ui" / "dist"

@asynccontextmanager
async def lifespan(app: FastAPI):
    """Lifespan context manager for startup and shutdown."""
    # Startup
    yield
    # Shutdown - cleanup all running agents and assistant sessions
    await cleanup_all_managers()
    await cleanup_assistant_sessions()

# Create FastAPI app
app = FastAPI(
    title="Autonomous Coding UI",
    description="Web UI for the Autonomous Coding Agent",
    version="1.0.0",
    lifespan=lifespan,
)

# CORS - allow only localhost origins for security
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:5173",      # Vite dev server
        "http://127.0.0.1:5173",
        "http://localhost:8888",      # Production
        "http://127.0.0.1:8888",
    ],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# ============================================================================
# Security Middleware
# ============================================================================

@app.middleware("http")
async def require_localhost(request: Request, call_next):
    """Only allow requests from localhost."""
    client_host = request.client.host if request.client else None

    # Allow localhost connections
    if client_host not in ("127.0.0.1", "::1", "localhost", None):
        raise HTTPException(status_code=403, detail="Localhost access only")

    return await call_next(request)

# ============================================================================
# Include Routers
# ============================================================================

app.include_router(projects_router)
app.include_router(features_router)
app.include_router(agent_router)
app.include_router(spec_creation_router)
app.include_router(filesystem_router)
app.include_router(assistant_chat_router)
app.include_router(terminal_router)
app.include_router(git_router)
app.include_router(scheduler_router)
app.include_router(system_router)

# ============================================================================
# WebSocket Endpoint
# ============================================================================

@app.websocket("/ws/projects/{project_name}")
async def websocket_endpoint(websocket: WebSocket, project_name: str):
    """WebSocket endpoint for real-time project updates."""
    await project_websocket(websocket, project_name)

# ============================================================================
# Setup & Health Endpoints
# ============================================================================

@app.get("/api/health")
async def health_check():
    """Health check endpoint."""
    return {"status": "healthy"}

@app.get("/api/setup/status", response_model=SetupStatus)
async def setup_status():
    """Check system setup status."""
    # Check for Claude CLI
    claude_cli = shutil.which("claude") is not None

    # Check for credentials file
    credentials_path = Path.home() / ".claude" / ".credentials.json"
    credentials = credentials_path.exists()

    # Check for Node.js and npm
    node = shutil.which("node") is not None
    npm = shutil.which("npm") is not None

    return SetupStatus(
        claude_cli=claude_cli,
        credentials=credentials,
        node=node,
        npm=npm,
    )

# ============================================================================
# Static File Serving (Production)
# ============================================================================


total 144
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:57 ./
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:57 ../
-rw-r--r-- 1 AzureAD+MikeSwanson 4096   816 Jan 11 08:57 __init__.py
drwxr-xr-x 1 AzureAD+MikeSwanson 4096     0 Jan 11 08:58 __pycache__/
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  3608 Jan  8 08:02 agent.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 11974 Jan  8 08:02 assistant_chat.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  9684 Jan  8 08:02 features.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 14883 Jan  8 08:02 filesystem.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 15134 Jan 10 12:24 git.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 21495 Jan 10 15:19 projects.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  5911 Jan 10 12:53 scheduler.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 14694 Jan  9 10:29 spec_creation.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096  1240 Jan 11 08:56 system.py
-rw-r--r-- 1 AzureAD+MikeSwanson 4096 13581 Jan 10 12:17 terminal.py


Perfect! Now I have a comprehensive view. Let me create a summary report of my findings:

## COMPREHENSIVE GIT INTEGRATION EXPLORATION REPORT

Based on my thorough analysis of the Autocoder 2.0 codebase, here are the key findings for implementing git integration:

---

### 1. EXISTING GIT USAGE IN THE CODEBASE

**Git is already extensively integrated!** The codebase has:

- **File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\git_operations.py` (575 lines)
  - Full async git wrapper using `asyncio.create_subprocess_exec`
  - Supports: status, diff, log, add, commit, push, pull, fetch, checkout, branch creation, stash, reset
  - Returns structured data: `GitStatus`, `GitCommit`, `GitResult` classes
  - Uses semver approach with hash extraction via regex

- **File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\agents\git_agent.py` (406 lines)
  - Dedicated `GitAgent` class orchestrating version control
  - Tracks changes: `ChangeEntry` records which files changed, by which agent, for which feature
  - **Key methods**:
    - `track_change()`: Called by agents to log file modifications
    - `on_feature_complete()`: Auto-commits when feature passes
    - `on_session_end()`: Commits remaining changes at session end
    - `provide_review_context()`: Pulls latest and provides diffs for code review
    - `handle_conflict()`: Analyzes merge conflicts
  - Configurable: conventional/simple/detailed commit styles
  - Auto-push on: `["feature_complete", "session_end"]`

- **File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\routers\git.py` (490 lines)
  - FastAPI REST endpoints for git operations
  - WebSocket endpoint for real-time git status updates
  - All CRUD operations exposed: `/api/git/projects/{name}/status`, `/commit`, `/push`, `/pull`, etc.

---

### 2. SECURITY MODEL - BASH COMMAND RESTRICTIONS

**File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\security.py` (442 lines)

**Git commands are ALREADY ALLOWED:**
- Line 65: `"git"` is in `ALLOWED_COMMANDS` set
- Line 66: `"gh"` (GitHub CLI) is also allowed

**Security approach:**
- Uses an **allowlist** (whitelist) - only explicitly permitted commands can run
- `extract_commands()` function parses compound commands (pipes, `&&`, `||`, `;`)
- Special validation for sensitive commands: `pkill`, `chmod`, `init.sh`
- Git operations don't require extra validation beyond being in the allowlist

**Integration point**: The `bash_security_hook()` async function validates all Bash tool use before execution

---

### 3. PROJECT STRUCTURE & GIT REPOSITORY LOCATIONS

**Architecture**:
```
Project Directory (git repo root)
├── features.db (SQLite: feature tracking)
├── feature_graph.db (SQLite: feature dependencies & file locks)
├── .agent.lock (process lock file)
├── server/
│   ├── services/
│   │   ├── git_operations.py
│   │   └── feature_graph.py
│   ├── agents/
│   │   └── git_agent.py
│   └── routers/
│       └── git.py
└── [project source files]
```

**Git structure**: Each project is expected to be a git repository. Git operations work on `project_dir` as the working directory:
- File: `git_operations.py`, line 92: `cwd=str(self.project_dir)`
- Projects resolved via registry: `get_project_path(project_name)`

---

### 4. FEATURE COMPLETION HOOKS - NATURAL INTEGRATION POINTS

**Primary integration point**: `on_feature_complete(feature_id: int, feature_name: Optional[str])`

**File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\agents\git_agent.py`, lines 116-178

**Flow**:
1. When feature marked passing (via `feature_mark_passing` MCP tool)
2. `ParallelAgentRunner.run_single_feature()` calls `await self.git_agent.on_feature_complete(feature.id, feature.name)` (line 255)
3. Git agent automatically:
   - Collects all tracked changes for that feature
   - Generates commit message (conventional format: `feat: implement feature #ID`)
   - Stages & commits files
   - Optionally auto-pushes if `"feature_complete" in push_on` (line 170)

**Tracking**: Changes tracked via `track_change()` which populates `change_log: list[ChangeEntry]`
- Each entry: file, agent_id, feature_id, change_type, timestamp, summary

---

### 5. SESSION BOUNDARIES - AUTO-COMMIT POINTS

**Session end hook**: `on_session_end()` method

**File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\agents\git_agent.py`, lines 180-215

**Triggers**:
- Called from `ParallelAgentRunner.run_parallel()` when loop ends (line 255 in parallel_agent.py)
- Creates "WIP" commit message if multiple features in flight
- Auto-pushes if `"session_end" in push_on` config

**Config location**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\config.py`, lines 22-29

```python
@dataclass
class GitAgentConfig:
    auto_commit: bool = True
    auto_push: bool = False
    push_on: list[str] = ["feature_complete", "session_end"]
    pull_before: list[str] = ["code_review", "feature_start"]
```

---

### 6. PROCESS MANAGEMENT - SUBPROCESS EXECUTION MODEL

**File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\process_manager.py` (553 lines)

**Key patterns for git operations integration**:

1. **Async subprocess execution** (lines 89-101 in git_operations.py):
   ```python
   process = await asyncio.create_subprocess_exec(
       *cmd,
       cwd=str(self.project_dir),
       stdout=asyncio.subprocess.PIPE,
       stderr=asyncio.subprocess.PIPE,
   )
   stdout, stderr = await asyncio.wait_for(
       process.communicate(),
       timeout=timeout,
   )
   ```

2. **Output capturing & sanitization** (process_manager.py):
   - Lines 23-37: Sensitive data patterns (API keys, tokens, passwords)
   - Lines 40-44: `sanitize_output()` redacts before logging
   - Line 198: UTF-8 decode with error handling

3. **Callback pattern for real-time updates** (process_manager.py, lines 118-136):
   - Multiple output callbacks registered
   - Status callbacks for state changes
   - Async-safe via `_callbacks_lock`

4. **Cross-platform support**:
   - Uses `psutil` for process management
   - Process tree killing (recursive children)
   - Lock files to prevent duplicate agents

---

### 7. NATURAL INTEGRATION PATTERNS ALREADY ESTABLISHED

#### A. Git Agent Initialization
**File**: `parallel_agent.py`, lines 114-121
```python
self.git_agent = GitAgent(
    project_dir,
    GitAgentConfig(
        auto_commit=True,
        auto_push=False,
        commit_style="conventional",
    ),
)
```

#### B. Change Tracking During Coding
**File**: `parallel_agent.py`, lines 213-218
```python
for file in files_touched:
    await self.git_agent.track_change(
        file=file,
        agent_id=agent_id,
        feature_id=feature.id,
        change_type="modified",
    )
```

#### C. Feature Completion Hook
**File**: `parallel_agent.py`, line 255
```python
await self.git_agent.on_feature_complete(feature.id, feature.name)
```

#### D. Session End Hook
**File**: `parallel_agent.py`, line 256
```python
await self.git_agent.on_session_end()
```

#### E. REST API Endpoints
- `GET /api/git/projects/{project_name}/status` - repo status
- `POST /api/git/projects/{project_name}/commit` - create commit
- `POST /api/git/projects/{project_name}/push` - push changes
- `WebSocket /api/git/projects/{project_name}/ws` - real-time updates

---

### 8. FEATURE GRAPH & FILE LOCKING

**File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\server\services\feature_graph.py` (493 lines)

Tracks:
- Files touched by each feature (prevents parallel conflicts)
- Feature dependencies
- Complexity estimates
- Current file locks by feature_id

**Integration with git**: The GitAgent can access file conflict info via:
- `handle_conflict()` method detects merge markers
- `_can_auto_resolve()` checks for lock files, .mod, .sum files

---

### 9. MCP TOOL - FEATURE COMPLETION TRIGGER

**File**: `C:\Users\MikeSwanson\claude-projects\Autocode-remix\Autocoder-2.0\mcp_server\feature_mcp.py`

**The MCP tool `feature_mark_passing` is the event trigger for git commits:**
- Called by coding agents when feature complete
- Updates database: `Feature.passes = 1`
- **This is where git integration should be triggered**

---

## RECOMMENDED GIT INTEGRATION POINTS

### Priority 1 (Already Implemented)
1. ✅ Git command allowlist in security.py
2. ✅ GitOperations async wrapper
3. ✅ GitAgent orchestration
4. ✅ REST API endpoints
5. ✅ WebSocket real-time updates

### Priority 2 (Ready to Use)
1. Feature completion auto-commit workflow
2. Session end auto-commit
3. Change tracking via agents
4. Conflict detection and analysis

### Priority 3 (Future Enhancements)
1. Branch creation per feature
2. PR creation via GitHub API (gh CLI already allowed)
3. Automatic code review pulls
4. Merge conflict auto-resolution for lock files

---

## KEY FILES FOR INTEGRATION

| File | Purpose | Key Classes |
|------|---------|-------------|
| git_operations.py | Low-level git commands | `GitOperations`, `GitStatus`, `GitResult` |
| git_agent.py | Orchestration | `GitAgent`, `ChangeEntry`, `GitAgentConfig` |
| git.py | REST API | FastAPI routers with Pydantic models |
| security.py | Command validation | `bash_security_hook`, `ALLOWED_COMMANDS` |
| parallel_agent.py | Integration point | Calls git_agent methods on completion |
| process_manager.py | Process lifecycle | Subprocess execution, output sanitization |
| config.py | Configuration | `GitAgentConfig` with push_on/pull_before |
| feature_graph.py | File conflict tracking | File locks, dependencies |

---

## SUMMARY

The Autocoder 2.0 codebase has **comprehensive git integration already built-in**. The architecture naturally supports:

1. **Automatic commits** on feature completion
2. **Session-end commits** for work-in-progress
3. **Change tracking** by agent and feature
4. **Real-time git status** via WebSocket
5. **Conflict detection** and analysis
6. **Configurable push strategy** (auto-push on feature_complete/session_end)
7. **Security-validated** git command execution
8. **File locking** to prevent parallel conflicts

The main integration points are:
- **Git operations**: Async subprocess execution with output capturing
- **Feature completion**: Auto-commit hook in `on_feature_complete()`
- **Session boundaries**: Auto-commit on `on_session_end()`
- **Security**: Git already in allowlist, no special privileges needed
- **REST API**: Full CRUD operations exposed via FastAPI

This is a production-ready, well-architected implementation that requires minimal additional work to achieve the project's git workflow goals.