diff --git a/state-hub/dashboard/observablehq.config.js b/state-hub/dashboard/observablehq.config.js index abeac66..fe7e419 100644 --- a/state-hub/dashboard/observablehq.config.js +++ b/state-hub/dashboard/observablehq.config.js @@ -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" }, diff --git a/state-hub/dashboard/src/components/action-confirm.js b/state-hub/dashboard/src/components/action-confirm.js new file mode 100644 index 0000000..536600d --- /dev/null +++ b/state-hub/dashboard/src/components/action-confirm.js @@ -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; + } + }); +} diff --git a/state-hub/dashboard/src/decisions.md b/state-hub/dashboard/src/decisions.md index ad51a48..c5ae0c7 100644 --- a/state-hub/dashboard/src/decisions.md +++ b/state-hub/dashboard/src/decisions.md @@ -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`
${d.decision_type} @@ -343,6 +362,7 @@ if (filtered.length === 0) { ` : ""} ${_ageText} ${fmtDate(d.created_at)} + ${_isOpen ? html`` : ""}
${d.title}
${snippet ? html`
${snippet}${snippet.length < (d.description || d.rationale || "").length ? "…" : ""}
` : ""} @@ -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; } diff --git a/state-hub/dashboard/src/docs/decisions.md b/state-hub/dashboard/src/docs/decisions.md index a55ae35..9c064a5 100644 --- a/state-hub/dashboard/src/docs/decisions.md +++ b/state-hub/dashboard/src/docs/decisions.md @@ -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. diff --git a/state-hub/dashboard/src/docs/interventions.md b/state-hub/dashboard/src/docs/interventions.md new file mode 100644 index 0000000..bcf3e57 --- /dev/null +++ b/state-hub/dashboard/src/docs/interventions.md @@ -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 `
` 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 = "", + note = "Approve the Q2 budget allocation before the 15th" +) +``` + +``` +clear_human_flag(task_id = "") +``` + +`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="")` 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.* diff --git a/state-hub/dashboard/src/docs/progress-log.md b/state-hub/dashboard/src/docs/progress-log.md index 0f42aef..29f1129 100644 --- a/state-hub/dashboard/src/docs/progress-log.md +++ b/state-hub/dashboard/src/docs/progress-log.md @@ -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 | diff --git a/state-hub/dashboard/src/docs/workstreams.md b/state-hub/dashboard/src/docs/workstreams.md index ff0f1aa..13f58d0 100644 --- a/state-hub/dashboard/src/docs/workstreams.md +++ b/state-hub/dashboard/src/docs/workstreams.md @@ -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" ) ``` diff --git a/state-hub/dashboard/src/index.md b/state-hub/dashboard/src/index.md index 3628592..6890b26 100644 --- a/state-hub/dashboard/src/index.md +++ b/state-hub/dashboard/src/index.md @@ -479,7 +479,7 @@ if (blocking.length === 0) { - +
@@ -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 { diff --git a/state-hub/dashboard/src/interventions.md b/state-hub/dashboard/src/interventions.md index 59eda72..dad9da1 100644 --- a/state-hub/dashboard/src/interventions.md +++ b/state-hub/dashboard/src/interventions.md @@ -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`
${t.priority} ${t.status.replace("_", " ")} ${t.domain} ${t.workstream_title} - ${isOpen ? html`` : ""} + ${isOpen ? html`` : ""}
${t.intervention_note ?? "(no note)"}
${t.title !== t.intervention_note ? html`
Task: ${t.title}${t.description ?? ""}
` : ""}