diff --git a/.claude/rules/agents.md b/.claude/rules/agents.md deleted file mode 100644 index 0e8a5d9..0000000 --- a/.claude/rules/agents.md +++ /dev/null @@ -1,20 +0,0 @@ -## 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 deleted file mode 100644 index 7c2a645..0000000 --- a/.claude/rules/architecture.md +++ /dev/null @@ -1,8 +0,0 @@ -## Architecture - - - -## Quick Reference - -`~/state-hub/mcp_server/TOOLS.md` — MCP tool reference diff --git a/.claude/rules/credential-routing.md b/.claude/rules/credential-routing.md deleted file mode 100644 index 9697e2f..0000000 --- a/.claude/rules/credential-routing.md +++ /dev/null @@ -1,50 +0,0 @@ -# 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/.claude/rules/first-session.md b/.claude/rules/first-session.md deleted file mode 100644 index 79a850b..0000000 --- a/.claude/rules/first-session.md +++ /dev/null @@ -1,38 +0,0 @@ -## 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 deleted file mode 100644 index e8c0a37..0000000 --- a/.claude/rules/repo-boundary.md +++ /dev/null @@ -1,8 +0,0 @@ -## 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 deleted file mode 100644 index 2b4e5f8..0000000 --- a/.claude/rules/repo-identity.md +++ /dev/null @@ -1,5 +0,0 @@ -**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 deleted file mode 100644 index f38b7c5..0000000 --- a/.claude/rules/session-protocol.md +++ /dev/null @@ -1,85 +0,0 @@ -## 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 deleted file mode 100644 index dc53ac6..0000000 --- a/.claude/rules/stack-and-commands.md +++ /dev/null @@ -1,19 +0,0 @@ -## 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 deleted file mode 100644 index a547673..0000000 --- a/.claude/rules/workplan-convention.md +++ /dev/null @@ -1,40 +0,0 @@ -## 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/.custodian-brief.md b/.custodian-brief.md index b5c5291..4fc1373 100644 --- a/.custodian-brief.md +++ b/.custodian-brief.md @@ -1,18 +1,27 @@ - -# Custodian Brief — hub-core + +# Custodian Brief - hub-core -**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)* +**Project:** hub-core +**Domain:** inter_hub +**State Hub:** http://127.0.0.1:8000 +**Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409` -## Active Workstreams +## Open Workplans -*(none — repo may need first-session setup)* +### Bootstrap State Hub integration ---- -## MCP Orientation (when available) +Workplan file: `workplans/HUB-WP-0001-statehub-bootstrap.md` -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. +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 diff --git a/.forgejo/workflows/ci-smoke.yaml b/.forgejo/workflows/ci-smoke.yaml deleted file mode 100644 index bd44c56..0000000 --- a/.forgejo/workflows/ci-smoke.yaml +++ /dev/null @@ -1,29 +0,0 @@ -# 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 diff --git a/.repo-classification.yaml b/.repo-classification.yaml index 19a1fca..2ace476 100644 --- a/.repo-classification.yaml +++ b/.repo-classification.yaml @@ -1,10 +1,11 @@ # 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: human + classified_by: agent category: tooling domain: infotech secondary_domains: [] diff --git a/AGENTS.md b/AGENTS.md index f9c153d..2b0fc23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ **Purpose:** **Updated:** 2026-06-16. -**Domain:** infotech +**Domain:** inter_hub **Repo slug:** hub-core **Topic ID:** `1f2e4d10-c967-4803-ae6c-7f4b4e806409` **Workplan prefix:** `HUB-WP-` @@ -101,63 +101,6 @@ 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 @@ -181,7 +124,7 @@ anything needing analysis, design, approval, dependencies, or multiple phases. id: HUB-WP-NNNN type: workplan title: "..." -domain: infotech +domain: inter_hub repo: hub-core status: proposed | ready | active | blocked | backlog | finished | archived owner: codex diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index facf3d1..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,12 +0,0 @@ -# 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 diff --git a/hub_core/mcp/__init__.py b/hub_core/mcp/__init__.py index 59c6edf..d07266d 100644 --- a/hub_core/mcp/__init__.py +++ b/hub_core/mcp/__init__.py @@ -1,3 +1,3 @@ -from hub_core.mcp.server import CORE_TOOL_NAMES, HubCoreMCPServer +from hub_core.mcp.server import HubCoreMCPServer -__all__ = ["CORE_TOOL_NAMES", "HubCoreMCPServer"] +__all__ = ["HubCoreMCPServer"] diff --git a/hub_core/mcp/server.py b/hub_core/mcp/server.py index 4ffe947..ccdb6e3 100644 --- a/hub_core/mcp/server.py +++ b/hub_core/mcp/server.py @@ -8,36 +8,6 @@ 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. @@ -62,46 +32,24 @@ class HubCoreMCPServer: if register_tools: self.register_core_tools() - 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 register_core_tools(self) -> None: + @self.mcp.tool() def get_state_summary() -> str: return self._json(self._get("/state/summary/")) - @register("list_domains") + @self.mcp.tool() def list_domains(status: str | None = None) -> str: return self._json(self._get("/domains/", {"status": status})) - @register("get_domain_summary") + @self.mcp.tool() def get_domain_summary(domain_slug: str) -> str: return self._json(self._get(f"/domains/{domain_slug}/")) - @register("get_domain") + @self.mcp.tool() def get_domain(domain_slug: str) -> str: return self._json(self._get(f"/domains/{domain_slug}/")) - @register("send_message") + @self.mcp.tool() def send_message( from_agent: str, to_agent: str, @@ -122,7 +70,7 @@ class HubCoreMCPServer: ) ) - @register("get_messages") + @self.mcp.tool() def get_messages( to_agent: str | None = None, from_agent: str | None = None, @@ -141,11 +89,11 @@ class HubCoreMCPServer: ) ) - @register("mark_message_read") + @self.mcp.tool() def mark_message_read(message_id: str) -> str: return self._json(self._patch(f"/messages/{message_id}/read/", {})) - @register("reply_to_message") + @self.mcp.tool() def reply_to_message(message_id: str, from_agent: str, body: str) -> str: return self._json( self._post( @@ -154,7 +102,7 @@ class HubCoreMCPServer: ) ) - @register("register_capability") + @self.mcp.tool() def register_capability( domain: str, capability_type: str, @@ -177,7 +125,7 @@ class HubCoreMCPServer: ) ) - @register("list_capabilities") + @self.mcp.tool() def list_capabilities( domain: str | None = None, capability_type: str | None = None, @@ -190,7 +138,7 @@ class HubCoreMCPServer: ) ) - @register("request_capability") + @self.mcp.tool() def request_capability( title: str, capability_type: str, @@ -217,7 +165,7 @@ class HubCoreMCPServer: ) ) - @register("accept_capability_request") + @self.mcp.tool() def accept_capability_request( request_id: str, fulfilling_agent: str, @@ -233,7 +181,7 @@ class HubCoreMCPServer: ) ) - @register("update_capability_request_status") + @self.mcp.tool() def update_capability_request_status( request_id: str, status: str, @@ -246,7 +194,7 @@ class HubCoreMCPServer: ) ) - @register("list_capability_requests") + @self.mcp.tool() def list_capability_requests( domain: str | None = None, status: str | None = None, @@ -259,11 +207,11 @@ class HubCoreMCPServer: ) ) - @register("get_capability_request") + @self.mcp.tool() def get_capability_request(request_id: str) -> str: return self._json(self._get(f"/capability-requests/{request_id}/")) - @register("register_repo") + @self.mcp.tool() def register_repo( domain_slug: str, slug: str, @@ -288,17 +236,17 @@ class HubCoreMCPServer: ) ) - @register("update_repo_path") + @self.mcp.tool() 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}) ) - @register("list_domain_repos") + @self.mcp.tool() def list_domain_repos(domain_slug: str) -> str: return self._json(self._get("/repos/", {"domain": domain_slug})) - @register("check_repo_doi") + @self.mcp.tool() def check_repo_doi(repo_slug: str, force_refresh: bool = False) -> str: return self._json( self._get( @@ -307,11 +255,11 @@ class HubCoreMCPServer: ) ) - @register("get_doi_summary") + @self.mcp.tool() def get_doi_summary() -> str: return self._json(self._get("/repos/doi/summary/")) - @register("register_service") + @self.mcp.tool() def register_service( slug: str, name: str, @@ -336,7 +284,7 @@ class HubCoreMCPServer: ) ) - @register("list_services") + @self.mcp.tool() def list_services( gdpr_maturity: str | None = None, category: str | None = None, @@ -353,7 +301,7 @@ class HubCoreMCPServer: ) ) - @register("ingest_tpsc_tool") + @self.mcp.tool() def ingest_tpsc_tool(repo_slug: str, source_file: str, entries: list[dict[str, Any]]) -> str: return self._json( self._post( @@ -362,11 +310,11 @@ class HubCoreMCPServer: ) ) - @register("get_gdpr_report") + @self.mcp.tool() def get_gdpr_report() -> str: return self._json(self._get("/tpsc/report/gdpr/")) - @register("get_risks") + @self.mcp.tool() def get_risks( since: str | None = None, limit: int = 100, @@ -379,7 +327,7 @@ class HubCoreMCPServer: ) ) - @register("get_alerts") + @self.mcp.tool() def get_alerts( since: str | None = None, limit: int = 100, @@ -392,7 +340,7 @@ class HubCoreMCPServer: ) ) - @register("append_progress") + @self.mcp.tool() def append_progress( event_type: str, summary: str, diff --git a/hub_core/routers/__init__.py b/hub_core/routers/__init__.py index 5a809d0..c40dfae 100644 --- a/hub_core/routers/__init__.py +++ b/hub_core/routers/__init__.py @@ -2,7 +2,6 @@ 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 @@ -15,7 +14,6 @@ __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 881aeb2..c155f7b 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, Awaitable +from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select @@ -16,7 +16,6 @@ from hub_core.schemas.capability import ( CapabilityRequestCreate, CapabilityRequestDispute, CapabilityRequestPatch, - CapabilityRequestReroute, CapabilityRequestRead, CapabilityRequestStatusPatch, CatalogCreate, @@ -24,22 +23,6 @@ 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], @@ -179,242 +162,217 @@ def create_capability_request_read_router( return router -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: +def create_capabilities_router(get_session: Callable[..., AsyncSession]) -> APIRouter: router = APIRouter(tags=["capability-requests"]) - @router.post( - "/capability-requests/", - response_model=request_read_schema, - status_code=status.HTTP_201_CREATED, - ) - async def create_request( - body: request_create_schema, + @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), - ) -> 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: - fulfilling_domain_id, catalog_entry_id, routing_note = await _default_route_request( - session, - body, - catalog_model, + ) -> 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 - 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, - ) + @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) + async def create_request( + body: CapabilityRequestCreate, + 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." + 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." + + 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.post("/capability-requests/{request_id}/accept", response_model=request_read_schema) + @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) async def accept_request( request_id: uuid.UUID, - body: request_accept_schema, + body: CapabilityRequestAccept, session: AsyncSession = Depends(get_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) + ) -> CapabilityRequest: + req = await _get_request_or_404(request_id, session) req.status = "accepted" req.fulfilling_agent = body.fulfilling_agent - 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) - + 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}/status", response_model=request_read_schema) + @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: + req.fulfillment_context = body.fulfillment_context + await session.commit() + await session.refresh(req) + return req + + @router.patch("/capability-requests/{request_id}/status", response_model=CapabilityRequestRead) async def patch_request_status( request_id: uuid.UUID, - body: request_status_patch_schema, + body: CapabilityRequestStatusPatch, session: AsyncSession = Depends(get_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) - + ) -> CapabilityRequest: + req = await _get_request_or_404(request_id, session) 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 = now - - if after_status_change is not None: - await after_status_change(session, req, body, now) - + req.completed_at = datetime.now(tz=timezone.utc) await session.commit() await session.refresh(req) return req - @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) + @router.post("/capability-requests/{request_id}/dispute", response_model=CapabilityRequestRead) async def dispute_request( request_id: uuid.UUID, - body: request_dispute_schema, + body: CapabilityRequestDispute, session: AsyncSession = Depends(get_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) + ) -> CapabilityRequest: + req = await _get_request_or_404(request_id, session) req.status = "routing_disputed" req.dispute_reason = body.reason req.disputed_by = body.disputed_by req.dispute_suggested_domain = body.suggested_domain - req.disputed_at = now - - if after_dispute is not None: - await after_dispute(session, req, body, now) - + req.disputed_at = datetime.now(tz=timezone.utc) 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 @@ -442,32 +400,8 @@ async def _resolve_repo( return repo -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) +async def _resolve_catalog_entry(entry_id: uuid.UUID, session: AsyncSession) -> 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") return entry @@ -476,23 +410,18 @@ async def _resolve_catalog_entry( async def _find_catalog_route( capability_type: str, session: AsyncSession, - catalog_model: type[CapabilityCatalog] = CapabilityCatalog, -) -> Any | None: +) -> CapabilityCatalog | None: result = await session.execute( - select(catalog_model) - .where(catalog_model.capability_type == capability_type) - .where(catalog_model.status == "active") - .order_by(catalog_model.created_at.desc()) + select(CapabilityCatalog) + .where(CapabilityCatalog.capability_type == capability_type) + .where(CapabilityCatalog.status == "active") + .order_by(CapabilityCatalog.created_at.desc()) ) return result.scalars().first() -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) +async def _get_request_or_404(request_id: uuid.UUID, session: AsyncSession) -> CapabilityRequest: + req = await session.get(CapabilityRequest, request_id) if req is None: raise HTTPException(status_code=404, detail=f"Capability request '{request_id}' not found") return req diff --git a/hub_core/routers/progress.py b/hub_core/routers/progress.py index 63cf048..74877d7 100644 --- a/hub_core/routers/progress.py +++ b/hub_core/routers/progress.py @@ -28,7 +28,6 @@ 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, @@ -38,9 +37,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), ): @@ -52,16 +51,6 @@ 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: @@ -77,7 +66,6 @@ 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, @@ -90,7 +78,6 @@ 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, diff --git a/hub_core/schemas/capability.py b/hub_core/schemas/capability.py index 4e5d45a..32f0501 100644 --- a/hub_core/schemas/capability.py +++ b/hub_core/schemas/capability.py @@ -71,13 +71,6 @@ 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 c2af3da..e7bf7c3 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -14,7 +14,6 @@ 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, @@ -298,59 +297,6 @@ 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 9845b86..23bc793 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -1,8 +1,6 @@ import asyncio -from fastmcp import FastMCP - -from hub_core.mcp import CORE_TOOL_NAMES, HubCoreMCPServer +from hub_core.mcp import HubCoreMCPServer def test_mcp_base_server_constructs_without_registering_tools() -> None: @@ -31,21 +29,3 @@ 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-0001-statehub-bootstrap.md b/workplans/HUB-WP-0001-statehub-bootstrap.md index a58e263..007253d 100644 --- a/workplans/HUB-WP-0001-statehub-bootstrap.md +++ b/workplans/HUB-WP-0001-statehub-bootstrap.md @@ -4,58 +4,46 @@ type: workplan title: "Bootstrap State Hub integration" domain: inter_hub repo: hub-core -status: finished +status: ready owner: codex topic_slug: inter_hub created: "2026-06-16" -updated: "2026-06-22" -state_hub_workstream_id: "4a31e8cd-1a06-40bf-abc9-76ca9ac173f2" +updated: "2026-06-16" --- # Bootstrap State Hub integration -**Updated:** 2026-06-22. +**Updated:** 2026-06-16. ## Review Generated Integration Files ```task id: HUB-WP-0001-T01 -status: done +status: todo 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: done +status: todo 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: done +status: todo priority: medium -state_hub_task_id: "aca066a0-db3d-4496-821a-0fa13c2ae542" ``` Create the first implementation workplan for the repository's most important @@ -64,8 +52,3 @@ 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 deleted file mode 100644 index 01e9cb2..0000000 --- a/workplans/HUB-WP-0002-import-refactor-adapter-seams.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -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 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 - -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