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:
1 parent
5c8b69d53d
commit
d9eb121d12
49 files changed
+2162
-148
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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']})"
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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] || [])]);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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."));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user