Surface (requirement 001 phase 2): navigation Stream (Inbox, Regeln) / Agents / Artifacts with keys 1-3 and hash routes (#graph and #tree redirect), header with title, command palette and settings only, server-side badges (unsnoozed inbox, beats within 900 s, open jobs), a Mayday banner with "aufheben", four stream chips with agent:/art:/seit: search, Filter and time menus, the inbox in main, an agents toolbar (Funk an alle with Newsletter, Broadcast, Pan-Pan, Security, Mayday; Priorität; Tag/Nacht), Post or Call in the agent panel, Ambiente in the settings and read-aloud over the seven classes with Mayday locked. Rename (decision 9): hub/ is orchestrator/, the package kvb_orchestrator, the unit kvb-orchestrator-runner.service, scripts/orchestrator.sh, environment KVB_ORCHESTRATOR_*, make targets orchestrator-*, the MCP server, image and compose project kvb-orchestrator, the guard header X-Kvb-Orchestrator and the message marker [kvb-orchestrator msg <id>]. install runs migrate first: it stops the old container, removes its image and code, moves state, approvals and pinned keys, swaps the MCP registration, the PermissionRequest hook and the runner unit, and changes nothing on a second run (scratch-HOME test with stub docker, claude and systemctl). Saved views fall back to the old localStorage key once. MEDUSA acceptances carried to the renamed paths: 82 rows whose lines are unchanged (reason prefixed "renamed from <old path>, line unchanged"), and 7 rows whose text changed only in the product name, the package name, a temp prefix or a new class argument, each upheld by one kvb-think-criticize skeptic (approval.py:60, messaging.py:53, server.py:56, tests/hub_support.py:64, passkey.py:205, page-agent.js:75, language.py:46). Cost 101 %: the longer name adds 15 tokens of skill text; the operator approved it with the commit. 🎯 Fix: 92 % 💥 Break: 4 % 💸 Cost: 101 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 87 🚦 Gate: ⚠️ cost 101 % > 100: ask the operator before the commit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
133 lines
6.8 KiB
JavaScript
133 lines
6.8 KiB
JavaScript
function build(node, attrs, children) {
|
||
for (const [key, value] of Object.entries(attrs)) {
|
||
if (key === "on") {
|
||
for (const [name, handler] of Object.entries(value)) {
|
||
node.addEventListener(name, handler);
|
||
}
|
||
} else if (value !== null && value !== undefined && value !== false) {
|
||
node.setAttribute(key, value === true ? "" : String(value));
|
||
}
|
||
}
|
||
for (const child of children.flat(Infinity)) {
|
||
if (child !== null && child !== undefined && child !== false) {
|
||
node.append(child instanceof Node ? child : String(child));
|
||
}
|
||
}
|
||
return node;
|
||
}
|
||
const h = (tag, attrs, ...children) => build(document.createElement(tag), attrs, children);
|
||
const s = (tag, attrs, ...children) => build(document.createElementNS(NS, tag), attrs, children);
|
||
const dash = (value) => (value === "" || value === "-" || value === null || value === undefined ? "–" : value);
|
||
const targetsOf = (row) => (row.to.length ? row.to.join(", ") : "alle");
|
||
const fixed = (value) => value.toFixed(1);
|
||
const short = (text) => (text.length > 80 ? `${text.slice(0, 80)}…` : text);
|
||
const btn = (label, cls, run) => h("button", {type: "button", class: cls, on: {click: run}}, label);
|
||
const nowSeconds = () => Date.now() / 1000;
|
||
|
||
function ago(ts) {
|
||
const diff = ts - nowSeconds();
|
||
const size = Math.abs(diff);
|
||
if (size < 45) {
|
||
return "gerade eben";
|
||
}
|
||
if (size < 3600) {
|
||
return relative.format(Math.round(diff / 60), "minute");
|
||
}
|
||
if (size < DAY) {
|
||
return relative.format(Math.round(diff / 3600), "hour");
|
||
}
|
||
return relative.format(Math.round(diff / DAY), "day");
|
||
}
|
||
const stampOf = (ts) => new Date(ts * 1000);
|
||
const when = (ts) => h("time", {"data-ts": ts, datetime: stampOf(ts).toISOString(), title: absolute.format(stampOf(ts))}, ago(ts));
|
||
const pill = (text, tone) => h("span", {class: "pill", "data-tone": tone}, text);
|
||
const status = (value) => pill((STATUS[value] || [value])[0], (STATUS[value] || [value, "neutral"])[1]);
|
||
const klass = (value) => pill(CLASSES[value] || value, PILLS[value] || "neutral");
|
||
const counter = (value) => h("span", {class: "count"}, String(value));
|
||
const meta = (pairs) => h("dl", {class: "meta"}, pairs.map(([label, value, title]) => h("div", {}, h("dt", {}, label), h("dd", {}, h("code", {title: title || null}, dash(value))))));
|
||
const emptyState = (title, text) => h("div", {class: "empty"}, h("div", {class: "empty-mark", "aria-hidden": "true"}), h("h2", {}, title), h("p", {}, text));
|
||
const live = () => ui.preset !== -1 && ui.asOf === null;
|
||
const alarmRows = () => state.radio.filter((row) => (row.class === "mayday" || row.class === "pan-pan") && row.ts >= state.alarm_since).reverse();
|
||
const keyOf = (item) => `${item.kind}:${item.id}`;
|
||
const visibleInbox = () => state.inbox.filter((item) => ui.snoozed || !item.until);
|
||
const waitingCount = () => state.inbox.filter((item) => !item.until).length;
|
||
const isGreen = (item) => item.green && !item.held && !item.until && item.kind !== "operator";
|
||
const selectable = (item) => !item.held && !(item.kind === "review" && item.gate !== "pass") && !(item.kind === "operator" && !item.runnable) && !(item.kind === "decision" && !item.recommended);
|
||
const entryOf = (item, verb, label) => ({kind: item.kind, id: item.id, verb, label, sha256: item.sha256 || ""});
|
||
|
||
function tick() {
|
||
for (const node of document.querySelectorAll("time[data-ts]")) {
|
||
node.textContent = ago(Number(node.dataset.ts));
|
||
}
|
||
const updated = document.getElementById("updated");
|
||
updated.classList.toggle("offline", ui.offline);
|
||
updated.textContent = `${ui.offline ? "Keine Verbindung · " : ""}${live() ? "Live" : "Stand"} ${absolute.format(stampOf(live() ? state.now : state.until))}`;
|
||
}
|
||
|
||
function avatar(name) {
|
||
let hash = 2166136261;
|
||
for (const char of String(name)) {
|
||
hash = Math.imul(hash ^ char.codePointAt(0), 16777619) >>> 0;
|
||
}
|
||
return EMOJI[hash % EMOJI.length];
|
||
}
|
||
const face = (name, big) => h("span", {class: big ? "avatar big" : "avatar", "aria-hidden": "true"}, avatar(name));
|
||
const person = (name) => (name && name !== "-" && name !== "alle" && name !== HUMAN ? h("button", {type: "button", class: "linkish", title: `${name} öffnen`, on: {click: () => openAgent(name)}}, name) : dash(name));
|
||
const people = () => state.people || {};
|
||
const labelOf = (key) => (people()[key] || {name: String(key).replace(/^name:/, "")}).name;
|
||
const nameOf = (key) => (people()[key] || {now: String(key).replace(/^name:/, "")}).now;
|
||
const agentKey = (who) => (people()[who] ? who : (Object.entries(people()).find(([, entry]) => entry.now === who || entry.names.includes(who)) || [who])[0]);
|
||
const chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: CHAIR}).id;
|
||
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
|
||
|
||
function toast(title, lines, good) {
|
||
const box = h("div", {class: `toast${good ? "" : " bad"}`, role: "status", title: "Schließen"}, h("strong", {}, title), lines.length ? h("pre", {}, lines.join("\n")) : null);
|
||
box.addEventListener("click", () => box.remove());
|
||
document.getElementById("toasts").append(box);
|
||
window.setTimeout(() => box.remove(), good ? 9000 : 20000);
|
||
}
|
||
|
||
function runLines(data) {
|
||
return (data.runs || []).flatMap((run) => [`$ ${run.command} → ${run.code}`, run.stdout.trim(), run.stderr.trim()].filter(Boolean));
|
||
}
|
||
|
||
function copyButton(text) {
|
||
const button = h("button", {type: "button", class: "btn small ghost", title: "In die Zwischenablage kopieren"}, "Kopieren");
|
||
button.addEventListener("click", async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
button.textContent = "Kopiert";
|
||
} catch {
|
||
button.textContent = "Nicht möglich";
|
||
}
|
||
window.setTimeout(() => {
|
||
button.textContent = "Kopieren";
|
||
}, 1600);
|
||
});
|
||
return button;
|
||
}
|
||
|
||
function closeMenus(keep) {
|
||
for (const menu of document.querySelectorAll("details.menu[open]")) {
|
||
if (menu !== keep) {
|
||
menu.open = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
function openSheet(title, body, wide) {
|
||
const sheet = document.getElementById("sheet");
|
||
sheet.replaceChildren(h("div", {class: `sheet-box${wide ? " wide" : ""}`, role: "dialog", "aria-modal": "true", "aria-label": title},
|
||
h("div", {class: "sheet-head"}, h("h2", {class: "panel-title"}, title), h("span", {class: "spacer"}), h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: closeSheet}}, "×")),
|
||
h("div", {class: "sheet-body"}, body)));
|
||
sheet.hidden = false;
|
||
}
|
||
|
||
function closeSheet() {
|
||
const sheet = document.getElementById("sheet");
|
||
sheet.hidden = true;
|
||
sheet.replaceChildren();
|
||
reviewDom = null;
|
||
ui.review = null;
|
||
}
|