Add create-workstream: MCP tool, CLI commands, dashboard hint

MCP server: add create_workstream(topic_id, title, slug?, owner?,
  description?, due_date?) — auto-generates slug from title if omitted;
  emits workstream_created progress event. Now 12 tools total.

CLI: add two new subcommands —
  custodian create-workstream --domain DOMAIN --title TITLE [--slug] [--owner] [--description]
  custodian create-task --workstream ID_OR_SLUG --title TITLE [--priority] [--assignee]
  create-task accepts workstream UUID or slug (resolves via API).

Dashboard: hint box below "Open Workstreams by Domain" chart listing
  registered domains that have zero workstreams, with the exact
  custodian create-workstream command to run.

TOOLS.md: updated tool count (11 → 12) and added create_workstream row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 23:35:54 +01:00
parent 34b1114a01
commit ebe7369249
4 changed files with 173 additions and 3 deletions

View File

@@ -161,6 +161,88 @@ def cmd_register(args: argparse.Namespace) -> None:
print("Next: restart Claude Code for the MCP server to be active in this project.") print("Next: restart Claude Code for the MCP server to be active in this project.")
def cmd_create_workstream(args: argparse.Namespace) -> None:
"""Create a workstream under a domain's topic."""
_api_get("/state/health")
# Resolve topic_id from domain
topics = _api_get("/topics/?status=active")
match = next((t for t in topics if t.get("domain") == args.domain), None)
if not match:
print(f"ERROR: No active topic for domain '{args.domain}'.")
sys.exit(1)
topic_id = match["id"]
slug = args.slug or re.sub(r"[^a-z0-9]+", "-", args.title.lower()).strip("-")
ws = _api_post("/workstreams/", {
"topic_id": topic_id,
"title": args.title,
"slug": slug,
"description": args.description,
"owner": args.owner,
"status": "active",
})
_api_post("/progress/", {
"topic_id": topic_id,
"workstream_id": ws["id"],
"event_type": "workstream_created",
"summary": f"Workstream created: {args.title}",
"author": "custodian",
"detail": {"owner": args.owner, "slug": slug},
})
print(f"Created workstream: {ws['title']}")
print(f" id: {ws['id']}")
print(f" slug: {ws['slug']}")
print(f" domain: {args.domain}")
print(f" owner: {ws.get('owner') or ''}")
def cmd_create_task(args: argparse.Namespace) -> None:
"""Create a task under a workstream (by ID or slug)."""
_api_get("/state/health")
# Resolve workstream: accept UUID or slug
workstream_id = args.workstream
if not _is_uuid(workstream_id):
wss = _api_get("/workstreams/")
match = next((w for w in wss if w.get("slug") == workstream_id), None)
if not match:
print(f"ERROR: No workstream found with slug '{workstream_id}'.")
print(" Use 'custodian status' or check the dashboard for valid slugs.")
sys.exit(1)
workstream_id = match["id"]
task = _api_post("/tasks/", {
"workstream_id": workstream_id,
"title": args.title,
"priority": args.priority,
"description": args.description,
"assignee": args.assignee,
})
_api_post("/progress/", {
"workstream_id": workstream_id,
"task_id": task["id"],
"event_type": "task_created",
"summary": f"Task created: {args.title}",
"author": "custodian",
"detail": {"priority": args.priority},
})
print(f"Created task: {task['title']}")
print(f" id: {task['id']}")
print(f" priority: {task['priority']}")
print(f" status: {task['status']}")
def _is_uuid(s: str) -> bool:
import uuid as _uuid
try:
_uuid.UUID(s)
return True
except ValueError:
return False
def cmd_status(_args: argparse.Namespace) -> None: def cmd_status(_args: argparse.Namespace) -> None:
"""Quick status: API health + summary totals.""" """Quick status: API health + summary totals."""
health = _api_get("/state/health") health = _api_get("/state/health")
@@ -202,6 +284,22 @@ def main() -> None:
help="Project directory (defaults to current directory)", help="Project directory (defaults to current directory)",
) )
# create-workstream
cws = sub.add_parser("create-workstream", help="Create a workstream under a domain topic")
cws.add_argument("--domain", choices=VALID_DOMAINS, required=True, help="Domain to create the workstream under")
cws.add_argument("--title", required=True, help="Workstream title")
cws.add_argument("--slug", default=None, help="URL slug (auto-generated from title if omitted)")
cws.add_argument("--owner", default=None, help="Owner name")
cws.add_argument("--description", default=None, help="Optional description")
# create-task
ctask = sub.add_parser("create-task", help="Create a task under a workstream")
ctask.add_argument("--workstream", required=True, metavar="ID_OR_SLUG", help="Workstream UUID or slug")
ctask.add_argument("--title", required=True, help="Task title")
ctask.add_argument("--priority", choices=["low", "medium", "high", "critical"], default="medium")
ctask.add_argument("--assignee", default=None)
ctask.add_argument("--description", default=None)
# status # status
sub.add_parser("status", help="Show State Hub health and summary totals") sub.add_parser("status", help="Show State Hub health and summary totals")
@@ -209,6 +307,10 @@ def main() -> None:
if args.command == "register-project": if args.command == "register-project":
cmd_register(args) cmd_register(args)
elif args.command == "create-workstream":
cmd_create_workstream(args)
elif args.command == "create-task":
cmd_create_task(args)
elif args.command == "status": elif args.command == "status":
cmd_status(args) cmd_status(args)

