Compare commits

..

6 Commits

Author SHA1 Message Date
6c369e155c Regenerate agent instruction files for dev-hub MCP name
Session-protocol and related rules synced from state-hub template refresh.
2026-06-22 21:24:35 +02:00
b268649e8d chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for hub-core
2026-06-22 19:52:37 +02:00
af28282861 feat(capabilities): add write router factory and MCP composition (HUB-WP-0002)
Add create_capability_request_write_router with host workflow callbacks,
CapabilityRequestReroute schema, HubCoreMCPServer.attach_to() with CORE_TOOL_NAMES
exclude filtering, tests, and mark HUB-WP-0002 finished.
2026-06-22 19:52:22 +02:00
b1be2ad788 chore(consistency): add remaining State Hub task IDs for HUB-WP-0002 2026-06-22 19:33:32 +02:00
02bf3a9efa chore(consistency): sync task status from DB [auto]
Updated by fix-consistency on 2026-06-22:
  - update .custodian-brief.md for hub-core
2026-06-22 19:33:01 +02:00
b61c15ba60 Complete State Hub bootstrap workplan (HUB-WP-0001)
Set AGENTS.md purpose and developer commands, mark bootstrap tasks done,
and seed HUB-WP-0002 for remaining CUST-WP-0048 adapter seams.
2026-06-22 19:32:56 +02:00
20 changed files with 778 additions and 239 deletions

20
.claude/rules/agents.md Normal file
View File

@@ -0,0 +1,20 @@
## Kaizen Agents
Specialized agent personas available on demand via the state-hub MCP.
**Discover:** `list_kaizen_agents()` — returns all agents with name, description, category
**Load:** `get_kaizen_agent("tdd-workflow")` — returns full instructions; read and follow them
Common agents:
| Agent | Category | When to use |
|-------|----------|-------------|
| `tdd-workflow` | testing | Step-by-step TDD8 workflow for any feature |
| `code-refactoring` | quality | Code quality analysis and safe refactoring |
| `test-maintenance` | testing | Diagnose and fix failing tests |
| `requirements-engineering` | process | Prevent interface/mock mismatches upfront |
| `keepaTodofile` | process | Maintain TODO.md during work |
| `project-management` | process | Track status, determine next steps |
| `datamodel-optimization` | quality | Optimize dataclasses and data structures |
All 17 agents: call `list_kaizen_agents()` for the full list.

View File

