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

@@ -108,6 +108,40 @@ display(html`<div class="grid grid-cols-4" style="gap:1rem;margin-bottom:1.5rem"
</div>`);
```
## What's next?
```js
// next_steps comes from the summary poll (derived, never persisted)
const nextSteps = summary.next_steps ?? [];
const typeLabel = {
resolved_decision: "Decision resolved",
dependency_cleared: "Dependency cleared",
unblocked_task: "Task unblocked",
};
const typeBadgeClass = {
resolved_decision: "ns-badge-decision",
dependency_cleared: "ns-badge-dep",
unblocked_task: "ns-badge-task",
};
if (nextSteps.length === 0) {
display(html`<p class="ns-empty">No actionable suggestions right now — all open workstreams are making progress or waiting on decisions.</p>`);
} else {
display(html`<div class="ns-grid">${nextSteps.map(s => html`
<div class="ns-card">
<div class="ns-card-header">
<span class="ns-badge ${typeBadgeClass[s.type] ?? ''}">${typeLabel[s.type] ?? s.type}</span>
<span class="ns-domain">${s.domain ?? "—"}</span>
</div>
<div class="ns-ws">${s.workstream_title ?? "—"}</div>
<div class="ns-task">${s.task_title ? html`→ <strong>${s.task_title}</strong>` : ""}</div>
<div class="ns-msg">${s.message}</div>
</div>
`)}</div>`);
}
```
## Registered Projects
```js
@@ -358,4 +392,17 @@ display(Inputs.table((summary.recent_progress ?? []).map(e => ({
.r-msg { font-size: 0.8rem; color: #b45309; }
.r-copy { padding: 0.15rem 0.55rem; border-radius: 3px; border: 1px solid var(--theme-foreground-faint); background: var(--theme-background); color: var(--theme-foreground-muted); cursor: pointer; font-size: 0.75rem; }
.r-copy:hover { background: var(--theme-background-alt); }
/* What's next */
.ns-empty { color: gray; font-style: italic; }
.ns-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 0.75rem; }
.ns-card { background: var(--theme-background-alt); border-radius: 8px; padding: 0.85rem 1rem; border-left: 4px solid #555; }
.ns-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
.ns-badge { font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; padding: 0.15rem 0.45rem; border-radius: 10px; font-weight: 600; }
.ns-badge-decision { background: #d4edda; color: #155724; }
.ns-badge-dep { background: #cce5ff; color: #004085; }
.ns-badge-task { background: #fff3cd; color: #856404; }
.ns-domain { font-size: 0.75rem; color: gray; }
.ns-ws { font-weight: 600; font-size: 0.9rem; margin-bottom: 0.2rem; }
.ns-task { font-size: 0.85rem; margin-bottom: 0.35rem; }
.ns-msg { font-size: 0.78rem; color: #555; line-height: 1.4; }
</style>

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>