Entscheidungsinstanz an der Spitze: Mensch, Agent oder Mensch zuerst mit Frist (#690)

- cases.top in instance.json: Human | Agent | HumanFirst, agentId, humanFirstHours
- TopRole: der entscheidende Agent steht in keiner Einheit, liest keine Fremdinhalte
  und hat nur Taskboard, Escalation, Memory, FileRW, AgentComm; geprueft beim Setzen,
  bei jeder Zustellung und vor jeder Entscheidung
- CaseDesk: was bisher an der Spitze beim Menschen landete, geht nach Einstellung an
  den Agenten; Tagesdeckel und Frist gehen weiter an den Menschen. handle verwehrt,
  forward geht an den Menschen. Mensch zuerst: Uebergabe nach Ablauf, eigene Frist.
  Der Mensch kann einen Fall des Agenten selbst entscheiden.
- Beschluesse des Agenten: ByTopAgent, zum Lesen vorgelegt; jeder Beschluss laesst
  sich mit Begruendung zuruecknehmen und bindet dann niemanden mehr
- Briefing fuer den Agenten an der Spitze, auch ohne Einheiten
- OrgAdministration.SetTop mit Audit; Werkzeugsperre und Verschiebesperre
- WebUI Vorgaenge: Reiter Spitze, Lesen/Zuruecknehmen, Zaehler; i18n en/de
- Tests: TopRoleTests (15), OrgAdministrationTests (+2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-29 09:27:26 +02:00
co-authored by Claude Opus 5.5
parent e22edb418a
commit e8964e3c74
16 changed files with 1020 additions and 26 deletions
@@ -808,6 +808,17 @@ button.klein {
border-left: 3px solid var(--warnung);
}
/* #690: a withdrawn decision stays readable but steps back */
.fall-zurueckgenommen {
opacity: 0.65;
}
.fall-warnung {
border-left: 3px solid var(--gefahr);
padding: 8px 12px;
background: var(--surface-subtil);
}
.fall-verlauf-titel {
margin: 16px 0 8px;
font-size: 14px;
@@ -923,6 +923,36 @@
"cases.scopeAll": "die ganze Instanz",
"cases.decidedBy": "Entschieden von",
"cases.failed": "Fehlgeschlagen: {error}",
"cases.entry.revoked": "zurückgenommen",
"cases.tabTop": "Spitze",
"cases.atTop": "an der Spitze",
"cases.decideInstead": "Selbst entscheiden",
"cases.topAgentAt": "Entscheidender Agent übernimmt",
"cases.byTopAgent": "entscheidender Agent",
"cases.toRead": "zu lesen",
"cases.revoked": "zurückgenommen",
"cases.revokedReason": "Zurückgenommen",
"cases.markRead": "Als gelesen markieren",
"cases.revoke": "Zurücknehmen",
"cases.revokeTitle": "Beschluss {id} zurücknehmen",
"cases.revokeReason": "Begründung",
"cases.revokePlaceholder": "Warum er nicht mehr gilt",
"cases.revokeHint": "Der Beschluss bleibt mit deiner Begründung in der Sammlung, bindet aber ab jetzt niemanden mehr. Wer den Vorgang eröffnet hat oder Streitpartei war, erfährt es.",
"cases.revokedDone": "Beschluss zurückgenommen.",
"cases.topTitle": "Wer an der Spitze entscheidet",
"cases.topIntro": "Vorgänge, die oben im Baum ankommen — über der letzten Leitung —, werden hier entschieden. Die Spitze ist eine Rolle: ein Mensch, ein entscheidender Agent oder zuerst der Mensch und dahinter der Agent. Ein Vorgang, den bis zur Frist niemand entscheidet, kommt immer zu einem Menschen.",
"cases.topModeLabel": "Entschieden von",
"cases.topMode.Human": "Einem Menschen",
"cases.topMode.Agent": "Einem entscheidenden Agenten",
"cases.topMode.HumanFirst": "Zuerst einem Menschen, dann dem Agenten",
"cases.topAgent": "Entscheidender Agent",
"cases.topUnfit": "passt nicht",
"cases.topHumanFirstHours": "Stunden für den Menschen",
"cases.topReason": "Grund der Änderung",
"cases.topReasonPlaceholder": "Wird im Audit-Log festgehalten",
"cases.topSaved": "Die Spitze ist gesetzt.",
"cases.topProblem": "Der entscheidende Agent übernimmt gerade keine Vorgänge: {problem} Bis das behoben ist, gehen sie an einen Menschen.",
"cases.topRules": "Ein entscheidender Agent steht über allen Einheiten und darf nur Taskboard, Escalation, Memory, FileRW und AgentComm haben — er entscheidet, er führt nicht aus: keine Mail, kein Geld, keine Veröffentlichung, keine Änderung an Agenten oder Rechten. Jede Entscheidung braucht eine Begründung, bindet die ganze Instanz und wird dir hier zum Lesen oder Zurücknehmen vorgelegt. Er macht die Organisation schneller, nicht richtiger.",
"mcp.title": "Externer Zugang (MCP)",
"mcp.hint": "Mit einem persönlichen Token arbeitet dein eigenes Programm, etwa Claude Code, als dieser Agent: mit genau seinen Werkzeugen und Rechten. Jeder Aufruf wird protokolliert, und was eine Freigabe braucht, wird dir wie gewohnt vorgelegt.",
"mcp.label": "Name",
@@ -923,6 +923,36 @@
"cases.scopeAll": "the whole instance",
"cases.decidedBy": "Decided by",
"cases.failed": "Failed: {error}",
"cases.entry.revoked": "withdrawn",
"cases.tabTop": "Top",
"cases.atTop": "at the top",
"cases.decideInstead": "Decide instead",
"cases.topAgentAt": "Deciding agent takes over",
"cases.byTopAgent": "deciding agent",
"cases.toRead": "to read",
"cases.revoked": "withdrawn",
"cases.revokedReason": "Withdrawn",
"cases.markRead": "Mark as read",
"cases.revoke": "Withdraw",
"cases.revokeTitle": "Withdraw decision {id}",
"cases.revokeReason": "Reason",
"cases.revokePlaceholder": "Why it no longer holds",
"cases.revokeHint": "The decision stays in the collection with your reason, but binds nobody from now on. Everyone who raised the case or was a side of it is told.",
"cases.revokedDone": "Decision withdrawn.",
"cases.topTitle": "Who decides at the top",
"cases.topIntro": "Cases that reach the top of the tree — above the last lead — are decided here. The top is a role: a human, a deciding agent, or the human first with the agent behind. A case nobody decides by its deadline always comes to a human.",
"cases.topModeLabel": "Decided by",
"cases.topMode.Human": "A human",
"cases.topMode.Agent": "A deciding agent",
"cases.topMode.HumanFirst": "A human first, then the agent",
"cases.topAgent": "Deciding agent",
"cases.topUnfit": "does not fit",
"cases.topHumanFirstHours": "Hours the human has",
"cases.topReason": "Reason for the change",
"cases.topReasonPlaceholder": "Recorded in the audit log",
"cases.topSaved": "The top is set.",
"cases.topProblem": "The deciding agent takes no cases now: {problem} Until this is fixed, they go to a human.",
"cases.topRules": "A deciding agent stands above all units and may only hold Taskboard, Escalation, Memory, FileRW and AgentComm — it decides, it does not carry out: no mail, no money, no publishing, no changes to agents or rights. Every decision needs reasons, binds the whole instance and is presented to you here to read or withdraw. It makes the organisation faster, not more right.",
"mcp.title": "External access (MCP)",
"mcp.hint": "A personal token lets your own client, such as Claude Code, work as this agent: with exactly its tools and rights. Every call is audited, and what needs approval is put to you as usual.",
"mcp.label": "Name",
@@ -23,14 +23,26 @@ import { laufLink } from "./run-detail.js";
let reiter = "open";
/** Open cases a human holds — for the badge in the sidebar. */
/**
* Open cases a human holds, and decisions of the deciding agent not yet read (#690) —
* for the badge in the sidebar.
*/
export async function holeMenschenfaelle() {
let anzahl = 0;
try {
const liste = await api("/cases");
return Array.isArray(liste) ? liste.filter((c) => !c.holder).length : 0;
} catch {
return 0;
}
if (Array.isArray(liste)) anzahl += liste.filter((c) => !c.holder).length;
} catch { /* the badge is a hint, not a promise */ }
try {
const beschluesse = await api("/decisions");
if (Array.isArray(beschluesse)) anzahl += beschluesse.filter(zuLesen).length;
} catch { /* as above */ }
return anzahl;
}
/** A decision of the deciding agent the human has neither read nor withdrawn. */
function zuLesen(beschluss) {
return beschluss.byTopAgent && !beschluss.reviewedAt && !beschluss.revokedAt;
}
export async function renderCases() {
@@ -44,7 +56,7 @@ export async function renderCases() {
erstelleElement("span", { text: ` ${t("cases.refresh")}` })));
const leiste = erstelleElement("div", { klasse: "reiter-leiste" });
for (const [wert, schluessel] of [["open", "cases.tabOpen"], ["all", "cases.tabAll"], ["decisions", "cases.tabDecisions"]]) {
for (const [wert, schluessel] of [["open", "cases.tabOpen"], ["all", "cases.tabAll"], ["decisions", "cases.tabDecisions"], ["top", "cases.tabTop"]]) {
leiste.append(erstelleElement("button", {
klasse: `reiter-schaltflaeche${wert === reiter ? " aktiv" : ""}`,
text: t(schluessel),
@@ -69,8 +81,15 @@ async function laden() {
bereich.replaceChildren(erstelleElement("div", { klasse: "hinweis-zentriert", text: t("common.loading") }));
try {
if (reiter === "top") {
bereich.replaceChildren(spitzeKarte(await api("/cases/top")));
return;
}
if (reiter === "decisions") {
const liste = await api("/decisions");
// What the deciding agent decided and nobody has read yet comes first.
liste.sort((a, b) => (zuLesen(b) ? 1 : 0) - (zuLesen(a) ? 1 : 0));
bereich.replaceChildren(...(liste.length === 0
? [leer(t("cases.noDecisions"), t("cases.noDecisionsHint"))]
: liste.map(beschlussKarte)));
@@ -102,8 +121,15 @@ function fallKarte(fall) {
erstellePlakette(t(`cases.status.${fall.status}`), offen ? (beimMenschen ? "gefahr" : "neutral") : "erfolg"));
if (ueberfaellig && !beimMenschen) plaketten.append(erstellePlakette(t("cases.overdue"), "gefahr"));
if (fall.humanCalledAt) plaketten.append(erstellePlakette(t("cases.called"), "warnung"));
if (offen && fall.heldByTop) plaketten.append(erstellePlakette(t("cases.atTop"), "warnung"));
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
// #690: The human stands above the deciding agent and can take the case from it.
if (offen && fall.heldByTop) {
aktionen.append(
erstelleElement("button", { klasse: "schaltflaeche flach klein", onclick: () => entscheiden(fall) },
erstelleIcon(PFADE.haken, 14), erstelleElement("span", { text: ` ${t("cases.decideInstead")}` })));
}
if (beimMenschen) {
aktionen.append(
erstelleElement("button", { klasse: "schaltflaeche primaer klein", onclick: () => entscheiden(fall) },
@@ -121,6 +147,7 @@ function fallKarte(fall) {
zeile("cases.openedBy", `${fall.openedBy} · ${formatiereDatumZeit(fall.openedAt)}`);
zeile("cases.holder", fall.holder || t("cases.human"));
zeile("cases.deadline", formatiereDatumZeit(fall.deadlineUtc));
if (offen && fall.topAgentAt) zeile("cases.topAgentAt", formatiereDatumZeit(fall.topAgentAt));
if (fall.level > 0) zeile("cases.level", String(fall.level));
if (fall.parties.length > 0) zeile("cases.parties", fall.parties.join(" · "));
if (fall.informed.length > 0) zeile("cases.informed", fall.informed.join(", "));
@@ -157,8 +184,115 @@ function beschlussKarte(beschluss) {
zeile("cases.scope", beschluss.scopePath || t("cases.scopeAll"));
zeile("cases.decidedBy", `${beschluss.decidedBy === "human" ? t("cases.humanAuthor") : beschluss.decidedBy} · ${formatiereDatumZeit(beschluss.decidedAt)}`);
zeile("cases.id", `${beschluss.id} · ${beschluss.caseId}`);
if (beschluss.revokedAt) zeile("cases.revokedReason", `${beschluss.revokedReason ?? ""} · ${formatiereDatumZeit(beschluss.revokedAt)}`);
return erstelleKarte(beschluss.subject, [daten], { klasse: "fall-karte" });
// #690: What the deciding agent decided is presented to the human to read.
const plaketten = erstelleElement("div", { klasse: "gruppe-horizontal fall-plaketten" });
if (beschluss.byTopAgent) plaketten.append(erstellePlakette(t("cases.byTopAgent"), "neutral"));
if (zuLesen(beschluss)) plaketten.append(erstellePlakette(t("cases.toRead"), "warnung"));
if (beschluss.revokedAt) plaketten.append(erstellePlakette(t("cases.revoked"), "gefahr"));
const aktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
if (zuLesen(beschluss)) {
aktionen.append(erstelleElement("button", { klasse: "schaltflaeche primaer klein", onclick: () => gelesen(beschluss) },
erstelleIcon(PFADE.haken, 14), erstelleElement("span", { text: ` ${t("cases.markRead")}` })));
}
if (!beschluss.revokedAt) {
aktionen.append(erstelleElement("button", { klasse: "schaltflaeche flach klein", onclick: () => zuruecknehmen(beschluss) },
erstelleElement("span", { text: t("cases.revoke") })));
}
return erstelleKarte(beschluss.subject, [plaketten, daten], {
aktionen,
klasse: zuLesen(beschluss) ? "fall-karte fall-beim-menschen" : beschluss.revokedAt ? "fall-karte fall-zurueckgenommen" : "fall-karte"
});
}
async function gelesen(beschluss) {
try {
await api(`/decisions/${encodeURIComponent(beschluss.id)}/review`, { method: "POST" });
await laden();
} catch (err) {
zeigeMeldung(t("cases.failed", { error: err.message }), "gefahr");
}
}
function zuruecknehmen(beschluss) {
const grund = textfeld(t("cases.revokePlaceholder"));
const formular = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("cases.revokeReason") }), grund,
erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.revokeHint") }));
absenden(t("cases.revokeTitle", { id: beschluss.id }), formular, t("cases.revoke"), async () => {
await api(`/decisions/${encodeURIComponent(beschluss.id)}/revoke`, { method: "POST", body: { reason: grund.value.trim() } });
zeigeMeldung(t("cases.revokedDone"), "info");
});
}
// ─── the top (#690) ───
/** Who decides at the top: a human, an agent, or the human first with the agent behind. */
function spitzeKarte(spitze) {
const modus = erstelleElement("select", {});
for (const wert of ["Human", "Agent", "HumanFirst"])
modus.append(erstelleElement("option", { value: wert, text: t(`cases.topMode.${wert}`) }));
modus.value = spitze.mode;
const agent = erstelleElement("select", {});
agent.append(erstelleElement("option", { value: "", text: "—" }));
for (const k of spitze.candidates) {
const name = k.displayName && k.displayName !== k.agentId ? `${k.agentId} (${k.displayName})` : k.agentId;
// An agent that does not fit is shown, not hidden — the reason says what to change.
agent.append(erstelleElement("option", {
value: k.agentId,
text: k.unfit ? `${name} — ${t("cases.topUnfit")}` : name,
title: k.unfit ?? "",
disabled: Boolean(k.unfit) && k.agentId !== spitze.agentId
}));
}
agent.value = spitze.agentId ?? "";
const stunden = erstelleElement("input", { type: "number", min: "0.25", step: "0.25", value: String(spitze.humanFirstHours) });
const grund = erstelleElement("input", { type: "text", placeholder: t("cases.topReasonPlaceholder") });
const agentZeile = [erstelleElement("label", { text: t("cases.topAgent") }), agent];
const stundenZeile = [erstelleElement("label", { text: t("cases.topHumanFirstHours") }), stunden];
const sichtbarkeit = () => {
for (const el of agentZeile) el.hidden = modus.value === "Human";
for (const el of stundenZeile) el.hidden = modus.value !== "HumanFirst";
};
modus.addEventListener("change", sichtbarkeit);
sichtbarkeit();
const speichern = erstelleElement("button", { type: "submit", klasse: "schaltflaeche primaer", text: t("common.save") });
const formular = erstelleElement("form", { klasse: "formular-raster" },
erstelleElement("label", { text: t("cases.topModeLabel") }), modus,
...agentZeile,
...stundenZeile,
erstelleElement("label", { text: t("cases.topReason") }), grund,
erstelleElement("div", {}, speichern));
formular.addEventListener("submit", async (e) => {
e.preventDefault();
speichern.disabled = true;
try {
await api("/cases/top", {
method: "PUT",
body: { mode: modus.value, agentId: agent.value || null, humanFirstHours: Number(stunden.value) || null, reason: grund.value.trim() }
});
zeigeMeldung(t("cases.topSaved"), "info");
await laden();
} catch (err) {
zeigeMeldung(t("cases.failed", { error: err.message }), "gefahr");
} finally {
speichern.disabled = false;
}
});
const inhalt = [erstelleElement("p", { klasse: "gedaempft", text: t("cases.topIntro") })];
if (spitze.problem) inhalt.push(erstelleElement("p", { klasse: "fall-warnung", text: t("cases.topProblem", { problem: spitze.problem }) }));
inhalt.push(formular, erstelleElement("p", { klasse: "gedaempft text-klein", text: t("cases.topRules") }));
return erstelleKarte(t("cases.topTitle"), inhalt, { klasse: "fall-karte" });
}
// ─── the human's two answers ───