#!/usr/bin/env python3
"""
Loki Mode MCP Server

Exposes Loki Mode capabilities via Model Context Protocol:
- Task queue management
- Memory retrieval
- State management
- Metrics tracking

Uses StateManager for centralized state access with caching.

Usage:
    python -m mcp.server                    # STDIO mode (default)
    python -m mcp.server --transport http   # HTTP mode
"""

import asyncio
import sys
import os
import json
import logging
import threading
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional, List, Dict, Any

# Add parent directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

# Import event bus for tool call events
try:
    from events.bus import EventBus, EventType, EventSource, LokiEvent
    EVENT_BUS_AVAILABLE = True
except ImportError:
    EVENT_BUS_AVAILABLE = False

# Import learning collector for cross-tool learning
try:
    from mcp.learning_collector import get_mcp_learning_collector, MCPLearningCollector
    LEARNING_COLLECTOR_AVAILABLE = True
except ImportError:
    LEARNING_COLLECTOR_AVAILABLE = False
    get_mcp_learning_collector = None
    MCPLearningCollector = None

# Import StateManager for centralized state access
try:
    from state.manager import StateManager, ManagedFile, get_state_manager
    STATE_MANAGER_AVAILABLE = True
except ImportError:
    STATE_MANAGER_AVAILABLE = False
    StateManager = None
    ManagedFile = None
    get_state_manager = None


# Module-level StateManager instance
_state_manager = None

# Module-level LearningCollector instance
_learning_collector = None


def _get_learning_collector():
    """Get or create the LearningCollector instance for MCP server.

    Revalidates the cached collector against the current cwd-derived .loki dir
    (mirrors the StateManager realpath-compare-recreate guard in
    _get_mcp_state_manager). A multi-project MCP session can change cwd between
    calls; without this, the cached collector stays bound to a PREVIOUS
    project's .loki dir and contaminates one project's learnings into another.
    """
    global _learning_collector
    if not LEARNING_COLLECTOR_AVAILABLE:
        return None
    from pathlib import Path
    loki_dir = Path(os.getcwd()) / '.loki'
    if _learning_collector is not None:
        existing_dir = getattr(_learning_collector, 'loki_dir', None)
        if existing_dir and os.path.realpath(str(existing_dir)) != os.path.realpath(str(loki_dir)):
            # Project directory changed, recreate
            close = getattr(_learning_collector, 'close', None)
            if callable(close):
                close()
            _learning_collector = None
    if _learning_collector is None:
        _learning_collector = get_mcp_learning_collector(loki_dir=loki_dir)
    return _learning_collector


def _get_mcp_state_manager():
    """Get or create the StateManager instance for MCP server.

    BUG-PU-002: Recreates the StateManager if the underlying .loki directory
    has disappeared (e.g., project changed) to prevent stale file handle errors.
    """
    global _state_manager
    if not STATE_MANAGER_AVAILABLE:
        return None
    loki_dir = os.path.join(os.getcwd(), '.loki')
    if _state_manager is not None:
        # Verify the state manager's directory still matches cwd
        existing_dir = getattr(_state_manager, 'loki_dir', None) or \
                       getattr(_state_manager, '_loki_dir', None)
        if existing_dir and os.path.realpath(existing_dir) != os.path.realpath(loki_dir):
            # Project directory changed, recreate
            if hasattr(_state_manager, 'close'):
                _state_manager.close()
            _state_manager = None
    if _state_manager is None:
        _state_manager = get_state_manager(
            loki_dir=loki_dir,
            enable_watch=False,  # MCP server doesn't need file watching
            enable_events=False
        )
    return _state_manager


def cleanup_mcp_singletons():
    """Clean up module-level singletons to prevent resource leaks on restart.

    Call this before restarting the MCP server to release file handles
    held by the StateManager and LearningCollector instances.
    """
    global _state_manager, _learning_collector
    if _state_manager is not None:
        if hasattr(_state_manager, 'close'):
            _state_manager.close()
        _state_manager = None
    if _learning_collector is not None:
        if hasattr(_learning_collector, 'close'):
            _learning_collector.close()
        _learning_collector = None


# ============================================================
# PATH SECURITY - Prevent path traversal attacks
# ============================================================

# Allowed base directories relative to project root
ALLOWED_BASE_DIRS = ['.loki', 'memory']


class PathTraversalError(Exception):
    """Raised when a path traversal attempt is detected"""
    pass


def get_project_root() -> str:
    """Get the project root directory (current working directory)"""
    return os.path.realpath(os.getcwd())


def validate_path(path: str, allowed_dirs: List[str] = None) -> str:
    """
    Validate that a path is within allowed directories.

    Args:
        path: The path to validate (can be relative or absolute)
        allowed_dirs: List of allowed base directories relative to project root.
                      Defaults to ALLOWED_BASE_DIRS.

    Returns:
        The canonicalized absolute path if valid

    Raises:
        PathTraversalError: If the path attempts to escape allowed directories
    """
    if allowed_dirs is None:
        allowed_dirs = ALLOWED_BASE_DIRS

    project_root = get_project_root()

    # Build absolute path without resolving symlinks first
    if os.path.isabs(path):
        abs_path = path
    else:
        abs_path = os.path.join(project_root, path)

    # Walk each component to detect symlink chains escaping allowed dirs
    # This prevents symlinks that hop through directories outside the project
    parts = os.path.normpath(abs_path).split(os.sep)
    current = os.sep if abs_path.startswith(os.sep) else ''
    for part in parts:
        if not part:
            continue
        current = os.path.join(current, part)
        if os.path.islink(current):
            link_target = os.path.realpath(current)
            # Each symlink target must resolve within the project root
            if not link_target.startswith(project_root + os.sep) and link_target != project_root:
                raise PathTraversalError(
                    f"Access denied: Symlink '{current}' escapes project root "
                    f"(target: '{link_target}')"
                )

    # Resolve to absolute path, following all symlinks for final check
    resolved_path = os.path.realpath(abs_path)

    # Check if path is within any of the allowed directories
    for allowed_dir in allowed_dirs:
        allowed_base = os.path.realpath(os.path.join(project_root, allowed_dir))

        # Ensure allowed base ends with separator for proper prefix matching
        if not allowed_base.endswith(os.sep):
            allowed_base_check = allowed_base + os.sep
        else:
            allowed_base_check = allowed_base

        # Check if resolved path is the allowed base or a subdirectory of it
        if resolved_path == allowed_base or resolved_path.startswith(allowed_base_check):
            return resolved_path

    # Path is not within allowed directories
    raise PathTraversalError(
        f"Access denied: Path '{path}' resolves outside allowed directories. "
        f"Allowed: {', '.join(allowed_dirs)}"
    )


def safe_path_join(base_dir: str, *paths: str) -> str:
    """
    Safely join paths and validate the result is within allowed directories.

    Args:
        base_dir: Base directory (should be one of the allowed dirs)
        *paths: Additional path components to join

    Returns:
        The validated absolute path

    Raises:
        PathTraversalError: If the resulting path escapes allowed directories
    """
    project_root = get_project_root()

    # Build the full path
    full_path = os.path.join(project_root, base_dir, *paths)

    # Validate it stays within allowed directories
    return validate_path(full_path)


def safe_open(path: str, mode: str = 'r', allowed_dirs: List[str] = None, encoding: str = 'utf-8'):
    """
    Safely open a file after validating the path.

    Args:
        path: Path to the file
        mode: File open mode
        allowed_dirs: Allowed directories (defaults to ALLOWED_BASE_DIRS)
        encoding: File encoding (default: utf-8, ignored for binary modes)

    Returns:
        File handle

    Raises:
        PathTraversalError: If path escapes allowed directories
    """
    validated_path = validate_path(path, allowed_dirs)
    # Only pass encoding for text modes, not binary modes
    if 'b' in mode:
        return open(validated_path, mode)
    return open(validated_path, mode, encoding=encoding)


def safe_makedirs(path: str, exist_ok: bool = True, allowed_dirs: List[str] = None):
    """
    Safely create directories after validating the path.

    Args:
        path: Path to create
        exist_ok: If True, don't raise error if directory exists
        allowed_dirs: Allowed directories (defaults to ALLOWED_BASE_DIRS)

    Raises:
        PathTraversalError: If path escapes allowed directories
    """
    validated_path = validate_path(path, allowed_dirs)
    os.makedirs(validated_path, exist_ok=exist_ok)


def safe_exists(path: str, allowed_dirs: List[str] = None) -> bool:
    """
    Safely check if a path exists after validating it.

    Args:
        path: Path to check
        allowed_dirs: Allowed directories (defaults to ALLOWED_BASE_DIRS)

    Returns:
        True if path exists and is within allowed directories, False otherwise
    """
    try:
        validated_path = validate_path(path, allowed_dirs)
        return os.path.exists(validated_path)
    except PathTraversalError:
        return False


# Configure logging to stderr (critical for STDIO transport)
# Must be configured before using logger in event emission
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    stream=sys.stderr
)
logger = logging.getLogger('loki-mcp')


# ============================================================
# EVENT EMISSION - Non-blocking tool call events
# ============================================================

# Track tool call start times for duration calculation (per-tool stack)
_tool_call_start_times: Dict[str, List[float]] = {}
_tool_call_times_lock = threading.Lock()


def _emit_tool_event_async(tool_name: str, action: str, **kwargs) -> None:
    """
    Emit a tool event asynchronously (non-blocking).

    Args:
        tool_name: Name of the MCP tool being called
        action: 'start' or 'complete'
        **kwargs: Additional payload fields (parameters, result_status, error)
    """
    import time

    # Track timing for learning signals using a per-tool-name stack (thread-safe)
    if action == 'start':
        with _tool_call_times_lock:
            _tool_call_start_times.setdefault(tool_name, []).append(time.time())
    elif action == 'complete':
        # Pop the most recent start time for this tool
        start_time = None
        with _tool_call_times_lock:
            times = _tool_call_start_times.get(tool_name)
            if times:
                start_time = times.pop()
        if start_time:
            execution_time_ms = int((time.time() - start_time) * 1000)
            _emit_learning_signal_async(
                tool_name=tool_name,
                execution_time_ms=execution_time_ms,
                result_status=kwargs.get('result_status', 'unknown'),
                error=kwargs.get('error'),
                parameters=kwargs.get('parameters', {})
            )

    if not EVENT_BUS_AVAILABLE:
        return

    def emit():
        try:
            bus = EventBus()
            payload = {
                'action': action,
                'tool_name': tool_name,
                **kwargs
            }
            event = LokiEvent(
                type=EventType.COMMAND,
                source=EventSource.MCP,
                payload=payload
            )
            bus.emit(event)
        except Exception as e:
            # Never block the tool call for event emission failures
            logger.debug(f"Event emission failed (non-fatal): {e}")

    # Run in background thread to not block the tool call
    thread = threading.Thread(target=emit, daemon=True)
    thread.start()


