feat(orchestrator): W2 Kanban board with swimlanes and forecast

Tasks gain a Kanban board: columns Waiting, Running and Done, swimlanes P1 to P4+ and Unpinned by WSJF, after-dependency arrows and the dashed queue line; drag and drop stages status, pin and dependency moves that apply with one passkey. The burn chart gets the trend and a forecast. Committed below the bar on the operator's word 2026-11-12 (no further per-bundle tests, one gate after the merge): the W2 e2e never ran.

🎯 Fix: 80 %
💥 Break: 10 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: 100 %
🏆 Score: 72
🚦 Gate: ⛔ fix 80 % < 90, break 10 % > 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-12 16:14:36 +00:00
1 parent fd8fc08603
commit 8e63b21e21
18 files changed
+1375 -76

No files matched your search

+2 -23
View File
@@ -1,5 +1,5 @@
const TASK_STATUS = {queued: ["waiting", "info"], running: ["running", "ok"], blocked: ["blocked", "bad"], done: ["done", "neutral"], stopped: ["stopped", "neutral"], awaiting: ["waiting for a grant", "warn"]};
const TASK_COLUMNS = [["queued", "Waiting"], ["running", "Running"], ["blocked", "Blocked"], ["done", "Done"], ["stopped", "Stopped"]];
const TASK_COLUMNS = Kanban.COLUMNS;
const TASK_KINDS = {command: ["Command", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue job", "teal"]};
const TASK_FILTERS = [
["all", "All", "bi-list-check", () => true],
@@ -18,7 +18,6 @@
false: "ETA: the end of the ended task",
};
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"));
@@ -80,26 +79,6 @@
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"}, "empty"));
}));
}
function roleLetters(letters) {
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
}
@@ -239,7 +218,7 @@
const etas = etaOf();
return h("ol", {class: "task-list"}, Plan.sortRows(found, taskView.sort, taskView.descending).map((row) => taskItem(row, etas.get(row.task))));
},
board: taskBoard,
board: kanbanBoard,
rasci: taskMatrix,
gantt: ganttChart,
burn: (found) => burnChart(rows, found),