Implement State Hub v0.2: dependency graph, next-steps suggestions, design boundary

S0 — Design boundary formalised across all integration surfaces:
- TOOLS.md restructured with Design Boundary section, Sanctioned Write Tools,
  and Bootstrap-Only Tools (create_workstream, create_task) with explicit note
- project_claude_md.template and railiance CLAUDE.md updated with boundary note
  and get_next_steps() in session start protocol
- Global ~/.claude/CLAUDE.md updated accordingly

S1 — Workstream dependency graph:
- WorkstreamDependency model (directed edge, CASCADE on delete, unique pair constraint)
- Alembic migration 0b547c153153; script.py.mako added (was missing)
- REST API: POST/GET /workstreams/{id}/dependencies/, DELETE …/{dep_id} (hard delete)
- StateSummary open_workstreams enriched with depends_on/blocks lists
- MCP tools: create_dependency(), list_dependencies()
- Dashboard workstreams page: Dependencies section with relationship cards
- Seeded: custodian-agent-runtime → llm-shared-library + phase-0-operational-baseline

S2 — Suggesting Next Steps (sanctioned write use case #2):
- GET /state/next_steps derives suggestions from recently resolved decisions
  (→ first open task in same workstream) and cleared dependencies
  (→ first todo task in now-unblocked workstream)
- StateSummary.next_steps included on every summary call
- MCP tool: get_next_steps()
- Dashboard: "What's next?" card grid above Registered Projects

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 23:33:14 +01:00
parent 9965349135
commit f34b49ebde
15 changed files with 678 additions and 35 deletions

View File

@@ -8,36 +8,40 @@ const POLL = 15_000;
```
```js
// Fetch workstreams + topics in parallel, join on topic_id → domain/title
// Fetch workstreams + topics + summary (for dep graph) in parallel
const wsState = (async function*() {
while (true) {
let data = [], ok = false;
let data = [], openWs = [], ok = false;
try {
const [rw, rt] = await Promise.all([
const [rw, rt, rs] = await Promise.all([
fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`),
fetch(`${API}/state/summary`),
]);
ok = rw.ok && rt.ok;
ok = rw.ok && rt.ok && rs.ok;
if (ok) {
const [wsList, topicList] = await Promise.all([rw.json(), rt.json()]);
const [wsList, topicList, summary] = await Promise.all([rw.json(), rt.json(), rs.json()]);
const topicMap = Object.fromEntries(topicList.map(t => [t.id, t]));
data = wsList.map(w => ({
...w,
domain: topicMap[w.topic_id]?.domain ?? "unknown",
topic_title: topicMap[w.topic_id]?.title ?? "—",
}));
// open_workstreams from summary carry depends_on / blocks lists
openWs = summary.open_workstreams ?? [];
}
} catch {}
yield {data, ok, ts: new Date()};
yield {data, openWs, ok, ts: new Date()};
await new Promise(res => setTimeout(res, POLL));
}
})();
```
```js
const data = wsState.data ?? [];
const _ok = wsState.ok ?? false;
const _ts = wsState.ts;
const data = wsState.data ?? [];
const openWs = wsState.openWs ?? [];
const _ok = wsState.ok ?? false;
const _ts = wsState.ts;
```
# Workstreams
@@ -96,6 +100,47 @@ display(Plot.plot({
}));
```
## Dependencies
```js
// Build dep cards from the enriched open_workstreams in the summary
const wsWithDeps = openWs.filter(w =>
(domainFilter === "(all)" || (data.find(d => d.id === w.id)?.domain ?? "unknown") === domainFilter) &&
(statusFilter === "(all)" || w.status === statusFilter) &&
(w.depends_on.length > 0 || w.blocks.length > 0)
);
if (wsWithDeps.length === 0) {
display(html`<p class="dim">No dependency edges recorded for the current filter. Use <code>create_dependency()</code> via the MCP server to link workstreams.</p>`);
} 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>`);
}
```
<style>
.live-bar { font-size: 0.8rem; color: gray; text-align: right; margin-bottom: 0.5rem; }
.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; }
.dep-status-completed { background: #cce5ff; color: #004085; }
.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; }
</style>