feat(workbench): requirement 003 with the gates and register contracts

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 <noreply@anthropic.com>
This commit is contained in:
Kevin Veen-BirkenbachandClaude Opus 5.5 committed 2026-11-11 18:13:24 +00:00
1 parent e805b07c27
commit 1bcc8c135f
6 files changed
+701 -1

No files matched your search

+316
View File
@@ -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 <task>`; 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 <task>` with exactly one of `--status <s>`, `--after <t>`,
`--drop-after <t>`, `--prio <n>` 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 <ref> <path>:<from>-<to>:
"<quote>" <text>`.
- **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.
+1
View File
@@ -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)
+251
View File
@@ -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 `<level>\t<source>`, 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())
+23 -1
View File
@@ -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=[])
+94
View File
@@ -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()
+16
View File
@@ -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)