MCP server: add create_workstream(topic_id, title, slug?, owner?, description?, due_date?) — auto-generates slug from title if omitted; emits workstream_created progress event. Now 12 tools total. CLI: add two new subcommands — custodian create-workstream --domain DOMAIN --title TITLE [--slug] [--owner] [--description] custodian create-task --workstream ID_OR_SLUG --title TITLE [--priority] [--assignee] create-task accepts workstream UUID or slug (resolves via API). Dashboard: hint box below "Open Workstreams by Domain" chart listing registered domains that have zero workstreams, with the exact custodian create-workstream command to run. TOOLS.md: updated tool count (11 → 12) and added create_workstream row. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.2 KiB
6.2 KiB
| title |
|---|
| Overview |
const API = "http://127.0.0.1:8000";
const POLL = 15_000;
// Live polling — yields {data, ok, ts} every POLL ms
const summaryState = (async function*() {
while (true) {
let data, ok = false;
try {
const r = await fetch(`${API}/state/summary`);
ok = r.ok;
data = ok ? await r.json() : {error: `HTTP ${r.status}`};
} catch (e) {
data = {error: "API unreachable"};
}
yield {data, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
const summary = summaryState.data ?? {};
const _ok = summaryState.ok ?? false;
const _ts = summaryState.ts;
const totals = summary.totals ?? {};
const ws = totals.workstreams ?? {};
const tasks = totals.tasks ?? {};
const decisions = totals.decisions ?? {};
// Registered projects — milestone events tagged with registration
const regsState = (async function*() {
while (true) {
let rows = [];
try {
const r = await fetch(`${API}/progress/?event_type=milestone&limit=500`);
if (r.ok) {
const all = await r.json();
rows = all.filter(e => e.summary?.startsWith("Project registered with State Hub:"));
}
} catch {}
yield rows;
await new Promise(res => setTimeout(res, POLL));
}
})();
Custodian State Hub
display(html`<div class="live-bar">
<span style="color:${_ok ? 'var(--theme-foreground-focus)' : 'red'}">●</span>
${_ok
? `Live · updated ${_ts?.toLocaleTimeString()}`
: `<span style="color:red">Offline — run: <code>cd ~/the-custodian/state-hub && make api</code></span>`}
</div>`);
if (summary.error) display(html`<div class="warning">⚠️ ${summary.error}</div>`);
Status
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-num">${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-num">${(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-num">${tasks.blocked ?? 0}</p>
<small>of ${tasks.total ?? 0} total</small>
</div>
<div class="card">
<h3>Events Today</h3>
<p class="big-num">${(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>`);
Registered Projects
const regs = regsState ?? [];
if (regs.length === 0) {
display(html`<p style="color:gray">No projects registered yet. Run <code>custodian register-project</code> inside a repo.</p>`);
} else {
display(Inputs.table(regs.map(e => ({
Project: e.detail?.project_path?.split("/").at(-1) ?? "—",
Domain: e.detail?.domain ?? "—",
Path: e.detail?.project_path ?? "—",
Registered: new Date(e.created_at).toLocaleString(),
})), {maxWidth: 900}));
}
Open Workstreams by Domain
import * as Plot from "npm:@observablehq/plot";
const wsData = (summary.topics ?? []).map(t => ({
domain: t.domain,
count: (t.workstreams ?? []).length,
}));
display(Plot.plot({
x: {label: "Domain"},
y: {label: "Open workstreams", grid: true},
marks: [
Plot.barY(wsData, {x: "domain", y: "count", fill: "domain", tip: true}),
Plot.ruleY([0]),
],
marginBottom: 80,
width: 700,
}));
// Registered domains with no workstreams yet — show a getting-started hint
const regs = regsState ?? [];
const registeredDomains = new Set(regs.map(e => e.detail?.domain).filter(Boolean));
const emptyRegistered = (summary.topics ?? []).filter(t =>
registeredDomains.has(t.domain) && (t.workstreams ?? []).length === 0
);
if (emptyRegistered.length > 0) {
display(html`<div class="hint-box">
<strong>💡 Getting started</strong>
<p>These registered projects have no workstreams yet:</p>
<ul>${emptyRegistered.map(t => html`<li>
<strong>${t.domain}</strong> — open the repo in Claude Code and ask the Custodian to create one, or run:<br>
<code>custodian create-workstream --domain ${t.domain} --title "My first workstream"</code>
</li>`)}</ul>
</div>`);
}
Blocking Decisions
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
const in7 = new Date(Date.now() + 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
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}));