def _emit_learning_signal_async(
    tool_name: str,
    execution_time_ms: int,
    result_status: str,
    error: Optional[str] = None,
    parameters: Optional[Dict[str, Any]] = None
) -> None:
    """
    Emit a learning signal asynchronously (non-blocking).

    Emits ToolEfficiencySignal on every call, and ErrorPatternSignal on failures.

    Args:
        tool_name: Name of the MCP tool
        execution_time_ms: Execution time in milliseconds
        result_status: 'success' or 'error'
        error: Error message if failed
        parameters: Tool parameters for context
    """
    if not LEARNING_COLLECTOR_AVAILABLE:
        return

    def emit():
        try:
            collector = _get_learning_collector()
            if not collector:
                return

            success = result_status == 'success'

            # Emit tool efficiency signal
            collector.emit_tool_efficiency(
                tool_name=tool_name,
                action=f"mcp_tool_call",
                execution_time_ms=execution_time_ms,
                success=success,
                context={'parameters': parameters or {}},
            )

            # Emit error pattern if failed
            if not success and error:
                collector.emit_error_pattern(
                    tool_name=tool_name,
                    action=f"mcp_tool_call",
                    error_type='MCPToolError',
                    error_message=error,
                    context={'parameters': parameters or {}},
                )

            # Emit success pattern for successful calls
            if success:
                collector.emit_success_pattern(
                    tool_name=tool_name,
                    action=f"mcp_tool_call",
                    pattern_name=f"mcp_{tool_name}_success",
                    duration_seconds=execution_time_ms / 1000,
                    context={'parameters': parameters or {}},
                )

        except Exception as e:
            # Never block the tool call for learning signal emission failures
            logger.debug(f"Learning signal emission failed (non-fatal): {e}")

    # Run in background thread to not block the tool call
    thread = threading.Thread(target=emit, daemon=True)
    thread.start()


def _emit_context_relevance_signal(
    tool_name: str,
    query: str,
    retrieved_ids: List[str],
    context: Optional[Dict[str, Any]] = None
) -> None:
    """
    Emit a context relevance learning signal for memory/resource access.

    Args:
        tool_name: Name of the MCP tool
        query: The query used for retrieval
        retrieved_ids: IDs of retrieved items
        context: Additional context
    """
    if not LEARNING_COLLECTOR_AVAILABLE:
        return

    def emit():
        try:
            collector = _get_learning_collector()
            if not collector:
                return

            collector.emit_context_relevance(
                tool_name=tool_name,
                action='memory_retrieval',
                query=query,
                retrieved_ids=retrieved_ids,
                context=context or {},
            )
        except Exception as e:
            logger.debug(f"Context relevance signal emission failed (non-fatal): {e}")

    thread = threading.Thread(target=emit, daemon=True)
    thread.start()


# ============================================================
# Loading the pip MCP SDK's FastMCP under a NAMESPACE COLLISION
# ============================================================
#
# Root cause (task 562): this repo ships a local package named `mcp/`
# (this very file is mcp/server.py). That local package SHADOWS the
# pip-installed MCP SDK, which is also named `mcp`. The two cannot both
# own the top-level `mcp` name in one interpreter.
#
# The FastMCP loader that resolves this collision used to live inline here.
# Task 566 extracted it VERBATIM into mcp/_sdk_loader.py so the LSP proxy
# (mcp/lsp_proxy.py) can load FastMCP through the SAME battle-tested path
# instead of its old importlib.util shim, which silently degraded to a
# no-op under the MCP SDK 1.x package-directory layout. The shared module
# carries the full root-cause writeup; behavior here is unchanged (the
# three helpers are re-exported below under their original names so any
# existing `mcp.server._mcp_sdk_present` / `_load_real_fastmcp` reference
# keeps resolving).
from mcp._sdk_loader import (  # noqa: E402
    _real_mcp_search_dirs,
    _mcp_sdk_present,
    _load_real_fastmcp,
)


FastMCP = _load_real_fastmcp()

if FastMCP is None:
    logger.error(
        "MCP SDK (pip package 'mcp') not found or not importable. "
        "Install it, then re-run. The simplest path is: 'loki mcp', which "
        "creates a managed virtualenv at .loki/mcp-venv and installs "
        "mcp/requirements.txt for you. To install manually: "
        "pip install -r mcp/requirements.txt (or: pip install mcp)."
    )
    sys.exit(1)

# Read version from VERSION file instead of hardcoding
try:
    with open(os.path.join(os.path.dirname(__file__), '..', 'VERSION')) as _vf:
        _version = _vf.read().strip()
except Exception:
    _version = "unknown"

# Initialize FastMCP server.
#
# Task 562: pass only kwargs the installed SDK actually accepts. MCP SDK 1.x
# FastMCP.__init__ has no `version=`/`description=` parameters (it uses
# `instructions=`), so passing them raises TypeError and the server never
# starts. We introspect the signature and forward only supported optional
# kwargs, keeping forward/backward compatibility across SDK versions.
def _build_fastmcp():
    import inspect
    _kwargs = {}
    try:
        _params = inspect.signature(FastMCP.__init__).parameters
    except (TypeError, ValueError):  # pragma: no cover - defensive
        _params = {}
    _desc = "Loki Mode autonomous agent orchestration"
    if "instructions" in _params:
        _kwargs["instructions"] = _desc
    elif "description" in _params:
        _kwargs["description"] = _desc
    if "version" in _params:
        _kwargs["version"] = _version
    return FastMCP("loki-mode", **_kwargs)


mcp = _build_fastmcp()

# Propagate the loki-mode VERSION into serverInfo.version.
#
# FastMCP 1.x exposes no `version=` kwarg (see _build_fastmcp above), so the
# kwarg branch there never fires on the installed SDK. FastMCP DOES forward to
# an underlying lowlevel `Server` (mcp._mcp_server), whose `version` attribute
# is what `create_initialization_options()` reads into serverInfo at the
# initialize handshake -- falling back to importlib.metadata.version("mcp")
# (the SDK's OWN pip version, e.g. 1.27.x) when it is None. That fallback is
# why the listing surfaced the SDK version instead of ours. Setting this
# attribute is the only mechanism the installed SDK exposes to override
# serverInfo.version; `version=` is a documented public parameter on the
# lowlevel Server.__init__, so this is the supported field, not an internal
# hack. Guarded so a future SDK that already set a version (e.g. via the
# _build_fastmcp kwarg branch) is left untouched.
try:
    _inner = getattr(mcp, "_mcp_server", None)
    if _inner is not None and getattr(_inner, "version", None) in (None, ""):
        _inner.version = _version
except Exception:  # pragma: no cover - defensive: never block server startup
    pass

# ============================================================
# TOOLS - Functions Claude can call
# ============================================================

@mcp.tool()
async def loki_memory_retrieve(
    query: str,
    task_type: str = "implementation",
    top_k: int = 5
) -> str:
    """
    Retrieve relevant memories for a task using task-aware retrieval.

    Args:
        query: Search query describing what you're looking for
        task_type: Type of task (exploration, implementation, debugging, review, refactoring)
        top_k: Maximum number of results to return

    Returns:
        JSON array of relevant memory entries with summaries
    """
    _emit_tool_event_async(
        'loki_memory_retrieve', 'start',
        parameters={'query': query, 'task_type': task_type, 'top_k': top_k}
    )
    try:
        from memory.retrieval import MemoryRetrieval
        from memory.storage import MemoryStorage

        base_path = safe_path_join('.loki', 'memory')
        if not os.path.exists(base_path):
            result = json.dumps({"memories": [], "message": "Memory system not initialized"})
            _emit_tool_event_async('loki_memory_retrieve', 'complete', result_status='success')
            return result

        storage = MemoryStorage(base_path)
        retriever = MemoryRetrieval(storage)

        context = {"goal": query, "task_type": task_type}
        results = retriever.retrieve_task_aware(context, top_k=top_k)

        # Extract IDs for context relevance signal
        retrieved_ids = [r.get('id', '') for r in results if isinstance(r, dict)]

        # Emit context relevance signal for memory retrieval
        _emit_context_relevance_signal(
            tool_name='loki_memory_retrieve',
            query=query,
            retrieved_ids=retrieved_ids,
            context={'task_type': task_type, 'top_k': top_k}
        )

        result = json.dumps({
            "memories": results,
            "task_type": task_type,
            "count": len(results)
        }, default=str)
        _emit_tool_event_async('loki_memory_retrieve', 'complete', result_status='success')
        return result
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_memory_retrieve', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied", "memories": []})
    except Exception as e:
        logger.error(f"Memory retrieval failed: {e}")
        _emit_tool_event_async('loki_memory_retrieve', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e), "memories": []})


@mcp.tool()
async def loki_memory_store_pattern(
    pattern: str,
    category: str,
    correct_approach: str,
    incorrect_approach: str = "",
    confidence: float = 0.8
) -> str:
    """
    Store a new semantic pattern learned during this session.

    Args:
        pattern: Brief description of the pattern
        category: Category (api, testing, security, performance, architecture, etc.)
        correct_approach: The correct way to handle this situation
        incorrect_approach: What to avoid (optional)
        confidence: Confidence level 0.0-1.0

    Returns:
        Pattern ID if successful
    """
    # Validate confidence range
    if not (0.0 <= confidence <= 1.0):
        return json.dumps({"success": False, "error": "confidence must be between 0.0 and 1.0"})

    _emit_tool_event_async(
        'loki_memory_store_pattern', 'start',
        parameters={'pattern': pattern, 'category': category, 'confidence': confidence}
    )
    try:
        from memory.engine import MemoryEngine
        from memory.schemas import SemanticPattern

        base_path = safe_path_join('.loki', 'memory')
        engine = MemoryEngine(base_path=base_path)
        engine.initialize()

        pattern_obj = SemanticPattern(
            id=f"pattern-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}",
            pattern=pattern,
            category=category,
            conditions=[],
            correct_approach=correct_approach,
            incorrect_approach=incorrect_approach,
            confidence=confidence,
            source_episodes=[],
            usage_count=0,
            last_used=None,
            links=[]
        )

        pattern_id = engine.store_pattern(pattern_obj)
        _emit_tool_event_async('loki_memory_store_pattern', 'complete', result_status='success')
        return json.dumps({"success": True, "pattern_id": pattern_id})
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_memory_store_pattern', 'complete', result_status='error', error='Access denied')
        return json.dumps({"success": False, "error": "Access denied"})
    except Exception as e:
        logger.error(f"Pattern storage failed: {e}")
        _emit_tool_event_async('loki_memory_store_pattern', 'complete', result_status='error', error=str(e))
        return json.dumps({"success": False, "error": str(e)})


@mcp.tool()
async def loki_task_queue_list() -> str:
    """
    List all tasks in the Loki Mode task queue.

    Returns:
        JSON array of tasks with status, priority, and description
    """
    _emit_tool_event_async('loki_task_queue_list', 'start', parameters={})
    try:
        # Use StateManager if available
        manager = _get_mcp_state_manager()
        if manager and STATE_MANAGER_AVAILABLE:
            queue = manager.get_state("state/task-queue.json")
            if queue:
                # Strip internal fields before returning
                response = {k: v for k, v in queue.items() if k not in ("_next_id", "version")}
                _emit_tool_event_async('loki_task_queue_list', 'complete', result_status='success')
                return json.dumps(response, default=str)
            # If no queue found via StateManager, return empty
            result = json.dumps({"tasks": [], "message": "No task queue found"})
            _emit_tool_event_async('loki_task_queue_list', 'complete', result_status='success')
            return result

        # Fallback to direct file read
        queue_path = safe_path_join('.loki', 'state', 'task-queue.json')
        if not os.path.exists(queue_path):
            result = json.dumps({"tasks": [], "message": "No task queue found"})
            _emit_tool_event_async('loki_task_queue_list', 'complete', result_status='success')
            return result

        with safe_open(queue_path, 'r') as f:
            queue = json.load(f)

        # Strip internal fields before returning
        response = {k: v for k, v in queue.items() if k not in ("_next_id", "version")}
        _emit_tool_event_async('loki_task_queue_list', 'complete', result_status='success')
        return json.dumps(response, default=str)
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_task_queue_list', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied", "tasks": []})
    except Exception as e:
        logger.error(f"Task queue list failed: {e}")
        _emit_tool_event_async('loki_task_queue_list', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e), "tasks": []})


