From e6a6af035c9f73c21945594970beb6eb921fa2d6 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 8 Jul 2026 20:33:53 +0200 Subject: [PATCH] STATE-WP-0069: MCP Phase 1 deprecation + open_workplans summary alias Complete MCP legacy deprecation coverage (resource + docstrings + TOOLS.md). Dual-write open_workplans on /state/summary alongside open_workstreams; get_domain_summary prefers the new key. T06 internal rename started. --- api/routers/state.py | 39 ++++++++++--------- api/schemas/state.py | 1 + mcp_server/TOOLS.md | 21 +++++----- mcp_server/server.py | 20 +++++----- tests/test_mcp_legacy_deprecation.py | 16 +++++++- tests/test_routers_core.py | 2 + ...-workplan-terminology-legacy-retirement.md | 11 +++++- 7 files changed, 70 insertions(+), 40 deletions(-) diff --git a/api/routers/state.py b/api/routers/state.py index 0f0f218..d18f0e5 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -374,6 +374,25 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: ranked_suggestions = await _ranked_suggestion_digest(session, limit=10) + open_workplan_rows = [ + WorkstreamWithDeps( + **{ + **WorkstreamRead.model_validate(w).model_dump(), + "status": effective_status.get(w.id, w.status), + }, + tasks_total=sum(task_per_ws.get(w.id, {}).values()), + tasks_wait=task_per_ws.get(w.id, {}).get(TaskStatus.wait, 0), + tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), + tasks_progress=task_per_ws.get(w.id, {}).get(TaskStatus.progress, 0), + tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), + tasks_cancel=task_per_ws.get(w.id, {}).get(TaskStatus.cancel, 0), + depends_on=dep_index.get(w.id, {}).get("depends_on", []), + blocks=dep_index.get(w.id, {}).get("blocks", []), + blocked_reasons=blocked_reasons.get(w.id, []), + ) + for w in open_ws + ] + result = StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -394,24 +413,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: licence_risk_count=licence_risk_count, open_capability_requests=open_cap_req_count, ranked_suggestions=ranked_suggestions, - open_workstreams=[ - WorkstreamWithDeps( - **{ - **WorkstreamRead.model_validate(w).model_dump(), - "status": effective_status.get(w.id, w.status), - }, - tasks_total=sum(task_per_ws.get(w.id, {}).values()), - tasks_wait=task_per_ws.get(w.id, {}).get(TaskStatus.wait, 0), - tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), - tasks_progress=task_per_ws.get(w.id, {}).get(TaskStatus.progress, 0), - tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), - tasks_cancel=task_per_ws.get(w.id, {}).get(TaskStatus.cancel, 0), - depends_on=dep_index.get(w.id, {}).get("depends_on", []), - blocks=dep_index.get(w.id, {}).get("blocks", []), - blocked_reasons=blocked_reasons.get(w.id, []), - ) - for w in open_ws - ], + open_workplans=open_workplan_rows, + open_workstreams=open_workplan_rows, ) return result diff --git a/api/schemas/state.py b/api/schemas/state.py index e7a7eee..015b3f2 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -81,6 +81,7 @@ class StateSummary(BaseModel): blocked_tasks: list[TaskRead] = [] recent_progress: list[ProgressEventRead] open_workstreams: list[WorkstreamWithDeps] + open_workplans: list[WorkstreamWithDeps] = [] next_steps: list[NextStep] = [] domains: list[DomainSummary] = [] contribution_counts: dict[str, int] = {} diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 989a3a2..fff7854 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -16,7 +16,7 @@ Sanctioned writes (cross-cutting coordination — not bootstrap-only): | **Suggestion backlog** | `create_suggestion()`, `vet_suggestion()`, `decline_suggestion()`, `promote_suggestion_to_task()`, `bump_suggestion_relevance()` | All other mutate tools are **bootstrap-only**: use them during First Session Protocol -to give a freshly-registered project its initial workstream structure. +to give a freshly-registered project its initial workplan structure. Do not use them as a substitute for formal work definition inside the domain repo. --- @@ -96,11 +96,11 @@ and replay with statehub outbox status and statehub outbox replay. | Tool | Key Args | When to use | |------|----------|-------------| -| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. | -| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workstreams, last 20 events. Large (~10k tokens). API revision-caches unchanged snapshots (`X-StateHub-Cache: hit-revision`); use REST `?refresh=true` only when you need a forced rebuild. | -| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. | -| `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: wait/todo/progress/done/cancel | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. | -| `list_blocked_tasks(workstream_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. | +| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workplans, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. | +| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workplans, last 20 events. Large (~10k tokens). API revision-caches unchanged snapshots (`X-StateHub-Cache: hit-revision`); use REST `?refresh=true` only when you need a forced rebuild. | +| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workplans + recent events. | +| `list_tasks(workplan_id, status?)` | `workplan_id`: UUID (required); legacy alias `workstream_id`; `status?`: wait/todo/progress/done/cancel | List all tasks in a workplan. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. | +| `list_blocked_tasks(workplan_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workplan (`workstream_id` alias accepted). | | `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. | | `get_recent_progress(limit, since?)` | `limit` default 20; `since` ISO datetime | Reconstruct recent session history. | | `get_capability_profile(domain_slug?)` | `domain_slug`: optional domain slug | **Capability deep-dive.** Returns repos → capabilities tree for one domain or all active domains. Includes descriptions and keywords. For cross-domain architectural discussion or when a worker needs to understand what a domain provides without checking out its repos. | @@ -121,13 +121,13 @@ and replay with statehub outbox status and statehub outbox replay. ## Bootstrap-Only Tools > Use during **First Session Protocol** to give a freshly-registered project its -> initial workstream structure. Do not use for ongoing project management — +> initial workplan structure. Do not use for ongoing project management — > formal work structure belongs in the domain repo (workplans, requirements, milestones). | Tool | Key Args | Notes | |------|----------|-------| -| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. | -| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. | +| `create_workplan(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | **Preferred.** Creates workplan under a topic. Legacy alias: `create_workstream`. | +| `create_task(workplan_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workplan (`workstream_id` alias accepted). | | `update_task_status(task_id, status, ...)` | `status`: wait/todo/progress/done/cancel; `blocking_reason?` describes wait conditions | Legacy aliases `blocked`, `in_progress`, `cancelled`, and `canceled` are accepted during migration. | | `bulk_update_task_statuses(updates, author?, session_id?)` | `updates`: list of `{task_id, status, blocking_reason?}` | Updates many task statuses in one REST call and emits one `task_status_changed` progress event per task. Prefer this at session checkpoints instead of many single-task calls. | | `update_workstream_status(workstream_id, status)` | `status`: proposed/ready/active/blocked/backlog/finished/archived | Thin shortcut — use `update_workstream` for full field control. | @@ -186,7 +186,8 @@ Agents should call `record_token_event` (or pass `tokens_in`/`tokens_out` via |-----|---------| | `state://summary` | Full StateSummary JSON | | `state://topics` | Active topics list | -| `state://workstreams/{topic_slug}` | Workstreams for a topic (by slug) | +| `state://workplans/{topic_slug}` | Workplans for a topic (by slug) — preferred | +| `state://workstreams/{topic_slug}` | Legacy resource alias (deprecated; same payload + `_deprecation`) | | `state://decisions/blocking` | All pending decisions | | `state://tasks/blocked` | Legacy resource name; returns all `wait` tasks | diff --git a/mcp_server/server.py b/mcp_server/server.py index 7c2d612..ef4e55d 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -334,10 +334,10 @@ def get_domain_summary(domain_slug: str) -> str: topic_id = topic["id"] state_summary = _get("/state/summary") - workstreams = [ - ws for ws in state_summary.get("open_workstreams", []) - if ws.get("topic_id") == topic_id - ] + open_workplans = state_summary.get("open_workplans") or state_summary.get( + "open_workstreams", [] + ) + workstreams = [ws for ws in open_workplans if ws.get("topic_id") == topic_id] blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id}) recent = _get("/progress", {"topic_id": topic_id, "limit": 5}) repos = _get("/repos", {"domain": domain_slug}) @@ -467,7 +467,7 @@ def get_domain_summary(domain_slug: str) -> str: @mcp.tool() def get_topic(slug: str) -> str: - """Return a topic (with workstreams) by slug, plus its recent progress events.""" + """Return a topic (with workplans) by slug, plus its recent progress events.""" topics = _get("/topics") match = next((t for t in topics if t["slug"] == slug), None) if not match: @@ -1304,10 +1304,10 @@ def get_next_steps() -> str: """Surface contextual next-action suggestions derived from hub state. Returns suggestions based on: - - Recently resolved decisions → first open task in the same workstream - - Workstreams whose every dependency is now finished -> first todo task + - Recently resolved decisions → first open task in the same workplan + - Workplans whose every dependency is now finished -> first todo task - Each suggestion includes domain, workstream, task, and a plain-language + Each suggestion includes domain, workplan, task, and a plain-language message. The hub surfaces *what* and *where* — the domain owns *how*. Derived next steps may include open demand-weighted suggestions from the @@ -2525,13 +2525,13 @@ def update_repo_goal( @mcp.tool() def get_repo_dispatch(repo_slug: str) -> str: - """Return active workstreams, pending tasks, and goal for a repo. + """Return active workplans, pending tasks, and goal for a repo. Use this at the start of a repo agent session to discover what work is pending without needing to read the full state summary or scan workplan files. The response includes: - active_goal: the highest-priority active repo goal - - active_workstreams: list of active workstreams with pending tasks + - active_workplans: list of active workplans with pending tasks - human_interventions: tasks that need human input (needs_human=true) - last_state_synced_at: when the repo was last synced to the hub diff --git a/tests/test_mcp_legacy_deprecation.py b/tests/test_mcp_legacy_deprecation.py index 05f8721..3b10530 100644 --- a/tests/test_mcp_legacy_deprecation.py +++ b/tests/test_mcp_legacy_deprecation.py @@ -26,7 +26,7 @@ def _fake_get(path: str, params: dict | None = None): return [{"id": "topic-1", "domain_slug": "infotech", "title": "Infotech"}] if path == "/state/summary": return { - "open_workstreams": [ + "open_workplans": [ { "id": "wp-1", "topic_id": "topic-1", @@ -48,6 +48,20 @@ def _fake_get(path: str, params: dict | None = None): return [] +def test_legacy_workstreams_resource_includes_deprecation(monkeypatch) -> None: + def fake_get(path: str, params: dict | None = None): + if path == "/topics": + return [{"id": "topic-1", "slug": "infotech", "domain_slug": "infotech"}] + if path == "/workstreams": + return [{"id": "wp-1", "title": "Demo", "slug": "demo-wp"}] + return [] + + monkeypatch.setattr(server, "_get", fake_get) + payload = json.loads(server.resource_workstreams("infotech")) + assert payload["_deprecation"]["replacement"] == "state://workplans/{topic_slug}" + assert payload["workplans"] == payload["workstreams"] + + def test_get_domain_summary_goal_guidance_is_workplan_first(monkeypatch) -> None: monkeypatch.setattr(server, "_get", lambda path, params=None: _fake_get(path, params)) payload = json.loads(server.get_domain_summary("infotech")) diff --git a/tests/test_routers_core.py b/tests/test_routers_core.py index da75d63..e5166a7 100644 --- a/tests/test_routers_core.py +++ b/tests/test_routers_core.py @@ -430,6 +430,8 @@ class TestStateSummary: assert r.status_code == 200 body = r.json() assert "open_workstreams" in body + assert "open_workplans" in body + assert body["open_workplans"] == body["open_workstreams"] assert "blocking_decisions" in body assert "blocked_tasks" in body assert "domains" in body diff --git a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md index 38123e2..49d267f 100644 --- a/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md +++ b/workplans/STATE-WP-0069-workplan-terminology-legacy-retirement.md @@ -138,6 +138,12 @@ seven consecutive review windows. Done when MCP clients use workplan-named tools only and legacy MCP keys are removed from the registry or marked `retired` with zero usage. +Progress 2026-07-08 (Phase 1): `_deprecation` payloads on all legacy MCP tools +and `state://workstreams/{topic_slug}`; `state://workplans/{topic_slug}` preferred +resource; `get_domain_summary` workplan-first prose + `workplans` key; +TOOLS.md and tool docstrings updated. Phase 2 alias removal remains gated on +legacy-meter zero usage for seven consecutive review windows. + ## Task: REST `/workstreams` compat router retirement ```task @@ -184,7 +190,7 @@ finish transitions and legacy-meter shows zero event consumers on the old subjec ```task id: STATE-WP-0069-T06 -status: todo +status: progress priority: low state_hub_task_id: "221a69f9-9e61-4fde-9785-fa0dcaf698b4" ``` @@ -200,6 +206,9 @@ Coordinate with hub-core if shared types leak the old term. Done when internal code reads workplan-first and grep budget for `api:open_workstreams` hits zero. +Progress 2026-07-08: `/state/summary` now dual-writes `open_workplans` alongside +legacy `open_workstreams`; MCP `get_domain_summary` prefers `open_workplans`. + ## Task: Closeout — registry cleanup and verification ```task