View File

@@ -134,6 +134,26 @@ display(Plot.plot({
})); }));
``` ```
```js
// Registered domains with no workstreams yet — show a getting-started hint
const regs = regsState ?? [];
const registeredDomains = new Set(regs.map(e => e.detail?.domain).filter(Boolean));
const emptyRegistered = (summary.topics ?? []).filter(t =>
registeredDomains.has(t.domain) && (t.workstreams ?? []).length === 0
);
if (emptyRegistered.length > 0) {
display(html`<div class="hint-box">
<strong>💡 Getting started</strong>
<p>These registered projects have no workstreams yet:</p>
<ul>${emptyRegistered.map(t => html`<li>
<strong>${t.domain}</strong> — open the repo in Claude Code and ask the Custodian to create one, or run:<br>
<code>custodian create-workstream --domain ${t.domain} --title "My first workstream"</code>
</li>`)}</ul>
</div>`);
}
```
## Blocking Decisions ## Blocking Decisions
```js ```js
@@ -183,4 +203,6 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
.card.warn { border: 2px solid orange; } .card.warn { border: 2px solid orange; }
.big-num { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; } .big-num { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; }
.warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; } .warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; }
.hint-box { background: var(--theme-background-alt); border-left: 3px solid steelblue; border-radius: 4px; padding: 0.75rem 1rem; margin-top: 0.75rem; font-size: 0.9rem; }
.hint-box code { background: var(--theme-background); padding: 0.15rem 0.4rem; border-radius: 3px; font-size: 0.85rem; }
</style> </style>

View File

@@ -1,6 +1,6 @@
# State Hub MCP — Tool Reference Card # State Hub MCP — Tool Reference Card
Quick reference for all 11 tools and 5 resources. Read this instead of `server.py`. Quick reference for all 12 tools and 5 resources. Read this instead of `server.py`.
## Query Tools (read-only) ## Query Tools (read-only)
@@ -16,6 +16,7 @@ Quick reference for all 11 tools and 5 resources. Read this instead of `server.p
| Tool | Key Args | Notes | | Tool | Key Args | Notes |
|------|----------|-------| |------|----------|-------|
| `create_workstream(topic_id, title, ...)` | `slug?` (auto-generated); `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_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | | | `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | |
| `record_decision(title, ...)` | `decision_type`: made/pending; `topic_id?`; `workstream_id?`; `deadline?` | Financial/legal + pending → auto-escalated per constitution §4. At least one of topic_id/workstream_id required. | | `record_decision(title, ...)` | `decision_type`: made/pending; `topic_id?`; `workstream_id?`; `deadline?` | Financial/legal + pending → auto-escalated per constitution §4. At least one of topic_id/workstream_id required. |
@@ -40,8 +41,11 @@ Quick reference for all 11 tools and 5 resources. Read this instead of `server.p
## Common Patterns ## Common Patterns
```python ```python
# New workstream (via API — no MCP tool yet): # New workstream:
# POST /workstreams/ {"topic_id": "...", "slug": "...", "title": "...", "status": "active", "owner": "..."} create_workstream(topic_id="<uuid>", title="My Workstream", owner="me")
# New task:
create_task(workstream_id="<uuid>", title="Do the thing", priority="high")
# Session start ritual: # Session start ritual:
get_state_summary() get_state_summary()

View File

@@ -7,6 +7,7 @@ from __future__ import annotations
import json import json
import os import os
import re
import sys import sys
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -156,6 +157,47 @@ def get_recent_progress(limit: int = 20, since: str | None = None) -> str:
# Mutate tools # Mutate tools
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@mcp.tool()
def create_workstream(
topic_id: str,
title: str,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
) -> str:
"""Create a new workstream under a topic and emit a progress_event.
Args:
topic_id: UUID of the parent topic
title: workstream title
slug: URL-friendly identifier (auto-generated from title if omitted)
description: optional longer description
owner: optional owner name
due_date: optional ISO date string (YYYY-MM-DD)
"""
if not slug:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
ws = _post("/workstreams", {
"topic_id": topic_id,
"title": title,
"slug": slug,
"description": description,
"owner": owner,
"due_date": due_date,
"status": "active",
})
_post("/progress", {
"topic_id": topic_id,
"workstream_id": ws["id"],
"event_type": "workstream_created",
"summary": f"Workstream created: {title}",
"author": "custodian",
"detail": {"owner": owner, "slug": slug},
})
return json.dumps(ws, indent=2)
@mcp.tool() @mcp.tool()
def create_task( def create_task(
workstream_id: str, workstream_id: str,