@mcp.tool()
async def loki_task_queue_add(
    title: str,
    description: str,
    priority: str = "medium",
    phase: str = "development"
) -> str:
    """
    Add a new task to the Loki Mode task queue.

    Args:
        title: Brief task title
        description: Detailed task description
        priority: Priority level (low, medium, high, critical)
        phase: SDLC phase (discovery, architecture, development, testing, deployment)

    Returns:
        Task ID if successful
    """
    # Validate priority and phase enums
    valid_priorities = {"low", "medium", "high", "critical"}
    valid_phases = {"discovery", "architecture", "development", "testing", "deployment"}
    if priority not in valid_priorities:
        return json.dumps({"success": False, "error": f"priority must be one of: {', '.join(sorted(valid_priorities))}"})
    if phase not in valid_phases:
        return json.dumps({"success": False, "error": f"phase must be one of: {', '.join(sorted(valid_phases))}"})

    _emit_tool_event_async(
        'loki_task_queue_add', 'start',
        parameters={'title': title, 'priority': priority, 'phase': phase}
    )
    try:
        manager = _get_mcp_state_manager()

        # Load existing queue or create new - use StateManager if available
        if manager and STATE_MANAGER_AVAILABLE:
            queue = manager.get_state("state/task-queue.json", default={"tasks": [], "version": "1.0"})
        else:
            queue_path = safe_path_join('.loki', 'state', 'task-queue.json')
            state_dir = safe_path_join('.loki', 'state')
            safe_makedirs(state_dir, exist_ok=True)

            if os.path.exists(queue_path):
                with safe_open(queue_path, 'r') as f:
                    queue = json.load(f)
            else:
                queue = {"tasks": [], "version": "1.0"}

        # Create new task with monotonic counter to avoid ID collisions after deletions
        # When _next_id is missing, scan existing IDs to find the max and use max+1
        if "_next_id" not in queue:
            existing_ids = []
            for t in queue.get("tasks", []):
                try:
                    existing_ids.append(int(t["id"].replace("task-", "")))
                except (ValueError, KeyError):
                    pass
            next_id = (max(existing_ids) + 1) if existing_ids else 1
        else:
            next_id = queue["_next_id"]
        task_id = f"task-{next_id:04d}"
        queue["_next_id"] = next_id + 1
        task = {
            "id": task_id,
            "title": title,
            "description": description,
            "priority": priority,
            "phase": phase,
            "status": "pending",
            "created_at": datetime.now(timezone.utc).isoformat()
        }

        # Use setdefault so an existing-but-malformed queue dict that lacks a
        # "tasks" key does not raise KeyError, consistent with the safe
        # queue.get("tasks", ...) read above.
        queue.setdefault("tasks", []).append(task)

        # Save using StateManager if available
        if manager and STATE_MANAGER_AVAILABLE:
            manager.set_state("state/task-queue.json", queue, source="mcp-server")
        else:
            queue_path = safe_path_join('.loki', 'state', 'task-queue.json')
            with safe_open(queue_path, 'w') as f:
                json.dump(queue, f, indent=2)

        _emit_tool_event_async('loki_task_queue_add', 'complete', result_status='success')
        return json.dumps({"success": True, "task_id": task_id})
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_task_queue_add', 'complete', result_status='error', error='Access denied')
        return json.dumps({"success": False, "error": "Access denied"})
    except Exception as e:
        logger.error(f"Task add failed: {e}")
        _emit_tool_event_async('loki_task_queue_add', 'complete', result_status='error', error=str(e))
        return json.dumps({"success": False, "error": str(e)})


@mcp.tool()
async def loki_task_queue_update(
    task_id: str,
    status: str = None,
    priority: str = None
) -> str:
    """
    Update a task's status or priority.

    Args:
        task_id: ID of the task to update
        status: New status (pending, in_progress, completed, blocked)
        priority: New priority (low, medium, high, critical)

    Returns:
        Updated task if successful
    """
    # Validate status and priority enums when provided
    valid_statuses = {"pending", "in_progress", "completed", "blocked"}
    valid_priorities = {"low", "medium", "high", "critical"}
    if status is not None and status not in valid_statuses:
        return json.dumps({"success": False, "error": f"status must be one of: {', '.join(sorted(valid_statuses))}"})
    if priority is not None and priority not in valid_priorities:
        return json.dumps({"success": False, "error": f"priority must be one of: {', '.join(sorted(valid_priorities))}"})

    _emit_tool_event_async(
        'loki_task_queue_update', 'start',
        parameters={'task_id': task_id, 'status': status, 'priority': priority}
    )
    try:
        manager = _get_mcp_state_manager()

        # Load queue using StateManager if available
        if manager and STATE_MANAGER_AVAILABLE:
            queue = manager.get_state("state/task-queue.json")
            if not queue:
                _emit_tool_event_async('loki_task_queue_update', 'complete', result_status='error', error='Task queue not found')
                return json.dumps({"success": False, "error": "Task queue not found"})
        else:
            queue_path = safe_path_join('.loki', 'state', 'task-queue.json')
            if not os.path.exists(queue_path):
                _emit_tool_event_async('loki_task_queue_update', 'complete', result_status='error', error='Task queue not found')
                return json.dumps({"success": False, "error": "Task queue not found"})

            with safe_open(queue_path, 'r') as f:
                queue = json.load(f)

        # Find and update task
        for task in queue["tasks"]:
            if task["id"] == task_id:
                if status is not None:
                    task["status"] = status
                if priority is not None:
                    task["priority"] = priority
                task["updated_at"] = datetime.now(timezone.utc).isoformat()

                # Save using StateManager if available
                if manager and STATE_MANAGER_AVAILABLE:
                    manager.set_state("state/task-queue.json", queue, source="mcp-server")
                else:
                    queue_path = safe_path_join('.loki', 'state', 'task-queue.json')
                    with safe_open(queue_path, 'w') as f:
                        json.dump(queue, f, indent=2)

                _emit_tool_event_async('loki_task_queue_update', 'complete', result_status='success')
                return json.dumps({"success": True, "task": task})

        _emit_tool_event_async('loki_task_queue_update', 'complete', result_status='error', error=f'Task {task_id} not found')
        return json.dumps({"success": False, "error": f"Task {task_id} not found"})
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_task_queue_update', 'complete', result_status='error', error='Access denied')
        return json.dumps({"success": False, "error": "Access denied"})
    except Exception as e:
        logger.error(f"Task update failed: {e}")
        _emit_tool_event_async('loki_task_queue_update', 'complete', result_status='error', error=str(e))
        return json.dumps({"success": False, "error": str(e)})


