feat(tasks): add needs_human intervention flag (CUST-WP-0009)

- Migration b4c5d6e7f8a9: adds needs_human (bool) + intervention_note (text) to tasks
- API: needs_human filter on GET /tasks/; 422 if flagged without note
- 3 MCP tools: flag_for_human, clear_human_flag, list_human_interventions
- Dashboard: interventions.md with amber cards and "Mark done" button
- Policy router + workstream DoD policy (workstream-dod.md)
- Workstream lifecycle docs page + workplan CUST-WP-0010
- CLAUDE.md: add step 4 (run fix-consistency after workplan writes)
- consistency_check.py: promote C-11 unlinked tasks from INFO to WARN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 19:44:14 +01:00
parent 5c1b7e7e1d
commit c792ab0bc0
16 changed files with 794 additions and 55 deletions

View File

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

View File

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

41
api/routers/policy.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -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="<uuid>", status="completed")
# Or via REST
curl -X PATCH http://127.0.0.1:8000/workstreams/<uuid>/ \
-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.*

View File

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

View File

@@ -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`<div class="warning">⚠️ ${summary.error}</div>`);
```
## 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`<select class="ws-mode-select">
<optgroup label="By Status">
<option value="active" selected>active</option>
<option value="accepted">accepted</option>
<option value="finished">finished</option>
<option value="blocked">blocked</option>
<option value="stalled">stalled</option>
<option value="oldies">oldies</option>
</optgroup>
<optgroup label="Recently Changed">
<option value="1h">last 1 hour</option>
<option value="1d">last 24 hours</option>
<option value="7d">last 7 days</option>
<option value="30d">last 30 days</option>
<option value="today">today</option>
<option value="week">this week</option>
<option value="month">this month</option>
</optgroup>
</select>`);
```
```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`<p style="color:gray">No open workstreams.</p>`);
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`<p style="color:gray">${_emptyMsg[_chartMode] ?? "No workstreams."}</p>`);
} 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 => ({
<style>
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
.ws-mode-bar { margin-bottom: 0.75rem; }
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.card.warn { border: 2px solid orange; }
.card-link { cursor: pointer; transition: box-shadow 0.15s, transform 0.1s; text-decoration: none; color: inherit; display: block; }

View File

@@ -0,0 +1,213 @@
---
title: Interventions
---
```js
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
```
```js
// Live poll: human-flagged tasks + workstreams + topics
const interventionState = (async function*() {
while (true) {
let tasks = [], wsMap = {}, ok = false;
try {
const [rt, rw, rto, rr] = await Promise.all([
fetch(`${API}/tasks/?needs_human=true`),
fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
]);
ok = rt.ok && rw.ok && rto.ok && rr.ok;
if (ok) {
const [taskList, wsList, topicList, repoList] = await Promise.all([
rt.json(), rw.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]));
wsMap = Object.fromEntries(wsList.map(w => [w.id, {
...w,
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
}]));
tasks = taskList.map(t => ({
...t,
workstream_title: wsMap[t.workstream_id]?.title ?? "—",
domain: wsMap[t.workstream_id]?.domain ?? "unknown",
}));
}
} catch {}
yield {tasks, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const tasks = interventionState.tasks ?? [];
const _ok = interventionState.ok ?? false;
const _ts = interventionState.ts;
```
```js
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]);
const open = tasks.filter(t => OPEN_STATUSES.has(t.status));
const closed = tasks.filter(t => !OPEN_STATUSES.has(t.status));
// Domain breakdown for top-3
const domainCounts = {};
for (const t of open) {
domainCounts[t.domain] = (domainCounts[t.domain] ?? 0) + 1;
}
const top3Domains = Object.entries(domainCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 3);
const critHighCount = open.filter(t => t.priority === "critical" || t.priority === "high").length;
```
# Interventions
```js
import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js";
// ── KPI sidebar card ──────────────────────────────────────────────────────────
const _kpiBox = html`<div class="kpi-infobox">
<div class="kpi-infobox-title">Interventions</div>
<div class="kpi-row">
<span class="kpi-row-label">open</span>
<div class="kpi-row-right">
<div class="kpi-row-value">${open.length}</div>
</div>
</div>
<div class="kpi-row">
<span class="kpi-row-label">critical / high</span>
<div class="kpi-row-right">
<div class="kpi-row-value" style="color:${critHighCount > 0 ? '#dc2626' : 'inherit'}">${critHighCount}</div>
</div>
</div>
${top3Domains.map(([domain, count]) => html`
<div class="kpi-row">
<span class="kpi-row-label">${domain}</span>
<div class="kpi-row-right">
<div class="kpi-row-value" style="font-size:1rem">${count}</div>
</div>
</div>`)}
</div>`;
// ── Live indicator ────────────────────────────────────────────────────────────
const _liveEl = html`<div class="live-indicator">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: html`<span style="color:red">Offline — run: <code>make api</code></span>`}
</div>`;
withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("intervention-kpi-box", _kpiBox);
injectTocTop("live-indicator", _liveEl);
```
Tasks flagged `needs_human=true` — actions only Bernd can take.
---
## Open
```js
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2};
function sortTasks(arr) {
return [...arr].sort((a, b) => {
const pd = (PRIORITY_ORDER[a.priority] ?? 9) - (PRIORITY_ORDER[b.priority] ?? 9);
if (pd !== 0) return pd;
return (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
});
}
async function markDone(taskId) {
try {
await fetch(`${API}/tasks/${taskId}/`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({status: "done", needs_human: false, intervention_note: null}),
});
} catch {}
}
function renderCard(t) {
const isOpen = OPEN_STATUSES.has(t.status);
const borderColor = isOpen ? "#f59e0b" : "#22c55e";
return html`<div class="intervention-card" style="border-left-color:${borderColor}">
<div class="int-card-header">
<span class="task-badge task-priority-${t.priority}">${t.priority}</span>
<span class="task-status-chip status-chip-${t.status}">${t.status.replace("_", " ")}</span>
<span class="task-context">${t.domain}</span>
<span class="task-context task-ws-name">${t.workstream_title}</span>
${isOpen ? html`<button class="done-btn" onclick=${() => markDone(t.id)}>Mark done</button>` : ""}
</div>
<div class="int-action">${t.intervention_note ?? "(no note)"}</div>
${t.title !== t.intervention_note ? html`<details class="int-desc"><summary>Task: ${t.title}</summary>${t.description ?? ""}</details>` : ""}
</div>`;
}
if (open.length === 0) {
display(html`<p class="dim">No open interventions — you're clear! ✓</p>`);
} else {
display(html`<div class="task-list">${sortTasks(open).map(renderCard)}</div>`);
}
```
---
## Completed / Cancelled
```js
if (closed.length === 0) {
display(html`<p class="dim">No completed interventions yet.</p>`);
} else {
display(html`<div class="task-list">${[...closed].reverse().map(renderCard)}</div>`);
}
```
<style>
/* ── Live indicator ───────────────────────────────────────────────────────── */
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
/* ── KPI infobox ──────────────────────────────────────────────────────────── */
.kpi-infobox { background: var(--theme-background-alt, #f9f9f9); border: 1px solid var(--theme-foreground-faint, #e0e0e0); border-radius: 10px; padding: 0.75rem 1rem; position: relative; box-shadow: 0 1px 6px rgba(0,0,0,0.07); margin-bottom: 1.25rem; }
.kpi-infobox-title { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-muted, #888); margin-bottom: 0.55rem; }
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; }
.kpi-row + .kpi-row { border-top: 1px solid var(--theme-foreground-faint, #eee); }
.kpi-row-label { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); white-space: nowrap; }
.kpi-row-right { text-align: right; }
.kpi-row-value { font-size: 1.25rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; }
/* ── Intervention cards ───────────────────────────────────────────────────── */
.task-list { display: flex; flex-direction: column; gap: 0.6rem; }
.intervention-card { border-left: 4px solid #f59e0b; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.7rem 1rem; }
.int-card-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; font-size: 0.75rem; }
.int-action { font-weight: 600; font-size: 0.95rem; color: var(--theme-foreground, #222); margin-bottom: 0.2rem; }
.int-desc { font-size: 0.8rem; color: var(--theme-foreground-muted, #666); margin-top: 0.3rem; }
.int-desc summary { cursor: pointer; }
.done-btn { margin-left: auto; padding: 0.15rem 0.6rem; border-radius: 6px; border: 1px solid #22c55e; background: #f0fdf4; color: #166534; font-size: 0.7rem; font-weight: 600; cursor: pointer; }
.done-btn:hover { background: #dcfce7; }
/* ── Shared badges ────────────────────────────────────────────────────────── */
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.task-priority-critical { background: #fee2e2; color: #991b1b; }
.task-priority-high { background: #ffedd5; color: #9a3412; }
.task-priority-medium { background: #dbeafe; color: #1e40af; }
.task-priority-low { background: #f1f5f9; color: #475569; }
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
.status-chip-blocked { background: #fee2e2; color: #991b1b; }
.status-chip-in_progress { background: #dbeafe; color: #1e40af; }
.status-chip-todo { background: #f1f5f9; color: #475569; }
.status-chip-done { background: #dcfce7; color: #166534; }
.status-chip-cancelled { background: #f1f5f9; color: #64748b; }
.task-context { color: var(--theme-foreground-muted, #666); }
.task-ws-name { font-style: italic; }
.dim { color: gray; font-style: italic; }
</style>

View File

@@ -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`<div></div>`);
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`<div style="display:flex;gap:0.5rem;margin-bottom:1rem">${nodes}</div>`;
}
function _btn(label, primary = false) {
return html`<button style="
padding:0.35rem 0.9rem;border-radius:4px;cursor:pointer;font-size:13px;
background:${primary ? "#1e293b" : "#f1f5f9"};
color:${primary ? "#f8fafc" : "#1e293b"};
border:1px solid ${primary ? "#1e293b" : "#cbd5e1"};
">${label}</button>`;
}
function _render() {
_root.innerHTML = "";
if (_editing) {
const area = html`<textarea style="
width:100%;box-sizing:border-box;height:520px;
font-family:ui-monospace,monospace;font-size:13px;line-height:1.6;
padding:0.75rem;border:1px solid #cbd5e1;border-radius:4px;
background:#f8fafc;color:#1e293b;resize:vertical;
">${_content}</textarea>`;
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`<div style="
max-width:720px;line-height:1.7;
"></div>`;
body.innerHTML = marked.parse(_content);
_root.append(_toolbar(editBtn), body);
}
}
_render();
```

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
test

View File

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