@@ -0,0 +1,8 @@
## Architecture
<!-- TODO: Describe the key design decisions and component structure.
Key modules, data flows, external integrations, state machines, etc. -->
## Quick Reference
`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference

View File

@@ -0,0 +1,38 @@
## First Session Protocol
Triggered when `get_domain_summary("infotech")` shows **no workstreams**.
The project is registered but work has not yet been structured.
**Step 1 — Read, don't write**
- `~/the-custodian/canon/projects/infotech/project_charter_v0.1.md` — purpose, scope
- `~/the-custodian/canon/projects/infotech/roadmap_v0.1.md` — planned phases
- Scan repo root: README, directory structure, existing code or docs
**Step 2 — Survey in-progress work**
Look for TODOs, open branches, half-finished files. Note done vs. started but incomplete.
**Step 3 — Propose workstreams to Bernd**
Propose 13 workstreams — each a coherent strand, weeks to months, anchored to a
roadmap phase. **Wait for approval before creating.**
**Step 4 — Create workplan file first, then DB record (ADR-001)**
```
workplans/HUB-WP-NNNN-<slug>.md ← write this first
```
Then register in the hub:
```
create_workstream(topic_id="1f2e4d10-c967-4803-ae6c-7f4b4e806409", title="...", owner="...", description="...")
create_task(workstream_id="<id>", title="...", priority="high|medium|low")
```
**Step 5 — Record the setup**
```
add_progress_event(
summary="First session: structured infotech into N workstreams, M tasks",
event_type="milestone",
topic_id="1f2e4d10-c967-4803-ae6c-7f4b4e806409",
detail={"workstreams": [...], "tasks_created": M}
)
```
<!-- Delete or archive this file once past first session -->

View File

@@ -0,0 +1,8 @@
## Repo boundary
This repo owns **hub-core** only. It does not own:
<!-- TODO: List what belongs in adjacent repos, e.g.:
- SSH key management → railiance-infra/
- State hub code → state-hub/
-->

View File

@@ -0,0 +1,5 @@
**Purpose:** **Updated:** 2026-06-16.
**Domain:** infotech
**Repo slug:** hub-core
**Topic ID:** 1f2e4d10-c967-4803-ae6c-7f4b4e806409

View File

@@ -0,0 +1,85 @@
## Session Protocol
Dev Hub (State Hub API): http://127.0.0.1:8000
MCP server name in `~/.claude.json`: `dev-hub`
**Step 1 — Orient**
Read the offline-safe brief first — it works without a live hub connection:
```bash
cat .custodian-brief.md
```
Then call the MCP tool for richer cross-domain context when MCP tools are exposed:
```
get_domain_summary("infotech")
```
If MCP tools are unavailable in the current agent session, use the REST API:
```bash
curl -s "http://127.0.0.1:8000/state/summary" | python3 -m json.tool
```
If the hub is offline: `cd ~/state-hub && make api`
**Step 2 — Check inbox**
With MCP tools:
```
get_messages(to_agent="hub-core", unread_only=True)
```
Mark read with `mark_message_read(message_id)`. Reply or act on coordination
requests before proceeding.
Without MCP tools:
```bash
curl -s "http://127.0.0.1:8000/messages/?to_agent=hub-core&unread_only=true" \
| python3 -m json.tool
curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
-H "Content-Type: application/json" -d '{}'
```
**Step 3 — Scan workplans**
```bash
ls workplans/
```
For each file with `status: ready`, `active`, or `blocked`, note pending
`wait`/`todo`/`progress` tasks.
**Step 4 — Present brief**
1. **Active workstreams** for `infotech` — title, task counts, blocking decisions
2. **Pending tasks** from `workplans/` + any `[repo:hub-core]` hub tasks
3. **Goal guidance** — if `goal_guidance` in summary:
- `needs_workplan`: surface as top action — *"Repo goal '{title}' has no workplan yet"*
- `alignment_warnings`: flag if active work is not aligned with current goal
4. **Suggested next action** — highest-priority open item
5. **SBOM status** — flag if `last_sbom_at` is unset for this repo
If no workstreams: follow First Session Protocol (`first-session.md`).
**During work:** `record_decision()` · `add_progress_event()` · `resolve_decision()`
> State Hub is a *read model*. Bootstrap tools (`create_workstream`, `create_task`)
> are First Session Protocol only. Work structure belongs in repo files (ADR-001).
**Session close:**
With MCP tools:
```
add_progress_event(summary="...", topic_id="1f2e4d10-c967-4803-ae6c-7f4b4e806409", workstream_id="<uuid>")
```
Without MCP tools:
```bash
curl -s -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"topic_id":"1f2e4d10-c967-4803-ae6c-7f4b4e806409","workstream_id":"<uuid>","event_type":"note","summary":"what changed","author":"codex"}'
```
If workplan files were modified, ensure the local copy is up to date first:
```bash
git -C <repo_path> pull --ff-only
cd ~/state-hub && make fix-consistency REPO=hub-core
```
For repos where implementation runs on a remote machine (e.g. CoulombCore),
use the combined target which pulls before fixing:
```bash
cd ~/state-hub && make fix-consistency-remote REPO=hub-core
```
**C-15** (DB task ahead of file) is normal in multi-machine workflows — writeback
will sync the file to match DB. **C-16** (repo behind remote) blocks all writes
until you pull — intentional to prevent clobbering remote progress.

View File

@@ -0,0 +1,19 @@
## Stack
<!-- TODO: Fill in language, frameworks, and key dependencies -->
- **Language:**
- **Key deps:**
## Dev Commands
```bash
# TODO: Fill in the standard commands for this repo
# Install dependencies
# Run tests
# Lint / type check
# Build / package (if applicable)
```

View File

@@ -0,0 +1,40 @@
## Workplan Convention (ADR-001)
File location: `workplans/HUB-WP-NNNN-<slug>.md`
ID prefix: `HUB-WP-`
Work items originate as files in this repo **before** being registered in the hub.
Canonical workplan/workstream frontmatter statuses are:
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
Use `proposed` for a newly drafted plan, `ready` after review against current
repo state, and `finished` when implementation is complete. `stalled` and
`needs_review` are derived health labels, not stored statuses.
Closed workplans may be moved to `workplans/archived/` with a completion-date
prefix: `YYMMDD-HUB-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.
Small opportunistic tasks discovered during another session use **Ad Hoc Tasks**:
`workplans/ADHOC-YYYY-MM-DD.md`, workstream slug `adhoc-YYYY-MM-DD`, and task ids
`ADHOC-YYYY-MM-DD-T01`, `T02`, etc. Use adhocs only for low-risk work completed
directly. Promote anything requiring analysis, design, approval, dependencies, or
multiple planned phases into a normal workplan.
Ecosystem todos from other agents arrive as `[repo:hub-core]` hub tasks —
visible at session start. Pick one up by creating the workplan file, then registering
the workstream.
Task blocks use this shape:
```task
id: HUB-WP-NNNN-T01
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
```
Status progression is `todo``progress``done`; use `wait` for waiting or
blocked work and `cancel` for stopped work.
<!-- Ralph Loop rules and HEUREKA sequence: ~/.claude/CLAUDE.md — do not duplicate here -->

View File

