generated from coulomb/repo-seed
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
45
api/models/workstream_dependency.py
Normal file
45
api/models/workstream_dependency.py
Normal 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]
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
80
api/routers/workstream_dependencies.py
Normal file
80
api/routers/workstream_dependencies.py
Normal 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()
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
28
api/schemas/workstream_dependency.py
Normal file
28
api/schemas/workstream_dependency.py
Normal 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
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
```
|
||||
|
||||
@@ -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
26
migrations/script.py.mako
Normal 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"}
|
||||
@@ -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 ###
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user