Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 23:33:14 +01:00
parent 9965349135
commit f34b49ebde
15 changed files with 678 additions and 35 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -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]
)

View File

@@ -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:

View File

@@ -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()

View File

@@ -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] = []

View File

@@ -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] = []

View File

@@ -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

View File

@@ -108,6 +108,40 @@ display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem"
</div>`);
```
## 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`<p class="ns-empty">No actionable suggestions right now — all open workstreams are making progress or waiting on decisions.</p>`);
} else {
display(html`<div class="ns-grid">${nextSteps.map(s => html`
<div class="ns-card">
<div class="ns-card-header">
<span class="ns-badge ${typeBadgeClass[s.type] ?? ''}">${typeLabel[s.type] ?? s.type}</span>
<span class="ns-domain">${s.domain ?? "—"}</span>
</div>
<div class="ns-ws">${s.workstream_title ?? "—"}</div>
<div class="ns-task">${s.task_title ? html`→ <strong>${s.task_title}</strong>` : ""}</div>
<div class="ns-msg">${s.message}</div>
</div>
`)}</div>`);
}
```
## 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; }
</style>

View File

@@ -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`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workstreams.</p>`);
} else {
display(html`<div class="dep-grid">${wsWithDeps.map(w => {
const depRows = w.depends_on.map(d =>
html`<div class="dep-row dep-on">↳ depends on <strong>${d.workstream_title}</strong>${d.description ? html` <span class="dep-desc">— ${d.description}</span>` : ""}</div>`
);
const blockRows = w.blocks.map(d =>
html`<div class="dep-row dep-block">⊳ blocks <strong>${d.workstream_title}</strong>${d.description ? html` <span class="dep-desc">— ${d.description}</span>` : ""}</div>`
);
return html`<div class="dep-card">
<div class="dep-title">${w.title}</div>
<div class="dep-status dep-status-${w.status}">${w.status}</div>
${depRows}${blockRows}
</div>`;
})}</div>`);
}
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.dim { color: gray; font-style: italic; }
.dep-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.dep-card { border: 1px solid #e0e0e0; border-radius: 6px; padding: 0.75rem 1rem; background: var(--theme-background-alt, #fafafa); }
.dep-title { font-weight: 600; margin-bottom: 0.25rem; }
.dep-status { display: inline-block; font-size: 0.7rem; padding: 1px 6px; border-radius: 10px; margin-bottom: 0.5rem; text-transform: uppercase; }
.dep-status-active { background: #d4edda; color: #155724; }
.dep-status-blocked { background: #f8d7da; color: #721c24; }
.dep-status-completed { background: #cce5ff; color: #004085; }
.dep-row { font-size: 0.85rem; margin: 0.2rem 0 0 0.5rem; color: #444; }
.dep-on { color: #1a5276; }
.dep-block { color: #6e2f00; }
.dep-desc { color: #888; font-size: 0.8rem; }
</style>

View File

@@ -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="<uuid>", title="My Workstream", owner="me")
# New task:
create_task(workstream_id="<uuid>", 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="<uuid>", rationale="...", decided_by="Bernd")
# Session end:
add_progress_event(
summary="...",
event_type="note", # or milestone / insight / blocker
topic_id="<uuid>",
workstream_id="<uuid>", # optional
detail={"key": "value"}, # optional structured data
detail={"key": "value"}, # optional
)
# First Session Protocol only — bootstrap a new project:
create_workstream(topic_id="<uuid>", title="My Workstream", owner="me")
create_task(workstream_id="<uuid>", title="Do the thing", priority="high")
```

View File

@@ -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
# ---------------------------------------------------------------------------

26
migrations/script.py.mako Normal file
View File

@@ -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"}

View File

@@ -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 ###

View File

@@ -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.