Merge feature/operator-workbench into unify/skills-20261111
This commit is contained in:
commit
6b75be39d5
12 files changed
+771
-9
No files matched your search
@@ -0,0 +1,364 @@
|
|||||||
|
# 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 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;
|
||||||
|
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 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
|
||||||
|
|
||||||
|
```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 & W6A
|
||||||
|
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
|
||||||
|
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"}
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
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.
|
||||||
|
- [ ] 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
|
||||||
|
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.
|
||||||
|
- [ ] 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.
|
||||||
@@ -6,3 +6,4 @@ skill defines the format.
|
|||||||
|
|
||||||
- [001 - Agent Orchestrator](001-agent-orchestrator.md)
|
- [001 - Agent Orchestrator](001-agent-orchestrator.md)
|
||||||
- [002 - Cosmos](002-cosmos.md)
|
- [002 - Cosmos](002-cosmos.md)
|
||||||
|
- [003 - Operator Workbench](003-operator-workbench.md)
|
||||||
@@ -49,6 +49,8 @@ services:
|
|||||||
- /tree/branch-tree.json
|
- /tree/branch-tree.json
|
||||||
- --timers
|
- --timers
|
||||||
- /timers
|
- /timers
|
||||||
|
- --kept
|
||||||
|
- /kept
|
||||||
- --vendor
|
- --vendor
|
||||||
- /app/vendor
|
- /app/vendor
|
||||||
- --cleanup
|
- --cleanup
|
||||||
@@ -117,6 +119,12 @@ services:
|
|||||||
read_only: true
|
read_only: true
|
||||||
bind:
|
bind:
|
||||||
create_host_path: false
|
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
|
- type: bind
|
||||||
source: "${KVB_ORCHESTRATOR_APPROVERS:?written into .env by scripts/install.sh}"
|
source: "${KVB_ORCHESTRATOR_APPROVERS:?written into .env by scripts/install.sh}"
|
||||||
target: /approvers
|
target: /approvers
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ class Places:
|
|||||||
vendor: Path
|
vendor: Path
|
||||||
cleanup: Path
|
cleanup: Path
|
||||||
offices: Path
|
offices: Path
|
||||||
|
kept: Path
|
||||||
|
|
||||||
|
|
||||||
def environment(templates: Path) -> jinja2.Environment:
|
def environment(templates: Path) -> jinja2.Environment:
|
||||||
@@ -89,6 +90,7 @@ class Hub(Mcp, Reviewing, Asking, Messaging, Choosing, Approving, Denying, Reque
|
|||||||
agent_sessions: Path
|
agent_sessions: Path
|
||||||
model: Model
|
model: Model
|
||||||
timers: Path
|
timers: Path
|
||||||
|
kept: Path
|
||||||
settings: Settings
|
settings: Settings
|
||||||
redirects: Redirects
|
redirects: Redirects
|
||||||
speech: Speech
|
speech: Speech
|
||||||
@@ -137,6 +139,7 @@ def open_hub(council: Path, pages: Path, public_port: int, state: Path, host_sta
|
|||||||
agent_sessions=state / SNAPSHOT,
|
agent_sessions=state / SNAPSHOT,
|
||||||
model=model,
|
model=model,
|
||||||
timers=places.timers,
|
timers=places.timers,
|
||||||
|
kept=places.kept,
|
||||||
council=council,
|
council=council,
|
||||||
pages=pages,
|
pages=pages,
|
||||||
public_port=public_port,
|
public_port=public_port,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
--tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted
|
--tree-file: the JSON file `pkgmgr branch tree --output` writes, mounted
|
||||||
read-only; read again whenever it changes.
|
read-only; read again whenever it changes.
|
||||||
--timers: directory holding the systemd user units (*.timer).
|
--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,
|
--vendor: directory holding the vendored Bootstrap Icons sprite,
|
||||||
3d-force-graph and three with their licence texts.
|
3d-force-graph and three with their licence texts.
|
||||||
--cleanup: host-mounted directory the hub writes signed branch deletions
|
--cleanup: host-mounted directory the hub writes signed branch deletions
|
||||||
@@ -78,7 +80,7 @@ def main() -> int:
|
|||||||
parser.add_argument("--bind", required=True)
|
parser.add_argument("--bind", required=True)
|
||||||
for name in ("--listen-port", "--public-port"):
|
for name in ("--listen-port", "--public-port"):
|
||||||
parser.add_argument(name, type=int, required=True)
|
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"):
|
for name in (*paths, "--piper", "--voices", "--whisper", "--whisper-model", "--scratch"):
|
||||||
parser.add_argument(name, type=Path, required=True)
|
parser.add_argument(name, type=Path, required=True)
|
||||||
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
for name in ("--llm-url", "--llm-model", "--operator-voice"):
|
||||||
@@ -94,7 +96,7 @@ def main() -> int:
|
|||||||
transcripts=args.transcripts,
|
transcripts=args.transcripts,
|
||||||
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
scripts=Scripts(skills=args.skills, council=args.council, home=args.state),
|
||||||
templates=args.templates,
|
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)),
|
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),
|
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,
|
approvers=args.approvers,
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ install_hub() {
|
|||||||
port="$(port_of "${REPO_ROOT}/orchestrator/.env")"
|
port="$(port_of "${REPO_ROOT}/orchestrator/.env")"
|
||||||
log "skills: starting the Agent Orchestrator on http://localhost:${port}/"
|
log "skills: starting the Agent Orchestrator on http://localhost:${port}/"
|
||||||
rm -rf "${HUB}"
|
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}"
|
chmod 700 "${APPROVERS}"
|
||||||
cp -a "${REPO_ROOT}/orchestrator/." "${HUB}/"
|
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
|
# 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"
|
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: 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=<blob>'; until then every executor refuses"
|
log "skills: approvals need a pinned passkey: settings, 'Passkey registrieren', then 'make orchestrator-pin CREDENTIAL=<blob>'; 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' \
|
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}" >>"${HUB}/.env"
|
"$(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
|
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."
|
warn "skills: docker compose failed; the Agent Orchestrator stays down until 'make orchestrator-install' succeeds."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+251
@@ -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())
|
||||||
@@ -113,7 +113,12 @@ def database() -> Iterator[dict[str, dict]]:
|
|||||||
except OSError as error:
|
except OSError as error:
|
||||||
fail(f"cannot lock {LOCK}: {error.strerror}")
|
fail(f"cannot lock {LOCK}: {error.strerror}")
|
||||||
tasks = load()
|
tasks = load()
|
||||||
|
before = {task: json.dumps(row, sort_keys=True) for task, row in tasks.items()}
|
||||||
yield tasks
|
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)
|
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))
|
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 (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]]:
|
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)
|
check_task(args.task)
|
||||||
if args.prio is not None and args.prio < 1:
|
if args.prio is not None and args.prio < 1:
|
||||||
fail("--prio must be 1 or higher")
|
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)}
|
times = {f: stamp(getattr(args, f)) for f in ("start", "end") if getattr(args, f)}
|
||||||
for ref in args.artifact:
|
for ref in args.artifact:
|
||||||
if not ARTIFACT.match(ref):
|
if not ARTIFACT.match(ref):
|
||||||
@@ -375,6 +394,8 @@ def cmd_set(args: argparse.Namespace) -> int:
|
|||||||
value = getattr(args, field)
|
value = getattr(args, field)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
entry[field] = value
|
entry[field] = value
|
||||||
|
if args.unpin:
|
||||||
|
entry.pop("prio", None)
|
||||||
if args.status:
|
if args.status:
|
||||||
apply_status(entry, args.status)
|
apply_status(entry, args.status)
|
||||||
entry.update(times)
|
entry.update(times)
|
||||||
@@ -591,6 +612,7 @@ def main() -> int:
|
|||||||
for field in (*TEXT_FIELDS, "start", "end"):
|
for field in (*TEXT_FIELDS, "start", "end"):
|
||||||
p.add_argument(f"--{field}")
|
p.add_argument(f"--{field}")
|
||||||
p.add_argument("--prio", type=int)
|
p.add_argument("--prio", type=int)
|
||||||
|
p.add_argument("--unpin", action="store_true")
|
||||||
for flag in (*LIST_FIELDS.values(), "artifact"):
|
for flag in (*LIST_FIELDS.values(), "artifact"):
|
||||||
p.add_argument(f"--{flag}", action="append", default=[])
|
p.add_argument(f"--{flag}", action="append", default=[])
|
||||||
p.add_argument(f"--drop-{flag}", action="append", default=[])
|
p.add_argument(f"--drop-{flag}", action="append", default=[])
|
||||||
|
|||||||
+3
-2
@@ -47,11 +47,12 @@ class HubCase(unittest.TestCase):
|
|||||||
self.projects = base / "projects"
|
self.projects = base / "projects"
|
||||||
self.tree_file = base / "tree" / "branch-tree.json"
|
self.tree_file = base / "tree" / "branch-tree.json"
|
||||||
self.timers = base / "timers"
|
self.timers = base / "timers"
|
||||||
|
self.kept = base / "kept"
|
||||||
self.cleanup = base / "state" / "cleanup"
|
self.cleanup = base / "state" / "cleanup"
|
||||||
self.offices = base / "state" / "offices"
|
self.offices = base / "state" / "offices"
|
||||||
self.approvers = base / "config" / "approvers.json"
|
self.approvers = base / "config" / "approvers.json"
|
||||||
self.replay = base / "replay"
|
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()
|
path.mkdir()
|
||||||
silence = mock.patch.object(server.Handler, "log_message")
|
silence = mock.patch.object(server.Handler, "log_message")
|
||||||
silence.start()
|
silence.start()
|
||||||
@@ -70,7 +71,7 @@ class HubCase(unittest.TestCase):
|
|||||||
transcripts=self.projects,
|
transcripts=self.projects,
|
||||||
scripts=self.scripts,
|
scripts=self.scripts,
|
||||||
templates=HUB / "templates",
|
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=""),
|
model=server.Model(url="", model="", key=""),
|
||||||
engines=self.engines,
|
engines=self.engines,
|
||||||
approvers=self.approvers,
|
approvers=self.approvers,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -175,7 +175,7 @@ class TestHubSpeech(HubCase):
|
|||||||
args = {
|
args = {
|
||||||
"--bind": "127.0.0.1", "--listen-port": "0", "--public-port": "1", "--council": str(self.council), "--pages": str(self.pages), "--state": str(self.state),
|
"--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"),
|
"--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": "",
|
"--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),
|
"--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),
|
"--whisper-model": str(self.engines.whisper_model), "--scratch": str(self.engines.scratch), "--approvers": str(self.approvers),
|
||||||
|
|||||||
@@ -166,6 +166,22 @@ class TestRasci(unittest.TestCase):
|
|||||||
rows = json.loads((self.root / "rasci.json").read_text(encoding="utf-8"))
|
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}
|
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):
|
def test_after_stores_and_drops_a_dependency_and_refuses_unknown_tasks_and_cycles(self):
|
||||||
for task in ("work/a", "work/b", "work/c"):
|
for task in ("work/a", "work/b", "work/c"):
|
||||||
self._out("set", task)
|
self._out("set", task)
|
||||||
|
|||||||
Reference in new issue
Block a user