feat(orchestrator): tasks tab with register, inbox and queue

Add a Tasks tab that lists the kvb-org-rasci register next to the inbox
and the heavy-job queue, in list, board and RASCI matrix views, each
task with its roles, WSJF, open work and the merge flow of its worktree.

- Task priority pins and WSJF estimates are signed with the operator's
  passkey and applied by the runner through rasci.py; the hub verifies
  the signature itself before it queues a job or logs the change, the
  runner verifies it again and runs only the checked argv, from /, with
  a pinned PATH and a 30 s process-group limit.
- The runner refuses task changes into a council that is a symlink, not
  owned by its uid or given by a relative path, and while the installed
  council.py, nofollow.py or rasci.py differ from the tree.
- Only tasks the hub can sign whole are editable (task slug and length).
- Page lookups keyed by register text use Object.hasOwn, so a status or
  party named like an Object.prototype member cannot break the views.
- The inbox screen renders through the tasks screen, so its task filter
  shows with "Wartet auf Operator" pressed.

Tests: 604 unit tests OK, e2e 15/15 OK (Chromium and Firefox).

🎯 Fix: 93 %
💥 Break: 5 %
💸 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-07 23:43:36 +00:00
1 parent 01f791a1f4
commit c1310c3322
37 files changed
+1436 -80

No files matched your search

