feat(orchestrator): tasks tab with register, inbox and queue

Add a Tasks tab that lists the kvb-org-rasci register next to the inbox
and the heavy-job queue, in list, board and RASCI matrix views, each
task with its roles, WSJF, open work and the merge flow of its worktree.

- Task priority pins and WSJF estimates are signed with the operator's
  passkey and applied by the runner through rasci.py; the hub verifies
  the signature itself before it queues a job or logs the change, the
  runner verifies it again and runs only the checked argv, from /, with
  a pinned PATH and a 30 s process-group limit.
- The runner refuses task changes into a council that is a symlink, not
  owned by its uid or given by a relative path, and while the installed
  council.py, nofollow.py or rasci.py differ from the tree.
- Only tasks the hub can sign whole are editable (task slug and length).
- Page lookups keyed by register text use Object.hasOwn, so a status or
  party named like an Object.prototype member cannot break the views.
- The inbox screen renders through the tasks screen, so its task filter
  shows with "Wartet auf Operator" pressed.

Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox).

🎯 Fix: 93 %
💥 Break: 5 %
💸 Cost: 100 %
⏱️ Time: –
🛡️ Security: 100 %
🏆 Score: 88
🚦 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 23:43:36 +00:00
1 parent 01f791a1f4
commit c1310c3322
37 files changed
+1436 -80

No files matched your search

