# Implements: REQ-p00060-A, REQ-p00060-B, REQ-p00060-C, REQ-p00060-D, REQ-p00060-E
# Implements: REQ-o00060-A, REQ-o00060-B, REQ-o00060-C, REQ-o00060-D, REQ-o00060-E, REQ-o00060-F
# Implements: REQ-o00061-A, REQ-o00061-B, REQ-o00061-C, REQ-o00061-D
# Implements: REQ-o00062-A, REQ-o00062-B, REQ-o00062-C, REQ-o00062-D
# Implements: REQ-o00062-E, REQ-o00062-F, REQ-o00062-G
# Implements: REQ-o00063-A, REQ-o00063-B, REQ-o00063-C, REQ-o00063-D, REQ-o00063-E, REQ-o00063-F
# Implements: REQ-o00064-A, REQ-o00064-B, REQ-o00064-C, REQ-o00064-D, REQ-o00064-E
# Implements: REQ-d00060-A, REQ-d00060-B, REQ-d00060-C, REQ-d00060-D, REQ-d00060-E
# Implements: REQ-d00061-A, REQ-d00061-B, REQ-d00061-C, REQ-d00061-D, REQ-d00061-E
# Implements: REQ-d00061-F, REQ-d00061-G, REQ-d00061-H, REQ-d00061-I
# Implements: REQ-d00061-J, REQ-d00061-K, REQ-d00061-L, REQ-d00061-M
# Implements: REQ-d00062-A, REQ-d00062-B, REQ-d00062-C, REQ-d00062-D
# Implements: REQ-d00062-E, REQ-d00062-F
# Implements: REQ-d00063-A, REQ-d00063-B, REQ-d00063-C, REQ-d00063-D, REQ-d00063-E
# Implements: REQ-d00064-A, REQ-d00064-B, REQ-d00064-C, REQ-d00064-D, REQ-d00064-E
# Implements: REQ-d00065-A, REQ-d00065-B, REQ-d00065-C, REQ-d00065-D, REQ-d00065-E
# Implements: REQ-d00066-A, REQ-d00066-B, REQ-d00066-C, REQ-d00066-D
# Implements: REQ-d00066-E, REQ-d00066-F, REQ-d00066-G
# Implements: REQ-d00067-A, REQ-d00067-B, REQ-d00067-C, REQ-d00067-D
# Implements: REQ-d00067-E, REQ-d00067-F
# Implements: REQ-d00068-A, REQ-d00068-B, REQ-d00068-C, REQ-d00068-D
# Implements: REQ-d00068-E, REQ-d00068-F
# Implements: REQ-p00006-B
# Implements: REQ-d00074-A, REQ-d00074-B, REQ-d00074-C, REQ-d00074-D
# Implements: REQ-o00067-A, REQ-o00067-B, REQ-o00067-C, REQ-o00067-D, REQ-o00067-E, REQ-o00067-F
# Implements: REQ-d00075-A, REQ-d00075-B, REQ-d00075-C, REQ-d00075-D
# Implements: REQ-d00075-E, REQ-d00075-F, REQ-d00075-G
# Implements: REQ-o00068-A, REQ-o00068-B, REQ-o00068-C, REQ-o00068-D
# Implements: REQ-o00068-E, REQ-o00068-F
# Implements: REQ-d00076-A, REQ-d00076-B, REQ-d00076-C, REQ-d00076-D
# Implements: REQ-d00076-E, REQ-d00076-F, REQ-d00076-G
# Implements: REQ-d00133-A, REQ-d00133-B, REQ-d00133-C, REQ-d00133-D
# Implements: REQ-d00133-E, REQ-d00133-F
"""elspais.mcp.server - MCP server implementation.

Creates and runs the MCP server exposing elspais functionality.

This is a pure interface layer - it consumes TraceGraph directly
without creating intermediate data structures (REQ-p00060-B).
"""

from __future__ import annotations

import re
from collections.abc import Iterator
from pathlib import Path
from typing import Any

try:
    from mcp.server.fastmcp import FastMCP

    MCP_AVAILABLE = True
except ImportError:
    MCP_AVAILABLE = False
    FastMCP = None

from elspais.config import find_config_file, get_config
from elspais.config.schema import ElspaisConfig
from elspais.graph import NodeKind
from elspais.graph.annotators import (
    annotate_graph_git_state,
    count_by_coverage,
    count_by_git_status,
    count_by_level,
    count_code_coverage,
    count_with_code_refs,
)
from elspais.graph.factory import build_graph
from elspais.graph.federated import FederatedGraph
from elspais.graph.GraphNode import GraphNode
from elspais.graph.mutations import MutationEntry
from elspais.graph.relations import EdgeKind
from elspais.mcp.search import ParsedQuery, matches_node, parse_query, score_node
from elspais.utilities.patterns import build_resolver

# Known schema fields (by alias and Python name) for filtering non-schema keys
_SCHEMA_FIELDS = {f.alias or name for name, f in ElspaisConfig.model_fields.items()} | set(
    ElspaisConfig.model_fields.keys()
)


def _validate_config(config: dict[str, Any]) -> ElspaisConfig:
    """Validate a config dict into ElspaisConfig, stripping non-schema keys."""
    filtered = {k: v for k, v in config.items() if k in _SCHEMA_FIELDS}
    # Strip legacy-format associates (contains 'paths' list instead of named entries)
    assoc = filtered.get("associates")
    if isinstance(assoc, dict) and "paths" in assoc:
        filtered.pop("associates", None)
    return ElspaisConfig.model_validate(filtered)


# ─────────────────────────────────────────────────────────────────────────────
# Source path helper
# ─────────────────────────────────────────────────────────────────────────────


def _relative_source_path(node: Any, graph: FederatedGraph) -> str:
    """Return the node's source file path, relative to repo root.

    Implements: REQ-d00129-D
    Navigates to the FILE parent node to get the repo-relative path.
    Some parsers (notably CodeRefParser) may store absolute paths;
    this helper normalises the value.
    """
    fn = node.file_node()
    if not fn:
        return ""
    raw = fn.get_field("relative_path") or ""
    if not raw:
        return ""
    p = Path(raw)
    if p.is_absolute():
        try:
            return str(p.relative_to(graph.repo_root))
        except ValueError:
            return raw  # outside repo, keep as-is
    return raw


# ─────────────────────────────────────────────────────────────────────────────
# Shared coverage traversal iterator (REQ-d00066-B, REQ-d00066-D)
# ─────────────────────────────────────────────────────────────────────────────


def _iter_assertion_coverage(
    req_node: Any,
    kind_filter: NodeKind,
    *,
    edge_kinds: set[EdgeKind] | None = None,
    direct_only: bool = False,
) -> Iterator[tuple[Any, list[str]]]:
    """Yield ``(node, labels)`` for each TEST or CODE node covering *req_node*.

    Two-phase edge traversal:

    Phase 1 — ``req_node.iter_outgoing_edges()``:
      * If ``assertion_targets`` is set → those labels
      * If absent → ALL assertion labels (indirect / blanket coverage)

    Phase 2 — For each ASSERTION child → ``iter_outgoing_edges()``:
      * Yields ``(node, [that_label])``

    The same node may be yielded more than once (e.g. via both phases).
    Callers are responsible for deduplication.

    Args:
        edge_kinds: If set, only consider edges whose kind is in this set.
        direct_only: If True, skip Phase 1 edges that have no
            ``assertion_targets`` (blanket coverage).
    """
    # Collect all assertion labels for the indirect-coverage case
    all_labels: list[str] = []
    assertion_children: list[tuple[Any, str]] = []  # (assertion_node, label)
    for child in req_node.iter_children():
        if child.kind == NodeKind.ASSERTION:
            label = child.get_field("label", "")
            all_labels.append(label)
            assertion_children.append((child, label))

    # Phase 1: REQ → kind_filter edges
    for edge in req_node.iter_outgoing_edges():
        if edge_kinds and edge.kind not in edge_kinds:
            continue
        target = edge.target
        if target.kind != kind_filter:
            continue
        if edge.assertion_targets:
            yield target, list(edge.assertion_targets)
        elif not direct_only:
            yield target, list(all_labels)

    # Phase 2: ASSERTION → kind_filter edges
    for assertion_node, label in assertion_children:
        for edge in assertion_node.iter_outgoing_edges():
            if edge_kinds and edge.kind not in edge_kinds:
                continue
            target = edge.target
            if target.kind != kind_filter:
                continue
            yield target, [label]


def _serialize_test_info(test_node: Any, graph: FederatedGraph) -> dict[str, Any]:
    """Unified TEST-node serializer (superset of all consumers).

    Returns::

        {"id", "label", "file", "line", "name",
         "results": [{"id", "status", "duration", "file", "line"}]}
    """
    results: list[dict[str, Any]] = []
    for child in test_node.iter_children():
        if child.kind == NodeKind.RESULT:
            results.append(
                {
                    "id": child.id,
                    "status": child.get_field("status", "unknown"),
                    "duration": child.get_field("duration", 0.0),
                    "file": _relative_source_path(child, graph),
                    "line": child.get_field("parse_line") or 0,
                }
            )
    return {
        "id": test_node.id,
        "label": test_node.get_label(),
        "file": _relative_source_path(test_node, graph),
        "line": test_node.get_field("parse_line") or 0,
        "name": test_node.get_field("name", ""),
        "results": results,
    }


def _serialize_code_info(code_node: Any, graph: FederatedGraph) -> dict[str, Any]:
    """Unified CODE-node serializer.

    Returns::

        {"id", "label", "file", "line"}
    """
    return {
        "id": code_node.id,
        "label": code_node.get_label(),
        "file": _relative_source_path(code_node, graph),
        "line": code_node.get_field("parse_line") or 0,
    }


# ─────────────────────────────────────────────────────────────────────────────
# Serializers (REQ-d00064)
# ─────────────────────────────────────────────────────────────────────────────


def _serialize_requirement_summary(node: Any) -> dict[str, Any]:
    """Serialize a requirement node to summary format.

    REQ-d00064-A: Returns id, title, level, status only.
    REQ-d00064-C: Reads from node.get_field() and node.get_label().
    """
    return {
        "id": node.id,
        "title": node.get_label(),
        "level": node.get_field("level"),
        "status": node.get_field("status"),
    }


def _serialize_assertion(node: Any) -> dict[str, Any]:
    """Serialize an assertion node."""
    return {
        "id": node.id,
        "label": node.get_field("label"),
        "text": node.get_label(),
    }


def _serialize_node_generic(node: Any, graph: FederatedGraph | None = None) -> dict[str, Any]:
    """Serialize any graph node to full format with kind-specific properties.

    Returns a common envelope (id, kind, title, source, parents, children,
    links, keywords) plus a ``properties`` dict with kind-specific fields.

    REQ-d00064-B: Returns all fields including assertions and edges.
    REQ-d00064-C: Reads from node.get_field() and node.get_label().
    """
    from elspais.graph.relations import EdgeKind as EK

    kind = node.kind

    # ── Common: children ──
    # Use edges to access render_order metadata for STRUCTURES children
    children = []
    _seen_children: set[str] = set()
    for edge in node.iter_outgoing_edges():
        child = edge.target
        if child.id in _seen_children:
            continue
        _seen_children.add(child.id)
        ro = edge.metadata.get("render_order")
        if child.kind == NodeKind.ASSERTION:
            entry = {
                "kind": "assertion",
                "id": child.id,
                "label": child.get_field("label"),
                "text": child.get_label(),
                "line": child.get_field("parse_line"),
            }
            if ro is not None:
                entry["render_order"] = ro
            children.append(entry)
        elif child.kind == NodeKind.REMAINDER:
            entry = {
                "kind": "remainder",
                "id": child.id,
                "heading": child.get_field("heading"),
                "text": child.get_field("text"),
                "line": child.get_field("parse_line"),
                "content_type": child.get_field("content_type"),
            }
            if ro is not None:
                entry["render_order"] = ro
            children.append(entry)
        else:
            entry = {
                "kind": child.kind.value,
                "id": child.id,
                "title": child.get_label(),
                "line": child.get_field("parse_line"),
            }
            if ro is not None:
                entry["render_order"] = ro
            children.append(entry)

    # ── Common: parents with edge_kind and assertion_targets ──
    parents = []
    # Implements: REQ-d00069-G
    for edge in node.iter_incoming_edges():
        if edge.kind in (EK.IMPLEMENTS, EK.REFINES, EK.SATISFIES, EK.INSTANCE):
            parent = edge.source
            if parent.kind == NodeKind.REQUIREMENT:
                ref_id = parent.id
                if edge.assertion_targets:
                    ref_id = f"{parent.id}-{edge.assertion_targets[0]}"
                parents.append(
                    {
                        "id": parent.id,
                        "kind": parent.kind.value,
                        "title": parent.get_label(),
                        "edge_kind": edge.kind.value,
                        "assertion_targets": edge.assertion_targets,
                        "ref_id": ref_id,
                    }
                )

    # ── Common: non-hierarchical links (VALIDATES, VERIFIES, etc.) ──
    links = []
    for edge in node.iter_incoming_edges():
        if edge.kind not in (
            EK.IMPLEMENTS,
            EK.REFINES,
            EK.SATISFIES,
            EK.INSTANCE,
            EK.CONTAINS,
        ):
            link_entry: dict[str, Any] = {
                "id": edge.source.id,
                "kind": edge.source.kind.value,
                "title": edge.source.get_label(),
                "edge_kind": edge.kind.value,
            }
            if edge.assertion_targets:
                link_entry["assertion_targets"] = edge.assertion_targets
            links.append(link_entry)
    for edge in node.iter_outgoing_edges():
        if edge.kind not in (
            EK.IMPLEMENTS,
            EK.REFINES,
            EK.SATISFIES,
            EK.INSTANCE,
            EK.CONTAINS,
        ):
            link_entry = {
                "id": edge.target.id,
                "kind": edge.target.kind.value,
                "title": edge.target.get_label(),
                "edge_kind": edge.kind.value,
            }
            if edge.assertion_targets:
                link_entry["assertion_targets"] = edge.assertion_targets
            links.append(link_entry)

    # ── Common: keywords ──
    keywords = node.get_field("keywords", []) or []

    # ── Kind-specific properties ──
    properties: dict[str, Any] = {}
    if kind == NodeKind.REQUIREMENT:
        from elspais.graph.relations import Stereotype

        stereotype = node.get_field("stereotype", Stereotype.CONCRETE)
        properties = {
            "level": node.get_field("level"),
            "status": node.get_field("status"),
            "hash": node.get_field("hash"),
            "stereotype": stereotype.value if isinstance(stereotype, Stereotype) else stereotype,
        }
    elif kind == NodeKind.USER_JOURNEY:
        descriptor = None
        m = re.match(r"JNY-(.+)-\d+$", node.id)
        if m:
            descriptor = m.group(1)
        properties = {
            "actor": node.get_field("actor", ""),
            "goal": node.get_field("goal", ""),
            "context": node.get_field("context", ""),
            "sections": node.get_field("sections", []),
            "descriptor": descriptor,
        }
    elif kind == NodeKind.TEST:
        properties = {
            "function_name": node.get_field("function_name", ""),
            "class_name": node.get_field("class_name", ""),
        }
    elif kind == NodeKind.RESULT:
        properties = {
            "status": node.get_field("status", ""),
            "duration": node.get_field("duration", 0.0),
            "message": node.get_field("message", ""),
            "classname": node.get_field("classname", ""),
        }
    elif kind == NodeKind.CODE:
        properties = {
            "function_name": node.get_field("function_name", ""),
            "class_name": node.get_field("class_name", ""),
            "function_line": node.get_field("function_line", 0),
        }
    elif kind == NodeKind.ASSERTION:
        properties = {
            "label": node.get_field("label"),
            "text": node.get_label(),
        }
    elif kind == NodeKind.REMAINDER:
        properties = {
            "heading": node.get_field("heading"),
            "text": node.get_field("text"),
        }

    return {
        "id": node.id,
        "kind": kind.value,
        "title": node.get_label(),
        "source": {
            "path": (
                _relative_source_path(node, graph)
                if graph
                else (node.file_node().get_field("relative_path") if node.file_node() else None)
            ),
            "line": node.get_field("parse_line"),
        },
        "keywords": keywords,
        "parents": parents,
        "children": children,
        "links": links,
        "properties": properties,
    }


def _serialize_node_summary(node: Any) -> dict[str, Any]:
    """Serialize any graph node to lightweight summary format.

    Returns id, kind, title, plus kind-specific filterable properties.
    """
    kind = node.kind
    summary: dict[str, Any] = {
        "id": node.id,
        "kind": kind.value,
        "title": node.get_label(),
    }

    # Add filterable properties per kind
    if kind == NodeKind.REQUIREMENT:
        summary["level"] = node.get_field("level")
        summary["status"] = node.get_field("status")
    elif kind == NodeKind.USER_JOURNEY:
        summary["actor"] = node.get_field("actor", "")
        summary["goal"] = node.get_field("goal", "")
    elif kind == NodeKind.RESULT:
        summary["status"] = node.get_field("status", "")
    elif kind == NodeKind.TEST:
        summary["function_name"] = node.get_field("function_name", "")

    return summary


def _serialize_mutation_entry(entry: MutationEntry) -> dict[str, Any]:
    """Serialize a MutationEntry to dict format.

    REQ-o00062-E: Returns MutationEntry for audit trail.
    """
    return {
        "id": entry.id,
        "operation": entry.operation,
        "target_id": entry.target_id,
        "before_state": entry.before_state,
        "after_state": entry.after_state,
        "affects_hash": entry.affects_hash,
        "timestamp": entry.timestamp.isoformat(),
    }


def _serialize_broken_reference(ref: Any) -> dict[str, Any]:
    """Serialize a BrokenReference to dict format."""
    return {
        "source_id": ref.source_id,
        "target_id": ref.target_id,
        "edge_kind": str(ref.edge_kind) if hasattr(ref.edge_kind, "value") else ref.edge_kind,
    }


# ─────────────────────────────────────────────────────────────────────────────
# Core Tool Functions (REQ-o00060)
# ─────────────────────────────────────────────────────────────────────────────


def _get_graph_status(graph: FederatedGraph) -> dict[str, Any]:
    """Get graph status.

    REQ-d00060-A: Returns is_stale from metadata.
    REQ-d00060-B: Returns node_counts by calling nodes_by_kind().
    REQ-d00060-D: Returns root_count using graph.root_count().
    REQ-d00060-E: Does NOT iterate full graph for counts.
    """
    # Count nodes by kind using the efficient nodes_by_kind iterator
    node_counts: dict[str, int] = {}
    for kind in NodeKind:
        count = sum(1 for _ in graph.nodes_by_kind(kind))
        if count > 0:
            node_counts[kind.value] = count

    return {
        "root_count": graph.root_count(),
        "node_counts": node_counts,
        "total_nodes": graph.node_count(),
        "has_orphans": graph.has_orphans(),
        "has_broken_references": graph.has_broken_references(),
    }


def _get_active_mutated_reqs(graph: FederatedGraph) -> set[str]:
    """Return IDs of Active requirements that have pending mutations."""
    from elspais.graph import NodeKind

    mutated_ids: set[str] = set()
    for entry in graph.mutation_log.iter_entries():
        target = entry.target_id
        # Check if target or its parent is an Active requirement
        node = graph.find_by_id(target)
        if node is None:
            continue
        if node.kind == NodeKind.REQUIREMENT:
            if (node.status or "").lower() == "active":
                mutated_ids.add(node.id)
        elif node.kind == NodeKind.ASSERTION:
            for parent in node.iter_parents():
                if (
                    parent.kind == NodeKind.REQUIREMENT
                    and (parent.status or "").lower() == "active"
                ):
                    mutated_ids.add(parent.id)
    return mutated_ids


def _add_changelog_for_active_mutations(
    graph: FederatedGraph,
    repo_root: Path,
    config: dict,
    message: str,
) -> None:
    """Add changelog entries for mutated Active requirements after save."""
    from datetime import date

    from elspais.commands.validate import compute_hash_for_node
    from elspais.utilities.git import get_author_info
    from elspais.utilities.spec_writer import add_changelog_entry

    active_ids = _get_active_mutated_reqs(graph)
    if not active_ids:
        return

    typed_config = _validate_config(config) if isinstance(config, dict) else config
    id_source = typed_config.changelog.id_source
    try:
        author = get_author_info(id_source)
    except ValueError:
        return

    for req_id in active_ids:
        node = graph.find_by_id(req_id)
        if node is None:
            continue
        _fn = node.file_node()
        if _fn is None:
            continue
        computed = compute_hash_for_node(node, graph.hash_mode)
        file_path = repo_root / _fn.get_field("relative_path")
        entry = {
            "date": date.today().isoformat(),
            "hash": computed or node.hash or "________",
            "change_order": "-",
            "author_name": author["name"],
            "author_id": author["id"],
            "reason": message,
        }
        add_changelog_entry(file_path, req_id, entry)


def _refresh_graph(
    repo_root: Path,
    full: bool = False,
) -> tuple[dict[str, Any], FederatedGraph]:
    """Rebuild the graph from spec files.

    REQ-o00060-B: Forces graph rebuild.

    Args:
        repo_root: Repository root path.
        full: If True, clear all caches before rebuild.

    Returns:
        Tuple of (result dict, new TraceGraph).
    """
    # Build fresh graph
    try:
        new_graph = build_graph(repo_root=repo_root)
    except (ValueError, Exception) as e:
        error_msg = str(e)
        if ".elspais.toml" in error_msg:
            # Config parse error — return a descriptive error, not a stack trace
            from elspais.graph.federated import FederatedGraph

            return {
                "success": False,
                "message": f"CONFIG ERROR: {error_msg}",
                "node_count": 0,
            }, FederatedGraph.empty()
        raise

    # REQ-d00205-B: Extract root config from rebuilt graph for handler to sync
    root_config = None
    if hasattr(new_graph, "iter_repos"):
        for entry in new_graph.iter_repos():
            if entry.config is not None:
                root_config = entry.config
                break

    return {
        "success": True,
        "message": "Graph refreshed successfully",
        "node_count": new_graph.node_count(),
        "config": root_config,
    }, new_graph


