"""Custodian State Hub MCP Server (stdio).

Thin HTTP client over the FastAPI service — no direct DB access.
All business logic stays in the API; this layer is stateless.
"""
from __future__ import annotations

import json
import os
import re
import socket
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from uuid import UUID

import httpx
from fastmcp import FastMCP
from hub_core.mcp import HubCoreMCPServer

from mcp_server.constants import MCP_SERVER_NAME

API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")

mcp = FastMCP(
    name=MCP_SERVER_NAME,
    instructions=(
        "Custodian State Hub: tracks topics, workplans, tasks, decisions, and progress events. "
        "Start every session with get_state_summary() for orientation. "
        "When working inside a single registered domain repo, prefer get_domain_summary(domain_slug) "
        "— it returns the same actionable data scoped to that domain at ~10% of the token cost. "
        "All writes emit a progress_event automatically."
    ),
)

# Generic hub tools from hub-core; exclude dev-hub overrides with richer contracts.
_HUB_CORE_MCP_EXCLUDE = frozenset({
    "get_state_summary",
    "get_domain_summary",
    "list_domains",
    "list_domain_repos",
    "register_repo",
    "update_repo_path",
    "request_capability",
    "register_service",
    "ingest_tpsc_tool",
})
HubCoreMCPServer(
    name=MCP_SERVER_NAME,
    api_base=API_BASE,
    register_tools=False,
).attach_to(mcp, exclude=_HUB_CORE_MCP_EXCLUDE)

# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------

def _client() -> httpx.Client:
    return httpx.Client(base_url=API_BASE, timeout=30.0, follow_redirects=True)


def _get(path: str, params: dict | None = None) -> Any:
    if not path.endswith("/"):
        path = path + "/"
    try:
        with _client() as c:
            r = c.get(path, params={k: v for k, v in (params or {}).items() if v is not None})
            r.raise_for_status()
            return r.json()
    except httpx.HTTPStatusError as e:
        return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
    except Exception as e:
        return {"error": f"Request failed: {e}"}


def _post(path: str, body: dict) -> Any:
    if not path.endswith("/"):
        path = path + "/"
    try:
        with _client() as c:
            r = c.post(path, json={k: v for k, v in body.items() if v is not None})
            r.raise_for_status()
            return r.json()
    except httpx.HTTPStatusError as e:
        return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
    except Exception as e:
        return {"error": f"Request failed: {e}"}


def _patch(path: str, body: dict) -> Any:
    if not path.endswith("/"):
        path = path + "/"
    try:
        with _client() as c:
            r = c.patch(path, json={k: v for k, v in body.items() if v is not None})
            r.raise_for_status()
            return r.json()
    except httpx.HTTPStatusError as e:
        return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
    except Exception as e:
        return {"error": f"Request failed: {e}"}


def _delete(path: str) -> None:
    try:
        with _client() as c:
            r = c.delete(path)
            r.raise_for_status()
    except httpx.HTTPStatusError as e:
        return {"error": f"API {e.response.status_code}: {e.response.text[:300]}"}
    except Exception as e:
        return {"error": f"Request failed: {e}"}


def _mcp_error(tool_name: str, message: str, response: Any | None = None) -> dict[str, Any]:
    payload: dict[str, Any] = {"error": message, "tool": tool_name}
    if response is not None:
        payload["response"] = response
    return payload



def _mcp_queued(tool_name: str, response: dict[str, Any]) -> dict[str, Any]:
    return {
        "queued": True,
        "tool": tool_name,
        "message": "Write queued by State Hub edge relay; central commit is pending replay.",
        "receipt": response,
    }

def _response_error(
    tool_name: str,
    response: Any,
    required_fields: tuple[str, ...] = (),
) -> dict[str, Any] | None:
    """Return an MCP-visible error payload for failed or malformed API results."""
    if isinstance(response, dict) and response.get("queued") is True:
        return _mcp_queued(tool_name, response)
    if isinstance(response, dict) and isinstance(response.get("error"), str):
        return _mcp_error(tool_name, response["error"], response)
    if not isinstance(response, dict):
        return _mcp_error(tool_name, "API returned a non-object response", response)

    missing = [field for field in required_fields if response.get(field) is None]
    if missing:
        return _mcp_error(
            tool_name,
            f"API response missing required field(s): {', '.join(missing)}",
            response,
        )
    return None


def _normalize_progress_body(body: dict[str, Any]) -> dict[str, Any]:
    normalized = dict(body)
    workplan_id = normalized.get("workplan_id") or normalized.get("workstream_id")
    normalized.pop("workstream_id", None)
    if workplan_id is not None:
        normalized["workplan_id"] = workplan_id
    return normalized


def _emit_progress_event(
    tool_name: str,
    write_result: dict[str, Any],
    body: dict[str, Any],
) -> dict[str, Any] | None:
    progress = _post("/progress", _normalize_progress_body(body))
    error = _response_error(f"{tool_name}.progress_event", progress, ("id",))
    if error:
        return _mcp_error(
            tool_name,
            "Primary write succeeded, but automatic progress_event failed",
            {"write_result": write_result, "progress_error": error},
        )
    return None


def _json_result(result: Any) -> str:
    return json.dumps(result, indent=2)


_LEGACY_MCP_TOOL_REPLACEMENTS: dict[str, str] = {
    "create_workstream": "create_workplan",
    "list_workstreams": "list_workplans",
    "update_workstream": "update_workplan",
    "update_workstream_status": "update_workplan_status",
}

_LEGACY_MCP_RESOURCE_REPLACEMENTS: dict[str, str] = {
    "state://workstreams/{topic_slug}": "state://workplans/{topic_slug}",
}


def _legacy_mcp_deprecation(*, tool: str | None = None, resource: str | None = None) -> dict[str, str]:
    if tool:
        replacement = _LEGACY_MCP_TOOL_REPLACEMENTS[tool]
        return {
            "deprecated": "true",
            "tool": tool,
            "replacement": replacement,
            "message": (
                f"{tool} is a legacy MCP alias; use {replacement} instead. "
                "Retirement tracked by legacy-meter (STATE-WP-0069)."
            ),
        }
    if resource:
        replacement = _LEGACY_MCP_RESOURCE_REPLACEMENTS[resource]
        return {
            "deprecated": "true",
            "resource": resource,
            "replacement": replacement,
            "message": (
                f"{resource} is a legacy MCP resource; use {replacement} instead. "
                "Retirement tracked by legacy-meter (STATE-WP-0069)."
            ),
        }
    raise ValueError("tool or resource required")


def _attach_legacy_deprecation(tool_name: str, result_json: str) -> str:
    if tool_name not in _LEGACY_MCP_TOOL_REPLACEMENTS:
        return result_json
    try:
        payload = json.loads(result_json)
    except json.JSONDecodeError:
        return result_json
    if isinstance(payload, dict) and payload.get("error"):
        return result_json
    if isinstance(payload, dict):
        payload["_deprecation"] = _legacy_mcp_deprecation(tool=tool_name)
        return json.dumps(payload, indent=2)
    if isinstance(payload, list):
        return json.dumps(
            {
                "_deprecation": _legacy_mcp_deprecation(tool=tool_name),
                "workplans": payload,
                "workstreams": payload,
            },
            indent=2,
        )
    return result_json


# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------

@mcp.resource("state://summary")
def resource_summary() -> str:
    """Full StateSummary JSON — primary orientation resource."""
    return json.dumps(_get("/state/summary"), indent=2)


@mcp.resource("state://topics")
def resource_topics() -> str:
    """Active topics list."""
    return json.dumps(_get("/topics", {"status": "active"}), indent=2)


@mcp.resource("state://workplans/{topic_slug}")
def resource_workplans(topic_slug: str) -> str:
    """Workplans for a topic (by slug)."""
    topics = _get("/topics", {"status": "active"})
    match = next((t for t in topics if t["slug"] == topic_slug), None)
    if not match:
        return json.dumps({"error": f"Topic '{topic_slug}' not found"})
    return json.dumps(_get("/workplans", {"topic_id": match["id"]}), indent=2)


@mcp.resource("state://workstreams/{topic_slug}")
def resource_workstreams(topic_slug: str) -> str:
    """Legacy resource alias — prefer state://workplans/{topic_slug}."""
    topics = _get("/topics", {"status": "active"})
    match = next((t for t in topics if t["slug"] == topic_slug), None)
    if not match:
        return json.dumps({"error": f"Topic '{topic_slug}' not found"})
    rows = _get("/workstreams", {"topic_id": match["id"]})
    return json.dumps(
        {
            "_deprecation": _legacy_mcp_deprecation(resource="state://workstreams/{topic_slug}"),
            "workplans": rows,
            "workstreams": rows,
        },
        indent=2,
    )


@mcp.resource("state://decisions/blocking")
def resource_blocking_decisions() -> str:
    """All pending/escalated decisions."""
    return json.dumps(
        _get("/decisions", {"decision_type": "pending", "status": "open"}),
        indent=2,
    )


@mcp.resource("state://tasks/blocked")
def resource_blocked_tasks() -> str:
    """All tasks with status=wait. Legacy resource name kept for compatibility."""
    return json.dumps(_get("/tasks", {"status": "wait"}), indent=2)


# ---------------------------------------------------------------------------
# Query tools
# ---------------------------------------------------------------------------

@mcp.tool()
def get_state_summary() -> str:
    """Primary orientation tool. Call at the start of every session.

    Returns a full snapshot: topic/workplan/task/decision totals, blocking
    decisions, waiting tasks, open workplans, and the 20 most recent events.

    NOTE: This response is large (~10k tokens). When working inside a single
    registered domain repo, use get_domain_summary(domain_slug) instead —
    same actionable data scoped to one domain at ~10% of the token cost.
    """
    return json.dumps(_get("/state/summary"), indent=2)


@mcp.tool()
def get_domain_summary(domain_slug: str) -> str:
    """Lightweight session orientation for a single domain.

    Use this instead of get_state_summary() when working in a registered
    domain repo — returns only what is relevant to the specified domain,
    typically 80-90% fewer tokens than the full summary.

    Args:
        domain_slug: the domain slug, e.g. "railiance", "markitect"

    Returns: topic, active workplans, open blocking decisions for this
    topic, 5 most recent progress events, repo SBOM status, and goal guidance
    (needs_workplan signals + alignment warnings).
    """
    topics = _get("/topics")
    topic = next((t for t in topics if t.get("domain_slug") == domain_slug), None)
    if not topic:
        return json.dumps({"error": f"No topic found for domain '{domain_slug}'"})

    topic_id = topic["id"]

    state_summary = _get("/state/summary")
    open_workplans = state_summary.get("open_workplans") or state_summary.get(
        "open_workstreams", []
    )
    workstreams = [ws for ws in open_workplans if ws.get("topic_id") == topic_id]
    blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
    recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
    repos = _get("/repos", {"domain": domain_slug})

    # ── Goal guidance ──────────────────────────────────────────────────────────
    # Fetch active repo goals per repo, then cross-reference with workstreams.
    repo_by_id = {r["id"]: r for r in repos}
    ws_by_repo_goal: dict[str, list] = {}
    for ws in workstreams:
        if ws.get("repo_goal_id"):
            ws_by_repo_goal.setdefault(ws["repo_goal_id"], []).append(ws)

    # repo_id → list of active workstreams (for alignment check)
    ws_by_repo: dict[str, list] = {}
    for ws in workstreams:
        if ws.get("repo_id"):
            ws_by_repo.setdefault(ws["repo_id"], []).append(ws)

    needs_workplan: list[dict] = []   # active goal with no linked workplan
    alignment_warnings: list[dict] = []  # workplans not linked to active goal

    for repo in repos:
        repo_slug = repo["slug"]
        repo_id   = repo["id"]
        active_goals = _get("/repo-goals", {"repo_slug": repo_slug, "status": "active"})
        if not active_goals:
            continue
        active_goal_ids = {g["id"] for g in active_goals}

        for goal in active_goals:
            linked = ws_by_repo_goal.get(goal["id"], [])
            if not linked:
                needs_workplan.append({
                    "repo_slug": repo_slug,
                    "goal_id": goal["id"],
                    "goal_title": goal["title"],
                    "goal_description": goal["description"],
                    "priority": goal["priority"],
                    "action": (
                        f"No workplan is linked to repo goal '{goal['title']}'. "
                        "Create a workplan file in workplans/ and run fix-consistency "
                        f"with repo_goal_id='{goal['id']}' to start delivering this goal."
                    ),
                })

        # Check if repo has active workplans not tied to any active goal
        repo_ws = ws_by_repo.get(repo_id, [])
        unlinked_ws = [
            ws for ws in repo_ws
            if ws.get("repo_goal_id") not in active_goal_ids
        ]
        if unlinked_ws:
            recent_ws = max(unlinked_ws, key=lambda w: w.get("updated_at", ""))
            alignment_warnings.append({
                "repo_slug": repo_slug,
                "recent_workplan_id": recent_ws["id"],
                "recent_workplan_title": recent_ws["title"],
                "recent_workstream_id": recent_ws["id"],
                "recent_workstream_title": recent_ws["title"],
                "active_goal_titles": [g["title"] for g in active_goals],
                "message": (
                    f"Workplan '{recent_ws['title']}' is not linked to the current "
                    f"repo goal(s) for {repo_slug}. "
                    "Continue this workplan if the work is still relevant, but verify "
                    "alignment with the active goal before committing to new tasks."
                ),
            })

    goal_guidance: dict = {}
    if needs_workplan or alignment_warnings:
        goal_guidance = {
            "needs_workplan": needs_workplan,
            "alignment_warnings": alignment_warnings,
        }

    result: dict = {
        "domain": domain_slug,
        "topic_id": topic_id,
        "topic_title": topic["title"],
        "workplans": workstreams,
        "workstreams": workstreams,
        "blocking_decisions": blocking,
        "recent_progress": recent,
        "repos": [{"slug": r["slug"], "last_sbom_at": r.get("last_sbom_at")} for r in repos],
    }
    if goal_guidance:
        result["goal_guidance"] = goal_guidance

    inbox_hygiene: dict[str, Any] = {}
    try:
        from scripts.consistency_check import collect_inbox_hygiene, STALE_UNREAD_DAYS
    except ImportError:
        collect_inbox_hygiene = None  # type: ignore[assignment]
        STALE_UNREAD_DAYS = 3
    if collect_inbox_hygiene is not None:
        for repo in repos:
            repo_slug = repo["slug"]
            hygiene = collect_inbox_hygiene(API_BASE, repo_slug)
            if (
                hygiene["stale_unread_count"]
                or hygiene["missing_thread"]
                or hygiene["work_requests_unpromoted"]
            ):
                inbox_hygiene[repo_slug] = {
                    "stale_unread_count": hygiene["stale_unread_count"],
                    "stale_unread_days": STALE_UNREAD_DAYS,
                    "stale_unread": hygiene["stale_unread"][:5],
                    "missing_thread_count": len(hygiene["missing_thread"]),
                    "work_requests_unpromoted": hygiene["work_requests_unpromoted"][:3],
                }
    if inbox_hygiene:
        result["inbox_hygiene"] = inbox_hygiene

    # Compact capabilities list (type + title + repo_slug only, capped at 20)
    caps_raw = _get("/capability-catalog/", {"domain": domain_slug, "status": "active"})
    if isinstance(caps_raw, list):
        compact_caps = [
            {"type": c["capability_type"], "title": c["title"], "repo_slug": c.get("repo_slug")}
            for c in caps_raw[:20]
        ]
        result["capabilities"] = compact_caps
        if len(caps_raw) > 20:
            result["capabilities_truncated"] = True

    return json.dumps(result, indent=2)


