STATE-WP-0069: MCP Phase 1 deprecation + open_workplans summary alias
Some checks are pending
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run

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.
This commit is contained in:
tegwick 2026-07-08 20:33:53 +02:00
parent 9e93b517e4
commit e6a6af035c
7 changed files with 70 additions and 40 deletions

View file

@ -374,6 +374,25 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
ranked_suggestions = await _ranked_suggestion_digest(session, limit=10) 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( result = StateSummary(
generated_at=datetime.now(tz=timezone.utc), generated_at=datetime.now(tz=timezone.utc),
totals=totals, totals=totals,
@ -394,24 +413,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary:
licence_risk_count=licence_risk_count, licence_risk_count=licence_risk_count,
open_capability_requests=open_cap_req_count, open_capability_requests=open_cap_req_count,
ranked_suggestions=ranked_suggestions, ranked_suggestions=ranked_suggestions,
open_workstreams=[ open_workplans=open_workplan_rows,
WorkstreamWithDeps( open_workstreams=open_workplan_rows,
**{
**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
],
) )
return result return result

View file

@ -81,6 +81,7 @@ class StateSummary(BaseModel):
blocked_tasks: list[TaskRead] = [] blocked_tasks: list[TaskRead] = []
recent_progress: list[ProgressEventRead] recent_progress: list[ProgressEventRead]
open_workstreams: list[WorkstreamWithDeps] open_workstreams: list[WorkstreamWithDeps]
open_workplans: list[WorkstreamWithDeps] = []
next_steps: list[NextStep] = [] next_steps: list[NextStep] = []
domains: list[DomainSummary] = [] domains: list[DomainSummary] = []
contribution_counts: dict[str, int] = {} contribution_counts: dict[str, int] = {}

View file

@ -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()` | | **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 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. 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 | | 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_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 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_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 workstreams + recent events. | | `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workplans + 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_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(workstream_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. | | `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. | | `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_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. | | `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 ## Bootstrap-Only Tools
> Use during **First Session Protocol** to give a freshly-registered project its > 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). > formal work structure belongs in the domain repo (workplans, requirements, milestones).
| Tool | Key Args | Notes | | 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_workplan(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | **Preferred.** Creates workplan under a topic. Legacy alias: `create_workstream`. |
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a 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. | | `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. | | `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. | | `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://summary` | Full StateSummary JSON |
| `state://topics` | Active topics list | | `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://decisions/blocking` | All pending decisions |
| `state://tasks/blocked` | Legacy resource name; returns all `wait` tasks | | `state://tasks/blocked` | Legacy resource name; returns all `wait` tasks |

View file

@ -334,10 +334,10 @@ def get_domain_summary(domain_slug: str) -> str:
topic_id = topic["id"] topic_id = topic["id"]
state_summary = _get("/state/summary") state_summary = _get("/state/summary")
workstreams = [ open_workplans = state_summary.get("open_workplans") or state_summary.get(
ws for ws in state_summary.get("open_workstreams", []) "open_workstreams", []
if ws.get("topic_id") == topic_id )
] workstreams = [ws for ws in open_workplans if ws.get("topic_id") == topic_id]
blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id}) blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
recent = _get("/progress", {"topic_id": topic_id, "limit": 5}) recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
repos = _get("/repos", {"domain": domain_slug}) repos = _get("/repos", {"domain": domain_slug})
@ -467,7 +467,7 @@ def get_domain_summary(domain_slug: str) -> str:
@mcp.tool() @mcp.tool()
def get_topic(slug: str) -> str: 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") topics = _get("/topics")
match = next((t for t in topics if t["slug"] == slug), None) match = next((t for t in topics if t["slug"] == slug), None)
if not match: if not match:
@ -1304,10 +1304,10 @@ def get_next_steps() -> str:
"""Surface contextual next-action suggestions derived from hub state. """Surface contextual next-action suggestions derived from hub state.
Returns suggestions based on: Returns suggestions based on:
- Recently resolved decisions first open task in the same workstream - Recently resolved decisions first open task in the same workplan
- Workstreams whose every dependency is now finished -> first todo task - 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*. message. The hub surfaces *what* and *where* the domain owns *how*.
Derived next steps may include open demand-weighted suggestions from the Derived next steps may include open demand-weighted suggestions from the
@ -2525,13 +2525,13 @@ def update_repo_goal(
@mcp.tool() @mcp.tool()
def get_repo_dispatch(repo_slug: str) -> str: 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 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 pending without needing to read the full state summary or scan workplan
files. The response includes: files. The response includes:
- active_goal: the highest-priority active repo goal - 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) - human_interventions: tasks that need human input (needs_human=true)
- last_state_synced_at: when the repo was last synced to the hub - last_state_synced_at: when the repo was last synced to the hub

View file

@ -26,7 +26,7 @@ def _fake_get(path: str, params: dict | None = None):
return [{"id": "topic-1", "domain_slug": "infotech", "title": "Infotech"}] return [{"id": "topic-1", "domain_slug": "infotech", "title": "Infotech"}]
if path == "/state/summary": if path == "/state/summary":
return { return {
"open_workstreams": [ "open_workplans": [
{ {
"id": "wp-1", "id": "wp-1",
"topic_id": "topic-1", "topic_id": "topic-1",
@ -48,6 +48,20 @@ def _fake_get(path: str, params: dict | None = None):
return [] 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: def test_get_domain_summary_goal_guidance_is_workplan_first(monkeypatch) -> None:
monkeypatch.setattr(server, "_get", lambda path, params=None: _fake_get(path, params)) monkeypatch.setattr(server, "_get", lambda path, params=None: _fake_get(path, params))
payload = json.loads(server.get_domain_summary("infotech")) payload = json.loads(server.get_domain_summary("infotech"))

View file

@ -430,6 +430,8 @@ class TestStateSummary:
assert r.status_code == 200 assert r.status_code == 200
body = r.json() body = r.json()
assert "open_workstreams" in body assert "open_workstreams" in body
assert "open_workplans" in body
assert body["open_workplans"] == body["open_workstreams"]
assert "blocking_decisions" in body assert "blocking_decisions" in body
assert "blocked_tasks" in body assert "blocked_tasks" in body
assert "domains" in body assert "domains" in body

View file

@ -138,6 +138,12 @@ seven consecutive review windows.
Done when MCP clients use workplan-named tools only and legacy MCP keys are Done when MCP clients use workplan-named tools only and legacy MCP keys are
removed from the registry or marked `retired` with zero usage. 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: REST `/workstreams` compat router retirement
```task ```task
@ -184,7 +190,7 @@ finish transitions and legacy-meter shows zero event consumers on the old subjec
```task ```task
id: STATE-WP-0069-T06 id: STATE-WP-0069-T06
status: todo status: progress
priority: low priority: low
state_hub_task_id: "221a69f9-9e61-4fde-9785-fa0dcaf698b4" 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 Done when internal code reads workplan-first and grep budget for
`api:open_workstreams` hits zero. `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: Closeout — registry cleanup and verification
```task ```task