+8
View File
@@ -407,6 +407,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "style-inbox.css" %}
{% include "style-ui.css" %}
{% include "style-tasks.css" %}
</style>
</head>
<body>
@@ -506,10 +508,16 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-tree.js" %}
{% include "page-flow.js" %}
{% include "page-cleanup.js" %}
{% include "page-offices.js" %}
{% include "page-tasks.js" %}
{% include "page-tasks-prio.js" %}
{% include "page-time.js" %}
{% include "page-pulses.js" %}
+6 -4
View File
@@ -1,15 +1,17 @@
const Nav = (() => {
"use strict";
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const TASK_VIEWS = [["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]];
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
const ICONS = {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git",
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git", tasks: "bi-list-check",
orbit: "bi-globe2", chord: "bi-bezier2", swimlane: "bi-bar-chart-steps", tree: "bi-diagram-3", "3d": "bi-badge-3d", hierarchy: "bi-diagram-2",
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])];
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
function tabOf(screen) {
@@ -36,7 +38,7 @@ const Nav = (() => {
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
return {TABS, SUBS, VIEWS, TASK_VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
+15 -3
View File
@@ -49,6 +49,9 @@
for (const overlap of repo.overlaps || []) {
links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count});
}
for (const edge of flowEdges(repo)) {
links.push({source: `branch:${repo.path}\u0000${edge.from}`, target: `branch:${repo.path}\u0000${edge.into}`, kind: "flow", side: edge.side});
}
}
for (const task of data.tasks || []) {
const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""});
@@ -113,7 +116,7 @@
const holder = model.byId.get(`wt:${wt.path}`);
const leaves = model.nodes.filter((entry) => entry.parent === holder.id);
leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null)));
layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null));
layer.append(artMark(holder, moon, 7, [wt.state === "changed" ? "dirty" : "", wt.open_work.length ? "open" : ""].filter(Boolean).join(" "), null));
});
layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null));
});
@@ -148,6 +151,13 @@
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
}
}
for (const edge of flowEdges(repo)) {
const from = spots.get(edge.from);
const to = spots.get(edge.into);
const bend = edge.side === "pull" ? 0.55 : 0.15;
arcs.append(s("path", {class: "edge t-direct flow", "data-side": edge.side, d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed((from.x + to.x) * bend)},${fixed((from.y + to.y) * bend)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-direct)"},
s("title", {}, `${edge.from} → ${edge.into}: Merge-Flow, ${edge.side === "pull" ? `${edge.into} zieht ${edge.ref} vor dem Gate` : `${edge.from} mergt fertig in ${edge.ref}`}`)));
}
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
const labels = branches.map((branch) => {
const at = spots.get(branch.name);
@@ -192,7 +202,7 @@
function artLegend(view) {
const words = {
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation",
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation · gepunktet = Merge-Flow des Worktrees",
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
};
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
@@ -212,9 +222,11 @@
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
} else if (node.kind === "branch") {
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
data.worktrees.map((wt) => flowDetails(wt.flow, data.name)),
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") {
body.push(h("div", {class: "states"}, worktreeStates(data)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
flowDetails(data.flow, data.branch),
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
} else if (node.kind === "agent") {
+1 -1
View File
@@ -60,7 +60,7 @@
direct: [[740, 0, 0.2]],
radio: [[520, 0, 0.07]],
};
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
+68
View File
@@ -0,0 +1,68 @@
const WORK_CHIPS = {dirty: ["bi-pencil-square", "nicht committet", "warn"], unpushed: ["bi-cloud-upload", "ungepusht", "info"], unmerged: ["bi-signpost-split", "nicht gemergt", "neutral"], task: ["bi-card-checklist", "Aufgabe offen", "violet"]};
const FLOW_SIDES = {pull: "zieht vor dem Gate", push: "mergt fertig in"};
function workChips(reasons) {
if (!reasons || !reasons.length) {
return null;
}
return h("span", {class: "states work", role: "group", "aria-label": "Offene Arbeit"}, reasons.map((reason) => {
const [name, label, tone] = Object.hasOwn(WORK_CHIPS, reason) ? WORK_CHIPS[reason] : ["bi-question-circle", reason, "neutral"];
return h("span", {class: "state", "data-tone": tone, title: `offene Arbeit: ${label}`}, icon(name), label);
}));
}
function flowLine(flow, branch) {
if (!flow) {
return null;
}
if (flow.source === "invalid") {
return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge-Flow ungültig: pkgmgr kann die Flow-Datei nicht lesen");
}
const refs = (side) => (flow[side].length ? flow[side].map((ref) => h("code", {class: "flow-ref", title: `${FLOW_SIDES[side]}: ${ref}`}, ref)) : h("span", {class: "muted"}, "–"));
const arrow = () => h("span", {class: "flow-arrow", ...named("fließt nach")}, icon("bi-arrow-right"));
return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge-Flow: ${flow.pull.join(", ") || "keine Quelle"} nach ${branch || "–"} nach ${flow.push.join(", ") || "kein Ziel"}`},
h("span", {class: "flow-label"}, icon("bi-sign-merge-right"), "Flow"),
h("span", {class: "flow-side"}, refs("pull")), arrow(),
h("code", {class: "flow-ref here", title: "dieser Branch"}, branch || "–"), arrow(),
h("span", {class: "flow-side"}, refs("push")));
}
function flowDetails(flow, branch) {
if (!flow) {
return h("p", {class: "muted"}, "Kein Merge-Flow gesetzt.");
}
if (flow.source === "invalid") {
return flowLine(flow, branch);
}
return [h("h3", {}, "Merge-Flow"), flowLine(flow, branch), meta([["Zieht vor dem Gate", flow.pull.join(", ")], ["Mergt fertig in", flow.push.join(", ")], ["Quelle", flow.source]])];
}
function flowTarget(names, ref) {
if (names.has(ref)) {
return ref;
}
const bare = ref.includes("/") ? ref.slice(ref.indexOf("/") + 1) : "";
return names.has(bare) ? bare : null;
}
function flowEdges(repo) {
const names = new Set(repo.branches.map((entry) => entry.name));
const edges = [];
for (const branch of repo.branches) {
for (const flow of branch.worktrees.map((wt) => wt.flow).filter((found) => found && found.source !== "invalid")) {
for (const ref of flow.pull) {
const from = flowTarget(names, ref);
if (from && from !== branch.name) {
edges.push({from, into: branch.name, side: "pull", ref});
}
}
for (const ref of flow.push) {
const into = flowTarget(names, ref);
if (into && into !== branch.name) {
edges.push({from: branch.name, into, side: "push", ref});
}
}
}
}
return edges;
}
+1 -1
View File
@@ -54,7 +54,7 @@
const inInbox = ui.screen === "inbox";
const current = inInbox ? visibleInbox()[ui.cursor] : null;
const index = Number(event.key) - 1;
if (/^[1-3]$/.test(event.key)) {
if (/^[1-9]$/.test(event.key) && index < Nav.TABS.length) {
event.preventDefault();
go(Nav.TABS[index][0], null);
} else if (inInbox && (event.key === "j" || event.key === "k")) {
+1 -1
View File
@@ -1,7 +1,7 @@
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.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...["agents", "artifacts", "tasks"].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)})),
+4 -2
View File
@@ -44,11 +44,11 @@
if (ui.screen === "agents") {
return JSON.stringify([ui.views.agents]);
}
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
return JSON.stringify(ui.screen === "artifacts" || ui.screen === "tasks" ? [ui.views[ui.screen]] : ui.screen === "rules" ? state.rules : []);
}
function screenOf() {
return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen]();
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen]();
}
function render(force) {
@@ -66,6 +66,7 @@
radioDom = null;
inboxDom = null;
inboxSig = "";
taskView.dom = null;
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren(screenOf());
@@ -82,6 +83,7 @@
drawToolbar();
drawInbox();
drawRadio();
drawTasks();
drawStatus();
tick();
}
+1 -1
View File
@@ -27,7 +27,7 @@
let state = JSON.parse(document.getElementById("state").textContent);
const KINDS = Object.entries(state.kinds);
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
+104
View File
@@ -0,0 +1,104 @@
const FIBONACCI = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
const PRIO_MAX = 999;
const ESTIMATOR = HUMAN;
const shownArgv = (argv) => ["rasci.py", ...argv].join(" ");
const prioText = (text) => (/^[1-9][0-9]{0,2}$/.test(text.trim()) ? String(Number(text.trim())) : "");
function fibonacciPicker(label, picked) {
return h("select", {class: "field", "aria-label": label}, FIBONACCI.map((value) => h("option", {value, selected: value === picked}, String(value))));
}
async function changeTask(row, argv, label, outcome) {
const items = [{kind: "task", id: row.task, argv}];
outcome.replaceChildren(h("p", {class: "muted"}, "Signiere mit dem Passkey …"));
const signed = await proofFor(items, label);
if (signed === null) {
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
return;
}
const result = await sendJson("/api/tasks", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast(`${label}: nicht möglich`, fail(result), false);
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
return;
}
outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signiert und an den Runner übergeben (Job ${result.data.jobs.join(", ")}); er ruft rasci.py auf dem Host auf.`));
toast(`${label}: an den Runner übergeben`, [shownArgv(argv)], true);
await load();
}
function priorityForm(row) {
const outcome = h("div", {class: "mini", "aria-live": "polite"});
const own = row.estimates[ESTIMATOR] || null;
const prio = h("input", {type: "number", class: "field", min: 1, max: PRIO_MAX, step: 1, value: row.prio === null ? "" : row.prio, "aria-label": "Priorität", placeholder: "1"});
const value = fibonacciPicker("Geschäftswert", own ? own.value : 5);
const complexity = fibonacciPicker("Komplexität", own ? own.complexity : 3);
const pinCode = h("code", {});
const estimateCode = h("code", {});
const prioArgv = () => ["set", row.task, "--prio", prioText(prio.value)];
const estimateArgv = () => ["estimate", row.task, ESTIMATOR, "--value", value.value, "--complexity", complexity.value];
const pin = h("button", {type: "button", class: "btn small primary", ...named("Anheften", "Anheften: Priorität mit dem Passkey signieren")}, icon("bi-pin-angle-fill"), "Anheften");
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Schätzung speichern", "Schätzung signiert an den Runner geben")}, icon("bi-calculator"), "Schätzung speichern");
const sync = () => {
pin.disabled = !prioText(prio.value);
pinCode.textContent = pin.disabled ? `Priorität 1 bis ${PRIO_MAX} eingeben` : shownArgv(prioArgv());
estimateCode.textContent = shownArgv(estimateArgv());
};
for (const field of [prio, value, complexity]) {
field.addEventListener("input", sync);
}
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priorität ${row.task}`, outcome));
estimate.addEventListener("click", () => changeTask(row, estimateArgv(), `WSJF ${row.task}`, outcome));
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Schätzung verwerfen", "Schätzung verwerfen: deine Schätzung mit dem Passkey signiert löschen"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Schätzung verwerfen") : null;
sync();
return h("section", {class: "task-edit", "aria-label": "Priorität und WSJF"},
h("h3", {}, "Priorität anheften"),
h("div", {class: "task-form"}, h("label", {}, "Priorität (1 zuerst)", prio), pin),
h("div", {class: "command"}, pinCode),
h("h3", {}, `WSJF schätzen als ${ESTIMATOR}`),
h("div", {class: "task-form"}, h("label", {}, "Geschäftswert", value), h("label", {}, "Komplexität", complexity), estimate, drop),
h("div", {class: "command"}, estimateCode),
h("p", {class: "muted"}, "Dein Passkey signiert genau diesen Aufruf; der Runner auf dem Host prüft die Signatur und die Form der Argumente und startet rasci.py ohne Shell. Die Priorität der Agenten bleibt unberührt."),
onLocalhost() ? null : h("p", {class: "muted"}, "Signieren geht nur unter ", localLink()),
outcome);
}
function taskRoles(row) {
const entries = Object.entries(row.roles);
return [h("h3", {}, "RASCI", counter(entries.length)),
entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "Keine Rollen eingetragen.")];
}
function taskEstimates(row) {
const entries = Object.entries(row.estimates);
const score = row.wsjf;
return [h("h3", {}, "WSJF-Schätzungen", counter(entries.length)),
entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Geschäftswert ${entry.value} · Komplexität ${entry.complexity}`))))) : h("p", {class: "muted"}, "Noch keine Schätzung: die Aufgabe rangiert nach den geschätzten."),
score ? h("p", {class: "muted"}, `Median ${score.value} / ${score.complexity} = WSJF ${score.score}`) : null];
}
function taskJobs(row) {
const jobs = state.jobs.filter((job) => job.kind === "task" && job.task === row.task).slice(-5).reverse();
return jobs.length ? [h("h3", {}, "Signierte Änderungen", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status},
h("div", {class: "row"}, h("code", {}, job.command), status(job.status)),
job.status === "refused" || job.status === "failed" ? h("pre", {class: "notes"}, job.output) : null)))] : null;
}
function taskSheet(id) {
const row = state.tasks.find((entry) => entry.task === id);
if (!row) {
return;
}
const facts = [["Ziel", row.objective], ["Key Results", row.total ? `${row.met} von ${row.total} erfüllt` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]];
openSheet(row.task, [
h("div", {class: "who"}, taskBadges(row, true), row.worker !== "-" ? [face(row.worker, false), person(row.worker)] : null),
h("p", {class: "text"}, titleOf(row)),
meta(row.queue ? [["Queue", `${row.queue.kind} für ${row.queue.parent}`], ...facts.slice(2)] : facts),
workChips(row.open_work),
flowDetails(row.flow, branchOf(row)),
taskRoles(row),
row.kind === "task" ? taskEstimates(row) : null,
row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Beendet: Priorität und WSJF bleiben, wie sie sind." : "Priorität und WSJF gibt es nur für Aufgaben des Registers, nicht für Befehle, Hotfixes und Queue-Jobs."),
taskJobs(row),
], false);
}
+177
View File
@@ -0,0 +1,177 @@
const TASK_STATUS = {queued: ["wartet", "info"], running: ["läuft", "ok"], blocked: ["blockiert", "bad"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], awaiting: ["wartet auf Freigabe", "warn"]};
const TASK_COLUMNS = [["queued", "Wartet"], ["running", "Läuft"], ["blocked", "Blockiert"], ["done", "Erledigt"], ["stopped", "Gestoppt"]];
const TASK_KINDS = {command: ["Befehl", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue-Job", "teal"]};
const TASK_FILTERS = [
["alle", "Alle", "bi-list-check", () => true],
["offen", "Offen", "bi-play-circle", (row) => !row.ended],
["arbeit", "Offene Arbeit", "bi-pencil-square", (row) => row.open_work.length > 0],
["operator", "Wartet auf Operator", "bi-person-raised-hand", null],
["freigabe", "Wartet auf Freigabe", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
["beendet", "Beendet", "bi-check2-circle", (row) => row.ended],
];
const ROLE_TONES = {R: "ok", A: "violet", S: "info", C: "warn", I: "neutral"};
const ROLE_WORDS = {R: "Responsible", A: "Accountable", S: "Supportive", C: "Consulted", I: "Informed"};
const MATRIX_PARTIES = 40;
const taskView = {filter: "alle", q: "", dom: null, sig: ""};
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
const titleOf = (row) => (row.title !== "-" ? row.title : row.task);
const openJobs = (task) => state.jobs.filter((job) => job.kind === "task" && job.task === task && (job.status === "queued" || job.status === "running"));
function taskStatus(row) {
const [label, tone] = Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status] : [row.status, "neutral"];
return pill(label, tone);
}
function taskBadges(row, withStatus) {
const [kindName, kindTone] = Object.hasOwn(TASK_KINDS, row.kind) ? TASK_KINDS[row.kind] : [null, null];
const score = row.wsjf;
return [
kindName ? pill(kindName, kindTone) : null,
withStatus ? taskStatus(row) : null,
row.total ? pill(`KR ${row.met}/${row.total}`, row.met === row.total ? "ok" : "info") : null,
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "angeheftete Priorität"}, `Prio ${row.prio}`) : null,
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: Geschäftswert ${score.value} / Komplexität ${score.complexity}`}, `WSJF ${score.score}`) : null,
openJobs(row.task).length ? pill("Änderung beim Runner", "warn") : null,
];
}
function roleSummary(row) {
const holders = (letter) => Object.entries(row.roles).filter(([, letters]) => letters.includes(letter)).map(([party]) => party);
return ["R", "A"].map((letter) => (holders(letter).length ? h("span", {title: ROLE_WORDS[letter]}, `${letter}: ${holders(letter).join(", ")}`) : null));
}
function taskPlace(row) {
if (row.kind === "queue") {
return h("span", {}, `${row.queue.kind} für ${row.queue.parent} · eingereiht `, when(row.queue.enqueued));
}
const place = row.worktree || (row.workdir !== "-" ? row.workdir : "");
return place ? h("code", {title: row.repo || place}, place) : null;
}
function opener(row) {
return h("button", {type: "button", class: "linkish", title: `${row.task} öffnen`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
}
function taskItem(row) {
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
return h("li", {class: classes, "data-task": row.task, "data-kind": row.kind, "data-status": row.status},
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
row.editable ? iconBtn("bi-sort-numeric-down", "Priorität", "btn small soft", () => taskSheet(row.task), `Priorität und WSJF von ${row.task}: anheften oder schätzen, mit Passkey signiert`) : null),
opener(row),
row.objective !== "-" ? h("div", {class: "muted"}, `Ziel: ${row.objective}`) : null,
h("div", {class: "task-meta"}, row.worker !== "-" ? h("span", {class: "who"}, face(row.worker, false), person(row.worker)) : null, roleSummary(row), taskPlace(row)),
flowLine(row.flow, branchOf(row)),
workChips(row.open_work));
}
function taskCard(row) {
const classes = ["task-card", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
return h("li", {class: classes, "data-task": row.task},
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task)),
opener(row),
h("div", {class: "states"}, taskBadges(row, row.status === "awaiting")),
row.worker !== "-" ? h("span", {class: "muted"}, row.worker) : null,
row.flow ? flowLine(row.flow, branchOf(row)) : null,
workChips(row.open_work));
}
function taskBoard(rows) {
return h("div", {class: "task-board"}, TASK_COLUMNS.map(([id, label]) => {
const cards = rows.filter((row) => columnOf(row) === id);
return h("section", {class: "card task-column", "data-column": id, "aria-label": label},
h("h3", {class: "group-title"}, label, counter(cards.length)),
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "leer"));
}));
}
function roleLetters(letters) {
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
}
function taskMatrix(rows) {
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "de"));
if (!parties.length) {
return emptyState("Keine Rollen", "Keine der gezeigten Aufgaben nennt R, A, S, C oder I.");
}
const columns = parties.slice(0, MATRIX_PARTIES);
const legend = h("div", {class: "legend"}, Object.entries(ROLE_WORDS).map(([letter, word]) => h("span", {}, roleLetters(letter), word)),
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} weitere Parteien nicht gezeigt`) : null);
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Aufgabe"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
const line = (row) => h("tr", {"data-task": row.task, class: row.open_work.length ? "open-work" : null},
h("th", {scope: "row"}, h("div", {class: "matrix-task"}, h("code", {class: "task-id"}, row.task), opener(row))),
columns.map((party) => {
const letters = Object.hasOwn(row.roles, party) ? row.roles[party] : "";
return h("td", {title: letters ? `${party}: ${letters}` : null}, roleLetters(letters));
}));
const table = h("table", {class: "matrix", "aria-label": "RASCI-Matrix: Aufgaben mal Parteien"}, head, h("tbody", {}, rows.map(line)));
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
}
function chooseTaskFilter(id) {
if (id === "operator") {
go("inbox", null);
return;
}
taskView.filter = id;
if (ui.screen === "inbox") {
go("tasks", null);
} else {
drawTasks();
}
}
function taskChips(rows, inbox) {
return TASK_FILTERS.map(([id, label, name, test]) => {
const total = test ? rows.filter(test).length : waitingCount();
return h("button", {type: "button", class: "chip preset", "data-filter": id, "aria-pressed": String(inbox ? id === "operator" : id === taskView.filter), title: `${label}: ${total}`, on: {click: () => chooseTaskFilter(id)}},
icon(name), label, counter(total));
});
}
function taskViewSwitch(inbox) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(!inbox && ui.views.tasks === id), on: {click: () => go("tasks", id)},
}, icon(Nav.ICONS[id]))));
}
function tasksScreen() {
const inbox = ui.screen === "inbox";
const view = ui.views.tasks;
const chips = h("div", {class: "chips", role: "group", "aria-label": "Aufgabenfilter"});
const count = h("span", {class: "muted"});
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Aufgabe, Titel, Worker, Branch filtern …", value: taskView.q, "aria-label": "Aufgaben filtern", on: {input: (event) => {
taskView.q = event.target.value;
drawTasks();
}}});
const body = inbox ? inboxScreen() : h("div", {class: "tasks-body"});
taskView.dom = {chips, count, body, inbox, view};
taskView.sig = "";
return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), chips, body);
}
function drawTasks() {
const dom = taskView.dom;
if (!dom) {
return;
}
const q = taskView.q.trim().toLowerCase();
const sig = JSON.stringify([state.tasks, state.jobs, taskView.filter, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
if (sig === taskView.sig) {
return;
}
taskView.sig = sig;
const rows = state.tasks;
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
if (dom.inbox) {
dom.count.textContent = `${rows.length} Aufgaben im Register`;
return;
}
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
const hit = (row) => !q || [row.task, row.title, row.worker, row.branch, row.workdir, row.objective].some((text) => String(text).toLowerCase().includes(q));
const shown = rows.filter(test).filter(hit);
dom.count.textContent = `${shown.length} von ${rows.length} Aufgaben`;
const views = {list: (found) => h("ol", {class: "task-list"}, found.map(taskItem)), board: taskBoard, rasci: taskMatrix};
dom.body.replaceChildren(shown.length ? views[dom.view](shown) : emptyState("Keine Aufgabe", "Kein Eintrag passt zu diesem Filter."));
}
+10 -3
View File
@@ -50,16 +50,21 @@
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null);
}
function openWork(wt) {
return wt.open_work.length ? chip("bi-lightning-charge", `offene Arbeit: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null;
}
function worktreeRow(wt) {
const review = wt.review;
return h("div", {class: "wt"},
return h("div", {class: wt.open_work.length ? "wt open-work" : "wt", "data-open-work": wt.open_work.join(" ") || null},
h("div", {class: "wt-head"},
h("span", {class: "states"}, worktreeStates(wt)),
h("span", {class: "states"}, worktreeStates(wt), openWork(wt)),
h("code", {}, wt.path),
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
copyButton(wt.path)),
flowLine(wt.flow, wt.branch),
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
}
@@ -114,9 +119,11 @@
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
const dirty = trees.filter((wt) => wt.dirty).length;
const merged = repo.branches.filter((entry) => entry.cleanup).length;
const busy = trees.filter((wt) => wt.open_work.length).length;
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null),
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null,
busy ? chip("bi-lightning-charge", `${busy} Worktrees mit offener Arbeit`, "warn", busy) : null),
branches.map((entry) => branchRow(repo, entry)),
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
details.addEventListener("toggle", () => {
+44
View File
@@ -0,0 +1,44 @@
.graph.tasks { grid-template-columns: minmax(0, 1fr); }
.tasks-body { min-width: 0; }
.tasks .controls input[type="search"] { flex: 1; min-width: min(240px, 100%); }
.tasks > .chips { margin-bottom: 0; }
.chip .count { margin-left: 2px; }
.task-list, .task-cards { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
.task-row { display: grid; gap: 6px; min-width: 0; padding: var(--sp-3); border: 1px solid var(--line); border-radius: 12px; background: var(--glass-card); }
.task-row .head-row { margin-bottom: 0; }
.task-row > .linkish { justify-self: start; font-size: var(--fs-lg); text-align: left; }
.task-row.open-work, .task-card.open-work, .wt.open-work, .matrix tr.open-work > th { box-shadow: inset 3px 0 0 var(--warn); }
.task-row.ended, .task-card.ended { opacity: .72; }
.task-id { font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; }
.task-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; min-width: 0; color: var(--muted); font-size: var(--fs-sm); }
.task-meta code { overflow-wrap: anywhere; }
.task-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 12px; align-items: start; }
.task-column { display: grid; gap: 8px; min-width: 0; padding: var(--sp-3); }
.task-card { display: grid; gap: 4px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); }
.task-card .linkish { overflow-wrap: anywhere; }
.flow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; font-size: var(--fs-sm); }
.flow-label { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-weight: 650; }
.flow-side { display: inline-flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.flow-ref { padding: 0 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--sunken); line-height: 20px; overflow-wrap: anywhere; }
.flow-ref.here { border-color: transparent; background: var(--accent-soft); color: var(--accent); font-weight: 650; }
.flow-arrow { display: inline-flex; color: var(--muted); }
.flow.invalid { gap: 6px; color: var(--bad); }
.states.work .state { gap: 5px; }
.matrix-box { display: grid; gap: 8px; min-width: 0; }
.matrix-scroll { max-width: 100%; max-height: 70vh; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
table.matrix { border-collapse: separate; border-spacing: 0; font-size: var(--fs-sm); }
table.matrix th, table.matrix td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: center; vertical-align: middle; white-space: nowrap; }
table.matrix thead th { position: sticky; top: 0; z-index: 1; background: var(--panel); vertical-align: bottom; }
table.matrix th[scope="row"], table.matrix thead th:first-child { position: sticky; left: 0; background: var(--panel); text-align: left; }
table.matrix thead th:first-child { z-index: 2; }
table.matrix th[scope="row"] { z-index: 1; min-width: min(240px, 52vw); max-width: 320px; white-space: normal; }
.matrix-task { display: grid; gap: 2px; }
.matrix .party { display: inline-block; max-height: 150px; overflow: hidden; text-overflow: ellipsis; writing-mode: vertical-rl; transform: rotate(180deg); }
.matrix td .letter + .letter { margin-left: 2px; }
.task-edit { display: grid; gap: 8px; }
.task-edit h3 { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.task-form { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.task-form label { display: grid; gap: 4px; color: var(--muted); font-size: var(--fs-sm); }
.task-form input[type="number"] { width: 120px; }
svg .node.a-worktree.open circle { stroke: var(--warn); stroke-width: 2.6; stroke-dasharray: 3 2; }
svg .edge.flow { stroke-dasharray: 2 5; }