This repository has been archived on 2026-09-17. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
nixos-config/hosts/blunix/localServices/board-agent/board.html

1710 lines
70 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Intent-Board</title>
<style>
:root {
color-scheme: light dark;
--bg-page: #f2f3f5;
--text-primary: #1a1a1a;
--header-bg: #20242c;
--header-text: #fff;
--header-subtext: #aab0bd;
--accent: #4a7dfc;
--accent-strong: #244fb5;
--smoke-text: #cdd8ff;
--smoke-danger-border: #d98a80;
--smoke-danger-text: #ffd9d2;
--column-bg: #fff;
--column-shadow: rgba(0, 0, 0, 0.08);
--column-heading: #555;
--agent-column-bg: #eef1f6;
--agent-column-heading: #666;
--config-label: #666;
--card-bg: #fafafa;
--card-border: #e2e2e2;
--card-actions-border: #aeb7c5;
--card-actions-bg: #fff;
--card-actions-text: #253044;
--card-actions-danger-border: #cf8a82;
--card-actions-danger-text: #8f2117;
--children-border: #d8d8d8;
--family-lightness: 95%;
--family-border-lightness: 70%;
--intent-border: #4a7dfc;
--research-border: #8b5cf6;
--idea-border: #999;
--draft-border: #e67e22;
--badge-border: #bbb;
--badge-bg: rgba(255, 255, 255, 0.78);
--badge-text: #333;
--failed: #c0392b;
--failed-strong: #8f2117;
--done: #2a8a4a;
--done-strong: #176b35;
--queued: #9a6f00;
--queued-strong: #765500;
--queued-status: #b8860b;
--status-text: #777;
--source-missing: #b05a00;
--muted-text: #999;
--dialog-bg: #fff;
--dialog-shadow: rgba(0, 0, 0, 0.3);
--dialog-backdrop: rgba(0, 0, 0, 0.55);
--review-border: #ddd;
--review-path-text: #666;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #16181d;
--text-primary: #e7e9ee;
--header-bg: #0e1015;
--header-text: #f2f3f5;
--header-subtext: #8b93a3;
--accent: #7d9fff;
--accent-strong: #aec2ff;
--smoke-text: #cdd8ff;
--smoke-danger-border: #d98a80;
--smoke-danger-text: #ffd9d2;
--column-bg: #21242b;
--column-shadow: rgba(0, 0, 0, 0.4);
--column-heading: #9aa1b0;
--agent-column-bg: #262a33;
--agent-column-heading: #9aa1b0;
--config-label: #9aa1b0;
--card-bg: #292d36;
--card-border: #3a3f4b;
--card-actions-border: #4b5261;
--card-actions-bg: #2f333d;
--card-actions-text: #cfd6e4;
--card-actions-danger-border: #a4574c;
--card-actions-danger-text: #ff9d8f;
--children-border: #454b58;
--family-lightness: 22%;
--family-border-lightness: 45%;
--intent-border: #7d9fff;
--research-border: #a988ff;
--idea-border: #7d8494;
--draft-border: #f0994d;
--badge-border: #4b5261;
--badge-bg: rgba(255, 255, 255, 0.06);
--badge-text: #cfd6e4;
--failed: #ff6b57;
--failed-strong: #ff9d8f;
--done: #4bc678;
--done-strong: #7fdba0;
--queued: #e0b84d;
--queued-strong: #f0cf7a;
--queued-status: #e0b84d;
--status-text: #9aa1b0;
--source-missing: #e0a35a;
--muted-text: #7d8494;
--dialog-bg: #21242b;
--dialog-shadow: rgba(0, 0, 0, 0.6);
--dialog-backdrop: rgba(0, 0, 0, 0.7);
--review-border: #3a3f4b;
--review-path-text: #9aa1b0;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, sans-serif;
background: var(--bg-page);
color: var(--text-primary);
}
header {
padding: 1rem 1.5rem;
background: var(--header-bg);
color: var(--header-text);
}
header h1 { margin: 0; font-size: 1.1rem; font-weight: 600; }
header p { margin: 0.2rem 0 0; font-size: 0.8rem; color: var(--header-subtext); }
.smoke-test-button {
margin: 0.5rem 0.5rem 0 0;
padding: 0.3rem 0.6rem;
border: 1px solid var(--accent);
border-radius: 6px;
background: none;
color: var(--smoke-text);
cursor: pointer;
font-size: 0.8rem;
}
.smoke-test-button.danger { border-color: var(--smoke-danger-border); color: var(--smoke-danger-text); }
main {
display: grid;
grid-template-columns: 260px repeat(7, minmax(230px, 1fr));
gap: 1rem;
padding: 1rem;
overflow-x: auto;
align-items: flex-start;
}
.column {
background: var(--column-bg);
border-radius: 8px;
min-width: 230px;
flex: 1 0 220px;
padding: 0.75rem;
box-shadow: 0 1px 2px var(--column-shadow);
}
.column h2 {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--column-heading);
margin: 0 0 0.5rem;
}
.agent-column {
position: sticky;
top: 1rem;
background: var(--agent-column-bg);
}
.agent-column h3 { margin: 0.7rem 0 0.25rem; font-size: 0.7rem; color: var(--agent-column-heading); }
.agent-column .column-config { margin: 0 0 0.5rem; }
.column-config {
display: grid;
gap: 0.3rem;
margin: 0 0 0.75rem;
font-size: 0.72rem;
}
.column-config label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
color: var(--config-label);
}
.column-config select,
.column-config input {
min-width: 0;
width: 9rem;
padding: 0.15rem;
font: inherit;
}
.card {
position: relative;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 6px;
padding: 0.5rem 1.4rem 0.5rem 0.6rem;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.card-actions { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-top: 0.55rem; }
.card-actions button {
border: 1px solid var(--card-actions-border); border-radius: 5px; padding: 0.28rem 0.45rem;
background: var(--card-actions-bg); color: var(--card-actions-text); cursor: pointer; font-size: 0.72rem;
}
.card-actions button.primary { border-color: var(--accent); color: var(--accent-strong); }
.card-actions button.danger { border-color: var(--card-actions-danger-border); color: var(--card-actions-danger-text); }
.child-navigation { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.35rem; }
.child-navigation button { font-size: 0.68rem; }
.card .children {
margin-top: 0.5rem;
padding-left: 0.6rem;
border-left: 2px solid var(--children-border);
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.card .children .card {
margin-bottom: 0;
font-size: 0.82rem;
}
.card.family {
background: hsl(var(--family-hue) 55% var(--family-lightness));
border-color: hsl(var(--family-hue) 42% var(--family-border-lightness));
color: var(--text-primary);
}
.card.intent { border-left: 3px solid var(--intent-border); }
.card.research { border-left: 3px solid var(--research-border); }
.card.idea { border-left: 3px solid var(--idea-border); }
.card.draft { border-left: 3px solid var(--draft-border); }
.draft-transcript {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin: 0.35rem 0;
}
.draft-transcript .status { margin: 0; }
.card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.35rem;
}
.card-badge {
display: inline-block;
max-width: 100%;
padding: 0.12rem 0.35rem;
border: 1px solid var(--badge-border);
border-radius: 999px;
background: var(--badge-bg);
color: var(--badge-text);
font-size: 0.68rem;
line-height: 1.35;
overflow-wrap: anywhere;
}
.card-badge.failed { border-color: var(--failed); color: var(--failed-strong); }
.card-badge.done { border-color: var(--done); color: var(--done-strong); }
.card-badge.queued { border-color: var(--queued); color: var(--queued-strong); }
.card .intent-link,
.card .review-link {
display: inline-block;
margin-top: 0.25rem;
margin-right: 0.6rem;
font-size: 0.75rem;
color: var(--accent);
cursor: pointer;
text-decoration: underline;
}
.card .intent-link:focus-visible,
.card .review-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.card .status {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--status-text);
}
.card .status.failed { color: var(--failed); }
.card .status.needs-human { color: var(--queued-status); font-weight: 600; }
.card .status.done { color: var(--done); }
.card .status.queued { color: var(--queued-status); }
.card .source-missing { color: var(--source-missing); }
.card .commit-hash {
display: block;
margin-top: 0.25rem;
font: 0.72rem/1.3 ui-monospace, monospace;
color: var(--muted-text);
}
.card button.delete {
position: absolute;
top: 0.3rem;
right: 0.3rem;
border: none;
background: none;
color: var(--muted-text);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
}
.add-idea {
display: block;
width: 100%;
margin-top: 0.4rem;
padding: 0.35rem;
border: 1px dashed var(--badge-border);
border-radius: 6px;
background: none;
cursor: pointer;
color: var(--config-label);
font-size: 0.85rem;
}
dialog {
width: min(52rem, calc(100vw - 2rem));
max-height: calc(100vh - 2rem);
border: 0;
border-radius: 8px;
padding: 0;
color: inherit;
background: var(--dialog-bg);
box-shadow: 0 12px 40px var(--dialog-shadow);
}
dialog::backdrop { background: var(--dialog-backdrop); }
.review-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1rem 1rem 0.75rem;
border-bottom: 1px solid var(--review-border);
}
.review-header h2 { margin: 0; font-size: 1.1rem; }
.review-path { margin: 0.25rem 0 0; color: var(--review-path-text); font-size: 0.8rem; }
.review-close { font: inherit; cursor: pointer; }
.review-content {
margin: 0;
padding: 1rem;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 0.88rem/1.45 ui-monospace, monospace;
}
header .toolbar { display:flex; align-items:center; gap:.7rem; flex-wrap:wrap; margin-top:.8rem; }
button, input, select { font:inherit; }
button { cursor:pointer; }
button:focus-visible, [role=button]:focus-visible, input:focus-visible, select:focus-visible { outline:3px solid var(--accent); outline-offset:3px; }
.toolbar input { flex:1; min-width:150px; padding:.55rem; border-radius:6px; border:1px solid var(--card-border); }
.toolbar button, .toolbar select { padding:.5rem .7rem; border-radius:6px; }
#connection { margin:.5rem 0 0; font-size:.8rem; }
#connection.stale { color:var(--failed); font-weight:600; }
#summary { display:flex; gap:.6rem; flex-wrap:wrap; margin:.7rem 0 0; font-size:.85rem; }
#summary button { background:transparent; color:inherit; border:1px solid var(--config-label); border-radius:6px; padding:.35rem .6rem; }
main { grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); overflow:visible; }
.column { min-width:0; }
.column h2 { display:flex; justify-content:space-between; }
.card { padding:.9rem; margin-bottom:.8rem; overflow-wrap:anywhere; }
.card-title { display:block; font-size:.96rem; font-weight:600; line-height:1.4; }
.card-summary { margin:.6rem 0; font-size:.82rem; line-height:1.45; }
.card-state { display:block; font-weight:600; color:var(--accent-strong); }
.card-state.attention { color:var(--failed-strong); }
.card-state.done { color:var(--done-strong); }
.card-reason { display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; margin:.3rem 0; }
.card-meta { color:var(--status-text); font-size:.75rem; margin:.3rem 0; }
.card-actions { display:flex; flex-wrap:wrap; gap:.35rem; margin-top:.7rem; }
.children { margin-top:.5rem; padding-left:.5rem; }
details > summary { cursor:pointer; padding:.4rem 0; font-size:.82rem; }
.empty { color:var(--status-text); font-size:.85rem; padding:.5rem 0; }
.agent-column { position:static; width:100%; box-shadow:none; }
#settings-dialog { width:min(650px,95vw); }
#settings-content { padding:1rem; overflow:auto; }
#card-dialog { width:min(980px,95vw); }
.detail-tabs { display:flex; gap:.4rem; padding:.8rem 1rem; flex-wrap:wrap; border-bottom:1px solid var(--review-border); }
.detail-tabs button { padding:.45rem .65rem; }
#card-content { padding:1rem; overflow:auto; line-height:1.6; overflow-wrap:anywhere; }
#card-content h3 { margin:1rem 0 .4rem; }
#card-content pre { white-space:pre-wrap; font:inherit; }
#card-content dl { margin:.4rem 0; padding:.6rem; border-left:2px solid var(--card-border); }
#card-content dt { font-size:.8rem; color:var(--status-text); }
#card-content dd { margin:0 0 .7rem; }
.card button { border:1px solid var(--card-actions-border); border-radius:5px; padding:.3rem .5rem; background:var(--card-actions-bg); color:var(--card-actions-text); cursor:pointer; }
.card-actions details { font-size:.8rem; }
.card-actions details button { margin:.3rem .3rem 0 0; }
input::placeholder { color:var(--status-text); opacity:1; }
main.list-view { display:block; max-width:1050px; margin:auto; }
@media(max-width:700px) {
main { display:block; padding:.6rem; }
header { padding:.8rem; }
.column { margin-bottom:.8rem; }
.toolbar input { width:100%; }
dialog { max-height:94vh; }
.children { padding-left:.25rem; }
}
</style>
</head>
<body>
<header>
<h1>Intent-Board</h1>
<p>Goals, evidence, and the next action · approved changes deploy automatically</p>
<div class="toolbar">
<input id="search" type="search" placeholder="Search goals, results, and blockers" aria-label="Search cards">
<select id="view" aria-label="Board view"><option value="all">Active goals</option><option value="attention">Needs my attention</option><option value="running">Running</option><option value="ready">Ready</option><option value="deploy">Deploying</option><option value="done">Done</option><option value="archived">Archive</option></select>
<button id="settings-open" type="button">Agent settings</button>
<button id="refresh" type="button">Refresh</button>
</div>
<div id="summary" aria-label="Goal counts"></div>
<p id="connection" role="status" aria-live="polite">Connecting…</p>
</header>
<main id="board"></main>
<dialog id="settings-dialog" aria-labelledby="settings-title">
<div class="review-header"><h2 id="settings-title">Agent settings</h2><form method="dialog"><button>Close</button></form></div>
<div id="settings-content"></div>
<details style="padding:1rem"><summary>Pipeline test tools</summary> <button id="smoke-test-direct" class="smoke-test-button" type="button">+ Smoke-Test (klein)</button>
<button id="smoke-test-split" class="smoke-test-button" type="button">+ Smoke-Test (groß)</button>
<button id="smoke-test-remove-direct" class="smoke-test-button" type="button">+ Deinstall-Test (klein)</button>
<button id="smoke-test-remove-split" class="smoke-test-button" type="button">+ Deinstall-Test (groß)</button>
<button id="smoke-test-cleanup" class="smoke-test-button danger" type="button">🧹 Smoke-Tests entfernen</button>
</details>
</dialog>
<dialog id="card-dialog" aria-labelledby="card-title">
<div class="review-header"><div><h2 id="card-title"></h2><p id="card-id" class="review-path"></p></div><form method="dialog"><button>Close</button></form></div>
<nav id="card-tabs" class="detail-tabs" aria-label="Card details"></nav>
<div id="card-content"></div>
</dialog>
<dialog id="review-dialog" aria-labelledby="review-title">
<div class="review-header">
<div>
<h2 id="review-title">Review-Bericht</h2>
<p id="review-path" class="review-path"></p>
</div>
<form method="dialog">
<button class="review-close" aria-label="Review-Bericht schließen">Schließen</button>
</form>
</div>
<pre id="review-content" class="review-content"></pre>
</dialog>
<dialog id="intent-dialog" aria-labelledby="intent-title">
<div class="review-header">
<div>
<h2 id="intent-title">Intent</h2>
<p id="intent-path" class="review-path"></p>
</div>
<form method="dialog">
<button class="review-close" aria-label="Intent schließen">Schließen</button>
</form>
</div>
<pre id="intent-content" class="review-content"></pre>
</dialog>
<dialog id="metrics-dialog" aria-labelledby="metrics-title">
<div class="review-header">
<div>
<h2 id="metrics-title">Laufmetriken</h2>
<p id="metrics-path" class="review-path"></p>
</div>
<form method="dialog">
<button class="review-close" aria-label="Laufmetriken schließen">Schließen</button>
</form>
</div>
<pre id="metrics-content" class="review-content"></pre>
</dialog>
<script>
const LIFECYCLE_GROUPS = [
{ id: "attention", label: "Needs attention" },
{ id: "running", label: "Running" },
{ id: "ready", label: "Ready" },
{ id: "backlog", label: "Backlog" },
{ id: "deploy", label: "Deploying" },
{ id: "done", label: "Done" },
{ id: "archived", label: "Archived" },
];
const JOB_COLUMNS = new Set(["operator_angefordert", "in_umsetzung", "in_umsetzung_leicht", "review", "recherche_laeuft", "klaerung_reparatur"]);
const RUNTIME_OPTIONS = [
["codex", "Codex"],
["claude", "Claude"],
["openrouter", "OpenRouter"],
];
// Kept in sync by hand with orchestration.py's MODEL_POOL, which is the
// server-side allowlist an Operator-authored escalation step's model is
// validated against (docs/intent/board-operator-escalation-ladder.md).
// This list additionally carries a "Default" ("") entry per runtime that
// MODEL_POOL deliberately does not -- an escalation step always names a
// concrete model, never "use the column default".
const MODEL_OPTIONS = {
codex: [["", "Default"], ["gpt-6-astra", "Astra"], ["gpt-5.6-sol", "Sol"], ["gpt-5.6-terra", "Terra"], ["gpt-5.6-luna", "Luna"]],
claude: [["", "Default"], ["sonnet", "Sonnet"], ["opus", "Opus"], ["haiku", "Haiku"]],
openrouter: [
["", "Default"],
["moonshotai/kimi-k2-thinking", "moonshotai/kimi-k2-thinking"],
["deepseek/deepseek-v3.2", "deepseek/deepseek-v3.2"],
["cohere/north-mini-code:free", "cohere/north-mini-code:free"],
["nvidia/nemotron-3-super-120b-a12b:free", "nvidia/nemotron-3-super-120b-a12b:free"],
],
};
const REASONING_OPTIONS = [["", "Default"], ["low", "low"], ["medium", "medium"], ["high", "high"]];
let columnConfig = {};
function addOptions(select, options) {
for (const [value, label] of options) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
select.appendChild(option);
}
}
function modelOptionsForRuntime(runtime) {
return MODEL_OPTIONS[runtime] || MODEL_OPTIONS.codex;
}
function syncModelOptions(modelSelect, runtime, value) {
const options = modelOptionsForRuntime(runtime);
modelSelect.replaceChildren();
addOptions(modelSelect, options);
const allowed = new Set(options.map(([optionValue]) => optionValue));
modelSelect.value = allowed.has(value) ? value : "";
modelSelect.dataset.savedValue = modelSelect.value;
}
function addColumnConfig(columnEl, phase) {
if (!JOB_COLUMNS.has(phase)) return;
const config = columnConfig[phase] || {};
const runtime = RUNTIME_OPTIONS.some(([value]) => value === config.runtime)
? config.runtime
: "codex";
const controls = document.createElement("div");
controls.className = "column-config";
const fields = [
["Runtime", "runtime", RUNTIME_OPTIONS, "select"],
["Modell", "model", modelOptionsForRuntime(runtime), "select"],
["Reasoning", "reasoning_effort", REASONING_OPTIONS, "select"],
];
if (phase === "in_umsetzung" || phase === "in_umsetzung_leicht" || phase === "klaerung_reparatur") {
fields.push(["Iterationen", "iteration_limit", null, "number"]);
}
for (const [labelText, field, options, type] of fields) {
const label = document.createElement("label");
label.textContent = `${labelText}: `;
const input = document.createElement(type === "select" ? "select" : "input");
input.dataset.field = field;
input.dataset.savedValue = String(field === "runtime" ? runtime : config[field] ?? "");
if (type === "select") addOptions(input, options);
else {
input.type = "number";
input.min = "5";
input.max = "40";
}
input.value = String(field === "runtime" ? runtime : config[field] ?? "");
if (field === "model") {
syncModelOptions(input, runtime, input.value);
}
input.addEventListener("change", () => {
if (field === "runtime") {
const modelSelect = controls.querySelector('select[data-field="model"]');
syncModelOptions(modelSelect, input.value, "");
columnConfig[phase] = { ...(columnConfig[phase] || {}), runtime: input.value, model: "" };
saveColumnConfig(phase, input);
saveColumnConfig(phase, modelSelect);
return;
}
saveColumnConfig(phase, input);
});
label.appendChild(input);
controls.appendChild(label);
}
columnEl.appendChild(controls);
}
// Applies one runtime to every job-triggering phase at once by driving each
// phase's own runtime <select> and its existing "change" listener (added in
// addColumnConfig) -- that listener already resets the model to "Default"
// and persists via saveColumnConfig, so this never duplicates that logic.
function addGlobalRuntimeControl(columnEl) {
const wrapper = document.createElement("div");
wrapper.className = "column-config";
const label = document.createElement("label");
label.textContent = "Alle Agents: Runtime: ";
const select = document.createElement("select");
addOptions(select, [["", "-- wählen --"], ...RUNTIME_OPTIONS]);
select.addEventListener("change", () => {
const runtime = select.value;
if (!runtime) return;
for (const runtimeSelect of columnEl.querySelectorAll('select[data-field="runtime"]')) {
if (runtimeSelect.value !== runtime) {
runtimeSelect.value = runtime;
runtimeSelect.dispatchEvent(new Event("change"));
}
}
select.value = "";
});
label.appendChild(select);
wrapper.appendChild(label);
columnEl.appendChild(wrapper);
}
// Kept in sync by hand with AGENT_ROLE_FILES in trigger.py -- which role(s)
// a phase's job actually runs. klaerung_reparatur covers two distinct roles
// depending on the requested task_type (Diagnose uses Inspector, Repair
// uses Fixer), so it lists both.
const PHASE_AGENT_ROLES = {
operator_angefordert: [["operator", "Operator"]],
in_umsetzung: [["builder", "Builder"]],
in_umsetzung_leicht: [["builder", "Builder"]],
review: [["reviewer", "Reviewer"]],
recherche_laeuft: [["researcher", "Researcher"]],
klaerung_reparatur: [["inspector", "Inspector"], ["fixer", "Fixer"]],
};
// Same dialog + fetch-on-open mechanism as showIntent()/showReview() above,
// just pointed at a fixed agents/<role>.md file instead of a card's intent.
async function showAgentPrompt(role, label) {
const dialog = document.getElementById("intent-dialog");
const title = document.getElementById("intent-title");
const path = document.getElementById("intent-path");
const content = document.getElementById("intent-content");
title.textContent = `Agent-Prompt: ${label}`;
path.textContent = "Wird geladen…";
content.textContent = "";
dialog.showModal();
try {
const res = await fetch(`/api/board/agents/${encodeURIComponent(role)}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const prompt = await res.json();
path.textContent = prompt.path;
content.textContent = prompt.content;
} catch (error) {
path.textContent = "Prompt konnte nicht geladen werden.";
content.textContent = error.message;
}
}
function appendAgentPromptLinks(columnEl, phase) {
const roles = PHASE_AGENT_ROLES[phase];
if (!roles) return;
const links = document.createElement("div");
links.className = "child-navigation";
for (const [role, label] of roles) {
const link = document.createElement("span");
link.className = "intent-link";
link.textContent = label;
link.tabIndex = 0;
link.setAttribute("role", "button");
link.setAttribute("aria-haspopup", "dialog");
link.setAttribute("aria-label", `${label}-Rollenprompt öffnen`);
link.addEventListener("click", () => showAgentPrompt(role, label));
link.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
showAgentPrompt(role, label);
}
});
links.appendChild(link);
}
columnEl.appendChild(links);
}
// A dedicated, always-visible column instead of the earlier collapsible
// <details> panel tucked inside "Ready" -- that panel re-rendered closed on
// every 5s refresh (render() clears and rebuilds the whole board), so an
// operator had to keep re-opening it. Fixed on the left, not a lifecycle
// group: it never holds cards and is not a PATCH target.
function buildAgentColumn() {
const columnEl = document.createElement("section");
columnEl.className = "column agent-column";
const heading = document.createElement("h2");
heading.textContent = "Agents";
columnEl.appendChild(heading);
addGlobalRuntimeControl(columnEl);
for (const phase of JOB_COLUMNS) {
const phaseHeading = document.createElement("h3");
phaseHeading.textContent = phase;
columnEl.appendChild(phaseHeading);
appendAgentPromptLinks(columnEl, phase);
addColumnConfig(columnEl, phase);
}
return columnEl;
}
async function saveColumnConfig(column, input) {
const field = input.dataset.field;
const previous = input.dataset.savedValue;
const value = input.type === "number" ? Number(input.value) : input.value;
input.disabled = true;
try {
const res = await fetch(`/api/board/column-config/${encodeURIComponent(column)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [field]: value }),
});
if (!res.ok) throw new Error("column config update failed");
const saved = await res.json();
columnConfig[column] = { ...(columnConfig[column] || {}), ...saved };
input.dataset.savedValue = String(saved[field]);
} catch (_error) {
input.value = previous;
window.alert("Spaltenkonfiguration konnte nicht gespeichert werden.");
} finally {
input.disabled = false;
}
}
async function fetchBoard() {
const res = await fetch("/api/board");
if (!res.ok) throw new Error("board fetch failed");
const data = await res.json();
columnConfig = data.column_config || {};
return data.cards || [];
}
async function patchPhase(id, phase, taskType = null) {
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phase, ...(taskType ? { task_type: taskType } : {}) }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
}
async function applyRouting(id) {
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/apply-routing`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
}
async function retryReview(id) {
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/retry-review`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
}
async function submitFixerAnswer(id, answer) {
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/fixer-answer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
}
async function createIdea(title) {
const res = await fetch("/api/board/ideas", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error("create failed");
}
async function createDraft(topic) {
const res = await fetch("/api/board/drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ topic }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || "create failed");
}
}
async function answerDraft(id, answer) {
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/draft-answer`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer }),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
}
async function retryDraft(id) {
const res = await fetch(`/api/board/cards/${encodeURIComponent(id)}/draft-retry`, { method: "POST" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
}
async function createSmokeTest(kind) {
const res = await fetch(`/api/board/smoke-tests/${kind}`, { method: "POST" });
if (!res.ok) throw new Error("smoke test create failed");
}
async function deleteSmokeTestCards() {
const res = await fetch("/api/board/smoke-tests", { method: "DELETE" });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `HTTP ${res.status}`);
}
return res.json();
}
async function deleteCard(id) {
const response=await fetch(`/api/board/cards/${encodeURIComponent(id)}`, { method: "DELETE" });
if(!response.ok)throw new Error((await response.json()).error || "HTTP "+response.status);
}
async function showReview(card) {
const dialog = document.getElementById("review-dialog");
const title = document.getElementById("review-title");
const path = document.getElementById("review-path");
const content = document.getElementById("review-content");
title.textContent = `Review: ${card.title}`;
path.textContent = "Bericht wird geladen…";
content.textContent = "";
dialog.showModal();
try {
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/review`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const report = await res.json();
path.textContent = report.path;
content.textContent = report.content;
} catch (error) {
path.textContent = "Bericht konnte nicht geladen werden.";
content.textContent = error.message;
}
}
async function showResearchReport(card) {
const dialog = document.getElementById("review-dialog");
const title = document.getElementById("review-title");
const path = document.getElementById("review-path");
const content = document.getElementById("review-content");
title.textContent = `Recherche-Bericht: ${card.title}`;
path.textContent = "Bericht wird geladen…";
content.textContent = "";
dialog.showModal();
try {
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/research`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const report = await res.json();
path.textContent = report.path;
content.textContent = report.content;
} catch (error) {
path.textContent = "Bericht konnte nicht geladen werden.";
content.textContent = error.message;
}
}
function showBuilderReport(card) {
// Unlike showReview(), the text is already part of the /api/board
// payload (see merged_board()'s builder_report field) -- no commit
// means no docs/review/*.md file to fetch, so this reuses the review
// dialog markup but fills it in directly instead of via a second request.
const dialog = document.getElementById("review-dialog");
const title = document.getElementById("review-title");
const path = document.getElementById("review-path");
const content = document.getElementById("review-content");
title.textContent = `Builder-Bericht: ${card.title}`;
path.textContent = "Kein Commit -- Antwort des Builders";
content.textContent = card.builder_report || "";
dialog.showModal();
}
async function showIntent(card) {
const dialog = document.getElementById("intent-dialog");
const title = document.getElementById("intent-title");
const path = document.getElementById("intent-path");
const content = document.getElementById("intent-content");
title.textContent = `Intent: ${card.title}`;
path.textContent = "Intent wird geladen…";
content.textContent = "";
dialog.showModal();
try {
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/intent`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const intent = await res.json();
path.textContent = intent.path;
content.textContent = intent.content;
} catch (error) {
path.textContent = "Intent konnte nicht geladen werden.";
content.textContent = error.message;
}
}
function statusLabel(card, children = []) {
if (card.display) return card.display.label;
if (!["intent", "research"].includes(card.source) || !card.last_run_status) return "";
if (!card.parent_id && children.length) {
if (children.some((child) => ["queued", "running", "queued_builder"].includes(child.last_run_status)
|| ["in_umsetzung", "in_umsetzung_leicht", "review"].includes(child.phase))) return "Kinder laufen";
if (children.some((child) => child.last_run_status === "failed" || child.phase === "planung_mit_feedback")) return "Durch Kind blockiert";
if (children.every((child) => ["bereit_fuer_deployment", "deployed"].includes(child.phase))) return "Kinder abgeschlossen";
}
const map = {
queued: "läuft…",
done: "abgeschlossen",
failed: "fehlgeschlagen",
restore_failed: "Quelldatei konnte nicht wiederhergestellt werden",
};
return map[card.last_run_status] || card.last_run_status;
}
function showFailure(card) {
const dialog = document.getElementById("intent-dialog");
document.getElementById("intent-title").textContent = `Fehlerdetails: ${card.title}`;
document.getElementById("intent-path").textContent = card.failure_stage || "Worker";
document.getElementById("intent-content").textContent = card.failure_detail || "Unbekannter Fehler";
dialog.showModal();
}
function uniqueStrings(value) {
if (!Array.isArray(value)) return [];
return [...new Set(value.filter((item) => typeof item === "string" && item.trim()))];
}
function cardFamilyId(card) {
if (typeof card.parent_id === "string" && card.parent_id) return card.parent_id;
if (uniqueStrings(card.depends_on).length || uniqueStrings(card.child_slugs).length) return card.id;
return "";
}
function familyHue(familyId) {
let hash = 2166136261;
for (const character of familyId) {
hash ^= character.charCodeAt(0);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash >>> 0) % 360;
}
function addBadge(container, label, value, className = "") {
if (value === undefined || value === null || value === "") return;
const badge = document.createElement("span");
badge.className = `card-badge ${className}`.trim();
badge.textContent = `${label}: ${value}`;
container.appendChild(badge);
}
function profileLabel(runtime, model, reasoning) {
const parts = [runtime, model, reasoning ? `Reasoning ${reasoning}` : ""]
.filter((value) => typeof value === "string" && value.trim());
return parts.join(" / ");
}
// Always visible regardless of family/children -- appendOrchestrationBadges
// bails out entirely for a card with no parent/children, which is exactly
// the "operator_klaerung_noetig" case (Operator decided needs_human, or
// crashed, before ever producing children), so that badge row is not a
// reliable place to surface why this card stopped.
function appendOperatorReason(el, card) {
if (!card.operator_reason) return;
const reason = document.createElement("p");
reason.className = card.operator_decision === "needs_human" ? "status needs-human" : "status";
reason.textContent = `Operator (${card.operator_decision}): ${card.operator_reason}`;
el.appendChild(reason);
if (card.operator_decision === "needs_human" && card.human_action) {
const action = document.createElement("p");
action.className = "status needs-human";
action.textContent = `Nächster Schritt: ${card.human_action}`;
el.appendChild(action);
}
}
// docs/intent/board-operator-escalation-ladder.md: auto_retry_attempts now
// counts steps of a per-card escalation ladder (0-2 Operator-authored
// steps, or a single synthetic fallback step for a card that never went
// through the Operator -- see orchestration.escalation_ladder_for()) rather
// than a flat 0/1 flag, so a bare "queued_builder" status no longer says
// whether this is attempt 1 or a later, differently-configured one.
function appendEscalationBadge(el, card) {
if (!card.auto_retry_attempts) return;
const ladderLength = (card.escalation_attempts && card.escalation_attempts.length) || 1;
const totalAttempts = 1 + ladderLength;
const current = Math.min(1 + card.auto_retry_attempts, totalAttempts);
const badge = document.createElement("p");
const exhausted = card.last_run_status === "limit_reached" && card.auto_retry_attempts >= ladderLength;
const running = ["queued", "running", "queued_builder"].includes(card.last_run_status);
badge.className = `status ${exhausted ? "failed" : running ? "queued" : "done"}`;
const profile = card.model ? ` (${card.model}, ${card.iteration_limit ?? "?"} Iterationen)` : "";
const outcome = exhausted ? "terminal erschöpft" : running ? "Eskalation läuft" : "Eskalation aufgefangen";
badge.textContent = `${outcome}: Versuch ${current} von ${totalAttempts}${profile}`;
el.appendChild(badge);
const configured = uniqueStrings((card.escalation_attempts || []).map((step) =>
step && `${step.model || "Default"}/${step.reasoning_effort || "Default"}/${step.iteration_limit || "?"}`
));
if (configured.length) {
const history = document.createElement("p");
history.className = "status";
history.textContent = `Eskalationsplan: ${configured.join(" → ")}`;
el.appendChild(history);
}
}
function lifecycleGroup(card) {
if (card.display) return card.display.group;
if (card.phase === "archiviert") return "archived";
if (["bereit_fuer_deployment"].includes(card.phase)) return "deploy";
if (["deployed", "recherche_fertig", "aufgeteilt"].includes(card.phase)) return "done";
if (card.source === "idea" || card.phase === "idea") return "backlog";
const attentionPhases = new Set([
"durch_kinder_blockiert", "operator_klaerung_noetig", "planung_mit_feedback",
"recherche_fehlgeschlagen", "fixer_rueckfrage", "fixer_fehlgeschlagen",
"inspector_rueckfrage", "inspector_fehlgeschlagen",
"entwurf_frage", "entwurf_fehlgeschlagen",
]);
if (attentionPhases.has(card.phase) || ["failed", "no_changes", "check_failed", "deploy_failed", "limit_reached"].includes(card.last_run_status)) {
return "attention";
}
if (["geplant", "recherche_angefordert"].includes(card.phase)) return "ready";
return "running";
}
function appendCardActions(el, card) {
const actions = Array.isArray(card.available_actions) ? card.available_actions : [];
if (!actions.length) return;
const container = document.createElement("div");
container.className = "card-actions";
const more = document.createElement("details");
const moreLabel = document.createElement("summary");moreLabel.textContent = "More actions";more.appendChild(moreLabel);
for (const [index, action] of actions.entries()) {
const button = document.createElement("button");
button.type = "button";
button.textContent = action.label;
if (["operator", "builder", "research", "diagnose"].includes(action.id)) button.classList.add("primary");
if (action.id === "repair") button.classList.add("danger");
button.addEventListener("click", async (event) => {
event.stopPropagation();
button.disabled = true;
try {
if (action.endpoint) {
const response = await fetch("/api/board/cards/"+encodeURIComponent(card.id)+"/"+action.endpoint, {method:"POST"});
if (!response.ok) throw new Error((await response.json()).error || "HTTP "+response.status);
}
else await patchPhase(card.id, action.phase, action.task_type || null);
await render();
} catch (error) {
window.alert(`${action.label} fehlgeschlagen: ${error.message}`);
button.disabled = false;
}
});
(index === 0 ? container : more).appendChild(button);
}
if(actions.length > 1)container.appendChild(more);
el.appendChild(container);
}
function appendChildNavigation(el, card) {
const childIds = uniqueStrings(card.blocking_children).length
? uniqueStrings(card.blocking_children)
: uniqueStrings([...(card.depends_on || []), ...(card.child_slugs || [])]);
if (!childIds.length) return;
const navigation = document.createElement("div");
navigation.className = "child-navigation";
for (const childId of childIds) {
const button = document.createElement("button");
button.type = "button";
button.textContent = `Zum Kind: ${childId}`;
button.addEventListener("click", () => {
const child = document.querySelector(`.card[data-id="${CSS.escape(childId)}"]`);
if (child) { child.scrollIntoView({ behavior: "smooth", block: "center" }); child.focus(); }
});
navigation.appendChild(button);
}
el.appendChild(navigation);
}
function reviewedSuccessfully(card) {
return card.review_decision === "bereit_fuer_deployment"
|| ["bereit_fuer_deployment", "deployed"].includes(card.phase);
}
function orchestrationChildren(card, cards) {
const declared = new Set([
...uniqueStrings(card.depends_on),
...uniqueStrings(card.child_slugs),
]);
return cards.filter((candidate) => candidate.parent_id === card.id || declared.has(candidate.id));
}
function progressLabel(card, children) {
const progress = card.child_progress;
if (typeof progress === "string" && progress.trim()) return progress;
if (progress && typeof progress === "object" && !Array.isArray(progress)) {
const successful = Number(progress.successful);
const total = Number(progress.total);
if (Number.isFinite(successful) && Number.isFinite(total)) {
return `${successful}/${total} Kinder erfolgreich`;
}
}
if (!children.length) return "";
return `${children.filter(reviewedSuccessfully).length}/${children.length} Kinder erfolgreich`;
}
function appendOrchestrationBadges(el, card, cards) {
const familyId = cardFamilyId(card);
if (!familyId) return;
el.classList.add("family");
el.style.setProperty("--family-hue", String(familyHue(familyId)));
const badges = document.createElement("div");
badges.className = "card-badges";
if (card.parent_id) addBadge(badges, "Parent", card.parent_id);
else addBadge(badges, "Familie", familyId);
const children = orchestrationChildren(card, cards);
if (!card.parent_id) {
addBadge(badges, "Fortschritt", progressLabel(card, children));
const activeWaves = [...new Set(children
.filter((child) => child.last_run_status === "queued"
|| ["in_umsetzung", "in_umsetzung_leicht", "review"].includes(child.phase))
.map((child) => child.wave)
.filter((wave) => wave !== undefined && wave !== null && wave !== ""))]
.sort((left, right) => Number(left) - Number(right));
if (activeWaves.length) addBadge(badges, "Aktive Wellen", activeWaves.map((wave) => `W${wave}`).join(", "));
const derivedBlocking = children
.filter((child) => child.last_run_status === "failed" || child.phase === "planung_mit_feedback")
.map((child) => child.id);
const blocking = uniqueStrings(card.blocking_children).length
? uniqueStrings(card.blocking_children)
: derivedBlocking;
if (blocking.length) addBadge(badges, "Blockierende Kinder", blocking.join(", "), "failed");
}
if (card.wave !== undefined && card.wave !== null && card.wave !== "") {
addBadge(badges, "Welle", `W${card.wave}`);
}
const blockedBy = uniqueStrings(card.blocked_by);
if (card.parent_id) addBadge(badges, "Blockiert durch", blockedBy.length ? blockedBy.join(", ") : "niemanden");
addBadge(badges, "Empfohlene Lane", card.recommended_lane);
const recommendedProfile = card.recommended_profile && typeof card.recommended_profile === "object"
? card.recommended_profile
: {};
addBadge(
badges,
"Empfohlenes Profil",
profileLabel(
card.recommended_runtime || recommendedProfile.runtime,
card.recommended_model || recommendedProfile.model,
card.recommended_reasoning_effort || recommendedProfile.reasoning_effort,
),
);
addBadge(badges, "Aktuelle Lane", card.lane);
addBadge(badges, "Aktuelles Profil", profileLabel(card.runtime, card.model, card.reasoning_effort));
const label = statusLabel(card, children) || card.phase;
addBadge(badges, "Status", label, card.last_run_status);
el.appendChild(badges);
if (!card.parent_id) {
const reviewed = children.filter((child) => child.review_report);
if (reviewed.length) {
const links = document.createElement("div");
links.className = "child-navigation";
const heading = document.createElement("span");
heading.textContent = "Familien-Reviews:";
links.appendChild(heading);
reviewed.forEach((child) => {
const button = document.createElement("button");
button.type = "button";
button.textContent = child.id;
button.addEventListener("click", () => showReview(child));
links.appendChild(button);
});
el.appendChild(links);
}
}
}
// docs/intent/board-intent-run-metrics-baseline.md: a compact rollup of
// this card's run_history, present only for the small, explicitly
// enrolled BOARD_METRICS_SLUGS pilot set -- absent (undefined) for every
// other card, in which case this renders nothing at all.
function appendMetricsBadge(el, card) {
const summary = card.metrics_summary;
if (!summary || typeof summary !== "object") return;
const badges = document.createElement("div");
badges.className = "card-badges";
addBadge(badges, "Läufe", `${summary.total} gesamt (${summary.board} Board / ${summary.baseline} Baseline)`);
addBadge(badges, "Erfolgreich", `${summary.successful}/${summary.total}`);
el.appendChild(badges);
}
function metricNumber(value) {
return Number.isFinite(Number(value)) ? Number(value).toLocaleString("de-DE") : "–";
}
function metricValue(source, key) {
if (!source || typeof source !== "object") return null;
if (source[key] !== undefined) return source[key];
return source.usage && source.usage[key] !== undefined ? source.usage[key] : null;
}
function formatPipelineMetrics(pipeline) {
if (!pipeline || typeof pipeline !== "object") return "Für diesen Lauf liegen noch keine Messwerte vor.";
const totals = pipeline.totals || {};
const lines = [
`Pipeline: ${pipeline.pipeline_run_id || "–"}`,
`Jobs: ${metricNumber(totals.job_count ?? (pipeline.jobs || []).length)}`,
`Input-Tokens: ${metricNumber(metricValue(totals, "input_tokens"))}`,
`Davon Cache: ${metricNumber(metricValue(totals, "cached_input_tokens"))}`,
`Output-Tokens: ${metricNumber(metricValue(totals, "output_tokens"))}`,
`Reasoning-Tokens: ${metricNumber(metricValue(totals, "reasoning_output_tokens"))}`,
`Gesamt-Tokens: ${metricNumber(metricValue(totals, "total_tokens"))}`,
`Reported cost: ${metricNumber(totals.cost_usd)} USD (${totals.cost_jobs ?? "?"}/${totals.job_count ?? "?"} jobs measured)`,
`Summierte Job-Laufzeit: ${metricNumber(metricValue(totals, "duration_seconds"))} s`,
"",
"Phasen:",
];
for (const job of pipeline.jobs || []) {
const actual = job.actual_profile || {};
const configured = job.configured_profile || {};
const usage = job.usage || {};
const timing = job.timing || {};
const profile = [
actual.runtime || configured.runtime,
actual.model || configured.model,
actual.reasoning_effort || configured.reasoning_effort,
].filter(Boolean).join(" / ");
lines.push(
`- ${job.role || "Job"}: ${metricNumber(usage.total_tokens)} Tokens` +
` · ${metricNumber(timing.duration_seconds)} s` +
` · ${profile || "Profil unbekannt"}` +
` · ${(job.outcome && job.outcome.status) || "läuft"}`
);
}
return lines.join("\n");
}
async function showTelemetry(card) {
const dialog = document.getElementById("metrics-dialog");
const path = document.getElementById("metrics-path");
const content = document.getElementById("metrics-content");
path.textContent = card.pipeline_run_id || card.id;
content.textContent = "Lade Messwerte …";
dialog.showModal();
try {
const res = await fetch(`/api/board/cards/${encodeURIComponent(card.id)}/telemetry`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const payload = await res.json();
content.textContent = formatPipelineMetrics(payload.current_pipeline);
} catch (error) {
content.textContent = `Laufmetriken konnten nicht geladen werden: ${error.message}`;
}
}
// The board is refreshed periodically. Keep an unsent Fixer answer outside
// the recreated DOM so a refresh cannot erase text the operator is typing.
const fixerAnswerDrafts = new Map(Object.entries(JSON.parse(sessionStorage.getItem("boardClarificationDrafts") || "{}")));
const fixerAnswerFocus = {
cardId: null,
selectionStart: null,
selectionEnd: null,
scrollTop: 0,
};
function rememberFixerAnswerFocus() {
const active = document.activeElement;
if (!active || active.tagName !== "TEXTAREA" || !active.dataset.fixerCardId) return;
fixerAnswerFocus.cardId = active.dataset.fixerCardId;
fixerAnswerFocus.selectionStart = active.selectionStart;
fixerAnswerFocus.selectionEnd = active.selectionEnd;
fixerAnswerFocus.scrollTop = active.scrollTop;
}
function restoreFixerAnswerFocus() {
if (!fixerAnswerFocus.cardId) return;
const answer = Array.from(document.querySelectorAll("textarea[data-fixer-card-id]"))
.find((candidate) => candidate.dataset.fixerCardId === fixerAnswerFocus.cardId);
if (!answer) return;
answer.focus({ preventScroll: true });
try {
answer.setSelectionRange(fixerAnswerFocus.selectionStart, fixerAnswerFocus.selectionEnd);
} catch (_error) {
// A changed draft can make an old selection invalid; retaining focus is
// still preferable to interrupting the operator's input.
}
answer.scrollTop = fixerAnswerFocus.scrollTop;
}
// docs/intent/board-interactive-planner-intake.md: renders the running
// transcript of an interactive Planner intake dialogue plus whatever the
// current phase needs next -- a "thinking" status, the latest question
// (with quick-reply buttons for its suggested options, plus a free-text
// answer), or a failure with a retry button. Refresh no longer steals
// focus out of the free-text answer mid-keystroke -- see
// boardEditInProgress() below, a general fix, not specific to this field.
function appendDraftDialogue(el, card) {
const conversation = Array.isArray(card.draft_conversation) ? card.draft_conversation : [];
const transcript = document.createElement("div");
transcript.className = "draft-transcript";
for (const turn of conversation) {
const line = document.createElement("p");
line.className = "status";
line.textContent = `${turn.role === "user" ? "Du" : "Planner"}: ${turn.text || ""}`;
transcript.appendChild(line);
}
el.appendChild(transcript);
if (card.phase === "entwurf_laeuft") {
const thinking = document.createElement("p");
thinking.className = "status queued";
thinking.textContent = "Planner denkt nach…";
el.appendChild(thinking);
return;
}
if (card.phase === "entwurf_frage") {
const lastTurn = conversation[conversation.length - 1] || {};
const options = Array.isArray(lastTurn.options) ? lastTurn.options : [];
if (options.length) {
const quickReplies = document.createElement("div");
quickReplies.className = "card-actions";
for (const option of options) {
const button = document.createElement("button");
button.type = "button";
button.textContent = option;
button.addEventListener("click", async (event) => {
event.stopPropagation();
button.disabled = true;
try {
await answerDraft(card.id, option);
await render();
} catch (error) {
window.alert(`Antwort fehlgeschlagen: ${error.message}`);
button.disabled = false;
}
});
quickReplies.appendChild(button);
}
el.appendChild(quickReplies);
}
const answer = document.createElement("textarea");
answer.rows = 2;
answer.maxLength = 4000;
answer.placeholder = "Eigene Antwort";
answer.addEventListener("mousedown", (event) => event.stopPropagation());
el.appendChild(answer);
const send = document.createElement("button");
send.type = "button";
send.textContent = "Antwort senden";
send.addEventListener("click", async (event) => {
event.stopPropagation();
send.disabled = true;
try {
await answerDraft(card.id, answer.value);
await render();
} catch (error) {
window.alert(`Antwort fehlgeschlagen: ${error.message}`);
send.disabled = false;
}
});
el.appendChild(send);
return;
}
if (card.phase === "entwurf_fehlgeschlagen") {
const failure = document.createElement("p");
failure.className = "status failed";
failure.textContent = card.failure_detail || "Unbekannter Fehler";
el.appendChild(failure);
const retry = document.createElement("button");
retry.type = "button";
retry.textContent = "Erneut versuchen";
retry.addEventListener("click", async (event) => {
event.stopPropagation();
retry.disabled = true;
try {
await retryDraft(card.id);
await render();
} catch (error) {
window.alert(`Erneuter Versuch fehlgeschlagen: ${error.message}`);
retry.disabled = false;
}
});
el.appendChild(retry);
}
}
function renderCard(card, cards, ancestry = new Set()) {
const el = document.createElement("article");
el.className = "card " + card.source;
el.dataset.id = card.id;
el.tabIndex = -1;
const title = document.createElement("span");
title.className = "card-title";
title.textContent = card.title.replace(/^Intent:\s*/i, "");
el.appendChild(title);
const summary = document.createElement("div");
summary.className = "card-summary";
const state = document.createElement("span");
state.className = "card-state " + lifecycleGroup(card);
state.textContent = card.display?.label || statusLabel(card) || card.phase;
summary.appendChild(state);
const reason = document.createElement("p");
reason.className = "card-reason";
reason.textContent = card.display?.reason || card.failure_detail || "";
summary.appendChild(reason);
const meta = document.createElement("p");
meta.className = "card-meta";
const when = card.last_run_at || card.updated_at;
meta.textContent = [
card.display?.next_actor && "Next: " + card.display.next_actor,
when && new Date(when).toLocaleString(),
card.commit_hash && "Commit " + card.commit_hash.slice(0, 7),
].filter(Boolean).join(" · ");
summary.appendChild(meta);
if (card.parent_id) {
const parent = document.createElement("p");
parent.className = "card-meta";
parent.textContent = "Part of " + card.parent_id;
summary.appendChild(parent);
}
el.appendChild(summary);
appendOperatorReason(el, card);
if (card.source_missing) textSection(el, "Source missing", "Restore the intent file before starting another attempt.");
if (card.source === "draft") appendDraftDialogue(el, card);
const clarificationQuestion = card.clarification_question || card.fixer_question;
if (["fixer_rueckfrage", "inspector_rueckfrage"].includes(card.phase) && clarificationQuestion) {
const question = document.createElement("p");
question.className = "status";
question.textContent = `Rückfrage (${card.clarification_task_type || "repair"}): ${clarificationQuestion}`;
el.appendChild(question);
const answer = document.createElement("textarea");
answer.rows = 3;
answer.maxLength = 4000;
answer.placeholder = "Your answer";
answer.dataset.fixerCardId = card.id;
answer.value = fixerAnswerDrafts.get(card.id) || "";
answer.addEventListener("input", () => {
fixerAnswerDrafts.set(card.id, answer.value);
sessionStorage.setItem("boardClarificationDrafts", JSON.stringify(Object.fromEntries(fixerAnswerDrafts)));
rememberFixerAnswerFocus();
});
answer.addEventListener("focus", rememberFixerAnswerFocus);
answer.addEventListener("click", rememberFixerAnswerFocus);
answer.addEventListener("keyup", rememberFixerAnswerFocus);
answer.addEventListener("mousedown", (event) => event.stopPropagation());
el.appendChild(answer);
const send = document.createElement("button");
send.type = "button";
send.textContent = "Send answer";
send.addEventListener("click", async (event) => {
event.stopPropagation();
try {
await submitFixerAnswer(card.id, answer.value);
fixerAnswerDrafts.delete(card.id);
sessionStorage.setItem("boardClarificationDrafts", JSON.stringify(Object.fromEntries(fixerAnswerDrafts)));
if (fixerAnswerFocus.cardId === card.id) fixerAnswerFocus.cardId = null;
await render();
} catch (error) {
window.alert(error.message);
}
});
el.appendChild(send);
}
if (card.routing_pending && card.routing_recommendation) {
const recommendation = document.createElement("p");
recommendation.className = "status";
recommendation.textContent = `Empfehlung: ${card.routing_recommendation} — ${card.routing_reason || ""}`;
el.appendChild(recommendation);
const apply = document.createElement("button");
apply.type = "button";
apply.textContent = "Empfehlung anwenden";
apply.addEventListener("click", async (event) => {
event.stopPropagation();
try { await applyRouting(card.id); await render(); }
catch (error) { window.alert(error.message); }
});
el.appendChild(apply);
}
const detail = document.createElement("button");
detail.type = "button";
detail.textContent = "Details & evidence";
detail.dataset.action = "details";
detail.setAttribute("aria-haspopup", "dialog");
detail.addEventListener("click", () => showCard(card));
el.appendChild(detail);
if (["idea", "draft"].includes(card.source)) {
const remove=document.createElement("button");remove.textContent="Delete";
remove.addEventListener("click",async()=>{
if(!window.confirm("Delete this card and its planning conversation?"))return;
try{await deleteCard(card.id);await render(true);}catch(error){window.alert(error.message);}
});el.appendChild(remove);
}
appendCardActions(el, card);
const directChildren = ancestry.has(card.id) ? [] : cards.filter(c => c.parent_id === card.id);
if (directChildren.length && document.getElementById("view").value === "all") {
const family = document.createElement("details");
family.dataset.family = card.id;
family.open = expandedFamilies.has(card.id);
family.addEventListener("toggle", () => family.open ? expandedFamilies.add(card.id) : expandedFamilies.delete(card.id));
const heading = document.createElement("summary");
heading.textContent = directChildren.length + " children · " + (card.display?.children_successful || 0) + " completed";
family.appendChild(heading);
const children = document.createElement("div");
children.className = "children";
const chain = new Set(ancestry); chain.add(card.id);
for (const child of directChildren) children.appendChild(renderCard(child, cards, chain));
family.appendChild(children); el.appendChild(family);
}
return el;
}
const expandedFamilies = new Set();
let cachedCards = [], lastFingerprint = "", loading = false, lastRefresh = null;
function drawBoard() {
const board = document.getElementById("board");
const focused = document.activeElement;
const focusId = focused?.closest("[data-id]")?.dataset.id;
const focusText = focused?.textContent;
rememberFixerAnswerFocus();
board.innerHTML = "";
const query = document.getElementById("search").value.toLowerCase().trim();
const view = document.getElementById("view").value;
board.classList.toggle("list-view", view !== "all");
const ids = new Set(cachedCards.map(c => c.id));
const matching = cachedCards.filter(c => !query || JSON.stringify([c.title,c.id,c.display,c.failure_detail]).toLowerCase().includes(query));
const selected = matching.filter(c => (view !== "all" ? lifecycleGroup(c) === view : lifecycleGroup(c) !== "archived")
&& (view !== "all" || query || !c.parent_id || !ids.has(c.parent_id)));
const groups = view === "all" ? LIFECYCLE_GROUPS.filter(g => g.id !== "archived") : LIFECYCLE_GROUPS.filter(g => g.id === view);
for (const group of groups) {
const cards = selected.filter(c => lifecycleGroup(c) === group.id)
.sort((a,b) => String(a.updated_at || a.last_run_at || "").localeCompare(String(b.updated_at || b.last_run_at || "")));
if (!cards.length && !["ready","attention","backlog"].includes(group.id) && view === "all") continue;
const column = document.createElement("section"); column.className = "column"; column.dataset.lifecycle = group.id;
const heading = document.createElement("h2"); heading.textContent = group.label + " · " + cards.length; column.appendChild(heading);
for (const card of cards) column.appendChild(renderCard(card, cachedCards));
if (!cards.length) { const empty=document.createElement("p");empty.className="empty";empty.textContent="No cards here.";column.appendChild(empty); }
if (group.id === "backlog") {
for (const [label, create] of [["+ Idea", createIdea], ["+ Plan a goal", createDraft]]) {
const button=document.createElement("button");button.textContent=label;
button.addEventListener("click",async()=>{ const text=window.prompt("What would you like to achieve?");if(text?.trim()){try{await create(text.trim());await render(true);}catch(e){window.alert(e.message);}} });
column.appendChild(button);
}
}
board.appendChild(column);
}
const summary=document.getElementById("summary");summary.replaceChildren();
for(const group of LIFECYCLE_GROUPS){
const cards=cachedCards.filter(c=>lifecycleGroup(c)===group.id);
if(!cards.length)continue;
const button=document.createElement("button");button.textContent=group.label+": "+cards.length;
button.addEventListener("click",()=>{document.getElementById("view").value=group.id;drawBoard();});summary.appendChild(button);
}
if (focusId) {
const card=board.querySelector('[data-id="'+CSS.escape(focusId)+'"]');
const target=card && [...card.querySelectorAll("button,[role=button]")].find(e=>e.textContent===focusText);
target?.focus({preventScroll:true});
}
restoreFixerAnswerFocus();
}
async function render(force=false) {
if(loading)return;
loading=true;
try {
const cards=await fetchBoard();
lastRefresh=new Date();
const connection=document.getElementById("connection");
connection.className="";
connection.textContent="Updated "+lastRefresh.toLocaleTimeString()+" · Counts include child tasks";
const fingerprint=JSON.stringify(cards);
cachedCards=cards;
if(force || fingerprint!==lastFingerprint){lastFingerprint=fingerprint;drawBoard();}
if(!document.getElementById("settings-dialog").open){
document.getElementById("settings-content").replaceChildren(buildAgentColumn());
}
} catch(error) {
const connection=document.getElementById("connection");
connection.className="stale";
connection.textContent="Board unavailable — showing "+(lastRefresh?"data from "+lastRefresh.toLocaleTimeString():"no data")+". Retrying automatically.";
console.error(error);
} finally { loading=false; }
}
function textSection(container, title, value) {
if(value===undefined || value===null || value==="")return;
const heading=document.createElement("h3");heading.textContent=title;container.appendChild(heading);
function appendValue(parent,value) {
if(Array.isArray(value)) {
if(!value.length){const empty=document.createElement("p");empty.textContent="No records yet.";parent.appendChild(empty);return;}
const list=document.createElement("ol");
for(const item of value){const entry=document.createElement("li");appendValue(entry,item);list.appendChild(entry);}parent.appendChild(list);
} else if(value && typeof value==="object") {
const list=document.createElement("dl");
for(const [key,item] of Object.entries(value)){
if(item===null || item===undefined)continue;
const label=document.createElement("dt");label.textContent=key.replaceAll("_"," ");list.appendChild(label);
const detail=document.createElement("dd");appendValue(detail,item);list.appendChild(detail);
}
if(list.children.length)parent.appendChild(list);
else {const empty=document.createElement("p");empty.textContent="Not recorded.";parent.appendChild(empty);}
} else {const body=document.createElement("pre");body.textContent=String(value ?? "Not recorded");parent.appendChild(body);}
}
appendValue(container,value);
}
async function showCard(card, tab="Overview") {
const dialog=document.getElementById("card-dialog");
document.getElementById("card-title").textContent=card.title.replace(/^Intent:\s*/i,"");
document.getElementById("card-id").textContent=card.id;
const nav=document.getElementById("card-tabs");nav.replaceChildren();
for(const name of ["Overview","Attempts","Evidence","Conversation","Changes"]){
const button=document.createElement("button");button.textContent=name;button.setAttribute("aria-pressed",String(name===tab));
button.addEventListener("click",()=>showCard(card,name));nav.appendChild(button);
}
const host=document.getElementById("card-content");
const content=document.createElement("div");host.replaceChildren(content);
if(!dialog.open)dialog.showModal();
if(tab==="Overview"){
textSection(content,"Goal",card.display?.label || card.phase);
textSection(content,"Next action",card.display?.reason);
textSection(content,"Responsible",card.display?.next_actor);
textSection(content,"Execution",card.display?.job_state);
textSection(content,"Last outcome",card.last_run_status);
textSection(content,"Acceptance criteria",card.acceptance_criteria);
textSection(content,"Dependencies",card.blocked_by || card.depends_on);
textSection(content,"Operator decision",card.operator_reason);
textSection(content,"Profile",profileLabel(card.runtime,card.model,card.reasoning_effort));
} else if(tab==="Evidence"){
textSection(content,"Diagnosis",card.inspector_result || {repository:card.repo_status,evidence:card.repo_evidence});
textSection(content,"Activation receipt",{status:card.deployment_status,revision:card.deployed_commit_hash,system:card.activated_system});
textSection(content,"Failure",card.failure_detail);
textSection(content,"Preserved unpublished worktree",card.preserved_worktree);
textSection(content,"Builder report",card.builder_report);
if(card.review_report)await loadDetailReport(content,card,"review","Review");
if(card.research_report)await loadDetailReport(content,card,"research","Research findings");
} else if(tab==="Conversation"){
textSection(content,"Planning conversation",card.draft_conversation || []);
textSection(content,"Clarifications",card.clarification_qa || card.fixer_qa || []);
textSection(content,"Current question",card.clarification_question || card.fixer_question);
} else if(tab==="Changes"){
textSection(content,"Reviewed commit",card.commit_hash);
textSection(content,"Review verdict",card.review_decision);
textSection(content,"Write scope",card.write_scope);
if(["intent","research"].includes(card.source))await loadDetailReport(content,card,card.source==="research"?"research-intent":"intent","Source intent");
} else {
textSection(content,"Previous attempts",card.attempt_history || []);
textSection(content,"Review history",card.review_history || []);
try{
const response=await fetch("/api/board/cards/"+encodeURIComponent(card.id)+"/telemetry");
if(!response.ok)throw new Error("HTTP "+response.status);
const telemetry=await response.json();
textSection(content,"Current pipeline",formatPipelineMetrics(telemetry.current_pipeline));
textSection(content,"Recorded runs",telemetry.runs);
}catch(error){textSection(content,"Telemetry",error.message);}
}
}
async function loadDetailReport(content,card,kind,label){
try{
const response=await fetch("/api/board/cards/"+encodeURIComponent(card.id)+"/"+kind);
if(!response.ok)throw new Error("HTTP "+response.status);
const report=await response.json();textSection(content,label,report.content || report.report || report);
}catch(error){textSection(content,label,error.message);}
}
document.getElementById("search").addEventListener("input",drawBoard);
document.getElementById("view").addEventListener("change",drawBoard);
document.getElementById("refresh").addEventListener("click",()=>render(true));
document.getElementById("settings-open").addEventListener("click",()=>document.getElementById("settings-dialog").showModal());
for (const [id, kind] of [
["smoke-test-direct", "direct"],
["smoke-test-split", "split"],
["smoke-test-remove-direct", "remove-direct"],
["smoke-test-remove-split", "remove-split"],
]) {
document.getElementById(id).addEventListener("click", async () => {
try {
await createSmokeTest(kind);
} catch (error) {
window.alert(`Smoke-Test konnte nicht angelegt werden: ${error.message}`);
}
render();
});
}
document.getElementById("smoke-test-cleanup").addEventListener("click", async () => {
if (!window.confirm("Alle Smoke-Test-Karten (inkl. Split-/Fan-out-Kinder und Synthese) vom gesamten Board entfernen?")) return;
try {
const result = await deleteSmokeTestCards();
window.alert(`Entfernt: ${result.removed_cards} Karte(n), ${result.removed_files} Datei(en).`);
} catch (error) {
window.alert(`Smoke-Tests konnten nicht entfernt werden: ${error.message}`);
}
render();
});
render();
// render() clears and rebuilds the whole board (see its own comment above),
// which throws away focus and in-progress keystrokes on any form control --
// jarring if the periodic tick fires while someone is mid-edit. Pausing the
// tick while a board input/select/textarea is focused is a plain skip, not
// a reschedule: the very next explicit action-triggered render() (a submit,
// a column-config save, ...) still shows the latest state, and the next
// 5s tick after the field loses focus catches up normally.
function boardEditInProgress() {
const active = document.activeElement;
const board = document.getElementById("board");
if (!active || !board || !board.contains(active)) return false;
return ["INPUT", "TEXTAREA", "SELECT"].includes(active.tagName);
}
setInterval(() => {
if (boardEditInProgress()) return;
render();
}, 5000);
</script>
</body>
</html>