Files
skills/orchestrator/templates/page-graph-charts.js
T
Kevin Veen-BirkenbachandClaude Opus 5.5 6f940b4415 feat(orchestrator): rename kvb-hub to Agent Orchestrator with the phase 2 surface
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>
2026-11-06 22:20:06 +00:00

210 lines
11 KiB
JavaScript
Raw 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.
function orbit() {
const nodes = state.graph.nodes;
positions = layout(nodes);
const svg = chartFrame(400, "Orbit: Operator in der Mitte; wer zusammen arbeitet oder funkt, rückt zusammen, Stille driften nach außen");
svg.append(...RINGS.map((r) => s("circle", {class: "ring", r})));
const edgeLayer = s("g", {});
const halos = new Map();
for (const link of aggregate()) {
const from = positions.get(link.source);
const shade = tone(link.cls);
const words = `${link.count}× ${CLASSES[link.cls] || link.cls}`;
if (!from) {
continue;
}
if (link.target === "*") {
const index = halos.get(link.source) || 0;
halos.set(link.source, index + 1);
edgeLayer.append(s("circle", {class: `halo t-${shade}`, cx: fixed(from.x), cy: fixed(from.y), r: radiusOf(link.source) + 7 + index * 7, "stroke-width": width(link.count)},
s("title", {}, `${link.source} an alle: ${words}`)));
continue;
}
const to = positions.get(link.target);
if (!to || to === from) {
continue;
}
const control = Space.bend(from, to);
const start = toward(from, control, radiusOf(link.source) + 2);
const end = toward(to, control, radiusOf(link.target) + 4);
edgeLayer.append(s("path", {class: `edge t-${shade}`, d: `M${fixed(start.x)},${fixed(start.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(end.x)},${fixed(end.y)}`, "stroke-width": fixed(width(link.count)), "marker-end": `url(#tip-${shade})`},
s("title", {}, `${link.source} → ${link.target}: ${words}`)));
}
const nodeLayer = s("g", {}, nodes.map((node) => nodeMark(node, positions.get(node.id), node.kind === "operator" || node.kind === "subagent" ? null : RADIUS[node.kind] + 9)));
const pulseLayer = s("g", {});
svg.append(edgeLayer, nodeLayer, pulseLayer);
graphDom.pulses = pulseLayer;
return svg;
}
function chord() {
const nodes = state.graph.nodes.filter((node) => node.kind !== "subagent");
const fresh = targets();
const radius = 250;
const step = (2 * Math.PI) / Math.max(nodes.length, 1);
const spots = new Map(nodes.map((node) => {
const angle = spotOf(node.id, fresh).chord;
return [node.id, {x: radius * Math.cos(angle), y: radius * Math.sin(angle), angle}];
}));
const svg = chartFrame(450, "Chord: Nachrichten zwischen Agenten, Reihenfolge nach Nähe");
const arcs = s("g", {});
const point = (angle, r) => `${fixed(r * Math.cos(angle))},${fixed(r * Math.sin(angle))}`;
for (const node of nodes) {
const at = spots.get(node.id);
const half = Math.min(step * 0.36, 0.5);
arcs.append(s("path", {class: `group-arc k-${node.kind}`, d: `M${point(at.angle - half, radius + 14)} A${radius + 14},${radius + 14} 0 0 1 ${point(at.angle + half, radius + 14)}`}));
}
const pairs = new Map();
const fans = new Map();
for (const link of aggregate()) {
const shade = tone(link.cls);
if (!spots.has(link.source)) {
continue;
}
if (link.target === "*") {
const key = `${link.source}\u0000${shade}`;
fans.set(key, {source: link.source, shade, count: (fans.has(key) ? fans.get(key).count : 0) + link.count});
continue;
}
if (!spots.has(link.target) || link.target === link.source) {
continue;
}
const [a, b] = [link.source, link.target].sort();
const key = `${a}\u0000${b}\u0000${shade}`;
pairs.set(key, {a, b, shade, count: (pairs.has(key) ? pairs.get(key).count : 0) + link.count});
}
const lanes = LEGEND.map(([name]) => name);
for (const pair of pairs.values()) {
const from = spots.get(pair.a);
const to = spots.get(pair.b);
const pull = 0.06 + 0.12 * lanes.indexOf(pair.shade);
const control = {x: ((from.x + to.x) / 2) * pull, y: ((from.y + to.y) / 2) * pull};
const start = toward(from, control, RADIUS.session + 2);
const end = toward(to, control, RADIUS.session + 2);
arcs.append(s("path", {class: `edge t-${pair.shade}`, d: `M${fixed(start.x)},${fixed(start.y)} Q${fixed(control.x)},${fixed(control.y)} ${fixed(end.x)},${fixed(end.y)}`, "stroke-width": fixed(width(pair.count))},
s("title", {}, `${pair.a} ↔ ${pair.b}: ${pair.count}× ${LEGEND.find(([name]) => name === pair.shade)[1]}`)));
}
const offsets = new Map();
for (const fan of fans.values()) {
const at = spots.get(fan.source);
const index = offsets.get(fan.source) || 0;
offsets.set(fan.source, index + 1);
const angle = at.angle + (index - 1) * 0.05;
const length = Math.min(8 + 6 * Math.log2(1 + fan.count), 40);
arcs.append(s("line", {class: `tick t-${fan.shade}`, x1: fixed((radius + 22) * Math.cos(angle)), y1: fixed((radius + 22) * Math.sin(angle)), x2: fixed((radius + 22 + length) * Math.cos(angle)), y2: fixed((radius + 22 + length) * Math.sin(angle)), "stroke-width": 4},
s("title", {}, `${fan.source} an alle: ${fan.count}×`)));
}
svg.append(arcs, s("g", {}, nodes.map((node) => nodeMark(node, spots.get(node.id), 74))));
return svg;
}
function swimlane() {
const rank = new Map([HUMAN, ...state.graph.layout.order].map((id, index) => [id, index]));
const lanes = state.graph.nodes.filter((node) => node.kind !== "subagent").sort((a, b) => (rank.has(a.id) ? rank.get(a.id) : 1e6) - (rank.has(b.id) ? rank.get(b.id) : 1e6));
const begin = state.since;
const end = Math.max(state.until, state.since + 1);
const left = 160;
const right = 24;
const top = 40;
const lane = 36;
const full = 1120;
const height = top + lanes.length * lane + 12;
const x = (ts) => left + ((ts - begin) / (end - begin)) * (full - left - right);
const y = new Map(lanes.map((node, index) => [node.id, top + index * lane + lane / 2]));
const svg = s("svg", {viewBox: `0 0 ${full} ${height}`, class: "chart", role: "img", "aria-label": "Swimlane: Nachrichten über die Zeit"}, defs());
lanes.forEach((node, index) => {
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top + index * lane, width: full, height: lane}));
svg.append(s("text", {class: "lane-label", x: 14, y: top + index * lane + lane / 2, dy: "0.35em", "text-anchor": "start", on: {click: () => select(node.id)}}, node.label));
});
for (let index = 0; index <= 6; index += 1) {
const ts = begin + ((end - begin) * index) / 6;
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: top - 6, y2: height - 8}));
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: top - 14, "text-anchor": index === 0 ? "start" : index === 6 ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
}
if (live()) {
svg.append(s("line", {class: "now", x1: fixed(x(end)), x2: fixed(x(end)), y1: top - 6, y2: height - 8}));
}
const marks = s("g", {});
const arrows = (cls, ts, from, to, words) => {
const x0 = fixed(x(ts));
const y0 = y.get(from);
if (y0 === undefined) {
return;
}
const shade = tone(cls);
const hint = s("title", {}, `${clock.format(stampOf(ts))} · ${dash(labelOf(from))} → ${to.length ? to.map(labelOf).join(", ") : "alle"}\n${words}`);
if (!to.length) {
const rays = [[-6, -13], [0, -15], [6, -13], [-6, 13], [0, 15], [6, 13]].map(([dx, dy]) => `M${x0},${y0} l${dx},${dy}`).join(" ");
marks.append(s("g", {class: `mark t-${shade}`}, hint, s("path", {d: rays}), s("circle", {cx: x0, cy: y0, r: 3.5})));
return;
}
const bundle = s("g", {class: `mark t-${shade}`}, hint, s("circle", {cx: x0, cy: y0, r: 3}));
for (const target of to) {
const y1 = y.get(target);
if (y1 !== undefined && y1 !== y0) {
bundle.append(s("line", {x1: x0, y1: y0, x2: x0, y2: y1 > y0 ? y1 - 6 : y1 + 6, "marker-end": `url(#tip-${shade})`}));
}
}
marks.append(bundle);
};
for (const row of state.radio) {
arrows(row.class, row.ts, row.source, row.targets, row.text.length > 120 ? `${row.text.slice(0, 120)}…` : row.text);
}
for (const grant of state.grants) {
arrows("grant", grant.ts, chairKey(), [grant.target], `Queue-Freigabe ${grant.kind} (${grant.id})`);
}
svg.append(marks);
return svg;
}
function legend() {
const axis = graph3d() ? depth() : "none";
const words = {
none: "Linienstärke = Anzahl · Nähe = Zusammenarbeit der letzten Zeit",
time: "Weltlinie = Agent, Lage = Nähe der Zusammenarbeit zu ihrer Zeit · Bogen = ein Ereignis · Ebene = Stand der Wiedergabe",
spawn: "Ebenen = Spawn-Tiefe · Bogen = Nachrichten im Zeitraum, Zahl im Stream",
};
return h("div", {class: "legend"}, LEGEND.map(([name, label]) => h("span", {}, h("i", {class: `swatch t-${name}`}), label)),
axis === "none" ? null : h("span", {}, h("i", {class: "swatch t-spawn"}), "Spawn"),
h("span", {class: "muted"}, words[axis]));
}
function side() {
const node = state.graph.nodes.find((entry) => entry.id === ui.node);
if (!node) {
const where = graph3d() ? "eine Weltlinie oder darunter auf ihren Namen" : "einen Knoten";
return [h("h2", {class: "panel-title"}, "Details"), h("p", {class: "muted"}, `Klicke auf ${where}: sein Panel zeigt Aufgaben, Claims, Subagenten und Verlauf.`)];
}
const messages = state.radio.filter((row) => row.source === node.id || row.targets.includes(node.id)).slice(-10).reverse();
return [
h("div", {class: "panel-head"},
h("div", {}, h("h2", {class: "panel-title"}, node.label), h("span", {class: "muted"}, `${NODE_KINDS[node.kind]}${node.parent ? ` von ${labelOf(node.parent)}` : ""}`)),
h("button", {type: "button", class: "btn small ghost", "aria-label": "Schließen", on: {click: () => select(node.id)}}, "×")),
h("h3", {}, "Letzte Nachrichten", counter(messages.length)),
messages.length ? h("ul", {class: "mini"}, messages.map((row) => h("li", {},
h("div", {class: "row"}, klass(row.class), when(row.ts)),
h("span", {class: "muted"}, `${dash(row.from)} → ${targetsOf(row)}`),
h("span", {}, row.text)))) : h("p", {class: "muted"}, "Keine Nachrichten im gewählten Zeitraum."),
];
}
function viewSwitch(screen) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {type: "button", "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)}}, name)));
}
const later = (what) => emptyState(`${what} folgt`, "Diese Ansicht ist geplant und kommt mit dem nächsten Ausbau.");
function agentsScreen() {
const view = ui.views.agents;
const stageBox = h("div", {class: "stage"});
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const toolbar = agentsToolbar();
graphDom = {stage: stageBox, aside, pulses: null, space: null, toolbar};
const body = view === "tree" ? later("Der Spawn-Baum") : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? spaceTools() : null), body);
}
function artifactsScreen() {
const view = ui.views.artifacts;
return h("div", {class: "graph"}, h("div", {class: "card controls"}, viewSwitch("artifacts")), view === "tree" ? treeScreen() : later(`Artifacts ${Nav.VIEWS.find(([id]) => id === view)[1]}`));
}