+121 -10
View File
@@ -6,28 +6,42 @@
--approvers: the pinned passkeys, e.g. ~/.config/kvb-orchestrator/approvers.json.
--replay: the runner's replay store, outside the hub's reach, e.g.
~/.local/state/kvb-orchestrator-approvals/runner.json.
--rasci: the installed register script, e.g.
~/.claude/skills/kvb-org-rasci/scripts/rasci.py; optional.
--council: the council directory holding rasci.json, the agents'
AGENTS_COUNCIL_ROOT; optional, task changes need both.
--once: handle the queued jobs once and exit.
Returns:
Runs as the operator, outside any sandbox, every operator command a
pinned passkey signed in the Agent Orchestrator: exactly the signed text, with bash
in the signed directory, each signature once and before its expiry.
Refuses every other job. Posts the last 200 output lines back while it
runs and the exit code at its end. The exit code is 0.
Runs every signed task change (priority pin or WSJF estimate) as
`python3 -I -B <rasci> <argv>` from / without a shell, after checking the
argv shape itself, and kills its process group after 30 s. Refuses task
changes without --rasci and --council or into a council that is a
symlink or another uid's, and every other job. Posts the last 200 output lines
back while it runs and the exit code at its end. The exit code is 0.
"""
from __future__ import annotations
import argparse
import collections
import contextlib
import json
import os
import signal
import stat
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from collections.abc import Callable
from pathlib import Path
from typing import NamedTuple
import hub_api
import signing
@@ -35,7 +49,16 @@ import verifier
LINES = 200
BEAT = 2.0
SAFE_PATH = "/usr/bin:/bin"
TASK_JOB = "task"
TASK_LIMIT = 30.0
REFUSED = "refused: no valid operator signature for this command"
UNREGISTERED = "refused: this runner was started without --rasci and --council; reinstall its unit with make orchestrator-runner"
class Register(NamedTuple):
rasci: Path
council: Path
def call(base: str, path: str, payload: dict | None) -> dict:
@@ -72,6 +95,43 @@ def refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
return None
def task_refusal(job: dict, trust: verifier.Trust, now: float) -> str | None:
"""Args:
job: the claimed task change with task, argv and proof.
trust: the pinned keys, the runner's replay store and the origin.
now: the current time in epoch seconds.
Returns:
Why the change must not run, or None when its argv has one of the
shapes signing.task_argv() allows and a pinned key signed it.
"""
try:
argv = signing.task_argv(job["task"], job["argv"])
verifier.check(job.get("proof"), signing.task_item(job["task"], argv), "", trust, now)
except (signing.Refused, OSError) as error:
return f"{REFUSED}: {error}"
return None
def council_refusal(council: Path) -> str | None:
"""Args:
council: the directory rasci.py writes the register into.
Returns:
Why no task change may run there, or None for a real directory of
the runner's own uid, given by an absolute path.
"""
if not council.is_absolute():
return f"refused: the council {council} is no absolute path"
try:
found = os.lstat(council)
except OSError as error:
return f"refused: the council {council} cannot be read: {error}"
if not stat.S_ISDIR(found.st_mode) or found.st_uid != os.geteuid():
return f"refused: the council {council} is a symlink, no directory or not owned by uid {os.geteuid()}"
return None
def execute(job: dict, report: Callable[[dict], None]) -> int:
"""Args:
job: the claimed and verified job with command and directory.
@@ -81,12 +141,46 @@ def execute(job: dict, report: Callable[[dict], None]) -> int:
Returns:
The exit code.
"""
return spawn(["bash", "-c", job["command"]], job["directory"], None, report)
def change(job: dict, register: Register, report: Callable[[dict], None]) -> int:
"""Args:
job: the claimed and verified task change with task and argv.
register: the register script and the council it writes.
report: called with {"output", "code", "refused"}.
Returns:
The exit code of rasci.py, run from / on the argv task_argv() checked.
Raises:
signing.Refused for an argv of any other shape.
"""
argv = signing.task_argv(job["task"], job["argv"])
env = {"PATH": SAFE_PATH, "HOME": str(Path.home()), "AGENTS_COUNCIL_ROOT": str(register.council)}
return spawn([sys.executable, "-I", "-B", str(register.rasci), *argv], "/", env, report, TASK_LIMIT)
def spawn(argv: list[str], cwd: str, env: dict[str, str] | None, report: Callable[[dict], None], limit: float | None = None) -> int:
"""Args:
argv: the program and its arguments, run without a shell.
cwd: the directory it runs in.
env: its whole environment, or None for the runner's own.
report: called with {"output", "code", "refused"}; code None while
the program runs.
limit: seconds until its whole process group is killed, or None to
let it run.
Returns:
The exit code, -9 after the kill.
"""
tail: collections.deque = collections.deque(maxlen=LINES)
lock = threading.Lock()
try:
process = subprocess.Popen(
["bash", "-c", job["command"]],
cwd=job["directory"],
argv,
cwd=cwd,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@@ -99,11 +193,19 @@ def execute(job: dict, report: Callable[[dict], None]) -> int:
return 127
reader = threading.Thread(target=collect, args=(process.stdout, tail, lock), daemon=True)
reader.start()
deadline = None if limit is None else time.monotonic() + limit
while True:
try:
code = process.wait(BEAT)
code = process.wait(BEAT if deadline is None else max(0.0, min(BEAT, deadline - time.monotonic())))
break
except subprocess.TimeoutExpired:
if deadline is not None and time.monotonic() >= deadline:
with contextlib.suppress(ProcessLookupError):
os.killpg(process.pid, signal.SIGKILL)
code = process.wait()
with lock:
tail.append(f"killed after {limit:g} s\n")
break
with lock:
output = "".join(tail)
report({"output": output, "code": None, "refused": False})
@@ -122,18 +224,24 @@ def sender(base: str, job_id: str) -> Callable[[dict], None]:
return send
def cycle(base: str, trust: verifier.Trust) -> None:
def cycle(base: str, trust: verifier.Trust, register: Register | None) -> None:
for job in call(base, "/api/runner/jobs", None)["jobs"]:
try:
claimed = call(base, f"/api/runner/{job['id']}/claim", {})
except urllib.error.HTTPError:
continue
send = sender(base, claimed["id"])
reason = refusal(claimed, trust, time.time())
if reason is None:
execute(claimed, send)
task = claimed.get("kind") == TASK_JOB
if not task:
reason = refusal(claimed, trust, time.time())
else:
reason = UNREGISTERED if register is None else council_refusal(register.council) or task_refusal(claimed, trust, time.time())
if reason is not None:
send({"output": reason, "code": None, "refused": True})
elif task:
change(claimed, register, send)
else:
execute(claimed, send)
def main() -> int:
@@ -142,13 +250,16 @@ def main() -> int:
parser.add_argument("--interval", type=float, required=True)
parser.add_argument("--approvers", type=Path, required=True)
parser.add_argument("--replay", type=Path, required=True)
parser.add_argument("--rasci", type=Path)
parser.add_argument("--council", type=Path)
parser.add_argument("--once", action="store_true")
args = parser.parse_args()
base = hub_api.base_url(args.port)
trust = verifier.Trust(approvers=args.approvers, replay=args.replay, origin=signing.origin_of(args.port))
register = Register(rasci=args.rasci, council=args.council) if args.rasci and args.council else None
while True:
try:
cycle(base, trust)
cycle(base, trust, register)
except (OSError, ValueError, KeyError, TypeError):
pass
if args.once:
+35
View File
@@ -22,6 +22,11 @@ WORK = re.compile(r"(?=[^\x00-\x1f\x7f]*\S)[^\x00-\x1f\x7f]{1,300}")
HOLDER = re.compile(r"[^\x00-\x1f\x7f]{0,200}")
OFFICE_ACTIONS = ("appoint", "takeover", "recall")
OFFICE_KEYS = ("action", "office", "holder", "session", "work")
TASK = re.compile(r"[a-z0-9][a-z0-9._-]{0,99}(?:/[a-z0-9][a-z0-9._-]{0,99}){0,7}")
PRIO = re.compile(r"[1-9][0-9]{0,2}")
FIBONACCI = ("1", "2", "3", "5", "8", "13", "21", "34", "55", "89")
ESTIMATOR = "Operator"
COMMANDS = "operator/"
APPROVAL_KEYS = {"v", "kind", "items", "nonce", "expires"}
RULE_KEYS = {"v", "kind", "id", "kinds", "tool", "pattern", "scope", "expires"}
WARNINGS = (
@@ -138,6 +143,36 @@ def stream_item(origins: list[str]) -> dict:
return {"kind": "streams", "origins": origins}
def task_item(task: str, argv: list[str]) -> dict:
return {"kind": "task", "id": task, "argv": argv}
def task_argv(task: object, argv: object) -> list[str]:
"""Args:
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`.
Returns:
argv, the hub and the runner check it the same way.
Raises:
Refused for every other task or argv.
"""
if not isinstance(task, str) or not TASK.fullmatch(task) or task.startswith(COMMANDS):
raise Refused("the task is no register task slug outside operator/")
if not isinstance(argv, list) or not all(isinstance(part, str) for part in argv):
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
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")
def approval_payload(hashes: list[str], nonce: str, expires: int) -> dict:
return {"v": VERSION, "kind": "approve", "items": sorted(set(hashes)), "nonce": nonce, "expires": expires}
@@ -21,6 +21,7 @@ from .signed import (
signing,
verifier,
)
from .tasking import task_request
BATCH_LIMIT = 100
INBOX_KINDS = ("review", "ask", "decision", "operator", "rule")
@@ -242,6 +243,8 @@ class Approving:
signed = self.deletable(raw)
elif office_request(raw):
signed = self.appointable(raw)
elif task_request(raw):
signed = self.task_changes(raw)
else:
signed = [item["signed"] for item in self.checked(raw) if item["signed"] is not None]
return self.challenge_for(signed)
+14 -4
View File
@@ -41,14 +41,24 @@ def timer_rows(folder: Path) -> list[dict]:
return rows
def queue_rows(council: Path) -> list[dict]:
def queue_entries(council: Path) -> list[tuple[dict, float]]:
"""Returns:
Each kvb-org-queue entry of queue.json with its enqueue time; an
entry without one is no entry.
"""
data = read_json(council / "queue.json")
entries = data.get("entries") if isinstance(data, dict) else None
rows = []
found = []
for entry in entries if isinstance(entries, list) else []:
created = epoch(entry.get("enqueued")) if isinstance(entry, dict) else None
if created is None:
continue
if created is not None:
found.append((entry, created))
return found
def queue_rows(council: Path) -> list[dict]:
rows = []
for entry, created in queue_entries(council):
running = "läuft" if entry.get("status") == "active" else "wartet"
rows.append({
"key": f"queue:{shown(entry.get('id'))}", "type": "queue", "session": shown(entry.get("session")), "sub": "", "created": created,
+4 -1
View File
@@ -39,6 +39,7 @@ CLEANUP_ROUTE = "/api/cleanup"
CLEANUP_STATE = re.compile(r"/api/cleanup/([0-9a-f]{16})")
OFFICES_ROUTE = "/api/offices"
OFFICE_STATE = re.compile(r"/api/offices/([0-9a-f]{16})")
TASKS_ROUTE = "/api/tasks"
VENDOR_ROUTE = re.compile(r"/vendor/([a-z0-9.-]{1,64})")
VENDOR = {"3d-force-graph.min.js": "3d-force-graph/3d-force-graph.min.js", "three.module.js": "three/three.module.js", "three.core.js": "three/three.core.js"}
SCRIPT_TYPE = "text/javascript; charset=utf-8"
@@ -48,7 +49,7 @@ MESSAGE_SIGN_ROUTE = "/api/messages/sign"
BOARD_SIGN_ROUTE = "/api/board/sign"
SETTINGS_SIGN_ROUTE = "/api/settings/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, 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)
class Handler(Exchange):
@@ -157,6 +158,8 @@ class Handler(Exchange):
return lambda body: self.answer(lambda: hub.cleanup(body), HTTPStatus.ACCEPTED)
if path == OFFICES_ROUTE:
return lambda body: self.answer(lambda: hub.appoint(body), HTTPStatus.ACCEPTED)
if path == TASKS_ROUTE:
return lambda body: self.answer(lambda: hub.change_tasks(body), HTTPStatus.ACCEPTED)
if inbox:
return lambda body: self.answer(lambda: hub.mark(body), HTTPStatus.OK)
if undo:
+2 -1
View File
@@ -31,6 +31,7 @@ from .settings import Settings
from .signed import signing
from .skill_scripts import Runner
from .sources import Sources
from .tasking import Tasking
from .timeline import Timeline
from .transcripts import Transcripts
from .tree import TREE_HEARTBEAT, Tree
@@ -60,7 +61,7 @@ def environment(templates: Path) -> jinja2.Environment:
@dataclass(frozen=True)
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
class Hub(Mcp, Reviewing, Asking, Messaging, Approving, Denying, Requesting, Cleaning, Offices, Tasking, Inbox, Rules, Jobs, Sources, Timeline, View, Language):
council: Path
pages: Path
public_port: int
+10 -2
View File
@@ -11,10 +11,17 @@ from .skill_scripts import OPERATOR_SCRIPT, OUTPUT_LIMIT
JOB_OPEN = ("queued", "running")
RUN_OUTPUT = 64 << 10
CONSOLE = ("id", "status", "code", "output", "created_ts", "started", "finished")
TASK_JOB = "task"
def runner_job(job: dict) -> dict:
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, "proof": job.get("proof")}
"""Returns:
What the runner claims: an operator command, or a task change with
kind and argv; a runner that predates task changes verifies the
latter as an operator command and refuses it.
"""
change = {"kind": TASK_JOB, "argv": job["argv"]} if job.get("kind") == TASK_JOB else {}
return {**{key: job[key] for key in ("id", "task", "command", "directory", "sha256")}, **change, "proof": job.get("proof")}
def console_of(job: dict | None) -> dict | None:
@@ -83,7 +90,8 @@ class Jobs:
job, changed = self.jobs.transition(job_id, "running", {"status": status, "code": code, "output": output, "finished": time.time()})
if not changed:
raise Conflict({"id": job_id, "status": job["status"]})
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if status == "done" else []
command_done = status == "done" and job.get("kind") != TASK_JOB
runs = [self.scripts.run(OPERATOR_SCRIPT, ["done", job["task"].removeprefix(OPERATOR)])] if command_done else []
tail = "\n".join(output.splitlines()[-20:])
self.log.add({"kind": "run", "title": f"{status}: {job['command'][:200]}", "text": tail, "ref": {"job": job_id, "task": job["task"], "code": code, "status": status}})
return {"id": job_id, "status": status, "code": code, "runs": runs}
+1 -1
View File
@@ -26,7 +26,7 @@ class NoRedirect(urllib.request.HTTPRedirectHandler):
LLM_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect)
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts")
SCREENS = ("stream", "inbox", "rules", "agents", "artifacts", "tasks")
IDS = {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Ids from items."}
NL_ACTIONS = [
{"name": "approve", "description": "Approve inbox items; a decision takes its recommended option.", "parameters": {"ids": IDS}},
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import time
from .errors import Conflict, Missing, ToolError
from .fields import decode, expect
from .jobs import TASK_JOB
from .register import REGISTER, read_register, status_of
from .signed import signing
from .tasks import editable
CHANGE_LIMIT = 10
CHANGE_KEYS = ("kind", "id", "argv")
def task_request(raw: object) -> bool:
return isinstance(raw, list) and bool(raw) and all(isinstance(entry, dict) and entry.get("kind") == TASK_JOB for entry in raw)
def shown_command(argv: list[str]) -> str:
return " ".join(["rasci.py", *argv])
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.
Returns:
The signed items, each for an open register task outside
operator/ and hotfix/ whose argv signing.task_argv() accepts.
"""
if not isinstance(raw, list) or not 1 <= len(raw) <= CHANGE_LIMIT:
raise ToolError(f"items must be a list of 1 to {CHANGE_LIMIT} task changes")
tasks = read_register(self.council / REGISTER)
signed: list[dict] = []
for entry in raw:
if not isinstance(entry, dict):
raise ToolError("every item must be an object with kind, id and argv")
expect(entry, CHANGE_KEYS)
try:
argv = signing.task_argv(entry["id"], entry["argv"])
except signing.Refused as error:
raise ToolError(f"{entry['id']!s:.200}: {error}") from None
row = tasks.get(entry["id"])
if row is None:
raise Missing(entry["id"])
if not editable(entry["id"], row):
raise Conflict({"id": entry["id"], "status": status_of(row)})
item = signing.task_item(entry["id"], argv)
if item in signed:
raise ToolError("items must be distinct")
signed.append(item)
return signed
def change_tasks(self, body: bytes) -> dict:
payload = decode(body)
expect(payload, ("items", "proof"))
items = self.task_changes(payload["items"])
proof = self.covered(payload["proof"], items, time.time())
for item in items:
self.still_signed(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}
ids.append(self.jobs.add({**fields, "code": None, "output": "", "created_ts": time.time(), "started": None, "finished": None})["id"])
commands = [shown_command(item["argv"]) for item in items]
self.log.add({"kind": "run", "title": f"Aufgabe signiert geändert: {', '.join(item['id'] for item in items)}"[:200], "text": "\n".join(commands), "ref": {"jobs": ids, "status": "queued"}})
return {"jobs": ids, "status": "queued"}
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import itertools
import statistics
from .council import agent
from .records import iso_epoch, shown
from .register import ENDED, OPERATOR, parties, register_row, status_of
from .signed import signing
from .tree import inside
ENDED_SHOWN = 100
OPEN_SHOWN = 300
QUEUE_SHOWN = 100
PARTIES_SHOWN = 40
TEXT_SHOWN = 500
FIBONACCI = (1, 2, 3, 5, 8, 13, 21, 34, 55, 89)
LETTERS = "RASCI"
HOTFIX = "hotfix/"
QUEUE = "queue/"
def kind_of(task: str) -> str:
if task.startswith(OPERATOR):
return "command"
return "hotfix" if task.startswith(HOTFIX) else "task"
def editable(task: str, row: dict) -> bool:
shown_whole = len(task) <= TEXT_SHOWN and signing.TASK.fullmatch(task) is not None
return shown_whole and kind_of(task) == "task" and status_of(row) not in ENDED
def fibonacci(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value in FIBONACCI
def estimates_of(row: dict) -> dict[str, dict]:
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"]}
return found
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.
"""
if not estimates:
return None
value = statistics.median(entry["value"] for entry in estimates.values())
complexity = statistics.median(entry["complexity"] for entry in estimates.values())
return {"value": value, "complexity": complexity, "score": round(value / complexity, 2)}
def prio_of(row: dict) -> int | None:
prio = row.get("prio")
return prio if isinstance(prio, int) and not isinstance(prio, bool) else None
def roles_of(row: dict) -> dict[str, str]:
letters = {party[:TEXT_SHOWN]: "".join(letter for letter in LETTERS if letter in held) for party, held in itertools.islice(parties(row).items(), PARTIES_SHOWN)}
return {party: held for party, held in letters.items() if held}
def worktrees(scan: dict) -> list[dict]:
found = []
for repo in scan["repos"]:
trees = [(entry["name"], tree) for entry in repo["branches"] for tree in entry["worktrees"]] + [("", tree) for tree in repo["detached"]]
found += [{"path": tree["path"], "branch": name, "repo": repo["name"], "flow": tree["flow"], "open_work": tree["open_work"]} for name, tree in trees]
return found
def placed(row: dict, trees: list[dict]) -> dict | None:
"""Returns:
The worktree a register row or queue entry works in: the innermost
one holding its workdir, else the only one on its branch.
"""
workdir, name = row.get("workdir"), row.get("branch")
if isinstance(workdir, str) and workdir.startswith("/"):
holding = [tree for tree in trees if inside(workdir, tree["path"])]
if holding:
return max(holding, key=lambda tree: len(tree["path"]))
named = [tree for tree in trees if isinstance(name, str) and name and tree["branch"] == name]
return named[0] if len(named) == 1 else None
def located(row: dict, trees: list[dict], open_task: bool) -> dict:
tree = placed(row, trees)
own = ["task"] if open_task else []
if tree is None:
return {"worktree": "", "worktree_branch": "", "repo": "", "flow": None, "open_work": own}
return {"worktree": tree["path"], "worktree_branch": tree["branch"], "repo": tree["repo"], "flow": tree["flow"], "open_work": [*own, *tree["open_work"]]}
def task_row(task: str, row: dict, trees: list[dict]) -> dict:
estimates = estimates_of(row)
ended = status_of(row) in ENDED
shown_row = {key: value[:TEXT_SHOWN] if isinstance(value, str) else value for key, value in register_row(task, row).items()}
return {
**shown_row,
"kind": kind_of(task),
"ended": ended,
"editable": editable(task, row),
"prio": prio_of(row),
"estimates": estimates,
"wsjf": wsjf(estimates),
"roles": roles_of(row),
"queue": None,
**located(row, trees, not ended),
}
def queue_row(entry: dict, created: float, trees: list[dict]) -> dict:
text = {name: shown(entry.get(name))[:TEXT_SHOWN] for name in ("id", "cmd", "kind", "task", "workdir")}
return {
"task": f"{QUEUE}{text['id']}",
"title": text["cmd"],
"status": "running" if entry.get("status") == "active" else "awaiting",
"worker": agent(entry.get("name"))[:TEXT_SHOWN] or "-",
"responsible": "-",
"accountable": "-",
"objective": "-",
"branch": "-",
"workdir": text["workdir"],
"met": 0,
"total": 0,
"kind": "queue",
"ended": False,
"editable": False,
"prio": None,
"estimates": {},
"wsjf": None,
"roles": {},
"queue": {"kind": text["kind"], "parent": text["task"], "enqueued": created},
**located(entry, trees, False),
}
def rank(task: str, row: dict) -> tuple:
prio, score = prio_of(row), wsjf(estimates_of(row))
return (prio is None, prio or 0, score is None, -(score["score"] if score else 0), task)
def task_rows(tasks: dict[str, dict], queue: list[tuple[dict, float]], scan: dict) -> list[dict]:
"""Args:
tasks: the register rows by task.
queue: the kvb-org-queue entries with their enqueue time.
scan: the pkgmgr tree as read_tree() maps it.
Returns:
The OPEN_SHOWN first open tasks in register rank (pin, then WSJF,
then name), the QUEUE_SHOWN oldest queue jobs, then the ENDED_SHOWN
most recently ended tasks; each with its worktree's flow and open work.
"""
trees = worktrees(scan)
current = sorted((task for task, row in tasks.items() if status_of(row) not in ENDED), key=lambda task: rank(task, tasks[task]))
ended = sorted((task for task, row in tasks.items() if status_of(row) in ENDED), key=lambda task: (-(iso_epoch(tasks[task].get("end")) or 0), task))
jobs = sorted(queue, key=lambda pair: pair[1])[:QUEUE_SHOWN]
return [
*(task_row(task, tasks[task], trees) for task in current[:OPEN_SHOWN]),
*(queue_row(entry, created, trees) for entry, created in jobs),
*(task_row(task, tasks[task], trees) for task in ended[:ENDED_SHOWN]),
]
+41 -6
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import json
import re
import threading
from dataclasses import dataclass
from pathlib import Path
@@ -11,24 +12,57 @@ from .register import ENDED, status_of
from .signed import signing
LABEL_PARTS = 3
FLOW_LIMIT = 16
FLOW_REF = re.compile(r"[^\x00-\x20\x7f]{1,255}")
FLOW_SOURCE = re.compile(r"[a-z][a-z0-9-]{0,31}")
INVALID = "invalid"
# renaming it silently stops the tree timer: kvb-orchestrator-tree.service tests this file's age
TREE_HEARTBEAT = "tree-heartbeat"
def flow(item: dict) -> dict | None:
"""Returns:
The worktree's pkgmgr merge flow: pull, the sources merged into the
branch before a gate, and push, the targets the finished branch
merges into, each capped at FLOW_LIMIT; None when the item names no
flow, source invalid with empty lists when pkgmgr found the flow
file broken or the field has the wrong shape.
"""
raw = item.get("flow")
if raw is None:
return None
broken = {"pull": [], "push": [], "source": INVALID}
if not isinstance(raw, dict) or not isinstance(raw.get("source"), str) or not FLOW_SOURCE.fullmatch(raw["source"]) or raw["source"] == INVALID:
return broken
lists = [raw.get(side)[:FLOW_LIMIT] if isinstance(raw.get(side), list) else None for side in ("pull", "push")]
if not all(names is not None and all(isinstance(name, str) and FLOW_REF.fullmatch(name) for name in names) for names in lists):
return broken
return {"pull": lists[0], "push": lists[1], "source": raw["source"]}
def work(item: dict, dirty: bool | None) -> list[str]:
ahead = item["ahead"]
found = (("dirty", dirty is True), ("unpushed", isinstance(ahead, int) and ahead > 0), ("unmerged", item["state"] == "unmerged"))
return [reason for reason, hit in found if hit]
def worktree(item: dict) -> dict:
state = item["worktree_state"]
unread = state == "changed" and item["staged"] is None
dirty = None if state == "missing" or unread else state == "changed"
return {
"path": item["worktree"],
"head": item["head"] or "",
"branch": item["branch"] or "",
"prunable": state == "missing",
"dirty": None if state == "missing" or unread else state == "changed",
"dirty": dirty,
"state": state,
"staged": item["staged"],
"unstaged": item["unstaged"],
"untracked": item["untracked"],
"locked": item["locked"],
"flow": flow(item),
"open_work": work(item, dirty),
}
@@ -131,12 +165,13 @@ def assign(scan: dict, tasks: dict[str, dict], claims: list[dict], reviews: list
trees = []
for tree in item["worktrees"]:
workers = set()
for task, row in current.items():
if owns(row, tree["path"], item["name"]):
workers |= holders[task]
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"])})
held = sorted(task for task, row in current.items() if owns(row, tree["path"], item["name"]))
for task in held:
workers |= holders[task]
open_work = [*tree["open_work"], "task"] if held else tree["open_work"]
trees.append({**tree, "workers": sorted(workers, key=str.lower), "review": latest.get(tree["path"]), "tasks": held, "open_work": open_work})
branches.append({**item, "worktrees": trees})
repos.append({**entry, "branches": branches, "detached": [{**tree, "workers": [], "review": latest.get(tree["path"])} for tree in entry["detached"]]})
repos.append({**entry, "branches": branches, "detached": [{**tree, "workers": [], "review": latest.get(tree["path"]), "tasks": []} for tree in entry["detached"]]})
return {**scan, "repos": repos}
+3
View File
@@ -13,6 +13,7 @@ from .council import (
sessions,
silence_of,
)
from .crons import queue_entries
from .errors import ToolError
from .events import KINDS, VIEWS
from .fields import decode, instant, single
@@ -35,6 +36,7 @@ from .reviewing import review_row
from .settings import media_origins, valid_settings
from .signed import signing
from .skill_scripts import OUTPUT_LIMIT
from .tasks import task_rows
RADIO_WINDOW = 86400
JOBS_SHOWN = 20
@@ -126,6 +128,7 @@ 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)),
"radio": radio,
"claims": claims,
@@ -3,8 +3,9 @@ Description=Agent Orchestrator runner: runs the operator commands a pinned passk
[Service]
Type=simple
Environment=PATH=/usr/bin:/bin
EnvironmentFile=%h/.local/share/kvb-orchestrator/.env
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json
ExecStart=/usr/bin/env python3 %h/.local/share/kvb-orchestrator/client/runner.py --port ${KVB_ORCHESTRATOR_PORT} --interval 2 --approvers %h/.config/kvb-orchestrator/approvers.json --replay %h/.local/state/kvb-orchestrator-approvals/runner.json --rasci %h/.claude/skills/kvb-org-rasci/scripts/rasci.py --council ${KVB_ORCHESTRATOR_COUNCIL}
Restart=always
RestartSec=5
+8
View File
@@ -407,6 +407,8 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "style-inbox.css" %}
{% include "style-ui.css" %}
{% include "style-tasks.css" %}
</style>
</head>
<body>
@@ -506,10 +508,16 @@ pre.notes { margin: 0; padding: 8px 10px; background: var(--sunken); border-radi
{% include "page-tree.js" %}
{% include "page-flow.js" %}
{% include "page-cleanup.js" %}
{% include "page-offices.js" %}
{% include "page-tasks.js" %}
{% include "page-tasks-prio.js" %}
{% include "page-time.js" %}
{% include "page-pulses.js" %}
+6 -4
View File
@@ -1,15 +1,17 @@
const Nav = (() => {
"use strict";
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"]];
const TABS = [["stream", "Stream"], ["agents", "Agents"], ["artifacts", "Artifacts"], ["tasks", "Tasks"]];
const SUBS = {stream: [["inbox", "Inbox"], ["rules", "Regeln"], ["radio", "Radio"]]};
const VIEWS = [["orbit", "Orbit"], ["chord", "Chord"], ["swimlane", "Swimlane"], ["tree", "Tree"], ["3d", "3D"]];
const TASK_VIEWS = [["list", "Liste"], ["board", "Board"], ["rasci", "RASCI"]];
const OWN = {agents: [["hierarchy", "Hierarchie"]]};
const ICONS = {
stream: "bi-activity", inbox: "bi-inbox", rules: "bi-card-checklist", radio: "bi-boombox", agents: "bi-people", artifacts: "bi-git",
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",
};
const OLD = {graph: ["agents", "orbit"], tree: ["artifacts", "tree"]};
const viewsOf = (screen) => [...VIEWS, ...(OWN[screen] || [])];
const viewsOf = (screen) => (screen === "tasks" ? TASK_VIEWS : [...VIEWS, ...(OWN[screen] || [])]);
const known = (screen, view) => viewsOf(screen).some(([id]) => id === view);
function tabOf(screen) {
@@ -36,7 +38,7 @@ const Nav = (() => {
return {screen: "stream", view: null, redirect: null};
}
return {TABS, SUBS, VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
return {TABS, SUBS, VIEWS, TASK_VIEWS, ICONS, viewsOf, route, hashOf, tabOf};
})();
if (typeof module === "object" && module.exports) {
module.exports = Nav;
+15 -3
View File
@@ -49,6 +49,9 @@
for (const overlap of repo.overlaps || []) {
links.push({source: `branch:${repo.path}\u0000${overlap.a}`, target: `branch:${repo.path}\u0000${overlap.b}`, kind: "overlap", count: overlap.count});
}
for (const edge of flowEdges(repo)) {
links.push({source: `branch:${repo.path}\u0000${edge.from}`, target: `branch:${repo.path}\u0000${edge.into}`, kind: "flow", side: edge.side});
}
}
for (const task of data.tasks || []) {
const tid = add({id: `task:${task.task}`, kind: "task", label: task.task, depth: 0, ts: nowSeconds(), parent: null, data: task, repo: ""});
@@ -113,7 +116,7 @@
const holder = model.byId.get(`wt:${wt.path}`);
const leaves = model.nodes.filter((entry) => entry.parent === holder.id);
leaves.forEach((leaf, place) => layer.append(artMark(leaf, around(moon, place, leaves.length, 15), 4.5, leaf.kind === "agent" ? "satellite" : "cargo", null)));
layer.append(artMark(holder, moon, 7, wt.state === "changed" ? "dirty" : "", null));
layer.append(artMark(holder, moon, 7, [wt.state === "changed" ? "dirty" : "", wt.open_work.length ? "open" : ""].filter(Boolean).join(" "), null));
});
layer.append(artMark(node, at, radius, `tone-${toneOf(branch)}`, repo.branches.length <= 40 ? short(branch.name).slice(0, 28) : null));
});
@@ -148,6 +151,13 @@
s("title", {}, `${merge.from} → ${merge.into}: gemergt durch ${merge.by}`)));
}
}
for (const edge of flowEdges(repo)) {
const from = spots.get(edge.from);
const to = spots.get(edge.into);
const bend = edge.side === "pull" ? 0.55 : 0.15;
arcs.append(s("path", {class: "edge t-direct flow", "data-side": edge.side, d: `M${fixed(from.x)},${fixed(from.y)} Q${fixed((from.x + to.x) * bend)},${fixed((from.y + to.y) * bend)} ${fixed(to.x)},${fixed(to.y)}`, "stroke-width": 2, "marker-end": "url(#tip-direct)"},
s("title", {}, `${edge.from} → ${edge.into}: Merge-Flow, ${edge.side === "pull" ? `${edge.into} zieht ${edge.ref} vor dem Gate` : `${edge.from} mergt fertig in ${edge.ref}`}`)));
}
const marks = branches.map((branch) => artMark({id: `branch:${repo.path}\u0000${branch.name}`, kind: "branch", label: branch.name}, spots.get(branch.name), 8, `tone-${toneOf(branch)}`, null));
const labels = branches.map((branch) => {
const at = spots.get(branch.name);
@@ -192,7 +202,7 @@
function artLegend(view) {
const words = {
orbit: "Sonne = Repository · Planet = Branch, Größe = Commits voraus · Mond = Worktree · Satellit = Agent oder Artefakt",
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation",
chord: "Band = Dateien, die beide Branches ändern (aus den Reviews im Hub, nur dort bekannt) · gestrichelt = Merge laut gc-Klassifikation · gepunktet = Merge-Flow des Worktrees",
swimlane: "Commit = freigegebenes Review · Gate = Review-Gate und Queue-Läufe · Push = Runner-Befehl mit push · Zeitraum = 24 h ▾",
};
const marks = view === "swimlane" ? Object.values(LANE_MARKS).map(([label, name]) => h("span", {}, icon(name), label)) : [];
@@ -212,9 +222,11 @@
h("p", {class: "muted"}, `${data.branches.length} Branches · ${data.branches.reduce((sum, entry) => sum + entry.worktrees.length, 0) + data.detached.length} Worktrees`));
} else if (node.kind === "branch") {
body.push(h("div", {class: "states"}, branchStates(data)), meta([["Upstream", data.upstream], ["Kopf", data.head.slice(0, 12), data.head], ["Gemergt in", data.merged_into], ["Grund", data.reason]]),
data.worktrees.map((wt) => flowDetails(wt.flow, data.name)),
h("h3", {}, "Trockenlauf"), h("pre", {class: "notes"}, data.plan.join("\n")), h("div", {class: "actions"}, deleteButton(data)));
} else if (node.kind === "worktree") {
body.push(h("div", {class: "states"}, worktreeStates(data)), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
body.push(h("div", {class: "states"}, worktreeStates(data)), workChips(data.open_work), meta([["Pfad", data.path], ["Branch", data.branch], ["HEAD", data.head.slice(0, 12), data.head]]),
flowDetails(data.flow, data.branch),
data.workers.length ? h("div", {class: "who"}, data.workers.map((name) => [face(name, false), person(name)])) : h("p", {class: "muted"}, "Kein Agent arbeitet hier."),
data.review ? h("div", {class: "who"}, status(data.review.status), h("span", {}, data.review.title)) : null, h("div", {class: "actions"}, copyButton(data.path)));
} else if (node.kind === "agent") {
+1 -1
View File
@@ -60,7 +60,7 @@
direct: [[740, 0, 0.2]],
radio: [[520, 0, 0.07]],
};
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3", "Stream · Agents · Artifacts"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const KEYS = [["Strg+K", "Befehle, Ansichten, Agenten und KI-Befehl"], ["1 · 2 · 3 · 4", "Stream · Agents · Artifacts · Tasks"], ["j / k", "Inbox: nächster / vorheriger Eintrag"], ["x", "Inbox: Eintrag auswählen"], ["a", "Inbox: Eintrag freigeben (3 s rückgängig)"], ["Enter", "Inbox: Eintrag öffnen"], ["/", "Stream durchsuchen"], ["h", "Himmel ansehen: ziehen dreht den Blick, Rad oder zwei Finger zoomen, Esc beendet"], ["Pfeile · + / − · 0", "Himmel (fokussiert): drehen · zoomen · zurücksetzen"], ["Ziehen · Rad", "3D: drehen, rechts ziehen verschiebt, Rad zoomt"],["🎙 halten", "Spracheingabe ins Agentenfeld oder die Befehlszeile, nie automatisch gesendet"], ["?", "Diese Hilfe"], ["Esc", "Schließen"]];
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");
const relative = new Intl.RelativeTimeFormat("de", {numeric: "auto"});
const absolute = new Intl.DateTimeFormat("de-DE", {dateStyle: "medium", timeStyle: "short"});
+68
View File
@@ -0,0 +1,68 @@
const WORK_CHIPS = {dirty: ["bi-pencil-square", "nicht committet", "warn"], unpushed: ["bi-cloud-upload", "ungepusht", "info"], unmerged: ["bi-signpost-split", "nicht gemergt", "neutral"], task: ["bi-card-checklist", "Aufgabe offen", "violet"]};
const FLOW_SIDES = {pull: "zieht vor dem Gate", push: "mergt fertig in"};
function workChips(reasons) {
if (!reasons || !reasons.length) {
return null;
}
return h("span", {class: "states work", role: "group", "aria-label": "Offene Arbeit"}, reasons.map((reason) => {
const [name, label, tone] = Object.hasOwn(WORK_CHIPS, reason) ? WORK_CHIPS[reason] : ["bi-question-circle", reason, "neutral"];
return h("span", {class: "state", "data-tone": tone, title: `offene Arbeit: ${label}`}, icon(name), label);
}));
}
function flowLine(flow, branch) {
if (!flow) {
return null;
}
if (flow.source === "invalid") {
return h("div", {class: "flow invalid", "data-flow": "invalid"}, icon("bi-exclamation-triangle"), "Merge-Flow ungültig: pkgmgr kann die Flow-Datei nicht lesen");
}
const refs = (side) => (flow[side].length ? flow[side].map((ref) => h("code", {class: "flow-ref", title: `${FLOW_SIDES[side]}: ${ref}`}, ref)) : h("span", {class: "muted"}, "–"));
const arrow = () => h("span", {class: "flow-arrow", ...named("fließt nach")}, icon("bi-arrow-right"));
return h("div", {class: "flow", "data-flow": flow.source, role: "group", "aria-label": `Merge-Flow: ${flow.pull.join(", ") || "keine Quelle"} nach ${branch || "–"} nach ${flow.push.join(", ") || "kein Ziel"}`},
h("span", {class: "flow-label"}, icon("bi-sign-merge-right"), "Flow"),
h("span", {class: "flow-side"}, refs("pull")), arrow(),
h("code", {class: "flow-ref here", title: "dieser Branch"}, branch || "–"), arrow(),
h("span", {class: "flow-side"}, refs("push")));
}
function flowDetails(flow, branch) {
if (!flow) {
return h("p", {class: "muted"}, "Kein Merge-Flow gesetzt.");
}
if (flow.source === "invalid") {
return flowLine(flow, branch);
}
return [h("h3", {}, "Merge-Flow"), flowLine(flow, branch), meta([["Zieht vor dem Gate", flow.pull.join(", ")], ["Mergt fertig in", flow.push.join(", ")], ["Quelle", flow.source]])];
}
function flowTarget(names, ref) {
if (names.has(ref)) {
return ref;
}
const bare = ref.includes("/") ? ref.slice(ref.indexOf("/") + 1) : "";
return names.has(bare) ? bare : null;
}
function flowEdges(repo) {
const names = new Set(repo.branches.map((entry) => entry.name));
const edges = [];
for (const branch of repo.branches) {
for (const flow of branch.worktrees.map((wt) => wt.flow).filter((found) => found && found.source !== "invalid")) {
for (const ref of flow.pull) {
const from = flowTarget(names, ref);
if (from && from !== branch.name) {
edges.push({from, into: branch.name, side: "pull", ref});
}
}
for (const ref of flow.push) {
const into = flowTarget(names, ref);
if (into && into !== branch.name) {
edges.push({from: branch.name, into, side: "push", ref});
}
}
}
}
return edges;
}
+1 -1
View File
@@ -54,7 +54,7 @@
const inInbox = ui.screen === "inbox";
const current = inInbox ? visibleInbox()[ui.cursor] : null;
const index = Number(event.key) - 1;
if (/^[1-3]$/.test(event.key)) {
if (/^[1-9]$/.test(event.key) && index < Nav.TABS.length) {
event.preventDefault();
go(Nav.TABS[index][0], null);
} else if (inInbox && (event.key === "j" || event.key === "k")) {
+1 -1
View File
@@ -1,7 +1,7 @@
function paletteItems() {
return [
...Nav.TABS.flatMap(([id, label], index) => [{label: `Ansicht: ${label}`, hint: String(index + 1), run: () => go(id, null)}, ...(Nav.SUBS[id] || []).map(([sub, name]) => ({label: `Ansicht: ${label} › ${name}`, hint: Nav.hashOf(sub, null), run: () => go(sub, null)}))]),
...["agents", "artifacts"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...["agents", "artifacts", "tasks"].flatMap((screen) => Nav.viewsOf(screen).map(([id, name]) => ({label: `${Nav.TABS.find(([tab]) => tab === screen)[1]}: ${name}`, hint: Nav.hashOf(screen, id), run: () => go(screen, id)}))),
...Object.entries(state.views).map(([id, view]) => ({label: `Stream: ${view.label}`, hint: "Kategorie", run: () => useView(id)})),
...userViews.map((view) => ({label: `Stream: ${view.name}`, hint: "eigene Ansicht", run: () => useSaved(view)})),
...agentNames().map((name) => ({label: `${avatar(name)} ${name}`, hint: "Agent", run: () => openAgent(name)})),
+4 -2
View File
@@ -44,11 +44,11 @@
if (ui.screen === "agents") {
return JSON.stringify([ui.views.agents]);
}
return JSON.stringify(ui.screen === "artifacts" ? [ui.views.artifacts] : ui.screen === "rules" ? state.rules : []);
return JSON.stringify(ui.screen === "artifacts" || ui.screen === "tasks" ? [ui.views[ui.screen]] : ui.screen === "rules" ? state.rules : []);
}
function screenOf() {
return {stream: streamScreen, inbox: inboxScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen}[ui.screen]();
return {stream: streamScreen, inbox: tasksScreen, rules: rulesScreen, radio: radioScreen, agents: agentsScreen, artifacts: artifactsScreen, tasks: tasksScreen}[ui.screen]();
}
function render(force) {
@@ -66,6 +66,7 @@
radioDom = null;
inboxDom = null;
inboxSig = "";
taskView.dom = null;
const main = document.getElementById("main");
main.setAttribute("aria-labelledby", `tab-${ui.screen}`);
main.replaceChildren(screenOf());
@@ -82,6 +83,7 @@
drawToolbar();
drawInbox();
drawRadio();
drawTasks();
drawStatus();
tick();
}
+1 -1
View File
@@ -27,7 +27,7 @@
let state = JSON.parse(document.getElementById("state").textContent);
const KINDS = Object.entries(state.kinds);
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const ui = {screen: "stream", views: {agents: "orbit", artifacts: "tree", tasks: "list"}, preset: DAY, from: null, to: null, asOf: null, playing: false, busy: false, offline: false, agent: null, order: null, draft: "", node: null, cursor: 0, selected: new Set(), snoozed: false, review: null, file: 0};
const filter = {view: "fuer-dich", saved: "", kinds: new Set(state.views["fuer-dich"].kinds), waiting: true, q: "", agent: "", session: "", sub: "", parts: new Set(PARTS.map(([id]) => id))};
const stream = {key: null, events: [], entries: [], last: 0, sessions: {}, busy: false, again: false, dom: null, error: ""};
const tree = {data: null, model: null, busy: false, q: "", open: new Set(), dom: null};
+104
View File
@@ -0,0 +1,104 @@
const FIBONACCI = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89];
const PRIO_MAX = 999;
const ESTIMATOR = HUMAN;
const shownArgv = (argv) => ["rasci.py", ...argv].join(" ");
const prioText = (text) => (/^[1-9][0-9]{0,2}$/.test(text.trim()) ? String(Number(text.trim())) : "");
function fibonacciPicker(label, picked) {
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"}, "Signiere mit dem Passkey …"));
const signed = await proofFor(items, label);
if (signed === null) {
outcome.replaceChildren(h("p", {class: "muted"}, "Nicht signiert: nichts wurde übergeben."));
return;
}
const result = await sendJson("/api/tasks", {items, proof: signed.proof});
if (!result.ok || !result.data) {
toast(`${label}: nicht möglich`, fail(result), false);
outcome.replaceChildren(h("p", {class: "muted"}, `${result.status} ${result.text}`.trim()));
return;
}
outcome.replaceChildren(h("p", {class: "muted", "data-jobs": result.data.jobs.join(" ")}, `Signiert und an den Runner übergeben (Job ${result.data.jobs.join(", ")}); er ruft rasci.py auf dem Host auf.`));
toast(`${label}: an den Runner übergeben`, [shownArgv(argv)], true);
await load();
}
function priorityForm(row) {
const outcome = h("div", {class: "mini", "aria-live": "polite"});
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": "Priorität", placeholder: "1"});
const value = fibonacciPicker("Geschäftswert", own ? own.value : 5);
const complexity = fibonacciPicker("Komplexität", own ? own.complexity : 3);
const pinCode = h("code", {});
const estimateCode = h("code", {});
const prioArgv = () => ["set", row.task, "--prio", prioText(prio.value)];
const estimateArgv = () => ["estimate", row.task, ESTIMATOR, "--value", value.value, "--complexity", complexity.value];
const pin = h("button", {type: "button", class: "btn small primary", ...named("Anheften", "Anheften: Priorität mit dem Passkey signieren")}, icon("bi-pin-angle-fill"), "Anheften");
const estimate = h("button", {type: "button", class: "btn small primary", ...named("Schätzung speichern", "Schätzung signiert an den Runner geben")}, icon("bi-calculator"), "Schätzung speichern");
const sync = () => {
pin.disabled = !prioText(prio.value);
pinCode.textContent = pin.disabled ? `Priorität 1 bis ${PRIO_MAX} eingeben` : shownArgv(prioArgv());
estimateCode.textContent = shownArgv(estimateArgv());
};
for (const field of [prio, value, complexity]) {
field.addEventListener("input", sync);
}
pin.addEventListener("click", () => changeTask(row, prioArgv(), `Priorität ${row.task}`, outcome));
estimate.addEventListener("click", () => changeTask(row, estimateArgv(), `WSJF ${row.task}`, outcome));
const drop = own ? h("button", {type: "button", class: "btn small danger", ...named("Schätzung verwerfen", "Schätzung verwerfen: deine Schätzung mit dem Passkey signiert löschen"), on: {click: () => changeTask(row, ["estimate", row.task, ESTIMATOR, "--drop"], `WSJF ${row.task}`, outcome)}}, icon("bi-x-circle"), "Schätzung verwerfen") : null;
sync();
return h("section", {class: "task-edit", "aria-label": "Priorität und WSJF"},
h("h3", {}, "Priorität anheften"),
h("div", {class: "task-form"}, h("label", {}, "Priorität (1 zuerst)", prio), pin),
h("div", {class: "command"}, pinCode),
h("h3", {}, `WSJF schätzen als ${ESTIMATOR}`),
h("div", {class: "task-form"}, h("label", {}, "Geschäftswert", value), h("label", {}, "Komplexität", complexity), estimate, drop),
h("div", {class: "command"}, estimateCode),
h("p", {class: "muted"}, "Dein Passkey signiert genau diesen Aufruf; der Runner auf dem Host prüft die Signatur und die Form der Argumente und startet rasci.py ohne Shell. Die Priorität der Agenten bleibt unberührt."),
onLocalhost() ? null : h("p", {class: "muted"}, "Signieren geht nur unter ", localLink()),
outcome);
}
function taskRoles(row) {
const entries = Object.entries(row.roles);
return [h("h3", {}, "RASCI", counter(entries.length)),
entries.length ? h("ul", {class: "mini"}, entries.map(([party, letters]) => h("li", {}, h("div", {class: "row"}, h("span", {class: "who"}, face(party, false), person(party)), h("span", {}, roleLetters(letters)))))) : h("p", {class: "muted"}, "Keine Rollen eingetragen.")];
}
function taskEstimates(row) {
const entries = Object.entries(row.estimates);
const score = row.wsjf;
return [h("h3", {}, "WSJF-Schätzungen", 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"}, `Geschäftswert ${entry.value} · Komplexität ${entry.complexity}`))))) : h("p", {class: "muted"}, "Noch keine Schätzung: die Aufgabe rangiert nach den geschätzten."),
score ? h("p", {class: "muted"}, `Median ${score.value} / ${score.complexity} = WSJF ${score.score}`) : null];
}
function taskJobs(row) {
const jobs = state.jobs.filter((job) => job.kind === "task" && job.task === row.task).slice(-5).reverse();
return jobs.length ? [h("h3", {}, "Signierte Änderungen", counter(jobs.length)), h("ul", {class: "mini"}, jobs.map((job) => h("li", {"data-job": job.status},
h("div", {class: "row"}, h("code", {}, job.command), status(job.status)),
job.status === "refused" || job.status === "failed" ? h("pre", {class: "notes"}, job.output) : null)))] : null;
}
function taskSheet(id) {
const row = state.tasks.find((entry) => entry.task === id);
if (!row) {
return;
}
const facts = [["Ziel", row.objective], ["Key Results", row.total ? `${row.met} von ${row.total} erfüllt` : ""], ["Workdir", row.workdir], ["Branch", row.branch], ["Worktree", row.worktree], ["Repository", row.repo]];
openSheet(row.task, [
h("div", {class: "who"}, taskBadges(row, true), row.worker !== "-" ? [face(row.worker, false), person(row.worker)] : null),
h("p", {class: "text"}, titleOf(row)),
meta(row.queue ? [["Queue", `${row.queue.kind} für ${row.queue.parent}`], ...facts.slice(2)] : facts),
workChips(row.open_work),
flowDetails(row.flow, branchOf(row)),
taskRoles(row),
row.kind === "task" ? taskEstimates(row) : null,
row.editable ? priorityForm(row) : h("p", {class: "muted"}, row.ended ? "Beendet: Priorität und WSJF bleiben, wie sie sind." : "Priorität und WSJF gibt es nur für Aufgaben des Registers, nicht für Befehle, Hotfixes und Queue-Jobs."),
taskJobs(row),
], false);
}
+177
View File
@@ -0,0 +1,177 @@
const TASK_STATUS = {queued: ["wartet", "info"], running: ["läuft", "ok"], blocked: ["blockiert", "bad"], done: ["erledigt", "neutral"], stopped: ["gestoppt", "neutral"], awaiting: ["wartet auf Freigabe", "warn"]};
const TASK_COLUMNS = [["queued", "Wartet"], ["running", "Läuft"], ["blocked", "Blockiert"], ["done", "Erledigt"], ["stopped", "Gestoppt"]];
const TASK_KINDS = {command: ["Befehl", "bad"], hotfix: ["Hotfix", "warn"], queue: ["Queue-Job", "teal"]};
const TASK_FILTERS = [
["alle", "Alle", "bi-list-check", () => true],
["offen", "Offen", "bi-play-circle", (row) => !row.ended],
["arbeit", "Offene Arbeit", "bi-pencil-square", (row) => row.open_work.length > 0],
["operator", "Wartet auf Operator", "bi-person-raised-hand", null],
["freigabe", "Wartet auf Freigabe", "bi-hourglass-split", (row) => row.kind === "queue" && row.status === "awaiting"],
["beendet", "Beendet", "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: "alle", q: "", dom: null, sig: ""};
const columnOf = (row) => (TASK_COLUMNS.some(([id]) => id === row.status) ? row.status : "queued");
const branchOf = (row) => row.worktree_branch || (row.branch !== "-" ? row.branch : "");
const titleOf = (row) => (row.title !== "-" ? row.title : row.task);
const openJobs = (task) => state.jobs.filter((job) => job.kind === "task" && job.task === task && (job.status === "queued" || job.status === "running"));
function taskStatus(row) {
const [label, tone] = Object.hasOwn(TASK_STATUS, row.status) ? TASK_STATUS[row.status] : [row.status, "neutral"];
return pill(label, tone);
}
function taskBadges(row, withStatus) {
const [kindName, kindTone] = Object.hasOwn(TASK_KINDS, row.kind) ? TASK_KINDS[row.kind] : [null, null];
const score = row.wsjf;
return [
kindName ? pill(kindName, kindTone) : null,
withStatus ? taskStatus(row) : null,
row.total ? pill(`KR ${row.met}/${row.total}`, row.met === row.total ? "ok" : "info") : null,
row.prio !== null ? h("span", {class: "pill", "data-tone": "violet", title: "angeheftete Priorität"}, `Prio ${row.prio}`) : null,
score ? h("span", {class: "pill", "data-tone": "teal", title: `WSJF: Geschäftswert ${score.value} / Komplexität ${score.complexity}`}, `WSJF ${score.score}`) : null,
openJobs(row.task).length ? pill("Änderung beim Runner", "warn") : null,
];
}
function roleSummary(row) {
const holders = (letter) => Object.entries(row.roles).filter(([, letters]) => letters.includes(letter)).map(([party]) => party);
return ["R", "A"].map((letter) => (holders(letter).length ? h("span", {title: ROLE_WORDS[letter]}, `${letter}: ${holders(letter).join(", ")}`) : null));
}
function taskPlace(row) {
if (row.kind === "queue") {
return h("span", {}, `${row.queue.kind} für ${row.queue.parent} · eingereiht `, when(row.queue.enqueued));
}
const place = row.worktree || (row.workdir !== "-" ? row.workdir : "");
return place ? h("code", {title: row.repo || place}, place) : null;
}
function opener(row) {
return h("button", {type: "button", class: "linkish", title: `${row.task} öffnen`, on: {click: () => taskSheet(row.task)}}, row.kind === "command" ? h("code", {}, titleOf(row)) : titleOf(row));
}
function taskItem(row) {
const classes = ["task-row", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
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"}),
row.editable ? iconBtn("bi-sort-numeric-down", "Priorität", "btn small soft", () => taskSheet(row.task), `Priorität und WSJF von ${row.task}: anheften oder schätzen, mit Passkey signiert`) : null),
opener(row),
row.objective !== "-" ? h("div", {class: "muted"}, `Ziel: ${row.objective}`) : null,
h("div", {class: "task-meta"}, row.worker !== "-" ? h("span", {class: "who"}, face(row.worker, false), person(row.worker)) : null, roleSummary(row), taskPlace(row)),
flowLine(row.flow, branchOf(row)),
workChips(row.open_work));
}
function taskCard(row) {
const classes = ["task-card", row.open_work.length ? "open-work" : "", row.ended ? "ended" : ""].filter(Boolean).join(" ");
return h("li", {class: classes, "data-task": row.task},
h("div", {class: "head-row"}, h("code", {class: "task-id"}, row.task)),
opener(row),
h("div", {class: "states"}, taskBadges(row, row.status === "awaiting")),
row.worker !== "-" ? h("span", {class: "muted"}, row.worker) : null,
row.flow ? flowLine(row.flow, branchOf(row)) : null,
workChips(row.open_work));
}
function taskBoard(rows) {
return h("div", {class: "task-board"}, TASK_COLUMNS.map(([id, label]) => {
const cards = rows.filter((row) => columnOf(row) === id);
return h("section", {class: "card task-column", "data-column": id, "aria-label": label},
h("h3", {class: "group-title"}, label, counter(cards.length)),
cards.length ? h("ol", {class: "task-cards"}, cards.map(taskCard)) : h("p", {class: "muted"}, "leer"));
}));
}
function roleLetters(letters) {
return [...letters].map((letter) => h("span", {class: "letter", "data-tone": ROLE_TONES[letter], title: ROLE_WORDS[letter]}, letter));
}
function taskMatrix(rows) {
const parties = [...new Set(rows.flatMap((row) => Object.keys(row.roles)))].sort((a, b) => Number(a !== HUMAN) - Number(b !== HUMAN) || a.localeCompare(b, "de"));
if (!parties.length) {
return emptyState("Keine Rollen", "Keine der gezeigten Aufgaben nennt R, A, S, C oder I.");
}
const columns = parties.slice(0, MATRIX_PARTIES);
const legend = h("div", {class: "legend"}, Object.entries(ROLE_WORDS).map(([letter, word]) => h("span", {}, roleLetters(letter), word)),
parties.length > columns.length ? h("span", {class: "muted"}, `${parties.length - columns.length} weitere Parteien nicht gezeigt`) : null);
const head = h("thead", {}, h("tr", {}, h("th", {scope: "col"}, "Aufgabe"), columns.map((party) => h("th", {scope: "col", title: party}, h("span", {class: "party"}, party)))));
const line = (row) => h("tr", {"data-task": row.task, class: row.open_work.length ? "open-work" : null},
h("th", {scope: "row"}, h("div", {class: "matrix-task"}, h("code", {class: "task-id"}, row.task), opener(row))),
columns.map((party) => {
const letters = Object.hasOwn(row.roles, party) ? row.roles[party] : "";
return h("td", {title: letters ? `${party}: ${letters}` : null}, roleLetters(letters));
}));
const table = h("table", {class: "matrix", "aria-label": "RASCI-Matrix: Aufgaben mal Parteien"}, head, h("tbody", {}, rows.map(line)));
return h("div", {class: "matrix-box"}, h("div", {class: "matrix-scroll"}, table), legend);
}
function chooseTaskFilter(id) {
if (id === "operator") {
go("inbox", null);
return;
}
taskView.filter = id;
if (ui.screen === "inbox") {
go("tasks", null);
} else {
drawTasks();
}
}
function taskChips(rows, inbox) {
return TASK_FILTERS.map(([id, label, name, test]) => {
const total = test ? rows.filter(test).length : waitingCount();
return h("button", {type: "button", class: "chip preset", "data-filter": id, "aria-pressed": String(inbox ? id === "operator" : id === taskView.filter), title: `${label}: ${total}`, on: {click: () => chooseTaskFilter(id)}},
icon(name), label, counter(total));
});
}
function taskViewSwitch(inbox) {
return h("div", {class: "segmented", role: "group", "aria-label": "Ansicht"}, Nav.TASK_VIEWS.map(([id, name]) => h("button", {
type: "button", class: "icon-only", ...named(name), "aria-pressed": String(!inbox && ui.views.tasks === id), on: {click: () => go("tasks", id)},
}, icon(Nav.ICONS[id]))));
}
function tasksScreen() {
const inbox = ui.screen === "inbox";
const view = ui.views.tasks;
const chips = h("div", {class: "chips", role: "group", "aria-label": "Aufgabenfilter"});
const count = h("span", {class: "muted"});
const search = inbox ? null : h("input", {type: "search", class: "field", placeholder: "Aufgabe, Titel, Worker, Branch filtern …", value: taskView.q, "aria-label": "Aufgaben filtern", on: {input: (event) => {
taskView.q = event.target.value;
drawTasks();
}}});
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);
}
function drawTasks() {
const dom = taskView.dom;
if (!dom) {
return;
}
const q = taskView.q.trim().toLowerCase();
const sig = JSON.stringify([state.tasks, state.jobs, taskView.filter, q, waitingCount(), Math.floor(nowSeconds() / 60)]);
if (sig === taskView.sig) {
return;
}
taskView.sig = sig;
const rows = state.tasks;
dom.chips.replaceChildren(...taskChips(rows, dom.inbox));
if (dom.inbox) {
dom.count.textContent = `${rows.length} Aufgaben im Register`;
return;
}
const [, , , test] = TASK_FILTERS.find(([id]) => id === taskView.filter);
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} von ${rows.length} Aufgaben`;
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("Keine Aufgabe", "Kein Eintrag passt zu diesem Filter."));
}
+10 -3
View File
@@ -50,16 +50,21 @@
h("code", {}, found.ref), h("span", {class: "muted"}, found.task), found.ended ? pill("Aufgabe beendet", "warn") : null);
}
function openWork(wt) {
return wt.open_work.length ? chip("bi-lightning-charge", `offene Arbeit: ${wt.open_work.map((reason) => (WORK_CHIPS[reason] || [null, reason])[1]).join(", ")}`, "warn", wt.open_work.length) : null;
}
function worktreeRow(wt) {
const review = wt.review;
return h("div", {class: "wt"},
return h("div", {class: wt.open_work.length ? "wt open-work" : "wt", "data-open-work": wt.open_work.join(" ") || null},
h("div", {class: "wt-head"},
h("span", {class: "states"}, worktreeStates(wt)),
h("span", {class: "states"}, worktreeStates(wt), openWork(wt)),
h("code", {}, wt.path),
h("span", {class: "muted", title: wt.head}, wt.head.slice(0, 10)),
wt.workers.length ? h("span", {class: "who"}, wt.workers.map((name) => [face(name, false), person(name)])) : h("span", {class: "muted"}, "niemand"),
review ? [status(review.status), review.gate ? pill(review.gate === "pass" ? "Gate grün" : "Gate rot", review.gate === "pass" ? "ok" : "bad") : null] : null,
copyButton(wt.path)),
flowLine(wt.flow, wt.branch),
wt.artefacts && wt.artefacts.length ? h("ul", {class: "leaves", "aria-label": "Artefakte"}, wt.artefacts.map(leafRow)) : null);
}
@@ -114,9 +119,11 @@
const trees = repo.branches.flatMap((entry) => entry.worktrees).concat(repo.detached);
const dirty = trees.filter((wt) => wt.dirty).length;
const merged = repo.branches.filter((entry) => entry.cleanup).length;
const busy = trees.filter((wt) => wt.open_work.length).length;
const details = h("details", {class: "card repo", open: Boolean(q) || tree.open.has(repo.path)},
h("summary", {}, icon("bi-git"), h("span", {}, repo.name), counter(`${repo.branches.length} Branches`), counter(`${trees.length} Worktrees`),
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null),
dirty ? chip("bi-pencil-square", `${dirty} Worktrees mit Änderungen`, "warn", dirty) : null, merged ? chip("bi-sign-merge-left", `${merged} gemergt, löschbar`, "ok", merged) : null,
busy ? chip("bi-lightning-charge", `${busy} Worktrees mit offener Arbeit`, "warn", busy) : null),
branches.map((entry) => branchRow(repo, entry)),
detached.length ? h("div", {class: "branch-row"}, h("div", {class: "branch-name"}, h("strong", {}, "losgelöster HEAD")), detached.map(worktreeRow)) : null);
details.addEventListener("toggle", () => {
+44
View File
@@ -0,0 +1,44 @@
.graph.tasks { grid-template-columns: minmax(0, 1fr); }
.tasks-body { min-width: 0; }
.tasks .controls input[type="search"] { flex: 1; min-width: min(240px, 100%); }
.tasks > .chips { margin-bottom: 0; }
.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); }
.task-row .head-row { margin-bottom: 0; }
.task-row > .linkish { justify-self: start; font-size: var(--fs-lg); text-align: left; }
.task-row.open-work, .task-card.open-work, .wt.open-work, .matrix tr.open-work > th { box-shadow: inset 3px 0 0 var(--warn); }
.task-row.ended, .task-card.ended { opacity: .72; }
.task-id { font-size: var(--fs-sm); color: var(--muted); overflow-wrap: anywhere; }
.task-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; min-width: 0; color: var(--muted); font-size: var(--fs-sm); }
.task-meta code { overflow-wrap: anywhere; }
.task-board { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); gap: 12px; align-items: start; }
.task-column { display: grid; gap: 8px; min-width: 0; padding: var(--sp-3); }
.task-card { display: grid; gap: 4px; min-width: 0; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: var(--sunken); font-size: var(--fs-sm); }
.task-card .linkish { overflow-wrap: anywhere; }
.flow { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; font-size: var(--fs-sm); }
.flow-label { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-weight: 650; }
.flow-side { display: inline-flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.flow-ref { padding: 0 6px; border: 1px solid var(--line); border-radius: 6px; background: var(--sunken); line-height: 20px; overflow-wrap: anywhere; }
.flow-ref.here { border-color: transparent; background: var(--accent-soft); color: var(--accent); font-weight: 650; }
.flow-arrow { display: inline-flex; color: var(--muted); }
.flow.invalid { gap: 6px; color: var(--bad); }
.states.work .state { gap: 5px; }
.matrix-box { display: grid; gap: 8px; min-width: 0; }
.matrix-scroll { max-width: 100%; max-height: 70vh; overflow: auto; border: 1px solid var(--line); border-radius: var(--radius); background: var(--glass-card); }
table.matrix { border-collapse: separate; border-spacing: 0; font-size: var(--fs-sm); }
table.matrix th, table.matrix td { padding: 6px 8px; border-bottom: 1px solid var(--line); text-align: center; vertical-align: middle; white-space: nowrap; }
table.matrix thead th { position: sticky; top: 0; z-index: 1; background: var(--panel); vertical-align: bottom; }
table.matrix th[scope="row"], table.matrix thead th:first-child { position: sticky; left: 0; background: var(--panel); text-align: left; }
table.matrix thead th:first-child { z-index: 2; }
table.matrix th[scope="row"] { z-index: 1; min-width: min(240px, 52vw); max-width: 320px; white-space: normal; }
.matrix-task { display: grid; gap: 2px; }
.matrix .party { display: inline-block; max-height: 150px; overflow: hidden; text-overflow: ellipsis; writing-mode: vertical-rl; transform: rotate(180deg); }
.matrix td .letter + .letter { margin-left: 2px; }
.task-edit { display: grid; gap: 8px; }
.task-edit h3 { margin: 6px 0 0; color: var(--muted); font-size: var(--fs-xs); font-weight: 650; letter-spacing: .04em; text-transform: uppercase; }
.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; }
svg .node.a-worktree.open circle { stroke: var(--warn); stroke-width: 2.6; stroke-dasharray: 3 2; }
svg .edge.flow { stroke-dasharray: 2 5; }