state-hub/dashboard/src/index.md
tegwick ebe7369249 Add create-workstream: MCP tool, CLI commands, dashboard hint
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>
2026-02-24 23:35:54 +01:00

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}));