Requirement 001 phase 6, S5: - Tasks tab: every pending ask, rule request, operator row and choice card shows under "waits on Operator"; a choice request goes out on a fixed channel button, post and call with an agent multi-select, and a click on an option sends a passkey-signed answer the agent checks with operator_message. The unsigned /api/decisions/<id>/choose route is gone, and a decision, choice answer or choice recipient whose name holds "*" is refused, so a signed answer never reaches every agent. - Gantt view: one bar per task from start to end (running to now), one arrow per explicit, branch-base and queue-order edge, hatched forecast bars (always, points only, real only) from the median duration or points times median hours per point. - rasci.py set --after/--drop-after stores task dependencies and refuses unknown tasks and cycles. - Estimation toggle (No estimate default, Story points) is stored in the hub settings; burndown and burnup per whole register, list filter or one agent or line, counting tasks or summing complexity with the unestimated tasks as their own line. - New skill kvb-org-queue-estimate: "Request estimates" (story points mode, passkey-signed board action) or the skill sends exactly one newsletter listing every unestimated task with its RASCI parties; a party name that is no plain name shows as "unnamed party". - Business value per task or in bulk is stored only with a verified passkey assertion over the exact ids and values; WSJF uses it instead of the median. The task list sorts by every decision-50 mode both ways. - e2e: the walk's pending ask lives an hour instead of 60 s, and the CDP and BiDi drivers retry removing the browser profile while the browser exits (ENOTEMPTY). Evidence: make test 709 OK, ruff and markdownlint clean (/tmp/s5-make-test-fix-2.log); e2e 26 OK in Chromium and Firefox (/tmp/s5-e2e-fix-2.log); MEDUSA 50 files, an independent skeptic rejected four holes (unsigned choose route, unsigned estimates request, free-text party names, "*" recipients incl. normalizer bypasses), all fixed and re-verified, the 8 remaining pattern hits accepted, rescan 0 open. 🎯 Fix: 92 % 💥 Break: 4 % 💸 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>
73 lines
3.2 KiB
JavaScript
73 lines
3.2 KiB
JavaScript
const CHOICE_ICONS = {post: "bi-envelope", call: "bi-telephone", newsletter: "bi-newspaper", broadcast: "bi-broadcast", "pan-pan": "bi-exclamation-triangle", security: "bi-shield-exclamation", mayday: "bi-life-preserver"};
|
|
const choiceAsk = {to: new Set(), dom: null, names: ""};
|
|
|
|
async function requestChoices(cls) {
|
|
const direct = TO_ONE.some(([id]) => id === cls);
|
|
const to = direct ? [...choiceAsk.to].sort((a, b) => a.localeCompare(b)) : ["*"];
|
|
if ((direct && !to.length) || (cls === "mayday" && !confirmMayday())) {
|
|
return;
|
|
}
|
|
const label = `Choice request (${CLASSES[cls]}) to ${direct ? to.join(", ") : "all"}`;
|
|
const signed = await signedProof("/api/choices/sign", {class: cls, to}, label);
|
|
if (!signed) {
|
|
return;
|
|
}
|
|
const result = await sendJson("/api/choices", {class: cls, to, proof: signed.proof});
|
|
if (!result.ok || !result.data) {
|
|
toast(`${label} failed`, fail(result), false);
|
|
return;
|
|
}
|
|
toast(`${label}: ${result.data.ok ? "sent" : "stored, sending failed"}`, [...result.data.ids.map((id) => `kvb-orchestrator msg ${id}`), ...runLines(result.data)], result.data.ok);
|
|
await load();
|
|
}
|
|
|
|
function choiceBar() {
|
|
const picks = h("div", {class: "choice-agents", role: "group", "aria-label": "Post or call to"});
|
|
const count = counter(0);
|
|
const menu = h("details", {class: "menu"}, h("summary", named("Agents", "Agents: who gets a post or a call"), icon("bi-people"), count, caret()),
|
|
h("div", {class: "menu-box"}, h("h3", {}, "Post or call to"), picks));
|
|
const button = ([id, label]) => h("button", {type: "button", class: "btn small", "data-channel": id, ...named(label, `${label}: ask for every open decision as a choice, signed with your passkey. ${CHANNEL_HINTS[id]}`), on: {click: () => requestChoices(id)}},
|
|
icon(CHOICE_ICONS[id]), label);
|
|
const direct = TO_ONE.map(button);
|
|
choiceAsk.dom = {picks, count, direct};
|
|
choiceAsk.names = "";
|
|
return h("div", {class: "card controls choice-request", role: "group", "aria-label": "Request choices"},
|
|
h("span", {class: "group-title"}, "Request choices"), menu, direct, TO_ALL.map(button));
|
|
}
|
|
|
|
function syncChoices() {
|
|
choiceAsk.dom.count.textContent = String(choiceAsk.to.size);
|
|
for (const button of choiceAsk.dom.direct) {
|
|
button.disabled = !choiceAsk.to.size;
|
|
}
|
|
}
|
|
|
|
function pickChoice(name, on) {
|
|
if (on) {
|
|
choiceAsk.to.add(name);
|
|
} else {
|
|
choiceAsk.to.delete(name);
|
|
}
|
|
syncChoices();
|
|
}
|
|
|
|
function drawChoices() {
|
|
const dom = choiceAsk.dom;
|
|
if (!dom || !dom.picks.isConnected) {
|
|
return;
|
|
}
|
|
const names = agentNames();
|
|
for (const name of [...choiceAsk.to]) {
|
|
if (!names.includes(name)) {
|
|
choiceAsk.to.delete(name);
|
|
}
|
|
}
|
|
const sig = JSON.stringify(names);
|
|
if (sig !== choiceAsk.names) {
|
|
choiceAsk.names = sig;
|
|
dom.picks.replaceChildren(...(names.length ? names.map((name) => h("label", {class: "check"},
|
|
h("input", {type: "checkbox", value: name, checked: choiceAsk.to.has(name), on: {change: (event) => pickChoice(name, event.target.checked)}}), name)) : [h("p", {class: "muted"}, "No agent known yet.")]));
|
|
}
|
|
syncChoices();
|
|
}
|