@mcp.tool()
def get_topic(slug: str) -> str:
    """Return a topic (with workplans) by slug, plus its recent progress events."""
    topics = _get("/topics")
    match = next((t for t in topics if t["slug"] == slug), None)
    if not match:
        return json.dumps({"error": f"Topic '{slug}' not found"})
    topic_detail = _get(f"/topics/{match['id']}")
    recent = _get("/progress", {"topic_id": match["id"], "limit": 10})
    return json.dumps({"topic": topic_detail, "recent_progress": recent}, indent=2)


@mcp.tool()
def create_topic(slug: str, title: str, domain: str, description: str | None = None) -> str:
    """Create a new topic under an existing domain.

    Args:
        slug: URL-safe identifier, e.g. "inter_hub" (must be unique).
        title: Human-readable name, e.g. "Inter-Hub Federation".
        domain: Domain slug the topic belongs to, e.g. "custodian".
        description: Optional one-sentence description.

    Returns the created TopicRead on success, or an error dict if the slug
    already exists or the domain is not found.
    """
    payload: dict = {"slug": slug, "title": title, "domain": domain}
    if description:
        payload["description"] = description
    return json.dumps(_post("/topics", payload), indent=2)


@mcp.tool()
def list_tasks(
    workplan_id: str | None = None,
    workstream_id: str | None = None,
    status: str | None = None,
) -> str:
    """List all tasks in a workplan, optionally filtered by status.

    Args:
        workplan_id: UUID of the workplan (preferred).
        workstream_id: legacy alias for workplan_id.
        status: Optional filter — wait | todo | progress | done | cancel.

    Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
    matching task. Use this to look up task UUIDs before calling update_task_status,
    or to check which tasks from a workplan file are already synced to the DB.
    """
    parent_id = workplan_id or workstream_id
    if not parent_id:
        return _json_result(_mcp_error("list_tasks", "workplan_id is required"))
    return json.dumps(
        _get("/tasks", {"workplan_id": parent_id, "status": status}),
        indent=2,
    )


@mcp.tool()
def list_blocked_tasks(
    workplan_id: str | None = None,
    workstream_id: str | None = None,
) -> str:
    """List all waiting tasks, optionally filtered by workplan.

    Args:
        workplan_id: UUID of the workplan (preferred).
        workstream_id: legacy alias for workplan_id.
    """
    parent_id = workplan_id or workstream_id
    return json.dumps(
        _get("/tasks", {"status": "wait", "workplan_id": parent_id}),
        indent=2,
    )


@mcp.tool()
def list_pending_decisions(topic_id: str | None = None) -> str:
    """List pending decisions sorted by deadline (nulls last).

    Optionally filter by topic_id. Escalated decisions are included and
    highlighted by their escalation_note.
    """
    results = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
    return json.dumps(results, indent=2)


@mcp.tool()
def get_recent_progress(limit: int = 20, since: str | None = None) -> str:
    """Retrieve recent progress events to reconstruct session history.

    Args:
        limit: max events to return (default 20)
        since: ISO datetime string — only events after this timestamp
    """
    return json.dumps(_get("/progress", {"limit": limit, "since": since}), indent=2)


@mcp.tool()
def list_flow_definitions() -> str:
    """List registered declarative flow definitions.

    Returns each entity type, its workstations, and entry/exit assertion counts.
    Use this for orientation before calling get_flow_state or advance_workstation.
    """
    return json.dumps(_get("/flows/definitions"), indent=2)


@mcp.tool()
def get_flow_state(entity_type: str, entity_id: str) -> str:
    """Return the declarative flow state for one entity.

    Args:
        entity_type: workplan | workstream | task | contribution | capability_request
        entity_id: UUID of the entity

    Returns current workstation, exit-blocking assertions, reachable
    workstations, and unreachable workstations with the first blocking
    assertion for each.
    """
    return json.dumps(_get(f"/flows/{entity_type}/{entity_id}"), indent=2)


@mcp.tool()
def advance_workstation(entity_type: str, entity_id: str, target_workstation: str) -> str:
    """Attempt to move an entity to a target workstation.

    Args:
        entity_type: workplan | workstream | task | contribution | capability_request
        entity_id: UUID of the entity
        target_workstation: desired workstation/status name

    Returns the new FlowResult on success. If the target is unreachable, the
    response contains a 409-equivalent error with machine-readable failing
    assertions.
    """
    result = _post(f"/flows/{entity_type}/{entity_id}/advance/{target_workstation}", {})
    if not isinstance(result, dict) or "error" not in result:
        _post("/progress", {
            "event_type": "workstation_advanced",
            "summary": f"{entity_type} {entity_id} advanced to {target_workstation}",
            "author": "custodian",
            "detail": {
                "entity_type": entity_type,
                "entity_id": entity_id,
                "target_workstation": target_workstation,
                "flow_result": result,
            },
        })
    return json.dumps(result, indent=2)


# ---------------------------------------------------------------------------
# Workplan helpers (preferred) + legacy workstream aliases
# ---------------------------------------------------------------------------

def _workplan_id_from_response(payload: dict[str, Any]) -> str | None:
    return payload.get("workplan_id") or payload.get("workstream_id") or payload.get("id")


def _create_workplan_impl(
    *,
    repo_id: str,
    title: str,
    topic_id: str | None = None,
    slug: str | None = None,
    description: str | None = None,
    owner: str | None = None,
    due_date: str | None = None,
    planning_priority: str | None = None,
    planning_order: int | None = None,
    tool_name: str = "create_workplan",
) -> str:
    if not slug:
        slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
    wp = _post("/workplans", {
        "repo_id": repo_id,
        "topic_id": topic_id,
        "title": title,
        "slug": slug,
        "description": description,
        "owner": owner,
        "due_date": due_date,
        "status": "active",
        "planning_priority": planning_priority,
        "planning_order": planning_order,
    })
    if error := _response_error(tool_name, wp, ("id",)):
        return _json_result(error)

    progress_error = _emit_progress_event(tool_name, wp, {
        "topic_id": topic_id,
        "workplan_id": wp["id"],
        "workstream_id": wp["id"],
        "event_type": "workplan_created",
        "summary": f"Workplan created: {title}",
        "author": "custodian",
        "detail": {"owner": owner, "slug": slug, "repo_id": repo_id},
    })
    if progress_error:
        return _json_result(progress_error)
    return _attach_legacy_deprecation(tool_name, _json_result(wp))


def _update_workplan_status_impl(workplan_id: str, status: str, *, tool_name: str) -> str:
    wp = _patch(f"/workplans/{workplan_id}", {"status": status})
    if error := _response_error(tool_name, wp, ("id", "title")):
        return _json_result(error)

    progress_error = _emit_progress_event(tool_name, wp, {
        "workplan_id": workplan_id,
        "workstream_id": workplan_id,
        "topic_id": wp.get("topic_id"),
        "event_type": "workplan_status_changed",
        "summary": f"Workplan status → {status}: {wp['title']}",
        "author": "custodian",
    })
    if progress_error:
        return _json_result(progress_error)
    return _attach_legacy_deprecation(tool_name, _json_result(wp))


def _update_workplan_impl(
    workplan_id: str,
    *,
    title: str | None = None,
    description: str | None = None,
    owner: str | None = None,
    due_date: str | None = None,
    repo_goal_id: str | None = None,
    status: str | None = None,
) -> str:
    payload: dict[str, Any] = {}
    if title is not None:
        payload["title"] = title
    if description is not None:
        payload["description"] = description
    if owner is not None:
        payload["owner"] = owner
    if due_date is not None:
        payload["due_date"] = due_date
    if status is not None:
        payload["status"] = status
    if repo_goal_id is not None:
        payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
    return _json_result(_patch(f"/workplans/{workplan_id}", payload))


def _update_workplan_legacy_impl(
    workplan_id: str,
    *,
    tool_name: str,
    title: str | None = None,
    description: str | None = None,
    owner: str | None = None,
    due_date: str | None = None,
    repo_goal_id: str | None = None,
    status: str | None = None,
) -> str:
    return _attach_legacy_deprecation(
        tool_name,
        _update_workplan_impl(
            workplan_id,
            title=title,
            description=description,
            owner=owner,
            due_date=due_date,
            repo_goal_id=repo_goal_id,
            status=status,
        ),
    )


# ---------------------------------------------------------------------------
# Mutate tools
# ---------------------------------------------------------------------------

@mcp.tool()
def create_workplan(
    repo_id: str,
    title: str,
    topic_id: str | None = None,
    slug: str | None = None,
    description: str | None = None,
    owner: str | None = None,
    due_date: str | None = None,
    planning_priority: str | None = None,
    planning_order: int | None = None,
) -> str:
    """Create a new repo-anchored workplan and emit a progress_event.

    Args:
        repo_id: UUID of the owning repository (required)
        title: workplan title
        topic_id: optional topic UUID for cross-repo tagging
        slug: URL-friendly identifier (auto-generated from title if omitted)
        description: optional longer description
        owner: optional owner name
        due_date: optional ISO date string (YYYY-MM-DD)
        planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
        planning_order: optional numeric ordering hint inside a repo
    """
    return _create_workplan_impl(
        repo_id=repo_id,
        title=title,
        topic_id=topic_id,
        slug=slug,
        description=description,
        owner=owner,
        due_date=due_date,
        planning_priority=planning_priority,
        planning_order=planning_order,
        tool_name="create_workplan",
    )


@mcp.tool()
def create_workstream(
    title: str,
    repo_id: str | None = None,
    topic_id: str | None = None,
    slug: str | None = None,
    description: str | None = None,
    owner: str | None = None,
    due_date: str | None = None,
    planning_priority: str | None = None,
    planning_order: int | None = None,
) -> str:
    """DEPRECATED legacy alias — use create_workplan(repo_id=...) instead."""
    if not repo_id:
        return _json_result(_mcp_error("create_workstream", "repo_id is required"))
    return _create_workplan_impl(
        repo_id=repo_id,
        title=title,
        topic_id=topic_id,
        slug=slug,
        description=description,
        owner=owner,
        due_date=due_date,
        planning_priority=planning_priority,
        planning_order=planning_order,
        tool_name="create_workstream",
    )


@mcp.tool()
def create_task(
    workplan_id: str | None = None,
    workstream_id: str | None = None,
    title: str = "",
    priority: str = "medium",
    description: str | None = None,
    assignee: str | None = None,
    due_date: str | None = None,
) -> str:
    """Create a new task and emit a progress_event.

    Args:
        workplan_id: UUID of the parent workplan (preferred)
        workstream_id: legacy alias for workplan_id
        title: task title
        priority: low | medium | high | critical
        description: optional longer description
        assignee: optional assignee name
        due_date: optional ISO date string (YYYY-MM-DD)
    """
    parent_id = workplan_id or workstream_id
    if not parent_id:
        return _json_result(_mcp_error("create_task", "workplan_id is required"))
    task = _post("/tasks", {
        "workplan_id": parent_id,
        "title": title,
        "priority": priority,
        "description": description,
        "assignee": assignee,
        "due_date": due_date,
    })
    if error := _response_error("create_task", task, ("id",)):
        return _json_result(error)

    progress_error = _emit_progress_event("create_task", task, {
        "workplan_id": parent_id,
        "workstream_id": parent_id,
        "task_id": task["id"],
        "event_type": "task_created",
        "summary": f"Task created: {title}",
        "author": "custodian",
        "detail": {"priority": priority, "assignee": assignee},
    })
    if progress_error:
        return _json_result(progress_error)
    return _json_result(task)


@mcp.tool()
def update_task_status(
    task_id: str,
    status: str,
    blocking_reason: Optional[str] = None,
    tokens_in: Optional[int] = None,
    tokens_out: Optional[int] = None,
    workplan_tokens_in: Optional[int] = None,
    workplan_tokens_out: Optional[int] = None,
    note: Optional[str] = None,
    model: Optional[str] = None,
    agent: Optional[str] = None,
    session_id: Optional[str] = None,
) -> str:
    """Update a task's status. Canonical status values are wait/todo/progress/done/cancel.

    When status='done', always records a token event using the best available data:
      Tier 1 (best):   pass tokens_in + tokens_out — exact counts from the session
                       note defaults to "measured"; pass note="userbased" if the
                       numbers were provided by a human rather than read from the bar
      Tier 2:          pass workplan_tokens_in + workplan_tokens_out — total workplan
                       effort prorated across task count (note="workplan")
      Tier 3 (fallback): no token args — heuristic 1000 in / 500 out (note="heuristic")

    Best practice: read tokens from the Claude Code status bar and pass exact counts.

    Args:
        task_id: UUID of the task
        status: wait | todo | progress | done | cancel
        blocking_reason: optional wait-condition detail
        tokens_in: exact input token count for this task (Tier 1)
        tokens_out: exact output token count for this task (Tier 1)
        workplan_tokens_in: total input tokens for the whole workplan (Tier 2)
        workplan_tokens_out: total output tokens for the whole workplan (Tier 2)
        note: override the auto note — use "userbased" when counts came from a human;
              omit to get the default ("measured" for Tier 1, "workplan"/"heuristic" otherwise)
        model: model identifier, e.g. 'claude-sonnet-4-6'
        agent: agent name, e.g. 'custodian', 'ralph'
        session_id: agent session identifier
    """
    body: dict[str, Any] = {
        "status": status,
        "model": model,
        "agent": agent,
        "session_id": session_id,
    }
    if blocking_reason:
        body["blocking_reason"] = blocking_reason
    if tokens_in is not None:
        body["tokens_in"] = tokens_in
    if tokens_out is not None:
        body["tokens_out"] = tokens_out
    if workplan_tokens_in is not None:
        body["workplan_tokens_in"] = workplan_tokens_in
    if workplan_tokens_out is not None:
        body["workplan_tokens_out"] = workplan_tokens_out
    if note is not None:
        body["token_note"] = note

    task = _patch(f"/tasks/{task_id}", body)
    if error := _response_error("update_task_status", task, ("id", "title")):
        return _json_result(error)

    progress_error = _emit_progress_event("update_task_status", task, {
        "task_id": task_id,
        "workplan_id": task.get("workplan_id") or task.get("workstream_id"),
        "event_type": "task_status_changed",
        "summary": f"Task status → {status}: {task['title']}",
        "author": "custodian",
        "detail": {"blocking_reason": blocking_reason},
    })
    if progress_error:
        return _json_result(progress_error)

    return _json_result(task)


@mcp.tool()
def bulk_update_task_statuses(
    updates: list[dict[str, Any]],
    author: str | None = "custodian",
    session_id: str | None = None,
) -> str:
    """Update many task statuses in one call and emit one progress_event per task.

    Args:
        updates: list of {task_id, status, blocking_reason?}; status values are
            wait | todo | progress | done | cancel
        author: optional progress event author (defaults to custodian)
        session_id: optional agent session identifier for progress events
    """
    result = _post("/tasks/bulk-status-sync", {
        "updates": updates,
        "author": author,
        "session_id": session_id,
    })
    if error := _response_error(
        "bulk_update_task_statuses",
        result,
        ("updated", "progress_event_ids"),
    ):
        return _json_result(error)
    return _json_result(result)


@mcp.tool()
def flag_for_human(task_id: str, note: str) -> str:
    """Flag a task as requiring human intervention.

    Sets needs_human=True and records the required action as intervention_note.
    Emits a progress event so the flag is visible in session history.

    Args:
        task_id: UUID of the task to flag
        note: description of the action required from the human (required)
    """
    task = _patch(f"/tasks/{task_id}", {
        "needs_human": True,
        "intervention_note": note,
    })
    if error := _response_error("flag_for_human", task, ("id", "title")):
        return _json_result(error)

    progress_error = _emit_progress_event("flag_for_human", task, {
        "task_id": task_id,
        "workplan_id": task.get("workplan_id") or task.get("workstream_id"),
        "event_type": "task_flagged_human",
        "summary": f"Task flagged for human intervention: {task['title']}",
        "author": "custodian",
        "detail": {"intervention_note": note},
    })
    if progress_error:
        return _json_result(progress_error)
    return _json_result(task)


@mcp.tool()
def clear_human_flag(task_id: str) -> str:
    """Clear the human-intervention flag from a task.

    Sets needs_human=False. The intervention_note is preserved as a
    historical record. Call this after the human has completed the action.

    Args:
        task_id: UUID of the task to clear
    """
    task = _patch(f"/tasks/{task_id}", {
        "needs_human": False,
    })
    if error := _response_error("clear_human_flag", task, ("id", "title")):
        return _json_result(error)

    progress_error = _emit_progress_event("clear_human_flag", task, {
        "task_id": task_id,
        "workplan_id": task.get("workplan_id") or task.get("workstream_id"),
        "event_type": "task_flag_cleared",
        "summary": f"Human-intervention flag cleared: {task['title']}",
        "author": "custodian",
    })
    if progress_error:
        return _json_result(progress_error)
    return _json_result(task)


@mcp.tool()
def list_human_interventions(
    workplan_id: str | None = None,
    workstream_id: str | None = None,
) -> str:
    """List all tasks flagged for human intervention.

    Returns tasks where needs_human=True, optionally filtered to one workplan.
    Use this at session start to surface Bernd's action items.

    Args:
        workplan_id: optional UUID to scope results to one workplan (preferred).
        workstream_id: legacy alias for workplan_id.
    """
    parent_id = workplan_id or workstream_id
    return json.dumps(
        _get(
            "/tasks",
            {"needs_human": "true", "workplan_id": parent_id},
        ),
        indent=2,
    )


@mcp.tool()
def record_decision(
    title: str,
    decision_type: str = "pending",
    topic_id: str | None = None,
    workplan_id: str | None = None,
    workstream_id: str | None = None,
    description: str | None = None,
    rationale: str | None = None,
    decided_by: str | None = None,
    deadline: str | None = None,
) -> str:
    """Record a decision (made or pending).

    Pending decisions touching financial/legal topics are auto-escalated per
    constitution §4.

    Args:
        title: decision title
        decision_type: made | pending
        topic_id: optional topic UUID
        workplan_id: optional workplan UUID (preferred; at least one required)
        workstream_id: legacy alias for workplan_id
        description: optional context
        rationale: reasoning behind the decision
        decided_by: person/agent who decided
        deadline: ISO datetime string for when decision is needed
    """
    parent_id = workplan_id or workstream_id
    decision = _post("/decisions", {
        "title": title,
        "decision_type": decision_type,
        "topic_id": topic_id,
        "workplan_id": parent_id,
        "description": description,
        "rationale": rationale,
        "decided_by": decided_by,
        "deadline": deadline,
    })
    if error := _response_error("record_decision", decision, ("id",)):
        return _json_result(error)

    progress_error = _emit_progress_event("record_decision", decision, {
        "topic_id": topic_id,
        "workplan_id": parent_id,
        "workstream_id": parent_id,
        "decision_id": decision["id"],
        "event_type": "decision_recorded",
        "summary": f"Decision recorded ({decision_type}): {title}",
        "author": "custodian",
        "detail": {"status": decision.get("status"), "escalation_note": decision.get("escalation_note")},
    })
    if progress_error:
        return _json_result(progress_error)
    return _json_result(decision)


@mcp.tool()
def resolve_decision(
    decision_id: str,
    rationale: str,
    decided_by: str,
) -> str:
    """Mark a decision as resolved.

    Args:
        decision_id: UUID of the decision
        rationale: final reasoning/outcome
        decided_by: who resolved it
    """
    decision = _patch(f"/decisions/{decision_id}", {
        "status": "resolved",
        "decision_type": "made",
        "rationale": rationale,
        "decided_by": decided_by,
        "decided_at": datetime.now(tz=timezone.utc).isoformat(),
    })
    if error := _response_error("resolve_decision", decision, ("id", "title")):
        return _json_result(error)

    progress_error = _emit_progress_event("resolve_decision", decision, {
        "topic_id": decision.get("topic_id"),
        "workplan_id": decision.get("workplan_id") or decision.get("workstream_id"),
        "decision_id": decision_id,
        "event_type": "decision_resolved",
        "summary": f"Decision resolved by {decided_by}: {decision['title']}",
        "author": "custodian",
        "detail": {"rationale": rationale},
    })
    if progress_error:
        return _json_result(progress_error)
    return _json_result(decision)


@mcp.tool()
def add_progress_event(
    summary: str,
    event_type: str = "note",
    topic_id: str | None = None,
    workplan_id: str | None = None,
    workstream_id: str | None = None,
    task_id: str | None = None,
    detail: dict | str | None = None,
) -> str:
    """Append a progress event to the log.

    Args:
        summary: human-readable summary of what happened
        event_type: free-form label, e.g. note | milestone | blocker | insight
        topic_id: optional topic UUID
        workplan_id: optional workplan UUID (preferred)
        workstream_id: legacy alias for workplan_id
        task_id: optional task UUID
        detail: optional structured data (JSONB); accepts a dict or a JSON string
    """
    if isinstance(detail, str):
        try:
            detail = json.loads(detail)
        except (json.JSONDecodeError, ValueError):
            detail = {"raw": detail}
    event = _post("/progress", {
        "topic_id": topic_id,
        "workplan_id": workplan_id or workstream_id,
        "task_id": task_id,
        "event_type": event_type,
        "summary": summary,
        "author": "custodian",
        "detail": detail,
    })
    if error := _response_error("add_progress_event", event, ("id",)):
        return _json_result(error)
    return _json_result(event)


@mcp.tool()
def list_workplans(
    repo_id: str | None = None,
    topic_id: str | None = None,
    status: str | None = None,
    owner: str | None = None,
    slug: str | None = None,
) -> str:
    """List workplans with optional filters."""
    return json.dumps(
        _get("/workplans", {
            "repo_id": repo_id,
            "topic_id": topic_id,
            "status": status,
            "owner": owner,
            "slug": slug,
        }),
        indent=2,
    )


@mcp.tool()
def list_workstreams(
    topic_id: str | None = None,
    repo_id: str | None = None,
    status: str | None = None,
    owner: str | None = None,
    slug: str | None = None,
) -> str:
    """DEPRECATED legacy alias — use list_workplans instead."""
    return _attach_legacy_deprecation(
        "list_workstreams",
        list_workplans(
            repo_id=repo_id,
            topic_id=topic_id,
            status=status,
            owner=owner,
            slug=slug,
        ),
    )


@mcp.tool()
def update_workplan_status(workplan_id: str, status: str) -> str:
    """Update a workplan's status.

    Args:
        workplan_id: UUID of the workplan
        status: proposed | ready | active | blocked | backlog | finished | archived
    """
    return _update_workplan_status_impl(workplan_id, status, tool_name="update_workplan_status")


@mcp.tool()
def update_workstream_status(
    status: str,
    workplan_id: str | None = None,
    workstream_id: str | None = None,
) -> str:
    """DEPRECATED legacy alias — use update_workplan_status instead."""
    parent_id = workplan_id or workstream_id
    if not parent_id:
        return _json_result(_mcp_error("update_workstream_status", "workplan_id is required"))
    return _update_workplan_status_impl(parent_id, status, tool_name="update_workstream_status")


@mcp.tool()
def update_workplan(
    workplan_id: str,
    title: str | None = None,
    description: str | None = None,
    owner: str | None = None,
    due_date: str | None = None,
    repo_goal_id: str | None = None,
    status: str | None = None,
) -> str:
    """Update fields on an existing workplan."""
    return _update_workplan_impl(
        workplan_id,
        title=title,
        description=description,
        owner=owner,
        due_date=due_date,
        repo_goal_id=repo_goal_id,
        status=status,
    )


@mcp.tool()
def update_workstream(
    workplan_id: str | None = None,
    workstream_id: str | None = None,
    title: str | None = None,
    description: str | None = None,
    owner: str | None = None,
    due_date: str | None = None,
    repo_goal_id: str | None = None,
    status: str | None = None,
) -> str:
    """DEPRECATED legacy alias — use update_workplan instead."""
    parent_id = workplan_id or workstream_id
    if not parent_id:
        return _json_result(_mcp_error("update_workstream", "workplan_id is required"))
    return _update_workplan_legacy_impl(
        parent_id,
        tool_name="update_workstream",
        title=title,
        description=description,
        owner=owner,
        due_date=due_date,
        repo_goal_id=repo_goal_id,
        status=status,
    )


# ---------------------------------------------------------------------------
# Next-steps suggestion tool (S2.3) — sanctioned write use case #2
# ---------------------------------------------------------------------------

@mcp.tool()
def get_next_steps() -> str:
    """Surface contextual next-action suggestions derived from hub state.

    Returns suggestions based on:
    - Recently resolved decisions → first open task in the same workplan
    - Workplans whose every dependency is now finished -> first todo task

    Each suggestion includes domain, workplan, task, and a plain-language
    message. The hub surfaces *what* and *where* — the domain owns *how*.

    Derived next steps may include open demand-weighted suggestions from the
    persisted suggestion backlog (STATE-WP-0061).
    """
    return json.dumps(_get("/state/next_steps"), indent=2)


