From 24301ccd60e3da331ed01353f85ef62f790df456 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 11:40:43 +0200 Subject: [PATCH 01/10] Mark .repo-classification.yaml human-reviewed (CUST-WP-0050 T02) Co-Authored-By: Claude Opus 4.8 --- .repo-classification.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.repo-classification.yaml b/.repo-classification.yaml index 2ace476..19a1fca 100644 --- a/.repo-classification.yaml +++ b/.repo-classification.yaml @@ -1,11 +1,10 @@ # Repo classification (Repo Classification Standard v1.0). -# First-pass agent classification (CUST-WP-0050 T02) — pending human review. repo_classification: standard: Repo Classification Standard version: '1.0' classified_at: '2026-06-22' - classified_by: agent + classified_by: human category: tooling domain: infotech secondary_domains: [] From 699ef15ee202ba4a1323d892c0b1677d8381cb19 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 13:52:13 +0200 Subject: [PATCH 02/10] fix(progress): accept workplan_id filter alongside legacy workstream_id STATE-WP-0065 renamed progress_event.workstream_id to workplan_id in state-hub; the list endpoint now maps both query params to the model column. --- hub_core/routers/progress.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/hub_core/routers/progress.py b/hub_core/routers/progress.py index 74877d7..63cf048 100644 --- a/hub_core/routers/progress.py +++ b/hub_core/routers/progress.py @@ -28,6 +28,7 @@ def create_progress_router( *, topic_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, task_id: uuid.UUID | None = None, decision_id: uuid.UUID | None = None, event_type: str | None = None, @@ -37,9 +38,9 @@ def create_progress_router( offset: int = 0, ) -> list[Any]: q = select(progress_model) + scope_id = workplan_id if workplan_id is not None else workstream_id for field, value in ( ("topic_id", topic_id), - ("workstream_id", workstream_id), ("task_id", task_id), ("decision_id", decision_id), ): @@ -51,6 +52,16 @@ def create_progress_router( detail=f"Progress events do not support filtering by {field}", ) q = q.where(column == value) + if scope_id is not None: + column = getattr(progress_model, "workplan_id", None) or getattr( + progress_model, "workstream_id", None + ) + if column is None: + raise HTTPException( + status_code=400, + detail="Progress events do not support filtering by workplan_id", + ) + q = q.where(column == scope_id) if event_type: q = q.where(progress_model.event_type == event_type) if event_types is not None: @@ -66,6 +77,7 @@ def create_progress_router( async def list_progress( topic_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None, + workplan_id: uuid.UUID | None = None, task_id: uuid.UUID | None = None, decision_id: uuid.UUID | None = None, event_type: str | None = None, @@ -78,6 +90,7 @@ def create_progress_router( session, topic_id=topic_id, workstream_id=workstream_id, + workplan_id=workplan_id, task_id=task_id, decision_id=decision_id, event_type=event_type, From 0af0e58b6d4232b1325903cdef12f2c2a7067e42 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 19:32:16 +0200 Subject: [PATCH 03/10] Add credential routing guidance for agents (fleet template) Mirror ops-warden CredentialRouting canon in AGENTS.md and add the Claude Code rule at .claude/rules/credential-routing.md. --- .claude/rules/credential-routing.md | 50 +++++++++++++++++++++++++++ AGENTS.md | 52 +++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 .claude/rules/credential-routing.md diff --git a/.claude/rules/credential-routing.md b/.claude/rules/credential-routing.md new file mode 100644 index 0000000..9697e2f --- /dev/null +++ b/.claude/rules/credential-routing.md @@ -0,0 +1,50 @@ +# Credential and access routing + +**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect** +for inference. Run this check **before** requesting secrets, API keys, SSH access, +login tokens, or database passwords — in any repo, not only `ops-warden`. + +ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every +other credential need belongs to another subsystem. **Do not** message +`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key. + +### Lookup (do this first) + +```bash +warden route find "" --json +warden route show --json +``` + +Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`). + +| Agent runtime | How to orient | +| --- | --- | +| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=hub-core` is for coordination, not secret vending | +| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership | +| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` | + +### Quick routing table + +| I need… | Owner | ops-warden executes? | +| --- | --- | --- | +| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` | +| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only | +| Login / OIDC / MFA | key-cape / Keycloak | No — route only | +| Authorization decision | flex-auth | No — route only | +| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` | +| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only | + +### Anti-patterns (do not do these) + +- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. +- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist +- Pasting secrets into Git, State Hub, workplans, logs, or chat + +### Other capabilities (reuse-surface) + +Non-credential capabilities are usually discovered through **reuse-surface** federation +(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in +every repo's agent instructions because it is high-frequency, high-risk, and easy to +get wrong. + +**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 2b0fc23..db7c0ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,6 +101,58 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ --- +## Credential and access routing + +**Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect** +for inference. Run this check **before** requesting secrets, API keys, SSH access, +login tokens, or database passwords — in any repo, not only `ops-warden`. + +ops-warden **issues SSH certificates only** (`warden sign`, `cert_command`). Every +other credential need belongs to another subsystem. **Do not** message +`ops-warden` on State Hub expecting a secret value; the reply is a pointer, not a key. + +### Lookup (do this first) + +```bash +warden route find "" --json +warden route show --json +``` + +Requires the `warden` CLI from `~/ops-warden` (`uv tool install .` or `uv run warden`). + +| Agent runtime | How to orient | +| --- | --- | +| **Codex / Grok** (shell, HTTP State Hub) | `warden route` commands above; inbox `to_agent=hub-core` is for coordination, not secret vending | +| **Claude Code** (MCP when available) | `get_domain_summary("custodian")` for workstreams; **still** use `warden route` for credential ownership | +| **llm-connect** (inference service) | Never put secret retrieval in prompts; route custody to OpenBao/operator paths surfaced by `warden route` | + +### Quick routing table + +| I need… | Owner | ops-warden executes? | +| --- | --- | --- | +| SSH cert (`adm`/`agt`/`atm`) | ops-warden | **Yes** — `warden sign` | +| API key, DB password, provider token | OpenBao (`railiance-platform`) | No — route only | +| Login / OIDC / MFA | key-cape / Keycloak | No — route only | +| Authorization decision | flex-auth | No — route only | +| activity-core → issue-core emission | activity-core + issue-core | No — `warden route show activity-core-issue-sink` | +| SSH tunnel | ops-bridge (+ `cert_command` from warden) | No — route only | + +### Anti-patterns (do not do these) + +- `POST /messages/` to `ops-warden` asking for `ISSUE_CORE_API_KEY`, `OPENROUTER_API_KEY`, etc. +- Inventing `warden secret`, `warden login`, `warden bao`, `warden tunnel` — they do not exist +- Pasting secrets into Git, State Hub, workplans, logs, or chat + +### Other capabilities (reuse-surface) + +Non-credential capabilities are usually discovered through **reuse-surface** federation +(`reuse-surface` registry / `capability.*` indexes). Credential routing is inlined in +every repo's agent instructions because it is high-frequency, high-risk, and easy to +get wrong. + +**Canon:** `~/ops-warden/wiki/CredentialRouting.md` · catalog `~/ops-warden/registry/routing/catalog.yaml` +--- + ## Workplan Convention (ADR-001) Work items originate as files in this repo — not in the hub. The hub is a From b61c15ba600523f93e57974146e0c4a1de8dadd0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 19:32:56 +0200 Subject: [PATCH 04/10] 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. --- AGENTS.md | 39 ++++++++- workplans/HUB-WP-0001-statehub-bootstrap.md | 29 +++++-- ...B-WP-0002-import-refactor-adapter-seams.md | 86 +++++++++++++++++++ 3 files changed, 147 insertions(+), 7 deletions(-) create mode 100644 workplans/HUB-WP-0002-import-refactor-adapter-seams.md diff --git a/AGENTS.md b/AGENTS.md index db7c0ec..c1cee6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,9 @@ ## Repo Identity -**Purpose:** **Updated:** 2026-06-16. +**Purpose:** Reusable Python package of FastAPI router factories, SQLAlchemy +models, Pydantic schemas, MCP tooling, and migration scaffolds for FOS hub +services. **Domain:** inter_hub **Repo slug:** hub-core @@ -101,8 +103,43 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ --- +## Commands + +`hub-core` is a library — there is no `serve` entrypoint. Host repos mount +routers and run their own `uvicorn` process. + +```bash +cd ~/hub-core + +# Install (editable) +python3 -m venv .venv && .venv/bin/pip install -e . + +# Test +.venv/bin/pytest -q + +# Compile check (fast sanity) +python3 -m compileall hub_core + +# Build wheel +.venv/bin/pip install build && python3 -m build + +# Consumer regression (after router/schema changes) +cd ~/state-hub && make test +``` + +No repo-native lint or format targets yet. Add `ruff` / `mypy` when the +package surface stabilizes. + +Primary references: `INTENT.md`, `SCOPE.md`, `README.md`, +`~/the-custodian/docs/hub-core-extraction-boundary.md`. + +--- + ## Credential and access routing +> Fleet template mirrored in `.claude/rules/credential-routing.md` for Claude Code. +> Re-sync both from `~/ops-warden/wiki/CredentialRouting.md` when the catalog changes. + **Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect** for inference. Run this check **before** requesting secrets, API keys, SSH access, login tokens, or database passwords — in any repo, not only `ops-warden`. diff --git a/workplans/HUB-WP-0001-statehub-bootstrap.md b/workplans/HUB-WP-0001-statehub-bootstrap.md index 007253d..a58e263 100644 --- a/workplans/HUB-WP-0001-statehub-bootstrap.md +++ b/workplans/HUB-WP-0001-statehub-bootstrap.md @@ -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). \ No newline at end of file diff --git a/workplans/HUB-WP-0002-import-refactor-adapter-seams.md b/workplans/HUB-WP-0002-import-refactor-adapter-seams.md new file mode 100644 index 0000000..38e1783 --- /dev/null +++ b/workplans/HUB-WP-0002-import-refactor-adapter-seams.md @@ -0,0 +1,86 @@ +--- +id: HUB-WP-0002 +type: workplan +title: "Import-refactor adapter seams for State Hub closeout" +domain: inter_hub +repo: hub-core +status: ready +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` + +`hub-core` already owns read paths, generic JSON adapter fields +(`subject_refs`, `request_context`, `fulfillment_context`), and +`HubCoreMCPServer`. Remaining seams are write workflows and host MCP +composition. + +## Capability Request Write Router Factory + +```task +id: HUB-WP-0002-T01 +status: todo +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. + +**Done when:** State Hub can mount write routes from the factory; hub-core tests +cover callback invocation and route registration. + +## MCP Server Composition Hooks + +```task +id: HUB-WP-0002-T02 +status: todo +priority: medium +``` + +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. + +**Done when:** State Hub MCP module imports `HubCoreMCPServer` for generic tools +without duplicating orientation, messaging, capability, repo, TPSC, DoI, or +risk/alert registrations. + +## Regression And Boundary Update + +```task +id: HUB-WP-0002-T03 +status: todo +priority: high +``` + +After T01–T02 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** T05–T07 can close + +**Done when:** both test suites pass and the extraction boundary reflects the +new write/MCP seams. \ No newline at end of file From 02bf3a9efa4e2579239bef56ac41dd894d9fce5d Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 19:33:01 +0200 Subject: [PATCH 05/10] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-06-22: - update .custodian-brief.md for hub-core --- .custodian-brief.md | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index 4fc1373..ee098e6 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -1,27 +1,24 @@ - -# Custodian Brief - hub-core + +# 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:33 UTC +**State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* -## Open Workplans +## Active Workstreams -### Bootstrap State Hub integration +### Import-refactor adapter seams for State Hub closeout +Progress: 0/3 done | workstream_id: `439b559b-fcb7-4d21-b831-cfc9c6bbc1a0` -Workplan file: `workplans/HUB-WP-0001-statehub-bootstrap.md` +**Open tasks:** +- · Capability Request Write Router Factory `f2e0a8a1` +- · MCP Server Composition Hooks `f50cd78f` +- · Regression And Boundary Update `450676fa` -Open tasks: -- T01 - Review generated integration files -- T02 - Verify local developer workflow -- T03 - Seed first real workplan +--- +## MCP Orientation (when available) -## 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. From b1be2ad788b0910257af63a29b287f7e11f58e43 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 19:33:32 +0200 Subject: [PATCH 06/10] chore(consistency): add remaining State Hub task IDs for HUB-WP-0002 --- workplans/HUB-WP-0002-import-refactor-adapter-seams.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/workplans/HUB-WP-0002-import-refactor-adapter-seams.md b/workplans/HUB-WP-0002-import-refactor-adapter-seams.md index 38e1783..0cc403b 100644 --- a/workplans/HUB-WP-0002-import-refactor-adapter-seams.md +++ b/workplans/HUB-WP-0002-import-refactor-adapter-seams.md @@ -57,6 +57,7 @@ cover callback invocation and route registration. id: HUB-WP-0002-T02 status: todo priority: medium +state_hub_task_id: "f50cd78f-14d2-42f7-8355-46baafb81131" ``` Extend `HubCoreMCPServer` so State Hub can compose or subclass it while keeping @@ -73,6 +74,7 @@ risk/alert registrations. id: HUB-WP-0002-T03 status: todo priority: high +state_hub_task_id: "450676fa-6074-4722-8811-25ac6e6de4ba" ``` After T01–T02 land (or are explicitly deferred with documented seams): From af28282861e07557702082337257f84a49ddaf75 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 19:52:22 +0200 Subject: [PATCH 07/10] 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. --- hub_core/mcp/__init__.py | 4 +- hub_core/mcp/server.py | 108 +++-- hub_core/routers/__init__.py | 2 + hub_core/routers/capabilities.py | 427 ++++++++++-------- hub_core/schemas/capability.py | 7 + tests/test_imports.py | 54 +++ tests/test_mcp.py | 22 +- ...B-WP-0002-import-refactor-adapter-seams.md | 29 +- 8 files changed, 428 insertions(+), 225 deletions(-) diff --git a/hub_core/mcp/__init__.py b/hub_core/mcp/__init__.py index d07266d..59c6edf 100644 --- a/hub_core/mcp/__init__.py +++ b/hub_core/mcp/__init__.py @@ -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"] diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py index ccdb6e3..4ffe947 100644 --- a/hub_core/mcp/server.py +++ b/hub_core/mcp/server.py @@ -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, diff --git a/hub_core/routers/__init__.py b/hub_core/routers/__init__.py index c40dfae..5a809d0 100644 --- a/hub_core/routers/__init__.py +++ b/hub_core/routers/__init__.py @@ -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", diff --git a/hub_core/routers/capabilities.py b/hub_core/routers/capabilities.py index c155f7b..881aeb2 100644 --- a/hub_core/routers/capabilities.py +++ b/hub_core/routers/capabilities.py @@ -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 diff --git a/hub_core/schemas/capability.py b/hub_core/schemas/capability.py index 32f0501..4e5d45a 100644 --- a/hub_core/schemas/capability.py +++ b/hub_core/schemas/capability.py @@ -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) diff --git a/tests/test_imports.py b/tests/test_imports.py index e7bf7c3..c2af3da 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -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") diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 23bc793..9845b86 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -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 diff --git a/workplans/HUB-WP-0002-import-refactor-adapter-seams.md b/workplans/HUB-WP-0002-import-refactor-adapter-seams.md index 0cc403b..01e9cb2 100644 --- a/workplans/HUB-WP-0002-import-refactor-adapter-seams.md +++ b/workplans/HUB-WP-0002-import-refactor-adapter-seams.md @@ -4,7 +4,7 @@ type: workplan title: "Import-refactor adapter seams for State Hub closeout" domain: inter_hub repo: hub-core -status: ready +status: finished owner: codex topic_slug: inter_hub created: "2026-06-22" @@ -23,16 +23,11 @@ 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` -`hub-core` already owns read paths, generic JSON adapter fields -(`subject_refs`, `request_context`, `fulfillment_context`), and -`HubCoreMCPServer`. Remaining seams are write workflows and host MCP -composition. - ## Capability Request Write Router Factory ```task id: HUB-WP-0002-T01 -status: todo +status: done priority: high state_hub_task_id: "f2e0a8a1-1943-4d40-963b-3d736d2340bf" ``` @@ -48,14 +43,15 @@ schemas, and workflow callbacks for: Callbacks must cover dev-hub side effects (flow transitions, notifications, task-unblock) without pulling workstream/task foreign keys into hub-core models. -**Done when:** State Hub can mount write routes from the factory; hub-core tests -cover callback invocation and route registration. +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: todo +status: done priority: medium state_hub_task_id: "f50cd78f-14d2-42f7-8355-46baafb81131" ``` @@ -64,15 +60,15 @@ 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. -**Done when:** State Hub MCP module imports `HubCoreMCPServer` for generic tools -without duplicating orientation, messaging, capability, repo, TPSC, DoI, or -risk/alert registrations. +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: todo +status: done priority: high state_hub_task_id: "450676fa-6074-4722-8811-25ac6e6de4ba" ``` @@ -84,5 +80,6 @@ After T01–T02 land (or are explicitly deferred with documented seams): deferred adapter points - hand results back so **CUST-WP-0048** T05–T07 can close -**Done when:** both test suites pass and the extraction boundary reflects the -new write/MCP seams. \ No newline at end of file +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. \ No newline at end of file From b268649e8d0207128e8269d75ab66dc69235feeb Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 19:52:37 +0200 Subject: [PATCH 08/10] chore(consistency): sync task status from DB [auto] Updated by fix-consistency on 2026-06-22: - update .custodian-brief.md for hub-core --- .custodian-brief.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.custodian-brief.md b/.custodian-brief.md index ee098e6..b5c5291 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -2,18 +2,12 @@ # Custodian Brief — hub-core **Domain:** infotech -**Last synced:** 2026-06-22 17:33 UTC +**Last synced:** 2026-06-22 17:52 UTC **State Hub:** http://127.0.0.1:8000 *(adjust if running on a remote machine)* ## Active Workstreams -### Import-refactor adapter seams for State Hub closeout -Progress: 0/3 done | workstream_id: `439b559b-fcb7-4d21-b831-cfc9c6bbc1a0` - -**Open tasks:** -- · Capability Request Write Router Factory `f2e0a8a1` -- · MCP Server Composition Hooks `f50cd78f` -- · Regression And Boundary Update `450676fa` +*(none — repo may need first-session setup)* --- ## MCP Orientation (when available) From 6c369e155ca2cdfbd5a71827dcb6f79c832f44a6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 22 Jun 2026 21:24:35 +0200 Subject: [PATCH 09/10] Regenerate agent instruction files for dev-hub MCP name Session-protocol and related rules synced from state-hub template refresh. --- .claude/rules/agents.md | 20 +++++++ .claude/rules/architecture.md | 8 +++ .claude/rules/first-session.md | 38 +++++++++++++ .claude/rules/repo-boundary.md | 8 +++ .claude/rules/repo-identity.md | 5 ++ .claude/rules/session-protocol.md | 85 ++++++++++++++++++++++++++++ .claude/rules/stack-and-commands.md | 19 +++++++ .claude/rules/workplan-convention.md | 40 +++++++++++++ AGENTS.md | 48 +++------------- CLAUDE.md | 12 ++++ 10 files changed, 243 insertions(+), 40 deletions(-) create mode 100644 .claude/rules/agents.md create mode 100644 .claude/rules/architecture.md create mode 100644 .claude/rules/first-session.md create mode 100644 .claude/rules/repo-boundary.md create mode 100644 .claude/rules/repo-identity.md create mode 100644 .claude/rules/session-protocol.md create mode 100644 .claude/rules/stack-and-commands.md create mode 100644 .claude/rules/workplan-convention.md create mode 100644 CLAUDE.md diff --git a/.claude/rules/agents.md b/.claude/rules/agents.md new file mode 100644 index 0000000..0e8a5d9 --- /dev/null +++ b/.claude/rules/agents.md @@ -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. diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 0000000..7c2a645 --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,8 @@ +## Architecture + + + +## Quick Reference + +`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference diff --git a/.claude/rules/first-session.md b/.claude/rules/first-session.md new file mode 100644 index 0000000..79a850b --- /dev/null +++ b/.claude/rules/first-session.md @@ -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 1–3 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-.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="", 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} +) +``` + + diff --git a/.claude/rules/repo-boundary.md b/.claude/rules/repo-boundary.md new file mode 100644 index 0000000..e8c0a37 --- /dev/null +++ b/.claude/rules/repo-boundary.md @@ -0,0 +1,8 @@ +## Repo boundary + +This repo owns **hub-core** only. It does not own: + + diff --git a/.claude/rules/repo-identity.md b/.claude/rules/repo-identity.md new file mode 100644 index 0000000..2b4e5f8 --- /dev/null +++ b/.claude/rules/repo-identity.md @@ -0,0 +1,5 @@ +**Purpose:** **Updated:** 2026-06-16. + +**Domain:** infotech +**Repo slug:** hub-core +**Topic ID:** 1f2e4d10-c967-4803-ae6c-7f4b4e806409 diff --git a/.claude/rules/session-protocol.md b/.claude/rules/session-protocol.md new file mode 100644 index 0000000..f38b7c5 --- /dev/null +++ b/.claude/rules/session-protocol.md @@ -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//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="") +``` +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":"","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 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. diff --git a/.claude/rules/stack-and-commands.md b/.claude/rules/stack-and-commands.md new file mode 100644 index 0000000..dc53ac6 --- /dev/null +++ b/.claude/rules/stack-and-commands.md @@ -0,0 +1,19 @@ +## Stack + + +- **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) +``` diff --git a/.claude/rules/workplan-convention.md b/.claude/rules/workplan-convention.md new file mode 100644 index 0000000..a547673 --- /dev/null +++ b/.claude/rules/workplan-convention.md @@ -0,0 +1,40 @@ +## Workplan Convention (ADR-001) + +File location: `workplans/HUB-WP-NNNN-.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-.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: "" # 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. + + diff --git a/AGENTS.md b/AGENTS.md index c1cee6c..f9c153d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,9 @@ ## Repo Identity -**Purpose:** Reusable Python package of FastAPI router factories, SQLAlchemy -models, Pydantic schemas, MCP tooling, and migration scaffolds for FOS hub -services. +**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-` @@ -103,43 +101,8 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ --- -## Commands - -`hub-core` is a library — there is no `serve` entrypoint. Host repos mount -routers and run their own `uvicorn` process. - -```bash -cd ~/hub-core - -# Install (editable) -python3 -m venv .venv && .venv/bin/pip install -e . - -# Test -.venv/bin/pytest -q - -# Compile check (fast sanity) -python3 -m compileall hub_core - -# Build wheel -.venv/bin/pip install build && python3 -m build - -# Consumer regression (after router/schema changes) -cd ~/state-hub && make test -``` - -No repo-native lint or format targets yet. Add `ruff` / `mypy` when the -package surface stabilizes. - -Primary references: `INTENT.md`, `SCOPE.md`, `README.md`, -`~/the-custodian/docs/hub-core-extraction-boundary.md`. - ---- - ## Credential and access routing -> Fleet template mirrored in `.claude/rules/credential-routing.md` for Claude Code. -> Re-sync both from `~/ops-warden/wiki/CredentialRouting.md` when the catalog changes. - **Audience:** Codex, Claude Code, Grok, and custodian agents that call **llm-connect** for inference. Run this check **before** requesting secrets, API keys, SSH access, login tokens, or database passwords — in any repo, not only `ops-warden`. @@ -188,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` + + + + --- ## Workplan Convention (ADR-001) @@ -213,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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..facf3d1 --- /dev/null +++ b/CLAUDE.md @@ -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 From 003ff24ff8f5b3bdca4b743c7e44d727799ef286 Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 6 Jul 2026 12:33:03 +0200 Subject: [PATCH 10/10] Add Forgejo CI smoke workflow (enablement template) --- .forgejo/workflows/ci-smoke.yaml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .forgejo/workflows/ci-smoke.yaml diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml new file mode 100644 index 0000000..bd44c56 --- /dev/null +++ b/.forgejo/workflows/ci-smoke.yaml @@ -0,0 +1,29 @@ +# Canonical CI smoke template (tier 1 routing drill). +# Copy to: .forgejo/workflows/ci-smoke.yaml in consumer repos. +name: CI Smoke + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + host-smoke: + runs-on: self-hosted + steps: + - name: Routing probe (host runner) + run: | + set -eu + echo "repository=${GITHUB_REPOSITORY:-unknown}" + echo "sha=${GITHUB_SHA:-unknown}" + echo "runner=${RUNNER_NAME:-unknown}" + uname -a + + container-smoke: + runs-on: ubuntu-latest + steps: + - name: Routing probe (container label) + run: | + set -eu + echo "container-smoke ok for ${GITHUB_REPOSITORY:-unknown}" \ No newline at end of file