feat(classification-spine): implement STATE-WP-0065 repo-anchored model

Replace the ad-hoc coordination-domain spine with the Repo Classification
Standard: 14 market domains, classification columns on managed_repos, and
workplans anchored by repo_id (topic_id optional).

- Add Alembic migration d8e9f0a1b2c3 with data backfill and workstream→workplan rename
- Add api/classification.py validation and register-from-classification tooling
- Expose workplan-first REST/MCP surface with legacy workstream aliases
- Add C-24 consistency rule and legacy domain frontmatter mapping
- Update dashboard repos page with category/capability/stake filters
- Update orientation docs; mark STATE-WP-0065 finished
This commit is contained in:
2026-06-22 13:52:13 +02:00
parent 279be4ffbd
commit 0949d4c0d8
84 changed files with 4494 additions and 1111 deletions

View File

@@ -358,18 +358,29 @@ def create_topic(slug: str, title: str, domain: str, description: str | None = N
@mcp.tool()
def list_tasks(workstream_id: str, status: str | None = None) -> str:
"""List all tasks in a workstream, optionally filtered by status.
def list_tasks(
workplan_id: str | None = None,
workstream_id: str | None = None,
status: str | None = None,
) -> str:
"""List all tasks in a workplan, optionally filtered by status.
Args:
workstream_id: UUID of the workstream (required).
workplan_id: UUID of the workplan (preferred).
workstream_id: legacy alias for workplan_id.
status: Optional filter — wait | todo | progress | done | cancel.
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
matching task. Use this to look up task UUIDs before calling update_task_status,
or to check which tasks from a workplan file are already synced to the DB.
"""
return json.dumps(_get("/tasks", {"workstream_id": workstream_id, "status": status}), indent=2)
parent_id = workplan_id or workstream_id
if not parent_id:
return _json_result(_mcp_error("list_tasks", "workplan_id is required"))
return json.dumps(
_get("/tasks", {"workplan_id": parent_id, "workstream_id": parent_id, "status": status}),
indent=2,
)
@mcp.tool()
@@ -455,37 +466,30 @@ def advance_workstation(entity_type: str, entity_id: str, target_workstation: st
# ---------------------------------------------------------------------------
# Mutate tools
# Workplan helpers (preferred) + legacy workstream aliases
# ---------------------------------------------------------------------------
@mcp.tool()
def create_workstream(
topic_id: str,
def _workplan_id_from_response(payload: dict[str, Any]) -> str | None:
return payload.get("workplan_id") or payload.get("workstream_id") or payload.get("id")
def _create_workplan_impl(
*,
repo_id: str,
title: str,
topic_id: str | None = None,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
repo_id: str | None = None,
planning_priority: str | None = None,
planning_order: int | None = None,
tool_name: str = "create_workplan",
) -> 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)
repo_id: UUID of the owning repository (GEMS primary; strongly recommended per ADR-001)
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
planning_order: optional numeric ordering hint inside a repo/domain
"""
if not slug:
slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")
ws = _post("/workstreams", {
wp = _post("/workplans", {
"repo_id": repo_id,
"topic_id": topic_id,
"title": title,
"slug": slug,
@@ -493,30 +497,147 @@ def create_workstream(
"owner": owner,
"due_date": due_date,
"status": "active",
"repo_id": repo_id,
"planning_priority": planning_priority,
"planning_order": planning_order,
})
if error := _response_error("create_workstream", ws, ("id",)):
if error := _response_error(tool_name, wp, ("id",)):
return _json_result(error)
progress_error = _emit_progress_event("create_workstream", ws, {
progress_error = _emit_progress_event(tool_name, wp, {
"topic_id": topic_id,
"workstream_id": ws["id"],
"event_type": "workstream_created",
"summary": f"Workstream created: {title}",
"workplan_id": wp["id"],
"workstream_id": wp["id"],
"event_type": "workplan_created",
"summary": f"Workplan created: {title}",
"author": "custodian",
"detail": {"owner": owner, "slug": slug},
"detail": {"owner": owner, "slug": slug, "repo_id": repo_id},
})
if progress_error:
return _json_result(progress_error)
return _json_result(ws)
return _json_result(wp)
def _update_workplan_status_impl(workplan_id: str, status: str, *, tool_name: str) -> str:
wp = _patch(f"/workplans/{workplan_id}", {"status": status})
if error := _response_error(tool_name, wp, ("id", "title")):
return _json_result(error)
progress_error = _emit_progress_event(tool_name, wp, {
"workplan_id": workplan_id,
"workstream_id": workplan_id,
"topic_id": wp.get("topic_id"),
"event_type": "workplan_status_changed",
"summary": f"Workplan status → {status}: {wp['title']}",
"author": "custodian",
})
if progress_error:
return _json_result(progress_error)
return _json_result(wp)
def _update_workplan_impl(
workplan_id: str,
*,
title: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
repo_goal_id: str | None = None,
status: str | None = None,
) -> str:
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if owner is not None:
payload["owner"] = owner
if due_date is not None:
payload["due_date"] = due_date
if status is not None:
payload["status"] = status
if repo_goal_id is not None:
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
return _json_result(_patch(f"/workplans/{workplan_id}", payload))
# ---------------------------------------------------------------------------
# Mutate tools
# ---------------------------------------------------------------------------
@mcp.tool()
def create_workplan(
repo_id: str,
title: str,
topic_id: str | None = None,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
planning_priority: str | None = None,
planning_order: int | None = None,
) -> str:
"""Create a new repo-anchored workplan and emit a progress_event.
Args:
repo_id: UUID of the owning repository (required)
title: workplan title
topic_id: optional topic UUID for cross-repo tagging
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)
planning_priority: optional planning priority (critical/high/medium/low or repo-local value)
planning_order: optional numeric ordering hint inside a repo
"""
return _create_workplan_impl(
repo_id=repo_id,
title=title,
topic_id=topic_id,
slug=slug,
description=description,
owner=owner,
due_date=due_date,
planning_priority=planning_priority,
planning_order=planning_order,
tool_name="create_workplan",
)
@mcp.tool()
def create_workstream(
title: str,
repo_id: str | None = None,
topic_id: str | None = None,
slug: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
planning_priority: str | None = None,
planning_order: int | None = None,
) -> str:
"""Legacy alias for create_workplan — prefer create_workplan(repo_id=...)."""
if not repo_id:
return _json_result(_mcp_error("create_workstream", "repo_id is required"))
return _create_workplan_impl(
repo_id=repo_id,
title=title,
topic_id=topic_id,
slug=slug,
description=description,
owner=owner,
due_date=due_date,
planning_priority=planning_priority,
planning_order=planning_order,
tool_name="create_workstream",
)
@mcp.tool()
def create_task(
workstream_id: str,
title: str,
workplan_id: str | None = None,
workstream_id: str | None = None,
title: str = "",
priority: str = "medium",
description: str | None = None,
assignee: str | None = None,
@@ -525,15 +646,19 @@ def create_task(
"""Create a new task and emit a progress_event.
Args:
workstream_id: UUID of the parent workstream
workplan_id: UUID of the parent workplan (preferred)
workstream_id: legacy alias for workplan_id
title: task title
priority: low | medium | high | critical
description: optional longer description
assignee: optional assignee name
due_date: optional ISO date string (YYYY-MM-DD)
"""
parent_id = workplan_id or workstream_id
if not parent_id:
return _json_result(_mcp_error("create_task", "workplan_id is required"))
task = _post("/tasks", {
"workstream_id": workstream_id,
"workplan_id": parent_id,
"title": title,
"priority": priority,
"description": description,
@@ -544,7 +669,8 @@ def create_task(
return _json_result(error)
progress_error = _emit_progress_event("create_task", task, {
"workstream_id": workstream_id,
"workplan_id": parent_id,
"workstream_id": parent_id,
"task_id": task["id"],
"event_type": "task_created",
"summary": f"Task created: {title}",
@@ -865,27 +991,81 @@ def add_progress_event(
@mcp.tool()
def update_workstream_status(workstream_id: str, status: str) -> str:
"""Update a workstream's status.
def list_workplans(
repo_id: str | None = None,
topic_id: str | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
) -> str:
"""List workplans with optional filters."""
return json.dumps(
_get("/workplans", {
"repo_id": repo_id,
"topic_id": topic_id,
"status": status,
"owner": owner,
"slug": slug,
}),
indent=2,
)
@mcp.tool()
def list_workstreams(
topic_id: str | None = None,
repo_id: str | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
) -> str:
"""Legacy alias for list_workplans."""
return list_workplans(
repo_id=repo_id,
topic_id=topic_id,
status=status,
owner=owner,
slug=slug,
)
@mcp.tool()
def update_workplan_status(workplan_id: str, status: str) -> str:
"""Update a workplan's status.
Args:
workstream_id: UUID of the workstream
workplan_id: UUID of the workplan
status: proposed | ready | active | blocked | backlog | finished | archived
"""
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
if error := _response_error("update_workstream_status", ws, ("id", "title")):
return _json_result(error)
return _update_workplan_status_impl(workplan_id, status, tool_name="update_workplan_status")
progress_error = _emit_progress_event("update_workstream_status", ws, {
"workstream_id": workstream_id,
"topic_id": ws.get("topic_id"),
"event_type": "workstream_status_changed",
"summary": f"Workstream status → {status}: {ws['title']}",
"author": "custodian",
})
if progress_error:
return _json_result(progress_error)
return _json_result(ws)
@mcp.tool()
def update_workstream_status(workstream_id: str, status: str) -> str:
"""Legacy alias for update_workplan_status."""
return _update_workplan_status_impl(workstream_id, status, tool_name="update_workstream_status")
@mcp.tool()
def update_workplan(
workplan_id: str,
title: str | None = None,
description: str | None = None,
owner: str | None = None,
due_date: str | None = None,
repo_goal_id: str | None = None,
status: str | None = None,
) -> str:
"""Update fields on an existing workplan."""
return _update_workplan_impl(
workplan_id,
title=title,
description=description,
owner=owner,
due_date=due_date,
repo_goal_id=repo_goal_id,
status=status,
)
@mcp.tool()
@@ -898,32 +1078,16 @@ def update_workstream(
repo_goal_id: str | None = None,
status: str | None = None,
) -> str:
"""Update fields on an existing workstream.
Args:
workstream_id: UUID of the workstream
title: new title (optional)
description: new description (optional)
owner: new owner (optional)
due_date: ISO date string YYYY-MM-DD (optional)
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
status: proposed | ready | active | blocked | backlog | finished | archived (optional)
"""
payload: dict = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if owner is not None:
payload["owner"] = owner
if due_date is not None:
payload["due_date"] = due_date
if status is not None:
payload["status"] = status
if repo_goal_id is not None:
payload["repo_goal_id"] = repo_goal_id if repo_goal_id else None
ws = _patch(f"/workstreams/{workstream_id}", payload)
return json.dumps(ws, indent=2)
"""Legacy alias for update_workplan."""
return _update_workplan_impl(
workstream_id,
title=title,
description=description,
owner=owner,
due_date=due_date,
repo_goal_id=repo_goal_id,
status=status,
)
# ---------------------------------------------------------------------------
@@ -951,6 +1115,41 @@ def get_next_steps() -> str:
# Dependency graph tools (S1.4)
# ---------------------------------------------------------------------------
def _create_dependency_impl(
*,
from_workplan_id: str,
to_workplan_id: str | None = None,
to_task_id: str | None = None,
relationship_type: str = "blocks",
description: str | None = None,
) -> str:
dep = _post(f"/workplans/{from_workplan_id}/dependencies", {
"to_workplan_id": to_workplan_id,
"to_task_id": to_task_id,
"relationship_type": relationship_type,
"description": description,
})
return json.dumps(dep, indent=2)
@mcp.tool()
def create_workplan_dependency(
from_workplan_id: str,
to_workplan_id: str | None = None,
to_task_id: str | None = None,
relationship_type: str = "blocks",
description: str | None = None,
) -> str:
"""Record that one workplan depends on another workplan or task."""
return _create_dependency_impl(
from_workplan_id=from_workplan_id,
to_workplan_id=to_workplan_id,
to_task_id=to_task_id,
relationship_type=relationship_type,
description=description,
)
@mcp.tool()
def create_dependency(
from_workstream_id: str,
@@ -959,25 +1158,14 @@ def create_dependency(
relationship_type: str = "blocks",
description: str | None = None,
) -> str:
"""Record that one workstream depends on another workstream or task.
Semantics: from_workstream cannot fully proceed until the target reaches
a satisfactory state. Provide exactly one of to_workstream_id or to_task_id.
Args:
from_workstream_id: UUID of the workstream that has the dependency
to_workstream_id: UUID of the workstream it depends on
to_task_id: UUID of the task it depends on
relationship_type: blocks | starts_after | informs | soft_dependency
description: optional human-readable explanation of the dependency
"""
dep = _post(f"/workstreams/{from_workstream_id}/dependencies", {
"to_workstream_id": to_workstream_id,
"to_task_id": to_task_id,
"relationship_type": relationship_type,
"description": description,
})
return json.dumps(dep, indent=2)
"""Legacy alias for create_workplan_dependency."""
return _create_dependency_impl(
from_workplan_id=from_workstream_id,
to_workplan_id=to_workstream_id,
to_task_id=to_task_id,
relationship_type=relationship_type,
description=description,
)
@mcp.tool()
@@ -990,9 +1178,15 @@ def list_dependencies(workstream_id: str) -> str:
Args:
workstream_id: UUID of the workstream to inspect
"""
edges = _get(f"/workstreams/{workstream_id}/dependencies")
depends_on = [e for e in edges if e["from_workstream_id"] == workstream_id]
blocks = [e for e in edges if e.get("to_workstream_id") == workstream_id]
edges = _get(f"/workplans/{workstream_id}/dependencies")
depends_on = [
e for e in edges
if e.get("from_workplan_id", e.get("from_workstream_id")) == workstream_id
]
blocks = [
e for e in edges
if e.get("to_workplan_id", e.get("to_workstream_id")) == workstream_id
]
return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2)
@@ -1227,13 +1421,48 @@ def archive_domain(slug: str) -> str:
@mcp.tool()
def list_domain_repos(domain_slug: str) -> str:
"""List all repositories registered under a domain.
def list_domain_repos(
domain_slug: str,
category: str | None = None,
capability_tag: str | None = None,
business_stake: str | None = None,
) -> str:
"""List repositories registered under a domain, with optional classification filters.
Args:
domain_slug: Domain slug to filter by
category: optional repo classification category
capability_tag: optional capability tag filter
business_stake: optional business stake filter
"""
return json.dumps(_get("/repos", {"domain": domain_slug}), indent=2)
return json.dumps(
_get("/repos", {
"domain": domain_slug,
"category": category,
"capability_tag": capability_tag,
"business_stake": business_stake,
}),
indent=2,
)
@mcp.tool()
def list_repos_by_classification(
category: str | None = None,
domain: str | None = None,
capability_tag: str | None = None,
business_stake: str | None = None,
) -> str:
"""List repos filtered by classification spine fields."""
return json.dumps(
_get("/repos", {
"domain": domain,
"category": category,
"capability_tag": capability_tag,
"business_stake": business_stake,
}),
indent=2,
)
@mcp.tool()
@@ -1275,6 +1504,60 @@ def register_repo(
return json.dumps(repo, indent=2)
@mcp.tool()
def register_repo_from_classification(
repo_slug: str,
dry_run: bool = False,
) -> str:
"""Register or update a repo from its committed ``.repo-classification.yaml``.
Reads the classification file from the repo's local checkout (this host's
registered path), validates against the canon allowed-values, and upserts the
``managed_repo`` row including market-domain assignment.
Args:
repo_slug: Registered repo slug (e.g. 'state-hub', 'the-custodian').
dry_run: If True, report what would change without writing.
"""
import subprocess
script = Path(__file__).parent.parent / "scripts" / "register_from_classification.py"
cmd = [
sys.executable,
str(script),
"--slug",
repo_slug,
"--json",
]
if dry_run:
cmd.append("--dry-run")
result = subprocess.run(cmd, capture_output=True, text=True)
try:
data = json.loads(result.stdout)
except json.JSONDecodeError:
return (
f"register-from-classification failed (exit {result.returncode}):\n"
f"{result.stderr or result.stdout or '(no output)'}"
)
summary = data.get("summary", {})
lines = [
f"register-from-classification: {repo_slug}",
(
f"registered={summary.get('registered', 0)} "
f"updated={summary.get('updated', 0)} "
f"skipped={summary.get('skipped', 0)} "
f"invalid={summary.get('invalid', 0)}"
),
]
for row in data.get("results", []):
lines.append(f" [{row.get('outcome')}] {row.get('detail', '')}")
if result.returncode != 0:
lines.append("(completed with invalid rows)")
return "\n".join(lines)
@mcp.tool()
def update_repo_path(repo_slug: str, path: str, host: str | None = None) -> str:
"""Register or update the local filesystem path for a repo on a specific host.