@mcp.tool()
async def loki_state_get() -> str:
    """
    Get the current Loki Mode state including phase, metrics, and status.

    Returns:
        JSON object with current state information
    """
    _emit_tool_event_async('loki_state_get', 'start', parameters={})
    try:
        continuity_path = safe_path_join('.loki', 'CONTINUITY.md')
        loki_dir = safe_path_join('.loki')

        state = {
            "initialized": os.path.exists(loki_dir),
            "autonomy_state": None,
            "continuity_exists": os.path.exists(continuity_path),
            "timestamp": datetime.now(timezone.utc).isoformat()
        }

        # Use StateManager for autonomy state if available
        manager = _get_mcp_state_manager()
        if manager and STATE_MANAGER_AVAILABLE:
            autonomy_data = manager.get_state(ManagedFile.AUTONOMY)
            if autonomy_data:
                state["autonomy_state"] = autonomy_data
        else:
            # Fallback to direct file read
            state_path = safe_path_join('.loki', 'autonomy-state.json')
            if os.path.exists(state_path):
                with safe_open(state_path, 'r') as f:
                    state["autonomy_state"] = json.load(f)

        # Get memory stats
        try:
            from memory.engine import MemoryEngine
            memory_path = safe_path_join('.loki', 'memory')
            engine = MemoryEngine(base_path=memory_path)
            state["memory_stats"] = engine.get_stats()
        except Exception:
            state["memory_stats"] = None

        _emit_tool_event_async('loki_state_get', 'complete', result_status='success')
        return json.dumps(state, default=str)
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_state_get', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"State get failed: {e}")
        _emit_tool_event_async('loki_state_get', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_metrics_efficiency() -> str:
    """
    Get efficiency metrics for the current session.

    Returns:
        JSON object with token usage, tool calls, and efficiency ratios
    """
    _emit_tool_event_async('loki_metrics_efficiency', 'start', parameters={})
    try:
        metrics_path = safe_path_join('.loki', 'metrics', 'tool-usage.jsonl')

        if not os.path.exists(metrics_path):
            result = json.dumps({"message": "No metrics collected yet", "tool_calls": 0})
            _emit_tool_event_async('loki_metrics_efficiency', 'complete', result_status='success')
            return result

        tool_counts = {}
        total_calls = 0

        with safe_open(metrics_path, 'r') as f:
            for line in f:
                try:
                    entry = json.loads(line)
                    tool = entry.get("tool", "unknown")
                    tool_counts[tool] = tool_counts.get(tool, 0) + 1
                    total_calls += 1
                except json.JSONDecodeError:
                    continue

        _emit_tool_event_async('loki_metrics_efficiency', 'complete', result_status='success')
        return json.dumps({
            "total_tool_calls": total_calls,
            "tool_breakdown": tool_counts,
            "timestamp": datetime.now(timezone.utc).isoformat()
        })
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_metrics_efficiency', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"Metrics get failed: {e}")
        _emit_tool_event_async('loki_metrics_efficiency', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_memory_capture_session_summary(
    goal: str,
    outcome: str = "success",
    files_modified: Optional[List[str]] = None,
    files_read: Optional[List[str]] = None,
    tool_calls_summary: Optional[str] = None,
    duration_seconds: int = 0,
) -> str:
    """v7.7.18 capture wedge: store an episode for the current agent session.

    Call this voluntarily at iteration close (or session end) to write a
    structured Episode into the project's .loki/memory/ store. Solves
    the diagnosis root cause where memory only captured during `loki
    start` sessions, missing all interactive Claude Code / Cursor /
    Cline / Aider work.

    Args:
        goal: Short description of what the session tried to accomplish
            (will be truncated to 500 chars, scrubbed for secrets).
        outcome: One of "success" | "failure" | "partial". Default "success".
        files_modified: List of file paths that were created or edited.
        files_read: List of file paths that were read for context.
        tool_calls_summary: Optional free-text summary of major actions
            taken (truncated to 1000 chars, scrubbed).
        duration_seconds: Approximate session duration. Default 0.

    Returns:
        JSON: {"episode_path": "<path>"} on success, or
        {"error": "...", "disabled": true} if LOKI_MEMORY_CAPTURE_DISABLED
        env var blocks capture, or {"error": "..."} on failure.
    """
    _emit_tool_event_async(
        'loki_memory_capture_session_summary', 'start',
        parameters={'goal_len': len(goal or ''), 'outcome': outcome,
                    'files_modified_count': len(files_modified or []),
                    'files_read_count': len(files_read or [])}
    )
    try:
        from memory.ingest import ingest_from_summary, _capture_disabled

        base_path = safe_path_join('.loki', 'memory')
        # v7.7.23: pass base_path so the .loki/config.json memory.disabled
        # opt-out is honored in addition to the env escape hatch.
        if _capture_disabled(base_path):
            _emit_tool_event_async(
                'loki_memory_capture_session_summary', 'complete',
                result_status='skipped', error='disabled via env or config'
            )
            return json.dumps({
                "error": "memory capture disabled (LOKI_MEMORY_CAPTURE_DISABLED or .loki/config.json memory.disabled)",
                "disabled": True,
            })
        path = ingest_from_summary(
            base_path,
            goal=goal,
            outcome=outcome,
            files_modified=files_modified,
            files_read=files_read,
            tool_calls_summary=tool_calls_summary,
            duration_seconds=duration_seconds,
        )
        if path is None:
            _emit_tool_event_async(
                'loki_memory_capture_session_summary', 'complete',
                result_status='error', error='ingest_from_summary returned None'
            )
            return json.dumps({"error": "ingest failed (check .loki/memory/.errors.log)"})
        _emit_tool_event_async(
            'loki_memory_capture_session_summary', 'complete',
            result_status='success', episode_path=path
        )
        return json.dumps({"episode_path": path})
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async(
            'loki_memory_capture_session_summary', 'complete',
            result_status='error', error='Access denied'
        )
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"loki_memory_capture_session_summary failed: {e}")
        _emit_tool_event_async(
            'loki_memory_capture_session_summary', 'complete',
            result_status='error', error=str(e)
        )
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_consolidate_memory(since_hours: int = 24) -> str:
    """
    Run memory consolidation to extract patterns from recent episodes.

    Args:
        since_hours: Process episodes from the last N hours

    Returns:
        Consolidation results with patterns created/merged
    """
    _emit_tool_event_async(
        'loki_consolidate_memory', 'start',
        parameters={'since_hours': since_hours}
    )
    try:
        from memory.consolidation import ConsolidationPipeline
        from memory.storage import MemoryStorage

        base_path = safe_path_join('.loki', 'memory')
        storage = MemoryStorage(base_path)
        pipeline = ConsolidationPipeline(storage)

        result = pipeline.consolidate(since_hours=since_hours)
        _emit_tool_event_async('loki_consolidate_memory', 'complete', result_status='success')
        return json.dumps(result, default=str)
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('loki_consolidate_memory', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"Consolidation failed: {e}")
        _emit_tool_event_async('loki_consolidate_memory', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_complete_task(
    completion_statement: str,
    evidence: str,
    confidence: str = "medium",
) -> str:
    """
    Declare that the current PRD / task is complete.

    Replaces the legacy 'COMPLETION PROMISE FULFILLED: ...' prose string with a
    structured tool call. The orchestrator (run.sh) detects this via a signal
    file and stops the iteration loop gracefully.

    Args:
        completion_statement: A short statement of what is complete (for example,
            "PRD requirements implemented, all tests passing, checklist 100%").
        evidence: Concrete evidence supporting the claim -- tests that passed,
            checklist items verified, files created/modified, metrics hit.
        confidence: One of 'high', 'medium', 'low' (default 'medium').
            'low' signals the orchestrator should still run the completion council.

    Returns:
        JSON: {"recorded": true, "path": ".loki/events.jsonl"} on success,
        {"error": "..."} otherwise.
    """
    _emit_tool_event_async(
        'loki_complete_task', 'start',
        parameters={
            'confidence': confidence,
            'statement_len': len(completion_statement or ''),
            'evidence_len': len(evidence or ''),
        },
    )

    # Validate inputs
    if not completion_statement or not completion_statement.strip():
        _emit_tool_event_async(
            'loki_complete_task', 'complete',
            result_status='error', error='completion_statement required')
        return json.dumps({"error": "completion_statement is required"})
    if not evidence or not evidence.strip():
        _emit_tool_event_async(
            'loki_complete_task', 'complete',
            result_status='error', error='evidence required')
        return json.dumps({"error": "evidence is required"})

    confidence_norm = (confidence or 'medium').strip().lower()
    if confidence_norm not in ('high', 'medium', 'low'):
        confidence_norm = 'medium'

    timestamp = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')

    payload = {
        'type': 'task_completion_claim',
        'statement': completion_statement.strip(),
        'evidence': evidence.strip(),
        'confidence': confidence_norm,
        'timestamp': timestamp,
    }

    # Wrap event record with timestamp and type at the outer level so it matches
    # the shape of other events in .loki/events.jsonl.
    event_record = {
        'timestamp': timestamp,
        'type': 'task_completion_claim',
        'data': payload,
    }

    try:
        # Ensure .loki/ and .loki/signals/ exist under the project root
        loki_dir = safe_path_join('.loki')
        os.makedirs(loki_dir, exist_ok=True)
        signals_dir = safe_path_join('.loki', 'signals')
        os.makedirs(signals_dir, exist_ok=True)

        events_path = safe_path_join('.loki', 'events.jsonl')
        with safe_open(events_path, 'a') as f:
            f.write(json.dumps(event_record) + '\n')

        signal_path = safe_path_join('.loki', 'signals', 'TASK_COMPLETION_CLAIMED')
        with safe_open(signal_path, 'w') as f:
            f.write(json.dumps(payload, indent=2))

        _emit_tool_event_async(
            'loki_complete_task', 'complete', result_status='success')
        return json.dumps({
            "recorded": True,
            "path": ".loki/events.jsonl",
            "signal": ".loki/signals/TASK_COMPLETION_CLAIMED",
            "confidence": confidence_norm,
        })
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked in loki_complete_task: {e}")
        _emit_tool_event_async(
            'loki_complete_task', 'complete',
            result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"loki_complete_task failed: {e}")
        _emit_tool_event_async(
            'loki_complete_task', 'complete',
            result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


# ============================================================
# RESOURCES - Data that can be read
# ============================================================

@mcp.resource("loki://state/continuity")
async def get_continuity() -> str:
    """Get the current CONTINUITY.md content"""
    try:
        continuity_path = safe_path_join('.loki', 'CONTINUITY.md')
        if os.path.exists(continuity_path):
            with safe_open(continuity_path, 'r') as f:
                return f.read()
        return "# CONTINUITY.md not found"
    except PathTraversalError:
        return "# Access denied"
    except Exception as e:
        # Match the tool-handler error-envelope pattern so a corrupt or
        # unreadable state file (e.g. IsADirectoryError, OSError) returns an
        # honest error string instead of raising uncaught into the MCP runtime.
        logger.error(f"get_continuity failed: {e}")
        return f"# Error reading CONTINUITY.md: {e}"


@mcp.resource("loki://memory/index")
async def get_memory_index() -> str:
    """Get the memory index (Layer 1)"""
    try:
        # Use StateManager if available
        manager = _get_mcp_state_manager()
        if manager and STATE_MANAGER_AVAILABLE:
            index_data = manager.get_state(ManagedFile.MEMORY_INDEX)
            if index_data:
                return json.dumps(index_data)
            return json.dumps({"topics": [], "message": "Index not initialized"})

        # Fallback to direct file read. Parse-and-reserialize so a corrupt
        # index.json yields a clean error envelope instead of serving corrupt
        # bytes as a successful response (or raising on a downstream consumer).
        index_path = safe_path_join('.loki', 'memory', 'index.json')
        if os.path.exists(index_path):
            with safe_open(index_path, 'r') as f:
                raw = f.read()
            try:
                return json.dumps(json.loads(raw))
            except (json.JSONDecodeError, ValueError) as e:
                logger.error(f"get_memory_index: corrupt index.json: {e}")
                return json.dumps({"error": f"corrupt index.json: {e}", "topics": []})
        return json.dumps({"topics": [], "message": "Index not initialized"})
    except PathTraversalError:
        return json.dumps({"error": "Access denied", "topics": []})
    except Exception as e:
        # Generic envelope so any other state-file failure (OSError,
        # IsADirectoryError) returns honestly rather than raising uncaught.
        logger.error(f"get_memory_index failed: {e}")
        return json.dumps({"error": str(e), "topics": []})


@mcp.resource("loki://queue/pending")
async def get_pending_tasks() -> str:
    """Get all pending tasks from the queue"""
    try:
        # Use StateManager if available
        manager = _get_mcp_state_manager()
        if manager and STATE_MANAGER_AVAILABLE:
            queue = manager.get_state("state/task-queue.json")
            if queue:
                pending = [t for t in queue.get("tasks", []) if t.get("status") == "pending"]
                return json.dumps({"pending_tasks": pending, "count": len(pending)})
            return json.dumps({"pending_tasks": [], "count": 0})

        # Fallback to direct file read
        queue_path = safe_path_join('.loki', 'state', 'task-queue.json')
        if os.path.exists(queue_path):
            with safe_open(queue_path, 'r') as f:
                queue = json.load(f)
                pending = [t for t in queue.get("tasks", []) if t.get("status") == "pending"]
                return json.dumps({"pending_tasks": pending, "count": len(pending)})
        return json.dumps({"pending_tasks": [], "count": 0})
    except PathTraversalError:
        return json.dumps({"error": "Access denied", "pending_tasks": [], "count": 0})
    except Exception as e:
        # Generic envelope: a degraded install (STATE_MANAGER_AVAILABLE=False)
        # does a bare json.load on .loki/state/task-queue.json; a corrupt file
        # raises JSONDecodeError that must return an error, not crash the runtime.
        logger.error(f"get_pending_tasks failed: {e}")
        return json.dumps({"error": str(e), "pending_tasks": [], "count": 0})


# ============================================================
# ENTERPRISE TOOLS (P0-1)
# ============================================================

@mcp.tool()
async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
    """
    Start a new Loki Mode project from a PRD.

    Args:
        prd_content: Inline PRD content (takes priority over prd_path)
        prd_path: Path to a PRD file on disk

    Returns:
        JSON with project initialization status
    """
    _emit_tool_event_async('loki_start_project', 'start', parameters={'prd_path': prd_path})
    try:
        content = prd_content
        if not content and prd_path:
            # Resolve symlinks and validate path is within project root
            try:
                resolved = validate_path(prd_path, allowed_dirs=['.'])
            except PathTraversalError as e:
                _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error=str(e))
                return json.dumps({"error": str(e)})
            if os.path.exists(resolved) and os.path.isfile(resolved):
                with open(resolved, 'r', encoding='utf-8') as f:
                    content = f.read()
            else:
                _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='PRD file not found')
                return json.dumps({"error": f"PRD file not found: {prd_path}"})

        if not content:
            _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='No PRD content or path provided')
            return json.dumps({"error": "No PRD content or path provided"})

        # Initialize project state using safe path operations
        state_dir = safe_path_join('.loki', 'state')
        safe_makedirs(state_dir, exist_ok=True)

        # Persist PRD content so downstream tools can access it
        prd_dest = safe_path_join('.loki', 'state', 'prd.md')
        with safe_open(prd_dest, 'w') as f:
            f.write(content)

        project = {
            "status": "initialized",
            "prd_length": len(content),
            "prd_path": prd_path or "inline",
            "created_at": datetime.now(timezone.utc).isoformat(),
        }
        state_path = safe_path_join('.loki', 'state', 'project.json')
        with safe_open(state_path, 'w') as f:
            json.dump(project, f, indent=2)

        _emit_tool_event_async('loki_start_project', 'complete', result_status='success')
        return json.dumps({"success": True, **project})
    except PathTraversalError as e:
        _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": f"Access denied: {e}"})
    except Exception as e:
        logger.error(f"Start project failed: {e}")
        _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_project_status() -> str:
    """
    Get the current project status including RARV cycle state, agent activity, and task progress.

    Returns:
        JSON with project status, phase, iteration, agents, and task counts
    """
    _emit_tool_event_async('loki_project_status', 'start', parameters={})
    try:
        status = {}

        # Read orchestrator state
        orch_path = safe_path_join('.loki', 'state', 'orchestrator.json')
        if os.path.exists(orch_path):
            with safe_open(orch_path, 'r') as f:
                status["orchestrator"] = json.load(f)

        # Read project state
        proj_path = safe_path_join('.loki', 'state', 'project.json')
        if os.path.exists(proj_path):
            with safe_open(proj_path, 'r') as f:
                status["project"] = json.load(f)

        # Read task queue summary
        queue_path = safe_path_join('.loki', 'state', 'task-queue.json')
        if os.path.exists(queue_path):
            with safe_open(queue_path, 'r') as f:
                queue = json.load(f)
                tasks = queue.get("tasks", [])
                status["tasks"] = {
                    "total": len(tasks),
                    "pending": sum(1 for t in tasks if t.get("status") == "pending"),
                    "in_progress": sum(1 for t in tasks if t.get("status") == "in_progress"),
                    "completed": sum(1 for t in tasks if t.get("status") == "completed"),
                }

        if not status:
            status = {"status": "no_project", "message": "No active project found"}

        _emit_tool_event_async('loki_project_status', 'complete', result_status='success')
        return json.dumps(status, default=str)
    except PathTraversalError:
        _emit_tool_event_async('loki_project_status', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"Project status failed: {e}")
        _emit_tool_event_async('loki_project_status', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_agent_metrics() -> str:
    """
    Get agent metrics including token usage, task completion rates, and timing.

    Returns:
        JSON with per-agent metrics and aggregates
    """
    _emit_tool_event_async('loki_agent_metrics', 'start', parameters={})
    try:
        metrics = {"agents": [], "aggregate": {}}

        # Read efficiency metrics
        metrics_dir = safe_path_join('.loki', 'metrics', 'efficiency')
        if os.path.isdir(metrics_dir):
            for fname in os.listdir(metrics_dir):
                if fname.endswith('.json'):
                    fpath = safe_path_join('.loki', 'metrics', 'efficiency', fname)
                    with safe_open(fpath, 'r') as f:
                        metrics["agents"].append(json.load(f))

        # Read token economics
        econ_path = safe_path_join('.loki', 'metrics', 'token-economics.json')
        if os.path.exists(econ_path):
            with safe_open(econ_path, 'r') as f:
                metrics["token_economics"] = json.load(f)

        metrics["agent_count"] = len(metrics["agents"])
        _emit_tool_event_async('loki_agent_metrics', 'complete', result_status='success')
        return json.dumps(metrics, default=str)
    except PathTraversalError:
        _emit_tool_event_async('loki_agent_metrics', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"Agent metrics failed: {e}")
        _emit_tool_event_async('loki_agent_metrics', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
    """
    List available checkpoints or restore project state from a specific checkpoint.

    Args:
        checkpoint_id: ID of checkpoint to restore (empty = list all)

    Returns:
        JSON with available checkpoints or restoration result
    """
    _emit_tool_event_async('loki_checkpoint_restore', 'start', parameters={'checkpoint_id': checkpoint_id})
    try:
        cp_dir = safe_path_join('.loki', 'state', 'checkpoints')
        if not os.path.isdir(cp_dir):
            _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='success')
            return json.dumps({"checkpoints": [], "message": "No checkpoints directory"})

        checkpoints = []
        for fname in sorted(os.listdir(cp_dir)):
            if fname.endswith('.json'):
                fpath = safe_path_join('.loki', 'state', 'checkpoints', fname)
                with safe_open(fpath, 'r') as f:
                    cp = json.load(f)
                    cp["id"] = fname.replace('.json', '')
                    checkpoints.append(cp)

        if not checkpoint_id:
            _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='success')
            return json.dumps({"checkpoints": checkpoints, "count": len(checkpoints)})

        # Find and restore specific checkpoint
        target = next((c for c in checkpoints if c["id"] == checkpoint_id), None)
        if not target:
            _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='error', error='Checkpoint not found')
            return json.dumps({"error": f"Checkpoint not found: {checkpoint_id}"})

        # Write checkpoint state as current state, stripping the injected "id" field
        restored_state = {k: v for k, v in target.items() if k != "id"}
        state_path = safe_path_join('.loki', 'state', 'orchestrator.json')
        with safe_open(state_path, 'w') as f:
            json.dump(restored_state, f, indent=2)

        _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='success')
        return json.dumps({"restored": True, "checkpoint_id": checkpoint_id})
    except PathTraversalError:
        _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"Checkpoint restore failed: {e}")
        _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_quality_report() -> str:
    """
    Get quality gate results including blind review scores, council verdicts, and test coverage.

    Returns:
        JSON with quality gate status, review results, and coverage metrics
    """
    _emit_tool_event_async('loki_quality_report', 'start', parameters={})
    try:
        report = {"gates": [], "council": None, "coverage": None}

        # Read quality gate results
        gates_path = safe_path_join('.loki', 'state', 'quality-gates.json')
        if os.path.exists(gates_path):
            with safe_open(gates_path, 'r') as f:
                report["gates"] = json.load(f)

        # Read council results
        council_path = safe_path_join('.loki', 'state', 'council-results.json')
        if os.path.exists(council_path):
            with safe_open(council_path, 'r') as f:
                report["council"] = json.load(f)

        # Read coverage
        coverage_path = safe_path_join('.loki', 'metrics', 'coverage.json')
        if os.path.exists(coverage_path):
            with safe_open(coverage_path, 'r') as f:
                report["coverage"] = json.load(f)

        _emit_tool_event_async('loki_quality_report', 'complete', result_status='success')
        return json.dumps(report, default=str)
    except PathTraversalError:
        _emit_tool_event_async('loki_quality_report', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})
    except Exception as e:
        logger.error(f"Quality report failed: {e}")
        _emit_tool_event_async('loki_quality_report', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


# ============================================================
# CODE SEARCH - ChromaDB-backed semantic code search
# ============================================================

# ChromaDB connection (lazy-initialized)
_chroma_client = None
_chroma_collection = None

CHROMA_HOST = os.environ.get("LOKI_CHROMA_HOST", "localhost")
CHROMA_PORT = int(os.environ.get("LOKI_CHROMA_PORT", "8100"))
CHROMA_COLLECTION = os.environ.get("LOKI_CHROMA_COLLECTION", "loki-codebase")

# Code-index freshness manifest (written by tools/index-codebase.py). Resolved
# relative to the repo root the same way the indexer resolves it, so the two
# agree on a single location. mcp/server.py -> parent.parent == repo root.
_CODE_INDEX_REPO_ROOT = Path(__file__).resolve().parent.parent
CODE_INDEX_MANIFEST_PATH = _CODE_INDEX_REPO_ROOT / ".loki" / "state" / "code-index-manifest.json"


def _code_index_staleness() -> dict:
    """Compare the code-index manifest mtimes against current files on disk.

    Self-contained (no import of tools/index-codebase.py, which loads chromadb
    at module top under a possibly-different Python). Mirrors the mtime
    staleness pattern in memory/retrieval.py. A missing manifest degrades to
    not-stale so the happy path on a fresh repo is unaffected.

    Returns {"stale": bool, "stale_files": int}.
    """
    try:
        data = json.loads(CODE_INDEX_MANIFEST_PATH.read_text())
        files = data.get("files", {})
        if not isinstance(files, dict) or not files:
            return {"stale": False, "stale_files": 0}
    except Exception:
        return {"stale": False, "stale_files": 0}

    stale = 0
    for rel, entry in files.items():
        abs_path = _CODE_INDEX_REPO_ROOT / rel
        try:
            if not abs_path.exists():
                stale += 1
            elif os.path.getmtime(abs_path) != entry.get("mtime"):
                stale += 1
        except OSError:
            stale += 1
    return {"stale": stale > 0, "stale_files": stale}


def _maybe_autoreindex_code() -> None:
    """Opt-in incremental re-index when the manifest is stale.

    Gated behind LOKI_CODE_INDEX_AUTOREINDEX=1 because embeddings cost compute.
    Default behavior is warn-if-stale (the tools just report the staleness
    fields). Best-effort: never raises into the caller.
    """
    if os.environ.get("LOKI_CODE_INDEX_AUTOREINDEX", "0") != "1":
        return
    if not _code_index_staleness().get("stale"):
        return
    try:
        import subprocess
        indexer = _CODE_INDEX_REPO_ROOT / "tools" / "index-codebase.py"
        py = "/opt/homebrew/bin/python3.12"
        if not Path(py).exists():
            py = sys.executable
        subprocess.run([py, str(indexer), "--changed"],
                       cwd=str(_CODE_INDEX_REPO_ROOT),
                       capture_output=True, timeout=300)
    except Exception as e:
        logger.warning(f"Auto-reindex (LOKI_CODE_INDEX_AUTOREINDEX) failed: {e}")


def _get_chroma_collection():
    """Get or create ChromaDB collection (lazy connection).

    BUG-PU-002: Improved reconnection with timeout to prevent hanging
    when ChromaDB container is stopped or unreachable after idle.
    """
    global _chroma_client, _chroma_collection
    if _chroma_collection is not None:
        try:
            _chroma_client.heartbeat()
            return _chroma_collection
        except Exception:
            logger.info("ChromaDB heartbeat failed, reconnecting...")
            _chroma_client = None
            _chroma_collection = None
    try:
        import chromadb
        from chromadb.config import Settings
        _chroma_client = chromadb.HttpClient(
            host=CHROMA_HOST,
            port=CHROMA_PORT,
            settings=Settings(
                chroma_client_auth_provider=None,
                anonymized_telemetry=False,
            ),
        )
        # Verify connectivity before returning
        _chroma_client.heartbeat()
        _chroma_collection = _chroma_client.get_collection(name=CHROMA_COLLECTION)
        return _chroma_collection
    except Exception as e:
        logger.warning(f"ChromaDB not available: {e}")
        _chroma_client = None
        _chroma_collection = None
        return None


@mcp.tool()
async def loki_code_search(
    query: str,
    n_results: int = 10,
    language: Optional[str] = None,
    file_filter: Optional[str] = None,
    type_filter: Optional[str] = None,
) -> str:
    """Search the loki-mode codebase semantically.

    Finds functions, classes, and code sections by meaning, not just keywords.
    Returns file paths, line numbers, and code snippets ranked by relevance.

    Args:
        query: Natural language search query (e.g., "rate limit detection",
               "model selection for RARV tier", "how does the council vote")
        n_results: Number of results to return (default 10, max 30)
        language: Filter by language: "shell", "python", "markdown" (optional)
        file_filter: Filter by file path substring (e.g., "autonomy/", "dashboard/") (optional)
        type_filter: Filter by chunk type: "function", "class", "header", "section", "file" (optional)
    """
    _emit_tool_event_async('loki_code_search', 'start',
                           parameters={'query': query, 'n_results': n_results,
                                       'language': language, 'file_filter': file_filter,
                                       'type_filter': type_filter})

    # Warn-if-stale (default) or opt-in auto-reindex before querying.
    # _maybe_autoreindex_code runs a synchronous subprocess (timeout up to
    # 300s) when LOKI_CODE_INDEX_AUTOREINDEX=1; offload it so it cannot
    # freeze the MCP event loop while the indexer runs.
    await asyncio.to_thread(_maybe_autoreindex_code)
    _staleness = _code_index_staleness()

    collection = _get_chroma_collection()
    if collection is None:
        # Emit 'complete' to balance the 'start' above. Without this the
        # per-tool start-time stack in _tool_call_start_times leaks one entry
        # per call (and ChromaDB-unavailable is the common default path), and
        # a later successful call would pop a stale start time, producing a
        # wildly wrong execution_time_ms learning signal.
        _emit_tool_event_async('loki_code_search', 'complete',
                               result_status='error', error='ChromaDB not available')
        return json.dumps({
            "error": "ChromaDB not available. Start it with: docker start loki-chroma",
            "hint": "Re-index with: python3.12 tools/index-codebase.py --reset"
        })

    n_results = min(max(1, n_results), 30)

    # Build where filter
    where_clauses = []
    if language:
        where_clauses.append({"language": language})
    if type_filter:
        where_clauses.append({"type": type_filter})

    where = None
    if len(where_clauses) == 1:
        where = where_clauses[0]
    elif len(where_clauses) > 1:
        where = {"$and": where_clauses}

    try:
        # collection.query is a blocking HTTP call to ChromaDB; offload it so
        # the MCP event loop is not frozen for the duration of the request.
        results = await asyncio.to_thread(
            collection.query,
            query_texts=[query],
            n_results=n_results,
            where=where,
            include=["documents", "metadatas", "distances"],
        )

        # Format results
        output = []
        for i in range(len(results["ids"][0])):
            meta = results["metadatas"][0][i]
            doc = results["documents"][0][i]
            dist = results["distances"][0][i]

            # Apply file_filter post-query (ChromaDB where doesn't support substring match)
            if file_filter and file_filter not in meta.get("file", ""):
                continue

            # Truncate document for response
            preview = doc[:500] + "..." if len(doc) > 500 else doc

            output.append({
                "file": meta.get("file", ""),
                "line": meta.get("line", 0),
                "name": meta.get("name", ""),
                "type": meta.get("type", ""),
                "language": meta.get("language", ""),
                "relevance": round(max(0.0, 1.0 - dist / 2.0), 4),  # L2 distance to similarity
                "preview": preview,
            })

        _emit_tool_event_async('loki_code_search', 'complete',
                               result_status='success', result_count=len(output))
        return json.dumps({
            "query": query,
            "results": output,
            "total": len(output),
            "stale": _staleness["stale"],
            "stale_files": _staleness["stale_files"],
        })

    except Exception as e:
        logger.error(f"Code search failed: {e}")
        _emit_tool_event_async('loki_code_search', 'complete',
                               result_status='error', error=str(e))
        # Generic envelope: do not leak raw exception text (may include
        # ChromaDB connection details / internal field names) to client.
        return json.dumps({
            "error": "Code search failed",
            "code": "CHROMA_QUERY_ERROR",
            "hint": "Ensure ChromaDB is running: docker start loki-chroma",
        })


@mcp.tool()
async def loki_code_search_stats() -> str:
    """Get statistics about the code search index.

    Shows total chunks, files indexed, breakdown by language and type.
    Useful for verifying the index is up to date.
    """
    _staleness = _code_index_staleness()

    collection = _get_chroma_collection()
    if collection is None:
        return json.dumps({"error": "ChromaDB not available"})

    try:
        count = collection.count()

        # Short-circuit on empty collection to avoid limit=0 error
        if count == 0:
            return json.dumps({
                "total_chunks": 0,
                "unique_files": 0,
                "by_language": {},
                "by_type": {},
                "reindex_command": "python3.12 tools/index-codebase.py --reset",
                "stale": _staleness["stale"],
                "stale_files": _staleness["stale_files"],
            })

        results = collection.get(limit=count, include=["metadatas"])

        langs = {}
        types = {}
        files = set()
        for meta in results["metadatas"]:
            lang = meta.get("language", "unknown")
            typ = meta.get("type", "unknown")
            langs[lang] = langs.get(lang, 0) + 1
            types[typ] = types.get(typ, 0) + 1
            files.add(meta.get("file", ""))

        return json.dumps({
            "total_chunks": count,
            "unique_files": len(files),
            "by_language": langs,
            "by_type": types,
            "reindex_command": "python3.12 tools/index-codebase.py --reset",
            "stale": _staleness["stale"],
            "stale_files": _staleness["stale_files"],
        })
    except Exception as e:
        logger.error(f"Code search stats failed: {e}")
        # Generic envelope: do not leak raw exception text to client.
        return json.dumps({
            "error": "Index unavailable",
            "code": "CHROMA_STATS_ERROR",
            "hint": "docker start loki-chroma",
        })


# ============================================================
# MEMORY SEARCH TOOLS (v6.15.0) - SQLite FTS5 powered
# ============================================================

@mcp.tool()
async def mem_search(
    query: str,
    collection: str = "all",
    limit: int = 10,
) -> str:
    """
    Search memory using full-text search (FTS5).

    Fast keyword search across all memory types. Supports AND, OR, NOT
    operators and prefix matching (e.g. "debug*").

    Args:
        query: Search query (plain text or FTS5 syntax)
        collection: Which memories to search (episodes, patterns, skills, all)
        limit: Maximum results to return

    Returns:
        JSON array of matching memories with relevance scores
    """
    _emit_tool_event_async(
        'mem_search', 'start',
        parameters={'query': query, 'collection': collection, 'limit': limit}
    )
    try:
        base_path = safe_path_join('.loki', 'memory')
        if not os.path.exists(base_path):
            result = json.dumps({"results": [], "message": "Memory system not initialized"})
            _emit_tool_event_async('mem_search', 'complete', result_status='success')
            return result

        # Use retrieval-based search
        from memory.retrieval import MemoryRetrieval
        from memory.storage import MemoryStorage
        storage = MemoryStorage(base_path)
        retriever = MemoryRetrieval(storage)
        context = {"goal": query, "task_type": "exploration"}
        results = retriever.retrieve_task_aware(context, top_k=limit)

        # Filter results by collection parameter when not "all".
        # retrieve_task_aware tags every item with _source in
        # {episodic, semantic, skills, anti_patterns}; it does NOT emit
        # _type/type. Map _source to the public collection vocabulary so the
        # filter actually matches (previously every result was dropped because
        # result_type defaulted to "unknown").
        collection_type_map = {
            "episodes": "episode",
            "patterns": "pattern",
            "skills": "skill",
        }
        filter_type = collection_type_map.get(collection)
        source_to_type = {
            "episodic": "episode",
            "semantic": "pattern",
            "skills": "skill",
            "anti_patterns": "pattern",
        }

        # Compact results for token efficiency
        compact = []
        for r in results:
            result_type = source_to_type.get(
                r.get("_source"),
                r.get("_type", r.get("type", "unknown")),
            )
            # Apply collection filter
            if filter_type and result_type != filter_type:
                continue
            entry = {
                "id": r.get("id", ""),
                "type": result_type,
                "summary": (
                    r.get("goal", "") or
                    r.get("pattern", "") or
                    r.get("description", "") or
                    r.get("name", "")
                )[:200],
            }
            if r.get("_score"):
                entry["score"] = round(r["_score"], 3)
            if r.get("outcome"):
                entry["outcome"] = r["outcome"]
            if r.get("category"):
                entry["category"] = r["category"]
            compact.append(entry)

        result = json.dumps({"results": compact, "count": len(compact)}, default=str)
        _emit_tool_event_async('mem_search', 'complete', result_status='success')
        return result
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('mem_search', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied", "results": []})
    except Exception as e:
        logger.error(f"mem_search failed: {e}")
        _emit_tool_event_async('mem_search', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e), "results": []})