def _matches_query(
    node: GraphNode,
    field: str,
    regex: bool,
    compiled_pattern: re.Pattern[str] | None,
    parsed: Any = None,
) -> bool:
    """Check if a node matches a query against specified fields.

    Reusable matching logic shared by search() and scoped_search().

    REQ-d00061-B: Supports field parameter (id, title, body, keywords, all).
    REQ-d00061-C: Supports regex=True for regex matching.
    REQ-d00061-F: Multi-term AND via parsed query delegation.
    REQ-p00050-D: Single code path for query matching.

    Args:
        node: The graph node to check.
        field: Which field(s) to match: "id", "title", "body", "keywords", or "all".
        regex: Whether to use regex matching.
        compiled_pattern: Pre-compiled regex pattern (required when regex=True).
        parsed: Pre-parsed ParsedQuery (used when regex=False).
    """
    # Implements: REQ-d00061-B, REQ-d00061-C, REQ-d00061-F, REQ-p00050-D

    if not regex:
        return matches_node(node, parsed, field) if parsed else False

    # Regex path
    if field in ("id", "all"):
        if compiled_pattern.search(node.id):  # type: ignore[union-attr]
            return True

    if field in ("title", "all"):
        title = node.get_label() or ""
        if compiled_pattern.search(title):  # type: ignore[union-attr]
            return True

    if field in ("body", "all"):
        from elspais.graph.render import reconstruct_body_text

        if node.kind == NodeKind.REQUIREMENT:
            body = reconstruct_body_text(node)
            if body and compiled_pattern.search(body):  # type: ignore[union-attr]
                return True

    if field in ("keywords", "all"):
        keywords = node.get_field("keywords", [])
        for keyword in keywords:
            if compiled_pattern.search(keyword):  # type: ignore[union-attr]
                return True

    return False


def _search(
    graph: FederatedGraph,
    query: str,
    field: str = "all",
    regex: bool = False,
    limit: int = 50,
) -> list[dict[str, Any]]:
    """Search requirements.

    REQ-d00061-A: Iterates graph.nodes_by_kind(REQUIREMENT).
    REQ-d00061-B: Supports field parameter (id, title, body, keywords, all).
    REQ-d00061-C: Supports regex=True for regex matching.
    REQ-d00061-D: Returns serialized requirement summaries.
    REQ-d00061-E: Limits results to prevent unbounded sizes.
    REQ-d00061-F: Multi-term AND queries via parsed query.
    REQ-d00061-L: Score and sort by relevance descending.
    REQ-d00061-M: Include score in results.
    """
    if regex:
        try:
            compiled_pattern = re.compile(query, re.IGNORECASE)
        except re.error:
            return []
        results: list[dict[str, Any]] = []
        for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
            if _matches_query(node, field, True, compiled_pattern):
                results.append(_serialize_requirement_summary(node))
                if len(results) >= limit:
                    break
        return results

    # Multi-term scored path
    parsed = parse_query(query)
    if parsed.is_empty:
        return []

    scored: list[tuple[float, dict[str, Any]]] = []
    for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
        s = score_node(node, parsed, field)
        if s > 0:
            entry = _serialize_requirement_summary(node)
            entry["score"] = s
            scored.append((s, entry))
    scored.sort(key=lambda x: x[0], reverse=True)
    return [entry for _, entry in scored[:limit]]


def _minimize_requirement_set(
    graph: FederatedGraph,
    req_ids: list[str],
    edge_kinds: set[EdgeKind],
) -> dict[str, Any]:
    """Prune a requirement set to its most-specific members.

    Removes ancestors already covered by more-specific descendants in the set.

    REQ-d00077-A: Resolves each ID via graph index, separating found/not_found.
    REQ-d00077-B: Walks UP via iter_outgoing_edges() filtered by edge_kinds.
    REQ-d00077-C: Prunes req R if another req C has R in its ancestor set.
    REQ-d00077-D: Records superseded_by for each pruned req.
    REQ-d00077-E: Returns {minimal_set, pruned, not_found, stats}.
    """
    # Implements: REQ-o00069-A, REQ-o00069-B, REQ-o00069-C, REQ-o00069-D, REQ-o00069-E
    # Implements: REQ-d00077-A, REQ-d00077-B, REQ-d00077-C, REQ-d00077-D, REQ-d00077-E

    # REQ-d00077-A: Resolve IDs, separating found/not_found
    found: dict[str, GraphNode] = {}
    not_found: list[str] = []
    for req_id in req_ids:
        node = graph.find_by_id(req_id)
        if node is not None and node.kind == NodeKind.REQUIREMENT:
            found[req_id] = node
        else:
            not_found.append(req_id)

    if not found:
        return {
            "minimal_set": [],
            "pruned": [],
            "not_found": not_found,
            "stats": {"input_count": len(req_ids), "minimal_count": 0, "pruned_count": 0},
        }

    found_ids = set(found.keys())

    # REQ-d00077-B: For each found req, collect transitive ancestors
    ancestor_map: dict[str, set[str]] = {}
    for req_id, node in found.items():
        ancestors: set[str] = set()
        visited: set[str] = set()
        stack = [node]
        while stack:
            current = stack.pop()
            for edge in current.iter_outgoing_edges():
                if edge.kind in edge_kinds:
                    parent = edge.target
                    if parent.id not in visited:
                        visited.add(parent.id)
                        ancestors.add(parent.id)
                        stack.append(parent)
        ancestor_map[req_id] = ancestors

    # REQ-d00077-C: Prune — R is redundant if another C in the set has R in its ancestors
    # REQ-d00077-D: Record superseded_by
    pruned_items: dict[str, list[str]] = {}  # pruned_id -> [superseding_ids]
    for req_id in found_ids:
        for other_id in found_ids:
            if other_id != req_id and req_id in ancestor_map[other_id]:
                if req_id not in pruned_items:
                    pruned_items[req_id] = []
                pruned_items[req_id].append(other_id)

    minimal_ids = found_ids - set(pruned_items.keys())

    # REQ-d00077-E: Build result
    minimal_set = [
        _serialize_requirement_summary(found[rid]) for rid in req_ids if rid in minimal_ids
    ]
    pruned = [
        {**_serialize_requirement_summary(found[rid]), "superseded_by": sorted(pruned_items[rid])}
        for rid in req_ids
        if rid in pruned_items
    ]

    return {
        "minimal_set": minimal_set,
        "pruned": pruned,
        "not_found": not_found,
        "stats": {
            "input_count": len(req_ids),
            "minimal_count": len(minimal_set),
            "pruned_count": len(pruned),
        },
    }


def _collect_scope_ids(
    graph: FederatedGraph,
    scope_id: str,
    direction: str,
) -> set[str] | None:
    """Collect node IDs reachable from scope_id in the given direction.

    REQ-d00078-A: BFS via iter_children() for descendants, walk via iter_parents() for ancestors.
    REQ-d00078-B: Includes scope_id itself, uses visited set for DAG dedup.

    Returns:
        Set of reachable node IDs, or None if scope_id not found.
    """
    # Implements: REQ-d00078-A, REQ-d00078-B
    scope_node = graph.find_by_id(scope_id)
    if scope_node is None:
        return None

    visited: set[str] = {scope_id}
    stack: list[GraphNode] = [scope_node]

    while stack:
        current = stack.pop()
        if direction == "descendants":
            neighbors = current.iter_children()
        else:  # ancestors
            neighbors = current.iter_parents()
        for neighbor in neighbors:
            if neighbor.id not in visited:
                visited.add(neighbor.id)
                stack.append(neighbor)

    return visited


def _match_assertions(
    node: GraphNode,
    include_assertions: bool,
    *,
    parsed: Any = None,
    compiled_pattern: re.Pattern[str] | None = None,
    field: str = "all",
) -> list[dict[str, Any]]:
    """Check assertion children for matches. Returns matched assertion dicts."""
    if not include_assertions:
        return []
    matched: list[dict[str, Any]] = []
    for child in node.iter_children():
        if child.kind != NodeKind.ASSERTION:
            continue
        if parsed is not None:
            if matches_node(child, parsed, field):
                matched.append(_serialize_assertion(child))
        elif compiled_pattern is not None:
            assertion_text = child.get_label() or ""
            if compiled_pattern.search(assertion_text):
                matched.append(_serialize_assertion(child))
    return matched


def _scoped_search_regex(
    graph: FederatedGraph,
    compiled_pattern: re.Pattern[str],
    scope_ids: set[str],
    scope_id: str,
    direction: str,
    field: str,
    include_assertions: bool,
    limit: int,
) -> dict[str, Any]:
    """Regex path for scoped search. Kept separate for clarity."""
    results: list[dict[str, Any]] = []
    for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
        if node.id not in scope_ids:
            continue
        matched = _matches_query(node, field, True, compiled_pattern)
        matched_assertions = _match_assertions(
            node,
            include_assertions,
            compiled_pattern=compiled_pattern,
        )
        if matched or matched_assertions:
            entry = _serialize_requirement_summary(node)
            if matched_assertions:
                entry["matched_assertions"] = matched_assertions
            results.append(entry)
            if len(results) >= limit:
                break
    return {"results": results, "scope_id": scope_id, "direction": direction}


def _scoped_search(
    graph: FederatedGraph,
    query: str,
    scope_id: str,
    direction: str = "descendants",
    field: str = "all",
    regex: bool = False,
    include_assertions: bool = False,
    limit: int = 50,
) -> dict[str, Any]:
    """Search requirements within a scoped subgraph.

    REQ-d00078-C: Iterates only REQUIREMENT nodes in the scope set.
    REQ-d00078-D: Checks assertion text when include_assertions=True.
    REQ-d00078-E: Returns results plus scope_id and direction metadata.
    REQ-o00070-E: Reuses _matches_query() for matching.
    REQ-d00061-F: Multi-term AND queries via parsed query.
    REQ-d00061-L: Score and sort by relevance descending.
    REQ-d00061-M: Include score in results.
    """
    # Implements: REQ-o00070-A, REQ-o00070-B, REQ-o00070-C, REQ-o00070-D, REQ-o00070-E
    # Implements: REQ-d00078-C, REQ-d00078-D, REQ-d00078-E

    # REQ-o00070-D: Return error if scope_id not found
    scope_ids = _collect_scope_ids(graph, scope_id, direction)
    if scope_ids is None:
        return {"error": f"Scope node '{scope_id}' not found"}

    if regex:
        try:
            compiled_pattern = re.compile(query, re.IGNORECASE)
        except re.error:
            return {"results": [], "scope_id": scope_id, "direction": direction}
        return _scoped_search_regex(
            graph,
            compiled_pattern,
            scope_ids,
            scope_id,
            direction,
            field,
            include_assertions,
            limit,
        )

    # Multi-term scored path
    parsed = parse_query(query)
    if parsed.is_empty:
        return {"results": [], "scope_id": scope_id, "direction": direction}

    scored: list[tuple[float, dict[str, Any]]] = []

    for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
        if node.id not in scope_ids:
            continue

        node_score = score_node(node, parsed, field)
        matched_assertions = _match_assertions(
            node,
            include_assertions,
            parsed=parsed,
            field=field,
        )

        if node_score > 0 or matched_assertions:
            entry = _serialize_requirement_summary(node)
            entry["score"] = node_score
            if matched_assertions:
                entry["matched_assertions"] = matched_assertions
            scored.append((node_score, entry))

    scored.sort(key=lambda x: x[0], reverse=True)
    return {
        "results": [entry for _, entry in scored[:limit]],
        "scope_id": scope_id,
        "direction": direction,
    }


def _discover_requirements(
    graph: FederatedGraph,
    query: str,
    scope_id: str,
    direction: str = "descendants",
    field: str = "all",
    regex: bool = False,
    include_assertions: bool = False,
    limit: int = 50,
    edge_kinds: set[EdgeKind] | None = None,
) -> dict[str, Any]:
    """Search within a subgraph and return only the most-specific matches.

    Chains _scoped_search() with _minimize_requirement_set() to prune
    ancestors from results.

    REQ-d00079-A: Calls _scoped_search(), extracts IDs, passes to _minimize_requirement_set().
    REQ-d00079-B: Returns {results, pruned, stats} with minimal-set items.
    REQ-d00079-C: Preserves matched_assertions metadata on minimal-set items.
    REQ-o00071-B: Chains scoped_search through minimize_requirement_set.
    """
    # Implements: REQ-o00071-A, REQ-o00071-B, REQ-o00071-C, REQ-o00071-D
    # Implements: REQ-d00079-A, REQ-d00079-B, REQ-d00079-C

    if edge_kinds is None:
        edge_kinds = {EdgeKind.IMPLEMENTS, EdgeKind.REFINES}

    # REQ-d00079-A: Get candidates via scoped_search
    scoped_result = _scoped_search(
        graph, query, scope_id, direction, field, regex, include_assertions, limit
    )

    # Propagate errors
    if "error" in scoped_result:
        return scoped_result

    candidates = scoped_result.get("results", [])
    if not candidates:
        return {
            "results": [],
            "pruned": [],
            "scope_id": scope_id,
            "direction": direction,
            "stats": {"candidate_count": 0, "minimal_count": 0, "pruned_count": 0},
        }

    # Extract IDs and call minimize
    candidate_ids = [r["id"] for r in candidates]
    minimize_result = _minimize_requirement_set(graph, candidate_ids, edge_kinds)

    # Build lookup for candidate data (preserves matched_assertions)
    candidate_lookup = {r["id"]: r for r in candidates}
    minimal_ids = {r["id"] for r in minimize_result["minimal_set"]}

    # REQ-d00079-C: Preserve matched_assertions from scoped_search
    results = [candidate_lookup[rid] for rid in candidate_ids if rid in minimal_ids]

    # Build pruned list with superseded_by from minimize
    pruned = minimize_result["pruned"]

    return {
        "results": results,
        "pruned": pruned,
        "scope_id": scope_id,
        "direction": direction,
        "stats": {
            "candidate_count": len(candidates),
            "minimal_count": len(results),
            "pruned_count": len(pruned),
        },
    }


def _discover_assertions(
    graph: FederatedGraph,
    query: str,
    scope_id: str = "",
    direction: str = "descendants",
    field: str = "all",
    regex: bool = False,
    limit: int = 50,
    edge_kinds: set[EdgeKind] | None = None,
) -> dict[str, Any]:
    """Search for assertions, returning them as top-level results with scores.

    When scope_id is non-empty, delegates to _discover_requirements (scoped
    search + minimize) with include_assertions=True, then flattens assertions
    into top-level results.  When scope_id is empty, searches all requirements
    globally (no scoping, no minimize — every requirement is a candidate).

    Each surviving requirement contributes all its assertions to the result.
    Assertions whose text directly matched the query receive a boosted score;
    assertions that only inherited relevance from their parent requirement
    receive the parent's score as a baseline.

    Returns:
        {"assertions": [...], "stats": {...}}
        Each assertion entry: {id, label, text, requirement_id,
        requirement_title, level, score, direct_match}.
    """
    if edge_kinds is None:
        edge_kinds = {EdgeKind.IMPLEMENTS, EdgeKind.REFINES}

    parsed = parse_query(query) if not regex else None

    # --- Find matching requirements with assertion metadata ---
    if scope_id:
        disc_result = _discover_requirements(
            graph,
            query,
            scope_id,
            direction,
            field,
            regex,
            include_assertions=True,
            limit=limit,
            edge_kinds=edge_kinds,
        )
        if "error" in disc_result:
            return disc_result
        matched_reqs = disc_result.get("results", [])
    else:
        # Global search: score all requirements, no scoping or minimize
        matched_reqs = _search_with_assertions(graph, query, field, regex, limit, parsed)

    # --- Flatten: promote assertions to top-level results ---
    assertion_results: list[dict[str, Any]] = []

    for req in matched_reqs:
        req_score = req.get("score", 0.0)
        req_id = req["id"]
        req_title = req.get("title", "")
        level = req.get("level", "")
        directly_matched = {a["id"] for a in req.get("matched_assertions", [])}

        # Find the actual requirement node to iterate ALL its assertions
        req_node = graph.find_by_id(req_id)
        if req_node is None:
            continue

        for child in req_node.iter_children():
            if child.kind != NodeKind.ASSERTION:
                continue

            # Score: direct text match gets boosted, others get parent baseline
            direct_match = child.id in directly_matched
            if direct_match and parsed is not None:
                assertion_score = req_score + score_node(child, parsed, field)
            elif direct_match:
                assertion_score = req_score + 10  # regex match bonus
            else:
                assertion_score = req_score

            assertion_results.append(
                {
                    "id": child.id,
                    "label": child.get_field("label"),
                    "text": child.get_label(),
                    "requirement_id": req_id,
                    "requirement_title": req_title,
                    "level": level,
                    "score": assertion_score,
                    "direct_match": direct_match,
                }
            )

    # Sort by score descending, limit
    assertion_results.sort(key=lambda a: a["score"], reverse=True)
    assertion_results = assertion_results[:limit]

    return {
        "assertions": assertion_results,
        "stats": {
            "requirements_matched": len(matched_reqs),
            "assertions_returned": len(assertion_results),
        },
    }


def _search_with_assertions(
    graph: FederatedGraph,
    query: str,
    field: str,
    regex: bool,
    limit: int,
    parsed: ParsedQuery | None,
) -> list[dict[str, Any]]:
    """Global search across all requirements, including assertion matching.

    Like _search() but also checks assertion text and returns
    matched_assertions metadata on each result.
    """
    if regex:
        try:
            compiled_pattern = re.compile(query, re.IGNORECASE)
        except re.error:
            return []
        results: list[dict[str, Any]] = []
        for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
            node_matched = _matches_query(node, field, True, compiled_pattern)
            matched_assertions = _match_assertions(
                node,
                True,
                compiled_pattern=compiled_pattern,
            )
            if node_matched or matched_assertions:
                entry = _serialize_requirement_summary(node)
                entry["score"] = 1.0
                if matched_assertions:
                    entry["matched_assertions"] = matched_assertions
                results.append(entry)
                if len(results) >= limit:
                    break
        return results

    if parsed is None or parsed.is_empty:
        return []

    scored: list[tuple[float, dict[str, Any]]] = []
    for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
        node_score = score_node(node, parsed, field)
        matched_assertions = _match_assertions(
            node,
            True,
            parsed=parsed,
            field=field,
        )
        if node_score > 0 or matched_assertions:
            entry = _serialize_requirement_summary(node)
            entry["score"] = node_score
            if matched_assertions:
                entry["matched_assertions"] = matched_assertions
            scored.append((node_score, entry))

    scored.sort(key=lambda x: x[0], reverse=True)
    return [entry for _, entry in scored[:limit]]


def _get_node(graph: FederatedGraph, node_id: str) -> dict[str, Any]:
    """Get any graph node by ID.

    Returns the generic node envelope with kind-specific properties.
    No kind restriction — any node kind is valid.

    Args:
        graph: The TraceGraph to query.
        node_id: The node ID to look up.

    Returns:
        Serialized node dict, or dict with 'error' key if not found.
    """
    node = graph.find_by_id(node_id)
    if node is None:
        return {"error": f"Node '{node_id}' not found"}
    return _serialize_node_generic(node, graph)


def _get_requirement(graph: FederatedGraph, req_id: str) -> dict[str, Any]:
    """Get requirement details in a focused, readable format.

    Returns only the fields useful for understanding a requirement:
    identity, body, assertions, hierarchy, and coverage metrics.
    For the full generic node envelope use ``_get_node()`` / ``get_node()``.

    REQ-d00062-A: Uses graph.find_by_id() for O(1) lookup.
    REQ-d00062-B: Returns node fields.
    REQ-d00062-C: Returns assertions from iter_children().
    REQ-d00062-D: Returns relationships from iter_outgoing_edges().
    REQ-d00062-E: Returns metrics from node without recomputation.
    REQ-d00062-F: Returns error for non-existent requirements.
    """
    from elspais.graph.relations import EdgeKind as EK
    from elspais.graph.render import reconstruct_body_text

    node = graph.find_by_id(req_id)

    if node is None:
        return {"error": f"Requirement '{req_id}' not found"}

    if node.kind != NodeKind.REQUIREMENT:
        return {"error": f"Node '{req_id}' is not a requirement"}

    # Assertions
    assertions = []
    for child in node.iter_children(edge_kinds={EK.STRUCTURES}):
        if child.kind == NodeKind.ASSERTION:
            assertions.append(
                {"id": child.id, "label": child.get_field("label"), "text": child.get_label()}
            )

    # Hierarchy: requirement parents and children only
    req_parents = []
    for edge in node.iter_incoming_edges():
        if (
            edge.kind in (EK.IMPLEMENTS, EK.REFINES, EK.SATISFIES)
            and edge.source.kind == NodeKind.REQUIREMENT
        ):
            req_parents.append(
                {
                    "id": edge.source.id,
                    "title": edge.source.get_label(),
                    "edge_kind": edge.kind.value,
                }
            )

    req_children = []
    for child in node.iter_children():
        if child.kind == NodeKind.REQUIREMENT:
            req_children.append(
                {"id": child.id, "title": child.get_label(), "level": child.get_field("level")}
            )

    # Coverage metrics
    metrics_data = None
    metrics = node.get_metric("rollup_metrics")
    if metrics is not None:
        metrics_data = {
            "referenced_pct": metrics.implemented.indirect_pct,
            "total_assertions": metrics.total_assertions,
            "covered_assertions": metrics.implemented.indirect,
        }

    # Source location
    fn = node.file_node()
    source_path = (
        _relative_source_path(node, graph)
        if graph
        else (fn.get_field("relative_path") if fn else None)
    )

    return {
        "id": node.id,
        "title": node.get_label(),
        "level": node.get_field("level"),
        "status": node.get_field("status"),
        "hash": node.get_field("hash"),
        "body": reconstruct_body_text(node),
        "source": {"path": source_path, "line": node.get_field("parse_line")},
        "assertions": assertions,
        "parents": req_parents,
        "children": req_children,
        "coverage": metrics_data,
    }


