generated from coulomb/repo-seed
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:
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user