@mcp.tool()
async def mem_timeline(
    around_id: str = "",
    limit: int = 20,
    since_hours: int = 24,
) -> str:
    """
    Get chronological context from memory timeline.

    Shows recent actions, key decisions, and episode traces in time order.
    Use around_id to get context surrounding a specific memory entry.

    Args:
        around_id: Optional memory ID to center the timeline around
        limit: Maximum timeline entries to return
        since_hours: Only show entries from the last N hours (default 24)

    Returns:
        JSON timeline with actions and decisions
    """
    _emit_tool_event_async(
        'mem_timeline', 'start',
        parameters={'around_id': around_id, 'limit': limit, 'since_hours': since_hours}
    )
    try:
        base_path = safe_path_join('.loki', 'memory')
        if not os.path.exists(base_path):
            result = json.dumps({"timeline": [], "message": "Memory system not initialized"})
            _emit_tool_event_async('mem_timeline', 'complete', result_status='success')
            return result

        from datetime import timedelta
        cutoff = datetime.now(timezone.utc) - timedelta(hours=since_hours)

        from memory.storage import MemoryStorage
        storage = MemoryStorage(base_path)
        timeline = storage.get_timeline()
        actions = timeline.get("recent_actions", [])[:limit]

        episode_ids = storage.list_episodes(since=cutoff, limit=limit)
        episodes = []
        for eid in episode_ids:
            ep = storage.load_episode(eid)
            if ep:
                episodes.append({
                    "id": ep.get("id"),
                    "timestamp": ep.get("timestamp"),
                    "phase": ep.get("phase"),
                    "goal": (ep.get("goal", "") or "")[:150],
                    "outcome": ep.get("outcome"),
                    "duration_seconds": ep.get("duration_seconds"),
                    "files_modified": ep.get("files_modified", [])[:5],
                })

        result = json.dumps({
            "actions": actions,
            "episodes": episodes,
            "decisions": timeline.get("key_decisions", [])[:10],
            "active_context": timeline.get("active_context", {}),
        }, default=str)
        _emit_tool_event_async('mem_timeline', 'complete', result_status='success')
        return result
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('mem_timeline', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied", "timeline": []})
    except Exception as e:
        logger.error(f"mem_timeline failed: {e}")
        _emit_tool_event_async('mem_timeline', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e), "timeline": []})


