From 00f07237c5ad89316c89202fbb017788c22d7f9b Mon Sep 17 00:00:00 2001 From: LeTuR Date: Fri, 11 Sep 2026 00:04:13 +0200 Subject: [PATCH 1/6] feat(onboarding): make setup guided, and give it scripts to be guided with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding probed prerequisites in prose, asked the operator to type owners the machine already knows, and printed a layout.lua block for them to paste — which is how a setup ends with an invisible queue pane. Four scripts now own the mechanical halves, and the skill asks at the four points where the answer is genuinely the operator's. - scripts/preflight.sh: every dependency in one pass, in three tiers, each row carrying what breaks without it and the command that fixes it. quota-axi and glab join the list; so does the git commit-signing configuration, which fails check.sh queue in a dozen unrelated-looking ways. --commands hands over the runnable lines, --tier narrows them. - scripts/discover-owners.sh: owner candidates from the gh session, the git config and the remotes of the clones already on disk, each with its evidence. It matches an ssh host alias, so a machine with two GitHub accounts is not invisible to it. GitLab remotes are evidence, never owners. - scripts/place-pane.sh: writes the guarded block into layout.lua, right of the terminal by default. On the operator's word only — it refuses a layout it cannot read, backs up first, re-reads its own edit with lua, and verifies with plugin check. The slot comes from the pane. - scripts/onboarding-selftest.sh + check.sh onboarding + a CI job: all three driven offline against stubs and a stock layout fixture. The skill is now seven announced steps with four asks: install what is missing, which owners the map covers, where the pane goes, and whether the reconciler comes up. --- .agents/skills/fleet-onboarding/SKILL.md | 383 ++++++++++++-------- .agents/skills/fleet-pane/SKILL.md | 79 +++-- .agents/skills/update-fleet/SKILL.md | 6 +- .github/workflows/ci.yml | 22 ++ AGENTS.md | 13 +- README.md | 34 +- scripts/check.sh | 54 ++- scripts/discover-owners.sh | 226 ++++++++++++ scripts/fixtures/layout/README.md | 23 ++ scripts/fixtures/layout/stock.lua | 149 ++++++++ scripts/install-extension.sh | 11 +- scripts/onboarding-selftest.sh | 423 +++++++++++++++++++++++ scripts/place-pane.sh | 218 ++++++++++++ scripts/preflight.sh | 342 ++++++++++++++++++ 14 files changed, 1793 insertions(+), 190 deletions(-) create mode 100755 scripts/discover-owners.sh create mode 100644 scripts/fixtures/layout/README.md create mode 100644 scripts/fixtures/layout/stock.lua create mode 100755 scripts/onboarding-selftest.sh create mode 100755 scripts/place-pane.sh create mode 100755 scripts/preflight.sh diff --git a/.agents/skills/fleet-onboarding/SKILL.md b/.agents/skills/fleet-onboarding/SKILL.md index 35e93da..bd2f05e 100644 --- a/.agents/skills/fleet-onboarding/SKILL.md +++ b/.agents/skills/fleet-onboarding/SKILL.md @@ -1,21 +1,42 @@ --- name: fleet-onboarding -description: Take a fresh clone of this control plane to a working fleet — discover the GitHub owners, write registry/owners.txt, sync the registry, install the thurbox extension and the TUI queue pane, bring the reconciler up, and verify each step. Use when someone has just cloned the repo, asks how to set the control plane up, asks to start or restart the fleet reconciler, or invokes /fleet-onboarding. +description: Take a fresh clone of this control plane to a working fleet — check and install the dependencies, discover the GitHub owners from the machine itself, sync the registry, install the thurbox extension, place the TUI queue pane on the operator's screen, and bring the reconciler up. Use when someone has just cloned the repo, asks how to set the control plane up, asks to install fleet's dependencies or the queue pane, asks to start or restart the fleet reconciler, or invokes /fleet-onboarding. user-invocable: true allowed-tools: Read, Edit, Write, Bash, Glob, Grep, AskUserQuestion --- ## fleet-onboarding -Takes a fresh clone of fleet to a control plane that actually runs: owners -known, registry synced, thurbox extension and TUI queue pane installed, and the -reconciler up. +Takes a fresh clone of fleet to a control plane that actually runs: dependencies +installed, owners known, registry synced, thurbox extension installed, the queue +pane **on screen**, and the reconciler up. -**Do the work, don't narrate it.** The steps are mechanical — -`registry/owners.txt`, `scripts/sync-registry.sh`, -`scripts/install-extension.sh`, `scripts/reconcile.sh` — -and the user should not be reading a numbered list and typing along. Infer what is discoverable, ask once about the one thing that -genuinely needs them, run the scripts, and **verify each step landed**. +**Do the work, don't narrate it — but keep the operator oriented while you do.** +Every step is a script in `scripts/`, and running them is yours. What the +operator needs from you is a sense of where they are, and a real say at the +four points where the answer is genuinely theirs. + +### The shape of a run + +Seven steps, in this order, each announced in one line before you do it: + +```text +Step 1/7 Dependencies preflight.sh, then install what is missing [ask] +Step 2/7 This checkout is this the clone to keep? +Step 3/7 Owners discover-owners.sh, then confirm [ask] +Step 4/7 Registry sync-registry.sh +Step 5/7 Extension install-extension.sh +Step 6/7 Queue pane place it on screen — right by default [ask] +Step 7/7 Reconciler reconcile.sh ensure [ask] +``` + +**Four questions, and no more than four.** Everything else is discoverable or +has one correct answer. Ask each one at the step it belongs to and not before — +a wall of questions up front is asked before the operator has seen anything, and +answered blind. + +**Say what each step landed, in one line, with the evidence.** "Registry: +41 repos across 3 owners" is the report; the command's own output is not. **A fresh clone is mostly empty.** No `registry/owners.txt`, no generated map, no context files, no run logs, no queue: everything a running fleet writes is @@ -24,50 +45,73 @@ is tracked is the machinery plus the `_TEMPLATE.md` forms. Say that when it comes up; a user who finds half the layout missing should hear that it is correct. -The scripts remain the supported manual path — each one's own header is its -full usage. +Each script's own header is its full usage, and each remains the supported +manual path. -## 0. Preflight — before anything is written +## Step 1/7 — Dependencies -Probe every prerequisite **first**. A half-onboarded clone (owners written, no -registry) is worse than one that never started. +```bash +./scripts/preflight.sh +``` -| Need | Probe | If missing, say | -|---|---|---| -| `git` | `command -v git` | install git | -| `gh` | `command -v gh` | install the GitHub CLI: | -| `gh` authenticated | `gh auth status` | `gh auth login` | -| `glab`, only if this fleet works on GitLab | `command -v glab` | install the GitLab CLI: | -| `glab` authenticated | `glab auth status` | `glab auth login` (`GITLAB_HOST` for a self-hosted instance) | -| `jq` | `command -v jq` | install jq (`brew install jq`, `apt install jq`, …) | -| `thurbox-cli` | `command -v thurbox-cli` | install thurbox: | -| thurbox ≥ floor | compare against `min_thurbox_version` in `extension.toml.in` | `thurbox-cli` is too old; upgrade to the floor or newer | +One pass over everything fleet needs, in three tiers, each row carrying what +breaks without it and the command that installs it. **Read the table; do not +re-probe it tool by tool.** It exits non-zero when a REQUIRED dependency is +missing or a `thurbox-cli` is below the manifest's floor. + +| Tier | What it means | +|---|---| +| required | fleet cannot run — `git`, `gh` (authenticated), `jq`, `python3` + PyYAML, `thurbox-cli` | +| recommended | a named capability degrades — `quota-axi` for fuel and `refuel`, `glab` for GitLab | +| gate | only `./scripts/check.sh` needs it — `lua`, `shellcheck`, `rumdl`, `prek`, and the git commit-signing configuration | `gh` is required even on a fleet whose work is entirely on GitLab: it is what builds the repo map from `registry/owners.txt`, which is a list of GITHUB -owners. `glab` is what fleet asks about a GitLab merge request, and nothing -here needs it until a task's repository lives there. - -**Read the version floor from the manifest, never from memory.** It is one -number with one owner, and `extension.toml.in` records why it sits there: +owners. `quota-axi` is the one most often missed, and it is not decorative — +without it the pane's fuel rows and `./scripts/fleet-status.sh` have nothing to +read, and `queue.sh refuel` cannot tell a spent account window from a live one +before it restarts a worker. + +The last gate row is not a tool at all: **git commit signing turned on with no +key outside this checkout**. Nothing here needs it fixed to run a fleet, but it +makes `./scripts/check.sh queue` fail in a dozen unrelated-looking ways, since +that selftest commits in throwaway repos where an `includeIf gitdir:` key does +not apply. Report it as what it is — a gate problem with a one-line fix and no +bearing on the rest of the setup. + +**ASK — installing is the operator's call.** A package manager is the one part +of this setup that touches the machine outside the checkout, so nothing is +installed unasked. Show the missing rows and ask: + +- **Install everything missing** (recommended) — required, recommended and gate +- **Required and recommended** — skip the tools only the gate needs +- **Required only** — the smallest thing that runs +- **Skip** — nothing is installed + +Then run the lines, which are the script's own — one flag per answer, so which +lines to run is never your judgement call: ```bash -floor=$(sed -n 's/^min_thurbox_version *= *"\(.*\)"/\1/p' extension.toml.in) -have=$(thurbox-cli --version | awk '{print $NF}') -[ "$(printf '%s\n%s\n' "$floor" "$have" | sort -V | head -1)" = "$floor" ] || - echo "thurbox-cli $have is below the $floor floor" +./scripts/preflight.sh --commands # everything missing +./scripts/preflight.sh --commands --tier required --tier recommended +./scripts/preflight.sh --commands --tier required ``` -Report **every** missing prerequisite in one pass with its remedy, then stop; -discovering them one restart at a time is the frustrating version of this. +Run them one at a time and show what each said; several need `sudo`, and an +operator watching a sudo prompt should know which command asked for it. An +install that fails is reported and does not stop the others — one missing gate +tool is not a reason to abandon a setup. -`jq` is needed by both `scripts/sync-registry.sh` (step 3) and -`scripts/install-extension.sh` (step 4); `thurbox-cli` by steps 4 and 5. If -thurbox is the only thing missing you may still do steps 1 to 3 — say plainly -that steps 4 and 5 are deferred and what to run once thurbox is installed. Both -are the same command, so that is one sentence, not two. +Then **re-run `./scripts/preflight.sh` and read it back.** That is the +verification, not the package manager's exit code. -## 1. The checkout — is this the one to keep? +If a REQUIRED tool is still missing after that, stop here and say which. A +half-onboarded clone — owners written, no registry — is worse than one that +never started. The single exception is `thurbox-cli`: steps 1 to 4 are still +worth doing without it, so say plainly that steps 5 and 6 are deferred and that +`./scripts/install-extension.sh` is the one command that picks them both up. + +## Step 2/7 — This checkout There is one remote and nothing to wire: @@ -75,46 +119,64 @@ There is one remote and nothing to wire: git remote -v # origin -> their own copy of fleet ``` -What matters here is **which directory this is**. Step 4 bakes this checkout's +What matters here is **which directory this is**. Step 5 bakes this checkout's absolute path into the thurbox extension, and a Mission Control session registered against a scratch copy self-heals forever against a directory that is about to vanish. So if the working directory is a thurbox worktree, a temp directory or an obvious throwaway, say so now and stop — moving later costs a -session deletion (see step 4), and it is free to avoid here. +session deletion (see step 5), and it is free to avoid here. `./scripts/sync-checkout.sh` is how changes arrive afterwards. It runs from the `SessionStart` hook and only ever fast-forwards, so there is nothing to configure; it is worth knowing it exists because it is also what reports that a pull left the running lead session holding stale instructions. -## 2. Owners — infer, then confirm once +## Step 3/7 — Owners -`registry/owners.txt` is the one input that genuinely needs the user, and it is -mostly **discoverable** — asking them to type what an authenticated `gh` session -already knows is the friction this skill exists to remove. +`registry/owners.txt` is the one input that genuinely needs the operator, and +nearly all of it is already on the machine. Ask the machine first: ```bash -gh api user --jq .login # their username -gh api user/orgs --jq '.[].login' # the orgs they belong to +./scripts/discover-owners.sh ``` -If the org call errors or comes back empty on an account you expect orgs for, -the token is missing the scope: `gh auth refresh -s read:org`. Say which it was -rather than silently treating it as "no orgs". +Three sources, each candidate printed with the evidence behind it: + +- **gh account and orgs** — `gh api user`, `gh api user/orgs` +- **git config** — `github.user`, and a `@users.noreply.github.com` commit email +- **local clones** — the remotes of every checkout under `~/code`, `~/src`, + this clone's own parent and the rest, counted per owner. It matches an ssh + host ALIAS (`git@github-perso:owner/repo`) as well as `github.com`, so a + machine with two GitHub accounts is not invisible to it. + +A GitLab remote it finds is printed in its own section and is **not** a +candidate: the map is built with `gh`, and a GitLab repo is targeted per task +through the forge seam instead. Say that if the operator asks why their GitLab +group is not on the list. -Then **one** question, not one per owner: show the discovered list and ask -whether to cover all of it, just their username, or a subset they name. An -account with no orgs has nothing to ask about — write the username and move on. +**ASK — one question, not one per owner.** Show the candidates with their +evidence and ask which the map should cover: -`registry/owners.txt` is **gitignored** and will not exist in a fresh clone. -Start it from the tracked example rather than writing one from memory: +- **All of them** — every candidate found +- **Just my account** — the narrowest useful map +- **A subset I name** — they pick from the list +- **Scan somewhere else first** — their clones live outside the default roots, + so run `./scripts/discover-owners.sh ~/that/dir` and ask again with the + fuller list + +An account with no orgs and no other evidence has nothing to ask about — write +the username and move on. Discovery exiting 1 means the machine said nothing at +all: no `gh` session, no `github.user`, no GitHub remote under the roots it +scanned. Then, and only then, ask them to type their username, and offer the +directory scan as the alternative. + +Then write the file. It is **gitignored** and will not exist in a fresh clone, +so start from the tracked example rather than from memory: ```bash [ -f registry/owners.txt ] || cp registry/owners.example.txt registry/owners.txt ``` -Then write the confirmed owners into it: - - **Keep the comment header.** It documents the file's own format for whoever edits it later by hand. - Replace the two `# your-github-username` / `# your-org` placeholder lines with @@ -123,8 +185,8 @@ Then write the confirmed owners into it: present, and leave the existing order alone — the sync emits owners in this file's order, so reshuffling it churns the generated map for nothing. -Verify before moving on; the sync refuses to run on a file with no active -entries, and it is better to catch that here: +Verify before moving on; the sync refuses a file with no active entries, and it +is better to catch that here: ```bash grep -vE '^[[:space:]]*(#|$)' registry/owners.txt @@ -137,15 +199,16 @@ thing worth saying about the latter: it is committed to a **public** repo, so nothing environment-specific goes in it, and a credential should reach a worker by inheriting the thurbox server's environment rather than by living in a file. -## 3. Registry +## Step 4/7 — Registry ```bash ./scripts/sync-registry.sh ``` -It enumerates every repo the user's own `gh` session can reach, keeps the ones -under those owners, and writes `registry/repos.generated.yaml` — **generated**, -so never hand-edit it and never hand-write it if the script fails. +It enumerates every repo the operator's own `gh` session can reach, keeps the +ones under those owners, and writes `registry/repos.generated.yaml` — +**generated**, so never hand-edit it and never hand-write it if the script +fails. Verify the map is not empty, and read the totals back as the evidence: @@ -158,7 +221,7 @@ tail -3 registry/repos.generated.yaml # totals: repos / owners typo yields a quietly thinner map. Surface that warning — it almost always means a typo or an org the token cannot see, and it is fixable in seconds now. -## 4. Thurbox extension +## Step 5/7 — Thurbox extension ```bash ./scripts/install-extension.sh @@ -168,7 +231,10 @@ It renders two gitignored files and installs them: `extension.toml` from `extension.toml.in` (it carries this clone's absolute path), and `FLEET.rendered.md` from `FLEET.md` (it carries the two names in `orchestration/voice.example.conf` — what the lead calls the operator, and what -it answers to; copy that file to `voice.conf` beside it to change either). +it answers to; copy that file to `voice.conf` beside it to change either). It +also hands the queue pane to `thurbox-cli plugin install`, which step 6 is +about. + Verify, rather than trusting the installer's own closing message: ```bash @@ -179,52 +245,70 @@ That exits non-zero and answers `{"error": ...}` when no manifest is registered, which is the honest signal that the install did not take. **The trap that matters most here:** `[[sessions]] repo_path` is baked in at -install time. Run this from **the clone the user intends to keep** — not a -thurbox worktree, not a scratch copy, not a temp directory. A Mission Control -session registered against a disposable path self-heals forever against a -directory that is about to vanish. +install time. Run this from **the clone the operator intends to keep** — not a +thurbox worktree, not a scratch copy, not a temp directory. Re-running the installer does not fix it. thurbox reuses an extension's session by name and never moves it, so a second install rewrites the manifest, reports success, and leaves the session on the old path — and `extension status` still calls that healthy, because it checks that the session EXISTS, not where it points. The installer catches this and exits non-zero; the remedy it names -deletes the session and its history, so hand that decision to the user: +deletes the session and its history, so hand that decision to the operator: ```bash thurbox-cli extension deactivate fleet # deletes the session ./scripts/install-extension.sh # respawns it at the right path ``` -## 5. The queue pane +## Step 6/7 — The queue pane, on screen -`./scripts/install-extension.sh` in step 4 installed it already — it installs -the thurbox extension and the TUI pane in one pass. This step is about the half -of it that **is not finished when that script exits 0**. +Step 5 installed the pane. This step is the half that **is not finished when +that script exits 0**, and skipping it is how an operator ends a setup with a +pane that loads, lists, declares its keys — and draws nothing. -A thurbox pane names a *slot*; the arrangement decides where that slot goes. A -pane no arrangement places loads cleanly, declares its keys, appears in -`thurbox-cli plugin list` — and draws nothing. It is the failure with no -symptom, so do not take the installer's word for it. Ask the thing that can tell -the two apart: +A thurbox pane names a *slot*; the arrangement decides where that slot goes. +Ask the one thing that can tell an installed pane from a placed one: ```bash thurbox-cli plugin check ``` -It loads the interface exactly as thurbox does and **exits non-zero** on a pane -that loaded but is placed by nothing, naming the file and the block to add. - | It says | What it means | What you do | |---|---|---| | `✓ loads — … fleetqueue …`, exits 0 | installed and placed | say that `F3` opens it | -| `✗ … nothing places slot "fleetqueue"` | installed, invisible | print the block below | -| no `fleetqueue` anywhere | the install did not take | re-run step 4 and read its output | +| `✗ … nothing places slot "fleetqueue"` | installed, invisible | the ask below | +| no `fleetqueue` anywhere | the install did not take | re-run step 5 and read its output | + +**ASK — always, and never place it silently.** `layout.lua` is the operator's +file: every pane on their screen shares it, and a mistake there takes the whole +interface rather than one column. So the edit happens on their word, and this is +the question: -**The block is the user's edit, not yours.** `layout.lua` is shared by every pane -on their screen — a mistake there takes the whole interface, not one column — so -do not write it for them and do not offer to. Print it, say where it goes, and -say plainly that you stopped there on purpose: +- **Place it on the right** (recommended) — a column to the right of the + terminal, `pct = 30, min = 34`, which is where the queue reads best: the + session list on the left, the agent in the middle, the queue on the right +- **Place it on the left** — between the session list and the terminal +- **Show me the block, I will add it myself** — print it and stop +- **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: + +```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 with no `columns` list it recognises +is left untouched and the block printed instead; 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: ```lua if panels.shown("fleetqueue") and filled(ctx, "fleetqueue") then @@ -232,57 +316,59 @@ if panels.shown("fleetqueue") and filled(ctx, "fleetqueue") then end ``` -Give them the guard, not just the slot. `plugin check` suggests a bare +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. -It belongs beside the other side columns, inside the `columns` list of -`layout.lua` in the interface directory. Read that directory back rather than -assuming `~/.config/thurbox/ui` — a dev build's is elsewhere, and this says which -rule chose it: +**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, +which needs the **`run` capability**. Declaring it does not grant it and you +cannot grant it for them — the switch is thurbox's own settings, `Ctrl+,` → +`]` → `t`. Say it once. Until they do, the pane draws an honest "not trusted +yet" rather than an empty column, so nothing is broken in the meantime. + +**On a re-run**, `plugin install` reports the pane `current`, `plugin check` +says whether the block is already there, and `place-pane.sh` says "already +placed" and changes nothing. Check before you speak; a second run must never +propose a block that is already in the file. If `thurbox-cli` was missing at +step 1, defer this step exactly as step 5 is deferred: same script, same +sentence. -```bash -thurbox-cli plugin dir --text | head -1 -``` +## Step 7/7 — The reconciler -One more 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, which -needs the **`run` capability**. Declaring it does not grant it and you cannot -grant it for them — the switch is thurbox's own settings, `Ctrl+,` → `]` → `t`. -Say it once. Until they do, the pane draws an honest "not trusted yet" rather -than an empty column, so nothing is broken in the meantime. - -**On a re-run**, `plugin install` reports the pane `current` and changes nothing, -and the `layout.lua` block is one the user either already added or has not — which -is exactly what `plugin check` answers. Check before you speak; a second run must -never suggest adding a block that is already there. If `thurbox-cli` was missing at -preflight, defer this step exactly as step 4 is deferred: same script, same -sentence. +**ASK — the loop runs on their machine, and it is theirs to start.** One +question, with what it does in the option itself: -## 6. The reconciler +- **Bring it up now** (recommended) — folds thurbox's event stream and runs + `collect`, `shepherd` and `refuel` on their own intervals +- **Leave it down** — every one of those then happens only when the lead + remembers, and `./scripts/reconcile.sh ensure` starts it later ```bash ./scripts/reconcile.sh ensure +./scripts/reconcile.sh status ``` -A supervised loop that keeps the queue's records level with the world: it folds -`thurbox-cli watch`'s event stream continuously and runs `queue.sh collect`, -`shepherd` and `refuel` on their own intervals. Without it, every one of those -happens only when the lead remembers — which is how one session ended with 19 -of 20 progress timelines empty and three merged pull requests unnoticed for -forty minutes. +Without it, one session ended with 19 of 20 progress timelines empty and three +merged pull requests unnoticed for forty minutes. That is what the recommended +answer is buying. -**`ensure`, never `start`, for exactly the reason above.** It has the same -`down` flag with the same durability, in `orchestration/reconcile/down`, and -the same three correct answers on a re-run: started it, adopted it, or left it -down because the user asked. - -```bash -./scripts/reconcile.sh status -``` +**`ensure`, never `start`, for exactly that reason.** It has the same `down` +flag with the same durability, in `orchestration/reconcile/down`, and the same +three correct answers on a re-run: started it, adopted it, or left it down +because the operator asked. Three things to pass on, once: @@ -300,31 +386,38 @@ Three things to pass on, once: Optionally, and only if they ask for it: `./scripts/reconcile.sh hook` prints a Claude Code `Stop` hook that makes a finishing worker nudge the loop into its next pass immediately. It goes in `~/.config/thurbox/hooks/claude.json`, which -is **thurbox's file and not fleet's** — so this prints the block and the user -pastes it, and a thurbox update may take it away again. It is an accelerator, -never the mechanism: a worker that ran out of quota fires no hook at all. +is **thurbox's file and not fleet's** — so this prints the block and the +operator pastes it, and a thurbox update may take it away again. It is an +accelerator, never the mechanism: a worker that ran out of quota fires no hook +at all. -## 7. Hand over +## Hand over -**Nothing this skill wrote is tracked.** `registry/owners.txt`, -`registry/repos.generated.yaml`, `extension.toml` and `FLEET.rendered.md` are -all gitignored, so -`git status` is clean and there is nothing to commit or push. That is the -design, not a step you forgot: this repo is public, and an index of every repo -the operator can reach — along with one machine's absolute paths — does not -belong in it. `.gitignore`'s header has the reasoning. +Close with a short recap: the seven steps, one line each, and what each landed — +dependencies installed, owners written, N repos across M owners, extension +healthy, pane placed on the right, reconciler up. -Say it explicitly — a user who set up a control plane and sees an empty +**Nothing this skill wrote to the repo is tracked.** `registry/owners.txt`, +`registry/repos.generated.yaml`, `extension.toml` and `FLEET.rendered.md` are +all gitignored, so `git status` is clean and there is nothing to commit or push. +That is the design, not a step you forgot: this repo is public, and an index of +every repo the operator can reach — along with one machine's absolute paths — +does not belong in it. `.gitignore`'s header has the reasoning. Say it +explicitly; an operator who set up a control plane and sees an empty `git status` will otherwise assume it failed. +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. + Gate anyway; the `yaml` check is the one that asserts the generated map's shape: ```bash ./scripts/check.sh ``` -Then tell them the one thing that is theirs to do next: open the `mission -control` session in thurbox and give it a goal. Everything else — playbooks, run +Then tell them the one thing that is theirs to do next: open the Mission +Control session in thurbox and give it a goal. Everything else — playbooks, run logs, worker sessions — follows from that, and `AGENTS.md` is where the session picks the loop up. @@ -342,18 +435,18 @@ Assume someone runs this twice. Every step above **converges**: | Step | Second run | |---|---| -| Preflight | pure probes, writes nothing | +| Dependencies | pure probes; nothing is installed without the same question | | Checkout | a question, not a write | -| Owners | adds only missing entries; never duplicates or reorders | +| Owners | discovery re-reads the machine and marks what is already configured; adds only missing entries, never duplicates or reorders | | Registry | the script rewrites the file wholesale from live GitHub | | Extension | a reinstall keeps existing `agents.toml` entries, so a customized model survives | -| Queue pane | `plugin install` reports it `current`; `plugin check` says whether the `layout.lua` block is already there, so it is never suggested twice | +| Queue pane | `plugin install` reports it `current`, and `place-pane.sh` says "already placed" and touches nothing | | Reconciler | `ensure` adopts a running one, and a `down` flag it wrote stays honoured; never a twin | So do not refuse on an already-configured clone. Detect it — `registry/owners.txt` with active entries, the generated map there, the -extension healthy — say which parts are already in place, and offer to refresh -the map rather than redoing everything. +extension healthy, `plugin check` green — say which parts are already in place, +and offer to refresh the map rather than redoing everything. The one thing a re-run does **not** fix is a **rename**. thurbox names a session when it SPAWNS it and has no verb that renames one, and `ensure_extension` diff --git a/.agents/skills/fleet-pane/SKILL.md b/.agents/skills/fleet-pane/SKILL.md index 0e05b37..1b0147c 100644 --- a/.agents/skills/fleet-pane/SKILL.md +++ b/.agents/skills/fleet-pane/SKILL.md @@ -1,6 +1,6 @@ --- name: fleet-pane -description: Put the fleet queue pane on the operator's thurbox screen and diagnose it when it is installed and drawing nothing, or drawing the wrong thing. Covers the install (a side effect of scripts/install-extension.sh), what verifies it, the layout.lua block that places it and that nothing here writes, the F-key that hides it, and removal. Use when asked to install, place, hide, remove or debug the TUI queue pane, when the pane is there and empty, or when it draws too much to read. +description: Put the fleet queue pane on the operator's thurbox screen and diagnose it when it is installed and drawing nothing, or drawing the wrong thing. Covers the install (a side effect of scripts/install-extension.sh), what verifies it, the layout.lua block that places it and the script that writes that block on the operator's word, the F-key that hides it, and removal. Use when asked to install, place, hide, remove or debug the TUI queue pane, when the pane is there and empty, or when it draws too much to read. user-invocable: true allowed-tools: Read, Bash, Glob, Grep --- @@ -11,14 +11,19 @@ allowed-tools: Read, Bash, Glob, Grep the view is and why it is built the way it is; this skill owns getting it onto a screen and finding out why it is not on one. -> **Installing the pane and seeing the pane are two different things, and only -> one of them is fleet's.** `thurbox-cli plugin install` succeeding and -> `thurbox-cli plugin list` showing the pane are both true of a pane the -> operator cannot see. A thurbox pane names a *slot*; the ARRANGEMENT decides -> where that slot goes, and the arrangement is `layout.lua` — a file every pane -> on their screen shares, so **nothing in this repo writes it and neither do -> you**. A pane no arrangement places loads cleanly, declares its keys, appears -> in `plugin list`, and draws nothing. +> **Installing the pane and seeing the pane are two different things.** +> `thurbox-cli plugin install` succeeding and `thurbox-cli plugin list` showing +> the pane are both true of a pane the operator cannot see. A thurbox pane +> names a *slot*; the ARRANGEMENT decides where that slot goes, and the +> arrangement is `layout.lua` — a file every pane on their screen shares. A +> pane no arrangement places loads cleanly, declares its keys, appears in +> `plugin list`, and draws nothing. +> +> **`./scripts/place-pane.sh` writes that block, and only ever because the +> operator said to.** It is never a step that happens on the way to something +> else: ask, then run it. It refuses a layout it cannot recognise, backs the +> file up, re-reads its own edit with `lua`, and verifies with `plugin check`. +> §4 is the whole of it. That is the failure with no symptom, and every message the operator has says it should be working. Reach for §4 before anything else when a pane is "installed @@ -175,7 +180,7 @@ whether anything draws it. `--json` adds `installed_from`, which is the useful part: it names the checkout the pane was installed from, so a stale path here and a moved clone are the same bug. -## 4. Placing it — the operator's edit, not yours +## 4. Placing it — the operator's call, and then the script's job The block goes **inside the `columns` list** of `layout.lua`, beside the other side columns: @@ -186,18 +191,38 @@ if panels.shown("fleetqueue") and filled(ctx, "fleetqueue") then end ``` -Find the file rather than assuming `~/.config/thurbox/ui` — a dev build's -interface directory is elsewhere: +**Ask first — always.** A mistake in `layout.lua` takes the whole interface +down, not one column, and it is the operator's file: the bundled panes, their +arrangement and every other plugin they have all live in it. So the choice is +theirs, and it is a real one — right of the terminal (the recommended place: a +narrow readout beside the agent, session list still on the left), left of it, +or the block printed for them to add by hand. + +On yes: ```bash -thurbox-cli plugin dir --text | head -1 +./scripts/place-pane.sh --dry-run # the file, the anchor and the exact block +./scripts/place-pane.sh # right of the terminal +./scripts/place-pane.sh --left # between the session list and the terminal +./scripts/place-pane.sh --check # is it placed? changes nothing ``` -**Print the block, say where it goes, and stop there on purpose.** A mistake in -`layout.lua` takes the whole interface down, not one column, and it is the -operator's file — the bundled panes, their arrangement and any other plugin they -have all live in it. This skill does not carry write tools for that reason. Say -plainly that you stopped, rather than offering to do it. +What makes that safe enough to run at all, and what its header argues in full: +it refuses a layout with no `columns` list it recognises rather than guessing, +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. + +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 +build's interface directory is elsewhere: + +```bash +thurbox-cli plugin dir --text | head -1 +``` **Give the guard, not just the slot.** `plugin check` suggests a bare `{ slot = "fleetqueue" }`, and that is enough to make the pane DRAW — which is @@ -237,9 +262,11 @@ removes nothing. That one command takes back the file, its `plugins.toml` entry and the lock together; `plugin list` names the path to pass while it is still installed, and `scripts/install-extension.sh`'s header owns this. -Removing the pane leaves the `layout.lua` block behind. It is guarded by -`filled(ctx, "fleetqueue")`, so an orphaned block carves nothing and is -harmless — but it is the operator's line to delete, on the same terms as §4. +Removing the pane leaves the `layout.lua` block behind, and `place-pane.sh` +has no verb that takes it back out — a block it did not necessarily write is +not one it should delete. It is guarded by `filled(ctx, "fleetqueue")`, so an +orphaned block carves nothing and is harmless; deleting it is the operator's +line, on the same terms as §4. Taking back the whole extension is a different verb — `thurbox-cli extension deactivate` / `uninstall`, which the installer's closing @@ -260,8 +287,8 @@ spells out in the column itself, so: | What you see | What it means | What to do | |---|---|---| -| no column; `plugin check` exits non-zero | installed, placed by nothing | §4 — print the block | -| column opens and never closes | placement block is missing `panels.shown` | §4 — add the guard | +| no column; `plugin check` exits non-zero | installed, placed by nothing | §4 — ask, then `./scripts/place-pane.sh` | +| column opens and never closes | placement block is missing `panels.shown` | §4 — the guard is missing from a hand-added block | | `F3` opens Help, Theme or Settings | the chord collides with a kernel one | rebind in thurbox settings; `check.sh pane` refuses a kernel chord in the repo | | `not trusted yet` | the `run` capability is declared, not granted | the operator grants it: settings (`Ctrl+,`) → `]` → `t`. You cannot do it for them | | `no '' session` | no session by the name the pane probes | the extension has not been installed, or the lead was renamed — §2, and `extension.toml.in`'s RENAMING header | @@ -299,7 +326,11 @@ 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. It holds one spelling of the slot name, the +drifting apart from the pane, and `./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 diff --git a/.agents/skills/update-fleet/SKILL.md b/.agents/skills/update-fleet/SKILL.md index 5f50489..bd5ea23 100644 --- a/.agents/skills/update-fleet/SKILL.md +++ b/.agents/skills/update-fleet/SKILL.md @@ -144,9 +144,9 @@ than inferring the pane from the exit code** — §4. ## 4. Pane — the installed plugin is stale `.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 that nothing -here writes, 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 +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. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8272c12..385f2a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,6 +191,27 @@ jobs: sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua - run: ./scripts/check.sh pane + # Unfiltered, for the same reason the pane job is: what it covers spans four + # scripts, a fixture and the pane's own slot declaration, so a paths filter + # would have to list all of them to be correct. It is also the setup nobody + # re-runs — a regression here is invisible to every operator who is already + # set up, and total for the next one. Two seconds, offline, all stubs. + onboarding: + name: Onboarding + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + # place-pane.sh reads its own edit back with `lua` and restores the + # backup when the result no longer parses; without an interpreter that + # assertion silently skips. The apt dance is the pane job's — see there. + - name: Install lua + run: | + sudo rm -f /etc/apt/sources.list.d/google-chrome.list /etc/apt/sources.list.d/google-chrome.sources + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends lua5.4 + sudo ln -sf /usr/bin/lua5.4 /usr/local/bin/lua + - run: ./scripts/check.sh onboarding + # Single required status check. Every job feeds this gate; a branch ruleset # requires only "All Checks", so adding or removing jobs never needs a # ruleset change — but a new job MUST be added to `needs:` below, or it can @@ -208,6 +229,7 @@ jobs: - reconcile - skills - pane + - onboarding runs-on: ubuntu-latest steps: - name: Verify all checks passed diff --git a/AGENTS.md b/AGENTS.md index de95f88..10a98de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,13 @@ names every path and the reason for each. Everything outside that fence is the lead's judgement and nothing ever overwrites it. Gitignored, like everything a run produces; the template is the one tracked file there. +- `scripts/preflight.sh` — every dependency fleet needs, in one pass, in three + tiers (required / recommended / gate), each row carrying what breaks without + it and the command that installs it. It probes and prints; installing is the + operator's, which is what `--commands` is for. `scripts/discover-owners.sh` + is its counterpart for the one input the map needs: it reads the `gh` + session, the git config and the remotes of the clones already on the disk, + and prints owner candidates with the evidence for each. Both write nothing. - `.agents/skills//SKILL.md` — agent skills, in one agent-agnostic tree. `.claude/skills` is a **symlink** to it, so Claude Code and opencode (which auto-discovers `.claude/skills`) both load the same copy. Never add a second @@ -98,7 +105,8 @@ names every path and the reason for each. registers the same skill twice. Five skills live there: `fleet-queue` (the queue: intake, ordering, dispatch, and the two halves of completion), `thurbox-session` (driving one worker session), `fleet-onboarding` (a fresh - clone to a working control plane), + clone to a working control plane: dependencies, owners, registry, extension, + the pane on screen, the loop up), `fleet-pane` (getting the TUI queue pane onto a screen, and diagnosing one that is installed and drawing nothing), and `update-fleet` (a working control plane that is BEHIND origin, and the consequences of the sync that @@ -249,7 +257,8 @@ CI only runs on pull requests, and routine control-plane changes go straight to ```bash ./scripts/check.sh # shellcheck, markdown, YAML, profiles, queue, - # reconciler, status, skills, pane + # reconciler, status, skills, pane, voice, + # onboarding ./scripts/check.sh --fix # same, applying the fixes a check can apply ``` diff --git a/README.md b/README.md index 9a657cd..5816a27 100644 --- a/README.md +++ b/README.md @@ -57,16 +57,30 @@ Then open the clone in your agent CLI and run: ``` The [onboarding skill](.agents/skills/fleet-onboarding/SKILL.md) does the setup -rather than instructing you through it — prerequisites, your GitHub owners, the -repo map, the thurbox extension, the queue pane and the reconciler — -verifying each step and naming anything missing with its remedy -before it writes a thing. -Run it twice and it converges. It hands you a guarded block to add yourself: -the pane's slot in your thurbox `layout.lua`. - -Requires `jq` and `thurbox-cli` **2.19.0 or newer**, plus the CLI for each forge -you work on: `gh` for GitHub, `glab` for GitLab, each authenticated. `gh` is not -optional even on a GitLab-only fleet — it is what builds the repo map. +rather than instructing you through it — seven steps: dependencies, this +checkout, your GitHub owners, the repo map, the thurbox extension, the queue +pane on screen, and the reconciler. It verifies each one and names anything +missing with its remedy before it writes a thing. Run it twice and it +converges. + +Four of those steps ask you something, and only four. Whether to install the +dependencies that are missing; which of the owners it found on your machine the +map should cover; where the queue pane goes (a column on the right, by +default); and whether to bring the reconciler up. It reads your `gh` session, +your git config and the remotes of the clones you already have, so the owners +step is a list to confirm rather than one to type. + +What it needs, and what it will tell you itself: + +```bash +./scripts/preflight.sh # every dependency, in three tiers, with why +./scripts/preflight.sh --commands # exactly what to run for the ones missing +``` + +`git`, `gh` (authenticated), `jq`, `python3` with PyYAML and `thurbox-cli` +**2.19.0 or newer** are required; `quota-axi` and `glab` are recommended, and +each names what degrades without it. `gh` is not optional even on a GitLab-only +fleet — it is what builds the repo map. That done, open the Mission Control session in thurbox and give it a goal. diff --git a/scripts/check.sh b/scripts/check.sh index 44e921e..f015ab6 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -13,8 +13,8 @@ # scripts/check.sh --fix markdown # apply the fixes a check can apply # # Checks: shell, markdown, yaml, profiles, queue, reconcile, status, skills, -# pane, voice. Only `markdown` has a fixer; `--fix` is a no-op for the rest, so -# `scripts/check.sh --fix` is always safe to run. +# pane, voice, onboarding. 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 # fails the check rather than skipping it — a gate that silently passes when @@ -542,6 +542,51 @@ check_voice() { [ "$miss" -eq 0 ] && ok "voice: $conf renders into FLEET.md's placeholders" } +# THE SETUP NOBODY RE-RUNS. Onboarding's three 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 for everyone who is not. `onboarding-selftest.sh` drives all three +# offline, against stubs on a PATH built from scratch and a copy of a stock +# layout, and its header argues each claim. +# +# What the greps here add is the seam that selftest cannot see: the pane's slot +# has ONE spelling, in the pane, and `place-pane.sh` writes a block into the +# operator's `layout.lua`. A copy of the slot name in the writer is a rename +# that half-lands — the pane declaring one slot and the arrangement carving +# another, which draws nothing and looks installed. +check_onboarding() { + local miss=0 pane="interface/fleet_queue.lua" writer="scripts/place-pane.sh" + local slot + slot="$(sed -n 's/^local SLOT = "\(.*\)"$/\1/p' "$pane" | head -1)" + + if [ -z "$slot" ]; then + fail "onboarding: could not read the slot name from $pane" + miss=1 + elif grep -q "\"$slot\"" "$writer"; then + fail "onboarding: $writer spells the slot \"$slot\" itself; it reads it from $pane so a rename cannot half-land" + miss=1 + elif ! grep -q "$pane" "$writer"; then + fail "onboarding: $writer no longer reads the slot from $pane" + miss=1 + fi + + # The floor has one owner too, and preflight is now a second reader of it. + if ! grep -q "min_thurbox_version" scripts/preflight.sh; then + fail "onboarding: scripts/preflight.sh does not read the thurbox floor from extension.toml.in" + miss=1 + fi + + if ./scripts/onboarding-selftest.sh >/dev/null 2>&1; then + ok "onboarding: scripts/onboarding-selftest.sh" + else + ./scripts/onboarding-selftest.sh + fail "onboarding: scripts/onboarding-selftest.sh" + miss=1 + fi + + [ "$miss" -eq 0 ] && ok "onboarding: the pane's slot is spelled once, in $pane" +} + checks=() for arg in "$@"; do case "$arg" in @@ -551,7 +596,7 @@ for arg in "$@"; do done if [ ${#checks[@]} -eq 0 ]; then - checks=(shell markdown yaml profiles queue reconcile status skills pane voice) + checks=(shell markdown yaml profiles queue reconcile status skills pane voice onboarding) fi for c in "${checks[@]}"; do @@ -566,8 +611,9 @@ for c in "${checks[@]}"; do skills) check_skills ;; pane) check_pane ;; voice) check_voice ;; + onboarding) check_onboarding ;; *) - printf 'error: unknown check %q (want: shell markdown yaml profiles queue reconcile status skills pane voice)\n' "$c" >&2 + printf 'error: unknown check %q (want: shell markdown yaml profiles queue reconcile status skills pane voice onboarding)\n' "$c" >&2 exit 2 ;; esac diff --git a/scripts/discover-owners.sh b/scripts/discover-owners.sh new file mode 100755 index 0000000..801b850 --- /dev/null +++ b/scripts/discover-owners.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash +# Who this operator's repos belong to, guessed from what is already on the +# machine — so onboarding asks one question instead of asking them to type a +# list they have written down nowhere. +# +# registry/owners.txt is the one input the repo map genuinely needs, and every +# part of it is already recorded somewhere: in the `gh` session, in the git +# configuration, and in the remotes of the clones the operator has been working +# in for years. This reads all three and prints candidates with the EVIDENCE for +# each, because a guess an operator cannot check is one they have to verify by +# hand anyway. +# +# gh account `gh api user` — the account the registry sync reads GitHub as +# gh org `gh api user/orgs` — orgs that session can see +# git config `github.user`, and a @users.noreply.github.com commit email +# local clones the `origin` of every git checkout under the roots scanned +# +# IT WRITES NOTHING. registry/owners.txt is the operator's file and stays +# theirs; this hands the candidates to whoever is about to ask them. +# +# GITHUB OWNERS ONLY, because that is what the file holds — the map is built +# from `gh`. GitLab clones found on the way are reported in their own section +# and belong in no owners file: a task targets a GitLab repo by path, through +# the forge seam in scripts/lib/forge.py. +# +# Usage: +# scripts/discover-owners.sh # candidates with their evidence +# scripts/discover-owners.sh --plain # bare owner names, one per line +# scripts/discover-owners.sh ~/work ~/oss # scan these roots instead +# +# Exit: 0 when at least one candidate was found, 1 when none was — which on an +# authenticated machine means `gh auth status` is the thing to read first. + +set -uo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")/.." || exit 2 +REPO_ROOT="$PWD" + +PLAIN=0 +ROOTS=() +while [ $# -gt 0 ]; do + case "$1" in + --plain) PLAIN=1 ;; + -h | --help) + sed -n '2,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + -*) + printf 'usage: %s [--plain] [root ...]\n' "$0" >&2 + exit 2 + ;; + *) ROOTS+=("$1") ;; + esac + shift +done + +# Where clones live, when the caller named no root. The checkout's own parent +# first — a control plane is usually cloned beside the work it orchestrates — +# then the handful of directories people actually keep code in. A root that +# does not exist is skipped, so this list costs nothing on a machine that uses +# none of them. +if [ ${#ROOTS[@]} -eq 0 ]; then + ROOTS=("$(dirname "$REPO_ROOT")" "$HOME/code" "$HOME/src" "$HOME/dev" + "$HOME/projects" "$HOME/work" "$HOME/git" "$HOME/repos") +fi + +declare -A SOURCES=() # owner -> "gh account, local clones (12)" +declare -A CLONES=() # owner -> number of local checkouts found +declare -A GITLAB=() # host/owner -> number of local checkouts found +ORDER=() # first-seen order, so gh's answers lead + +note() { + local owner="$1" source="$2" + if [ -z "${SOURCES[$owner]+x}" ]; then + SOURCES["$owner"]="$source" + ORDER+=("$owner") + else + case "${SOURCES[$owner]}" in + *"$source"*) ;; + *) SOURCES["$owner"]="${SOURCES[$owner]}, $source" ;; + esac + fi +} + +# --- what gh already knows ---------------------------------------------------- + +gh_login="" +scope_hint="" +if command -v gh >/dev/null 2>&1 && gh auth status >/dev/null 2>&1; then + gh_login="$(gh api user --jq .login 2>/dev/null)" + [ -n "$gh_login" ] && note "$gh_login" "gh account" + + # An org list that comes back empty on an account with orgs is a SCOPE + # problem and not an answer, and the two look identical from here — so the + # remedy is printed rather than the emptiness being treated as fact. + orgs="$(gh api user/orgs --jq '.[].login' 2>/dev/null)" + if [ -n "$orgs" ]; then + while IFS= read -r org; do + [ -n "$org" ] && note "$org" "gh org" + done <<<"$orgs" + else + scope_hint="gh listed no orgs. If you expect some, the token is missing a scope: gh auth refresh -s read:org" + fi +else + scope_hint="gh is not authenticated, so the account and its orgs could not be read: gh auth login" +fi + +# --- what the git configuration remembers ------------------------------------- +# +# Two fields carry a GitHub identity, and both are common on a machine whose +# `gh` was never logged in: `github.user`, which several tools set, and the +# noreply commit address, which is `12345+name@users.noreply.github.com`. + +cfg_user="$(git config --get github.user 2>/dev/null)" +[ -n "$cfg_user" ] && note "$cfg_user" "git config github.user" + +cfg_email="$(git config --get user.email 2>/dev/null)" +case "$cfg_email" in +*@users.noreply.github.com) + handle="${cfg_email%@users.noreply.github.com}" + handle="${handle##*+}" + [ -n "$handle" ] && note "$handle" "git commit email" + ;; +esac + +# --- what the clones on this disk say ----------------------------------------- +# +# The strongest evidence there is: an owner the operator has actually been +# working in. Remotes are read from each checkout's config file rather than by +# running git in it, so a scan over hundreds of directories costs no processes. + +scan_root() { + local root="$1" cfg url rest host owner + [ -d "$root" ] || return 0 + while IFS= read -r cfg; do + [ -f "$cfg" ] || continue + while IFS= read -r url; do + # Host and owner out of any remote shape, INCLUDING an ssh host + # ALIAS. `git@github-perso:Thurbeen/fleet.git` is what a machine + # with two GitHub accounts looks like, and matching on the literal + # `github.com` finds none of those clones — which on this very + # checkout was every one of them. + rest="${url#*://}" + rest="${rest#*@}" + host="${rest%%[:/]*}" + owner="${rest#*[:/]}" + owner="${owner%%/*}" + [ -n "$owner" ] || continue + case "$host" in + *github*) CLONES["$owner"]=$((${CLONES[$owner]:-0} + 1)) ;; + *gitlab*) GITLAB["$host/$owner"]=$((${GITLAB[$host/$owner]:-0} + 1)) ;; + esac + done < <(sed -n 's/^[[:space:]]*url[[:space:]]*=[[:space:]]*//p' "$cfg" 2>/dev/null) + done < <(find "$root" -maxdepth 5 -type d -name .git -prune -print 2>/dev/null | + sed 's|$|/config|') +} + +seen_roots="" +for root in "${ROOTS[@]}"; do + [ -d "$root" ] || continue + real="$(cd "$root" && pwd -P)" || continue + case "$seen_roots" in *"|$real|"*) continue ;; esac + seen_roots="$seen_roots|$real|" + scan_root "$real" +done + +# Clone counts join the candidate list AFTER gh's answers, biggest first, so +# the order an operator reads is the order they would pick in. +if [ ${#CLONES[@]} -gt 0 ]; then + while IFS= read -r line; do + count="${line%% *}" + owner="${line#* }" + note "$owner" "local clones ($count)" + done < <(for owner in "${!CLONES[@]}"; do printf '%s %s\n' "${CLONES[$owner]}" "$owner"; done | sort -rn) +fi + +# --- output ------------------------------------------------------------------- + +if [ ${#ORDER[@]} -eq 0 ]; then + if [ "$PLAIN" -eq 0 ]; then + printf 'No candidate owners found.\n\n' >&2 + [ -n "$scope_hint" ] && printf ' %s\n' "$scope_hint" >&2 + printf ' Nothing on this machine names a GitHub owner: no gh session, no\n' >&2 + printf ' github.user, and no clone with a github.com remote under the roots\n' >&2 + printf ' scanned. Name a root to scan, or write registry/owners.txt by hand\n' >&2 + printf ' from registry/owners.example.txt.\n' >&2 + fi + exit 1 +fi + +if [ "$PLAIN" -eq 1 ]; then + printf '%s\n' "${ORDER[@]}" + exit 0 +fi + +# Which candidates the operator has already committed to, so a re-run says +# "already there" instead of proposing the same list twice. +declare -A CONFIGURED=() +if [ -f registry/owners.txt ]; then + while IFS= read -r line; do + line="${line%%#*}" + line="$(printf '%s' "$line" | tr -d '[:space:]')" + [ -n "$line" ] && CONFIGURED["$line"]=1 + done