feat(orchestrator): S5 Tasks with choices, Gantt, estimates and burn charts

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>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-09 06:53:55 +00:00
1 parent 5c8b69d53d
commit d9eb121d12
49 files changed
+2162 -148

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
from __future__ import annotations
import json
import subprocess
import unittest
from hub_support import PLAN
HOUR = 3600
NOW = 100 * HOUR
PLAN_PROBE = """
const P = require(process.argv[1]);
const H = 3600;
const now = 100 * H;
const row = (task, fields) => ({task, kind: "task", status: "queued", ended: false, start: null, end: null, points: null, wsjf: null, value: null, worker: "-", title: "-", roles: {}, ...fields});
const rows = [
row("work/a", {status: "done", ended: true, start: 80 * H, end: 82 * H, points: 2, worker: "Skills"}),
row("work/b", {status: "done", ended: true, start: 84 * H, end: 88 * H, points: 2, roles: {Stage: "R"}}),
row("work/c", {status: "done", ended: true, start: 90 * H, end: 91 * H}),
row("work/run", {status: "running", start: 95 * H, points: 5, worker: "Stage"}),
row("work/next", {points: 3}),
row("work/tail", {}),
row("work/stop", {status: "stopped", ended: true, start: 85 * H, end: 86 * H, points: 1}),
row("queue/x", {kind: "queue", status: "awaiting"}),
row("operator/push", {kind: "command", status: "done", ended: true, end: 70 * H}),
];
const edges = [
{from: "work/run", to: "work/next", kind: "after"},
{from: "work/next", to: "work/tail", kind: "after"},
{from: "work/c", to: "work/tail", kind: "base"},
{from: "work/run", to: "queue/x", kind: "queue"},
{from: "work/gone", to: "work/tail", kind: "base"},
];
const loop = [row("loop/a", {}), row("loop/b", {})];
const looped = [{from: "loop/a", to: "loop/b", kind: "base"}, {from: "loop/b", to: "loop/a", kind: "queue"}];
const lay = (estimation, forecast) => P.bars(rows, edges, {now, estimation, forecast}).map((bar) => [bar.task, bar.start / H, bar.end / H, bar.forecast, bar.dot]);
const sorts = [
row("t1", {wsjf: {score: 2}, value: 8, points: 3, status: "running", start: 5, worker: "Bravo", title: "Zulu"}),
row("t2", {status: "done", start: 3, end: 9}),
row("t3", {wsjf: {score: 5}, value: 3, points: 1, worker: "alpha", title: "Alpha"}),
row("t4", {wsjf: {score: 2}, value: 13, points: 8, status: "blocked", start: 1, end: 4, worker: "Charlie", title: "mike"}),
];
const context = {filtered: [rows[0], rows[7]], since: 90 * H, until: 99 * H, now, holders: {"identity/oidc": "Stage"}};
const scope = (choice) => {
const found = P.scoped(rows, choice, context);
return {tasks: found.rows.map((entry) => entry.task), from: found.from / H, to: found.to / H};
};
const series = (estimation) => P.burn(P.scoped(rows, "all", context).rows, {estimation, from: 70 * H, to: now}).points.map((point) => [point.t / H, point.scope, point.done, point.remaining, point.unestimated]);
console.log(JSON.stringify({
options: [P.FORECASTS, P.ESTIMATIONS, P.SORTS.map(([id]) => id), P.SERIES],
medians: P.medians(rows),
median: [P.median([]), P.median([3, 1, 2]), P.median([4, 1, 3, 2])],
bars: {noneAlways: lay("none", "always"), pointsAlways: lay("points", "always"), pointsOnly: lay("points", "points"), noneOnly: lay("none", "points"), real: lay("points", "real")},
loop: P.bars(loop, looped, {now, estimation: "none", forecast: "always"}).map((bar) => bar.task),
empty: P.bars([row("work/new", {}), row("work/gone", {status: "done", ended: true})], [], {now, estimation: "none", forecast: "always"}),
scopes: {all: scope("all"), filter: scope("filter"), agent: scope("agent:Stage"), line: scope("line:identity/oidc"), none: scope("agent:Nobody")},
burn: {none: series("none"), points: series("points")},
sorted: Object.fromEntries(P.SORTS.map(([id]) => [id, [P.sortRows(sorts, id, false).map((entry) => entry.task), P.sortRows(sorts, id, true).map((entry) => entry.task)]])),
unknown: P.sortRows(sorts, "nope", false).map((entry) => entry.task),
}));
"""
class TestPlanModule(unittest.TestCase):
@classmethod
def setUpClass(cls):
done = subprocess.run(["node", "-e", PLAN_PROBE, str(PLAN)], capture_output=True, text=True, timeout=60, check=True)
cls.data = json.loads(done.stdout)
def test_the_selectors_offer_the_forecasts_estimations_sort_modes_and_series(self):
forecasts, estimations, sorts, series = self.data["options"]
self.assertEqual(forecasts, [["always", "always"], ["points", "points only"], ["real", "real only"]])
self.assertEqual(estimations, [["none", "No estimate"], ["points", "Story points"]])
self.assertEqual(sorts, ["prio", "wsjf", "value", "complexity", "status", "start", "end", "agent", "title"])
self.assertEqual(series, ["scope", "done", "remaining", "unestimated"])
def test_medians_take_done_tasks_only_and_the_points_of_those_with_points(self):
self.assertEqual(self.data["medians"], {"duration": 2 * HOUR, "perPoint": 1.5 * HOUR})
self.assertEqual(self.data["median"], [None, 2, 2.5])
def test_without_estimate_a_forecast_is_as_long_as_the_median_duration_behind_its_predecessors(self):
self.assertEqual(self.data["bars"]["noneAlways"], [
["work/a", 80, 82, False, False],
["work/b", 84, 88, False, False],
["work/c", 90, 91, False, False],
["work/run", 95, 100, False, False],
["work/next", 100, 102, True, False],
["work/tail", 102, 104, True, False],
["work/stop", 85, 86, False, False],
["queue/x", 100, 102, True, False],
["operator/push", 70, 70, False, True],
])
def test_with_story_points_a_forecast_is_points_times_the_median_hours_per_point(self):
bars = {task: rest for task, *rest in self.data["bars"]["pointsAlways"]}
self.assertEqual(bars["work/next"], [100, 104.5, True, False])
self.assertEqual(bars["work/tail"], [104.5, 106.5, True, False])
def test_points_only_draws_a_dot_behind_the_predecessors_of_a_task_without_estimate(self):
only = {task: rest for task, *rest in self.data["bars"]["pointsOnly"]}
self.assertEqual((only["work/next"], only["work/tail"], only["queue/x"]), ([100, 104.5, True, False], [104.5, 104.5, True, True], [100, 100, True, True]))
none = {task: rest for task, *rest in self.data["bars"]["noneOnly"]}
self.assertEqual((none["work/next"], none["work/tail"]), ([100, 100, True, True], [100, 100, True, True]))
def test_real_only_draws_no_forecast(self):
self.assertEqual([task for task, *_ in self.data["bars"]["real"]], ["work/a", "work/b", "work/c", "work/run", "work/stop", "operator/push"])
self.assertFalse(any(bar[3] for bar in self.data["bars"]["real"]))
def test_a_cycle_still_draws_every_task_no_done_task_means_a_dot_and_an_ended_task_without_times_no_bar(self):
self.assertEqual(self.data["loop"], ["loop/a", "loop/b"])
self.assertEqual(self.data["empty"], [{"task": "work/new", "start": NOW, "end": NOW, "forecast": True, "dot": True}])
def test_the_scopes_are_the_whole_register_the_filter_in_its_window_and_one_agent_or_line(self):
work = ["work/a", "work/b", "work/c", "work/run", "work/next", "work/tail", "work/stop"]
scopes = self.data["scopes"]
self.assertEqual(scopes["all"], {"tasks": work, "from": 80, "to": 100})
self.assertEqual(scopes["filter"], {"tasks": ["work/a"], "from": 90, "to": 99})
self.assertEqual(scopes["agent"], {"tasks": ["work/b", "work/run"], "from": 84, "to": 100})
self.assertEqual(scopes["line"], scopes["agent"])
self.assertEqual(scopes["none"], {"tasks": [], "from": 76, "to": 100})
def test_without_estimate_the_burn_counts_tasks(self):
self.assertEqual(self.data["burn"]["none"], [
[70, 2, 0, 2, 0], [80, 3, 0, 3, 0], [82, 3, 1, 2, 0], [84, 4, 1, 3, 0], [85, 5, 1, 4, 0], [86, 4, 1, 3, 0],
[88, 4, 2, 2, 0], [90, 5, 2, 3, 0], [91, 5, 3, 2, 0], [95, 6, 3, 3, 0], [100, 6, 3, 3, 0],
])
def test_with_story_points_the_burn_sums_complexity_and_counts_the_unestimated_apart(self):
burn = self.data["burn"]["points"]
self.assertEqual(burn[0], [70, 3, 0, 3, 1])
self.assertEqual(burn[-1], [100, 12, 4, 8, 1])
self.assertEqual([point[4] for point in burn], [1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1])
def test_the_list_sorts_by_every_mode_in_both_directions_with_missing_keys_last(self):
self.assertEqual(self.data["sorted"], {
"prio": [["t1", "t2", "t3", "t4"], ["t4", "t3", "t2", "t1"]],
"wsjf": [["t1", "t4", "t3", "t2"], ["t3", "t1", "t4", "t2"]],
"value": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
"complexity": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
"status": [["t3", "t1", "t4", "t2"], ["t2", "t4", "t1", "t3"]],
"start": [["t4", "t2", "t1", "t3"], ["t1", "t2", "t4", "t3"]],
"end": [["t4", "t2", "t1", "t3"], ["t2", "t4", "t1", "t3"]],
"agent": [["t3", "t1", "t4", "t2"], ["t4", "t1", "t3", "t2"]],
"title": [["t3", "t4", "t2", "t1"], ["t1", "t2", "t4", "t3"]],
})
self.assertEqual(self.data["unknown"], ["t1", "t2", "t3", "t4"])
if __name__ == "__main__":
unittest.main()