def _get_hierarchy(graph: FederatedGraph, req_id: str) -> dict[str, Any]:
    """Get requirement hierarchy.

    REQ-d00063-A: Returns ancestors by walking iter_parents() recursively.
    REQ-d00063-B: Returns children from iter_children().
    REQ-d00063-D: Returns node summaries (id, title, level).
    REQ-d00063-E: Handles DAG with multiple parents.
    """
    node = graph.find_by_id(req_id)

    if node is None:
        return {"error": f"Requirement '{req_id}' not found"}

    # Collect ancestors recursively (handles DAG)
    ancestors = []
    visited = set()

    def walk_ancestors(n):
        for parent in n.iter_parents():
            if parent.id not in visited and parent.kind == NodeKind.REQUIREMENT:
                visited.add(parent.id)
                ancestors.append(_serialize_requirement_summary(parent))
                walk_ancestors(parent)

    walk_ancestors(node)

    # Collect children (only requirements, not assertions)
    children = []
    for child in node.iter_children():
        if child.kind == NodeKind.REQUIREMENT:
            children.append(_serialize_requirement_summary(child))

    return {
        "id": req_id,
        "ancestors": ancestors,
        "children": children,
    }


# ─────────────────────────────────────────────────────────────────────────────
# Workspace Context Tools (REQ-o00061)
# ─────────────────────────────────────────────────────────────────────────────

# Detail profile descriptions for discoverability
_WORKSPACE_DETAIL_PROFILES: dict[str, str] = {
    "default": "Basic project info and version",
    "testing": "ID patterns, assertion format, test configuration",
    "code-refs": "Code directories, comment styles, reference keywords",
    "coverage": "Coverage stats, level counts, associate list",
    "retrofit": "Full patterns, hierarchy rules, code + test config",
    "manager": "Health flags, coverage stats, change metrics",
    "worktree": "Associate paths, ID patterns, hierarchy rules",
    "all": "Everything from all profiles combined",
}


def _get_elspais_version() -> str:
    """Return the installed elspais version string."""
    try:
        from elspais import __version__

        return __version__
    except Exception:
        return "unknown"


def _build_base_workspace_info(working_dir: Path, config: dict[str, Any]) -> dict[str, Any]:
    """Build the base workspace info dict (always returned).

    REQ-o00061-A: Returns repository path, project name, and configuration summary.
    REQ-o00061-D: Reads configuration from unified config system.
    """
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    project_name = typed_config.project.name or "unknown"

    config_file = find_config_file(working_dir)

    # Check for local override file
    local_config_exists = False
    if config_file:
        local_path = config_file.parent / ".elspais.local.toml"
        local_config_exists = local_path.is_file()

    config_summary = {
        "prefix": typed_config.project.namespace,
        "spec_directories": typed_config.scanning.spec.directories,
        "testing_enabled": typed_config.scanning.test.enabled,
        "local_config": local_config_exists,
    }

    return {
        "repo_path": str(working_dir),
        "project_name": project_name,
        "elspais_version": _get_elspais_version(),
        "config_file": str(config_file) if config_file else None,
        "detail": "default",
        "available_details": dict(_WORKSPACE_DETAIL_PROFILES),
        "config_summary": config_summary,
    }


# ── Shared profile helpers ──────────────────────────────────────────────────


def _build_id_patterns(config: dict[str, Any]) -> dict[str, Any]:
    """Build ID pattern info from config."""
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    namespace = typed_config.project.namespace
    id_patterns = typed_config.id_patterns
    canonical = id_patterns.canonical
    component = id_patterns.component

    # Synthesize example IDs for each type
    digits = component.digits
    leading_zeros = component.leading_zeros
    example_num = "0" * (digits - 1) + "1" if leading_zeros else "1"
    examples = {}
    for level_key, level_config in typed_config.levels.items():
        type_letter = level_config.letter
        examples[level_key] = f"{namespace}-{type_letter}{example_num}"

    return {
        "prefix": namespace,
        "template": canonical,
        "id_format": component.model_dump(),
        "levels": {k: v.model_dump() for k, v in typed_config.levels.items()},
        "examples": examples,
    }


def _build_assertion_format(config: dict[str, Any]) -> dict[str, Any]:
    """Build assertion format info from config."""
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    namespace = typed_config.project.namespace
    id_patterns = typed_config.id_patterns
    assertions = id_patterns.assertions

    # Pick first type for the example
    first_type_letter = "p"
    for _key, level_config in typed_config.levels.items():
        first_type_letter = level_config.letter
        break

    digits = id_patterns.component.digits
    example_num = "0" * (digits - 1) + "1"

    # Read multi-assertion separator from id-patterns assertions (default: "+")
    ma_sep = assertions.multi_separator or "+"

    return {
        "label_style": assertions.label_style,
        "max_count": assertions.max_count,
        "example": f"{namespace}-{first_type_letter}{example_num}-A",
        "multi_assertion_syntax": (
            f"{namespace}-{first_type_letter}{example_num}-A{ma_sep}B{ma_sep}C expands to "
            f"{namespace}-{first_type_letter}{example_num}-A, "
            f"{namespace}-{first_type_letter}{example_num}-B, "
            f"{namespace}-{first_type_letter}{example_num}-C"
        ),
    }


def _build_hierarchy_rules(config: dict[str, Any]) -> dict[str, Any]:
    """Build hierarchy rules info from config."""
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    levels = typed_config.levels

    # Build from levels config
    rules = {}
    for name, level in levels.items():
        if level.implements:
            rules[name] = list(level.implements)
    return {"rules": rules}


def _build_coverage_stats(graph: FederatedGraph | None, config: dict[str, Any]) -> dict[str, Any]:
    """Build coverage statistics from graph."""
    if graph is None:
        return {"error": "graph not available"}

    from elspais.config import get_status_roles

    exclude = get_status_roles(config).coverage_excluded_statuses()
    return {
        "by_coverage": count_by_coverage(graph, exclude_status=exclude),
        "by_level": count_by_level(graph, config=config),
        "code_reference_coverage": count_with_code_refs(graph, exclude_status=exclude),
    }


def _build_associates_info(
    config: dict[str, Any], working_dir: Path, include_paths: bool = False
) -> dict[str, Any]:
    """Build associates info from [associates] TOML config."""
    try:
        from elspais.config import get_associates_config

        assoc_map = get_associates_config(config)
    except Exception:
        return {"count": 0, "associates": [], "config_file": None}

    result = []
    for name, entry in assoc_map.items():
        info: dict[str, Any] = {
            "name": name,
            "code": name,
            "enabled": True,
        }
        if include_paths:
            info["path"] = entry.get("path", "")
            info["local_path"] = None
            info["spec_path"] = "spec"
        result.append(info)

    return {
        "count": len(result),
        "associates": result,
        "config_file": None,
    }


def _build_change_metrics(graph: FederatedGraph | None) -> dict[str, Any]:
    """Build change metrics from graph."""
    if graph is None:
        return {"error": "graph not available"}

    annotate_graph_git_state(graph)
    return count_by_git_status(graph)


# ── Profile functions ───────────────────────────────────────────────────────


def _workspace_profile_testing(
    base: dict[str, Any],
    working_dir: Path,
    config: dict[str, Any],
    graph: FederatedGraph | None,
) -> dict[str, Any]:
    """Profile for writing/updating tests with REQ references."""
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    result = dict(base)
    result["detail"] = "testing"

    result["id_patterns"] = _build_id_patterns(config)
    result["assertion_format"] = _build_assertion_format(config)
    result["testing"] = {
        "reference_keyword": typed_config.scanning.test.reference_keyword,
        "test_dirs": typed_config.scanning.test.directories,
        "file_patterns": typed_config.scanning.test.file_patterns,
        "result_files": typed_config.scanning.result.file_patterns,
    }

    return result


def _workspace_profile_code_refs(
    base: dict[str, Any],
    working_dir: Path,
    config: dict[str, Any],
    graph: FederatedGraph | None,
) -> dict[str, Any]:
    """Profile for adding code references (# Implements: comments)."""
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    result = dict(base)
    result["detail"] = "code-refs"

    result["id_patterns"] = _build_id_patterns(config)
    result["code_references"] = {
        "code_directories": list(typed_config.scanning.code.directories),
        "separators": typed_config.id_patterns.separators,
        "case_sensitive": False,
    }
    result["assertion_format"] = _build_assertion_format(config)

    return result


def _workspace_profile_coverage(
    base: dict[str, Any],
    working_dir: Path,
    config: dict[str, Any],
    graph: FederatedGraph | None,
) -> dict[str, Any]:
    """Profile for sign-off/coverage reporting."""
    result = dict(base)
    result["detail"] = "coverage"

    result["coverage_stats"] = _build_coverage_stats(graph, config)
    result["associates"] = _build_associates_info(config, working_dir, include_paths=False)

    return result


def _workspace_profile_retrofit(
    base: dict[str, Any],
    working_dir: Path,
    config: dict[str, Any],
    graph: FederatedGraph | None,
) -> dict[str, Any]:
    """Profile for systematically adding traceability to existing code."""
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    result = dict(base)
    result["detail"] = "retrofit"

    result["id_patterns"] = _build_id_patterns(config)
    result["assertion_format"] = _build_assertion_format(config)
    result["hierarchy_rules"] = _build_hierarchy_rules(config)

    result["code_references"] = {
        "code_directories": list(typed_config.scanning.code.directories),
        "separators": typed_config.id_patterns.separators,
        "case_sensitive": False,
    }

    result["testing"] = {
        "reference_keyword": typed_config.scanning.test.reference_keyword,
        "test_dirs": typed_config.scanning.test.directories,
        "file_patterns": typed_config.scanning.test.file_patterns,
        "result_files": typed_config.scanning.result.file_patterns,
    }

    result["associates"] = _build_associates_info(config, working_dir, include_paths=False)

    return result


def _workspace_profile_manager(
    base: dict[str, Any],
    working_dir: Path,
    config: dict[str, Any],
    graph: FederatedGraph | None,
) -> dict[str, Any]:
    """Profile for manager quick status/health check."""
    result = dict(base)
    result["detail"] = "manager"

    result["coverage_stats"] = _build_coverage_stats(graph, config)
    result["health"] = {
        "has_orphans": graph.has_orphans() if graph else None,
        "has_broken_references": graph.has_broken_references() if graph else None,
        "orphan_count": graph.orphan_count() if graph else None,
        "broken_reference_count": (len(graph.broken_references()) if graph else None),
    }
    result["change_metrics"] = _build_change_metrics(graph)

    return result


def _workspace_profile_worktree(
    base: dict[str, Any],
    working_dir: Path,
    config: dict[str, Any],
    graph: FederatedGraph | None,
) -> dict[str, Any]:
    """Profile for bulk changes across repos (renumbering, worktree ops)."""
    result = dict(base)
    result["detail"] = "worktree"

    result["id_patterns"] = _build_id_patterns(config)
    result["hierarchy_rules"] = _build_hierarchy_rules(config)
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    result["associates"] = _build_associates_info(config, working_dir, include_paths=True)
    result["config_summary"]["spec_directories"] = typed_config.scanning.spec.directories

    return result


def _workspace_profile_all(
    base: dict[str, Any],
    working_dir: Path,
    config: dict[str, Any],
    graph: FederatedGraph | None,
) -> dict[str, Any]:
    """Profile returning all available information."""
    typed_config = _validate_config(config) if isinstance(config, dict) else config
    result = dict(base)
    result["detail"] = "all"

    # ID patterns and assertion format
    result["id_patterns"] = _build_id_patterns(config)
    result["assertion_format"] = _build_assertion_format(config)
    result["hierarchy_rules"] = _build_hierarchy_rules(config)

    # Code references
    result["code_references"] = {
        "code_directories": list(typed_config.scanning.code.directories),
        "separators": typed_config.id_patterns.separators,
        "case_sensitive": False,
    }

    # Testing
    result["testing"] = {
        "reference_keyword": typed_config.scanning.test.reference_keyword,
        "test_dirs": typed_config.scanning.test.directories,
        "file_patterns": typed_config.scanning.test.file_patterns,
        "result_files": typed_config.scanning.result.file_patterns,
    }

    # Coverage and health (graph-dependent)
    result["coverage_stats"] = _build_coverage_stats(graph, config)
    result["health"] = {
        "has_orphans": graph.has_orphans() if graph else None,
        "has_broken_references": graph.has_broken_references() if graph else None,
        "orphan_count": graph.orphan_count() if graph else None,
        "broken_reference_count": (len(graph.broken_references()) if graph else None),
    }
    result["change_metrics"] = _build_change_metrics(graph)

    # Associates with full paths
    result["associates"] = _build_associates_info(config, working_dir, include_paths=True)

    return result


_WORKSPACE_PROFILE_DISPATCH: dict[str, Any] = {
    "testing": _workspace_profile_testing,
    "code-refs": _workspace_profile_code_refs,
    "coverage": _workspace_profile_coverage,
    "retrofit": _workspace_profile_retrofit,
    "manager": _workspace_profile_manager,
    "worktree": _workspace_profile_worktree,
    "all": _workspace_profile_all,
}


# Implements: REQ-d00205-A, REQ-d00205-D
def _get_workspace_info(
    working_dir: Path,
    config: dict[str, Any] | None = None,
    graph: FederatedGraph | None = None,
    detail: str = "default",
) -> dict[str, Any]:
    """Get workspace information with use-case-driven detail levels.

    REQ-o00061-A: Returns repository path, project name, and configuration summary.
    REQ-o00061-D: Reads configuration from unified config system.
    REQ-d00205-A: Includes federation details when multiple repos present.
    REQ-d00205-D: Derives root config from graph when config not provided.

    Args:
        working_dir: The repository root directory.
        config: Optional pre-loaded config dict.
        graph: Optional TraceGraph for coverage/health profiles.
        detail: Detail profile to use. See _WORKSPACE_DETAIL_PROFILES.

    Returns:
        Workspace information dict with profile-specific sections.
    """
    # REQ-d00205-D: Derive root config from graph when not provided
    if config is None and graph is not None and hasattr(graph, "iter_repos"):
        for entry in graph.iter_repos():
            if entry.config is not None:
                config = entry.config
                break
    if config is None:
        config = get_config(start_path=working_dir, quiet=True)

    base = _build_base_workspace_info(working_dir, config)

    # REQ-d00205-A: Include federation details when multi-repo graph present
    if graph is not None and hasattr(graph, "iter_repos"):
        repos_info = []
        for entry in graph.iter_repos():
            repo_info: dict[str, Any] = {
                "name": entry.name,
                "path": str(entry.repo_root),
                "status": "error" if entry.graph is None else "ok",
            }
            if entry.git_origin:
                repo_info["git_origin"] = entry.git_origin
            if entry.error:
                repo_info["error"] = entry.error
            repos_info.append(repo_info)
        if len(repos_info) > 1:
            base["federation"] = {"repos": repos_info, "root_repo": graph._root_repo}

    if detail == "default":
        return base

    profile_fn = _WORKSPACE_PROFILE_DISPATCH.get(detail)
    if profile_fn is None:
        base["warning"] = f"Unknown detail '{detail}'. Returning default info."
        return base

    return profile_fn(base, working_dir, config, graph)


def _get_project_summary(
    graph: FederatedGraph, working_dir: Path, config: dict[str, Any] | None = None
) -> dict[str, Any]:
    """Get project summary statistics.

    REQ-o00061-B: Returns requirement counts by level, coverage statistics, and change metrics.
    REQ-o00061-C: Uses graph aggregate functions from annotators module.

    Args:
        graph: The TraceGraph to analyze.
        working_dir: The repository root directory.
        config: Optional config dict for deriving level keys.

    Returns:
        Project summary dict.
    """
    # Use aggregate functions from annotators (REQ-o00061-C)
    level_counts = count_by_level(graph, config=config)
    from elspais.config import get_status_roles

    coverage_exclude = get_status_roles(config or {}).coverage_excluded_statuses()
    coverage_stats = count_by_coverage(graph, exclude_status=coverage_exclude)
    # Annotate git state before counting (idempotent, safe to call multiple times)
    annotate_graph_git_state(graph)
    change_metrics = count_by_git_status(graph)

    result: dict[str, Any] = {
        "requirements_by_level": level_counts,
        "coverage": coverage_stats,
        "changes": change_metrics,
        "total_nodes": graph.node_count(),
        "orphan_count": graph.orphan_count(),
        "broken_reference_count": len(graph.broken_references()),
    }

    code_cov = count_code_coverage(graph)
    if code_cov["total_executable_lines"] > 0:
        result["code_coverage"] = code_cov

    return result


def _get_changed_requirements(graph: FederatedGraph) -> dict[str, Any]:
    """Get requirements with git changes.

    Annotates the graph with git state, then filters for requirement nodes
    where any git flag is True.

    Args:
        graph: The TraceGraph to analyze.

    Returns:
        Dict with 'requirements' list and 'summary' counts.
    """
    annotate_graph_git_state(graph)

    changed: list[dict[str, Any]] = []
    for node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
        is_uncommitted = node.get_metric("is_uncommitted", False)
        is_branch_changed = node.get_metric("is_branch_changed", False)
        is_moved = node.get_metric("is_moved", False)

        if is_uncommitted or is_branch_changed or is_moved:
            entry = _serialize_requirement_summary(node)
            entry["git_state"] = {
                "is_uncommitted": is_uncommitted,
                "is_untracked": node.get_metric("is_untracked", False),
                "is_modified": node.get_metric("is_modified", False),
                "is_branch_changed": is_branch_changed,
                "is_moved": is_moved,
                "is_new": node.get_metric("is_new", False),
            }
            entry["source"] = _relative_source_path(node, graph) or None
            changed.append(entry)

    summary = count_by_git_status(graph)

    return {
        "requirements": changed,
        "count": len(changed),
        "summary": summary,
    }


def _get_agent_instructions(config: dict[str, Any], working_dir: Path) -> dict[str, Any]:
    """Load all content rules configured for this project.

    Args:
        config: Project configuration dict.
        working_dir: Repository root directory.

    Returns:
        Dict with 'instructions' list and 'count'.
    """
    from elspais.content_rules import load_content_rules

    rules = load_content_rules(config, working_dir)
    if not rules:
        return {"instructions": [], "count": 0}

    return {
        "instructions": [
            {
                "title": rule.title,
                "type": rule.type,
                "applies_to": rule.applies_to,
                "content": rule.content,
            }
            for rule in rules
        ],
        "count": len(rules),
    }


_TOOL_DOCS: dict[str, str] = {}  # Populated by create_server() after tool registration

_FAQ_ENTRIES: list[dict[str, str]] = [
    {
        "topic": "linking",
        "question": "How do I link a test to a requirement?",
        "answer": (
            "Add a comment above the test function at column 0 (no indentation):\n"
            "\n"
            "  # Implements: REQ-d00001-A\n"
            "  def test_hashing(self):\n"
            "      ...\n"
            "\n"
            "Any keyword works: Implements, Tests, Validates, Refines.\n"
            "All create VERIFIES edges in the traceability graph.\n"
            "The comment character (#, //, --) MUST start at column 0."
        ),
    },
    {
        "topic": "linking",
        "question": "Why is my test still showing as unlinked?",
        "answer": (
            "Common causes:\n"
            "1. The comment is indented (must start at column 0)\n"
            "2. The requirement ID doesn't exist in any spec file\n"
            "3. The graph hasn't been refreshed (run refresh_graph(full=True))\n"
            "4. The file isn't in a configured test directory (check scanning.test.directories)"
        ),
    },
    {
        "topic": "linking",
        "question": "Can I use # Implements: in test files or only # Tests:?",
        "answer": (
            "Both work identically in test files. The scanner recognizes all reference\n"
            "keywords (Implements, Tests, Validates, Refines) and always creates VERIFIES\n"
            "edges for test files. The keyword choice is purely stylistic."
        ),
    },
    {
        "topic": "linking",
        "question": "How do I link a test to multiple assertions?",
        "answer": (
            "Use multi-assertion syntax with + separator:\n"
            "\n"
            "  # Implements: REQ-d00001-A+B+C\n"
            "\n"
            "This expands to REQ-d00001-A, REQ-d00001-B, REQ-d00001-C.\n"
            "Or list them comma-separated:\n"
            "\n"
            "  # Implements: REQ-d00001-A, REQ-d00002-B"
        ),
    },
    {
        "topic": "linking",
        "question": "How do file-level vs function-level links work?",
        "answer": (
            "A comment placed before the first function definition applies to ALL\n"
            "test functions in the file (file-level default).\n"
            "A comment placed above a specific function applies only to that function.\n"
            "Function-level links override file-level defaults."
        ),
    },
    {
        "topic": "coverage",
        "question": "What is the difference between direct and indirect coverage?",
        "answer": (
            "Direct coverage: a test explicitly references a requirement via comment\n"
            "or function name.\n"
            "Indirect coverage: a test covers code that implements a requirement,\n"
            "creating an inferred coverage chain (test -> code -> requirement).\n"
            "Both contribute to the coverage percentage."
        ),
    },
    {
        "topic": "coverage",
        "question": "Why does coverage show less than I expect?",
        "answer": (
            "Coverage is calculated per-assertion, not per-requirement.\n"
            "A requirement with 5 assertions needs all 5 covered.\n"
            "Check with get_test_coverage(req_id) to see which assertions are covered."
        ),
    },
    {
        "topic": "coverage",
        "question": "How do I find coverage gaps?",
        "answer": (
            "CLI: Use gap flags on the health command:\n"
            "  elspais health --untested    (requirements without test coverage)\n"
            "  elspais health --uncovered   (requirements without code coverage)\n"
            "  elspais health --unvalidated (requirements without UAT coverage)\n"
            "  elspais health --untraced    (all gaps: uncovered + untested"
            " + unvalidated + failing)\n"
            "  elspais health --failing     (requirements with failing results)\n"
            "\n"
            "MCP: Use these tools:\n"
            "  get_uncovered_assertions()          (all assertions without test coverage)\n"
            "  get_uncovered_assertions(req_id)     (gaps for a specific requirement)\n"
            "  get_test_coverage(req_id)            (detailed coverage breakdown)\n"
            "  get_project_summary()                (high-level coverage statistics)"
        ),
    },
    {
        "topic": "mutation",
        "question": "How do I persist changes made with mutate_* tools?",
        "answer": (
            "All mutate_* tools modify the in-memory graph only.\n"
            "To persist to spec files, call save_mutations().\n"
            "Use save_mutations(save_branch=True) to create a safety branch first.\n"
            "Use undo_last_mutation() or undo_to_mutation(id) to revert mistakes."
        ),
    },
    {
        "topic": "health",
        "question": "What does 'structural orphan' mean vs 'unlinked'?",
        "answer": (
            "Structural orphan: a node with no FILE parent (not contained in any file).\n"
            "This usually indicates a graph build error.\n"
            "Unlinked: a CODE or TEST node that exists in a file but has no traceability\n"
            "edge to any requirement. This is normal for utility code/tests."
        ),
    },
    {
        "topic": "assertions",
        "question": "What is the assertion format?",
        "answer": (
            "Assertions are lettered sub-items of a requirement:\n"
            "\n"
            "  **A.** The system SHALL do X.\n"
            "  **B.** The system SHALL do Y.\n"
            "\n"
            "Labels are uppercase letters (A-Z). Use SHALL for mandatory behavior.\n"
            "Reference them as REQ-d00001-A, REQ-d00001-B, etc."
        ),
    },
]


