generated from coulomb/repo-seed
feat: add State Hub bulk status skill
This commit is contained in:
@@ -6,10 +6,18 @@ from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.database import get_session
|
||||
from api.models.progress_event import ProgressEvent
|
||||
from api.models.task import Task, TaskStatus
|
||||
from api.models.token_event import TokenEvent
|
||||
from api.models.workstream import Workstream
|
||||
from api.schemas.task import TaskCountRead, TaskCreate, TaskRead, TaskUpdate
|
||||
from api.schemas.task import (
|
||||
TaskCountRead,
|
||||
TaskCreate,
|
||||
TaskRead,
|
||||
TaskStatusBulkSync,
|
||||
TaskStatusBulkSyncRead,
|
||||
TaskUpdate,
|
||||
)
|
||||
from api.services.lifecycle import status_value, transition_task_status
|
||||
from api.task_status import normalize_task_status
|
||||
|
||||
@@ -88,6 +96,84 @@ async def create_task(
|
||||
return task
|
||||
|
||||
|
||||
@router.post("/bulk-status-sync", response_model=TaskStatusBulkSyncRead)
|
||||
async def bulk_status_sync(
|
||||
body: TaskStatusBulkSync,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> TaskStatusBulkSyncRead:
|
||||
seen: set[uuid.UUID] = set()
|
||||
duplicate_ids: list[str] = []
|
||||
tasks_by_id: dict[uuid.UUID, Task] = {}
|
||||
missing_ids: list[str] = []
|
||||
|
||||
for update in body.updates:
|
||||
if update.task_id in seen:
|
||||
duplicate_ids.append(str(update.task_id))
|
||||
continue
|
||||
seen.add(update.task_id)
|
||||
task = await session.get(Task, update.task_id)
|
||||
if task is None:
|
||||
missing_ids.append(str(update.task_id))
|
||||
else:
|
||||
tasks_by_id[update.task_id] = task
|
||||
|
||||
if duplicate_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"message": "duplicate task_id values are not allowed", "task_ids": duplicate_ids},
|
||||
)
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"message": "one or more tasks were not found", "task_ids": missing_ids},
|
||||
)
|
||||
|
||||
updated: list[Task] = []
|
||||
events: list[ProgressEvent] = []
|
||||
author = body.author or "custodian"
|
||||
for update in body.updates:
|
||||
task = tasks_by_id[update.task_id]
|
||||
previous_status = status_value(task.status)
|
||||
target_status = status_value(update.status)
|
||||
if update.blocking_reason is not None:
|
||||
task.blocking_reason = update.blocking_reason
|
||||
ws = await session.get(Workstream, task.workstream_id)
|
||||
transition_task_status(
|
||||
task,
|
||||
update.status,
|
||||
parent_workstream=ws,
|
||||
previous_task_status=previous_status,
|
||||
)
|
||||
event = ProgressEvent(
|
||||
task_id=task.id,
|
||||
workstream_id=task.workstream_id,
|
||||
event_type="task_status_changed",
|
||||
summary=f"Task status -> {target_status}: {task.title}",
|
||||
author=author,
|
||||
session_id=body.session_id,
|
||||
detail={
|
||||
"bulk_status_sync": True,
|
||||
"previous_status": previous_status,
|
||||
"status": target_status,
|
||||
"blocking_reason": update.blocking_reason,
|
||||
},
|
||||
)
|
||||
session.add(event)
|
||||
updated.append(task)
|
||||
events.append(event)
|
||||
|
||||
await session.commit()
|
||||
for task in updated:
|
||||
await session.refresh(task)
|
||||
for event in events:
|
||||
await session.refresh(event)
|
||||
|
||||
return TaskStatusBulkSyncRead(
|
||||
updated=updated,
|
||||
progress_event_ids=[event.id for event in events],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{task_id}", response_model=TaskRead)
|
||||
async def get_task(
|
||||
task_id: uuid.UUID,
|
||||
|
||||
@@ -77,6 +77,25 @@ class TaskUpdate(TaskStatusMixin):
|
||||
return self
|
||||
|
||||
|
||||
class TaskStatusBulkUpdate(TaskStatusMixin):
|
||||
task_id: uuid.UUID
|
||||
status: TaskStatus
|
||||
blocking_reason: str | None = None
|
||||
|
||||
|
||||
class TaskStatusBulkSync(BaseModel):
|
||||
updates: list[TaskStatusBulkUpdate]
|
||||
author: str | None = "custodian"
|
||||
session_id: str | None = None
|
||||
|
||||
@field_validator("updates")
|
||||
@classmethod
|
||||
def updates_required(cls, value: list[TaskStatusBulkUpdate]):
|
||||
if not value:
|
||||
raise ValueError("at least one task status update is required")
|
||||
return value
|
||||
|
||||
|
||||
class TaskRead(TaskStatusMixin):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: uuid.UUID
|
||||
@@ -99,3 +118,8 @@ class TaskCountRead(TaskStatusMixin):
|
||||
workstream_id: uuid.UUID
|
||||
status: TaskStatus
|
||||
count: int
|
||||
|
||||
|
||||
class TaskStatusBulkSyncRead(BaseModel):
|
||||
updated: list[TaskRead]
|
||||
progress_event_ids: list[uuid.UUID]
|
||||
|
||||
@@ -31,6 +31,7 @@ endpoint they wrap:
|
||||
| `create_workstream(...)` | `POST /workstreams/` |
|
||||
| `create_task(...)` | `POST /tasks/` |
|
||||
| `update_task_status(...)` | `PATCH /tasks/{task_id}` |
|
||||
| `bulk_update_task_statuses(...)` | `POST /tasks/bulk-status-sync` |
|
||||
| `record_decision(...)` | `POST /decisions/` |
|
||||
| `add_progress_event(...)` | `POST /progress/` |
|
||||
|
||||
@@ -93,6 +94,7 @@ entity while recording the missing progress event.
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
|
||||
|
||||
|
||||
@@ -633,6 +633,34 @@ def update_task_status(
|
||||
return _json_result(task)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def bulk_update_task_statuses(
|
||||
updates: list[dict[str, Any]],
|
||||
author: str | None = "custodian",
|
||||
session_id: str | None = None,
|
||||
) -> str:
|
||||
"""Update many task statuses in one call and emit one progress_event per task.
|
||||
|
||||
Args:
|
||||
updates: list of {task_id, status, blocking_reason?}; status values are
|
||||
wait | todo | progress | done | cancel
|
||||
author: optional progress event author (defaults to custodian)
|
||||
session_id: optional agent session identifier for progress events
|
||||
"""
|
||||
result = _post("/tasks/bulk-status-sync", {
|
||||
"updates": updates,
|
||||
"author": author,
|
||||
"session_id": session_id,
|
||||
})
|
||||
if error := _response_error(
|
||||
"bulk_update_task_statuses",
|
||||
result,
|
||||
("updated", "progress_event_ids"),
|
||||
):
|
||||
return _json_result(error)
|
||||
return _json_result(result)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def flag_for_human(task_id: str, note: str) -> str:
|
||||
"""Flag a task as requiring human intervention.
|
||||
|
||||
65
skills/state-hub/SKILL.md
Normal file
65
skills/state-hub/SKILL.md
Normal file
@@ -0,0 +1,65 @@
|
||||
---
|
||||
name: state-hub
|
||||
description: Use when coordinating with Custodian State Hub: orienting with domain summaries, checking agent inbox messages, updating workplan-backed task status, recording decisions/progress, or batching task status sync through MCP/REST without re-discovering tool schemas.
|
||||
---
|
||||
|
||||
# State Hub Coordination
|
||||
|
||||
Use this skill at the start and close of State Hub aware coding sessions. The
|
||||
hub is a read/cache/index model over repo-owned workplan files; do not invent
|
||||
work structure in the hub when a workplan file is the canon.
|
||||
|
||||
## Session Flow
|
||||
|
||||
1. Orient with `get_domain_summary(domain_slug)` when working inside one domain
|
||||
repo. Use `get_state_summary()` only for cross-domain/custodian-wide work.
|
||||
2. Check inbox with `get_messages(to_agent=<repo-slug>, unread_only=true)`.
|
||||
Mark acted-on messages with `mark_message_read(message_id)`.
|
||||
3. During work, edit the workplan file first. Mirror task/workstream status to
|
||||
the hub at checkpoints.
|
||||
4. Prefer `bulk_update_task_statuses(...)` for checkpoint syncs with multiple
|
||||
task updates. Use `update_task_status(...)` for one-off changes.
|
||||
5. Close with one concise `add_progress_event(...)`, then run the repo's
|
||||
`make fix-consistency REPO=<repo-slug>` command when workplan files changed.
|
||||
|
||||
## High-Frequency MCP Signatures
|
||||
|
||||
```text
|
||||
get_domain_summary(domain_slug: str) -> str
|
||||
get_messages(to_agent?: str, from_agent?: str, unread_only: bool = false, limit: int = 20) -> str
|
||||
send_message(from_agent: str, to_agent: str, subject: str, body: str, thread_id?: str) -> str
|
||||
|
||||
create_workstream(topic_id: str, title: str, slug?: str, description?: str, owner?: str, due_date?: str, repo_id?: str, planning_priority?: str, planning_order?: int) -> str
|
||||
create_task(workstream_id: str, title: str, priority: str = "medium", description?: str, assignee?: str, due_date?: str) -> str
|
||||
update_task_status(task_id: str, status: str, blocking_reason?: str, tokens_in?: int, tokens_out?: int, workplan_tokens_in?: int, workplan_tokens_out?: int, note?: str, model?: str, agent?: str, session_id?: str) -> str
|
||||
bulk_update_task_statuses(updates: list[dict], author?: str = "custodian", session_id?: str) -> str
|
||||
add_progress_event(summary: str, event_type: str = "note", topic_id?: str, workstream_id?: str, task_id?: str, detail?: dict | str) -> str
|
||||
record_decision(title: str, decision_type: str = "pending", topic_id?: str, workstream_id?: str, description?: str, rationale?: str, decided_by?: str, deadline?: str) -> str
|
||||
```
|
||||
|
||||
`bulk_update_task_statuses` updates `N` task statuses in one call:
|
||||
|
||||
```json
|
||||
{
|
||||
"updates": [
|
||||
{"task_id": "<uuid>", "status": "progress"},
|
||||
{"task_id": "<uuid>", "status": "wait", "blocking_reason": "waiting for operator"}
|
||||
],
|
||||
"author": "codex",
|
||||
"session_id": "<optional-session-id>"
|
||||
}
|
||||
```
|
||||
|
||||
Each bulk item emits a `task_status_changed` progress event. Keep separate
|
||||
progress notes coarse: milestones, blockers, handoffs, or final summaries.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Canon lives in files: workplans, `INTENT.md`, repo docs, and commits.
|
||||
- The hub indexes and broadcasts state; it is not a substitute workplan author.
|
||||
- For new multi-step work, create or update the workplan file, then sync.
|
||||
- If MCP returns an error payload, use the matching REST endpoint as fallback
|
||||
and record what happened once the write succeeds.
|
||||
|
||||
For REST paths, response shapes, and fallback examples, read
|
||||
`references/tool-signatures.md`.
|
||||
97
skills/state-hub/references/tool-signatures.md
Normal file
97
skills/state-hub/references/tool-signatures.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# State Hub Tool Signatures
|
||||
|
||||
Load this reference when a session needs exact REST fallback paths or batched
|
||||
write payloads.
|
||||
|
||||
## Orientation
|
||||
|
||||
```text
|
||||
MCP: get_domain_summary(domain_slug)
|
||||
REST: GET /state/summary then filter by topic/domain when MCP is unavailable
|
||||
```
|
||||
|
||||
Use `get_domain_summary("custodian")` inside State Hub work. It returns the
|
||||
domain topic, active workstreams, blocking decisions, recent progress, repos,
|
||||
and compact capability hints.
|
||||
|
||||
## Agent Messages
|
||||
|
||||
```text
|
||||
MCP: get_messages(to_agent?, from_agent?, unread_only?, limit?)
|
||||
REST: GET /messages/?to_agent=<repo>&unread_only=true
|
||||
|
||||
MCP: send_message(from_agent, to_agent, subject, body, thread_id?)
|
||||
REST: POST /messages/
|
||||
|
||||
MCP: mark_message_read(message_id)
|
||||
REST: PATCH /messages/{message_id}/read
|
||||
```
|
||||
|
||||
Use repo slugs as agent names. Use `broadcast` only for genuinely shared
|
||||
coordination.
|
||||
|
||||
## Workstreams and Tasks
|
||||
|
||||
```text
|
||||
MCP: create_workstream(topic_id, title, slug?, description?, owner?, due_date?, repo_id?, planning_priority?, planning_order?)
|
||||
REST: POST /workstreams/
|
||||
|
||||
MCP: create_task(workstream_id, title, priority="medium", description?, assignee?, due_date?)
|
||||
REST: POST /tasks/
|
||||
|
||||
MCP: update_task_status(task_id, status, blocking_reason?, tokens_in?, tokens_out?, workplan_tokens_in?, workplan_tokens_out?, note?, model?, agent?, session_id?)
|
||||
REST: PATCH /tasks/{task_id}
|
||||
```
|
||||
|
||||
Canonical task statuses are `wait`, `todo`, `progress`, `done`, and `cancel`.
|
||||
Legacy aliases are accepted during migration, but do not emit new workplan files
|
||||
with old vocabulary.
|
||||
|
||||
## Bulk Task Status Sync
|
||||
|
||||
```text
|
||||
MCP: bulk_update_task_statuses(updates, author?, session_id?)
|
||||
REST: POST /tasks/bulk-status-sync
|
||||
```
|
||||
|
||||
Payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"updates": [
|
||||
{"task_id": "uuid-1", "status": "progress"},
|
||||
{"task_id": "uuid-2", "status": "done"},
|
||||
{"task_id": "uuid-3", "status": "wait", "blocking_reason": "needs approval"}
|
||||
],
|
||||
"author": "codex",
|
||||
"session_id": "optional-session-id"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"updated": [
|
||||
{"id": "uuid-1", "status": "progress", "...": "..."}
|
||||
],
|
||||
"progress_event_ids": ["event-uuid-1"]
|
||||
}
|
||||
```
|
||||
|
||||
The endpoint rejects duplicate task ids with `400` and missing task ids with
|
||||
`404` before changing any task. Each successful item emits one
|
||||
`task_status_changed` progress event with `detail.bulk_status_sync = true`.
|
||||
|
||||
## Progress and Decisions
|
||||
|
||||
```text
|
||||
MCP: add_progress_event(summary, event_type="note", topic_id?, workstream_id?, task_id?, detail?)
|
||||
REST: POST /progress/
|
||||
|
||||
MCP: record_decision(title, decision_type="pending", topic_id?, workstream_id?, description?, rationale?, decided_by?, deadline?)
|
||||
REST: POST /decisions/
|
||||
```
|
||||
|
||||
Prefer one progress event per checkpoint. A useful close event says what changed,
|
||||
which tests ran, whether consistency sync passed, and what remains.
|
||||
@@ -180,6 +180,50 @@ class TestMCPWriteTools:
|
||||
)
|
||||
]
|
||||
|
||||
async def test_bulk_update_task_statuses_returns_rest_shape(self, monkeypatch):
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def fake_post(path: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
calls.append((path, body))
|
||||
assert path == "/tasks/bulk-status-sync"
|
||||
return {
|
||||
"updated": [
|
||||
{"id": "task-1", "title": "First", "status": "done"},
|
||||
{"id": "task-2", "title": "Second", "status": "wait"},
|
||||
],
|
||||
"progress_event_ids": ["event-1", "event-2"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(server, "_post", fake_post)
|
||||
|
||||
body = await _call_tool(
|
||||
"bulk_update_task_statuses",
|
||||
{
|
||||
"author": "codex",
|
||||
"session_id": "session-1",
|
||||
"updates": [
|
||||
{"task_id": "task-1", "status": "done"},
|
||||
{"task_id": "task-2", "status": "wait", "blocking_reason": "needs input"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert body["progress_event_ids"] == ["event-1", "event-2"]
|
||||
assert [task["status"] for task in body["updated"]] == ["done", "wait"]
|
||||
assert calls == [
|
||||
(
|
||||
"/tasks/bulk-status-sync",
|
||||
{
|
||||
"updates": [
|
||||
{"task_id": "task-1", "status": "done"},
|
||||
{"task_id": "task-2", "status": "wait", "blocking_reason": "needs input"},
|
||||
],
|
||||
"author": "codex",
|
||||
"session_id": "session-1",
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
async def test_record_decision_returns_rest_shape_and_emits_progress(self, monkeypatch):
|
||||
calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
|
||||
121
tests/test_task_bulk_status_sync.py
Normal file
121
tests/test_task_bulk_status_sync.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
async def _create_domain(client, slug: str = "bulk-domain"):
|
||||
r = await client.post("/domains/", json={"slug": slug, "name": "Bulk Domain"})
|
||||
assert r.status_code == 201
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_topic(client, domain_slug: str = "bulk-domain"):
|
||||
r = await client.post(
|
||||
"/topics/",
|
||||
json={"slug": "bulk-topic", "title": "Bulk Topic", "domain": domain_slug},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_workstream(client, topic_id: str):
|
||||
r = await client.post(
|
||||
"/workstreams/",
|
||||
json={"topic_id": topic_id, "slug": "bulk-ws", "title": "Bulk Workstream"},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _create_task(client, workstream_id: str, title: str):
|
||||
r = await client.post(
|
||||
"/tasks/",
|
||||
json={"workstream_id": workstream_id, "title": title},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _seed_two_tasks(client):
|
||||
await _create_domain(client)
|
||||
topic = await _create_topic(client)
|
||||
ws = await _create_workstream(client, topic["id"])
|
||||
first = await _create_task(client, ws["id"], "First bulk task")
|
||||
second = await _create_task(client, ws["id"], "Second bulk task")
|
||||
return ws, first, second
|
||||
|
||||
|
||||
class TestTaskBulkStatusSync:
|
||||
async def test_updates_many_tasks_and_emits_progress_events(self, client):
|
||||
ws, first, second = await _seed_two_tasks(client)
|
||||
|
||||
r = await client.post(
|
||||
"/tasks/bulk-status-sync",
|
||||
json={
|
||||
"author": "codex",
|
||||
"session_id": "session-1",
|
||||
"updates": [
|
||||
{"task_id": first["id"], "status": "progress"},
|
||||
{"task_id": second["id"], "status": "wait", "blocking_reason": "needs operator"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert [task["id"] for task in body["updated"]] == [first["id"], second["id"]]
|
||||
assert [task["status"] for task in body["updated"]] == ["progress", "wait"]
|
||||
assert body["updated"][1]["blocking_reason"] == "needs operator"
|
||||
assert len(body["progress_event_ids"]) == 2
|
||||
|
||||
progress = await client.get("/progress/", params={"workstream_id": ws["id"]})
|
||||
assert progress.status_code == 200
|
||||
events = progress.json()
|
||||
assert [event["id"] for event in events] == body["progress_event_ids"]
|
||||
assert [event["event_type"] for event in events] == ["task_status_changed", "task_status_changed"]
|
||||
assert events[0]["author"] == "codex"
|
||||
assert events[0]["session_id"] == "session-1"
|
||||
assert events[0]["detail"]["bulk_status_sync"] is True
|
||||
assert events[0]["detail"]["previous_status"] == "todo"
|
||||
assert events[0]["detail"]["status"] == "progress"
|
||||
|
||||
async def test_duplicate_task_ids_are_rejected_without_updates(self, client):
|
||||
_, first, _ = await _seed_two_tasks(client)
|
||||
|
||||
r = await client.post(
|
||||
"/tasks/bulk-status-sync",
|
||||
json={
|
||||
"updates": [
|
||||
{"task_id": first["id"], "status": "progress"},
|
||||
{"task_id": first["id"], "status": "done"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert r.json()["detail"]["task_ids"] == [first["id"]]
|
||||
|
||||
task = await client.get(f"/tasks/{first['id']}")
|
||||
assert task.status_code == 200
|
||||
assert task.json()["status"] == "todo"
|
||||
|
||||
async def test_missing_task_ids_are_rejected_without_updates(self, client):
|
||||
import uuid
|
||||
|
||||
_, first, _ = await _seed_two_tasks(client)
|
||||
missing_id = str(uuid.uuid4())
|
||||
|
||||
r = await client.post(
|
||||
"/tasks/bulk-status-sync",
|
||||
json={
|
||||
"updates": [
|
||||
{"task_id": first["id"], "status": "progress"},
|
||||
{"task_id": missing_id, "status": "done"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 404
|
||||
assert r.json()["detail"]["task_ids"] == [missing_id]
|
||||
|
||||
task = await client.get(f"/tasks/{first['id']}")
|
||||
assert task.status_code == 200
|
||||
assert task.json()["status"] == "todo"
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "State Hub Agent Skill — front-load tool schemas + batched writes"
|
||||
domain: custodian
|
||||
repo: state-hub
|
||||
status: ready
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-07"
|
||||
@@ -37,7 +37,7 @@ of sessions).
|
||||
|
||||
```task
|
||||
id: STATE-WP-0058-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "6f211ccc-e505-419d-90cc-25874ba98f39"
|
||||
```
|
||||
@@ -54,7 +54,7 @@ model). Directly targets the `ToolSearch`-thrash finding.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0058-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "2138505a-ea49-4ccc-9dec-a176badaa7a5"
|
||||
```
|
||||
@@ -69,7 +69,7 @@ preserve the automatic `progress_event` semantics on each write.
|
||||
|
||||
```task
|
||||
id: STATE-WP-0058-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "cf8d41f8-7831-4c1b-9dfa-58660174294b"
|
||||
```
|
||||
@@ -83,3 +83,27 @@ workplan updates, notify the operator to run from `~/state-hub`:
|
||||
```bash
|
||||
make fix-consistency REPO=state-hub
|
||||
```
|
||||
|
||||
## Verification Notes
|
||||
|
||||
Completed 2026-06-07:
|
||||
|
||||
- Added `skills/state-hub/SKILL.md` plus
|
||||
`skills/state-hub/references/tool-signatures.md` to front-load the
|
||||
high-frequency State Hub MCP signatures, session flow, ADR-001 files-first
|
||||
boundary, REST fallbacks, and batched-write guidance.
|
||||
- Added `POST /tasks/bulk-status-sync` for checkpoint task-status batching.
|
||||
The endpoint updates all requested task statuses in one transaction, rejects
|
||||
duplicate task ids with `400`, rejects missing task ids with `404`, and emits
|
||||
one `task_status_changed` progress event per successful item.
|
||||
- Added MCP wrapper `bulk_update_task_statuses(updates, author?, session_id?)`
|
||||
and documented it in `mcp_server/TOOLS.md`.
|
||||
- Sent completion/signature handoff to `helix_forge` as message
|
||||
`7236bd1c-b60d-481a-bc05-3080c4b46f72`, followed by tracked-path
|
||||
correction `1a322eed-96d6-45d4-9e0a-685a6f66e180`.
|
||||
|
||||
Verification:
|
||||
|
||||
- `.venv/bin/python -m pytest tests/test_task_bulk_status_sync.py tests/test_mcp_write_tools.py -q` -> 12 passed
|
||||
- `.venv/bin/python -m pytest tests/test_task_bulk_status_sync.py tests/test_mcp_write_tools.py tests/test_mcp_smoke.py -q` -> 25 passed
|
||||
- `git diff --check` -> clean
|
||||
|
||||
Reference in New Issue
Block a user