From fa2b6ff69845c64cb6a331f53abe6f42c6342207 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 24 Feb 2026 23:35:54 +0100 Subject: [PATCH] Add create-workstream: MCP tool, CLI commands, dashboard hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- state-hub/custodian_cli.py | 102 +++++++++++++++++++++++++++++++ state-hub/dashboard/src/index.md | 22 +++++++ state-hub/mcp_server/TOOLS.md | 10 ++- state-hub/mcp_server/server.py | 42 +++++++++++++ 4 files changed, 173 insertions(+), 3 deletions(-) diff --git a/state-hub/custodian_cli.py b/state-hub/custodian_cli.py index cabe266..408ad72 100644 --- a/state-hub/custodian_cli.py +++ b/state-hub/custodian_cli.py @@ -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.") +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: """Quick status: API health + summary totals.""" health = _api_get("/state/health") @@ -202,6 +284,22 @@ def main() -> None: 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 sub.add_parser("status", help="Show State Hub health and summary totals") @@ -209,6 +307,10 @@ def main() -> None: if args.command == "register-project": 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": cmd_status(args) diff --git a/state-hub/dashboard/src/index.md b/state-hub/dashboard/src/index.md index f9d3998..1c549df 100644 --- a/state-hub/dashboard/src/index.md +++ b/state-hub/dashboard/src/index.md @@ -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`
+ 💡 Getting started +

These registered projects have no workstreams yet:

+
    ${emptyRegistered.map(t => html`
  • + ${t.domain} — open the repo in Claude Code and ask the Custodian to create one, or run:
    + custodian create-workstream --domain ${t.domain} --title "My first workstream" +
  • `)}
+
`); +} +``` + ## Blocking Decisions ```js @@ -183,4 +203,6 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .card.warn { border: 2px solid orange; } .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; } +.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; } diff --git a/state-hub/mcp_server/TOOLS.md b/state-hub/mcp_server/TOOLS.md index eca80f8..109f4c5 100644 --- a/state-hub/mcp_server/TOOLS.md +++ b/state-hub/mcp_server/TOOLS.md @@ -1,6 +1,6 @@ # 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) @@ -16,6 +16,7 @@ Quick reference for all 11 tools and 5 resources. Read this instead of `server.p | 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. | | `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. | @@ -40,8 +41,11 @@ Quick reference for all 11 tools and 5 resources. Read this instead of `server.p ## Common Patterns ```python -# New workstream (via API — no MCP tool yet): -# POST /workstreams/ {"topic_id": "...", "slug": "...", "title": "...", "status": "active", "owner": "..."} +# New workstream: +create_workstream(topic_id="", title="My Workstream", owner="me") + +# New task: +create_task(workstream_id="", title="Do the thing", priority="high") # Session start ritual: get_state_summary() diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index 7337f25..7971472 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/mcp_server/server.py @@ -7,6 +7,7 @@ from __future__ import annotations import json import os +import re import sys from datetime import datetime from typing import Any @@ -156,6 +157,47 @@ def get_recent_progress(limit: int = 20, since: str | None = None) -> str: # 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() def create_task( workstream_id: str,