refactor(hub-core): mount capability write router and compose MCP tools

Use create_capability_request_write_router with dev-hub callbacks and attach
generic HubCoreMCPServer tools while keeping enriched local overrides.
This commit is contained in:
2026-06-22 19:52:22 +02:00
parent c421a2a60d
commit 262682cdf0
2 changed files with 130 additions and 453 deletions

View File

@@ -1,8 +1,7 @@
import re
import uuid
from datetime import datetime, timezone
from fastapi import Depends, HTTPException, status
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -26,47 +25,40 @@ from api.schemas.capability_request import (
from hub_core.routers.capabilities import (
create_capability_catalog_router,
create_capability_request_read_router,
)
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
)
router.include_router(
create_capability_request_read_router(
get_session,
domain_model=Domain,
request_model=CapabilityRequest,
request_read_schema=CapabilityRequestRead,
)
create_capability_request_write_router,
)
# ---------------------------------------------------------------------------
# Capability Request endpoints
# Write-router callbacks
# ---------------------------------------------------------------------------
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
async def create_request(
async def _route_capability(
session: AsyncSession,
body: CapabilityRequestCreate,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req_domain = await _resolve_domain(body.requesting_domain, session)
# Route to provider
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability(
session, body.capability_type, body.title, body.description or ""
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
fulfilling_domain_id, catalog_entry_id, routing_note = await _route_capability_match(
session,
body.capability_type,
body.title,
body.description or "",
)
return fulfilling_domain_id, catalog_entry_id, routing_note
req = CapabilityRequest(
def _build_capability_request(
body: CapabilityRequestCreate,
requesting_domain: Domain,
fulfilling_domain_id: uuid.UUID | None,
catalog_entry_id: uuid.UUID | None,
routing_note: str | None,
) -> CapabilityRequest:
return CapabilityRequest(
title=body.title,
description=body.description,
capability_type=body.capability_type,
priority=body.priority,
requesting_domain_id=req_domain.id,
requesting_domain_id=requesting_domain.id,
requesting_agent=body.requesting_agent,
requesting_workplan_id=body.requesting_workplan_id,
blocking_task_id=body.blocking_task_id,
@@ -74,12 +66,17 @@ async def create_request(
catalog_entry_id=catalog_entry_id,
routing_note=routing_note,
)
session.add(req)
await session.flush() # get req.id before creating notification
# Auto-notify
if fulfilling_domain_id:
ful_domain = await session.get(Domain, fulfilling_domain_id)
async def _notify_on_create(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestCreate,
) -> None:
await session.flush()
if req.fulfilling_domain_id:
ful_domain = await session.get(Domain, req.fulfilling_domain_id)
to_agent = ful_domain.slug if ful_domain else "broadcast"
else:
to_agent = "broadcast"
@@ -98,29 +95,16 @@ async def create_request(
),
)
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/accept", response_model=CapabilityRequestRead)
async def accept_request(
request_id: uuid.UUID,
body: CapabilityRequestAccept,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, "accepted")
now = datetime.now(tz=timezone.utc)
req.status = "accepted"
req.fulfilling_agent = body.fulfilling_agent
def _apply_accept_fields(req: CapabilityRequest, body: CapabilityRequestAccept) -> None:
req.fulfilling_workplan_id = body.fulfilling_workplan_id
req.accepted_at = now
# If no fulfilling domain was set by routing, infer from the accepting agent's context
# (The agent can also PATCH it later if needed)
async def _notify_on_accept(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestAccept,
) -> None:
_add_notification(
session,
from_agent=body.fulfilling_agent,
@@ -129,30 +113,14 @@ async def accept_request(
body=f"Your capability request **{req.title}** has been accepted by **{body.fulfilling_agent}**.",
)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
async def patch_request_status(
request_id: uuid.UUID,
async def _on_status_change(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestStatusPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, body.status)
req.status = body.status
if body.note:
req.resolution_note = body.note
now = datetime.now(tz=timezone.utc)
# Status-specific side effects
now: datetime,
) -> None:
if body.status == "completed":
req.completed_at = now
# Auto-unblock the blocking task
if req.blocking_task_id:
task = await session.get(Task, req.blocking_task_id)
if task and task.status == "wait":
@@ -200,23 +168,12 @@ async def patch_request_status(
body=f"Work on capability **{req.title}** is now in progress.",
)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}", response_model=CapabilityRequestRead)
async def patch_request(
request_id: uuid.UUID,
async def _apply_capability_patch(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Correct mutable metadata: catalog_entry_id (re-derives fulfilling domain),
priority, blocking_task_id, fulfilling_workplan_id.
Only fields present in the request body (non-None) are updated.
"""
req = await _get_request_or_404(request_id, session)
) -> bool:
corrections: list[str] = []
if body.catalog_entry_id is not None:
@@ -225,8 +182,6 @@ async def patch_request(
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id
# Re-derive fulfilling domain from catalog entry
old_domain_id = req.fulfilling_domain_id
req.fulfilling_domain_id = entry.domain_id
corrections.append(
f"catalog_entry: {old_entry_id}{entry.id} ({entry.title}); "
@@ -246,44 +201,25 @@ async def patch_request(
corrections.append(f"fulfilling_workplan_id → {body.fulfilling_workplan_id}")
if not corrections:
return req # no-op
return False
correction_note = "hub correction: " + "; ".join(corrections)
req.routing_note = (req.routing_note + "\n" + correction_note) if req.routing_note else correction_note
await session.commit()
await session.refresh(req)
return req
return True
# ---------------------------------------------------------------------------
# Dispute endpoints
# ---------------------------------------------------------------------------
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
async def dispute_request(
request_id: uuid.UUID,
async def _notify_on_dispute(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestDispute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Flag a routing decision as incorrect. Transitions to routing_disputed."""
req = await _get_request_or_404(request_id, session)
_check_transition(req.status, "routing_disputed")
now = datetime.now(tz=timezone.utc)
req.status = "routing_disputed"
req.dispute_reason = body.reason
req.disputed_by = body.disputed_by
req.dispute_suggested_domain = body.suggested_domain
req.disputed_at = now
now: datetime,
) -> None:
dispute_entry = (
f"disputed by {body.disputed_by}: {body.reason}"
+ (f" (suggested: {body.suggested_domain})" if body.suggested_domain else "")
)
req.routing_note = (req.routing_note + "\n" + dispute_entry) if req.routing_note else dispute_entry
# Notify custodian
_add_notification(
session,
from_agent=body.disputed_by,
@@ -297,7 +233,6 @@ async def dispute_request(
+ f"\nCurrently routed to: {req.fulfilling_domain_slug or 'unrouted'}"
),
)
# Notify current fulfilling domain
if req.fulfilling_domain_slug:
_add_notification(
session,
@@ -312,52 +247,13 @@ async def dispute_request(
),
)
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/reroute", response_model=CapabilityRequestRead)
async def reroute_request(
request_id: uuid.UUID,
async def _notify_on_reroute(
session: AsyncSession,
req: CapabilityRequest,
body: CapabilityRequestReroute,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
"""Re-route a disputed request to a new domain. Resets to requested."""
req = await _get_request_or_404(request_id, session)
if req.status != "routing_disputed":
raise HTTPException(
status_code=422,
detail=f"Cannot reroute from status '{req.status}'. Only 'routing_disputed' requests can be rerouted.",
)
if body.catalog_entry_id is None and body.domain is None:
raise HTTPException(status_code=422, detail="Either catalog_entry_id or domain must be provided.")
if body.catalog_entry_id is not None:
entry = await session.get(CapabilityCatalog, body.catalog_entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{body.catalog_entry_id}' not found")
req.catalog_entry_id = entry.id
req.fulfilling_domain_id = entry.domain_id
new_domain_slug = (await session.get(Domain, entry.domain_id)).slug if entry.domain_id else "unknown"
else:
new_domain = await _resolve_domain(body.domain, session)
req.fulfilling_domain_id = new_domain.id
new_domain_slug = new_domain.slug
old_domain = req.dispute_suggested_domain or "unknown"
# Clear dispute fields
req.dispute_reason = None
req.disputed_by = None
req.dispute_suggested_domain = None
req.disputed_at = None
req.status = "requested"
reroute_entry = f"re-routed by {body.rerouted_by}{new_domain_slug}: {body.note}"
req.routing_note = (req.routing_note + "\n" + reroute_entry) if req.routing_note else reroute_entry
# Notify requester
new_domain_slug: str,
) -> None:
_add_notification(
session,
from_agent=body.rerouted_by,
@@ -368,7 +264,6 @@ async def reroute_request(
f"**Note:** {body.note}"
),
)
# Notify new fulfilling domain
_add_notification(
session,
from_agent=body.rerouted_by,
@@ -383,24 +278,20 @@ async def reroute_request(
),
)
await session.commit()
await session.refresh(req)
return req
# ---------------------------------------------------------------------------
# Routing algorithm
# ---------------------------------------------------------------------------
async def _route_capability(
session: AsyncSession, capability_type: str, title: str, description: str
async def _route_capability_match(
session: AsyncSession,
capability_type: str,
title: str,
description: str,
) -> tuple[uuid.UUID | None, uuid.UUID | None, str]:
"""Find the best-matching catalog entry for a capability request.
Returns (domain_id, catalog_entry_id, routing_note).
Uses word-boundary matching on (title + description) combined to avoid
false positives from substring matches (e.g. 'postgres' inside 'postgresql',
'ha' inside 'has').
"""
q = select(CapabilityCatalog).where(
CapabilityCatalog.capability_type == capability_type,
@@ -412,20 +303,19 @@ async def _route_capability(
return None, None, f"no active catalog entries for type '{capability_type}' — broadcast"
if len(entries) == 1:
e = entries[0]
return e.domain_id, e.id, f"single match: '{e.title}' (domain={e.domain_id})"
entry = entries[0]
return entry.domain_id, entry.id, f"single match: '{entry.title}' (domain={entry.domain_id})"
# Score by word-boundary keyword overlap against title + description combined
combined = f"{title} {description or ''}".lower()
scored: list[tuple[int, CapabilityCatalog]] = []
for entry in entries:
keywords = [kw for kw in (entry.keywords or []) if len(kw) >= 3]
score = sum(
1 for kw in keywords
if re.search(r'\b' + re.escape(kw.lower()) + r'\b', combined)
if re.search(r"\b" + re.escape(kw.lower()) + r"\b", combined)
)
scored.append((score, entry))
scored.sort(key=lambda x: -x[0])
scored.sort(key=lambda item: -item[0])
best_score, best = scored[0]
if best_score == 0:
@@ -456,7 +346,6 @@ def _add_notification(
subject: str,
body: str,
) -> None:
"""Create an AgentMessage notification in the current session (no commit)."""
msg = AgentMessage(
from_agent=from_agent,
to_agent=to_agent,
@@ -466,21 +355,6 @@ def _add_notification(
session.add(msg)
async def _resolve_domain(slug: str, session: AsyncSession) -> Domain:
result = await session.execute(select(Domain).where(Domain.slug == slug))
domain = result.scalar_one_or_none()
if domain is None:
raise HTTPException(status_code=404, detail=f"Domain '{slug}' not found")
return domain
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
req = await session.get(CapabilityRequest, request_id)
if req is None:
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
return req
def _check_transition(current: str, target: str) -> None:
can_reach, failures, flow_result = evaluate_transition(
"capability_request",
@@ -500,3 +374,44 @@ def _check_transition(current: str, target: str) -> None:
"flow_result": flow_result_to_dict(flow_result),
},
)
router = create_capability_catalog_router(
get_session,
domain_model=Domain,
repo_model=ManagedRepo,
catalog_model=CapabilityCatalog,
)
router.include_router(
create_capability_request_read_router(
get_session,
domain_model=Domain,
request_model=CapabilityRequest,
request_read_schema=CapabilityRequestRead,
)
)
router.include_router(
create_capability_request_write_router(
get_session,
domain_model=Domain,
catalog_model=CapabilityCatalog,
request_model=CapabilityRequest,
request_create_schema=CapabilityRequestCreate,
request_accept_schema=CapabilityRequestAccept,
request_patch_schema=CapabilityRequestPatch,
request_status_patch_schema=CapabilityRequestStatusPatch,
request_dispute_schema=CapabilityRequestDispute,
request_reroute_schema=CapabilityRequestReroute,
request_read_schema=CapabilityRequestRead,
route_request=_route_capability,
build_request=_build_capability_request,
on_request_persisted=_notify_on_create,
check_transition=_check_transition,
apply_accept_fields=_apply_accept_fields,
after_accept=_notify_on_accept,
after_status_change=_on_status_change,
apply_patch=_apply_capability_patch,
after_dispute=_notify_on_dispute,
after_reroute=_notify_on_reroute,
)
)

View File

@@ -17,6 +17,7 @@ from uuid import UUID
import httpx
from fastmcp import FastMCP
from hub_core.mcp import HubCoreMCPServer
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
@@ -31,6 +32,24 @@ mcp = FastMCP(
),
)
# 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="state-hub",
api_base=API_BASE,
register_tools=False,
).attach_to(mcp, exclude=_HUB_CORE_MCP_EXCLUDE)
# ---------------------------------------------------------------------------
# HTTP helpers
# ---------------------------------------------------------------------------
@@ -2224,124 +2243,10 @@ def get_repo_dispatch(repo_slug: str) -> str:
# ---------------------------------------------------------------------------
# Agent Inbox (inter-agent message passing)
# Capability Catalog & Requests (dev-hub extensions)
# Messaging and catalog CRUD/list tools come from hub_core.mcp.HubCoreMCPServer.
# ---------------------------------------------------------------------------
@mcp.tool()
def send_message(from_agent: str, to_agent: str, subject: str, body: str, thread_id: str | None = None) -> str:
"""Send a message from one agent to another (or 'broadcast' for all).
Use this to coordinate with other Claude instances — e.g. a worker agent
reporting status back to the orchestrator, or the hub agent dispatching
instructions to a domain agent.
Args:
from_agent: Sender identifier (e.g. 'hub', 'marki-docx', 'railiance')
to_agent: Recipient identifier or 'broadcast' for all agents
subject: Short subject line (max 500 chars)
body: Full message body (markdown supported)
thread_id: UUID of the root message to create a thread (optional)
"""
payload: dict = {"from_agent": from_agent, "to_agent": to_agent, "subject": subject, "body": body}
if thread_id:
payload["thread_id"] = thread_id
msg = _post("/messages/", payload)
return json.dumps(msg, indent=2)
@mcp.tool()
def get_messages(to_agent: str | None = None, from_agent: str | None = None, unread_only: bool = False, limit: int = 20) -> str:
"""List messages in the agent inbox.
Call this at session start to check for pending coordination messages.
Args:
to_agent: Filter by recipient (your agent name, or omit for all)
from_agent: Filter by sender (optional)
unread_only: Return only unread messages (default: False)
limit: Maximum number of messages to return (default: 20)
"""
params: dict = {"limit": limit, "unread_only": unread_only}
if to_agent:
params["to_agent"] = to_agent
if from_agent:
params["from_agent"] = from_agent
return json.dumps(_get("/messages/", params), indent=2)
@mcp.tool()
def mark_message_read(message_id: str) -> str:
"""Mark an inbox message as read.
Args:
message_id: UUID of the message to mark as read
"""
return json.dumps(_patch(f"/messages/{message_id}/read", {}), indent=2)
@mcp.tool()
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
"""Reply to a message. Marks the original as read and creates a reply in the same thread.
Args:
message_id: UUID of the message to reply to
from_agent: Your agent identifier
body: Reply body (markdown supported)
"""
return json.dumps(_post(f"/messages/{message_id}/reply", {"from_agent": from_agent, "body": body}), indent=2)
# ---------------------------------------------------------------------------
# Capability Catalog & Requests
# ---------------------------------------------------------------------------
@mcp.tool()
def register_capability(
domain: str,
capability_type: str,
title: str,
description: str | None = None,
keywords: list[str] | None = None,
repo_slug: str | None = None,
) -> str:
"""Register a capability that a domain can provide. Used for routing requests.
Args:
domain: Domain slug (e.g. 'railiance', 'markitect')
capability_type: Category (e.g. 'infrastructure', 'api', 'data', 'security', 'documentation')
title: Short title for this capability
description: Longer description (optional)
keywords: List of keywords for routing (e.g. ['cluster', 'k8s', 'privacy'])
repo_slug: Optional repo slug to attribute this capability to a specific repo
"""
entry = _post("/capability-catalog", {
"domain": domain,
"capability_type": capability_type,
"title": title,
"description": description,
"keywords": keywords or [],
"repo_slug": repo_slug,
})
return json.dumps(entry, indent=2)
@mcp.tool()
def list_capabilities(
domain: str | None = None,
capability_type: str | None = None,
) -> str:
"""Browse the capability catalog — what domains can provide.
Args:
domain: Filter by domain slug (optional)
capability_type: Filter by type (optional)
"""
return json.dumps(_get("/capability-catalog", {
"domain": domain,
"capability_type": capability_type,
}), indent=2)
@mcp.tool()
def get_capability_profile(domain_slug: str | None = None) -> str:
"""Full capability registry: domain → repos (with description) → capabilities.
@@ -2470,48 +2375,6 @@ def request_capability(
return json.dumps(req, indent=2)
@mcp.tool()
def accept_capability_request(
request_id: str,
fulfilling_agent: str,
fulfilling_workstream_id: str | None = None,
) -> str:
"""Accept a capability request. Assigns yourself as the fulfilling agent.
Args:
request_id: UUID of the capability request
fulfilling_agent: Your agent identifier (e.g. 'railiance-worker')
fulfilling_workstream_id: UUID of your workstream for this work (optional)
"""
result = _post(f"/capability-requests/{request_id}/accept", {
"fulfilling_agent": fulfilling_agent,
"fulfilling_workstream_id": fulfilling_workstream_id,
})
return json.dumps(result, indent=2)
@mcp.tool()
def update_capability_request_status(
request_id: str,
status: str,
note: str | None = None,
) -> str:
"""Advance a capability request through its lifecycle.
On 'completed': auto-unblocks the blocking task if one was set.
Args:
request_id: UUID of the capability request
status: in_progress | ready_for_review | completed | rejected | withdrawn
note: Optional note (required for rejection, recommended for completion)
"""
result = _patch(f"/capability-requests/{request_id}/status", {
"status": status,
"note": note,
})
return json.dumps(result, indent=2)
@mcp.tool()
def patch_capability_request(
request_id: str,
@@ -2552,36 +2415,6 @@ def patch_capability_request(
return json.dumps(_patch(f"/capability-requests/{request_id}", body), indent=2)
@mcp.tool()
def list_capability_requests(
domain: str | None = None,
status: str | None = None,
capability_type: str | None = None,
) -> str:
"""List capability requests with optional filters.
Args:
domain: Filter by requesting OR fulfilling domain slug
status: Filter by status (requested/accepted/in_progress/ready_for_review/completed/rejected/withdrawn)
capability_type: Filter by capability type
"""
return json.dumps(_get("/capability-requests", {
"domain": domain,
"status": status,
"capability_type": capability_type,
}), indent=2)
@mcp.tool()
def get_capability_request(request_id: str) -> str:
"""Get a single capability request by ID.
Args:
request_id: UUID of the capability request
"""
return json.dumps(_get(f"/capability-requests/{request_id}"), indent=2)
@mcp.tool()
def dispute_capability_routing(
request_id: str,
@@ -2688,30 +2521,6 @@ def register_service(
}), indent=2)
@mcp.tool()
def list_services(
gdpr_maturity: str | None = None,
category: str | None = None,
pricing_model: str | None = None,
) -> str:
"""Browse the Third-Party Services Catalog (TPSC).
Returns services with their GDPR maturity level and gdpr_warning flag
(True when maturity is unknown, non_compliant, or initial — may limit
use in corporate/GDPR-regulated environments).
Args:
gdpr_maturity: Filter by maturity level (unknown/non_compliant/initial/developing/defined/managed/certified)
category: Filter by category (e.g. 'llm_inference', 'storage')
pricing_model: Filter by pricing model (free/paid/freemium/usage_based/unknown)
"""
return json.dumps(_get("/tpsc/catalog", {
"gdpr_maturity": gdpr_maturity,
"category": category,
"pricing_model": pricing_model,
}), indent=2)
@mcp.tool()
def ingest_tpsc_tool(repo_slug: str) -> str:
"""Ingest tpsc.yaml service dependency declarations for a repo.
@@ -2750,53 +2559,6 @@ def ingest_tpsc_tool(repo_slug: str) -> str:
return output.strip()
@mcp.tool()
def get_gdpr_report() -> str:
"""Get an aggregated GDPR compliance report across all repos' latest TPSC snapshots.
Returns a warning summary for services with gdpr_maturity in:
unknown | non_compliant | initial
These may limit usability in GDPR-regulated / corporate environments.
Services at 'developing' or above have at least a DPA available.
"""
return json.dumps(_get("/tpsc/report/gdpr"), indent=2)
# ---------------------------------------------------------------------------
# Repository Definition of Integrated (DoI)
# ---------------------------------------------------------------------------
@mcp.tool()
def check_repo_doi(repo_slug: str) -> str:
"""Evaluate the 14 DoI criteria for a repo and return a full report.
Criteria are grouped into three tiers:
Core (C1C4): registered, domain, path, remote URL
Standard (C5C9): SCOPE.md, CLAUDE.md, workplan, SBOM, TPSC
Full (C10C14): repo goal, capabilities, agents, clean consistency, host paths
Status values: pass | fail | warn | skip
The 'tier' field shows the highest tier where ALL criteria pass or warn:
none | core | standard | full
Args:
repo_slug: Registered repo slug (e.g. 'llm-connect', 'the-custodian')
"""
return json.dumps(_get(f"/repos/{repo_slug}/doi"), indent=2)
@mcp.tool()
def get_doi_summary() -> str:
"""Return DoI tier for all active repos, sorted worst-first.
Useful at session start to spot repos that need integration work.
Tiers: none (red) → core → standard → full (green).
"""
return json.dumps(_get("/repos/doi/summary"), indent=2)
# ---------------------------------------------------------------------------
# Interactive / ad-hoc task recording
# ---------------------------------------------------------------------------