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>
This commit is contained in:
2026-02-24 17:47:49 +01:00
commit 0ea2788943
48 changed files with 8567 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Observable data loader: all decisions."""
import json
import os
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
try:
with urllib.request.urlopen(f"{API_BASE}/decisions", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.URLError as e:
print(json.dumps({"error": str(e), "decisions": []}))

View File

@@ -0,0 +1,16 @@
#!/usr/bin/env python3
"""Observable data loader: recent progress events (last 200)."""
import json
import os
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
try:
url = f"{API_BASE}/progress?limit=200"
with urllib.request.urlopen(url, timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.URLError as e:
print(json.dumps({"error": str(e), "events": []}))

View File

@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Observable data loader: fetches /state/summary from the API."""
import json
import os
import sys
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
try:
with urllib.request.urlopen(f"{API_BASE}/state/summary", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.URLError as e:
# Return empty structure so the dashboard can show an error state
print(json.dumps({
"error": str(e),
"generated_at": None,
"totals": {
"topics": {"active": 0, "paused": 0, "archived": 0, "total": 0},
"workstreams": {"active": 0, "blocked": 0, "completed": 0, "archived": 0, "total": 0},
"tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0},
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
},
"topics": [],
"blocking_decisions": [],
"blocked_tasks": [],
"recent_progress": [],
"open_workstreams": [],
}))

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
"""Observable data loader: all workstreams."""
import json
import os
import urllib.request
import urllib.error
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
try:
with urllib.request.urlopen(f"{API_BASE}/workstreams", timeout=10) as resp:
data = json.loads(resp.read())
print(json.dumps(data))
except urllib.error.URLError as e:
print(json.dumps({"error": str(e), "workstreams": []}))

View File

@@ -0,0 +1,70 @@
---
title: Decisions
---
# Decisions
```js
const decisions = await FileAttachment("data/decisions.json").json();
const data = Array.isArray(decisions) ? decisions : [];
const pending = data.filter(d => d.decision_type === "pending");
const made = data.filter(d => d.decision_type === "made");
```
```js
const tab = view(Inputs.select(["Pending", "Made"], { label: "View" }));
```
```js
const shown = tab === "Pending" ? pending : made;
display(Inputs.table(shown.map(d => ({
Title: d.title,
Status: d.status + (d.escalation_note ? " ⚠️" : ""),
Decided_by: d.decided_by ?? "—",
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
Rationale: (d.rationale ?? "").slice(0, 80),
Updated: new Date(d.updated_at).toLocaleDateString(),
})), { rows: 30 }));
```
## Resolution Velocity
```js
import * as Plot from "npm:@observablehq/plot";
const resolved = made.filter(d => d.decided_at);
const byMonth = resolved.reduce((acc, d) => {
const m = d.decided_at.slice(0, 7);
acc[m] = (acc[m] ?? 0) + 1;
return acc;
}, {});
display(Plot.plot({
title: "Decisions Resolved per Month",
x: { label: "Month", tickRotate: -30 },
y: { label: "Count", grid: true },
marks: [
Plot.barY(
Object.entries(byMonth).map(([month, count]) => ({ month, count })),
{ x: "month", y: "count", fill: "steelblue", tip: true }
),
Plot.ruleY([0]),
],
marginBottom: 60,
width: 700,
}));
```
```js
if (tab === "Pending" && pending.filter(d => d.escalation_note).length > 0) {
display(html`<div class="escalation-box">
<strong>⚠️ Escalated decisions require human approval before any action is taken (constitution §4).</strong>
<ul>${pending.filter(d => d.escalation_note).map(d => html`<li><b>${d.title}</b>: ${d.escalation_note}</li>`)}</ul>
</div>`);
}
```
<style>
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
</style>

122
dashboard/src/index.md Normal file
View File

@@ -0,0 +1,122 @@
---
title: Overview
---
# Custodian State Hub
```js
const summary = await FileAttachment("data/summary.json").json();
const totals = summary.totals ?? {};
const ws = totals.workstreams ?? {};
const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {};
const topics = totals.topics ?? {};
```
```js
if (summary.error) display(html`<div class="warning">⚠️ API unreachable: ${summary.error}. Run <code>make api</code>.</div>`);
```
## Status
```js
display(html`<div class="grid grid-cols-4" style="gap:1rem; margin-bottom:1.5rem;">
<div class="card">
<h3>Active Workstreams</h3>
<p class="big-number">${ws.active ?? 0}</p>
<small>${ws.blocked ?? 0} blocked</small>
</div>
<div class="card ${(decisions.open + decisions.escalated) > 0 ? 'warn' : ''}">
<h3>Blocking Decisions</h3>
<p class="big-number">${(decisions.open ?? 0) + (decisions.escalated ?? 0)}</p>
<small>${decisions.escalated ?? 0} escalated</small>
</div>
<div class="card ${(tasks.blocked ?? 0) > 0 ? 'warn' : ''}">
<h3>Blocked Tasks</h3>
<p class="big-number">${tasks.blocked ?? 0}</p>
<small>of ${tasks.total ?? 0} total</small>
</div>
<div class="card">
<h3>Progress Events Today</h3>
<p class="big-number">${(summary.recent_progress ?? []).filter(e => e.created_at?.startsWith(new Date().toISOString().slice(0,10))).length}</p>
<small>last 20 shown below</small>
</div>
</div>`);
```
## Tasks by Domain
```js
import * as Plot from "npm:@observablehq/plot";
const tasksByDomain = [];
for (const topic of (summary.topics ?? [])) {
for (const ws of (topic.workstreams ?? [])) {
// workstream stubs don't include tasks in summary — show per-topic WS count as proxy
}
tasksByDomain.push({ domain: topic.domain, status: topic.status, count: (topic.workstreams ?? []).length });
}
display(Plot.plot({
title: "Open Workstreams by Domain",
x: { label: "Domain" },
y: { label: "Count", grid: true },
marks: [
Plot.barY(tasksByDomain, { x: "domain", y: "count", fill: "domain", tip: true }),
Plot.ruleY([0]),
],
marginBottom: 80,
width: 700,
}));
```
## Blocking Decisions
```js
const blocking = summary.blocking_decisions ?? [];
if (blocking.length === 0) {
display(html`<p style="color:green">✓ No blocking decisions.</p>`);
} else {
display(Inputs.table(blocking.map(d => ({
Title: d.title,
Status: d.status,
Deadline: d.deadline ? new Date(d.deadline).toLocaleDateString() : "—",
Escalated: d.escalation_note ? "⚠️" : "",
}))));
}
```
## Decisions Due Within 7 Days
```js
const now = new Date();
const in7 = new Date(now.getTime() + 7*24*60*60*1000);
const due = (summary.blocking_decisions ?? []).filter(d => d.deadline && new Date(d.deadline) <= in7);
if (due.length === 0) {
display(html`<p>No decisions due in next 7 days.</p>`);
} else {
display(Inputs.table(due.map(d => ({
Title: d.title,
Deadline: new Date(d.deadline).toLocaleString(),
Status: d.status,
}))));
}
```
## Recent Activity
```js
display(Inputs.table((summary.recent_progress ?? []).map(e => ({
Time: new Date(e.created_at).toLocaleString(),
Type: e.event_type,
Author: e.author ?? "—",
Summary: e.summary,
})), { maxWidth: 900 }));
```
<style>
.card { background: var(--theme-background-alt); border-radius: 8px; padding: 1rem; }
.card.warn { border: 2px solid orange; }
.big-number { font-size: 2.5rem; font-weight: bold; margin: 0.25rem 0; }
.warning { background: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; padding: 0.75rem; }
</style>

77
dashboard/src/progress.md Normal file
View File

@@ -0,0 +1,77 @@
---
title: Progress
---
# Progress Log
*Append-only per constitution §5 — no deletions.*
```js
const events = await FileAttachment("data/progress.json").json();
const data = Array.isArray(events) ? events : [];
```
```js
const authorFilter = view(Inputs.select(
["(all)", ...new Set(data.map(e => e.author ?? "unknown"))],
{ label: "Author" }
));
const typeFilter = view(Inputs.select(
["(all)", ...new Set(data.map(e => e.event_type))],
{ label: "Event type" }
));
const sinceFilter = view(Inputs.date({ label: "Since" }));
```
```js
const filtered = data.filter(e =>
(authorFilter === "(all)" || (e.author ?? "unknown") === authorFilter) &&
(typeFilter === "(all)" || e.event_type === typeFilter) &&
(!sinceFilter || new Date(e.created_at) >= sinceFilter)
);
display(html`<p><strong>${filtered.length}</strong> events shown (append-only, no deletions).</p>`);
display(Inputs.table(filtered.map(e => ({
Time: new Date(e.created_at).toLocaleString(),
Type: e.event_type,
Author: e.author ?? "—",
Summary: e.summary,
})), { rows: 50 }));
```
## Event Volume (Last 30 Days)
```js
import * as Plot from "npm:@observablehq/plot";
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 30);
const byDay = data
.filter(e => new Date(e.created_at) >= cutoff)
.reduce((acc, e) => {
const day = e.created_at.slice(0, 10);
acc[day] = (acc[day] ?? 0) + 1;
return acc;
}, {});
display(Plot.plot({
title: "Progress Events per Day (30-day window)",
x: { label: "Date", tickRotate: -30 },
y: { label: "Events", grid: true },
marks: [
Plot.areaY(
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })),
{ x: "day", y: "count", fill: "steelblue", fillOpacity: 0.3 }
),
Plot.lineY(
Object.entries(byDay).sort().map(([day, count]) => ({ day, count })),
{ x: "day", y: "count", stroke: "steelblue" }
),
Plot.ruleY([0]),
],
marginBottom: 60,
width: 750,
}));
```

View File

@@ -0,0 +1,67 @@
---
title: Workstreams
---
# Workstreams
```js
const workstreams = await FileAttachment("data/workstreams.json").json();
const data = Array.isArray(workstreams) ? workstreams : [];
```
```js
const domainFilter = view(Inputs.select(
["(all)", ...new Set(data.map(w => w.domain ?? "unknown"))],
{ label: "Domain" }
));
const statusFilter = view(Inputs.select(
["(all)", "active", "blocked", "completed", "archived"],
{ label: "Status" }
));
const ownerFilter = view(Inputs.text({ label: "Owner contains" }));
```
```js
const filtered = data.filter(w =>
(domainFilter === "(all)" || w.domain === domainFilter) &&
(statusFilter === "(all)" || w.status === statusFilter) &&
(!ownerFilter || (w.owner ?? "").toLowerCase().includes(ownerFilter.toLowerCase()))
);
const STATUS_COLOR = {
active: "green",
blocked: "orange",
completed: "blue",
archived: "gray",
};
display(Inputs.table(filtered.map(w => ({
Title: w.title,
Domain: w.domain,
Status: w.status,
Owner: w.owner ?? "—",
"Due": w.due_date ?? "—",
"Updated": new Date(w.updated_at).toLocaleDateString(),
})), {
rows: 20,
}));
```
```js
import * as Plot from "npm:@observablehq/plot";
display(Plot.plot({
title: "Workstream Status Distribution",
marks: [
Plot.barX(
Object.entries(
filtered.reduce((acc, w) => { acc[w.status] = (acc[w.status] ?? 0) + 1; return acc; }, {})
).map(([status, count]) => ({ status, count })),
{ y: "status", x: "count", fill: "status", tip: true }
),
Plot.ruleX([0]),
],
marginLeft: 80,
width: 500,
}));
```