Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
---
2026-06-04 08:25:31 +02:00
title: Workplans
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
---
2026-02-24 23:19:26 +01:00
```js
2026-05-11 17:58:18 +02:00
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
2026-05-18 01:31:36 +02:00
import {WORKSTREAM_STATUSES, isClosedWorkstream, normalizeWorkstreamStatus} from "./components/workplan-status.js";
2026-02-24 23:19:26 +01:00
```
```js
2026-05-11 17:58:18 +02:00
// Fetch workstreams + topics + dep edges in parallel; /state/deps replaces the
// heavier /state/summary which was only used here to extract dependency edges.
2026-02-24 23:19:26 +01:00
const wsState = (async function*() {
2026-05-06 04:04:53 +02:00
let failures = 0;
2026-02-24 23:19:26 +01:00
while (true) {
2026-02-25 23:33:14 +01:00
let data = [], openWs = [], ok = false;
2026-02-24 23:19:26 +01:00
try {
2026-05-11 17:58:18 +02:00
const [rw, rt, rr, rd] = await Promise.all([
2026-06-04 08:25:31 +02:00
apiFetch("/workplans/"),
2026-05-06 04:04:53 +02:00
apiFetch("/topics/"),
apiFetch("/repos/"),
2026-05-11 17:58:18 +02:00
apiFetch("/state/deps"),
2026-02-24 23:19:26 +01:00
]);
2026-05-11 17:58:18 +02:00
ok = rw.ok & & rt.ok & & rr.ok & & rd.ok;
2026-02-24 23:19:26 +01:00
if (ok) {
2026-05-11 17:58:18 +02:00
const [wsList, topicList, repoList, depsList] = await Promise.all([rw.json(), rt.json(), rr.json(), rd.json()]);
2026-02-24 23:19:26 +01:00
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
2026-03-02 23:39:17 +01:00
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
2026-02-24 23:19:26 +01:00
data = wsList.map(w => ({
...w,
2026-05-18 01:31:36 +02:00
status: normalizeWorkstreamStatus(w.status),
2026-03-02 23:39:17 +01:00
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
2026-02-24 23:19:26 +01:00
topic_title: topicMap[w.topic_id]?.title ?? "—",
}));
2026-05-11 17:58:18 +02:00
openWs = depsList;
2026-02-24 23:19:26 +01:00
}
} catch {}
2026-05-06 04:04:53 +02:00
failures = ok ? 0 : failures + 1;
2026-02-25 23:33:14 +01:00
yield {data, openWs, ok, ts: new Date()};
2026-05-11 17:58:18 +02:00
await waitForVisible(pollDelay({ok, base: POLL_HEAVY, failures}));
2026-02-24 23:19:26 +01:00
}
})();
```
```js
2026-02-25 23:33:14 +01:00
const data = wsState.data ?? [];
const openWs = wsState.openWs ?? [];
const _ok = wsState.ok ?? false;
const _ts = wsState.ts;
2026-02-24 23:19:26 +01:00
```
2026-02-27 00:03:27 +01:00
```js
// ── Workstream Health Index (WHI) ────────────────────────────────────────────
const _idToDomain = Object.fromEntries(data.map(w => [w.id, w.domain ?? "unknown"]));
2026-05-18 01:31:36 +02:00
const _closedIds = new Set(data.filter(w => isClosedWorkstream(w.status)).map(w => w.id));
2026-02-27 00:03:27 +01:00
const _openCount = openWs.length;
const _allEdges = openWs.flatMap(w => w.depends_on.map(d => ({from: w.id, to: d.workstream_id})));
const _totalEdges = _allEdges.length;
// Dependency Density
const _DD = _openCount > 0 ? _totalEdges / _openCount : 0;
// Blocked Ratio
const _BR = _openCount > 0 ? openWs.filter(w => w.status === "blocked").length / _openCount : 0;
// Single-Point Risk — max inbound edges on one incomplete workstream
const _inbound = {};
for (const e of _allEdges) {
2026-05-18 01:31:36 +02:00
if (!_closedIds.has(e.to)) _inbound[e.to] = (_inbound[e.to] ?? 0) + 1;
2026-02-27 00:03:27 +01:00
}
const _SPR = _openCount > 0
? (Object.keys(_inbound).length > 0 ? Math.max(...Object.values(_inbound)) : 0) / _openCount
: 0;
2026-05-18 01:31:36 +02:00
// Parallel Execution Potential — ready/active workstreams with all deps finished
2026-02-27 00:03:27 +01:00
const _PEP = _openCount > 0
2026-05-18 01:31:36 +02:00
? openWs.filter(w => ["ready", "active"].includes(normalizeWorkstreamStatus(w.status)) & & w.depends_on.every(d => _closedIds.has(d.workstream_id))).length / _openCount
2026-02-27 00:03:27 +01:00
: 0;
// Cross-Domain Dependency Ratio
const _crossEdges = _allEdges.filter(e => (_idToDomain[e.from] ?? "?") !== (_idToDomain[e.to] ?? "?")).length;
const _CDDR = _totalEdges > 0 ? _crossEdges / _totalEdges : 0;
// Cycle Presence Indicator — DFS with visited/inStack colouring
function _detectCycle(nodes, edges) {
const adj = Object.fromEntries(nodes.map(n => [n.id, []]));
for (const e of edges) { if (adj[e.from] !== undefined) adj[e.from].push(e.to); }
const visited = new Set(), inStack = new Set();
function dfs(id) {
if (inStack.has(id)) return true;
if (visited.has(id)) return false;
visited.add(id); inStack.add(id);
for (const nx of (adj[id] ?? [])) { if (dfs(nx)) return true; }
inStack.delete(id);
return false;
}
for (const n of nodes) { if (!visited.has(n.id) & & dfs(n.id)) return 1; }
return 0;
}
const _CPI = _detectCycle(openWs, _allEdges);
// WHI aggregation — DD normalised at DD_critical = 1.0, CPI halves the score
const _DDnorm = Math.min(1, _DD / 1.0);
let _WHI = 0.30*(1 - _DDnorm) + 0.25*(1 - _BR) + 0.15*(1 - _SPR) + 0.20*_PEP + 0.10*(1 - _CDDR);
if (_CPI === 1) _WHI *= 0.5;
_WHI = Math.max(0, Math.min(1, _WHI));
// Per-domain breakdown — intra-domain edges only (measures domain autonomy)
const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unknown"))].sort().map(domain => {
const nodes = openWs.filter(w => (_idToDomain[w.id] ?? "unknown") === domain);
const edges = nodes.flatMap(w =>
w.depends_on
.filter(d => (_idToDomain[d.workstream_id] ?? "unknown") === domain)
.map(d => ({from: w.id, to: d.workstream_id}))
);
const oc = nodes.length;
if (oc === 0) return null;
const te = edges.length;
const dd = oc > 0 ? te / oc : 0;
const br = oc > 0 ? nodes.filter(w => w.status === "blocked").length / oc : 0;
const pep = oc > 0 ? nodes.filter(w => {
2026-05-18 01:31:36 +02:00
if (!["ready", "active"].includes(normalizeWorkstreamStatus(w.status))) return false;
2026-02-27 00:03:27 +01:00
const intraDeps = w.depends_on.filter(d => (_idToDomain[d.workstream_id] ?? "unknown") === domain);
2026-05-18 01:31:36 +02:00
return intraDeps.every(d => _closedIds.has(d.workstream_id));
2026-02-27 00:03:27 +01:00
}).length / oc : 0;
const inb = {};
for (const e of edges) inb[e.to] = (inb[e.to] ?? 0) + 1;
const spr = oc > 0 ? (Object.keys(inb).length > 0 ? Math.max(...Object.values(inb)) : 0) / oc : 0;
const cpi = _detectCycle(nodes, edges);
const ddN = Math.min(1, dd / 1.0);
let whi = 0.30*(1 - ddN) + 0.25*(1 - br) + 0.15*(1 - spr) + 0.20*pep + 0.10; // CDDR=0 within domain
if (cpi === 1) whi *= 0.5;
return {domain, whi: Math.max(0, Math.min(1, whi)), br, pep, cpi, openCount: oc};
}).filter(Boolean);
```
2026-06-04 08:25:31 +02:00
# Workplans
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
```js
feat(dashboard): add entity detail modal and fixed-layout tables
Replace Inputs.table() with buildEntityTable() across workstreams and
tasks pages. Add click-to-detail modal (openEntityModal) on all entity
list views: workstreams, tasks, extension points, and technical debt.
- New component: src/components/entity-modal.js
- openEntityModal(entity, type) — full-detail overlay (Esc/click-outside to close)
- buildEntityTable(rows, cols, onRowClick) — table-layout:fixed, overflow-safe wrapper
- CSS injected lazily; no separate stylesheet required
- Tables: table-layout:fixed keeps content within the content column;
title col 32%, workstream col 14%, all cells ellipsis + title tooltip
- Cards (EP, TD): onclick → modal; workstream name span gets title tooltip
- Blocked task cards also wired to modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:28:44 +01:00
import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js";
import "./components/help-tip.js";
import {openEntityModal, buildEntityTable} from "./components/entity-modal.js";
2026-05-19 02:16:24 +02:00
import {statusControl} from "./components/status-control.js";
dashboard: move live indicator to TOC sidebar on all pages; add live-data docs
- All four pages (index, workstreams, decisions, progress) now inject the
live indicator into #observablehq-toc via injectTocTop("live-indicator", el)
Left-aligned (no text-align: right), position:relative + padding-right for
the ? button affordance
- decisions.md: splits the former combined "decisions-sidebar" widget into two
separate injectTocTop calls — KPI box first (ends lower), live indicator
second (ends at top); both now have their own stable ids
- withDocHelp(_liveEl, "/docs/live-data") wires the ? button on every page
- src/docs/live-data.md: new documentation page explaining poll interval (15s),
indicator colour semantics, offline recovery, and which endpoints each page hits
- Removes the .live-bar CSS class from all pages; replaces with .live-indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:18:09 +01:00
2026-02-27 00:03:27 +01:00
// ── Live indicator ────────────────────────────────────────────────────────────
dashboard: move live indicator to TOC sidebar on all pages; add live-data docs
- All four pages (index, workstreams, decisions, progress) now inject the
live indicator into #observablehq-toc via injectTocTop("live-indicator", el)
Left-aligned (no text-align: right), position:relative + padding-right for
the ? button affordance
- decisions.md: splits the former combined "decisions-sidebar" widget into two
separate injectTocTop calls — KPI box first (ends lower), live indicator
second (ends at top); both now have their own stable ids
- withDocHelp(_liveEl, "/docs/live-data") wires the ? button on every page
- src/docs/live-data.md: new documentation page explaining poll interval (15s),
indicator colour semantics, offline recovery, and which endpoints each page hits
- Removes the .live-bar CSS class from all pages; replaces with .live-indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:18:09 +01:00
const _liveEl = html`< div class = "live-indicator" >
2026-02-24 23:19:26 +01:00
< span style = "color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}" > ●< / span >
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
dashboard: move live indicator to TOC sidebar on all pages; add live-data docs
- All four pages (index, workstreams, decisions, progress) now inject the
live indicator into #observablehq-toc via injectTocTop("live-indicator", el)
Left-aligned (no text-align: right), position:relative + padding-right for
the ? button affordance
- decisions.md: splits the former combined "decisions-sidebar" widget into two
separate injectTocTop calls — KPI box first (ends lower), live indicator
second (ends at top); both now have their own stable ids
- withDocHelp(_liveEl, "/docs/live-data") wires the ? button on every page
- src/docs/live-data.md: new documentation page explaining poll interval (15s),
indicator colour semantics, offline recovery, and which endpoints each page hits
- Removes the .live-bar CSS class from all pages; replaces with .live-indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:18:09 +01:00
: html`<span style="color:red">Offline — run: <code>make api</code></span>` }
< / div > `;
withDocHelp(_liveEl, "/docs/live-data");
2026-02-27 00:03:27 +01:00
// ── WHI card ──────────────────────────────────────────────────────────────────
function _whiColor(v) { return v >= 0.75 ? "#16a34a " : v >= 0.50 ? "#d97706 " : "#dc2626 "; }
function _whiLabel(v) { return v >= 0.75 ? "Healthy" : v >= 0.50 ? "Optimizable" : "Critical"; }
function _warnLevel(name, val) {
if (name === "PEP") return val < 0.30 ? 2 : val < 0 . 60 ? 1 : 0 ;
if (name === "DD") return val > 1.0 ? 2 : val > 0.50 ? 1 : 0;
if (name === "BR") return val > 0.40 ? 2 : val > 0.20 ? 1 : 0;
if (name === "SPR") return val > 0.40 ? 2 : val > 0.25 ? 1 : 0;
if (name === "CDDR") return val > 0.40 ? 1 : 0;
return 0;
}
function _warnColor(lv) { return lv === 2 ? "#dc2626 " : lv === 1 ? "#d97706 " : "var(--theme-foreground-muted, #666 )"; }
const _whiMetrics = [
2026-06-04 08:25:31 +02:00
{name: "DD", val: _DD, fmt: v => v.toFixed(2), label: "Dependency Density", desc: "Average number of dependencies per open workplan; high values indicate a tightly coupled graph that is hard to parallelise."},
{name: "BR", val: _BR, fmt: v => (v*100).toFixed(0)+"%", label: "Blocked Ratio", desc: "Share of open workplans currently in a blocked state; directly reduces the work that can proceed right now."},
{name: "SPR", val: _SPR, fmt: v => (v*100).toFixed(0)+"%", label: "Single-Point Risk", desc: "Share of workplans depended on by others but with no incoming dependencies themselves; losing one stalls everything downstream."},
{name: "PEP", val: _PEP, fmt: v => (v*100).toFixed(0)+"%", label: "Parallel Execution Potential", desc: "Share of open workplans with zero blocking dependencies that could start or continue immediately."},
2026-02-27 08:11:09 +01:00
{name: "CDDR", val: _CDDR, fmt: v => (v*100).toFixed(0)+"%", label: "Cross-Domain Dependency Ratio", desc: "Share of dependency edges that cross domain boundaries; high values mean progress in one domain is gated on another team or project."},
2026-02-27 00:03:27 +01:00
];
const _whiBox = html`< div class = "kpi-infobox whi-box" >
2026-06-04 08:25:31 +02:00
< div class = "kpi-infobox-title" > Workplan Health< / div >
2026-02-27 00:03:27 +01:00
${_openCount === 0
2026-06-04 08:25:31 +02:00
? html`<div class="kpi-row"><span class="kpi-muted">No active workplans</span></div>`
2026-02-27 00:03:27 +01:00
: html`
< div class = "whi-score-row" >
< span class = "whi-value" style = "color:${_whiColor(_WHI)}" > ${(_WHI*100).toFixed(0)}< span class = "whi-pct" > %< / span > < / span >
< span class = "whi-label" style = "color:${_whiColor(_WHI)}" > ${_whiLabel(_WHI)}< / span >
< / div >
${_CPI === 1 ? html`<div class="whi-cycle-alert">⚠ Cycle detected — deadlock</div>` : ""}
< div class = "whi-metrics" >
${_whiMetrics.map(m => {
const lv = _warnLevel(m.name, m.val);
return html`< div class = "whi-metric-row" >
2026-02-27 08:11:09 +01:00
< help-tip class = "whi-metric-name" style = "color:${_warnColor(lv)}" label = "${m.label}" description = "${m.desc}" doc = "/docs/workstream-health-index" > ${m.name}< / help-tip >
< span class = "whi-metric-val" style = "color:${_warnColor(lv)}" > ${m.fmt(m.val)}< / span >
2026-02-27 00:03:27 +01:00
< / div > `;
})}
< / div >
${_domainBreakdown.length > 1 ? html`
< div class = "whi-domains" >
< div class = "whi-domain-header" > by domain< / div >
${_domainBreakdown.map(d => html`< div class = "whi-domain-row" >
< span class = "whi-domain-dot" style = "background:${_whiColor(d.whi)}" > < / span >
2026-02-27 08:11:09 +01:00
< help-tip class = "whi-domain-name"
label="${d.domain.replaceAll('_', ' ')}"
description="Domain-scoped WHI (intra-domain edges only). Open: ${d.openCount} · Blocked: ${(d.br*100).toFixed(0)}% · Runnable: ${(d.pep*100).toFixed(0)}%"
doc="/docs/workstream-health-index">${d.domain}< / help-tip >
2026-02-27 00:03:27 +01:00
< span class = "whi-domain-score" style = "color:${_whiColor(d.whi)}" > ${(d.whi*100).toFixed(0)}%< / span >
2026-06-04 08:25:31 +02:00
${d.cpi === 1 ? html`<help-tip style="color:#d97706;font-size:0.7rem" label="Dependency Cycle" description="A circular dependency exists within this domain — workplans are waiting on each other and cannot all proceed." doc="/docs/workstream-health-index">⚠</help-tip>` : ""}
2026-02-27 00:03:27 +01:00
< / div > `)}
< / div > ` : ""}
`}
< / div > `;
withDocHelp(_whiBox, "/docs/workstream-health-index");
// ── Inject into TOC sidebar: WHI first (lower), live last (top) ───────────────
injectTocTop("whi-kpi-box", _whiBox);
dashboard: move live indicator to TOC sidebar on all pages; add live-data docs
- All four pages (index, workstreams, decisions, progress) now inject the
live indicator into #observablehq-toc via injectTocTop("live-indicator", el)
Left-aligned (no text-align: right), position:relative + padding-right for
the ? button affordance
- decisions.md: splits the former combined "decisions-sidebar" widget into two
separate injectTocTop calls — KPI box first (ends lower), live indicator
second (ends at top); both now have their own stable ids
- withDocHelp(_liveEl, "/docs/live-data") wires the ? button on every page
- src/docs/live-data.md: new documentation page explaining poll interval (15s),
indicator colour semantics, offline recovery, and which endpoints each page hits
- Removes the .live-bar CSS class from all pages; replaces with .live-indicator
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 16:18:09 +01:00
injectTocTop("live-indicator", _liveEl);
Add Decisions and Workstreams reference docs with heading help wiring
- Remove residual constitution footnote from progress page header
- Create src/docs/decisions.md: types, statuses, resolution history chart,
filter bar, card anatomy, Decision Health KPI, escalation protocol
- Create src/docs/workstreams.md: status distribution chart, filter bar,
table columns, dependency graph, create/update patterns
- Wire withDocHelp(h1) on Decisions and Workstreams pages pointing to new docs
- Add both pages to Reference nav section in observablehq.config.js
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 18:12:12 +01:00
const _h1 = document.querySelector("#observablehq -main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/workstreams"); }
2026-05-23 19:11:30 +02:00
display(html`<p class="dim" style="margin-top:-0.25rem"><a href="./workplan-queue">Workplan queue</a></p>` );
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
```
```js
2026-02-26 00:19:58 +01:00
import {MultiSelect} from "./components/multiselect.js";
feat(state-hub): implement v0.5 — dynamic domains & multi-repo
Replaces the hardcoded 6-domain PostgreSQL ENUM with a first-class
`domains` DB table, and adds a `managed_repos` table for multi-repo
support per domain.
P1 — Domain as a DB entity:
- Migration b1c2d3e4f5a6: creates `domains` table, migrates topics.domain
ENUM column to domain_id FK, drops the domain ENUM type
- Domain ORM model (api/models/domain.py) + Pydantic schemas
- Domain API router: GET/POST /domains/, GET/PATCH /domains/{slug}/,
rename and archive endpoints with EP/TD cascade on rename
- Topic model updated: domain_id FK + @property domain_slug for
backwards-compatible JSON serialization (field renamed domain → domain_slug)
- TopicCreate/TopicRead updated; seed.py rewritten to use FK lookup
P2 — Multi-repo support:
- ManagedRepo ORM model (api/models/managed_repo.py) + schemas
- Repo API router: GET/POST /repos/, GET/PATCH /repos/{slug}/, archive
- Makefile: add-domain, rename-domain, add-repo, list-repos targets
- register_project.sh: verify domain via /domains/ API + POST /repos/
P3 — MCP tools & live validation:
- 6 new MCP tools: list_domains, create_domain, rename_domain,
archive_domain, list_domain_repos, register_repo
- EP/TD routers: replace hardcoded VALID_DOMAINS set with per-request
DB lookup — returns 422 with list of valid slugs on unknown domain
- State summary: adds domains: list[DomainSummary] (slug, name,
repo_count, active_workstream_count, ep_count, td_count)
- TOOLS.md updated with domain management section
P4 — Dashboard:
- New domains.md page with KPI row + domain cards + repo lists
- domains.json.py + repos.json.py data loaders
- Domains page added to observablehq.config.js nav
- workstreams.md, extensions.md, techdept.md: domain_slug fix +
dynamic domain list loaded from /domains/ API (no longer hardcoded)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:20:15 +01:00
// Load domain slugs from API (dynamic — works with new domains after v0.5)
const _domainsResp = await fetch(`${API}/domains/?status=active` ).catch(() => null);
const DOMAINS = _domainsResp?.ok
? (await _domainsResp.json()).map(d => d.slug)
: ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
2026-05-18 01:31:36 +02:00
const STATUSES = WORKSTREAM_STATUSES;
2026-02-26 00:05:58 +01:00
2026-02-26 16:49:33 +01:00
// Create filter form without displaying — shown below the chart
const _filtersForm = Inputs.form(
2026-02-26 00:05:58 +01:00
{
2026-02-26 00:19:58 +01:00
domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}),
status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}),
owner: Inputs.text({placeholder: "Owner…", style: "width:120px"}),
2026-02-26 00:05:58 +01:00
},
{
template: ({domain, status, owner}) => html`< div class = "filter-bar" >
2026-02-26 00:19:58 +01:00
${domain}${status}
2026-03-18 02:17:04 +01:00
< div class = "filter-text-input" > ${owner}< / div >
2026-02-26 00:05:58 +01:00
< / div > `,
}
2026-02-26 16:49:33 +01:00
);
```
```js
const filters = Generators.input(_filtersForm);
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
```
```js
2026-02-26 00:05:58 +01:00
// Empty array = no filter applied (show all)
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
const filtered = data.filter(w =>
2026-02-26 00:05:58 +01:00
(filters.domain.length === 0 || filters.domain.includes(w.domain)) & &
(filters.status.length === 0 || filters.status.includes(w.status)) & &
(!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase()))
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
);
```
2026-02-24 23:19:26 +01:00
## Status Distribution
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
```js
import * as Plot from "npm:@observablehq/plot ";
2026-02-24 23:19:26 +01:00
const byStatus = Object.entries(
filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {})
).map(([status, count]) => ({status, count}));
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
display(Plot.plot({
marks: [
2026-02-24 23:19:26 +01:00
Plot.barX(byStatus, {y: "status", x: "count", fill: "status", tip: true}),
Add state-hub v0.1 — local-first state service for the Custodian
Implements the first live layer of the Custodian cognitive infrastructure:
PostgreSQL schema, FastAPI REST API, FastMCP stdio server, and Observable
Framework telemetry dashboard.
- state-hub/: full stack (docker-compose, FastAPI, Alembic, MCP server, dashboard)
- 5 DB tables: topics, workstreams, tasks, decisions, progress_events
- 11 MCP tools + 5 resources registered in .mcp.json
- Observable dashboard: Overview, Workstreams, Decisions, Progress pages
- CLAUDE.md: session protocol (get_state_summary / add_progress_event ritual)
- ~/.claude/CLAUDE.md: global cross-project reference to the hub
- scripts/pull_image.py: WSL2 TLS-resilient Docker image downloader
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 17:47:49 +01:00
Plot.ruleX([0]),
],
marginLeft: 80,
width: 500,
}));
```
2026-02-24 23:19:26 +01:00
2026-06-04 08:25:31 +02:00
## All Workplans
2026-02-26 16:50:44 +01:00
2026-02-26 16:49:33 +01:00
```js
display(_filtersForm);
feat(dashboard): add entity detail modal and fixed-layout tables
Replace Inputs.table() with buildEntityTable() across workstreams and
tasks pages. Add click-to-detail modal (openEntityModal) on all entity
list views: workstreams, tasks, extension points, and technical debt.
- New component: src/components/entity-modal.js
- openEntityModal(entity, type) — full-detail overlay (Esc/click-outside to close)
- buildEntityTable(rows, cols, onRowClick) — table-layout:fixed, overflow-safe wrapper
- CSS injected lazily; no separate stylesheet required
- Tables: table-layout:fixed keeps content within the content column;
title col 32%, workstream col 14%, all cells ellipsis + title tooltip
- Cards (EP, TD): onclick → modal; workstream name span gets title tooltip
- Blocked task cards also wired to modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:28:44 +01:00
{
// Enrich each workstream with tasks/deps data from open_workstreams summary
const _openWsMap = Object.fromEntries(openWs.map(w => [w.id, w]));
const _wsTable = buildEntityTable(
filtered,
[
{label: "Title", key: "title", cls: "et-title-col et-title-cell",
render: w => w.title},
{label: "Domain", key: "domain"},
2026-05-19 02:16:24 +02:00
{label: "Status", render: w => statusControl({entity: w, type: "workstream", statuses: WORKSTREAM_STATUSES})},
feat(dashboard): add entity detail modal and fixed-layout tables
Replace Inputs.table() with buildEntityTable() across workstreams and
tasks pages. Add click-to-detail modal (openEntityModal) on all entity
list views: workstreams, tasks, extension points, and technical debt.
- New component: src/components/entity-modal.js
- openEntityModal(entity, type) — full-detail overlay (Esc/click-outside to close)
- buildEntityTable(rows, cols, onRowClick) — table-layout:fixed, overflow-safe wrapper
- CSS injected lazily; no separate stylesheet required
- Tables: table-layout:fixed keeps content within the content column;
title col 32%, workstream col 14%, all cells ellipsis + title tooltip
- Cards (EP, TD): onclick → modal; workstream name span gets title tooltip
- Blocked task cards also wired to modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 18:28:44 +01:00
{label: "Owner", render: w => w.owner ?? "—"},
{label: "Due", render: w => w.due_date ?? "—"},
{label: "Updated", render: w => new Date(w.updated_at).toLocaleDateString()},
],
w => openEntityModal({...w, ..._openWsMap[w.id]}, "workstream"),
);
display(_wsTable);
}
2026-02-26 16:49:33 +01:00
```
2026-02-25 23:33:14 +01:00
## Dependencies
```js
// Build dep cards from the enriched open_workstreams in the summary
2026-02-26 00:05:58 +01:00
const wsWithDeps = openWs.filter(w => {
const domain = data.find(d => d.id === w.id)?.domain ?? "unknown";
return (filters.domain.length === 0 || filters.domain.includes(domain)) & &
(filters.status.length === 0 || filters.status.includes(w.status)) & &
(w.depends_on.length > 0 || w.blocks.length > 0);
});
2026-02-25 23:33:14 +01:00
if (wsWithDeps.length === 0) {
2026-06-04 08:25:31 +02:00
display(html`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workplans.</p>` );
2026-02-25 23:33:14 +01:00
} else {
display(html`< div class = "dep-grid" > ${wsWithDeps.map(w => {
const depRows = w.depends_on.map(d =>
html`<div class="dep-row dep-on">↳ depends on <strong>${d.workstream_title}</strong>${d.description ? html` < span class = "dep-desc" > — ${d.description}</ span > ` : ""}</div>`
);
const blockRows = w.blocks.map(d =>
html`<div class="dep-row dep-block">⊳ blocks <strong>${d.workstream_title}</strong>${d.description ? html` < span class = "dep-desc" > — ${d.description}</ span > ` : ""}</div>`
);
return html`< div class = "dep-card" >
< div class = "dep-title" > ${w.title}< / div >
< div class = "dep-status dep-status-${w.status}" > ${w.status}< / div >
${depRows}${blockRows}
< / div > `;
})}< / div > `);
}
```
2026-02-24 23:19:26 +01:00
< style >
2026-02-26 16:42:00 +01:00
.live-indicator { font-size: 0.8rem; color: gray; position: relative; padding: 0.55rem 1.8rem 0.55rem 0.7rem; margin-bottom: 0.75rem; }
2026-02-27 00:03:27 +01:00
/* ── KPI infobox base (shared) ───────────────────────────────────────────── */
.kpi-row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; padding: 0.3rem 0; }
.kpi-muted { color: var(--theme-foreground-faint, #aaa ); font-style: italic; font-size: 0.8rem; }
/* ── WHI card ────────────────────────────────────────────────────────────── */
.whi-score-row { display: flex; align-items: baseline; gap: 0.4rem; margin: 0.35rem 0 0.5rem; }
.whi-value { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1; }
.whi-pct { font-size: 1rem; font-weight: 600; }
.whi-label { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; align-self: center; }
.whi-cycle-alert { background: #fef2f2 ; color: #dc2626 ; border-radius: 4px; padding: 0.2rem 0.45rem; font-size: 0.72rem; font-weight: 600; margin-bottom: 0.4rem; }
.whi-metrics { border-top: 1px solid var(--theme-foreground-faint, #eee ); padding-top: 0.35rem; margin-bottom: 0.35rem; }
.whi-metric-row { display: flex; justify-content: space-between; padding: 0.16rem 0; }
2026-02-27 08:11:09 +01:00
.whi-metric-name { font-family: monospace; font-size: 0.72rem; }
2026-02-27 00:03:27 +01:00
.whi-metric-val { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.78rem; }
.whi-domains { border-top: 1px solid var(--theme-foreground-faint, #eee ); padding-top: 0.35rem; }
.whi-domain-header { font-size: 0.65rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--theme-foreground-faint, #aaa ); margin-bottom: 0.2rem; }
.whi-domain-row { display: flex; align-items: center; gap: 0.3rem; padding: 0.1rem 0; }
.whi-domain-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.whi-domain-name { flex: 1; font-size: 0.75rem; color: var(--theme-foreground-muted, #666 ); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.whi-domain-score { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 0.75rem; }
2026-02-25 23:33:14 +01:00
.dim { color: gray; font-style: italic; }
.dep-grid { display: flex; flex-direction: column; gap: 0.75rem; }
.dep-card { border: 1px solid #e0e0e0 ; border-radius: 6px; padding: 0.75rem 1rem; background: var(--theme-background-alt, #fafafa ); }
.dep-title { font-weight: 600; margin-bottom: 0.25rem; }
.dep-status { display: inline-block; font-size: 0.7rem; padding: 1px 6px; border-radius: 10px; margin-bottom: 0.5rem; text-transform: uppercase; }
.dep-status-active { background: #d4edda ; color: #155724 ; }
.dep-status-blocked { background: #f8d7da ; color: #721c24 ; }
2026-05-18 01:31:36 +02:00
.dep-status-proposed { background: #fef3c7 ; color: #92400e ; }
.dep-status-ready { background: #e0f2fe ; color: #075985 ; }
.dep-status-finished { background: #cce5ff ; color: #004085 ; }
.dep-status-backlog { background: #f1f5f9 ; color: #64748b ; }
2026-02-25 23:33:14 +01:00
.dep-row { font-size: 0.85rem; margin: 0.2rem 0 0 0.5rem; color: #444 ; }
.dep-on { color: #1a5276 ; }
.dep-block { color: #6e2f00 ; }
.dep-desc { color: #888 ; font-size: 0.8rem; }
2026-02-24 23:19:26 +01:00
< / style >