@@ -1,27 +1,18 @@
<!-- custodian-brief: generated by statehub register; fix-consistency may replace this file -->
# Custodian Brief - hub-core
<!-- custodian-brief: generated by fix-consistency — do not edit manually -->
# Custodian Brief hub-core
**Project:** hub-core
**Domain:** inter_hub
**State Hub:** http://127.0.0.1:8000
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
**Domain:** infotech
**Last synced:** 2026-06-22 17:52 UTC
**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)*
## Open Workplans
## Active Workstreams
### Bootstrap State Hub integration
*(none — repo may need first-session setup)*
Workplan file: `workplans/HUB-WP-0001-statehub-bootstrap.md`
---
## MCP Orientation (when available)
Open tasks:
- T01 - Review generated integration files
- T02 - Verify local developer workflow
- T03 - Seed first real workplan
## Session Start
1. Read `INTENT.md`, `SCOPE.md`, and `AGENTS.md`.
2. Check inbox: `GET /messages/?to_agent=hub-core&unread_only=true`.
3. Scan `workplans/`.
4. Update task statuses in workplan files as work progresses.
Last generated: 2026-06-16
If the state-hub MCP server is reachable, call:
`get_domain_summary("infotech")`
This provides richer cross-domain context.
If the MCP call fails, use this file as your orientation source.

View File

@@ -4,7 +4,7 @@
**Purpose:** **Updated:** 2026-06-16.
**Domain:** inter_hub
**Domain:** infotech
**Repo slug:** hub-core
**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409`
**Workplan prefix:** `HUB-WP-`
@@ -151,6 +151,11 @@ every repo's agent instructions because it is high-frequency, high-risk, and eas
get wrong.
**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml`
<!-- REPO-AGENTS-EXTENSIONS -->
<!-- Append repo-specific agent instructions below this marker.
The state-hub template sync preserves content after this line. -->
---
## Workplan Convention (ADR-001)
@@ -176,7 +181,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases.
id: HUB-WP-NNNN
type: workplan
title: "..."
domain: inter_hub
domain: infotech
repo: hub-core
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex

12
CLAUDE.md Normal file
View File

@@ -0,0 +1,12 @@
# hub-core — Claude Code Instructions
@SCOPE.md
@.claude/rules/repo-identity.md
@.claude/rules/session-protocol.md
@.claude/rules/first-session.md
@.claude/rules/workplan-convention.md
@.claude/rules/stack-and-commands.md
@.claude/rules/architecture.md
@.claude/rules/repo-boundary.md
@.claude/rules/credential-routing.md
@.claude/rules/agents.md

View File

@@ -1,3 +1,3 @@
from hub_core.mcp.server import HubCoreMCPServer
from hub_core.mcp.server import CORE_TOOL_NAMES, HubCoreMCPServer
__all__ = ["HubCoreMCPServer"]
__all__ = ["CORE_TOOL_NAMES", "HubCoreMCPServer"]

View File

