From 1bcc8c135f7b8a5657703c8f147da10ba11cae83 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 11 Nov 2026 18:13:24 +0000 Subject: [PATCH 1/3] feat(workbench): requirement 003 with the gates and register contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requirement 003 Operator Workbench (operator-confirmed 2026-11-11) and the contracts its work packages build on: gates.py keeps gate rules, branch parents and gate receipts in ~/.local/state/agents-council/gates.db (resolve takes the branch's own level, else the strictest of all parents; derive records the nearest ancestor tips; tree hashes everything a worktree holds through a temporary index). rasci.py gains --unpin, stamps every changed row with updated, and ranks a task after its after-dependencies. ๐ŸŽฏ Fix: 95 % ๐Ÿ’ฅ Break: 3 % ๐Ÿ’ธ Cost: 100 % โฑ๏ธ Time: 100 % ๐Ÿ›ก๏ธ Security: 100 % ๐Ÿ† Score: 92 ๐Ÿšฆ Gate: โœ… 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- docs/requirements/003-operator-workbench.md | 316 ++++++++++++++++++++ docs/requirements/README.md | 1 + skills/kvb-check-gate/scripts/gates.py | 251 ++++++++++++++++ skills/kvb-org-rasci/scripts/rasci.py | 24 +- tests/test_gates.py | 94 ++++++ tests/test_rasci.py | 16 + 6 files changed, 701 insertions(+), 1 deletion(-) create mode 100644 docs/requirements/003-operator-workbench.md create mode 100755 skills/kvb-check-gate/scripts/gates.py create mode 100644 tests/test_gates.py diff --git a/docs/requirements/003-operator-workbench.md b/docs/requirements/003-operator-workbench.md new file mode 100644 index 0000000..3157107 --- /dev/null +++ b/docs/requirements/003-operator-workbench.md @@ -0,0 +1,316 @@ +# 003 - Operator Workbench + +## User Story + +As the operator of many parallel agents, I want to handle every open task, +review, plan, branch gate and resource figure from one simple, interactive +Agent Orchestrator, and agents that commit through fast deterministic hooks +instead of skipping them, so that I steer the work by drag and drop, see what +changed and what it costs, and a gate means something again. + +## Decisions (operator-confirmed 2026-11-11; do not re-litigate) + +1. **One document**: the whole package lives here as work packages W1 to W5. + Rejected: one document per feature. +2. **Operator tasks next to Tasks**: the Inbox entry leaves the top navigation + and the Stream; the Tasks entry carries the count of open operator tasks; + the Tasks filter "Waits on Operator" shows them as Stream cards with + checkboxes and the bulk bar. The Stream keeps its operator cards with their + actions. Rejected: a separate Operator tab, a Stream without operator rows. +3. **Diff preview**: every review card shows its diff expanded; the requesting + agent's script ships it with the review (the hub reads no git, 001); one + block per file, a file over 400 changed lines collapsed, 2 MiB cap with the + rest listed by stat; lines MEDUSA's secret rules match are masked. + Rejected: the hub reads git. + Every Ask for a `git commit` shows the staged diff the same way (with + `-a` or `--all` the diff against HEAD); the permission hook on the host + attaches it (operator order 2026-11-11). +4. **Inline feedback**: selected lines of a diff or a command go back to the + agent as one radio `post` with path, line range, quote and text, through + the right-click menu, the key `f` or a button at the selection. +5. **Kanban**: columns Waiting, Running and Done; Blocked and Stopped become + labels; `operator/*` rows stay off the board; swimlanes P1, P2, P3, P4+ and + Unpinned (by WSJF), collapsible; Done older than 24 h hidden by default. +6. **Lines**: solid arrows are `after` dependencies, several into one card; one + dashed line runs through the cards holding a live queue entry, in queue + order. Rejected: rank lines per lane, dependencies only. +7. **Drag and drop**: card to column = status, to lane = priority pin, handle + onto a card = `after`; a click on an arrow removes it; a click on a card + opens its popup. Moves are staged, shown at once and applied with one + passkey; each affected agent gets a post. Rejected: a passkey per move, no + passkey. +8. **Dependencies are enforced**: the queue holds a task until its `after` + tasks are done, and the rank follows them. Rejected: display only, warning + only. +9. **Forecast**: two lines in distinct colours, the finish at fixed scope and + the finish at growing scope (done trend meets scope trend); โˆž when never + reached. Scope excludes `operator/*`; a task unchanged for 3 days is + labelled stale and counted apart. Rejected: count everything, only drop + operator rows. +10. **Clean all merged**: dry run, one passkey, signed job. Local branches + merged by ancestry and without a remote branch need no token; remote + branches and squash-merge candidates need the GitHub token, stored through + an operator row. Rejected: wait for the token, ancestry for remotes. +11. **Ancestry**: Artifacts and Agents get a family tree (generations top down) + in the slot of today's Tree view. A branch may have several parents; + parents are recorded at creation (post-checkout hook), older branches are + derived and marked derived, and the operator corrects them by drag and + drop. Rejected: derivation only, manual only. +12. **Gates**: a level (none < quick < test < quality-high < e2e) per branch + and action (commit, merge, push, pr), inherited from all ancestors, the + strictest wins, an own rule wins over all; kept in the local database + `~/.local/state/agents-council/gates.db` (the council root lives in the + tmpfs `/tmp` and loses it at reboot) that agents read through `gates.py`; + set in every Artifacts view. Rejected: a committed `.gates.yml`, repo plus + local. +13. **Hooks with receipts**: pre-commit runs only quick checks of the staged + files (< 30 s); heavy gates run through the queue and on green write a + receipt (tree sha of the tested content, level, log); commit-msg, + pre-merge-commit and pre-push only compare the receipt with the resolved + level (< 1 s). `--no-verify` only on the operator's word for that commit. + Core's hooks change only in a core PR agreed with Experiment and Main. + Rejected: heavy gates at pre-push, skills-side checks with `--no-verify`. +14. **Resources**: a host unit samples every 60 s without any LLM call: disks + per mount, CPU, RAM, swap, load, GPU and VRAM (`nvidia-smi` only while the + dGPU is awake, else asleep), the queue; tokens per agent and model from + the transcripts' usage fields. 7 days at 1 min, 90 days at 15 min. + Replaces 001 S7. +15. **Simplicity**: icons, counts and names instead of text; every action is + reachable in the GUI. +16. **Delivery**: one subtree `feature/operator-workbench` with split + worktrees, at most 4 subagents at a time on W1 to W4, W5 serially in the + requesting agent with Experiment; one gate (make test, e2e Chromium and + Firefox at 1920, 1366 and 390 px, MEDUSA, 90/5); master only on the + operator's word. Rejected: 8 in parallel, serial without subagents. + +## Approach + +```mermaid +flowchart TD + subgraph P["๐Ÿงญ 0 ยท Preparation"] + P1["๐Ÿ“‹ Write requirement"] --> P2{"โœ… Operator approves?"} + P2 -- "โœ๏ธ corrections" --> P1 + end + P2 -- "๐Ÿค– yes" --> C1 + subgraph C["๐Ÿ“ 1 ยท Contracts"] + C1["๐Ÿ—„๏ธ gates.db schema, staged-change job, diff payload, sampler files"] --> C2["๐Ÿ”ด Red tests per work package"] + end + C2 --> W1A & W2A & W3A & W4A & W5A + subgraph W1["๐Ÿ“ฅ W1 ยท Operator tasks"] + W1A["๐Ÿ› ๏ธ Nav badge, Waits on Operator cards, diff, feedback"] --> W1B{"๐Ÿงช Green?"} + W1B -- "๐Ÿ”ด fix" --> W1A + end + subgraph W2["๐Ÿ—‚๏ธ W2 ยท Kanban and forecast"] + W2A["๐Ÿ› ๏ธ Lanes, labels, drag and drop, arrows, popup, two forecasts, stale"] --> W2B{"๐Ÿงช Green?"} + W2B -- "๐Ÿ”ด fix" --> W2A + end + subgraph W3["๐ŸŒณ W3 ยท Artifacts"] + W3A["๐Ÿ› ๏ธ Clean all merged, ancestry views, gate icons"] --> W3B{"๐Ÿงช Green?"} + W3B -- "๐Ÿ”ด fix" --> W3A + end + subgraph W4["๐Ÿ“ˆ W4 ยท Resources"] + W4A["๐Ÿ› ๏ธ Sampler unit, token parser, view"] --> W4B{"๐Ÿงช Green?"} + W4B -- "๐Ÿ”ด fix" --> W4A + end + subgraph W5["๐Ÿช W5 ยท Gates, receipts, hooks"] + W5A["๐Ÿ› ๏ธ gates.py, receipts, hooks, queue after-wait"] --> W5B{"๐Ÿค Experiment agrees?"} + W5B -- "โœ๏ธ changes" --> W5A + end + W1B & W2B & W3B & W4B -- "๐ŸŸข" --> U1 + W5B -- "๐Ÿ‘" --> U1 + subgraph U["๐Ÿ”€ 2 ยท Unify"] + U1["๐Ÿ”€ Merge splits into the subtree"] --> U2{"๐Ÿงช Gate: make test, e2e x3, MEDUSA, 90/5"} + U2 -- "๐Ÿ”ด fix" --> U1 + U2 -- "๐ŸŸข" --> U3["๐Ÿ“ธ Screenshots 1920 / 1366 / 390"] + U3 --> UF{"๐Ÿ’ฌ Operator feedback?"} + UF -- "โœ๏ธ changes" --> U1 + end + UF -- "๐Ÿ‘ commit word" --> D1["๐Ÿ Merge to master, install row, core PR proposal"] +``` + +## Acceptance Criteria + +### W1 Operator tasks + +- [ ] The top navigation has no Inbox entry; the Tasks entry shows the count of + waiting, unsnoozed operator tasks, hidden at 0. +- [ ] The Stream has no inbox button; `#stream/inbox` opens `#tasks/operator`. +- [ ] `#tasks/operator` shows each operator task as a Stream card (command, + directory, reason, run, deny, copy, snooze, pin) with a checkbox; the + bulk bar acts on the checked cards. +- [ ] The Stream keeps its operator cards with their actions. +- [ ] The bulk approve counts and approves every checked card, asks, reviews + and operator commands alike, with one passkey. +- [ ] Approving or denying an Ask in the hub also settles the agent's native + permission dialog where the runtime allows it (the permission hook + waits for the hub's decision); where it does not, the limit is recorded + here. +- [ ] A review card shows its diff expanded: one unified block per file, + additions and deletions coloured, a file over 400 changed lines + collapsed, a binary file named only. +- [ ] The review request of `approval.py` (kvb-git-diff) carries + `git diff --cached` of the exact staged tree; past 2 MiB the rest is + listed per file with its stat. +- [ ] An Ask for a `git commit` carries the staged diff of its working + directory (with `-a` or `--all` the diff against HEAD), attached by + `permission_hook.py`, and shows it like a review, feedback included. +- [ ] A diff line MEDUSA's secret rules match is shown masked. +- [ ] Selecting lines in a diff or a command and choosing Feedback (right-click + menu, key `f`, or the button at the selection) posts one radio `post` to + the requesting agent with path, line range, quote and text; the feedback + shows under the card. + +### W2 Kanban + +- [ ] Columns Waiting, Running and Done; no Blocked or Stopped column. +- [ ] A blocked task carries a red Blocked label, a stopped one a grey Stopped + label in Done; `operator/*` rows are not on the board. +- [ ] Swimlanes P1, P2, P3, P4+ and Unpinned, collapsible, Unpinned sorted by + WSJF rank; Done cards older than 24 h hidden until shown. +- [ ] Each `after` dependency is a solid arrow into the dependent card, several + per card allowed; the cards holding a live queue entry are joined by one + dashed line in queue order. +- [ ] Dragging a card to a column stages a status, to a lane a pin (P4+ the + lowest free pin from 4, Unpinned none), its handle onto a card an + `after`; a click on an arrow stages its removal; every move is also in + the card's keyboard menu. +- [ ] Staged moves show at once as pending; "Apply n changes" signs them with + one passkey; the host executor applies them through `rasci.py`, refuses + a cycle or a `done` with an open key result, and the card shows why. +- [ ] Each affected agent receives one post listing the operator's changes to + its rows. +- [ ] A click on a card opens its detail popup. + +### W2 Forecast + +- [ ] The burn chart draws, in distinct colours with a legend, the finish at + fixed scope and the finish at growing scope, each ending at its date; a + line never reached shows โˆž. +- [ ] Scope excludes `operator/*`; a task without a register change for 3 days + carries a Stale label, is counted apart and can be stopped from the + board. + +### W3 Artifacts + +- [ ] "Clean all merged" shows the gc dry run of every `merged-clean` item; one + passkey runs the signed job. +- [ ] Without a GitHub token, local branches merged by ancestry into the + default branch and without a remote branch can be deleted; the others + show "token needed", and an operator row stores the token. +- [ ] Artifacts and Agents show Ancestry in the Tree slot: generations top + down, several parents per node; a derived parent edge is dashed. +- [ ] Dragging a branch onto another stages a parent edge, applied with the + board's passkey batch. +- [ ] Agents' Ancestry shows operator, chair, sessions and subagents. +- [ ] Every Artifacts view shows each branch's effective gate level per action + as an icon (inherited ones faint, source in the tooltip) and lets the + operator set a rule, signed. + +### W5 Gates, receipts and hooks + +- [ ] `gates.py` keeps rules, parents and receipts in + `~/.local/state/agents-council/gates.db`. +- [ ] `gates.py resolve --repo R --branch B --action A` prints the level and its + source: the branch's own rule, else the strictest resolved level of all + ancestors, else none. +- [ ] The post-checkout hook records as parents every local branch whose tip + equals the new branch's start commit; `gates.py parents derive` adds + derived parents for the others. +- [ ] A green queue gate writes a receipt: repo, tree sha of the tested content + (tracked, staged, unstaged, untracked, via a temporary index), level, + log, time. +- [ ] `gates.py hooks install --repo R` installs pre-commit (quick checks of the + staged files, under 30 s on this repository), commit-msg, + pre-merge-commit, pre-push and post-checkout. +- [ ] commit-msg, pre-merge-commit and pre-push pass when a receipt for the + resulting tree meets the resolved level, else refuse with the + `queue.py hold` command that produces it; each check stays under 1 s. +- [ ] `queue.py hold` for a task whose `after` tasks are not all done waits with + `waits for `; the register rank places a task after its `after` + tasks. +- [ ] The commit skills commit through the hooks; `--no-verify` only on the + operator's word for that commit. +- [ ] Experiment's and Main's answer on core's hooks is recorded here; core's + `.pre-commit-config.yaml` changes only in a core PR. + +### W4 Resources + +- [ ] `resources.py sample`, run every 60 s by a user timer that + `make orchestrator-install` enables, appends one row: used and total per + mount, CPU, RAM and swap percent, load, GPU utilization and VRAM per + GPU, waiting and active queue jobs. +- [ ] An NVIDIA GPU whose PCI `runtime_status` is not `active` is recorded as + asleep without running `nvidia-smi`. +- [ ] `resources.py tokens` adds input, output, cache-read and cache-write + tokens per agent and model from transcript usage fields since the last + byte offset; no sampler or parser calls an LLM. +- [ ] Rows older than 7 days fold into 15-minute means; means older than 90 + days are dropped. +- [ ] The Resources entry shows on one time axis disks, CPU, RAM, swap, GPU, + VRAM and stacked tokens per agent; bands mark idle (< 30 %) and + saturated (> 90 %); a marker flags spans where jobs waited while CPU and + RAM stayed under 50 %; the view reads only the sampler files. +- [ ] 001 S7 points to this section. + +### Contracts + +The work packages meet only through these interfaces: + +- **Gates**: `skills/kvb-check-gate/scripts/gates.py` (its docstring is the + reference). The hub reads `gates.py show --json`; a GUI change is a signed + job whose argv is `rule set|drop` or `parents add|drop` + (`signing.gate_argv`), run by the host runner with `--gates`. +- **Register**: the board's signed task changes (`signing.task_argv`) accept + `set ` with exactly one of `--status `, `--after `, + `--drop-after `, `--prio ` or `--unpin`; every changed row carries + `updated` (ISO time), which Stale reads. +- **Review diff**: the review request of `approval.py` gains `diff`, a list of + `{path, status, additions, deletions, patch, masked}`: status one of A, M, + D, R or B (binary), patch the unified text (empty for binary files and + past the 2 MiB cap), masked the line numbers shown masked. An Ask for a + `git commit` carries the same `diff` from `permission_hook.py`. +- **Feedback**: a feedback is a message to the requesting agent through the + existing signed message flow, text `Feedback on :-: + "" `. +- **Resources**: the host unit writes `samples.jsonl` (`{t, disks: [{mount, + used, total}], cpu, ram, swap, load, gpus: [{name, util, vram_used, + vram_total} | {name, asleep: true}], queue: {waiting, active}}`), + `tokens.jsonl` (`{t, agent, model, input, output, cache_read, + cache_write}`, t the hour) and `offsets.json` under + `~/.local/state/agents-council/resources/`. +- **Mounts**: the hub mounts `~/.local/state/agents-council` read-only and + opens `gates.db` read-only. + +### Simplicity + +- [ ] Every new control is a Bootstrap Icon with `title` and `aria-label`; no new + visible text beyond counts and names. + +### Tests + +- Unit: + - [ ] Board staging, lanes and refusals (cycle, open key result); forecast + fixed, growing and never; stale; scope without `operator/*`. + - [ ] Gates resolve (own rule, strictest of several ancestors, none); + parents derive; receipt tree sha with unstaged and untracked files; + hook pass and refuse; queue wait on `after`. + - [ ] Resources sample parsing on fixtures (df, meminfo, nvidia-smi, asleep + GPU); token offsets; retention fold; diff cap and masking; feedback + payload. +- Integration: + - [ ] A scratch repository with installed hooks refuses a commit without a + receipt and passes it after a queue gate; refuses a merge below the + parent's level; post-checkout records parents. + - [ ] The host executor applies a signed board batch to a scratch register + and refuses a forged one; deletes a local merged branch without a + token and refuses a remote one. + - [ ] The sampler writes rows in a scratch HOME. +- E2E, headless Chromium and Firefox at 1920, 1366 and 390 px: + - [ ] Tasks badge, no Inbox entry, operator cards with bulk actions, review + diff expanded, feedback by right-click and `f` reaching the radio + store. + - [ ] Kanban lanes, no Blocked or Stopped column, drags to column, lane and + dependency, Apply with a test passkey, arrows and the dashed queue + line, card popup; forecast two lines and โˆž. + - [ ] Artifacts clean-all dry run, Ancestry in both tabs, a gate set; + Resources charts; no page scrollbar. diff --git a/docs/requirements/README.md b/docs/requirements/README.md index 2994547..611a809 100644 --- a/docs/requirements/README.md +++ b/docs/requirements/README.md @@ -6,3 +6,4 @@ skill defines the format. - [001 - Agent Orchestrator](001-agent-orchestrator.md) - [002 - Cosmos](002-cosmos.md) +- [003 - Operator Workbench](003-operator-workbench.md) diff --git a/skills/kvb-check-gate/scripts/gates.py b/skills/kvb-check-gate/scripts/gates.py new file mode 100755 index 0000000..98a8fde --- /dev/null +++ b/skills/kvb-check-gate/scripts/gates.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Gate rules, branch parents and gate receipts of every repository on this host. + +Args: + --db: the SQLite database, default ~/.local/state/agents-council/gates.db + (GATES_DB overrides it); it survives reboots, the council root under + /tmp does not. + + rule set --repo R --branch B --action A --level L: the branch's own level + for one action. rule drop --repo R --branch B --action A removes it. + parents add|drop --repo R --branch B --parent P [--source S]: one parent + edge; S is recorded, derived or operator (default operator). + parents derive --repo R: gives every local branch without a parent, except + the default branch, the nearest local branches whose tip is its + ancestor, as derived parents. + resolve --repo R --branch B --action A: prints `\t`, the + branch's own level, else the strictest resolved level of all its + parents, else `none\t-`. + tree --worktree W: prints the tree sha of everything W holds, tracked, + staged, unstaged and untracked, ignored files left out. + receipt add --repo R --tree T --level L --log P: records a green gate. + receipt check --repo R --tree T --level L: exit 0 when a receipt for T + meets L, else 1. + show --json [--repo R]: {repo: {branch: {parents: [{parent, source}], + rules: {action: level}, resolved: {action: {level, source}}}}} for the + local branches of R, or of every repository the database knows. + + R is any path inside the repository; it is stored as the absolute path of + the main checkout, so linked worktrees share it. + +Exit codes: + 0 done, 1 a receipt check that fails, 2 usage error. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +LEVELS = ("none", "quick", "test", "quality-high", "e2e") +ACTIONS = ("commit", "merge", "push", "pr") +SOURCES = ("recorded", "derived", "operator") +DEFAULT_DB = Path.home() / ".local" / "state" / "agents-council" / "gates.db" +SCHEMA = """ +CREATE TABLE IF NOT EXISTS rules (repo TEXT, branch TEXT, action TEXT, level TEXT, set_at REAL, + PRIMARY KEY (repo, branch, action)); +CREATE TABLE IF NOT EXISTS parents (repo TEXT, branch TEXT, parent TEXT, source TEXT, + PRIMARY KEY (repo, branch, parent)); +CREATE TABLE IF NOT EXISTS receipts (repo TEXT, tree TEXT, level TEXT, log TEXT, at REAL, + PRIMARY KEY (repo, tree, level)); +""" + + +def fail(message: str) -> None: + print(f"gates: {message}", file=sys.stderr) + sys.exit(2) + + +def git(cwd: str | Path, *args: str, env: dict | None = None) -> str: + result = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=False, env=env) + if result.returncode != 0: + fail(result.stderr.strip() or f"git {args[0]} failed in {cwd}") + return result.stdout.strip() + + +def ancestor(repo: str, older: str, newer: str) -> bool: + return subprocess.run( + ["git", "merge-base", "--is-ancestor", older, newer], cwd=repo, capture_output=True, check=False + ).returncode == 0 + + +def root(path: str) -> str: + common = Path(git(path, "rev-parse", "--path-format=absolute", "--git-common-dir")) + return str(common.parent if common.name == ".git" else common) + + +def connect(path: Path) -> sqlite3.Connection: + path.parent.mkdir(parents=True, exist_ok=True) + db = sqlite3.connect(path) + db.executescript(SCHEMA) + return db + + +def branches(repo: str) -> dict[str, str]: + listed = git(repo, "for-each-ref", "refs/heads", "--format=%(refname:short) %(objectname)") + return dict(line.split(" ", 1) for line in listed.splitlines() if line) + + +def default_branch(repo: str, local: dict[str, str]) -> str | None: + remote = subprocess.run( + ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd=repo, capture_output=True, text=True, + check=False, + ).stdout.strip() + named = remote.removeprefix("origin/") if remote else None + return next((name for name in (named, "main", "master") if name in local), None) + + +def parents_of(db: sqlite3.Connection, repo: str, branch: str) -> list[tuple[str, str]]: + return db.execute( + "SELECT parent, source FROM parents WHERE repo = ? AND branch = ? ORDER BY parent", (repo, branch) + ).fetchall() + + +def resolve(db: sqlite3.Connection, repo: str, branch: str, action: str, seen: frozenset = frozenset()) -> tuple[str, str | None]: + own = db.execute( + "SELECT level FROM rules WHERE repo = ? AND branch = ? AND action = ?", (repo, branch, action) + ).fetchone() + if own: + return own[0], branch + best: tuple[str, str | None] = ("none", None) + for parent, _ in parents_of(db, repo, branch): + if parent in seen or parent == branch: + continue + level, source = resolve(db, repo, parent, action, seen | {branch}) + if LEVELS.index(level) > LEVELS.index(best[0]): + best = (level, source) + return best + + +def derive(db: sqlite3.Connection, repo: str) -> list[tuple[str, str]]: + local = branches(repo) + default = default_branch(repo, local) + added = [] + for branch, tip in sorted(local.items()): + if branch == default or parents_of(db, repo, branch): + continue + candidates = [ + other for other, other_tip in local.items() + if other != branch and (other_tip != tip or other == default) and ancestor(repo, other_tip, tip) + ] + nearest = [ + other for other in candidates + if not any(more != other and local[more] != local[other] and ancestor(repo, local[other], local[more]) for more in candidates) + ] + for parent in sorted(nearest): + db.execute("INSERT OR IGNORE INTO parents VALUES (?, ?, ?, 'derived')", (repo, branch, parent)) + added.append((branch, parent)) + db.commit() + return added + + +def worktree_tree(worktree: str) -> str: + with tempfile.TemporaryDirectory(prefix="kvb-gates-") as scratch: + env = {**os.environ, "GIT_INDEX_FILE": str(Path(scratch) / "index")} + git(worktree, "read-tree", "HEAD", env=env) + git(worktree, "add", "-A", ".", env=env) + return git(worktree, "write-tree", env=env) + + +def show(db: sqlite3.Connection, repos: list[str]) -> dict: + shown = {} + for repo in repos: + named = set(branches(repo)) | {row[0] for row in db.execute("SELECT branch FROM rules WHERE repo = ?", (repo,))} + shown[repo] = { + branch: { + "parents": [{"parent": parent, "source": source} for parent, source in parents_of(db, repo, branch)], + "rules": dict(db.execute("SELECT action, level FROM rules WHERE repo = ? AND branch = ?", (repo, branch))), + "resolved": { + action: dict(zip(("level", "source"), resolve(db, repo, branch, action))) for action in ACTIONS + }, + } + for branch in sorted(named) + } + return shown + + +def parser() -> argparse.ArgumentParser: + top = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + top.add_argument("--db", type=Path, default=Path(os.environ.get("GATES_DB", DEFAULT_DB))) + sub = top.add_subparsers(dest="command", required=True) + rule = sub.add_parser("rule").add_subparsers(dest="verb", required=True) + for verb in ("set", "drop"): + p = rule.add_parser(verb) + p.add_argument("--repo", required=True) + p.add_argument("--branch", required=True) + p.add_argument("--action", required=True, choices=ACTIONS) + if verb == "set": + p.add_argument("--level", required=True, choices=LEVELS) + parents = sub.add_parser("parents").add_subparsers(dest="verb", required=True) + for verb in ("add", "drop"): + p = parents.add_parser(verb) + p.add_argument("--repo", required=True) + p.add_argument("--branch", required=True) + p.add_argument("--parent", required=True) + if verb == "add": + p.add_argument("--source", choices=SOURCES, default="operator") + parents.add_parser("derive").add_argument("--repo", required=True) + p = sub.add_parser("resolve") + p.add_argument("--repo", required=True) + p.add_argument("--branch", required=True) + p.add_argument("--action", required=True, choices=ACTIONS) + sub.add_parser("tree").add_argument("--worktree", required=True) + receipt = sub.add_parser("receipt").add_subparsers(dest="verb", required=True) + for verb in ("add", "check"): + p = receipt.add_parser(verb) + p.add_argument("--repo", required=True) + p.add_argument("--tree", required=True) + p.add_argument("--level", required=True, choices=LEVELS) + if verb == "add": + p.add_argument("--log", required=True) + p = sub.add_parser("show") + p.add_argument("--json", action="store_true", required=True) + p.add_argument("--repo") + return top + + +def main() -> int: + args = parser().parse_args() + if args.command == "tree": + print(worktree_tree(args.worktree)) + return 0 + db = connect(args.db) + repo = root(args.repo) if getattr(args, "repo", None) else None + if args.command == "rule" and args.verb == "set": + db.execute("INSERT OR REPLACE INTO rules VALUES (?, ?, ?, ?, ?)", (repo, args.branch, args.action, args.level, time.time())) + elif args.command == "rule": + db.execute("DELETE FROM rules WHERE repo = ? AND branch = ? AND action = ?", (repo, args.branch, args.action)) + elif args.command == "parents" and args.verb == "add": + if args.parent == args.branch: + fail(f"{args.branch} cannot be its own parent") + db.execute("INSERT OR REPLACE INTO parents VALUES (?, ?, ?, ?)", (repo, args.branch, args.parent, args.source)) + elif args.command == "parents" and args.verb == "drop": + db.execute("DELETE FROM parents WHERE repo = ? AND branch = ? AND parent = ?", (repo, args.branch, args.parent)) + elif args.command == "parents": + for branch, parent in derive(db, repo): + print(f"{branch}\t{parent}\tderived") + elif args.command == "resolve": + level, source = resolve(db, repo, args.branch, args.action) + print(f"{level}\t{source or '-'}") + elif args.command == "receipt" and args.verb == "add": + db.execute("INSERT OR REPLACE INTO receipts VALUES (?, ?, ?, ?, ?)", (repo, args.tree, args.level, args.log, time.time())) + elif args.command == "receipt": + levels = [row[0] for row in db.execute("SELECT level FROM receipts WHERE repo = ? AND tree = ?", (repo, args.tree))] + return 0 if any(LEVELS.index(level) >= LEVELS.index(args.level) for level in levels) else 1 + else: + known = {row[0] for row in db.execute("SELECT repo FROM rules")} | {row[0] for row in db.execute("SELECT repo FROM parents")} + repos = [repo] if repo else sorted(known) + print(json.dumps(show(db, repos))) + db.commit() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/kvb-org-rasci/scripts/rasci.py b/skills/kvb-org-rasci/scripts/rasci.py index 8365664..7376f0c 100755 --- a/skills/kvb-org-rasci/scripts/rasci.py +++ b/skills/kvb-org-rasci/scripts/rasci.py @@ -113,7 +113,12 @@ def database() -> Iterator[dict[str, dict]]: except OSError as error: fail(f"cannot lock {LOCK}: {error.strerror}") tasks = load() + before = {task: json.dumps(row, sort_keys=True) for task, row in tasks.items()} yield tasks + now = stamp() + for task, row in tasks.items(): + if before.get(task) != json.dumps(row, sort_keys=True): + row["updated"] = now nofollow.replace(ROOT, DB, json.dumps(tasks, indent=1, sort_keys=True), FILE_MODE) @@ -284,7 +289,19 @@ def ranked(rows: dict[str, dict]) -> list[str]: agent = order.get(row.get("worker"), len(order)) return (pinned is None, pinned or 0, score is None, -(score[2] if score else 0), agent, task) - return sorted(rows, key=key) + placed: list[str] = [] + + def place(task: str, trail: frozenset) -> None: + if task in placed or task in trail: + return + for before in rows[task].get("after", []): + if before in rows: + place(before, trail | {task}) + placed.append(task) + + for task in sorted(rows, key=key): + place(task, frozenset()) + return placed def by_agent(rows: dict[str, dict]) -> dict[str, list[str]]: @@ -362,6 +379,8 @@ def cmd_set(args: argparse.Namespace) -> int: check_task(args.task) if args.prio is not None and args.prio < 1: fail("--prio must be 1 or higher") + if args.unpin and args.prio is not None: + fail("--unpin removes the pin; it cannot go with --prio") times = {f: stamp(getattr(args, f)) for f in ("start", "end") if getattr(args, f)} for ref in args.artifact: if not ARTIFACT.match(ref): @@ -375,6 +394,8 @@ def cmd_set(args: argparse.Namespace) -> int: value = getattr(args, field) if value is not None: entry[field] = value + if args.unpin: + entry.pop("prio", None) if args.status: apply_status(entry, args.status) entry.update(times) @@ -591,6 +612,7 @@ def main() -> int: for field in (*TEXT_FIELDS, "start", "end"): p.add_argument(f"--{field}") p.add_argument("--prio", type=int) + p.add_argument("--unpin", action="store_true") for flag in (*LIST_FIELDS.values(), "artifact"): p.add_argument(f"--{flag}", action="append", default=[]) p.add_argument(f"--drop-{flag}", action="append", default=[]) diff --git a/tests/test_gates.py b/tests/test_gates.py new file mode 100644 index 0000000..f51103e --- /dev/null +++ b/tests/test_gates.py @@ -0,0 +1,94 @@ +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +GATES = Path(__file__).resolve().parents[1] / "skills" / "kvb-check-gate" / "scripts" / "gates.py" + + +def git(repo: Path, *args: str) -> str: + return subprocess.run(["git", *args], cwd=repo, capture_output=True, text=True, check=True).stdout.strip() + + +class TestGates(unittest.TestCase): + def setUp(self): + scratch = tempfile.TemporaryDirectory() + self.addCleanup(scratch.cleanup) + self.base = Path(scratch.name) + self.repo = self.base / "repo" + self.repo.mkdir() + git(self.repo, "init", "-q", "-b", "main") + git(self.repo, "config", "user.email", "t@example.org") + git(self.repo, "config", "user.name", "t") + self.commit("a") + self.env = {**os.environ, "GATES_DB": str(self.base / "gates.db")} + + def commit(self, name: str) -> None: + (self.repo / name).write_text(name, encoding="utf-8") + git(self.repo, "add", name) + git(self.repo, "commit", "-q", "-m", name) + + def gates(self, *args: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(GATES), *args], cwd=self.repo, env=self.env, capture_output=True, text=True, check=False + ) + + def resolve(self, branch: str, action: str = "merge") -> str: + return self.gates("resolve", "--repo", str(self.repo), "--branch", branch, "--action", action).stdout.strip() + + def test_a_branch_takes_its_own_rule_else_the_strictest_of_all_parents_else_none(self): + repo = str(self.repo) + self.assertEqual(self.resolve("fix"), "none\t-") + for parent, level in (("iam", "test"), ("production", "quality-high")): + self.gates("rule", "set", "--repo", repo, "--branch", parent, "--action", "merge", "--level", level) + self.gates("parents", "add", "--repo", repo, "--branch", "fix", "--parent", parent) + self.assertEqual(self.resolve("fix"), "quality-high\tproduction") + self.gates("rule", "set", "--repo", repo, "--branch", "fix", "--action", "merge", "--level", "quick") + self.assertEqual(self.resolve("fix"), "quick\tfix") + self.assertEqual(self.resolve("fix", "push"), "none\t-") + + def test_derive_gives_each_branch_its_nearest_ancestor_tips_and_the_default_branch_none(self): + git(self.repo, "checkout", "-q", "-b", "feature") + self.commit("b") + git(self.repo, "checkout", "-q", "-b", "fix") + self.commit("c") + derived = self.gates("parents", "derive", "--repo", str(self.repo)).stdout.splitlines() + self.assertEqual(sorted(derived), ["feature\tmain\tderived", "fix\tfeature\tderived"]) + shown = json.loads(self.gates("show", "--json", "--repo", str(self.repo)).stdout) + branches = next(iter(shown.values())) + self.assertEqual(branches["main"]["parents"], []) + self.assertEqual(branches["fix"]["parents"], [{"parent": "feature", "source": "derived"}]) + + def test_the_tree_covers_unstaged_and_untracked_files_but_not_ignored_ones(self): + tree = lambda: self.gates("tree", "--worktree", str(self.repo)).stdout.strip() + head = git(self.repo, "rev-parse", "HEAD^{tree}") + self.assertEqual(tree(), head) + (self.repo / ".gitignore").write_text("ignored\n", encoding="utf-8") + git(self.repo, "add", ".gitignore") + git(self.repo, "commit", "-q", "-m", "ignore") + (self.repo / "ignored").write_text("x", encoding="utf-8") + self.assertEqual(tree(), git(self.repo, "rev-parse", "HEAD^{tree}")) + (self.repo / "a").write_text("changed", encoding="utf-8") + (self.repo / "new").write_text("new", encoding="utf-8") + dirty = tree() + self.assertEqual(git(self.repo, "status", "--porcelain"), "M a\n?? new") + git(self.repo, "add", "-A") + self.assertEqual(dirty, git(self.repo, "write-tree")) + + def test_a_receipt_meets_its_own_level_and_every_lower_one(self): + repo, tree = str(self.repo), git(self.repo, "rev-parse", "HEAD^{tree}") + check = lambda level: self.gates("receipt", "check", "--repo", repo, "--tree", tree, "--level", level).returncode + self.assertEqual(check("quick"), 1) + self.gates("receipt", "add", "--repo", repo, "--tree", tree, "--level", "test", "--log", "/tmp/x.log") + self.assertEqual((check("quick"), check("test"), check("quality-high")), (0, 0, 1)) + + def test_a_branch_cannot_be_its_own_parent(self): + result = self.gates("parents", "add", "--repo", str(self.repo), "--branch", "fix", "--parent", "fix") + self.assertEqual(result.returncode, 2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_rasci.py b/tests/test_rasci.py index 37a2544..99b0aa4 100644 --- a/tests/test_rasci.py +++ b/tests/test_rasci.py @@ -166,6 +166,22 @@ class TestRasci(unittest.TestCase): rows = json.loads((self.root / "rasci.json").read_text(encoding="utf-8")) return {task: row["after"] for task, row in rows.items() if "after" in row} + def test_unpin_drops_the_pin_a_change_stamps_only_its_row_and_the_rank_follows_after(self): + rows = lambda: json.loads((self.root / "rasci.json").read_text(encoding="utf-8")) + self._out("set", "work/a", "--prio", "1", "--worker", "One") + self._out("set", "work/b", "--prio", "2", "--worker", "One") + self.assertEqual(self._out("agents")[0].split("\t")[3], "work/a, work/b") + self._out("set", "work/a", "--after", "work/b") + self.assertEqual(self._out("agents")[0].split("\t")[3], "work/b, work/a") + stamped = rows()["work/a"]["updated"] + self._out("set", "work/b", "--unpin") + self.assertNotIn("prio", rows()["work/b"]) + self.assertEqual(rows()["work/a"]["updated"], stamped) + self.assertIn("updated", rows()["work/b"]) + refused = self._run("set", "work/b", "--unpin", "--prio", "3") + self.assertEqual(refused.returncode, 2) + self.assertIn("--unpin", refused.stderr) + def test_after_stores_and_drops_a_dependency_and_refuses_unknown_tasks_and_cycles(self): for task in ("work/a", "work/b", "work/c"): self._out("set", task) From 64c63efc26542b9e0b5ffee0ead718ff5b4331ab Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 11 Nov 2026 22:50:49 +0000 Subject: [PATCH 2/3] feat(orchestrator): mount the host's agents-council state read-only as /kept MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The W4 resources view reads gates.db and the resources/ files of the host sampler. The installer creates ~/.local/state/agents-council/resources and writes KVB_ORCHESTRATOR_KEPT into .env; compose binds it read-only at /kept without creating the host path, and the server passes --kept to the hub. ๐ŸŽฏ Fix: 90 % ๐Ÿ’ฅ Break: 3 % ๐Ÿ’ธ Cost: 100 % โฑ๏ธ Time: 100 % ๐Ÿ›ก๏ธ Security: 100 % ๐Ÿ† Score: 87 ๐Ÿšฆ Gate: โœ… 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- orchestrator/compose.yml | 8 ++++++++ orchestrator/kvb_orchestrator/hub.py | 3 +++ orchestrator/server.py | 6 ++++-- scripts/orchestrator.sh | 6 +++--- tests/hub_case.py | 5 +++-- tests/test_hub_speech.py | 2 +- 6 files changed, 22 insertions(+), 8 deletions(-) diff --git a/orchestrator/compose.yml b/orchestrator/compose.yml index fee538c..c7bf2b0 100644 --- a/orchestrator/compose.yml +++ b/orchestrator/compose.yml @@ -49,6 +49,8 @@ services: - /tree/branch-tree.json - --timers - /timers + - --kept + - /kept - --vendor - /app/vendor - --cleanup @@ -117,6 +119,12 @@ services: read_only: true bind: create_host_path: false + - type: bind + source: "${KVB_ORCHESTRATOR_KEPT:?written into .env by scripts/install.sh}" + target: /kept + read_only: true + bind: + create_host_path: false - type: bind source: "${KVB_ORCHESTRATOR_APPROVERS:?written into .env by scripts/install.sh}" target: /approvers diff --git a/orchestrator/kvb_orchestrator/hub.py b/orchestrator/kvb_orchestrator/hub.py index 10364cc..51d86ea 100644 --- a/orchestrator/kvb_orchestrator/hub.py +++ b/orchestrator/kvb_orchestrator/hub.py @@ -49,6 +49,7 @@ class Places: vendor: Path cleanup: Path offices: Path + kept: Path def environment(templates: Path) -> jinja2.Environment: @@ -89,6 +90,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Reque agent_sessions: Path model: Model timers: Path + kept: Path settings: Settings redirects: Redirects speech: Speech @@ -137,6 +139,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta agent_sessions=state / SNAPSHOT, model=model, timers=places.timers, + kept=places.kept, council=council, pages=pages, public_port=public_port, diff --git a/orchestrator/server.py b/orchestrator/server.py index 7030b87..ea3e696 100755 --- a/orchestrator/server.py +++ b/orchestrator/server.py @@ -25,6 +25,8 @@ --tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted read-only; read again whenever it changes. --timers: directory holding the systemd user units (*.timer). + --kept: ~/.local/state/agents-council, mounted read-only: gates.db and + the resources/ files of the host sampler. --vendor: directory holding the vendored Bootstrap Icons sprite, 3d-force-graph and three with their licence texts. --cleanup: host-mounted directory the hub writes signed branch deletions @@ -78,7 +80,7 @@ def main() -> int: parser.add_argument("--bind", required=True) for name in ("--listen-port", "--public-port"): parser.add_argument(name, type=int, required=True) - paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--vendor", "--cleanup", "--offices", "--llm-key-file", "--approvers") + paths = ("--council", "--pages", "--state", "--host-state", "--skills", "--transcripts", "--templates", "--tree-file", "--timers", "--kept", "--vendor", "--cleanup", "--offices", "--llm-key-file", "--approvers") for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"): parser.add_argument(name, type=Path, required=True) for name in ("--llm-url", "--llm-model", "--operator-voice"): @@ -94,7 +96,7 @@ def main() -> int: transcripts=args.transcripts, scripts=Scripts(skills=args.skills, council=args.council, home=args.state), templates=args.templates, - places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup, offices=args.offices), + places=Places(tree_file=args.tree_file, timers=args.timers, vendor=args.vendor, cleanup=args.cleanup, offices=args.offices, kept=args.kept), model=Model(url=args.llm_url.strip(), model=args.llm_model.strip(), key=read_key(args.llm_key_file)), engines=Engines(piper=args.piper, voices=args.voices, operator_voice=args.operator_voice, whisper=args.whisper, whisper_model=args.whisper_model, scratch=args.scratch), approvers=args.approvers, diff --git a/scripts/orchestrator.sh b/scripts/orchestrator.sh index cd08030..67f4552 100644 --- a/scripts/orchestrator.sh +++ b/scripts/orchestrator.sh @@ -150,7 +150,7 @@ install_hub() { port="$(port_of "${REPO_ROOT}/orchestrator/.env")" log "skills: starting the Agent Orchestrator on http://localhost:${port}/" rm -rf "${HUB}" - mkdir -p "${HUB}" "${STATE}" "${APPROVALS}" "${COUNCIL}" "${HOME}/.claude/projects" "${TREE_DIR}" "${UNITS}" "${APPROVERS}" + mkdir -p "${HUB}" "${STATE}" "${APPROVALS}" "${COUNCIL}" "${HOME}/.claude/projects" "${TREE_DIR}" "${UNITS}" "${APPROVERS}" "${HOME}/.local/state/agents-council/resources" chmod 700 "${APPROVERS}" cp -a "${REPO_ROOT}/orchestrator/." "${HUB}/" # Python loads a cached .pyc whose header matches the source mtime and size, and cp -a keeps the mtime: one planted in the sandbox-writable tree would run on the host @@ -158,8 +158,8 @@ install_hub() { chmod +x "${HUB}/client/permission_hook.py" "${HUB}/client/runner.py" "${HUB}/client/approval.py" "${HUB}/client/cleanup.py" "${HUB}/client/offices.py" "${HUB}/client/sessions.py" log "skills: the PermissionRequest hook client is ${HUB}/client/permission_hook.py" log "skills: approvals need a pinned passkey: settings, 'Passkey registrieren', then 'make orchestrator-pin CREDENTIAL='; until then every executor refuses" - printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_TREE=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\n' \ - "$(id -u)" "$(id -g)" "${STATE}" "${COUNCIL}" "${HOME}/.claude/skills" "${HOME}/.claude/projects" "${TREE_DIR}" "${UNITS}" "${APPROVERS}" >>"${HUB}/.env" + printf 'KVB_ORCHESTRATOR_USER=%s:%s\nKVB_ORCHESTRATOR_STATE=%s\nKVB_ORCHESTRATOR_COUNCIL=%s\nKVB_ORCHESTRATOR_SKILLS=%s\nKVB_ORCHESTRATOR_PROJECTS=%s\nKVB_ORCHESTRATOR_TREE=%s\nKVB_ORCHESTRATOR_TIMERS=%s\nKVB_ORCHESTRATOR_APPROVERS=%s\nKVB_ORCHESTRATOR_KEPT=%s\n' \ + "$(id -u)" "$(id -g)" "${STATE}" "${COUNCIL}" "${HOME}/.claude/skills" "${HOME}/.claude/projects" "${TREE_DIR}" "${UNITS}" "${APPROVERS}" "${HOME}/.local/state/agents-council" >>"${HUB}/.env" if ! docker compose --file "${HUB}/compose.yml" up -d --build; then warn "skills: docker compose failed; the Agent Orchestrator stays down until 'make orchestrator-install' succeeds." fi diff --git a/tests/hub_case.py b/tests/hub_case.py index 11dfe4a..674c68d 100644 --- a/tests/hub_case.py +++ b/tests/hub_case.py @@ -47,11 +47,12 @@ class HubCase(unittest.TestCase): self.projects = base / "projects" self.tree_file = base / "tree" / "branch-tree.json" self.timers = base / "timers" + self.kept = base / "kept" self.cleanup = base / "state" / "cleanup" self.offices = base / "state" / "offices" self.approvers = base / "config" / "approvers.json" self.replay = base / "replay" - for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers): + for path in (self.pages, self.state, self.outside, self.projects, self.tree_file.parent, self.timers, self.kept): path.mkdir() silence = mock.patch.object(server.Handler, "log_message") silence.start() @@ -70,7 +71,7 @@ class HubCase(unittest.TestCase): transcripts=self.projects, scripts=self.scripts, templates=HUB / "templates", - places=server.Places(tree_file=self.tree_file, timers=self.timers, vendor=HUB / "vendor", cleanup=self.cleanup, offices=self.offices), + places=server.Places(tree_file=self.tree_file, timers=self.timers, vendor=HUB / "vendor", cleanup=self.cleanup, offices=self.offices, kept=self.kept), model=server.Model(url="", model="", key=""), engines=self.engines, approvers=self.approvers, diff --git a/tests/test_hub_speech.py b/tests/test_hub_speech.py index 6d11db6..26adb58 100644 --- a/tests/test_hub_speech.py +++ b/tests/test_hub_speech.py @@ -175,7 +175,7 @@ class TestHubSpeech(HubCase): args = { "--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state), "--host-state": str(HOST_STATE), "--skills": str(self.outside), "--transcripts": str(self.projects), "--templates": str(HUB / "templates"), - "--tree-file": str(self.tree_file), "--timers": str(self.timers), "--vendor": str(HUB / "vendor"), "--cleanup": str(self.cleanup), "--offices": str(self.offices), + "--tree-file": str(self.tree_file), "--timers": str(self.timers), "--kept": str(self.kept), "--vendor": str(HUB / "vendor"), "--cleanup": str(self.cleanup), "--offices": str(self.offices), "--llm-key-file": os.devnull, "--llm-url": "", "--llm-model": "", "--piper": str(self.engines.piper), "--voices": str(self.engines.voices), "--operator-voice": "en_US-test-high:9", "--whisper": str(self.engines.whisper), "--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers), From 07099c42aba24f3104d0fd6b20e1f637f765008a Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 11 Nov 2026 22:51:37 +0000 Subject: [PATCH 3/3] docs(requirements): 003 W6 speech bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 17 and work package W6: a toolbar toggle shows speech bubbles at the agents in every view; the speaking agent's bubble grows and reveals its text word by word in Comic Neue at reading pace, waiting agents queue as small bubbles sized by order, and a bubble fades once read. ๐ŸŽฏ Fix: 92 % ๐Ÿ’ฅ Break: 2 % ๐Ÿ’ธ Cost: 100 % โฑ๏ธ Time: 100 % ๐Ÿ›ก๏ธ Security: 100 % ๐Ÿ† Score: 90 ๐Ÿšฆ Gate: โœ… 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- docs/requirements/003-operator-workbench.md | 60 ++++++++++++++++++--- 1 file changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/requirements/003-operator-workbench.md b/docs/requirements/003-operator-workbench.md index 3157107..d1b34c9 100644 --- a/docs/requirements/003-operator-workbench.md +++ b/docs/requirements/003-operator-workbench.md @@ -10,7 +10,7 @@ changed and what it costs, and a gate means something again. ## Decisions (operator-confirmed 2026-11-11; do not re-litigate) -1. **One document**: the whole package lives here as work packages W1 to W5. +1. **One document**: the whole package lives here as work packages W1 to W6. Rejected: one document per feature. 2. **Operator tasks next to Tasks**: the Inbox entry leaves the top navigation and the Stream; the Tasks entry carries the count of open operator tasks; @@ -78,10 +78,19 @@ changed and what it costs, and a gate means something again. 15. **Simplicity**: icons, counts and names instead of text; every action is reachable in the GUI. 16. **Delivery**: one subtree `feature/operator-workbench` with split - worktrees, at most 4 subagents at a time on W1 to W4, W5 serially in the - requesting agent with Experiment; one gate (make test, e2e Chromium and - Firefox at 1920, 1366 and 390 px, MEDUSA, 90/5); master only on the + worktrees, at most 4 subagents at a time on W1 to W4 and W6, W5 serially + in the requesting agent with Experiment; one gate (make test, e2e Chromium + and Firefox at 1920, 1366 and 390 px, MEDUSA, 90/5); master only on the operator's word. Rejected: 8 in parallel, serial without subagents. +17. **Speech bubbles**: one toolbar icon switches speech bubbles on in every + view that shows agents. The agent being read aloud gets the largest + bubble, its words appear one by one in time with the audio; when it ends + the bubble fades while the next one grows and is read. Waiting messages + show a small `โ€ฆ` bubble, the newest smallest, steps in between. Without + TTS the bubbles run at reading pace. Font: vendored Comic Neue (SIL OFL + 1.1). Rejected: a ticker, the full text with a karaoke mark, bubbles only + with audio, system fonts only, a switch only in the settings, a caption + under the speaker only. ## Approach @@ -95,7 +104,7 @@ flowchart TD subgraph C["๐Ÿ“ 1 ยท Contracts"] C1["๐Ÿ—„๏ธ gates.db schema, staged-change job, diff payload, sampler files"] --> C2["๐Ÿ”ด Red tests per work package"] end - C2 --> W1A & W2A & W3A & W4A & W5A + C2 --> W1A & W2A & W3A & W4A & W5A & W6A subgraph W1["๐Ÿ“ฅ W1 ยท Operator tasks"] W1A["๐Ÿ› ๏ธ Nav badge, Waits on Operator cards, diff, feedback"] --> W1B{"๐Ÿงช Green?"} W1B -- "๐Ÿ”ด fix" --> W1A @@ -116,7 +125,11 @@ flowchart TD W5A["๐Ÿ› ๏ธ gates.py, receipts, hooks, queue after-wait"] --> W5B{"๐Ÿค Experiment agrees?"} W5B -- "โœ๏ธ changes" --> W5A end - W1B & W2B & W3B & W4B -- "๐ŸŸข" --> U1 + subgraph W6["๐Ÿ’ฌ W6 ยท Speech bubbles"] + W6A["๐Ÿ› ๏ธ Bubble layer, anchors per view, word reveal, queue sizes"] --> W6B{"๐Ÿงช Green?"} + W6B -- "๐Ÿ”ด fix" --> W6A + end + W1B & W2B & W3B & W4B & W6B -- "๐ŸŸข" --> U1 W5B -- "๐Ÿ‘" --> U1 subgraph U["๐Ÿ”€ 2 ยท Unify"] U1["๐Ÿ”€ Merge splits into the subtree"] --> U2{"๐Ÿงช Gate: make test, e2e x3, MEDUSA, 90/5"} @@ -281,6 +294,35 @@ The work packages meet only through these interfaces: - **Mounts**: the hub mounts `~/.local/state/agents-council` read-only and opens `gates.db` read-only. +### W6 Speech bubbles + +- [ ] A `bi-chat-quote` toolbar icon (`title` and `aria-label` "Speech + bubbles") switches the bubbles on and off for every view that shows + agents (Agents graph, 3D, Hierarchy, Radio, Stream); the choice is kept + in the hub settings and survives a reload. +- [ ] One overlay layer (fixed, `pointer-events: none`) draws all bubbles; a + DOM view anchors a bubble at the visible `[data-face]` element of its + agent, the 3D view projects the agent's node through the camera on every + frame; an agent without an anchor in the active view speaks from a + caption bar at the bottom. +- [ ] The bubble of `speech.current` is the largest (scale 1) and shows its + line word by word, word i of n at i/n of `buffer.duration`; text beyond + 6 lines scrolls up inside the bubble. On `ended` it fades out within + 400 ms while the next item grows to scale 1 and starts. +- [ ] Every item of `speech.pending` shows a `โ€ฆ` bubble at its agent, scaled + by queue position: current 1, then 0.8, 0.65, at least 0.5, so the + newest is the smallest; an agent with several waiting items shows one + bubble at the size of its earliest. +- [ ] With speech off, TTS unavailable or muted, the same rows run through the + same queue timed at 180 words per minute, at least 1.5 s each. +- [ ] Bubble text uses Comic Neue as a vendored woff2 with its SIL OFL 1.1 + text in `orchestrator/vendor/`, stack `"Comic Neue", "Comic Sans MS", + cursive`; no external font request, the CSP stays unchanged. +- [ ] Stop speaking clears every bubble at once; switching the bubbles off + leaves speech playback unchanged; `prefers-reduced-motion` drops the + scaling and scrolling animation. +- [ ] Bubble text is set as text content, never as HTML. + ### Simplicity - [ ] Every new control is a Bootstrap Icon with `title` and `aria-label`; no new @@ -297,6 +339,8 @@ The work packages meet only through these interfaces: - [ ] Resources sample parsing on fixtures (df, meminfo, nvidia-smi, asleep GPU); token offsets; retention fold; diff cap and masking; feedback payload. + - [ ] Bubble schedule: scale per queue position, word reveal times from a + duration, reading-pace duration, one bubble per agent. - Integration: - [ ] A scratch repository with installed hooks refuses a commit without a receipt and passes it after a queue gate; refuses a merge below the @@ -314,3 +358,7 @@ The work packages meet only through these interfaces: line, card popup; forecast two lines and โˆž. - [ ] Artifacts clean-all dry run, Ancestry in both tabs, a gate set; Resources charts; no page scrollbar. + - [ ] Bubbles on: two radio rows without TTS show the first agent's bubble + largest with its words appearing, the second as a smaller `โ€ฆ`; after + the first ends the second grows; the setting survives a reload; off + hides the layer.