# ---------------------------------------------------------------------------
# Demand-weighted suggestion backlog (STATE-WP-0061)
# ---------------------------------------------------------------------------

@mcp.tool()
def list_suggestions(
    domain: str | None = None,
    stage: str | None = None,
    rank: str | None = None,
    limit: int = 50,
) -> str:
    """List persisted suggestions, optionally ranked by WSJF."""
    return json.dumps(
        _get("/suggestions", {
            "domain": domain,
            "stage": stage,
            "rank": rank,
            "limit": limit,
        }),
        indent=2,
    )


@mcp.tool()
def create_suggestion(
    domain: str,
    title: str,
    description: str | None = None,
    origin_ref: str | None = None,
    workplan_id: str | None = None,
    base_value: float = 3.0,
    job_size: float = 3.0,
) -> str:
    """Record a gated need as a relevance-accruing suggestion."""
    return json.dumps(_post("/suggestions", {
        "domain": domain,
        "title": title,
        "description": description,
        "origin_ref": origin_ref,
        "workplan_id": workplan_id,
        "base_value": base_value,
        "job_size": job_size,
    }), indent=2)


@mcp.tool()
def vet_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str:
    """Promote a suggestion to a vetted requirement with an append-only note."""
    return json.dumps(_post(f"/suggestions/{suggestion_id}/vet", {
        "note": note,
        "author": author,
    }), indent=2)


@mcp.tool()
def decline_suggestion(suggestion_id: str, note: str, author: str | None = None) -> str:
    """Decline a suggestion or requirement."""
    return json.dumps(_post(f"/suggestions/{suggestion_id}/decline", {
        "note": note,
        "author": author,
    }), indent=2)


@mcp.tool()
def promote_suggestion_to_task(
    suggestion_id: str,
    note: str | None = None,
    task_title: str | None = None,
    author: str | None = None,
) -> str:
    """Promote a vetted requirement into a real Task."""
    return json.dumps(_post(f"/suggestions/{suggestion_id}/promote", {
        "note": note,
        "task_title": task_title,
        "author": author,
    }), indent=2)


@mcp.tool()
def bump_suggestion_relevance(
    suggestion_id: str,
    reason: str | None = None,
    author: str | None = None,
) -> str:
    """Explicitly bump demand relevance when an agent hits an unmet gated need."""
    return json.dumps(_post(f"/suggestions/{suggestion_id}/bump-relevance", {
        "reason": reason,
        "author": author,
    }), indent=2)


# ---------------------------------------------------------------------------
# Dependency graph tools (S1.4)
# ---------------------------------------------------------------------------

def _create_dependency_impl(
    *,
    from_workplan_id: str,
    to_workplan_id: str | None = None,
    to_task_id: str | None = None,
    relationship_type: str = "blocks",
    description: str | None = None,
) -> str:
    dep = _post(f"/workplans/{from_workplan_id}/dependencies", {
        "to_workplan_id": to_workplan_id,
        "to_task_id": to_task_id,
        "relationship_type": relationship_type,
        "description": description,
    })
    return json.dumps(dep, indent=2)


@mcp.tool()
def create_workplan_dependency(
    from_workplan_id: str,
    to_workplan_id: str | None = None,
    to_task_id: str | None = None,
    relationship_type: str = "blocks",
    description: str | None = None,
) -> str:
    """Record that one workplan depends on another workplan or task."""
    return _create_dependency_impl(
        from_workplan_id=from_workplan_id,
        to_workplan_id=to_workplan_id,
        to_task_id=to_task_id,
        relationship_type=relationship_type,
        description=description,
    )


@mcp.tool()
def create_dependency(
    from_workplan_id: str | None = None,
    from_workstream_id: str | None = None,
    to_workplan_id: str | None = None,
    to_workstream_id: str | None = None,
    to_task_id: str | None = None,
    relationship_type: str = "blocks",
    description: str | None = None,
) -> str:
    """Legacy alias for create_workplan_dependency."""
    source_id = from_workplan_id or from_workstream_id
    target_id = to_workplan_id or to_workstream_id
    if not source_id:
        return _json_result(_mcp_error("create_dependency", "from_workplan_id is required"))
    return _create_dependency_impl(
        from_workplan_id=source_id,
        to_workplan_id=target_id,
        to_task_id=to_task_id,
        relationship_type=relationship_type,
        description=description,
    )


@mcp.tool()
def list_dependencies(
    workplan_id: str | None = None,
    workstream_id: str | None = None,
) -> str:
    """Return all dependency edges touching a workplan (both directions).

    The response distinguishes edges where this workplan is the dependent
    (depends_on) from edges where it is the blocker (blocks).

    Args:
        workplan_id: UUID of the workplan to inspect (preferred).
        workstream_id: legacy alias for workplan_id.
    """
    parent_id = workplan_id or workstream_id
    if not parent_id:
        return _json_result(_mcp_error("list_dependencies", "workplan_id is required"))
    edges = _get(f"/workplans/{parent_id}/dependencies")
    depends_on = [
        e for e in edges
        if e.get("from_workplan_id", e.get("from_workstream_id")) == parent_id
    ]
    blocks = [
        e for e in edges
        if e.get("to_workplan_id", e.get("to_workstream_id")) == parent_id
    ]
    return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)


# ---------------------------------------------------------------------------
# Extension points & technical debt
# ---------------------------------------------------------------------------

@mcp.tool()
def register_extension_point(
    domain: str,
    title: str,
    ep_type: str,
    description: str | None = None,
    location: str | None = None,
    priority: str = "medium",
    ep_id: str | None = None,
    topic_id: str | None = None,
    workplan_id: str | None = None,
    workstream_id: str | None = None,
) -> str:
    """Register a discovered extension point — optional future functionality not yet committed.

    Extension points capture design forks: things the system *could* do that
    have been noticed and parked for deliberate later consideration.

    Args:
        domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities
        title: short description of the extension
        ep_type: api | schema | mcp | dashboard | architecture | integration | other
        description: longer explanation of what the extension would add
        location: file:line or module where the extension point was noticed
        priority: low | medium | high | critical
        ep_id: optional human-readable ID, e.g. EP-CUST-001 (auto-assigned if omitted)
        topic_id: UUID of related topic
        workplan_id: UUID of related workplan (preferred)
        workstream_id: legacy alias for workplan_id
    """
    parent_id = workplan_id or workstream_id
    ep = _post("/extension-points", {
        "domain": domain, "title": title, "ep_type": ep_type,
        "description": description, "location": location,
        "priority": priority, "ep_id": ep_id,
        "topic_id": topic_id, "workplan_id": parent_id,
    })
    _post("/progress", {
        "summary": f"Extension point registered: [{ep.get('ep_id') or ep['id'][:8]}] {title} ({ep_type}, {domain})",
        "event_type": "extension_point",
        "detail": {"id": ep["id"], "ep_id": ep.get("ep_id"), "ep_type": ep_type, "domain": domain},
    })
    return json.dumps(ep, indent=2)


@mcp.tool()
def list_extension_points(
    domain: str | None = None,
    status: str | None = None,
    ep_type: str | None = None,
) -> str:
    """List extension points, optionally filtered.

    Args:
        domain: filter by domain
        status: open | in_progress | addressed | deferred | wont_fix
        ep_type: api | schema | mcp | dashboard | architecture | integration | other
    """
    return json.dumps(_get("/extension-points", {
        "domain": domain, "status": status, "ep_type": ep_type,
    }), indent=2)


@mcp.tool()
def update_ep_status(ep_uuid: str, status: str) -> str:
    """Update the status of an extension point.

    Args:
        ep_uuid: UUID of the extension point
        status: open | in_progress | addressed | deferred | wont_fix
    """
    ep = _patch(f"/extension-points/{ep_uuid}", {"status": status})
    _post("/progress", {
        "summary": f"Extension point status → {status}: {ep['title']}",
        "event_type": "extension_point",
        "detail": {"id": ep_uuid, "status": status},
    })
    return json.dumps(ep, indent=2)


@mcp.tool()
def register_technical_debt(
    domain: str,
    title: str,
    debt_type: str,
    description: str | None = None,
    location: str | None = None,
    severity: str = "medium",
    td_id: str | None = None,
    topic_id: str | None = None,
    workplan_id: str | None = None,
    workstream_id: str | None = None,
) -> str:
    """Register a technical debt item — a known quality compromise to address later.

    Technical debt captures intentional or discovered shortcuts, design
    weaknesses, missing tests, and similar issues that reduce codebase health.

    Args:
        domain: one of custodian | railiance | markitect | coulomb_social | personhood | foerster_capabilities
        title: short description of the debt
        debt_type: design | implementation | test | docs | dependencies | performance | security | other
        description: what the issue is and what the correct fix would be
        location: file:line or module where the debt lives
        severity: low | medium | high | critical
        td_id: optional human-readable ID, e.g. TD-CUST-001
        topic_id: UUID of related topic
        workplan_id: UUID of related workplan (preferred)
        workstream_id: legacy alias for workplan_id
    """
    parent_id = workplan_id or workstream_id
    td = _post("/technical-debt", {
        "domain": domain, "title": title, "debt_type": debt_type,
        "description": description, "location": location,
        "severity": severity, "td_id": td_id,
        "topic_id": topic_id, "workplan_id": parent_id,
    })
    _post("/progress", {
        "summary": f"Technical debt registered: [{td.get('td_id') or td['id'][:8]}] {title} ({debt_type}, {severity}, {domain})",
        "event_type": "technical_debt",
        "detail": {"id": td["id"], "td_id": td.get("td_id"), "debt_type": debt_type, "severity": severity, "domain": domain},
    })
    return json.dumps(td, indent=2)


@mcp.tool()
def list_technical_debt(
    domain: str | None = None,
    status: str | None = None,
    debt_type: str | None = None,
    severity: str | None = None,
) -> str:
    """List technical debt items, optionally filtered.

    Args:
        domain: filter by domain
        status: open | in_progress | resolved | deferred | wont_fix
        debt_type: design | implementation | test | docs | dependencies | performance | security | other
        severity: low | medium | high | critical
    """
    return json.dumps(_get("/technical-debt", {
        "domain": domain, "status": status,
        "debt_type": debt_type, "severity": severity,
    }), indent=2)


@mcp.tool()
def update_td_status(td_uuid: str, status: str) -> str:
    """Update the status of a technical debt item.

    Args:
        td_uuid: UUID of the technical debt item
        status: open | in_progress | resolved | deferred | wont_fix
    """
    td = _patch(f"/technical-debt/{td_uuid}", {"status": status})
    _post("/progress", {
        "summary": f"Technical debt status → {status}: {td['title']}",
        "event_type": "technical_debt",
        "detail": {"id": td_uuid, "status": status},
    })
    return json.dumps(td, indent=2)


# ---------------------------------------------------------------------------
# Domain lifecycle + repo registration tools (v0.5)
# ---------------------------------------------------------------------------

@mcp.tool()
def list_domains(status: str = "active") -> str:
    """List all registered domains.

    Args:
        status: active | archived | all (default: active)
    """
    return json.dumps(_get("/domains", {"status": status}), indent=2)


@mcp.tool()
def create_domain(slug: str, name: str, description: str | None = None) -> str:
    """Create a new domain.

    Args:
        slug: URL-friendly identifier (lowercase, underscored), e.g. 'my_project'
        name: Human-readable display name
        description: optional longer description
    """
    domain = _post("/domains", {"slug": slug, "name": name, "description": description})
    _post("/progress", {
        "event_type": "milestone",
        "summary": f"Domain created: {slug} ({name})",
        "author": "custodian",
        "detail": {"slug": slug, "name": name},
    })
    return json.dumps(domain, indent=2)


@mcp.tool()
def rename_domain(slug: str, new_slug: str, new_name: str) -> str:
    """Rename a domain — cascades to EP/TD string columns.

    Args:
        slug: Current domain slug
        new_slug: New URL-friendly identifier
        new_name: New human-readable display name
    """
    domain = _patch(f"/domains/{slug}/rename", {"new_slug": new_slug, "new_name": new_name})
    _post("/progress", {
        "event_type": "milestone",
        "summary": f"Domain renamed: {slug} → {new_slug} ({new_name})",
        "author": "custodian",
        "detail": {"old_slug": slug, "new_slug": new_slug, "new_name": new_name},
    })
    return json.dumps(domain, indent=2)


@mcp.tool()
def archive_domain(slug: str) -> str:
    """Archive a domain (soft-delete). Fails if active topics exist.

    Args:
        slug: Domain slug to archive
    """
    domain = _patch(f"/domains/{slug}/archive", {})
    _post("/progress", {
        "event_type": "note",
        "summary": f"Domain archived: {slug}",
        "author": "custodian",
        "detail": {"slug": slug},
    })
    return json.dumps(domain, indent=2)


@mcp.tool()
def list_domain_repos(
    domain_slug: str,
    category: str | None = None,
    capability_tag: str | None = None,
    business_stake: str | None = None,
) -> str:
    """List repositories registered under a domain, with optional classification filters.

    Args:
        domain_slug: Domain slug to filter by
        category: optional repo classification category
        capability_tag: optional capability tag filter
        business_stake: optional business stake filter
    """
    return json.dumps(
        _get("/repos", {
            "domain": domain_slug,
            "category": category,
            "capability_tag": capability_tag,
            "business_stake": business_stake,
        }),
        indent=2,
    )


@mcp.tool()
def list_repos_by_classification(
    category: str | None = None,
    domain: str | None = None,
    capability_tag: str | None = None,
    business_stake: str | None = None,
) -> str:
    """List repos filtered by classification spine fields."""
    return json.dumps(
        _get("/repos", {
            "domain": domain,
            "category": category,
            "capability_tag": capability_tag,
            "business_stake": business_stake,
        }),
        indent=2,
    )


@mcp.tool()
def register_repo(
    domain_slug: str,
    name: str,
    slug: str | None = None,
    local_path: str | None = None,
    remote_url: str | None = None,
    description: str | None = None,
) -> str:
    """Register a git repository under a domain.

    Args:
        domain_slug: Domain slug (must already exist)
        name: Human-readable repository name
        slug: URL-friendly identifier (auto-generated from name if omitted)
        local_path: Absolute local filesystem path to the repo
        remote_url: Remote git URL (Gitea, GitHub, etc.)
        description: optional description
    """
    import re as _re
    if not slug:
        slug = _re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
    repo = _post("/repos", {
        "domain_slug": domain_slug,
        "slug": slug,
        "name": name,
        "local_path": local_path,
        "remote_url": remote_url,
        "description": description,
    })
    _post("/progress", {
        "event_type": "milestone",
        "summary": f"Repo registered: {name} under domain '{domain_slug}'",
        "author": "custodian",
        "detail": {"slug": slug, "domain_slug": domain_slug, "local_path": local_path, "remote_url": remote_url},
    })
    return json.dumps(repo, indent=2)


@mcp.tool()
def register_repo_from_classification(
    repo_slug: str,
    dry_run: bool = False,
) -> str:
    """Register or update a repo from its committed ``.repo-classification.yaml``.

    Reads the classification file from the repo's local checkout (this host's
    registered path), validates against the canon allowed-values, and upserts the
    ``managed_repo`` row including market-domain assignment.

    Args:
        repo_slug: Registered repo slug (e.g. 'state-hub', 'the-custodian').
        dry_run: If True, report what would change without writing.
    """
    import subprocess

    script = Path(__file__).parent.parent / "scripts" / "register_from_classification.py"
    cmd = [
        sys.executable,
        str(script),
        "--slug",
        repo_slug,
        "--json",
    ]
    if dry_run:
        cmd.append("--dry-run")

    result = subprocess.run(cmd, capture_output=True, text=True)
    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError:
        return (
            f"register-from-classification failed (exit {result.returncode}):\n"
            f"{result.stderr or result.stdout or '(no output)'}"
        )

    summary = data.get("summary", {})
    lines = [
        f"register-from-classification: {repo_slug}",
        (
            f"registered={summary.get('registered', 0)} "
            f"updated={summary.get('updated', 0)} "
            f"skipped={summary.get('skipped', 0)} "
            f"invalid={summary.get('invalid', 0)}"
        ),
    ]
    for row in data.get("results", []):
        lines.append(f"  [{row.get('outcome')}] {row.get('detail', '')}")
    if result.returncode != 0:
        lines.append("(completed with invalid rows)")
    return "\n".join(lines)


@mcp.tool()
def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str:
    """Register or update the local filesystem path for a repo on a specific host.

    Use this when a repo lives at a different absolute path on different machines
    (e.g. /home/worsch/marki-docx on the workstation vs /home/tegwick/marki-docx
    on custodiancore). The consistency checker will prefer the host-specific path
    over the legacy local_path field.

    Args:
        repo_slug: Managed-repo slug (e.g. 'marki-docx')
        path: Absolute local path on the target machine (e.g. '/home/tegwick/marki-docx')
        host: Hostname to register the path for. Defaults to the current machine's hostname.
    """
    import socket as _socket
    if not host:
        host = _socket.gethostname()
    repo = _post(f"/repos/{repo_slug}/paths", {"host": host, "path": path})
    return json.dumps(repo, indent=2)


# ---------------------------------------------------------------------------
# Shared path resolution helper
# ---------------------------------------------------------------------------

def _resolve_repo_path(repo: dict) -> str:
    """Return the best local filesystem path for *repo* on this host.

    Resolution order — each candidate is expanded (supports ``~``) and
    verified to exist before being accepted:

    1. ``host_paths[hostname]``   — host-specific override
    2. ``local_path``             — default fallback

    Returns the resolved path string, or ``""`` if no valid path is found.
    """
    import socket as _socket
    hostname = _socket.gethostname()
    host_paths = repo.get("host_paths") or {}

    candidates = []
    if host_paths.get(hostname):
        candidates.append(host_paths[hostname])
    if repo.get("local_path"):
        candidates.append(repo["local_path"])

    for raw in candidates:
        resolved = str(Path(raw).expanduser())
        if Path(resolved).is_dir():
            return resolved

    return ""


# ---------------------------------------------------------------------------
# Kaizen Agents
# ---------------------------------------------------------------------------

def _kaizen_agents_dir() -> Path:
    """Resolve the kaizen-agentic agents/ directory."""
    repo = _get("/repos/kaizen-agentic")
    base = _resolve_repo_path(repo)
    if not base:
        import socket as _socket
        hostname = _socket.gethostname()
        raise FileNotFoundError(
            f"kaizen-agentic path not found on host '{hostname}'. "
            "Register it with update_repo_path('kaizen-agentic', '/path/to/repo')."
        )
    agents_dir = Path(base) / "agents"
    if not agents_dir.is_dir():
        raise FileNotFoundError(f"agents/ directory not found at {agents_dir}")
    return agents_dir


@mcp.tool()
def list_kaizen_agents(category: str | None = None) -> str:
    """List all available kaizen agent personas.

    Reads agent metadata from kaizen-agentic/agents/agent-*.md frontmatter.
    Each agent is a specialized instruction set Claude can load and follow.

    Args:
        category: Optional filter (e.g. 'testing', 'quality', 'process', 'infrastructure').
                  Returns all agents when omitted.

    Returns:
        JSON list of {name, description, category, file} objects.
    """
    import re as _re
    agents_dir = _kaizen_agents_dir()
    result = []
    for f in sorted(agents_dir.glob("agent-*.md")):
        name = f.stem.removeprefix("agent-")
        text = f.read_text(encoding="utf-8")
        # Extract optional YAML frontmatter fields
        fm_match = _re.match(r"^---\n(.*?\n)---\n", text, _re.DOTALL)
        meta: dict = {}
        if fm_match:
            for line in fm_match.group(1).splitlines():
                if ":" in line:
                    k, _, v = line.partition(":")
                    meta[k.strip()] = v.strip()
        agent_category = meta.get("category", "")
        if category and agent_category.lower() != category.lower():
            continue
        # Fall back to first non-empty line after frontmatter as description
        desc = meta.get("description", "")
        if not desc:
            for line in text.split("\n"):
                line = line.strip()
                if line and not line.startswith("#") and not line.startswith("---"):
                    desc = line[:120]
                    break
        result.append({"name": name, "description": desc, "category": agent_category, "file": f.name})
    return json.dumps(result, indent=2)


@mcp.tool()
def get_kaizen_agent(name: str) -> str:
    """Load the full instructions for a kaizen agent persona.

    Read the returned markdown and follow the instructions it contains.
    Use list_kaizen_agents() to discover available agent names.

    Args:
        name: Agent name without 'agent-' prefix (e.g. 'tdd-workflow', 'code-refactoring').

    Returns:
        Full markdown content of the agent definition file.
    """
    agents_dir = _kaizen_agents_dir()
    agent_file = agents_dir / f"agent-{name}.md"
    if not agent_file.exists():
        available = [f.stem.removeprefix("agent-") for f in sorted(agents_dir.glob("agent-*.md"))]
        return json.dumps({"error": f"Agent '{name}' not found.", "available": available})
    return agent_file.read_text(encoding="utf-8")


# ---------------------------------------------------------------------------
# ADR-001 compliance validation
# ---------------------------------------------------------------------------

@mcp.tool()
def validate_repo_adr(repo_slug: str, domain_slug: str | None = None) -> str:
    """Check whether a repository is consistent with ADR-001.

    Validates that workplan files exist in workplans/ with correct frontmatter,
    that state_hub_workstream_id references resolve to real DB records, and that
    no active state-hub workstreams for the domain lack a backing file (orphan
    detection — DB-only records are an ADR-001 violation).

    The repo path is resolved from the DB: host_paths[hostname] is tried first
    (with existence check), then local_path — both support ~ expansion. This tool always runs against
    the server's copy of the repo. Remote agents on a different branch should
    sync first, or run validate_repo_adr.py locally with
    --api-base http://127.0.0.1:18000.

    Args:
        repo_slug: Registered repo slug (e.g. 'the-custodian', 'ops-bridge').
        domain_slug: Domain slug for orphan detection (e.g. 'custodian').
                     If omitted, inferred from workplan frontmatter.
    """
    import socket as _socket
    import subprocess

    repo = _get(f"/repos/{repo_slug}")
    if isinstance(repo, dict) and repo.get("error"):
        return f"Repo '{repo_slug}' not found: {repo['error']}"

    repo_path = _resolve_repo_path(repo)
    if not repo_path:
        hostname = _socket.gethostname()
        return (
            f"⚠ No accessible path found for repo '{repo_slug}' on host '{hostname}'.\n"
            f"Register with: update_repo_path('{repo_slug}', '/path/to/repo')\n"
            f"Remote agents: run validate_repo_adr.py locally with "
            f"--api-base {API_BASE}"
        )

    script = Path(__file__).parent.parent / "scripts" / "validate_repo_adr.py"
    cmd = [sys.executable, str(script), repo_path, "--json",
           "--api-base", API_BASE]
    if domain_slug:
        cmd += ["--domain", domain_slug]

    result = subprocess.run(cmd, capture_output=True, text=True)
    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError:
        return f"Validator script error:\n{result.stderr or result.stdout or '(no output)'}"

    findings = data.get("findings", [])
    summary = data.get("summary", {})
    overall = data.get("result", "unknown")

    failures = [f for f in findings if f["level"] == "FAIL"]
    warnings = [f for f in findings if f["level"] == "WARN"]

    lines = [f"ADR-001 Compliance: {repo_slug} ({repo_path})", ""]

    if failures:
        lines.append(f"FAILURES ({len(failures)}):")
        for f in failures:
            loc = f"  [{f['file']}]" if f.get("file") else ""
            lines.append(f"  FAIL  {f['check']}{loc}")
            lines.append(f"        {f['detail']}")
        lines.append("")

    if warnings:
        lines.append(f"WARNINGS ({len(warnings)}):")
        for f in warnings:
            loc = f"  [{f['file']}]" if f.get("file") else ""
            lines.append(f"  WARN  {f['check']}{loc}")
            lines.append(f"        {f['detail']}")
        lines.append("")

    lines.append(
        f"Summary: {summary.get('pass', 0)} pass | "
        f"{summary.get('warn', 0)} warn | "
        f"{summary.get('fail', 0)} fail"
    )
    lines.append(f"Result: {'FAIL' if overall == 'fail' else 'PASS (with warnings)' if overall == 'warn' else 'PASS'}")
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# ADR-001 consistency checking engine
# ---------------------------------------------------------------------------

@mcp.tool()
def check_repo_consistency(repo_slug: str, fix: bool = False) -> str:
    """Run ADR-001 consistency check for a registered repo.

    Performs bidirectional checks between workplan files in the repo and the
    state-hub DB. The file is always authoritative: drift is reported with the
    file value as the expected value.

    Checks: missing workplans/, parse errors, stale DB references, status/title
    drift, unlinked workplans, orphan DB workstreams, repo mismatches, task
    status drift, unlinked tasks, and orphan DB tasks.

    Args:
        repo_slug: Registered repo slug (e.g. 'the-custodian', 'activity-core').
        fix: If True, apply auto-fixable issues: status drift (C-04), title drift
             (C-05), create missing DB workstreams (C-06), repo mismatch (C-09),
             task status drift (C-10), create unlinked tasks (C-11).
    """
    import socket as _socket
    import subprocess

    # Pre-flight: verify this host has the repo path registered and accessible.
    repo = _get(f"/repos/{repo_slug}")
    if isinstance(repo, dict) and repo.get("error"):
        return f"Repo '{repo_slug}' not found: {repo['error']}"
    repo_path = _resolve_repo_path(repo)
    if not repo_path:
        hostname = _socket.gethostname()
        return (
            f"⚠ No accessible path found for repo '{repo_slug}' on host '{hostname}'.\n"
            f"Register with: update_repo_path('{repo_slug}', '/path/to/repo')\n"
            f"Remote agents: run consistency_check.py locally with "
            f"--api-base {API_BASE}"
        )

    script = Path(__file__).parent.parent / "scripts" / "consistency_check.py"
    cmd = [sys.executable, str(script), "--repo", repo_slug, "--json",
           "--api-base", API_BASE]
    if fix:
        cmd.append("--fix")

    result = subprocess.run(cmd, capture_output=True, text=True)
    try:
        data = json.loads(result.stdout)
    except json.JSONDecodeError:
        return f"Consistency check script error:\n{result.stderr or result.stdout or '(no output)'}"

    issues = data.get("issues", [])
    summary = data.get("summary", {})
    overall = data.get("result", "unknown")
    fixes = data.get("fixes_applied", [])

    failures = [i for i in issues if i["severity"] == "FAIL"]
    warnings = [i for i in issues if i["severity"] == "WARN"]
    infos = [i for i in issues if i["severity"] == "INFO"]

    lines = [
        f"Consistency Check: {repo_slug}",
        f"Path: {data.get('repo_path', '?')}",
        "",
    ]

    for sev, group in (("FAIL", failures), ("WARN", warnings), ("INFO", infos)):
        if not group:
            continue
        lines.append(f"{sev}S ({len(group)}):")
        for i in group:
            loc = f"  [{i['file_path']}]" if i.get("file_path") else ""
            fix_tag = " [fixable]" if i.get("fixable") else ""
            lines.append(f"  {i['check_id']}{loc}{fix_tag}")
            lines.append(f"    {i['message']}")
        lines.append("")

    if fixes:
        lines.append(f"Fixes applied ({len(fixes)}):")
        for f in fixes:
            lines.append(f"  {f}")
        lines.append("")

    lines.append(
        f"Summary: {summary.get('fail', 0)} fail | "
        f"{summary.get('warn', 0)} warn | "
        f"{summary.get('info', 0)} info"
    )
    lines.append(
        f"Result: {'FAIL' if overall == 'fail' else 'PASS (with warnings)' if overall in ('warn',) else 'PASS'}"
    )
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Contribution tracking (v0.3)
# ---------------------------------------------------------------------------

