From a60c14c806e6aa17af5979d1604fa8e77972fa8c Mon Sep 17 00:00:00 2001 From: LeTuR Date: Fri, 11 Sep 2026 23:19:15 +0000 Subject: [PATCH 1/6] fix(queue): take the auto-merge allowlist out of tracked code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where fleet may merge was a literal in scripts/lib/queue.py, so naming a repository meant committing it to a PUBLIC, agnostic repo — and every clone inherited the last operator's merge rights over repositories that are not theirs. Four such commits is what it took to notice. The list is now orchestration/auto-merge.conf: the operator's own file, gitignored beside owners.txt, voice.conf and session-glyphs.conf, read on every shepherd pass so an edit needs no reinstall and no restart. orchestration/auto-merge.example.conf is the tracked copy and names NOTHING, so a fresh clone merges nowhere until its operator says otherwise, and shepherd says so by name rather than producing the same silence a repo nobody listed would. FLEET_AUTO_MERGE_REPOS still REPLACES the set. Nothing about the merge gates moved: the head branch must live in that repo, the body must carry a no-mistakes attestation for its CURRENT head, every check must have concluded and passed, the forge must call it mergeable, and whoever opened it must be able to push there. Squash stays the only method. It also closes a latent bug the old comment named: the literal was never parsed, so a bare slug written into it would have matched nothing, refused nothing and failed no test. One parser now serves both sources, so the file can no more carry an unqualified entry than the environment can. Gated against regression. check.sh gains an `automerge` check that fails if the tracked copy ever names a repository or if a fresh clone would read a non-empty set — proved to fail by adding an entry. queue-selftest.sh's 9i and 9j, which existed to assert that two named operator repositories were on the list, become one section about the mechanism: a repository the file names merges on exactly the old gates, an unvetted one is still handed back, a bare slug is refused in the file as in the environment, no file at all merges nothing and says which file would name one, and the tracked copy names none. The run reads a throwaway list via FLEET_AUTO_MERGE_ROOT so its verdicts no longer depend on whose machine it ran on. The same pass de-personalises what was left: an operator's project name in a --condition help example and their GitHub login in the shepherd fixtures. ./scripts/check.sh exits 0 locally, every check included. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hp6M6Hw8TYWtJHGfSuwd4F --- .agents/skills/fleet-onboarding/SKILL.md | 14 ++ .agents/skills/fleet-queue/SKILL.md | 25 ++- .agents/skills/update-fleet/SKILL.md | 32 +++ .gitignore | 9 + AGENTS.md | 7 +- FLEET.md | 5 +- README.md | 8 + orchestration/auto-merge.example.conf | 70 +++++++ orchestration/queue/POLICY.md | 8 +- scripts/check.sh | 58 +++++- scripts/lib/queue.py | 172 ++++++++++------ scripts/queue-selftest.sh | 250 +++++++++++------------ scripts/queue.sh | 7 +- 13 files changed, 442 insertions(+), 223 deletions(-) create mode 100644 orchestration/auto-merge.example.conf diff --git a/.agents/skills/fleet-onboarding/SKILL.md b/.agents/skills/fleet-onboarding/SKILL.md index 62dbd44..394c417 100644 --- a/.agents/skills/fleet-onboarding/SKILL.md +++ b/.agents/skills/fleet-onboarding/SKILL.md @@ -437,6 +437,20 @@ The one thing outside the repo that did change is the operator's own `layout.lua`, if they said yes in step 6 — with a `.bak-` beside it. Say that too. +**And one thing is deliberately NOT set up: where fleet may merge.** A fresh +clone has no `orchestration/auto-merge.conf`, and the tracked +`auto-merge.example.conf` beside it names no repository, so `queue.sh shepherd` +reviews every pull request and merges none of them — it says so by name rather +than reporting the same silence a repo nobody listed would produce. That is +correct: this repo is public and agnostic, and nobody should inherit another +operator's merge rights by cloning it. Offer the file rather than writing it, +and say what naming a repository in it actually authorises — the example's own +header owns the format and the five gates a merge still has to clear: + +```bash +cp -n orchestration/auto-merge.example.conf orchestration/auto-merge.conf +``` + Gate anyway; the `yaml` check is the one that asserts the generated map's shape: ```bash diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index 035deb0..9842ff5 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -244,7 +244,7 @@ waiting: 2 task(s) — each held by a durable, recorded blocker reads the detected_agent field 01 introduces report-status-honestly/05-read-the-tenant held by missing-credential outside the queue (az is authenticated for - the mazet tenant) — only `block --clear` releases it: the brief's first + the billing tenant) — only `block --clear` releases it: the brief's first instruction reads Azure and `az account show` fails ``` @@ -300,7 +300,7 @@ than a task: ```bash ./scripts/queue.sh block vending-machine-egress-resume/01-vm-identity-reconciliation \ - --condition 'az is authenticated for the mazet tenant' \ + --condition 'az is authenticated for the billing tenant' \ --kind missing-credential \ --why 'the first instruction in the brief reads Azure, and az account show fails' ``` @@ -330,7 +330,7 @@ observe, so no timer, no `collect`, no `reap` and no later dispatch appearing to work will release it: ```bash -./scripts/queue.sh block --clear --condition 'az is authenticated for the mazet tenant' +./scripts/queue.sh block --clear --condition 'az is authenticated for the billing tenant' ``` That is deliberate. A condition that expired on its own would put back exactly @@ -819,15 +819,18 @@ Three things it will not do, and they are what make it safe to run: could not determine and carries on. A PR it could not read is never called broken and never called ready. -**On merging, which is the part that runs unattended.** `Thurbeen/fleet` is -public and has a fork, so "merge every open PR on a timer" has to survive a -stranger opening one. Fleet merges only in the repos on `AUTO_MERGE_REPOS` in -`scripts/lib/queue.py` — `github.com/LeTuR/mazet`, -`github.com/Thurbeen/fleet`, `github.com/Thurbeen/thurbox` and -`github.com/Thurbeen/thurview` — and only when **all** of these hold. Entries -there are HOST-QUALIFIED and one that names no forge is refused rather than +**On merging, which is the part that runs unattended.** A repo can be public +and have forks, so "merge every open PR on a timer" has to survive a stranger +opening one. Fleet merges only in the repositories **the operator named in +`orchestration/auto-merge.conf`** — their own file, gitignored, absent by +default, and read on every pass — and only when **all** of these hold. This +repo ships `orchestration/auto-merge.example.conf`, which names NOTHING, so a +fresh clone merges nowhere until somebody writes that file; `shepherd` says so +by name rather than reporting the same silence a repo nobody listed produces. +Entries are HOST-QUALIFIED and one that names no forge is refused rather than matched: `Thurbeen/fleet` on github.com and `Thurbeen/fleet` on a self-hosted -instance are not the same repository. +instance are not the same repository. `FLEET_AUTO_MERGE_REPOS` in the +environment REPLACES the file rather than adding to it. - **The head branch is in that repository**, not a fork. A stranger cannot create a branch here, so this is the one claim about a pull request that diff --git a/.agents/skills/update-fleet/SKILL.md b/.agents/skills/update-fleet/SKILL.md index bd5ea23..df4ff1a 100644 --- a/.agents/skills/update-fleet/SKILL.md +++ b/.agents/skills/update-fleet/SKILL.md @@ -99,6 +99,7 @@ do. Otherwise map the list: | `extension.toml.in`, `FLEET.md`, `orchestration/voice.example.conf` — or a `reinstall-extension:` line | §3 | the installed extension no longer matches what it was rendered from | | `interface/fleet_queue.lua` | §4 | the installed plugin is a stale copy of that file | | `registry/owners.txt` | §5 | the generated map covers the wrong owners | +| `orchestration/auto-merge.example.conf`, or `scripts/lib/queue.py`'s allowlist | §5b | `shepherd` may now merge in a different set of repos, or in none | | `scripts/reconcile.sh` | §6 | the running reconciler loop is executing old code | | `FLEET.md`, `AGENTS.md`, `CLAUDE.md`, `.agents/skills`, `.claude/skills`, `.claude/settings.json` — or a `restart-lead:` line | §8 | the lead is holding instructions it froze at launch | @@ -108,6 +109,37 @@ lead's standing context — so it needs both the reinstall in §3 and the hand-over in §8. A change to `orchestration/voice.example.conf` (or your own `voice.conf`) needs the same two: it moves what the rendered payload calls you. +### §5b — where fleet may merge, which a sync can silently empty + +**One update in fleet's history moves this on its own: the one that took the +auto-merge allowlist out of `scripts/lib/queue.py` and put it in +`orchestration/auto-merge.conf`.** Before it, the repositories fleet merged in +were a literal in tracked code, so every clone carried one operator's merge +rights. After it they are the operator's own gitignored file, and the tracked +copy beside it names nothing — so an operator who syncs across that change and +writes no file finds `shepherd` merging **nowhere** and saying so on every pass. +That is the intended default and not a regression, but it is silent unless +somebody looks. + +So after a sync that touched either path, ask: + +```bash +./scripts/queue.sh shepherd --dry-run | tail -6 +``` + +A pass that prints `Fleet merges NOTHING` is telling you the file does not +exist. If the operator wants unattended merges back, copy the tracked form and +name their own repositories in it — host-qualified, and only repositories where +the gates mean something: + +```bash +cp -n orchestration/auto-merge.example.conf orchestration/auto-merge.conf +$EDITOR orchestration/auto-merge.conf +``` + +It is read on every pass, so nothing needs a reinstall or a restart. Both files +are gitignored below the tracked one, so there is nothing to commit. + `scripts/lib/queue.py` is deliberately absent from this table, and so is `scripts/lib/notify_lead.py`. The reconciler's loop never sources either — every pass shells out to `./scripts/queue.sh` and to `python3 diff --git a/.gitignore b/.gitignore index e64d144..a62d756 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,15 @@ # when you want something other than SLAYER and VEGA. /orchestration/voice.conf +# WORKING STATE. The repositories fleet may MERGE in unattended. This is the +# one list where being wrong means acting on somebody else's code, and which +# repositories those are is a property of the operator and never of this public, +# agnostic repo — so it is ignored for the same reason owners.txt is, and +# orchestration/auto-merge.example.conf is the tracked copy that documents the +# format and deliberately names NONE. The reader falls back to it, so a fresh +# clone merges nothing until this file says otherwise. +/orchestration/auto-merge.conf + # MACHINE. Whether this terminal's font draws a two-cell emoji at two cells is # true of one machine, so the glyph setting is one machine's answer. # orchestration/session-glyphs.example.conf is the tracked copy that carries the diff --git a/AGENTS.md b/AGENTS.md index af109cd..2da60c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -210,8 +210,11 @@ The loop, driven by `./scripts/queue.sh`: on the repos the queue's tasks name, not the tasks' recorded artifacts. One is linked back by artifact or head branch; an unlinked one is still classified and merged, it just has no session to fix it. It merges - only in the repos `AUTO_MERGE_REPOS` names in `scripts/lib/queue.py` — each - entry host-qualified, and one that names no forge refused rather than + only in the repos the operator named in `orchestration/auto-merge.conf` — + their own file, gitignored, read every pass, with the tracked + `auto-merge.example.conf` beside it naming NONE, so a fresh clone of this + public repo merges nowhere until its operator says otherwise; each entry + host-qualified, and one that names no forge refused rather than matched — and only for one whose head branch is in that repo, opened by someone who can push there, carrying a `no-mistakes` attestation for its **current** head. diff --git a/FLEET.md b/FLEET.md index d45fd3f..e928f57 100644 --- a/FLEET.md +++ b/FLEET.md @@ -92,8 +92,9 @@ YAML by hand. Nothing to push — the map is gitignored. not just recorded artifacts, dispatches a fixer for one that conflicts, fails a check, was reviewed with changes requested, or was declared `no-mistakes` and carries no attestation for its current head, and squash-merges one that - clears every gate in the repos `AUTO_MERGE_REPOS` allows — entries there - name their forge (`github.com/Thurbeen/fleet`, + clears every gate in the repos `orchestration/auto-merge.conf` names — your + own file, gitignored, empty until you write it, so fleet merges nowhere + until then. Entries there name their forge (`github.com/owner/repo`, `gitlab.example.com/acme/group/widgets`), because a bare `owner/repo` is two different repositories once two forges are configured. It writes down what it saw either way, so a task's record says `checks-running` or diff --git a/README.md b/README.md index 1f18296..ca8ad9d 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,14 @@ your working copy and are gitignored — this repo is public, and none of that i something to publish, so back that copy up yourself if it matters beyond this machine. `.gitignore`'s header names every path and the reason for each. +Your settings are yours the same way, and each has a tracked `.example` beside +it documenting the format: `registry/owners.txt` (the owners the map covers), +`orchestration/voice.conf` (what the lead calls you), `session-glyphs.conf` +(the mark fleet's sessions wear) and `orchestration/auto-merge.conf` — **the +repositories fleet may merge in unattended, which the tracked copy deliberately +leaves empty.** Clone this and fleet merges nowhere until you say otherwise; no +operator inherits another's merge rights. + ## More - [`AGENTS.md`](AGENTS.md) — how an agent should operate inside this repo, and diff --git a/orchestration/auto-merge.example.conf b/orchestration/auto-merge.example.conf new file mode 100644 index 0000000..1acea25 --- /dev/null +++ b/orchestration/auto-merge.example.conf @@ -0,0 +1,70 @@ +# The repositories fleet is allowed to MERGE in unattended, and this is the +# tracked copy of them — which is why it names none. +# +# COPY IT TO `auto-merge.conf` BESIDE THIS FILE TO NAME ANY. That copy is +# gitignored, the same way `registry/owners.txt`, `voice.conf` and +# `session-glyphs.conf` are, and for the same reason stated harder: WHICH +# REPOSITORIES YOU MAY MERGE IN IS A PROPERTY OF YOU, NOT OF THIS REPO. This +# one is public and agnostic — it is machinery anybody can run — so an operator +# who clones it must never inherit another operator's merge rights, and an +# operator who adds their own must never push them back. `scripts/queue.sh +# shepherd` reads THIS file when the copy is absent, so a fresh clone merges +# NOTHING until somebody says otherwise, and that silence is the correct +# default rather than an omission. +# +# THE HISTORY THIS FILE EXISTS TO CLOSE. The set used to be a literal in +# `scripts/lib/queue.py`, so every repository an operator wanted merged was a +# commit in a public repository naming that operator's projects — and the +# fourth such commit is what made the mistake obvious. Nothing about the gates +# below changed; only where the answer lives did. +# +# NO RE-INSTALL AND NO RESTART. Unlike `voice.example.conf` and +# `session-glyphs.example.conf`, nothing renders this: `shepherd` reads it on +# every pass, so an edit here takes effect on the next one. +# +# --- THE FORMAT --------------------------------------------------------------- +# +# One repository per line, HOST-QUALIFIED — `github.com/owner/repo`, +# `gitlab.example.com/group/sub/repo`. Blank lines and everything after a `#` +# are ignored, the same as `registry/owners.txt`. +# +# An entry that names no host is REFUSED with a line on stderr rather than +# matched against a bare `owner/repo`: `Thurbeen/fleet` is a different +# repository on github.com and on a self-hosted instance, and this is the one +# list where matching the wrong one means acting on somebody else's code. +# +# `FLEET_AUTO_MERGE_REPOS` in the environment REPLACES what is here rather than +# adding to it — a fleet driving somebody else's repositories is a different +# fleet, not this one plus an extra. +# +# --- WHAT PUTTING A REPOSITORY HERE MEANS ------------------------------------- +# +# Not "merge everything in it". A pull request there is merged unattended only +# when ALL of these hold, and `scripts/lib/queue.py` is where they are enforced: +# +# - its head branch lives IN that repository — a fork is reported, never +# merged, because a stranger cannot create a branch inside your repo; +# - its body carries a `no-mistakes` attestation naming its CURRENT head +# commit, so a stale attestation from an earlier push cannot authorise the +# push that replaced it; +# - every check has CONCLUDED and passed; +# - the forge itself calls it mergeable; +# - whoever opened it can push there. +# +# So the test for adding a repository is whether those gates MEAN anything in +# it: a repo with its own `.no-mistakes.yaml` and its own CI has a pipeline +# behind the attestation gate and checks behind the checks gate. A repo with +# neither is one where every gate but the last is vacuous — that is the test, +# not whether your fleet happens to have work there. +# +# Fleet merges by SQUASH and by nothing else. A repository whose remote forbids +# squash produces a recorded refusal on every pass rather than a merge by some +# other method, so check that before adding one. +# +# --- YOUR REPOSITORIES -------------------------------------------------------- +# +# Uncomment and edit in your own copy. Nothing is enabled here, and +# `./scripts/check.sh queue` fails this file if anything ever is. + +# github.com/your-name/your-repo +# github.com/your-org/another-repo diff --git a/orchestration/queue/POLICY.md b/orchestration/queue/POLICY.md index ba6be93..b82bde0 100644 --- a/orchestration/queue/POLICY.md +++ b/orchestration/queue/POLICY.md @@ -107,10 +107,10 @@ The default for every task here is the frontmatter at the top of this file. A repo that forbids squash — a GitLab project can, with `squash_option: never` — is one fleet reports and leaves for you. - **You do not merge.** Opening it is where your work ends. `queue.sh - shepherd` may later merge it for you in the repos its `AUTO_MERGE_REPOS` - allowlist names (host-qualified, as in `github.com/owner/repo` or - `gitlab.example.com/group/project`), but only once it clears its gates — - never merge it yourself in the meantime. + shepherd` may later merge it for you in the repos the operator's own + `orchestration/auto-merge.conf` names (host-qualified, as in + `github.com/owner/repo` or `gitlab.example.com/group/project`), but only once + it clears its gates — never merge it yourself in the meantime. ## Reporting back — write a file, do not send mail diff --git a/scripts/check.sh b/scripts/check.sh index fbca65d..872c168 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -13,7 +13,7 @@ # scripts/check.sh --fix markdown # apply the fixes a check can apply # # Checks: shell, markdown, yaml, profiles, queue, reconcile, status, skills, -# pane, voice, onboarding, sync. Only `markdown` has a fixer; `--fix` is a no-op for +# pane, voice, automerge, onboarding, sync. Only `markdown` has a fixer; `--fix` is a no-op for # the rest, so `scripts/check.sh --fix` is always safe to run. # # Requires: shellcheck, rumdl, python3 (with PyYAML), lua. A missing tool @@ -570,6 +570,57 @@ check_voice() { [ "$miss" -eq 0 ] && ok "voice: $conf renders into FLEET.md's placeholders" } +# WHERE FLEET MAY MERGE, WHICH IS THE OPERATOR'S AND NOT THIS REPO'S. The +# allowlist used to be a literal in scripts/lib/queue.py, so naming a +# repository meant committing it to a PUBLIC repo, and every clone inherited +# the last operator's merge rights. It is orchestration/auto-merge.conf now — +# the operator's, gitignored — and this gate is what keeps it from drifting +# back: the tracked copy must name NOTHING, and the set a fresh clone would +# read must come out empty. Textual, because the failure mode is: somebody adds +# "just one" entry to the shipped file and it ships to everybody. +check_automerge() { + need python3 automerge || return + + local example="orchestration/auto-merge.example.conf" miss=0 + if [ ! -f "$example" ]; then + fail "automerge: $example is missing; a fresh clone would document no format" + return + fi + + # Every line with its comment cut off. Anything left is an entry, and an + # entry here is one operator's repository published in everybody's copy. + local live + live="$(sed 's/#.*//' "$example" | grep -E '[^[:space:]]')" + if [ -n "$live" ]; then + fail "automerge: $example names a repository; the tracked copy must name none" + printf '%s\n' "$live" | sed 's/^/ /' >&2 + miss=1 + fi + + # And the set itself, read the way `shepherd` reads it. A checkout with an + # operator's own auto-merge.conf in it answers about that file instead, so + # the gate reads the tracked one directly there rather than passing on a + # result about somebody's private list. + local shipped + if [ -f orchestration/auto-merge.conf ]; then + shipped="skip" + else + shipped="$(FLEET_AUTO_MERGE_REPOS='' python3 -c ' +import sys +sys.path.insert(0, "scripts/lib") +import queue as q +print("entries=" + (" ".join(sorted(q.auto_merge_repos(q.checkout_root()))) or "none")) +' 2>&1)" + if [ "$shipped" != "entries=none" ]; then + fail "automerge: a fresh clone would inherit a merge allowlist: $shipped" + miss=1 + fi + fi + + [ "$miss" -eq 0 ] && + ok "automerge: $example names no repository; a fresh clone merges nowhere" +} + # THE SETUP NOBODY RE-RUNS. Onboarding's scripts — preflight, discover-owners, # place-pane — are the ones every operator runs once and never again, so a # regression in them is invisible to everyone who is already set up and total @@ -601,7 +652,7 @@ for arg in "$@"; do done if [ ${#checks[@]} -eq 0 ]; then - checks=(shell markdown yaml profiles queue reconcile status skills pane voice onboarding sync) + checks=(shell markdown yaml profiles queue reconcile status skills pane voice automerge onboarding sync) fi for c in "${checks[@]}"; do @@ -617,9 +668,10 @@ for c in "${checks[@]}"; do skills) check_skills ;; pane) check_pane ;; voice) check_voice ;; + automerge) check_automerge ;; onboarding) check_onboarding ;; *) - printf 'error: unknown check %q (want: shell markdown yaml profiles queue reconcile status skills pane voice onboarding sync)\n' "$c" >&2 + printf 'error: unknown check %q (want: shell markdown yaml profiles queue reconcile status skills pane voice automerge onboarding sync)\n' "$c" >&2 exit 2 ;; esac diff --git a/scripts/lib/queue.py b/scripts/lib/queue.py index d92daa4..9ad638d 100644 --- a/scripts/lib/queue.py +++ b/scripts/lib/queue.py @@ -1859,7 +1859,7 @@ def block_on_condition(q: Queue, task: Task, condition: str, args) -> int: if not condition: raise QueueError( "--condition is the wait itself, in words: --condition 'az is\n" - "authenticated for the mazet tenant'. Nothing removes one but\n" + "authenticated for the billing tenant'. Nothing removes one but\n" "`block --clear --condition` naming it back, so a blank one is a\n" "wait nobody could name and nobody could release." ) @@ -4298,63 +4298,47 @@ def cmd_refuel(args) -> int: # WHERE FLEET IS ALLOWED TO MERGE. An explicit allowlist and not a flag, # because the blast radius of getting this wrong is somebody else's repository. -# A repo that is not named here is reported `ready to merge` and left for a -# human, which is what every repo did before this list existed. +# A repo that is not named is reported `ready to merge` and left for a human, +# which is what every repo did before this list existed. # -# The gates below are the operator's, and all must hold: the head branch lives -# in this repository (`classify`'s `foreign` check — a fork is never merged), -# the body carries a `no-mistakes` attestation naming the pull request's -# CURRENT head commit (so a stale attestation from an earlier push can never -# authorise the push that replaced it), every check has CONCLUDED and passed, -# the forge itself calls it mergeable, and whoever opened it can push to this -# repo (`author_can_push` — the last thing checked, because it is the one claim -# the pull request body cannot make for itself). +# THE LIST IS NOT HERE, AND THAT IS THE POINT. It lives in +# `orchestration/auto-merge.conf` — the operator's, gitignored, read on every +# pass — with `orchestration/auto-merge.example.conf` as the tracked copy that +# documents the format and names NOTHING. `Thurbeen/fleet` is public and +# agnostic, so a repository literal in this file is one operator's merge rights +# published in somebody else's machinery: a fresh clone would inherit them, and +# every change to them would be a commit here naming that operator's projects. +# Four such commits is what it took to notice. The file's own header owns the +# format and the test for adding an entry; the gates below are unchanged and +# are what putting a repository there actually means. +# +# The gates are the operator's, and all must hold: the head branch lives in +# this repository (`classify`'s `foreign` check — a fork is never merged), the +# body carries a `no-mistakes` attestation naming the pull request's CURRENT +# head commit (so a stale attestation from an earlier push can never authorise +# the push that replaced it), every check has CONCLUDED and passed, the forge +# itself calls it mergeable, and whoever opened it can push to this repo +# (`author_can_push` — the last thing checked, because it is the one claim the +# pull request body cannot make for itself). # # HOST-QUALIFIED, and an entry that names no host is refused rather than # guessed at (`forge.RepoId.parse`). `Thurbeen/fleet` is a different repository # on github.com and on a self-hosted instance, and this is the one list where -# matching the wrong one means acting on somebody else's code. -# -# `Thurbeen/thurview` was added on the operator's standing instruction, "merge -# everything when ready". It qualifies on the same terms rather than looser -# ones: it carries its own `.no-mistakes.yaml` and CI, so the attestation and -# checks gates above mean there exactly what they mean here. A repo WITHOUT -# those would be a repo where every gate but `author_can_push` is vacuous — -# that is the test for adding the next one, not whether the fleet happens to -# have work there. +# matching the wrong one means acting on somebody else's code. EVERY source is +# parsed the same way — the conf file as well as the environment — which is the +# bug the literal carried: a literal was never parsed, so a bare slug written +# into it would have matched nothing, refused nothing, and failed no test. # -# `Thurbeen/thurbox` was added on the same instruction and passes the same -# test: its own `.no-mistakes.yaml` runs `just lint` and a rustdoc build with -# warnings denied, and its CI is four workflows. So the attestation gate has a -# pipeline behind it there and the checks gate has checks behind it, which is -# the whole of what the test asks. Nothing was relaxed for it — thurbox pull -# requests clear the same five gates fleet's own do, and -# `queue-selftest.sh`'s 9i is where that is checked. -# -# `LeTuR/mazet` was added on the same instruction and is the first entry under -# an owner no other entry shares — which changes nothing about the test, and -# it passes it: its own `.no-mistakes.yaml` names a lint, a format and a test -# command, and its CI is three workflows. What DID hold it back was the -# remote. It allowed rebase only until 2026-09-11, and `MERGE_METHOD` below is -# squash, so every pass would have produced the refusal rather than a merge. -# It now allows squash and nothing else, with the pull request title as the -# commit subject — the same shape as fleet's own remotes. `queue-selftest.sh`'s -# 9j is where this one is checked, and 9j also reads the whole set back and -# puts every entry through `forge.RepoId.parse`: a bare slug written HERE is -# never parsed at runtime, so it would match nothing and refuse nothing. -AUTO_MERGE_REPOS = { - "github.com/LeTuR/mazet", - "github.com/Thurbeen/fleet", - "github.com/Thurbeen/thurbox", - "github.com/Thurbeen/thurview", -} +# AN EMPTY SET IS A VALID ANSWER and the default one. A fleet nobody has told +# where it may merge merges nowhere and says so; it does not guess. -# The one way to say it somewhere other than here, and it REPLACES the set -# rather than adding to it: a fleet driving somebody else's repositories is a -# different fleet, not this one plus an extra. Every entry is parsed, and one -# that names no host is dropped with a line on stderr rather than matched -# against a bare slug — which is the mistake this whole seam exists to make -# impossible. `queue-selftest.sh` is the second fleet it was written for. +AUTO_MERGE_CONF = "orchestration/auto-merge.conf" +AUTO_MERGE_CONF_DEFAULTS = "orchestration/auto-merge.example.conf" + +# The one way to say it somewhere other than the conf file, and it REPLACES the +# set rather than adding to it: a fleet driving somebody else's repositories is +# a different fleet, not this one plus an extra. `queue-selftest.sh` is the +# second fleet it was written for. AUTO_MERGE_ENV = "FLEET_AUTO_MERGE_REPOS" # Squash because it is the only method fleet's own remotes allow, so the pull @@ -4365,19 +4349,23 @@ def cmd_refuel(args) -> int: DELETE_MERGED_BRANCH = True -def auto_merge_repos() -> set: - """The repositories fleet may merge in, host-qualified, every time.""" - raw = os.environ.get(AUTO_MERGE_ENV, "").strip() - if not raw: - return AUTO_MERGE_REPOS +def parse_auto_merge(entries, source: str) -> set: + """Host-qualified repositories out of raw entries, refusing the rest. + + One parser for both sources, so the conf file can no more carry a bare + slug than the environment can. A refusal is LOUD — a line on stderr naming + the entry — because silence here reads exactly like a repository fleet + declined to merge in for one of the five good reasons. + """ out = set() - for entry in re.split(r"[,\s]+", raw): + for entry in entries: + entry = entry.strip() if not entry: continue repo = forge.RepoId.parse(entry) if repo is None: print( - f"{AUTO_MERGE_ENV}: ignoring {entry!r} — an auto-merge entry must " + f"{source}: ignoring {entry!r} — an auto-merge entry must " "name its forge, as in github.com/owner/repo", file=sys.stderr, ) @@ -4386,6 +4374,40 @@ def auto_merge_repos() -> set: return out +def auto_merge_conf_path(root: str | None = None) -> str: + """The auto-merge list in force: the operator's copy, or the tracked one. + + `FLEET_AUTO_MERGE_ROOT` overrides where it is read from, the same way + `FLEET_GLYPH_ROOT` relocates the glyph setting — so a selftest can exercise + the file itself without inheriting whatever the developer's own gitignored + auto-merge.conf says. + """ + root = root or os.environ.get("FLEET_AUTO_MERGE_ROOT") or checkout_root() + path = os.path.join(root, AUTO_MERGE_CONF) + if not os.path.exists(path): + path = os.path.join(root, AUTO_MERGE_CONF_DEFAULTS) + return path + + +def auto_merge_repos(root: str | None = None) -> set: + """The repositories fleet may merge in, host-qualified, every time. + + Read as DATA — one repository per line, `#` starts a comment — and never + executed. The environment REPLACES the file rather than adding to it, and + a missing file is an empty set: a fleet nobody told merges nowhere. + """ + raw = os.environ.get(AUTO_MERGE_ENV, "").strip() + if raw: + return parse_auto_merge(re.split(r"[,\s]+", raw), AUTO_MERGE_ENV) + path = auto_merge_conf_path(root) + try: + with open(path) as fh: + lines = [line.partition("#")[0] for line in fh] + except OSError: + return set() + return parse_auto_merge(lines, path) + + def pr_ref(artifact: str) -> forge.ChangeRef | None: """The change request an artifact names, or None for anything else.""" which, ref = forge.for_url(artifact) @@ -5241,7 +5263,7 @@ def shepherd_targets(tasks: list) -> dict: The queue's tasks name their repositories: an artifact URL gives the repository — host and path — outright, and a task that has not reported one yet inherits the repository of the other tasks sharing its local checkout. - Merging stays limited to AUTO_MERGE_REPOS whatever comes out of here: + Merging stays limited to `auto_merge_repos()` whatever comes out of here: knowing about a repository and being allowed to merge in it are different questions. """ @@ -5367,14 +5389,30 @@ def cmd_shepherd(args) -> int: ) if not args.dry_run and any(r["action"] == "dispatched" for r in rows): print(" Fixers are working in place on the existing branches; nothing forked.") - print( - " Merging is limited to " + ", ".join(sorted(auto_merge_repos())) - + ", and only for a pull request whose\n" - " head branch is in that repo, that someone who can push there " - "opened, that\n" - " carries a no-mistakes attestation for its CURRENT head, whose " - "checks passed,\n and that the forge itself calls mergeable." - ) + # An empty allowlist is the default a fresh clone has, so it gets a + # sentence rather than a dangling "limited to , and only for" — and that + # sentence names the file, because "fleet merged nothing" and "nobody has + # told fleet where it may merge" are the same output otherwise. + allowed = sorted(auto_merge_repos()) + if allowed: + print( + " Merging is limited to " + ", ".join(allowed) + + ", and only for a pull request whose\n" + " head branch is in that repo, that someone who can push there " + "opened, that\n" + " carries a no-mistakes attestation for its CURRENT head, whose " + "checks passed,\n and that the forge itself calls mergeable." + ) + else: + print( + " Fleet merges NOTHING: no repository is named in " + f"{auto_merge_conf_path()}\n" + f" (nor in ${AUTO_MERGE_ENV}). Every pull request above is " + "yours to merge. Copy\n" + f" {AUTO_MERGE_CONF_DEFAULTS} to {AUTO_MERGE_CONF} and name " + "your own; its\n header owns the format and the five gates a " + "merge still has to clear." + ) if not args.dry_run: refresh_run_logs(q) return 0 diff --git a/scripts/queue-selftest.sh b/scripts/queue-selftest.sh index 647645e..eab909f 100755 --- a/scripts/queue-selftest.sh +++ b/scripts/queue-selftest.sh @@ -202,6 +202,28 @@ export FLEET_QUEUE_DIR="$tmp/queue" # of this file would scaffold logs into the operator's own orchestration/runs/. export FLEET_RUNS_DIR="$tmp/runs" +# --- the auto-merge allowlist, which is a FILE and not a literal ------------- +# +# `orchestration/auto-merge.conf` is the operator's, gitignored, and absent on +# most machines; `orchestration/auto-merge.example.conf` is the tracked +# fallback and names NOTHING. Neither is what the fixtures below want: reading +# the operator's would make the verdicts depend on whose laptop this ran on, +# and reading the tracked one would leave every merge test asserting that fleet +# merges nowhere. +# +# So the whole run reads a throwaway list naming exactly the two repositories +# the fixtures open pull requests in. `FLEET_AUTO_MERGE_ROOT` is the same kind +# of override as `FLEET_GLYPH_ROOT`. Sections that want a different answer set +# `FLEET_AUTO_MERGE_REPOS`, which REPLACES this, or point the root somewhere +# else — 9i does both. +export FLEET_AUTO_MERGE_ROOT="$tmp/automerge" +mkdir -p "$FLEET_AUTO_MERGE_ROOT/orchestration" +cat >"$FLEET_AUTO_MERGE_ROOT/orchestration/auto-merge.conf" <<'EOF' +# The repositories this selftest's fixtures open pull requests in. +github.com/Thurbeen/fleet +github.com/Thurbeen/thurbox +EOF + # --- `glab`, a STAND-IN on PATH for the whole run ---------------------------- # # The GitLab adapter asks `glab auth status` which instances this machine @@ -2051,7 +2073,7 @@ git -C "$srepo" -c user.email=t@t -c user.name=t commit -q --allow-empty -m base # owner is an organisation and the author is a person inside it. `stranger` has # no file here, so the stub answers `none` for them. mkdir -p "$shep/perms" -echo admin >"$shep/perms/LeTuR" +echo admin >"$shep/perms/maintainer" stopic="$($QUEUE topic add shepherd-cases --title 'The PRs, after the work' \ --prompt 'watch every open PR and dispatch a fixer when one goes bad' 2>/dev/null)" @@ -2111,7 +2133,7 @@ def pr(n, owner="Thurbeen/fleet", sha=None, body=None, **kw): "mergeable": "MERGEABLE", "reviewDecision": "", "statusCheckRollup": [green], "body": attested(sha) if body is None else body, "headRefName": "fix/x", "baseRefName": "main", "headRefOid": sha, - "author": {"login": "LeTuR", "is_bot": False}, + "author": {"login": "maintainer", "is_bot": False}, "headRepositoryOwner": {"login": owner.split("/")[0]}, "isCrossRepository": False, } @@ -2473,23 +2495,39 @@ refute "and nothing from it is merged" "pr merge" "$(cat "$shep/many.log")" refute "and it is not reported as having zero open pull requests either" \ "no open pull requests" "$out" -# --- 9i. thurbox is on the allowlist, on the same gates as fleet ------------- +# --- 9i. the allowlist is a FILE the operator owns, and the tracked one is empty # -# The allowlist grew, so the claim under test is that adding a repository adds -# a REPOSITORY and not a looser rule. Two pull requests on thurbox, both green -# and mergeable and both on branches that are ours: the attested one merges the -# way fleet's own do, and the one nothing vetted is still handed back. +# Where fleet may merge used to be a literal in `scripts/lib/queue.py`, so the +# only way an operator could name a repository was a commit in this public repo +# naming their projects — and every clone inherited whatever the last operator +# had named. The list is now `orchestration/auto-merge.conf`: the operator's, +# gitignored, and absent by default. # -# The third claim is the one this addition could quietly weaken. The allowlist -# is matched HOST-QUALIFIED, so `Thurbeen/thurbox` — the way a person writes it -# and the way the operator asked for it — is refused rather than matched -# against the bare slug. 13e proves that for a forge that is not GitHub; this -# proves it for the repo that was just added, where the bare slug is the -# plausible typo. - -ttopic="$($QUEUE topic add thurbox-allowlist --title 'Auto-merge in thurbox' \ - --prompt 'thurbox merges on the same gates as fleet, and only host-qualified' 2>/dev/null)" -$QUEUE add "$ttopic" attested --title 'A thurbox PR the pipeline vetted' \ +# Five claims, and the first two are the ones the literal used to carry: +# +# a repository NAMED in the file merges on exactly the gates fleet's own +# pull requests clear — an attested, green one goes, an unvetted one is +# still handed back, so naming a repository adds a REPOSITORY and not a +# looser rule; +# a repository the file does not name is reported and left alone; +# the file is matched HOST-QUALIFIED, so a bare `owner/repo` written INTO IT +# is refused rather than matched against the slug. This is the claim the +# literal could not make: nothing parsed it, so a bare slug there would +# have matched nothing, refused nothing and failed no test; +# with no file at all the set is EMPTY and the pass says so by naming the +# file, because "fleet merged nothing" and "nobody told fleet where it may +# merge" are otherwise the same output; +# the TRACKED copy names no repository, so a fresh clone of a public repo +# merges nowhere until its own operator says otherwise. That last one is +# the whole reason this section exists. +# +# `Thurbeen/thurbox` is the fixture repository throughout, named by the +# throwaway conf at the top of this file. Which repositories a real fleet +# merges in is not this repo's business and so is not asserted anywhere. + +ttopic="$($QUEUE topic add thurbox-allowlist --title 'Auto-merge from the conf file' \ + --prompt 'a named repo merges on fleet own gates, and only host-qualified' 2>/dev/null)" +$QUEUE add "$ttopic" attested --title 'A PR the pipeline vetted' \ --repo "$srepo" --branch tbx/attested --number 01 >/dev/null cat >"$FLEET_QUEUE_DIR/$ttopic/01-attested/result.md" <<'EOF' --- @@ -2520,7 +2558,7 @@ def pr(n, branch, body): "url": f"https://github.com/Thurbeen/thurbox/pull/{n}", "mergeable": "MERGEABLE", "reviewDecision": "", "statusCheckRollup": [green], "body": body, "headRefName": branch, "baseRefName": "main", - "headRefOid": sha, "author": {"login": "LeTuR", "is_bot": False}, + "headRefOid": sha, "author": {"login": "maintainer", "is_bot": False}, "headRepositoryOwner": {"login": "Thurbeen"}, "isCrossRepository": False, }, open(f"{out}/{n}.json", "w")) @@ -2538,11 +2576,11 @@ PY env PATH="$shep/bin:$base_path" $QUEUE collect >/dev/null out="$(env PATH="$shep/bin:$base_path" $QUEUE shepherd --topic "$ttopic" 2>&1)" -expect "the shepherd reaches thurbox at all" "Thurbeen/thurbox" "$out" +expect "the shepherd reaches a repository the conf file names" "Thurbeen/thurbox" "$out" if grep -qx 201 "$shep/merged" 2>/dev/null; then - pass "an attested, green thurbox pull request is merged unattended" + pass "an attested, green pull request there is merged unattended" else - fail "an attested, green thurbox pull request is merged unattended" \ + fail "an attested, green pull request there is merged unattended" \ "$out$nl$(cat "$shep/merged" 2>/dev/null)" fi expect "and by the same squash fleet's own are merged by" \ @@ -2550,130 +2588,80 @@ expect "and by the same squash fleet's own are merged by" \ "$(cat "$shep/gh.log")" if grep -qx 202 "$shep/merged" 2>/dev/null; then - fail "joining the allowlist loosens no gate: an unattested one is not merged" \ + fail "naming a repository loosens no gate: an unattested one is not merged" \ "$(cat "$shep/merged")" else - pass "joining the allowlist loosens no gate: an unattested one is not merged" + pass "naming a repository loosens no gate: an unattested one is not merged" fi expect "and it is named for what it lacks, not passed over" \ "the body carries no no-mistakes attestation" "$out" -# The typo the operator's own words invite: the allowlist is host-qualified, -# and `Thurbeen/thurbox` names no forge. -out="$(env PATH="$shep/bin:$base_path" FLEET_AUTO_MERGE_REPOS="Thurbeen/thurbox" \ +# The typo the operator's own words invite, made IN THE FILE this time: the +# list is host-qualified wherever it is written, and a bare slug names no forge. +bareroot="$tmp/automerge-bare" +mkdir -p "$bareroot/orchestration" +printf '# a slug, which names no forge\nThurbeen/thurbox\n' \ + >"$bareroot/orchestration/auto-merge.conf" +out="$(env PATH="$shep/bin:$base_path" FLEET_AUTO_MERGE_ROOT="$bareroot" \ $QUEUE shepherd --topic "$ttopic" --dry-run 2>&1)" -expect "a bare Thurbeen/thurbox is refused, not matched against the slug" \ +expect "a bare slug in the conf file is refused, not matched against it" \ "must name its forge" "$out" -refute "and nothing in thurbox would be merged under it" "would-merge" "$out" - -# --- 9j. mazet is on the allowlist, and the whole set names its forge -------- -# -# The allowlist grew a second time, and this entry is the first under an owner -# no other entry shares. The two claims 9i makes about thurbox are made again -# here about `github.com/LeTuR/mazet`, because they are claims about an ENTRY -# and not about the code once and for all: the gates travel with it — an -# attested one merges, an unvetted one is still handed back — and it is matched -# HOST-QUALIFIED, so `LeTuR/mazet` is refused rather than matched against the -# bare slug. -# -# The third claim is the one only the whole set can make, and it is the one -# nothing above would catch. `auto_merge_repos()` parses what the ENVIRONMENT -# overrides it with; the literal in `queue.py` is never parsed, so a bare slug -# written there would match nothing, refuse nothing, and fail no test here. -# So the set itself is read and every entry put through the same parse. - -maztopic="$($QUEUE topic add mazet-allowlist --title 'Auto-merge in mazet' \ - --prompt 'mazet merges on the same gates as fleet, and only host-qualified' 2>/dev/null)" -$QUEUE add "$maztopic" attested --title 'A mazet PR the pipeline vetted' \ - --repo "$srepo" --branch mzt/attested --number 01 >/dev/null -cat >"$FLEET_QUEUE_DIR/$maztopic/01-attested/result.md" <<'EOF' ---- -outcome: shipped -artifact: https://github.com/LeTuR/mazet/pull/301 ---- -Shipped it. -EOF -git -C "$srepo" branch mzt/attested +refute "and nothing in that repo would be merged under it" "would-merge" "$out" -python3 - "$shep/gh" <<'PY' -import json -import sys - -out = sys.argv[1] -green = {"__typename": "CheckRun", "name": "CI", "status": "COMPLETED", - "conclusion": "SUCCESS"} -STEPS = [ - {"step": s, "status": "completed"} - for s in ("intent", "rebase", "review", "test", "document", "lint", "push") -] + [{"step": "pr", "status": "running"}, {"step": "ci", "status": "pending"}] - - -def pr(n, branch, body): - sha = f"{n:040d}" - json.dump({ - "number": n, "state": "OPEN", "title": f"PR {n}", "isDraft": False, - "url": f"https://github.com/LeTuR/mazet/pull/{n}", - "mergeable": "MERGEABLE", "reviewDecision": "", "statusCheckRollup": [green], - "body": body, "headRefName": branch, "baseRefName": "main", - "headRefOid": sha, "author": {"login": "LeTuR", "is_bot": False}, - "headRepositoryOwner": {"login": "LeTuR"}, "isCrossRepository": False, - }, open(f"{out}/{n}.json", "w")) - - -payload = json.dumps({"head_sha": f"{301:040d}", "steps": STEPS}) -pr(301, "mzt/attested", - f"\n\nShipped it.\n") -# Green in every way the forge can see, and nothing vetted the head that would -# land. No task records it either, so nothing here spawns a fixer. -pr(302, "mzt/unvetted", "Reviewed, tested, linted, and opened through the pipeline.\n") -PY - -env PATH="$shep/bin:$base_path" $QUEUE collect >/dev/null - -out="$(env PATH="$shep/bin:$base_path" $QUEUE shepherd --topic "$maztopic" 2>&1)" -expect "the shepherd reaches mazet at all" "LeTuR/mazet" "$out" -if grep -qx 301 "$shep/merged" 2>/dev/null; then - pass "an attested, green mazet pull request is merged unattended" -else - fail "an attested, green mazet pull request is merged unattended" \ - "$out$nl$(cat "$shep/merged" 2>/dev/null)" -fi -expect "and by the same squash fleet's own are merged by" \ - "pr merge https://github.com/LeTuR/mazet/pull/301 --squash --delete-branch" \ - "$(cat "$shep/gh.log")" - -if grep -qx 302 "$shep/merged" 2>/dev/null; then - fail "the second addition loosens no gate either: an unattested one is not merged" \ - "$(cat "$shep/merged")" -else - pass "the second addition loosens no gate either: an unattested one is not merged" -fi - -# The typo the operator's own words invite, for the new entry as for the last. -out="$(env PATH="$shep/bin:$base_path" FLEET_AUTO_MERGE_REPOS="LeTuR/mazet" \ - $QUEUE shepherd --topic "$maztopic" --dry-run 2>&1)" -expect "a bare LeTuR/mazet is refused, not matched against the slug" \ +# The same typo in the environment, which REPLACES the file rather than adding +# to it — so this also proves the file did not leak past the override. +out="$(env PATH="$shep/bin:$base_path" FLEET_AUTO_MERGE_REPOS="Thurbeen/thurbox" \ + $QUEUE shepherd --topic "$ttopic" --dry-run 2>&1)" +expect "a bare slug in the environment is refused the same way" \ "must name its forge" "$out" -refute "and nothing in mazet would be merged under it" "would-merge" "$out" - -# The set itself: four repositories, every one of them host-qualified. -allowlist="$(python3 - <<'PY' +refute "and the conf file does not leak past an override that replaces it" \ + "would-merge" "$out" + +# NO FILE AT ALL, which is what a fresh clone that never wrote one has. The +# set is empty, nothing is merged, and the pass names the file rather than +# reporting the same silence a repo nobody listed would produce. +emptyroot="$tmp/automerge-none" +mkdir -p "$emptyroot/orchestration" +out="$(env PATH="$shep/bin:$base_path" FLEET_AUTO_MERGE_ROOT="$emptyroot" \ + $QUEUE shepherd --topic "$ttopic" --dry-run 2>&1)" +expect "with no list at all, fleet merges nothing" "Fleet merges NOTHING" "$out" +expect "and says which file would name one" "orchestration/auto-merge.conf" "$out" +refute "and nothing at all would be merged" "would-merge" "$out" + +# THE TRACKED COPY, read out of this checkout rather than out of $tmp. This is +# the claim the whole change exists for: a public, agnostic repo hands a fresh +# clone no merge rights over anybody's repositories. +shipped="$(python3 - <<'PY' import sys sys.path.insert(0, "scripts/lib") import forge import queue as q -repos = sorted(q.auto_merge_repos()) -print("entries=" + " ".join(repos)) +root = q.checkout_root() +path = q.auto_merge_conf_path(root) +repos = sorted(q.auto_merge_repos(root)) +print("tracked=" + q.AUTO_MERGE_CONF_DEFAULTS) +print("entries=" + (" ".join(repos) or "none")) print("unqualified=" + (" ".join(r for r in repos if forge.RepoId.parse(r) is None) or "none")) +print("reading=" + ("operator" if path.endswith(q.AUTO_MERGE_CONF) else "tracked")) PY )" -expect "the allowlist is the four repositories fleet may merge in" \ - "entries=github.com/LeTuR/mazet github.com/Thurbeen/fleet github.com/Thurbeen/thurbox github.com/Thurbeen/thurview" \ - "$allowlist" -expect "and every entry in it names its forge, so none can match a bare slug" \ - "unqualified=none" "$allowlist" +if printf '%s' "$shipped" | grep -q '^reading=tracked$'; then + expect "the copy this repo SHIPS names no repository at all" \ + "entries=none" "$shipped" +else + # The developer running this has their own auto-merge.conf, which is the + # point of the file. Read the tracked one directly instead of skipping. + tracked="$(sed 's/#.*//' orchestration/auto-merge.example.conf | tr -d '[:space:]')" + if [ -z "$tracked" ]; then + pass "the copy this repo SHIPS names no repository at all" + else + fail "the copy this repo SHIPS names no repository at all" "$tracked" + fi +fi +expect "and whatever is in force names its forge, so none can match a bare slug" \ + "unqualified=none" "$shipped" # --- 10. the shepherd writes down the publish state it already saw ----------- # @@ -5406,9 +5394,9 @@ expect "shepherd lists what is open on the discovered instance" \ "acme/group/widgets on gitlab.example.com" "$out" expect "and a mergeable, attested one there is handed back, not merged" \ "fleet does not merge in acme/group/widgets on gitlab.example.com" "$out" -# What the set IS belongs to 9j, which reads it back whole; what belongs here -# is that discovery added nothing to it, so this names the host and not the -# entries — a fifth entry is not a failure of section 14. +# What the set IS belongs to 9i, which reads it out of the file; what belongs +# here is that discovery added nothing to it, so this names the host and not +# the entries — another entry is not a failure of section 14. limited="$(printf '%s\n' "$out" | grep 'Merging is limited to')" expect "and the pass says what it limits merging to" "Merging is limited to" "$limited" refute "because discovering the instance put nothing of it on that set" \ @@ -5707,7 +5695,7 @@ done # --- the refusals come first: a new form is a new way to spell the old lie --- if out="$($QUEUE block "$xtopic/01-vm-identity" \ - --condition 'az is authenticated for the mazet tenant' 2>&1)"; then + --condition 'az is authenticated for the billing tenant' 2>&1)"; then fail "a condition with no kind and no reason is refused" "$out" else expect "a condition with no kind and no reason is refused" "--kind" "$out" @@ -5771,7 +5759,7 @@ fi # --- recording one, and where it then shows up ------------------------------- -AZ='az is authenticated for the mazet tenant' +AZ='az is authenticated for the billing tenant' if ! out="$($QUEUE block "$xtopic/01-vm-identity" --condition "$AZ" \ --kind missing-credential \ --why "the brief's first instruction reads Azure and az account show fails" 2>&1)"; then diff --git a/scripts/queue.sh b/scripts/queue.sh index b9b9532..8cec07f 100755 --- a/scripts/queue.sh +++ b/scripts/queue.sh @@ -154,9 +154,10 @@ # determine and carries on. A change request it could not read # is never called broken, and never called ready. # NEVER TOUCH only artifacts recorded on this queue's own tasks, and it -# A STRANGER merges only in the repos AUTO_MERGE_REPOS names, each of -# which names its forge (`github.com/owner/repo`, -# `gitlab.example.com/group/project`). +# A STRANGER merges only in the repos the operator's own, gitignored +# orchestration/auto-merge.conf names — nowhere at all until +# that file exists — each of which names its forge +# (`github.com/owner/repo`, `gitlab.example.com/group/project`). # # Usage: # scripts/queue.sh topic add --title T --prompt 'the ask' # or --prompt-file F|- From 544e404df131449cc9ae5221549377eb141127f8 Mon Sep 17 00:00:00 2001 From: LeTuR Date: Sat, 12 Sep 2026 07:55:00 +0000 Subject: [PATCH 2/6] docs(skills): cut duplication and restate nothing a pointer can carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A review of all five skills against two standards this repo already sets: fleet-queue §2's own style contract (cut rhetorical contrast and persuasion, keep every measured fact) and AGENTS.md's rule that a file points at the authority rather than repeating it. Audited for defects first — every referenced path, every queue.sh subcommand and every § cross-reference resolves, so nothing here is a correctness fix. What was wrong was density and one ordering bug. The structural cuts, which are where the value is: - fleet-pane §1 described what the pane RENDERS — fuel bars, the ⇡ row's composition, the order a narrow column drops parts in — which interface/fleet_queue.lua's header owns and the skill's own opening sentence says it owns. Kept what an agent acts on (the FUEL_GLYPH shear remedy, that `green` is not the ok colour, the name-probe that explains most of §7) and pointed at the file for the rest. - fleet-onboarding step 6 restated fleet-pane §4 almost verbatim: the same script flags, the same guarantees, the same guard explanation. It now keeps its own four-option ask and the block to print, and delegates the mechanics. - fleet-queue §5d repeated three of AGENTS.md's four reconciler bullets, including the same incident and duration — and AGENTS.md is always in the lead's context, so that was duplication inside one context window. Kept the two points that are about the lead rather than the loop. - update-fleet §5b was mine from the previous commit and was a 25-line section about a one-time migration in a skill about routine updates. Now six lines and a command. One real defect: fleet-queue's §5a sat AFTER §5d and at a different heading level, so §5d's own title referenced a section the reader had not reached. Moved into order, levelled to ###. fleet-queue's frontmatter description named no command, so "run shepherd" or "what is blocked" had only the skill's name to match on. It now lists the queue's verbs. Every edit was checked against a fact extractor that diffs code spans, fenced blocks, flags, dates and quantities before and after. thurbox-session and update-fleet lost none; the rest lost only rendering internals the lua header owns and strings that moved to the skill that owns them. Measured: 24,038 words to 22,822, and markdown issues 23 to 10 with every remaining one pre-existing and deliberate. The rhetorical-construction count barely moved, and that is the honest result rather than a miss — in this domain most `is not` clauses are correcting a belief the reader would otherwise hold, which is exactly what the style contract permits. ./scripts/check.sh exits 0, every check included. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hp6M6Hw8TYWtJHGfSuwd4F --- .agents/skills/fleet-onboarding/SKILL.md | 51 +-- .agents/skills/fleet-pane/SKILL.md | 183 +++++------ .agents/skills/fleet-queue/SKILL.md | 379 +++++++++++------------ .agents/skills/thurbox-session/SKILL.md | 34 +- .agents/skills/update-fleet/SKILL.md | 83 ++--- 5 files changed, 313 insertions(+), 417 deletions(-) diff --git a/.agents/skills/fleet-onboarding/SKILL.md b/.agents/skills/fleet-onboarding/SKILL.md index 394c417..4f97d3a 100644 --- a/.agents/skills/fleet-onboarding/SKILL.md +++ b/.agents/skills/fleet-onboarding/SKILL.md @@ -318,24 +318,16 @@ the question: - **Skip** — the pane stays installed and invisible; `./scripts/place-pane.sh` places it whenever they want it -On yes, run the script that does it: +On yes: ```bash ./scripts/place-pane.sh --dry-run # the file, the anchor, the exact block ./scripts/place-pane.sh # right of the terminal (--left for the other side) ``` -It refuses rather than guesses. A layout it does not recognise — no `columns` -list it knows, or none of the helpers the block calls — is left untouched and -the block printed instead, naming the part it could not find; the file is -backed up to `layout.lua.bak-` before any edit; the result is -re-read with `lua` and the backup restored if it no longer parses; and it -finishes by running `thurbox-cli plugin check`, which is the verification. A -layout that already carves the slot is left exactly as it is — including one -the operator arranged differently, which is theirs and not yours to correct. - -If they chose to add it themselves, print this and say plainly that you stopped -there on purpose: +If they chose to add it themselves, print this block — with its guard, since a +bare `{ slot = "fleetqueue" }` draws but leaves `F3` opening a pane that never +closes — and say plainly that you stopped there on purpose: ```lua if panels.shown("fleetqueue") and filled(ctx, "fleetqueue") then @@ -343,21 +335,11 @@ if panels.shown("fleetqueue") and filled(ctx, "fleetqueue") then end ``` -It goes inside the `columns` list of `layout.lua`, beside the other side -columns — after the `center` line for the right-hand column. Read the interface -directory back rather than assuming `~/.config/thurbox/ui`; a dev build's is -elsewhere, and this says which rule chose it: - -```bash -thurbox-cli plugin dir --text | head -1 -``` - -**Give them the guard, not just the slot.** `plugin check` suggests a bare -`{ slot = "fleetqueue" }`, and that is enough to make the pane DRAW — which is -all `check` knows about. It is not enough to make `F3` work: an unguarded slot -is carved on every frame, so the key flips a panel state nothing reads and the -pane opens and never closes. `panels` and `filled` already exist in the stock -`layout.lua`, guarding the session list the same way. +**`.agents/skills/fleet-pane/` §4 owns the rest and this step does not restate +it**: where the block goes, what `place-pane.sh` refuses and backs up, and +`thurbox-cli plugin dir --text | head -1` for the interface directory (a dev +build's is not `~/.config/thurbox/ui`). Its §7 is the symptom table if the pane +comes back placed and empty. **One last thing that is theirs and not yours.** The pane finds the queue by running `./scripts/queue.sh root` in the Mission Control session's checkout, @@ -437,15 +419,12 @@ The one thing outside the repo that did change is the operator's own `layout.lua`, if they said yes in step 6 — with a `.bak-` beside it. Say that too. -**And one thing is deliberately NOT set up: where fleet may merge.** A fresh -clone has no `orchestration/auto-merge.conf`, and the tracked -`auto-merge.example.conf` beside it names no repository, so `queue.sh shepherd` -reviews every pull request and merges none of them — it says so by name rather -than reporting the same silence a repo nobody listed would produce. That is -correct: this repo is public and agnostic, and nobody should inherit another -operator's merge rights by cloning it. Offer the file rather than writing it, -and say what naming a repository in it actually authorises — the example's own -header owns the format and the five gates a merge still has to clear: +**One thing is NOT set up, by design: where fleet may merge.** A fresh clone +has no `orchestration/auto-merge.conf` and the tracked example names no +repository, so `queue.sh shepherd` reviews every pull request and merges none — +saying so by name. Nobody inherits another operator's merge rights by cloning a +public repo. Offer the file, never write it; the example's header owns the +format and the gates a merge still clears: ```bash cp -n orchestration/auto-merge.example.conf orchestration/auto-merge.conf diff --git a/.agents/skills/fleet-pane/SKILL.md b/.agents/skills/fleet-pane/SKILL.md index 9b20db0..3ce8dd0 100644 --- a/.agents/skills/fleet-pane/SKILL.md +++ b/.agents/skills/fleet-pane/SKILL.md @@ -32,92 +32,55 @@ but not there". ## 1. What it is, and what it is not A **readout, not a place you go**. `focusable = false`, so the focus ring walks -past it, `ctrl+h`/`ctrl+l` never land on it, and there is no key on it that -dispatches, collects or merges anything — `scripts/queue.sh` stays the only -thing that writes to the queue. The wheel scrolls it. Its one action is the -F-key in §5. +past it, `ctrl+h`/`ctrl+l` never land on it, and no key on it dispatches, +collects or merges anything — `scripts/queue.sh` stays the only thing that +writes to the queue. The wheel scrolls it. Its one action is the F-key in §5. It is the fleet's only live view of the queue, over the same records -`./scripts/queue.sh list` reads. `queue.sh show` is still the place to read a -task in full; the pane is for not asking, to notice a task changed state. - -**The top rows are the fuel, not a task.** The account's remaining provider -windows are the constraint every row under them competes for, so they sit above -the counters: a head row carrying the reserve and how old the reading is, then -**one row per subscription** — the provider's name, a bar, and its percentage. -The bar is a second encoding of the number and never a replacement, it is -coloured by the same reserve the head row names, and it marks where that floor -falls across it. The pane does not read `quota-axi` — it asks -`./scripts/fleet-status.sh --fuel`, the same reading the status screen prints, -on a five-minute TTL of its own because that reading costs a network call. -FLEET.md's `## Fuel` section owns the reserve, which arrives on the record so -the pane never spells the number itself. - -**A provider that could not be read is not drawn at all** — no bar, no number, -no row. The exception is nothing reading at all: then the head row itself says -`unavailable` with the reason under it, because a fuel block that quietly -disappeared would read as "nothing to report" when it means "nobody could -tell". Either way `./scripts/fleet-status.sh` names every provider and the -reason its fetch failed. - -**Two readings are not bars**, and each looks different: a probe that has not -answered is a spinner, and a stale reading is hatched and flagged, because a -number that is remembered rather than observed must not look identical to one -that was just measured. - -**The ⛽ on the head row has an off switch, and it is `FUEL_GLYPH` at the top of -`interface/fleet_queue.lua`.** Set it to nil and the block draws exactly what it -drew before the glyph existed. It is a switch because U+26FD is -East_Asian_Width WIDE — two terminal cells, not one. The pane's own budgets -measure it correctly (`widgets.len` is the kernel's `unicode-width`, the table -the painter lays out with), but a font that draws it narrow, or a multiplexer -that disagrees about its width, shears every row below it. **If the column looks -sheared by one cell, turn the glyph off before looking anywhere else** — and no -variation selector is used, so the terminal draws whatever presentation it -already has. - -**What a narrow column drops**, and this one is routinely thirty cells wide: the -reserve on the head row first, then the bar (under five cells it is a -decoration). The number never goes. The detail row under a reading — the binding window and when it -comes back — is drawn only when exactly one provider carries a number; several -readings at two rows each would push the queue itself off the column, and -`./scripts/fleet-status.sh` is where every window is printed in full. - -**The `⇡` row under a task is its ARTIFACT's state, and it IS the artifact -row** — it replaces it rather than sitting above it. It says what the task was -told to produce (`publish.method` — `no-mistakes`, `pr` or `push`), which pull -request or commit that turned out to be, what fleet last saw when it looked at -it, and how long ago it looked. The whole row is the link: Ctrl+Click it and the -terminal opens the change request. Every word on it comes off `task.yaml`'s -`publish` block, written by `collect`, `shepherd` and `reap` — the commands that -do the looking — so the pane calls no `gh` and says nothing `queue.sh show` -would not print in the same word. Colour carries the verdict, and **`green` is -not the ok colour**: it means every gate the forge knows about holds and nobody -vetted it, which is a different claim from `ready` and is why fleet will not -merge it for you. The note beside a state is its next move, not its colour — -`— yours to merge` on green, `— review` on `open`, since `open` is a fact -`collect` proved, not a verdict, and gets no colour that would claim one. A -narrow column drops the parts in a fixed order — the method first, then the -note, then the age, then the `#44` — and the link survives losing its label. -`interface/fleet_queue.lua` (`PUBLISH_WORD`, `PUBLISH_LADDER`) is the owner of -that order and argues it in place. - -**No row is drawn for a task with nothing to report about its publish**: a -record from before `publish` existed, or a task whose publish has not started. -The absence is what "nothing yet" looks like here, as it is for the documents -row, which no longer draws `0 events`. +`./scripts/queue.sh list` reads. `queue.sh show` is still where you read a task +in full; the pane is for noticing a task changed state without asking. + +**`interface/fleet_queue.lua`'s header owns what it draws and why** — the fuel +rows above the counters, the `⇡` artifact row under a task (its declared +`publish.method` — `no-mistakes`, `pr` or `push` — what that turned out to be, +and what fleet last saw), and what a narrow column drops first (`PUBLISH_WORD`, +`PUBLISH_LADDER`). + +**It calls nothing itself.** Fuel comes from `./scripts/fleet-status.sh --fuel` +on a five-minute TTL, never from `quota-axi` directly; FLEET.md's `## Fuel` +section owns the reserve. Every word of the `⇡` row comes off `task.yaml`'s +`publish` block, written by `collect`, `shepherd` and `reap` — so the pane runs +no `gh` and says nothing `queue.sh show` would not print in the same word. + +Four consequences are worth knowing here because they turn into questions: + +- **An absent row means "nothing to report", never a fault.** A provider that + could not be read is not drawn; a task whose publish has not started has no + `⇡` row. Only nothing reading at all draws a head row saying `unavailable`, + with the reason under it. `./scripts/fleet-status.sh` names every provider + and why its fetch failed. +- **A remembered reading never looks measured.** A probe that has not answered + is a spinner and a stale one is hatched and flagged. +- **`green` is not the ok colour.** It means every gate the forge knows about + holds and *nobody vetted it* — a different claim from `ready`, and why fleet + will not merge it for you. The note beside a state is its next move, not its + colour: `— yours to merge` on green, `— review` on `open`. +- **If the column looks sheared by one cell, turn `FUEL_GLYPH` off before + looking anywhere else.** It is at the top of `interface/fleet_queue.lua`; set + it to nil and the block draws what it drew before the glyph existed. U+26FD is + East_Asian_Width WIDE — two cells — and the pane measures it correctly, but a + font that draws it narrow shears every row below it. It runs inside the thurbox interface, which knows nothing about fleet, so it finds the control plane by **probing the lead session by NAME** and running `./scripts/queue.sh root` in it. Two consequences that explain most of §7: the -lead session must exist under the name the pane expects, and the pane needs -thurbox's `run` capability to ask it anything. The name lives in the -`CONTROL_PLANE` constant at the top of `interface/fleet_queue.lua` and in -`extension.toml.in`, which owns renaming it — read it there rather than -remembering it. - -The constant holds the name **without the glyph**, and matches the lead behind -any single mark in front of it: which glyph the lead wears is a setting +lead must exist under the name the pane expects, and the pane needs thurbox's +`run` capability to ask it anything. The name lives in the `CONTROL_PLANE` +constant at the top of `interface/fleet_queue.lua` and in `extension.toml.in`, +which owns renaming it. + +That constant holds the name **without the glyph**, matching the lead behind any +single mark: which glyph the lead wears is a setting (`orchestration/session-glyphs.example.conf`) that `scripts/install-extension.sh` renders into the manifest, and a pane spelling one of its values would say "no session" the day the operator flipped it. @@ -145,10 +108,10 @@ Two things to get right before running it: that session's conversation history. That is the operator's call to make, not yours — surface it, do not run it. -The pane install is deliberately not fatal to the extension install: a control -plane with no pane still works, so a `plugin install` that failed prints a -warning and the script still exits 0. Read the output; do not infer the pane -from the exit code. +The pane install is not fatal to the extension install — a control plane with +no pane still works — so a failed `plugin install` prints a warning and the +script still exits 0. Read the output; do not infer the pane from the exit +code. ## 3. Verifying @@ -207,15 +170,12 @@ On yes: ./scripts/place-pane.sh --check # is it placed? changes nothing ``` -What makes that safe enough to run at all, and what its header argues in full: -it refuses a layout it does not recognise rather than guessing and says which -part it could not find, it is idempotent — a layout already carving the slot is -left exactly as the operator arranged it — it backs the file up to -`layout.lua.bak-` first, it re-reads its own edit with `lua` and -puts the backup back if the result no longer parses, and it finishes with -`thurbox-cli plugin check`. The slot it writes is read from -`interface/fleet_queue.lua`, never spelled in the script, so a rename cannot -half-land. +What makes it safe enough to run, argued in full in its header: it refuses a +layout it does not recognise and names the part it could not find, it is +idempotent, it backs the file up to `layout.lua.bak-`, it re-reads +its own edit with `lua` and restores the backup if the result no longer parses, +and it finishes with `thurbox-cli plugin check`. The slot it writes is read from +`interface/fleet_queue.lua`, so a rename cannot half-land. If they would rather do it themselves, print the block, name the file, and stop there on purpose. Find it rather than assuming `~/.config/thurbox/ui` — a dev @@ -326,25 +286,20 @@ the harness to reproduce a shape you are chasing. ## 8. The gate -`./scripts/check.sh pane` is what keeps this skill and the installer from -drifting apart from the pane, and `./scripts/check.sh onboarding` covers the +`./scripts/check.sh pane` keeps this skill, the installer and the pane from +drifting apart: it holds ONE spelling of the slot name, the placement guard, the +`plugin remove` path and the F-key across all three — this file among them — and +refuses a binding on a chord the kernel owns. `check_pane` in `scripts/check.sh` +is what it asserts; what matters here is that an edit to any of those strings +must go green in `./scripts/check.sh` before it ships. + +It also runs `./scripts/pane-selftest.sh` (needs `lua`), which renders the pane +offline and asserts the DESIGN rather than the wiring — one row per task, no row +carrying no information, finished work weighing less than running work, and all +of it still fitting thirty columns. `./scripts/check.sh onboarding` covers the writer: `scripts/onboarding-selftest.sh` §3 drives `place-pane.sh` against a -copy of a stock layout — placed right by default, left on `--left`, idempotent, -backed up, refused on an arrangement it cannot read, and still parsing as Lua -afterwards. It holds one spelling of the slot name, the -placement guard, the `plugin remove` path and the F-key across the pane, the -installer and the documents that print the block — this file among them — and it -refuses a binding on a chord the kernel owns. Read `check_pane` in -`scripts/check.sh` for what exactly it asserts; the point here is that it does, -so an edit to any of those strings must go green in `./scripts/check.sh` before -it ships. - -It also runs `./scripts/pane-selftest.sh`, which is the half the greps cannot -reach: it renders the pane offline and asserts the design rather than the -wiring — one row per task, no row that carries no information, finished work -weighing less than running work, the counter row and the section headings not -contradicting each other, and all of it still fitting thirty columns. It needs -`lua`. - -Neither half is a Lua linter. The pane's own gate is `thurbox-cli plugin -check`, which needs a thurbox install, so it belongs at install time — §3. +stock layout — placed right by default, left on `--left`, idempotent, backed up, +refused on an arrangement it cannot read, still parsing as Lua afterwards. + +Neither is a Lua linter. The pane's own gate is `thurbox-cli plugin check`, +which needs a thurbox install, so it belongs at install time — §3. diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index 9842ff5..e9d315c 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -1,6 +1,6 @@ --- name: fleet-queue -description: Turn a prompt into durable task records, dispatch every independent task at once, and learn what finished by reading a stream and a file instead of being interrupted. Use whenever the control plane is given work — especially work spanning several projects, several tasks, or several merges at the same time — and whenever you are asked what is in flight. +description: Turn a prompt into durable task records, dispatch every independent task at once, and learn what finished by reading a stream and a file instead of being interrupted. Use whenever the control plane is given work — especially work spanning several projects, several tasks, or several merges at the same time — whenever you are asked what is in flight, blocked or waiting, and for any of the queue's own verbs: topic add, add, plan, block, dispatch, send, watch, collect, shepherd, reap, refuel, list, show, archive, or the reconcile loop that runs them. user-invocable: true allowed-tools: Read, Edit, Write, Bash, Glob, Grep --- @@ -81,22 +81,18 @@ validate on its own. The decomposition is yours. `--touches` is the paths you expect the task to change. It is a **risk signal that gets reported**, never a reason to hold anything back — see §3. -**`--branch` must not exist yet.** thurbox's `--worktree-branch` only ever -CREATES a branch, so a name already in the repo — `main` itself, a branch left -behind by an earlier run — cannot have a worktree cut for it. `add` refuses it -here, naming it: the alternative was a spawn that died at `dispatch` with -thurbox's own exit status, a task left `queued`, and a hand-edited `task.yaml`. -A repo this machine cannot read is not asked, so a `--host` task still finds -out at dispatch. - -**`--title` becomes the worker's session NAME.** thurbox makes a path segment -of that name, so it refuses one carrying `/`, `\` or `..`, one starting `.`, -and one over its 64-byte cap — `Rust crate, CI/CD and the profile model` is a -title `add` used to take and `dispatch` could never spawn. `add` refuses it -here for the same reason as `--branch`: the repair afterwards is a hand-edit of -`title` in `task.yaml` and the brief's H1, because nothing retitles a task. -Every character thurbox accepts is still accepted — a title is human-facing -text — and the check is on the rendered name, glyph and cut included. +Two things `add` refuses up front, because both used to fail at `dispatch` +instead — leaving a task `queued` and needing a hand-edit of `task.yaml`: + +- **`--branch` must not exist yet.** thurbox's `--worktree-branch` only ever + CREATES a branch, so `main` itself or one left behind by an earlier run + cannot have a worktree cut for it. A repo this machine cannot read is not + asked, so a `--host` task still finds out at dispatch. +- **`--title` becomes the worker's session NAME.** thurbox makes a path segment + of it, refusing `/`, `\`, `..`, a leading `.`, and anything over its 64-byte + cap — `Rust crate, CI/CD and the profile model` is a title `add` used to take + and `dispatch` could never spawn. Judged on the RENDERED name, glyph and cut + included; every character thurbox accepts is still accepted. ### `--host` — running a task on another machine @@ -181,16 +177,14 @@ padding and is the reason the worker gets it right on the first pass. content that belongs under one of the four. Inside a section, `X, not Y` — and `is not`, `That is …`, `deliberately`, `on purpose` — earns its place only where the reader would otherwise believe Y. Seven briefs written before this -rule carried 33 `X, not Y`s, 18 bare `is not`s and 20 invented headings between -them — several of the headings were themselves the construction ("The lever, -and it is the repo's own rule") — and none of it told a worker anything. +rule carried 33 `X, not Y`s, 18 bare `is not`s and 20 invented headings, and +none of it told a worker anything. **Cut persuasion.** The worker follows the brief; it does not have to be convinced. Drop the sentence explaining why the task is worth doing, the one -saying a decision was weighed carefully, and the one reassuring the reader -that something is settled. "Serialize with `queue.sh block`" carries -everything that "Serialize with `queue.sh block` — this is deliberate and the -right call" carries. +saying a decision was weighed carefully, and the one reassuring the reader that +something is settled. "Serialize with `queue.sh block`" carries everything +"Serialize with `queue.sh block` — this is deliberate and the right call" does. **Keep every measured fact.** Counts, file paths, sizes, exact token and version values, command names, and the specific past failure a constraint @@ -289,11 +283,10 @@ open pull request does not, and neither does an abandoned task. ### When the thing holding a task is not a task — `--condition` Sometimes a task is ready by every record and unrunnable in fact. On 2026-09-11 -`vending-machine-egress-resume/01-vm-identity-reconciliation` was exactly that: -its brief's first instruction reads Azure and `az` was not authenticated. There -was nothing to write down, so `plan` called it ready, the reconciler woke the -lead to dispatch it, and the only honest answer was to refuse in conversation -and leave the record saying nothing. +a task whose brief's first instruction read Azure sat with `az` unauthenticated: +nothing could be written down, so `plan` called it ready, the reconciler woke +the lead to dispatch it, and the only honest answer was to refuse in +conversation and leave the record silent. **That is what the second form of blocker is for.** It names a CONDITION rather than a task: @@ -333,11 +326,11 @@ work will release it: ./scripts/queue.sh block --clear --condition 'az is authenticated for the billing tenant' ``` -That is deliberate. A condition that expired on its own would put back exactly -the silence it was recorded to break. The cost is that a stale one holds a task -forever, which is why `--why` is required and why `plan`, `list`, `show`, -`fleet-status.sh` and the TUI pane all carry it in front of you — the pane draws -it as `⊘` rather than `↳`, because the wait it marks has no actor but you. +A condition that expired on its own would put back the silence it was recorded +to break. The cost is that a stale one holds a task forever, which is why +`--why` is required and why `plan`, `list`, `show`, `fleet-status.sh` and the +TUI pane all carry it in front of you — the pane draws it `⊘` rather than `↳`, +because the wait it marks has no actor but you. ## 4. Dispatch — the whole ready set, in one go @@ -506,8 +499,7 @@ RELEASE ./scripts/queue.sh reap [--dry-run] **A remote task completes the same way.** `collect` fetches that worker's `result.md` off its host over ssh and writes it into the task's own, then reads it like any other. Everything downstream sees a local file and never learns -which machine wrote it — which is the point, and why a remote worker still does -not send mail. +which machine wrote it, so a remote worker still does not send mail. ### `collect` verifies the artifact — you do not have to take the worker on trust @@ -547,10 +539,11 @@ Three answers, and the third is not the second: | it is not, or not from this branch | **leaves the task OPEN** and says so, loudly | | could not run | closes the task, and says the check could not run | -"Could not run" is the forge CLI absent, no network, a change request it cannot read, or a -base branch this machine cannot see. That must never read as a pass or a fail — -CI and an offline laptop both still have to collect. `queue.sh show ` -prints the method and the verdict, so both survive the scrollback. +"Could not run" is the forge CLI absent, no network, a change request it +cannot read, or a base branch this machine cannot see. That must never read as +a pass or a fail — CI and an offline laptop both still have to collect. +`queue.sh show ` prints the method and the verdict, so both survive the +scrollback. **The head-branch check is the one a worker cannot write for itself.** Whatever the body says, "this change request comes from this task's branch" is a fact of @@ -561,6 +554,118 @@ When a task is held open: read the artifact, then send that worker back to publish again and collect again. If you have read it yourself and judged it good as it stands, `collect --allow-unverified` closes it and records that you did. +### 5a. Shepherd the pull requests — the fourth thing + +A task closes when its worker writes `result.md`. **The pull request it named +goes on living** — it turns `CONFLICTING` when the one under it merges, its +checks fail, a review lands on it, and none of that reaches the task that +opened it. + +```bash +./scripts/queue.sh shepherd --dry-run # what it would dispatch and merge +./scripts/queue.sh shepherd # do it +``` + +**It asks the forge, not the records.** A task records ONE `artifact` — the +first pull request its worker reported. #25 was a *second* pull request from a +task whose artifact still pointed at the already-merged #23, so a shepherd +reading artifacts could not see it and the unattended pass would never have +merged it; a PR opened outside the queue was invisible the same way. So it asks +the forge for every open change request against every repo the queue's tasks +name, and each open pull request gets exactly one of these: + +| What the forge says | What happens | +|---|---| +| the head branch is in someone else's fork | reported, never merged, **never given an agent** | +| `mergeable: CONFLICTING` | a fixer is dispatched to rebase | +| a check failed | a fixer is dispatched to fix it | +| `reviewDecision: CHANGES_REQUESTED` | a fixer is dispatched to address it | +| a `no-mistakes` task's PR with no attestation for this head commit | a fixer is dispatched to re-run `/no-mistakes --yes` | +| attested, checks green, `MERGEABLE`, ours | **squash-merged**, in the allowlisted repos only | +| checks green, `MERGEABLE`, ours, and nothing attested it | recorded `green` and reported `ready to merge — not attested; yours`, **never merged by fleet** | +| anything it could not read | reported, and otherwise left alone | + +A PR is tied back to a task by its recorded `artifact` or by its **head +branch** matching the task's. One that matches neither is still classified and +still merged — it simply has no session to send a fixer into, and the output +names it as belonging to no task rather than passing over it in silence. + +**A remote task's pull request is classified and merged like any other, and its +fixer is withheld.** The fixer needs a checkout of the PR's head branch, and a +remote task's checkout is on its host; spawning there is not yet built. The +shepherd says so by name rather than reporting the host's repo as "not a git +checkout", which is true and sends you looking in the wrong place. Send the fix +into that worker's own session while it is still alive — which is exactly what +§5b keeps it alive for. + +**Dispatching the fixer is the point**, not the report: noticing was never the +expensive part. The fixer gets a written brief of its own — the condition, which +PR merged underneath it and what that deleted, and that the fix updates the PR +**in place** — and it lands on a checkout of the branch that already exists, so +the push reaches the pull request that is already open. + +Three things it will not do, and they are what make it safe to run: + +- **It will not dispatch twice for one pull request.** The fixer it sent is + recorded on the task under `shepherd`; a second pass checks that session's + liveness, not whether the condition still matches — a PR can drift to a + different condition while the fixer is mid-fix, and that drift never reads + as nobody being on it. A liveness check that comes back unknown is left + alone rather than guessed. `--force` overrides, once you have decided the + first one is not coming back. +- **It will not interrupt a working session.** A PR whose own worker is + `working` or `blocked` is left alone. So is one whose state is merely + *observed* — `running`, `uncovered`, `unreported` are not the agent saying it + is at rest (`thurbox-session` §4a). +- **It will not guess.** No forge, no network, no thurbox: it says what it + could not determine and carries on. A PR it could not read is never called broken + and never called ready. + +**On merging, which is the part that runs unattended.** A repo can be public +and have forks, so "merge every open PR on a timer" has to survive a stranger +opening one. Fleet merges only in the repositories **the operator named in +`orchestration/auto-merge.conf`** — their own file, gitignored, absent by +default, and read on every pass — and only when **all** of these hold. This +repo ships `orchestration/auto-merge.example.conf`, which names NOTHING, so a +fresh clone merges nowhere until somebody writes that file; `shepherd` says so +by name rather than reporting the same silence a repo nobody listed produces. +Entries are HOST-QUALIFIED and one that names no forge is refused rather than +matched: `Thurbeen/fleet` on github.com and `Thurbeen/fleet` on a self-hosted +instance are not the same repository. `FLEET_AUTO_MERGE_REPOS` in the +environment REPLACES the file rather than adding to it. + +- **The head branch is in that repository**, not a fork. A stranger cannot + create a branch here, so this is the one claim about a pull request that + whoever opened it cannot write for themselves. +- **Whoever opened it can push there.** Anyone with read access can open a + pull request between two branches that already exist, and the body would + then be theirs to write. +- **A `no-mistakes` attestation naming its CURRENT head commit.** Not the five + `## ` headings — those are text anyone can paste, so counting them let a + body authorise its own merge. The attestation is an HTML comment carrying + the commit the pipeline ran on and a status per step; one from an earlier + push is refused, because a verdict is about the code it saw. A PR whose task + declared another method carries none, is recorded `green` rather than + `ready`, and is handed back: the checks it passed are whatever checks that + repo happens to have, and nothing says review, tests and lint ran on the + head that would land. +- **Every check concluded and passed, and GitHub says `MERGEABLE`.** + +A PR failing any of them is not merged, and one that is not ours is not given +an agent either. Only the attestation gate is method-aware, and only in the one +direction: a task that was declared `no-mistakes` and carries no attestation +gets the fixer it always got, and a task that was never asked for one gets +neither the fixer nor the merge. Everywhere outside the allowlist it reports +`ready to merge` and stops, which is what every repo did before that list +existed. + +**Run it the way you run `collect`.** It is a sibling and not part of it — +`collect` reads local files and works with the network down, and folding a +session-spawning, GitHub-calling side effect into it would make it fail for +reasons unrelated to what it was asked. So `collect` names it whenever it +closed a task that left a PR open, and `shepherd --json` is the seam anything +else reads it through. + ### 5b. `reap` — a session lives until its work lands, and not one turn longer **The gate is the merge, not the conclusion.** For the two methods that end in a @@ -601,7 +706,7 @@ the word. `idle`, `done` and `stopped` are the only three it acts on: (`thurbox-session` §4a), and treating them as `idle` kills live work. Deletion is `session delete --force`, because a plain delete only soft-deletes the row and leaves the TUI to reap the window and worktrees on a sync that, run -headless, never comes — and freeing the disk is the whole point. The record +headless, never comes, leaving the disk unfreed. The record keeps a receipt, so `list` and `show` stop naming an id that no longer resolves. @@ -657,20 +762,20 @@ result, `reap` sees a task that is not finished. Nothing in the loop notices. ./scripts/queue.sh refuel # just that task's ``` -**It asks the ACCOUNT before it looks at a single session, and that order is the -whole point.** The quota window it reads is the operator's own subscription — -the lead and every worker draw on it. It reads the `claude` account alone, -through `fleet_status.probe_fuel`: the fleet runs `claude` agents, so a spent -window on a provider the fleet does not dispatch through must not strand a -`claude` worker at its limit. `fleet-status.sh`'s `FUEL` section reads every -authenticated provider instead (`fleet_status.probe_fuel_all`), so the two can -legitimately disagree — the screen may show a provider fine while `refuel` still -reports `claude` spent, or vice versa; a task running another agent is reported -undetermined rather than guessed at. So while `claude` is spent, every session -is stuck for the same reason, and restarting them is worse than useless: each -one resumes, hits the same wall within seconds, and burns the reset it was -waiting for. Three concurrent pipeline runs did exactly that on 2026-08-29 and -lost every step in flight. +**It asks the ACCOUNT before it looks at a single session.** That window is the +operator's own subscription, which the lead and every worker draw on: while it +is spent every session is stuck for the same reason, and restarting them is +worse than useless — each resumes, hits the same wall within seconds, and burns +the reset it was waiting for. Three concurrent pipeline runs did that on +2026-08-29 and lost every step in flight. + +It reads the `claude` account alone, through `fleet_status.probe_fuel`, because +the fleet dispatches `claude` agents and a spent window on a provider it never +uses must not strand one. `fleet-status.sh`'s `FUEL` section reads every +authenticated provider (`fleet_status.probe_fuel_all`), **so the two can +legitimately disagree** — the screen may show a provider fine while `refuel` +reports `claude` spent. A task running another agent is reported undetermined +rather than guessed at. ```text account claude spent 0% remaining — five_hour resets 2026-09-09T02:10:00+00:00 @@ -728,141 +833,19 @@ spotted. ``` It folds `watch` continuously and runs `collect`, `shepherd` and `refuel` on -their own intervals. Four things to know and nothing else: - -- **It changes nothing about how you work.** You still plan, still write - briefs, still `dispatch`. It reconciles the RECORDS with the world; deciding - what runs is yours and it has no verb for it. -- **`queue.sh` is still the only writer.** The loop shells out and never - touches a record. So `list` and the TUI pane cannot start disagreeing with - it. +their own intervals. **AGENTS.md's reconciler section owns what it may and may +not do**, and `reconcile.sh`'s header argues each interval. Two things belong +here, because they are about you rather than about it: + - **It will type one line at you, and only ever this one:** that N tasks are - ready and nothing will dispatch them. A blocker cleared, the loop may not - act on it, and you were not looking — on 2026-09-10 that sat for six and a - half hours until the operator asked for status. Treat the line as `plan` - already run: `dispatch`. It arrives once per transition and never mid-turn, - so a second one means the ready set has grown again. + ready and nothing will dispatch them. Treat it as `plan` already run — + `dispatch`. It arrives once per transition and never mid-turn, so a second + line means the ready set grew again. An unprompted line there is this, not a + bug. - **Run the commands anyway when you want an answer NOW.** `collect` is - idempotent and reading it yourself is always allowed; the loop only means you - are rarely the first to notice. - -It is a supervised loop and not a cron — `FLEET.md`'s `## What you are not` -owns why that distinction is the whole point, and `reconcile.sh`'s own header -argues each interval. A worker's `Stop` hook can `reconcile.sh nudge` to bring -the periodic pass forward, which is an accelerator: a worker that ran out of -quota fires no hook, so the timer is what actually catches it. - -## 5a. Shepherd the pull requests — the fourth thing - -A task closes when its worker writes `result.md`. **The pull request it named -goes on living** — it turns `CONFLICTING` when the one under it merges, its -checks fail, a review lands on it, and none of that reaches the task that -opened it. - -```bash -./scripts/queue.sh shepherd --dry-run # what it would dispatch and merge -./scripts/queue.sh shepherd # do it -``` - -**It asks the forge, not the records.** A task records ONE `artifact` — the -first pull request its worker reported. #25 was a *second* pull request from a -task whose artifact still pointed at the already-merged #23, so a shepherd -reading artifacts could not see it and the unattended pass would never have -merged it; a PR opened outside the queue was invisible the same way. So it asks -the forge for every open change request against every repo the queue's tasks -name, and each open pull request gets exactly one of these: - -| What the forge says | What happens | -|---|---| -| the head branch is in someone else's fork | reported, never merged, **never given an agent** | -| `mergeable: CONFLICTING` | a fixer is dispatched to rebase | -| a check failed | a fixer is dispatched to fix it | -| `reviewDecision: CHANGES_REQUESTED` | a fixer is dispatched to address it | -| a `no-mistakes` task's PR with no attestation for this head commit | a fixer is dispatched to re-run `/no-mistakes --yes` | -| attested, checks green, `MERGEABLE`, ours | **squash-merged**, in the allowlisted repos only | -| checks green, `MERGEABLE`, ours, and nothing attested it | recorded `green` and reported `ready to merge — not attested; yours`, **never merged by fleet** | -| anything it could not read | reported, and otherwise left alone | - -A PR is tied back to a task by its recorded `artifact` or by its **head -branch** matching the task's. One that matches neither is still classified and -still merged — it simply has no session to send a fixer into, and the output -names it as belonging to no task rather than passing over it in silence. - -**A remote task's pull request is classified and merged like any other, and its -fixer is withheld.** The fixer needs a checkout of the PR's head branch, and a -remote task's checkout is on its host; spawning there is not yet built. The -shepherd says so by name rather than reporting the host's repo as "not a git -checkout", which is true and sends you looking in the wrong place. Send the fix -into that worker's own session while it is still alive — which is exactly what -§5b keeps it alive for. - -**Dispatching the fixer is the point**, not the report: noticing was never the -expensive part. The fixer gets a written brief of its own — the condition, which -PR merged underneath it and what that deleted, and that the fix updates the PR -**in place** — and it lands on a checkout of the branch that already exists, so -the push reaches the pull request that is already open. - -Three things it will not do, and they are what make it safe to run: - -- **It will not dispatch twice for one pull request.** The fixer it sent is - recorded on the task under `shepherd`; a second pass checks that session's - liveness, not whether the condition still matches — a PR can drift to a - different condition while the fixer is mid-fix, and that drift never reads - as nobody being on it. A liveness check that comes back unknown is left - alone rather than guessed. `--force` overrides, once you have decided the - first one is not coming back. -- **It will not interrupt a working session.** A PR whose own worker is - `working` or `blocked` is left alone. So is one whose state is merely - *observed* — `running`, `uncovered`, `unreported` are not the agent saying it - is at rest (`thurbox-session` §4a). -- **It will not guess.** No forge, no network, no thurbox: it says what it - could not determine and carries on. A PR it could not read is never called broken - and never called ready. - -**On merging, which is the part that runs unattended.** A repo can be public -and have forks, so "merge every open PR on a timer" has to survive a stranger -opening one. Fleet merges only in the repositories **the operator named in -`orchestration/auto-merge.conf`** — their own file, gitignored, absent by -default, and read on every pass — and only when **all** of these hold. This -repo ships `orchestration/auto-merge.example.conf`, which names NOTHING, so a -fresh clone merges nowhere until somebody writes that file; `shepherd` says so -by name rather than reporting the same silence a repo nobody listed produces. -Entries are HOST-QUALIFIED and one that names no forge is refused rather than -matched: `Thurbeen/fleet` on github.com and `Thurbeen/fleet` on a self-hosted -instance are not the same repository. `FLEET_AUTO_MERGE_REPOS` in the -environment REPLACES the file rather than adding to it. - -- **The head branch is in that repository**, not a fork. A stranger cannot - create a branch here, so this is the one claim about a pull request that - whoever opened it cannot write for themselves. -- **Whoever opened it can push there.** Anyone with read access can open a - pull request between two branches that already exist, and the body would - then be theirs to write. -- **A `no-mistakes` attestation naming its CURRENT head commit.** Not the five - `## ` headings — those are text anyone can paste, so counting them let a - body authorise its own merge. The attestation is an HTML comment carrying - the commit the pipeline ran on and a status per step; one from an earlier - push is refused, because a verdict is about the code it saw. A PR whose task - declared another method carries none, is recorded `green` rather than - `ready`, and is handed back: the checks it passed are whatever checks that - repo happens to have, and nothing says review, tests and lint ran on the - head that would land. -- **Every check concluded and passed, and GitHub says `MERGEABLE`.** - -A PR failing any of them is not merged, and one that is not ours is not given -an agent either. Only the attestation gate is method-aware, and only in the one -direction: a task that was declared `no-mistakes` and carries no attestation -gets the fixer it always got, and a task that was never asked for one gets -neither the fixer nor the merge. Everywhere outside the allowlist it reports -`ready to merge` and stops, which is what every repo did before that list -existed. - -**Run it the way you run `collect`.** It is a sibling and not part of it — -`collect` reads local files and works with the network down, and folding a -session-spawning, GitHub-calling side effect into it would make it fail for -reasons unrelated to what it was asked. So `collect` names it whenever it -closed a task that left a PR open, and `shepherd --json` is the seam anything -else reads it through. + idempotent; the loop only means you are rarely the first to notice. It + changes nothing about how you plan, write briefs or dispatch — it has no verb + for any of that. ## The run log — the queue writes the facts, you write the judgement @@ -870,7 +853,7 @@ One log per topic, opened by `topic add`, refreshed by `dispatch`, `collect` and `shepherd` as they go. It exists because it used to not: two consecutive runs went unrecorded, one file surviving only because its lead was being migrated and the other reconstructed from chat history at the end. The -instruction was there both times, which is what makes it a tool gap. +instruction was there both times, so the gap was the tool's. ```text everything between the fences is GENERATED — the task @@ -944,15 +927,15 @@ and you remain the only thing that writes here. ## 7. Where this lives, and what that costs Everything under `orchestration/queue/` is gitignored working state — your -prompts, your briefs, your results. `README.md`, `POLICY.md` and -`OPERATOR.example.md` are the three -exceptions: standing documentation, not one operator's data, which is exactly -why every brief can point at the policy instead of carrying a copy. The -operator's own `OPERATOR.md` is ignored with the rest — theirs to write, read by -every worker whose brief was scaffolded while it existed. The machinery is tracked; the -queue is not, because this repo is public and none of that belongs in it. It -also means **the repo does not back your queue up**. Say that plainly when -someone assumes otherwise; `.gitignore`'s header owns the full reasoning. +prompts, your briefs, your results — because this repo is public. `README.md`, +`POLICY.md` and `OPERATOR.example.md` are the three exceptions: standing +documentation rather than one operator's data, which is why every brief can +point at the policy instead of carrying a copy. The operator's own `OPERATOR.md` +is ignored with the rest, read by every worker whose brief was scaffolded while +it existed. + +So **the repo does not back your queue up.** Say that plainly when someone +assumes otherwise; `.gitignore`'s header owns the reasoning. `./scripts/check.sh queue` validates your records and re-proves the ordering and wake claims against a throwaway queue. It runs in the gate, so a change that diff --git a/.agents/skills/thurbox-session/SKILL.md b/.agents/skills/thurbox-session/SKILL.md index e9e274c..198197f 100644 --- a/.agents/skills/thurbox-session/SKILL.md +++ b/.agents/skills/thurbox-session/SKILL.md @@ -353,11 +353,8 @@ thurbox-cli session create --name 'Add a license header to every source file' \ the agent in a per-session **symlink workspace** (`~/.local/share/thurbox/workspaces//`) holding one symlink per repo, with the agent's cwd set there, so every repo appears as a -subdirectory. It is agent-neutral — thurbox passes no `--add-dir`-style flags to -Claude itself — symlinks only, rebuilt idempotently on each launch, and removed -on delete without touching the repos. - -The consequences: +subdirectory. Symlinks only, rebuilt on each launch, removed on delete without +touching the repos. The consequences: - The session's `cwd` field still points at the **primary** repo (display, editor, git context). The workspace is a spawn-time process-cwd detail, never @@ -425,16 +422,15 @@ the WHEN thurbox-cli watch --json [--since ] the WHAT a result file the worker wrote when it knew what it had concluded. ``` -**Both halves are needed, and the stream alone is not enough.** A transition -says a turn ended. That is not the claim that the task finished — an agent -reports `done` at the end of every turn, including the one where it gave up. -A lead that treats "turn ended" as "task done" closes tasks that failed. +**The stream alone is not enough.** A transition says a turn ended, and an +agent reports `done` at the end of every turn — including the one where it gave +up. A lead that treats "turn ended" as "task done" closes tasks that failed. -`./scripts/queue.sh` implements exactly this pair and is how the control plane -should run any real work: `watch` folds transitions into each task's record and -closes nothing; `collect` reads the worker's own result file and only then does -a task close. See `.agents/skills/fleet-queue/SKILL.md`. Put the result -contract at the end of every brief: +`./scripts/queue.sh` implements exactly this pair: `watch` folds transitions +into each task's record and closes nothing; `collect` reads the worker's own +result file and only then does a task close. See +`.agents/skills/fleet-queue/SKILL.md`. Put the result contract at the end of +every brief: ```markdown Write /result.md when you finish or conclude you cannot: @@ -539,12 +535,10 @@ thurbox-cli session get --json | jq '{agent,detected_agent,state,state_so `uncovered` from `list` and `running` from `get`, for the same session at the same moment, and both are true. -**None of this is a completion signal.** `done` means *a turn* finished, not -that the work is finished — an agent reports `done` at the end of every turn it -takes. Use state to supervise: to spot a `blocked` worker waiting on an approval -nobody is going to give, or a `working` one whose report has aged past anything -plausible. Completion still arrives as the result file of §4, because only the -worker knows whether it is done. +**None of this is a completion signal.** Use state to SUPERVISE — to spot a +`blocked` worker waiting on an approval nobody will give, or a `working` one +whose report has aged past anything plausible. Completion arrives as §4's result +file, because only the worker knows whether it is done. ### 4b. A `working` that never ends — the session that ran out of fuel diff --git a/.agents/skills/update-fleet/SKILL.md b/.agents/skills/update-fleet/SKILL.md index df4ff1a..88b0491 100644 --- a/.agents/skills/update-fleet/SKILL.md +++ b/.agents/skills/update-fleet/SKILL.md @@ -9,12 +9,10 @@ allowed-tools: Read, Bash, Glob, Grep **`fleet-onboarding` is "this clone is not yet a working fleet"; this skill is "this working fleet is behind origin"** — it fast-forwards the checkout and then -re-applies only what that sync actually touched, and where the two skills meet -the same step, this one points at that one rather than repeating it. +re-applies only what that sync actually touched. Scope is the **control plane only**. thurbox, Claude Code and the agent tooling -have their own update paths, and folding them in here would make the skill -unpredictable about what it just changed. Say so if asked to do more. +have their own update paths. Say so if asked to do more. > **This skill changes no tracked state on its own initiative.** No commits, no > pushes, no reverts, no `git checkout -- .`, no rebase, no reset. It runs @@ -103,49 +101,36 @@ do. Otherwise map the list: | `scripts/reconcile.sh` | §6 | the running reconciler loop is executing old code | | `FLEET.md`, `AGENTS.md`, `CLAUDE.md`, `.agents/skills`, `.claude/skills`, `.claude/settings.json` — or a `restart-lead:` line | §8 | the lead is holding instructions it froze at launch | -`FLEET.md` is deliberately in two rows: the extension's `[[files]]` payload is -`FLEET.rendered.md`, which the installer renders FROM it, *and* it is the -lead's standing context — so it needs both the reinstall in §3 and the -hand-over in §8. A change to `orchestration/voice.example.conf` (or your own -`voice.conf`) needs the same two: it moves what the rendered payload calls you. +`FLEET.md` is in two rows: the extension's `[[files]]` payload is +`FLEET.rendered.md`, rendered FROM it, *and* it is the lead's standing context +— so it needs the reinstall in §3 and the hand-over in §8. So does a change to +`orchestration/voice.example.conf` (or your own `voice.conf`): it moves what the +rendered payload calls you. ### §5b — where fleet may merge, which a sync can silently empty -**One update in fleet's history moves this on its own: the one that took the -auto-merge allowlist out of `scripts/lib/queue.py` and put it in -`orchestration/auto-merge.conf`.** Before it, the repositories fleet merged in -were a literal in tracked code, so every clone carried one operator's merge -rights. After it they are the operator's own gitignored file, and the tracked -copy beside it names nothing — so an operator who syncs across that change and -writes no file finds `shepherd` merging **nowhere** and saying so on every pass. -That is the intended default and not a regression, but it is silent unless -somebody looks. - -So after a sync that touched either path, ask: +The auto-merge allowlist moved out of `scripts/lib/queue.py` into the +operator's gitignored `orchestration/auto-merge.conf`. The tracked copy beside +it names nothing, so an operator who syncs across that change and writes no +file finds `shepherd` merging nowhere. Intended, and silent unless somebody +looks: ```bash ./scripts/queue.sh shepherd --dry-run | tail -6 -``` - -A pass that prints `Fleet merges NOTHING` is telling you the file does not -exist. If the operator wants unattended merges back, copy the tracked form and -name their own repositories in it — host-qualified, and only repositories where -the gates mean something: - -```bash cp -n orchestration/auto-merge.example.conf orchestration/auto-merge.conf $EDITOR orchestration/auto-merge.conf ``` -It is read on every pass, so nothing needs a reinstall or a restart. Both files -are gitignored below the tracked one, so there is nothing to commit. +`Fleet merges NOTHING` in that output means the file does not exist. Entries +are host-qualified; the example's header owns the format and the gates. +Read every pass, so no reinstall and no restart, and both files are gitignored. -`scripts/lib/queue.py` is deliberately absent from this table, and so is -`scripts/lib/notify_lead.py`. The reconciler's loop never sources either — every -pass shells out to `./scripts/queue.sh` and to `python3 -scripts/lib/notify_lead.py` as fresh subprocesses, so a change to either -reaches the loop on its very next call, with no restart needed. §6 covers only -`scripts/reconcile.sh` itself, which the running loop does hold in memory. +`scripts/lib/queue.py` and `scripts/lib/notify_lead.py` are absent from this +table: the reconciler's loop sources neither — every pass shells out to +`./scripts/queue.sh` and `python3 scripts/lib/notify_lead.py` as fresh +subprocesses, so a change reaches the loop on its next call with no restart. §6 +covers `scripts/reconcile.sh` itself, which the running loop does hold in +memory. Run §3–§6 in any order, then §7, then §8 last — §8 is the one that cannot be automated, and everything else should already be done when you raise it. @@ -178,9 +163,10 @@ than inferring the pane from the exit code** — §4. `.agents/skills/fleet-pane/` owns the pane end to end: the install, the one command that verifies it, the `layout.lua` block that places it and the script that writes that block once the operator says so, the F-key, removal, and the -symptom table for a pane that is installed and drawing nothing. **Use that skill; do not restate its procedure -here.** §3 already re-ran the install, so what is left is its verification step -and, if that comes back unplaced, its placement section. +symptom table for a pane that is installed and drawing nothing. **Use that +skill; do not restate its procedure here.** §3 already re-ran the install, so +what is left is its verification step and, if that comes back unplaced, its +placement section. ## 5. Registry — only when the owners changed @@ -272,20 +258,19 @@ own `restart-lead:` message: the quotes. `extension.toml.in`'s RENAMING header owns why the glyph is part of the name. -**This is not the fork sequence.** `extension.toml.in`'s RENAMING header -documents a conversation-preserving fork, and that sequence exists to move a -name to a *new* one. Here the name must not move, so it does not apply — and -its second step would be asking thurbox to spawn a session under a name the live -lead still holds. `restart` needs none of it. +**Not the fork sequence.** `extension.toml.in`'s RENAMING header documents a +conversation-preserving fork, which moves a name to a *new* one; its second step +would ask thurbox to spawn under a name the live lead still holds. Here the name +must not move. Two costs to state before the operator runs it: - **A turn in flight dies with the window.** Check the lead is at rest first - (`thurbox-cli session get ''`), and read the state word the - way `.agents/skills/thurbox-session/` §4a says to — `idle` is not the only - word that is not `working`. -- **The old instructions are still in the resumed history.** That is the - trade-off `restart` makes, and for most updates it is the right one. + (`thurbox-cli session get ''`), reading the state word the way + `.agents/skills/thurbox-session/` §4a says to — `idle` is not the only word + that is not `working`. +- **The old instructions are still in the resumed history**, which is the cost + `restart` trades for the conversation, and what the next section is for. ### When the new instructions have to win From 5d986553fc900987d29afd8175a82faf60258d1e Mon Sep 17 00:00:00 2001 From: LeTuR Date: Sat, 12 Sep 2026 08:37:11 +0000 Subject: [PATCH 3/6] feat(queue): make the publishing tool and the agent configuration, not code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit queue.py's own comment said the publish methods are ARTIFACT SHAPES and not tool names, "which is the whole of fleet's agnosticism, and it lasts exactly as long as nothing branches on it". One of the three shapes was `no-mistakes` — one operator's pipeline — the code branched on it, the attestation format was that tool's, and tracked POLICY.md shipped it as every clone's default. The same sweep found the agent hardcoded the same way. THE PUBLISH SEAM. The third shape is `attested`: a pull request whose body carries an attestation for the commit that would merge. What an attestation LOOKS like is ATTESTATION_MARKER in the operator's gitignored orchestration/publish.conf, so fleet reads their pipeline's format rather than dictating one; PIPELINE_COMMIT_PREFIX is theirs too and is empty by default, so fleet never claims to know which commits a pipeline wrote. `no-mistakes` is accepted wherever a method is read and means `attested`, so existing task records load unedited. The default method and its free-text command moved out of tracked POLICY.md into the same conf — a block still in POLICY.md is honoured and warns once on stderr, so a live fleet keeps working while it moves. The fixer brief now quotes the task's own recorded `how` instead of a command this repo picked. THE AGENT SEAM. `--agent claude` is gone from all three dispatch sites: with no AGENT in orchestration/agent.conf, session create keeps thurbox's own agents.toml default, which is where that answer already lived. refuel no longer gates on a literal `claude` window — it takes FUEL_PROVIDER, or maps the agent in hand through AGENT_PROVIDERS (identity by default), and reports `undetermined` — which restarts nothing — when tasks disagree or when `quota-axi auth` holds no credential for the derived name. Fleet carries no provider list; it asks quota-axi, so the set grows with the tool. HOW AN AGENT SAYS IT RAN OUT is now a table with one entry per agent fleet has actually WATCHED do it — `claude` today — the same shape as session-trust.sh's per-agent dialog table. An agent with no entry answers `undetermined` rather than being guessed at, and LIMIT_BANNER / TRANSCRIPT_DIR teach fleet one without a code change. Nothing is matched here that nobody observed: a guessed pattern restarts a live worker mid-turn. Both tracked example files name nothing. check.sh's `automerge` check now fails any tracked setting that carries a repository, a tool, a vendor or an agent, and reads every occurrence rather than the first — proved by appending a leak below a correct line and watching it fail. queue-selftest.sh gets its own throwaway publish and agent settings so its verdicts stop depending on whose machine ran it, and section 8h becomes the unconfigured-clone proof: no POLICY frontmatter and no publish.conf defaults to `pr` with no tool named anywhere in the brief, the shipped example names no METHOD but `pr` and no HOW at all, the three methods are artifact shapes, and the retired spelling still resolves. ./scripts/check.sh exits 0, every check included. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hp6M6Hw8TYWtJHGfSuwd4F --- .agents/skills/fleet-onboarding/SKILL.md | 20 +- .agents/skills/fleet-pane/SKILL.md | 2 +- .agents/skills/fleet-queue/SKILL.md | 52 ++- .agents/skills/thurbox-session/SKILL.md | 12 +- .agents/skills/update-fleet/SKILL.md | 27 ++ .gitignore | 13 + AGENTS.md | 16 +- FLEET.md | 2 +- interface/fleet_queue.lua | 4 +- orchestration/agent.example.conf | 81 ++++ orchestration/auto-merge.example.conf | 4 +- orchestration/publish.example.conf | 68 +++ orchestration/queue/POLICY.md | 37 +- orchestration/queue/README.md | 4 +- scripts/check.sh | 48 +- scripts/lib/fleet_status.py | 6 +- scripts/lib/forge.py | 2 +- scripts/lib/pane_harness.lua | 8 +- scripts/lib/queue.py | 567 +++++++++++++++++------ scripts/pane-selftest.sh | 6 +- scripts/queue-selftest.sh | 136 ++++-- scripts/queue.sh | 8 +- 22 files changed, 879 insertions(+), 244 deletions(-) create mode 100644 orchestration/agent.example.conf create mode 100644 orchestration/publish.example.conf diff --git a/.agents/skills/fleet-onboarding/SKILL.md b/.agents/skills/fleet-onboarding/SKILL.md index 4f97d3a..0f636a0 100644 --- a/.agents/skills/fleet-onboarding/SKILL.md +++ b/.agents/skills/fleet-onboarding/SKILL.md @@ -419,7 +419,25 @@ The one thing outside the repo that did change is the operator's own `layout.lua`, if they said yes in step 6 — with a `.bak-` beside it. Say that too. -**One thing is NOT set up, by design: where fleet may merge.** A fresh clone +**Two things are NOT set up, by design.** + +*How tasks publish, and which agent they run.* `orchestration/publish.conf` and +`agent.conf` are the operator's, gitignored, and the tracked examples beside +them name no tool, no vendor and no agent — so a fresh clone publishes by the +one shape that needs no setup (`pr`: a pull request from the task's branch) and +leaves `session create` thurbox's own default agent. Offer them, and say what +each buys: a default publish command so the lead never retypes `--publish`, an +`ATTESTATION_MARKER` if their pipeline attests, and `FUEL_PROVIDER` so `refuel` +knows whose quota window to gate on. Without that last one `refuel` derives it +from the tasks and reports `undetermined` when they disagree, which restarts +nothing. + +```bash +cp -n orchestration/publish.example.conf orchestration/publish.conf +cp -n orchestration/agent.example.conf orchestration/agent.conf +``` + +*Where fleet may merge.* A fresh clone has no `orchestration/auto-merge.conf` and the tracked example names no repository, so `queue.sh shepherd` reviews every pull request and merges none — saying so by name. Nobody inherits another operator's merge rights by cloning a diff --git a/.agents/skills/fleet-pane/SKILL.md b/.agents/skills/fleet-pane/SKILL.md index 3ce8dd0..ef0a4ad 100644 --- a/.agents/skills/fleet-pane/SKILL.md +++ b/.agents/skills/fleet-pane/SKILL.md @@ -42,7 +42,7 @@ in full; the pane is for noticing a task changed state without asking. **`interface/fleet_queue.lua`'s header owns what it draws and why** — the fuel rows above the counters, the `⇡` artifact row under a task (its declared -`publish.method` — `no-mistakes`, `pr` or `push` — what that turned out to be, +`publish.method` — `attested`, `pr` or `push` — what that turned out to be, and what fleet last saw), and what a narrow column drops first (`PUBLISH_WORD`, `PUBLISH_LADDER`). diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index e9d315c..1b8a712 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -511,23 +511,31 @@ LEAVE BEHIND, and `collect` goes and looks for that: | `--publish` | the worker produces | what collect asks | |---|---|---| -| `no-mistakes` | a PR through the pipeline | the forge: a PR from this task's branch, its body carrying a `no-mistakes` attestation for the commit that would merge | +| `attested` | a PR carrying an attestation | the forge: a PR from this task's branch, its body carrying an attestation for the commit that would merge | | `pr` | a PR by any means at all | the forge: a PR from this task's branch, open or merged | | `push` | a commit on the base branch | git: that commit is an ancestor of `origin/` | -`--how` is the other half and it is FREE TEXT — "run `/no-mistakes --yes`", "run -`/publish`", "use `make release`". It is rendered into the brief's Publish line -and **nothing ever parses it**, which is exactly what lets a task name a -publisher fleet has never heard of. Fleet knows the artifact's shape; your words -tell the worker how to make one. - -You rarely type either. `orchestration/queue/POLICY.md`'s YAML frontmatter holds -this operator's default (`no-mistakes`, `run /no-mistakes --yes`), and every -task takes it unless `add` says otherwise — because a `--publish` forgotten on -one task would downgrade that task's verification in silence. +**Those three words are SHAPES and none of them is a tool.** A pipeline, an +in-house script, `make release`, a slash command — every one of them ends in a +pull request or a commit on the base branch. `--how` is the other half and it is +FREE TEXT — "run `/publish`", "use `make release`". It is rendered into the +brief's Publish line and **nothing ever parses it**, which is what lets a task +name a publisher fleet has never heard of. Fleet knows the artifact's shape; +your words tell the worker how to make one. + +`no-mistakes` was a fourth method until it was recognised as one operator's tool +name in tracked code. It still means `attested` wherever a method is read, so old +records load; new ones say `attested`. + +You rarely type either. `orchestration/publish.conf` holds the operator's +default — gitignored, with a tracked `publish.example.conf` that ships `pr` and +names no tool — and every task takes it unless `add` says otherwise, because a +`--publish` forgotten on one task would downgrade that task's verification in +silence. **What an attestation LOOKS like is theirs too**: `ATTESTATION_MARKER` +in that file, so fleet reads their pipeline's format rather than dictating one. ```text - topic/02-document-the-states shipped https://…/pull/1001 [publish verified: no-mistakes] + topic/02-document-the-states shipped https://…/pull/1001 [publish verified: attested] topic/03-render-detected-agent: NOT CLOSED — nothing proves this task published ``` @@ -580,7 +588,7 @@ name, and each open pull request gets exactly one of these: | `mergeable: CONFLICTING` | a fixer is dispatched to rebase | | a check failed | a fixer is dispatched to fix it | | `reviewDecision: CHANGES_REQUESTED` | a fixer is dispatched to address it | -| a `no-mistakes` task's PR with no attestation for this head commit | a fixer is dispatched to re-run `/no-mistakes --yes` | +| an `attested` task's PR with no attestation for this head commit | a fixer is dispatched to publish it again, naming that task's own command | | attested, checks green, `MERGEABLE`, ours | **squash-merged**, in the allowlisted repos only | | checks green, `MERGEABLE`, ours, and nothing attested it | recorded `green` and reported `ready to merge — not attested; yours`, **never merged by fleet** | | anything it could not read | reported, and otherwise left alone | @@ -640,20 +648,20 @@ environment REPLACES the file rather than adding to it. - **Whoever opened it can push there.** Anyone with read access can open a pull request between two branches that already exist, and the body would then be theirs to write. -- **A `no-mistakes` attestation naming its CURRENT head commit.** Not the five - `## ` headings — those are text anyone can paste, so counting them let a +- **An attestation naming its CURRENT head commit.** Not the `## ` headings a + pipeline prints — those are text anyone can paste, so counting them let a body authorise its own merge. The attestation is an HTML comment carrying - the commit the pipeline ran on and a status per step; one from an earlier - push is refused, because a verdict is about the code it saw. A PR whose task - declared another method carries none, is recorded `green` rather than - `ready`, and is handed back: the checks it passed are whatever checks that - repo happens to have, and nothing says review, tests and lint ran on the - head that would land. + the commit the pipeline ran on and a status per step, and its marker is the + operator's (`ATTESTATION_MARKER`); one from an earlier push is refused, + because a verdict is about the code it saw. A PR whose task declared another + method carries none, is recorded `green` rather than `ready`, and is handed + back: the checks it passed are whatever checks that repo happens to have, and + nothing says review, tests and lint ran on the head that would land. - **Every check concluded and passed, and GitHub says `MERGEABLE`.** A PR failing any of them is not merged, and one that is not ours is not given an agent either. Only the attestation gate is method-aware, and only in the one -direction: a task that was declared `no-mistakes` and carries no attestation +direction: a task that was declared `attested` and carries none gets the fixer it always got, and a task that was never asked for one gets neither the fixer nor the merge. Everywhere outside the allowlist it reports `ready to merge` and stops, which is what every repo did before that list diff --git a/.agents/skills/thurbox-session/SKILL.md b/.agents/skills/thurbox-session/SKILL.md index 198197f..4808f45 100644 --- a/.agents/skills/thurbox-session/SKILL.md +++ b/.agents/skills/thurbox-session/SKILL.md @@ -550,14 +550,22 @@ readings tell it apart from a genuinely slow turn: | where | what it says | |---|---| -| `session capture --lines 200 --json` | the agent's own banner, as rendered: `You've hit your session limit · resets 11:30pm (Europe/Paris)` | -| `~/.claude/projects/**/.jsonl` | the same event recorded, and more precisely: `"error": "rate_limit"`, `"apiErrorStatus": 429`, and the `quotaLimits` window that rejected the turn — `rateLimitType` and `resetsAt` | +| `session capture --lines 200 --json` | the agent's own banner, as rendered — `claude`'s reads `You've hit your session limit · resets 11:30pm (Europe/Paris)` | +| that agent's transcript (`claude`: `~/.claude/projects/**/.jsonl`) | the same event recorded, and more precisely: `"error": "rate_limit"`, `"apiErrorStatus": 429`, and the `quotaLimits` window that rejected the turn — `rateLimitType` and `resetsAt` | `agent_session_id` from `session get --json` is what names that transcript, and the record has to be the LAST conversational entry: what follows a rejection in a wedged session is bookkeeping, and a session that came back has an ordinary turn after it. +**Neither reading is hardcoded to one agent.** `scripts/lib/queue.py` keeps one +entry per agent fleet has actually WATCHED hit a limit — `claude` today — the +same way §1b's table keeps one per trust dialog. An agent with no entry is +reported `undetermined`, which restarts nothing, and `LIMIT_BANNER` / +`TRANSCRIPT_DIR` in `orchestration/agent.conf` teach it one without a code +change. Nothing is matched that nobody observed: a guessed pattern restarts a +live worker mid-turn. + **Ask the account before you restart anything.** The limit is not the session's, it is the operator's subscription window, shared by every session on this machine — `quota-axi` reads it. While it is spent, a `session restart` resumes diff --git a/.agents/skills/update-fleet/SKILL.md b/.agents/skills/update-fleet/SKILL.md index 88b0491..6c6f38e 100644 --- a/.agents/skills/update-fleet/SKILL.md +++ b/.agents/skills/update-fleet/SKILL.md @@ -98,6 +98,7 @@ do. Otherwise map the list: | `interface/fleet_queue.lua` | §4 | the installed plugin is a stale copy of that file | | `registry/owners.txt` | §5 | the generated map covers the wrong owners | | `orchestration/auto-merge.example.conf`, or `scripts/lib/queue.py`'s allowlist | §5b | `shepherd` may now merge in a different set of repos, or in none | +| `orchestration/publish.example.conf`, `agent.example.conf`, or POLICY.md's frontmatter | §5c | tasks may publish a different way, or `refuel` may gate on a different account | | `scripts/reconcile.sh` | §6 | the running reconciler loop is executing old code | | `FLEET.md`, `AGENTS.md`, `CLAUDE.md`, `.agents/skills`, `.claude/skills`, `.claude/settings.json` — or a `restart-lead:` line | §8 | the lead is holding instructions it froze at launch | @@ -125,6 +126,32 @@ $EDITOR orchestration/auto-merge.conf are host-qualified; the example's header owns the format and the gates. Read every pass, so no reinstall and no restart, and both files are gitignored. +### §5c — the publish default and the agent, which moved out of tracked files + +Two settings left tracked files for the same reason the allowlist did: a tool +name or a vendor name in a file this public repo ships is one operator's setup +handed to every clone. + +- **The publish default left `orchestration/queue/POLICY.md`'s frontmatter** for + `orchestration/publish.conf`. A block still in POLICY.md is honoured and + warns once on stderr, so nothing breaks while you move it — but that file is + tracked, so leaving it there ships your pipeline to everyone. +- **The third method is `attested`, not `no-mistakes`.** The old word still + reads as that shape, so existing records load; what an attestation looks like + is now `ATTESTATION_MARKER` in the same file. +- **`refuel` no longer assumes `claude`.** It derives the provider from the + agent in hand, or takes `FUEL_PROVIDER` from `orchestration/agent.conf`, and + reports `undetermined` — restarting nothing — rather than gating on a window + it guessed. + +```bash +cp -n orchestration/publish.example.conf orchestration/publish.conf +cp -n orchestration/agent.example.conf orchestration/agent.conf +./scripts/queue.sh add --help | grep -A2 publish # the three shapes +``` + +Read on every pass, so no reinstall and no restart. + `scripts/lib/queue.py` and `scripts/lib/notify_lead.py` are absent from this table: the reconciler's loop sources neither — every pass shells out to `./scripts/queue.sh` and `python3 scripts/lib/notify_lead.py` as fresh diff --git a/.gitignore b/.gitignore index a62d756..9680590 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,19 @@ # clone merges nothing until this file says otherwise. /orchestration/auto-merge.conf +# WORKING STATE. How your tasks publish and what fleet looks for afterwards: +# your default method, the sentence naming YOUR publishing tool, and the +# attestation marker your pipeline emits. orchestration/publish.example.conf is +# the tracked copy; it models artifact SHAPES and names no tool, because a tool +# name in tracked code is one operator's pipeline in everybody's machinery. +/orchestration/publish.conf + +# WORKING STATE. Which agent your workers run, and whose quota window `refuel` +# gates on. orchestration/agent.example.conf is the tracked copy and leaves both +# empty — thurbox's own agents.toml already holds the first, and fleet reads the +# second off the tasks rather than guessing. +/orchestration/agent.conf + # MACHINE. Whether this terminal's font draws a two-cell emoji at two cells is # true of one machine, so the glyph setting is one machine's answer. # orchestration/session-glyphs.example.conf is the tracked copy that carries the diff --git a/AGENTS.md b/AGENTS.md index 2da60c9..a49e6e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,18 @@ names every path and the reason for each. as opposed to where its work goes. `./scripts/session-flags.sh ` renders one into `session create` flags. One file, one layer — edit it directly. The file's own header owns the rules that keep a profile safe. +- `orchestration/publish.example.conf` and `agent.example.conf` — the two + settings that keep fleet agnostic about YOUR tools. The first holds the + default publish method and the free-text command that produces it, plus the + attestation marker your pipeline emits; `scripts/lib/queue.py` models three + ARTIFACT SHAPES (`attested`, `pr`, `push`) and no tool names, so a publisher + fleet has never heard of still works. The second holds which agent your + workers run, which provider `refuel` gates on, and how that agent says it hit + a limit. **Both tracked copies name nothing** — `./scripts/check.sh automerge` + fails one that does — so a fresh clone inherits no operator's pipeline, + vendor or agent. Copy either to a gitignored `*.conf` beside it to set + anything. `no-mistakes` is still accepted wherever a method is read and means + `attested`. - `orchestration/session-glyphs.example.conf` — the mark fleet's sessions wear in the thurbox session list: `📡` on the lead, `🚀` on every worker, under ONE `GLYPHS=on|off` setting whose `off` is the one-cell `⌖` and no worker prefix. @@ -217,12 +229,12 @@ The loop, driven by `./scripts/queue.sh`: host-qualified, and one that names no forge refused rather than matched — and only for one whose head branch is in that repo, opened by someone who can - push there, carrying a `no-mistakes` attestation for its **current** head. + push there, carrying an attestation for its **current** head. Squash is the only method fleet merges by, and a forge or a project that forbids squash — a GitLab project can — is a refusal fleet RECORDS rather than a merge by some other method. That attestation gate is the one thing the declared publish - method moves: a task that was declared `no-mistakes` and carries none gets a + method moves: a task that was declared `attested` and carries none gets a fixer, one that was never asked for one is recorded `green` and handed back unmerged. Every pass writes what it saw onto the task's `publish` block. `--dry-run` first; the fleet-queue skill owns the rest. diff --git a/FLEET.md b/FLEET.md index e928f57..8eb040f 100644 --- a/FLEET.md +++ b/FLEET.md @@ -90,7 +90,7 @@ YAML by hand. Nothing to push — the map is gitignored. task, and `collect` names `shepherd` whenever it closed one that left one open. It asks the forge for every open change request on the queue's repos, not just recorded artifacts, dispatches a fixer for one that conflicts, fails - a check, was reviewed with changes requested, or was declared `no-mistakes` + a check, was reviewed with changes requested, or was declared `attested` and carries no attestation for its current head, and squash-merges one that clears every gate in the repos `orchestration/auto-merge.conf` names — your own file, gitignored, empty until you write it, so fleet merges nowhere diff --git a/interface/fleet_queue.lua b/interface/fleet_queue.lua index 9a29726..c8e9250 100644 --- a/interface/fleet_queue.lua +++ b/interface/fleet_queue.lua @@ -28,7 +28,7 @@ -- ● Declare the publish method on the ta… -- 01-declare-publish-method landed 24m -- brief · 14 events · shipped --- ⇡ no-mistakes · #43 · merged 20m +-- ⇡ attested · #43 · merged 20m -- ◐ Record the publish state the shepher… -- 02-shepherd-records-… dispatched 15m -- ↳ ✓ 01-declare-publish-met… consumes @@ -980,7 +980,7 @@ local PUBLISH_GLYPH = "⇡" --- at 44 and far over it at 30 — the two widths `scripts/pane-selftest.sh` --- renders, and 30 is what this pane routinely gets. So the one segment that --- answers "what do I do about this" was drawn at no width an operator has, ---- while `no-mistakes` — the same word under every task in a fleet with one +--- while `attested` — the same word under every task in a fleet with one --- publish method — was drawn at both. The note is the row's ACTION and the --- method is its provenance; provenance is what a column this narrow can afford --- to lose. diff --git a/orchestration/agent.example.conf b/orchestration/agent.example.conf new file mode 100644 index 0000000..eb698d8 --- /dev/null +++ b/orchestration/agent.example.conf @@ -0,0 +1,81 @@ +# WHICH AGENT THE FLEET RUNS, and whose quota window gates a restart — the +# tracked copy, and it names none. +# +# COPY IT TO `agent.conf` BESIDE THIS FILE TO SET EITHER. That copy is +# gitignored, like `publish.conf` and `auto-merge.conf` beside it: which agent +# you dispatch is a property of you, not of this repo. + +# --- THE AGENT ---------------------------------------------------------------- +# +# Passed as `--agent` on every spawn fleet makes, unless the task names its own +# with `queue.sh add --agent`. +# +# EMPTY IS THE DEFAULT AND MEANS "thurbox's". `agents.toml` already records the +# operator's choice and `session create` already honours it, so a name here is +# a second copy of that answer — set one only to make the fleet's workers +# differ from the rest of your sessions. +AGENT= + +# --- THE ACCOUNT `refuel` GATES ON -------------------------------------------- +# +# `queue.sh refuel` restarts nothing while the account's quota window is spent: +# a resumed worker would hit the same wall within seconds and burn the reset +# everyone is waiting for. The window belongs to a PROVIDER, and reading the +# wrong one is worse than reading none — a spent window on a provider the fleet +# never dispatches through must not strand a worker at its limit. +# +# EMPTY MEANS FLEET DOES NOT GUESS. `refuel` then reads the provider off the +# tasks it is about to act on: one agent across them all and it uses that, no +# agreement and it reports `undetermined`, which restarts nothing and says why. +# Set a name to gate on one provider regardless. +# +# `./scripts/fleet-status.sh --fuel` is unaffected either way: the screen reads +# every authenticated provider, so it can legitimately show a picture this +# setting disagrees with. +FUEL_PROVIDER= + +# --- TEACHING FLEET AN AGENT IT HAS NOT WATCHED ------------------------------- +# +# `queue.sh refuel` restarts a worker only on a CONJUNCTION: a `working` state +# gone stale AND the agent itself saying it hit a limit. The second half is +# agent-specific by nature — a banner on the pane, or a rate-limit record in a +# transcript — so `scripts/lib/queue.py` keeps one entry per agent fleet has +# actually WATCHED do it, the same way `scripts/session-trust.sh` keeps one +# entry per agent's trust dialog. +# +# AN AGENT WITH NO ENTRY IS NOT GUESSED AT: refuel reports it `undetermined`, +# which restarts nothing, and names these two settings. Inventing a pattern for +# an agent nobody has watched hit its limit is how a live worker gets restarted +# mid-turn. +# +# `LIMIT_BANNER` is a case-insensitive regex matched against the last lines of +# the pane. Match the SENTENCE, not the whole line, so a window name and a +# reset time can vary: +# +# LIMIT_BANNER=you'?ve hit your \w+ limit +# +# `TRANSCRIPT_DIR` is a directory of per-session records named +# `.jsonl`, one JSON object per line, where the rejected turn +# carries `error: "rate_limit"`. It outranks the banner where it can be read, +# being the same event recorded rather than rendered. Leave it empty for an +# agent that keeps no such file and the banner answers alone. +# +# Both apply to whichever agent your workers run. Fleet ships the `claude` +# entry because that is the one it has observed; if you have watched another +# agent hit its limit, these two lines are the whole of teaching it. +LIMIT_BANNER= +TRANSCRIPT_DIR= + +# --- WHEN THE AGENT AND ITS PROVIDER ARE NAMED DIFFERENTLY -------------------- +# +# `refuel` reads the quota of the PROVIDER behind the agent in hand. quota-axi +# names most providers after that agent, so the default is IDENTITY and this is +# empty. Set pairs where they come apart: +# +# AGENT_PROVIDERS=codex=openai,my-wrapper=anthropic +# +# Fleet carries no list of providers. `quota-axi auth` is asked which ones this +# machine holds a credential for, so the set grows with the tool — and a +# provider it does not name is reported by name rather than read past, because +# gating on the WRONG window is worse than gating on none. +AGENT_PROVIDERS= diff --git a/orchestration/auto-merge.example.conf b/orchestration/auto-merge.example.conf index 1acea25..fd22a69 100644 --- a/orchestration/auto-merge.example.conf +++ b/orchestration/auto-merge.example.conf @@ -44,7 +44,7 @@ # # - its head branch lives IN that repository — a fork is reported, never # merged, because a stranger cannot create a branch inside your repo; -# - its body carries a `no-mistakes` attestation naming its CURRENT head +# - its body carries an attestation naming its CURRENT head # commit, so a stale attestation from an earlier push cannot authorise the # push that replaced it; # - every check has CONCLUDED and passed; @@ -52,7 +52,7 @@ # - whoever opened it can push there. # # So the test for adding a repository is whether those gates MEAN anything in -# it: a repo with its own `.no-mistakes.yaml` and its own CI has a pipeline +# it: a repo with its own publish pipeline and its own CI has a pipeline # behind the attestation gate and checks behind the checks gate. A repo with # neither is one where every gate but the last is vacuous — that is the test, # not whether your fleet happens to have work there. diff --git a/orchestration/publish.example.conf b/orchestration/publish.example.conf new file mode 100644 index 0000000..1827f42 --- /dev/null +++ b/orchestration/publish.example.conf @@ -0,0 +1,68 @@ +# How a task PUBLISHES, and what fleet goes and looks for afterwards — the +# tracked copy, which names no tool. +# +# COPY IT TO `publish.conf` BESIDE THIS FILE TO CHANGE ANYTHING. That copy is +# gitignored, the same way `auto-merge.conf` and `registry/owners.txt` are: +# which publishing tool you run is a property of you, not of this repo. The +# reader falls back to THIS file, so the defaults below need no copy at all. +# +# WHAT FLEET KNOWS AND WHAT IT DOES NOT. `scripts/lib/queue.py` models three +# ARTIFACT SHAPES — what a finished task must leave behind — and nothing else: +# +# pr a pull request from this task's branch, by any means at all +# attested that, plus a body carrying an ATTESTATION for the commit that +# would merge — so a verdict from an earlier push cannot authorise +# the push that replaced it +# push a commit that is an ancestor of origin/ +# +# A shape is not a tool. Your pipeline, an in-house script, `make release`, a +# slash command — all of them end in one of those three, which is how fleet +# stays agnostic about a tool it has never heard of. The tool rides on the +# record as `publish.how`: free text, rendered into the brief, NEVER parsed. +# +# `no-mistakes` was a fourth method here until it was recognised as a tool name +# rather than a shape. It is still accepted wherever a method is read and means +# `attested`; new records should say `attested`. + +# --- THE DEFAULT EVERY TASK TAKES --------------------------------------------- +# +# `queue.sh add` uses these unless `--publish` / `--how` says otherwise, so a +# lead never retypes them — a forgotten flag would silently downgrade that +# task's verification, which is the failure this whole subsystem prevents. +# +# `METHOD` is one of the three words above. `HOW` is free text: the sentence +# the brief hands the worker, naming your tool in your words. +# +# The shipped default is `pr`, because it needs no setup anywhere: a pull +# request from the task's branch is the whole proof. `attested` needs the +# marker below to mean anything. +METHOD=pr +HOW= + +# --- WHAT AN ATTESTATION LOOKS LIKE ------------------------------------------- +# +# Only read when a task's method is `attested`. Fleet looks for an HTML comment +# in the change request body: +# +# +# +# `head_sha` must equal the pull request's CURRENT head, and `steps` must be a +# non-empty list. An HTML comment rather than prose because the five headings a +# pipeline prints are text anyone can paste, and a check that counted them +# would let a body authorise its own merge. +# +# Set `ATTESTATION_MARKER` to whatever your pipeline already emits — fleet +# reads your format rather than asking you to emit fleet's. +ATTESTATION_MARKER=fleet-attestation + +# The subject prefix of commits YOUR PIPELINE makes on its own, if it makes +# any. Purely to improve one message: a pipeline that writes the attestation +# and then pushes CI fixes on top leaves a body attesting an ancestor of the +# head. Refusing that is right either way, but "somebody pushed over the +# pipeline" and "the pipeline did this to itself" have different remedies, and +# only the second is answered by running the tool again. +# +# Matched case-insensitively at the start of a commit subject, after an +# optional `chore: `. Empty means fleet never claims to know, which is the +# honest answer when it does not. +PIPELINE_COMMIT_PREFIX= diff --git a/orchestration/queue/POLICY.md b/orchestration/queue/POLICY.md index b82bde0..7c175bb 100644 --- a/orchestration/queue/POLICY.md +++ b/orchestration/queue/POLICY.md @@ -1,16 +1,3 @@ ---- -# The publish method every task under this policy gets unless `queue.sh add` -# says otherwise, and the words the brief uses to name the tool. `method` is -# one of `no-mistakes`, `pr` or `push` — what a task must PRODUCE — and `how` -# is free text that fleet renders into the brief and never parses. -# -# Delete this block and tasks default to `pr`, which needs no setup: a pull -# request from the task's branch is the whole proof. -publish: - method: no-mistakes - how: run `/no-mistakes --yes` ---- - # Standing policy for fleet workers This is the policy every task in every repo runs under. `queue.sh add`'s brief @@ -59,7 +46,7 @@ that tool. Do not switch methods. request — a pull request on GitHub, a merge request on GitLab — git for a commit on the base branch. A task whose artifact is not there, or is not from your branch, **is not closed** — the lead sees it at collect time and sends you back. -So verify your own artifact before you report done. For a `no-mistakes` task +So verify your own artifact before you report done. For an `attested` task that is one command, in the CLI your forge has. On GitHub: @@ -89,15 +76,19 @@ code the pipeline saw, so one naming any other commit proves nothing about what would merge, and `collect` holds your task open exactly as it does for a body with no attestation at all. -They come apart on their own. The pipeline writes the attestation while it -opens the change request and can then push its own `no-mistakes: apply CI fixes` -commit on top, which leaves the head one commit ahead of what was attested — -this is what happened to #38, #40 and #48. **Run `/no-mistakes --yes` again** -and it re-attests the new head; then run the command above once more before -you write `result.md`. Never hand-edit the body to name the head: an -attestation you typed attests nothing. - -The default for every task here is the frontmatter at the top of this file. +They come apart on their own. A pipeline writes the attestation while it opens +the change request and can then push its own CI-fix commit on top, which leaves +the head one commit ahead of what was attested — this is what happened to pull +requests #38, #40 and #48. **Run your Publish line's command again** and it +re-attests the new head; then run the command above once more before you write +`result.md`. +Never hand-edit the body to name the head: an attestation you typed attests +nothing. + +Your brief's Publish line is the authority on the method and the tool. The +default behind it is the operator's, in `orchestration/publish.conf`, and this +file no longer carries one — it is tracked, and a tool name here would be one +operator's pipeline shipped to every clone. ## Do not merge diff --git a/orchestration/queue/README.md b/orchestration/queue/README.md index 157e051..c43e62b 100644 --- a/orchestration/queue/README.md +++ b/orchestration/queue/README.md @@ -89,9 +89,9 @@ task finished — only the worker's own `result.md`, read by `queue.sh collect`, closes anything. **`collect` checks the artifact it is handed.** Each task declares a publish -METHOD — `no-mistakes`, `pr` or `push` — naming what it must produce, and +METHOD — `attested`, `pr` or `push` — naming what it must produce, and `collect` goes and looks: the forge for a change request from that task's own -branch (carrying a `no-mistakes` attestation for its head, for that method), +branch (carrying an attestation for its head, for that method), git for a commit that reached the base branch. A task whose artifact is not there is reported and left OPEN, because "use the pipeline" is an instruction about a method and a method leaves no trace anyone can read. A check that could diff --git a/scripts/check.sh b/scripts/check.sh index 872c168..77962c8 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -617,8 +617,54 @@ print("entries=" + (" ".join(sorted(q.auto_merge_repos(q.checkout_root()))) or " fi fi + # THE SAME RULE FOR EVERY TRACKED SETTING. An owner, a repository, a + # publishing tool or an agent written into a file this repo SHIPS is one + # operator's setup handed to every clone. The example files carry defaults; + # none of them may carry a name. + local pub="orchestration/publish.example.conf" + local ag="orchestration/agent.example.conf" + local f val + for f in "$pub" "$ag"; do + if [ ! -f "$f" ]; then + fail "automerge: $f is missing; a fresh clone would document no format" + miss=1 + fi + done + # A tool name reaches a worker only through HOW, which nothing parses. The + # tracked copy must leave it empty, and the method must be the one shape + # that needs no tool at all. + # EVERY occurrence, not the first: a leak appended below a correct line is + # exactly the edit a first-match read would wave through. + if [ -f "$pub" ]; then + val="$(sed -n 's/^METHOD=//p' "$pub" | tr -d '[:space:]')" + [ "$val" = pr ] || + { fail "automerge: $pub ships METHOD=$val; the tracked default must be pr"; miss=1; } + val="$(sed -n 's/^HOW=//p' "$pub" | tr -d '[:space:]')" + [ -z "$val" ] || + { fail "automerge: $pub names a tool in HOW ($val); that is the operator's"; miss=1; } + fi + # An agent or a provider here would gate every operator's fleet on one + # operator's vendor. Empty means "thurbox's own" and "derive it". + if [ -f "$ag" ]; then + for key in AGENT FUEL_PROVIDER LIMIT_BANNER TRANSCRIPT_DIR AGENT_PROVIDERS; do + val="$(sed -n "s/^$key=//p" "$ag" | tr -d '[:space:]')" + [ -z "$val" ] || + { fail "automerge: $ag ships $key=$val; that is the operator's"; miss=1; } + done + fi + # And no method may be a tool name again: the three are artifact shapes. + local shapes + shapes="$(python3 -c ' +import sys +sys.path.insert(0, "scripts/lib") +import queue as q +print(" ".join(sorted(q.PUBLISH_METHODS))) +' 2>&1)" + [ "$shapes" = "attested pr push" ] || + { fail "automerge: the publish methods are '''$shapes''', and must be artifact shapes"; miss=1; } + [ "$miss" -eq 0 ] && - ok "automerge: $example names no repository; a fresh clone merges nowhere" + ok "automerge: no tracked setting names a repository, a tool or an agent" } # THE SETUP NOBODY RE-RUNS. Onboarding's scripts — preflight, discover-owners, diff --git a/scripts/lib/fleet_status.py b/scripts/lib/fleet_status.py index 623ac5f..2aadbaf 100644 --- a/scripts/lib/fleet_status.py +++ b/scripts/lib/fleet_status.py @@ -366,7 +366,11 @@ def probe_checkout() -> dict: # --- fuel -------------------------------------------------------------------- -FUEL_PROVIDER = "claude" +# The provider the SCREEN leads with, and the fallback when quota-axi names +# none. Not a gate: `probe_fuel_all()` reads every authenticated provider, and +# `scripts/lib/queue.py`'s `refuel` asks for its provider by name rather than +# inheriting this. A literal here only decides which row sorts first. +FUEL_PROVIDER = os.environ.get("FLEET_FUEL_PROVIDER", "").strip() or "claude" # The floor the lead does not dispatch past, in percent remaining. FLEET.md's # `## Fuel` section owns the rule; this is the same number so the screen can # print it beside the reading. diff --git a/scripts/lib/forge.py b/scripts/lib/forge.py index 8f6d681..5272848 100644 --- a/scripts/lib/forge.py +++ b/scripts/lib/forge.py @@ -22,7 +22,7 @@ repo_from_remote which repository is this checkout's `origin` get one change request in full: what `collect` needs to check a publish claim — the body carrying the - no-mistakes attestation, the head commit it must + publish attestation, the head commit it must name, the branch it is open from, and the commits that grew the head since state open / merged / closed, for the landing check. A diff --git a/scripts/lib/pane_harness.lua b/scripts/lib/pane_harness.lua index 566d647..b20c44d 100644 --- a/scripts/lib/pane_harness.lua +++ b/scripts/lib/pane_harness.lua @@ -240,7 +240,7 @@ local TOPICS = { id = "01-declare-publish-method", state = "landed", title = "Declare the publish method on the task", outcome = "shipped", artifact = "https://github.com/Thurbeen/fleet/pull/43", brief = 1, events = 14, result = 1, moved = ago(24), - publish = { "no-mistakes", "merged", ago(20) }, + publish = { "attested", "merged", ago(20) }, }, { id = "02-shepherd-records-publish", state = "dispatched", @@ -275,7 +275,7 @@ local TOPICS = { id = "01-remove-the-monitor", state = "done", title = "Remove the web monitor and everything that starts it", outcome = "shipped", artifact = "https://github.com/Thurbeen/fleet/pull/47", brief = 1, events = 9, result = 1, moved = ago(48), - publish = { "no-mistakes", "green", ago(9) }, + publish = { "attested", "green", ago(9) }, }, { -- Held by a CONDITION rather than by a task: the second form of @@ -296,7 +296,7 @@ local TOPICS = { -- forges fleet ships an adapter for rather than only one. artifact = "https://gitlab.example.com/acme/group/widgets/-/merge_requests/52", brief = 1, events = 2, result = 1, moved = ago(3), - publish = { "no-mistakes", "open", ago(3) }, + publish = { "attested", "open", ago(3) }, }, }, }, @@ -308,7 +308,7 @@ local TOPICS = { id = "01-probe-the-host", state = "landed", title = "Probe the host before spawning anything", outcome = "shipped", artifact = "https://github.com/Thurbeen/fleet/pull/31", brief = 1, events = 11, result = 1, moved = ago(2880), - publish = { "no-mistakes", "merged", ago(2870) }, + publish = { "attested", "merged", ago(2870) }, }, }, }, diff --git a/scripts/lib/queue.py b/scripts/lib/queue.py index 9ad638d..99da0b6 100644 --- a/scripts/lib/queue.py +++ b/scripts/lib/queue.py @@ -357,8 +357,8 @@ def brief_shortfall(path: str) -> str: # the worker's instructions, `publish_verdict` goes and looks for `artifact`, # and `report_unverified` and `cmd_show` quote `proof` when it does not hold. # -# WHY A CHECK AT ALL. "Open the pull request by running `/no-mistakes --yes`" -# is an instruction about a METHOD, and a method leaves no trace: a worker that +# WHY A CHECK AT ALL. "Open the pull request by running the pipeline" is an +# instruction about a METHOD, and a method leaves no trace: a worker that # produced a good-looking PR with a bare `gh pr create` satisfied every visible # requirement. Two tasks were once collected `shipped` that way and nothing # noticed until the operator read the bodies himself. Naming the ARTIFACT is @@ -371,19 +371,41 @@ def brief_shortfall(path: str) -> str: # the record as `publish.how`: free text, rendered into the brief, never # parsed. That is the whole of fleet's agnosticism, and it lasts exactly as # long as nothing branches on it. +# +# IT DID NOT LAST. The third shape was called `no-mistakes` — one operator's +# pipeline — for as long as this table has existed, so the code branched on a +# tool name, this repo shipped that tool as its tracked default, and every +# clone inherited a pipeline most of them do not run. It is `attested` now: a +# pull request whose body carries an attestation for the commit that would +# merge. WHAT an attestation looks like is `ATTESTATION_MARKER` in +# `orchestration/publish.conf`, so fleet reads the operator's format rather +# than asking them to emit fleet's. PUBLISH_DEFAULT = "pr" +# Accepted wherever a method is read, so a record written before the rename +# still loads and `--publish no-mistakes` still works. New records say +# `attested`; nothing writes the old word. +PUBLISH_ALIASES = {"no-mistakes": "attested"} + + +def publish_method(word: str | None) -> str | None: + """One method word, with the retired spelling folded into its shape.""" + if not word: + return word + return PUBLISH_ALIASES.get(word, word) + + PUBLISH_METHODS = { - "no-mistakes": { + "attested": { "brief": ( - "open a pull request through the `no-mistakes` pipeline, which is " - "the review, the tests, the lint, the push and the pull request in " - "one pass" + "open a pull request from this task's branch whose body carries an " + "attestation for the commit that would merge — the Publish line " + "below names the command that produces one" ), "artifact": "that pull request's URL", "proof": ( - "the pull request is from this task's branch and its body carries a " - "`no-mistakes` attestation for the commit that would merge" + "the pull request is from this task's branch and its body carries " + "an attestation for the commit that would merge" ), }, "pr": { @@ -498,9 +520,30 @@ def glyph_conf(root: str | None = None) -> dict[str, str]: session-glyphs.conf says. """ root = root or os.environ.get("FLEET_GLYPH_ROOT") or checkout_root() - path = os.path.join(root, GLYPH_CONF) - if not os.path.exists(path): - path = os.path.join(root, GLYPH_CONF_DEFAULTS) + return read_kv_conf(conf_path(GLYPH_CONF, GLYPH_CONF_DEFAULTS, root)) + + +# --- the operator's settings files, which are all read the same way ---------- +# +# One parse for every `orchestration/*.conf`: the operator's copy when it is +# there, the tracked `*.example.conf` beside it when it is not. Read as DATA — +# `KEY=value`, no quoting, no continuation — and never executed. A setting that +# can run is a different kind of file. +# +# The tracked copy is where the DEFAULT lives, so every one of these ships a +# working answer and a fresh clone needs no configuration at all. What none of +# them ships is a name: an owner, a repository, a tool or an agent written into +# a tracked file is one operator's setup published in everybody's machinery. + + +def conf_path(name: str, defaults: str, root: str) -> str: + """The operator's copy of one conf, or the tracked example beside it.""" + path = os.path.join(root, name) + return path if os.path.exists(path) else os.path.join(root, defaults) + + +def read_kv_conf(path: str) -> dict[str, str]: + """`KEY=value` lines, as data. An unreadable file is no settings at all.""" conf: dict[str, str] = {} try: with open(path) as fh: @@ -515,6 +558,34 @@ def glyph_conf(root: str | None = None) -> dict[str, str]: return conf +PUBLISH_CONF = "orchestration/publish.conf" +PUBLISH_CONF_DEFAULTS = "orchestration/publish.example.conf" +AGENT_CONF = "orchestration/agent.conf" +AGENT_CONF_DEFAULTS = "orchestration/agent.example.conf" + + +def publish_conf(root: str | None = None) -> dict[str, str]: + """The publish settings in force. `FLEET_PUBLISH_ROOT` relocates them.""" + root = root or os.environ.get("FLEET_PUBLISH_ROOT") or checkout_root() + return read_kv_conf(conf_path(PUBLISH_CONF, PUBLISH_CONF_DEFAULTS, root)) + + +def agent_conf(root: str | None = None) -> dict[str, str]: + """The agent settings in force. `FLEET_AGENT_ROOT` relocates them.""" + root = root or os.environ.get("FLEET_AGENT_ROOT") or checkout_root() + return read_kv_conf(conf_path(AGENT_CONF, AGENT_CONF_DEFAULTS, root)) + + +def configured_agent() -> str | None: + """The agent every spawn names, or None to leave thurbox its own default. + + Empty is the shipped answer and is not a gap: `agents.toml` already records + which agent the operator runs and `session create` already honours it, so a + name here would be a second copy of that answer. + """ + return agent_conf().get("AGENT", "").strip() or None + + def worker_glyph(root: str | None = None) -> str: """The mark every worker fleet spawns wears, or "" when glyphs are off.""" conf = glyph_conf(root) @@ -604,57 +675,86 @@ def policy_path() -> str: return os.path.join(checkout_root(), POLICY_FILE) -def policy_publish_default() -> tuple[str, str | None]: - """The operator's own publish default, from POLICY.md's YAML frontmatter. - - --- - publish: - method: no-mistakes - how: run `/no-mistakes --yes` - --- - - Here, and deliberately not in OPERATOR.md, whose own example file says in - bold that it is prose and that nothing parses it. POLICY.md is fleet's - standing policy, it is tracked, it is already the file that says how a - worker publishes, and every brief already points at it — so a change to - this default is reviewable in a diff rather than a surprise in a record. - - It exists because the alternative is the lead retyping `--publish` on every - task, and a forgotten flag would silently downgrade that task's - verification, which is the failure this whole subsystem exists to prevent. - - No frontmatter is the SHIPPED state and answers `pr` with no `how`: a fresh - clone needs no configuration at all. A word that is not a method is refused - rather than ignored, for the same reason — ignoring it would downgrade - quietly. +_POLICY_PUBLISH_WARNED = False + + +def policy_publish_block() -> dict | None: + """POLICY.md's retired `publish:` frontmatter, or None when it has none. + + Tracked file, so a block here is one operator's tool in everybody's copy — + `publish.conf` is where it belongs. Honoured while it exists because + dropping it would downgrade a live fleet's verification in silence, and + said out loud once per process so the move is noticed. """ + global _POLICY_PUBLISH_WARNED try: with open(policy_path()) as fh: text = fh.read() except OSError: - return PUBLISH_DEFAULT, None + return None + if not text.startswith("---"): + return None + parts = text.split("---", 2) + if len(parts) != 3: + return None + try: + doc = yaml.safe_load(parts[1]) + except yaml.YAMLError as exc: + raise QueueError(f"{policy_path()}: its frontmatter is not YAML: {exc}") + if not isinstance(doc, dict) or "publish" not in doc: + return None + pub = doc["publish"] + if not isinstance(pub, dict): + raise QueueError( + f"{policy_path()}: publish is {pub!r}, and must be a mapping with " + "method/how, not a bare value" + ) + if not _POLICY_PUBLISH_WARNED: + _POLICY_PUBLISH_WARNED = True + print( + f"{POLICY_FILE}: its `publish:` frontmatter still decides the default. " + f"That file is TRACKED, so it ships your tool to every clone — move " + f"the two values to {PUBLISH_CONF} (see {PUBLISH_CONF_DEFAULTS}) and " + "delete the block.", + file=sys.stderr, + ) + return pub - block: dict = {} - if text.startswith("---"): - parts = text.split("---", 2) - if len(parts) == 3: - try: - doc = yaml.safe_load(parts[1]) - except yaml.YAMLError as exc: - raise QueueError(f"{policy_path()}: its frontmatter is not YAML: {exc}") - if isinstance(doc, dict) and "publish" in doc: - pub = doc["publish"] - if not isinstance(pub, dict): - raise QueueError( - f"{policy_path()}: publish is {pub!r}, and must be a " - "mapping with method/how, not a bare value" - ) - block = pub - - method = str(block.get("method") or "").strip() or PUBLISH_DEFAULT + +def policy_publish_default() -> tuple[str, str | None]: + """The operator's own publish default, from `orchestration/publish.conf`. + + METHOD=attested + HOW=run `/your-pipeline --yes` + + IT MOVED OUT OF POLICY.md, which is TRACKED. Putting it there made the + default reviewable in a diff, which was the argument for it — and made this + repo ship one operator's pipeline as every clone's default, which is the + argument against and the larger one. `publish.example.conf` is the tracked + half now: it documents the three artifact shapes, ships `pr`, and names no + tool. POLICY.md still SAYS how a worker publishes, because every brief + points at it; it no longer decides for somebody else's fleet. + + A `publish:` block still in POLICY.md's frontmatter is honoured and warned + about once, so an operator who syncs across the move keeps working while + they are told where it went. Ignoring it would silently downgrade every + task's verification, which is the failure this whole subsystem prevents. + + The shipped answer is `pr` with no `how`: a fresh clone needs no + configuration at all. A word that is not a method is refused rather than + ignored, for the same reason. + """ + block = policy_publish_block() + where = policy_path() + if block is None: + conf = publish_conf() + block = {"method": conf.get("METHOD", ""), "how": conf.get("HOW", "")} + where = PUBLISH_CONF + + method = publish_method(str(block.get("method") or "").strip()) or PUBLISH_DEFAULT if method not in PUBLISH_METHODS: raise QueueError( - f"{policy_path()}: publish.method is {method!r}, and the methods are " + f"{where}: publish method is {method!r}, and the methods are " + ", ".join(sorted(PUBLISH_METHODS)) ) how = str(block.get("how") or "").strip() @@ -1542,7 +1642,7 @@ def cmd_add(args) -> int: # Resolution, first hit wins and per FIELD. A stated method with no stated # tool drops the operator's global one rather than inheriting it: "run - # `/no-mistakes --yes`" is the wrong sentence to hand a `push` task. A + # attesting pipeline" is the wrong sentence to hand a `push` task. A # stated `how` alone keeps the operator's method, because a lead adding a # note about the tool must not be able to downgrade the check by accident. method, how = policy_publish_default() @@ -2491,7 +2591,9 @@ def spawn_commands(task: Task) -> tuple[list, str]: flags = profile_flags(d.get("profile") or "default") # A profile carrying `command` replaces `--agent`; thurbox refuses both. if "--command" not in flags: - create += ["--agent", d.get("agent") or "claude"] + agent = d.get("agent") or configured_agent() + if agent: + create += ["--agent", agent] parent = os.environ.get("THURBOX_SESSION") if parent: create += ["--parent", parent] @@ -3039,7 +3141,7 @@ def task_publish(task: Task) -> tuple[str, str | None]: A record written before this field existed has no `publish` block, and it must keep being verified exactly as it was dispatched. So the fallback is the OPERATOR's default from POLICY.md and never the bare `pr` that ships as - fleet's: this repo's frontmatter says `no-mistakes`, so every task already + fleet's: an operator whose default is `attested`, so every task already in its queue keeps the check it was opened under. A record that DOES declare a method declares its `how` with it, absent @@ -3094,7 +3196,7 @@ def pull_request_verdict(task: Task, method: str, outcome, url) -> tuple[str, st request is from this task's branch" is the one claim about it that a worker cannot write into its own result.md. - `no-mistakes` then asks for the attestation rather than for headings in the + `attested` then asks for the attestation rather than for headings in the prose. Same class of evidence — the tool's own trace in the body — and strictly stronger, because it names the head commit the pipeline ran on and a stale one is refused. It is also the check the shepherd already makes, so @@ -3124,7 +3226,7 @@ def pull_request_verdict(task: Task, method: str, outcome, url) -> tuple[str, st f"is {branch}" ) - if method == "no-mistakes": + if method == "attested": attested, why = attestation_verdict(cr.body, cr.head_sha) if not attested: why += pipeline_moved_the_head(cr) @@ -3137,17 +3239,28 @@ def pull_request_verdict(task: Task, method: str, outcome, url) -> tuple[str, st return "passed", f"the pull request is {cr.state} and is from {branch}" -# The pipeline's own commits, which are the ONE way a `no-mistakes` branch -# grows a new head without anybody having touched it. The attestation is -# written during the `pr` step and CI fixes are pushed on top of it, so the -# body ends up attesting a commit that is now an ancestor of the head. +# The pipeline's own commits, which are the ONE way an `attested` branch grows +# a new head without anybody having touched it. The attestation is written +# during the `pr` step and CI fixes are pushed on top of it, so the body ends up +# attesting a commit that is now an ancestor of the head. # # Refusing that is right and stays right — an attestation for an ancestor # describes code that is not what would merge. But "somebody pushed over the # pipeline" and "the pipeline did this to itself" are the same refusal with # different remedies, and only the second is answered by running the tool # again. Reading #48 today, a lead cannot tell which one it is looking at. -PIPELINE_COMMIT_RE = re.compile(r"^(?:chore:\s*)?no-mistakes[:\s]", re.I) +# +# NONE IS THE SHIPPED ANSWER, because the subject a pipeline writes is that +# pipeline's convention. With `PIPELINE_COMMIT_PREFIX` unset fleet never claims +# to know which of the two it is looking at, which is honest rather than thin. + + +def pipeline_commit_re() -> "re.Pattern | None": + """The subject this fleet's pipeline writes, or None when none is set.""" + prefix = publish_conf().get("PIPELINE_COMMIT_PREFIX", "").strip() + if not prefix: + return None + return re.compile(r"^(?:chore:\s*)?" + re.escape(prefix) + r"[:\s]", re.I) def pipeline_moved_the_head(cr: forge.ChangeRequest) -> str: @@ -3162,7 +3275,7 @@ def pipeline_moved_the_head(cr: forge.ChangeRequest) -> str: absent list is silence, and silence must not become a claim about who pushed what. """ - m = ATTESTATION_RE.search(cr.body or "") + m = attestation_re().search(cr.body or "") if not m: return "" try: @@ -3170,22 +3283,23 @@ def pipeline_moved_the_head(cr: forge.ChangeRequest) -> str: except ValueError: return "" attested = str(doc.get("head_sha") or "").lower() if isinstance(doc, dict) else "" + own = pipeline_commit_re() head = (cr.head_sha or "").lower() commits = cr.commits - if not attested or not head or not commits: + if own is None or not attested or not head or not commits: return "" oids = [c.sha.lower() for c in commits] if oids[-1] != head or attested not in oids: return "" after = commits[oids.index(attested) + 1:] - if not after or not all(PIPELINE_COMMIT_RE.match(c.headline) for c in after): + if not after or not all(own.match(c.headline) for c in after): return "" named = ", ".join(f"{c.sha[:8]} “{c.headline}”" for c in after[:2]) return ( f"; the pipeline pushed that head itself ({named}) after it attested, so " - "nothing else has moved this branch — re-run `/no-mistakes --yes` and it " - "will attest the commit that would merge" + "nothing else has moved this branch — run this task's publish command " + "again and it will attest the commit that would merge" ) @@ -3798,16 +3912,57 @@ def cmd_reap(args) -> int: # recovery. REFUEL_CAP = 3 -# The agent's own limit line, as observed on 2026-09-08 in this machine's -# transcripts and on the pane that renders them: +# HOW EACH AGENT SAYS IT RAN OUT, one entry per agent fleet has actually +# WATCHED do it — the same shape as `scripts/session-trust.sh`'s per-agent +# table, and for the same reason: fleet drives several agents, so knowing one +# of them is a fact about that agent and not a assumption about all of them. # -# You've hit your session limit · resets 11:30pm (Europe/Paris) +# `banner` is the sentence on the pane. `transcript` is the directory of +# per-session records, with `home_env` the agent's own knob for moving it; a +# transcript is read only if `jsonl` says its records are one JSON object per +# line with a `rate_limit` error on the rejected turn. # -# Matched on the sentence and not the whole line, so the window's name and the -# reset time can vary. Nothing is matched that was not observed: an agent whose -# banner reads differently answers `quiet` here and is reported as such rather -# than guessed at. -LIMIT_BANNER_RE = re.compile(r"you'?ve hit your \w+ limit", re.I) +# AN AGENT THAT IS NOT HERE IS NOT GUESSED AT. It answers `undetermined`, which +# restarts nothing, and `refuel` says which setting would teach fleet its +# signal. Inventing a pattern for an agent nobody has watched hit its limit is +# how a live worker gets restarted mid-turn, so nothing is matched here that +# was not observed. +AGENT_LIMIT_SIGNALS = { + # Observed 2026-09-08, on the pane and in the transcripts: + # You've hit your session limit · resets 11:30pm (Europe/Paris) + # Matched on the sentence, so the window's name and reset time can vary. + "claude": { + "banner": r"you'?ve hit your \w+ limit", + "home_env": "CLAUDE_CONFIG_DIR", + "home": "~/.claude", + "transcript": "projects", + "jsonl": True, + }, +} + + +def limit_signal(agent: str | None) -> dict: + """How this agent reports exhaustion — the operator's answer outranks ours. + + `LIMIT_BANNER` and `TRANSCRIPT_DIR` in `orchestration/agent.conf` teach + fleet an agent it has never watched, without a code change and without + fleet claiming to know a sentence nobody observed. + """ + sig = dict(AGENT_LIMIT_SIGNALS.get((agent or "").strip(), {})) + conf = agent_conf() + banner = conf.get("LIMIT_BANNER", "").strip() + tdir = conf.get("TRANSCRIPT_DIR", "").strip() + if banner: + sig["banner"] = banner + if tdir: + sig.update({"home": tdir, "home_env": "", "transcript": "", "jsonl": True}) + return sig + + +def limit_banner_re(agent: str | None) -> "re.Pattern | None": + """The sentence that agent prints at its limit, or None to not guess.""" + pattern = limit_signal(agent).get("banner") + return re.compile(pattern, re.I) if pattern else None # How much pane to ask for, and how much of it the banner has to be in. The # banner sits in the scrollback of a session that already recovered from an @@ -3822,10 +3977,11 @@ def cmd_reap(args) -> int: # restart is worth making. It is read through `fleet_status.probe_fuel()` — # `refuel/02-fuel-gauge` put that there for the lead's screen — rather than # parsed a second time here: one reader means quota-axi's schema moving costs -# one edit. `probe_fuel()` stays single-provider (`claude` alone) on purpose, -# so the lead's screen, which reads every authenticated provider through -# `probe_fuel_all()`, can legitimately show a different picture than this -# command does. +# one edit. `probe_fuel()` takes ONE provider and this command names which, +# because the screen reads every authenticated provider through +# `probe_fuel_all()` and the two may legitimately show different pictures: a +# spent window on a provider the fleet never dispatches through is not a reason +# to leave a worker sitting at its limit. # # Loaded by path and LAZILY, because fleet_status.py imports this file: at # import time that is a cycle, and inside the one function that needs it, it is @@ -3835,7 +3991,79 @@ def cmd_reap(args) -> int: # The provider that gauge reads, and so the only agent whose account this can # speak for. A task running something else is not refused — its account window # is undetermined, and undetermined restarts nothing. -FUEL_AGENT = "claude" +# +# NO NAME IS WRITTEN HERE. A literal would gate every operator's fleet on one +# operator's vendor, and reading the WRONG provider is worse than reading none: +# a spent window somewhere the fleet never dispatches would strand a worker at +# its limit. `FUEL_PROVIDER` in `orchestration/agent.conf` sets it outright; +# with that empty this reads the provider off the tasks in hand, and answers +# None when they do not agree — which reaches every caller as `undetermined`, +# which restarts nothing. + + +def agent_providers() -> dict[str, str]: + """`agent=provider` pairs from agent.conf, for a fleet whose names differ. + + Empty is the shipped answer and means IDENTITY: quota-axi names most + providers after the agent that draws on them, so a map is only needed where + they come apart. Nothing here lists the providers quota-axi supports — + `fleet_status.authenticated_providers()` asks it, so the set grows with the + tool and never with a table in this file. + """ + raw = agent_conf().get("AGENT_PROVIDERS", "").strip() + out: dict[str, str] = {} + for pair in re.split(r"[,\s]+", raw): + agent, _, provider = pair.partition("=") + if agent.strip() and provider.strip(): + out[agent.strip()] = provider.strip() + return out + + +def fuel_agent(tasks=()) -> str | None: + """The provider `refuel` may speak for on this pass, or None to not guess. + + `FUEL_PROVIDER` pins one outright. Otherwise the agent in hand is mapped + through `AGENT_PROVIDERS` and, failing that, used as its own provider name. + Tasks that do not agree on one agent answer None, which reaches the caller + as `undetermined` and restarts nothing. + """ + pinned = agent_conf().get("FUEL_PROVIDER", "").strip() + if pinned: + return pinned + agents = {(t.doc.get("agent") or "").strip() for t in tasks} + agents.discard("") + if not agents: + agent = configured_agent() + elif len(agents) == 1: + agent = agents.pop() + else: + return None + if not agent: + return None + return agent_providers().get(agent, agent) + + +def provider_is_known(provider: str) -> tuple[bool, str]: + """Does quota-axi hold a credential for this provider? + + Asked rather than assumed, because quota-axi supports many providers and a + list in this file would be stale the day it gained another. A provider it + does not name is reported, never guessed past: reading the WRONG window is + worse than reading none. + """ + try: + names, why = fuel_gauge().authenticated_providers() + except (OSError, ImportError, AttributeError, SyntaxError) as exc: + return True, f"the provider list could not be read ({exc})" + if why: + return True, "" + if provider in names: + return True, "" + return False, ( + f"{QUOTA_CMD} holds no credential for `{provider}` — it names " + + (", ".join(names) if names else "none") + + f". Map the agent to its provider with AGENT_PROVIDERS in {AGENT_CONF}" + ) def fuel_gauge(): @@ -3847,7 +4075,7 @@ def fuel_gauge(): return module -def account_fuel() -> tuple[str, str]: +def account_fuel(provider: str) -> tuple[str, str]: """('fuel' | 'spent' | 'unknown', detail) for the account every session spends. `effectivePercentRemaining` is the subscription window the lead and every @@ -3857,7 +4085,7 @@ def account_fuel() -> tuple[str, str]: no token, usage, cost or limit field at all. """ try: - sec = fuel_gauge().probe_fuel() + sec = fuel_gauge().probe_fuel(provider) except (OSError, ImportError, AttributeError, SyntaxError) as exc: return "unknown", f"the fuel gauge could not be loaded: {exc}" if sec.get("unavailable"): @@ -3875,23 +4103,34 @@ def account_fuel() -> tuple[str, str]: return ("spent" if remaining <= 0 else "fuel"), detail -def transcript_root() -> str: - """Where Claude Code keeps its transcripts. `CLAUDE_CONFIG_DIR` is its own - knob for moving them, so this reads that rather than assuming a home.""" - home = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~/.claude") - return os.path.join(home, "projects") +def transcript_root(agent: str | None = None) -> str: + """Where this agent keeps its transcripts, or "" when fleet does not know. + + Each agent's own env knob is read rather than a home being assumed, and an + agent with no entry and no `TRANSCRIPT_DIR` returns "" so every caller + degrades to `undetermined` instead of globbing somebody else's directory. + """ + sig = limit_signal(agent) + home = sig.get("home") + if not home: + return "" + env = sig.get("home_env") + home = (env and os.environ.get(env)) or os.path.expanduser(home) + return os.path.join(home, sig["transcript"]) if sig.get("transcript") else home -def transcript_file(agent_sid: str) -> str: +def transcript_file(agent_sid: str, agent: str | None = None) -> str: """The agent's own transcript, named by the session id thurbox records.""" - root = transcript_root() + root = transcript_root(agent) + if not root: + return "" hits = glob.glob(os.path.join(root, "*", f"{agent_sid}.jsonl")) if not hits: hits = glob.glob(os.path.join(root, "**", f"{agent_sid}.jsonl"), recursive=True) return hits[0] if hits else "" -def transcript_exhaustion(agent_sid: str) -> tuple[str, str]: +def transcript_exhaustion(agent_sid: str, agent: str | None = None) -> tuple[str, str]: """Did the agent's LAST turn end on the quota rejecting it? The precise source, and the one this section derives its number from: the @@ -3905,9 +4144,16 @@ def transcript_exhaustion(agent_sid: str) -> tuple[str, str]: """ if not agent_sid: return "unknown", "the session records no agent_session_id" - path = transcript_file(agent_sid) + root = transcript_root(agent) + if not root: + return "unknown", ( + f"fleet has not watched `{agent or 'this agent'}` hit a limit, so it " + f"has no transcript to read — name one with TRANSCRIPT_DIR in " + f"{AGENT_CONF}" + ) + path = transcript_file(agent_sid, agent) if not path: - return "unknown", f"no transcript for {agent_sid} under {transcript_root()}" + return "unknown", f"no transcript for {agent_sid} under {root}" try: with open(path, "rb") as fh: fh.seek(0, os.SEEK_END) @@ -3945,7 +4191,7 @@ def transcript_exhaustion(agent_sid: str) -> tuple[str, str]: ) -def pane_exhaustion(sid: str) -> tuple[str, str]: +def pane_exhaustion(sid: str, agent: str | None = None) -> tuple[str, str]: """Does the agent's own limit banner stand at the bottom of its pane? The tail only. The banner stays in the scrollback of a session that already @@ -3967,9 +4213,16 @@ def pane_exhaustion(sid: str) -> tuple[str, str]: doc = json.loads(proc.stdout) except ValueError: return "unknown", "session capture did not answer JSON" + banner = limit_banner_re(agent) + if banner is None: + return "unknown", ( + f"fleet has not watched `{agent or 'this agent'}` hit a limit, so it " + f"does not know its banner — set LIMIT_BANNER in {AGENT_CONF} to " + "teach it one" + ) lines = [ln.strip() for ln in str(doc.get("output") or "").splitlines() if ln.strip()] for line in lines[-PANE_TAIL_LINES:]: - if LIMIT_BANNER_RE.search(line): + if banner.search(line): return "exhausted", f"the pane ends on the agent's own banner: {line}" return "quiet", f"no limit banner in the pane's last {PANE_TAIL_LINES} lines" @@ -3981,10 +4234,11 @@ def exhaustion(doc: dict) -> tuple[str, str]: event, recorded rather than rendered, and it says which window rejected the turn. The pane is what answers for an agent that keeps no transcript here. """ - seen, detail = transcript_exhaustion(doc.get("agent_session_id") or "") + agent = doc.get("detected_agent") or doc.get("reports_as") or doc.get("agent") + seen, detail = transcript_exhaustion(doc.get("agent_session_id") or "", agent) if seen != "unknown": return seen, detail - pane, pane_detail = pane_exhaustion(str(doc.get("id") or "")) + pane, pane_detail = pane_exhaustion(str(doc.get("id") or ""), agent) if pane != "unknown": return pane, pane_detail return "undetermined", f"{detail}; {pane_detail}" @@ -4073,9 +4327,25 @@ def refuel(q: Queue, ref: str | None = None, dry: bool = False) -> int: f"refuel: {len(holders)} recorded session(s); the cap is {REFUEL_CAP} " "restart(s) per task" ) - verdict, detail = account_fuel() - print(f" account {FUEL_AGENT:<10} " - f"{'undetermined' if verdict == 'unknown' else verdict:<12} {detail}") + provider = fuel_agent(holders) + if provider is None: + print( + " account ? undetermined no provider to read: " + f"{AGENT_CONF} names none and these\n" + " tasks do not agree on one agent. Undetermined restarts " + "nothing — name a\n" + f" FUEL_PROVIDER there (see {AGENT_CONF_DEFAULTS}) to gate on " + "one window." + ) + verdict, detail = "unknown", "no provider named" + else: + known, why = provider_is_known(provider) + if not known: + verdict, detail = "unknown", why + else: + verdict, detail = account_fuel(provider) + print(f" account {provider:<10} " + f"{'undetermined' if verdict == 'unknown' else verdict:<12} {detail}") if verdict == "spent": print( " The account window is SPENT, and it is the operator's own " @@ -4112,10 +4382,11 @@ def refuel(q: Queue, ref: str | None = None, dry: bool = False) -> int: "worker still in flight is refuelled") kept += 1 continue - agent = task.doc.get("agent") or FUEL_AGENT - if agent != FUEL_AGENT: + agent = task.doc.get("agent") or provider + if provider is None or agent != provider: + runs = agent or "thurbox's own default" print(f" {task.ref:<46} undetermined the fuel gauge reads the " - f"{FUEL_AGENT} account and this task runs `{agent}`") + f"{provider or 'unnamed'} account and this task runs `{runs}`") kept += 1 continue if verdict != "fuel": @@ -4123,7 +4394,7 @@ def refuel(q: Queue, ref: str | None = None, dry: bool = False) -> int: # hundred characters of it per task buries the one thing a reader # is looking for, which is which tasks it applies to. word = "kept" if verdict == "spent" else "undetermined" - print(f" {task.ref:<46} {word:<13} the {FUEL_AGENT} account window is " + print(f" {task.ref:<46} {word:<13} the {provider} account window is " f"{'spent' if verdict == 'spent' else 'unreadable'} — see above") kept += 1 continue @@ -4314,7 +4585,7 @@ def cmd_refuel(args) -> int: # # The gates are the operator's, and all must hold: the head branch lives in # this repository (`classify`'s `foreign` check — a fork is never merged), the -# body carries a `no-mistakes` attestation naming the pull request's CURRENT +# body carries an attestation naming the pull request's CURRENT # head commit (so a stale attestation from an earlier push can never authorise # the push that replaced it), every check has CONCLUDED and passed, the forge # itself calls it mergeable, and whoever opened it can push to this repo @@ -4457,13 +4728,25 @@ def check_verdicts(cr: forge.ChangeRequest) -> tuple[list, list]: # is public and has a fork, and this command merges unattended on a timer. The # five headings are text, and text in a pull request body is written by whoever # opened the pull request — so a check that counts them lets a body authorise -# its own merge. `no-mistakes` leaves something a body cannot fake as easily: -# an attestation naming the exact commit the pipeline ran on. A stale one from -# an earlier push is refused for the same reason, because the pipeline's -# verdict is about the code it saw and not about the branch's name. -ATTESTATION_RE = re.compile( - r"", re.S -) +# its own merge. An attestation naming the exact commit the pipeline ran on is +# something a body cannot fake as easily. A stale one from an earlier push is +# refused for the same reason: the verdict is about the code it saw and not +# about the branch's name. +# +# WHICH MARKER is the operator's, because their pipeline already emits one and +# asking them to emit fleet's instead would be fleet dictating a format to a +# tool it has never heard of. `orchestration/publish.example.conf` owns the +# shape; this only compiles whatever it names. + + +def attestation_re(marker: str | None = None) -> "re.Pattern": + """The HTML comment this fleet's pipeline leaves, per publish.conf.""" + marker = marker or publish_conf().get("ATTESTATION_MARKER", "").strip() + if not marker: + marker = "fleet-attestation" + return re.compile( + r"", re.S + ) # The attestation is written DURING the pipeline's `pr` step, so in every body # that carries one `pr` reads `running` and `ci` reads `pending`. Demanding @@ -4481,37 +4764,37 @@ def attestation_verdict(body: str, head_sha: str) -> tuple[bool, str]: False is never "probably fine": every way of failing to read the attestation is a way of not being merged. """ - m = ATTESTATION_RE.search(body or "") + m = attestation_re().search(body or "") if not m: return False, ( - "the body carries no no-mistakes attestation, so nothing but its own " + "the body carries no attestation, so nothing but its own " "prose says the pipeline ever ran" ) try: doc = json.loads(m.group(1)) except ValueError: - return False, "the no-mistakes attestation is not valid JSON" + return False, "the attestation is not valid JSON" if not isinstance(doc, dict): - return False, "the no-mistakes attestation is not an object" + return False, "the attestation is not an object" attested = str(doc.get("head_sha") or "") if not attested: - return False, "the no-mistakes attestation names no head_sha" + return False, "the attestation names no head_sha" if not head_sha: return False, "GitHub did not say which commit this pull request's head is" if attested.lower() != head_sha.lower(): return False, ( - f"the no-mistakes attestation is for {attested[:8]}, and the head is " + f"the attestation is for {attested[:8]}, and the head is " f"{head_sha[:8]} — it attests a push that is no longer what would merge" ) steps = doc.get("steps") if not isinstance(steps, list) or not steps: - return False, "the no-mistakes attestation lists no steps" + return False, "the attestation lists no steps" unfinished = [] for st in steps: if not isinstance(st, dict): - return False, "the no-mistakes attestation's steps are malformed" + return False, "the attestation's steps are malformed" name = str(st.get("step") or "an unnamed step") status = str(st.get("status") or "").lower() allowed = ( @@ -4538,7 +4821,7 @@ def classify(cr: forge.ChangeRequest, method: str | None) -> tuple[str, str]: `method` is the publish method of the task this pull request belongs to, or None when no task records it. It gates exactly ONE condition: `policy`, "there is no attestation", is a fault only for a task that was declared - `no-mistakes`. A `pr`-method pull request was never supposed to carry one, + `attested`. A `pr`-method pull request was never supposed to carry one, and the fixer sent at it would tell its worker to go and run a tool the operator may not have installed. None keeps the old reading, because an unlinked pull request is one fleet knows nothing about and the attestation @@ -4584,7 +4867,7 @@ def classify(cr: forge.ChangeRequest, method: str | None) -> tuple[str, str]: cr.review_decision == "changes-requested", "a reviewer requested changes", ), - "policy": (not attested and method in (None, "no-mistakes"), attest_why), + "policy": (not attested and method in (None, "attested"), attest_why), } for condition in FIXABLE: hit, why = fixable[condition] @@ -4633,7 +4916,7 @@ def publish_word(cr: forge.ChangeRequest, condition: str, method: str | None) -> ready the pipeline vetted the exact head that would merge. Review, tests and lint ran on THIS commit and said so in a form the body cannot fake (`attestation_verdict`, and see the comment - above `ATTESTATION_RE` for why the prose above it could). + above `attestation_re` for why the prose above it could). This is what fleet merges unattended. green the forge is happy and NOBODY vetted anything. The checks that passed are whatever checks that repo happens to have, which @@ -4641,7 +4924,7 @@ def publish_word(cr: forge.ChangeRequest, condition: str, method: str | None) -> reports it and leaves it; the operator merges it, having looked. - For two months every pull request in this queue was a no-mistakes pull + For two months every pull request in this queue was an attested pull request, so "the forge is happy" and "the pipeline vetted it" were the same fact, and both the operator and this code learned to read one as the other. The moment a `pr`-method task exists they come apart, and the only things @@ -4744,17 +5027,15 @@ def base_drift(repo: str, base: str, branch: str) -> str: Address the review that requested changes, then push to the same branch. Reply to the review only if something in it was mistaken; otherwise let the diff be the answer.""", + # `{how}` is the task's OWN recorded publish command, free text the operator + # wrote — fleet never spells a tool here, because the tool is theirs. "policy": """\ Nothing on this pull request shows the required pipeline ran on the commit it -would merge. `no-mistakes` leaves an attestation in the body naming the exact -head commit it ran against, and this one either has none or has one for an -earlier push. Re-run it: - - /no-mistakes --yes - -on this branch. That rewrites the body — attestation and all five sections — -and actually runs the checks, against what is on the branch now. Do not open a -second pull request; the pipeline updates the one that is already there, and +would merge. It must carry an attestation in its body naming the exact head +commit the pipeline ran against, and this one either has none or has one for an +earlier push. Publish it again, on this branch — {how} — so the body is +rewritten, attestation and all, against what is on the branch now. Do not open +a second pull request; the pipeline updates the one that is already there, and do not hand-edit the body, because an attestation you typed attests nothing.""", } @@ -4763,7 +5044,12 @@ def fixer_brief(task: Task, cr: forge.ChangeRequest, condition: str, detail: str, drift: str) -> str: n = cr.number base = cr.base_branch or task.doc.get("base") or "main" - work = FIXER_WORK[condition].format(base=base) + # A task that recorded no publish command gets a sentence that says so + # rather than an empty gap where a command should be. + how = (task.doc.get("publish") or {}).get("how") or ( + "run this repository's publishing pipeline" + ) + work = FIXER_WORK[condition].format(base=base, how=how) parts = [ f"# {FIXER_TITLES[condition].format(n=n, base=base)}", "", @@ -4930,7 +5216,9 @@ def spawn_fixer(task: Task, name: str, brief_path: str, branch: str) -> tuple[st ] flags = profile_flags(task.doc.get("profile") or "default") if "--command" not in flags: - create += ["--agent", task.doc.get("agent") or "claude"] + agent = task.doc.get("agent") or configured_agent() + if agent: + create += ["--agent", agent] parent = os.environ.get("THURBOX_SESSION") if parent: create += ["--parent", parent] @@ -5400,7 +5688,7 @@ def cmd_shepherd(args) -> int: + ", and only for a pull request whose\n" " head branch is in that repo, that someone who can push there " "opened, that\n" - " carries a no-mistakes attestation for its CURRENT head, whose " + " carries an attestation for its CURRENT head, whose " "checks passed,\n and that the forge itself calls mergeable." ) else: @@ -6248,7 +6536,10 @@ def build_parser() -> argparse.ArgumentParser: a.add_argument("--branch", required=True) a.add_argument("--base", default="main") a.add_argument("--profile", default="default") - a.add_argument("--agent", default="claude", help="the agent to launch (default claude)") + a.add_argument( + "--agent", + help="the agent to launch (default: orchestration/agent.conf, else thurbox's own)", + ) a.add_argument( "--publish", choices=sorted(PUBLISH_METHODS), diff --git a/scripts/pane-selftest.sh b/scripts/pane-selftest.sh index 08f2293..ec6a96e 100755 --- a/scripts/pane-selftest.sh +++ b/scripts/pane-selftest.sh @@ -32,7 +32,7 @@ # fact about the pull request; what the operator does about it is a # second thing, and the row's `note` is where it lives. The note was the # FIRST thing the width ladder dropped, so it reached neither width this -# file renders while `no-mistakes` — one word under every task in the +# file renders while `attested` — one word under every task in the # queue — reached both. These assert the order that fixes it. # # And the rule none of that may cost: it still degrades. The pane routinely @@ -153,9 +153,9 @@ expect "a green one still says whose merge it is" "green — yours to merge" "$W # The order the ladder gives things up in, pinned: the METHOD is provenance and # the NOTE is the action, so a row too narrow for both keeps the action. Before -# this, `no-mistakes · #47 · green` is what 44 columns drew and the note was +# this, `attested · #47 · green` is what 44 columns drew and the note was # drawn at no width at all. -refute "the method gives way to the note, not the reverse" "no-mistakes · #47" "$WIDE" +refute "the method gives way to the note, not the reverse" "attested · #47" "$WIDE" # A blocker recorded against a task that is NOT waiting holds nothing — # `queue.py` clears a `queued` task and no other — so drawing it would explain diff --git a/scripts/queue-selftest.sh b/scripts/queue-selftest.sh index eab909f..93bf3c7 100755 --- a/scripts/queue-selftest.sh +++ b/scripts/queue-selftest.sh @@ -36,7 +36,7 @@ # cannot read is left exactly as it is. What it saw is WRITTEN DOWN on the # task, and the gate is method-aware: a pull request the forge is happy # with but nobody attested is recorded `green`, handed back and never -# merged, while a `no-mistakes` task with no attestation is recorded +# merged, while an `attested` task with no attestation is recorded # `unattested` and still gets its fixer. # 11. A task can name a HOST and run there, and a task that names none takes # exactly the path it took before the flag existed. Nothing is spawned on @@ -224,6 +224,27 @@ github.com/Thurbeen/fleet github.com/Thurbeen/thurbox EOF +# --- the publish and agent settings, which are FILES and not literals -------- +# +# Same hazard as the allowlist above: read the operator's own `publish.conf` +# and these verdicts depend on whose laptop ran them. The run gets its own, +# carrying exactly the shipped defaults plus the one thing the fixtures need — +# a `how` sentence naming a tool that does not exist, which is the point: fleet +# renders it and never parses it. +export FLEET_PUBLISH_ROOT="$tmp/publish" +export FLEET_AGENT_ROOT="$tmp/publish" +mkdir -p "$FLEET_PUBLISH_ROOT/orchestration" +cat >"$FLEET_PUBLISH_ROOT/orchestration/publish.conf" <<'EOF' +METHOD=attested +HOW=run `/publish --yes` +ATTESTATION_MARKER=fleet-attestation +PIPELINE_COMMIT_PREFIX=publish +EOF +cat >"$FLEET_AGENT_ROOT/orchestration/agent.conf" <<'EOF' +AGENT=claude +FUEL_PROVIDER=claude +EOF + # --- `glab`, a STAND-IN on PATH for the whole run ---------------------------- # # The GitLab adapter asks `glab auth status` which instances this machine @@ -337,7 +358,7 @@ esac SH chmod +x "$ghbin/gh" -# The body a `no-mistakes` pull request carries: an attestation naming the +# The body a `attested` pull request carries: an attestation naming the # commit the pipeline ran on. It is written DURING the `pr` step, so `pr` reads # `running` and `ci` `pending` in every real one. cat >"$tmp/attest.py" <<'PY' @@ -349,7 +370,7 @@ steps = [ for s in ("intent", "rebase", "review", "test", "document", "lint", "push") ] + [{"step": "pr", "status": "running"}, {"step": "ci", "status": "pending"}] payload = json.dumps({"head_sha": sys.argv[1], "steps": steps}) -print(f"") +print(f"") print() print("Shipped it.") PY @@ -1083,7 +1104,7 @@ expect "check validates every record" "ok" "$out" # --- 8. collect verifies the artifact instead of trusting the worker --------- # # The bug this proves gone: a brief said "open the PR by running -# `/no-mistakes --yes`", which is an instruction about a METHOD, and a method +# `/publish --yes`", which is an instruction about a METHOD, and a method # leaves no trace a checker can read. Two tasks were collected `shipped` with # hand-made `gh pr create` PRs and nothing noticed until an operator read the # bodies himself. So a task declares what its publish must LEAVE BEHIND, and @@ -1091,7 +1112,7 @@ expect "check validates every record" "ok" "$out" # for a commit on the base branch. # # These four tasks take the operator's own default from POLICY.md's -# frontmatter, which is `no-mistakes`: a pull request from the task's own +# frontmatter, which is `attested`: a pull request from the task's own # branch whose body attests the commit that would merge. 8b below covers the # other two methods and the declaration that chooses between them. # @@ -1132,12 +1153,12 @@ out="$($QUEUE collect 2>&1)" expect "an attested PR from the task's own branch collects clean" \ "02-document-the-states" "$out" expect "and collect says which method it verified" \ - "[publish verified: no-mistakes]" "$out" + "[publish verified: attested]" "$out" expect "a PR that skipped the pipeline is caught" "03-render-detected-agent" "$out" expect "the refusal says what the body does not carry" "attestation" "$out" expect "the refusal says the task was not closed" "NOT CLOSED" "$out" -expect "and states what would have proved it" "no-mistakes" "$out" +expect "and states what would have proved it" "attested" "$out" expect "and quotes the tool the brief named, in the operator's own words" \ "Its brief said:" "$out" @@ -1190,7 +1211,7 @@ expect "and the record says missing, not skipped" "missing" "$state" # # Fleet used to know exactly one way of publishing and hard-coded the proof of # it. That is the same non-agnosticism whichever tool is hard-coded, so a task -# now declares an ARTIFACT SHAPE — `no-mistakes`, `pr` or `push` — and the tool +# now declares an ARTIFACT SHAPE — `attested`, `pr` or `push` — and the tool # rides beside it as `--how`, free text that is rendered into the brief and # never parsed. That last part is the whole property: an operator's own # `/publish` skill, or a repo's `make release`, works because fleet does not @@ -1233,15 +1254,15 @@ $QUEUE add "$ptopic" push-task --title 'Publish straight onto the base branch' \ b="$(brief_text "$FLEET_QUEUE_DIR/$ptopic/01-pipeline-task/BRIEF.md")" expect "a task with no --publish takes POLICY.md's own default" \ - "**Publish.** \`no-mistakes\`" "$b" + "**Publish.** \`attested\`" "$b" expect "and the brief names the tool in the operator's own words" \ - "Here that means: run \`/no-mistakes --yes\`." "$b" + "Here that means: run \`/publish --yes\`." "$b" b="$(brief_text "$FLEET_QUEUE_DIR/$ptopic/02-pr-task/BRIEF.md")" expect "a --publish pr task says so" "**Publish.** \`pr\`" "$b" expect "and carries the --how it was given" "operator xyz skill" "$b" refute "and not the operator's default tool, which belongs to another method" \ - "\`no-mistakes\`" "$b" + "\`attested\`" "$b" b="$(brief_text "$FLEET_QUEUE_DIR/$ptopic/03-push-task/BRIEF.md")" expect "a --publish push task says so" "**Publish.** \`push\`" "$b" @@ -1383,7 +1404,7 @@ done # (f) A record written before any of this existed. It carries no `publish` # block at all, and it must keep the verification it was dispatched under — # which is the operator's POLICY.md default and NOT the `pr` fleet ships. -# A `pr` reading would pass this pull request; a `no-mistakes` one holds it. +# A `pr` reading would pass this pull request; a `attested` one holds it. $QUEUE add "$ptopic" legacy-record --title 'A task from before the field existed' \ --repo /tmp/repo-a --branch fix/legacy-record --number 09 >/dev/null @@ -1413,7 +1434,7 @@ expect "and against the operator's default, not the one fleet ships" \ state="$($QUEUE show "$ptopic/09-legacy-record" 2>&1)" refute "so it is held open, exactly as it would have been before" \ "state: done" "$state" -expect "and show reports the method it was read as" "publish: no-mistakes" "$state" +expect "and show reports the method it was read as" "publish: attested" "$state" out="$($QUEUE check 2>&1)" expect "and check is happy with a record that declares nothing" "ok" "$out" @@ -1445,8 +1466,11 @@ expect "and the task still closes — an offline machine must still collect" \ "state: done" "$state" expect "and the record says gh was never there to ask" "gh not found" "$state" -# (h) A POLICY.md with no frontmatter — the state this repo shipped in, and the -# state a fresh clone is in. It answers `pr`, and nothing errors. +# (h) A clone nobody has configured: no `publish:` frontmatter in POLICY.md +# AND no `orchestration/publish.conf`, which is what a fresh clone of a +# public repo is. The tracked `publish.example.conf` answers, it says `pr`, +# and no tool is named anywhere in the brief — because naming one would +# mean this repo shipped somebody else's pipeline. bare="$tmp/bare-clone" mkdir -p "$bare/scripts/lib" "$bare/orchestration/queue" @@ -1459,7 +1483,13 @@ cat >"$bare/orchestration/queue/POLICY.md" <<'EOF' No frontmatter here, which is what every clone starts with. EOF -bq() { env FLEET_QUEUE_DIR="$tmp/bare-queue" "$bare/scripts/queue.sh" "$@"; } +# Its own conf root, with nothing in it, so the tracked example is what is read. +mkdir -p "$bare/orchestration" +cp orchestration/publish.example.conf "$bare/orchestration/publish.example.conf" +bq() { + env FLEET_QUEUE_DIR="$tmp/bare-queue" FLEET_PUBLISH_ROOT="$bare" \ + FLEET_AGENT_ROOT="$bare" "$bare/scripts/queue.sh" "$@" +} if out="$(bq topic add unconfigured --prompt 'a clone nobody has configured' 2>&1)"; then pass "a clone whose POLICY.md has no frontmatter opens a topic" @@ -1473,8 +1503,46 @@ expect "and its task defaults to pr, which needs no setup at all" \ "**Publish.** \`pr\`" "$b" refute "with no tool named, because nobody named one" "Here that means" "$b" +# THE CLAIM THE WHOLE AGNOSTICISM RESTS ON: the copy this repo SHIPS names no +# tool. A method that was a tool name, or a `HOW` with a command in it, would +# hand every clone one operator's pipeline — the same mistake the auto-merge +# allowlist made. +shipped_publish="$(python3 - <<'PY' +import sys + +sys.path.insert(0, "scripts/lib") +import queue as q + +conf = q.read_kv_conf("orchestration/publish.example.conf") +print("method=" + (conf.get("METHOD") or "none")) +print("how=" + (conf.get("HOW") or "none")) +print("methods=" + " ".join(sorted(q.PUBLISH_METHODS))) +PY +)" +expect "the tracked publish default is pr, which needs no tool" \ + "method=pr" "$shipped_publish" +expect "and it names no command at all" "how=none" "$shipped_publish" +expect "and the three methods are artifact shapes, none of them a tool name" \ + "methods=attested pr push" "$shipped_publish" + +# The retired spelling still loads, so a record written before the rename is +# not a record that has to be hand-edited. +alias_reads="$(python3 - <<'PY' +import sys + +sys.path.insert(0, "scripts/lib") +import queue as q + +print("alias=" + str(q.publish_method("no-mistakes"))) +print("kept=" + str(q.publish_method("pr"))) +PY +)" +expect "a task recorded under the old tool name still reads as its shape" \ + "alias=attested" "$alias_reads" +expect "and every other method is untouched by that" "kept=pr" "$alias_reads" + # (i) The stale attestation the pipeline caused ITSELF, told apart from every -# other one. `no-mistakes` writes the attestation while it opens the pull +# other one. `attested` writes the attestation while it opens the pull # request and can then push its own CI fixes on top, which leaves the body # naming an ancestor of the head — the shape of #38, #40 and #48, three # pull requests that could never auto-merge and that read, at collect @@ -1490,8 +1558,8 @@ $QUEUE add "$ptopic" pipeline-pushed-after \ attested_sha="$(printf 'a%.0s' $(seq 40))" pipeline_pr 1014 fix/pipeline-pushed-after "$attested_sha" pr_history 1014 \ - "$attested_sha chore: no-mistakes document - Sync the docs" \ - "$(printf '%040d' 1014) no-mistakes: apply CI fixes" + "$attested_sha chore: publish document - Sync the docs" \ + "$(printf '%040d' 1014) publish: apply CI fixes" cat >"$FLEET_QUEUE_DIR/$ptopic/10-pipeline-pushed-after/result.md" <<'EOF' --- outcome: shipped @@ -1505,7 +1573,7 @@ $QUEUE add "$ptopic" pushed-over-pipeline \ --repo /tmp/repo-a --branch fix/pushed-over-pipeline --number 11 >/dev/null pipeline_pr 1015 fix/pushed-over-pipeline "$attested_sha" pr_history 1015 \ - "$attested_sha chore: no-mistakes document - Sync the docs" \ + "$attested_sha chore: publish document - Sync the docs" \ "$(printf '%040d' 1015) fix: one more thing I thought of" cat >"$FLEET_QUEUE_DIR/$ptopic/11-pushed-over-pipeline/result.md" <<'EOF' --- @@ -1521,8 +1589,8 @@ expect "an attestation the pipeline outran is still not proof" \ expect "and the refusal names what moved the head" \ "the pipeline pushed that head itself" "$out" expect "and the commit that did it, so a lead need not go and look" \ - "no-mistakes: apply CI fixes" "$out" -expect "and the one thing that fixes it" "re-run \`/no-mistakes --yes\`" "$out" + "publish: apply CI fixes" "$out" +expect "and the one thing that fixes it" "publish command again" "$out" state="$($QUEUE show "$ptopic/10-pipeline-pushed-after" 2>&1)" refute "the gate is exactly as strict as it was — nothing here closes a task" \ @@ -1682,8 +1750,8 @@ copied = [m for m, spec in q.PUBLISH_METHODS.items() if spec["proof"] in text] print(f"default={method} how={bool(how)} copied={copied}") PY )" -expect "the operator's default is read out of POLICY.md, not typed into the code" \ - "default=no-mistakes how=True" "$policy_vs_code" +expect "the operator's default is read out of publish.conf, not typed into the code" \ + "default=attested how=True" "$policy_vs_code" expect "and the policy restates no proof sentence, so none of them can drift" \ "copied=[]" "$policy_vs_code" @@ -2111,7 +2179,7 @@ import sys out = sys.argv[1] green = {"__typename": "CheckRun", "name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"} -# Copied from a real no-mistakes body. The attestation is written DURING the +# Copied from a real attested body. The attestation is written DURING the # `pr` step, so `pr` reads `running` and `ci` `pending` in every body that # carries one; everything up to and including the push is `completed`. STEPS = [ @@ -2122,7 +2190,7 @@ STEPS = [ def attested(sha, steps=None): payload = json.dumps({"head_sha": sha, "steps": steps or STEPS}) - return f"\n\nShipped it.\n" + return f"\n\nShipped it.\n" def pr(n, owner="Thurbeen/fleet", sha=None, body=None, **kw): @@ -2565,7 +2633,7 @@ def pr(n, branch, body): payload = json.dumps({"head_sha": f"{201:040d}", "steps": STEPS}) pr(201, "tbx/attested", - f"\n\nShipped it.\n") + f"\n\nShipped it.\n") # Green in every way the forge can see, and nothing vetted the head that would # land. No task records it either, so nothing here spawns a fixer. pr(202, "tbx/unvetted", "Reviewed, tested, linted, and opened through the pipeline.\n") @@ -2594,7 +2662,7 @@ else pass "naming a repository loosens no gate: an unattested one is not merged" fi expect "and it is named for what it lacks, not passed over" \ - "the body carries no no-mistakes attestation" "$out" + "the body carries no attestation" "$out" # The typo the operator's own words invite, made IN THE FILE this time: the # list is host-qualified wherever it is written, and a bare slug names no forge. @@ -2676,7 +2744,7 @@ expect "and whatever is in force names its forge, so none can match a bare slug" # a `pr` task whose PR is green, mergeable and ours is recorded `green`, # gets no fixer, and is NOT merged — the forge is happy and NOTHING # vetted the head that would land, which is a different sentence -# a `no-mistakes` task whose PR carries no attestation is still recorded +# a `attested` task whose PR carries no attestation is still recorded # `unattested` and still gets the `policy` fixer # a dry run writes none of it # the landing sweep says merged/closed in that same block @@ -2735,11 +2803,11 @@ count_is "and no fixer goes out for it, on a branch a fixer could have had" \ "$(grep -c 'session create .*__09-plain-pr' "$shep/tbx.log")" 0 \ "$out$nl$(cat "$shep/tbx.log")" -# (c) The `no-mistakes` half of the same gate, unchanged: 03-skipped declared +# (c) The `attested` half of the same gate, unchanged: 03-skipped declared # the pipeline and opened its pull request by hand. state="$($QUEUE show "$stopic/03-skipped" 2>&1)" -expect "a no-mistakes PR with no attestation is recorded unattested" \ +expect "a attested PR with no attestation is recorded unattested" \ "published: unattested" "$state" if grep -q 'session create .*__03-skipped' "$shep/tbx.log"; then pass "and still gets the policy fixer it always got" @@ -2969,7 +3037,7 @@ expect "collect fetches a remote worker's result over ssh" \ "result fetched from me@devbox" "$out" expect "and closes the task on it, exactly as it would locally" "shipped" "$out" expect "and the publish check ran on it like any other" \ - "[publish verified: no-mistakes]" "$out" + "[publish verified: attested]" "$out" if [ -f "$FLEET_QUEUE_DIR/$rtopic/22-build-on-devbox/result.md" ]; then pass "the fetched result lands in the queue, where it outlives the host" else @@ -4523,7 +4591,7 @@ steps = [{"step": s, "status": "completed"} for s in ("intent", "rebase", "review", "test", "document", "lint", "push")] steps += [{"step": "pr", "status": "running"}, {"step": "ci", "status": "pending"}] payload = json.dumps({"head_sha": sha, "steps": steps}) -body = f"\n\n" + "\n".join( +body = f"\n\n" + "\n".join( f"## {h}\nx\n" for h in ("Intent", "What Changed", "Risk Assessment", "Testing", "Pipeline") ) @@ -4906,7 +4974,7 @@ doc.update({ "has_conflicts": False, "detailed_merge_status": "mergeable", "author": {"id": 7, "username": "letur", "name": "letur", "state": "active"}, - "description": "\n\n" % payload + "description": "\n\n" % payload + "\n".join("## %s\nx\n" % h for h in ("Intent", "What Changed", "Risk Assessment", "Testing", "Pipeline")), diff --git a/scripts/queue.sh b/scripts/queue.sh index 8cec07f..b0e0917 100755 --- a/scripts/queue.sh +++ b/scripts/queue.sh @@ -51,12 +51,12 @@ # `collect` reads the result.md a worker wrote when it knew what it had # concluded, and only that closes a task. It also CHECKS that # task's artifact against the PUBLISH METHOD the task declares — -# `no-mistakes`, `pr` or `push`, which name what the work must +# `attested`, `pr` or `push`, which name what the work must # LEAVE BEHIND rather than which tool made it. So "publish the way # your brief says" stops being an unverifiable instruction about a # method: collect asks the forge for a change request — a pull # request on GitHub, a merge request on GitLab — from this task's -# own branch (and, for `no-mistakes`, an attestation for the +# own branch (and, for `attested`, an attestation for the # commit that would merge), or asks git whether a `push` task's # commit reached the base branch. An artifact that is not there is # reported and the task is left OPEN; a check that could not run @@ -107,7 +107,7 @@ # `shepherd` asks the FORGE for every open change request on the repos this # queue's tasks name, DISPATCHES A FIXER for one that conflicts, # fails a check, has a review asking for changes, or was declared -# `no-mistakes` and carries no attestation — and squash-merges +# `attested` and carries no attestation — and squash-merges # one that clears every gate. It is a fourth thing, after both # halves of completion. # @@ -163,7 +163,7 @@ # scripts/queue.sh topic add --title T --prompt 'the ask' # or --prompt-file F|- # scripts/queue.sh add --title T --repo P --branch B [--base main] # [--host H] [--profile default] [--touches a,b] [--brief-file F] -# [--publish no-mistakes|pr|push] [--how 'run `/publish`'] +# [--publish attested|pr|push] [--how 'run `/publish`'] # # --brief-file fills whichever of the brief's four # # sections its own `## ` headings name; a body with no # # headings all goes into `What to do`. A file that From 632a8d4c9974cf4550ca105ff687b266c91a0140 Mon Sep 17 00:00:00 2001 From: LeTuR Date: Sat, 12 Sep 2026 10:48:59 +0200 Subject: [PATCH 4/6] feat(fleet): make the merge set, the publisher and the agent operator config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things fleet treated as facts about the world were facts about one operator: which repositories it may merge, which pipeline produces a publishable artifact, and which agent its workers run. All three were literals in tracked code, in a PUBLIC repo, so every clone inherited the last operator's answers. THE MERGE SET. Where fleet may merge was a literal in scripts/lib/queue.py, so naming a repository meant committing it — and every clone inherited merge rights over repositories that are not theirs. It is now orchestration/auto-merge.conf, the operator's own gitignored file beside owners.txt and session-glyphs.conf, read on every shepherd pass so an edit needs no reinstall and no restart. The tracked example names NOTHING, so a fresh clone merges nowhere until its operator says otherwise, and shepherd says which file would name one rather than producing the same silence as a repo nobody listed. FLEET_AUTO_MERGE_REPOS still REPLACES the set. It also closes a latent bug the old comment named: the literal was never parsed, so a bare slug written into it would have matched nothing and failed no test. One parser now serves both sources. THE PUBLISH SEAM. queue.py's own comment said the publish methods are ARTIFACT SHAPES and not tool names, "which is the whole of fleet's agnosticism, and it lasts exactly as long as nothing branches on it". One of the three shapes was `no-mistakes` — one operator's pipeline — the code branched on it, the attestation format was that tool's, and tracked POLICY.md shipped it as every clone's default. The third shape is now `attested`: a change request whose body carries an attestation for the commit that would merge. What an attestation LOOKS like is ATTESTATION_MARKER in the operator's gitignored orchestration/publish.conf, so fleet reads their pipeline's format rather than dictating one; PIPELINE_COMMIT_PREFIX is theirs too and is empty by default, so fleet never claims to know which commits a pipeline wrote. `no-mistakes` is accepted wherever a method is read and means `attested`, so existing task records load unedited. The default method and its free-text command moved out of tracked POLICY.md into the same conf — a block still in POLICY.md is honoured and warns once on stderr, so a live fleet keeps working while it moves. THE AGENT SEAM, in all four places an agent's name reached code: - DISPATCH. `--agent claude` is gone from all three spawn sites. With no AGENT in orchestration/agent.conf, session create keeps thurbox's own agents.toml default, which is where that answer already lived. - THE LEAD ITSELF, which the first pass missed: extension.toml.in carried `agent = "claude"` in tracked configuration, so a fresh clone launched Mission Control on one vendor's agent whatever its operator runs. It is now `__LEAD_AGENT__`, rendered by install-extension.sh from the same AGENT setting every worker reads, next to `__REPO_PATH__` and `__LEAD_GLYPH__`; unset still renders thurbox's stock `claude`, because thurbox must be given a name. - REFUEL. It no longer gates on a literal `claude` window: FUEL_PROVIDER, or the agent in hand mapped through AGENT_PROVIDERS (identity by default), and `undetermined` — which restarts nothing — when tasks disagree or quota-axi holds no credential for the derived name. Fleet carries no provider list; it asks quota-axi, so the set grows with the tool. fleet_status.py's own `FUEL_PROVIDER = ... or "claude"` was the last literal and is gone: the screen leads with the operator's provider, and with none named reads whichever one holds a credential rather than one this repo picked. - THE TRUST DIALOG, which §12b below found. session-trust.sh keeps one row per agent fleet has watched and REFUSES an unlisted one — correctly, since a guessed Enter exits Claude Code — but had no way for an operator to teach it theirs. So refuel restarted a worker on an untaught agent and never typed the brief into it. TRUST_SIGNATURE and TRUST_KEYS in agent.conf now teach it one, `TRUST_KEYS=none` says there is no dialog, and with neither set it still refuses rather than guessing. How an agent says it RAN OUT stays a table with one entry per agent fleet has actually watched do it; an agent with no entry answers `undetermined` rather than being guessed at. A guessed pattern restarts a live worker mid-turn. DRIVEN, NOT ASSERTED. scripts/lib/forge.py is this repo's standard — an interface, two implementations, and selftests that drive the second with the first's CLI a tripwire on PATH. The agent seam did not meet it: §12 ran `claude` end to end, so every built-in would have passed. queue-selftest.sh §12b now runs the whole of §12 again for `nova`, an agent with no entry anywhere in the code: the banner, the transcript layout, the provider and the trust dialog all come from agent.conf, CLAUDE_CONFIG_DIR is unset and the quota document names another provider, so a literal reached for on that path fails the section. fleet-status-selftest.sh §6d does the same for the fuel reader over three answers to "which provider" — the operator's agent, an explicit pin, and an unconfigured clone — with a grep over fleet_status.py as the tripwire. check.sh's `automerge` check fails any tracked setting carrying a repository, a tool, a vendor or an agent, reading every occurrence rather than the first, and now covers the two trust keys. CONTEXT, WHICH IS THE PRODUCT HERE. Measured in words, over the files a lead loads every session: 59879e13 #74 base #74 head now AGENTS+FLEET+CLAUDE 5,623 6,854 7,026 6,344 the five SKILL.md 21,312 24,028 23,748 23,762 The growth this branch was asked about is mostly not this branch: 16 commits landed on main between 59879e13 and its base, and they are +1,231 of the +1,403 always-loaded words and all +2,716 of the skills. The docs commit here was -280 on the skills and +172 on the always-loaded set. Two cuts, both against AGENTS.md's own rule that a file points at the authority rather than repeating it. AGENTS.md's numbered loop restated fleet-queue's §1-§7 and queue.sh's header in full; it is an index again, with every rule checked to still exist in the file that owns it before its copy went. FLEET.md's loop restated AGENTS.md's inside the same context window — both are always loaded — so it keeps what is the lead's and defers the mechanics. The forge bullet stopped summarising a docstring that says it owns the subject. Net: the always-loaded set is 682 words below this PR's head and 510 below its base. ./scripts/check.sh exits 0, every check included. --- .agents/skills/fleet-queue/SKILL.md | 15 +-- AGENTS.md | 141 +++++++++---------------- FLEET.md | 68 +++++-------- extension.toml.in | 21 ++-- orchestration/agent.example.conf | 24 +++++ scripts/check.sh | 3 +- scripts/fleet-status-selftest.sh | 112 +++++++++++++++++++- scripts/install-extension.sh | 41 +++++++- scripts/lib/fleet_status.py | 124 +++++++++++++++++----- scripts/queue-selftest.sh | 153 +++++++++++++++++++++++++++- scripts/session-trust.sh | 37 ++++++- 11 files changed, 554 insertions(+), 185 deletions(-) diff --git a/.agents/skills/fleet-queue/SKILL.md b/.agents/skills/fleet-queue/SKILL.md index 1b8a712..7a93367 100644 --- a/.agents/skills/fleet-queue/SKILL.md +++ b/.agents/skills/fleet-queue/SKILL.md @@ -777,13 +777,14 @@ worse than useless — each resumes, hits the same wall within seconds, and burn the reset it was waiting for. Three concurrent pipeline runs did that on 2026-08-29 and lost every step in flight. -It reads the `claude` account alone, through `fleet_status.probe_fuel`, because -the fleet dispatches `claude` agents and a spent window on a provider it never -uses must not strand one. `fleet-status.sh`'s `FUEL` section reads every -authenticated provider (`fleet_status.probe_fuel_all`), **so the two can -legitimately disagree** — the screen may show a provider fine while `refuel` -reports `claude` spent. A task running another agent is reported undetermined -rather than guessed at. +It reads ONE account, through `fleet_status.probe_fuel`: the provider the +tasks in hand draw on, derived from their agent or pinned by `FUEL_PROVIDER` in +`orchestration/agent.conf`. A spent window on a provider the fleet never +dispatches must not strand a worker, and tasks that disagree on an agent are +`undetermined` rather than guessed at. `fleet-status.sh`'s `FUEL` section reads +every authenticated provider (`probe_fuel_all`), **so the two can legitimately +disagree** — the screen may show one provider fine while `refuel` reports the +fleet's own spent. ```text account claude spent 0% remaining — five_hour resets 2026-09-09T02:10:00+00:00 diff --git a/AGENTS.md b/AGENTS.md index a49e6e9..783980c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,23 +65,15 @@ names every path and the reason for each. builds no forge URL. TWO implementations ship — GitHub through `gh`, GitLab through `glab` — and each is a CONFIGURATION and not an assumption, so a self-hosted instance is the ordinary case and not a special one. **Which - hosts the GitLab adapter owns is READ OFF THE MACHINE**: `forge.py`'s - `configured_hosts` takes every instance `glab auth status` reports, because - that is where the operator's answer already lives and `GITLAB_HOST` is a - variable nothing exports. `GITLAB_HOST` still decides when it IS set, the - GitHub adapter still takes `GH_HOST` alone, and discovery never becomes a - requirement — `configured_hosts`' own docstring owns those three and why. - `python3 scripts/lib/forge.py hosts ` prints the list for a shell - caller. The file's own header owns the interface and how to add a third. Two - things follow: a repository is identified by HOST plus path + hosts the GitLab adapter owns is READ OFF THE MACHINE**, from `glab auth + status`; `configured_hosts`' own docstring owns that rule and its two + overrides, and the file's header owns the interface and how to add a third. + Two things follow: a repository is identified by HOST plus path (`github.com/Thurbeen/fleet`), because a bare `owner/repo` names two - different repositories once two forges exist; and `queue-selftest.sh` drives - `collect`, the landing check and `shepherd` through a second forge with no - network behind it — §13 through a fake one, §14 through the real GitLab - adapter over recorded `glab` output in `scripts/fixtures/glab/` (whose README - says which files are recorded and which are constructed). `gh` on those - sections' PATH is a tripwire, which is what keeps the seam honest rather than - merely asserted. + different repositories once two forges exist; and **the seam is driven, not + asserted** — `queue-selftest.sh` §13 and §14 run `collect`, the landing check + and `shepherd` through a second forge with `gh` on PATH as a tripwire. That + is the bar every other seam here is judged against. - `orchestration/reconcile/` — the reconciler's runtime state: its supervisor's pid, the heartbeat proving its loop is ticking, its log, the advisory `nudge` flag, the `down` flag, and `notified.json` — which ready tasks the lead has @@ -159,7 +151,9 @@ This repo drives [thurbox](https://github.com/Thurbeen/thurbox) **directly**. Do not invoke an external `orchestrate` skill or any other outside orchestration workflow — the control plane is self-contained. -The loop, driven by `./scripts/queue.sh`: +The loop, driven by `./scripts/queue.sh`, whose header is its full usage and +whose rules `.agents/skills/fleet-queue/` owns. What follows is the index, not +a second copy — read the skill before you run any of it: 1. **Intake.** A prompt becomes a topic, kept verbatim, decomposed into tasks — one repo, one branch, one thing a single worker can finish and validate. @@ -168,86 +162,45 @@ The loop, driven by `./scripts/queue.sh`: looks like, from scratch. `dispatch` refuses a brief that is still the scaffold's placeholder. 3. **Order, then dispatch the whole ready set at once.** File or subsystem - overlap is a RISK SIGNAL that gets reported rather than held back. Serialize - only for a true semantic dependency, shared mutable external state, an - incompatible concurrent migration, or another concrete condition that makes - independent progress unsafe — and record it with `queue.sh block`, which - refuses one that names no kind and no reason. A queue that runs one task at - a time is slower than no queue at all. **A blocker names a task or a - CONDITION**, and the second form is what a task held by something the queue - cannot observe gets written down as — a credential, an approval, a window, a - machine somebody has to fix, a decision nobody has made. `--on ` clears - when that task LANDS; `--condition ''` clears only when somebody runs - `block --clear` naming it back, so nothing — no timer, no `collect`, no - `reap` — can release a task on a guess. Before it existed, a task whose brief - began by reading an Azure nobody was logged into read as ready and the - reconciler woke the lead to dispatch it. + overlap is a RISK SIGNAL that gets reported rather than held back; serialize + only for a concrete condition that makes independent progress unsafe, and + record it with `queue.sh block`. **A blocker names a task or a CONDITION**, + and only a person clears the second kind — nothing releases a task on a + guess. A queue that runs one task at a time is slower than no queue at all. 4. Each worker targets a real repo and its own git worktree — the control plane holds the plan and the log, never the workers' branches. `dispatch` gets each new session past its agent's trust dialog before it sends the brief (`./scripts/session-trust.sh`), because sending one into that dialog is how - every fleet-spawned worker used to break. A task may name a `--host` from - thurbox's `hosts.toml` and run on that machine instead; `--repo` is then a - path THERE, three probes run before anything is spawned — reachable, the - repo is there, and it has its own credentials for the forge THAT repo's - `origin` names, which is why a GitLab checkout is not probed against - github.com — and the brief and the result travel by ssh so that completion - stays one model. No host means no change. + every fleet-spawned worker used to break. A task may name a `--host` and run + on that machine instead, probed first and carried by ssh, so that completion + stays one model. 5. **Completion is two things you read, never something that interrupts you.** - `queue.sh watch` folds `thurbox-cli watch`'s event stream into each task's - record and closes nothing; `queue.sh collect` reads the `result.md` the - worker wrote and only that closes a task. A turn ending is not a task - finishing. The run log records itself as this happens — `topic add` opened - it and each of these commands refreshes its facts — so what is left for you - is the half no record can hold: the goal in your words, the decisions, what - went wrong, the outcome. Write those into it while you still know them. -6. **Release is a third thing, and it is not manual.** `outcome: shipped` - means a change request is OPEN, or, for a task whose declared publish method - is `push`, a commit already on the base branch — and that session is kept as - the cheap way to fix what review finds. A task moves to `landed` only when - the FORGE says its artifact merged (immediately, for `push`, since there is - nothing open to wait on), and `queue.sh reap` — which `collect` runs itself — - deletes the session and its worktree then. It never touches one thurbox says - is working or blocked, nor one a worker gave up in: that session is the - evidence. `reap --dry-run` says what it would do. Blockers clear on `landed` - too, so a dependent task waits for the code to actually be on `main`. The - same sweep ARCHIVES a topic whose every task reached `landed` or `abandoned` - — a flag on `topic.yaml` that drops it from all four default views, each of - which still prints how many it is hiding. `stuck` and `failed` are not - terminal for that, `list --archived` and `show ` still reach it, and - `add` un-archives. + `queue.sh watch` folds thurbox's event stream into each task's record and + closes nothing; `queue.sh collect` reads the `result.md` the worker wrote, + and only that closes a task. A turn ending is not a task finishing. The run + log refreshes its own facts as this happens, which leaves you the half no + record can hold: the goal in your words, the decisions, what went wrong. + Write those in while you still know them. +6. **Release is a third thing, and it is not manual.** `shipped` means the + artifact exists and the session is kept, because it is the cheap way to fix + what review finds. Only the FORGE saying it merged moves a task to `landed`, + and `queue.sh reap` — which `collect` runs itself — deletes the session + then. It never touches one that is working, blocked, or was given up in: + that session is the evidence. Blockers clear on `landed`, and a topic whose + every task is terminal archives itself. 7. **The change request outlives the task, so `queue.sh shepherd` is a fourth - thing, run as reflexively as `collect`** — which names it whenever it closed - a task that left one open. It asks the FORGE for every open change request - on the repos the queue's tasks name, not the tasks' recorded artifacts. One - is linked back by artifact or head branch; an unlinked one is - still classified and merged, it just has no session to fix it. It merges - only in the repos the operator named in `orchestration/auto-merge.conf` — - their own file, gitignored, read every pass, with the tracked - `auto-merge.example.conf` beside it naming NONE, so a fresh clone of this - public repo merges nowhere until its operator says otherwise; each entry - host-qualified, and one that names no forge refused rather than - matched — and only for one whose head branch is in that repo, opened by - someone who can - push there, carrying an attestation for its **current** head. - Squash is the only method fleet merges by, and a forge or a project that - forbids squash — a GitLab project can — is a refusal fleet RECORDS rather - than a merge by some other method. - That attestation gate is the one thing the declared publish - method moves: a task that was declared `attested` and carries none gets a - fixer, one that was never asked for one is recorded `green` and handed back - unmerged. Every pass writes what it saw onto the task's `publish` block. - `--dry-run` first; the fleet-queue skill owns the rest. + thing, run as reflexively as `collect`.** It asks the FORGE for every open + change request on the repos the queue names, not the tasks' recorded + artifacts, and merges only where the operator's own + `orchestration/auto-merge.conf` says it may — the tracked example names + NONE, so a fresh clone of this public repo merges nowhere. Squash is the + only method it merges by. `--dry-run` first. 8. **A worker that hits its agent's token limit does not fail — it sits, and - nothing above ever notices.** `queue.sh refuel` is a fifth thing: it asks the - account's shared quota window first, via `quota-axi`, and restarts nothing - while that window is spent — a resumed worker would only hit the same wall - and burn the reset. With fuel in the account it restarts a session only when - a stale `working` state is paired with the agent's own limit signal, caps - restarts at three per task, and writes neither `state` nor `outcome` — - `collect` alone still closes the task. -9. Review the PRs; the operator merges every one `shepherd` did not, and - everything after that is `reap`'s. + nothing above ever notices.** `queue.sh refuel` is a fifth thing: the + account's shared quota window first, and a restart only when a stale + `working` is paired with the agent's own limit signal. +9. Review the change requests; the operator merges every one `shepherd` did + not, and everything after that is `reap`'s. **Nothing above happens because somebody remembered to run it.** `./scripts/reconcile.sh` is a supervised loop — `ensure` / `start` / `stop` / @@ -343,9 +296,11 @@ know before you debug the extension: extension's real update command. - **The lead SESSION is Mission Control; the EXTENSION is still `fleet`**, which is why every command above still takes `fleet`. The extension registers - **no agent of its own**: the lead binds to thurbox's stock `claude`, so it - inherits the hook settings that let it report state and whatever model - `claude` defaults to. `extension.toml.in`'s no-`[[agents]]` note owns why. A + **no agent of its own**: the lead binds to a stock thurbox agent — `AGENT` in + `orchestration/agent.conf`, rendered into the manifest, else thurbox's own + `claude` — so it inherits the hook settings that let it report state and + whatever model that agent defaults to. + `extension.toml.in`'s no-`[[agents]]` note owns why. A glyph is part of the session name because thurbox has no per-session icon field, and WHICH glyph is a setting (see the glyph bullet above), so the mailbox address must be **pasted** out of `thurbox-cli session list`, not diff --git a/FLEET.md b/FLEET.md index 8eb040f..7390723 100644 --- a/FLEET.md +++ b/FLEET.md @@ -70,6 +70,9 @@ YAML by hand. Nothing to push — the map is gitignored. ## The loop +`AGENTS.md` owns the mechanics of every step below and `fleet-queue` owns how +to run them. What is here is what is YOURS in each. + 1. **A prompt becomes a topic**, not a turn in this conversation. `./scripts/queue.sh topic add` keeps it verbatim; `add` decomposes it into tasks, one per unit of work. @@ -82,33 +85,18 @@ YAML by hand. Nothing to push — the map is gitignored. 4. **`watch` on your own cadence, then `collect`.** The event stream says WHEN a turn ended; the worker's own result file says WHAT it concluded. A turn ending is not a task finishing, and only `collect` closes anything. -5. The run log records itself: `topic add` opened it and `dispatch`, `collect` - and `shepherd` keep its facts current as you run them. Write the goal, - decisions and outcome into it in your own words — that half never comes - from a record. It is gitignored and not backed up by the repo. -6. **`shepherd`, as reflexively as `collect`.** The change request outlives the - task, and `collect` names `shepherd` whenever it closed one that left one - open. It asks the forge for every open change request on the queue's repos, - not just recorded artifacts, dispatches a fixer for one that conflicts, fails - a check, was reviewed with changes requested, or was declared `attested` - and carries no attestation for its current head, and squash-merges one that - clears every gate in the repos `orchestration/auto-merge.conf` names — your - own file, gitignored, empty until you write it, so fleet merges nowhere - until then. Entries there name their forge (`github.com/owner/repo`, - `gitlab.example.com/acme/group/widgets`), because a bare `owner/repo` - is two different repositories once two forges are configured. It writes down - what it saw either way, so a task's record says `checks-running` or - `unattested` and not just `shipped`. +5. The run log records itself as you run those. Write the goal, decisions and + outcome into it in your own words — that half never comes from a record. It + is gitignored and not backed up by the repo. +6. **`shepherd`, as reflexively as `collect`**, which names it whenever it + closed a task that left a change request open. It writes down what it saw + either way, so a task's record says `checks-running` or `unattested` and not + just `shipped`. 7. Review them; the operator merges every one `shepherd` did not. Sessions - release themselves once their artifact lands on the base branch — a merged - change request, or, for a task that published by pushing directly, the - commit itself — `collect` reaps them, `queue.sh reap --dry-run` shows what - it would do — see `AGENTS.md`. + release themselves once their artifact lands, and `collect` reaps them. 8. **`refuel` a worker that hit its agent's token limit and never reported — thurbox keeps saying `working` because the idle hook never fires.** It asks - the account's own quota window (below) before it looks at any session, and - restarts nothing while that window is spent; see `AGENTS.md` and - `fleet-queue` §5c. + the account's own quota window (below) before it looks at any session. **A landing is not an ending — it is what unblocks the next task.** So `plan` is the last thing you run before you go quiet, not the first thing you run when @@ -148,19 +136,18 @@ per-worker reading to be had — `thurbox-cli session get` carries no token, usage, cost or limit field at all. **One reading per subscription you actually have.** `quota-axi auth` says which -providers hold a working credential — `claude`, and whatever else is signed in -on the machine — and those, in one call, are what gets read; a provider with no -credential is never probed. Each is its OWN reading, on its own clock, and -nothing is summed or averaged across them: the screen prints a block per -provider and the pane draws a labelled bar beside each percentage. A provider -whose fetch failed says so on the screen and carries no number at all, never a -zero; the pane leaves it out entirely, and says `unavailable` only when nothing -read. - -A provider's windows reset independently — claude has three, a session window, -a week and a per-model week. That provider's reading is the lowest of them, the -screen names which one binds and prints them all with their own resets, and a -reading served from cache says `stale` and how old it is. +providers hold a working credential, and those, in one call, are what gets +read; a provider with no credential is never probed. Each is its OWN reading, +on its own clock, and nothing is summed or averaged across them: the screen +prints a block per provider and the pane draws a labelled bar beside each +percentage. A provider whose fetch failed says so on the screen and carries no +number at all, never a zero; the pane leaves it out entirely, and says +`unavailable` only when nothing read. + +A provider's windows reset independently — a session window, a week, a +per-model week. That provider's reading is the lowest of them, the screen names +which one binds and prints them all with their own resets, and a reading served +from cache says `stale` and how old it is. **The reserve is 20%, per provider. Below it you dispatch nothing new.** That is the rule, and it is checkable rather than a feeling: the screen prints the @@ -170,9 +157,10 @@ field, which is that window's pace against its reset clock and is `unknown` for every window whose fetch failed. Nothing enforces the floor for you — `queue.sh dispatch` does not read fuel and must not, because a queue that stops on a bad parse is worse than one that spends. `queue.sh refuel` does read -it, and reads `claude` ALONE: that is the agent the workers run, so a spent -window on a provider fleet does not dispatch is no reason to leave a `claude` -worker sitting at its limit. +it, and reads ONE provider: the one your agent draws on, from +`orchestration/agent.conf`. A spent window on a provider fleet does not +dispatch is no reason to leave a worker sitting at its limit, and tasks that +disagree on an agent are `undetermined`, which restarts nothing. Near the floor you spend fuel on dispatching and on nothing else: diff --git a/extension.toml.in b/extension.toml.in index 4b2e8b1..25bd278 100644 --- a/extension.toml.in +++ b/extension.toml.in @@ -285,7 +285,7 @@ version = "1.3.0" # this extension's own version; bump on changes min_thurbox_version = "2.19.0" # No `home`: defaults to `/extensions/fleet` (override with --home). -# --- no [[agents]] block: the lead runs the stock `claude` agent --------------- +# --- no [[agents]] block: the lead runs a STOCK thurbox agent ------------------ # # There used to be one here, named `fleet`, and it existed only to pin a model: # `args = ["--model", "claude-opus-4-8"]`. Everything else in it — @@ -305,10 +305,19 @@ min_thurbox_version = "2.19.0" # still runs `agent = "claude"` with `hook_coverage: full`. The bug was latent, # waiting for the next `extension deactivate` + install to recreate the session. # -# So: no agent is registered, the session binds to `claude` below, and the model -# is whatever `claude` defaults to. To pin one anyway, add the block back with -# BOTH flags — the settings path and the model — or set the model in -# `~/.claude/settings.json`, which needs no block at all. +# So: no agent is registered, the session binds to `__LEAD_AGENT__` below, and +# the model is whatever that agent defaults to. To pin one anyway, add the block +# back with BOTH flags — the settings path and the model — or set the model in +# that agent's own settings, which needs no block at all. +# +# WHICH stock agent is the operator's setting and not this repo's. `AGENT` in +# `orchestration/agent.conf` names it — the same setting every worker spawn +# reads — and `scripts/install-extension.sh` substitutes it here the way it +# substitutes `__REPO_PATH__`. With it unset the placeholder renders `claude`, +# which is the agent thurbox ships and the one this manifest was written +# against: a fallback for a name thurbox must be given, not a claim that fleet +# needs Claude. A change takes effect on the next install and, like a rename, +# reaches only a session that is recreated. # --- payload files laid down under home --------------------------------------- # @@ -356,7 +365,7 @@ target = "FLEET.rendered.md" [[sessions]] name = "__LEAD_GLYPH__ Mission Control" -agent = "claude" +agent = "__LEAD_AGENT__" # The session opens your checkout, not the extension home: it needs registry/ # and orchestration/ in hand, and the repo's SessionStart hook # (.claude/settings.json) fast-forwards main before any work starts. diff --git a/orchestration/agent.example.conf b/orchestration/agent.example.conf index eb698d8..9f01ae9 100644 --- a/orchestration/agent.example.conf +++ b/orchestration/agent.example.conf @@ -66,6 +66,30 @@ FUEL_PROVIDER= LIMIT_BANNER= TRANSCRIPT_DIR= +# --- ITS TRUST DIALOG, WHICH IS THE OTHER HALF OF THE SAME PROBLEM ------------ +# +# `scripts/session-trust.sh` answers the "do you trust this folder" dialog +# before a brief is typed, because sending a prompt into that dialog is how +# every fleet-spawned worker used to break. Its table carries one row per agent +# fleet has watched, and an agent that is not in it is REFUSED rather than +# guessed at — the row for Claude Code is why: its default selection is "No, +# exit", so a bare Enter kills the agent. +# +# These two teach it yours. `TRUST_SIGNATURE` is a case-insensitive regex +# matched against the pane; make it specific enough that ordinary agent output +# cannot produce it. `TRUST_KEYS` is the space-separated key sequence that +# accepts, defaulting to `enter`: +# +# TRUST_SIGNATURE=do you trust the contents of this directory +# TRUST_KEYS=down enter +# +# `TRUST_KEYS=none` is the other answer: this agent shows no dialog at all, so +# there is nothing to answer and the session is only confirmed as up. An agent +# whose trust is a LAUNCH FLAG rather than a keystroke is neither — put the +# flag in an `orchestration/session-profiles.yaml` profile instead. +TRUST_SIGNATURE= +TRUST_KEYS= + # --- WHEN THE AGENT AND ITS PROVIDER ARE NAMED DIFFERENTLY -------------------- # # `refuel` reads the quota of the PROVIDER behind the agent in hand. quota-axi diff --git a/scripts/check.sh b/scripts/check.sh index 77962c8..3991980 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -646,7 +646,8 @@ print("entries=" + (" ".join(sorted(q.auto_merge_repos(q.checkout_root()))) or " # An agent or a provider here would gate every operator's fleet on one # operator's vendor. Empty means "thurbox's own" and "derive it". if [ -f "$ag" ]; then - for key in AGENT FUEL_PROVIDER LIMIT_BANNER TRANSCRIPT_DIR AGENT_PROVIDERS; do + for key in AGENT FUEL_PROVIDER LIMIT_BANNER TRANSCRIPT_DIR \ + AGENT_PROVIDERS TRUST_SIGNATURE TRUST_KEYS; do val="$(sed -n "s/^$key=//p" "$ag" | tr -d '[:space:]')" [ -z "$val" ] || { fail "automerge: $ag ships $key=$val; that is the operator's"; miss=1; } diff --git a/scripts/fleet-status-selftest.sh b/scripts/fleet-status-selftest.sh index 3836e66..d637966 100755 --- a/scripts/fleet-status-selftest.sh +++ b/scripts/fleet-status-selftest.sh @@ -307,6 +307,13 @@ expect "and the floor is named as the thing it is under" "under the 20% reserve" stale="$(sandbox "$tmp/bin-stale" "${BASE_TOOLS[@]}")" cat >"$stale/quota-axi" <<'STUB' #!/bin/sh +if [ "$1" = auth ]; then + cat <<'JSON' +{"generatedAt":"2026-09-08T21:29:21.000Z","schemaVersion":1,"auth":[ + {"provider":"claude","sources":[{"source":"oauth-file","status":"available"}]}]} +JSON + exit 0 +fi cat <<'JSON' {"generatedAt":"2026-09-08T21:29:21.000Z","schemaVersion":5,"providers":[ {"provider":"claude","plan":"max","source":"cache", @@ -341,6 +348,13 @@ expect "and why it could not be refreshed" "rate limited" "$cached" mute="$(sandbox "$tmp/bin-mute" "${BASE_TOOLS[@]}")" cat >"$mute/quota-axi" <<'STUB' #!/bin/sh +if [ "$1" = auth ]; then + cat <<'JSON' +{"generatedAt":"2026-03-15T16:42:00.000Z","schemaVersion":1,"auth":[ + {"provider":"claude","sources":[{"source":"oauth-file","status":"available"}]}]} +JSON + exit 0 +fi cat <<'JSON' {"generatedAt":"2026-03-15T16:42:00.000Z","schemaVersion":5,"providers":[ {"provider":"claude","windows":[], @@ -414,8 +428,11 @@ PY expect "read_at is epoch seconds a pane can subtract" "epoch" "$age" # The unavailable case is the one a pane gets wrong: it must be a REASON, never -# a zero and never an empty record that reads as 0% left. -mutrec="$(PATH="$mute" "$STATUS" --fuel 2>&1)" +# a zero and never an empty record that reads as 0% left. The provider is NAMED +# here because this stub's `auth` read discovers none, and with nothing +# discovered and nothing configured the honest answer is that there is nothing +# to read — see 6c, which is where that seam is proved. +mutrec="$(PATH="$mute" FLEET_FUEL_PROVIDER=claude "$STATUS" --fuel 2>&1)" expect "an unreadable reading is a reason, in quota-axi's words" \ "unavailable auth_required; Claude sign-in required" "$mutrec" refute "and carries no invented number" "remaining " "$mutrec" @@ -546,6 +563,97 @@ else fail "--fuel is one fetch" "$(cat "$tmp/quota-calls")" fi +# --- 6d. WHICH provider is the operator's setting, never a name in the code --- +# +# `scripts/lib/forge.py` sets the bar this section is written to: a seam is +# worth what a second implementation driven through it is worth, and an +# assertion that one exists is worth nothing. So the same stub is read three +# times over three different answers to "which provider", and the reading has +# to follow the setting each time. +# +# The FIRST answer is `orchestration/agent.conf`, which is the operator's file +# and the same one `queue.py` reads for `refuel` — relocated with +# FLEET_AGENT_ROOT so this run never sees, and never writes, the real one. +# `AGENT` alone is enough: with no `FUEL_PROVIDER`, the agent's own name is its +# provider name, which is the identity map `agent_providers()` ships. +# +# The THIRD is nothing at all — a fresh clone, which is what the tracked +# `agent.example.conf` leaves behind. The screen must still read, so it reads +# whichever provider holds a credential. THAT is the claim a literal used to +# hide: with `or "claude"` in the code, this case read Claude on a machine that +# had never signed in to it. + +seam="$(sandbox "$tmp/bin-seam" "${BASE_TOOLS[@]}")" +cat >"$seam/quota-axi" <<'STUB' +#!/bin/sh +if [ "$1" = auth ]; then + cat <<'JSON' +{"generatedAt":"2026-03-15T16:42:00.000Z","schemaVersion":1,"auth":[ + {"provider":"zai","sources":[{"source":"opencode:auth.json","status":"available"}]}, + {"provider":"nova","sources":[{"source":"auth-json","status":"available"}]}]} +JSON + exit 0 +fi +cat <<'JSON' +{"generatedAt":"2026-03-15T16:42:00.000Z","schemaVersion":5,"providers":[ + {"provider":"zai","plan":"pro","source":"oauth", + "windows":[{"id":"five_hour","label":"5h","percentRemaining":61, + "resetsAt":"2026-03-15T21:00:00.000Z"}], + "state":{"status":"ok","stale":false}}, + {"provider":"nova","plan":"team","source":"oauth", + "windows":[{"id":"five_hour","label":"5h","percentRemaining":12, + "resetsAt":"2026-03-15T22:00:00.000Z"}], + "state":{"status":"ok","stale":false}}]} +JSON +STUB +chmod +x "$seam/quota-axi" + +first_record() { printf '%s\n' "$1" | sed -n 's/^provider //p' | head -1; } + +conf="$tmp/agentconf" +mkdir -p "$conf/orchestration" +printf 'AGENT=nova\n' >"$conf/orchestration/agent.conf" +byconf="$(PATH="$seam" FLEET_AGENT_ROOT="$conf" "$STATUS" --fuel 2>&1)" +expect "the agent the operator named is the reading fleet leads with" \ + "nova" "$(first_record "$byconf")" +expect "and the other subscription is still read, just not first" \ + "provider zai" "$byconf" + +printf 'AGENT=nova\nFUEL_PROVIDER=zai\n' >"$conf/orchestration/agent.conf" +bypin="$(PATH="$seam" FLEET_AGENT_ROOT="$conf" "$STATUS" --fuel 2>&1)" +expect "FUEL_PROVIDER outranks the agent, for a fleet whose names differ" \ + "zai" "$(first_record "$bypin")" + +# The gate's own entry point, which is what `queue.sh refuel` calls: ONE +# provider, and with none passed it is the operator's — never an average and +# never a name from this file. +gate="$(PATH="$seam" FLEET_AGENT_ROOT="$conf" python3 - <<'PY' 2>&1 +import importlib.util +spec = importlib.util.spec_from_file_location("fs", "scripts/lib/fleet_status.py") +mod = importlib.util.module_from_spec(spec) +spec.loader.exec_module(mod) +print("gate=" + str(mod.probe_fuel()["provider"])) +PY +)" +expect "the gate reads the one provider the operator named" "gate=zai" "$gate" + +rm -f "$conf/orchestration/agent.conf" +bare_conf="$(PATH="$seam" FLEET_AGENT_ROOT="$conf" "$STATUS" --fuel 2>&1)" +expect "an unconfigured clone still reads the subscriptions it has" \ + "provider nova" "$bare_conf" +refute "and names no vendor this repo chose" "claude" "$bare_conf" + +# The tripwire, and the reason this section is not prose: the module that +# decides which provider to read must not carry an agent's name at all. A +# fallback literal would pass every assertion above on a machine that happens +# to be signed in to it. +if grep -qiE 'claude|anthropic' scripts/lib/fleet_status.py; then + fail "the fuel reader names no vendor" \ + "$(grep -inE 'claude|anthropic' scripts/lib/fleet_status.py)" +else + pass "the fuel reader names no vendor, so there is nothing to fall back to" +fi + # --- 7. it reads, and only reads --------------------------------------------- snapshot() { find "$FLEET_QUEUE_DIR" -type f -exec sha256sum {} + | sort; } diff --git a/scripts/install-extension.sh b/scripts/install-extension.sh index dee8c39..24087bd 100755 --- a/scripts/install-extension.sh +++ b/scripts/install-extension.sh @@ -19,6 +19,12 @@ # spawns. Nothing else in the repo spells the glyph: the pane matches the lead # without it, and prose calls the lead Mission Control. # +# `__LEAD_AGENT__` is the third, and it is here because WHICH agent runs the +# lead is the operator's answer and this repo is public. `AGENT` in +# `orchestration/agent.conf` — the same setting every worker spawn reads — is +# where it lives, and with none set this renders thurbox's stock `claude`, +# which is a name thurbox must be given rather than a coupling to one vendor. +# # IT ALSO RENDERS THE PAYLOAD, for the same reason and out of a second setting. # `FLEET.md` is the lead's standing context, and the two names it is written # around — what the lead calls the operator, and what it answers to — are the @@ -170,6 +176,28 @@ case "$LEAD_GLYPH" in ;; esac +# The agent the LEAD session binds to, read the same way and from the same pair +# the worker spawns read: `AGENT` in the operator's `orchestration/agent.conf`, +# else the tracked example, which names none. thurbox needs a name here, so an +# unset setting falls back to its own stock `claude` — the agent it ships and +# the one this manifest was written against. FLEET_AGENT_ROOT relocates the +# pair so the gate can render an operator's answer without having one. +AGENT_ROOT="${FLEET_AGENT_ROOT:-$REPO_ROOT}" +AGENT_CONF="$AGENT_ROOT/orchestration/agent.conf" +[ -f "$AGENT_CONF" ] || AGENT_CONF="$AGENT_ROOT/orchestration/agent.example.conf" +LEAD_AGENT="" +[ -f "$AGENT_CONF" ] && LEAD_AGENT="$(sed -n 's/^AGENT=//p' "$AGENT_CONF" | head -1)" +[ -n "$LEAD_AGENT" ] || LEAD_AGENT="claude" + +# A thurbox agent name is a bare identifier; anything else would either break +# the substitution below or register a session against an agent that cannot +# exist. +case "$LEAD_AGENT" in +*[!A-Za-z0-9_-]*) + die "AGENT in $AGENT_CONF is not a bare agent name: $LEAD_AGENT" + ;; +esac + # The voice setting, read the same way and from the same kind of pair: the # operator's own copy when there is one, the tracked defaults when there is not. # FLEET_VOICE_CONF is the seam `scripts/check.sh voice` renders through, so the @@ -207,19 +235,22 @@ done tmp="$(mktemp)" trap 'rm -f "$tmp"' EXIT -sed -e "s|__REPO_PATH__|$REPO_ROOT|g" -e "s|__LEAD_GLYPH__|$LEAD_GLYPH|g" "$IN" >"$tmp" +sed -e "s|__REPO_PATH__|$REPO_ROOT|g" -e "s|__LEAD_GLYPH__|$LEAD_GLYPH|g" \ + -e "s|__LEAD_AGENT__|$LEAD_AGENT|g" "$IN" >"$tmp" # Refuse to install a half-rendered manifest: an unsubstituted placeholder would -# register a session pointing at a directory literally named __REPO_PATH__, or -# a lead whose name begins with the word __LEAD_GLYPH__. -if grep -q '__REPO_PATH__\|__LEAD_GLYPH__' "$tmp"; then +# register a session pointing at a directory literally named __REPO_PATH__, a +# lead whose name begins with the word __LEAD_GLYPH__, or one bound to an agent +# thurbox has never heard of. +if grep -q '__REPO_PATH__\|__LEAD_GLYPH__\|__LEAD_AGENT__' "$tmp"; then die "placeholder survived substitution; $OUT not written" fi [ -s "$tmp" ] || die "rendered manifest is empty; $OUT not written" mv "$tmp" "$OUT" trap - EXIT -printf 'rendered %s (repo_path = %s, lead glyph = %s)\n' "$OUT" "$REPO_ROOT" "$LEAD_GLYPH" +printf 'rendered %s (repo_path = %s, lead glyph = %s, agent = %s)\n' \ + "$OUT" "$REPO_ROOT" "$LEAD_GLYPH" "$LEAD_AGENT" # The payload, from the same tracked source and under the same refusal: a # surviving placeholder would ship the lead a context file telling it to address diff --git a/scripts/lib/fleet_status.py b/scripts/lib/fleet_status.py index 2aadbaf..8a3a7f8 100644 --- a/scripts/lib/fleet_status.py +++ b/scripts/lib/fleet_status.py @@ -369,8 +369,61 @@ def probe_checkout() -> dict: # The provider the SCREEN leads with, and the fallback when quota-axi names # none. Not a gate: `probe_fuel_all()` reads every authenticated provider, and # `scripts/lib/queue.py`'s `refuel` asks for its provider by name rather than -# inheriting this. A literal here only decides which row sorts first. -FUEL_PROVIDER = os.environ.get("FLEET_FUEL_PROVIDER", "").strip() or "claude" +# inheriting this. +# +# NO NAME IS WRITTEN HERE, for the same reason `queue.py`'s `fuel_agent()` +# writes none: a literal would make one operator's vendor this repo's answer, +# and this file is tracked. The operator's own `orchestration/agent.conf` is +# where that answer already lives — `FUEL_PROVIDER` outright, else `AGENT` +# read as its own provider name, which is the identity `agent_providers()` +# ships. With neither set the screen has no preference and simply draws +# quota-axi's own order; `FLEET_FUEL_PROVIDER` overrides both for one run. +AGENT_CONF = "orchestration/agent.conf" +AGENT_CONF_DEFAULTS = "orchestration/agent.example.conf" + + +def agent_conf() -> dict: + """`KEY=value` lines from the agent settings in force, read as data. + + The operator's copy when it exists, the tracked example beside it when it + does not — the same two-file rule `queue.py` applies to every + `orchestration/*.conf`, restated here only because this module is loaded + from `queue.py` and cannot import it back. + """ + root = os.environ.get("FLEET_AGENT_ROOT") or REPO_ROOT + path = os.path.join(root, AGENT_CONF) + if not os.path.exists(path): + path = os.path.join(root, AGENT_CONF_DEFAULTS) + conf = {} + try: + with open(path) as fh: + for raw in fh: + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + conf[key.strip()] = value.strip() + except OSError: + return {} + return conf + + +def fuel_provider() -> str: + """The provider this screen leads with, or "" for no preference. + + `FLEET_FUEL_PROVIDER` for one run, else the operator's `FUEL_PROVIDER`, + else their `AGENT` read as its own provider name — the identity map + `queue.py`'s `agent_providers()` ships. "" is a real answer and means the + operator has named none; `probe_fuel()` then reads whichever provider holds + a credential rather than this file naming a vendor. + """ + pinned = os.environ.get("FLEET_FUEL_PROVIDER", "").strip() + if pinned: + return pinned + conf = agent_conf() + return conf.get("FUEL_PROVIDER", "").strip() or conf.get("AGENT", "").strip() + + # The floor the lead does not dispatch past, in percent remaining. FLEET.md's # `## Fuel` section owns the rule; this is the same number so the screen can # print it beside the reading. @@ -398,11 +451,11 @@ def fuel_reason(state: dict) -> str: def fuel_windows(provider: dict) -> list: """The provider's windows that carry a number, in declaration order. - Claude has three and they reset independently — `five_hour`, `seven_day` - and a `model:` week — so there is no one reset to report and - quota-axi deliberately does not invent one. A window with no `resetsAt` has - not been triggered yet rather than being a gap, so it is kept and its reset - is simply absent. + A provider can have several that reset independently — a session window, a + week, a per-model week — so there is no one reset to report and quota-axi + deliberately does not invent one. A window with no `resetsAt` has not been + triggered yet rather than being a gap, so it is kept and its reset is + simply absent. """ out = [] for w in provider.get("windows") or []: @@ -435,14 +488,19 @@ def authenticated_providers() -> tuple[list, str | None]: this read into a write. FLEET'S OWN PROVIDER LEADS and the rest follow in quota-axi's order, so the - reading fleet's workers actually spend is the first one drawn. And when - `auth` cannot be read at all, that provider ALONE is the answer: a status - screen reporting no fuel because a discovery call failed is worse than one - reporting the single reading the fleet runs on. + reading fleet's workers actually spend is the first one drawn. Which one + that is comes from `fuel_provider()` — the operator's setting — and when + they have named none there is no preference to apply and quota-axi's own + order stands. When `auth` cannot be read at all, that named provider ALONE + is the answer, because a status screen reporting no fuel because a + discovery call failed is worse than one reporting the single reading the + fleet runs on; with none named there is nothing to fall back to and the + section says so rather than guessing a vendor. """ + lead = fuel_provider() doc, why = run_json(["quota-axi", "auth", "--json"], timeout=15) if why: - return [FUEL_PROVIDER], why + return ([lead] if lead else []), why names = [] for entry in (doc or {}).get("auth") or []: if not isinstance(entry, dict): @@ -453,9 +511,13 @@ def authenticated_providers() -> tuple[list, str | None]: if name: names.append(name) if not names: - return [FUEL_PROVIDER], "quota-axi auth named no provider with a credential" + return ( + [lead] if lead else [], + "quota-axi auth named no provider with a credential", + ) # Stable, so the rest keep quota-axi's own order behind the one fleet runs. - names.sort(key=lambda n: n != FUEL_PROVIDER) + if lead: + names.sort(key=lambda n: n != lead) return names, None @@ -565,7 +627,7 @@ def fuel_record(doc, provider: str, read_at: int) -> dict: return sec -def probe_fuel(provider: str = FUEL_PROVIDER) -> dict: +def probe_fuel(provider: str | None = None) -> dict: """ONE provider's remaining windows, per quota-axi. Fleet's own by default. THE ONLY SOURCE. `thurbox-cli session get --json` carries no token, usage, @@ -581,13 +643,28 @@ def probe_fuel(provider: str = FUEL_PROVIDER) -> dict: THIS IS THE GATE'S ENTRY POINT, and that is why it takes one provider. `scripts/lib/queue.py`'s `account_fuel()` calls it to decide whether - `queue.sh refuel` restarts anything, and the fleet runs `claude` agents — - so it must gate on the `claude` window and never on an average or on - whichever provider happens to be lowest. A spent `zai` window is not a - reason to leave a `claude` worker sitting at its limit. The SCREEN reads - every authenticated provider instead, through `probe_fuel_all()`. + `queue.sh refuel` restarts anything, and it passes the provider IT derived + from the agent the tasks in hand are running. The gate must read that one + window and never an average or whichever provider happens to be lowest: a + spent window on a provider the fleet never dispatches is no reason to leave + a worker sitting at its limit, and reading the wrong window is worse than + reading none. The SCREEN reads every authenticated provider instead, + through `probe_fuel_all()`. With no argument this falls back to + `fuel_provider()`, which is the operator's setting and not a name this file + chose. """ read_at = int(time.time()) + provider = provider or fuel_provider() + if not provider: + # The operator has named none, so the one to read is whichever they are + # actually signed in to. Asked rather than guessed: a vendor written + # here would be this repo answering a question that is theirs. + names, why = authenticated_providers() + provider = names[0] if names else "" + if not provider: + sec = fuel_blank(provider, read_at) + sec["unavailable"] = why or "no provider has a credential to read" + return sec doc, why = fuel_read([provider]) if why: sec = fuel_blank(provider, read_at) @@ -600,8 +677,8 @@ def probe_fuel_all() -> dict: """Every authenticated provider's reading, in one quota-axi call. ONE READING PER SUBSCRIPTION THE OPERATOR ACTUALLY HAS. The account may - hold several — `claude`, `codex`, `zai` — and a screen that reported only - the first would be silent about the windows the operator is also spending. + hold several, and a screen that reported only the first would be silent + about the windows the operator is also spending. Which ones exist is `authenticated_providers()`'s question, asked of credentials on disk; a provider with none is never probed, because that round trip only ever ends in what `auth` already said. @@ -813,8 +890,9 @@ def render_fuel(sec: dict) -> list: "every session spends them at once", )] if sec.get("discovery"): + read = ", ".join(r.get("provider") or "?" for r in sec["providers"]) lines.append(cont(f"providers not discovered ({sec['discovery']}) — " - f"read {FUEL_PROVIDER} alone")) + f"read {read} alone")) for rec in sec["providers"]: lines += fuel_provider_lines(rec) return lines diff --git a/scripts/queue-selftest.sh b/scripts/queue-selftest.sh index 93bf3c7..2585781 100755 --- a/scripts/queue-selftest.sh +++ b/scripts/queue-selftest.sh @@ -482,9 +482,13 @@ chmod +x "$tbxbin/thurbox-cli" # `session_is [hook_state_age_secs]`. The hook fields come with # it because `refuel` reads them: a session that ran dry reads `working` with an # age that keeps growing, and nothing else in `get --json` says so. +# WHICH agent the stub reports is a variable and not a literal, because the +# agent seam is only worth what a second agent driven through it proves — +# §12b sets it to one fleet has no built-in signal for. +STUB_AGENT=claude session_is() { - printf '{"id":"%s","name":"%s","state":"%s","agent":"claude","hook_reported":true,' \ - "$1" "worker $1" "$2" >"$sessions/$1.json" + printf '{"id":"%s","name":"%s","state":"%s","agent":"%s","hook_reported":true,' \ + "$1" "worker $1" "$2" "${4:-$STUB_AGENT}" >"$sessions/$1.json" printf '"hook_state":"%s","hook_state_age_secs":%s,"agent_session_id":"agent-%s"}\n' \ "$2" "${3:-5}" "$1" >>"$sessions/$1.json" } @@ -598,12 +602,14 @@ cat "$quota" SH chmod +x "$quotabin/quota-axi" -# `quota_is ` for the five-hour window, which is -# the one a session runs dry against. +# `quota_is [provider]` for the five-hour +# window, which is the one a session runs dry against. The provider is a +# parameter for the same reason the stub agent is: §12b reads a different one. +QUOTA_PROVIDER=claude quota_is() { cat >"$quota" <"$restarts" +: >"$sends" +STUB_AGENT=nova + +novaconf="$tmp/nova-conf" +mkdir -p "$novaconf/orchestration" "$tmp/nova-transcripts" +cat >"$novaconf/orchestration/agent.conf" <<'EOF' +AGENT=nova +FUEL_PROVIDER=nova +LIMIT_BANNER=quota exhausted for this workspace +TRANSCRIPT_DIR=__TDIR__ +TRUST_SIGNATURE=do you trust the contents of this workspace +TRUST_KEYS=enter +EOF +sed -i "s|__TDIR__|$tmp/nova-transcripts|" "$novaconf/orchestration/agent.conf" +export FLEET_AGENT_ROOT="$novaconf" + +ntopic="$($QUEUE topic add nova-dry --title 'A second agent runs dry' \ + --prompt 'the same sweep, for an agent fleet has no built-in signal for')" +$QUEUE add "$ntopic" ran-dry --title 'Task ran-dry' --repo /tmp/repo-a \ + --branch fix/nova --number 01 >/dev/null +$QUEUE attach "$ntopic/01-ran-dry" bbbbbbb1-0000-0000-0000-000000000001 >/dev/null +$QUEUE add "$ntopic" just-slow --title 'Task just-slow' --repo /tmp/repo-a \ + --branch fix/nova-slow --number 02 >/dev/null +$QUEUE attach "$ntopic/02-just-slow" bbbbbbb2-0000-0000-0000-000000000002 >/dev/null + +# The operator's sentence, not one this repo wrote down. +cat >"$panes/bbbbbbb1-0000-0000-0000-000000000001.txt" <<'EOF' +> running the gate + +quota exhausted for this workspace — try again after 02:00 +EOF +session_is bbbbbbb1-0000-0000-0000-000000000001 working 7200 +session_is bbbbbbb2-0000-0000-0000-000000000002 working 7200 + +# The gate first: a spent window on the provider the operator NAMED stops the +# sweep, and the provider is `nova` because they said so. +quota_is 0 "2026-09-09T04:00:00+00:00" nova +out="$($QUEUE refuel 2>&1)" +expect "the account window read is the one the operator's agent draws on" \ + "nova" "$out" +expect "and a spent one stops the sweep for a second agent too" \ + "waiting on the window" "$out" +refute "so nothing is restarted while that window is gone" \ + "bbbbbbb1" "$(cat "$restarts")" + +# A document that names ONLY the first agent's provider is not a reading of +# this one: undetermined, which restarts nothing, rather than a wrong window. +quota_is 62 "2026-09-09T04:00:00+00:00" claude +out="$($QUEUE refuel 2>&1)" +expect "another provider's window is not this agent's reading" \ + "undetermined" "$out" +refute "and undetermined restarts nothing" "bbbbbbb1" "$(cat "$restarts")" + +quota_is 62 "2026-09-09T04:00:00+00:00" nova +out="$($QUEUE refuel 2>&1)" +expect "the banner the operator configured is what marks a dry session" \ + "restarted" "$out" +expect "and it is the session that carries it" \ + "bbbbbbb1-0000-0000-0000-000000000001" "$(cat "$restarts")" +expect "a stale working state is still only a slow worker" "02-just-slow" "$out" +refute "so the second session is left alone" "bbbbbbb2" "$(cat "$restarts")" +expect "and the restarted worker is pointed back at its own brief" \ + "$FLEET_QUEUE_DIR/$ntopic/01-ran-dry/BRIEF.md" "$(cat "$sends")" + +# The transcript, at the directory the operator named and in no layout fleet +# assumed: `TRANSCRIPT_DIR` is read as the directory itself, so the records sit +# directly in it rather than under a per-project subdirectory. +: >"$restarts" +rm -f "$panes/bbbbbbb1-0000-0000-0000-000000000001.txt" +$QUEUE add "$ntopic" from-transcript --title 'Task from-transcript' \ + --repo /tmp/repo-a --branch fix/nova-tr --number 03 >/dev/null +$QUEUE attach "$ntopic/03-from-transcript" bbbbbbb3-0000-0000-0000-000000000003 >/dev/null +session_is bbbbbbb3-0000-0000-0000-000000000003 working 7200 +python3 - "$tmp/nova-transcripts/agent-bbbbbbb3-0000-0000-0000-000000000003.jsonl" <<'PY' +import json +import sys + +rows = [ + {"type": "user", "timestamp": "2026-09-08T18:00:00.000Z", + "message": {"role": "user", "content": "Read /brief and do what it says."}}, + {"type": "assistant", "timestamp": "2026-09-08T19:56:38.987Z", + "isApiErrorMessage": True, "error": "rate_limit", "apiErrorStatus": 429, + "quotaLimits": {"status": "rejected", "resetsAt": 1788999000, + "rateLimitType": "workspace_day"}, + "message": {"role": "assistant", "model": "", "content": [ + {"type": "text", + "text": "quota exhausted for this workspace — try again after 02:00"}]}}, +] +with open(sys.argv[1], "w") as fh: + for row in rows: + fh.write(json.dumps(row) + "\n") +PY +out="$($QUEUE refuel "$ntopic/03-from-transcript" --dry-run 2>&1)" +expect "the transcript is read where the operator said it is" "transcript" "$out" +expect "and names the window that rejected the turn" "workspace_day" "$out" +expect "so that session would be restarted on the record, not on a pane" \ + "would restart" "$out" + +# An agent with NO entry and NO settings is the honest failure: fleet says it +# does not know rather than matching a sentence nobody has watched it print. +cat >"$novaconf/orchestration/agent.conf" <<'EOF' +AGENT=unheard +EOF +out="$($QUEUE refuel "$ntopic/03-from-transcript" --dry-run 2>&1)" +expect "an agent fleet has never watched is not guessed at" "undetermined" "$out" +refute "and nothing is restarted on a guess" "would restart" "$out" + +unset FLEET_AGENT_ROOT +STUB_AGENT=claude +QUOTA_PROVIDER=claude + # --- 13. the display never contradicts itself -------------------------------- # # Five readings that were all wrong on one screen, every one of them produced diff --git a/scripts/session-trust.sh b/scripts/session-trust.sh index 2970825..e0477e0 100755 --- a/scripts/session-trust.sh +++ b/scripts/session-trust.sh @@ -59,6 +59,12 @@ # This script refuses them and says where the flag goes: # a profile in orchestration/session-profiles.yaml. # +# AN AGENT THAT IS NOT IN THE TABLE is the operator's to teach, not fleet's to +# guess: `TRUST_SIGNATURE` and `TRUST_KEYS` in orchestration/agent.conf, with +# `TRUST_KEYS=none` for an agent that shows no dialog at all. With neither set +# this refuses and sends nothing, because the `claude` row above is why — +# guessing a keystroke there exits the agent. +# # Usage: # scripts/session-trust.sh [--timeout SECS] [--json] # @@ -191,9 +197,34 @@ cursor | muse) exit 3 ;; *) - say "no trust gate is known for '$agent'; sending nothing. If it stops at - startup, add it to the table in $here/session-trust.sh" unknown-agent - exit 3 + # NOT IN THE TABLE IS NOT THE END. The table is what fleet has WATCHED, + # and the operator has watched their own agent — so `TRUST_SIGNATURE` and + # `TRUST_KEYS` in `orchestration/agent.conf` teach it one, the same way + # `LIMIT_BANNER` and `TRANSCRIPT_DIR` there teach `refuel` one. Without + # them this still refuses rather than guessing a keystroke: a bare Enter + # into Claude Code's dialog exits the agent, and an invented answer would + # do that to somebody's. + agent_root="${FLEET_AGENT_ROOT:-$(dirname "$here")}" + agent_conf="$agent_root/orchestration/agent.conf" + [ -f "$agent_conf" ] || agent_conf="$agent_root/orchestration/agent.example.conf" + if [ -f "$agent_conf" ]; then + signature="$(sed -n 's/^TRUST_SIGNATURE=//p' "$agent_conf" | head -1)" + keys="$(sed -n 's/^TRUST_KEYS=//p' "$agent_conf" | head -1)" + fi + if [ "$keys" = none ]; then + # The operator says this agent shows no dialog. Nothing to answer; + # it is still confirmed as up below. + signature="" + keys="" + elif [ -z "$signature" ]; then + say "no trust gate is known for '$agent'; sending nothing. Teach fleet + one with TRUST_SIGNATURE and TRUST_KEYS in + orchestration/agent.conf, or add it to the table in + $here/session-trust.sh" unknown-agent + exit 3 + else + keys="${keys:-enter}" + fi ;; esac From db6b30f544afdf71a5555d520cd08683983736a7 Mon Sep 17 00:00:00 2001 From: letur Date: Sat, 12 Sep 2026 11:31:33 +0200 Subject: [PATCH 5/6] chore: no-mistakes review - Replace grep tripwire with behavioral test; add missing README conf entries --- README.md | 4 +++- scripts/fleet-status-selftest.sh | 35 ++++++++++++++++++++++++-------- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ca8ad9d..281a0ac 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,9 @@ machine. `.gitignore`'s header names every path and the reason for each. Your settings are yours the same way, and each has a tracked `.example` beside it documenting the format: `registry/owners.txt` (the owners the map covers), `orchestration/voice.conf` (what the lead calls you), `session-glyphs.conf` -(the mark fleet's sessions wear) and `orchestration/auto-merge.conf` — **the +(the mark fleet's sessions wear), `orchestration/publish.conf` (the publish +method and the command that produces it), `orchestration/agent.conf` (which +agent your workers run) and `orchestration/auto-merge.conf` — **the repositories fleet may merge in unattended, which the tracked copy deliberately leaves empty.** Clone this and fleet merges nowhere until you say otherwise; no operator inherits another's merge rights. diff --git a/scripts/fleet-status-selftest.sh b/scripts/fleet-status-selftest.sh index d637966..0d7d7a8 100755 --- a/scripts/fleet-status-selftest.sh +++ b/scripts/fleet-status-selftest.sh @@ -643,16 +643,33 @@ expect "an unconfigured clone still reads the subscriptions it has" \ "provider nova" "$bare_conf" refute "and names no vendor this repo chose" "claude" "$bare_conf" -# The tripwire, and the reason this section is not prose: the module that -# decides which provider to read must not carry an agent's name at all. A -# fallback literal would pass every assertion above on a machine that happens -# to be signed in to it. -if grep -qiE 'claude|anthropic' scripts/lib/fleet_status.py; then - fail "the fuel reader names no vendor" \ - "$(grep -inE 'claude|anthropic' scripts/lib/fleet_status.py)" -else - pass "the fuel reader names no vendor, so there is nothing to fall back to" +# The tripwire: an unconfigured clone whose ONLY credentialed provider +# happens to be named `claude` must still read it by discovery, not because +# the module special-cases that name. A fallback literal would behave +# identically to a real discovery path on THIS machine, so the seam swaps in +# a credential the module has never heard the name of and the reading has to +# follow. +cat >"$seam/quota-axi" <<'STUB' +#!/bin/sh +if [ "$1" = auth ]; then + cat <<'JSON' +{"generatedAt":"2026-03-15T16:42:00.000Z","schemaVersion":1,"auth":[ + {"provider":"glorbnak","sources":[{"source":"opencode:auth.json","status":"available"}]}]} +JSON + exit 0 fi +cat <<'JSON' +{"generatedAt":"2026-03-15T16:42:00.000Z","schemaVersion":5,"providers":[ + {"provider":"glorbnak","plan":"pro","source":"oauth", + "windows":[{"id":"five_hour","label":"5h","percentRemaining":48, + "resetsAt":"2026-03-15T21:00:00.000Z"}], + "state":{"status":"ok","stale":false}}]} +JSON +STUB +chmod +x "$seam/quota-axi" +sole_credential="$(PATH="$seam" FLEET_AGENT_ROOT="$conf" "$STATUS" --fuel 2>&1)" +expect "the sole credentialed provider is read by discovery, name notwithstanding" \ + "provider glorbnak" "$sole_credential" # --- 7. it reads, and only reads --------------------------------------------- From 1ca377b6fb4fb760eb0eff67e8b9ad1776f036a7 Mon Sep 17 00:00:00 2001 From: letur Date: Sat, 12 Sep 2026 11:46:52 +0200 Subject: [PATCH 6/6] chore: no-mistakes document - docs: fix stale claude-only refs in extension.toml.in and trust-dialog table --- .agents/skills/thurbox-session/SKILL.md | 6 ++++++ extension.toml.in | 6 ++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.agents/skills/thurbox-session/SKILL.md b/.agents/skills/thurbox-session/SKILL.md index 4808f45..4c89c74 100644 --- a/.agents/skills/thurbox-session/SKILL.md +++ b/.agents/skills/thurbox-session/SKILL.md @@ -196,6 +196,12 @@ The per-agent differences, one of which is a trap: | `grok`, `kimi` | no dialog inside a git repo, which a worktree always is. | | `cursor`, `muse` | **not a keystroke** — a launch flag (`--trust`, `--yolo`). Use the `cursor-trusted` / `muse-trusted` profiles in `orchestration/session-profiles.yaml` (§1d). | +**An agent not in this table is refused, not guessed at** — a wrong keystroke +can exit the agent instead of dismissing a dialog. Teach it one with +`TRUST_SIGNATURE` and `TRUST_KEYS` in `orchestration/agent.conf` +(`TRUST_KEYS=none` for an agent with no dialog at all); `session-trust.sh`'s +header owns the mechanics. + **Which path the trust is recorded against** (observed 2026-09-07, Claude Code): answering inside a worktree records it against the **repository's main worktree path**, not the worktree's own. So the first worker in a repo meets the dialog diff --git a/extension.toml.in b/extension.toml.in index 25bd278..5110d9c 100644 --- a/extension.toml.in +++ b/extension.toml.in @@ -225,8 +225,10 @@ # RENAMING THE EXTENSION is a different, heavier operation that none of the # above needs: change the top-level `name`, then `thurbox-cli extension # uninstall fleet` BEFORE installing. `[[sessions]] agent` is not part of it — -# it names the stock `claude` agent, which is thurbox's and not this -# extension's to rename. Skip that uninstall and you get two registered +# it names whichever stock agent `__LEAD_AGENT__` renders to (the operator's +# `AGENT` in `orchestration/agent.conf`, else thurbox's own `claude`), and +# that agent is thurbox's to rename, not this extension's. Skip that uninstall +# and you get two registered # extensions and two self-healing sessions, each recreating itself, plus an # orphaned `~/.config/thurbox/extensions/fleet/`. #