feat(orchestrator): hierarchy view from council offices with a separate agent order

The Agents views gain a Hierarchie view: an org chart of the chair and
the line offices read from the council claims. The operator appoints,
hands over and recalls an office there; the page signs each change with
the passkey and a host unit (client/offices.py, offices path unit)
verifies the signature, removes a live holder itself and runs a plain
council.py claim for the successor. council.py loses the --operator
flag, so no agent can override a live holder.

The agent order is now separate from task priority: rasci.py agents
order stores it in agent-order.json beside the register through the
nofollow helpers and never writes a --prio pin. It only breaks ties of
equal task rank, also among the queue grants. The Hierarchie view edits
it as a drag list.

Host units run the installed skill scripts, so orchestrator.sh keeps the
offices unit off while the installed council.py, nofollow.py or rasci.py
differ from the tree. Job files, council files and hub records are read
with O_NOFOLLOW and O_NONBLOCK and size caps; nofollow.rewrite replaces
a claim without creating directories, so a beat never revives a released
office.

🎯 Fix: 92 %
💥 Break: 4 %
💸 Cost: 100 %
⏱️ Time: 79 %
🛡️ Security: 100 %
🏆 Score: 100
🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 %

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-07 17:46:39 +00:00
1 parent 8e180be256
commit 4540494c30
53 files changed
+1821 -211

No files matched your search