def _get_faq(topic: str) -> dict[str, Any]:
    """Return FAQ entries, optionally filtered by topic keyword."""
    if topic:
        needle = topic.lower()
        entries = [
            e
            for e in _FAQ_ENTRIES
            if needle in e["topic"]
            or needle in e["question"].lower()
            or needle in e["answer"].lower()
        ]
    else:
        entries = list(_FAQ_ENTRIES)

    return {
        "entries": entries,
        "count": len(entries),
        "filter": topic or "(all)",
    }


def _get_docs(topic: str) -> dict[str, Any]:
    """Return documentation content for a topic, or search all help surfaces.

    If topic matches an exact topic name, returns that topic's full content.
    Otherwise, searches docs, CLI flag docstrings, MCP tool docstrings,
    and FAQ entries for the query string, returning full matched sections
    with source labels.
    """
    from elspais.utilities.docs_loader import get_available_topics, load_topic

    available = get_available_topics()

    if not topic:
        return {
            "topics": available,
            "count": len(available),
            "hint": (
                "Call docs(topic) with a topic name,"
                " or pass a search phrase to find relevant sections."
            ),
        }

    # Exact topic match
    content = load_topic(topic)
    if content is not None:
        return {
            "topic": topic,
            "content": content,
        }

    # Search across all help surfaces
    needle = topic.lower()
    matches: list[dict[str, Any]] = []

    # Build variant sets for fuzzy singular/plural matching.
    # For each query word, generate {word, word±s} so "coverage gaps"
    # matches text containing "coverage" AND ("gap" OR "gaps").
    words = needle.split()
    variant_sets: list[set[str]] = []
    for w in words:
        variants = {w}
        if w.endswith("s") and len(w) > 2:
            variants.add(w[:-1])  # strip trailing s
        else:
            variants.add(w + "s")  # add trailing s
        variant_sets.append(variants)

    def _text_matches(text: str) -> bool:
        """Return True if text contains at least one variant from each set."""
        lowered = text.lower()
        # Fast path: single-word or exact phrase match
        if needle in lowered:
            return True
        # Multi-word: each variant set must have at least one hit
        return all(any(v in lowered for v in vs) for vs in variant_sets)

    # 1. Search docs/cli/*.md files (section-level granularity)
    for t in available:
        tc = load_topic(t)
        if tc is None:
            continue
        lines = tc.splitlines()
        sections: list[tuple[int, int]] = []
        section_start = 0
        for i, line in enumerate(lines):
            if line.startswith("## ") and i > 0:
                sections.append((section_start, i))
                section_start = i
        sections.append((section_start, len(lines)))

        for s_start, s_end in sections:
            section_text = "\n".join(lines[s_start:s_end]).rstrip("\n")
            if _text_matches(section_text):
                matches.append(
                    {
                        "source": f"docs/cli/{t}.md",
                        "content": section_text,
                    }
                )

    # 2. Search CLI flag docstrings (from args.py dataclasses)
    try:
        import dataclasses

        from elspais.commands import args as args_mod

        for name in dir(args_mod):
            obj = getattr(args_mod, name)
            if not dataclasses.is_dataclass(obj) or not isinstance(obj, type):
                continue
            for field in dataclasses.fields(obj):
                doc = field.metadata.get("help", "") or ""
                # tyro uses the field's docstring from the class body
                # Access via __dataclass_fields__ won't have it; check class source
                if not doc:
                    # Get the docstring from the class annotations
                    # Fall back to field name + type
                    doc = ""
                # Search field name + any doc
                field_text = f"--{field.name}: {doc}" if doc else f"--{field.name}"
                if _text_matches(field_text) or _text_matches(field.name):
                    matches.append(
                        {
                            "source": f"cli:{name}.{field.name}",
                            "content": field_text,
                        }
                    )
    except Exception:
        pass

    # 3. Search MCP tool docstrings (collected at registration)
    for tool_name, doc in _TOOL_DOCS.items():
        if _text_matches(doc) or _text_matches(tool_name):
            matches.append(
                {
                    "source": f"mcp:{tool_name}",
                    "content": doc,
                }
            )

    # 4. Search FAQ entries
    for entry in _FAQ_ENTRIES:
        combined = f"{entry['question']}\n{entry['answer']}"
        if _text_matches(combined):
            matches.append(
                {
                    "source": f"faq:{entry['topic']}",
                    "content": f"Q: {entry['question']}\nA: {entry['answer']}",
                }
            )

    if not matches:
        return {
            "query": topic,
            "matches": [],
            "hint": f"No results for '{topic}'. Available topics: {', '.join(available)}",
        }

    return {
        "query": topic,
        "matches": matches,
        "count": len(matches),
    }


# ─────────────────────────────────────────────────────────────────────────────
# Mutation Tool Functions (REQ-o00062)
# ─────────────────────────────────────────────────────────────────────────────


