From fb7d1c2876632946072f8e0eecf66b7cfbaf2d1d Mon Sep 17 00:00:00 2001 From: letur Date: Thu, 10 Sep 2026 00:05:02 +0200 Subject: [PATCH 1/3] feat(voice): drop the lead's status table for a configurable register MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI queue pane draws the board live, so a table pasted into a reply repeats what the operator is already looking at. `FLEET.md`'s "How you report" now makes the default reply one or two lines, naming a task only when something changed or surprised. The register the section already described is now written around two names the operator owns rather than a literal: `FLEET.md` carries `@OPERATOR_NAME@` and `@ASSISTANT_NAME@`, `orchestration/voice.example.conf` holds the tracked defaults (SLAYER, VEGA) with a gitignored `voice.conf` beside it, and `scripts/install-extension.sh` renders both into a gitignored `FLEET.rendered.md` that the manifest ships as its payload — the same tracked/generated split the session glyph already uses, and for the same reason: an operator who renames themselves dirties no tracked file that `sync-checkout.sh` would then refuse to fast-forward. Two rules go in beside it: register, not costume, and a register is free where a bit costs a paragraph. Every accuracy rule survives unchanged. `scripts/check.sh voice` is the gate — it asserts the placeholders are in FLEET.md and the defaults are not, then renders through `install-extension.sh --render-only` off a throwaway conf and checks the names landed. Claude-Session: https://claude.ai/code/session_01ByfHVZg4W61Zw5Hv9g15pH --- .agents/skills/fleet-onboarding/SKILL.md | 12 ++- .agents/skills/update-fleet/SKILL.md | 10 +- .gitignore | 14 +++ CONTRIBUTING.md | 11 ++- FLEET.md | 60 +++++++----- extension.toml.in | 32 +++++-- orchestration/voice.example.conf | 47 ++++++++++ scripts/check.sh | 84 ++++++++++++++++- scripts/install-extension.sh | 111 ++++++++++++++++++++++- scripts/sync-checkout.sh | 15 ++- 10 files changed, 341 insertions(+), 55 deletions(-) create mode 100644 orchestration/voice.example.conf diff --git a/.agents/skills/fleet-onboarding/SKILL.md b/.agents/skills/fleet-onboarding/SKILL.md index 13dfef9..588a69a 100644 --- a/.agents/skills/fleet-onboarding/SKILL.md +++ b/.agents/skills/fleet-onboarding/SKILL.md @@ -159,9 +159,12 @@ a typo or an org the token cannot see, and it is fixable in seconds now. ./scripts/install-extension.sh ``` -It renders `extension.toml` (gitignored — it carries this clone's absolute -path) from `extension.toml.in`, then installs it. Verify, rather than trusting -the installer's own closing message: +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). +Verify, rather than trusting the installer's own closing message: ```bash thurbox-cli extension status fleet --json @@ -294,7 +297,8 @@ never the mechanism: a worker that ran out of quota fires no hook at all. ## 7. Hand over **Nothing this skill wrote is tracked.** `registry/owners.txt`, -`registry/repos.generated.yaml` and `extension.toml` are all gitignored, so +`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 diff --git a/.agents/skills/update-fleet/SKILL.md b/.agents/skills/update-fleet/SKILL.md index 0977b3b..b349ecc 100644 --- a/.agents/skills/update-fleet/SKILL.md +++ b/.agents/skills/update-fleet/SKILL.md @@ -96,15 +96,17 @@ do. Otherwise map the list: | A path in the range | Step | Why | |---|---|---| -| `extension.toml.in`, `FLEET.md` — or a `reinstall-extension:` line | §3 | the installed extension no longer matches what it was rendered from | +| `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 | | `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: it is the extension's `[[files]]` -payload *and* the lead's standing context, so it needs both the reinstall in §3 -and the hand-over in §8. +`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. `scripts/lib/queue.py` is deliberately absent from this table. The reconciler's loop never sources it — every pass calls `./scripts/queue.sh` as diff --git a/.gitignore b/.gitignore index a350c9c..e64d144 100644 --- a/.gitignore +++ b/.gitignore @@ -23,10 +23,24 @@ # extension.toml.in's header says why the path cannot be a `~`. /extension.toml +# GENERATED. FLEET.md with the two voice names substituted in, written by +# scripts/install-extension.sh and shipped as the extension's [[files]] payload. +# FLEET.md is the tracked source and carries the placeholders; +# orchestration/voice.example.conf carries the defaults. Rendering to a second +# file is what lets an operator rename themselves without dirtying the tree +# scripts/sync-checkout.sh has to fast-forward. +/FLEET.rendered.md + # WORKING STATE. Your GitHub owners. registry/owners.example.txt is the tracked # copy that documents the format, so a fresh clone still knows what goes here. /registry/owners.txt +# WORKING STATE. What you want the lead to call you, and what it answers to. +# orchestration/voice.example.conf is the tracked copy that carries the +# defaults and the renderer falls back to it — so this file only needs to exist +# when you want something other than SLAYER and VEGA. +/orchestration/voice.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/CONTRIBUTING.md b/CONTRIBUTING.md index 41d014b..43da0b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -110,9 +110,9 @@ Adding a file means deciding which side it is on, and saying so: | If it is… | Then | Example | |---|---|---| | machinery — scripts, skills, playbooks, prose | tracked | `scripts/`, `playbooks/ship-feature.md` | -| generated from a live source | ignored | `repos.generated.yaml` | +| generated from a live source, or rendered from a tracked one | ignored | `repos.generated.yaml`, `FLEET.rendered.md` | | true on one machine | ignored | `extension.toml`, `orchestration/reconcile/` | -| the operator's own working state | ignored | `runs/-.md`, `queue//` | +| the operator's own working state | ignored | `runs/-.md`, `queue//`, `voice.conf` | Prefer a whole ignored **directory** with a `!` negation for the one tracked form it contains, the way `registry/context/`, `orchestration/runs/` and @@ -160,6 +160,13 @@ session reports nothing and renders as `uncovered` while it works. `./scripts/install-extension.sh` and is gitignored — it carries your clone's absolute path. Edit the `.in` file and re-run the installer. +The same run renders `FLEET.rendered.md`, the payload the manifest ships, from +the tracked `FLEET.md` — substituting the two names in +`orchestration/voice.example.conf` (or the gitignored `voice.conf` beside it). +Edit `FLEET.md`, never the rendered copy. Rendering to a second file is what +lets an operator change what the lead calls them without dirtying the tree +`./scripts/sync-checkout.sh` has to fast-forward. + `min_thurbox_version` there is a claim about the whole range the manifest supports, and the file's header records why the floor sits where it does, along with why the path cannot be a `~`. Raise it only alongside the reason. diff --git a/FLEET.md b/FLEET.md index ecc305c..5f994af 100644 --- a/FLEET.md +++ b/FLEET.md @@ -4,7 +4,7 @@ You are the **Mission Control** session: the long-lived control plane for its owner's work across GitHub — whichever accounts and orgs are listed in `registry/owners.txt`. -You are called Mission Control, and the session wears a mark in front of that: +The SESSION is called Mission Control, and it wears a mark in front of that: thurbox has no per-session icon field, so the glyph the TUI shows can only live in the name. Which glyph is a setting the operator can turn off — `orchestration/session-glyphs.example.conf` is where it is chosen and @@ -203,37 +203,43 @@ it was worth keeping. ## How you report -**A routine status reply is a table, then AT MOST one line under it** — and -nothing under it at all when nothing surprised you. This is the shape: +You answer to @ASSISTANT_NAME@; the operator is @OPERATOR_NAME@. Mission +Control stays the SESSION's name — thurbox's, and the mailbox address. -```text -TOPIC TASK STATE ARTIFACT -shepherd-prs merge-open-prs shipped PR #34 (checks green) -remote-hosts probe-timeouts working — -declutter-app strip-dead-css blocked waits on #34 - -One surprise: probe-timeouts found ssh probes run serially. -``` +**The default reply is one or two lines.** Name a task only when something +about it CHANGED or surprised you. `interface/fleet_queue.lua` draws the board +live in a thurbox column — topics, states, artifacts — so a status table in a +reply repeats what @OPERATOR_NAME@ is already looking at, which the "one fact +in one place" rule below already forbids. -`./scripts/fleet-status.sh` is that opening block in ONE call — fuel, queue, -sessions, PRs, checkout — so assemble it from five commands only when that one -has failed you. +`./scripts/fleet-status.sh` answers "where are we" in ONE call — fuel, queue, +sessions, PRs, checkout. Run it when asked and assemble the same picture from +five commands only when it has failed you. Asked is the condition: unprompted, +it is the table again. -**The register is Mission Control's, and it lives in verb choice and -terseness, not in props.** +**The register lives in verb choice and terseness, not in props.** Short +declarative sentences. No adjectives, no build-up, no reassurance. State a +limit as a fact and move on. | do | example | | --- | --- | -| terse status calls | `Three on the board, one holding.` | -| go/no-go phrasing for a gate | `#34 is go — checks green.` | -| telemetry words for an unfinished thing | `probe-timeouts running, no result yet.` | +| terse status calls | `Three running. One holding.` | +| go/no-go phrasing for a gate | `#34 is clear. Checks green.` | +| telemetry words for an unfinished thing | `probe-timeouts running. No result yet.` | | hold/release words for a blocker | `Holding 03 until #34 is on main.` | +| a limit stated flat | `I cannot merge that. You can.` | | do not | why | | --- | --- | -| quoted film lines, "Houston", ranks, callsigns, an invented ship | it is a register, not a costume | -| emoji, rocket glyphs, ASCII flourish | the operator reads this in a terminal | -| a voice word that softens a state word | the rule below outranks this one | +| quoted lines, callsigns, ranks, an invented ship or facility | it is a register, not a costume | +| roleplay narration, in-fiction preamble, a themed sign-off | same rule, and it costs a paragraph | +| emoji, glyphs, ASCII flourish | @OPERATOR_NAME@ reads this in a terminal | +| a voice word standing in for a state word | the accuracy rules below outrank this one | + +**The register is free; a bit is not.** A register is how the sentences you were +already writing get phrased — it adds no tokens. A bit adds a paragraph nobody +asked for. When the two are indistinguishable in effect, you have written the +bit. Cut it. The register never costs a fact. Where the two pull against each other, the fact wins: @@ -246,12 +252,16 @@ fact wins: - **No estimates** — not time, not effort, not percent complete. - **Report the artifact, not the intention, and never restate the brief.** A PR URL and its check status. "The worker should have opened a PR" is not a - result, and the operator already approved the brief — give them the outcome + result, and @OPERATOR_NAME@ already approved the brief — give the outcome and what was surprising. - **Say what you did not do**, and why, in one line. Silence about a skipped step reads as completion. -- **One fact in one place.** Do not repeat in prose what the block above - already shows, and never re-explain a settled decision — act on it. +- **One fact in one place.** Do not repeat in prose what the pane already + shows, and never re-explain a settled decision — act on it. + +Both names are settings, not literals: `orchestration/voice.example.conf` +carries them, a gitignored `voice.conf` beside it overrides, and +`scripts/install-extension.sh` renders them into the copy you are reading. ## Rules that bite diff --git a/extension.toml.in b/extension.toml.in index 4e2287d..929b0d1 100644 --- a/extension.toml.in +++ b/extension.toml.in @@ -229,9 +229,11 @@ # extensions and two self-healing sessions, each recreating itself, plus an # orphaned `~/.config/thurbox/extensions/fleet/`. # -# FLEET.md's FILENAME is separate again: this manifest names it four times (one -# [[files]] path, three [[symlinks]] targets), so renaming the file means -# editing those four lines too. Neither rename above requires it. +# THE PAYLOAD's FILENAME is separate again: this manifest names +# `FLEET.rendered.md` four times (one [[files]] path, three [[symlinks]] +# targets), so renaming it — or renaming the `FLEET.md` it is rendered from — +# means editing those four lines and `scripts/install-extension.sh` together. +# Neither rename above requires it. # # WORKER session names are in none of this. They are free-form strings that # nothing resolves — by the convention in orchestration/playbooks/ each is an @@ -247,7 +249,7 @@ name = "fleet" description = "Control-plane session: the repo map and thurbox orchestration" config_version = 1 -version = "1.2.0" # this extension's own version; bump on changes +version = "1.3.0" # this extension's own version; bump on changes # WHY THE FLOOR IS 2.19.0 — it is a claim about what this control plane's own # prose relies on, not a note about the version somebody happens to run. @@ -312,23 +314,35 @@ min_thurbox_version = "2.19.0" # Only the standing context ships. The registry, playbooks and run logs stay in # the git checkout — they are versioned there, and `repos.generated.yaml` is # generated, so shipping a copy would immediately drift. +# +# AND WHAT SHIPS IS THE RENDERED COPY, NOT THE TRACKED ONE. `FLEET.md` is the +# source and carries `@OPERATOR_NAME@` and `@ASSISTANT_NAME@`; +# `scripts/install-extension.sh` substitutes the two names out of +# `orchestration/voice.example.conf` (or the gitignored `voice.conf` beside it) +# and writes `FLEET.rendered.md`, which is gitignored exactly as the rendered +# manifest is. Rendering to a second file rather than in place is the whole +# point: an operator who changed what the lead calls them has edited no tracked +# file, so `scripts/sync-checkout.sh` still has a clean tree to fast-forward. +# +# The `substitute` flag below would not have done this. It substitutes `{home}` +# and nothing else, so the names could never have travelled that way. [[files]] -path = "FLEET.md" +path = "FLEET.rendered.md" -# --- context-file symlinks: surface FLEET.md to each CLI's convention --------- +# --- context-file symlinks: surface the payload to each CLI's convention ------ [[symlinks]] link = "CLAUDE.md" -target = "FLEET.md" +target = "FLEET.rendered.md" [[symlinks]] link = "AGENTS.md" -target = "FLEET.md" +target = "FLEET.rendered.md" [[symlinks]] link = "GEMINI.md" -target = "FLEET.md" +target = "FLEET.rendered.md" # --- runtime resources: ensured on activate, self-healed if deleted ----------- # diff --git a/orchestration/voice.example.conf b/orchestration/voice.example.conf new file mode 100644 index 0000000..0af86c1 --- /dev/null +++ b/orchestration/voice.example.conf @@ -0,0 +1,47 @@ +# The two names the lead's standing context is written around — what it calls +# you, and what it answers to — and this is the tracked copy of them. +# +# COPY IT TO `voice.conf` BESIDE THIS FILE TO CHANGE ANYTHING. That copy is +# gitignored, the same way `session-glyphs.conf` and `registry/owners.txt` are: +# what you want to be called is a property of you, not of this repo. The reader +# falls back to THIS file when it is absent, so the defaults below need no copy +# at all — and an operator who edited a tracked file instead would hand +# `scripts/sync-checkout.sh` a dirty tree, which it refuses to fast-forward. +# +# WHY IT IS RENDERED AND NOT READ. `FLEET.md` carries `@OPERATOR_NAME@` and +# `@ASSISTANT_NAME@` — `@`-delimited, because markdown reads `__x__` as bold — +# and `scripts/install-extension.sh` substitutes these into `FLEET.rendered.md`, +# the payload the extension actually ships, the same way it substitutes +# `__LEAD_GLYPH__` into the manifest. The alternative — +# FLEET.md telling the lead to go and read this file — is a read that happens +# once and is then forgotten, because the lead freezes its instructions at +# launch and never reloads them. A name it was never told is a name it invents. +# +# SO CHANGING ONE OF THESE NEEDS A RE-INSTALL AND A LEAD RESTART, in that +# order, and installing is not applying: +# +# ./scripts/install-extension.sh +# thurbox-cli session restart '' +# +# `.agents/skills/update-fleet/` owns that sequence and its cost. Until you run +# it, the running lead goes on using the names it was rendered with. +# +# THESE ARE A REGISTER, NOT A COSTUME. FLEET.md's `## How you report` holds the +# rule and it outranks whatever you put here: the names live in verb choice and +# terseness, and every accuracy rule in that section outranks both. A name here +# buys no roleplay, no preamble and no paragraph of theme. + +# --- THE NAMES ---------------------------------------------------------------- +# +# `OPERATOR_NAME` what the lead calls YOU. +# `ASSISTANT_NAME` what the lead answers to. The SESSION is still called +# Mission Control — that name is thurbox's, it is what the +# TUI lists and the mailbox addresses, and it is set in +# `extension.toml.in`. This is the name in the prose, which is +# a different thing and free to differ. +# +# Plain text, one line each, and no shell metacharacters: the renderer refuses a +# name carrying a quote, a pipe or a backslash rather than letting it break the +# substitution. +OPERATOR_NAME=SLAYER +ASSISTANT_NAME=VEGA diff --git a/scripts/check.sh b/scripts/check.sh index 14921af..fc3aec1 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. Only `markdown` has a fixer; `--fix` is a no-op for the rest, so +# 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. # # Requires: shellcheck, rumdl, python3 (with PyYAML), lua. A missing tool @@ -464,6 +464,83 @@ check_skills() { [ "$missing" -eq 0 ] && ok "skills: $link -> $target resolves; every skill has a SKILL.md" } +# THE VOICE, WHICH IS A SETTING AND NOT A LITERAL. `FLEET.md` is the lead's +# standing context and the source of the extension's payload, and the two names +# in it — what the lead calls the operator and what it answers to — are the +# operator's choice, exactly as the session glyph is. So `orchestration/voice.example.conf` +# is the one place they are spelled, `scripts/install-extension.sh` renders them +# into the gitignored `FLEET.rendered.md` the manifest actually ships, and +# `FLEET.md` carries placeholders. A name written into FLEET.md would be a +# second copy of the setting that no `voice.conf` could move. +check_voice() { + need git voice || return + + local conf="orchestration/voice.example.conf" miss=0 + if [ ! -f "$conf" ]; then + fail "voice: $conf is missing; nothing would render the lead's names" + return + fi + + local key val names=() + for key in OPERATOR_NAME ASSISTANT_NAME; do + val="$(sed -n "s/^$key=//p" "$conf" | head -1)" + if [ -z "$val" ]; then + fail "voice: $conf sets no $key" + miss=1 + else + names+=("$val") + fi + done + + # The placeholders have to be IN FLEET.md, and the defaults have to not be: + # the render is what puts a name in front of the lead, and a literal beside + # the placeholder is the copy that stops moving. + for key in @OPERATOR_NAME@ @ASSISTANT_NAME@; do + if ! grep -qF -- "$key" FLEET.md; then + fail "voice: FLEET.md carries no $key; the render would have nothing to substitute" + miss=1 + fi + done + for val in ${names[@]+"${names[@]}"}; do + if grep -qF -- "$val" FLEET.md; then + fail "voice: FLEET.md spells '$val' itself; the name is a setting the render carries in, never one the prose holds" + miss=1 + fi + done + + # AND THE RENDER ITSELF, which is the only part of this a grep cannot + # argue with. Rendered into a temp directory, off an override conf, so the + # gate never touches the operator's own rendered payload. + local tmp + tmp="$(mktemp -d)" || { + fail "voice: could not make a temp directory to render into" + return + } + printf 'OPERATOR_NAME=GATEOP\nASSISTANT_NAME=GATEAI\n' >"$tmp/voice.conf" + + local out="$tmp/FLEET.rendered.md" report + if ! report="$(FLEET_VOICE_CONF="$tmp/voice.conf" \ + ./scripts/install-extension.sh --render-only "$tmp" 2>&1)"; then + fail "voice: install-extension.sh --render-only failed: $report" + miss=1 + elif [ ! -f "$out" ]; then + fail "voice: --render-only wrote no $out" + miss=1 + else + grep -qF -- GATEOP "$out" || + { fail "voice: the rendered payload does not carry OPERATOR_NAME from the conf"; miss=1; } + grep -qF -- GATEAI "$out" || + { fail "voice: the rendered payload does not carry ASSISTANT_NAME from the conf"; miss=1; } + if grep -qE -- '@(OPERATOR|ASSISTANT)_NAME@' "$out"; then + fail "voice: a name placeholder survived into the rendered payload" + miss=1 + fi + fi + rm -rf "$tmp" + + [ "$miss" -eq 0 ] && ok "voice: $conf renders into FLEET.md's placeholders" +} + checks=() for arg in "$@"; do case "$arg" in @@ -473,7 +550,7 @@ for arg in "$@"; do done if [ ${#checks[@]} -eq 0 ]; then - checks=(shell markdown yaml profiles queue reconcile status skills pane) + checks=(shell markdown yaml profiles queue reconcile status skills pane voice) fi for c in "${checks[@]}"; do @@ -487,8 +564,9 @@ for c in "${checks[@]}"; do status) check_status ;; skills) check_skills ;; pane) check_pane ;; + voice) check_voice ;; *) - printf 'error: unknown check %q (want: shell markdown yaml profiles queue reconcile status skills pane)\n' "$c" >&2 + printf 'error: unknown check %q (want: shell markdown yaml profiles queue reconcile status skills pane voice)\n' "$c" >&2 exit 2 ;; esac diff --git a/scripts/install-extension.sh b/scripts/install-extension.sh index 033c3e0..d42597f 100755 --- a/scripts/install-extension.sh +++ b/scripts/install-extension.sh @@ -19,6 +19,23 @@ # spawns. Nothing else in the repo spells the glyph: the pane matches the lead # without it, and prose calls the lead Mission Control. # +# 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 +# operator's, not this repo's. So FLEET.md carries `@OPERATOR_NAME@` and +# `@ASSISTANT_NAME@` — `@`-delimited and not `__`-delimited like the manifest's +# placeholders, because markdown reads `__x__` as bold and the linter says so — +# `orchestration/voice.example.conf` is the one place they +# are chosen (`voice.conf` beside it is the gitignored override), and this +# script writes the substituted copy to a gitignored `FLEET.rendered.md` that +# the manifest ships as its `[[files]]` payload. Rendering to a SECOND file and +# not in place is what keeps `scripts/sync-checkout.sh` a clean tree to +# fast-forward: an operator who renamed themselves has changed no tracked file. +# +# AND A RENDERED PAYLOAD REACHES NO RUNNING LEAD. The session froze FLEET.md at +# launch, so a new name is a re-install AND a restart — the same two steps a +# glyph needs, and `.agents/skills/update-fleet/` owns them. +# # CHANGING THE GLYPH IS A RENAME, and this script cannot apply one. It renders # and installs the new name; the session that is already running keeps the old # one, because thurbox has no rename verb and `ensure_extension` matches by @@ -62,7 +79,19 @@ # command takes back the file, its `plugins.toml` entry and the lock together — # `plugin list` says where it came from in the meantime. # -# Requires: git, thurbox-cli, jq. +# Usage: +# ./scripts/install-extension.sh # render, then install +# ./scripts/install-extension.sh --render-only +# # render both files into +# # and stop. Touches +# # nothing thurbox owns and +# # needs neither thurbox-cli +# # nor jq, which is how +# # `scripts/check.sh voice` +# # exercises the substitution +# # without installing. +# +# Requires: git, thurbox-cli, jq — the last two only for a real install. set -euo pipefail @@ -71,17 +100,36 @@ die() { exit 1 } +RENDER_ONLY="" +case "${1-}" in +--render-only) + [ $# -eq 2 ] || die "--render-only takes a directory" + RENDER_ONLY="$2" + [ -d "$RENDER_ONLY" ] || die "not a directory: $RENDER_ONLY" + ;; +"") ;; +*) die "unknown argument: $1 (usage: $0 [--render-only ])" ;; +esac + command -v git >/dev/null || die "git not found" -command -v thurbox-cli >/dev/null || die "thurbox-cli not found; install thurbox first" -command -v jq >/dev/null || die "jq not found" +if [ -z "$RENDER_ONLY" ]; then + command -v thurbox-cli >/dev/null || die "thurbox-cli not found; install thurbox first" + command -v jq >/dev/null || die "jq not found" +fi REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repository; run this from your clone" +DEST="${RENDER_ONLY:-$REPO_ROOT}" + IN="$REPO_ROOT/extension.toml.in" -OUT="$REPO_ROOT/extension.toml" +OUT="$DEST/extension.toml" + +FLEET_IN="$REPO_ROOT/FLEET.md" +FLEET_OUT="$DEST/FLEET.rendered.md" [ -f "$IN" ] || die "missing $IN" +[ -f "$FLEET_IN" ] || die "missing $FLEET_IN" case "$REPO_ROOT" in /*) ;; @@ -120,6 +168,37 @@ case "$LEAD_GLYPH" in ;; 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 +# gate can prove the substitution without writing over the operator's answer. +VOICE_CONF="${FLEET_VOICE_CONF:-}" +if [ -z "$VOICE_CONF" ]; then + VOICE_CONF="$REPO_ROOT/orchestration/voice.conf" + [ -f "$VOICE_CONF" ] || VOICE_CONF="$REPO_ROOT/orchestration/voice.example.conf" +fi +[ -f "$VOICE_CONF" ] || die "missing the voice setting: $VOICE_CONF" + +voice_setting() { + sed -n "s/^$1=//p" "$VOICE_CONF" | head -1 +} + +OPERATOR_NAME="$(voice_setting OPERATOR_NAME)" +ASSISTANT_NAME="$(voice_setting ASSISTANT_NAME)" +[ -n "$OPERATOR_NAME" ] || die "no OPERATOR_NAME in $VOICE_CONF" +[ -n "$ASSISTANT_NAME" ] || die "no ASSISTANT_NAME in $VOICE_CONF" + +# `|` is the sed delimiter below, and a backslash or a `&` in the replacement is +# sed's own syntax rather than the name the operator typed. Refuse rather than +# render something they did not write. +for name in "$OPERATOR_NAME" "$ASSISTANT_NAME"; do + case "$name" in + *'|'* | *\\* | *'&'* | *"'"* | *'"'*) + die "a name in $VOICE_CONF contains a quote, a pipe, a backslash or an '&': $name" + ;; + esac +done + tmp="$(mktemp)" trap 'rm -f "$tmp"' EXIT @@ -137,6 +216,30 @@ mv "$tmp" "$OUT" trap - EXIT printf 'rendered %s (repo_path = %s, lead glyph = %s)\n' "$OUT" "$REPO_ROOT" "$LEAD_GLYPH" +# 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 +# the operator as @OPERATOR_NAME@. +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT + +sed -e "s|@OPERATOR_NAME@|$OPERATOR_NAME|g" \ + -e "s|@ASSISTANT_NAME@|$ASSISTANT_NAME|g" "$FLEET_IN" >"$tmp" + +if grep -q '@OPERATOR_NAME@\|@ASSISTANT_NAME@' "$tmp"; then + die "placeholder survived substitution; $FLEET_OUT not written" +fi +[ -s "$tmp" ] || die "rendered payload is empty; $FLEET_OUT not written" + +mv "$tmp" "$FLEET_OUT" +trap - EXIT +printf 'rendered %s (operator = %s, lead answers to = %s)\n' \ + "$FLEET_OUT" "$OPERATOR_NAME" "$ASSISTANT_NAME" + +if [ -n "$RENDER_ONLY" ]; then + printf '\n--render-only: nothing was installed.\n' + exit 0 +fi + # Read the names out of the manifest rather than hardcoding them, so a rename # (extension.toml.in's header owns the procedure) reaches this script's checks # and hints for free. diff --git a/scripts/sync-checkout.sh b/scripts/sync-checkout.sh index 950a510..571b859 100755 --- a/scripts/sync-checkout.sh +++ b/scripts/sync-checkout.sh @@ -34,10 +34,17 @@ set -uo pipefail # thurbox extension no longer matches the manifest it was rendered from. Neither # is fixable from here, so both are reported as actions for the operator. INSTRUCTION_PATHS=(FLEET.md AGENTS.md CLAUDE.md .agents/skills .claude/skills .claude/settings.json) -# session-glyphs.example.conf is a wiring path because the installer RENDERS it -# into the manifest: new defaults there are a new lead name, and a name is the -# one thing a re-install cannot apply on its own. -WIRING_PATHS=(extension.toml.in FLEET.md orchestration/session-glyphs.example.conf) +# session-glyphs.example.conf and voice.example.conf are wiring paths because +# the installer RENDERS both: new defaults in the first are a new lead name, and +# a name is the one thing a re-install cannot apply on its own; new defaults in +# the second change what the rendered FLEET payload calls the operator, which +# needs the re-install AND the restart above. +WIRING_PATHS=( + extension.toml.in + FLEET.md + orchestration/session-glyphs.example.conf + orchestration/voice.example.conf +) # The lead's name as the INSTALLED manifest spells it — never a literal here. # The glyph in front of it is a setting (orchestration/session-glyphs.conf), so From 69f6e6d070d20fa7665e4d111f8a5604a1e67167 Mon Sep 17 00:00:00 2001 From: letur Date: Thu, 10 Sep 2026 00:11:42 +0200 Subject: [PATCH 2/3] chore: no-mistakes review - Reject '@' in operator/assistant name to prevent placeholder collision --- scripts/install-extension.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/install-extension.sh b/scripts/install-extension.sh index d42597f..476b737 100755 --- a/scripts/install-extension.sh +++ b/scripts/install-extension.sh @@ -189,12 +189,15 @@ ASSISTANT_NAME="$(voice_setting ASSISTANT_NAME)" [ -n "$ASSISTANT_NAME" ] || die "no ASSISTANT_NAME in $VOICE_CONF" # `|` is the sed delimiter below, and a backslash or a `&` in the replacement is -# sed's own syntax rather than the name the operator typed. Refuse rather than -# render something they did not write. +# sed's own syntax rather than the name the operator typed. `@` is the +# placeholder delimiter itself: a name containing `@ASSISTANT_NAME@` or +# `@OPERATOR_NAME@` would have the OTHER substitution rewrite it after this +# one applied, silently swapping one operator's name for the other's. Refuse +# rather than render something they did not write. for name in "$OPERATOR_NAME" "$ASSISTANT_NAME"; do case "$name" in - *'|'* | *\\* | *'&'* | *"'"* | *'"'*) - die "a name in $VOICE_CONF contains a quote, a pipe, a backslash or an '&': $name" + *'|'* | *\\* | *'&'* | *"'"* | *'"'* | *'@'*) + die "a name in $VOICE_CONF contains a quote, a pipe, a backslash, an '&' or an '@': $name" ;; esac done From 3041ddb47210951544568bbbe09e075099f5a851 Mon Sep 17 00:00:00 2001 From: letur Date: Thu, 10 Sep 2026 00:19:28 +0200 Subject: [PATCH 3/3] chore: no-mistakes document - Sync voice.example.conf's denylist comment with the & and @ rejections --- orchestration/voice.example.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/orchestration/voice.example.conf b/orchestration/voice.example.conf index 0af86c1..546743e 100644 --- a/orchestration/voice.example.conf +++ b/orchestration/voice.example.conf @@ -41,7 +41,7 @@ # a different thing and free to differ. # # Plain text, one line each, and no shell metacharacters: the renderer refuses a -# name carrying a quote, a pipe or a backslash rather than letting it break the -# substitution. +# name carrying a quote, a pipe, a backslash, an `&` or an `@` rather than +# letting it break the substitution or collide with the other placeholder. OPERATOR_NAME=SLAYER ASSISTANT_NAME=VEGA