From f34b49ebde3c33b47fb47487ad7bd45a185dbe69 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 25 Feb 2026 23:33:14 +0100 Subject: [PATCH] Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S0 — Design boundary formalised across all integration surfaces: - TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools, and Bootstrap-Only Tools (create_workstream, create_task) with explicit note - project_claude_md.template and railiance CLAUDE.md updated with boundary note and get_next_steps() in session start protocol - Global ~/.claude/CLAUDE.md updated accordingly S1 — Workstream dependency graph: - WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint) - Alembic migration 0b547c153153; script.py.mako added (was missing) - REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete) - StateSummary open_workstreams enriched with depends_on/blocks lists - MCP tools: create_dependency(), list_dependencies() - Dashboard workstreams page: Dependencies section with relationship cards - Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline S2 — Suggesting Next Steps (sanctioned write use case #2): - GET /state/next_steps derives suggestions from recently resolved decisions (→ first open task in same workstream) and cleared dependencies (→ first todo task in now-unblocked workstream) - StateSummary.next_steps included on every summary call - MCP tool: get_next_steps() - Dashboard: "What's next?" card grid above Registered Projects Co-Authored-By: Claude Sonnet 4.6 --- api/main.py | 5 +- api/models/__init__.py | 2 + api/models/workstream_dependency.py | 45 +++++ api/routers/state.py | 189 +++++++++++++++++- api/routers/workstream_dependencies.py | 80 ++++++++ api/schemas/state.py | 23 ++- api/schemas/workstream.py | 7 + api/schemas/workstream_dependency.py | 28 +++ dashboard/src/index.md | 47 +++++ dashboard/src/workstreams.md | 63 +++++- mcp_server/TOOLS.md | 70 +++++-- mcp_server/server.py | 70 +++++++ migrations/script.py.mako | 26 +++ ...b547c153153_add_workstream_dependencies.py | 45 +++++ scripts/project_claude_md.template | 13 +- 15 files changed, 678 insertions(+), 35 deletions(-) create mode 100644 api/models/workstream_dependency.py create mode 100644 api/routers/workstream_dependencies.py create mode 100644 api/schemas/workstream_dependency.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/0b547c153153_add_workstream_dependencies.py diff --git a/api/main.py b/api/main.py index e776a1c..f0949cf 100644 --- a/api/main.py +++ b/api/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from api.database import engine -from api.routers import decisions, progress, state, tasks, topics, workstreams +from api.routers import decisions, progress, state, tasks, topics, workstreams, workstream_dependencies @asynccontextmanager @@ -23,12 +23,13 @@ app = FastAPI( app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], - allow_methods=["GET", "POST", "PATCH"], + allow_methods=["GET", "POST", "PATCH", "DELETE"], allow_headers=["Content-Type"], ) app.include_router(topics.router) app.include_router(workstreams.router) +app.include_router(workstream_dependencies.router) app.include_router(tasks.router) app.include_router(decisions.router) app.include_router(progress.router) diff --git a/api/models/__init__.py b/api/models/__init__.py index 455f824..f9c2107 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -1,6 +1,7 @@ from api.models.base import Base from api.models.topic import Topic, TopicStatus, Domain from api.models.workstream import Workstream, WorkstreamStatus +from api.models.workstream_dependency import WorkstreamDependency from api.models.task import Task, TaskStatus, TaskPriority from api.models.decision import Decision, DecisionType, DecisionStatus from api.models.progress_event import ProgressEvent @@ -9,6 +10,7 @@ __all__ = [ "Base", "Topic", "TopicStatus", "Domain", "Workstream", "WorkstreamStatus", + "WorkstreamDependency", "Task", "TaskStatus", "TaskPriority", "Decision", "DecisionType", "DecisionStatus", "ProgressEvent", diff --git a/api/models/workstream_dependency.py b/api/models/workstream_dependency.py new file mode 100644 index 0000000..e0ebcd4 --- /dev/null +++ b/api/models/workstream_dependency.py @@ -0,0 +1,45 @@ +import uuid + +from sqlalchemy import ForeignKey, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from api.models.base import Base, TimestampMixin, new_uuid + + +class WorkstreamDependency(Base, TimestampMixin): + """Directed dependency edge: `from_workstream` depends on `to_workstream`. + + Semantics: `to_workstream` must reach a satisfactory state before + `from_workstream` can fully proceed. Hard deletes are intentional — + removing an edge removes a constraint, not information. + """ + + __tablename__ = "workstream_dependencies" + __table_args__ = ( + UniqueConstraint("from_workstream_id", "to_workstream_id", name="uq_ws_dep_pair"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=new_uuid + ) + from_workstream_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workstreams.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + to_workstream_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workstreams.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + + from_workstream: Mapped["Workstream"] = relationship( # noqa: F821 + "Workstream", foreign_keys=[from_workstream_id] + ) + to_workstream: Mapped["Workstream"] = relationship( # noqa: F821 + "Workstream", foreign_keys=[to_workstream_id] + ) diff --git a/api/routers/state.py b/api/routers/state.py index 97593b2..f217eda 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends from fastapi.responses import JSONResponse @@ -8,13 +8,15 @@ from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session, engine from api.models.decision import Decision, DecisionStatus, DecisionType from api.models.progress_event import ProgressEvent -from api.models.task import Task, TaskStatus +from api.models.task import Task, TaskPriority, TaskStatus from api.models.topic import Topic, TopicStatus from api.models.workstream import Workstream, WorkstreamStatus +from api.models.workstream_dependency import WorkstreamDependency from api.schemas.decision import DecisionRead from api.schemas.progress_event import ProgressEventRead from api.schemas.state import ( DecisionTotals, + NextStep, StateSummary, TaskTotals, Totals, @@ -23,7 +25,8 @@ from api.schemas.state import ( ) from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts +from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps +from api.schemas.workstream_dependency import WorkstreamDepStub router = APIRouter(prefix="/state", tags=["state"]) @@ -70,6 +73,53 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ): task_per_ws.setdefault(ws_id, {})[tstat] = cnt + # Dependency graph for open workstreams + open_ws_ids = [w.id for w in open_ws] + dep_rows = [] + if open_ws_ids: + dep_result = await session.execute( + select(WorkstreamDependency).where( + (WorkstreamDependency.from_workstream_id.in_(open_ws_ids)) + | (WorkstreamDependency.to_workstream_id.in_(open_ws_ids)) + ) + ) + dep_rows = list(dep_result.scalars().all()) + + # Build a slug+title lookup for all workstreams referenced in deps + dep_ws_ids = set() + for d in dep_rows: + dep_ws_ids.add(d.from_workstream_id) + dep_ws_ids.add(d.to_workstream_id) + ws_lookup: dict = {w.id: w for w in open_ws} + extra_ids = dep_ws_ids - set(ws_lookup.keys()) + if extra_ids: + extra_rows = await session.execute( + select(Workstream).where(Workstream.id.in_(extra_ids)) + ) + for w in extra_rows.scalars(): + ws_lookup[w.id] = w + + # Index: workstream_id → (depends_on stubs, blocks stubs) + dep_index: dict = {w.id: {"depends_on": [], "blocks": []} for w in open_ws} + for d in dep_rows: + from_id, to_id = d.from_workstream_id, d.to_workstream_id + if from_id in dep_index and to_id in ws_lookup: + dep_index[from_id]["depends_on"].append(WorkstreamDepStub( + dep_id=d.id, + workstream_id=to_id, + workstream_slug=ws_lookup[to_id].slug, + workstream_title=ws_lookup[to_id].title, + description=d.description, + )) + if to_id in dep_index and from_id in ws_lookup: + dep_index[to_id]["blocks"].append(WorkstreamDepStub( + dep_id=d.id, + workstream_id=from_id, + workstream_slug=ws_lookup[from_id].slug, + workstream_title=ws_lookup[from_id].title, + description=d.description, + )) + # Totals — one GROUP BY per table topic_counts = {r[0]: r[1] for r in await session.execute( select(Topic.status, func.count()).group_by(Topic.status) @@ -115,6 +165,8 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm ), ) + next_steps = await _derive_next_steps(session) + return StateSummary( generated_at=datetime.now(tz=timezone.utc), totals=totals, @@ -122,20 +174,149 @@ async def get_summary(session: AsyncSession = Depends(get_session)) -> StateSumm blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], blocked_tasks=[TaskRead.model_validate(t) for t in blocked], recent_progress=[ProgressEventRead.model_validate(e) for e in recent], + next_steps=next_steps, open_workstreams=[ - WorkstreamWithTaskCounts( + WorkstreamWithDeps( **WorkstreamRead.model_validate(w).model_dump(), tasks_total=sum(task_per_ws.get(w.id, {}).values()), tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0), tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0), tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), + depends_on=dep_index.get(w.id, {}).get("depends_on", []), + blocks=dep_index.get(w.id, {}).get("blocks", []), ) for w in open_ws ], ) +_PRIORITY_RANK = { + TaskPriority.critical: 0, + TaskPriority.high: 1, + TaskPriority.medium: 2, + TaskPriority.low: 3, +} + + +async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: + """Derive contextual next-action suggestions from current hub state. + + Two signal sources: + 1. Recently resolved decisions (last 7 days) → first open task in same workstream + 2. Workstreams whose every dependency is now completed → first todo task in that workstream + """ + steps: list[NextStep] = [] + seen_task_ids: set = set() + + # ── Signal 1: recently resolved decisions ──────────────────────────────── + cutoff = datetime.now(tz=timezone.utc) - timedelta(days=7) + resolved_rows = await session.execute( + select(Decision) + .where(Decision.status == DecisionStatus.resolved) + .where(Decision.decided_at >= cutoff) + .where(Decision.workstream_id.isnot(None)) + .order_by(Decision.decided_at.desc()) + ) + for decision in resolved_rows.scalars().all(): + open_tasks_rows = await session.execute( + select(Task) + .where(Task.workstream_id == decision.workstream_id) + .where(Task.status.in_([TaskStatus.todo, TaskStatus.in_progress])) + ) + open_tasks = list(open_tasks_rows.scalars().all()) + if not open_tasks: + continue + task = min(open_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) + if task.id in seen_task_ids: + continue + ws = await session.get(Workstream, decision.workstream_id) + topic = await session.get(Topic, ws.topic_id) if ws else None + steps.append(NextStep( + type="resolved_decision", + domain=topic.domain if topic else None, + workstream_id=ws.id if ws else None, + workstream_title=ws.title if ws else None, + workstream_slug=ws.slug if ws else None, + task_id=task.id, + task_title=task.title, + message=( + f"Decision '{decision.title}' was resolved → " + f"'{task.title}' is the next open task in '{ws.title if ws else '?'}'" + ), + )) + seen_task_ids.add(task.id) + + # ── Signal 2: cleared dependencies ────────────────────────────────────── + all_dep_rows = await session.execute(select(WorkstreamDependency)) + all_deps = list(all_dep_rows.scalars().all()) + + # Group from_workstream_id → set of to_workstream_ids + dep_map: dict = {} + for d in all_deps: + dep_map.setdefault(d.from_workstream_id, set()).add(d.to_workstream_id) + + for from_ws_id, to_ws_ids in dep_map.items(): + # All targets must be completed + all_done = True + for to_id in to_ws_ids: + to_ws = await session.get(Workstream, to_id) + if to_ws is None or to_ws.status != WorkstreamStatus.completed: + all_done = False + break + if not all_done: + continue + + from_ws = await session.get(Workstream, from_ws_id) + if from_ws is None or from_ws.status not in (WorkstreamStatus.active, WorkstreamStatus.blocked): + continue + + todo_rows = await session.execute( + select(Task) + .where(Task.workstream_id == from_ws_id) + .where(Task.status == TaskStatus.todo) + ) + todo_tasks = list(todo_rows.scalars().all()) + if not todo_tasks: + continue + task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) + if task.id in seen_task_ids: + continue + topic = await session.get(Topic, from_ws.topic_id) + blocker_slugs = ", ".join( + (await session.get(Workstream, tid)).slug + for tid in to_ws_ids + if await session.get(Workstream, tid) + ) + steps.append(NextStep( + type="dependency_cleared", + domain=topic.domain if topic else None, + workstream_id=from_ws.id, + workstream_title=from_ws.title, + workstream_slug=from_ws.slug, + task_id=task.id, + task_title=task.title, + message=( + f"All dependencies of '{from_ws.title}' are completed ({blocker_slugs}) → " + f"'{task.title}' is ready to start" + ), + )) + seen_task_ids.add(task.id) + + return steps + + +@router.get("/next_steps", response_model=list[NextStep]) +async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]: + """Derive contextual next-action suggestions from current hub state. + + Returns suggestions based on: + - Recently resolved decisions → first open task in the same workstream + - Workstreams whose every dependency workstream is now completed → first todo task + """ + return await _derive_next_steps(session) + + @router.get("/health") async def health_check() -> dict: try: diff --git a/api/routers/workstream_dependencies.py b/api/routers/workstream_dependencies.py new file mode 100644 index 0000000..35433cf --- /dev/null +++ b/api/routers/workstream_dependencies.py @@ -0,0 +1,80 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from api.database import get_session +from api.models.workstream import Workstream +from api.models.workstream_dependency import WorkstreamDependency +from api.schemas.workstream_dependency import WorkstreamDependencyCreate, WorkstreamDependencyRead + +router = APIRouter(prefix="/workstreams", tags=["dependencies"]) + + +@router.post( + "/{workstream_id}/dependencies/", + response_model=WorkstreamDependencyRead, + status_code=status.HTTP_201_CREATED, +) +async def create_dependency( + workstream_id: uuid.UUID, + body: WorkstreamDependencyCreate, + session: AsyncSession = Depends(get_session), +) -> WorkstreamDependency: + """Record that workstream_id depends on body.to_workstream_id.""" + if await session.get(Workstream, workstream_id) is None: + raise HTTPException(status_code=404, detail="from workstream not found") + if await session.get(Workstream, body.to_workstream_id) is None: + raise HTTPException(status_code=404, detail="to workstream not found") + if workstream_id == body.to_workstream_id: + raise HTTPException(status_code=422, detail="a workstream cannot depend on itself") + + dep = WorkstreamDependency( + from_workstream_id=workstream_id, + to_workstream_id=body.to_workstream_id, + description=body.description, + ) + session.add(dep) + await session.commit() + await session.refresh(dep) + return dep + + +@router.get( + "/{workstream_id}/dependencies/", + response_model=list[WorkstreamDependencyRead], +) +async def list_dependencies( + workstream_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> list[WorkstreamDependency]: + """Return all dependency edges touching this workstream (both directions).""" + if await session.get(Workstream, workstream_id) is None: + raise HTTPException(status_code=404, detail="workstream not found") + rows = await session.execute( + select(WorkstreamDependency).where( + (WorkstreamDependency.from_workstream_id == workstream_id) + | (WorkstreamDependency.to_workstream_id == workstream_id) + ) + ) + return list(rows.scalars().all()) + + +@router.delete( + "/{workstream_id}/dependencies/{dep_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_dependency( + workstream_id: uuid.UUID, + dep_id: uuid.UUID, + session: AsyncSession = Depends(get_session), +) -> None: + """Hard-delete a dependency edge. Removing a constraint is safe — no information is lost.""" + dep = await session.get(WorkstreamDependency, dep_id) + if dep is None: + raise HTTPException(status_code=404, detail="dependency not found") + if dep.from_workstream_id != workstream_id: + raise HTTPException(status_code=403, detail="dependency does not belong to this workstream") + await session.delete(dep) + await session.commit() diff --git a/api/schemas/state.py b/api/schemas/state.py index 66c1f53..bad27e1 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -1,3 +1,4 @@ +import uuid from datetime import datetime from pydantic import BaseModel @@ -6,7 +7,7 @@ from api.schemas.decision import DecisionRead from api.schemas.progress_event import ProgressEventRead from api.schemas.task import TaskRead from api.schemas.topic import TopicWithWorkstreams -from api.schemas.workstream import WorkstreamWithTaskCounts +from api.schemas.workstream import WorkstreamWithDeps class TopicTotals(BaseModel): @@ -48,6 +49,23 @@ class Totals(BaseModel): decisions: DecisionTotals +class NextStep(BaseModel): + """A derived suggestion pointing to where work should happen next. + + Suggestions are never persisted — they are computed on demand from + current hub state: recently resolved decisions, newly unblocked tasks, + cleared dependencies. + """ + type: str # unblocked_task | resolved_decision | dependency_cleared + domain: str | None = None + workstream_id: uuid.UUID | None = None + workstream_title: str | None = None + workstream_slug: str | None = None + task_id: uuid.UUID | None = None + task_title: str | None = None + message: str # plain-language explanation + + class StateSummary(BaseModel): generated_at: datetime totals: Totals @@ -55,4 +73,5 @@ class StateSummary(BaseModel): blocking_decisions: list[DecisionRead] blocked_tasks: list[TaskRead] recent_progress: list[ProgressEventRead] - open_workstreams: list[WorkstreamWithTaskCounts] + open_workstreams: list[WorkstreamWithDeps] + next_steps: list[NextStep] = [] diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index ae633bc..08058d9 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -4,6 +4,7 @@ from datetime import date, datetime from pydantic import BaseModel, ConfigDict from api.models.workstream import WorkstreamStatus +from api.schemas.workstream_dependency import WorkstreamDepStub class WorkstreamCreate(BaseModel): @@ -44,3 +45,9 @@ class WorkstreamWithTaskCounts(WorkstreamRead): tasks_in_progress: int = 0 tasks_blocked: int = 0 tasks_done: int = 0 + + +class WorkstreamWithDeps(WorkstreamWithTaskCounts): + """WorkstreamWithTaskCounts enriched with dependency graph edges.""" + depends_on: list[WorkstreamDepStub] = [] + blocks: list[WorkstreamDepStub] = [] diff --git a/api/schemas/workstream_dependency.py b/api/schemas/workstream_dependency.py new file mode 100644 index 0000000..ad6ce89 --- /dev/null +++ b/api/schemas/workstream_dependency.py @@ -0,0 +1,28 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class WorkstreamDependencyCreate(BaseModel): + to_workstream_id: uuid.UUID + description: str | None = None + + +class WorkstreamDependencyRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + from_workstream_id: uuid.UUID + to_workstream_id: uuid.UUID + description: str | None = None + created_at: datetime + updated_at: datetime + + +class WorkstreamDepStub(BaseModel): + """Minimal projection of the other end of a dependency edge.""" + dep_id: uuid.UUID + workstream_id: uuid.UUID + workstream_slug: str + workstream_title: str + description: str | None = None diff --git a/dashboard/src/index.md b/dashboard/src/index.md index 16fe14b..cd76269 100644 --- a/dashboard/src/index.md +++ b/dashboard/src/index.md @@ -108,6 +108,40 @@ display(html`
`); ``` +## What's next? + +```js +// next_steps comes from the summary poll (derived, never persisted) +const nextSteps = summary.next_steps ?? []; + +const typeLabel = { + resolved_decision: "Decision resolved", + dependency_cleared: "Dependency cleared", + unblocked_task: "Task unblocked", +}; +const typeBadgeClass = { + resolved_decision: "ns-badge-decision", + dependency_cleared: "ns-badge-dep", + unblocked_task: "ns-badge-task", +}; + +if (nextSteps.length === 0) { + display(html`

No actionable suggestions right now — all open workstreams are making progress or waiting on decisions.

`); +} else { + display(html`
${nextSteps.map(s => html` +
+
+ ${typeLabel[s.type] ?? s.type} + ${s.domain ?? "—"} +
+
${s.workstream_title ?? "—"}
+
${s.task_title ? html`→ ${s.task_title}` : ""}
+
${s.message}
+
+ `)}
`); +} +``` + ## Registered Projects ```js @@ -358,4 +392,17 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ .r-msg { font-size: 0.8rem; color: #b45309; } .r-copy { padding: 0.15rem 0.55rem; border-radius: 3px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); color: var(--theme-foreground-muted); cursor: pointer; font-size: 0.75rem; } .r-copy:hover { background: var(--theme-background-alt); } +/* What's next */ +.ns-empty { color: gray; font-style: italic; } +.ns-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; } +.ns-card { background: var(--theme-background-alt); border-radius: 8px; padding: 0.85rem 1rem; border-left: 4px solid #555; } +.ns-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } +.ns-badge { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.15rem 0.45rem; border-radius: 10px; font-weight: 600; } +.ns-badge-decision { background: #d4edda; color: #155724; } +.ns-badge-dep { background: #cce5ff; color: #004085; } +.ns-badge-task { background: #fff3cd; color: #856404; } +.ns-domain { font-size: 0.75rem; color: gray; } +.ns-ws { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; } +.ns-task { font-size: 0.85rem; margin-bottom: 0.35rem; } +.ns-msg { font-size: 0.78rem; color: #555; line-height: 1.4; } diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 9b62e8c..2a663a5 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -8,36 +8,40 @@ const POLL = 15_000; ``` ```js -// Fetch workstreams + topics in parallel, join on topic_id → domain/title +// Fetch workstreams + topics + summary (for dep graph) in parallel const wsState = (async function*() { while (true) { - let data = [], ok = false; + let data = [], openWs = [], ok = false; try { - const [rw, rt] = await Promise.all([ + const [rw, rt, rs] = await Promise.all([ fetch(`${API}/workstreams/`), fetch(`${API}/topics/`), + fetch(`${API}/state/summary`), ]); - ok = rw.ok && rt.ok; + ok = rw.ok && rt.ok && rs.ok; if (ok) { - const [wsList, topicList] = await Promise.all([rw.json(), rt.json()]); + const [wsList, topicList, summary] = await Promise.all([rw.json(), rt.json(), rs.json()]); const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); data = wsList.map(w => ({ ...w, domain: topicMap[w.topic_id]?.domain ?? "unknown", topic_title: topicMap[w.topic_id]?.title ?? "—", })); + // open_workstreams from summary carry depends_on / blocks lists + openWs = summary.open_workstreams ?? []; } } catch {} - yield {data, ok, ts: new Date()}; + yield {data, openWs, ok, ts: new Date()}; await new Promise(res => setTimeout(res, POLL)); } })(); ``` ```js -const data = wsState.data ?? []; -const _ok = wsState.ok ?? false; -const _ts = wsState.ts; +const data = wsState.data ?? []; +const openWs = wsState.openWs ?? []; +const _ok = wsState.ok ?? false; +const _ts = wsState.ts; ``` # Workstreams @@ -96,6 +100,47 @@ display(Plot.plot({ })); ``` +## Dependencies + +```js +// Build dep cards from the enriched open_workstreams in the summary +const wsWithDeps = openWs.filter(w => + (domainFilter === "(all)" || (data.find(d => d.id === w.id)?.domain ?? "unknown") === domainFilter) && + (statusFilter === "(all)" || w.status === statusFilter) && + (w.depends_on.length > 0 || w.blocks.length > 0) +); + +if (wsWithDeps.length === 0) { + display(html`

No dependency edges recorded for the current filter. Use create_dependency() via the MCP server to link workstreams.

`); +} else { + display(html`
${wsWithDeps.map(w => { + const depRows = w.depends_on.map(d => + html`
↳ depends on ${d.workstream_title}${d.description ? html` — ${d.description}` : ""}
` + ); + const blockRows = w.blocks.map(d => + html`
⊳ blocks ${d.workstream_title}${d.description ? html` — ${d.description}` : ""}
` + ); + return html`
+
${w.title}
+
${w.status}
+ ${depRows}${blockRows} +
`; + })}
`); +} +``` + diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 109f4c5..6c3b830 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -1,8 +1,26 @@ # State Hub MCP — Tool Reference Card -Quick reference for all 12 tools and 5 resources. Read this instead of `server.py`. +Quick reference for all tools and resources. -## Query Tools (read-only) +## Design Boundary + +The State Hub is a **read model**. It observes and visualises cross-domain state +that originates in the projects themselves. + +Two write operations are permanently sanctioned: + +| Use Case | Tools | +|---|---| +| **Resolving Decisions** | `resolve_decision()` — decisions are cross-cutting; resolution must propagate across all domains | +| **Suggesting Next Steps** | `get_next_steps()` *(v0.2)* — surface what is unblocked; the domain does the work | + +All other mutate tools are **bootstrap-only**: use them during First Session Protocol +to give a freshly-registered project its initial workstream structure. +Do not use them as a substitute for formal work definition inside the domain repo. + +--- + +## Query Tools (read-only, use freely) | Tool | Key Args | When to use | |------|----------|-------------| @@ -12,18 +30,33 @@ Quick reference for all 12 tools and 5 resources. Read this instead of `server.p | `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. | -## Mutate Tools (each auto-emits a progress_event) +--- + +## Sanctioned Write Tools | 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. | +| `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. | +| `resolve_decision(decision_id, rationale, decided_by)` | all required | Marks decision resolved, emits progress event, writes DECISIONS.md to project directory. | +| `add_progress_event(summary, ...)` | `event_type`: note/milestone/blocker/insight; `topic_id?`; `workstream_id?`; `task_id?`; `detail?` | Append-only log entry. **Use at session end.** | + +--- + +## Bootstrap-Only Tools + +> Use during **First Session Protocol** to give a freshly-registered project its +> initial workstream structure. Do not use for ongoing project management — +> formal work structure belongs in the domain repo (workplans, requirements, milestones). + +| 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_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. | -| `resolve_decision(decision_id, rationale, decided_by)` | all required | Marks decision resolved and records who decided. | -| `add_progress_event(summary, ...)` | `event_type`: note/milestone/blocker/insight; `topic_id?`; `workstream_id?`; `task_id?`; `detail?` | Append-only log entry. **Use at session end.** | | `update_workstream_status(workstream_id, status)` | `status`: active/blocked/completed/archived | | +--- + ## Resources (URI-addressable, read-only) | URI | Returns | @@ -34,28 +67,33 @@ Quick reference for all 12 tools and 5 resources. Read this instead of `server.p | `state://decisions/blocking` | All pending decisions | | `state://tasks/blocked` | All blocked tasks | +--- + ## Domain Slugs `custodian` · `railiance` · `markitect` · `coulomb-social` · `personhood` · `foerster-capabilities` +--- + ## Common Patterns ```python -# 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: +# Session start: get_state_summary() -# Session end ritual: +# Decision resolved in the hub UI or via tool: +resolve_decision(decision_id="", rationale="...", decided_by="Bernd") + +# Session end: add_progress_event( summary="...", event_type="note", # or milestone / insight / blocker topic_id="", workstream_id="", # optional - detail={"key": "value"}, # optional structured data + detail={"key": "value"}, # optional ) + +# First Session Protocol only — bootstrap a new project: +create_workstream(topic_id="", title="My Workstream", owner="me") +create_task(workstream_id="", title="Do the thing", priority="high") ``` diff --git a/mcp_server/server.py b/mcp_server/server.py index d096023..bae547e 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -62,6 +62,12 @@ def _patch(path: str, body: dict) -> Any: return r.json() +def _delete(path: str) -> None: + with _client() as c: + r = c.delete(path) + r.raise_for_status() + + # --------------------------------------------------------------------------- # Resources # --------------------------------------------------------------------------- @@ -398,6 +404,70 @@ def update_workstream_status(workstream_id: str, status: str) -> str: return json.dumps(ws, indent=2) +# --------------------------------------------------------------------------- +# Next-steps suggestion tool (S2.3) — sanctioned write use case #2 +# --------------------------------------------------------------------------- + +@mcp.tool() +def get_next_steps() -> str: + """Surface contextual next-action suggestions derived from hub state. + + Returns suggestions based on: + - Recently resolved decisions → first open task in the same workstream + - Workstreams whose every dependency is now completed → first todo task + + Each suggestion includes domain, workstream, task, and a plain-language + message. The hub surfaces *what* and *where* — the domain owns *how*. + + This is one of the two sanctioned write-side use cases of the State Hub + (the other is resolve_decision). Suggestions are derived, not persisted. + """ + return json.dumps(_get("/state/next_steps"), indent=2) + + +# --------------------------------------------------------------------------- +# Dependency graph tools (S1.4) +# --------------------------------------------------------------------------- + +@mcp.tool() +def create_dependency( + from_workstream_id: str, + to_workstream_id: str, + description: str | None = None, +) -> str: + """Record that one workstream depends on another. + + Semantics: from_workstream cannot fully proceed until to_workstream reaches + a satisfactory state. + + Args: + from_workstream_id: UUID of the workstream that has the dependency + to_workstream_id: UUID of the workstream it depends on + description: optional human-readable explanation of the dependency + """ + dep = _post(f"/workstreams/{from_workstream_id}/dependencies", { + "to_workstream_id": to_workstream_id, + "description": description, + }) + return json.dumps(dep, indent=2) + + +@mcp.tool() +def list_dependencies(workstream_id: str) -> str: + """Return all dependency edges touching a workstream (both directions). + + The response distinguishes edges where this workstream is the dependent + (depends_on) from edges where it is the blocker (blocks). + + 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["to_workstream_id"] == workstream_id] + return json.dumps({"depends_on": depends_on, "blocks": blocks}, indent=2) + + # --------------------------------------------------------------------------- # Entry point # --------------------------------------------------------------------------- diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0b547c153153_add_workstream_dependencies.py b/migrations/versions/0b547c153153_add_workstream_dependencies.py new file mode 100644 index 0000000..ac39033 --- /dev/null +++ b/migrations/versions/0b547c153153_add_workstream_dependencies.py @@ -0,0 +1,45 @@ +"""add_workstream_dependencies + +Revision ID: 0b547c153153 +Revises: 0001 +Create Date: 2026-02-25 17:26:54.017622 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0b547c153153' +down_revision: Union[str, None] = '0001' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('workstream_dependencies', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('from_workstream_id', sa.UUID(), nullable=False), + sa.Column('to_workstream_id', sa.UUID(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['from_workstream_id'], ['workstreams.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['to_workstream_id'], ['workstreams.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('from_workstream_id', 'to_workstream_id', name='uq_ws_dep_pair') + ) + op.create_index(op.f('ix_workstream_dependencies_from_workstream_id'), 'workstream_dependencies', ['from_workstream_id'], unique=False) + op.create_index(op.f('ix_workstream_dependencies_to_workstream_id'), 'workstream_dependencies', ['to_workstream_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_workstream_dependencies_to_workstream_id'), table_name='workstream_dependencies') + op.drop_index(op.f('ix_workstream_dependencies_from_workstream_id'), table_name='workstream_dependencies') + op.drop_table('workstream_dependencies') + # ### end Alembic commands ### diff --git a/scripts/project_claude_md.template b/scripts/project_claude_md.template index e0cf2a0..73b5402 100644 --- a/scripts/project_claude_md.template +++ b/scripts/project_claude_md.template @@ -20,14 +20,23 @@ Call the tool first, then respond based on what you find. ``` cd ~/the-custodian/state-hub && make api ``` -2. Check whether this domain has any open workstreams in the summary. +2. Call `get_next_steps()` — surfaces contextual suggestions from recently resolved + decisions and cleared workstream dependencies. Act on these before starting new work. +3. Check whether this domain has any open workstreams in the summary. - **If workstreams exist:** review blocking decisions before starting work. - **If no workstreams exist:** follow the First Session Protocol below. **During work:** -- Use `create_task()` / `update_task_status()` to track concrete deliverables. - Use `record_decision()` for any decision that affects direction or dependencies. - Use `add_progress_event()` for notable events (milestones, blockers, insights). +- Use `resolve_decision()` to close a decision once the choice is made — this is one + of the two sanctioned write operations in the hub. + +> **Design boundary:** The State Hub is a *read model*. Two write operations are +> permanently sanctioned: **Resolving Decisions** and **Suggesting Next Steps** (v0.2). +> The bootstrap tools (`create_workstream`, `create_task`, `update_task_status`) are +> only for First Session Protocol. Formal work structure — requirements, workplans, +> milestones, tasks — belongs in the domain repo, not managed through the hub. **At the end of every session:** - Call `add_progress_event()` with a summary of what was accomplished or decided.