@mcp.tool()
async def mem_get(
    ids: str,
) -> str:
    """
    Fetch full details for one or more memory entries by ID.

    Use after mem_search to get complete data for specific results.

    Args:
        ids: Comma-separated list of memory IDs to fetch

    Returns:
        JSON object with full memory details keyed by ID
    """
    _emit_tool_event_async(
        'mem_get', 'start',
        parameters={'ids': ids}
    )
    try:
        base_path = safe_path_join('.loki', 'memory')
        if not os.path.exists(base_path):
            result = json.dumps({"entries": {}, "message": "Memory system not initialized"})
            _emit_tool_event_async('mem_get', 'complete', result_status='success')
            return result

        id_list = [i.strip() for i in ids.split(",") if i.strip()]
        if not id_list:
            _emit_tool_event_async('mem_get', 'complete', result_status='error', error='No IDs provided')
            return json.dumps({"entries": {}, "error": "No IDs provided"})

        # Cap at 20 to prevent abuse
        id_list = id_list[:20]

        from memory.storage import MemoryStorage
        storage = MemoryStorage(base_path)

        entries = {}
        for mem_id in id_list:
            mem_id = mem_id.strip()
            # Try each collection
            data = storage.load_episode(mem_id)
            if data:
                data["_type"] = "episode"
                entries[mem_id] = data
                continue

            data = storage.load_pattern(mem_id)
            if data:
                data["_type"] = "pattern"
                entries[mem_id] = data
                continue

            data = storage.load_skill(mem_id)
            if data:
                data["_type"] = "skill"
                entries[mem_id] = data
                continue

            entries[mem_id] = None  # Not found

        result = json.dumps({
            "entries": entries,
            "found": sum(1 for v in entries.values() if v is not None),
            "total_requested": len(id_list),
        }, default=str)
        _emit_tool_event_async('mem_get', 'complete', result_status='success')
        return result
    except PathTraversalError as e:
        logger.error(f"Path traversal attempt blocked: {e}")
        _emit_tool_event_async('mem_get', 'complete', result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied", "entries": {}})
    except Exception as e:
        logger.error(f"mem_get failed: {e}")
        _emit_tool_event_async('mem_get', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e), "entries": {}})


