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

+119 -9
View File
@@ -3,8 +3,8 @@ from __future__ import annotations
import itertools
import statistics
from .council import agent
from .records import iso_epoch, shown
from .council import HUMAN, agent
from .records import epoch, iso_epoch, shown
from .register import ENDED, OPERATOR, parties, register_row, status_of
from .signed import signing
from .tree import inside
@@ -13,11 +13,13 @@ ENDED_SHOWN = 100
OPEN_SHOWN = 300
QUEUE_SHOWN = 100
PARTIES_SHOWN = 40
AFTER_SHOWN = 40
TEXT_SHOWN = 500
FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89)
LETTERS = "RASCI"
HOTFIX = "hotfix/"
QUEUE = "queue/"
HOUR = 3600
def kind_of(task: str) -> str:
@@ -36,23 +38,42 @@ def fibonacci(value: object) -> bool:
def estimates_of(row: dict) -> dict[str, dict]:
"""Returns:
Each party's Fibonacci business value and complexity; the
Operator's complexity is None where it recorded a business value
alone.
"""
raw = row.get("estimates")
found = {}
for party, entry in itertools.islice(raw.items(), PARTIES_SHOWN) if isinstance(raw, dict) else ():
if isinstance(entry, dict) and fibonacci(entry.get("value")) and fibonacci(entry.get("complexity")):
found[str(party)[:TEXT_SHOWN]] = {"value": entry["value"], "complexity": entry["complexity"]}
alone = party == HUMAN and isinstance(entry, dict) and "complexity" not in entry
if isinstance(entry, dict) and fibonacci(entry.get("value")) and (alone or fibonacci(entry.get("complexity"))):
found[str(party)[:TEXT_SHOWN]] = {"value": entry["value"], "complexity": entry.get("complexity")}
return found
def business_value(estimates: dict[str, dict]) -> float | None:
if HUMAN in estimates:
return estimates[HUMAN]["value"]
return statistics.median(entry["value"] for entry in estimates.values()) if estimates else None
def points(estimates: dict[str, dict]) -> float | None:
sized = [entry["complexity"] for entry in estimates.values() if entry["complexity"] is not None]
return statistics.median(sized) if sized else None
def wsjf(estimates: dict[str, dict]) -> dict | None:
"""Returns:
Median business value, median complexity and their quotient, the
same reading as rasci.py's wsjf(); None without an estimate.
Business value, median complexity and their quotient, the same
reading as rasci.py's wsjf(): the Operator's business value where
it gave one, else the median; None while no estimate carries a
complexity.
"""
if not estimates:
complexity = points(estimates)
if complexity is None:
return None
value = statistics.median(entry["value"] for entry in estimates.values())
complexity = statistics.median(entry["complexity"] for entry in estimates.values())
value = business_value(estimates)
return {"value": value, "complexity": complexity, "score": round(value / complexity, 2)}
@@ -96,6 +117,11 @@ def located(row: dict, trees: list[dict], open_task: bool) -> dict:
return {"worktree": tree["path"], "worktree_branch": tree["branch"], "repo": tree["repo"], "flow": tree["flow"], "open_work": [*own, *tree["open_work"]]}
def after_of(row: dict) -> list[str]:
raw = row.get("after")
return [other for other in raw[:AFTER_SHOWN] if isinstance(other, str) and len(other) <= TEXT_SHOWN] if isinstance(raw, list) else []
def task_row(task: str, row: dict, trees: list[dict]) -> dict:
estimates = estimates_of(row)
ended = status_of(row) in ENDED
@@ -108,6 +134,12 @@ def task_row(task: str, row: dict, trees: list[dict]) -> dict:
"prio": prio_of(row),
"estimates": estimates,
"wsjf": wsjf(estimates),
"value": business_value(estimates),
"points": points(estimates),
"start": iso_epoch(row.get("start")),
"end": iso_epoch(row.get("end")) if ended else None,
"after": after_of(row),
"base": shown(row.get("base"))[:TEXT_SHOWN],
"roles": roles_of(row),
"queue": None,
**located(row, trees, not ended),
@@ -134,6 +166,12 @@ def queue_row(entry: dict, created: float, trees: list[dict]) -> dict:
"prio": None,
"estimates": {},
"wsjf": None,
"value": None,
"points": None,
"start": (epoch(entry.get("granted")) or created) if entry.get("status") == "active" else None,
"end": None,
"after": [],
"base": "-",
"roles": {},
"queue": {"kind": text["kind"], "parent": text["task"], "enqueued": created},
**located(entry, trees, False),
@@ -165,3 +203,75 @@ def task_rows(tasks: dict[str, dict], queue: list[tuple[dict, float]], scan: dic
*(queue_row(entry, created, trees) for entry, created in jobs),
*(task_row(task, tasks[task], trees) for task in ended[:ENDED_SHOWN]),
]
def register_ranks(tasks: dict[str, dict], agents: list[str]) -> dict[str, int]:
"""Returns:
Each open task's index in rasci.py's ranked(): pin, WSJF, the
worker's place in the operator's agent order, then the name.
"""
order = {name: index for index, name in enumerate(agents)}
def key(task: str) -> tuple:
worker = tasks[task].get("worker")
return (*rank(task, tasks[task])[:4], order.get(worker, len(order)) if isinstance(worker, str) else len(order), task)
current = sorted((task for task, row in tasks.items() if status_of(row) not in ENDED), key=key)
return {task: index for index, task in enumerate(current)}
def queue_chain(queue: list[tuple[dict, float]], tasks: dict[str, dict], agents: list[str], now: float) -> list[tuple[str, bool]]:
"""Args:
queue: the kvb-org-queue entries with their enqueue time.
tasks: the register rows by task.
agents: the operator's agent order.
now: epoch seconds.
Returns:
The entry ids in grant order, each with whether it waits: the
active ones by grant time, then the waiting ones as queue.py's
ordered() sorts them (hotfix first, then the register rank less one
per whole hour waited, then the holder's agent order, then the
enqueue sequence).
"""
ranks = register_ranks(tasks, agents)
order = {name: index for index, name in enumerate(agents)}
def waiting_key(pair: tuple[dict, float]) -> tuple:
entry, created = pair
task, name, seq = entry.get("task"), entry.get("name"), entry.get("seq")
hours = int((now - created) // HOUR)
place = ranks[task] if isinstance(task, str) and task in ranks else len(ranks)
holder = order[name] if isinstance(name, str) and name in order else len(order)
return (not entry.get("hotfix"), place - hours, holder, seq if isinstance(seq, int) and not isinstance(seq, bool) else 0)
active = sorted((pair for pair in queue if pair[0].get("status") == "active"), key=lambda pair: epoch(pair[0].get("granted")) or pair[1])
waiting = sorted((pair for pair in queue if pair[0].get("status") == "waiting"), key=waiting_key)
return [(shown(entry.get("id"))[:TEXT_SHOWN], entry.get("status") == "waiting") for entry, _ in [*active, *waiting]]
def branch_of(row: dict) -> str:
return row["branch"] if row["branch"] != "-" else row["worktree_branch"]
def task_edges(rows: list[dict], chain: list[tuple[str, bool]]) -> list[dict]:
"""Args:
rows: the shown task rows of task_rows().
chain: queue_chain().
Returns:
One {"from", "to", "kind"} edge per predecessor among the shown
rows: kind after for an explicit `rasci.py set --after`, base for a
task whose base branch is another task's branch, queue for each
waiting queue job and the job ahead of it.
"""
shown_tasks = {row["task"] for row in rows}
edges = [{"from": other, "to": row["task"], "kind": "after"} for row in rows for other in row["after"] if other in shown_tasks and other != row["task"]]
branches: dict[str, list[str]] = {}
for row in rows:
if row["kind"] != "queue" and branch_of(row):
branches.setdefault(branch_of(row), []).append(row["task"])
edges += [{"from": other, "to": row["task"], "kind": "base"} for row in rows if row["kind"] != "queue" for other in branches.get(row["base"], []) if other != row["task"]]
jobs = [(f"{QUEUE}{job}", waits) for job, waits in chain]
edges += [{"from": ahead, "to": job, "kind": "queue"} for (ahead, _), (job, waits) in itertools.pairwise(jobs) if waits and {ahead, job} <= shown_tasks]
return edges