feat(dashboard): Interventions page improvements and action-confirm modal

- Move Interventions under Workstreams in the navigator
- Add action-confirm.js: shared modal component for actions requiring a
  mandatory comment (survives live-poll re-renders, unlike inline DOM mutation)
- Wire action-confirm into Interventions (Mark done) and Decisions (Resolve)
- Fix Interventions completed section: fetch all tasks and filter client-side
  so resolved interventions (needs_human=false) still appear under Completed
- Add docs/interventions.md help page with ? button on the h1
- Replace all hardcoded "Bernd" with "human" across dashboard src and docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 23:15:06 +01:00
parent 25b4dd07f5
commit b9f5d46428
9 changed files with 426 additions and 31 deletions

View File

@@ -5,7 +5,6 @@ export default {
// ── Overview ──────────────────────────────────────────────────────────────
{ name: "Overview", path: "/" },
{ name: "Todo", path: "/todo" },
{ name: "Interventions", path: "/interventions" },
// ── Organizational Entity Views ───────────────────────────────────────────
{ name: "Domains", path: "/domains" },
{ name: "Repos", path: "/repos" },
@@ -15,11 +14,12 @@ export default {
collapsible: true,
open: false,
pages: [
{ name: "Decisions", path: "/decisions" },
{ name: "Tasks", path: "/tasks" },
{ name: "Debt", path: "/techdept" },
{ name: "Extends", path: "/extensions" },
{ name: "Dependencies", path: "/dependencies" },
{ name: "Decisions", path: "/decisions" },
{ name: "Tasks", path: "/tasks" },
{ name: "Interventions", path: "/interventions" },
{ name: "Debt", path: "/techdept" },
{ name: "Extends", path: "/extensions" },
{ name: "Dependencies", path: "/dependencies" },
],
},
// ── Functional Report Views ────────────────────────────────────────────────
@@ -49,6 +49,7 @@ export default {
{ name: "Domains", path: "/docs/domains" },
{ name: "Extension Points", path: "/docs/extensions" },
{ name: "Inter-Repo Communication", path: "/docs/inter-repo-communication" },
{ name: "Interventions", path: "/docs/interventions" },
{ name: "Live Data", path: "/docs/live-data" },
{ name: "Overview", path: "/docs/overview" },
{ name: "Progress Log", path: "/docs/progress-log" },

View File

@@ -0,0 +1,270 @@
/**
* action-confirm — modal dialog that requires a non-empty comment before
* confirming an action (e.g. resolving a decision, marking an intervention done).
*
* Lives on document.body so it survives live-poll re-renders of the page content.
*
* Usage:
* import {openActionConfirm} from "./components/action-confirm.js";
*
* openActionConfirm({
* title: "Mark as Done", // modal header
* entityTitle: task.title, // shown as context below the header
* label: "Resolution comment", // textarea label
* placeholder: "What was done?", // textarea placeholder
* confirmLabel: "Mark Done", // confirm button text
* onConfirm: async (comment) => { ... }, // called with trimmed comment
* // onConfirm should throw (or return a rejected promise) on API error
* });
*/
const _STYLE_ID = "action-confirm-styles";
function _ensureStyles() {
if (typeof document === "undefined" || document.getElementById(_STYLE_ID)) return;
const s = document.createElement("style");
s.id = _STYLE_ID;
s.textContent = `
/* ── Backdrop ────────────────────────────────────────────────────────────── */
.ac-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.45);
z-index: 9200; display: flex; align-items: center; justify-content: center;
animation: _ac-fade 0.15s ease;
}
@keyframes _ac-fade { from { opacity: 0 } to { opacity: 1 } }
/* ── Box ──────────────────────────────────────────────────────────────────── */
.ac-box {
width: min(480px, 92vw);
background: var(--theme-background, #fff);
border-radius: 12px;
box-shadow: 0 16px 56px rgba(0,0,0,0.28);
display: flex; flex-direction: column; overflow: hidden;
animation: _ac-rise 0.15s ease;
}
@keyframes _ac-rise {
from { transform: translateY(12px); opacity: 0 }
to { transform: translateY(0); opacity: 1 }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.ac-header {
display: flex; align-items: flex-start; gap: 0.75rem;
padding: 0.85rem 1rem 0.75rem;
border-bottom: 1px solid var(--theme-foreground-faint, #e8e8e8);
background: var(--theme-background-alt, #f7f7f7);
}
.ac-title {
flex: 1; font-size: 0.95rem; font-weight: 700; line-height: 1.3;
color: var(--theme-foreground, #111);
}
.ac-close {
background: none; border: 1px solid transparent; cursor: pointer;
font-size: 0.9rem; color: var(--theme-foreground-muted, #888);
padding: 0.15rem 0.45rem; border-radius: 6px; flex-shrink: 0;
font-family: inherit; line-height: 1.3;
}
.ac-close:hover {
border-color: var(--theme-foreground-faint, #ccc);
background: var(--theme-background, #fff); color: var(--theme-foreground, #111);
}
/* ── Body ─────────────────────────────────────────────────────────────────── */
.ac-body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
.ac-entity-title {
font-size: 0.85rem; color: var(--theme-foreground-muted, #555);
background: var(--theme-background-alt, #f9f9f9);
border: 1px solid var(--theme-foreground-faint, #eee);
border-radius: 6px; padding: 0.45rem 0.65rem; line-height: 1.45;
word-break: break-word;
}
.ac-label {
font-size: 0.72rem; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.06em; color: var(--theme-foreground-muted, #666);
margin-bottom: 0.25rem; display: block;
}
.ac-textarea {
width: 100%; box-sizing: border-box;
min-height: 80px; resize: vertical;
font-size: 0.87rem; line-height: 1.5; font-family: inherit;
padding: 0.5rem 0.65rem;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
border-radius: 6px;
background: var(--theme-background, #fff);
color: var(--theme-foreground, #111);
transition: border-color 0.1s, box-shadow 0.1s;
}
.ac-textarea:focus { outline: none; border-color: steelblue; box-shadow: 0 0 0 2px #bfdbfe; }
.ac-textarea.ac-invalid { border-color: #dc2626; box-shadow: 0 0 0 2px #fecaca; }
.ac-error {
font-size: 0.8rem; color: #dc2626;
background: #fef2f2; border: 1px solid #fecaca;
border-radius: 6px; padding: 0.35rem 0.6rem;
}
/* ── Footer ───────────────────────────────────────────────────────────────── */
.ac-footer {
display: flex; justify-content: flex-end; gap: 0.5rem;
padding: 0.65rem 1rem 0.9rem;
border-top: 1px solid var(--theme-foreground-faint, #e8e8e8);
}
.ac-btn-cancel {
padding: 0.3rem 0.85rem; border-radius: 6px;
border: 1px solid var(--theme-foreground-faint, #d1d5db);
background: var(--theme-background, #fff);
color: var(--theme-foreground-muted, #555);
font-size: 0.85rem; font-family: inherit; cursor: pointer;
}
.ac-btn-cancel:hover { border-color: #9ca3af; color: var(--theme-foreground, #111); }
.ac-btn-confirm {
padding: 0.3rem 0.85rem; border-radius: 6px;
border: 1px solid #22c55e; background: #f0fdf4; color: #166534;
font-size: 0.85rem; font-weight: 600; font-family: inherit; cursor: pointer;
transition: background 0.1s;
}
.ac-btn-confirm:hover:not(:disabled) { background: #dcfce7; }
.ac-btn-confirm:disabled { opacity: 0.5; cursor: not-allowed; }
`;
document.head.append(s);
}
/**
* @param {object} opts
* @param {string} opts.title Modal header text
* @param {string} [opts.entityTitle] Optional context shown below the header
* @param {string} opts.label Textarea label
* @param {string} [opts.placeholder] Textarea placeholder
* @param {string} [opts.confirmLabel] Confirm button label (default: "Confirm")
* @param {Function} opts.onConfirm async (comment: string) => void — throw to show error
*/
export function openActionConfirm({
title,
entityTitle,
label,
placeholder = "Add a comment…",
confirmLabel = "Confirm",
onConfirm,
}) {
_ensureStyles();
document.getElementById("_ac-root")?.remove();
const root = document.createElement("div");
root.id = "_ac-root";
root.className = "ac-backdrop";
root.setAttribute("role", "dialog");
root.setAttribute("aria-modal", "true");
root.setAttribute("aria-label", title);
// ── Header ────────────────────────────────────────────────────────────────
const header = document.createElement("div");
header.className = "ac-header";
const titleEl = document.createElement("div");
titleEl.className = "ac-title";
titleEl.textContent = title;
const closeBtn = document.createElement("button");
closeBtn.className = "ac-close";
closeBtn.textContent = "✕";
closeBtn.setAttribute("aria-label", "Cancel");
header.append(titleEl, closeBtn);
// ── Body ──────────────────────────────────────────────────────────────────
const body = document.createElement("div");
body.className = "ac-body";
if (entityTitle) {
const ctx = document.createElement("div");
ctx.className = "ac-entity-title";
ctx.textContent = entityTitle;
body.append(ctx);
}
const labelEl = document.createElement("label");
labelEl.className = "ac-label";
labelEl.textContent = label;
const textarea = document.createElement("textarea");
textarea.className = "ac-textarea";
textarea.placeholder = placeholder;
textarea.rows = 3;
labelEl.append(textarea); // make label clickable
const fieldWrap = document.createElement("div");
fieldWrap.append(labelEl);
body.append(fieldWrap);
const errorEl = document.createElement("div");
errorEl.className = "ac-error";
errorEl.style.display = "none";
body.append(errorEl);
// ── Footer ────────────────────────────────────────────────────────────────
const footer = document.createElement("div");
footer.className = "ac-footer";
const cancelBtn = document.createElement("button");
cancelBtn.className = "ac-btn-cancel";
cancelBtn.textContent = "Cancel";
const confirmBtn = document.createElement("button");
confirmBtn.className = "ac-btn-confirm";
confirmBtn.textContent = confirmLabel;
footer.append(cancelBtn, confirmBtn);
// ── Assemble ──────────────────────────────────────────────────────────────
const box = document.createElement("div");
box.className = "ac-box";
box.append(header, body, footer);
root.append(box);
document.body.append(root);
// Focus the textarea after animation
setTimeout(() => textarea.focus(), 50);
// ── Behaviour ─────────────────────────────────────────────────────────────
const close = () => root.remove();
cancelBtn.addEventListener("click", close);
closeBtn.addEventListener("click", close);
root.addEventListener("click", e => { if (e.target === root) close(); });
const onKey = e => {
if (e.key === "Escape") { close(); document.removeEventListener("keydown", onKey); }
};
document.addEventListener("keydown", onKey);
textarea.addEventListener("input", () => {
textarea.classList.remove("ac-invalid");
errorEl.style.display = "none";
});
confirmBtn.addEventListener("click", async () => {
const comment = textarea.value.trim();
if (!comment) {
textarea.classList.add("ac-invalid");
textarea.focus();
return;
}
confirmBtn.disabled = true;
cancelBtn.disabled = true;
confirmBtn.textContent = "…";
errorEl.style.display = "none";
try {
await onConfirm(comment);
close();
} catch (err) {
errorEl.textContent = err?.message ?? "Request failed — check that the API is running.";
errorEl.style.display = "";
confirmBtn.disabled = false;
cancelBtn.disabled = false;
confirmBtn.textContent = confirmLabel;
}
});
}

View File

@@ -103,8 +103,9 @@ function fmtDuration(ms) {
# Decisions
```js
import {withDocHelp} from "./components/doc-overlay.js";
import {injectTocTop} from "./components/toc-sidebar.js";
import {withDocHelp} from "./components/doc-overlay.js";
import {injectTocTop} from "./components/toc-sidebar.js";
import {openActionConfirm} from "./components/action-confirm.js";
// ── KPI computation (uses full data, not filtered) ──────────────────────────
const _resolved5 = data
@@ -331,6 +332,24 @@ if (filtered.length === 0) {
const _ageText = d.decided_at ? `took ${fmtDuration(_ageMs)}` : `open ${fmtDuration(_ageMs)}`;
const _ageWarn = _isOpen && _meanResolveMs !== null && _ageMs > _meanResolveMs;
function onResolve() {
openActionConfirm({
title: "Resolve Decision",
entityTitle: d.title,
label: "Rationale",
placeholder: "Why is this resolved, and what was decided?",
confirmLabel: "Resolve",
onConfirm: async (rationale) => {
const res = await fetch(`${API}/decisions/${d.id}/`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({status: "resolved", rationale, decided_by: "human"}),
});
if (!res.ok) throw new Error(`API error ${res.status}`);
},
});
}
return html`<div class="dec-item" style="border-left-color:${border}">
<div class="dec-item-header">
<span class="dec-badge ${TYPE_CLASS[d.decision_type] ?? ''}">${d.decision_type}</span>
@@ -343,6 +362,7 @@ if (filtered.length === 0) {
</span>` : ""}
<span class="dec-age ${_ageWarn ? 'dec-age-warn' : ''}">${_ageText}</span>
<span class="dec-date">${fmtDate(d.created_at)}</span>
${_isOpen ? html`<button class="dec-resolve-btn" onclick=${onResolve}>Resolve</button>` : ""}
</div>
<div class="dec-title">${d.title}</div>
${snippet ? html`<div class="dec-snippet">${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}</div>` : ""}
@@ -453,6 +473,10 @@ if (escalated.length > 0) {
.dec-resolved-by { font-size: 0.78rem; color: #22c55e; margin-top: 0.3rem; }
.dec-escalation-note { font-size: 0.78rem; color: #b45309; margin-top: 0.3rem; background: #fef3c7; border-radius: 4px; padding: 0.25rem 0.5rem; }
/* ── Resolve button ───────────────────────────────────────────────────────── */
.dec-resolve-btn { margin-left: auto; padding: 0.15rem 0.6rem; border-radius: 6px; border: 1px solid #22c55e; background: #f0fdf4; color: #166534; font-size: 0.7rem; font-weight: 600; cursor: pointer; }
.dec-resolve-btn:hover { background: #dcfce7; }
/* ── Escalation warning ───────────────────────────────────────────────────── */
.escalation-box { background: #fff3cd; border: 2px solid orange; border-radius: 8px; padding: 1rem; margin-top: 1rem; }
</style>

View File

@@ -105,7 +105,7 @@ Any `pending` decision whose title or rationale contains financial or legal keyw
- Sort to the top of the list
- Carry an amber escalation note explaining the reason
- Trigger the warning box at the bottom of the page listing all escalated items
- **Must be reviewed and approved by Bernd before any related action is taken (constitution §4)**
- **Must be reviewed and approved by the human before any related action is taken (constitution §4)**
To clear an escalation, resolve the decision via `resolve_decision()` in the MCP server or the REST API.

View File

@@ -0,0 +1,84 @@
---
title: Interventions — Reference
---
# Interventions — Reference
The Interventions page lists every task that has been flagged `needs_human=true` — actions that only a human can take. It polls the API every 15 seconds and organises tasks into **Open** and **Completed / Cancelled** sections.
---
## What is a human intervention?
A human intervention is a task that an agent has determined cannot proceed without direct human action — for example, approving a financial decision, confirming a legal commitment, or resolving a sensitive ambiguity. Flagging a task does not change its work status; the task continues to be `todo`, `in_progress`, or `blocked` while awaiting attention.
---
## KPI sidebar card
| Metric | Meaning |
|---|---|
| **open** | Number of tasks currently flagged and not yet resolved |
| **critical / high** | Subset with `critical` or `high` priority — shown in red when non-zero |
| **by domain** | Top-3 domains by open intervention count |
---
## Open section
Tasks are sorted by priority (critical → high → medium → low), then by status (blocked → in_progress → todo). Each card shows:
| Element | Meaning |
|---|---|
| Priority badge | `critical` / `high` / `medium` / `low` |
| Status chip | Current task status |
| Domain | Source domain slug |
| Workstream | Parent workstream title |
| Action note | The `intervention_note` — what the human needs to do |
| Task detail | Expandable `<details>` with the task title and description (shown when different from the action note) |
---
## Marking an intervention as done
Click **Mark done** on any open card. An inline form appears requiring a **resolution comment** — a short note describing what was done. The comment is mandatory; clicking **Confirm** without entering text highlights the field in red and does nothing.
Once confirmed, the API call sets `status = done`, `needs_human = false`, and replaces the action note with your resolution comment. The card moves to the **Completed / Cancelled** section on the next poll.
Click **Cancel** to dismiss the form without making changes.
---
## Flagging and clearing interventions via MCP
```
flag_for_human(
task_id = "<uuid>",
note = "Approve the Q2 budget allocation before the 15th"
)
```
```
clear_human_flag(task_id = "<uuid>")
```
`clear_human_flag` sets `needs_human = false` and preserves the `intervention_note` as a historical record. It does **not** change the task's work status. Use the dashboard's **Mark done** button when you also want to advance the status to `done`.
---
## Filtering by workstream
`list_human_interventions(workstream_id="<uuid>")` via MCP returns only interventions for a specific workstream — useful for scoped reviews in agent sessions.
---
## REST endpoints
| Method | Path | Effect |
|---|---|---|
| `GET` | `/tasks/?needs_human=true` | List all flagged tasks |
| `PATCH` | `/tasks/{id}/` | Update status, needs_human, intervention_note |
---
*Interventions are never created from the dashboard — they are raised by agents during task execution and resolved by the human.*

View File

@@ -64,7 +64,7 @@ Every Claude Code session working in this repository should:
1. **Start** — call `get_state_summary()` for orientation
2. **End** — call `add_progress_event()` to log what was done, decided, or discovered
A session that produces no progress events is invisible to future sessions and to Bernd. The log is how continuity is maintained across context windows.
A session that produces no progress events is invisible to future sessions and to the human. The log is how continuity is maintained across context windows.
---
@@ -87,7 +87,7 @@ Via the REST API directly:
```bash
curl -X POST http://127.0.0.1:8000/progress/ \
-H "Content-Type: application/json" \
-d '{"summary": "…", "event_type": "note", "author": "Bernd"}'
-d '{"summary": "…", "event_type": "note", "author": "human"}'
```
---
@@ -96,7 +96,7 @@ curl -X POST http://127.0.0.1:8000/progress/ \
| Filter | Effect |
|---|---|
| **Author** | Restrict to events by a specific author (`custodian`, `Bernd`, etc.) |
| **Author** | Restrict to events by a specific author (`custodian`, `human`, etc.) |
| **Event type** | Restrict to one category of event |
| **Since** | Show only events after a chosen date |

View File

@@ -84,7 +84,7 @@ create_workstream(
title = "Build user authentication",
description = "JWT-based auth, refresh tokens, middleware",
status = "active",
owner = "Bernd",
owner = "human",
due_date = "2026-04-01"
)
```

View File

@@ -479,7 +479,7 @@ if (blocking.length === 0) {
<label>Your decision &amp; rationale</label>
<textarea class="r-text" rows="4" placeholder="State the chosen option and your reasoning…"></textarea>
<label>Decided by</label>
<input class="r-by" type="text" value="Bernd">
<input class="r-by" type="text" value="human">
<div class="dec-resolve-actions">
<button class="r-submit">Record &amp; close</button>
<span class="r-msg"></span>
@@ -512,7 +512,7 @@ if (blocking.length === 0) {
btn.addEventListener("click", async () => {
const rationale = card.querySelector(".r-text").value.trim();
const decidedBy = card.querySelector(".r-by").value.trim() || "Bernd";
const decidedBy = card.querySelector(".r-by").value.trim() || "human";
if (!rationale) { msg.textContent = "⚠ Please enter a rationale."; return; }
btn.disabled = true; btn.textContent = "Saving…";
try {

View File

@@ -8,13 +8,13 @@ const POLL = 15_000;
```
```js
// Live poll: human-flagged tasks + workstreams + topics
// Live poll: all tasks (filtered client-side) + workstreams + topics
const interventionState = (async function*() {
while (true) {
let tasks = [], wsMap = {}, ok = false;
try {
const [rt, rw, rto, rr] = await Promise.all([
fetch(`${API}/tasks/?needs_human=true`),
fetch(`${API}/tasks/?limit=500`),
fetch(`${API}/workstreams/`),
fetch(`${API}/topics/`),
fetch(`${API}/repos/`),
@@ -51,8 +51,10 @@ const _ts = interventionState.ts;
```js
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]);
const open = tasks.filter(t => OPEN_STATUSES.has(t.status));
const closed = tasks.filter(t => !OPEN_STATUSES.has(t.status));
// open = currently flagged for human action
// closed = previously flagged (intervention_note records the resolution comment)
const open = tasks.filter(t => t.needs_human === true);
const closed = tasks.filter(t => t.intervention_note && !OPEN_STATUSES.has(t.status) && !t.needs_human);
// Domain breakdown for top-3
const domainCounts = {};
@@ -107,15 +109,20 @@ withDocHelp(_liveEl, "/docs/live-data");
injectTocTop("intervention-kpi-box", _kpiBox);
injectTocTop("live-indicator", _liveEl);
const _h1 = document.querySelector("#observablehq-main h1");
if (_h1) { _h1.style.position = "relative"; withDocHelp(_h1, "/docs/interventions"); }
```
Tasks flagged `needs_human=true` — actions only Bernd can take.
Tasks flagged `needs_human=true` — actions only a human can take.
---
## Open
```js
import {openActionConfirm} from "./components/action-confirm.js";
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2};
@@ -127,26 +134,35 @@ function sortTasks(arr) {
});
}
async function markDone(taskId) {
try {
await fetch(`${API}/tasks/${taskId}/`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({status: "done", needs_human: false, intervention_note: null}),
});
} catch {}
}
function renderCard(t) {
const isOpen = OPEN_STATUSES.has(t.status);
const borderColor = isOpen ? "#f59e0b" : "#22c55e";
function onMarkDone() {
openActionConfirm({
title: "Mark Intervention as Done",
entityTitle: t.title,
label: "Resolution comment",
placeholder: "What was done to resolve this?",
confirmLabel: "Mark Done",
onConfirm: async (comment) => {
const res = await fetch(`${API}/tasks/${t.id}/`, {
method: "PATCH",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({status: "done", needs_human: false, intervention_note: comment}),
});
if (!res.ok) throw new Error(`API error ${res.status}`);
},
});
}
return html`<div class="intervention-card" style="border-left-color:${borderColor}">
<div class="int-card-header">
<span class="task-badge task-priority-${t.priority}">${t.priority}</span>
<span class="task-status-chip status-chip-${t.status}">${t.status.replace("_", " ")}</span>
<span class="task-context">${t.domain}</span>
<span class="task-context task-ws-name">${t.workstream_title}</span>
${isOpen ? html`<button class="done-btn" onclick=${() => markDone(t.id)}>Mark done</button>` : ""}
${isOpen ? html`<button class="done-btn" onclick=${onMarkDone}>Mark done</button>` : ""}
</div>
<div class="int-action">${t.intervention_note ?? "(no note)"}</div>
${t.title !== t.intervention_note ? html`<details class="int-desc"><summary>Task: ${t.title}</summary>${t.description ?? ""}</details>` : ""}