# ============================================================
# GIT INTELLIGENCE TOOLS (Phase 4 - Repowise native equivalent)
# ============================================================

@mcp.tool()
async def loki_get_hotspots(
    limit: int = 10,
) -> str:
    """Get the most frequently changed files in the repository.

    Identifies code hotspots based on git commit frequency analysis.
    These files deserve extra care during changes (higher risk of regressions).

    Args:
        limit: Number of top hotspot files to return (default 10, max 30)
    """
    _emit_tool_event_async('loki_get_hotspots', 'start',
                           parameters={'limit': limit})

    limit = min(max(1, limit), 30)

    try:
        hotspots_path = safe_path_join('.loki', 'intelligence', 'hotspots.txt')
    except PathTraversalError:
        _emit_tool_event_async('loki_get_hotspots', 'complete',
                               result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})

    if not os.path.exists(hotspots_path):
        _emit_tool_event_async('loki_get_hotspots', 'complete',
                               result_status='error', error='Not available')
        return json.dumps({
            "error": "Git intelligence not yet generated",
            "hint": "Run 'loki start' or wait for the next autonomous iteration"
        })

    try:
        results = []
        with safe_open(hotspots_path, 'r') as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                # Format: "  42 path/to/file.py"
                parts = line.split(None, 1)
                if len(parts) == 2:
                    try:
                        changes = int(parts[0])
                    except ValueError:
                        continue
                    results.append({"file": parts[1], "changes": changes})
                if len(results) >= limit:
                    break

        _emit_tool_event_async('loki_get_hotspots', 'complete',
                               result_status='success', result_count=len(results))
        return json.dumps({"hotspots": results, "total": len(results)})
    except Exception as e:
        logger.error(f"loki_get_hotspots failed: {e}")
        _emit_tool_event_async('loki_get_hotspots', 'complete',
                               result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_get_co_changes(
    file_path: str,
) -> str:
    """Find files that frequently change together with a given file.

    Uses git co-change analysis to identify coupling between files.
    Useful for understanding hidden dependencies and ensuring related
    files are updated together.

    Args:
        file_path: Path to the file to find co-change partners for
    """
    _emit_tool_event_async('loki_get_co_changes', 'start',
                           parameters={'file_path': file_path})

    try:
        co_changes_path = safe_path_join('.loki', 'intelligence', 'co-changes.json')
    except PathTraversalError:
        _emit_tool_event_async('loki_get_co_changes', 'complete',
                               result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})

    if not os.path.exists(co_changes_path):
        _emit_tool_event_async('loki_get_co_changes', 'complete',
                               result_status='error', error='Not available')
        return json.dumps({
            "error": "Git intelligence not yet generated",
            "hint": "Run 'loki start' or wait for the next autonomous iteration"
        })

    try:
        with safe_open(co_changes_path, 'r') as f:
            pairs = json.load(f)

        # Filter pairs involving the requested file. The co-changes.json
        # producer lives outside this repo, so treat its shape as an external
        # contract: skip malformed entries (not a 2-element pair) instead of
        # letting one bad row raise and abort the whole tool, and skip
        # self-pairs (a file is not its own co-change partner) which would
        # otherwise report file_path against itself.
        results = []
        for entry in pairs:
            try:
                pair_files, count = entry
            except (ValueError, TypeError):
                continue
            if not isinstance(pair_files, (list, tuple)) or len(pair_files) != 2:
                continue
            # Coerce the count to int so a mixed-type producer (one row's
            # count a string, another's an int) does not raise TypeError when
            # results are sorted below. Skip rows whose count is not numeric.
            try:
                count = int(count)
            except (ValueError, TypeError):
                continue
            a, b = pair_files[0], pair_files[1]
            if a == b:
                continue
            if a == file_path:
                results.append({"partner": b, "co_changes": count})
            elif b == file_path:
                results.append({"partner": a, "co_changes": count})

        # Sort by co-change count descending
        results.sort(key=lambda x: x["co_changes"], reverse=True)

        _emit_tool_event_async('loki_get_co_changes', 'complete',
                               result_status='success', result_count=len(results))
        return json.dumps({
            "file": file_path,
            "co_changed_with": results,
            "total": len(results)
        })
    except Exception as e:
        logger.error(f"loki_get_co_changes failed: {e}")
        _emit_tool_event_async('loki_get_co_changes', 'complete',
                               result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_get_doc_coverage() -> str:
    """Get documentation coverage status for the project.

    Reads from the docs manifest to report which files are documented,
    which have stale documentation, and which are missing docs entirely.
    Useful for prioritizing documentation work.
    """
    _emit_tool_event_async('loki_get_doc_coverage', 'start')

    try:
        manifest_path = safe_path_join('.loki', 'docs', 'docs-manifest.json')
    except PathTraversalError:
        _emit_tool_event_async('loki_get_doc_coverage', 'complete',
                               result_status='error', error='Access denied')
        return json.dumps({"error": "Access denied"})

    if not os.path.exists(manifest_path):
        _emit_tool_event_async('loki_get_doc_coverage', 'complete',
                               result_status='error', error='Not available')
        return json.dumps({
            "error": "Documentation manifest not found",
            "hint": "Create .loki/docs/docs-manifest.json with file documentation status"
        })

    try:
        with safe_open(manifest_path, 'r') as f:
            manifest = json.load(f)

        total_files = manifest.get("total_files", 0)
        documented_files = manifest.get("documented_files", 0)
        coverage_pct = round((documented_files / total_files * 100) if total_files > 0 else 0, 1)

        stale_docs = manifest.get("stale_docs", [])
        missing_docs = manifest.get("missing_docs", [])

        result = {
            "coverage_pct": coverage_pct,
            "total_files": total_files,
            "documented_files": documented_files,
            "stale_docs": stale_docs[:20],  # Limit to avoid huge responses
            "missing_docs": missing_docs[:20],
        }

        _emit_tool_event_async('loki_get_doc_coverage', 'complete',
                               result_status='success')
        return json.dumps(result)
    except Exception as e:
        logger.error(f"loki_get_doc_coverage failed: {e}")
        _emit_tool_event_async('loki_get_doc_coverage', 'complete',
                               result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


# ============================================================
# Phase 1 artifact tools (v7.5.3) -- expose findings + learnings +
# counter-evidence templates produced by the Bun runner under .loki/.
# ============================================================

@mcp.tool()
async def loki_findings(iteration: int = -1) -> str:
    """Read structured code-review findings for a given iteration.

    Args:
        iteration: iteration number (default -1 = most recent).
    Returns: JSON {iteration, review_id, findings: [...]}.
    """
    _emit_tool_event_async('loki_findings', 'start',
                           parameters={'iteration': iteration})
    try:
        import re as _re
        state_dir = safe_path_join('.loki', 'state')
        if iteration >= 0:
            persisted = safe_path_join('.loki', 'state', f'findings-{iteration}.json')
            if os.path.exists(persisted):
                with safe_open(persisted, 'r') as f:
                    data = json.load(f)
                _emit_tool_event_async('loki_findings', 'complete', result_status='success')
                return json.dumps(data)
        if iteration < 0 and os.path.exists(state_dir):
            files = [n for n in os.listdir(state_dir) if _re.fullmatch(r'findings-\d+\.json', n)]
            if files:
                files.sort(key=lambda n: int(_re.sub(r'\D', '', n) or '0'))
                with safe_open(safe_path_join('.loki', 'state', files[-1]), 'r') as f:
                    data = json.load(f)
                _emit_tool_event_async('loki_findings', 'complete', result_status='success')
                return json.dumps(data)
        reviews_dir = safe_path_join('.loki', 'quality', 'reviews')
        if not os.path.exists(reviews_dir):
            _emit_tool_event_async('loki_findings', 'complete', result_status='success')
            return json.dumps({"iteration": iteration, "review_id": None, "findings": []})
        candidates = sorted([d for d in os.listdir(reviews_dir)
                             if d.startswith('review-')
                             and os.path.isdir(os.path.join(reviews_dir, d))])
        if iteration >= 0:
            candidates = [c for c in candidates if c.endswith(f'-{iteration}')]
        if not candidates:
            _emit_tool_event_async('loki_findings', 'complete', result_status='success')
            return json.dumps({"iteration": iteration, "review_id": None, "findings": []})
        latest = candidates[-1]
        review_path = safe_path_join('.loki', 'quality', 'reviews', latest)
        sev_re = _re.compile(r'\[(Critical|High|Medium|Low)\]\s*(.+)', _re.IGNORECASE)
        file_re = _re.compile(r'([\w./\-]+\.[a-zA-Z]+):(\d+)')
        findings = []
        for entry in os.listdir(review_path):
            if not entry.endswith('.txt'):
                continue
            if entry in ('diff.txt', 'files.txt', 'anti-sycophancy.txt'):
                continue
            if entry.endswith('-prompt.txt'):
                continue
            reviewer = entry[:-4]
            try:
                entry_path = safe_path_join(review_path, entry)
            except PathTraversalError:
                # Skip listdir entries that resolve outside the review dir
                # (defensive against e.g. "../etc/passwd"-style names).
                continue
            with safe_open(entry_path, 'r') as f:
                body = f.read()
            for line in body.splitlines():
                stripped = line.strip().lstrip('-* ').strip()
                m = sev_re.match(stripped)
                if not m:
                    continue
                sev_raw, desc = m.group(1), m.group(2).strip()
                sev = sev_raw[:1].upper() + sev_raw[1:].lower()
                fl = file_re.search(desc)
                findings.append({
                    "severity": sev, "reviewer": reviewer, "description": desc,
                    "file": fl.group(1) if fl else None,
                    "line": int(fl.group(2)) if fl else None,
                    "raw": stripped,
                })
        result = {"iteration": iteration, "review_id": latest, "findings": findings}
        _emit_tool_event_async('loki_findings', 'complete', result_status='success')
        return json.dumps(result)
    except Exception as e:
        logger.error(f"loki_findings failed: {e}")
        _emit_tool_event_async('loki_findings', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_learnings(limit: int = 50) -> str:
    """Read recent learnings (newest first) from relevant-learnings.json."""
    _emit_tool_event_async('loki_learnings', 'start', parameters={'limit': limit})
    try:
        path = safe_path_join('.loki', 'state', 'relevant-learnings.json')
        if not os.path.exists(path):
            _emit_tool_event_async('loki_learnings', 'complete', result_status='success')
            return json.dumps({"version": 1, "learnings": [], "total": 0})
        try:
            with safe_open(path, 'r') as f:
                data = json.load(f)
        except (json.JSONDecodeError, ValueError) as je:
            # Surface corruption explicitly rather than masking it as empty.
            logger.error(f"loki_learnings corrupt file at {path}: {je}")
            _emit_tool_event_async('loki_learnings', 'complete',
                                   result_status='error', error=str(je))
            return json.dumps({
                "error": "Learning file corrupted",
                "code": "LEARNINGS_CORRUPT",
                "path": path,
                "entries": [],
            })
        learnings = data.get('learnings', []) if isinstance(data, dict) else []
        if not isinstance(learnings, list):
            learnings = []
        sliced = list(reversed(learnings))[:max(1, int(limit))]
        result = {"version": data.get('version', 1) if isinstance(data, dict) else 1,
                  "total": len(learnings), "learnings": sliced}
        _emit_tool_event_async('loki_learnings', 'complete', result_status='success')
        return json.dumps(result)
    except Exception as e:
        logger.error(f"loki_learnings failed: {e}")
        _emit_tool_event_async('loki_learnings', 'complete', result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


@mcp.tool()
async def loki_counter_evidence_template(iteration: int) -> str:
    """Generate a counter-evidence file template for the given iteration.

    Pre-fills canonical findingId for each Critical/High finding so the
    user only has to fill in `claim` + `proofType`. Save the template
    body to .loki/state/counter-evidence-<iteration>.json to dispute
    findings via the override council.
    """
    _emit_tool_event_async('loki_counter_evidence_template', 'start',
                           parameters={'iteration': iteration})
    try:
        findings_data = json.loads(await loki_findings(iteration=iteration))
        if 'error' in findings_data:
            _emit_tool_event_async('loki_counter_evidence_template', 'complete',
                                   result_status='error',
                                   error=findings_data.get('error'))
            return json.dumps(findings_data)
        findings = findings_data.get('findings', [])
        if not findings:
            _emit_tool_event_async('loki_counter_evidence_template', 'complete',
                                   result_status='success')
            return json.dumps({"iteration": iteration, "template": None,
                               "message": f"No findings for iteration {iteration}."})
        evidence = []
        for f in findings:
            if f.get('severity') not in ('Critical', 'High'):
                continue
            head = (f.get('raw') or '').strip()[:80]
            evidence.append({
                "findingId": f"{f.get('reviewer', '')}::{head}",
                "claim": "(describe why this finding is wrong, in one sentence)",
                "proofType": "duplicate-code-path",
                "artifacts": ["(file path or command output backing your claim)"],
                "_finding_for_reference": {
                    "severity": f.get('severity'),
                    "description": f.get('description'),
                    "file": f.get('file'),
                    "line": f.get('line'),
                },
            })
        result = {
            "iteration": iteration,
            "template": {"iteration": iteration, "evidence": evidence},
            "usage": (f"Save to .loki/state/counter-evidence-{iteration}.json "
                      "after filling in `claim` and `proofType`. proofType MUST "
                      "be one of: file-exists, test-passes, grep-miss, "
                      "reviewer-misread, duplicate-code-path, out-of-scope."),
        }
        _emit_tool_event_async('loki_counter_evidence_template', 'complete',
                               result_status='success')
        return json.dumps(result)
    except Exception as e:
        logger.error(f"loki_counter_evidence_template failed: {e}")
        _emit_tool_event_async('loki_counter_evidence_template', 'complete',
                               result_status='error', error=str(e))
        return json.dumps({"error": str(e)})


# ============================================================
# PROMPTS - Pre-built prompt templates
# ============================================================

@mcp.prompt()
async def loki_start(prd_path: str = "") -> str:
    """Initialize a Loki Mode session with optional PRD"""
    return f"""You are now operating in Loki Mode - autonomous agent orchestration.

RARV Cycle: Reason -> Act -> Reflect -> Verify

Current PRD: {prd_path or 'None specified'}

Steps:
1. Analyze the PRD and extract requirements
2. Break down into actionable tasks
3. Execute tasks following RARV cycle
4. Verify completion against acceptance criteria

Use loki_* tools to manage tasks and memory.
Begin by analyzing the requirements."""


@mcp.prompt()
async def loki_phase_report() -> str:
    """Generate a status report for the current phase"""
    return """Generate a comprehensive status report including:

1. Current SDLC Phase
2. Tasks Completed / In Progress / Pending
3. Quality Gate Status
4. Key Decisions Made
5. Blockers or Risks
6. Next Steps

Use loki_state_get and loki_task_queue_list to gather data."""


# ============================================================
# MANAGED MEMORY TOOLS (PII redaction, read proxy)
#
# The actual implementation lives in mcp/managed_tools.py so unit tests can
# import the core redact function without booting the FastMCP runtime.
# loki_memory_redact appears below for grep-ability and is a thin wrapper.
# ============================================================

try:
    from mcp.managed_tools import register_managed_tools
    register_managed_tools(mcp)
    # Emit tool-call events by wrapping the registered tool's underlying
    # callable. We reference loki_memory_redact by name here for discoverability.
    _MANAGED_MEMORY_TOOLS = ("loki_memory_redact",)
except Exception as _managed_err:
    import sys as _sys
    print(
        f"[warn] managed_tools registration skipped: {_managed_err}",
        file=_sys.stderr,
    )


# ============================================================
# MAGIC MODULES TOOLS (spec-driven component generation)
# ============================================================

try:
    from mcp.magic_tools import register_magic_tools
    register_magic_tools(mcp)
except Exception as _magic_err:
    # Magic Modules is optional; log and continue if unavailable
    import sys as _sys
    print(f"[warn] magic_tools registration skipped: {_magic_err}", file=_sys.stderr)


# ============================================================
# HTTP TRANSPORT (loopback bind + optional bearer-token auth)
# ============================================================

def _run_http_transport(port):
    """Run the MCP server over Streamable HTTP, bound explicitly to loopback.

    Two hardening properties over a bare FastMCP HTTP launch:

      1. Explicit loopback bind. We set host='127.0.0.1' ourselves and run the
         ASGI app via uvicorn rather than relying on any implicit default, so a
         reader of `lsof`/`ss` sees 127.0.0.1 unambiguously and a future SDK
         default change cannot silently widen the bind to 0.0.0.0.

      2. Optional bearer-token auth. When LOKI_MCP_AUTH_TOKEN is set in the
         environment, a Starlette middleware requires every HTTP request to
         carry `Authorization: Bearer <token>` (rejecting mismatches with 401).
         When the env var is unset or empty, NO middleware is installed at all,
         so the request path is exactly the unauthenticated FastMCP behavior.

    FastMCP's own run(transport='streamable-http') internally does the same
    thing (build the Streamable-HTTP ASGI app, then uvicorn.run over it); we
    inline that so we can inject the explicit host and the optional middleware.

    hasattr-guarded against the whole supported mcp 1.x range (requirements.txt
    pins mcp>=1.0.0,<2.0.0): if the installed SDK lacks streamable_http_app or
    the host/port settings, we fail with a clear message rather than an
    AttributeError.
    """
    if not hasattr(mcp, "streamable_http_app"):
        logger.error(
            "Installed MCP SDK does not support Streamable HTTP transport "
            "(no FastMCP.streamable_http_app). Upgrade the 'mcp' package "
            "(pip install -U mcp) or use the default stdio transport."
        )
        sys.exit(1)

    host = "127.0.0.1"

    # Pin host/port on the SDK settings too, so any code that reads them (and
    # the SDK's own transport-security allowed_hosts, which defaults to
    # 127.0.0.1/localhost) stays consistent with what uvicorn actually binds.
    try:
        if hasattr(mcp, "settings"):
            if hasattr(mcp.settings, "host"):
                mcp.settings.host = host
            if hasattr(mcp.settings, "port"):
                mcp.settings.port = port
    except Exception as _set_err:  # pragma: no cover - defensive
        logger.warning("Could not pin MCP host/port settings: %s", _set_err)

    app = mcp.streamable_http_app()

    token = os.environ.get("LOKI_MCP_AUTH_TOKEN", "")
    if token:
        # Only import the middleware pieces when auth is actually requested, so
        # the unauthenticated path has zero new dependencies or behavior.
        from starlette.middleware.base import BaseHTTPMiddleware
        from starlette.responses import JSONResponse

        expected_header = "Bearer " + token

        async def _require_bearer(request, call_next):
            # BaseHTTPMiddleware auto-forwards non-http scopes (lifespan etc.),
            # so the StreamableHTTPSessionManager lifespan still starts.
            provided = request.headers.get("authorization", "")
            # Constant-time compare to avoid leaking the token via timing.
            import hmac
            if not hmac.compare_digest(provided, expected_header):
                return JSONResponse(
                    {"error": "unauthorized"},
                    status_code=401,
                    headers={"WWW-Authenticate": "Bearer"},
                )
            return await call_next(request)

        app.add_middleware(BaseHTTPMiddleware, dispatch=_require_bearer)
        logger.info(
            "MCP HTTP auth enabled: bearer token required "
            "(LOKI_MCP_AUTH_TOKEN is set)."
        )
    else:
        logger.info(
            "MCP HTTP auth disabled: no LOKI_MCP_AUTH_TOKEN set "
            "(loopback-only, unauthenticated)."
        )

    import uvicorn
    logger.info("MCP Streamable HTTP listening on http://%s:%d/mcp", host, port)
    uvicorn.run(app, host=host, port=port, log_level="info")


# ============================================================
# MAIN
# ============================================================

def main():
    import argparse
    import atexit
    parser = argparse.ArgumentParser(description='Loki Mode MCP Server')
    parser.add_argument('--transport', choices=['stdio', 'http'], default='stdio',
                       help=('Transport mechanism (default: stdio). "http" binds '
                             '127.0.0.1 (loopback) explicitly; set env '
                             'LOKI_MCP_AUTH_TOKEN to require a bearer token.'))
    parser.add_argument('--port', type=int, default=8421,
                       help='Port for HTTP transport (default: 8421)')
    parser.add_argument('--check-sdk', action='store_true',
                       help=('Probe only: exit 0 if the MCP SDK loaded and the '
                             'server object built, non-zero otherwise. Used by '
                             '`loki mcp` to verify a venv before launching. '
                             'Does not start a server.'))
    args = parser.parse_args()

    # --check-sdk: if we reached here, the module-level loader already imported
    # FastMCP and built `mcp` (otherwise the module would have sys.exit(1)'d at
    # import). So reaching main() with a live `mcp` object means the SDK is
    # genuinely importable. Report success and exit without starting a server.
    if args.check_sdk:
        if mcp is not None:
            print("MCP SDK OK", file=sys.stderr)
            sys.exit(0)
        sys.exit(1)

    # Register cleanup to prevent file handle leaks on shutdown/restart
    atexit.register(cleanup_mcp_singletons)

    logger.info(f"Starting Loki Mode MCP server (transport: {args.transport})")

    if args.transport == 'http':
        # Explicit loopback bind + optional bearer-token auth. Note: FastMCP has
        # no 'http' transport literal (it uses 'streamable-http') and run() takes
        # no port= kwarg, so the old mcp.run(transport='http', port=...) call
        # never worked; _run_http_transport is the supported path.
        _run_http_transport(args.port)
    else:
        mcp.run(transport='stdio')


if __name__ == '__main__':
    main()