@mcp.resource("state://contributions")
def resource_contributions() -> str:
    """All contribution artifacts (BR/FR/EP/UPR)."""
    return json.dumps(_get("/contributions"), indent=2)


@mcp.resource("state://sbom/aggregated")
def resource_sbom_aggregated() -> str:
    """Aggregated SBOM entries across all repos."""
    return json.dumps(_get("/sbom"), indent=2)


@mcp.resource("state://sbom/{repo_slug}")
def resource_sbom_repo(repo_slug: str) -> str:
    """SBOM view for a specific repo (by slug)."""
    return json.dumps(_get(f"/sbom/{repo_slug}"), indent=2)


@mcp.tool()
def register_contribution(
    type: str,
    title: str,
    target_org: str | None = None,
    target_repo: str | None = None,
    body_path: str | None = None,
    related_workplan_id: str | None = None,
    related_workstream_id: str | None = None,
    notes: str | None = None,
) -> str:
    """Register a new upstream contribution artifact (BR/FR/EP/UPR).

    Args:
        type: br | fr | ep | upr
        title: Short human-readable title
        target_org: GitHub org or owner of the upstream project
        target_repo: Repository name of the upstream project
        body_path: Relative path to the Markdown artifact file in the repo
        related_workplan_id: UUID of the related workplan (preferred, optional)
        related_workstream_id: legacy alias for related_workplan_id
        notes: Any additional notes (optional)
    """
    parent_id = related_workplan_id or related_workstream_id
    contrib = _post("/contributions", {
        "type": type,
        "title": title,
        "target_org": target_org,
        "target_repo": target_repo,
        "body_path": body_path,
        "related_workplan_id": parent_id,
        "notes": notes,
    })
    _post("/progress", {
        "workplan_id": parent_id,
        "event_type": "contribution_registered",
        "summary": f"Contribution registered [{type.upper()}]: {title}",
        "author": "custodian",
        "detail": {
            "contribution_id": contrib["id"],
            "type": type,
            "target": f"{target_org}/{target_repo}" if target_org else target_repo,
            "body_path": body_path,
        },
    })
    return json.dumps(contrib, indent=2)


@mcp.tool()
def update_contribution_status(
    contribution_id: str,
    status: str,
    notes: str | None = None,
) -> str:
    """Update the status of a contribution artifact.

    Valid transitions: draft→submitted→acknowledged→accepted→merged
                                    ↘             ↘
                                  rejected      withdrawn

    Args:
        contribution_id: UUID of the contribution
        status: submitted | acknowledged | accepted | rejected | merged | withdrawn
        notes: Optional context for the status change
    """
    contrib = _patch(f"/contributions/{contribution_id}/status", {
        "status": status,
        "notes": notes,
    })
    _post("/progress", {
        "event_type": "contribution_status_changed",
        "summary": f"Contribution status → {status}: {contrib['title']}",
        "author": "custodian",
        "detail": {"contribution_id": contribution_id, "status": status, "notes": notes},
    })
    return json.dumps(contrib, indent=2)


@mcp.tool()
def get_contributions(
    type: str | None = None,
    status: str | None = None,
    target_repo: str | None = None,
) -> str:
    """List contribution artifacts, optionally filtered.

    Args:
        type: br | fr | ep | upr (optional)
        status: draft | submitted | acknowledged | accepted | rejected | merged | withdrawn (optional)
        target_repo: filter by upstream repo name (optional)
    """
    return json.dumps(_get("/contributions", {
        "type": type, "status": status, "target_repo": target_repo,
    }), indent=2)


@mcp.tool()
def ingest_sbom_tool(repo_slug: str, lockfile_path: str | None = None) -> str:
    """Ingest a lockfile into the State Hub SBOM store for a repo.

    Parses the lockfile and POSTs entries to /sbom/ingest/. Each call creates
    a new SBOMSnapshot; previous snapshots are retained as history.

    The repo root is resolved from the DB using the current machine's hostname
    (host_paths[hostname] → local_path fallback). lockfile_path, when given,
    is treated as relative to the repo root. Omit it to auto-detect the lockfile.

    Args:
        repo_slug: Managed-repo slug (must be registered via register_repo)
        lockfile_path: Path to the lockfile, relative to repo root
                       (e.g. "uv.lock", "frontend/package-lock.json").
                       Omit to auto-detect from the repo root.
    """
    import socket as _socket
    import subprocess

    repo = _get(f"/repos/{repo_slug}")
    if isinstance(repo, dict) and repo.get("error"):
        return f"Repo '{repo_slug}' not found: {repo['error']}"

    repo_root = _resolve_repo_path(repo)
    if not repo_root:
        hostname = _socket.gethostname()
        return (
            f"⚠ No accessible path found for repo '{repo_slug}' on host '{hostname}'.\n"
            f"Register with: update_repo_path('{repo_slug}', '/path/to/repo')"
        )

    script = Path(__file__).parent.parent / "scripts" / "ingest_sbom.py"
    cmd = [sys.executable, str(script), "--repo", repo_slug,
           "--repo-path", repo_root, "--api-base", API_BASE]

    if lockfile_path:
        resolved = Path(repo_root) / lockfile_path
        if not resolved.exists():
            return f"⚠ Lockfile not found: {resolved}"
        cmd += ["--lockfile", str(resolved)]

    result = subprocess.run(cmd, capture_output=True, text=True)
    output = (result.stdout + result.stderr).strip()
    if result.returncode != 0:
        return f"ingest_sbom failed (exit {result.returncode}):\n{output}"
    return output


@mcp.tool()
def get_licence_report() -> str:
    """Get a licence report across all ingested SBOM entries.

    Returns packages grouped by SPDX licence identifier, with copyleft
    flag (GPL/AGPL/LGPL/EUPL/CDDL/MPL) and repos using each licence.
    """
    return json.dumps(_get("/sbom/report/licences"), indent=2)


# ---------------------------------------------------------------------------
# Domain goals & repo goals (v0.7)
# ---------------------------------------------------------------------------

@mcp.tool()
def create_domain_goal(domain_slug: str, title: str, description: str) -> str:
    """Create a new domain goal and make it active (superseding any existing active goal).

    A domain goal captures the high-level strategic intent for a domain. Only one
    domain goal can be active at a time; creating a new active one supersedes the
    previous active goal.

    Args:
        domain_slug: Slug of the domain (e.g. 'railiance', 'markitect')
        title: Short goal title
        description: Full description of the goal and its boundary conditions
    """
    domains = _get("/domains", {"status": "active"})
    domain = next((d for d in domains if d["slug"] == domain_slug), None)
    if not domain:
        return json.dumps({"error": f"Domain '{domain_slug}' not found"})
    goal = _post("/domain-goals", {
        "domain_id": domain["id"],
        "title": title,
        "description": description,
        "status": "active",
    })
    _post("/progress", {
        "event_type": "goal_created",
        "summary": f"Domain goal created [{domain_slug}]: {title}",
        "detail": {"goal_id": goal["id"], "domain_slug": domain_slug},
    })
    return json.dumps(goal, indent=2)


@mcp.tool()
def get_domain_goals(domain_slug: str, status: str | None = None) -> str:
    """List domain goals for a domain, optionally filtered by status.

    Args:
        domain_slug: Slug of the domain (e.g. 'railiance')
        status: active | archived | superseded (omit for all)
    """
    return json.dumps(_get("/domain-goals", {"domain_slug": domain_slug, "status": status}), indent=2)


@mcp.tool()
def activate_domain_goal(goal_id: str) -> str:
    """Set a domain goal as the active goal, superseding any currently active one.

    Args:
        goal_id: UUID of the domain goal to activate
    """
    goal = _post(f"/domain-goals/{goal_id}/activate", {})
    _post("/progress", {
        "event_type": "goal_activated",
        "summary": f"Domain goal activated: {goal['title']}",
        "detail": {"goal_id": goal_id, "domain_slug": goal.get("domain_slug")},
    })
    return json.dumps(goal, indent=2)


@mcp.tool()
def create_repo_goal(
    repo_slug: str,
    title: str,
    description: str,
    domain_goal_id: str | None = None,
    priority: int = 100,
) -> str:
    """Create a new repository goal.

    Repository goals capture what needs to be achieved in a specific repository.
    Multiple active repo goals can coexist; priority (lower number = higher priority)
    determines ordering. Optionally link to the parent domain goal.

    Args:
        repo_slug: Slug of the repository (e.g. 'railiance-cluster')
        title: Short goal title
        description: Full description including boundary conditions and scope
        domain_goal_id: UUID of the parent domain goal (optional)
        priority: Integer priority — lower numbers = higher priority (default 100)
    """
    repos = _get("/repos")
    repo = next((r for r in repos if r["slug"] == repo_slug), None)
    if not repo:
        return json.dumps({"error": f"Repo '{repo_slug}' not found"})
    goal = _post("/repo-goals", {
        "repo_id": repo["id"],
        "title": title,
        "description": description,
        "domain_goal_id": domain_goal_id,
        "priority": priority,
        "status": "active",
    })
    _post("/progress", {
        "event_type": "goal_created",
        "summary": f"Repo goal created [{repo_slug}]: {title}",
        "detail": {"goal_id": goal["id"], "repo_slug": repo_slug, "priority": priority},
    })
    return json.dumps(goal, indent=2)


@mcp.tool()
def get_repo_goals(repo_slug: str, status: str | None = None) -> str:
    """List repository goals for a repo, ordered by priority.

    Args:
        repo_slug: Slug of the repository (e.g. 'railiance-cluster')
        status: active | paused | completed | archived (omit for all)
    """
    return json.dumps(_get("/repo-goals", {"repo_slug": repo_slug, "status": status}), indent=2)


@mcp.tool()
def update_repo_goal(
    goal_id: str,
    title: str | None = None,
    description: str | None = None,
    priority: int | None = None,
    status: str | None = None,
    domain_goal_id: str | None = None,
) -> str:
    """Update a repository goal (title, description, priority, status, or domain link).

    Args:
        goal_id: UUID of the repo goal
        title: New title (optional)
        description: New description (optional)
        priority: New priority integer — lower = higher priority (optional)
        status: active | paused | completed | archived (optional)
        domain_goal_id: Link or re-link to a domain goal UUID (optional)
    """
    updates: dict = {}
    if title is not None:
        updates["title"] = title
    if description is not None:
        updates["description"] = description
    if priority is not None:
        updates["priority"] = priority
    if status is not None:
        updates["status"] = status
    if domain_goal_id is not None:
        updates["domain_goal_id"] = domain_goal_id
    goal = _patch(f"/repo-goals/{goal_id}", updates)
    _post("/progress", {
        "event_type": "goal_updated",
        "summary": f"Repo goal updated: {goal['title']}",
        "detail": {"goal_id": goal_id, "changes": list(updates.keys())},
    })
    return json.dumps(goal, indent=2)


@mcp.tool()
def get_repo_dispatch(repo_slug: str) -> str:
    """Return active workplans, pending tasks, and goal for a repo.

    Use this at the start of a repo agent session to discover what work is
    pending without needing to read the full state summary or scan workplan
    files. The response includes:
      - active_goal: the highest-priority active repo goal
      - active_workplans: list of active workplans with pending tasks
      - human_interventions: tasks that need human input (needs_human=true)
      - last_state_synced_at: when the repo was last synced to the hub

    Args:
        repo_slug: Slug of the repository (e.g. 'marki-docx')
    """
    return json.dumps(_get(f"/repos/{repo_slug}/dispatch"), indent=2)


# ---------------------------------------------------------------------------
# Capability Catalog & Requests (dev-hub extensions)
# Messaging and catalog CRUD/list tools come from hub_core.mcp.HubCoreMCPServer.
# ---------------------------------------------------------------------------