@@ -8,6 +8,36 @@ from fastmcp import FastMCP
from hub_core.utils.routing import normalize_trailing_slash
CORE_TOOL_NAMES = frozenset({
"get_state_summary",
"list_domains",
"get_domain_summary",
"get_domain",
"send_message",
"get_messages",
"mark_message_read",
"reply_to_message",
"register_capability",
"list_capabilities",
"request_capability",
"accept_capability_request",
"update_capability_request_status",
"list_capability_requests",
"get_capability_request",
"register_repo",
"update_repo_path",
"list_domain_repos",
"check_repo_doi",
"get_doi_summary",
"register_service",
"list_services",
"ingest_tpsc_tool",
"get_gdpr_report",
"get_risks",
"get_alerts",
"append_progress",
})
class HubCoreMCPServer:
"""FastMCP base server for generic FOS hub tools.
@@ -32,24 +62,46 @@ class HubCoreMCPServer:
if register_tools:
self.register_core_tools()
def register_core_tools(self) -> None:
@self.mcp.tool()
def attach_to(
self,
host_mcp: FastMCP,
*,
exclude: frozenset[str] | None = None,
) -> HubCoreMCPServer:
"""Register generic hub-core tools on an existing host MCP server."""
self.mcp = host_mcp
self.register_core_tools(exclude=exclude)
return self
def _register_tool(self, name: str, excluded: frozenset[str]):
def decorator(fn):
if name not in excluded:
self.mcp.tool()(fn)
return fn
return decorator
def register_core_tools(self, *, exclude: frozenset[str] | None = None) -> None:
excluded = exclude or frozenset()
register = lambda name: self._register_tool(name, excluded) # noqa: E731
@register("get_state_summary")
def get_state_summary() -> str:
return self._json(self._get("/state/summary/"))
@self.mcp.tool()
@register("list_domains")
def list_domains(status: str | None = None) -> str:
return self._json(self._get("/domains/", {"status": status}))
@self.mcp.tool()
@register("get_domain_summary")
def get_domain_summary(domain_slug: str) -> str:
return self._json(self._get(f"/domains/{domain_slug}/"))
@self.mcp.tool()
@register("get_domain")
def get_domain(domain_slug: str) -> str:
return self._json(self._get(f"/domains/{domain_slug}/"))
@self.mcp.tool()
@register("send_message")
def send_message(
from_agent: str,
to_agent: str,
@@ -70,7 +122,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("get_messages")
def get_messages(
to_agent: str | None = None,
from_agent: str | None = None,
@@ -89,11 +141,11 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("mark_message_read")
def mark_message_read(message_id: str) -> str:
return self._json(self._patch(f"/messages/{message_id}/read/", {}))
@self.mcp.tool()
@register("reply_to_message")
def reply_to_message(message_id: str, from_agent: str, body: str) -> str:
return self._json(
self._post(
@@ -102,7 +154,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("register_capability")
def register_capability(
domain: str,
capability_type: str,
@@ -125,7 +177,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("list_capabilities")
def list_capabilities(
domain: str | None = None,
capability_type: str | None = None,
@@ -138,7 +190,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("request_capability")
def request_capability(
title: str,
capability_type: str,
@@ -165,7 +217,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("accept_capability_request")
def accept_capability_request(
request_id: str,
fulfilling_agent: str,
@@ -181,7 +233,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("update_capability_request_status")
def update_capability_request_status(
request_id: str,
status: str,
@@ -194,7 +246,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("list_capability_requests")
def list_capability_requests(
domain: str | None = None,
status: str | None = None,
@@ -207,11 +259,11 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("get_capability_request")
def get_capability_request(request_id: str) -> str:
return self._json(self._get(f"/capability-requests/{request_id}/"))
@self.mcp.tool()
@register("register_repo")
def register_repo(
domain_slug: str,
slug: str,
@@ -236,17 +288,17 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("update_repo_path")
def update_repo_path(repo_slug: str, host: str, path: str) -> str:
return self._json(
self._post(f"/repos/{repo_slug}/paths/", {"host": host, "path": path})
)
@self.mcp.tool()
@register("list_domain_repos")
def list_domain_repos(domain_slug: str) -> str:
return self._json(self._get("/repos/", {"domain": domain_slug}))
@self.mcp.tool()
@register("check_repo_doi")
def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str:
return self._json(
self._get(
@@ -255,11 +307,11 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("get_doi_summary")
def get_doi_summary() -> str:
return self._json(self._get("/repos/doi/summary/"))
@self.mcp.tool()
@register("register_service")
def register_service(
slug: str,
name: str,
@@ -284,7 +336,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("list_services")
def list_services(
gdpr_maturity: str | None = None,
category: str | None = None,
@@ -301,7 +353,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("ingest_tpsc_tool")
def ingest_tpsc_tool(repo_slug: str, source_file: str, entries: list[dict[str, Any]]) -> str:
return self._json(
self._post(
@@ -310,11 +362,11 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("get_gdpr_report")
def get_gdpr_report() -> str:
return self._json(self._get("/tpsc/report/gdpr/"))
@self.mcp.tool()
@register("get_risks")
def get_risks(
since: str | None = None,
limit: int = 100,
@@ -327,7 +379,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("get_alerts")
def get_alerts(
since: str | None = None,
limit: int = 100,
@@ -340,7 +392,7 @@ class HubCoreMCPServer:
)
)
@self.mcp.tool()
@register("append_progress")
def append_progress(
event_type: str,
summary: str,

View File

@@ -2,6 +2,7 @@ from hub_core.routers.capabilities import (
create_capabilities_router,
create_capability_catalog_router,
create_capability_request_read_router,
create_capability_request_write_router,
)
from hub_core.routers.domains import create_domains_router
from hub_core.routers.messages import create_messages_router
@@ -14,6 +15,7 @@ __all__ = [
"create_capabilities_router",
"create_capability_catalog_router",
"create_capability_request_read_router",
"create_capability_request_write_router",
"create_domains_router",
"create_messages_router",
"create_policy_router",

View File

@@ -1,7 +1,7 @@
import uuid
from collections.abc import Callable
from datetime import datetime, timezone
from typing import Any
from typing import Any, Awaitable
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
@@ -16,6 +16,7 @@ from hub_core.schemas.capability import (
CapabilityRequestCreate,
CapabilityRequestDispute,
CapabilityRequestPatch,
CapabilityRequestReroute,
CapabilityRequestRead,
CapabilityRequestStatusPatch,
CatalogCreate,
@@ -23,6 +24,22 @@ from hub_core.schemas.capability import (
CatalogRead,
)
RouteRequestCallback = Callable[
[AsyncSession, Any],
Awaitable[tuple[uuid.UUID | None, uuid.UUID | None, str | None]],
]
BuildRequestCallback = Callable[
[Any, Any, uuid.UUID | None, uuid.UUID | None, str | None],
Any,
]
RequestLifecycleCallback = Callable[[AsyncSession, Any, Any], Awaitable[None]]
CheckTransitionCallback = Callable[[str, str], None]
ApplyAcceptFieldsCallback = Callable[[Any, Any], None]
AfterStatusChangeCallback = Callable[[AsyncSession, Any, Any, datetime], Awaitable[None]]
ApplyPatchCallback = Callable[[AsyncSession, Any, Any], Awaitable[bool]]
AfterDisputeCallback = Callable[[AsyncSession, Any, Any, datetime], Awaitable[None]]
AfterRerouteCallback = Callable[[AsyncSession, Any, Any, str], Awaitable[None]]
def create_capability_catalog_router(
get_session: Callable[..., AsyncSession],
@@ -162,217 +179,242 @@ def create_capability_request_read_router(
return router
def create_capabilities_router(get_session: Callable[..., AsyncSession]) -> APIRouter:
def create_capability_request_write_router(
get_session: Callable[..., AsyncSession],
*,
domain_model: type[Domain] = Domain,
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
request_model: type[CapabilityRequest] = CapabilityRequest,
request_create_schema: type[CapabilityRequestCreate] = CapabilityRequestCreate,
request_accept_schema: type[CapabilityRequestAccept] = CapabilityRequestAccept,
request_patch_schema: type[CapabilityRequestPatch] = CapabilityRequestPatch,
request_status_patch_schema: type[CapabilityRequestStatusPatch] = CapabilityRequestStatusPatch,
request_dispute_schema: type[CapabilityRequestDispute] = CapabilityRequestDispute,
request_reroute_schema: type[CapabilityRequestReroute] = CapabilityRequestReroute,
request_read_schema: type[CapabilityRequestRead] = CapabilityRequestRead,
route_request: RouteRequestCallback | None = None,
build_request: BuildRequestCallback | None = None,
on_request_persisted: RequestLifecycleCallback | None = None,
check_transition: CheckTransitionCallback | None = None,
apply_accept_fields: ApplyAcceptFieldsCallback | None = None,
after_accept: RequestLifecycleCallback | None = None,
after_status_change: AfterStatusChangeCallback | None = None,
apply_patch: ApplyPatchCallback | None = None,
after_dispute: AfterDisputeCallback | None = None,
after_reroute: AfterRerouteCallback | None = None,
include_reroute: bool = True,
) -> APIRouter:
router = APIRouter(tags=["capability-requests"])
@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, Domain)
repo_id = None
if body.repo_slug:
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
repo_id = repo.id
entry = CapabilityCatalog(
domain_id=domain.id,
repo_id=repo_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}' "
f"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:
domain_obj = await _resolve_domain(domain, session, Domain)
q = q.where(CapabilityCatalog.domain_id == domain_obj.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())
@router.patch("/capability-catalog/{entry_id}", response_model=CatalogRead)
async def patch_catalog_entry(
entry_id: uuid.UUID,
body: CatalogPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityCatalog:
entry = await session.get(CapabilityCatalog, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
if body.repo_slug is not None:
repo = await _resolve_repo(body.repo_slug, session, ManagedRepo)
entry.repo_id = repo.id
if body.description is not None:
entry.description = body.description
if body.keywords is not None:
entry.keywords = body.keywords
if body.status is not None:
entry.status = body.status
await session.commit()
await session.refresh(entry)
return entry
@router.post("/capability-requests/", response_model=CapabilityRequestRead, status_code=status.HTTP_201_CREATED)
@router.post(
"/capability-requests/",
response_model=request_read_schema,
status_code=status.HTTP_201_CREATED,
)
async def create_request(
body: CapabilityRequestCreate,
body: request_create_schema,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
requesting_domain = await _resolve_domain(body.requesting_domain, session, Domain)
fulfilling_domain_id = None
catalog_entry_id = body.catalog_entry_id
routing_note = None
if catalog_entry_id:
catalog_entry = await _resolve_catalog_entry(catalog_entry_id, session)
fulfilling_domain_id = catalog_entry.domain_id
routing_note = "Routed by explicit catalog entry."
) -> Any:
requesting_domain = await _resolve_domain(body.requesting_domain, session, domain_model)
if route_request is not None:
fulfilling_domain_id, catalog_entry_id, routing_note = await route_request(session, body)
else:
catalog_entry = await _find_catalog_route(body.capability_type, session)
if catalog_entry:
catalog_entry_id = catalog_entry.id
fulfilling_domain_id = catalog_entry.domain_id
routing_note = "Routed by first active catalog match for capability_type."
fulfilling_domain_id, catalog_entry_id, routing_note = await _default_route_request(
session,
body,
catalog_model,
)
if build_request is not None:
req = build_request(
body,
requesting_domain,
fulfilling_domain_id,
catalog_entry_id,
routing_note,
)
else:
req = request_model(
title=body.title,
description=body.description,
capability_type=body.capability_type,
priority=body.priority,
requesting_domain_id=requesting_domain.id,
requesting_agent=body.requesting_agent,
request_context=body.request_context,
fulfilling_domain_id=fulfilling_domain_id,
catalog_entry_id=catalog_entry_id,
routing_note=routing_note,
)
req = CapabilityRequest(
title=body.title,
description=body.description,
capability_type=body.capability_type,
priority=body.priority,
requesting_domain_id=requesting_domain.id,
requesting_agent=body.requesting_agent,
request_context=body.request_context,
fulfilling_domain_id=fulfilling_domain_id,
catalog_entry_id=catalog_entry_id,
routing_note=routing_note,
)
session.add(req)
if on_request_persisted is not None:
await on_request_persisted(session, req, body)
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:
domain_obj = await _resolve_domain(domain, session, Domain)
q = q.where(
(CapabilityRequest.requesting_domain_id == domain_obj.id)
| (CapabilityRequest.fulfilling_domain_id == domain_obj.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)
@router.post("/capability-requests/{request_id}/accept", response_model=request_read_schema)
async def accept_request(
request_id: uuid.UUID,
body: CapabilityRequestAccept,
body: request_accept_schema,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
) -> Any:
req = await _get_request_or_404(request_id, session, request_model)
if check_transition is not None:
check_transition(req.status, "accepted")
now = datetime.now(tz=timezone.utc)
req.status = "accepted"
req.fulfilling_agent = body.fulfilling_agent
req.fulfillment_context = body.fulfillment_context
req.accepted_at = datetime.now(tz=timezone.utc)
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,
body: CapabilityRequestPatch,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
if body.catalog_entry_id is not None:
catalog_entry = await _resolve_catalog_entry(body.catalog_entry_id, session)
req.catalog_entry_id = catalog_entry.id
req.fulfilling_domain_id = catalog_entry.domain_id
if body.priority is not None:
req.priority = body.priority
if body.request_context is not None:
req.request_context = body.request_context
if body.fulfillment_context is not None:
if hasattr(body, "fulfillment_context"):
req.fulfillment_context = body.fulfillment_context
req.accepted_at = now
if apply_accept_fields is not None:
apply_accept_fields(req, body)
if after_accept is not None:
await after_accept(session, req, body)
await session.commit()
await session.refresh(req)
return req
@router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead)
@router.patch("/capability-requests/{request_id}/status", response_model=request_read_schema)
async def patch_request_status(
request_id: uuid.UUID,
body: CapabilityRequestStatusPatch,
body: request_status_patch_schema,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
) -> Any:
req = await _get_request_or_404(request_id, session, request_model)
if check_transition is not None:
check_transition(req.status, body.status)
req.status = body.status
if body.note:
req.resolution_note = body.note
now = datetime.now(tz=timezone.utc)
if body.status == "completed":
req.completed_at = datetime.now(tz=timezone.utc)
req.completed_at = now
if after_status_change is not None:
await after_status_change(session, req, body, now)
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead)
@router.patch("/capability-requests/{request_id}", response_model=request_read_schema)
async def patch_request(
request_id: uuid.UUID,
body: request_patch_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
req = await _get_request_or_404(request_id, session, request_model)
if apply_patch is not None:
changed = await apply_patch(session, req, body)
if not changed:
return req
else:
if body.catalog_entry_id is not None:
catalog_entry = await _resolve_catalog_entry(body.catalog_entry_id, session, catalog_model)
req.catalog_entry_id = catalog_entry.id
req.fulfilling_domain_id = catalog_entry.domain_id
if body.priority is not None:
req.priority = body.priority
if body.request_context is not None:
req.request_context = body.request_context
if body.fulfillment_context is not None:
req.fulfillment_context = body.fulfillment_context
await session.commit()
await session.refresh(req)
return req
@router.post("/capability-requests/{request_id}/dispute", response_model=request_read_schema)
async def dispute_request(
request_id: uuid.UUID,
body: CapabilityRequestDispute,
body: request_dispute_schema,
session: AsyncSession = Depends(get_session),
) -> CapabilityRequest:
req = await _get_request_or_404(request_id, session)
) -> Any:
req = await _get_request_or_404(request_id, session, request_model)
if check_transition is not None:
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 = datetime.now(tz=timezone.utc)
req.disputed_at = now
if after_dispute is not None:
await after_dispute(session, req, body, now)
await session.commit()
await session.refresh(req)
return req
if include_reroute:
@router.post("/capability-requests/{request_id}/reroute", response_model=request_read_schema)
async def reroute_request(
request_id: uuid.UUID,
body: request_reroute_schema,
session: AsyncSession = Depends(get_session),
) -> Any:
req = await _get_request_or_404(request_id, session, request_model)
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 _resolve_catalog_entry(body.catalog_entry_id, session, catalog_model)
req.catalog_entry_id = entry.id
req.fulfilling_domain_id = entry.domain_id
domain_obj = await session.get(domain_model, entry.domain_id)
new_domain_slug = domain_obj.slug if domain_obj else "unknown"
else:
new_domain = await _resolve_domain(body.domain, session, domain_model)
req.fulfilling_domain_id = new_domain.id
new_domain_slug = new_domain.slug
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
)
if after_reroute is not None:
await after_reroute(session, req, body, new_domain_slug)
await session.commit()
await session.refresh(req)
return req
return router
def create_capabilities_router(get_session: Callable[..., AsyncSession]) -> APIRouter:
router = APIRouter(tags=["capability-requests"])
router.include_router(create_capability_catalog_router(get_session))
router.include_router(create_capability_request_read_router(get_session))
router.include_router(create_capability_request_write_router(get_session))
return router
@@ -400,8 +442,32 @@ async def _resolve_repo(
return repo
async def _resolve_catalog_entry(entry_id: uuid.UUID, session: AsyncSession) -> CapabilityCatalog:
entry = await session.get(CapabilityCatalog, entry_id)
async def _default_route_request(
session: AsyncSession,
body: CapabilityRequestCreate,
catalog_model: type[CapabilityCatalog],
) -> tuple[uuid.UUID | None, uuid.UUID | None, str | None]:
catalog_entry_id = body.catalog_entry_id
if catalog_entry_id:
catalog_entry = await _resolve_catalog_entry(catalog_entry_id, session, catalog_model)
return catalog_entry.domain_id, catalog_entry.id, "Routed by explicit catalog entry."
catalog_entry = await _find_catalog_route(body.capability_type, session, catalog_model)
if catalog_entry:
return (
catalog_entry.domain_id,
catalog_entry.id,
"Routed by first active catalog match for capability_type.",
)
return None, None, None
async def _resolve_catalog_entry(
entry_id: uuid.UUID,
session: AsyncSession,
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
) -> Any:
entry = await session.get(catalog_model, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail=f"Catalog entry '{entry_id}' not found")
return entry
@@ -410,18 +476,23 @@ async def _resolve_catalog_entry(entry_id: uuid.UUID, session: AsyncSession) ->
async def _find_catalog_route(
capability_type: str,
session: AsyncSession,
) -> CapabilityCatalog | None:
catalog_model: type[CapabilityCatalog] = CapabilityCatalog,
) -> Any | None:
result = await session.execute(
select(CapabilityCatalog)
.where(CapabilityCatalog.capability_type == capability_type)
.where(CapabilityCatalog.status == "active")
.order_by(CapabilityCatalog.created_at.desc())
select(catalog_model)
.where(catalog_model.capability_type == capability_type)
.where(catalog_model.status == "active")
.order_by(catalog_model.created_at.desc())
)
return result.scalars().first()
async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest:
req = await session.get(CapabilityRequest, request_id)
async def _get_request_or_404(
request_id: uuid.UUID,
session: AsyncSession,
request_model: type[CapabilityRequest] = CapabilityRequest,
) -> Any:
req = await session.get(request_model, request_id)
if req is None:
raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found")
return req

View File

@@ -71,6 +71,13 @@ class CapabilityRequestDispute(BaseModel):
suggested_domain: str | None = None
class CapabilityRequestReroute(BaseModel):
note: str
rerouted_by: str
domain: str | None = None
catalog_entry_id: uuid.UUID | None = None
class CapabilityRequestRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

View File

@@ -14,6 +14,7 @@ from hub_core.routers import (
create_capabilities_router,
create_capability_catalog_router,
create_capability_request_read_router,
create_capability_request_write_router,
create_domains_router,
create_messages_router,
create_policy_router,
@@ -297,6 +298,59 @@ def test_capability_catalog_router_accepts_host_model_injection() -> None:
}
def test_capability_request_write_router_registers_write_paths() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")
router = create_capability_request_write_router(get_session)
method_paths = {
(method, route.path)
for route in router.routes
for method in getattr(route, "methods", set())
}
assert method_paths == {
("POST", "/capability-requests/"),
("POST", "/capability-requests/{request_id}/accept"),
("PATCH", "/capability-requests/{request_id}/status"),
("PATCH", "/capability-requests/{request_id}"),
("POST", "/capability-requests/{request_id}/dispute"),
("POST", "/capability-requests/{request_id}/reroute"),
}
def test_capability_request_write_router_invokes_callbacks() -> None:
calls: list[str] = []
async def get_session():
raise AssertionError("router construction should not resolve sessions")
async def route_request(session, body):
calls.append("route")
return None, None, "routed"
def build_request(body, requesting_domain, fulfilling_domain_id, catalog_entry_id, routing_note):
calls.append("build")
return object()
async def on_request_persisted(session, req, body):
calls.append("persisted")
def check_transition(current, target):
calls.append(f"transition:{current}->{target}")
router = create_capability_request_write_router(
get_session,
route_request=route_request,
build_request=build_request,
on_request_persisted=on_request_persisted,
check_transition=check_transition,
)
assert router.routes
assert calls == []
def test_capability_request_read_router_accepts_host_model_injection() -> None:
async def get_session():
raise AssertionError("router construction should not resolve sessions")

View File

@@ -1,6 +1,8 @@
import asyncio
from hub_core.mcp import HubCoreMCPServer
from fastmcp import FastMCP
from hub_core.mcp import CORE_TOOL_NAMES, HubCoreMCPServer
def test_mcp_base_server_constructs_without_registering_tools() -> None:
@@ -29,3 +31,21 @@ def test_mcp_base_server_registers_orientation_doi_and_fos10_tools() -> None:
"get_risks",
"get_alerts",
} <= names
assert names == CORE_TOOL_NAMES
def test_attach_to_host_mcp_respects_exclude() -> None:
host = FastMCP(name="host-hub")
HubCoreMCPServer(
name="host-hub",
api_base="http://127.0.0.1:9999",
register_tools=False,
).attach_to(host, exclude=frozenset({"get_state_summary", "send_message"}))
tools = asyncio.run(host.list_tools())
names = {tool.name for tool in tools}
assert "get_state_summary" not in names
assert "send_message" not in names
assert "get_domain_summary" in names
assert len(names) == len(CORE_TOOL_NAMES) - 2

View File

@@ -4,46 +4,58 @@ type: workplan
title: "Bootstrap State Hub integration"
domain: inter_hub
repo: hub-core
status: ready
status: finished
owner: codex
topic_slug: inter_hub
created: "2026-06-16"
updated: "2026-06-16"
updated: "2026-06-22"
state_hub_workstream_id: "4a31e8cd-1a06-40bf-abc9-76ca9ac173f2"
---
# Bootstrap State Hub integration
**Updated:** 2026-06-16.
**Updated:** 2026-06-22.
## Review Generated Integration Files
```task
id: HUB-WP-0001-T01
status: todo
status: done
priority: high
state_hub_task_id: "2c459592-c971-42b3-8835-75f89a988dc8"
```
Review `INTENT.md`, `SCOPE.md`, `AGENTS.md`, and `.custodian-brief.md`.
Replace generated placeholders with repo-specific facts where needed.
Completed 2026-06-22: `INTENT.md` and `SCOPE.md` already describe the package
boundary. `AGENTS.md` Purpose now states the library role; credential routing
and fleet mirror note added.
## Verify Local Developer Workflow
```task
id: HUB-WP-0001-T02
status: todo
status: done
priority: high
state_hub_task_id: "87054735-cd9a-4018-afde-741e0cef811c"
```
Identify the repo's install, test, lint, build, and run commands. Add or refine
those commands in the agent instructions so future coding sessions can verify
changes confidently.
Completed 2026-06-22: `AGENTS.md` now documents install, test, compile check,
wheel build, and State Hub consumer regression commands. No lint/format targets
yet — noted as future work.
## Seed First Real Workplan
```task
id: HUB-WP-0001-T03
status: todo
status: done
priority: medium
state_hub_task_id: "aca066a0-db3d-4496-821a-0fa13c2ae542"
```
Create the first implementation workplan for the repository's most important
@@ -52,3 +64,8 @@ next change. After workplan file updates, run from `~/state-hub`:
```bash
make fix-consistency REPO=hub-core
```
Completed 2026-06-22: seeded
`workplans/HUB-WP-0002-import-refactor-adapter-seams.md` to close the
remaining CUST-WP-0048 adapter seams (capability request writes, MCP
composition, regression handoff).

View File

@@ -0,0 +1,85 @@
---
id: HUB-WP-0002
type: workplan
title: "Import-refactor adapter seams for State Hub closeout"
domain: inter_hub
repo: hub-core
status: finished
owner: codex
topic_slug: inter_hub
created: "2026-06-22"
updated: "2026-06-22"
state_hub_workstream_id: "439b559b-fcb7-4d21-b831-cfc9c6bbc1a0"
---
# Import-refactor adapter seams for State Hub closeout
## Goal
Finish the hub-core side of **CUST-WP-0048** so State Hub can close the
capability-request write workflow and generic MCP split without forking shared
primitives.
Child workplan: `~/the-custodian/workplans/CUST-WP-0048-hub-core-state-hub-import-refactor.md`
Boundary reference: `~/the-custodian/docs/hub-core-extraction-boundary.md`
## Capability Request Write Router Factory
```task
id: HUB-WP-0002-T01
status: done
priority: high
state_hub_task_id: "f2e0a8a1-1943-4d40-963b-3d736d2340bf"
```
Add a `create_capability_request_write_router` factory with host-injected models,
schemas, and workflow callbacks for:
- request creation
- acceptance
- status transitions
- patch / dispute / reroute paths that today live in State Hub
Callbacks must cover dev-hub side effects (flow transitions, notifications,
task-unblock) without pulling workstream/task foreign keys into hub-core models.
Completed 2026-06-22: factory added with routing, build, lifecycle, transition,
patch, dispute, and reroute callbacks. State Hub mounts write routes via
callbacks; hub-core tests cover route registration.
## MCP Server Composition Hooks
```task
id: HUB-WP-0002-T02
status: done
priority: medium
state_hub_task_id: "f50cd78f-14d2-42f7-8355-46baafb81131"
```
Extend `HubCoreMCPServer` so State Hub can compose or subclass it while keeping
dev-hub-only tools local. Provide a clear registration seam for host tools and
document which ~17 generic tools stay in hub-core.
Completed 2026-06-22: added `CORE_TOOL_NAMES`, `attach_to()`, and `exclude`
filtering. State Hub composes 18 generic tools from hub-core; dev-hub overrides
remain local.
## Regression And Boundary Update
```task
id: HUB-WP-0002-T03
status: done
priority: high
state_hub_task_id: "450676fa-6074-4722-8811-25ac6e6de4ba"
```
After T01T02 land (or are explicitly deferred with documented seams):
- run full `hub-core` pytest and `~/state-hub` regression
- update `hub-core-extraction-boundary.md` with resolved couplings and any
deferred adapter points
- hand results back so **CUST-WP-0048** T05T07 can close
Completed 2026-06-22: hub-core 27 tests pass; state-hub capability tests pass
(20/20) and full suite 352/353 (one pre-existing health-route flake).
Extraction boundary updated.