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

+5 -3
View File
@@ -152,8 +152,9 @@ def task_argv(task: object, argv: object) -> list[str]:
task: the register task the change is for, a rasci.py slug outside
operator/.
argv: the rasci.py arguments, one of `set <task> --prio <1-999>`,
`estimate <task> Operator --value <fib> --complexity <fib>` or
`estimate <task> Operator --drop`.
`estimate <task> Operator --value <fib> --complexity <fib>`,
`estimate <task> Operator --value <fib>` (the operator's business
value alone) or `estimate <task> Operator --drop`.
Returns:
argv, the hub and the runner check it the same way.
@@ -167,7 +168,8 @@ def task_argv(task: object, argv: object) -> list[str]:
raise Refused("the argv is no list of texts")
rest = argv[3:]
pinned = argv[:3] == ["set", task, "--prio"] and len(argv) == 4 and bool(PRIO.fullmatch(argv[3]))
estimated = len(rest) == 4 and rest[0] == "--value" and rest[1] in FIBONACCI and rest[2] == "--complexity" and rest[3] in FIBONACCI
valued = len(rest) in (2, 4) and rest[0] == "--value" and rest[1] in FIBONACCI
estimated = valued and (len(rest) == 2 or (rest[2] == "--complexity" and rest[3] in FIBONACCI))
if pinned or (argv[:3] == ["estimate", task, ESTIMATOR] and (estimated or rest == ["--drop"])):
return argv
raise Refused("the argv is no task priority or WSJF estimate change of this task")
+9 -4
View File
@@ -4,6 +4,7 @@ import secrets
import time
from .asking import ask_state
from .choosing import answer_signed
from .cleanup import cleanup_request
from .denying import DENIABLE, DENY_KEYS, denial_note, request_status, request_words
from .errors import Conflict, Missing, ToolError
@@ -35,7 +36,8 @@ def inbox_item(raw: dict, label: str, title: str, gate: str, subject: dict | Non
label: the chosen option of a decision, else empty.
title: what the stream logs for the item.
gate: the review gate, else empty.
subject: what an approval signs; None for a decision.
subject: what an approval signs; for a decision the answer post to
the asking agent.
requester: the requesting agent's name for a denial, else empty.
Returns:
@@ -105,14 +107,15 @@ class Approving:
Returns:
The proof to relay, None when nothing needs a signature. The hub
verifies the signature of a denial itself, since it acts on it;
of an approval it checks the shape, and the executors verify.
of an approval it checks the shape, and the executors verify,
the hub itself for a choice answer when it sends it.
"""
if not signed:
if proof is not None:
raise ToolError("proof must be null when no item needs a signature")
return None
if not isinstance(proof, dict):
raise ToolError("not signed: an approval of an ask, review or operator command needs the passkey signature")
raise ToolError("not signed: an approval of an ask, review, choice or operator command needs the passkey signature")
expect(proof, ("payload", "assertion"))
try:
payload = signing.checked_approval(proof["payload"], now)
@@ -162,6 +165,8 @@ class Approving:
record, changed = self.asks.decide(item["id"], {"status": "allowed" if approve else "denied", "proof": proof})
elif item["kind"] == "review":
record, changed = self.reviews.decide(item["id"], {"status": "approved" if approve else "rejected", "proof": proof})
elif item["signed"] is not None:
return self.answer_choice(item, proof)
else:
record, changed = self.decisions.decide(item["id"], {"status": "decided", "choice": item["label"]})
if record is None:
@@ -218,7 +223,7 @@ class Approving:
if kind_name == "rule":
return inbox_item(raw, "", f"Rule {request_words(record)}", "", request_signed(record), self.requester(raw, record["agent"]))
title = {"review": record.get("title"), "ask": f"{record.get('tool')} {record.get('command')}", "decision": record.get("question")}[kind_name]
subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else None
subject = {"review": review_signed, "ask": ask_signed}[kind_name](record) if kind_name != "decision" else answer_signed(record, label)
return inbox_item(raw, label, title, record.get("gate", ""), subject, self.requester(raw, record["session"] if kind_name == "ask" else ""))
def checked(self, raw: object) -> list[dict]:
+13 -16
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import time
from .choosing import EVERYONE, answer_text
from .errors import Conflict, Missing, ToolError
from .fields import (
COMMAND_LIMIT,
@@ -35,7 +36,13 @@ def ask_row(ask: dict, names: dict[str, str]) -> dict:
def decision_state(decision: dict) -> dict:
return {key: decision[key] for key in ("id", "status", "choice", "decided")}
"""Returns:
The status and choice, with the id and text of the operator's
passkey-signed answer when a click chose it, else None for both.
"""
message = decision.get("message")
answer = None if message is None else answer_text(decision, decision["choice"])
return {**{key: decision[key] for key in ("id", "status", "choice", "decided")}, "message": message, "answer": answer}
def decision_row(decision: dict) -> dict:
@@ -87,20 +94,7 @@ class Asking:
if not changed:
raise Conflict(ask_state(decided, time.time()))
return ask_state(decided, time.time())
if kind_name != "decisions" or verb != "choose":
raise Missing(verb)
payload = decode(body)
expect(payload, ("label",))
label = field(payload, "label")
record = self.decisions.get(item_id)
if record is None:
raise Missing(item_id)
if label not in [option["label"] for option in record["options"]]:
raise ToolError(f"label must be one of the options of decision {item_id}")
decided, changed = self.decisions.decide(item_id, {"status": "decided", "choice": label})
if not changed:
raise Conflict(decision_state(decided))
return decision_state(decided)
raise Missing(verb)
def decision_request(self, arguments: dict) -> dict:
expect(arguments, ("question", "options", "agent", "recommended"))
@@ -114,7 +108,10 @@ class Asking:
recommended = arguments["recommended"]
if not isinstance(recommended, str) or recommended.strip() not in ["", *(option["label"] for option in parsed)]:
raise ToolError("recommended must be one of the option labels or an empty string")
fields = {"question": question, "options": parsed, "agent": field(arguments, "agent").strip(), "recommended": recommended.strip()}
asker = field(arguments, "agent").strip()
if EVERYONE in asker:
raise ToolError(f"agent names the asking agent, never a name with {EVERYONE}")
fields = {"question": question, "options": parsed, "agent": asker, "recommended": recommended.strip()}
record = self.decisions.add({**fields, "status": "pending", "choice": None, "decided": None, "created_ts": time.time()})
self.automate("decision", record)
return {"id": record["id"], "status": record["status"], "url": self.url()}
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
from .council import agent
from .errors import ToolError
from .fields import decode, expect, field
from .messaging import DIRECT, EVERYONE, TO_ALL
from .signed import signing
CHOICE_REQUEST = "send me your open decisions as choices"
CHANNELS = {**DIRECT, **TO_ALL}
CHOICE_KEYS = ("class", "to")
RECIPIENT_LIMIT = 40
ANSWER_CLASS = "post"
QUESTION_SHOWN = 200
def answer_text(decision: dict, label: str) -> str:
question = " ".join(decision["question"].split())[:QUESTION_SHOWN]
return f"Choice for decision {decision['id']} ({question}): {label}"
def answer_signed(decision: dict, label: str) -> dict:
"""Returns:
The post to the asking agent that a click on the option label
signs: the operator's answer operator_message proves.
"""
if EVERYONE in decision["agent"]:
raise ToolError(f"a choice answer goes to the asking agent, never to a name with {EVERYONE}")
return signing.message_item(agent(decision["agent"]), ANSWER_CLASS, answer_text(decision, label), "", "", "")
def recipients(payload: dict) -> list[str]:
"""Args:
payload: the page's class and to: 1 to RECIPIENT_LIMIT distinct
agent names for post and call, [EVERYONE] for a class to all.
"""
cls, names = payload["class"], payload["to"]
if not isinstance(cls, str) or cls not in CHANNELS:
raise ToolError(f"class must be one of {', '.join(CHANNELS)}")
if not isinstance(names, list) or not all(isinstance(name, str) for name in names):
raise ToolError("to must be a list of agent names")
if cls in TO_ALL:
if names != [EVERYONE]:
raise ToolError(f"a choice request to all names to [{EVERYONE}]")
return names
targets = [field({"to": name}, "to").strip() for name in names]
if not 1 <= len(targets) <= RECIPIENT_LIMIT or any(EVERYONE in target for target in targets):
raise ToolError(f"a post or a call names 1 to {RECIPIENT_LIMIT} agents, never a name with {EVERYONE}")
if len({agent(target) for target in targets}) != len(targets):
raise ToolError("to must name distinct agents")
return targets
class Choosing:
def choice_items(self, payload: dict) -> list[dict]:
return [signing.message_item(target, payload["class"], CHOICE_REQUEST, "", "", "") for target in recipients(payload)]
def sign_choices(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, CHOICE_KEYS)
return self.challenge_for(self.choice_items(payload))
def request_choices(self, body: bytes) -> dict:
"""Returns:
The ids of the stored requests, one per recipient, once one
passkey assertion signed each of them; nothing is stored or sent
before every item verified.
"""
payload = decode(body)
expect(payload, (*CHOICE_KEYS, "proof"))
items = self.choice_items(payload)
for item in items:
self.operator_proven(payload["proof"], item)
records = [self.stored_message(item, payload["proof"], None) for item in items]
runs = [self.radioed(record, CHANNELS[payload["class"]]) for record in records]
return {"ids": [record["id"] for record in records], "ok": all(run["code"] == 0 for run in runs), "runs": runs}
def answer_choice(self, item: dict, proof: dict) -> str:
"""Args:
item: a checked decision item whose signed is answer_signed().
proof: the relayed signature; the hub sends the answer itself,
so it verifies the signature here, once.
Returns:
The outcome words for the stream.
"""
record = self.decisions.get(item["id"])
if record is None:
return "unknown"
if record["status"] != "pending":
return f"already {record['status']}"
self.operator_proven(proof, item["signed"])
message = self.stored_message(item["signed"], proof, None)
decided, changed = self.decisions.decide(item["id"], {"status": "decided", "choice": item["label"], "message": message["id"]})
if not changed:
self.messages.remove(message["id"])
return f"already {decided['status']}"
run = self.radioed(message, CHANNELS[ANSWER_CLASS])
where = f"{ANSWER_CLASS} to {message['address']}"
return f"decided · {where}" if run["code"] == 0 else f"decided · {where} failed ({run['code']})"
+8 -2
View File
@@ -17,7 +17,7 @@ TTS_ROUTE = "/api/tts"
WAV_TYPE = "audio/wav"
STT_ROUTE = "/api/stt"
ROUTE = re.compile(r"/reviews/([0-9a-f]{16})/(page|files|approve|reject)")
ITEM = re.compile(r"/api/(asks|decisions)/([0-9a-f]{16})(?:/(allow|deny|choose))?")
ITEM = re.compile(r"/api/(asks|decisions)/([0-9a-f]{16})(?:/(allow|deny))?")
ENTRY = re.compile(r"/api/stream/([0-9]{1,12})")
BOARD = re.compile(r"/api/board/([a-z-]{1,32})")
UPLOADS = "/api/uploads"
@@ -48,8 +48,10 @@ VOICE_ITEM = re.compile(r"/api/voice/([0-9a-f]{16})(\.wav)?")
MESSAGE_SIGN_ROUTE = "/api/messages/sign"
BOARD_SIGN_ROUTE = "/api/board/sign"
SETTINGS_SIGN_ROUTE = "/api/settings/sign"
CHOICES_ROUTE = "/api/choices"
CHOICES_SIGN_ROUTE = "/api/choices/sign"
WAV_POSTS = (STT_ROUTE, VOICE_ROUTE)
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE)
ACTION_POSTS = ("/api/rules", "/api/nl", SETTINGS_ROUTE, TTS_ROUTE, STT_ROUTE, SIGN_ROUTE, RULE_SIGN_ROUTE, CLEANUP_ROUTE, OFFICES_ROUTE, TASKS_ROUTE, VOICE_ROUTE, MESSAGE_SIGN_ROUTE, SETTINGS_SIGN_ROUTE, CHOICES_ROUTE, CHOICES_SIGN_ROUTE)
class Handler(Exchange):
@@ -182,6 +184,10 @@ class Handler(Exchange):
return lambda body: self.answer(lambda: hub.voice_notes.submit(body), HTTPStatus.ACCEPTED)
if path == MESSAGE_SIGN_ROUTE:
return lambda body: self.answer(lambda: hub.sign_message(body), HTTPStatus.OK)
if path == CHOICES_SIGN_ROUTE:
return lambda body: self.answer(lambda: hub.sign_choices(body), HTTPStatus.OK)
if path == CHOICES_ROUTE:
return lambda body: self.answer(lambda: hub.request_choices(body), HTTPStatus.CREATED)
return None
def posted(self, path: str) -> bool:
+2 -1
View File
@@ -11,6 +11,7 @@ from .approving import Approving
from .artifacts import Touched
from .asking import Asking
from .batches import Pending
from .choosing import Choosing
from .cleanup import Cleaning, Spool
from .denying import Denying
from .engines import Engines, Speech
@@ -62,7 +63,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True)
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
council: Path
pages: Path
public_port: int
+9 -5
View File
@@ -5,6 +5,7 @@ from http import HTTPStatus
from . import NAME
from .asking import OPTION_LIMIT
from .choosing import CHOICE_REQUEST
from .errors import RpcError, ToolError
from .fields import WAIT_CAP
from .requesting import AGENT_CAP
@@ -64,19 +65,22 @@ class Mcp:
tool("review_list", "List the pending reviews.", {}),
tool(
"decision_request",
"Ask the operator to pick one of several options in the Agent Orchestrator; each option becomes a button. "
"Returns the decision id and the hub URL.",
"Ask the operator to pick one of several options in the Agent Orchestrator; the question shows as a choice card "
"under Waits on Operator in the Tasks tab and each option becomes a button. When an operator message asks "
f'"{CHOICE_REQUEST}", send each open decision of kvb-think-choice through this tool. Returns the decision id and the hub URL.',
{
"question": text_property("The question, plain text."),
"options": {"type": "array", "minItems": 1, "maxItems": OPTION_LIMIT, "items": option, "description": "The choices, at least one."},
"agent": text_property("Your agent name, shown with the question."),
"agent": text_property("Your agent name, shown with the question; the operator's signed answer is a post to it."),
"recommended": text_property("The label of the option you recommend, or an empty string."),
},
),
tool(
"decision_wait",
f"Wait until the operator picks an option or the timeout passes, then return the status "
f"(pending, decided) and the chosen label. Waits at most {WAIT_CAP} seconds.",
"Wait until the operator picks an option or the timeout passes, then return the status "
"(pending, decided), the chosen label and, for a click, message and answer: the id and text of the operator's "
"passkey-signed answer, a post to the asking agent; check it with operator_message(message, answer, your name) "
f"before acting on the choice. Waits at most {WAIT_CAP} seconds.",
{"id": text_property("Decision id that decision_request returned."), "timeout_seconds": wait},
),
tool(
+30 -11
View File
@@ -5,12 +5,12 @@ import secrets
import time
from .council import HUMAN, agent, ordered, read_claims
from .errors import Missing, ToolError
from .errors import Conflict, Missing, ToolError
from .fields import NAME_LIMIT, ORDER_NAME, decode, expect, field, message_text, single
from .records import write_atomic
from .register import REGISTER, agent_order, read_register
from .signed import message_signed, signing
from .skill_scripts import NIGHT, RADIO, RASCI
from .skill_scripts import ESTIMATE, NIGHT, RADIO, RASCI
NIGHTS = {
"drain": (["drain"], "Operator ran kvb-org-drain in the Agent Orchestrator: run kvb-agent-drain; verify with night.py status."),
@@ -23,7 +23,7 @@ TO_ALL = {"newsletter": ["post"], **{cls: ["send", cls] for cls in ("broadcast",
EVERYONE = "*"
DENIED = "Denied by the operator."
MESSAGE_KEYS = ("to", "text", "upload", "class", "voice")
SIGNED_BOARD = ("clear-mayday", *NIGHTS)
SIGNED_BOARD = ("clear-mayday", *NIGHTS, "estimates")
def chosen(payload: dict, choices: dict[str, list[str]]) -> list[str]:
@@ -142,17 +142,31 @@ class Messaging:
item, _, _ = self.composed(payload, {**DIRECT, **TO_ALL})
return self.challenge_for([item])
def stored_message(self, item: dict, proof: dict, upload: dict | None) -> dict:
"""Args:
item: the message item a pinned key signed.
proof: that signature, kept so operator_message can prove it.
upload: the attached upload or None.
Returns:
The stored record, addressed to the radio address the claims
give the recipient.
"""
target = item["to"]
claims = read_claims(self.council)
address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target)
fields = {"to": target, "address": address, "text": item["text"], "upload": upload, "voice": item["voice"] or None, "voice_sha256": item["voice_sha256"], "class": item["class"]}
return self.messages.add({**fields, "proof": proof, "created_ts": time.time()})
def radioed(self, record: dict, command: list[str]) -> dict:
targets = [] if record["to"] == EVERYONE else ["--to", record["address"]]
return self.scripts.run(RADIO, [*command, *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {self.relayed(record)}"])
def relay(self, payload: dict, choices: dict[str, list[str]]) -> dict:
with self.voice_notes.held(payload["voice"]):
item, command, upload = self.composed(payload, choices)
proof = self.operator_proven(payload["proof"], item)
target = item["to"]
claims = read_claims(self.council)
address = next((claim["address"] for claim in claims if claim["agent"] == target and claim["address"]), target)
fields = {"to": target, "address": address, "text": item["text"], "upload": upload, "voice": item["voice"] or None, "voice_sha256": item["voice_sha256"], "class": item["class"]}
record = self.messages.add({**fields, "proof": proof, "created_ts": time.time()})
targets = [] if target == EVERYONE else ["--to", address]
run = self.scripts.run(RADIO, [*command, *targets, "--session", HUMAN, f"[kvb-orchestrator msg {record['id']}] {self.relayed(record)}"])
record = self.stored_message(item, self.operator_proven(payload["proof"], item), upload)
run = self.radioed(record, command)
return {"id": record["id"], "ok": run["code"] == 0, "runs": [run]}
def message(self, body: bytes) -> dict:
@@ -191,6 +205,11 @@ class Messaging:
if unknown:
raise ToolError(f"unknown agents: {', '.join(unknown)[:300]}; the register and the council know {', '.join(known)[:600] or 'none'}")
runs = [self.scripts.run(RASCI, ["agents", "order", ",".join(names)])]
elif action == "estimates":
estimation = self.settings.get()["estimation"]
if estimation != "points":
raise Conflict({"estimation": estimation, "reason": "estimates are requested only in story points mode"})
runs = [self.scripts.run(ESTIMATE, ["request", "--session", HUMAN])]
elif action in NIGHTS:
command, text = NIGHTS[action]
runs = [self.scripts.run(NIGHT, [*command, "--session", HUMAN])]
@@ -14,6 +14,7 @@ from .fields import FIELDS, NAME_LIMIT, expect
from .records import read_json, write_atomic
THEMES = ("sky", "light", "dark", "custom")
ESTIMATIONS = ("none", "points")
TONES = ("mayday", "review", "direct", "radio")
COLOR = re.compile(r"#[0-9a-f]{6}")
CLOCK = re.compile(r"(?:[01][0-9]|2[0-3]):[0-5][0-9]")
@@ -44,6 +45,7 @@ SETTINGS = {
"graph": {"axis": "none", "yaw": 30.0, "pitch": 20.0, "zoom": 1.0, "pan_x": 0.0, "pan_y": 0.0},
"sky": {"azimuth": 180.0, "altitude": 0.0, "fov": 180.0},
},
"estimation": "none",
}
SPACE_AXES = ("none", "time", "spawn")
SPACE_LIMITS = {
@@ -189,6 +191,8 @@ def valid_settings(raw: object) -> dict:
raise ToolError(f"theme must be one of {', '.join(THEMES)}")
if not isinstance(data["streams"], list) or len(data["streams"]) > STREAMS_LIMIT:
raise ToolError(f"streams must be a list of at most {STREAMS_LIMIT} entries")
if not isinstance(data["estimation"], str) or data["estimation"] not in ESTIMATIONS:
raise ToolError(f"estimation must be one of {', '.join(ESTIMATIONS)}")
return {
"latitude": number_in(data["latitude"], "latitude", -90.0, 90.0),
"longitude": number_in(data["longitude"], "longitude", -180.0, 180.0),
@@ -201,6 +205,7 @@ def valid_settings(raw: object) -> dict:
"streams": [stream_entry(entry, index) for index, entry in enumerate(data["streams"])],
"speech": speech_setting(data["speech"]),
"space": space_setting(data["space"]),
"estimation": data["estimation"],
}
@@ -13,6 +13,7 @@ RADIO = "kvb-org-radio/scripts/radio.py"
RASCI = "kvb-org-rasci/scripts/rasci.py"
NIGHT = "kvb-org-suspend/scripts/night.py"
OPERATOR_SCRIPT = "kvb-org-operator/scripts/operator.py"
ESTIMATE = "kvb-org-queue-estimate/scripts/estimate.py"
class Runner(Protocol):
+5 -3
View File
@@ -9,7 +9,7 @@ from .register import REGISTER, read_register, status_of
from .signed import signing
from .tasks import editable
CHANGE_LIMIT = 10
CHANGE_LIMIT = 100
CHANGE_KEYS = ("kind", "id", "argv")
@@ -25,7 +25,9 @@ class Tasking:
def task_changes(self, raw: object) -> list[dict]:
"""Args:
raw: the page's items, each {"kind": "task", "id", "argv"} with
the rasci.py arguments of one priority pin or WSJF estimate.
the rasci.py arguments of one priority pin, WSJF estimate or
operator business value; a bulk change over a selection is
one item per task under one passkey assertion.
Returns:
The signed items, each for an open register task outside
@@ -60,7 +62,7 @@ class Tasking:
items = self.task_changes(payload["items"])
proof = self.covered(payload["proof"], items, time.time())
for item in items:
self.still_signed(proof, item)
self.operator_proven(proof, item)
ids = []
for item in items:
fields = {"kind": TASK_JOB, "task": item["id"], "argv": item["argv"], "command": shown_command(item["argv"]), "directory": "", "sha256": "", "status": "queued", "proof": proof}
+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
+7 -3
View File
@@ -36,7 +36,7 @@ from .reviewing import review_row
from .settings import media_origins, station_origins, valid_settings
from .signed import signing
from .skill_scripts import OUTPUT_LIMIT
from .tasks import task_rows
from .tasks import queue_chain, task_edges, task_rows
RADIO_WINDOW = 86400
JOBS_SHOWN = 20
@@ -103,6 +103,9 @@ class View:
people = {node["id"]: {"name": node["label"], "now": directory.now(node["id"]) or node["label"], "names": node["names"]} for node in drawn["nodes"] if node["kind"] != "subagent"}
settings = self.settings.get()
inbox = self.inbox(now, tasks, directory)
queue = queue_entries(self.council)
order = stored_order(self.council / AGENT_ORDER)
rows = task_rows(tasks, queue, self.tree.get())
return {
"settings": settings,
"media": media_origins(settings),
@@ -128,8 +131,9 @@ class View:
"messages": [message_row(message) for message in self.messages.listed() if begin <= message["created_ts"] <= end],
"operator": operator_rows(tasks),
"register": register_rows(tasks),
"tasks": task_rows(tasks, queue_entries(self.council), self.tree.get()),
"agent_order": agent_order(tasks, claims, stored_order(self.council / AGENT_ORDER)),
"tasks": rows,
"task_edges": task_edges(rows, queue_chain(queue, tasks, order, now)),
"agent_order": agent_order(tasks, claims, order),
"radio": radio,
"claims": claims,
"grants": grant_rows,
+6
View File
@@ -453,6 +453,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "popup.js" %}
{% include "faces.js" %}
{% include "plan.js" %}
</script>
<script nonce="{{ nonce }}">
{# Jinja drops each file's last newline and trim_blocks the one after the tag: the blank lines keep consecutive modules on separate lines #}
@@ -525,6 +527,10 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-tasks-prio.js" %}
{% include "page-tasks-gantt.js" %}
{% include "page-choices.js" %}
{% include "page-time.js" %}
{% include "page-pulses.js" %}
+2 -2
View File
@@ -3,12 +3,12 @@ const Nav = (() => {
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Firewall"], ["radio", "Radio"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"]];
const TASK_VIEWS = [["list", "List"], ["board", "Board"], ["rasci", "RASCI"], ["gantt", "Gantt"], ["burn", "Burndown and burnup"]];
const OWN = {agents: [["hierarchy", "Hierarchy"]]};
const ICONS = {
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",
list: "bi-list-task", board: "bi-kanban", rasci: "bi-grid-3x3", gantt: "bi-calendar-range", burn: "bi-graph-down-arrow",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
+72
View File
@@ -0,0 +1,72 @@
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();
}
+1 -1
View File
@@ -8,7 +8,7 @@
const PART_TONES = {user: "info", assistant: "ok", tool_call: "violet", tool_result: "teal", system: "neutral"};
const KIND_TONES = {radio: "teal", review: "info", ask: "warn", decision: "violet", operator: "bad", register: "neutral", rename: "info", cron: "teal", rule: "ok", approval: "ok", run: "info", transcript: "neutral"};
const VIEW_ICONS = {"fuer-dich": "bi-person-check", funk: "bi-broadcast", arbeit: "bi-briefcase", transkripte: "bi-chat-left-text"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Decision", operator: "Operator", rule: "Rule request"};
const ITEM_LABELS = {review: "Review", ask: "Ask", decision: "Choice", operator: "Operator", rule: "Rule request"};
const NIGHT = {day: ["Day", "ok"], drain: ["Draining", "warn"], suspend: ["Suspended", "violet"]};
const ACTIONS = [["clear-mayday", "Cancel mayday", "Lift the radio silence of the active mayday?"], ["drain", "Drain", "Drain all agents: finish the current task, accept nothing new (kvb-agent-drain)?"], ["suspend", "Suspend", "Suspend all agents: park work, write the handover, idle (kvb-agent-suspend)?"], ["resume", "Resume", "Resume all agents from their handover (kvb-agent-resume)?"]];
const UPLOAD_LIMIT = 50 * 1024 * 1024;
+3 -3
View File
@@ -57,13 +57,13 @@
openItem(item);
return null;
}
return submit([entryOf(item, "approve", item.recommended)], `Decision: ${item.recommended}`);
return submit([entryOf(item, "approve", item.recommended)], `Choice: ${item.recommended}`);
}
return submit([entryOf(item, "approve", "")], `${item.kind === "operator" ? "Run" : "Grant"}: ${short(item.title)}`);
}
const rejectOne = (item) => submit([entryOf(item, "reject", "")], `Denial: ${short(item.title)}`);
const chooseOne = (item, label) => submit([entryOf(item, "approve", label)], `Decision: ${label}`);
const chooseOne = (item, label) => submit([entryOf(item, "approve", label)], `Choice: ${label}`);
function bulk(items, label) {
const usable = items.filter(selectable);
@@ -162,7 +162,7 @@
item.kind === "rule" ? ruleRequestBody(item) : null,
item.kind === "ask" ? h("div", {class: "muted"}, `in ${dash(item.directory)}`) : null,
item.kind === "decision" ? h("div", {class: "options"}, item.options.map((option) => h("button", {
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
type: "button", class: `option${option.label === item.recommended ? " recommended" : ""}`, title: `${option.label}: your passkey signs this answer, a post to ${item.agent}`, disabled: item.held, on: {click: () => chooseOne(item, option.label)},
}, h("strong", {}, option.label), option.description ? h("span", {}, option.description) : null))) : null),
h("div", {class: "item-actions"},
actionsFor(item),
+2 -1
View File
@@ -67,7 +67,7 @@
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren(screenOf());
document.body.classList.toggle("fit", ["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen]));
document.body.classList.toggle("fit", ["tasks", "inbox"].includes(ui.screen) || (["agents", "artifacts"].includes(ui.screen) && !["tree", "hierarchy"].includes(ui.views[ui.screen])));
drawn.screen = ui.screen;
drawn.sig = sig;
if (g3.host && !g3.host.isConnected) {
@@ -81,6 +81,7 @@
drawInbox();
drawRadio();
drawTasks();
drawChoices();
drawStatus();
tick();
placePopup();
+1
View File
@@ -162,6 +162,7 @@
muted: [...absent, ...stations.filter((entry) => !entry.box.checked).map((entry) => entry.key)],
},
space: savedSpace(),
estimation: settings.estimation,
};
if (await saveSettings(next)) {
closeSheet();
+216
View File
@@ -0,0 +1,216 @@
const GANTT = {label: 240, right: 28, top: 34, lane: 30, width: 1180, bar: 16, ticks: 4};
const BURN = {width: 900, height: 340, left: 52, right: 24, top: 20, bottom: 40};
const EDGE_WORDS = {after: "Follows (rasci.py set --after)", base: "Branches from (base branch)", queue: "Waits behind (queue order)"};
const SERIES_WORDS = {scope: "Scope (burnup)", done: "Done (burnup)", remaining: "Remaining (burndown)", unestimated: "Unestimated tasks"};
const LINE_OFFICE = /^(?!work\/|cluster\/)[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/;
const estimation = () => settings.estimation;
const cut = (text, size) => (text.length > size ? `${text.slice(0, size - 1)}…` : text);
const lineHolders = () => Object.fromEntries(state.claims.filter((claim) => LINE_OFFICE.test(claim.office) && claim.agent).map((claim) => [claim.office, claim.agent]));
function ganttDefs() {
return s("defs", {},
s("pattern", {id: "gantt-hatch", width: 8, height: 8, patternUnits: "userSpaceOnUse", patternTransform: "rotate(45)"},
s("rect", {class: "hatch-ground", width: 8, height: 8}), s("line", {class: "hatch-line", x1: 0, y1: 0, x2: 0, y2: 8})),
s("marker", {id: "gantt-tip", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 8, markerHeight: 8, markerUnits: "userSpaceOnUse", orient: "auto-start-reverse"},
s("path", {class: "gantt-tip", d: "M0,0 L10,5 L0,10 z"})));
}
function ganttAxis(svg, x, begin, end, height) {
for (let index = 0; index <= GANTT.ticks; index += 1) {
const ts = begin + ((end - begin) * index) / GANTT.ticks;
svg.append(s("line", {class: "grid", x1: fixed(x(ts)), x2: fixed(x(ts)), y1: GANTT.top - 6, y2: height - 4}));
svg.append(s("text", {class: "axis", x: fixed(x(ts)), y: GANTT.top - 14, "text-anchor": index === 0 ? "start" : index === GANTT.ticks ? "end" : "middle"}, (end - begin > DAY ? absolute : clock).format(stampOf(ts))));
}
}
function ganttMark(row, bar, x, middle) {
const tone = Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status][1] : "neutral";
const classes = `${bar.dot ? "gantt-dot" : "gantt-bar"} tone-${tone}${bar.forecast ? " forecast" : ""}`;
const attrs = {class: classes, "data-task": bar.task, "data-start": bar.start, "data-end": bar.end, "data-forecast": String(bar.forecast)};
const words = s("title", {}, `${row.task}: ${bar.forecast ? "forecast " : ""}${absolute.format(stampOf(bar.start))} to ${absolute.format(stampOf(bar.end))}`);
if (bar.dot) {
return s("circle", {...attrs, cx: fixed(x(bar.start)), cy: fixed(middle), r: 5}, words);
}
return s("rect", {...attrs, x: fixed(x(bar.start)), y: fixed(middle - GANTT.bar / 2), width: fixed(Math.max(x(bar.end) - x(bar.start), 2)), height: GANTT.bar, rx: 3}, words);
}
function ganttChart(rows) {
const now = state.now;
const drawnBars = Plan.bars(rows, state.task_edges, {now, estimation: estimation(), forecast: taskView.forecast});
if (!drawnBars.length) {
return emptyState("No bar", "No shown task has started, and real only draws no forecast.");
}
const lanes = new Map(drawnBars.map((bar, index) => [bar.task, index]));
const bars = new Map(drawnBars.map((bar) => [bar.task, bar]));
const begin = Math.min(now, ...drawnBars.map((bar) => bar.start));
const end = Math.max(now, ...drawnBars.map((bar) => bar.end));
const span = Math.max(end - begin, 3600);
const height = GANTT.top + drawnBars.length * GANTT.lane + 10;
const x = (ts) => GANTT.label + ((ts - begin) / span) * (GANTT.width - GANTT.label - GANTT.right);
const middle = (task) => GANTT.top + lanes.get(task) * GANTT.lane + GANTT.lane / 2;
const svg = s("svg", {class: "chart gantt", width: GANTT.width, height, viewBox: `0 0 ${GANTT.width} ${height}`, role: "img", "aria-label": "Gantt: one bar per task from start to end, hatched bars are forecasts, arrows lead from a task to the one that follows it"}, ganttDefs());
ganttAxis(svg, x, begin, begin + span, height);
const byTask = new Map(rows.map((row) => [row.task, row]));
drawnBars.forEach((bar, index) => {
const row = byTask.get(bar.task);
const top = GANTT.top + index * GANTT.lane;
svg.append(s("rect", {class: index % 2 ? "lane odd" : "lane", x: 0, y: top, width: GANTT.width, height: GANTT.lane}));
svg.append(s("text", {class: "lane-label", x: 8, y: top + GANTT.lane / 2, dy: "0.35em", "data-task": row.task, on: {click: () => taskSheet(row.task)}}, s("title", {}, `${row.task}: ${titleOf(row)}`), cut(row.task, 30)));
svg.append(ganttMark(row, bar, x, middle(bar.task)));
});
svg.append(s("line", {class: "now", x1: fixed(x(now)), x2: fixed(x(now)), y1: GANTT.top - 6, y2: height - 4}));
const arrows = s("g", {class: "gantt-edges"});
for (const edge of state.task_edges) {
const from = bars.get(edge.from);
const to = bars.get(edge.to);
if (!from || !to) {
continue;
}
const [x1, y1, x2, y2] = [x(from.end), middle(edge.from), x(to.start), middle(edge.to)];
arrows.append(s("path", {class: `gantt-edge k-${edge.kind}`, "data-kind": edge.kind, "data-from": edge.from, "data-to": edge.to, d: `M${fixed(x1)},${fixed(y1)} C${fixed(x1 + 28)},${fixed(y1)} ${fixed(x2 - 28)},${fixed(y2)} ${fixed(x2)},${fixed(y2)}`, "marker-end": "url(#gantt-tip)"},
s("title", {}, `${edge.to}: ${EDGE_WORDS[edge.kind] || edge.kind} ${edge.from}`)));
}
svg.append(arrows);
const legend = h("div", {class: "legend"}, h("span", {class: "gantt-key forecast"}, "Forecast (hatched)"), Object.entries(EDGE_WORDS).map(([kind, words]) => h("span", {class: `gantt-key k-${kind}`}, words)));
return h("div", {class: "gantt-box"}, h("div", {class: "gantt-scroll"}, svg), legend);
}
function burnAxis(svg, from, to, top) {
const x = (t) => BURN.left + ((t - from) / Math.max(to - from, 1)) * (BURN.width - BURN.left - BURN.right);
const y = (value) => BURN.top + (1 - value / top) * (BURN.height - BURN.top - BURN.bottom);
for (const value of [0, top / 2, top]) {
svg.append(s("line", {class: "grid", x1: BURN.left, x2: BURN.width - BURN.right, y1: fixed(y(value)), y2: fixed(y(value))}));
svg.append(s("text", {class: "axis", x: BURN.left - 8, y: fixed(y(value)), dy: "0.35em", "text-anchor": "end"}, String(Math.round(value * 10) / 10)));
}
for (const [index, t] of [from, (from + to) / 2, to].entries()) {
svg.append(s("text", {class: "axis", x: fixed(x(t)), y: BURN.height - 14, "text-anchor": ["start", "middle", "end"][index]}, (to - from > DAY ? absolute : clock).format(stampOf(t))));
}
return {x, y};
}
function burnChart(rows, shown) {
const picked = Plan.scoped(rows, taskView.scope, {filtered: shown, since: state.since, until: state.until, now: state.now, holders: lineHolders()});
const series = Plan.burn(picked.rows, {estimation: estimation(), from: picked.from, to: picked.to}).points;
const names = estimation() === "points" ? Plan.SERIES : Plan.SERIES.filter((name) => name !== "unestimated");
const top = Math.max(1, ...series.flatMap((point) => names.map((name) => point[name])));
const unit = estimation() === "points" ? "story points; unestimated tasks as a line of their own" : "tasks";
const svg = s("svg", {class: "chart burn", viewBox: `0 0 ${BURN.width} ${BURN.height}`, role: "img", "aria-label": `Burndown and burnup in ${unit}`});
const {x, y} = burnAxis(svg, picked.from, picked.to, top);
const last = series[series.length - 1];
for (const name of names) {
const d = series.map((point, index) => (index ? `H${fixed(x(point.t))} V${fixed(y(point[name]))}` : `M${fixed(x(point.t))},${fixed(y(point[name]))}`)).join(" ");
svg.append(s("path", {class: `burn-line k-${name}`, "data-series": name, "data-last": String(last[name]), d}, s("title", {}, `${SERIES_WORDS[name]}: ${last[name]}`)));
}
const legend = h("div", {class: "legend"}, names.map((name) => h("span", {class: `burn-key k-${name}`, "data-series": name}, `${SERIES_WORDS[name]}: ${last[name]}`)));
return h("div", {class: "burn-box"}, svg, legend, h("p", {class: "muted"}, `${picked.rows.length} tasks in scope, counted in ${unit}`));
}
async function chooseEstimation(id) {
if (id === estimation()) {
return;
}
if (await saveSettings({...settings, estimation: id})) {
toast(`Estimation: ${Plan.ESTIMATIONS.find(([name]) => name === id)[1]}`, [], true);
}
drawTasks();
}
async function requestEstimates() {
const signed = await signedProof("/api/board/sign", {action: "estimates"}, "Request estimates");
if (signed) {
await act("estimates", "Request estimates", {proof: signed.proof});
}
}
function sortControls() {
const select = h("select", {class: "field", "aria-label": "Sort", on: {change: (event) => {
taskView.sort = event.target.value;
drawTasks();
}}}, Plan.SORTS.map(([id, label]) => h("option", {value: id, selected: id === taskView.sort}, label)));
const turn = (button) => relabel(button, taskView.descending ? "bi-sort-up" : "bi-sort-down", taskView.descending ? "Descending" : "Ascending", `${taskView.descending ? "Descending" : "Ascending"}: click to reverse`);
const direction = h("button", {type: "button", class: "btn small icon-only", "data-direction": "", on: {click: (event) => {
taskView.descending = !taskView.descending;
turn(event.currentTarget);
drawTasks();
}}});
turn(direction);
return h("div", {class: "plan-group", role: "group", "aria-label": "Sort order"}, select, direction);
}
function bulkControls() {
const value = fibonacciPicker("Business value of the selection", 8);
const count = counter(0);
const button = h("button", {type: "button", class: "btn small primary", id: "bulk-value", disabled: true, ...named("Set business value", "Set the business value of every selected task as Operator, signed once with the passkey"), on: {click: () => bulkValue(Number(value.value))}},
icon("bi-gem"), "Set business value", count);
taskView.bulk = {button, count};
return h("div", {class: "plan-group", role: "group", "aria-label": "Business value of the selection"}, value, button);
}
function forecastControl() {
return h("label", {class: "plan-group"}, "Forecast", h("select", {class: "field", "aria-label": "Forecast", on: {change: (event) => {
taskView.forecast = event.target.value;
drawTasks();
}}}, Plan.FORECASTS.map(([id, label]) => h("option", {value: id, selected: id === taskView.forecast}, label))));
}
function scopeChoices() {
const agents = [...new Set(state.tasks.filter((row) => row.kind !== "queue").flatMap((row) => [row.worker, ...Object.keys(row.roles)]).filter((name) => name && name !== "-"))].sort((a, b) => a.localeCompare(b, "en"));
return [["all", "Whole register"], ["filter", "List filter in the time window"], ...agents.map((name) => [`agent:${name}`, `Agent: ${name}`]), ...Object.entries(lineHolders()).map(([office, holder]) => [`line:${office}`, `Line: ${office} (${holder})`])];
}
function scopeControl() {
const choices = scopeChoices();
if (!choices.some(([id]) => id === taskView.scope)) {
taskView.scope = "all";
}
return h("label", {class: "plan-group"}, "Scope", h("select", {class: "field", "aria-label": "Scope", on: {change: (event) => {
taskView.scope = event.target.value;
drawTasks();
}}}, choices.map(([id, label]) => h("option", {value: id, selected: id === taskView.scope}, label))));
}
function planBar(view) {
const modes = h("div", {class: "segmented", role: "group", "aria-label": "Estimation"}, Plan.ESTIMATIONS.map(([id, label]) => h("button", {type: "button", "data-estimation": id, "aria-pressed": String(estimation() === id), title: `${label}: ${id === "points" ? "the Fibonacci complexity of rasci.py estimate" : "every task counts 1"}`, on: {click: () => chooseEstimation(id)}}, label)));
const request = h("button", {type: "button", class: "btn small", id: "request-estimates", hidden: estimation() !== "points", ...named("Request estimates", "Request estimates: one newsletter asks every agent with a RASCI role in an unestimated task for business value and complexity"), on: {click: requestEstimates}}, icon("bi-send-check"), "Request estimates");
const own = {list: () => [sortControls(), bulkControls()], gantt: forecastControl, burn: scopeControl}[view];
taskView.plan = {modes, request};
return h("div", {class: "card controls plan-bar", role: "toolbar", "aria-label": "Plan"}, modes, request, own ? own() : null);
}
function syncPlan() {
const plan = taskView.plan;
if (!plan) {
return;
}
for (const button of plan.modes.querySelectorAll("button")) {
button.setAttribute("aria-pressed", String(button.dataset.estimation === estimation()));
}
plan.request.hidden = estimation() !== "points";
if (taskView.bulk) {
const picked = state.tasks.filter((row) => row.editable && taskView.picked.has(row.task)).length;
taskView.bulk.count.textContent = String(picked);
taskView.bulk.button.disabled = picked === 0;
}
}
function pickTask(task, on) {
if (on) {
taskView.picked.add(task);
} else {
taskView.picked.delete(task);
}
syncPlan();
}
async function bulkValue(value) {
const rows = state.tasks.filter((row) => row.editable && taskView.picked.has(row.task));
if (!rows.length) {
return;
}
const changes = rows.map((row) => ({task: row.task, argv: ["estimate", row.task, ESTIMATOR, "--value", String(value)]}));
if (await changeTasks(changes, `Business value ${value} for ${rows.length} tasks`, null)) {
taskView.picked.clear();
taskView.sig = "";
drawTasks();
}
}
+29 -13
View File
@@ -8,23 +8,29 @@
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"}, "Signing with the passkey …"));
async function changeTasks(changes, label, outcome) {
const items = changes.map(({task, argv}) => ({kind: "task", id: task, argv}));
const tell = (attrs, text) => (outcome ? outcome.replaceChildren(h("p", {class: "muted", ...attrs}, text)) : null);
tell({}, "Signing with the passkey …");
const signed = await proofFor(items, label);
if (signed === null) {
outcome.replaceChildren(h("p", {class: "muted"}, "Not signed: nothing was handed over."));
return;
tell({}, "Not signed: nothing was handed over.");
return false;
}
const result = await sendJson("/api/tasks", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast(`${label}: not possible`, fail(result), false);
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
return;
tell({}, `${result.status} ${result.text}`.trim());
return false;
}
outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signed and handed to the runner (job ${result.data.jobs.join(", ")}); it calls rasci.py on the host.`));
toast(`${label}: handed to the runner`, [shownArgv(argv)], true);
tell({"data-jobs": result.data.jobs.join(" ")}, `Signed and handed to the runner (job ${result.data.jobs.join(", ")}); it calls rasci.py on the host.`);
toast(`${label}: handed to the runner`, changes.map(({argv}) => shownArgv(argv)), true);
await load();
return true;
}
function changeTask(row, argv, label, outcome) {
return changeTasks([{task: row.task, argv}], label, outcome);
}
function priorityForm(row) {
@@ -32,23 +38,29 @@
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": "Priority", placeholder: "1"});
const value = fibonacciPicker("Business value", own ? own.value : 5);
const complexity = fibonacciPicker("Complexity", own ? own.complexity : 3);
const complexity = fibonacciPicker("Complexity", own && own.complexity ? own.complexity : 3);
const alone = fibonacciPicker("Business value as Operator", own ? own.value : 8);
const pinCode = h("code", {});
const estimateCode = h("code", {});
const valueCode = h("code", {});
const prioArgv = () => ["set", row.task, "--prio", prioText(prio.value)];
const estimateArgv = () => ["estimate", row.task, ESTIMATOR, "--value", value.value, "--complexity", complexity.value];
const valueArgv = () => ["estimate", row.task, ESTIMATOR, "--value", alone.value];
const pin = h("button", {type: "button", class: "btn small primary", ...named("Pin", "Pin: sign the priority with the passkey")}, icon("bi-pin-angle-fill"), "Pin");
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Save estimate", "Hand the estimate to the runner, signed")}, icon("bi-calculator"), "Save estimate");
const setValue = h("button", {type: "button", class: "btn small primary", ...named("Set business value", "Set the business value as Operator, signed with the passkey; WSJF uses it instead of the median")}, icon("bi-gem"), "Set business value");
const sync = () => {
pin.disabled = !prioText(prio.value);
pinCode.textContent = pin.disabled ? `Enter a priority from 1 to ${PRIO_MAX}` : shownArgv(prioArgv());
estimateCode.textContent = shownArgv(estimateArgv());
valueCode.textContent = shownArgv(valueArgv());
};
for (const field of [prio, value, complexity]) {
for (const field of [prio, value, complexity, alone]) {
field.addEventListener("input", sync);
}
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priority ${row.task}`, outcome));
estimate.addEventListener("click", () => changeTask(row, estimateArgv(), `WSJF ${row.task}`, outcome));
setValue.addEventListener("click", () => changeTask(row, valueArgv(), `Business value ${row.task}`, outcome));
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Discard estimate", "Discard estimate: delete your estimate, signed with the passkey"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Discard estimate") : null;
sync();
return h("section", {class: "task-edit", "aria-label": "Priority and WSJF"},
@@ -58,6 +70,10 @@
h("h3", {}, `Estimate WSJF as ${ESTIMATOR}`),
h("div", {class: "task-form"}, h("label", {}, "Business value", value), h("label", {}, "Complexity", complexity), estimate, drop),
h("div", {class: "command"}, estimateCode),
h("h3", {}, `Business value as ${ESTIMATOR}`),
h("div", {class: "task-form"}, h("label", {}, "Business value", alone), setValue),
h("div", {class: "command"}, valueCode),
h("p", {class: "muted"}, "WSJF uses your business value instead of the median of the agents' values."),
h("p", {class: "muted"}, "Your passkey signs exactly this call; the runner on the host checks the signature and the shape of the arguments and starts rasci.py without a shell. The agent priority stays untouched."),
onLocalhost() ? null : h("p", {class: "muted"}, "Signing works only under ", localLink()),
outcome);
@@ -73,8 +89,8 @@
const entries = Object.entries(row.estimates);
const score = row.wsjf;
return [h("h3", {}, "WSJF estimates", 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"}, `Business value ${entry.value} · complexity ${entry.complexity}`))))) : h("p", {class: "muted"}, "No estimate yet: the task ranks after the estimated ones."),
score ? h("p", {class: "muted"}, `Median ${score.value} / ${score.complexity} = WSJF ${score.score}`) : null];
entries.length ? h("ul", {class: "mini"}, entries.map(([party, entry]) => h("li", {}, h("div", {class: "row"}, h("strong", {}, party), h("span", {class: "muted"}, `Business value ${entry.value} · complexity ${dash(entry.complexity)}`))))) : h("p", {class: "muted"}, "No estimate yet: the task ranks after the estimated ones."),
score ? h("p", {class: "muted"}, `${Object.hasOwn(row.estimates, ESTIMATOR) ? `${ESTIMATOR}'s business value` : "Median business value"} ${score.value} / median complexity ${score.complexity} = WSJF ${score.score}`) : null];
}
function taskJobs(row) {
+23 -7
View File
@@ -5,14 +5,14 @@
["all", "All", "bi-list-check", () => true],
["open", "Open", "bi-play-circle", (row) => !row.ended],
["work", "Open work", "bi-pencil-square", (row) => row.open_work.length > 0],
["operator", "Waiting for operator", "bi-person-raised-hand", null],
["operator", "Waits on Operator", "bi-person-raised-hand", null],
["grant", "Waiting for a grant", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
["ended", "Ended", "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: "all", q: "", dom: null, sig: ""};
const taskView = {filter: "all", q: "", dom: null, sig: "", sort: "prio", descending: false, forecast: "always", scope: "all", picked: new Set(), plan: null, bulk: null};
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
@@ -56,8 +56,9 @@
function taskItem(row) {
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
const pick = row.editable ? h("input", {type: "checkbox", class: "task-pick", "aria-label": `Select ${row.task}`, title: `Select ${row.task} for a bulk business value`, checked: taskView.picked.has(row.task), on: {change: (event) => pickTask(row.task, event.target.checked)}}) : null;
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"}),
h("div", {class: "head-row"}, pick, h("code", {class: "task-id"}, row.task), taskBadges(row, true), h("span", {class: "spacer"}),
row.editable ? iconBtn("bi-sort-numeric-down", "Priority", "btn small soft", () => taskSheet(row.task), `Priority and WSJF of ${row.task}: pin or estimate, signed with the passkey`) : null),
opener(row),
row.objective !== "-" ? h("div", {class: "muted"}, `Objective: ${row.objective}`) : null,
@@ -148,7 +149,9 @@
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);
taskView.plan = null;
taskView.bulk = null;
return h("div", {class: "graph tasks"}, h("div", {class: "card controls"}, taskViewSwitch(inbox), search, count), inbox ? null : planBar(view), choiceBar(), chips, body);
}
function drawTasks() {
@@ -157,12 +160,19 @@
return;
}
const q = taskView.q.trim().toLowerCase();
const sig = JSON.stringify([state.tasks, state.jobs, taskView.filter, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
const sig = JSON.stringify([state.tasks, state.jobs, state.task_edges, settings.estimation, taskView.filter, taskView.sort, taskView.descending, taskView.forecast, taskView.scope, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
if (sig === taskView.sig) {
return;
}
taskView.sig = sig;
const rows = state.tasks;
const editable = new Set(rows.filter((row) => row.editable).map((row) => row.task));
for (const task of [...taskView.picked]) {
if (!editable.has(task)) {
taskView.picked.delete(task);
}
}
syncPlan();
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
if (dom.inbox) {
dom.count.textContent = `${rows.length} tasks in the register`;
@@ -172,6 +182,12 @@
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} of ${rows.length} tasks`;
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("No task", "No entry matches this filter."));
const views = {
list: (found) => h("ol", {class: "task-list"}, Plan.sortRows(found, taskView.sort, taskView.descending).map(taskItem)),
board: taskBoard,
rasci: taskMatrix,
gantt: ganttChart,
burn: (found) => burnChart(rows, found),
};
dom.body.replaceChildren(shown.length || dom.view === "burn" ? views[dom.view](shown) : emptyState("No task", "No entry matches this filter."));
}
+176
View File
@@ -0,0 +1,176 @@
const Plan = (() => {
"use strict";
const FORECASTS = [["always", "always"], ["points", "points only"], ["real", "real only"]];
const ESTIMATIONS = [["none", "No estimate"], ["points", "Story points"]];
const SORTS = [["prio", "Prio"], ["wsjf", "WSJF"], ["value", "Business value"], ["complexity", "Complexity"], ["status", "Status"], ["start", "Start"], ["end", "End"], ["agent", "Agent"], ["title", "Title"]];
const STATUS_ORDER = ["queued", "awaiting", "claimed", "running", "blocked", "stale", "done", "stopped"];
const SERIES = ["scope", "done", "remaining", "unestimated"];
const OUTSIDE = ["queue", "command"];
function median(values) {
const sorted = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
if (!sorted.length) {
return null;
}
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}
/**
* @param rows - task rows with status, start, end (epoch seconds or null) and points (median complexity or null).
* @returns {duration, perPoint}: the median seconds a done task took from start to end, and the median seconds
* per point of the done tasks with points; null where no done task gives one.
*/
function medians(rows) {
const done = rows.filter((row) => row.status === "done" && row.start !== null && row.end !== null && row.end >= row.start);
return {
duration: median(done.map((row) => row.end - row.start)),
perPoint: median(done.filter((row) => row.points).map((row) => (row.end - row.start) / row.points)),
};
}
/**
* @param row - a task row not yet started.
* @param estimation - "none" or "points".
* @param forecast - "always" or "points" (points only).
* @param known - medians() of the rows.
* @returns the forecast length in seconds: points times the median seconds per point when story points apply,
* else under always the median duration of done tasks; 0 (a dot) where neither applies.
*/
function forecastLength(row, estimation, forecast, known) {
if (estimation === "points" && row.points && known.perPoint !== null) {
return row.points * known.perPoint;
}
return forecast === "always" && known.duration !== null ? known.duration : 0;
}
/**
* @param rows - task rows in their shown order.
* @param edges - {from, to, kind} edges; only those between the rows count.
* @param options - {now, estimation, forecast}.
* @returns one {task, start, end, forecast, dot} per drawn row: a started task from start to its end, an open one
* to now; a task not yet started, unless forecast is "real", as a forecast behind the latest end of its
* predecessors and now, forecastLength() long, a dot when 0. A task never started is left out under "real", an
* ended one without start and end always.
*/
function bars(rows, edges, options) {
const known = medians(rows);
const byTask = new Map(rows.map((row) => [row.task, row]));
const before = new Map(rows.map((row) => [row.task, []]));
for (const edge of edges) {
if (byTask.has(edge.from) && before.has(edge.to) && edge.from !== edge.to) {
before.get(edge.to).push(edge.from);
}
}
const placed = new Map();
const visiting = new Set();
const place = (row) => {
if (placed.has(row.task)) {
return placed.get(row.task);
}
if (visiting.has(row.task)) {
return null;
}
visiting.add(row.task);
let bar = null;
if (row.start !== null) {
const end = row.end !== null ? row.end : row.ended ? row.start : Math.max(options.now, row.start);
bar = {task: row.task, start: row.start, end, forecast: false, dot: end <= row.start};
} else if (row.end !== null) {
bar = {task: row.task, start: row.end, end: row.end, forecast: false, dot: true};
} else if (!row.ended && options.forecast !== "real") {
const ahead = before.get(row.task).map((task) => place(byTask.get(task))).filter(Boolean);
const start = Math.max(options.now, ...ahead.map((found) => found.end));
const length = forecastLength(row, options.estimation, options.forecast, known);
bar = {task: row.task, start, end: start + length, forecast: true, dot: length <= 0};
}
visiting.delete(row.task);
placed.set(row.task, bar);
return bar;
};
return rows.map(place).filter(Boolean);
}
/**
* @param rows - task rows.
* @param choice - "all" (the whole register over the whole time), "filter" (the filtered rows within the header
* window), "agent:<name>" (the tasks the agent works on or holds a RASCI role in) or "line:<office>" (those of
* the office's holder, over the whole time).
* @param context - {filtered, since, until, now, holders: {office: holder}}.
* @returns {rows, from, to}: the scope's rows without queue jobs and commands and its time range.
*/
function scoped(rows, choice, context) {
const own = (name) => (row) => row.worker === name || Object.hasOwn(row.roles, name);
const [kind, ...rest] = String(choice).split(":");
const key = rest.join(":");
const chosen = kind === "filter" ? context.filtered : kind === "agent" ? rows.filter(own(key)) : kind === "line" ? rows.filter(own(context.holders[key])) : rows;
const kept = chosen.filter((row) => !OUTSIDE.includes(row.kind));
if (kind === "filter") {
return {rows: kept, from: context.since, to: context.until};
}
const first = Math.min(...kept.flatMap((row) => [row.start, row.end]).filter((value) => value !== null));
return {rows: kept, from: Number.isFinite(first) ? Math.min(first, context.now) : context.now - 86400, to: context.now};
}
/**
* @param rows - the scope's task rows.
* @param options - {estimation, from, to}.
* @returns {points: [{t, scope, done, remaining, unestimated}]}, a step series at from, at every start or end
* inside the range and at to. A task joins the scope at its start (or end), from the range start when it has
* neither; a done task burns at its end and a stopped one leaves the scope then. With no estimate each task
* weighs 1; with story points it weighs its points and unestimated counts the open tasks without points.
*/
function burn(rows, options) {
const points = options.estimation === "points";
const weigh = (row) => (points ? row.points || 0 : 1);
const joined = (row) => (row.start !== null ? row.start : row.end !== null ? row.end : options.from);
const moments = rows.flatMap((row) => [row.start, row.end]).filter((value) => value !== null && value > options.from && value < options.to);
const times = [...new Set([options.from, ...moments, options.to])].sort((a, b) => a - b);
const at = (t) => {
const present = rows.filter((row) => joined(row) <= t && !(row.status === "stopped" && row.end !== null && row.end <= t));
const burnt = present.filter((row) => row.status === "done" && row.end !== null && row.end <= t);
const scope = present.reduce((sum, row) => sum + weigh(row), 0);
const done = burnt.reduce((sum, row) => sum + weigh(row), 0);
const open = present.filter((row) => !burnt.includes(row));
return {t, scope, done, remaining: scope - done, unestimated: points ? open.filter((row) => !row.points).length : 0};
};
return {points: times.map(at)};
}
const KEYS = {
prio: () => null,
wsjf: (row) => (row.wsjf ? row.wsjf.score : null),
value: (row) => row.value,
complexity: (row) => row.points,
status: (row) => (STATUS_ORDER.includes(row.status) ? STATUS_ORDER.indexOf(row.status) : STATUS_ORDER.length),
start: (row) => row.start,
end: (row) => row.end,
agent: (row) => (row.worker && row.worker !== "-" ? row.worker.toLowerCase() : null),
title: (row) => (row.title && row.title !== "-" ? row.title : row.task).toLowerCase(),
};
/**
* @param rows - task rows in the hub's register rank.
* @param mode - one of SORTS; prio keeps the register rank.
* @param descending - reverses the order of the keys.
* @returns a new array: rows without a key last in both directions, ties in register rank.
*/
function sortRows(rows, mode, descending) {
const key = KEYS[mode] || KEYS.prio;
const sign = descending ? -1 : 1;
const keyed = rows.map((row, index) => ({row, index, key: mode === "prio" ? index : key(row)}));
keyed.sort((a, b) => {
if ((a.key === null) !== (b.key === null)) {
return a.key === null ? 1 : -1;
}
const order = a.key === null ? 0 : typeof a.key === "string" ? a.key.localeCompare(b.key, "en") : a.key - b.key;
return sign * order || a.index - b.index;
});
return keyed.map((entry) => entry.row);
}
return {FORECASTS, ESTIMATIONS, SORTS, SERIES, STATUS_ORDER, median, medians, forecastLength, bars, scoped, burn, sortRows};
})();
if (typeof module === "object" && module.exports) {
module.exports = Plan;
}
+35
View File
@@ -2,6 +2,9 @@
.tasks-body { min-width: 0; }
.tasks .controls input[type="search"] { flex: 1; min-width: min(240px, 100%); }
.tasks > .chips { margin-bottom: 0; }
body.fit .graph.tasks > .tasks-body, body.fit .graph.tasks > .inbox { flex: 1; min-height: 0; overflow: auto; }
.choice-request { gap: var(--sp-2) var(--sp-3); }
.choice-agents { display: grid; gap: 6px; max-height: 40vh; overflow: auto; }
.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); }
@@ -40,5 +43,37 @@ table.matrix th[scope="row"] { z-index: 1; min-width: min(240px, 52vw); max-widt
.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; }
.plan-bar { gap: var(--sp-2) var(--sp-3); }
.plan-bar [hidden] { display: none; }
.plan-group { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--muted); font-size: var(--fs-sm); }
.plan-group select.field { width: auto; min-width: 0; max-width: min(260px, 100%); }
.task-pick { margin: 0; accent-color: var(--accent); }
.gantt-box, .burn-box { display: grid; gap: 8px; min-width: 0; }
.gantt-scroll { max-width: 100%; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
.gantt-scroll svg.gantt { display: block; max-width: none; }
svg .gantt-bar, svg .gantt-dot { fill: color-mix(in srgb, var(--c, var(--accent)) 70%, var(--panel)); stroke: var(--c, var(--accent)); stroke-width: 1; }
svg .gantt-bar.forecast, svg .gantt-dot.forecast { fill: url(#gantt-hatch); stroke-dasharray: 3 2; }
svg .tone-ok { --c: var(--ok); }
svg .tone-bad { --c: var(--bad); }
svg .tone-info { --c: var(--info); }
svg .tone-warn { --c: var(--warn); }
svg .tone-neutral { --c: var(--muted); }
svg .hatch-ground { fill: var(--accent-soft); }
svg .hatch-line { stroke: var(--accent); stroke-width: 3; }
svg .gantt-edge { fill: none; stroke: var(--c); stroke-width: 1.6; opacity: .8; }
svg .gantt-edge.k-after, .gantt-key.k-after { --c: var(--violet); }
svg .gantt-edge.k-base, .gantt-key.k-base { --c: var(--info); }
svg .gantt-edge.k-queue, .gantt-key.k-queue { --c: var(--warn); }
svg .gantt-tip { fill: var(--muted); }
.gantt-key, .burn-key { display: inline-flex; align-items: center; gap: 6px; }
.gantt-key::before, .burn-key::before { content: ""; width: 18px; height: 4px; border-radius: 2px; background: var(--c, var(--accent)); }
.gantt-key.forecast::before { height: 10px; background: repeating-linear-gradient(45deg, var(--accent) 0 3px, var(--accent-soft) 3px 6px); }
svg.burn { width: 100%; height: auto; max-height: 60vh; }
svg .burn-line { fill: none; stroke: var(--c); stroke-width: 2.4; }
svg .burn-line.k-scope, .burn-key.k-scope { --c: var(--info); }
svg .burn-line.k-done, .burn-key.k-done { --c: var(--ok); }
svg .burn-line.k-remaining, .burn-key.k-remaining { --c: var(--accent); }
svg .burn-line.k-unestimated, .burn-key.k-unestimated { --c: var(--warn); }
svg .burn-line.k-unestimated { stroke-dasharray: 6 4; }
svg .node.a-worktree.open circle { stroke: var(--warn); stroke-width: 2.6; stroke-dasharray: 3 2; }
svg .edge.flow { stroke-dasharray: 2 5; }