state-hub/mcp_server/server.py

3221 lines
113 KiB
Python
Raw Normal View History

"""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
2026-05-01 21:27:52 +02:00
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:
2026-08-09 16:19:53 +02:00
return httpx.Client(
base_url=API_BASE,
timeout=30.0,
follow_redirects=True,
trust_env=False,
)
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}"}
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
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}"}
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
2026-06-07 19:30:58 +02:00
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,
}
2026-06-07 19:30:58 +02:00
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)
2026-06-07 19:30:58 +02:00
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
2026-06-07 19:30:58 +02:00
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))
2026-06-07 19:30:58 +02:00
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)
# ---------------------------------------------------------------------------
# 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://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"]
2026-05-02 00:21:14 +02:00
state_summary = _get("/state/summary")
open_workplans = state_summary.get("open_workplans", [])
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})
2026-08-09 16:19:53 +02:00
active_goals = _get("/repo-goals", {"status": "active"})
goals_by_repo: dict[str, list[dict]] = {}
for goal in active_goals:
repo_key = str(goal.get("repo_id") or goal.get("repo_slug") or "")
goals_by_repo.setdefault(repo_key, []).append(goal)
# ── 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"]
2026-08-09 16:19:53 +02:00
repo_goals = goals_by_repo.get(str(repo_id), goals_by_repo.get(repo_slug, []))
if not repo_goals:
continue
2026-08-09 16:19:53 +02:00
active_goal_ids = {g["id"] for g in repo_goals}
2026-08-09 16:19:53 +02:00
for goal in repo_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"],
2026-08-09 16:19:53 +02:00
"active_goal_titles": [g["title"] for g in repo_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
# 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)
2026-05-02 00:21:14 +02:00
@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
2026-05-02 00:21:14 +02:00
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
2026-05-02 00:21:14 +02:00
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",)):
2026-06-07 19:30:58 +02:00
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},
})
2026-06-07 19:30:58 +02:00
if progress_error:
return _json_result(progress_error)
return _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 _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))
# ---------------------------------------------------------------------------
# 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_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,
})
2026-06-07 19:30:58 +02:00
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},
})
2026-06-07 19:30:58 +02:00
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)
2026-06-07 19:30:58 +02:00
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},
})
2026-06-07 19:30:58 +02:00
if progress_error:
return _json_result(progress_error)
2026-06-07 19:30:58 +02:00
return _json_result(task)
2026-06-07 20:11:07 +02:00
@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,
})
2026-06-07 19:30:58 +02:00
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},
})
2026-06-07 19:30:58 +02:00
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,
})
2026-06-07 19:30:58 +02:00
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",
})
2026-06-07 19:30:58 +02:00
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,
})
2026-06-07 19:30:58 +02:00
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")},
})
2026-06-07 19:30:58 +02:00
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(),
})
2026-06-07 19:30:58 +02:00
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},
})
2026-06-07 19:30:58 +02:00
if progress_error:
return _json_result(progress_error)
return _json_result(decision)
CUST-WP-0061-T01: intake work-record entity (stage 3) Fresh hub entity per the founder-reviewed decision (not a suggestions rename-bridge): kind: intake per canon/standards/work-record-types_v0.1.md, lifecycle open -> vetted -> routed -> closed(promoted|declined|absorbed). - api/models/base.py::new_uuid7 -- dependency-free RFC 9562 UUIDv7 generator (48-bit ms timestamp, version/variant bits, random remainder); existing tables keep new_uuid (UUIDv4) unchanged, this is opt-in for new work-record entities per the identity-layering canon - api/models/intake.py: Intake + IntakeNote ORM models, mirroring Decision's shape (topic/workplan/repo scope, lane, status, outcome, promoted_to back-link); CHECK constraints enforce scope-required, closed-requires-outcome, promoted-requires-promoted_to at the DB level - migrations/a7c3e9f1b4d2: intakes + intake_notes tables, 3 enum types - api/routers/intake.py: list/create/get/patch + /route + /close + /notes actions, mirroring decisions.py's pattern (409 on invalid transitions, progress event on close) - api/schemas/intake.py: Pydantic create/update/route/close/note schemas - mcp_server/server.py: create_intake, list_intakes, route_intake, close_intake tool wrappers - tests/test_intake.py: 12 tests against the real Postgres test DB (create/list/scope-validation, full lifecycle incl. 409s and the promoted-requires-promoted_to constraint, notes, UUIDv7 verification) Verified live against the running dev API + DB (not just pytest): applied the migration, restarted the MCP server, and ran a full create -> route -> close cycle over the real REST endpoints. No regressions: full existing suite (test_routers_core, test_suggestions, test_mcp_smoke, test_mcp_write_tools, test_mcp_registration, test_consistency_check, test_consistency_sweep) all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 00:27:45 +02:00
@mcp.tool()
def create_intake(
title: str,
topic_id: str | None = None,
workplan_id: str | None = None,
repo_id: str | None = None,
description: str | None = None,
lane: str = "green",
origin: str | None = None,
origin_ref: str | None = None,
source_repo_path: str | None = None,
) -> str:
"""Record an intake item — a spark: idea, finding, directive, or
request (work-record kind `intake`, canon/standards/
work-record-types_v0.1.md). Lifecycle: open -> vetted -> routed ->
closed(promoted|declined|absorbed).
Args:
title: short description of the intake item
topic_id: optional topic UUID (at least one of topic_id/workplan_id/repo_id required)
workplan_id: optional workplan UUID to scope this to
repo_id: optional managed-repo UUID to scope this to
description: optional longer context
lane: autonomy lane — green | blue | yellow | orange | red
origin: free-text source of the finding (e.g. "mail-triage", "founder-directive")
origin_ref: stable reference into the origin (e.g. a mail-log row id)
source_repo_path: repo-relative path of the file this was authored in, if known
"""
intake = _post("/intakes", {
"title": title,
"topic_id": topic_id,
"workplan_id": workplan_id,
"repo_id": repo_id,
"description": description,
"lane": lane,
"origin": origin,
"origin_ref": origin_ref,
"source_repo_path": source_repo_path,
})
if error := _response_error("create_intake", intake, ("id",)):
return _json_result(error)
return _json_result(intake)
@mcp.tool()
def list_intakes(
topic_id: str | None = None,
workplan_id: str | None = None,
repo_id: str | None = None,
status: str | None = None,
) -> str:
"""List intake items, optionally filtered by scope and/or status.
Args:
topic_id: optional topic UUID
workplan_id: optional workplan UUID
repo_id: optional managed-repo UUID
status: open | vetted | routed | closed
"""
return _json_result(_get("/intakes", {
"topic_id": topic_id,
"workplan_id": workplan_id,
"repo_id": repo_id,
"status_": status,
}))
@mcp.tool()
def route_intake(intake_id: str, routed_note: str | None = None) -> str:
"""Move an intake from open/vetted into routed — the state that makes
it eligible for promotion into a workplan, task, decision, or
engagement.
Args:
intake_id: UUID of the intake item
routed_note: optional note on where/how it should be routed
"""
result = _post(f"/intakes/{intake_id}/route", {"routed_note": routed_note})
if error := _response_error("route_intake", result, ("id",)):
return _json_result(error)
return _json_result(result)
@mcp.tool()
def close_intake(
intake_id: str,
outcome: str,
promoted_to: str | None = None,
note: str | None = None,
) -> str:
"""Close an intake item with an outcome.
Args:
intake_id: UUID of the intake item
outcome: promoted | declined | absorbed
promoted_to: canonical id of the record it became (required if outcome=promoted)
note: optional closing note
"""
result = _post(f"/intakes/{intake_id}/close", {
"outcome": outcome,
"promoted_to": promoted_to,
"note": note,
})
if error := _response_error("close_intake", result, ("id",)):
return _json_result(error)
return _json_result(result)
@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,
})
2026-06-07 19:30:58 +02:00
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 update_workplan_status(workplan_id: str, status: str) -> str:
"""Update a workplan's status.
Args:
workplan_id: UUID of the workplan
2026-05-18 01:31:36 +02:00
status: proposed | ready | active | blocked | backlog | finished | archived
"""
return _update_workplan_status_impl(workplan_id, status, tool_name="update_workplan_status")
2026-06-07 19:30:58 +02:00
@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,
)
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
# ---------------------------------------------------------------------------
# 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
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
Each suggestion includes domain, workplan, task, and a plain-language
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
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).
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
"""
return json.dumps(_get("/state/next_steps"), indent=2)
# ---------------------------------------------------------------------------
# Suggestion backlog — RETIRED (STATE-WP-0079-T05, slice E1)
# ---------------------------------------------------------------------------
# The six suggestion tools (list/create/vet/decline/promote/bump) were removed
# on 2026-08-20. Mutations had 410'd since CUST-WP-0061-T06; the reads existed
# only to keep the historical backlog reachable, and that history is now
# archived at the-custodian/docs/archived-suggestion-backlog.md.
#
# Successor: the intake work-record entity — create_intake / route_intake /
# close_intake, and scripts/promote_intake.py --to task. See
# canon/standards/work-record-types_v0.1.md.
#
# Kept as a comment rather than as 410 stubs: a retired tool that still appears
# in the tool list costs every agent session context on every call, which is the
# opposite of retiring it.
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
# ---------------------------------------------------------------------------
# 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",
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
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,
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
"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,
)
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
@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).
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
The response distinguishes edges where this workplan is the dependent
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
(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.
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
"""
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
]
Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 23:33:14 +01:00
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)
feat(state-hub): implement v0.5 — dynamic domains & multi-repo Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
# ---------------------------------------------------------------------------
# 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.
feat(state-hub): implement v0.5 — dynamic domains & multi-repo Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
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
feat(state-hub): implement v0.5 — dynamic domains & multi-repo Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
"""
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,
)
feat(state-hub): implement v0.5 — dynamic domains & multi-repo Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class `domains` DB table, and adds a `managed_repos` table for multi-repo support per domain. P1 — Domain as a DB entity: - Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain ENUM column to domain_id FK, drops the domain ENUM type - Domain ORM model (api/models/domain.py) + Pydantic schemas - Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/, rename and archive endpoints with EP/TD cascade on rename - Topic model updated: domain_id FK + @property domain_slug for backwards-compatible JSON serialization (field renamed domain → domain_slug) - TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup P2 — Multi-repo support: - ManagedRepo ORM model (api/models/managed_repo.py) + schemas - Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive - Makefile: add-domain, rename-domain, add-repo, list-repos targets - register_project.sh: verify domain via /domains/ API + POST /repos/ P3 — MCP tools & live validation: - 6 new MCP tools: list_domains, create_domain, rename_domain, archive_domain, list_domain_repos, register_repo - EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request DB lookup — returns 422 with list of valid slugs on unknown domain - State summary: adds domains: list[DomainSummary] (slug, name, repo_count, active_workstream_count, ep_count, td_count) - TOOLS.md updated with domain management section P4 — Dashboard: - New domains.md page with KPI row + domain cards + repo lists - domains.json.py + repos.json.py data loaders - Domains page added to observablehq.config.js nav - workstreams.md, extensions.md, techdept.md: domain_slug fix + dynamic domain list loaded from /domains/ API (no longer hardcoded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
@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
# ---------------------------------------------------------------------------
2026-05-01 21:27:52 +02:00
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"/workplans/{ws_id}") if ws_id else None
2026-05-01 21:27:52 +02:00
if not isinstance(ws, dict) or "error" in ws:
existing = _get("/workplans/", {"slug": ws_slug})
2026-05-01 21:27:52 +02:00
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("/workplans", {
2026-05-01 21:27:52 +02:00
"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()
2026-05-01 21:27:52 +02:00
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:
2026-05-01 21:27:52 +02:00
"""Record small opportunistic work as a file-backed Ad Hoc task.
2026-05-01 21:27:52 +02:00
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)
2026-05-01 21:27:52 +02:00
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")
2026-05-01 21:27:52 +02:00
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)
2026-05-01 21:27:52 +02:00
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"],
2026-05-01 21:27:52 +02:00
"workstream_slug": ws["slug"],
"workplan_file": str(wp_file),
"task_block_id": task_block_id,
"title": title,
"token_note": effective_note,
}, indent=2)
2026-05-01 21:27:52 +02:00
@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"))
# Default to loopback: this server is an unauthenticated proxy over the
# API, so it must not land on every interface by accident. In-cluster
# deployment sets MCP_HOST=0.0.0.0 because a Service routes to the pod
# IP, and a loopback bind is unreachable there (CUST-WP-0067-T08).
host = os.environ.get("MCP_HOST", "127.0.0.1")
mcp.run(transport=transport, host=host, port=port)