From f85c5e4d49b750d22b56e44289bf2c366d9c4c68 Mon Sep 17 00:00:00 2001 From: tegwick Date: Thu, 19 Mar 2026 21:07:50 +0100 Subject: [PATCH] feat(capability-requests): add cross-domain capability catalog and request routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a capability catalog (CUST-WP-0022) so domains can advertise what they provide and agents can request capabilities from other domains with auto-routing, lifecycle tracking, and task-unblocking on completion. - New models: CapabilityCatalog, CapabilityRequest with full lifecycle (requested → accepted → in_progress → ready_for_review → completed/rejected/withdrawn) - Migration i6d7e8f9a0b1: capability_catalog + capability_requests tables - Router /capability-catalog and /capability-requests with accept/status endpoints - 7 new MCP tools: register_capability, list_capabilities, request_capability, accept_capability_request, update_capability_request_status, list_capability_requests, get_capability_request - StateSummary gains open_capability_requests count - Dashboard: capability-requests.md page + docs/capabilities.md + docs/scope.md - SCOPE.md: three seed capabilities documented (MCP registration, state tracking, SBOM) - scope.template: Provided Capabilities section with example block - scripts/ingest_capabilities.py + make ingest-capabilities[/-all] targets Co-Authored-By: Claude Sonnet 4.6 (1M context) --- SCOPE.md | 25 ++ state-hub/Makefile | 14 + state-hub/api/main.py | 3 +- state-hub/api/models/__init__.py | 4 + state-hub/api/models/capability_catalog.py | 39 ++ state-hub/api/models/capability_request.py | 93 +++++ state-hub/api/routers/capability_requests.py | 358 ++++++++++++++++++ state-hub/api/routers/state.py | 9 + state-hub/api/schemas/capability_request.py | 79 ++++ state-hub/api/schemas/state.py | 1 + state-hub/dashboard/observablehq.config.js | 3 + .../dashboard/src/capability-requests.md | 250 ++++++++++++ state-hub/dashboard/src/docs/capabilities.md | 228 +++++++++++ state-hub/dashboard/src/docs/scope.md | 177 +++++++++ state-hub/mcp_server/server.py | 167 ++++++++ .../i6d7e8f9a0b1_capability_requests.py | 74 ++++ state-hub/scripts/ingest_capabilities.py | 253 +++++++++++++ .../scripts/project_rules/scope.template | 17 + state-hub/tests/test_capability_requests.py | 337 +++++++++++++++++ workplans/CUST-WP-0022-capability-requests.md | 146 +++++++ 20 files changed, 2276 insertions(+), 1 deletion(-) create mode 100644 state-hub/api/models/capability_catalog.py create mode 100644 state-hub/api/models/capability_request.py create mode 100644 state-hub/api/routers/capability_requests.py create mode 100644 state-hub/api/schemas/capability_request.py create mode 100644 state-hub/dashboard/src/capability-requests.md create mode 100644 state-hub/dashboard/src/docs/capabilities.md create mode 100644 state-hub/dashboard/src/docs/scope.md create mode 100644 state-hub/migrations/versions/i6d7e8f9a0b1_capability_requests.py create mode 100644 state-hub/scripts/ingest_capabilities.py create mode 100644 state-hub/tests/test_capability_requests.py create mode 100644 workplans/CUST-WP-0022-capability-requests.md diff --git a/SCOPE.md b/SCOPE.md index 0f4b1c2..7d7a591 100644 --- a/SCOPE.md +++ b/SCOPE.md @@ -97,6 +97,31 @@ The Custodian is both an **operational system** (State Hub: PostgreSQL + FastAPI --- +## Provided Capabilities + +```capability +type: api +title: MCP tool registration +description: Register and expose new MCP tools to all Claude Code sessions via the state-hub server. +keywords: [mcp, tool, api, registration, server] +``` + +```capability +type: data +title: Cross-domain state tracking +description: Track workstreams, tasks, decisions, and progress events across all six project domains. +keywords: [state, tracking, workstream, task, decision, progress] +``` + +```capability +type: api +title: SBOM and licence reporting +description: Ingest lockfiles from any repo and provide aggregated SBOM and copyleft licence risk reports. +keywords: [sbom, licence, license, dependency, lockfile, copyleft] +``` + +--- + ## Notes Dependency order for domain sequencing: Railiance → Markitect → Coulomb.social → Personhood/Foerster → Custodian. The consistency checker (`make fix-consistency REPO=the-custodian`) must be run after any workplan changes to keep the dashboard accurate. diff --git a/state-hub/Makefile b/state-hub/Makefile index 9016020..7712e70 100644 --- a/state-hub/Makefile +++ b/state-hub/Makefile @@ -169,6 +169,20 @@ ingest-sbom: $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ $(if $(DRY_RUN),--dry-run) +## Ingest capability declarations from SCOPE.md into the catalog. +## Usage: make ingest-capabilities REPO=the-custodian [REPO_PATH=/home/worsch/the-custodian] +## Or: make ingest-capabilities-all +## Add DRY_RUN=1 to preview without writing. +ingest-capabilities: + @test -n "$(REPO)" || (echo "ERROR: REPO is required."; exit 1) + uv run python scripts/ingest_capabilities.py --repo "$(REPO)" \ + $(if $(REPO_PATH),--repo-path "$(REPO_PATH)") \ + $(if $(DRY_RUN),--dry-run) + +ingest-capabilities-all: + uv run python scripts/ingest_capabilities.py --all \ + $(if $(DRY_RUN),--dry-run) + ## Run SBOM capture agent for a repo — generates/updates sbom-tools.yaml. ## Usage: make capture-tools REPO=railiance-infra [REPO_PATH=/home/worsch/railiance-infra] ## Add DRY_RUN=1 to preview without writing. diff --git a/state-hub/api/main.py b/state-hub/api/main.py index e73200f..605bce8 100644 --- a/state-hub/api/main.py +++ b/state-hub/api/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.database import engine from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies -from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages +from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests @asynccontextmanager @@ -47,6 +47,7 @@ app.include_router(repo_goals.router) app.include_router(contributions.router) app.include_router(sbom.router) app.include_router(messages.router) +app.include_router(capability_requests.router) app.include_router(state.router) app.include_router(policy.router) diff --git a/state-hub/api/models/__init__.py b/state-hub/api/models/__init__.py index 4b8a7a5..4b7e6f9 100644 --- a/state-hub/api/models/__init__.py +++ b/state-hub/api/models/__init__.py @@ -15,6 +15,8 @@ from api.models.contribution import Contribution, ContributionType, Contribution from api.models.sbom_snapshot import SBOMSnapshot from api.models.sbom_entry import SBOMEntry, Ecosystem from api.models.agent_message import AgentMessage +from api.models.capability_catalog import CapabilityCatalog +from api.models.capability_request import CapabilityRequest __all__ = [ "Base", @@ -34,4 +36,6 @@ __all__ = [ "SBOMSnapshot", "SBOMEntry", "Ecosystem", "AgentMessage", + "CapabilityCatalog", + "CapabilityRequest", ] diff --git a/state-hub/api/models/capability_catalog.py b/state-hub/api/models/capability_catalog.py new file mode 100644 index 0000000..5f1bcd7 --- /dev/null +++ b/state-hub/api/models/capability_catalog.py @@ -0,0 +1,39 @@ +import uuid + +from sqlalchemy import ARRAY, ForeignKey, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class CapabilityCatalog(Base, TimestampMixin): + __tablename__ = "capability_catalog" + __table_args__ = ( + UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + capability_type: Mapped[str] = mapped_column(String(50), nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + keywords: Mapped[list[str]] = mapped_column( + ARRAY(String), nullable=False, server_default="{}" + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="active", server_default="active" + ) + + domain: Mapped["Domain"] = relationship("Domain", lazy="selectin") # noqa: F821 + + @property + def domain_slug(self) -> str: + return self.domain.slug if self.domain is not None else "" diff --git a/state-hub/api/models/capability_request.py b/state-hub/api/models/capability_request.py new file mode 100644 index 0000000..c890d3e --- /dev/null +++ b/state-hub/api/models/capability_request.py @@ -0,0 +1,93 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, ForeignKey, String, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class CapabilityRequest(Base, TimestampMixin): + __tablename__ = "capability_requests" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + title: Mapped[str] = mapped_column(String(500), nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + capability_type: Mapped[str] = mapped_column(String(50), nullable=False) + priority: Mapped[str] = mapped_column( + String(20), nullable=False, default="medium", server_default="medium" + ) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="requested", server_default="requested" + ) + + # Requester side + requesting_domain_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + requesting_workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workstreams.id", ondelete="SET NULL"), + nullable=True, + ) + requesting_agent: Mapped[str] = mapped_column(String(100), nullable=False) + + # Fulfiller side (populated on accept / auto-route) + fulfilling_domain_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("domains.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + fulfilling_workstream_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workstreams.id", ondelete="SET NULL"), + nullable=True, + ) + fulfilling_agent: Mapped[str | None] = mapped_column(String(100), nullable=True) + + # Links + blocking_task_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("tasks.id", ondelete="SET NULL"), + nullable=True, + ) + catalog_entry_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("capability_catalog.id", ondelete="SET NULL"), + nullable=True, + ) + + resolution_note: Mapped[str | None] = mapped_column(Text, nullable=True) + accepted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + # Relationships + requesting_domain: Mapped["Domain"] = relationship( # noqa: F821 + "Domain", foreign_keys=[requesting_domain_id], lazy="selectin" + ) + fulfilling_domain: Mapped["Domain | None"] = relationship( # noqa: F821 + "Domain", foreign_keys=[fulfilling_domain_id], lazy="selectin" + ) + blocking_task: Mapped["Task | None"] = relationship("Task", lazy="selectin") # noqa: F821 + catalog_entry: Mapped["CapabilityCatalog | None"] = relationship( # noqa: F821 + "CapabilityCatalog", lazy="selectin" + ) + + @property + def requesting_domain_slug(self) -> str: + return self.requesting_domain.slug if self.requesting_domain else "" + + @property + def fulfilling_domain_slug(self) -> str | None: + return self.fulfilling_domain.slug if self.fulfilling_domain else None diff --git a/state-hub/api/routers/capability_requests.py b/state-hub/api/routers/capability_requests.py new file mode 100644 index 0000000..fda2b3d --- /dev/null +++ b/state-hub/api/routers/capability_requests.py @@ -0,0 +1,358 @@ +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.agent_message import AgentMessage +from api.models.capability_catalog import CapabilityCatalog +from api.models.capability_request import CapabilityRequest +from api.models.domain import Domain +from api.models.task import Task +from api.schemas.capability_request import ( + CatalogCreate, + CatalogRead, + CapabilityRequestAccept, + CapabilityRequestCreate, + CapabilityRequestRead, + CapabilityRequestStatusPatch, +) + +router = APIRouter(tags=["capability-requests"]) + +# --------------------------------------------------------------------------- +# Lifecycle guard +# --------------------------------------------------------------------------- + +_VALID_TRANSITIONS: dict[str, set[str]] = { + "requested": {"accepted", "rejected", "withdrawn"}, + "accepted": {"in_progress", "rejected", "withdrawn"}, + "in_progress": {"ready_for_review", "rejected", "withdrawn"}, + "ready_for_review": {"completed", "in_progress", "withdrawn"}, + "completed": set(), + "rejected": set(), + "withdrawn": set(), +} + + +# --------------------------------------------------------------------------- +# Capability Catalog endpoints +# --------------------------------------------------------------------------- + +@router.post("/capability-catalog/", response_model=CatalogRead, status_code=status.HTTP_201_CREATED) +async def create_catalog_entry( + body: CatalogCreate, + session: AsyncSession = Depends(get_session), +) -> CapabilityCatalog: + domain = await _resolve_domain(body.domain, session) + entry = CapabilityCatalog( + domain_id=domain.id, + capability_type=body.capability_type, + title=body.title, + description=body.description, + keywords=body.keywords, + ) + session.add(entry) + try: + await session.commit() + except Exception: + await session.rollback() + raise HTTPException( + status_code=409, + detail=f"Catalog entry '{body.title}' for type '{body.capability_type}' already exists in domain '{body.domain}'", + ) + await session.refresh(entry) + return entry + + +@router.get("/capability-catalog/", response_model=list[CatalogRead]) +async def list_catalog( + domain: str | None = Query(None), + capability_type: str | None = Query(None), + status_filter: str | None = Query(None, alias="status"), + session: AsyncSession = Depends(get_session), +) -> list[CapabilityCatalog]: + q = select(CapabilityCatalog).order_by(CapabilityCatalog.created_at.desc()) + if domain: + d = await _resolve_domain(domain, session) + q = q.where(CapabilityCatalog.domain_id == d.id) + if capability_type: + q = q.where(CapabilityCatalog.capability_type == capability_type) + if status_filter and status_filter != "all": + q = q.where(CapabilityCatalog.status == status_filter) + elif not status_filter: + q = q.where(CapabilityCatalog.status == "active") + result = await session.execute(q) + return list(result.scalars().all()) + + +# --------------------------------------------------------------------------- +# Capability Request endpoints +# --------------------------------------------------------------------------- + +@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED) +async def create_request( + 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 = await _route_capability( + session, body.capability_type, body.description or "" + ) + + req = CapabilityRequest( + title=body.title, + description=body.description, + capability_type=body.capability_type, + priority=body.priority, + requesting_domain_id=req_domain.id, + requesting_agent=body.requesting_agent, + requesting_workstream_id=body.requesting_workstream_id, + blocking_task_id=body.blocking_task_id, + fulfilling_domain_id=fulfilling_domain_id, + catalog_entry_id=catalog_entry_id, + ) + 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) + to_agent = ful_domain.slug if ful_domain else "broadcast" + else: + to_agent = "broadcast" + + _add_notification( + session, + from_agent="system", + to_agent=to_agent, + subject=f"[capability-request] {body.title}", + body=( + f"New capability request from **{body.requesting_agent}** " + f"({body.requesting_domain}):\n\n" + f"**Type:** {body.capability_type}\n" + f"**Priority:** {body.priority}\n\n" + f"{body.description or '(no description)'}" + ), + ) + + await session.commit() + await session.refresh(req) + return req + + +@router.get("/capability-requests/", response_model=list[CapabilityRequestRead]) +async def list_requests( + domain: str | None = Query(None, description="Filter by requesting OR fulfilling domain slug"), + status_filter: str | None = Query(None, alias="status"), + capability_type: str | None = Query(None), + session: AsyncSession = Depends(get_session), +) -> list[CapabilityRequest]: + q = select(CapabilityRequest).order_by(CapabilityRequest.created_at.desc()) + if domain: + d = await _resolve_domain(domain, session) + q = q.where( + (CapabilityRequest.requesting_domain_id == d.id) + | (CapabilityRequest.fulfilling_domain_id == d.id) + ) + if status_filter: + q = q.where(CapabilityRequest.status == status_filter) + if capability_type: + q = q.where(CapabilityRequest.capability_type == capability_type) + result = await session.execute(q) + return list(result.scalars().all()) + + +@router.get("/capability-requests/{request_id}", response_model=CapabilityRequestRead) +async def get_request( + request_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> CapabilityRequest: + return await _get_request_or_404(request_id, session) + + +@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 + req.fulfilling_workstream_id = body.fulfilling_workstream_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) + + _add_notification( + session, + from_agent=body.fulfilling_agent, + to_agent=req.requesting_agent, + subject=f"[capability-accepted] {req.title}", + 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, + 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 + 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 == "blocked": + task.status = "todo" + task.blocking_reason = None + + _add_notification( + session, + from_agent="system", + to_agent=req.requesting_agent, + subject=f"[capability-completed] {req.title}", + body=( + f"Capability request **{req.title}** has been completed.\n\n" + f"{body.note or ''}" + ), + ) + elif body.status == "ready_for_review": + _add_notification( + session, + from_agent=req.fulfilling_agent or "system", + to_agent=req.requesting_agent, + subject=f"[capability-ready] {req.title} -- please review", + body=( + f"Capability **{req.title}** is ready for your review and optimization.\n\n" + f"{body.note or ''}" + ), + ) + elif body.status == "rejected": + _add_notification( + session, + from_agent=req.fulfilling_agent or "system", + to_agent=req.requesting_agent, + subject=f"[capability-rejected] {req.title}", + body=( + f"Capability request **{req.title}** has been rejected.\n\n" + f"**Reason:** {body.note or '(no reason given)'}" + ), + ) + elif body.status == "in_progress": + _add_notification( + session, + from_agent=req.fulfilling_agent or "system", + to_agent=req.requesting_agent, + subject=f"[capability-in-progress] {req.title}", + body=f"Work on capability **{req.title}** is now in progress.", + ) + + await session.commit() + await session.refresh(req) + return req + + +# --------------------------------------------------------------------------- +# Routing algorithm +# --------------------------------------------------------------------------- + +async def _route_capability( + session: AsyncSession, capability_type: str, description: str +) -> tuple[uuid.UUID | None, uuid.UUID | None]: + """Find the best-matching domain for a capability request. + + Returns (domain_id, catalog_entry_id) or (None, None) for broadcast. + """ + q = select(CapabilityCatalog).where( + CapabilityCatalog.capability_type == capability_type, + CapabilityCatalog.status == "active", + ) + entries = list((await session.execute(q)).scalars().all()) + + if len(entries) == 1: + return entries[0].domain_id, entries[0].id + + if len(entries) > 1 and description: + desc_lower = description.lower() + scored: list[tuple[int, CapabilityCatalog]] = [] + for entry in entries: + score = sum(1 for kw in (entry.keywords or []) if kw.lower() in desc_lower) + scored.append((score, entry)) + scored.sort(key=lambda x: -x[0]) + if scored[0][0] > 0 and (len(scored) < 2 or scored[0][0] > scored[1][0]): + return scored[0][1].domain_id, scored[0][1].id + + return None, None + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _add_notification( + session: AsyncSession, + from_agent: str, + to_agent: str, + 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, + subject=subject, + body=body, + ) + 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: + allowed = _VALID_TRANSITIONS.get(current, set()) + if target not in allowed: + raise HTTPException( + status_code=422, + detail=( + f"Cannot transition from '{current}' to '{target}'. " + f"Allowed: {sorted(allowed) or 'none (terminal state)'}" + ), + ) diff --git a/state-hub/api/routers/state.py b/state-hub/api/routers/state.py index 715bb3f..f890450 100644 --- a/state-hub/api/routers/state.py +++ b/state-hub/api/routers/state.py @@ -6,6 +6,7 @@ from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session, engine +from api.models.capability_request import CapabilityRequest from api.models.contribution import Contribution, ContributionStatus, ContributionType from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.domain import Domain @@ -204,6 +205,13 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm if lic and any(pat in lic.upper() for pat in _COPYLEFT_PATS) ) + # Open capability requests (non-terminal statuses) + open_cap_req_count = (await session.execute( + select(func.count()).select_from(CapabilityRequest).where( + CapabilityRequest.status.in_(["requested", "accepted", "in_progress", "ready_for_review"]) + ) + )).scalar() or 0 + return StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -215,6 +223,7 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm domains=domain_summaries, contribution_counts=contribution_counts, licence_risk_count=licence_risk_count, + open_capability_requests=open_cap_req_count, open_workstreams=[ WorkstreamWithDeps( **WorkstreamRead.model_validate(w).model_dump(), diff --git a/state-hub/api/schemas/capability_request.py b/state-hub/api/schemas/capability_request.py new file mode 100644 index 0000000..e1f01f1 --- /dev/null +++ b/state-hub/api/schemas/capability_request.py @@ -0,0 +1,79 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +# --------------------------------------------------------------------------- +# Capability Catalog schemas +# --------------------------------------------------------------------------- + +class CatalogCreate(BaseModel): + domain: str # slug, resolved to domain_id in router + capability_type: str + title: str + description: str | None = None + keywords: list[str] = [] + + +class CatalogRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + domain_slug: str + capability_type: str + title: str + description: str | None = None + keywords: list[str] = [] + status: str + created_at: datetime + updated_at: datetime + + +# --------------------------------------------------------------------------- +# Capability Request schemas +# --------------------------------------------------------------------------- + +class CapabilityRequestCreate(BaseModel): + title: str + description: str | None = None + capability_type: str + priority: str = "medium" + requesting_domain: str # slug, resolved to domain_id in router + requesting_agent: str + requesting_workstream_id: uuid.UUID | None = None + blocking_task_id: uuid.UUID | None = None + + +class CapabilityRequestAccept(BaseModel): + fulfilling_agent: str + fulfilling_workstream_id: uuid.UUID | None = None + + +class CapabilityRequestStatusPatch(BaseModel): + status: str # in_progress | ready_for_review | completed | rejected | withdrawn + note: str | None = None + + +class CapabilityRequestRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: uuid.UUID + title: str + description: str | None = None + capability_type: str + priority: str + status: str + requesting_domain_slug: str + requesting_agent: str + requesting_workstream_id: uuid.UUID | None = None + fulfilling_domain_slug: str | None = None + fulfilling_agent: str | None = None + fulfilling_workstream_id: uuid.UUID | None = None + blocking_task_id: uuid.UUID | None = None + catalog_entry_id: uuid.UUID | None = None + resolution_note: str | None = None + accepted_at: datetime | None = None + completed_at: datetime | None = None + created_at: datetime + updated_at: datetime diff --git a/state-hub/api/schemas/state.py b/state-hub/api/schemas/state.py index 6d49e6d..5f21777 100644 --- a/state-hub/api/schemas/state.py +++ b/state-hub/api/schemas/state.py @@ -79,3 +79,4 @@ class StateSummary(BaseModel): domains: list[DomainSummary] = [] contribution_counts: dict[str, int] = {} licence_risk_count: int = 0 + open_capability_requests: int = 0 diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js index a6159f5..f975898 100644 --- a/state-hub/dashboard/observablehq.config.js +++ b/state-hub/dashboard/observablehq.config.js @@ -19,6 +19,7 @@ export default { pages: [ // ── Pages (Overview first, then alphabetical) ──────────────────────────── { name: "Overview", path: "/" }, + { name: "Capabilities", path: "/capability-requests" }, { name: "Contributions", path: "/contributions" }, { name: "Domains", path: "/domains" }, { name: "Goals", path: "/goals" }, @@ -66,6 +67,7 @@ export default { collapsible: true, open: false, pages: [ + { name: "Capabilities", path: "/docs/capabilities" }, { name: "Connecting to the Hub", path: "/docs/connecting" }, { name: "Contributions", path: "/docs/contributions" }, { name: "Decision Health", path: "/docs/decisions-kpi" }, @@ -84,6 +86,7 @@ export default { { name: "Repo Integration", path: "/docs/repo-integration" }, { name: "Repos", path: "/docs/repos" }, { name: "SBOM", path: "/docs/sbom" }, + { name: "SCOPE.md", path: "/docs/scope" }, { name: "Tasks", path: "/docs/tasks" }, { name: "Technical Debt", path: "/docs/debt" }, { name: "Todo", path: "/docs/todo" }, diff --git a/state-hub/dashboard/src/capability-requests.md b/state-hub/dashboard/src/capability-requests.md new file mode 100644 index 0000000..f7782d2 --- /dev/null +++ b/state-hub/dashboard/src/capability-requests.md @@ -0,0 +1,250 @@ +--- +title: Capability Requests +--- + +```js +import {API} from "./components/config.js"; +const POLL = 30_000; +``` + +```js +// Live poll for capability requests +const reqState = (async function*() { + while (true) { + let data = [], ok = false; + try { + const r = await fetch(`${API}/capability-requests/`); + ok = r.ok; + data = ok ? await r.json() : []; + } catch {} + yield {data, ok, ts: new Date()}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const requests = reqState.data ?? []; +const _ok = reqState.ok ?? false; +const _ts = reqState.ts; +``` + +# Capability Requests + +```js +import {injectTocTop} from "./components/toc-sidebar.js"; +import {withDocHelp} from "./components/doc-overlay.js"; + +const _liveEl = html`
+ + ${_ok ? `Live · ${_ts?.toLocaleTimeString()}` : html`API offline`} +
`; +withDocHelp(_liveEl, "/docs/live-data"); +injectTocTop("live-indicator", _liveEl); + +const _h1 = document.querySelector("#observablehq-main h1"); +if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/capabilities"); } +``` + +```js +// KPI sidebar +const open = requests.filter(r => ["requested","accepted","in_progress","ready_for_review"].includes(r.status)); +const completed = requests.filter(r => r.status === "completed"); +const avgFulfill = completed.length > 0 + ? (completed.reduce((s, r) => s + (new Date(r.completed_at) - new Date(r.created_at)), 0) / completed.length / 86400000).toFixed(1) + : "—"; +const critical = open.filter(r => r.priority === "critical" || r.priority === "high").length; + +const kpiEl = html`
+
Capability Requests
+
+ Open${open.length} + Avg fulfill${avgFulfill}d + High/Critical${critical} + Total${requests.length} +
+
`; +injectTocTop("cap-req-kpi", kpiEl); +``` + +```js +// Filters +const typeFilter = Inputs.select( + ["all", ...new Set(requests.map(r => r.capability_type))], + {label: "Type", value: "all"} +); +const statFilter = Inputs.select( + ["all", "requested", "accepted", "in_progress", "ready_for_review", "completed", "rejected", "withdrawn"], + {label: "Status", value: "all"} +); +const domFilter = Inputs.select( + ["all", ...new Set([...requests.map(r => r.requesting_domain_slug), ...requests.map(r => r.fulfilling_domain_slug).filter(Boolean)])], + {label: "Domain", value: "all"} +); +display(html`
+ ${typeFilter}${statFilter}${domFilter} +
`); +``` + +```js +const tf = typeFilter.value; +const sf = statFilter.value; +const df = domFilter.value; + +const filtered = requests.filter(r => + (tf === "all" || r.capability_type === tf) && + (sf === "all" || r.status === sf) && + (df === "all" || r.requesting_domain_slug === df || r.fulfilling_domain_slug === df) +); +``` + +## Summary + +```js +const priorityColors = {critical: "#e53935", high: "orange", medium: "steelblue", low: "#aaa"}; +display(html`
+

Requested

${requests.filter(r => r.status === "requested").length}

+

In Progress

${requests.filter(r => ["accepted","in_progress"].includes(r.status)).length}

+

Ready for Review

${requests.filter(r => r.status === "ready_for_review").length}

+

Completed

${completed.length}

+
`); +``` + +## Status Kanban + +```js +const statusCols = [ + {key: "requested", label: "Requested", color: "steelblue"}, + {key: "accepted", label: "Accepted", color: "#f0a500"}, + {key: "in_progress", label: "In Progress", color: "#2196f3"}, + {key: "ready_for_review", label: "Ready for Review", color: "#4caf50"}, + {key: "completed", label: "Completed", color: "#2e7d32"}, + {key: "rejected", label: "Rejected", color: "#e53935"}, + {key: "withdrawn", label: "Withdrawn", color: "#bbb"}, +]; + +const colMap = {}; +for (const r of filtered) { + (colMap[r.status] = colMap[r.status] ?? []).push(r); +} + +const activeCols = statusCols.filter(s => colMap[s.key]?.length); +if (activeCols.length === 0) { + display(html`

No capability requests match the current filters.

`); +} else { + const ageDays = (r) => ((Date.now() - new Date(r.created_at)) / 86400000).toFixed(0); + display(html`
+ ${activeCols.map(s => html` +
+
${s.label} ${colMap[s.key].length}
+ ${colMap[s.key].map(r => html` +
+
${r.capability_type}
+
${r.priority}
+
${r.title}
+
+ ${r.requesting_domain_slug} + ${r.fulfilling_domain_slug ? html` → ${r.fulfilling_domain_slug}` : html` → unassigned`} +
+
${ageDays(r)}d old
+
+ `)} +
+ `)} +
`); +} +``` + +## All Requests + +```js +display(Inputs.table(filtered.map(r => ({ + Type: r.capability_type, + Title: r.title, + Priority: r.priority, + Status: r.status, + Requester: r.requesting_domain_slug, + Provider: r.fulfilling_domain_slug ?? "—", + Agent: r.requesting_agent, + Created: new Date(r.created_at).toLocaleDateString(), +})), {maxWidth: 1000})); +``` + +--- + +## Capability Catalog + +```js +// Live poll for catalog entries +const catalogState = (async function*() { + while (true) { + let data = []; + try { + const r = await fetch(`${API}/capability-catalog/?status=all`); + if (r.ok) data = await r.json(); + } catch {} + yield data; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const catalog = catalogState ?? []; +``` + +```js +if (catalog.length === 0) { + display(html`

No capabilities registered yet. Add ```capability blocks to SCOPE.md files and run make ingest-capabilities-all.

`); +} else { + // Group by domain + const byDomain = {}; + for (const c of catalog) { + (byDomain[c.domain_slug] = byDomain[c.domain_slug] ?? []).push(c); + } + const typeColors = { + infrastructure: "#e65100", api: "#1565c0", data: "#2e7d32", + security: "#c62828", documentation: "#6a1b9a", other: "#888" + }; + display(html`
+ ${Object.entries(byDomain).sort((a, b) => a[0].localeCompare(b[0])).map(([domain, caps]) => html` +
+
${domain} ${caps.length}
+ ${caps.map(c => html` +
+
${c.capability_type}
+
${c.title}
+ ${c.description ? html`
${c.description}
` : ""} + ${c.keywords?.length ? html`
${c.keywords.map(k => html`${k}`)}
` : ""} +
+ `)} +
+ `)} +
`); +} +``` + + diff --git a/state-hub/dashboard/src/docs/capabilities.md b/state-hub/dashboard/src/docs/capabilities.md new file mode 100644 index 0000000..8b1893c --- /dev/null +++ b/state-hub/dashboard/src/docs/capabilities.md @@ -0,0 +1,228 @@ +--- +title: Capabilities — Reference +--- + +# Capabilities — Reference + +The Capability Requests page shows cross-domain provisioning requests — a +decoupled mechanism for one domain to request something that another domain is +responsible for, without needing to know *who* is responsible. + +--- + +## What is a capability? + +A **capability** is something a domain can provide to the broader ecosystem — +infrastructure provisioning, API endpoints, security tooling, documentation, +data pipelines, etc. Capabilities are registered in the **capability catalog** +so the system knows which domain provides what. + +A **capability request** is a structured declaration from a requester +("I need X") that the system routes to the right provider automatically. + +--- + +## Capability catalog + +The catalog is the routing backbone. Each entry registers one thing a domain +can provide. + +**Origin of truth: SCOPE.md** — following ADR-001, capability declarations +live in each repo's `SCOPE.md` file under the `## Provided Capabilities` +section. The state-hub catalog table is a derived index, reconstructable from +repo files via `make ingest-capabilities-all`. + +### SCOPE.md capability blocks + +Add fenced `capability` blocks to your repo's SCOPE.md: + +````markdown +## Provided Capabilities + +```capability +type: infrastructure +title: Cluster provisioning +description: Provision k8s clusters and managed instances for any domain. +keywords: [cluster, k8s, privacy, instance] +``` +```` + +| Field | Purpose | +|-------|---------| +| **type** | Category — `infrastructure`, `api`, `data`, `security`, `documentation`, `other` | +| **title** | Short name (unique within domain + type) | +| **description** | What this capability provides, in one or two sentences | +| **keywords** | Routing hints matched against request descriptions | + +### Ingesting into the catalog + +```bash +make ingest-capabilities REPO=the-custodian # single repo +make ingest-capabilities-all # all registered repos +make ingest-capabilities REPO=railiance-infra DRY_RUN=1 # preview +``` + +The ingest script reads `SCOPE.md` → parses `capability` blocks → upserts into +the `capability_catalog` table via the API. Existing entries (same domain + type ++ title) are skipped. + +### Browsing the catalog + +Via MCP: +``` +list_capabilities(domain="railiance") +``` + +Via API: +``` +GET /capability-catalog/?domain=railiance +``` + +The catalog is also shown at the bottom of the Capabilities dashboard page, +grouped by domain with type badges and keyword tags. + +--- + +## Routing algorithm + +When a request is created, the system auto-routes it: + +1. **Exact type match** — find catalog entries where `capability_type` matches +2. **Single match** — auto-assign the providing domain +3. **Multiple matches** — keyword-score the request description against each entry's keywords; pick the winner if unambiguous +4. **No match or tie** — leave the provider unassigned and **broadcast** a notification to all domains so one can claim it + +This means the requester never needs to know which domain owns a capability. + +--- + +## Request lifecycle + +``` +requested → accepted → in_progress → ready_for_review → completed + ↓ ↓ ↓ ↓ + withdrawn rejected withdrawn withdrawn + withdrawn +``` + +| Status | Meaning | +|--------|---------| +| **requested** | Need declared; routed (or broadcast) to provider | +| **accepted** | Provider acknowledged and claimed the request | +| **in_progress** | Provider is actively working on it | +| **ready_for_review** | Provider finished; requester should review and optimise | +| **completed** | Requester confirmed; capability is live | +| **rejected** | Provider cannot or will not fulfil the request | +| **withdrawn** | Requester cancelled the request | + +Transitions are enforced by the API — you cannot skip stages. Terminal states +(`completed`, `rejected`, `withdrawn`) allow no further transitions. + +--- + +## Auto-notifications + +Every lifecycle transition creates an **AgentMessage** atomically: + +| Transition | Notification to | +|------------|----------------| +| **requested** | Provider domain agent (or `broadcast` if unrouted) | +| **accepted** | Requesting agent | +| **in_progress** | Requesting agent | +| **ready_for_review** | Requesting agent | +| **completed** | Requesting agent | +| **rejected** | Requesting agent (with reason) | + +Notifications appear in the [Inbox](/inbox) page and are queryable via +`get_messages(to_agent="")`. + +--- + +## Auto-unblock + +A request can optionally link to a **blocking task** via `blocking_task_id`. +When the request reaches `completed`, the system automatically patches that +task from `blocked` → `todo` and clears its `blocking_reason`. This means +blocked work resumes without manual intervention. + +--- + +## Creating a request + +Via MCP: + +``` +request_capability( + title = "Privacy idea instance on cluster", + description = "Need a privacy idea instance provisioned on the k8s cluster", + capability_type = "infrastructure", + requesting_agent = "net-kingdom-worker", + requesting_domain = "custodian", + requesting_workstream_id = "", # optional + priority = "high", # low | medium | high | critical + blocking_task_id = "" # optional — auto-unblocked on completion +) +``` + +The system routes this to `railiance` (if a matching catalog entry exists), +creates an AgentMessage notification, and returns the request with +`fulfilling_domain_slug: "railiance"`. + +--- + +## Accepting and fulfilling + +The provider agent checks their inbox, sees the request, and accepts: + +``` +accept_capability_request( + request_id = "", + fulfilling_agent = "railiance-worker", + fulfilling_workstream_id = "" # optional +) +``` + +Then advances through the lifecycle: + +``` +update_capability_request_status(request_id, "in_progress") +update_capability_request_status(request_id, "ready_for_review", note="Instance up at 10.0.1.42") +``` + +The requester reviews and completes: + +``` +update_capability_request_status(request_id, "completed", note="Verified, looks good") +``` + +--- + +## Dashboard + +The Capabilities page shows: + +- **KPI sidebar** — open count, average fulfillment time, high/critical count +- **Summary cards** — requested, in progress, ready for review, completed +- **Kanban board** — cards grouped by status column +- **Table** — all requests with filters by type, status, and domain + +Each card shows the capability type, priority, requester → provider domains, +and age in days. + +--- + +## Relation to other concepts + +| Concept | Relationship | +|---------|-------------| +| **SCOPE.md** | Defines what a repo *is responsible for* — the catalog registers what it *can provide* | +| **Dependencies** | Workstream-to-workstream edges — capabilities are higher-level, domain-to-domain | +| **Extension Points** | Design forks for *future* enhancement — capabilities are *operational* requests | +| **Contributions** | Outbound upstream work — capabilities are *inbound* requests between internal domains | +| **Human Interventions** | Flagged tasks for Bernd — capabilities are agent-to-agent coordination | + +--- + +*Capability requests are a sanctioned write use case of the State Hub alongside +`resolve_decision` and `get_next_steps`. They do not originate in workplan files — +they are operational coordination.* diff --git a/state-hub/dashboard/src/docs/scope.md b/state-hub/dashboard/src/docs/scope.md new file mode 100644 index 0000000..fcef9bf --- /dev/null +++ b/state-hub/dashboard/src/docs/scope.md @@ -0,0 +1,177 @@ +--- +title: SCOPE.md — Reference +--- + +# SCOPE.md — Reference + +SCOPE.md is a lightweight, strategic orientation artifact placed at the root of +every registered repository. It helps humans and agents quickly understand what +a repo is about, when it is relevant, and where it fits in the ecosystem. + +--- + +## What SCOPE.md is + +SCOPE.md answers these questions **in under 60 seconds**: + +- What is this repository for? +- Should I care about it right now? +- When is it relevant to my work? +- Where does it fit in the ecosystem? +- Is it mature enough to trust or reuse? +- Does it overlap with something else? + +It is **not** a README, not architecture documentation, and not marketing text. +It is a pragmatic, scannable boundary definition. + +--- + +## Template structure + +Every SCOPE.md follows an 11-section template: + +| Section | Purpose | +|---------|---------| +| **One-liner** | One precise sentence describing the repo's purpose | +| **Core Idea** | Main capability and what problem it solves | +| **In Scope** | What the repo is explicitly responsible for — concrete, not vague | +| **Out of Scope** | What it deliberately does NOT do (often more important) | +| **Relevant When** | Real usage scenarios when someone should consider this repo | +| **Not Relevant When** | When someone should look elsewhere | +| **Current State** | Maturity indicators: status, implementation, stability, usage | +| **How It Fits** | Upstream dependencies, downstream consumers, often-used-with | +| **Terminology** | Domain terms, potential confusions with similar concepts | +| **Related / Overlapping** | Repos with similar or adjacent responsibilities | +| **Provided Capabilities** | What this repo's domain can provide to others on request | +| **Getting Oriented** | Entry points, key files, where to start | + +The template is at `state-hub/scripts/project_rules/scope.template`. + +--- + +## Current State indicators + +The Current State section uses four axes: + +| Axis | Values | +|------|--------| +| **Status** | concept / experimental / active / stable / deprecated | +| **Implementation** | idea / partial / substantial / complete | +| **Stability** | unstable / evolving / stable | +| **Usage** | none / personal / internal / production | + +These help an agent decide whether to depend on, extend, or avoid a repo +without needing to read its full codebase. + +--- + +## Design principles + +- **Intentionally short and scannable** — not comprehensive documentation +- **Pragmatic** — real usage scenarios, not ideals +- **Easy to maintain** — update when scope changes, not on every commit +- **Direct language** — no filler, no marketing, no invented features +- **Honest about gaps** — if something is incomplete or unstable, say so + +**Anti-goals:** +- No long prose or verbose explanations +- No repetition of README content +- No hiding ambiguity behind vague language +- No assumption of production readiness + +--- + +## How SCOPE.md is created + +### New repositories + +When a repo is registered via `make register-project`, the scaffold copies +`scope.template` → `SCOPE.md` at the repo root. The human or an agent then +populates the sections from the repo's actual state. + +### Existing repositories + +The **scope-analyst** kaizen agent persona can be loaded to generate or refine +a SCOPE.md: + +``` +get_kaizen_agent("scope-analyst") +``` + +This agent reads the repo's codebase, existing documentation, and CLAUDE.md +to produce a SCOPE.md that accurately reflects the current state. + +--- + +## Ecosystem coverage + +SCOPE.md files exist across all custodian domains: + +| Domain | Repos with SCOPE.md | +|--------|-------------------| +| **custodian** | the-custodian, kaizen-agentic, ops-bridge, activity-core | +| **custodian** (netkingdom) | net-kingdom, key-cape | +| **railiance** | railiance-apps, railiance-cluster, railiance-enablement, railiance-infra, railiance-platform | +| **markitect** | markitect_project | + +--- + +## Provided Capabilities section + +The `## Provided Capabilities` section uses fenced `capability` blocks that +are machine-readable and ingested into the state-hub capability catalog: + +````markdown +```capability +type: infrastructure +title: Cluster provisioning +description: Provision k8s clusters and managed instances for any domain. +keywords: [cluster, k8s, privacy, instance] +``` +```` + +| Field | Required | Purpose | +|-------|----------|---------| +| **type** | yes | Category: `infrastructure`, `api`, `data`, `security`, `documentation`, `other` | +| **title** | yes | Short name (unique within domain + type) | +| **description** | no | What this capability provides | +| **keywords** | no | Routing hints for auto-matching capability requests | + +The ingest script (`make ingest-capabilities-all`) parses these blocks from all +registered repos and populates the state-hub catalog table. This follows +ADR-001: **files are the origin of truth, DB is cache/index**. + +--- + +## Relation to capabilities + +SCOPE.md is both the **human-readable boundary definition** and the **origin of +truth for the capability catalog**: + +| | SCOPE.md | Capability Catalog (DB) | +|-|----------|------------------------| +| **Role** | Origin of truth | Derived index | +| **Granularity** | Per-repository | Per-domain (aggregated from repo files) | +| **Purpose** | "What is this repo?" + "What can it provide?" | Routing engine for capability requests | +| **Updates** | Edit the file, re-ingest | Auto-populated from SCOPE.md | +| **Readable without hub** | Yes — just open the file | No — requires API | + +This means a repo is fully self-describing: you can understand what it provides +by reading SCOPE.md alone, without any centralized infrastructure. + +--- + +## Relation to other concepts + +| Concept | Relationship | +|---------|-------------| +| **CLAUDE.md** | Build/test/lint instructions — *how* to work with the repo | +| **SCOPE.md** | Boundary definition — *what* the repo is and isn't | +| **Capability Catalog** | Operational routing — *what the domain can provide* on request | +| **Domain Goals** | Strategic direction — *where the domain is heading* | +| **Project Charters** | Founding intent — *why the domain exists* (in `canon/projects/`) | + +--- + +*SCOPE.md is the boundary definition layer. It tells you whether you are in the +right place before you start reading code.* diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index 37fd33a..d1bfa78 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/mcp_server/server.py @@ -1729,6 +1729,173 @@ def reply_to_message(message_id: str, from_agent: str, body: str) -> str: 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, +) -> 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']) + """ + entry = _post("/capability-catalog", { + "domain": domain, + "capability_type": capability_type, + "title": title, + "description": description, + "keywords": keywords or [], + }) + 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 request_capability( + title: str, + description: str, + capability_type: str, + requesting_agent: str, + requesting_domain: str, + 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_workstream_id: UUID of your workstream (optional) + priority: low | medium | high | critical (default: medium) + blocking_task_id: UUID of the task blocked until this is fulfilled (optional) + """ + req = _post("/capability-requests", { + "title": title, + "description": description, + "capability_type": capability_type, + "requesting_agent": requesting_agent, + "requesting_domain": requesting_domain, + "requesting_workstream_id": requesting_workstream_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 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 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) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/state-hub/migrations/versions/i6d7e8f9a0b1_capability_requests.py b/state-hub/migrations/versions/i6d7e8f9a0b1_capability_requests.py new file mode 100644 index 0000000..968a9d7 --- /dev/null +++ b/state-hub/migrations/versions/i6d7e8f9a0b1_capability_requests.py @@ -0,0 +1,74 @@ +"""add capability_catalog and capability_requests tables + +Revision ID: i6d7e8f9a0b1 +Revises: h5c6d7e8f9a0 +Create Date: 2026-03-19 +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision = "i6d7e8f9a0b1" +down_revision = "h5c6d7e8f9a0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "capability_catalog", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("domain_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, index=True), + sa.Column("capability_type", sa.String(50), nullable=False), + sa.Column("title", sa.String(255), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("keywords", sa.ARRAY(sa.String), nullable=False, server_default="{}"), + sa.Column("status", sa.String(20), nullable=False, server_default="active"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("domain_id", "capability_type", "title", name="uq_catalog_domain_type_title"), + ) + + op.create_table( + "capability_requests", + sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True, + server_default=sa.text("gen_random_uuid()")), + sa.Column("title", sa.String(500), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("capability_type", sa.String(50), nullable=False), + sa.Column("priority", sa.String(20), nullable=False, server_default="medium"), + sa.Column("status", sa.String(20), nullable=False, server_default="requested"), + sa.Column("requesting_domain_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("domains.id", ondelete="RESTRICT"), + nullable=False, index=True), + sa.Column("requesting_workstream_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("workstreams.id", ondelete="SET NULL"), + nullable=True), + sa.Column("requesting_agent", sa.String(100), nullable=False), + sa.Column("fulfilling_domain_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("domains.id", ondelete="SET NULL"), + nullable=True, index=True), + sa.Column("fulfilling_workstream_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("workstreams.id", ondelete="SET NULL"), + nullable=True), + sa.Column("fulfilling_agent", sa.String(100), nullable=True), + sa.Column("blocking_task_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("tasks.id", ondelete="SET NULL"), + nullable=True), + sa.Column("catalog_entry_id", postgresql.UUID(as_uuid=True), + sa.ForeignKey("capability_catalog.id", ondelete="SET NULL"), + nullable=True), + sa.Column("resolution_note", sa.Text, nullable=True), + sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("capability_requests") + op.drop_table("capability_catalog") diff --git a/state-hub/scripts/ingest_capabilities.py b/state-hub/scripts/ingest_capabilities.py new file mode 100644 index 0000000..4a4d0da --- /dev/null +++ b/state-hub/scripts/ingest_capabilities.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Ingest capability declarations from SCOPE.md files into the State Hub catalog. + +Reads ``## Provided Capabilities`` sections from SCOPE.md files in registered +repos and upserts them into the capability_catalog table via the API. + +Usage: + python ingest_capabilities.py --repo [--repo-path ] [--dry-run] + python ingest_capabilities.py --all [--dry-run] + +Capability blocks in SCOPE.md use this format: + + ```capability + type: infrastructure + title: Cluster provisioning + description: Provision k8s clusters for any domain. + keywords: [cluster, k8s, privacy, instance] + ``` + +Follows ADR-001: SCOPE.md files are the origin of truth; the DB catalog is +a derived index that can be fully reconstructed from repo files. +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import socket +import sys +import urllib.error +import urllib.request +from pathlib import Path + +try: + import yaml + _YAML_AVAILABLE = True +except ImportError: + _YAML_AVAILABLE = False + +API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/") + +# --------------------------------------------------------------------------- +# SCOPE.md parser +# --------------------------------------------------------------------------- + +_CAPABILITY_BLOCK_RE = re.compile(r"```capability\s*\n(.*?)\n```", re.DOTALL) + + +def parse_capabilities(scope_path: Path) -> list[dict]: + """Extract capability blocks from a SCOPE.md file.""" + if not scope_path.exists(): + return [] + text = scope_path.read_text() + blocks = _CAPABILITY_BLOCK_RE.findall(text) + capabilities = [] + for block in blocks: + cap = _parse_yaml_block(block) + if cap.get("type") and cap.get("title"): + capabilities.append({ + "capability_type": cap["type"], + "title": cap["title"], + "description": cap.get("description", ""), + "keywords": cap.get("keywords", []), + }) + return capabilities + + +def _parse_yaml_block(text: str) -> dict: + """Parse a YAML-like key: value block. Uses PyYAML if available, falls back to manual.""" + if _YAML_AVAILABLE: + try: + result = yaml.safe_load(text) + if isinstance(result, dict): + return result + except yaml.YAMLError: + pass + # Fallback: manual key: value parsing + result = {} + for line in text.strip().splitlines(): + line = line.strip() + if not line or ":" not in line: + continue + key, _, val = line.partition(":") + key = key.strip() + val = val.strip() + if val.startswith("[") and val.endswith("]"): + # Parse simple list: [a, b, c] + inner = val[1:-1] + result[key] = [v.strip().strip("'\"") for v in inner.split(",") if v.strip()] + else: + result[key] = val.strip("'\"") + return result + + +# --------------------------------------------------------------------------- +# API helpers +# --------------------------------------------------------------------------- + +def _api_get(path: str) -> dict | list | None: + url = f"{API_BASE}{path}" + # Add trailing slash before query params for FastAPI redirect avoidance + if "?" in url: + base, qs = url.split("?", 1) + if not base.endswith("/"): + base += "/" + url = f"{base}?{qs}" + elif not url.endswith("/"): + url += "/" + try: + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + except Exception as e: + print(f" GET {url} failed: {e}", file=sys.stderr) + return None + + +def _api_post(path: str, body: dict) -> dict | None: + url = f"{API_BASE}{path}" + if not url.endswith("/"): + url += "/" + data = json.dumps({k: v for k, v in body.items() if v is not None}).encode() + try: + req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + return json.loads(resp.read()) + except urllib.error.HTTPError as e: + body_text = e.read().decode()[:200] + print(f" POST {url} → {e.code}: {body_text}", file=sys.stderr) + return None + except Exception as e: + print(f" POST {url} failed: {e}", file=sys.stderr) + return None + + +def resolve_repo_path(repo: dict, override: str | None = None) -> str: + if override: + return override + hostname = socket.gethostname() + host_paths = repo.get("host_paths") or {} + return host_paths.get(hostname) or repo.get("local_path") or "" + + +# --------------------------------------------------------------------------- +# Ingest logic +# --------------------------------------------------------------------------- + +def ingest_repo(repo_slug: str, repo_path_override: str | None = None, dry_run: bool = False) -> int: + """Ingest capabilities from one repo's SCOPE.md. Returns count of capabilities found.""" + repo = _api_get(f"/repos/{repo_slug}") + if repo is None: + print(f" Repo '{repo_slug}' not found in state-hub", file=sys.stderr) + return 0 + + repo_path = resolve_repo_path(repo, repo_path_override) + if not repo_path: + print(f" Repo '{repo_slug}' has no local path on this host", file=sys.stderr) + return 0 + + scope_path = Path(repo_path) / "SCOPE.md" + if not scope_path.exists(): + print(f" {repo_slug}: no SCOPE.md at {scope_path}") + return 0 + + capabilities = parse_capabilities(scope_path) + if not capabilities: + print(f" {repo_slug}: no capability blocks in SCOPE.md") + return 0 + + # Resolve domain slug for this repo + domain_slug = repo.get("domain_slug") + if not domain_slug: + # Fetch domain from repo's domain_id + domains = _api_get("/domains/") or [] + domain_map = {d["id"]: d["slug"] for d in domains if isinstance(d, dict)} + domain_slug = domain_map.get(repo.get("domain_id"), "") + if not domain_slug: + print(f" {repo_slug}: cannot resolve domain slug", file=sys.stderr) + return 0 + + # Get existing catalog entries for this domain to avoid duplicates + existing = _api_get(f"/capability-catalog/?domain={domain_slug}&status=all") or [] + existing_keys = {(e["capability_type"], e["title"]) for e in existing if isinstance(e, dict)} + + count = 0 + for cap in capabilities: + key = (cap["capability_type"], cap["title"]) + if key in existing_keys: + print(f" {repo_slug}: skip (exists) {cap['capability_type']}/{cap['title']}") + continue + + if dry_run: + print(f" {repo_slug}: [dry-run] would create {cap['capability_type']}/{cap['title']}") + else: + result = _api_post("/capability-catalog", { + "domain": domain_slug, + "capability_type": cap["capability_type"], + "title": cap["title"], + "description": cap["description"], + "keywords": cap["keywords"], + }) + if result: + print(f" {repo_slug}: created {cap['capability_type']}/{cap['title']} → {result.get('id', '?')[:8]}") + else: + print(f" {repo_slug}: FAILED to create {cap['capability_type']}/{cap['title']}") + count += 1 + + return count + + +def ingest_all(dry_run: bool = False) -> None: + """Ingest capabilities from all registered repos.""" + repos = _api_get("/repos/") or [] + total = 0 + for repo in repos: + slug = repo.get("slug", "") + if not slug: + continue + print(f"\n[{slug}]") + total += ingest_repo(slug, dry_run=dry_run) + print(f"\nDone. {total} capability entries {'would be ' if dry_run else ''}ingested.") + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description="Ingest capabilities from SCOPE.md into state-hub catalog") + parser.add_argument("--repo", help="Repo slug to ingest") + parser.add_argument("--repo-path", help="Override repo filesystem path") + parser.add_argument("--all", action="store_true", help="Ingest from all registered repos") + parser.add_argument("--dry-run", action="store_true", help="Print what would be ingested without writing") + parser.add_argument("--api-base", help="Override API base URL") + args = parser.parse_args() + + if args.api_base: + global API_BASE + API_BASE = args.api_base.rstrip("/") + + if args.all: + ingest_all(dry_run=args.dry_run) + elif args.repo: + print(f"[{args.repo}]") + count = ingest_repo(args.repo, repo_path_override=args.repo_path, dry_run=args.dry_run) + print(f"\nDone. {count} capability entries {'would be ' if args.dry_run else ''}ingested.") + else: + parser.error("Specify --repo or --all") + + +if __name__ == "__main__": + main() diff --git a/state-hub/scripts/project_rules/scope.template b/state-hub/scripts/project_rules/scope.template index 605b284..d48e39a 100644 --- a/state-hub/scripts/project_rules/scope.template +++ b/state-hub/scripts/project_rules/scope.template @@ -115,6 +115,23 @@ --- +## Provided Capabilities + + + + + + + +--- + ## Notes diff --git a/state-hub/tests/test_capability_requests.py b/state-hub/tests/test_capability_requests.py new file mode 100644 index 0000000..a908b87 --- /dev/null +++ b/state-hub/tests/test_capability_requests.py @@ -0,0 +1,337 @@ +""" +Capability Request system tests: catalog CRUD, request lifecycle, routing, +auto-notifications, and task unblocking. + +All tests use a real PostgreSQL test database (no mocking). +""" +from __future__ import annotations + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +async def _create_domain(client, slug="testdomain", name="Test Domain"): + r = await client.post("/domains/", json={"slug": slug, "name": name}) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_topic(client, domain_slug="testdomain"): + r = await client.post("/topics/", json={ + "slug": "testtopic", "title": "Test Topic", "domain": domain_slug, + }) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_workstream(client, topic_id): + r = await client.post("/workstreams/", json={ + "topic_id": topic_id, "slug": "test-ws", "title": "Test WS", + }) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_task(client, workstream_id, title="Test task", status="blocked"): + r = await client.post("/tasks/", json={ + "workstream_id": workstream_id, "title": title, + }) + assert r.status_code == 201, r.text + task = r.json() + if status != "todo": + patch = {"status": status} + if status == "blocked": + patch["blocking_reason"] = "Waiting for capability request" + r2 = await client.patch(f"/tasks/{task['id']}", json=patch) + assert r2.status_code == 200, r2.text + return r2.json() + return task + + +async def _setup_two_domains(client): + """Create two domains: 'custodian' (requester) and 'railiance' (provider).""" + req_domain = await _create_domain(client, "custodian", "Custodian") + ful_domain = await _create_domain(client, "railiance", "Railiance") + return req_domain, ful_domain + + +async def _register_catalog(client, domain="railiance", cap_type="infrastructure", + title="Cluster provisioning", keywords=None): + r = await client.post("/capability-catalog/", json={ + "domain": domain, + "capability_type": cap_type, + "title": title, + "keywords": keywords or ["cluster", "k8s", "privacy"], + }) + assert r.status_code == 201, r.text + return r.json() + + +async def _create_request(client, title="Privacy idea instance", + description="Need a privacy idea instance on the cluster", + cap_type="infrastructure", agent="net-kingdom-worker", + domain="custodian", **kwargs): + r = await client.post("/capability-requests/", json={ + "title": title, + "description": description, + "capability_type": cap_type, + "requesting_agent": agent, + "requesting_domain": domain, + **kwargs, + }) + assert r.status_code == 201, r.text + return r.json() + + +# --------------------------------------------------------------------------- +# Catalog tests +# --------------------------------------------------------------------------- + +class TestCapabilityCatalog: + async def test_register_and_list(self, client): + await _setup_two_domains(client) + entry = await _register_catalog(client) + assert entry["capability_type"] == "infrastructure" + assert entry["domain_slug"] == "railiance" + + r = await client.get("/capability-catalog/") + assert r.status_code == 200 + assert len(r.json()) == 1 + + async def test_duplicate_entry_rejected(self, client): + await _setup_two_domains(client) + await _register_catalog(client) + r = await client.post("/capability-catalog/", json={ + "domain": "railiance", + "capability_type": "infrastructure", + "title": "Cluster provisioning", + "keywords": [], + }) + assert r.status_code == 409 + + async def test_filter_by_domain_and_type(self, client): + await _setup_two_domains(client) + await _register_catalog(client, domain="railiance", cap_type="infrastructure") + await _register_catalog(client, domain="railiance", cap_type="security", + title="TLS cert provisioning", keywords=["tls", "cert"]) + await _register_catalog(client, domain="custodian", cap_type="api", + title="MCP tool registration", keywords=["mcp"]) + + # Filter by domain + r = await client.get("/capability-catalog/", params={"domain": "railiance"}) + assert len(r.json()) == 2 + + # Filter by type + r = await client.get("/capability-catalog/", params={"capability_type": "api"}) + assert len(r.json()) == 1 + assert r.json()[0]["domain_slug"] == "custodian" + + +# --------------------------------------------------------------------------- +# Request lifecycle tests +# --------------------------------------------------------------------------- + +class TestCapabilityRequestLifecycle: + async def test_create_auto_routes_single_match(self, client): + await _setup_two_domains(client) + await _register_catalog(client) + req = await _create_request(client) + + assert req["status"] == "requested" + assert req["fulfilling_domain_slug"] == "railiance" + assert req["catalog_entry_id"] is not None + + async def test_create_broadcasts_when_no_catalog(self, client): + await _setup_two_domains(client) + req = await _create_request(client, cap_type="documentation") + + assert req["status"] == "requested" + assert req["fulfilling_domain_slug"] is None + assert req["catalog_entry_id"] is None + + async def test_create_broadcasts_when_ambiguous(self, client): + await _setup_two_domains(client) + # Two entries for same type, equal keyword scores + await _register_catalog(client, domain="railiance", cap_type="infrastructure", + title="K8s clusters", keywords=["k8s"]) + await _register_catalog(client, domain="custodian", cap_type="infrastructure", + title="Local infra", keywords=["local"]) + + req = await _create_request(client, description="Need something generic") + # Neither keyword matches, should broadcast + assert req["fulfilling_domain_slug"] is None + + async def test_valid_transitions(self, client): + await _setup_two_domains(client) + req = await _create_request(client) + + # accepted + r = await client.post(f"/capability-requests/{req['id']}/accept", json={ + "fulfilling_agent": "railiance-worker", + }) + assert r.status_code == 200 + assert r.json()["status"] == "accepted" + assert r.json()["accepted_at"] is not None + + # in_progress + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "in_progress", + }) + assert r.status_code == 200 + assert r.json()["status"] == "in_progress" + + # ready_for_review + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "ready_for_review", "note": "Privacy instance is up", + }) + assert r.status_code == 200 + assert r.json()["status"] == "ready_for_review" + + # completed + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "completed", "note": "Verified and running", + }) + assert r.status_code == 200 + assert r.json()["status"] == "completed" + assert r.json()["completed_at"] is not None + + async def test_invalid_transitions_422(self, client): + await _setup_two_domains(client) + req = await _create_request(client) + + # requested → in_progress (must accept first) + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "in_progress", + }) + assert r.status_code == 422 + + # requested → completed (skip steps) + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "completed", + }) + assert r.status_code == 422 + + async def test_accept_sets_fulfilling_fields(self, client): + await _setup_two_domains(client) + req = await _create_request(client) + + r = await client.post(f"/capability-requests/{req['id']}/accept", json={ + "fulfilling_agent": "railiance-worker", + }) + data = r.json() + assert data["fulfilling_agent"] == "railiance-worker" + assert data["accepted_at"] is not None + + async def test_complete_unblocks_blocking_task(self, client): + await _setup_two_domains(client) + topic = await _create_topic(client, "custodian") + ws = await _create_workstream(client, topic["id"]) + task = await _create_task(client, ws["id"], status="blocked") + + req = await _create_request(client, blocking_task_id=task["id"]) + + # Walk through lifecycle + await client.post(f"/capability-requests/{req['id']}/accept", json={ + "fulfilling_agent": "railiance-worker", + }) + await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "in_progress", + }) + await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "ready_for_review", + }) + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "completed", + }) + assert r.status_code == 200 + + # Verify task was unblocked + r = await client.get(f"/tasks/{task['id']}") + assert r.status_code == 200 + assert r.json()["status"] == "todo" + + async def test_complete_without_blocking_task(self, client): + await _setup_two_domains(client) + req = await _create_request(client) + + await client.post(f"/capability-requests/{req['id']}/accept", json={ + "fulfilling_agent": "railiance-worker", + }) + await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "in_progress", + }) + await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "ready_for_review", + }) + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "completed", + }) + # Should succeed without error (no task to unblock) + assert r.status_code == 200 + assert r.json()["status"] == "completed" + + async def test_notification_created_on_each_transition(self, client): + await _setup_two_domains(client) + await _register_catalog(client) + req = await _create_request(client) + + # Check notification was sent on creation (to railiance since it was auto-routed) + r = await client.get("/messages/", params={"to_agent": "railiance"}) + msgs = r.json() + assert any("[capability-request]" in m["subject"] for m in msgs) + + # Accept + await client.post(f"/capability-requests/{req['id']}/accept", json={ + "fulfilling_agent": "railiance-worker", + }) + r = await client.get("/messages/", params={"to_agent": "net-kingdom-worker"}) + msgs = r.json() + assert any("[capability-accepted]" in m["subject"] for m in msgs) + + # ready_for_review + await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "in_progress", + }) + await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "ready_for_review", + }) + r = await client.get("/messages/", params={"to_agent": "net-kingdom-worker"}) + msgs = r.json() + assert any("[capability-ready]" in m["subject"] for m in msgs) + + async def test_list_filters_by_domain_status_type(self, client): + await _setup_two_domains(client) + await _create_request(client, title="Req A") + await _create_request(client, title="Req B", cap_type="security") + + # Filter by type + r = await client.get("/capability-requests/", params={"capability_type": "security"}) + assert len(r.json()) == 1 + assert r.json()[0]["title"] == "Req B" + + # Filter by status + r = await client.get("/capability-requests/", params={"status": "requested"}) + assert len(r.json()) == 2 + + # Filter by domain + r = await client.get("/capability-requests/", params={"domain": "custodian"}) + assert len(r.json()) == 2 # requesting domain is custodian for both + + async def test_withdrawn_transition(self, client): + await _setup_two_domains(client) + req = await _create_request(client) + + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "withdrawn", "note": "No longer needed", + }) + assert r.status_code == 200 + assert r.json()["status"] == "withdrawn" + + # Terminal — cannot transition further + r = await client.patch(f"/capability-requests/{req['id']}/status", json={ + "status": "requested", + }) + assert r.status_code == 422 diff --git a/workplans/CUST-WP-0022-capability-requests.md b/workplans/CUST-WP-0022-capability-requests.md new file mode 100644 index 0000000..33836c2 --- /dev/null +++ b/workplans/CUST-WP-0022-capability-requests.md @@ -0,0 +1,146 @@ +--- +id: CUST-WP-0022 +type: workplan +title: Capability Request System +domain: custodian +status: done +owner: custodian +topic_slug: the-custodian +created: "2026-03-19" +updated: "2026-03-19" +state_hub_workstream_id: "7cc173c6-f63e-43b0-af8e-04df7f95b96c" +--- + +# CUST-WP-0022 — Capability Request System + +## Purpose + +Enable cross-domain capability requests without requiring the requester to know +which domain is responsible. A net-kingdom dev-worker can say "I need a privacy +idea instance on the cluster" and the system routes it to railiance, manages the +fulfillment lifecycle, and auto-notifies when work is ready for review. + +## Scope + +- `capability_catalog` table — domains register capabilities they can provide +- `capability_requests` table — lifecycle from requested → completed with auto-routing +- Soft routing via keyword matching; broadcast fallback when ambiguous +- Auto-notifications via AgentMessage on every lifecycle transition +- Auto-unblock of blocking tasks on completion +- 7 MCP tools, 1 dashboard page, StateSummary integration + +## Files Changed + +| File | Change | +|------|--------| +| `migrations/versions/i6d7e8f9a0b1_capability_requests.py` | New migration | +| `api/models/capability_catalog.py` | New model | +| `api/models/capability_request.py` | New model | +| `api/models/__init__.py` | Export new models | +| `api/schemas/capability_request.py` | New schemas (Catalog + Request CRUD) | +| `api/routers/capability_requests.py` | New router (lifecycle guard, routing, auto-notify) | +| `api/main.py` | Mount new router | +| `api/schemas/state.py` | Added `open_capability_requests` to StateSummary | +| `api/routers/state.py` | Count open requests in summary builder | +| `mcp_server/server.py` | 7 new MCP tools | +| `dashboard/src/capability-requests.md` | New dashboard page (Kanban + KPIs) | +| `dashboard/observablehq.config.js` | Nav entry | +| `tests/test_capability_requests.py` | 14 test cases | +| `scripts/ingest_capabilities.py` | Ingest `capability` blocks from SCOPE.md → catalog API | +| `scripts/project_rules/scope.template` | Added `## Provided Capabilities` section | +| `SCOPE.md` (repo root) | Added 3 capability blocks for custodian domain | +| `dashboard/src/docs/capabilities.md` | Reference doc for capabilities | +| `dashboard/src/docs/scope.md` | Reference doc for SCOPE.md | + +## Tasks + +### T01: Write TDD test suite +```task +id: CUST-WP-0022-T01 +status: done +priority: high +state_hub_task_id: "34f40ede-3396-4ef2-ae5a-9af4edbfc17d" +``` + +### T02: Alembic migration +```task +id: CUST-WP-0022-T02 +status: done +priority: high +state_hub_task_id: "094fd9de-e4fa-4b9f-bc70-31f049907fa9" +``` + +### T03: SQLAlchemy models +```task +id: CUST-WP-0022-T03 +status: done +priority: high +state_hub_task_id: "5fadabfb-100e-493e-a950-2b5c3d286c6b" +``` + +### T04: Pydantic schemas +```task +id: CUST-WP-0022-T04 +status: done +priority: high +state_hub_task_id: "1efabfde-e9de-41d9-a864-2eb1e9e2dae6" +``` + +### T05: Router with lifecycle + routing + auto-notify +```task +id: CUST-WP-0022-T05 +status: done +priority: high +state_hub_task_id: "57f754b6-022c-4829-adb5-45629567d575" +``` + +### T06: Run tests, iterate until green +```task +id: CUST-WP-0022-T06 +status: done +priority: high +state_hub_task_id: "41ef3722-ffbe-41bb-9ef4-8485db37bb0a" +``` + +### T07: MCP tools +```task +id: CUST-WP-0022-T07 +status: done +priority: medium +state_hub_task_id: "12f28272-2767-4d8e-80a0-a28bb172d6af" +``` + +### T08: Dashboard page +```task +id: CUST-WP-0022-T08 +status: done +priority: medium +state_hub_task_id: "d6596ac4-53ac-4b44-bdd2-635b5c109372" +``` + +### T09: StateSummary integration +```task +id: CUST-WP-0022-T09 +status: done +priority: medium +state_hub_task_id: "964e7f8b-e9f6-41f9-9eb1-ba35e48adf55" +``` + +### T10: Seed catalog entries (file-first via SCOPE.md) +```task +id: CUST-WP-0022-T10 +status: done +priority: low +state_hub_task_id: "2c747cde-bc00-4fd9-a3cd-625709ac6d6d" +``` + +## Design Notes + +- **Routing algorithm**: Exact match on capability_type in catalog. If 1 result → auto-assign. + If multiple → keyword-score description against entry keywords, pick unambiguous winner. + If 0 or tied → fulfilling_domain_id=NULL, broadcast AgentMessage. +- **Lifecycle transitions** guarded by `_VALID_TRANSITIONS` dict (same pattern as contributions). +- **Auto-unblock**: On `completed`, if `blocking_task_id` set and task status is `blocked`, + patches it to `todo` and clears `blocking_reason`. +- **Status uses String(20)** not SA Enum — avoids ALTER TYPE pain in future migrations. +- **Third sanctioned write use case** — alongside resolve_decision and get_next_steps.