+7
View File
@@ -402,6 +402,7 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
.graph3d .scene-nav-info, .graph3d .graph-info-msg { display: none; }
.graph3d .scene-container { position: relative; }
.float-tooltip-kap { position: absolute; z-index: 9; padding: 4px 8px; border-radius: 6px; background: var(--glass-card); color: var(--text); font-size: var(--fs-sm); white-space: nowrap; pointer-events: none; box-shadow: var(--shadow); }
{% include "style-hierarchy.css" %}
</style>
</head>
<body>
@@ -485,6 +486,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-spawn.js" %}
{% include "page-hierarchy.js" %}
{% include "page-agent-order.js" %}
{% include "page-graph3d.js" %}
{% include "page-artifacts.js" %}
@@ -493,6 +498,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-cleanup.js" %}
{% include "page-offices.js" %}
{% include "page-time.js" %}
{% include "page-pulses.js" %}
+7 -5
View File
@@ -3,12 +3,14 @@ const Nav = (() => {
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
const ICONS = {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", agents: "bi-people", artifacts: "bi-git",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const known = (view) => VIEWS.some(([id]) => id === view);
const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])];
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
function tabOf(screen) {
return Object.keys(SUBS).find((tab) => SUBS[tab].some(([id]) => id === screen)) || screen;
@@ -22,19 +24,19 @@ const Nav = (() => {
const [head, sub] = decodeURIComponent(String(hash).replace(/^#/, "")).split("/");
if (Object.hasOwn(OLD, head)) {
const [screen, fallback] = OLD[head];
const view = known(sub) ? sub : fallback;
const view = known(screen, sub) ? sub : fallback;
return {screen, view, redirect: hashOf(screen, view)};
}
if (Object.hasOwn(SUBS, head) && SUBS[head].some(([id]) => id === sub)) {
return {screen: sub, view: null, redirect: null};
}
if (TABS.some(([id]) => id === head) && head !== "stream") {
return {screen: head, view: known(sub) ? sub : null, redirect: null};
return {screen: head, view: known(head, sub) ? sub : null, redirect: null};
}
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, ICONS, route, hashOf, tabOf};
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
+102
View File
@@ -0,0 +1,102 @@
const orderDom = {box: null, dragged: null, focus: null};
function openAgentOrder() {
go("agents", "hierarchy");
window.setTimeout(() => {
const title = document.getElementById("agent-order-title");
if (title) {
title.scrollIntoView({block: "nearest"});
title.focus();
}
}, 50);
}
function syncOrder() {
const stored = state.agent_order;
if (ui.order === null || ui.order.length !== stored.length || ui.order.some((name) => !stored.includes(name))) {
ui.order = [...stored];
}
}
function placeAgent(next, focus) {
ui.order = next;
orderDom.focus = focus;
drawAgentOrder();
}
function moveAgent(name, step) {
const from = ui.order.indexOf(name);
const to = from + step;
if (from < 0 || to < 0 || to >= ui.order.length) {
return;
}
const next = ui.order.filter((entry) => entry !== name);
next.splice(to, 0, name);
placeAgent(next, `${step < 0 ? "up" : "down"}:${name}`);
}
function dropAgent(name) {
const dragged = orderDom.dragged;
orderDom.dragged = null;
if (dragged === null || dragged === name) {
return;
}
const next = ui.order.filter((entry) => entry !== dragged);
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
placeAgent(next, null);
}
function orderItem(name, index) {
const up = officeBtn("bi-arrow-up", `${name} nach oben`, `${name} nach oben: höhere Priorität`, () => moveAgent(name, -1), `up:${name}`);
const down = officeBtn("bi-arrow-down", `${name} nach unten`, `${name} nach unten: niedrigere Priorität`, () => moveAgent(name, 1), `down:${name}`);
up.disabled = index === 0;
down.disabled = index === ui.order.length - 1;
const item = h("li", {draggable: "true", "data-name": name}, icon("bi-grip-vertical", "grip"), h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name), h("span", {class: "spacer"}), up, down);
item.addEventListener("dragstart", (event) => {
orderDom.dragged = name;
item.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
});
item.addEventListener("dragend", () => item.classList.remove("dragging"));
item.addEventListener("dragover", (event) => event.preventDefault());
item.addEventListener("drop", (event) => {
event.preventDefault();
dropAgent(name);
});
return item;
}
async function saveAgentOrder() {
if (await act("order", "Priorität", {agents: ui.order})) {
ui.order = null;
drawAgentOrder();
}
}
function drawAgentOrder() {
const box = orderDom.box;
if (!box || !box.isConnected) {
orderDom.box = null;
return;
}
syncOrder();
const key = orderDom.focus || focusKey(box);
orderDom.focus = null;
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.agent_order);
box.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "agent-order-title", tabindex: "-1", "data-focus": "order:title"}, "Priorität der Agenten", counter(ui.order.length))),
h("p", {class: "muted"}, "Ordnet die Freigaben des Chairs und entscheidet nur bei gleicher Aufgabenpriorität; die --prio-Pins der Aufgaben bleiben unverändert."),
ui.order.length ? h("ol", {class: "order-list", "aria-label": "Reihenfolge der Agenten"}, ui.order.map(orderItem)) : h("p", {class: "muted"}, "Kein Agent mit Amt oder offener Aufgabe."),
h("div", {class: "actions"},
h("span", {class: "muted"}, "Ziehen oder Pfeile zum Umsortieren"),
h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn ghost", "data-focus": "order:reset", disabled: !changed, on: {click: () => placeAgent([...state.agent_order], "order:reset")}}, "Zurücksetzen"),
h("button", {type: "button", class: "btn primary", "data-focus": "order:save", disabled: !changed, on: {click: saveAgentOrder}}, "Reihenfolge speichern")));
const target = key && [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
if (target && target.disabled) {
refocus(box, key.startsWith("up:") ? `down:${key.slice(3)}` : key.startsWith("down:") ? `up:${key.slice(5)}` : "order:title");
} else {
refocus(box, key);
}
}
+1 -1
View File
@@ -135,6 +135,6 @@
}
function agentNames() {
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.priority]);
const names = new Set([...state.graph.nodes.filter((node) => node.kind === "session" || node.kind === "chair").map((node) => nameOf(node.id)), ...state.agent_order]);
return [...names].sort((a, b) => a.localeCompare(b));
}
+1 -52
View File
@@ -64,7 +64,7 @@
const night = h("details", {class: "menu"}, h("summary", named("Tag/Nacht"), icon("bi-moon-stars"), caret()), h("div", {class: "menu-box"}, mode,
h("p", {class: "muted"}, "Abend: Agenten beenden ihre Aufgabe. Sonnenuntergang: sie schlafen. Sonnenaufgang: alle wachen auf."),
h("div", {class: "actions"}, ACTIONS.slice(1).map(([id, label]) => btn(label, "btn small", () => confirmAct(id))))));
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", prioritySheet), night), clear, mode};
return {bar: h("div", {class: "toolbar", role: "toolbar", "aria-label": "Steuerung"}, toAll(), clear, iconBtn("bi-sort-down", "Priorität", "btn small", openAgentOrder, "Priorität: Reihenfolge der Agenten in der Hierarchie"), night), clear, mode};
}
function drawToolbar() {
@@ -75,54 +75,3 @@
graphDom.toolbar.clear.disabled = !state.silence;
graphDom.toolbar.mode.replaceChildren(pill(mode, tone));
}
function priorityContent() {
if (ui.order === null || ui.order.some((name) => !state.priority.includes(name)) || ui.order.length !== state.priority.length) {
ui.order = [...state.priority];
}
const list = h("ol", {class: "order-list"});
let dragged = null;
ui.order.forEach((name, index) => {
const item = h("li", {draggable: "true", "data-name": name}, h("span", {class: "rank"}, `${index + 1}.`), face(name, false), person(name));
item.addEventListener("dragstart", (event) => {
dragged = name;
item.classList.add("dragging");
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", name);
});
item.addEventListener("dragend", () => item.classList.remove("dragging"));
item.addEventListener("dragover", (event) => event.preventDefault());
item.addEventListener("drop", (event) => {
event.preventDefault();
if (dragged === null || dragged === name) {
return;
}
const next = ui.order.filter((entry) => entry !== dragged);
next.splice(next.indexOf(name) + (ui.order.indexOf(dragged) < ui.order.indexOf(name) ? 1 : 0), 0, dragged);
ui.order = next;
prioritySheet();
});
list.append(item);
});
const changed = JSON.stringify(ui.order) !== JSON.stringify(state.priority);
return [
ui.order.length ? list : h("p", {class: "muted"}, "Kein Agent mit offener Aufgabe."),
h("div", {class: "actions"},
h("span", {class: "muted"}, "Ziehen zum Umsortieren"),
h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn ghost", disabled: !changed, on: {click: () => {
ui.order = [...state.priority];
prioritySheet();
}}}, "Zurücksetzen"),
h("button", {type: "button", class: "btn primary", disabled: !changed, on: {click: async () => {
if (await act("order", "Priorität", {agents: ui.order})) {
ui.order = null;
prioritySheet();
}
}}}, "Reihenfolge speichern")),
];
}
function prioritySheet() {
openSheet("Priorität der Agenten", priorityContent(), false);
}
+16 -10
View File
@@ -47,30 +47,36 @@
return false;
}
outcome.replaceChildren(h("p", {class: "muted", "data-cleanup": result.data.id}, `Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit löscht jetzt.`));
followCleanup(result.data.id, outcome, Date.now());
followJob(`/api/cleanup/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
const {good, lines} = showJob(data, outcome, "branch", RESULT_LABELS);
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, lines, good);
fetchTree();
});
return true;
}
async function followCleanup(id, outcome, began) {
async function followJob(url, outcome, began, finish) {
let data = null;
try {
const response = await fetch(`/api/cleanup/${encodeURIComponent(id)}`, {cache: "no-store"});
const response = await fetch(url, {cache: "no-store"});
data = response.ok ? await response.json() : null;
} catch {
data = null;
}
if (!data || data.status !== "done") {
if (Date.now() - began < CLEANUP_PATIENCE && outcome.isConnected) {
window.setTimeout(() => followCleanup(id, outcome, began), CLEANUP_POLL);
window.setTimeout(() => followJob(url, outcome, began, finish), CLEANUP_POLL);
}
return;
}
const lines = data.items.map((item) => `${item.branch}: ${(RESULT_LABELS[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
finish(data);
}
function showJob(data, outcome, key, labels) {
const lines = data.error ? [data.error] : data.items.map((item) => `${item[key]}: ${(labels[item.status] || [item.status])[0]}${item.reason ? ` (${item.reason})` : ""}`);
outcome.replaceChildren(h("ul", {class: "mini", "aria-label": "Ergebnis"}, data.error ? h("li", {}, data.error) : data.items.map((item) => {
const [label, tone] = RESULT_LABELS[item.status] || [item.status, "neutral"];
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item.branch), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
const [label, tone] = labels[item.status] || [item.status, "neutral"];
return h("li", {"data-result": item.status}, h("div", {class: "row"}, h("code", {}, item[key]), pill(label, tone)), item.reason ? h("span", {class: "muted"}, item.reason) : null);
})));
const good = !data.error && data.items.every((item) => item.status === "done");
toast(`Löschen: ${good ? "erledigt" : "nicht alles gelöscht"}`, data.error ? [data.error] : lines, good);
fetchTree();
return {good: !data.error && data.items.every((item) => item.status === "done"), lines};
}
-1
View File
@@ -2,7 +2,6 @@
const NONCE = document.currentScript ? document.currentScript.nonce : "";
const ACTIVE_BEAT = 900;
const DAY = 86400;
const CHAIR = "Maintainer";
const HUMAN = "Operator";
const PRESETS = [[3600, "1 h"], [21600, "6 h"], [DAY, "24 h"], [7 * DAY, "7 d"], [0, "alle"], [-1, "eigener"]];
const EMOJI = ["🦊", "🐙", "🦉", "🐝", "🐢", "🦄", "🐳", "🦋", "🐧", "🦁", "🐼", "🐨", "🦜", "🐬", "🦀", "🐸", "🦔", "🦩", "🐞", "🌵", "🍀", "🌻", "🍄", "🌙", "⭐", "🔥", "🌊", "⚡", "🎈", "🎲", "🧭", "🚀", "🪐", "🔭", "🧩", "🎯", "🪁", "🧪", "🔮", "🗿", "🌋", "🍉", "🍋", "🥨", "🐋", "🦚", "🐌", "🌈"];
+1 -1
View File
@@ -81,7 +81,7 @@
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 chairKey = () => (state.graph.nodes.find((node) => node.kind === "chair") || {id: HUMAN}).id;
const stamp = (life) => [life.born ? `🐣 ${life.born}` : "", life.died ? `🪦 ${life.died}` : ""].filter(Boolean).join(" · ");
function toast(title, lines, good) {
+2 -2
View File
@@ -180,7 +180,7 @@
}
function viewSwitch(screen) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.VIEWS.map(([id, name]) => h("button", {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.viewsOf(screen).map(([id, name]) => h("button", {
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(ui.views[screen] === id), on: {click: () => go(screen, id)},
}, icon(Nav.ICONS[id]))));
}
@@ -191,6 +191,6 @@
const aside = h("aside", {class: "card panel", "aria-live": "polite"});
const toolbar = agentsToolbar();
graphDom = {stage: stageBox, aside, pulses: null, toolbar};
const body = view === "tree" ? spawnScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
const body = view === "tree" ? spawnScreen() : view === "hierarchy" ? hierarchyScreen() : h("div", {class: "graph-body"}, h("div", {class: "card stage-card"}, stageBox, view === "3d" ? null : legend()), aside);
return h("div", {class: "graph"}, toolbar.bar, h("div", {class: "card controls"}, viewSwitch("agents"), view === "3d" ? layoutTools("agents") : null), body);
}
+128
View File
@@ -0,0 +1,128 @@
const hier = {chart: null, sig: "", closed: new Set(), ids: 0};
const OFFICE_ICONS = {chair: "bi-award", line: "bi-person-badge", cluster: "bi-collection", work: "bi-briefcase", other: "bi-signpost-split"};
const OFFICE_GROUPS = [["cluster", "Organisatoren", "Organisator"], ["work", "Präsidenten", "Präsident"], ["other", "Weitere Ämter", "Amt"]];
function hierarchyScreen() {
hier.chart = h("section", {class: "card org-chart", "aria-labelledby": "org-title"});
orderDom.box = h("section", {class: "card agent-order", id: "agent-order", "aria-labelledby": "agent-order-title"});
hier.sig = "";
return h("div", {class: "hierarchy-body"}, hier.chart, orderDom.box);
}
function hierarchySig() {
const held = state.claims.map((claim) => [claim.office, claim.session, claim.agent, claim.work, claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT]);
return JSON.stringify([held, state.agent_order, state.people]);
}
function drawHierarchy(force) {
if (!hier.chart || !hier.chart.isConnected) {
hier.chart = null;
return;
}
const sig = hierarchySig();
if (!force && sig === hier.sig) {
return;
}
hier.sig = sig;
drawChart();
drawAgentOrder();
}
function focusKey(box) {
const at = document.activeElement;
return at && box.contains(at) ? at.dataset.focus || null : null;
}
function refocus(box, key) {
const found = key === null ? null : [...box.querySelectorAll("[data-focus]")].find((element) => element.dataset.focus === key);
if (found) {
found.focus();
}
}
function officeBtn(name, label, hint, run, focus) {
const button = iconBtn(name, label, "btn small ghost", run, hint);
button.dataset.focus = focus;
return button;
}
function holderOf(claim) {
return claim.agent || (claim.session === "-" ? "" : claim.session);
}
function officeRow(claim, label, title) {
const who = holderOf(claim);
const active = claim.beat !== null && claim.beat >= state.now - ACTIVE_BEAT;
return h("div", {class: "org-row", "data-office": claim.office},
h("span", {class: "org-office", title: `${title} ${claim.office}`}, icon(OFFICE_ICONS[claim.kind]), h("strong", {}, label)),
who ? h("button", {type: "button", class: "linkish org-holder", "data-focus": `agent:${claim.office}`, title: `${who} öffnen`, on: {click: () => openAgent(who)}}, face(who, false), who) : h("span", {class: "muted"}, "–"),
active ? chip("bi-play-circle", "aktiv", "ok") : chip("bi-pause-circle", "ohne Herzschlag", "neutral"),
claim.work && claim.work !== "-" ? h("span", {class: "muted org-work", title: claim.work}, short(claim.work)) : null,
h("span", {class: "org-actions"},
officeBtn("bi-arrow-left-right", "Übergeben", `Übergeben: ${claim.office} an eine andere Session, mit Passkey-Signatur`, () => officeSheet("takeover", claim), `takeover:${claim.office}`),
officeBtn("bi-person-x", "Abberufen", `Abberufen: ${claim.office} frei machen, mit Passkey-Signatur`, () => officeSheet("recall", claim), `recall:${claim.office}`)));
}
function vacantRow(office, label) {
return h("div", {class: "org-row vacant", "data-office": office},
h("span", {class: "org-office"}, icon(OFFICE_ICONS.chair), h("strong", {}, label)),
pill("unbesetzt", "warn"),
h("span", {class: "org-actions"}, officeBtn("bi-person-plus", "Besetzen", `Besetzen: ${office} einer Session geben, mit Passkey-Signatur`, () => officeSheet("appoint", {office, session: "", work: ""}), `appoint:${office}`)));
}
function groupRow(glyph, label, count, note) {
return h("div", {class: "org-row group"}, h("span", {class: "org-office"}, icon(glyph), h("strong", {}, label)), counter(count), h("span", {class: "muted"}, note));
}
function orgNode(id, label, row, children) {
if (!children.length) {
return h("li", {}, h("div", {class: "org-line"}, h("span", {class: "org-gap", "aria-hidden": "true"}), row));
}
const open = !hier.closed.has(id);
hier.ids += 1;
const list = h("ul", {class: "org-tree", id: `org-${hier.ids}`, hidden: !open}, children);
const toggle = h("button", {type: "button", class: "btn small ghost icon-only org-toggle", "aria-expanded": String(open), "aria-controls": list.id, "data-focus": `toggle:${id}`, ...named(`${open ? "Zuklappen" : "Aufklappen"}: ${label}`)}, icon(open ? "bi-chevron-down" : "bi-chevron-right"));
toggle.addEventListener("click", () => {
const now = list.hidden;
list.hidden = !now;
toggle.setAttribute("aria-expanded", String(now));
relabel(toggle, now ? "bi-chevron-down" : "bi-chevron-right", `${now ? "Zuklappen" : "Aufklappen"}: ${label}`);
if (now) {
hier.closed.delete(id);
} else {
hier.closed.add(id);
}
});
return h("li", {"data-node": id}, h("div", {class: "org-line"}, toggle, row), list);
}
function chartTree() {
const claims = state.claims;
const dimensions = new Map();
for (const claim of claims.filter((entry) => entry.kind === "line")) {
dimensions.set(claim.dimension, [...(dimensions.get(claim.dimension) || []), claim]);
}
const lines = [...dimensions].sort(([a], [b]) => a.localeCompare(b)).map(([dimension, held]) => orgNode(`dimension:${dimension}`, dimension,
groupRow("bi-diagram-2", dimension, held.length, held.length === 1 ? "Linie" : "Linien"),
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, claim.line, "Repräsentant"), []))));
const groups = OFFICE_GROUPS.map(([kind, label, title]) => [kind, label, title, claims.filter((entry) => entry.kind === kind)]).filter(([, , , held]) => held.length)
.map(([kind, label, title, held]) => orgNode(`group:${kind}`, label, groupRow(OFFICE_ICONS[kind], label, held.length, kind === "other" ? "" : `${kind}/…`),
held.map((claim) => orgNode(claim.office, claim.office, officeRow(claim, kind === "other" ? claim.office : claim.office.slice(kind.length + 1), title), []))));
const chair = claims.find((entry) => entry.kind === "chair");
const chairNode = orgNode("chair", "Chair", chair ? officeRow(chair, "Chair", "Amt") : vacantRow("chair", "Chair"), [...lines, ...groups]);
const root = h("div", {class: "org-row org-root"}, h("span", {class: "org-office"}, icon("bi-person-circle"), h("strong", {}, HUMAN)), h("span", {class: "muted"}, "vergibt jedes Amt mit Passkey-Signatur"));
return h("ul", {class: "org-tree root"}, orgNode("operator", HUMAN, root, [chairNode]));
}
function drawChart() {
const key = focusKey(hier.chart);
hier.ids = 0;
const count = state.claims.length;
hier.chart.replaceChildren(
h("div", {class: "inbox-head"}, h("h2", {class: "group-title", id: "org-title"}, "Hierarchie", counter(count)),
h("span", {class: "muted"}, "Formale Ämter aus dem Council: Operator → Chair → Dimensionen → Linien, dazu Organisatoren und Präsidenten"),
h("span", {class: "spacer"}),
h("button", {type: "button", class: "btn small soft", "data-focus": "appoint:new", ...named("Neues Amt", "Neues Amt: ein freies Amt einer Session geben, mit Passkey-Signatur"), on: {click: () => officeSheet("appoint", null)}}, icon("bi-person-plus"), "Neues Amt")),
chartTree());
refocus(hier.chart, key);
}
+68
View File
@@ -0,0 +1,68 @@
const OFFICE_RESULTS = {done: ["erledigt", "ok"], refused: ["verweigert", "bad"], failed: ["fehlgeschlagen", "bad"]};
const OFFICE_VERBS = {
appoint: ["Besetzen", "bi-person-plus", "council.py claim --operator auf dem Host: die gewählte Session erhält das freie Amt."],
takeover: ["Übergeben", "bi-arrow-left-right", "council.py takeover --operator auf dem Host: das Amt geht an die gewählte Session, auch wenn der bisherige Inhaber noch lebt."],
recall: ["Abberufen", "bi-person-x", "council.py release --operator auf dem Host: das Amt wird frei, auch wenn der Inhaber noch lebt."],
};
const OFFICE_HINT = "Der Host prüft deine Passkey-Signatur selbst und verweigert, wenn sich der Inhaber seit der Signatur geändert hat.";
function successors(office, holder) {
const [dimension, line] = office.split("/");
const lined = Boolean(line) && dimension !== "work" && dimension !== "cluster";
return state.graph.nodes
.filter((node) => (node.kind === "session" || node.kind === "chair") && SESSION_ID.test(node.id) && node.id !== holder)
.filter((node) => !lined || !state.claims.some((claim) => claim.session === node.id && claim.kind === "line" && claim.dimension === dimension && claim.office !== office))
.map((node) => [node.id, `${labelOf(node.id)} · ${node.id.slice(0, 8)}`]);
}
function officeSheet(action, claim) {
const office = claim ? claim.office : "";
const holder = claim && action !== "appoint" ? claim.session : "";
const [verb, glyph, text] = OFFICE_VERBS[action];
const name = h("input", {class: "field", "aria-label": "Amt", value: office, readonly: Boolean(office), maxlength: 129, autocomplete: "off", spellcheck: "false", placeholder: "chair, dimension/linie, cluster/slug oder work/slug"});
const picks = successors(office, holder);
const session = h("select", {class: "field", "aria-label": "Session"}, picks.map(([id, label]) => h("option", {value: id}, label)));
const work = h("input", {class: "field", "aria-label": "Arbeit", value: claim && claim.work !== "-" ? claim.work : "", maxlength: 300, autocomplete: "off", placeholder: "woran die Session in diesem Amt arbeitet"});
const outcome = h("div", {class: "mini", "aria-live": "polite"});
const recall = action === "recall";
const confirm = h("button", {type: "button", class: recall ? "btn danger" : "btn primary", disabled: !recall && !picks.length, ...named(verb, `${verb}: mit deiner Passkey-Signatur an den Host übergeben`)}, icon(glyph), verb);
confirm.addEventListener("click", async () => {
const item = {kind: "office", action, office: name.value.trim(), holder, session: recall ? "" : session.value, work: recall ? "" : work.value.trim()};
confirm.disabled = true;
if (!await signOffice([item], outcome, verb)) {
confirm.disabled = false;
}
});
openSheet(office ? `${office}: ${verb}` : "Neues Amt besetzen", [
h("p", {class: "muted"}, text, " ", OFFICE_HINT),
holder ? meta([["Inhaber", holderOf(claim) || holder, holder]]) : null,
h("div", {class: "office-form"},
h("label", {}, "Amt", name),
recall ? null : h("label", {}, "Session", picks.length ? session : h("span", {class: "muted"}, "Keine passende Session bekannt.")),
recall ? null : h("label", {}, "Arbeit", work)),
outcome,
h("div", {class: "actions"}, h("span", {class: "spacer"}), btn("Abbrechen", "btn ghost", closeSheet), confirm),
], false);
(office ? (recall ? confirm : session) : name).focus();
}
async function signOffice(items, outcome, verb) {
const signed = await proofFor(items, verb);
if (signed === null) {
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
return false;
}
const result = await sendJson("/api/offices", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast(`${verb}: nicht möglich`, fail(result), false);
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
return false;
}
outcome.replaceChildren(h("p", {class: "muted"},`Signiert und an den Host übergeben (Job ${result.data.id}); die Path-Unit ruft jetzt council.py auf.`));
followJob(`/api/offices/${encodeURIComponent(result.data.id)}`, outcome, Date.now(), (data) => {
const {good, lines} = showJob(data, outcome, "office", OFFICE_RESULTS);
toast(`${verb}: ${good ? "erledigt" : "nicht ausgeführt"}`, lines, good);
load();
});
return true;
}
+2 -2
View File
@@ -1,13 +1,13 @@
function paletteItems() {
return [
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
...["agents", "artifacts"].flatMap((screen) => Nav.VIEWS.map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...["agents", "artifacts"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
{label: "Funk an alle …", hint: "Newsletter, Broadcast, Pan-Pan, Security, Mayday", run: openToAll},
...ACTIONS.map(([id, label]) => ({label, hint: id === "clear-mayday" ? "Mayday" : "Tag/Nacht", run: () => confirmAct(id)})),
{label: "Priorität …", hint: "Reihenfolge der Agenten", run: prioritySheet},
{label: "Priorität …", hint: "Reihenfolge der Agenten in der Hierarchie", run: openAgentOrder},
{label: "Regeln …", hint: "automatische Freigaben", run: () => go("rules", null)},
{label: "Einstellungen …", hint: "Ambiente, Ort, Farben, Musik, Töne, Funk vorlesen, Radio", run: settingsSheet},
{label: settings.music.on ? "Musik aus" : "Musik an", hint: "Ambiente", run: toggleMusic},
+1
View File
@@ -77,6 +77,7 @@
stage();
drawSide();
drawSpawn();
drawHierarchy(force);
drawToolbar();
drawInbox();
drawStatus();
+2 -2
View File
@@ -1,5 +1,5 @@
function stage() {
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree") {
if (!graphDom || ui.screen !== "agents" || ui.views.agents === "tree" || ui.views.agents === "hierarchy") {
return;
}
graphDom.pulses = null;
@@ -11,7 +11,7 @@
}
function drawSide() {
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree") {
if (graphDom && ui.screen === "agents" && ui.views.agents !== "tree" && ui.views.agents !== "hierarchy") {
graphDom.aside.replaceChildren(...side());
}
}
@@ -0,0 +1,21 @@
.hierarchy-body { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 14px; align-items: start; }
@media (max-width: 960px) { .hierarchy-body { grid-template-columns: minmax(0, 1fr); } }
.org-chart .inbox-head { flex-wrap: wrap; }
.org-tree { display: grid; gap: 4px; margin: 0; padding: 0 0 0 16px; list-style: none; border-left: 1px solid var(--line); margin-left: 14px; }
.org-tree.root { margin-left: 0; padding-left: 0; border-left: 0; }
.org-tree[hidden] { display: none; }
.org-line { display: flex; align-items: flex-start; gap: 4px; }
.org-toggle { margin-top: 3px; }
.org-gap { flex: none; width: 30px; }
.org-row { display: flex; flex: 1; flex-wrap: wrap; align-items: center; gap: 6px 10px; min-width: 0; padding: 5px 8px; border-radius: 8px; }
.org-row:hover { background: var(--hover); }
.org-row.vacant { border: 1px dashed var(--line-strong); }
.org-row.group .org-office { color: var(--muted); }
.org-office, .org-holder { display: inline-flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; }
.org-work { flex: 1 1 140px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.org-actions { display: inline-flex; gap: 2px; margin-left: auto; }
.agent-order .order-list li { gap: 8px; padding: 6px 8px; }
.agent-order .order-list .linkish { min-width: 0; }
.agent-order .grip { flex: none; color: var(--muted); }
.office-form { display: grid; gap: 10px; margin: 10px 0; }
.office-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }