The kvb-hub is a local review hub in a container bound to 127.0.0.1: one page, JSON APIs and an MCP endpoint for the operator's inbox, asks, decisions, staged-commit reviews, the radio stream, the agent graph, the worktree tree, push-to-talk and read-aloud radio (Piper, whisper.cpp) and an optional LLM command line. The hub is only a relay. The host executors in hub/client (the runner user service, the PermissionRequest hook and the approval CLI the commit skills call) verify a WebAuthn passkey signature against keys pinned outside every sandbox, refuse replays and fail closed. scripts/hub.sh with make hub-install, hub-check, hub-pin, hub-runner and hub-hook installs and checks the hub; hub-hook writes ~/.claude/settings.json after a backup. make watch-check shows a stuck watch hook. The new kvb-git-diff skill and the commit skills route reviews through the hub; the test_hub_* suites replace test_hub.py. Security review (kvb-think-criticize, three skeptics) fixed four holes: signed rules auto-approved chained shell commands; the client and LLM openers followed redirects, the LLM one with its bearer key; the worktree scan ran git fsmonitor and filter commands from agent-writable repos; compose set no memory, pids or health limits. Documented until core requirement 093, on the operator's word: the hub page is trusted while signing and pinning, local processes can forge the unsigned operator actions, and the container reads the host /tmp. Committed on the operator's word despite the gate: the passkey path is not yet verified live. Accepted MEDUSA false positives (137 findings on 115 lines, rows in security-accepted.jsonl): EXA002 x17, MEDUSA-MM-SCAN-058 x12, B603 x8, MEDUSA-FTSC-SCAN-089 x7, MEDUSA-PRIV-SCAN-762 x6, AIC-EXFIL-005 x5, EXA006 x5, MEDUSA-FTSCAN-231 x5, WEB-SSRF x5, MEDUSA-INF-SCAN-2057 x3, RH005 x3, TC001 x3, LO002 x2, MEDUSA-AEXP-SCAN-096 x2, MEDUSA-CGS-SCAN-028 x2, MEDUSA-INF-SCAN-1350 x2, MEDUSA-INF-SCAN-2369 x2, MEDUSA-INF-SCAN-2440 x2, MEDUSA-INF-SCAN-2638 x2, MLSC-SERIAL-002 x2, PL002 x2, PQC005 x2, SAST-PATH-001 x2, TC004 x2, TC005 x2, TC006 x2, TC007 x2, AA-146, EXA007, FINE-050, GB-ENCODE-001, GB-ENCODE-003, MEDUSA-CGS-SCAN-029, MEDUSA-CGS-SCAN-103, MEDUSA-DP-SCAN-1034, MEDUSA-DP-SCAN-1916, MEDUSA-DP-SCAN-954, MEDUSA-EXF26-TOOL-004, MEDUSA-FT-TRIGGER-005, MEDUSA-INF-SCAN-2206, MEDUSA-INF-SCAN-2457, MEDUSA-INF-WEB-003, MEDUSA-INFRA-SCAN-030, MEDUSA-MP-SCAN-1135, MEDUSA-RLHF-POISON-002, MEDUSA-RT-SCAN-006, MM-ENCODE-001, MP-124, PQC001, PQC002, PQC006, RP001, TC009, TC010, WEB-FLASK-004. 🎯 Fix: 80 % 💥 Break: 4 % 💸 Cost: 129 % ⏱️ Time: – 🛡️ Security: 100 % 💰 Value: ? 🏆 Score: 60 🚦 Gate: ⛔ fix 80 % < 90; cost 129 % > 100: ask the operator before the commit ``` /home/kevinveenbirkenbach/Repositories/github.com/kevinveenbirkenbach/skills/.worktrees/feature/kvb-hub ``` Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
200 lines
11 KiB
JavaScript
200 lines
11 KiB
JavaScript
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() ? space.views.graph.axis : "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 graphScreen() {
|
||
const stageBox = h("div", {class: "stage"});
|
||
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
|
||
graphDom = {stage: stageBox, aside, pulses: null, space: null};
|
||
return h("div", {class: "graph"},
|
||
h("div", {class: "card controls"},
|
||
h("div", {class: "segmented", role: "tablist", "aria-label": "Darstellung"}, GRAPHS.map(([id, name]) => h("button", {type: "button", role: "tab", "aria-selected": String(ui.graph === id), on: {click: () => go("graph", id)}}, name))),
|
||
ui.graph === "orbit" ? spaceTools() : null),
|
||
h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, legend()), aside));
|
||
}
|