diff --git a/CLAUDE.md b/CLAUDE.md index 6a09048..d85c2a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -94,6 +94,12 @@ Every Claude Code session in this repository must follow this ritual: 1. Call `add_progress_event()` to log what was done, decided, or discovered 2. If new tasks were identified, create them with `create_task()` 3. If decisions were made, record them with `record_decision()` +4. If any workplan files were written or modified this session, run: + ```bash + cd state-hub && make fix-consistency REPO=the-custodian + ``` + This syncs task blocks → DB and updates task statuses. Without this step, the + "Open Workstreams by Domain" chart will show 0 progress even for completed work. The state hub is the episodic memory of this system. A session that produces no progress events is invisible to future sessions and to Bernd. diff --git a/state-hub/api/main.py b/state-hub/api/main.py index cd9e41e..2b5e9a5 100644 --- a/state-hub/api/main.py +++ b/state-hub/api/main.py @@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from api.database import engine from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies -from api.routers import domains, repos, contributions, sbom +from api.routers import domains, repos, contributions, sbom, policy @asynccontextmanager @@ -24,7 +24,7 @@ app = FastAPI( app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], - allow_methods=["GET", "POST", "PATCH", "DELETE"], + allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"], allow_headers=["Content-Type"], ) @@ -41,6 +41,7 @@ app.include_router(progress.router) app.include_router(contributions.router) app.include_router(sbom.router) app.include_router(state.router) +app.include_router(policy.router) @app.get("/", include_in_schema=False) diff --git a/state-hub/api/models/task.py b/state-hub/api/models/task.py index b829b04..2bd83da 100644 --- a/state-hub/api/models/task.py +++ b/state-hub/api/models/task.py @@ -2,7 +2,7 @@ import enum import uuid from datetime import date -from sqlalchemy import Date, Enum, ForeignKey, String, Text +from sqlalchemy import Boolean, Date, Enum, ForeignKey, String, Text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column, relationship @@ -44,6 +44,8 @@ class Task(Base, TimestampMixin): assignee: Mapped[str | None] = mapped_column(String(100), nullable=True) due_date: Mapped[date | None] = mapped_column(Date, nullable=True) blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True) + intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True) parent_task_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True ) diff --git a/state-hub/api/routers/policy.py b/state-hub/api/routers/policy.py new file mode 100644 index 0000000..85ead6f --- /dev/null +++ b/state-hub/api/routers/policy.py @@ -0,0 +1,41 @@ +import re +from pathlib import Path + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +POLICY_DIR = Path(__file__).parent.parent.parent / "policies" +_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$") + +router = APIRouter(prefix="/policy", tags=["policy"]) + + +class PolicyRead(BaseModel): + name: str + content: str + + +class PolicyUpdate(BaseModel): + content: str + + +def _policy_path(name: str) -> Path: + if not _VALID_NAME.match(name): + raise HTTPException(status_code=400, detail="Invalid policy name") + path = POLICY_DIR / f"{name}.md" + if not path.exists(): + raise HTTPException(status_code=404, detail=f"Policy '{name}' not found") + return path + + +@router.get("/{name}", response_model=PolicyRead) +def get_policy(name: str) -> PolicyRead: + path = _policy_path(name) + return PolicyRead(name=name, content=path.read_text()) + + +@router.put("/{name}", response_model=PolicyRead) +def update_policy(name: str, body: PolicyUpdate) -> PolicyRead: + path = _policy_path(name) + path.write_text(body.content) + return PolicyRead(name=name, content=body.content) diff --git a/state-hub/api/routers/tasks.py b/state-hub/api/routers/tasks.py index 8c3de56..0544adf 100644 --- a/state-hub/api/routers/tasks.py +++ b/state-hub/api/routers/tasks.py @@ -1,6 +1,6 @@ import uuid -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +16,7 @@ async def list_tasks( workstream_id: uuid.UUID | None = None, status: TaskStatus | None = None, assignee: str | None = None, + needs_human: bool | None = Query(None), session: AsyncSession = Depends(get_session), ) -> list[Task]: q = select(Task) @@ -25,6 +26,8 @@ async def list_tasks( q = q.where(Task.status == status) if assignee: q = q.where(Task.assignee == assignee) + if needs_human is not None: + q = q.where(Task.needs_human == needs_human) q = q.order_by(Task.created_at) result = await session.execute(q) return list(result.scalars().all()) diff --git a/state-hub/api/schemas/task.py b/state-hub/api/schemas/task.py index bc95b0a..8f7abf0 100644 --- a/state-hub/api/schemas/task.py +++ b/state-hub/api/schemas/task.py @@ -1,5 +1,6 @@ import uuid from datetime import date, datetime +from typing import Self from pydantic import BaseModel, ConfigDict, model_validator @@ -15,8 +16,16 @@ class TaskCreate(BaseModel): assignee: str | None = None due_date: date | None = None blocking_reason: str | None = None + needs_human: bool = False + intervention_note: str | None = None parent_task_id: uuid.UUID | None = None + @model_validator(mode="after") + def intervention_note_required_when_flagged(self) -> Self: + if self.needs_human and not self.intervention_note: + raise ValueError("intervention_note is required when needs_human is True") + return self + class TaskUpdate(BaseModel): title: str | None = None @@ -26,14 +35,22 @@ class TaskUpdate(BaseModel): assignee: str | None = None due_date: date | None = None blocking_reason: str | None = None + needs_human: bool | None = None + intervention_note: str | None = None parent_task_id: uuid.UUID | None = None @model_validator(mode="after") - def blocking_reason_required_when_blocked(self) -> "TaskUpdate": + def blocking_reason_required_when_blocked(self) -> Self: if self.status == TaskStatus.blocked and not self.blocking_reason: raise ValueError("blocking_reason is required when status is blocked") return self + @model_validator(mode="after") + def intervention_note_required_when_flagged(self) -> Self: + if self.needs_human and not self.intervention_note: + raise ValueError("intervention_note is required when needs_human is True") + return self + class TaskRead(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -46,6 +63,8 @@ class TaskRead(BaseModel): assignee: str | None = None due_date: date | None = None blocking_reason: str | None = None + needs_human: bool + intervention_note: str | None = None parent_task_id: uuid.UUID | None = None created_at: datetime updated_at: datetime diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js index 3fc6003..abeac66 100644 --- a/state-hub/dashboard/observablehq.config.js +++ b/state-hub/dashboard/observablehq.config.js @@ -3,8 +3,9 @@ export default { title: "Custodian State Hub", pages: [ // ── Overview ────────────────────────────────────────────────────────────── - { name: "Overview", path: "/" }, - { name: "Todo", path: "/todo" }, + { name: "Overview", path: "/" }, + { name: "Todo", path: "/todo" }, + { name: "Interventions", path: "/interventions" }, // ── Organizational Entity Views ─────────────────────────────────────────── { name: "Domains", path: "/domains" }, { name: "Repos", path: "/repos" }, @@ -25,6 +26,15 @@ export default { { name: "Contributions", path: "/contributions" }, { name: "SBOM", path: "/sbom" }, { name: "Progress", path: "/progress" }, + // ── Policy ──────────────────────────────────────────────────────────────── + { + name: "Policy", + collapsible: true, + open: true, + pages: [ + { name: "Workstream DoD", path: "/policy/workstream-dod" }, + ], + }, // ── Reference ───────────────────────────────────────────────────────────── { name: "Reference", @@ -50,6 +60,7 @@ export default { { name: "Technical Debt", path: "/docs/debt" }, { name: "Todo", path: "/docs/todo" }, { name: "Workstream Health", path: "/docs/workstream-health-index" }, + { name: "Workstream Lifecycle", path: "/docs/workstream-lifecycle" }, { name: "Workstreams", path: "/docs/workstreams" }, ], }, diff --git a/state-hub/dashboard/src/docs/workstream-lifecycle.md b/state-hub/dashboard/src/docs/workstream-lifecycle.md new file mode 100644 index 0000000..39cbef2 --- /dev/null +++ b/state-hub/dashboard/src/docs/workstream-lifecycle.md @@ -0,0 +1,104 @@ +--- +title: Workstream Lifecycle — Reference +--- + +# Workstream Lifecycle — Reference + +A workstream moves through a defined lifecycle from creation to sign-off. The +dashboard "Workstreams by Domain" chart exposes these states as selectable +filters so attention can be directed to the right workstreams at the right time. + +--- + +## Core lifecycle states + +These are the primary stages a workstream passes through in order: + +| State | Source | Meaning | +|---|---|---| +| **active** | DB `status = active` | Work is in progress or ready to start | +| **finished** | Derived — no open tasks | All tasks are done, but no explicit review has taken place yet | +| **accepted** | DB `status = completed` | Custodian and human have reviewed the workstream, quality checks passed, and it is formally signed off | + +The normal progression is: **active → finished → accepted**. + +`accepted` is the only state that requires an explicit action — it is set after +a deliberate review, not automatically. This makes it a reliable anchor: +anything in `finished` but not yet in `accepted` is work that still needs a +quality pass. + +--- + +## Attention signals + +These states are orthogonal to the core lifecycle — a workstream can be +`active` and `stalled` at the same time. They serve as health indicators +rather than lifecycle stages. + +| Signal | Source | Meaning | +|---|---|---| +| **blocked** | Derived — has ≥ 1 blocked task | At least one task is waiting on something external | +| **stalled** | Derived — `updated_at` > 7 days ago, has both done and open tasks | Work started but activity has stopped; needs a nudge | +| **oldies** | Derived — `created_at` > 7 days ago, zero done tasks | Workstream is old and nothing has been completed yet; may need re-evaluation | + +--- + +## The acceptance quality gate + +When a workstream reaches **finished** (all tasks done), the custodian's role is to: + +1. Review the deliverables against the workstream's stated purpose and scope +2. Check for missing tests, documentation, or follow-up issues +3. Create tasks for any gaps found — this moves the workstream back to **active** +4. Once satisfied, set `status = completed` via MCP or API — this marks it as **accepted** + +This pattern ensures that "done" and "accepted" are distinct signals. +`finished` is a fact about task counts; `accepted` is a statement of quality. + +``` +# Accept a workstream via MCP +update_workstream_status(workstream_id="", status="completed") + +# Or via REST +curl -X PATCH http://127.0.0.1:8000/workstreams// \ + -H "Content-Type: application/json" \ + -d '{"status": "completed"}' +``` + +--- + +## Time-based filters + +The chart also supports time-window filters that cut across all lifecycle states: + +| Filter | Shows workstreams where… | +|---|---| +| **last 1 hour** | `updated_at` or `created_at` within the last 60 minutes | +| **last 24 hours** | … within the last 24 hours | +| **last 7 days** | … within the last 7 days | +| **last 30 days** | … within the last 30 days | +| **today** | … since midnight today | +| **this week** | … since Monday of the current week | +| **this month** | … since the 1st of the current month | + +In time-based views, workstream labels are **bold** for accepted and blocked +workstreams to distinguish notable states at a glance. + +--- + +## DB status vs. dashboard state + +The DB stores a single `status` field on each workstream. The dashboard maps +this alongside derived task-count data to produce the richer set of filter states: + +| Dashboard state | DB `status` | Task-count condition | +|---|---|---| +| active | `active` | — | +| accepted | `completed` | — | +| finished | any | `todo + in_progress + blocked = 0` | +| blocked | any | `blocked ≥ 1` | +| stalled | any | `done ≥ 1` and `open ≥ 1` and `updated_at > 7d ago` | +| oldies | any | `done = 0` and `open ≥ 1` and `created_at > 7d ago` | + +*Workstreams are never hard-deleted — use `update_workstream_status(..., "completed")` or +`"archived"` to close them without losing history.* diff --git a/state-hub/dashboard/src/docs/workstreams.md b/state-hub/dashboard/src/docs/workstreams.md index 4e3920d..ff0f1aa 100644 --- a/state-hub/dashboard/src/docs/workstreams.md +++ b/state-hub/dashboard/src/docs/workstreams.md @@ -16,9 +16,11 @@ A horizontal bar chart showing the count of workstreams in each status for the c |---|---| | **active** | Work in progress or ready to start | | **blocked** | Waiting on something outside the workstream — see Dependencies | -| **completed** | Done | +| **completed** | Formally accepted after custodian review (shown as **accepted** in the overview chart) | | **archived** | Closed without completion; no longer relevant | +See [Workstream Lifecycle](/docs/workstream-lifecycle) for the full state model including derived states (finished, stalled, oldies). + --- ## Filter bar diff --git a/state-hub/dashboard/src/index.md b/state-hub/dashboard/src/index.md index b6c9808..3628592 100644 --- a/state-hub/dashboard/src/index.md +++ b/state-hub/dashboard/src/index.md @@ -65,6 +65,53 @@ const regsState = (async function*() { })(); ``` +```js +// All-workstreams + all-tasks poll — drives the multi-mode chart +const wsChartState = (async function*() { + while (true) { + let wsAll = [], ok = false; + try { + const [rw, rt, rto, rr] = await Promise.all([ + fetch(`${API}/workstreams/`), + fetch(`${API}/tasks/?limit=2000`), + fetch(`${API}/topics/`), + fetch(`${API}/repos/`), + ]); + ok = rw.ok && rt.ok && rto.ok && rr.ok; + if (ok) { + const [wsList, taskList, topicList, repoList] = await Promise.all([ + rw.json(), rt.json(), rto.json(), rr.json(), + ]); + const topicMap = Object.fromEntries(topicList.map(t => [t.id, t])); + const repoMap = Object.fromEntries(repoList.map(r => [r.id, r])); + // Aggregate task counts per workstream + const counts = {}; + for (const t of taskList) { + const wid = t.workstream_id; + if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}; + counts[wid].total++; + if (t.status === "done") counts[wid].done++; + else if (t.status === "in_progress") counts[wid].in_progress++; + else if (t.status === "blocked") counts[wid].blocked++; + else if (t.status === "todo") counts[wid].todo++; + } + wsAll = wsList.map(w => ({ + ...w, + domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown", + ...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), + })); + } + } catch {} + yield {wsAll, ok}; + await new Promise(res => setTimeout(res, POLL)); + } +})(); +``` + +```js +const wsAll = wsChartState.wsAll ?? []; +``` + # Custodian State Hub ```js @@ -88,72 +135,163 @@ if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/overview"); if (summary.error) display(html`
⚠️ ${summary.error}
`); ``` -## Open Workstreams by Domain +## Workstreams by Domain + +```js +// view() is the idiomatic Observable Framework reactive input: +// it displays the element AND returns a reactive value that re-runs dependent blocks. +const _chartMode = view(html``); +``` ```js import * as Plot from "npm:@observablehq/plot"; -const topicById = Object.fromEntries((summary.topics ?? []).map(t => [t.id, t.domain_slug])); +// ── Filter workstreams by selected mode ─────────────────────────────────────── +// "active" matches the DB status field directly. +// "accepted" = DB status "completed" (explicitly reviewed and signed off). +// "finished" = no open tasks remaining (derived from task counts). +// "blocked" = has ≥1 blocked task; "stalled" / "oldies" = activity-based. +// Time modes filter by updated_at / created_at. +const _STATUS_MODES = new Set(["active"]); -const openWs = (summary.open_workstreams ?? []).map(w => ({ - title: w.title, - domain: topicById[w.topic_id] ?? "unknown", - done: w.tasks_done ?? 0, - in_progress: w.tasks_in_progress ?? 0, - blocked: w.tasks_blocked ?? 0, - todo: w.tasks_todo ?? 0, - total: w.tasks_total ?? 0, -})).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title)); - -const statusOrder = ["done", "in progress", "blocked", "todo"]; -const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; - -const taskRows = openWs.flatMap(w => [ - {label: w.title, domain: w.domain, status: "done", count: w.done}, - {label: w.title, domain: w.domain, status: "in progress", count: w.in_progress}, - {label: w.title, domain: w.domain, status: "blocked", count: w.blocked}, - {label: w.title, domain: w.domain, status: "todo", count: w.todo}, -]).filter(d => d.count > 0); - -// y-axis shows domain (only for the first workstream in each domain group) -const yLabels = {}; -const _seenDomains = new Set(); -for (const w of openWs) { - yLabels[w.title] = _seenDomains.has(w.domain) ? "" : w.domain; - _seenDomains.add(w.domain); +function _timeCutoff(mode) { + const now = new Date(); + if (mode === "1h") return new Date(now - 60 * 60 * 1000); + if (mode === "1d") return new Date(now - 24 * 60 * 60 * 1000); + if (mode === "7d") return new Date(now - 7 * 24 * 60 * 60 * 1000); + if (mode === "30d") return new Date(now - 30 * 24 * 60 * 60 * 1000); + if (mode === "today") return new Date(now.getFullYear(), now.getMonth(), now.getDate()); + if (mode === "week") { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // back to Monday + return d; + } + if (mode === "month") return new Date(now.getFullYear(), now.getMonth(), 1); + return null; } -if (openWs.length === 0) { - display(html`

No open workstreams.

`); +const chartWs = ( + _STATUS_MODES.has(_chartMode) + ? wsAll.filter(w => w.status === _chartMode) + : _chartMode === "accepted" + ? wsAll.filter(w => w.status === "completed") + : _chartMode === "finished" + ? wsAll.filter(w => (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) === 0) + : _chartMode === "blocked" + ? wsAll.filter(w => (w.blocked ?? 0) > 0) + : _chartMode === "stalled" + ? wsAll.filter(w => { + const staleAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + return new Date(w.updated_at) < staleAt + && (w.done ?? 0) > 0 + && (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0; + }) + : _chartMode === "oldies" + ? wsAll.filter(w => { + const oldAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + return new Date(w.created_at) < oldAt + && (w.done ?? 0) === 0 + && (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0; + }) + : (() => { + const since = _timeCutoff(_chartMode); + return wsAll.filter(w => + new Date(w.updated_at) >= since || new Date(w.created_at) >= since + ); + })() +).sort((a, b) => a.domain.localeCompare(b.domain) || a.title.localeCompare(b.title)); + +// ── Status weight: bold for notable statuses in mixed-status modes ───────────── +// Color is NOT used for status — avoids green-on-green when completed bars fill the row. +const _isTimeBased = !_STATUS_MODES.has(_chartMode); +function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; } + +// ── y-axis: domain label for first workstream per group only ────────────────── +const _yLabels = {}; +const _seen = new Set(); +for (const w of chartWs) { + _yLabels[w.title] = _seen.has(w.domain) ? "" : w.domain; + _seen.add(w.domain); +} + +const statusOrder = ["done", "in progress", "blocked", "todo"]; +const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; + +const _taskRows = chartWs.flatMap(w => [ + {label: w.title, status: "done", count: w.done ?? 0}, + {label: w.title, status: "in progress", count: w.in_progress ?? 0}, + {label: w.title, status: "blocked", count: w.blocked ?? 0}, + {label: w.title, status: "todo", count: w.todo ?? 0}, +]).filter(d => d.count > 0); + +// ── Render ──────────────────────────────────────────────────────────────────── +if (chartWs.length === 0) { + const _emptyMsg = { + active: "No active workstreams.", accepted: "No accepted workstreams.", + finished: "No finished workstreams.", blocked: "No blocked workstreams.", + stalled: "No stalled workstreams — everything is moving.", + oldies: "No oldies — all older workstreams have at least one task done.", + "1h": "No workstreams changed in the last hour.", + "1d": "No workstreams changed in the last 24 hours.", + "7d": "No workstreams changed in the last 7 days.", + "30d": "No workstreams changed in the last 30 days.", + today: "No workstreams changed today.", + week: "No workstreams changed this week.", + month: "No workstreams changed this month.", + }; + display(html`

${_emptyMsg[_chartMode] ?? "No workstreams."}

`); } else { display(Plot.plot({ - y: {label: null, tickSize: 0, domain: openWs.map(w => w.title), tickFormat: t => yLabels[t] ?? ""}, + y: { + label: null, tickSize: 0, + domain: chartWs.map(w => w.title), + tickFormat: t => _yLabels[t] ?? "", + }, x: {label: "Tasks", grid: true}, color: {domain: statusOrder, range: statusColors, legend: true}, marks: [ - Plot.barX(taskRows, {y: "label", x: "count", fill: "status", tip: true}), - // Workstream title inside the bar - Plot.text(openWs.filter(w => w.total > 0), { - y: "title", x: 0, dx: 6, - text: d => d.title.length > 36 ? d.title.slice(0, 34) + "…" : d.title, - textAnchor: "start", fontSize: 10, fill: "#333", + Plot.barX(_taskRows, {y: "label", x: "count", fill: "status", tip: true}), + // Title label — pushed to lower half of bar row (dy: +7) to separate from count + Plot.text(chartWs.filter(w => w.total > 0), { + y: "title", x: 0, dx: 6, dy: 7, + text: d => d.title.length > 72 ? d.title.slice(0, 70) + "…" : d.title, + textAnchor: "start", fontSize: 10, fill: "#1e293b", + fontWeight: d => _isTimeBased ? _wsWeight(d.status) : "normal", }), - Plot.text(openWs.filter(w => w.total === 0), { - y: "title", x: 0, dx: 6, - text: d => `${d.title.length > 24 ? d.title.slice(0, 22) + "…" : d.title} — no tasks yet`, - textAnchor: "start", fontSize: 10, fill: "#aaa", + Plot.text(chartWs.filter(w => w.total === 0), { + y: "title", x: 0, dx: 6, dy: 7, + text: d => `${d.title.length > 48 ? d.title.slice(0, 46) + "…" : d.title} — no tasks yet`, + textAnchor: "start", fontSize: 10, fill: "#94a3b8", }), - // "done / total" label after the bar - Plot.text(openWs.filter(w => w.total > 0), { + // Count label — pushed to upper half of bar row (dy: -7) to separate from title + Plot.text(chartWs.filter(w => w.total > 0), { y: "title", x: "total", text: d => ` ${d.done}/${d.total}`, - dx: 4, textAnchor: "start", fontSize: 11, fill: "gray", + dx: 4, dy: -7, textAnchor: "start", fontSize: 11, fill: "gray", }), Plot.ruleX([0]), ], marginLeft: 160, marginRight: 70, - height: Math.max(80, openWs.length * 44 + 50), + height: Math.max(80, chartWs.length * 44 + 50), width: 700, })); } @@ -430,6 +568,7 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({ diff --git a/state-hub/dashboard/src/policy/workstream-dod.md b/state-hub/dashboard/src/policy/workstream-dod.md new file mode 100644 index 0000000..94a8c80 --- /dev/null +++ b/state-hub/dashboard/src/policy/workstream-dod.md @@ -0,0 +1,92 @@ +--- +title: Workstream Definition of Done +--- + +```js +const API = "http://127.0.0.1:8000"; +``` + +```js +import {marked} from "npm:marked"; + +const _resp = await fetch(`${API}/policy/workstream-dod`); +if (!_resp.ok) throw new Error(`Failed to load policy: ${_resp.status}`); +const _policy = await _resp.json(); +``` + +```js +let _content = _policy.content; +let _editing = false; + +const _root = display(html`
`); + +async function _save(text) { + const r = await fetch(`${API}/policy/workstream-dod`, { + method: "PUT", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({content: text}), + }); + if (!r.ok) throw new Error(`Save failed: ${r.status}`); + _content = text; +} + +function _toolbar(...nodes) { + return html`
${nodes}
`; +} + +function _btn(label, primary = false) { + return html``; +} + +function _render() { + _root.innerHTML = ""; + + if (_editing) { + const area = html``; + + const saveBtn = _btn("Save", true); + const cancelBtn = _btn("Cancel"); + + saveBtn.onclick = async () => { + saveBtn.disabled = true; + saveBtn.textContent = "Saving…"; + try { + await _save(area.value); + _editing = false; + _render(); + } catch (e) { + saveBtn.disabled = false; + saveBtn.textContent = "Save"; + alert(e.message); + } + }; + + cancelBtn.onclick = () => { _editing = false; _render(); }; + + _root.append(_toolbar(saveBtn, cancelBtn), area); + + } else { + const editBtn = _btn("Edit"); + editBtn.onclick = () => { _editing = true; _render(); }; + + const body = html`
`; + body.innerHTML = marked.parse(_content); + + _root.append(_toolbar(editBtn), body); + } +} + +_render(); +``` diff --git a/state-hub/mcp_server/TOOLS.md b/state-hub/mcp_server/TOOLS.md index c6483d3..55dc76e 100644 --- a/state-hub/mcp_server/TOOLS.md +++ b/state-hub/mcp_server/TOOLS.md @@ -58,6 +58,19 @@ Do not use them as a substitute for formal work definition inside the domain rep --- +## Human Interventions + +Tasks that agents cannot complete themselves are flagged with `needs_human=True`. +Use `list_human_interventions()` at session start to see Bernd's action items. + +| Tool | Key Args | Notes | +|------|----------|-------| +| `flag_for_human(task_id, note)` | `task_id`: UUID; `note`: action description (required) | Sets needs_human=True + intervention_note. Emits progress event. | +| `clear_human_flag(task_id)` | `task_id`: UUID | Clears flag after human completes the action. Emits progress event. | +| `list_human_interventions(workstream_id?)` | optional workstream filter | Returns all tasks with needs_human=True. | + +--- + ## Governance Tools | Tool | Key Args | When to use | diff --git a/state-hub/mcp_server/server.py b/state-hub/mcp_server/server.py index ed4984b..d7b64f2 100644 --- a/state-hub/mcp_server/server.py +++ b/state-hub/mcp_server/server.py @@ -321,6 +321,71 @@ def update_task_status( return json.dumps(task, indent=2) +@mcp.tool() +def flag_for_human(task_id: str, note: str) -> str: + """Flag a task as requiring human intervention. + + Sets needs_human=True and records the required action as intervention_note. + Emits a progress event so the flag is visible in session history. + + Args: + task_id: UUID of the task to flag + note: description of the action required from the human (required) + """ + task = _patch(f"/tasks/{task_id}", { + "needs_human": True, + "intervention_note": note, + }) + _post("/progress", { + "task_id": task_id, + "workstream_id": task.get("workstream_id"), + "event_type": "task_flagged_human", + "summary": f"Task flagged for human intervention: {task['title']}", + "author": "custodian", + "detail": {"intervention_note": note}, + }) + return json.dumps(task, indent=2) + + +@mcp.tool() +def clear_human_flag(task_id: str) -> str: + """Clear the human-intervention flag from a task. + + Sets needs_human=False. The intervention_note is preserved as a + historical record. Call this after the human has completed the action. + + Args: + task_id: UUID of the task to clear + """ + task = _patch(f"/tasks/{task_id}", { + "needs_human": False, + }) + _post("/progress", { + "task_id": task_id, + "workstream_id": task.get("workstream_id"), + "event_type": "task_flag_cleared", + "summary": f"Human-intervention flag cleared: {task['title']}", + "author": "custodian", + }) + return json.dumps(task, indent=2) + + +@mcp.tool() +def list_human_interventions(workstream_id: str | None = None) -> str: + """List all tasks flagged for human intervention. + + Returns tasks where needs_human=True, optionally filtered to one workstream. + Use this at session start to surface Bernd's action items. + + Args: + workstream_id: optional UUID to scope results to one workstream + """ + return json.dumps( + _get("/tasks", {"needs_human": "true", "workstream_id": workstream_id}), + indent=2, + ) + + @mcp.tool() def record_decision( title: str, diff --git a/state-hub/migrations/versions/b4c5d6e7f8a9_add_needs_human.py b/state-hub/migrations/versions/b4c5d6e7f8a9_add_needs_human.py new file mode 100644 index 0000000..2c64b53 --- /dev/null +++ b/state-hub/migrations/versions/b4c5d6e7f8a9_add_needs_human.py @@ -0,0 +1,33 @@ +"""Add needs_human flag and intervention_note to tasks + +Revision ID: b4c5d6e7f8a9 +Revises: a3b4c5d6e7f8 +Create Date: 2026-03-03 00:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "b4c5d6e7f8a9" +down_revision: Union[str, None] = "a3b4c5d6e7f8" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "tasks", + sa.Column("needs_human", sa.Boolean(), server_default="false", nullable=False), + ) + op.add_column( + "tasks", + sa.Column("intervention_note", sa.Text(), nullable=True), + ) + op.create_index("ix_tasks_needs_human", "tasks", ["needs_human"]) + + +def downgrade() -> None: + op.drop_index("ix_tasks_needs_human", table_name="tasks") + op.drop_column("tasks", "intervention_note") + op.drop_column("tasks", "needs_human") diff --git a/state-hub/policies/workstream-dod.md b/state-hub/policies/workstream-dod.md new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/state-hub/policies/workstream-dod.md @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/state-hub/scripts/consistency_check.py b/state-hub/scripts/consistency_check.py index 236ca97..f30f7da 100644 --- a/state-hub/scripts/consistency_check.py +++ b/state-hub/scripts/consistency_check.py @@ -15,7 +15,7 @@ Checks: C-08 orphan-db-completed INFO No Completed/archived DB workstream, no file C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location C-10 task-status-drift WARN Yes Task status differs between file and DB - C-11 task-unlinked INFO Yes Task block has no state_hub_task_id + C-11 task-unlinked WARN Yes Task block has no state_hub_task_id C-12 orphan-db-task WARN No DB task in workstream has no file backing Usage: @@ -475,7 +475,7 @@ def check_repo(api_base: str, repo_slug: str) -> ConsistencyReport: elif t_id: # C-11: task exists in file but not linked to DB report.add( - severity="INFO", check_id="C-11", + severity="WARN", check_id="C-11", message=f"Task '{t_id}' has no state_hub_task_id", file_path=f"{fname}#{t_id}", fixable=True, diff --git a/workplans/CUST-WP-0009-human-interventions.md b/workplans/CUST-WP-0009-human-interventions.md new file mode 100644 index 0000000..a16c45c --- /dev/null +++ b/workplans/CUST-WP-0009-human-interventions.md @@ -0,0 +1,77 @@ +--- +id: CUST-WP-0009 +type: workplan +title: Human Interventions Flag +domain: custodian +status: completed +owner: custodian +topic_slug: the-custodian +state_hub_workstream_id: "dd7db29a-fece-488a-a501-cadc860b2f50" +created: 2026-03-03 +updated: 2026-03-03 +--- + +# CUST-WP-0009 — Human Interventions Flag + +## Purpose + +Tasks in the state-hub can represent work for agents (custodian) or for humans (Bernd). +Currently there is no way to distinguish these at query time. Adding a `needs_human` flag +lets agents tag tasks they cannot complete themselves, surfaces them in a dedicated dashboard +page, and lets Bernd work through all his action items in one place. + +## Scope + +- `needs_human` boolean + `intervention_note` text on the `tasks` table +- API filter `GET /tasks/?needs_human=true` +- 3 MCP tools: `flag_for_human`, `clear_human_flag`, `list_human_interventions` +- Dashboard: new "Interventions" top-level page with amber-bordered cards and "Mark done" button + +## Files Changed + +| File | Change | +|------|--------| +| `state-hub/migrations/versions/b4c5d6e7f8a9_add_needs_human.py` | New migration | +| `state-hub/api/models/task.py` | Added `needs_human`, `intervention_note` fields | +| `state-hub/api/schemas/task.py` | Added fields + validators to Create/Update/Read | +| `state-hub/api/routers/tasks.py` | Added `needs_human` filter param | +| `state-hub/mcp_server/server.py` | Added 3 new tools | +| `state-hub/mcp_server/TOOLS.md` | Documented 3 new tools | +| `state-hub/dashboard/src/interventions.md` | New dashboard page | +| `state-hub/dashboard/observablehq.config.js` | Added nav entry | + +## Tasks + +### Step 1: Run migration +```task +id: CUST-WP-0009-T1 +title: Run Alembic migration b4c5d6e7f8a9 +status: done +priority: high +state_hub_task_id: "a2d5561f-9c1e-40c2-9259-c3d3cc9761b0" +``` + +### Step 2: Verify API +```task +id: CUST-WP-0009-T2 +title: Verify API validator and filter work +status: done +priority: medium +state_hub_task_id: "6b739018-c3bc-4e7c-aa28-9ca1a64406f1" +``` + +### Step 3: Verify dashboard +```task +id: CUST-WP-0009-T3 +title: Verify Interventions dashboard page loads +status: done +priority: medium +state_hub_task_id: "e474259d-c5ff-4c60-86c7-9585c0d0797f" +``` + +## Design Notes + +- `intervention_note` required when `needs_human=True` (API validator raises 422) +- Mirrors the `blocking_reason` pattern for blocked tasks +- Dashboard "Mark done" button PATCHes `{status: "done", needs_human: false}` +- MCP `clear_human_flag` is the agent-side equivalent (used after human completes the action) diff --git a/workplans/CUST-WP-0010-workstream-lifecycle-docs.md b/workplans/CUST-WP-0010-workstream-lifecycle-docs.md new file mode 100644 index 0000000..0d5ae01 --- /dev/null +++ b/workplans/CUST-WP-0010-workstream-lifecycle-docs.md @@ -0,0 +1,74 @@ +--- +id: CUST-WP-0010 +type: workplan +title: Workstream Lifecycle Documentation +domain: custodian +status: completed +owner: custodian +topic_slug: the-custodian +state_hub_workstream_id: "e73c41f1-45b7-4c92-8b45-3105a1936fff" +created: 2026-03-03 +updated: 2026-03-03 +--- + +# CUST-WP-0010 — Workstream Lifecycle Documentation + +## Purpose + +The dashboard "Workstreams by Domain" chart exposes seven computed states +(active, accepted, finished, blocked, stalled, oldies + time-based modes). +These need to be documented so that Bernd and the custodian share a common +vocabulary, and so the "accepted" quality-gate pattern is clearly understood +as the anchor for custodian review. + +## Scope + +- New dashboard reference page: `docs/workstream-lifecycle.md` +- Update `docs/workstreams.md` status table to reflect new state names +- Nav entry in `observablehq.config.js` + +## Files Changed + +| File | Change | +|------|--------| +| `state-hub/dashboard/src/docs/workstream-lifecycle.md` | New reference page | +| `state-hub/dashboard/src/docs/workstreams.md` | Update status table | +| `state-hub/dashboard/observablehq.config.js` | Add nav entry | + +## Tasks + +### Step 1: Write lifecycle docs page +```task +id: CUST-WP-0010-T1 +title: Create docs/workstream-lifecycle.md +status: done +priority: high +state_hub_task_id: "95e5810d-947a-4039-b017-9bee85cf4f48" +``` + +### Step 2: Update workstreams reference +```task +id: CUST-WP-0010-T2 +title: Update docs/workstreams.md status table +status: done +priority: medium +state_hub_task_id: "30d61368-8ede-4d84-9f98-44fe6ff57dda" +``` + +### Step 3: Add nav entry +```task +id: CUST-WP-0010-T3 +title: Add nav entry to observablehq.config.js +status: done +priority: medium +state_hub_task_id: "4cef0841-5324-4fbc-a44b-ad8520f77c9f" +``` + +## Design Notes + +- "accepted" maps to DB `status = "completed"` — the name change reflects intent +- The lifecycle is linear: active → finished (task-derived) → accepted (human+custodian gate) +- Attention signals (blocked, stalled, oldies) are orthogonal — a workstream can be + active AND stalled at the same time +- The custodian uses the "finished but not accepted" gap as the trigger to run + quality checks and create follow-up tasks before signing off