@mcp.tool()
def get_capability_profile(domain_slug: str | None = None) -> str:
    """Full capability registry: domain → repos (with description) → capabilities.

    Designed for deep-dive or cross-domain architectural discussion.

    Args:
        domain_slug: If provided, return profile for that one domain only.
                     If omitted, return profiles for all active domains.

    Returns a structured dict with repos nested under each domain, and
    capabilities nested under each repo. Domain-level capabilities
    (no repo assigned) appear under a synthetic entry with slug=null.
    """
    if domain_slug:
        domain_slugs = [domain_slug]
    else:
        domains_raw = _get("/domains/")
        if isinstance(domains_raw, dict) and "error" in domains_raw:
            return json.dumps(domains_raw, indent=2)
        domain_slugs = [d["slug"] for d in domains_raw if d.get("status") == "active"]

    # Fetch topics once for title lookup
    topics_raw = _get("/topics/")

    profiles = []
    for slug in domain_slugs:
        repos_raw = _get("/repos/", {"domain": slug})
        caps_raw = _get("/capability-catalog/", {"domain": slug, "status": "active"})
        if isinstance(caps_raw, dict) and "error" in caps_raw:
            caps_raw = []

        # Index capabilities by repo_slug (None → domain-level)
        caps_by_repo_slug: dict[str | None, list] = {}
        for cap in caps_raw:
            r_slug = cap.get("repo_slug")
            caps_by_repo_slug.setdefault(r_slug, []).append({
                "type": cap["capability_type"],
                "title": cap["title"],
                "description": cap.get("description"),
                "keywords": cap.get("keywords", []),
            })

        repo_entries = []
        if isinstance(repos_raw, list):
            for repo in repos_raw:
                repo_entries.append({
                    "slug": repo["slug"],
                    "name": repo["name"],
                    "description": repo.get("description"),
                    "capabilities": caps_by_repo_slug.get(repo["slug"], []),
                })

        # Domain-level caps (no repo assigned)
        domain_level = caps_by_repo_slug.get(None, [])
        if domain_level:
            repo_entries.append({
                "slug": None,
                "name": "(domain-level)",
                "description": None,
                "capabilities": domain_level,
            })

        # Get topic title
        topic_title = ""
        if isinstance(topics_raw, list):
            topic = next((t for t in topics_raw if t.get("domain_slug") == slug), None)
            if topic:
                topic_title = topic.get("title", "")

        profiles.append({
            "slug": slug,
            "title": topic_title,
            "repos": repo_entries,
        })

    if domain_slug and profiles:
        return json.dumps(profiles[0], indent=2)
    return json.dumps(profiles, indent=2)


@mcp.tool()
def request_capability(
    title: str,
    description: str,
    capability_type: str,
    requesting_agent: str,
    requesting_domain: str,
    requesting_workplan_id: str | None = None,
    requesting_workstream_id: str | None = None,
    priority: str = "medium",
    blocking_task_id: str | None = None,
) -> str:
    """Request a capability from another domain. Auto-routes to the responsible
    domain via the capability catalog. If no unique match, broadcasts to all.

    Args:
        title: Short title (e.g. 'Privacy idea instance on cluster')
        description: Detailed description of what you need
        capability_type: Category (e.g. 'infrastructure', 'api', 'data', 'security')
        requesting_agent: Your agent identifier (e.g. 'net-kingdom-worker')
        requesting_domain: Your domain slug (e.g. 'custodian')
        requesting_workplan_id: UUID of your workplan (preferred, optional)
        requesting_workstream_id: legacy alias for requesting_workplan_id
        priority: low | medium | high | critical (default: medium)
        blocking_task_id: UUID of the task blocked until this is fulfilled (optional)
    """
    parent_id = requesting_workplan_id or requesting_workstream_id
    req = _post("/capability-requests", {
        "title": title,
        "description": description,
        "capability_type": capability_type,
        "requesting_agent": requesting_agent,
        "requesting_domain": requesting_domain,
        "requesting_workplan_id": parent_id,
        "priority": priority,
        "blocking_task_id": blocking_task_id,
    })
    _post("/progress", {
        "event_type": "capability_requested",
        "summary": f"Capability requested: {title} ({capability_type})",
        "author": requesting_agent,
        "detail": {
            "capability_request_id": req.get("id"),
            "capability_type": capability_type,
            "routed_to": req.get("fulfilling_domain_slug"),
        },
    })
    return json.dumps(req, indent=2)


@mcp.tool()
def patch_capability_request(
    request_id: str,
    catalog_entry_id: Optional[str] = None,
    priority: Optional[str] = None,
    blocking_task_id: Optional[str] = None,
    fulfilling_workplan_id: Optional[str] = None,
    fulfilling_workstream_id: Optional[str] = None,
) -> dict:
    """Correct mutable metadata on a capability request.

    Correcting catalog_entry_id automatically re-derives the fulfilling domain.
    Use this when the hub mis-routed a request (wrong catalog entry or domain).
    Only provided (non-None) fields are updated.

    Args:
        request_id: UUID of the capability request to patch.
        catalog_entry_id: Correct catalog entry UUID. Re-derives fulfilling domain.
        priority: New priority (low/medium/high/critical).
        blocking_task_id: UUID of the task this request unblocks on completion.
        fulfilling_workplan_id: UUID of the workplan delivering this capability (preferred).
        fulfilling_workstream_id: legacy alias for fulfilling_workplan_id.

    Returns:
        Updated capability request dict, or {"error": "..."}.
    """
    parent_id = fulfilling_workplan_id or fulfilling_workstream_id
    body: dict = {}
    if catalog_entry_id is not None:
        body["catalog_entry_id"] = catalog_entry_id
    if priority is not None:
        body["priority"] = priority
    if blocking_task_id is not None:
        body["blocking_task_id"] = blocking_task_id
    if parent_id is not None:
        body["fulfilling_workplan_id"] = parent_id

    if not body:
        return {"error": "no fields provided to patch"}

    return json.dumps(_patch(f"/capability-requests/{request_id}", body), indent=2)


@mcp.tool()
def dispute_capability_routing(
    request_id: str,
    reason: str,
    disputed_by: str,
    suggested_domain: Optional[str] = None,
) -> str:
    """Flag a capability request routing as incorrect. Transitions to routing_disputed.

    Args:
        request_id: UUID of the capability request
        reason: Why the routing is wrong
        disputed_by: Agent raising the dispute (e.g. 'netkingdom-worker')
        suggested_domain: The domain slug this should be routed to (optional)
    """
    return json.dumps(_post(f"/capability-requests/{request_id}/dispute", {
        "reason": reason,
        "disputed_by": disputed_by,
        "suggested_domain": suggested_domain,
    }), indent=2)


@mcp.tool()
def reroute_capability_request(
    request_id: str,
    note: str,
    rerouted_by: str,
    domain: Optional[str] = None,
    catalog_entry_id: Optional[str] = None,
) -> str:
    """Re-route a disputed capability request to a new domain. Resets to requested.

    Args:
        request_id: UUID of the capability request (must be routing_disputed)
        note: Reason for the re-routing decision
        rerouted_by: Agent performing the re-route (e.g. 'custodian')
        domain: Target domain slug (used if catalog_entry_id not provided)
        catalog_entry_id: Preferred — UUID of catalog entry; re-derives domain automatically
    """
    return json.dumps(_post(f"/capability-requests/{request_id}/reroute", {
        "note": note,
        "rerouted_by": rerouted_by,
        "domain": domain,
        "catalog_entry_id": catalog_entry_id,
    }), indent=2)


# ---------------------------------------------------------------------------
# Third-Party Services Catalog (TPSC)
# ---------------------------------------------------------------------------

@mcp.tool()
def register_service(
    slug: str,
    name: str,
    provider: str | None = None,
    category: str | None = None,
    pricing_model: str = "unknown",
    gdpr_maturity: str = "unknown",
    gdpr_notes: str | None = None,
    dpa_available: bool = False,
    tos_url: str | None = None,
    privacy_policy_url: str | None = None,
    data_processing_regions: list[str] | None = None,
    data_retention_notes: str | None = None,
    website_url: str | None = None,
) -> str:
    """Register or update a service in the Third-Party Services Catalog (TPSC).

    GDPR maturity scale (CNIL/IAPP CMMI-aligned):
      unknown | non_compliant | initial | developing | defined | managed | certified

    Pricing model: free | paid | freemium | usage_based | unknown

    Args:
        slug: Unique identifier (e.g. 'openai-api', 'stripe')
        name: Human-readable service name
        provider: Company/organisation name
        category: Category (e.g. 'llm_inference', 'storage', 'payments', 'search')
        pricing_model: free | paid | freemium | usage_based | unknown
        gdpr_maturity: GDPR compliance maturity level (see scale above)
        gdpr_notes: Free-text GDPR notes (DPA details, transfer mechanisms, etc.)
        dpa_available: Whether a Data Processing Agreement is available
        tos_url: Terms of Service URL
        privacy_policy_url: Privacy Policy URL
        data_processing_regions: List of regions where data is processed (e.g. ['us', 'eu'])
        data_retention_notes: Data retention policy summary
        website_url: Service website URL
    """
    return json.dumps(_post("/tpsc/catalog", {
        "slug": slug,
        "name": name,
        "provider": provider,
        "category": category,
        "website_url": website_url,
        "pricing_model": pricing_model,
        "gdpr_maturity": gdpr_maturity,
        "gdpr_notes": gdpr_notes,
        "dpa_available": dpa_available,
        "tos_url": tos_url,
        "privacy_policy_url": privacy_policy_url,
        "data_processing_regions": data_processing_regions or [],
        "data_retention_notes": data_retention_notes,
    }), indent=2)


@mcp.tool()
def ingest_tpsc_tool(repo_slug: str) -> str:
    """Ingest tpsc.yaml service dependency declarations for a repo.

    Reads <repo_root>/tpsc.yaml, resolves service slugs against the catalog,
    and creates a new TPSC snapshot. The repo path is resolved the same way
    as the SBOM ingest tool (host_paths → local_path with existence check).

    Args:
        repo_slug: Registered repo slug (e.g. 'llm-connect', 'markitect-project')
    """
    import socket as _socket
    import subprocess

    repo = _get(f"/repos/{repo_slug}")
    if isinstance(repo, dict) and repo.get("error"):
        return f"Repo '{repo_slug}' not found: {repo['error']}"

    repo_root = _resolve_repo_path(repo)
    if not repo_root:
        hostname = _socket.gethostname()
        return (
            f"⚠ No accessible path found for repo '{repo_slug}' on host '{hostname}'.\n"
            f"Register with: update_repo_path('{repo_slug}', '/path/to/repo')"
        )

    script = Path(__file__).parent.parent / "scripts" / "ingest_tpsc.py"
    result = subprocess.run(
        ["uv", "run", "python", str(script), "--repo", repo_slug],
        capture_output=True, text=True,
        cwd=str(Path(__file__).parent.parent),
    )
    output = result.stdout + result.stderr
    if result.returncode != 0:
        return f"ingest_tpsc failed (exit {result.returncode}):\n{output}"
    return output.strip()


# ---------------------------------------------------------------------------
# Interactive / ad-hoc task recording
# ---------------------------------------------------------------------------


def _resolve_repo_path_for_host(repo: dict) -> str:
    hostname = socket.gethostname()
    host_paths = repo.get("host_paths") or {}
    candidates = []
    if host_paths.get(hostname):
        candidates.append(host_paths[hostname])
    if repo.get("local_path"):
        candidates.append(repo["local_path"])
    for raw in candidates:
        p = Path(raw).expanduser()
        if p.is_dir():
            return str(p)
    return ""


def _read_adhoc_workstream_id(wp_file: Path) -> str:
    if not wp_file.exists():
        return ""
    match = re.search(r'^state_hub_workstream_id:\s*"?([^"\n]+)"?', wp_file.read_text(encoding="utf-8"), re.MULTILINE)
    return match.group(1).strip() if match else ""


def _next_adhoc_task_id(wp_file: Path, adhoc_id: str) -> str:
    if not wp_file.exists():
        return f"{adhoc_id}-T01"
    text = wp_file.read_text(encoding="utf-8")
    nums = [int(m.group(1)) for m in re.finditer(rf"\b{re.escape(adhoc_id)}-T(\d+)\b", text)]
    return f"{adhoc_id}-T{(max(nums) + 1 if nums else 1):02d}"


def _ensure_adhoc_workplan(
    repo: dict,
    repo_slug: str,
    domain_slug: str,
    agent: str | None,
) -> tuple[dict, Path, str] | dict:
    repo_path = _resolve_repo_path_for_host(repo)
    if not repo_path:
        return {"error": f"No accessible local path for repo {repo_slug!r} on host {socket.gethostname()}."}

    today = datetime.now().date().isoformat()
    adhoc_id = f"ADHOC-{today}"
    ws_slug = f"adhoc-{today}"
    repo_dir = Path(repo_path)
    workplans_dir = repo_dir / "workplans"
    workplans_dir.mkdir(exist_ok=True)
    wp_file = workplans_dir / f"{adhoc_id}.md"

    ws_id = _read_adhoc_workstream_id(wp_file)
    ws = _get(f"/workstreams/{ws_id}") if ws_id else None
    if not isinstance(ws, dict) or "error" in ws:
        existing = _get("/workstreams/", {"slug": ws_slug})
        ws = existing[0] if isinstance(existing, list) and existing else None

    if not ws:
        topics = _get("/topics/")
        topic = next(
            (t for t in (topics if isinstance(topics, list) else [])
             if t.get("domain_slug") == domain_slug or t.get("domain") == domain_slug),
            None,
        )
        if not topic:
            return {"error": f"No topic found for domain {domain_slug!r} — cannot create adhoc workstream."}
        ws = _post("/workstreams", {
            "topic_id": topic["id"],
            "slug": ws_slug,
            "title": f"Ad Hoc Tasks — {today}",
            "description": "Small opportunistic tasks discovered during active sessions.",
            "owner": agent or "custodian",
            "repo_id": repo["id"],
        })
        if "error" in ws:
            return ws

    if not wp_file.exists():
        wp_file.write_text(
            f"""---
id: {adhoc_id}
type: workplan
title: "Ad Hoc Tasks — {today}"
domain: {domain_slug}
repo: {repo_slug}
status: active
owner: {agent or "custodian"}
topic_slug: {domain_slug}
created: "{today}"
updated: "{today}"
state_hub_workstream_id: "{ws["id"]}"
---

# {adhoc_id} — Ad Hoc Tasks

Small opportunistic tasks discovered during active work. Promote anything that
requires analysis, design, approval, dependencies, or multiple phases into a
normal workplan.
""",
            encoding="utf-8",
        )

    return ws, wp_file, adhoc_id