def _mutate_rename_node(graph: FederatedGraph, old_id: str, new_id: str) -> dict[str, Any]:
    """Rename a node.

    REQ-d00065-A: Delegates to graph.rename_node().
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.rename_node(old_id, new_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Renamed {old_id} to {new_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_update_title(graph: FederatedGraph, node_id: str, new_title: str) -> dict[str, Any]:
    """Update requirement title.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.update_title(node_id, new_title)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Updated title of {node_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_change_status(graph: FederatedGraph, node_id: str, new_status: str) -> dict[str, Any]:
    """Change requirement status.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.change_status(node_id, new_status)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Changed status of {node_id} to {new_status}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_add_requirement(
    graph: FederatedGraph,
    req_id: str,
    title: str,
    level: str,
    status: str = "Draft",
    parent_id: str | None = None,
    edge_kind: str | None = None,
) -> dict[str, Any]:
    """Add a new requirement.

    REQ-d00065-B: Delegates to graph.add_requirement().
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        # Convert edge_kind string to EdgeKind enum if provided
        edge_kind_enum = None
        if edge_kind:
            edge_kind_enum = EdgeKind[edge_kind.upper()]

        entry = graph.add_requirement(
            req_id=req_id,
            title=title,
            level=level,
            status=status,
            parent_id=parent_id,
            edge_kind=edge_kind_enum,
        )
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Added requirement {req_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_delete_requirement(
    graph: FederatedGraph, node_id: str, confirm: bool = False
) -> dict[str, Any]:
    """Delete a requirement.

    REQ-d00065-C: Calls graph.delete_requirement() only if confirm=True.
    REQ-o00062-F: Requires confirm=True for destructive operations.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    if not confirm:
        return {
            "success": False,
            "error": "Destructive operation requires confirm=True",
        }

    try:
        entry = graph.delete_requirement(node_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Deleted requirement {node_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# ─────────────────────────────────────────────────────────────────────────────
# Journey Mutation Functions
# ─────────────────────────────────────────────────────────────────────────────


def _mutate_update_journey_field(
    graph: FederatedGraph,
    node_id: str,
    field_name: str,
    value: str,
) -> dict[str, Any]:
    """Update a structured field on a USER_JOURNEY node."""
    try:
        entry = graph.update_journey_field(node_id, field_name, value)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Updated {field_name} of {node_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_journey_section(
    graph: FederatedGraph,
    node_id: str,
    action: str,
    name: str,
    new_name: str | None = None,
    content: str | None = None,
) -> dict[str, Any]:
    """Add, update, or delete a journey section."""
    try:
        if action == "add":
            entry = graph.add_journey_section(node_id, name, content or "")
            msg = f"Added section '{name}' to {node_id}"
        elif action == "update":
            entry = graph.update_journey_section(node_id, name, new_name, content)
            msg = f"Updated section '{name}' in {node_id}"
        elif action == "delete":
            entry = graph.delete_journey_section(node_id, name)
            msg = f"Deleted section '{name}' from {node_id}"
        else:
            return {"success": False, "error": f"Unknown action: {action}"}
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": msg,
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_add_journey(
    graph: FederatedGraph,
    journey_id: str,
    title: str,
    file_id: str,
) -> dict[str, Any]:
    """Create a new USER_JOURNEY node."""
    try:
        entry = graph.add_journey(journey_id, title, file_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Added journey {journey_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_delete_journey(
    graph: FederatedGraph,
    node_id: str,
    confirm: bool = False,
) -> dict[str, Any]:
    """Delete a USER_JOURNEY node."""
    if not confirm:
        return {
            "success": False,
            "error": "Destructive operation requires confirm=True",
        }
    try:
        entry = graph.delete_journey(node_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Deleted journey {node_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# ─────────────────────────────────────────────────────────────────────────────
# Assertion Mutation Functions (REQ-o00062-B)
# ─────────────────────────────────────────────────────────────────────────────


def _mutate_add_assertion(
    graph: FederatedGraph, req_id: str, label: str, text: str
) -> dict[str, Any]:
    """Add assertion to requirement.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.add_assertion(req_id, label, text)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Added assertion {req_id}-{label}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_update_assertion(
    graph: FederatedGraph, assertion_id: str, new_text: str
) -> dict[str, Any]:
    """Update assertion text.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.update_assertion(assertion_id, new_text)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Updated assertion {assertion_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_delete_assertion(
    graph: FederatedGraph,
    assertion_id: str,
    compact: bool = True,
    confirm: bool = False,
) -> dict[str, Any]:
    """Delete assertion.

    REQ-o00062-F: Requires confirm=True for destructive operations.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    if not confirm:
        return {
            "success": False,
            "error": "Destructive operation requires confirm=True",
        }

    try:
        entry = graph.delete_assertion(assertion_id, compact=compact)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Deleted assertion {assertion_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_rename_assertion(graph: FederatedGraph, old_id: str, new_label: str) -> dict[str, Any]:
    """Rename assertion label.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.rename_assertion(old_id, new_label)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Renamed assertion {old_id} to new label {new_label}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# ─────────────────────────────────────────────────────────────────────────────
# REMAINDER Section Mutation Functions
# ─────────────────────────────────────────────────────────────────────────────


def _mutate_update_remainder(
    graph: FederatedGraph, node_id: str, text: str | None = None, heading: str | None = None
) -> dict[str, Any]:
    """Update text and/or heading of a REMAINDER node."""
    try:
        entry = graph.update_remainder(node_id, text=text, heading=heading)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Updated remainder {node_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_add_remainder(
    graph: FederatedGraph, req_id: str, heading: str, text: str
) -> dict[str, Any]:
    """Create a new REMAINDER section on a requirement."""
    try:
        entry = graph.add_remainder(req_id, heading, text)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Added section '{heading}' to {req_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_delete_remainder(graph: FederatedGraph, node_id: str) -> dict[str, Any]:
    """Delete a REMAINDER section node."""
    try:
        entry = graph.delete_remainder(node_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Deleted remainder {node_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# ─────────────────────────────────────────────────────────────────────────────
# Edge Mutation Functions (REQ-o00062-C)
# ─────────────────────────────────────────────────────────────────────────────


def _normalize_assertion_targets(
    targets: list[str], target_id: str, config: dict[str, Any]
) -> list[str]:
    """Normalize assertion targets to bare labels using IdResolver.

    Callers may pass full assertion IDs like "REQ-p00044-E"; the Edge
    contract expects bare labels like "E".  Uses IdResolver.parse() to
    extract the assertion label when a full ID is provided.
    """
    resolver = build_resolver(config)
    normalized: list[str] = []
    for at in targets:
        parsed = resolver.parse(at)
        if parsed and parsed.assertions and parsed.fqn == target_id:
            normalized.extend(parsed.assertions)
        else:
            normalized.append(at)
    return normalized


def _mutate_add_edge(
    graph: FederatedGraph,
    source_id: str,
    target_id: str,
    edge_kind: str,
    assertion_targets: list[str] | None = None,
    config: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Add an edge between nodes.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        if assertion_targets and config:
            assertion_targets = _normalize_assertion_targets(assertion_targets, target_id, config)
        edge_kind_enum = EdgeKind[edge_kind.upper()]
        entry = graph.add_edge(
            source_id=source_id,
            target_id=target_id,
            edge_kind=edge_kind_enum,
            assertion_targets=assertion_targets,
        )
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Added edge {source_id} --[{edge_kind}]--> {target_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_change_edge_kind(
    graph: FederatedGraph,
    source_id: str,
    target_id: str,
    new_kind: str,
) -> dict[str, Any]:
    """Change edge type.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        new_kind_enum = EdgeKind[new_kind.upper()]
        entry = graph.change_edge_kind(source_id, target_id, new_kind_enum)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Changed edge {source_id} -> {target_id} to {new_kind}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# Implements: REQ-o00062-C
def _mutate_change_edge_targets(
    graph: FederatedGraph,
    source_id: str,
    target_id: str,
    assertion_targets: list[str],
) -> dict[str, Any]:
    """Change assertion targets on an edge.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.change_edge_targets(source_id, target_id, assertion_targets)
        if assertion_targets:
            targets_display = ", ".join(assertion_targets)
        else:
            targets_display = "(whole requirement)"
        msg = f"Changed targets on {source_id} -> {target_id}: {targets_display}"
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": msg,
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_delete_edge(
    graph: FederatedGraph,
    source_id: str,
    target_id: str,
    confirm: bool = False,
) -> dict[str, Any]:
    """Delete an edge.

    REQ-o00062-F: Requires confirm=True for destructive operations.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    if not confirm:
        return {
            "success": False,
            "error": "Destructive operation requires confirm=True",
        }

    try:
        entry = graph.delete_edge(source_id, target_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Deleted edge {source_id} -> {target_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _mutate_fix_broken_reference(
    graph: FederatedGraph,
    source_id: str,
    old_target_id: str,
    new_target_id: str,
) -> dict[str, Any]:
    """Fix a broken reference by redirecting to a valid target.

    REQ-d00065-D: Only parameter validation and delegation.
    REQ-o00062-E: Returns MutationEntry for audit.
    """
    try:
        entry = graph.fix_broken_reference(source_id, old_target_id, new_target_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Fixed reference {source_id}: {old_target_id} -> {new_target_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# Implements: REQ-o00063
def _mutate_move_node_to_file(
    graph: FederatedGraph,
    node_id: str,
    target_file_id: str,
) -> dict[str, Any]:
    """Move a content node to a different FILE.

    REQ-d00065-D: Only parameter validation and delegation.
    """
    try:
        entry = graph.move_node_to_file(node_id, target_file_id)
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Moved {node_id} to {target_file_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# Implements: REQ-o00063
def _mutate_rename_file(
    graph: FederatedGraph,
    file_id: str,
    new_relative_path: str,
    repo_root: Path | None = None,
) -> dict[str, Any]:
    """Rename a FILE node.

    REQ-d00065-D: Only parameter validation and delegation.
    """
    try:
        entry = graph.rename_file(file_id, new_relative_path, repo_root)
        new_id = entry.after_state.get("id", "")
        return {
            "success": True,
            "mutation": _serialize_mutation_entry(entry),
            "message": f"Renamed {file_id} to {new_id}",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


# ─────────────────────────────────────────────────────────────────────────────
# Undo Operations (REQ-o00062-G)
# ─────────────────────────────────────────────────────────────────────────────


def _undo_last_mutation(graph: FederatedGraph) -> dict[str, Any]:
    """Undo the most recent mutation.

    REQ-o00062-G: Reverses mutations using graph.undo_last().
    """
    entry = graph.undo_last()
    if entry is None:
        return {"success": False, "error": "No mutations to undo"}

    return {
        "success": True,
        "mutation": _serialize_mutation_entry(entry),
        "message": f"Undid {entry.operation} on {entry.target_id}",
    }


def _undo_to_mutation(graph: FederatedGraph, mutation_id: str) -> dict[str, Any]:
    """Undo all mutations back to a specific point.

    REQ-o00062-G: Reverses mutations using graph.undo_to().
    """
    try:
        entries = graph.undo_to(mutation_id)
        return {
            "success": True,
            "mutations_undone": len(entries),
            "mutations": [_serialize_mutation_entry(e) for e in entries],
            "message": f"Undid {len(entries)} mutations",
        }
    except (ValueError, KeyError) as e:
        return {"success": False, "error": str(e)}


def _get_mutation_log(graph: FederatedGraph, limit: int = 50) -> dict[str, Any]:
    """Get mutation history.

    Returns the most recent mutations from the log.
    """
    mutations = []
    for entry in graph.mutation_log.iter_entries():
        mutations.append(_serialize_mutation_entry(entry))
        if len(mutations) >= limit:
            break

    return {
        "mutations": mutations,
        "count": len(mutations),
    }


# ─────────────────────────────────────────────────────────────────────────────
# Inspection Functions
# ─────────────────────────────────────────────────────────────────────────────


def _get_orphaned_nodes(graph: FederatedGraph) -> dict[str, Any]:
    """Get structural orphans — nodes without any FILE ancestor.

    Structural orphans indicate build pipeline bugs where nodes failed
    to wire into the file structure. Excludes FILE and INSTANCE nodes.
    """
    orphans: list[dict[str, Any]] = []
    by_kind: dict[str, list[dict[str, Any]]] = {}

    for node in graph.orphaned_nodes():
        if node.kind == NodeKind.REQUIREMENT:
            entry = _serialize_requirement_summary(node)
        else:
            entry = {
                "id": node.id,
                "kind": node.kind.value,
                "label": node.get_label(),
            }
            _fn = node.file_node()
            _line = node.get_field("parse_line")
            if _fn and _line is not None:
                entry["source"] = f"{_fn.get_field('relative_path')}:{_line}"
        entry["kind"] = node.kind.value
        orphans.append(entry)

        kind_name = node.kind.value
        if kind_name not in by_kind:
            by_kind[kind_name] = []
        by_kind[kind_name].append(entry)

    return {
        "orphans": orphans,
        "count": len(orphans),
        "by_kind": {k: {"items": v, "count": len(v)} for k, v in by_kind.items()},
    }


def _get_unlinked_nodes(graph: FederatedGraph, kind: str | None = None) -> dict[str, Any]:
    """Get unlinked nodes — nodes with a FILE parent but no requirement link.

    Unlinked nodes are structurally sound (have a FILE parent via CONTAINS)
    but have no path to any REQUIREMENT through traceability edges.

    Args:
        graph: The trace graph to inspect.
        kind: Optional filter — "test" or "code". If None, returns both.

    Returns:
        Dictionary with counts by file and kind, plus a compact list of
        (source, label) pairs.
    """
    kinds_to_check: list[NodeKind] = []
    if kind is None:
        kinds_to_check = [NodeKind.TEST, NodeKind.CODE]
    elif kind == "test":
        kinds_to_check = [NodeKind.TEST]
    elif kind == "code":
        kinds_to_check = [NodeKind.CODE]

    by_file: dict[str, int] = {}
    by_kind_count: dict[str, int] = {}
    total = 0

    for nk in kinds_to_check:
        for node in graph.iter_unlinked(nk):
            total += 1
            kind_name = node.kind.value
            by_kind_count[kind_name] = by_kind_count.get(kind_name, 0) + 1

            _fn = node.file_node()
            file_path = _fn.get_field("relative_path") if _fn else "unknown"
            by_file[file_path] = by_file.get(file_path, 0) + 1

    # Sort files by count descending for quick triage
    sorted_files = sorted(by_file.items(), key=lambda x: -x[1])

    return {
        "count": total,
        "by_kind": by_kind_count,
        "by_file": [{"file": f, "count": c} for f, c in sorted_files],
    }


def _get_broken_references(graph: FederatedGraph) -> dict[str, Any]:
    """Get all broken references.

    Returns edges that point to non-existent nodes.
    """
    refs = [_serialize_broken_reference(ref) for ref in graph.broken_references()]

    return {
        "broken_references": refs,
        "count": len(refs),
    }


# ─────────────────────────────────────────────────────────────────────────────
# Keyword Search Tools (Phase 4)
# ─────────────────────────────────────────────────────────────────────────────


def _find_by_keywords(
    graph: FederatedGraph,
    keywords: list[str],
    match_all: bool = True,
    kind: str | None = None,
) -> dict[str, Any]:
    """Find nodes containing specified keywords.

    Args:
        graph: The TraceGraph to search.
        keywords: List of keywords to search for.
        match_all: If True, node must contain ALL keywords (AND).
                   If False, node must contain ANY keyword (OR).
        kind: Optional NodeKind value string to filter by (e.g. "requirement").

    Returns:
        Dict with 'success', 'results', and 'count'.
    """
    from elspais.graph.annotators import find_by_keywords

    kind_enum = None
    if kind:
        try:
            kind_enum = NodeKind(kind)
        except ValueError:
            return {"success": False, "error": f"Unknown kind: {kind}", "results": [], "count": 0}

    nodes = find_by_keywords(graph, keywords, match_all, kind=kind_enum)
    results = [_serialize_node_summary(node) for node in nodes]

    return {
        "success": True,
        "results": results,
        "count": len(results),
    }


def _get_all_keywords(graph: FederatedGraph) -> dict[str, Any]:
    """Get all unique keywords from the graph.

    Args:
        graph: The TraceGraph to scan.

    Returns:
        Dict with 'success', 'keywords', and 'count'.
    """
    from elspais.graph.annotators import collect_all_keywords

    keywords = collect_all_keywords(graph)

    return {
        "success": True,
        "keywords": keywords,
        "count": len(keywords),
    }


def _query_nodes(
    graph: FederatedGraph,
    kind: str | None = None,
    keywords: list[str] | None = None,
    match_all: bool = True,
    filters: dict[str, str] | None = None,
    limit: int = 50,
) -> dict[str, Any]:
    """Combined property + keyword filter query for any node kind.

    Starts with nodes filtered by kind (or all nodes), narrows by keywords
    using existing annotator infrastructure, then post-filters by property
    values.

    Args:
        graph: The TraceGraph to query.
        kind: Optional NodeKind value string (e.g. "requirement", "journey").
        keywords: Optional list of keywords to filter by.
        match_all: If True, node must contain ALL keywords.
        filters: Optional dict of property name → value for post-filtering.
        limit: Maximum results to return.

    Returns:
        Dict with 'results', 'count', and 'truncated' flag.
    """
    # 1. Start with nodes by kind (or all)
    kind_enum = None
    if kind:
        try:
            kind_enum = NodeKind(kind)
        except ValueError:
            return {"results": [], "count": 0, "truncated": False}
        candidates = list(graph.nodes_by_kind(kind_enum))
    else:
        candidates = list(graph.all_nodes())

    # 2. Keyword filter (if provided)
    if keywords:
        from elspais.graph.annotators import find_by_keywords

        keyword_ids = {n.id for n in find_by_keywords(graph, keywords, match_all, kind=kind_enum)}
        candidates = [n for n in candidates if n.id in keyword_ids]

    # 3. Property post-filters (known safe keys only)
    allowed_filter_keys = {"level", "status", "actor"}
    if filters:
        for key, value in filters.items():
            if key not in allowed_filter_keys:
                continue
            candidates = [
                n for n in candidates if (n.get_field(key, "") or "").upper() == value.upper()
            ]

    # 4. Serialize and limit
    total = len(candidates)
    results = [_serialize_node_summary(n) for n in candidates[:limit]]

    return {
        "results": results,
        "count": total,
        "truncated": total > limit,
    }


# ─────────────────────────────────────────────────────────────────────────────
# Test Coverage Tools (REQ-o00064)
# ─────────────────────────────────────────────────────────────────────────────


def _get_test_coverage(graph: FederatedGraph, req_id: str) -> dict[str, Any]:
    """Get test coverage information for a requirement.

    REQ-d00066-A: SHALL accept req_id parameter identifying the target requirement.
    REQ-d00066-B: SHALL return TEST nodes by finding edges targeting the requirement.
    REQ-d00066-C: SHALL return RESULT nodes linked to each TEST node.
    REQ-d00066-D: SHALL identify covered assertions via edge assertion_targets.
    REQ-d00066-E: SHALL return uncovered assertions as those with no incoming TEST edges.
    REQ-d00066-F: SHALL return coverage summary with percentage.
    REQ-d00066-G: SHALL use iterator-only API, not traverse full graph.

    Args:
        graph: The TraceGraph to query.
        req_id: The requirement ID to get coverage for.

    Returns:
        Dict with success, test_nodes, covered/uncovered assertions,
        and coverage statistics.
    """
    node = graph.find_by_id(req_id)
    if node is None:
        return {"success": False, "error": f"Requirement {req_id} not found"}

    if node.kind != NodeKind.REQUIREMENT:
        return {"success": False, "error": f"{req_id} is not a requirement"}

    # Collect assertions
    assertions: list[tuple[str, str]] = []
    for child in node.iter_children():
        if child.kind == NodeKind.ASSERTION:
            assertions.append((child.id, child.get_field("label", "")))

    assertion_ids = [a[0] for a in assertions]
    label_to_id = {label: aid for aid, label in assertions}

    # Deduplicated test serialization via shared iterator
    seen_test_ids: set[str] = set()
    test_nodes: list[dict[str, Any]] = []
    covered_assertion_ids: set[str] = set()

    for test_node, labels in _iter_assertion_coverage(node, NodeKind.TEST):
        # Track covered assertions
        for label in labels:
            if label in label_to_id:
                covered_assertion_ids.add(label_to_id[label])

        if test_node.id in seen_test_ids:
            continue
        seen_test_ids.add(test_node.id)

        test_nodes.append(_serialize_test_info(test_node, graph))

    covered_assertions = sorted(covered_assertion_ids)
    uncovered_assertions = sorted(set(assertion_ids) - covered_assertion_ids)

    total = len(assertion_ids)
    covered_count = len(covered_assertions)
    referenced_pct = (covered_count / total * 100) if total > 0 else 0.0

    # UAT coverage via JNY Validates edges
    seen_jny_ids: set[str] = set()
    jny_nodes: list[dict[str, Any]] = []
    covered_uat_assertion_ids: set[str] = set()

    for jny_node, labels in _iter_assertion_coverage(node, NodeKind.USER_JOURNEY):
        # Track covered assertions
        for label in labels:
            if label in label_to_id:
                covered_uat_assertion_ids.add(label_to_id[label])

        if jny_node.id in seen_jny_ids:
            continue
        seen_jny_ids.add(jny_node.id)

        file_node = jny_node.file_node()
        jny_nodes.append(
            {
                "id": jny_node.id,
                "title": jny_node.get_label(),
                "file": file_node.get_field("relative_path") if file_node else None,
            }
        )

    uat_covered_count = len(covered_uat_assertion_ids)
    uat_referenced_pct = (uat_covered_count / total * 100) if total > 0 else 0.0

    # Read uat_validated_pct from rollup_metrics if available
    rollup = node.get_metric("rollup_metrics")
    uat_validated_pct = rollup.uat_verified.indirect_pct if rollup is not None else 0.0

    return {
        "success": True,
        "req_id": req_id,
        "test_nodes": test_nodes,
        "covered_assertions": covered_assertions,
        "uncovered_assertions": uncovered_assertions,
        "total_assertions": total,
        "covered_count": covered_count,
        "referenced_pct": round(referenced_pct, 1),
        "uat": {
            "jny_nodes": jny_nodes,
            "covered_assertions": sorted(covered_uat_assertion_ids),
            "covered_count": uat_covered_count,
            "referenced_pct": round(uat_referenced_pct, 1),
            "validated_pct": round(uat_validated_pct, 1),
        },
    }


def _get_assertion_test_map(graph: FederatedGraph, req_id: str) -> dict[str, Any]:
    """Build per-assertion test coverage map for a requirement.

    Returns a structure mapping each assertion label to its tests and their
    results, enabling the UI to show validation buttons per assertion.

    Uses ``_iter_assertion_coverage`` for the shared two-phase traversal
    and ``_serialize_test_info`` for the unified serializer.

    Args:
        graph: The TraceGraph to query.
        req_id: The requirement ID.

    Returns:
        Dict with per-assertion test lists, coverage stats.
    """
    node = graph.find_by_id(req_id)
    if node is None:
        return {"success": False, "error": f"Requirement {req_id} not found"}

    if node.kind != NodeKind.REQUIREMENT:
        return {"success": False, "error": f"{req_id} is not a requirement"}

    # Collect assertions
    assertions: list[tuple[str, str]] = []
    for child in node.iter_children():
        if child.kind == NodeKind.ASSERTION:
            assertions.append((child.id, child.get_field("label", "")))

    # Per-assertion buckets
    assertion_tests: dict[str, dict[str, Any]] = {}
    for aid, label in assertions:
        assertion_tests[label] = {"assertion_id": aid, "tests": []}

    seen_per_assertion: dict[str, set[str]] = {label: set() for _, label in assertions}

    for test_node, labels in _iter_assertion_coverage(node, NodeKind.TEST):
        info = _serialize_test_info(test_node, graph)
        for label in labels:
            if label not in assertion_tests:
                continue
            if test_node.id in seen_per_assertion[label]:
                continue
            seen_per_assertion[label].add(test_node.id)
            assertion_tests[label]["tests"].append(info)

    total = len(assertions)
    covered_count = sum(1 for label in assertion_tests if assertion_tests[label]["tests"])
    referenced_pct = (covered_count / total * 100) if total > 0 else 0.0

    return {
        "success": True,
        "req_id": req_id,
        "assertion_tests": assertion_tests,
        "total_assertions": total,
        "covered_count": covered_count,
        "referenced_pct": round(referenced_pct, 1),
    }


def _get_assertion_uat_map(graph: FederatedGraph, req_id: str) -> dict[str, Any]:
    """Build per-assertion UAT (journey) coverage map for a requirement.

    Returns a structure mapping each assertion label to its USER_JOURNEY nodes
    and their results, enabling the UI to show Validated/Accepted buttons.

    Uses ``_iter_assertion_coverage`` for the shared two-phase traversal
    and ``_serialize_test_info`` for the unified serializer (JNY nodes have
    similar structure to TEST nodes with RESULT children).

    Args:
        graph: The TraceGraph to query.
        req_id: The requirement ID.

    Returns:
        Dict with per-assertion journey lists, coverage stats.
    """
    node = graph.find_by_id(req_id)
    if node is None:
        return {"success": False, "error": f"Requirement {req_id} not found"}

    if node.kind != NodeKind.REQUIREMENT:
        return {"success": False, "error": f"{req_id} is not a requirement"}

    # Collect assertions
    assertions: list[tuple[str, str]] = []
    for child in node.iter_children():
        if child.kind == NodeKind.ASSERTION:
            assertions.append((child.id, child.get_field("label", "")))

    # Per-assertion buckets
    assertion_journeys: dict[str, dict[str, Any]] = {}
    for aid, label in assertions:
        assertion_journeys[label] = {"assertion_id": aid, "journeys": []}

    seen_per_assertion: dict[str, set[str]] = {label: set() for _, label in assertions}

    for jny_node, labels in _iter_assertion_coverage(node, NodeKind.USER_JOURNEY):
        info = _serialize_test_info(jny_node, graph)
        for label in labels:
            if label not in assertion_journeys:
                continue
            if jny_node.id in seen_per_assertion[label]:
                continue
            seen_per_assertion[label].add(jny_node.id)
            assertion_journeys[label]["journeys"].append(info)

    total = len(assertions)
    covered_count = sum(1 for label in assertion_journeys if assertion_journeys[label]["journeys"])
    referenced_pct = (covered_count / total * 100) if total > 0 else 0.0

    return {
        "success": True,
        "req_id": req_id,
        "assertion_journeys": assertion_journeys,
        "total_assertions": total,
        "covered_count": covered_count,
        "referenced_pct": round(referenced_pct, 1),
    }


def _get_assertion_code_map(
    graph: FederatedGraph, req_id: str, edge_kind: str | None = None
) -> dict[str, Any]:
    """Build per-assertion code implementation map for a requirement.

    Returns a structure mapping each assertion label to its CODE nodes,
    enabling the UI to show "Implemented" buttons per assertion.

    Uses ``_iter_assertion_coverage`` for the shared two-phase traversal
    and ``_serialize_code_info`` for the unified serializer.

    Args:
        graph: The TraceGraph to query.
        req_id: The requirement ID.
        edge_kind: Optional edge kind filter ('implements' or 'refines').
            When set, only edges of that kind are returned, and blanket
            (untargeted) edges are excluded via direct_only=True.

    Returns:
        Dict with per-assertion code lists, coverage stats.
    """
    from elspais.graph.relations import EdgeKind

    node = graph.find_by_id(req_id)
    if node is None:
        return {"success": False, "error": f"Requirement {req_id} not found"}

    if node.kind != NodeKind.REQUIREMENT:
        return {"success": False, "error": f"{req_id} is not a requirement"}

    # Collect assertions
    assertions: list[tuple[str, str]] = []
    for child in node.iter_children():
        if child.kind == NodeKind.ASSERTION:
            assertions.append((child.id, child.get_field("label", "")))

    # Per-assertion buckets
    assertion_code: dict[str, dict[str, Any]] = {}
    for aid, label in assertions:
        assertion_code[label] = {"assertion_id": aid, "code_refs": []}

    seen_per_assertion: dict[str, set[str]] = {label: set() for _, label in assertions}

    # Build optional filter kwargs
    iter_kwargs: dict[str, Any] = {}
    if edge_kind:
        ek_map = {"implements": EdgeKind.IMPLEMENTS, "refines": EdgeKind.REFINES}
        ek = ek_map.get(edge_kind)
        if ek:
            iter_kwargs["edge_kinds"] = {ek}
            iter_kwargs["direct_only"] = True

    for code_node, labels in _iter_assertion_coverage(node, NodeKind.CODE, **iter_kwargs):
        info = _serialize_code_info(code_node, graph)
        for label in labels:
            if label not in assertion_code:
                continue
            if code_node.id in seen_per_assertion[label]:
                continue
            seen_per_assertion[label].add(code_node.id)
            assertion_code[label]["code_refs"].append(info)

    total = len(assertions)
    covered_count = sum(1 for label in assertion_code if assertion_code[label]["code_refs"])
    referenced_pct = (covered_count / total * 100) if total > 0 else 0.0

    return {
        "success": True,
        "req_id": req_id,
        "assertion_code": assertion_code,
        "total_assertions": total,
        "covered_count": covered_count,
        "referenced_pct": round(referenced_pct, 1),
    }


def _get_assertion_refines_map(graph: FederatedGraph, req_id: str) -> dict[str, Any]:
    """Build per-assertion refines map for a requirement.

    REFINES edges target REQUIREMENT nodes (not CODE). Each entry contains
    the refining requirement's ID and title.

    Args:
        graph: The TraceGraph to query.
        req_id: The requirement ID.

    Returns:
        Dict with per-assertion refines lists, refinement stats.
    """
    from elspais.graph.relations import EdgeKind

    node = graph.find_by_id(req_id)
    if node is None:
        return {"success": False, "error": f"Requirement {req_id} not found"}

    if node.kind != NodeKind.REQUIREMENT:
        return {"success": False, "error": f"{req_id} is not a requirement"}

    assertions: list[tuple[str, str]] = []
    for child in node.iter_children():
        if child.kind == NodeKind.ASSERTION:
            assertions.append((child.id, child.get_field("label", "")))

    assertion_refines: dict[str, dict[str, Any]] = {}
    for aid, label in assertions:
        assertion_refines[label] = {"assertion_id": aid, "refines_refs": []}

    seen_per_assertion: dict[str, set[str]] = {label: set() for _, label in assertions}

    for req_node, labels in _iter_assertion_coverage(
        node,
        NodeKind.REQUIREMENT,
        edge_kinds={EdgeKind.REFINES},
        direct_only=True,
    ):
        info = {
            "id": req_node.id,
            "title": req_node.get_field("title", "") or req_node.get_label() or req_node.id,
            "kind": req_node.kind.value,
        }
        for label in labels:
            if label not in assertion_refines:
                continue
            if req_node.id in seen_per_assertion[label]:
                continue
            seen_per_assertion[label].add(req_node.id)
            assertion_refines[label]["refines_refs"].append(info)

    total = len(assertions)
    refined_count = sum(
        1 for label in assertion_refines if assertion_refines[label]["refines_refs"]
    )

    return {
        "success": True,
        "req_id": req_id,
        "assertion_refines": assertion_refines,
        "total_assertions": total,
        "refined_count": refined_count,
    }


def _get_uncovered_assertions(
    graph: FederatedGraph,
    req_id: str | None = None,
    limit: int = 100,
    source: str = "test",
) -> dict[str, Any]:
    """Find assertions lacking test coverage.

    REQ-d00067-A: SHALL accept optional req_id parameter; when None, scan all requirements.
    REQ-d00067-B: SHALL iterate assertions using nodes_by_kind(ASSERTION).
    REQ-d00067-C: SHALL check each assertion for incoming edges from TEST nodes.
    REQ-d00067-D: SHALL return assertion details: id, text, label, parent requirement context.
    REQ-d00067-E: SHALL return parent requirement id and title for context.
    REQ-d00067-F: SHALL limit results to prevent unbounded response sizes.
    REQ-d00069-A: SHALL accept source parameter ('test', 'uat', 'both') to filter coverage source.

    Uses ``_iter_assertion_coverage`` to build the covered-labels set,
    which correctly handles indirect coverage (tests with no
    ``assertion_targets`` covering ALL assertions).

    Args:
        graph: The TraceGraph to query.
        req_id: Optional requirement ID to filter by. If None, scan all requirements.
        limit: Maximum number of results to return.
        source: Coverage source to consider: 'test' (default), 'uat', or 'both'.

    Returns:
        Dict with success and list of uncovered assertions with parent context.
    """

    def _covered_labels_for_req(req_node: Any) -> set[str]:
        """Return the set of assertion labels covered by at least one matching source."""
        covered: set[str] = set()
        if source in ("test", "both"):
            for _test_node, labels in _iter_assertion_coverage(req_node, NodeKind.TEST):
                covered.update(labels)
        if source in ("uat", "both"):
            for _jny_node, labels in _iter_assertion_coverage(req_node, NodeKind.USER_JOURNEY):
                covered.update(labels)
        return covered

    if req_id is not None:
        node = graph.find_by_id(req_id)
        if node is None:
            return {"success": False, "error": f"Requirement {req_id} not found"}

        if node.kind != NodeKind.REQUIREMENT:
            return {"success": False, "error": f"{req_id} is not a requirement"}

        covered = _covered_labels_for_req(node)
        uncovered_labels = []

        for child in node.iter_children():
            if child.kind != NodeKind.ASSERTION:
                continue
            if child.get_field("label", "") not in covered:
                uncovered_labels.append(child.get_field("label", ""))

        total = sum(1 for c in node.iter_children() if c.kind == NodeKind.ASSERTION)

        return {
            "success": True,
            "req_id": req_id,
            "title": node.get_label(),
            "total_assertions": total,
            "uncovered_count": len(uncovered_labels),
            "uncovered_labels": uncovered_labels,
        }

    # REQ-d00067-A: Scan all requirements, return requirement-level summary
    gaps: list[dict[str, Any]] = []

    for req_node in graph.nodes_by_kind(NodeKind.REQUIREMENT):
        covered = _covered_labels_for_req(req_node)
        assertions = [c for c in req_node.iter_children() if c.kind == NodeKind.ASSERTION]
        uncovered_labels = [
            c.get_field("label", "") for c in assertions if c.get_field("label", "") not in covered
        ]
        if not uncovered_labels:
            continue

        gaps.append(
            {
                "req_id": req_node.id,
                "title": req_node.get_label(),
                "total_assertions": len(assertions),
                "uncovered_count": len(uncovered_labels),
                "uncovered_labels": uncovered_labels,
            }
        )

        if len(gaps) >= limit:
            break

    gaps.sort(key=lambda g: g["req_id"])

    return {
        "success": True,
        "requirements": gaps,
        "count": len(gaps),
    }


def _find_assertions_by_keywords(
    graph: FederatedGraph,
    keywords: list[str],
    match_all: bool = True,
) -> dict[str, Any]:
    """Find assertions containing specified keywords.

    REQ-d00068-A: SHALL accept keywords list parameter with search terms.
    REQ-d00068-B: SHALL accept match_all boolean; True requires all keywords.
    REQ-d00068-C: SHALL search assertion text (SHALL statement content).
    REQ-d00068-D: SHALL return assertion id, text, label, and parent context.
    REQ-d00068-E: SHALL perform case-insensitive matching by default.
    REQ-d00068-F: SHALL complement find_by_keywords() which searches requirements.

    Args:
        graph: The TraceGraph to query.
        keywords: List of keywords to search for.
        match_all: If True, assertion must contain ALL keywords (AND).
                   If False, assertion must contain ANY keyword (OR).

    Returns:
        Dict with success and list of matching assertions with parent context.
    """
    from elspais.graph.annotators import find_by_keywords

    # REQ-d00068-C: Use graph API to search assertion nodes by keyword
    # REQ-d00068-E: find_by_keywords handles case normalization
    nodes = find_by_keywords(graph, keywords, match_all, kind=NodeKind.ASSERTION)

    # REQ-d00068-D: Format results with parent context
    results: list[dict[str, Any]] = []
    for node in nodes:
        # Find parent requirement
        parent_req = None
        for parent in node.iter_parents():
            if parent.kind == NodeKind.REQUIREMENT:
                parent_req = parent
                break

        results.append(
            {
                "id": node.id,
                "label": node.get_field("label", ""),
                "text": node.get_label() or "",
                "parent_id": parent_req.id if parent_req else None,
                "parent_title": parent_req.get_label() if parent_req else None,
            }
        )

    return {
        "success": True,
        "assertions": results,
        "count": len(results),
    }


# ─────────────────────────────────────────────────────────────────────────────
# File Mutation Tools (REQ-o00063)
# ─────────────────────────────────────────────────────────────────────────────


def _change_reference_type(
    repo_root: Path,
    req_id: str,
    target_id: str,
    new_type: str,
    save_branch: bool = False,
) -> dict[str, Any]:
    """Change a reference type in a spec file (Implements -> Refines or vice versa).

    REQ-o00063-A: Modify Implements/Refines relationships in spec files.
    Delegates core file I/O to ``utilities.spec_writer.change_reference_type``.

    Args:
        repo_root: Repository root path.
        req_id: ID of the requirement to modify.
        target_id: ID of the target requirement being referenced.
        new_type: New reference type ('IMPLEMENTS' or 'REFINES').
        save_branch: If True, create a safety branch before modifying.

    Returns:
        Success status and optional safety_branch name.
    """
    from elspais.utilities.git import create_safety_branch
    from elspais.utilities.spec_writer import change_reference_type as _crt

    # Find the spec file containing req_id
    spec_dir = repo_root / "spec"
    if not spec_dir.exists():
        return {"success": False, "error": "spec/ directory not found"}

    target_file = None
    for md_file in spec_dir.rglob("*.md"):
        content = md_file.read_text(encoding="utf-8")
        if re.search(rf"^#{{1,6}}\s+{re.escape(req_id)}:", content, re.MULTILINE):
            target_file = md_file
            break

    if target_file is None:
        return {"success": False, "error": f"Requirement {req_id} not found in spec files"}

    # Create safety branch if requested (REQ-o00063-D)
    safety_branch = None
    if save_branch:
        branch_result = create_safety_branch(repo_root, req_id)
        if not branch_result["success"]:
            error_msg = branch_result.get("error", "Failed to create safety branch")
            return {"success": False, "error": error_msg}
        safety_branch = branch_result["branch_name"]

    # Delegate to spec_writer for the actual file modification
    result = _crt(target_file, req_id, target_id, new_type)

    if result.get("success") and safety_branch:
        result["safety_branch"] = safety_branch

    return result


def _move_requirement(
    repo_root: Path,
    req_id: str,
    target_file: str,
    save_branch: bool = False,
) -> dict[str, Any]:
    """Move a requirement from one spec file to another.

    REQ-o00063-B: Relocate a requirement between spec files.
    Delegates core file I/O to ``utilities.spec_writer.move_requirement``.

    Args:
        repo_root: Repository root path.
        req_id: ID of the requirement to move.
        target_file: Relative path to the target file.
        save_branch: If True, create a safety branch before modifying.

    Returns:
        Success status and optional safety_branch name.
    """
    from elspais.utilities.git import create_safety_branch
    from elspais.utilities.spec_writer import move_requirement as _move_req

    spec_dir = repo_root / "spec"
    if not spec_dir.exists():
        return {"success": False, "error": "spec/ directory not found"}

    target_path = repo_root / target_file
    if not target_path.exists():
        return {"success": False, "error": f"Target file {target_file} not found"}

    # Find the source file containing req_id
    source_file = None
    for md_file in spec_dir.rglob("*.md"):
        content = md_file.read_text(encoding="utf-8")
        if re.search(rf"^#{{1,6}}\s+{re.escape(req_id)}:", content, re.MULTILINE):
            source_file = md_file
            break

    if source_file is None:
        return {"success": False, "error": f"Requirement {req_id} not found in spec files"}

    if source_file == target_path:
        return {"success": False, "error": "Source and target files are the same"}

    # Create safety branch if requested (REQ-o00063-D)
    safety_branch = None
    if save_branch:
        branch_result = create_safety_branch(repo_root, req_id)
        if not branch_result["success"]:
            error_msg = branch_result.get("error", "Failed to create safety branch")
            return {"success": False, "error": error_msg}
        safety_branch = branch_result["branch_name"]

    # Delegate to spec_writer for the actual file modification
    result = _move_req(source_file, target_path, req_id)

    if result.get("success") and safety_branch:
        result["safety_branch"] = safety_branch

    return result


def _restore_from_safety_branch(
    repo_root: Path,
    branch_name: str,
) -> dict[str, Any]:
    """Restore spec files from a safety branch.

    REQ-o00063-E: Revert file changes from a safety branch.

    Args:
        repo_root: Repository root path.
        branch_name: Name of the safety branch to restore from.

    Returns:
        Success status.
    """
    from elspais.utilities.git import restore_from_safety_branch

    return restore_from_safety_branch(repo_root, branch_name)


def _list_safety_branches_impl(repo_root: Path) -> dict[str, Any]:
    """List all safety branches.

    Args:
        repo_root: Repository root path.

    Returns:
        List of safety branch names.
    """
    from elspais.utilities.git import list_safety_branches

    branches = list_safety_branches(repo_root)
    return {"branches": branches, "count": len(branches)}


# ─────────────────────────────────────────────────────────────────────────────
# Link Suggestion (REQ-d00074)
# ─────────────────────────────────────────────────────────────────────────────


def _suggest_links_impl(
    graph: FederatedGraph,
    working_dir: Path,
    file_path: str | None = None,
    limit: int = 50,
) -> dict[str, Any]:
    """Suggest requirement links for unlinked test nodes.

    REQ-d00074-A: Returns structured suggestions from the core engine.
    REQ-d00074-C: Delegates to core engine, no analysis logic here.
    """
    from elspais.graph.link_suggest import suggest_links

    suggestions = suggest_links(
        graph,
        working_dir,
        file_path=file_path,
        limit=limit,
        discover_fn=_discover_assertions,
    )
    return {
        "suggestions": [s.to_dict() for s in suggestions],
        "count": len(suggestions),
    }


def _apply_link_impl(
    state: dict[str, Any],
    file_path: str,
    line: int,
    requirement_id: str,
) -> dict[str, Any]:
    """Apply a link by inserting a # Implements: comment.

    REQ-d00074-B: Inserts comment and refreshes graph.
    REQ-d00074-D: Validates target requirement exists before modifying.
    """
    from elspais.graph.link_suggest import apply_link_to_file, keyword_for_file

    graph = state["graph"]
    working_dir = state["working_dir"]
    config = state["config"]

    # Validate requirement exists
    target = graph.find_by_id(requirement_id)
    if target is None:
        return {
            "success": False,
            "error": f"Requirement '{requirement_id}' not found in graph",
        }

    abs_path = working_dir / file_path
    kw = keyword_for_file(abs_path, config)
    result = apply_link_to_file(abs_path, line, requirement_id, keyword=kw)

    if result is None:
        return {
            "success": False,
            "error": f"Could not write to file: {file_path}",
        }

    # Refresh graph after file modification
    _, new_graph = _refresh_graph(working_dir)
    state["graph"] = new_graph

    return {
        "success": True,
        "comment": result,
        "file": file_path,
        "line": line,
        "requirement_id": requirement_id,
    }


# ─────────────────────────────────────────────────────────────────────────────
# Subtree Extraction (REQ-o00067, REQ-d00075, REQ-d00133)
# ─────────────────────────────────────────────────────────────────────────────

# Conservative kind defaults per root kind (REQ-d00075-F, REQ-d00133-C)
_SUBTREE_KIND_DEFAULTS: dict[NodeKind, set[NodeKind]] = {
    NodeKind.REQUIREMENT: {NodeKind.REQUIREMENT, NodeKind.ASSERTION},
    NodeKind.USER_JOURNEY: {NodeKind.USER_JOURNEY},
    NodeKind.FILE: {NodeKind.REQUIREMENT, NodeKind.ASSERTION, NodeKind.REMAINDER},
}

# Implements: REQ-d00133-A, REQ-d00133-B
# Edge-kind filters per root kind for filtered traversal
_SUBTREE_EDGE_DEFAULTS: dict[NodeKind, set[EdgeKind]] = {
    NodeKind.FILE: {EdgeKind.CONTAINS},
    NodeKind.REQUIREMENT: {EdgeKind.IMPLEMENTS, EdgeKind.REFINES, EdgeKind.STRUCTURES},
}


def _compute_coverage_summary(req_node: Any) -> dict[str, Any]:
    """Lightweight coverage summary reusing _iter_assertion_coverage().

    REQ-d00075-B: Returns {total, covered, pct}.
    """
    # Count total assertions
    total = 0
    for child in req_node.iter_children():
        if child.kind == NodeKind.ASSERTION:
            total += 1

    if total == 0:
        return {"total": 0, "covered": 0, "pct": 0.0}

    # Collect covered assertion labels from both TEST and CODE edges
    covered_labels: set[str] = set()
    for _node, labels in _iter_assertion_coverage(req_node, NodeKind.TEST):
        covered_labels.update(labels)
    for _node, labels in _iter_assertion_coverage(req_node, NodeKind.CODE):
        covered_labels.update(labels)

    covered = len(covered_labels)
    return {
        "total": total,
        "covered": covered,
        "pct": round(covered / total * 100, 1) if total > 0 else 0.0,
    }


def _collect_subtree(
    graph: FederatedGraph,
    root_id: str,
    depth: int = 0,
    include_kinds: set[NodeKind] | None = None,
) -> list[tuple[Any, int]]:
    """BFS from root with depth tracking, kind filtering, DAG dedup.

    REQ-d00075-A: Uses node.iter_children() with visited set.
    REQ-o00067-A: BFS traversal from root.
    REQ-o00067-B: depth=0 means unlimited, depth=N limits to N levels.
    REQ-o00067-E: Deduplicates via visited set.
    REQ-d00133-A: FILE roots walk CONTAINS edges.
    REQ-d00133-B: REQUIREMENT roots walk domain edges.

    Returns:
        List of (node, depth_level) tuples in BFS order.
    """
    root_node = graph.find_by_id(root_id)
    if root_node is None:
        return []

    # Determine kind filter (REQ-o00067-C, REQ-d00075-F, REQ-d00133-C)
    if include_kinds is None:
        include_kinds = _SUBTREE_KIND_DEFAULTS.get(
            root_node.kind,
            {root_node.kind},
        )
        # Always include ASSERTION when REQUIREMENT is present
        if NodeKind.REQUIREMENT in include_kinds:
            include_kinds = include_kinds | {NodeKind.ASSERTION}

    # Determine edge-kind filter (REQ-d00133-A, REQ-d00133-B)
    edge_filter = _SUBTREE_EDGE_DEFAULTS.get(root_node.kind)

    visited: set[str] = {root_id}
    result: list[tuple[Any, int]] = [(root_node, 0)]
    queue: list[tuple[Any, int]] = [(root_node, 0)]

    while queue:
        current, current_depth = queue.pop(0)

        # Depth limit: don't expand children beyond limit
        if depth > 0 and current_depth >= depth:
            continue

        for child in current.iter_children(edge_kinds=edge_filter):
            if child.id in visited:
                continue
            if child.kind not in include_kinds:
                continue
            visited.add(child.id)
            result.append((child, current_depth + 1))
            queue.append((child, current_depth + 1))

    return result


def _subtree_to_markdown(
    collected: list[tuple[Any, int]],
    graph: FederatedGraph,
) -> str:
    """Render subtree as indented markdown.

    REQ-d00075-C: Indented headings + assertion bullets + coverage stats.
    """
    if not collected:
        return "*(empty subtree)*"

    lines: list[str] = []
    for node, depth_level in collected:
        indent = "  " * depth_level

        if node.kind == NodeKind.ASSERTION:
            label = node.get_field("label", "")
            text = node.get_label()
            lines.append(f"{indent}- **{label}**: {text}")
        elif node.kind == NodeKind.REQUIREMENT:
            level_str = node.get_field("level", "")
            status = node.get_field("status", "")
            title = node.get_label()
            # Coverage summary (REQ-o00067-F)
            cov = _compute_coverage_summary(node)
            cov_str = f" [{cov['covered']}/{cov['total']} covered, {cov['pct']}%]"
            lines.append(
                f"{indent}{'#' * min(depth_level + 1, 6)} {node.id}: {title} "
                f"({level_str}, {status}){cov_str}"
            )
        else:
            title = node.get_label()
            lines.append(f"{indent}{'#' * min(depth_level + 1, 6)} {node.id}: {title}")

    return "\n".join(lines)


def _subtree_to_flat(
    collected: list[tuple[Any, int]],
    graph: FederatedGraph,
    root_id: str,
) -> dict[str, Any]:
    """Render subtree as flat JSON structure.

    REQ-d00075-D: Returns {root_id, nodes, edges, stats}.
    """
    nodes: list[dict[str, Any]] = []
    edges: list[dict[str, Any]] = []
    total_reqs = 0
    total_assertions = 0

    for node, depth_level in collected:
        entry: dict[str, Any] = {
            "id": node.id,
            "kind": node.kind.value,
            "title": node.get_label(),
            "depth": depth_level,
        }

        if node.kind == NodeKind.REQUIREMENT:
            entry["level"] = node.get_field("level")
            entry["status"] = node.get_field("status")
            entry["coverage"] = _compute_coverage_summary(node)
            total_reqs += 1
        elif node.kind == NodeKind.ASSERTION:
            entry["label"] = node.get_field("label")
            entry["text"] = node.get_label()
            total_assertions += 1

        nodes.append(entry)

        # Collect edges to children that are in the collected set
        collected_ids = {n.id for n, _ in collected}
        for edge in node.iter_outgoing_edges():
            if edge.target.id in collected_ids:
                edges.append(
                    {
                        "source": node.id,
                        "target": edge.target.id,
                        "kind": edge.kind.value,
                    }
                )

    return {
        "root_id": root_id,
        "nodes": nodes,
        "edges": edges,
        "stats": {
            "total_nodes": len(nodes),
            "requirements": total_reqs,
            "assertions": total_assertions,
        },
    }


def _subtree_to_nested(
    node: Any,
    depth_limit: int,
    kind_filter: set[NodeKind],
    graph: FederatedGraph,
    _current_depth: int = 0,
    _visited: set[str] | None = None,
    _edge_filter: set[EdgeKind] | None = None,
) -> dict[str, Any]:
    """Render subtree as recursive nested JSON.

    REQ-d00075-E: Recursive JSON with children arrays.
    REQ-d00133-A: FILE roots walk CONTAINS edges.
    REQ-d00133-B: REQUIREMENT roots walk domain edges.
    """
    if _visited is None:
        _visited = set()
    _visited.add(node.id)

    entry: dict[str, Any] = {
        "id": node.id,
        "kind": node.kind.value,
        "title": node.get_label(),
    }

    if node.kind == NodeKind.REQUIREMENT:
        entry["level"] = node.get_field("level")
        entry["status"] = node.get_field("status")
        entry["coverage"] = _compute_coverage_summary(node)
    elif node.kind == NodeKind.ASSERTION:
        entry["label"] = node.get_field("label")
        entry["text"] = node.get_label()

    # Recurse into children
    children: list[dict[str, Any]] = []
    if depth_limit == 0 or _current_depth < depth_limit:
        for child in node.iter_children(edge_kinds=_edge_filter):
            if child.id in _visited:
                continue
            if child.kind not in kind_filter:
                continue
            children.append(
                _subtree_to_nested(
                    child,
                    depth_limit,
                    kind_filter,
                    graph,
                    _current_depth + 1,
                    _visited,
                    _edge_filter,
                )
            )
    entry["children"] = children
    return entry


def _get_subtree(
    graph: FederatedGraph,
    root_id: str,
    depth: int = 0,
    include_kinds: str = "",
    format: str = "markdown",
) -> dict[str, Any]:
    """Dispatcher for subtree extraction.

    REQ-o00067-A through REQ-o00067-F.
    """
    root_node = graph.find_by_id(root_id)
    if root_node is None:
        return {"error": f"Node '{root_id}' not found"}

    # Parse kind filter
    kind_set: set[NodeKind] | None = None
    if include_kinds:
        kind_set = set()
        for k in include_kinds.split(","):
            k = k.strip().lower()
            try:
                kind_set.add(NodeKind(k))
            except ValueError:
                return {"error": f"Unknown node kind: '{k}'"}

    if format == "nested":
        # Nested uses recursive approach directly
        if kind_set is None:
            kind_set = _SUBTREE_KIND_DEFAULTS.get(
                root_node.kind,
                {root_node.kind},
            )
            if NodeKind.REQUIREMENT in kind_set:
                kind_set = kind_set | {NodeKind.ASSERTION}
        # REQ-d00133-A, REQ-d00133-B: edge filter per root kind
        edge_filter = _SUBTREE_EDGE_DEFAULTS.get(root_node.kind)
        result = _subtree_to_nested(root_node, depth, kind_set, graph, _edge_filter=edge_filter)
        return {"format": "nested", "root_id": root_id, "tree": result}

    # BFS-based formats: markdown and flat
    collected = _collect_subtree(graph, root_id, depth, kind_set)

    if format == "markdown":
        return {
            "format": "markdown",
            "root_id": root_id,
            "content": _subtree_to_markdown(collected, graph),
        }
    elif format == "flat":
        return {
            "format": "flat",
            **_subtree_to_flat(collected, graph, root_id),
        }
    else:
        return {"error": f"Unknown format: '{format}'. Use 'markdown', 'flat', or 'nested'."}


# ─────────────────────────────────────────────────────────────────────────────
# Cursor Protocol (REQ-o00068, REQ-d00076)
# ─────────────────────────────────────────────────────────────────────────────


class CursorState:
    """Single-cursor state for incremental iteration over query results.

    REQ-d00076-A: Stores query, params, batch_size, materialized items, position.
    """

    __slots__ = ("query", "params", "batch_size", "items", "position")

    def __init__(
        self,
        query: str,
        params: dict[str, Any],
        batch_size: int,
        items: list[dict[str, Any]],
    ) -> None:
        self.query = query
        self.params = params
        self.batch_size = batch_size
        self.items = items
        self.position: int = 0


def _reshape_for_batch_size(
    nodes: list[tuple[Any, int]],
    batch_size: int,
    graph: FederatedGraph,
) -> list[dict[str, Any]]:
    """Reshape collected nodes based on batch_size semantics.

    REQ-o00068-E: batch_size controls item granularity.
      -1: Assertions as first-class items
       0: Each node is one item (requirements include assertions inline)
       1: Each node is one item + immediate children summaries

    REQ-d00076-G: Reuses existing serializers.
    """
    items: list[dict[str, Any]] = []

    for node, depth in nodes:
        if batch_size == -1:
            # Assertions as first-class items
            if node.kind == NodeKind.REQUIREMENT:
                items.append(
                    {
                        "id": node.id,
                        "kind": "requirement",
                        "title": node.get_label(),
                        "level": node.get_field("level"),
                        "status": node.get_field("status"),
                        "depth": depth,
                    }
                )
                # Emit each assertion as a separate item
                for child in node.iter_children():
                    if child.kind == NodeKind.ASSERTION:
                        items.append(_serialize_assertion(child))
            elif node.kind == NodeKind.ASSERTION:
                # Already emitted inline above when parent was processed;
                # skip if traversed independently
                continue
            else:
                items.append(_serialize_node_summary(node))

        elif batch_size == 0:
            # Each node is one item with assertions inline
            if node.kind == NodeKind.REQUIREMENT:
                assertions = []
                for child in node.iter_children():
                    if child.kind == NodeKind.ASSERTION:
                        assertions.append(_serialize_assertion(child))
                items.append(
                    {
                        "id": node.id,
                        "kind": "requirement",
                        "title": node.get_label(),
                        "level": node.get_field("level"),
                        "status": node.get_field("status"),
                        "depth": depth,
                        "assertions": assertions,
                        "coverage": _compute_coverage_summary(node),
                    }
                )
            elif node.kind == NodeKind.ASSERTION:
                continue  # Inlined in parent
            else:
                items.append(_serialize_node_summary(node))

        else:
            # batch_size >= 1: Node + immediate children summaries
            if node.kind == NodeKind.REQUIREMENT:
                assertions = []
                for child in node.iter_children():
                    if child.kind == NodeKind.ASSERTION:
                        assertions.append(_serialize_assertion(child))
                children_summaries = []
                for child in node.iter_children():
                    if child.kind == NodeKind.REQUIREMENT:
                        children_summaries.append(_serialize_requirement_summary(child))
                items.append(
                    {
                        "id": node.id,
                        "kind": "requirement",
                        "title": node.get_label(),
                        "level": node.get_field("level"),
                        "status": node.get_field("status"),
                        "depth": depth,
                        "assertions": assertions,
                        "coverage": _compute_coverage_summary(node),
                        "children": children_summaries,
                    }
                )
            elif node.kind == NodeKind.ASSERTION:
                continue  # Inlined in parent
            else:
                items.append(_serialize_node_summary(node))

    return items


def _materialize_cursor_items(
    query: str,
    params: dict[str, Any],
    batch_size: int,
    graph: FederatedGraph,
) -> list[dict[str, Any]]:
    """Run query and reshape results for cursor iteration.

    REQ-d00076-B: Dispatches to existing query helpers.
    REQ-o00068-F: Supports subtree, search, hierarchy, query_nodes,
                  test_coverage, uncovered_assertions, scoped_search.
    """
    if query == "subtree":
        root_id = params.get("root_id", "")
        depth = params.get("depth", 0)
        include_kinds_str = params.get("include_kinds", "")

        # Parse kind filter
        kind_set: set[NodeKind] | None = None
        if include_kinds_str:
            kind_set = set()
            for k in include_kinds_str.split(","):
                k = k.strip().lower()
                try:
                    kind_set.add(NodeKind(k))
                except ValueError:
                    return []

        collected = _collect_subtree(graph, root_id, depth, kind_set)
        return _reshape_for_batch_size(collected, batch_size, graph)

    elif query == "search":
        results = _search(
            graph,
            query=params.get("query", ""),
            field=params.get("field", "all"),
            regex=params.get("regex", False),
            limit=params.get("limit", 50),
        )
        return results  # Already serialized dicts

    elif query == "hierarchy":
        result = _get_hierarchy(graph, params.get("req_id", ""))
        if "error" in result:
            return []
        # Flatten: ancestors then children
        items: list[dict[str, Any]] = []
        for anc in result.get("ancestors", []):
            anc["_section"] = "ancestor"
            items.append(anc)
        for child in result.get("children", []):
            child["_section"] = "child"
            items.append(child)
        return items

    elif query == "query_nodes":
        kw_str = params.get("keywords")
        kw_list = None
        if kw_str:
            kw_list = [k.strip() for k in kw_str.split(",") if k.strip()]
        filters: dict[str, str] = {}
        if params.get("level"):
            filters["level"] = params["level"]
        if params.get("status"):
            filters["status"] = params["status"]
        if params.get("actor"):
            filters["actor"] = params["actor"]
        result = _query_nodes(
            graph,
            kind=params.get("kind"),
            keywords=kw_list,
            match_all=params.get("match_all", True),
            filters=filters or None,
            limit=params.get("limit", 50),
        )
        return result.get("results", [])

    elif query == "test_coverage":
        result = _get_test_coverage(graph, params.get("req_id", ""))
        if not result.get("success"):
            return []
        # Return test nodes as items
        return result.get("test_nodes", [])

    elif query == "uncovered_assertions":
        result = _get_uncovered_assertions(
            graph,
            req_id=params.get("req_id"),
            limit=params.get("limit", 100),
        )
        if not result.get("success"):
            return []
        # Per-requirement: single item; all: list of requirement summaries
        if "requirements" in result:
            return result["requirements"]
        return [result]

    elif query == "scoped_search":
        # Implements: REQ-o00068-F, REQ-d00076-B
        result = _scoped_search(
            graph,
            query=params.get("query", ""),
            scope_id=params.get("scope_id", ""),
            direction=params.get("direction", "descendants"),
            field=params.get("field", "all"),
            regex=params.get("regex", False),
            include_assertions=params.get("include_assertions", False),
            limit=params.get("limit", 50),
        )
        return result.get("results", [])

    else:
        return []


def _open_cursor(
    state: dict[str, Any],
    query: str,
    params: dict[str, Any],
    batch_size: int,
) -> dict[str, Any]:
    """Open a new cursor, auto-closing any previous one.

    REQ-o00068-A: Materializes results and returns first item + metadata.
    REQ-o00068-D: Single active cursor; new cursor discards previous.
    REQ-d00076-C: Stored in _state["cursor"].
    REQ-d00076-D: Returns first item, total count, and query metadata.
    """
    graph = state["graph"]
    items = _materialize_cursor_items(query, params, batch_size, graph)

    cursor = CursorState(
        query=query,
        params=params,
        batch_size=batch_size,
        items=items,
    )
    state["cursor"] = cursor

    first_item = items[0] if items else None
    if first_item is not None:
        cursor.position = 1

    return {
        "success": True,
        "query": query,
        "batch_size": batch_size,
        "total": len(items),
        "position": cursor.position,
        "remaining": len(items) - cursor.position,
        "current": first_item,
    }


def _cursor_next(
    state: dict[str, Any],
    count: int = 1,
) -> dict[str, Any]:
    """Advance cursor and return next items.

    REQ-o00068-B: Returns next count items, advances position.
    REQ-d00076-E: Returns items at [position:position+count], empty at end.
    """
    cursor = state.get("cursor")
    if cursor is None:
        return {"success": False, "error": "No active cursor. Use open_cursor() first."}

    start = cursor.position
    end = min(start + count, len(cursor.items))
    items = cursor.items[start:end]
    cursor.position = end

    return {
        "success": True,
        "items": items,
        "count": len(items),
        "position": cursor.position,
        "total": len(cursor.items),
        "remaining": len(cursor.items) - cursor.position,
    }


def _cursor_info(
    state: dict[str, Any],
) -> dict[str, Any]:
    """Return cursor position info without advancing.

    REQ-o00068-C: Returns position/total/remaining without advancing.
    REQ-d00076-F: Read-only, returns {position, total, remaining, query, batch_size}.
    """
    cursor = state.get("cursor")
    if cursor is None:
        return {"success": False, "error": "No active cursor. Use open_cursor() first."}

    return {
        "success": True,
        "position": cursor.position,
        "total": len(cursor.items),
        "remaining": len(cursor.items) - cursor.position,
        "query": cursor.query,
        "batch_size": cursor.batch_size,
    }


# ─────────────────────────────────────────────────────────────────────────────
# MCP Server Instructions
# ─────────────────────────────────────────────────────────────────────────────

MCP_SERVER_INSTRUCTIONS = """\
elspais MCP Server - AI-Driven Requirements Management

This server provides tools to navigate, analyze, and mutate a requirements traceability graph.
The graph is the single source of truth - all tools read directly from it.

## Quick Start

1. `docs(topic)` - Browse docs by topic, or search all help with a phrase
2. `faq(topic)` - Quick answers on common topics (linking, coverage, mutations, assertions)
3. `agent_instructions()` - Get project-specific authoring guidance
4. `get_workspace_info(detail=...)` - Understand what project you're working with
5. `get_project_summary()` - Get overview statistics and health metrics
6. `search(query)` - Find requirements by keyword
7. `get_requirement(req_id)` - Get full details including assertions
8. `get_hierarchy(req_id)` - Navigate parent/child relationships
9. `discover_requirements(query, scope_id)` - Find most-specific matches in a subgraph
10. `discover_assertions(query)` - Find assertions ranked by relevance

## Tools Overview

### Help & Documentation
- `docs(topic)` - Browse docs by topic name, or search all help surfaces with a phrase
  - Exact topic name (e.g., "checks") returns full content
  - Search phrase (e.g., "coverage gap") returns matching sections across all help
- `faq(topic)` - Quick Q&A on common topics (linking, coverage, mutations, assertions, health)
- `agent_instructions()` - Project-specific authoring rules and conventions

### Graph Status & Control
- `get_graph_status()` - Node counts, orphan/broken reference flags
- `refresh_graph(full=False, path="")` - Rebuild after spec file changes
  - path: switch to a different project directory before rebuilding

### Search & Navigation
- `search(query, field="all", regex=False, limit=50)` - Find requirements
  - field: "id", "title", "body", or "all"
  - regex: treat query as regex pattern
- `scoped_search(query, scope_id, direction="descendants", ...)` - Search within a subgraph
  - Restricts search to descendants or ancestors of scope_id
  - include_assertions: also match against assertion text
  - Returns results with scope context metadata
- `get_requirement(req_id)` - Full details with assertions and relationships
- `get_hierarchy(req_id)` - Ancestors (to roots) and direct children
- `minimize_requirement_set(req_ids, edge_kinds="")` - Prune to most-specific
  - Removes ancestors that are superseded by more-specific descendants
  - Returns minimal_set, pruned items with superseded_by, and stats
- `discover_requirements(query, scope_id, ...)` - Search + minimize in one step
  - Chains scoped_search with minimize_requirement_set
  - Returns only the most-specific matches within a subgraph
  - Pruned ancestors include superseded_by metadata
- `discover_assertions(query, scope_id?, ...)` - Find assertions ranked by relevance
  - Returns assertions as top-level scored results (not nested under requirements)
  - Searches requirement titles/bodies AND assertion text
  - Direct assertion text matches score higher than context-only matches
  - scope_id="" (default) searches all requirements globally

### Workspace Context
- `get_workspace_info(detail="default")` - Repo path, project name, version, configuration
  - detail: use-case profile selecting what information to return
  - "default": basic info, version, available_details (self-documenting)
  - "testing": ID patterns, assertion format, test dirs/patterns/keyword
  - "code-refs": code directories, comment styles, implements/refines keywords
  - "coverage": coverage stats by level, code ref coverage, associate list
  - "retrofit": full patterns + hierarchy rules + code refs + test config
  - "manager": health flags, coverage stats, change metrics
  - "worktree": associate paths (with local overrides), ID patterns, hierarchy rules
  - "all": everything from all profiles combined
- `get_project_summary()` - Counts by level, coverage stats, change metrics
- `get_changed_requirements()` - Requirements with uncommitted or branch changes
- `agent_instructions()` - Content rules providing authoring guidance for AI agents

### Node Mutations (in-memory)
- `mutate_rename_node(old_id, new_id)` - Rename requirement
- `mutate_update_title(node_id, new_title)` - Change title
- `mutate_change_status(node_id, new_status)` - Change status
- `mutate_add_requirement(req_id, title, level, ...)` - Create requirement
- `mutate_delete_requirement(node_id, confirm=True)` - Delete requirement (requires confirm)

### Assertion Mutations (in-memory)
- `mutate_add_assertion(req_id, label, text)` - Add assertion
- `mutate_update_assertion(assertion_id, new_text)` - Update text
- `mutate_delete_assertion(assertion_id, confirm=True)` - Delete (requires confirm)
- `mutate_rename_assertion(old_id, new_label)` - Rename label

### Edge Mutations (in-memory)
- `mutate_add_edge(source_id, target_id, edge_kind)` - Add relationship
- `mutate_change_edge_kind(source_id, target_id, new_kind)` - Change type
- `mutate_delete_edge(source_id, target_id, confirm=True)` - Delete (requires confirm)
- `mutate_fix_broken_reference(source_id, old_target, new_target)` - Fix broken ref

### Undo & Inspection
- `undo_last_mutation()` - Undo most recent mutation
- `undo_to_mutation(mutation_id)` - Undo back to specific point
- `get_mutation_log(limit=50)` - View mutation history
- `get_orphaned_nodes()` - List orphaned nodes
- `get_broken_references()` - List broken references

### Test Coverage Analysis
- `get_test_coverage(req_id)` - Get TEST nodes and coverage stats for a requirement
  - Returns test_nodes (with nested results), covered/uncovered assertions
  - Includes coverage percentage calculation
- `get_uncovered_assertions(req_id=None)` - Find assertions with no test coverage
  - When req_id is None, scans all requirements
  - Returns assertion details with parent requirement context
- `find_assertions_by_keywords(keywords, match_all=True)` - Search assertion text
  - match_all=True requires ALL keywords, False requires ANY
  - Complements find_by_keywords() which searches requirement titles

### Link Suggestion
- `suggest_links(file_path?, limit?)` - Suggest requirement links for unlinked tests
  - Uses heuristics: import chain, function name, file proximity, keyword overlap
  - Returns suggestions with confidence scores and reasons
- `apply_link(file_path, line, requirement_id)` - Insert # Implements: comment
  - Validates requirement exists before modifying files
  - Refreshes graph after insertion

### Subtree Extraction
- `get_subtree(root_id, depth=0, include_kinds="", format="markdown")` - Extract subgraph
  - depth: 0 = unlimited, N = max N levels from root
  - include_kinds: comma-separated NodeKind values (empty = smart defaults)
  - format: "markdown" (indented headings), "flat" (JSON with nodes/edges/stats),
    "nested" (recursive JSON with children arrays)
  - Includes coverage summary stats per requirement node
  - Deduplicates in DAG structures

### Cursor Protocol (Incremental Iteration)
- `open_cursor(query, params={}, batch_size=1)` - Open cursor over query results
  - query: "subtree", "search", "hierarchy", "query_nodes",
    "test_coverage", "uncovered_assertions", "scoped_search"
  - params: query-specific parameters (e.g. {root_id: "REQ-p00001"})
  - batch_size: -1 (assertions as separate items), 0 (nodes with inline assertions),
    1 (nodes with children summaries)
  - Returns first item + total/position/remaining metadata
  - Opening a new cursor auto-closes any previous cursor
- `cursor_next(count=1)` - Get next items and advance position
- `cursor_info()` - Check position/total/remaining without advancing

## Requirement Levels

Requirements follow a three-tier hierarchy:
- **PRD** (Product): High-level product requirements
- **OPS** (Operations): Operational/process requirements
- **DEV** (Development): Technical implementation requirements

Children implement parents: DEV -> OPS -> PRD

**Note:** The exact ID syntax (prefixes, patterns) and hierarchy rules are
configurable per project via `.elspais.toml`. Use `get_workspace_info()` to
see the current project's configuration including the ID prefix and pattern.

## Important: In-Memory vs File Mutations

Mutation tools modify the **in-memory graph only**. Changes are NOT persisted
to spec files automatically. This allows you to:
1. Draft changes and review them
2. Use undo to revert mistakes
3. Refresh the graph to discard all changes

To persist changes, use the file mutation tools:

### File Mutations (persistent)
- `save_mutations(save_branch)` - Persist ALL pending in-memory mutations to spec files
- `change_reference_type(req_id, target_id, new_type, save_branch)` - Change Implements/Refines
- `move_requirement(req_id, target_file, save_branch)` - Move requirement to different file
- `restore_from_safety_branch(branch_name)` - Revert file changes
- `list_safety_branches()` - List available safety branches

Use `save_branch=True` to create a safety branch before modifications, allowing rollback.
Use `save_mutations()` after making in-memory changes with `mutate_*` tools to persist them.

## Common Patterns

**Understanding a requirement:**
1. get_requirement("REQ-p00001") for details and assertions
2. get_hierarchy("REQ-p00001") to see where it fits

**Finding related requirements:**
1. search("authentication") to find by keyword
2. get_hierarchy() on results to navigate relationships

**Checking project health:**
1. get_graph_status() for orphans/broken refs
2. get_project_summary() for coverage statistics

**Finding coverage gaps:**
1. get_project_summary() for high-level coverage percentages
2. get_uncovered_assertions() for all assertions without test coverage
3. get_uncovered_assertions(req_id) to check a specific requirement
4. get_test_coverage(req_id) for detailed coverage breakdown per requirement
5. Also: docs("coverage gap") or faq("coverage") for CLI gap flags

**Drafting requirement changes:**
1. mutate_add_requirement() to create draft
2. mutate_add_assertion() to add assertions
3. get_mutation_log() to review changes
4. undo_last_mutation() if needed

**Extracting a scoped subtree for sub-agent consumption:**
1. get_subtree("REQ-p00001") for markdown overview
2. get_subtree("REQ-p00001", format="flat") for structured data
3. get_subtree("REQ-p00001", depth=2) to limit depth

**Incrementally exploring results with cursor:**
1. open_cursor("subtree", {"root_id": "REQ-p00001"}, batch_size=0)
2. cursor_info() to check how many items remain
3. cursor_next() to get next item, repeat as needed

**Discovering requirements for a ticket:**
1. discover_requirements("authentication", scope_id="REQ-p00001") for most-specific matches
2. get_requirement() on each result for full details and assertions
3. Or use scoped_search() + minimize_requirement_set() separately for more control

**After editing spec files:**
1. refresh_graph() to rebuild
2. get_graph_status() to verify health

**Switching to a different project:**
1. refresh_graph(path="/path/to/other/repo") to switch and rebuild
"""


# ─────────────────────────────────────────────────────────────────────────────
# MCP Server Factory
# ─────────────────────────────────────────────────────────────────────────────


def create_server(
    graph: FederatedGraph | None = None,
    working_dir: Path | None = None,
) -> FastMCP:
    """Create the MCP server with all tools registered.

    Args:
        graph: Optional pre-built graph (for testing).
        working_dir: Working directory for graph building.

    Returns:
        FastMCP server instance.
    """
    if not MCP_AVAILABLE:
        raise ImportError("MCP dependencies not installed. Install with: pip install elspais[mcp]")

    # Initialize working directory
    if working_dir is None:
        working_dir = Path.cwd()

    # Load config for the working directory
    config = get_config(start_path=working_dir, quiet=True)

    # Build initial graph if not provided
    if graph is None:
        try:
            graph = build_graph(config=config, repo_root=working_dir)
        except ValueError as e:
            import sys

            print(f"CONFIG ERROR: {e}", file=sys.stderr)
            graph = FederatedGraph.empty()

    # Create server with instructions for AI agents (REQ-d00065)
    mcp = FastMCP("elspais", instructions=MCP_SERVER_INSTRUCTIONS)

    # Store graph in closure for tools
    _state: dict[str, Any] = {
        "graph": graph,
        "working_dir": working_dir,
        "config": config,
    }

    # ─────────────────────────────────────────────────────────────────────
    # Register Tools
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def get_graph_status() -> dict[str, Any]:
        """Quick health snapshot: requirement/assertion/test counts, orphan and broken-ref flags.

        Use when: you need a fast overview of project health without running full checks.
        """
        return _get_graph_status(_state["graph"])

    @mcp.tool()
    def refresh_graph(full: bool = False, path: str = "") -> dict[str, Any]:
        """Reload the requirements graph from spec files on disk.

        Use when: spec files were edited outside of mutation tools, or after
        switching branches. Required after manual file edits to see changes.

        Args:
            full: If True, clear all caches before rebuild.
            path: Switch to a different project directory before rebuilding.
        """
        if path:
            new_dir = Path(path).resolve()
            if not new_dir.is_dir():
                return {"success": False, "message": f"Directory not found: {path}"}
            _state["working_dir"] = new_dir
            _state["config"] = get_config(start_path=new_dir, quiet=True)

        result, new_graph = _refresh_graph(
            _state["working_dir"],
            full=full,
        )
        _state["graph"] = new_graph
        # REQ-d00205-B: Sync config from rebuilt graph's root repo
        if result.get("config") is not None:
            _state["config"] = result["config"]
        result["working_dir"] = str(_state["working_dir"])
        return result

    @mcp.tool()
    def search(
        query: str,
        field: str = "all",
        regex: bool = False,
        limit: int = 50,
    ) -> list[dict[str, Any]]:
        """Find requirements by keyword, ID, or content. Returns ranked results.

        Use when: looking for requirements about a topic, finding a REQ by partial
        ID, or answering "which requirement covers X?". For searching within a
        specific subtree, use scoped_search() or discover_requirements() instead.

        Query syntax (when regex=False):
        - ``auth encryption`` — AND: both terms must match (default)
        - ``auth OR encryption`` — OR: either term matches
        - ``(auth OR login) encryption`` — grouping with parentheses
        - ``"data at rest"`` — exact phrase match
        - ``-deprecated`` — exclude results containing "deprecated"
        - ``=security`` — exact keyword tag match (not substring)

        Tip: space-separated terms are AND'd, which can be too restrictive.
        Use OR between terms when you want broader results:
        ``authentication OR login OR session`` finds any of those topics.

        Results are scored by field: ID match (100), title (50),
        keyword-exact (40), keyword-substring (25), body (10).
        """
        return _search(_state["graph"], query, field, regex, limit)

    @mcp.tool()
    def minimize_requirement_set(
        req_ids: list[str],
        edge_kinds: str = "implements,refines",
    ) -> dict[str, Any]:
        """Remove ancestors superseded by more-specific descendants from a requirement ID list.

        Use when: you have search results and want to eliminate redundant parent
        requirements already covered by their children in the list.
        """
        # REQ-d00077-F: Parse edge_kinds string to EdgeKind set
        parsed_kinds: set[EdgeKind] = set()
        for kind_str in edge_kinds.split(","):
            kind_str = kind_str.strip().lower()
            try:
                parsed_kinds.add(EdgeKind(kind_str))
            except ValueError:
                pass
        if not parsed_kinds:
            parsed_kinds = {EdgeKind.IMPLEMENTS, EdgeKind.REFINES}
        return _minimize_requirement_set(_state["graph"], req_ids, parsed_kinds)

    @mcp.tool()
    def scoped_search(
        query: str,
        scope_id: str,
        direction: str = "descendants",
        field: str = "all",
        regex: bool = False,
        include_assertions: bool = False,
        limit: int = 50,
    ) -> dict[str, Any]:
        """Search for requirements within a specific subtree only.

        Use when: you know the parent requirement and want to find matching
        descendants (or ancestors). Same query syntax as search().
        Prefer discover_requirements() when you want only the most-specific
        (leaf-level) matches with ancestors pruned out.
        """
        return _scoped_search(
            _state["graph"], query, scope_id, direction, field, regex, include_assertions, limit
        )

    @mcp.tool()
    def discover_requirements(
        query: str,
        scope_id: str,
        direction: str = "descendants",
        field: str = "all",
        regex: bool = False,
        include_assertions: bool = False,
        limit: int = 50,
        edge_kinds: str = "implements,refines",
    ) -> dict[str, Any]:
        """Search within a subtree and return only the most-specific (leaf-level) matches.

        Use when: finding which DEV/OPS requirements implement a concept under a
        specific PRD. Combines scoped_search + minimize_requirement_set in one call:
        searches descendants of scope_id, then prunes ancestor matches that are
        superseded by more-specific descendants.
        """
        # REQ-d00079-D: Parse edge_kinds and delegate
        parsed_kinds: set[EdgeKind] = set()
        for kind_str in edge_kinds.split(","):
            kind_str = kind_str.strip().lower()
            try:
                parsed_kinds.add(EdgeKind(kind_str))
            except ValueError:
                pass
        if not parsed_kinds:
            parsed_kinds = {EdgeKind.IMPLEMENTS, EdgeKind.REFINES}
        return _discover_requirements(
            _state["graph"],
            query,
            scope_id,
            direction,
            field,
            regex,
            include_assertions,
            limit,
            parsed_kinds,
        )

    @mcp.tool()
    def discover_assertions(
        query: str,
        scope_id: str = "",
        direction: str = "descendants",
        field: str = "all",
        regex: bool = False,
        limit: int = 50,
        edge_kinds: str = "implements,refines",
    ) -> dict[str, Any]:
        """Search for assertions, returning them as top-level scored results.

        Use when: finding which specific assertions a test or code change
        relates to. Searches requirement titles, bodies, and assertion text,
        then returns all assertions from matching requirements ranked by
        relevance. Assertions whose text directly matched score higher than
        siblings that were only pulled in by their parent requirement.

        When scope_id is provided, searches within that subtree and minimizes
        to the most-specific requirements first. When scope_id is empty
        (default), searches all requirements globally.
        """
        parsed_kinds: set[EdgeKind] = set()
        for kind_str in edge_kinds.split(","):
            kind_str = kind_str.strip().lower()
            try:
                parsed_kinds.add(EdgeKind(kind_str))
            except ValueError:
                pass
        if not parsed_kinds:
            parsed_kinds = {EdgeKind.IMPLEMENTS, EdgeKind.REFINES}
        return _discover_assertions(
            _state["graph"],
            query,
            scope_id,
            direction,
            field,
            regex,
            limit,
            parsed_kinds,
        )

    @mcp.tool()
    def get_requirement(req_id: str) -> dict[str, Any]:
        """Get focused details for a single requirement.

        Returns: id, title, level, status, hash, body, source location,
        assertions, parent/child requirements, and coverage metrics.
        For the full generic node envelope, use get_node().
        """
        return _get_requirement(_state["graph"], req_id)

    @mcp.tool()
    def get_node(node_id: str) -> dict[str, Any]:
        """Get full generic details for any graph node by ID.

        Supports requirement, assertion, test, code, and file nodes.

        Use when: you need the raw node envelope with all children, edges, and
        kind-specific properties. For requirements, prefer get_requirement() which
        returns a focused, readable format.
        """
        return _get_node(_state["graph"], node_id)

    @mcp.tool()
    def query_nodes(
        kind: str | None = None,
        keywords: str | None = None,
        match_all: bool = True,
        level: str | None = None,
        status: str | None = None,
        actor: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]:
        """List nodes filtered by structured criteria (kind, level, status, keywords).

        Use when: you need to enumerate nodes by type or status rather than
        free-text search. For example, "all Draft DEV requirements" or
        "all test nodes with keyword 'auth'". For free-text search, use search().

        Args:
            kind: NodeKind value: requirement, journey, test, result, code.
            keywords: Comma-separated keyword tags to match against.
            match_all: True (default) = ALL keywords must match, False = ANY.
            level: PRD, OPS, DEV (requirements only).
            status: Requirement or test result status.
            actor: Journey actor.
            limit: Max results (default 50).
        """
        kw_list = None
        if keywords:
            kw_list = [k.strip() for k in keywords.split(",") if k.strip()]
        filters = {}
        if level:
            filters["level"] = level
        if status:
            filters["status"] = status
        if actor:
            filters["actor"] = actor
        return _query_nodes(_state["graph"], kind, kw_list, match_all, filters or None, limit)

    @mcp.tool()
    def get_hierarchy(req_id: str) -> dict[str, Any]:
        """Trace a requirement's position in the hierarchy: ancestors up to roots, direct children.

        Use when: understanding where a requirement fits — what it implements (parents)
        and what implements it (children). Shows the full chain from DEV up to PRD.
        """
        return _get_hierarchy(_state["graph"], req_id)

    # ─────────────────────────────────────────────────────────────────────
    # Workspace Context Tools (REQ-o00061)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def get_workspace_info(detail: str = "default") -> dict[str, Any]:
        """Project identity, configuration, and federation details.

        Use when: understanding what project you're working with — name, ID patterns,
        spec directories, hierarchy rules, test config, associate repos, coverage stats.

        Args:
            detail: Profile to return: 'default', 'testing', 'code-refs',
                'coverage', 'retrofit', 'manager', 'worktree', or 'all'.
        """
        return _get_workspace_info(
            _state["working_dir"],
            config=_state["config"],
            graph=_state["graph"],
            detail=detail,
        )

    @mcp.tool()
    def get_project_summary() -> dict[str, Any]:
        """Project overview: requirement counts by level, coverage percentages, change metrics.

        Use when: you need a high-level status report of the project's requirements.
        """
        return _get_project_summary(_state["graph"], _state["working_dir"], _state["config"])

    @mcp.tool()
    def get_changed_requirements() -> dict[str, Any]:
        """Detect which requirements have been modified since last commit or branch point.

        Use when: checking what changed in the current working tree or feature branch,
        reviewing spec modifications before commit, or finding moved requirements.
        """
        return _get_changed_requirements(_state["graph"])

    @mcp.tool()
    def agent_instructions() -> dict[str, Any]:
        """Content rules and authoring guidance configured for this project.

        Use when: writing or editing requirements — tells you the project's
        conventions for assertion format, keyword style, hash handling, etc.
        """
        return _get_agent_instructions(_state["config"], _state["working_dir"])

    @mcp.tool()
    def faq(topic: str = "") -> dict[str, Any]:
        """Frequently asked questions about elspais concepts and usage.

        Use when: you need guidance on how something works (linking, coverage,
        assertions, etc.) or the user asks a "how do I…" question.

        Args:
            topic: Optional keyword to filter (e.g., "linking", "test", "coverage").
                   If empty, returns all FAQ entries.
        """
        return _get_faq(topic)

    @mcp.tool()
    def docs(topic: str = "") -> dict[str, Any]:
        """Browse or search elspais documentation.

        Use when: you need detailed guidance on a specific feature — linking,
        health checks, configuration, assertions, etc.

        Args:
            topic: Exact topic name (e.g., "checks") returns full content.
                   Any other string searches all docs for matching lines
                   and returns excerpts with context.
                   If empty, returns the list of available topics.
        """
        return _get_docs(topic)

    # ─────────────────────────────────────────────────────────────────────
    # Node Mutation Tools (REQ-o00062-A)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def mutate_rename_node(old_id: str, new_id: str) -> dict[str, Any]:
        """Rename a requirement's ID (e.g., REQ-d00001 -> REQ-d00010). Updates all references."""
        return _mutate_rename_node(_state["graph"], old_id, new_id)

    @mcp.tool()
    def mutate_update_title(node_id: str, new_title: str) -> dict[str, Any]:
        """Change a requirement's display title."""
        return _mutate_update_title(_state["graph"], node_id, new_title)

    @mcp.tool()
    def mutate_change_status(node_id: str, new_status: str) -> dict[str, Any]:
        """Change a requirement's status.

        Args:
            new_status: e.g., 'Active', 'Draft', 'Deprecated'.
        """
        return _mutate_change_status(_state["graph"], node_id, new_status)

    @mcp.tool()
    def mutate_add_requirement(
        req_id: str,
        title: str,
        level: str,
        status: str = "Draft",
        parent_id: str | None = None,
        edge_kind: str | None = None,
    ) -> dict[str, Any]:
        """Create a new requirement in the graph (in-memory until save_mutations).

        Args:
            req_id: The new requirement ID (e.g., REQ-d00010).
            title: Human-readable title.
            level: PRD, OPS, or DEV.
            status: Initial status (default: Draft).
            parent_id: Optional parent requirement to link to.
            edge_kind: Edge type if parent_id set ('IMPLEMENTS' or 'REFINES').
        """
        return _mutate_add_requirement(
            _state["graph"], req_id, title, level, status, parent_id, edge_kind
        )

    @mcp.tool()
    def mutate_delete_requirement(node_id: str, confirm: bool = False) -> dict[str, Any]:
        """Delete a requirement and its assertions. Returns error unless confirm=True."""
        return _mutate_delete_requirement(_state["graph"], node_id, confirm)

    # ─────────────────────────────────────────────────────────────────────
    # Assertion Mutation Tools (REQ-o00062-B)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def mutate_add_assertion(req_id: str, label: str, text: str) -> dict[str, Any]:
        """Add a testable assertion (A, B, C...) to a requirement. Text should include SHALL."""
        return _mutate_add_assertion(_state["graph"], req_id, label, text)

    @mcp.tool()
    def mutate_update_assertion(assertion_id: str, new_text: str) -> dict[str, Any]:
        """Rewrite an assertion's text (e.g., REQ-p00001-A)."""
        return _mutate_update_assertion(_state["graph"], assertion_id, new_text)

    @mcp.tool()
    def mutate_delete_assertion(
        assertion_id: str, compact: bool = True, confirm: bool = False
    ) -> dict[str, Any]:
        """Delete an assertion. Returns error unless confirm=True.

        Remaining labels re-sequenced if compact=True.
        """
        return _mutate_delete_assertion(_state["graph"], assertion_id, compact, confirm)

    @mcp.tool()
    def mutate_rename_assertion(old_id: str, new_label: str) -> dict[str, Any]:
        """Change an assertion's label (e.g., rename B to D). Updates all references."""
        return _mutate_rename_assertion(_state["graph"], old_id, new_label)

    # ─────────────────────────────────────────────────────────────────────
    # Edge Mutation Tools (REQ-o00062-C)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def mutate_add_edge(
        source_id: str,
        target_id: str,
        edge_kind: str,
        assertion_targets: list[str] | None = None,
    ) -> dict[str, Any]:
        """Add a traceability relationship (Implements, Refines, Validates) between nodes.

        source_id is the child (the requirement doing the implementing/refining).
        target_id is the parent (the requirement being implemented/refined).

        Args:
            edge_kind: 'IMPLEMENTS' or 'REFINES'.
            assertion_targets: Optional list of assertion labels or full assertion
                IDs to target (e.g. ["A", "B"] or ["REQ-p00044-A", "REQ-p00044-B"]).
                Full IDs are automatically normalized to bare labels.
        """
        return _mutate_add_edge(
            _state["graph"],
            source_id,
            target_id,
            edge_kind,
            assertion_targets,
            config=_state.get("config"),
        )

    @mcp.tool()
    def mutate_change_edge_kind(source_id: str, target_id: str, new_kind: str) -> dict[str, Any]:
        """Change an edge's relationship type.

        Args:
            new_kind: 'IMPLEMENTS' or 'REFINES'.
        """
        return _mutate_change_edge_kind(_state["graph"], source_id, target_id, new_kind)

    @mcp.tool()
    def mutate_delete_edge(source_id: str, target_id: str, confirm: bool = False) -> dict[str, Any]:
        """Remove a traceability relationship between nodes. Returns error unless confirm=True."""
        return _mutate_delete_edge(_state["graph"], source_id, target_id, confirm)

    @mcp.tool()
    def mutate_fix_broken_reference(
        source_id: str, old_target_id: str, new_target_id: str
    ) -> dict[str, Any]:
        """Repair a broken reference by redirecting it to a valid target node."""
        return _mutate_fix_broken_reference(
            _state["graph"], source_id, old_target_id, new_target_id
        )

    @mcp.tool()
    def mutate_change_edge_targets(
        source_id: str, target_id: str, assertion_targets: list[str]
    ) -> dict[str, Any]:
        """Change assertion targets on an IMPLEMENTS/REFINES edge.

        Args:
            source_id: The child/source node ID.
            target_id: The parent/target node ID.
            assertion_targets: Assertion labels to target (empty = whole req).
        """
        return _mutate_change_edge_targets(_state["graph"], source_id, target_id, assertion_targets)

    @mcp.tool()
    def mutate_move_node_to_file(node_id: str, target_file_id: str) -> dict[str, Any]:
        """Move a content node to a different FILE.

        Args:
            node_id: The node to move.
            target_file_id: The target FILE node ID (e.g. "file:spec/other.md").
        """
        return _mutate_move_node_to_file(_state["graph"], node_id, target_file_id)

    @mcp.tool()
    def mutate_rename_file(file_id: str, new_relative_path: str) -> dict[str, Any]:
        """Rename a FILE node and its on-disk path.

        Args:
            file_id: Current FILE node ID (e.g. "file:spec/main.md").
            new_relative_path: New repo-relative path (e.g. "spec/renamed.md").
        """
        return _mutate_rename_file(
            _state["graph"], file_id, new_relative_path, _state.get("repo_root")
        )

    # ─────────────────────────────────────────────────────────────────────
    # Undo & Inspection Tools (REQ-o00062-G)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def undo_last_mutation() -> dict[str, Any]:
        """Revert the most recent in-memory mutation (before save_mutations)."""
        return _undo_last_mutation(_state["graph"])

    @mcp.tool()
    def undo_to_mutation(mutation_id: str) -> dict[str, Any]:
        """Revert all in-memory mutations back to a specific point (inclusive).

        Use get_mutation_log() to find mutation IDs.
        """
        return _undo_to_mutation(_state["graph"], mutation_id)

    @mcp.tool()
    def get_mutation_log(limit: int = 50) -> dict[str, Any]:
        """List pending in-memory mutations (not yet saved to disk).

        Shows what save_mutations() will persist.
        """
        return _get_mutation_log(_state["graph"], limit)

    @mcp.tool()
    def get_orphaned_nodes() -> dict[str, Any]:
        """Find requirements not contained in any spec file (structural orphans).

        Use when: diagnosing graph health issues — orphaned nodes are typically
        the result of failed parsing or manual graph manipulation.
        """
        return _get_orphaned_nodes(_state["graph"])

    @mcp.tool()
    def get_unlinked_nodes(kind: str | None = None) -> dict[str, Any]:
        """Unlinked nodes — have a FILE parent but no requirement link.

        These are TEST or CODE nodes that exist in the file structure but
        are not connected to any requirement through traceability edges.

        Args:
            kind: Optional filter — "test" or "code". If omitted, returns both.
        """
        if kind is not None:
            kind = kind.lower()
            if kind not in ("test", "code"):
                return {"error": f"Invalid kind '{kind}'. Must be 'test' or 'code'."}
        return _get_unlinked_nodes(_state["graph"], kind)

    @mcp.tool()
    def get_broken_references() -> dict[str, Any]:
        """Find Implements/Refines references that point to non-existent requirement IDs.

        Use when: checking for broken links after renaming or deleting requirements.
        """
        return _get_broken_references(_state["graph"])

    # ─────────────────────────────────────────────────────────────────────
    # Keyword Search Tools (Phase 4)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def find_by_keywords(
        keywords: list[str],
        match_all: bool = True,
    ) -> dict[str, Any]:
        """Find requirements by curated keyword tags (from [keywords] metadata in specs).

        Use when: filtering by explicit keyword annotations, not free-text content.
        For free-text search across titles, bodies, and IDs, use search() instead.
        """
        return _find_by_keywords(_state["graph"], keywords, match_all)

    @mcp.tool()
    def get_all_keywords() -> dict[str, Any]:
        """List all keyword tags used across requirements.

        Useful for discovering available filter terms for find_by_keywords().
        """
        return _get_all_keywords(_state["graph"])

    # ─────────────────────────────────────────────────────────────────────
    # Test Coverage Tools (REQ-o00064)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def get_test_coverage(req_id: str) -> dict[str, Any]:
        """Which tests validate a requirement and which assertions are covered/uncovered.

        Use when: checking if a requirement has adequate test coverage, or finding
        which specific assertions still need tests written.
        """
        return _get_test_coverage(_state["graph"], req_id)

    @mcp.tool()
    def get_uncovered_assertions(
        req_id: str | None = None,
        source: str = "test",
    ) -> dict[str, Any]:
        """Find assertions that have no tests validating them.

        Use when: identifying test gaps — assertions that need tests written.

        Args:
            req_id: Optional requirement ID to check. When None, scans ALL requirements.
            source: Coverage source to consider: 'test' (default), 'uat', or 'both'.
        """
        return _get_uncovered_assertions(_state["graph"], req_id, source=source)

    @mcp.tool()
    def find_assertions_by_keywords(
        keywords: list[str],
        match_all: bool = True,
    ) -> dict[str, Any]:
        """Search assertion text for keywords (e.g., find all assertions mentioning "encrypt").

        Use when: looking for specific obligations across all requirements.
        Searches assertion text, not requirement titles — complements search().
        """
        return _find_assertions_by_keywords(_state["graph"], keywords, match_all)

    # ─────────────────────────────────────────────────────────────────────
    # File Mutation Tools (REQ-o00063)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def change_reference_type(
        req_id: str,
        target_id: str,
        new_type: str,
        save_branch: bool = False,
    ) -> dict[str, Any]:
        """Change a reference type in a spec file.

        Args:
            new_type: 'IMPLEMENTS' or 'REFINES'.
            save_branch: If True, create a safety branch before modifying.
        """
        result = _change_reference_type(
            _state["working_dir"], req_id, target_id, new_type, save_branch
        )
        # REQ-o00063-F: Refresh graph after file mutations
        if result.get("success"):
            new_result, new_graph = _refresh_graph(
                _state["working_dir"],
            )
            _state["graph"] = new_graph
        return result

    @mcp.tool()
    def move_requirement(
        req_id: str,
        target_file: str,
        save_branch: bool = False,
    ) -> dict[str, Any]:
        """Move a requirement to a different spec file.

        Args:
            target_file: Relative path to the target file (e.g., 'spec/other.md').
            save_branch: If True, create a safety branch before modifying.
        """
        result = _move_requirement(_state["working_dir"], req_id, target_file, save_branch)
        # REQ-o00063-F: Refresh graph after file mutations
        if result.get("success"):
            new_result, new_graph = _refresh_graph(
                _state["working_dir"],
            )
            _state["graph"] = new_graph
        return result

    @mcp.tool()
    def restore_from_safety_branch(branch_name: str) -> dict[str, Any]:
        """Restore spec files from a safety branch."""
        result = _restore_from_safety_branch(_state["working_dir"], branch_name)
        # REQ-o00063-F: Refresh graph after file mutations
        if result.get("success"):
            new_result, new_graph = _refresh_graph(
                _state["working_dir"],
            )
            _state["graph"] = new_graph
        return result

    @mcp.tool()
    def list_safety_branches() -> dict[str, Any]:
        """List git safety branches created by save_mutations(save_branch=True).

        Use for rollback via restore_from_safety_branch().
        """
        return _list_safety_branches_impl(_state["working_dir"])

    @mcp.tool()
    def save_mutations(
        save_branch: bool = False,
        message: str | None = None,
    ) -> dict[str, Any]:
        """Persist all pending in-memory mutations (from mutate_* tools) to spec files on disk.

        Walks the mutation log and replays each entry to the authoritative spec
        files.  Edge mutations are coalesced (multiple add/delete operations
        collapse to a single write per requirement).  After a successful save
        the graph is refreshed so the in-memory state matches disk.

        Args:
            save_branch: If True, create a git safety branch before writing.
            message: Changelog reason for Active requirement changes.
                Required when mutations affect Active requirements
                (when changelog enforcement is enabled).
        """
        # Implements: REQ-d00132-A, REQ-d00132-B
        from elspais.graph.render import render_save

        graph = _state["graph"]
        if graph is None:
            return {"success": False, "error": "graph not available"}

        # Check changelog enforcement for Active requirements
        config = _state.get("config", {})
        typed_config = _validate_config(config) if isinstance(config, dict) else config
        changelog_enforce = typed_config.changelog.hash_current

        if changelog_enforce:
            active_mutated = _get_active_mutated_reqs(graph)
            if active_mutated and not message:
                ids = ", ".join(sorted(active_mutated))
                return {
                    "success": False,
                    "error": (
                        f"Active requirement(s) modified: {ids}. "
                        "Provide a 'message' parameter with the "
                        "changelog reason."
                    ),
                }

        if save_branch:
            from elspais.utilities.git import create_safety_branch

            create_safety_branch(_state["working_dir"], "save-mutations")

        from elspais.utilities.patterns import build_resolver as _build_resolver_for_save

        result = render_save(
            graph, _state["working_dir"], resolver=_build_resolver_for_save(config)
        )

        # Add changelog entries for Active requirements after save
        if result.get("success") and changelog_enforce and message:
            _add_changelog_for_active_mutations(graph, _state["working_dir"], config, message)

        # REQ-o00063-F: Refresh graph after file mutations
        if result.get("success"):
            new_result, new_graph = _refresh_graph(
                _state["working_dir"],
            )
            _state["graph"] = new_graph

        return result

    # ─────────────────────────────────────────────────────────────────────
    # Link Suggestion Tools (REQ-d00074)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def suggest_links(
        file_path: str | None = None,
        limit: int = 50,
    ) -> dict[str, Any]:
        """Suggest which requirements an unlinked test or code file should reference.

        Use when: a test file has no ``# Implements:`` comment and you want
        recommendations based on function names, imports, and file proximity.
        """
        return _suggest_links_impl(
            _state["graph"],
            _state["working_dir"],
            file_path=file_path,
            limit=limit,
        )

    @mcp.tool()
    def apply_link(
        file_path: str,
        line: int,
        requirement_id: str,
    ) -> dict[str, Any]:
        """Insert a ``# Implements:`` comment linking a test to a requirement.

        Args:
            file_path: Path to the file to modify (relative to repo root).
            line: Line number to insert at (1-based). 0 means top of file.
        """
        return _apply_link_impl(
            _state,
            file_path=file_path,
            line=line,
            requirement_id=requirement_id,
        )

    # ─────────────────────────────────────────────────────────────────────
    # Subtree Extraction Tool (REQ-o00067)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def get_subtree(
        root_id: str,
        depth: int = 0,
        include_kinds: str = "",
        format: str = "markdown",
    ) -> dict[str, Any]:
        """Extract a requirement subtree as markdown, flat JSON, or nested JSON.

        Use when: providing context about a requirement and its descendants to a
        sub-agent, generating a scoped view of the hierarchy, or exporting a
        section of the traceability graph.

        Args:
            root_id: ID of the root node to start from.
            depth: Max depth from root (0 = unlimited).
            include_kinds: Comma-separated NodeKind values to include.
                Empty string uses conservative defaults per root kind.
            format: 'markdown', 'flat', or 'nested'.
        """
        return _get_subtree(
            _state["graph"],
            root_id=root_id,
            depth=depth,
            include_kinds=include_kinds,
            format=format,
        )

    # ─────────────────────────────────────────────────────────────────────
    # Cursor Protocol Tools (REQ-o00068)
    # ─────────────────────────────────────────────────────────────────────

    @mcp.tool()
    def open_cursor(
        query: str,
        params: dict[str, Any] | None = None,
        batch_size: int = 1,
    ) -> dict[str, Any]:
        """Open a cursor for paginated iteration over any query result set.

        Use when: a query might return many results and you want to process
        them one at a time without loading everything. Use cursor_next() to
        advance and cursor_info() to check position.

        Args:
            query: 'subtree', 'search', 'hierarchy', 'query_nodes',
                'test_coverage', 'uncovered_assertions', or 'scoped_search'.
            params: Query-specific parameters as a dict.
            batch_size: -1=assertions first-class, 0=nodes with inline
                assertions, 1=nodes with children summaries.
        """
        return _open_cursor(
            _state,
            query=query,
            params=params or {},
            batch_size=batch_size,
        )

    @mcp.tool()
    def cursor_next(
        count: int = 1,
    ) -> dict[str, Any]:
        """Get next item(s) from an open cursor. Call after open_cursor()."""
        return _cursor_next(_state, count=count)

    @mcp.tool()
    def cursor_info() -> dict[str, Any]:
        """Check cursor state: current position, total items, and how many remain."""
        return _cursor_info(_state)

    # ─────────────────────────────────────────────────────────────────────
    # Optional usage stats (stats key in config / ELSPAIS_STATS env var)
    # ─────────────────────────────────────────────────────────────────────
    stats_path = _state["config"].get("stats")
    if stats_path:
        from elspais.mcp.stats import ToolStats, instrument

        _stats = ToolStats(stats_path)
        for tool in mcp._tool_manager._tools.values():
            tool.fn = instrument(tool.fn, tool.name, _stats)

    # Collect tool docstrings for docs() search
    _TOOL_DOCS.clear()
    for tool in mcp._tool_manager._tools.values():
        doc = (tool.fn.__doc__ or "").strip()
        if doc:
            _TOOL_DOCS[tool.name] = doc

    return mcp


def _config_hash_for_daemon(working_dir: Path) -> str:
    """Compute config hash for daemon.json, best-effort."""
    try:
        from elspais.mcp.daemon import compute_config_hash

        config_path = working_dir / ".elspais.toml"
        if config_path.is_file():
            return compute_config_hash(config_path)
    except Exception:
        pass
    return ""


def run_server(
    working_dir: Path | None = None,
    transport: str = "stdio",
    port: int = 8000,
    ttl_minutes: int = 0,
    daemon_json: Path | None = None,
) -> None:
    """Run the MCP server.

    Args:
        working_dir: Working directory for graph building.
        transport: Transport type ('stdio', 'sse', or 'streamable-http').
        port: Port for HTTP transports (0 = auto-assign).
        ttl_minutes: Auto-exit after N minutes of inactivity (0 = forever).
        daemon_json: Path to write daemon state file (pid, port).
    """
    import os as _os

    # Support daemon mode: env var set by daemon.start_daemon()
    env_dj = _os.environ.get("_ELSPAIS_DAEMON_JSON")
    if env_dj and daemon_json is None:
        daemon_json = Path(env_dj)

    if transport == "stdio":
        mcp = create_server(working_dir=working_dir)
        mcp.run(transport="stdio")
    else:
        # HTTP: use unified app (REST + MCP + auto-refresh)
        import socket

        import anyio
        import uvicorn

        from elspais.server.app import create_app
        from elspais.server.state import AppState

        if working_dir is None:
            working_dir = Path.cwd()

        state = AppState.from_config(repo_root=working_dir)
        app = create_app(state)

        if ttl_minutes > 0:
            from elspais.server.middleware import TTLMiddleware

            app.add_middleware(TTLMiddleware, ttl_minutes=ttl_minutes)

        # Resolve ephemeral port if port=0
        if port == 0:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                s.bind(("127.0.0.1", 0))
                port = s.getsockname()[1]

        if daemon_json:
            from elspais.mcp.daemon import write_daemon_json

            write_daemon_json(
                repo_root=working_dir,
                pid=_os.getpid(),
                port=port,
                server_type="daemon",
            )

        uvi_config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
        anyio.run(uvicorn.Server(uvi_config).serve)
