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

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