@mcp.tool()
def record_adhoc_task(
    title: str,
    repo_slug: str,
    tokens_in: Optional[int] = None,
    tokens_out: Optional[int] = None,
    note: Optional[str] = None,
    model: Optional[str] = None,
    agent: Optional[str] = None,
    description: Optional[str] = None,
    session_id: Optional[str] = None,
) -> str:
    """Record small opportunistic work as a file-backed Ad Hoc task.

    Finds or creates today's workplans/ADHOC-YYYY-MM-DD.md and matching
    adhoc-YYYY-MM-DD workstream, appends a task block, marks the task done, and
    records token consumption through the task API.

    Token note convention:
      "measured"  — exact counts read from the Claude Code status bar (default when
                    tokens_in/tokens_out provided and note omitted)
      "userbased" — counts provided by a human (pass note="userbased" explicitly)
      "heuristic" — server fallback when no counts given (automatic)

    Use this for work done outside a formal workplan: quick fixes, config
    changes, code reviews, one-off investigations, or any session work worth
    tracking. Promote work needing analysis/design/approval into a normal
    workplan instead.

    Args:
        title: Short description of the work done
        repo_slug: Registered repo slug, e.g. 'the-custodian', 'inter-hub'
        tokens_in: Input token count (Tier 1 — read from Claude Code status bar)
        tokens_out: Output token count (Tier 1)
        note: Override token note — use "userbased" when counts came from a human
        model: Model identifier, e.g. 'claude-sonnet-4-6'
        agent: Agent name, e.g. 'custodian', 'ralph'
        description: Optional longer description of what was done
        session_id: Agent session identifier
    """
    # Resolve repo
    repos = _get("/repos/")
    if isinstance(repos, dict) and "error" in repos:
        return json.dumps(repos)
    repo = next((r for r in (repos or []) if r.get("slug") == repo_slug), None)
    if not repo:
        return json.dumps({"error": f"Repo not found: {repo_slug!r}. Register it first with register_repo()."})

    repo_id = repo["id"]
    domain_slug = repo.get("domain_slug") or repo.get("domain")
    ensured = _ensure_adhoc_workplan(repo, repo_slug, domain_slug, agent)
    if isinstance(ensured, dict) and "error" in ensured:
        return json.dumps(ensured)
    ws, wp_file, adhoc_id = ensured
    task_block_id = _next_adhoc_task_id(wp_file, adhoc_id)

    # Create task
    task = _post("/tasks", {
        "workstream_id": ws["id"],
        "title": title,
        "description": description,
        "priority": "medium",
    })
    if "error" in task:
        return json.dumps(task)

    # Mark done — triggers three-tier token recording in the router
    body: dict[str, Any] = {
        "status": "done",
        "model": model,
        "agent": agent,
        "session_id": session_id,
    }
    if tokens_in is not None:
        body["tokens_in"] = tokens_in
    if tokens_out is not None:
        body["tokens_out"] = tokens_out
    if note is not None:
        body["token_note"] = note

    _patch(f"/tasks/{task['id']}", body)

    now_day = datetime.now().date().isoformat()
    with wp_file.open("a", encoding="utf-8") as f:
        f.write(
            f"""

## {title}

```task
id: {task_block_id}
status: done
priority: medium
state_hub_task_id: "{task["id"]}"
```

{description or "Recorded as an Ad Hoc Task."}
"""
        )
    text = wp_file.read_text(encoding="utf-8")
    text = re.sub(r'^updated:\s*".*"$', f'updated: "{now_day}"', text, count=1, flags=re.MULTILINE)
    wp_file.write_text(text, encoding="utf-8")

    effective_note = note or ("measured" if tokens_in is not None else "heuristic")
    return json.dumps({
        "task_id": task["id"],
        "workstream_id": ws["id"],
        "workstream_slug": ws["slug"],
        "workplan_file": str(wp_file),
        "task_block_id": task_block_id,
        "title": title,
        "token_note": effective_note,
    }, indent=2)


@mcp.tool()
def record_interactive_task(
    title: str,
    repo_slug: str,
    tokens_in: Optional[int] = None,
    tokens_out: Optional[int] = None,
    note: Optional[str] = None,
    model: Optional[str] = None,
    agent: Optional[str] = None,
    description: Optional[str] = None,
    session_id: Optional[str] = None,
) -> str:
    """Deprecated alias for record_adhoc_task."""
    return record_adhoc_task(
        title=title,
        repo_slug=repo_slug,
        tokens_in=tokens_in,
        tokens_out=tokens_out,
        note=note,
        model=model,
        agent=agent,
        description=description,
        session_id=session_id,
    )


# ---------------------------------------------------------------------------
# Token events
# ---------------------------------------------------------------------------


@mcp.tool()
def record_token_event(
    tokens_in: int,
    tokens_out: int,
    task_id: Optional[str] = None,
    workplan_id: Optional[str] = None,
    workstream_id: Optional[str] = None,
    repo_id: Optional[str] = None,
    model: Optional[str] = None,
    agent: Optional[str] = None,
    ref_type: Optional[str] = None,
    ref_id: Optional[str] = None,
    note: Optional[str] = None,
    session_id: Optional[str] = None,
) -> str:
    """Record AI token consumption for a task, workstream, or session.

    workstream_id is auto-populated from the task if task_id is provided and
    workstream_id is omitted. Returns the created event id and running total
    for the task/workstream (if applicable).

    Args:
        tokens_in: Input token count
        tokens_out: Output token count
        task_id: UUID of the task (nullable)
        workplan_id: UUID of the workplan (preferred; nullable; auto-filled from task)
        workstream_id: legacy alias for workplan_id
        repo_id: UUID of the managed repo (nullable)
        model: Model identifier, e.g. 'claude-sonnet-4-6'
        agent: Agent name, e.g. 'custodian', 'ralph'
        ref_type: 'task'|'workstream'|'commit'|'release'|'session'
        ref_id: Commit SHA, release tag, or other reference string
        note: Free-text note
        session_id: Agent session identifier
    """
    parent_id = workplan_id or workstream_id
    body = {
        "tokens_in": tokens_in,
        "tokens_out": tokens_out,
        "task_id": task_id,
        "workplan_id": parent_id,
        "repo_id": repo_id,
        "model": model,
        "agent": agent,
        "ref_type": ref_type,
        "ref_id": ref_id,
        "note": note,
        "session_id": session_id,
    }
    result = _post("/token-events", body)
    if "error" in result:
        return json.dumps(result)

    out = {
        "event_id": result.get("id"),
        "tokens_total": result.get("tokens_total"),
        "tokens_in": result.get("tokens_in"),
        "tokens_out": result.get("tokens_out"),
    }

    # Append running total for the task if available
    scope_id = task_id or parent_id
    scope = "task" if task_id else ("workstream" if parent_id else None)
    if scope and scope_id:
        summary = _get("/token-events/summary", {"scope": scope, "id": scope_id})
        if "error" not in summary:
            out["running_total"] = {
                "scope": scope,
                "scope_id": scope_id,
                "tokens_total": summary.get("tokens_total"),
                "event_count": summary.get("event_count"),
            }

    return json.dumps(out, indent=2)


@mcp.tool()
def get_token_summary(scope: str, id: str) -> str:
    """Return token consumption summary for a given scope.

    Returns a formatted table of token usage aggregated by scope.

    Args:
        scope: One of: task | workstream | repo | commit | release | session
        id: UUID for task/workstream/repo scopes; ref_id string for commit/release/session
    """
    result = _get("/token-events/summary", {"scope": scope, "id": id})
    if "error" in result:
        return json.dumps(result)

    lines = [
        f"Token Summary — {scope}: {id}",
        f"{'─' * 50}",
        f"  tokens_in   : {result.get('tokens_in', 0):>10,}",
        f"  tokens_out  : {result.get('tokens_out', 0):>10,}",
        f"  tokens_total: {result.get('tokens_total', 0):>10,}",
        f"  event_count : {result.get('event_count', 0):>10,}",
    ]

    by_model = result.get("by_model", {})
    if by_model:
        lines.append("\nBy model:")
        for m, t in sorted(by_model.items(), key=lambda x: -x[1]):
            lines.append(f"  {m:<35} {t:>10,}")

    by_agent = result.get("by_agent", {})
    if by_agent:
        lines.append("\nBy agent:")
        for a, t in sorted(by_agent.items(), key=lambda x: -x[1]):
            lines.append(f"  {a:<35} {t:>10,}")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Interface Change Registry
# ---------------------------------------------------------------------------

@mcp.tool()
def register_interface_change(
    repo_slug: str,
    interface_type: str,
    change_type: str,
    title: str,
    description: str,
    affected_paths: list[str] | None = None,
    affected_repo_slugs: list[str] | None = None,
    planned_for: str | None = None,
    author: str = "custodian",
) -> str:
    """Create a draft InterfaceChange record.

    Documents a mutation to a published interface boundary. Records stay in
    'draft' status until explicitly published via publish_interface_change().

    Args:
        repo_slug: Slug of the repo that owns the interface.
        interface_type: One of: rest_api, mcp_tool, cli, schema, capability.
        change_type: One of: breaking, additive, deprecation, removal.
        title: Short summary (e.g. 'Remove trailing slash from param routes').
        description: Full before/after description of what changed.
        affected_paths: Specific endpoints, tool names, or fields changed.
        affected_repo_slugs: Repos known to consume this interface.
        planned_for: ISO date string (YYYY-MM-DD) if change is pre-announced.
        author: Agent or person creating this record.
    """
    payload = {
        "repo_slug": repo_slug,
        "interface_type": interface_type,
        "change_type": change_type,
        "title": title,
        "description": description,
        "affected_paths": affected_paths or [],
        "affected_repo_slugs": affected_repo_slugs or [],
        "author": author,
    }
    if planned_for:
        payload["planned_for"] = planned_for
    result = _post("/interface-changes/", payload)
    if isinstance(result, dict) and result.get("id"):
        return (
            f"Draft created: {result['title']}\n"
            f"ID: {result['id']}\n"
            f"Repo: {result['repo_slug']} / {result['interface_type']} / {result['change_type']}\n"
            f"Affected repos: {', '.join(result['affected_repo_slugs']) or '(none listed)'}\n"
            f"Status: draft — call publish_interface_change('{result['id']}') when ready."
        )
    return f"Error: {result}"


@mcp.tool()
def list_interface_changes(
    repo_slug: str | None = None,
    status: str | None = None,
    change_type: str | None = None,
    affected_repo: str | None = None,
) -> str:
    """List interface change records with optional filters.

    Args:
        repo_slug: Filter by originating repo.
        status: Filter by status: draft | published | resolved.
        change_type: Filter by change type: breaking | additive | deprecation | removal.
        affected_repo: Return changes that affect this repo slug.
    """
    params: dict = {}
    if repo_slug:
        params["repo_slug"] = repo_slug
    if status:
        params["status"] = status
    if change_type:
        params["change_type"] = change_type
    if affected_repo:
        params["affected_repo"] = affected_repo

    results = _get("/interface-changes/", params if params else None)
    if not isinstance(results, list):
        return f"Error: {results}"
    if not results:
        return "No interface changes found matching the given filters."

    lines = [f"Interface Changes ({len(results)} found):", ""]
    for r in results:
        planned = f" [planned {r['planned_for']}]" if r.get("planned_for") else ""
        pub = f" [published {r['published_at'][:10]}]" if r.get("published_at") else ""
        affected = ", ".join(r["affected_repo_slugs"]) or "(none)"
        lines += [
            f"[{r['status'].upper()}] {r['title']}{planned}{pub}",
            f"  ID: {r['id']}",
            f"  {r['repo_slug']} / {r['interface_type']} / {r['change_type']}",
            f"  Affected: {affected}",
            "",
        ]
    return "\n".join(lines)


@mcp.tool()
def publish_interface_change(change_id: str) -> str:
    """Publish a draft InterfaceChange, making it live and notifying affected agents.

    Transitions status draft → published, sets published_at, sends an inbox
    message to each affected_repo_slug agent, and appends a progress event.

    Args:
        change_id: UUID of the InterfaceChange to publish.
    """
    result = _post(f"/interface-changes/{change_id}/publish", {})
    if isinstance(result, dict) and result.get("status") == "published":
        n = len(result.get("affected_repo_slugs") or [])
        return (
            f"Published: {result['title']}\n"
            f"ID: {result['id']}\n"
            f"Notifications sent to {n} repo(s): "
            f"{', '.join(result['affected_repo_slugs']) or '(none)'}\n"
            f"Resolve with: resolve_interface_change('{result['id']}')"
        )
    return f"Error: {result}"


@mcp.tool()
def resolve_interface_change(change_id: str) -> str:
    """Mark a published InterfaceChange as resolved.

    Call this once all known dependents have adapted. Transitions
    status published → resolved and sets resolved_at.

    Args:
        change_id: UUID of the InterfaceChange to resolve.
    """
    result = _post(f"/interface-changes/{change_id}/resolve", {})
    if isinstance(result, dict) and result.get("status") == "resolved":
        return f"Resolved: {result['title']} (ID: {result['id']})"
    return f"Error: {result}"


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    transport = os.environ.get("MCP_TRANSPORT", "stdio")
    if transport == "stdio":
        mcp.run(transport="stdio")
    else:
        port = int(os.environ.get("MCP_PORT", "8001"))
        mcp.run(transport=transport, host="127.0.0.1", port=port)
