feat(seats): the env file's settings count, and a machine's own seats file

ApiConfig and PoolConfig read the seat order, the budgets and the
concurrency from the process environment alone, so a `.env` that said
REVIEW_GATES_SEAT_ORDER=oauth-first ordered nothing: the Python driver
reads those lines from the file under the process environment
(config_env, 2026-10-17: a setting lives beside the seats it orders).
SeatPool::SettingsOf does the same - the file's placement settings
under the process environment, in a map handed to the placement readers
alone (ApiConfig, PoolConfig, Seats::ConfigFromEnv) - and leaves the
process environment untouched, so no other reader sees a line of the
file: a path's or a binary's line reaches nothing, and two drivers
reading one .env keep one board and one ledger. The placement settings
are one list, SeatPool::PlacementSettings - the fifteen names those
readers read - for the map, the run's log and what makes a working
directory's .env a seats file; a REVIEW_GATES_* line of any other name
stays out of the map and is named, by the run's log and on `seats`'s
stderr. One dotenv grammar serves the tokens and the settings
(Seats::DotenvLines).

`run` and `seats` resolve the file from their own --env-file
(SeatPool::ResolveSeatsFile): the --env-file, else $SEATS_ENV_FILE, else
the working directory's .env - the operator's, as the Python driver and
`seats` read it, not the repository under review's - when it is a file
holding a seat or a placement setting, each with a value, or one that
cannot be read as UTF-8 text (its error is then the command's) - a
readable application .env, its Vite or Supabase lines, one of blank
placeholder lines or one that is no file is passed over, and the run
says so - else the machine's own
$XDG_CONFIG_HOME/crafter-review/env, else ~/.config/crafter-review/env:
one file for every repository on a machine, a repository's seats .env
winning over it. Each file is read once and the settings and the tokens
are taken from that read (SeatsFile::Text), so a file that can be read
only once - a pipe, `--env-file <(gpg -d seats.env.gpg)`, a FIFO - gives
its settings and its seats alike, and a failed read raises its own error
where the tokens are laid over the environment: SeatPool::WithFileTokens,
for `run` and `seats` alike, and LoadSeats takes the merged environment
and no longer reads a file by path. An empty `seats --env-file` means no file
now, as it did for `run`, and the `seats` report names the side that
runs first (it said "api seats run first" whatever the order).

Runs, Commands and KernelModes, the tests that run `run` and `seats`,
point XDG_CONFIG_HOME, and HOST_XDG_CONFIG_HOME for the Flatpak, at a
path of their own that nothing creates, so a global file stays out of
them; the grammar, the settings and their precedence, the lookup and the
one read have their tests, a run with `--env` (a prefix) and with a
repeated --env-file, and a `seats` with `--env`, take tokens and
settings from the one file, an application's .env and a venv are passed
over, a FIFO is read once, no line of the file reaches `stop`, every
listed placement setting moves a reader, another REVIEW_GATES_* line is
named and a .env of blank lines passed over, and the
README, the environment table and the procedure say the new rule.
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:51:48 +01:00
commit e215ad986e
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
16 changed files with 483 additions and 91 deletions

View file

@ -9,7 +9,7 @@ This file is for changing crafter-review itself. `README.md` says what it is and
- Each lens is one `claude --print` process per unit (block passes). Where the lens has one, a whole pass gates the blocks and hands them its summary.
- A pass returns structured output: a verdict, findings, notes, and one coverage entry per unit. `:Normalize` derives the verdict from the findings and fails closed on a missing or blocking entry.
- Clean results are stored per unit under a content key and reused across hashes (`:Plan`). The static lenses need three clean reads of a unit before it is reused; the functional lens needs one.
- Processes run on the seats of the consumer's `.env` (`:SeatPool`). A lens's processes get the environment the config names: `none`; `browser`, a Playwright MCP server per pass (`:Mcp`, `:Pool`); `supabase`, the local stack's database through the `sql` helper (`:PgWire`); or `browser+supabase`.
- Processes run on the seats of the seats file (`SeatPool::ResolveSeatsFile`: `--env-file`, `$SEATS_ENV_FILE`, the consumer's `.env`, else the machine's own) and of the process environment, the file's lines over the environment's; with no seat in either, on this machine's login. A lens's processes get the environment the config names: `none`; `browser`, a Playwright MCP server per pass (`:Mcp`, `:Pool`); `supabase`, the local stack's database through the `sql` helper (`:PgWire`); or `browser+supabase`.
- A browser pass holds a stack of its own (`Pool::Stacks`), and a pass launches only when it fits in free memory at its environment's measured cost (`Pool::Admission`, `:Memory`).
- Drivers on one machine share one board, `~/.cache/review-gates/seat-board.json` (`SeatPool::Board`). `State::RunLock` allows one full run per hash.
- The kernel modes (2026-10-31): `--mode series` and `--mode reply` run the pre-send pipeline of a kernel contributor's workbench with the same machinery. Their own pieces: the inventory of a cover letter, a mail draft and the camera review's shape (`:Inventory` PartitionReply, PartitionMail), the ten kernel lenses with their gates (`Plan::Lenses` rows with `kernel`, `Plan::Gates(kind)`), text units keyed on what they cite and the anchor (`Plan::TextDeps`) and a series' code units on the code like the push gates, the persona gate - one `maintainer-persona` instance per `--reviewers` entry, its whole pass pruning the not-mine units - with the persona vocabulary in `:Schema`, `:Normalize` and the matrix, the references prefetched through `utilities/lore.py` and the persona corpora (`:Refs`), the kernel texts in `:Prompt`, the `GATES CLEAN` verdict, `personaPass` in unit-state, and the subsystem's journal in the run layer and the dispositions block. Their prompts, keys and plans were checked byte for byte against the pipeline they replace, on its own artifacts.
@ -111,7 +111,7 @@ Conventions:
No file the drivers write holds these in practice, and a file that fails to parse is treated as a damaged one.
- **HTTP.** `ClientHTTP1::Fetch` ignores `*_proxy` variables. The OpenRouter requests send `User-Agent: crafter-review`; `app_ready` sends `review-gates`. Reason phrases are the standard ones, not the server's.
- **Seat placement** (SeatPool, Seats, Drivers):
- **Seat placement** (SeatPool, Seats, Drivers): The settings beside the seats count too. `run` (Gate 0) and `seats` each resolve the seats file from their own `--env-file` with `SeatPool::ResolveSeatsFile` (`--env-file`, `$SEATS_ENV_FILE`, `<cwd>/.env` when it is a file holding a seat or a placement setting, each with a value (a blank line is neither), or one that cannot be read as UTF-8 text (its error is then the command's) - a readable application `.env`, or one that is no file, is passed over, recorded as `passedOver` and logged by the run - the machine's own), reading each file once (`SeatsFile::Text()`: a pipe or a FIFO `--env-file` gives its settings and its seats alike, and a failed read raises its own error where the tokens are laid over the environment, `SeatPool::WithFileTokens` - the one place, so `LoadSeats` takes the merged environment and reads no file). `SeatPool::SettingsOf` lays the file's placement settings (`Seats::SettingsFromEnvFile`, one dotenv grammar with the tokens', `DotenvLines`; `SeatPool::PlacementSettings`, the fifteen names the readers read, also deciding what makes `<cwd>/.env` a seats file - a test pins that each moves a reader) under the process environment into a map handed to the placement readers alone - `ApiConfig`, `PoolConfig`, `Seats::ConfigFromEnv` - names its other `REVIEW_GATES_*` lines (the run logs them, `seats` prints them on stderr), and the process environment is not touched, so no other reader sees a line of the file: a path's or a binary's line reaches nothing, and two drivers reading one `.env` keep one board and one ledger (the Python driver's `config_env` of 2026-10-17; the first self-review's rounds 3 to 10, 2026-11-01, reworked it as an application to the process environment until round 11 found the map the readers already took; the per-seat `ANTHROPIC_API_KEY<n>_GRANT_DAY` cycle days are still unported: one `REVIEW_GATES_API_CYCLE_DAY` for every API seat) (SeatPool).
- An account whose 5-hour limit Anthropic has removed reports the weekly window alone. Its burst window (`Seats::BurstWindow`) is the weekly one: it is ranked and admitted by the weekly window with the passes in flight projected on it (0.04 points per pass at xhigh, 0.125 otherwise), under the 99 % cutoff.
- A pass's projected share of the windows follows the run's effort: 0.5 / 0.125 points of the 5-hour window and 0.125 / 0.04 of the weekly one at max / xhigh (`SeatPool::LensCostPct`, `LensCost7dPct`), the pooled means of 5438 passes on 141 seat-runs (2026-10-12..15) rounded up. The weekly cutoff is 99 %: the running passes are projected against it, so the last percent is spent a pass at a time.
- Every OAuth account and the local login takes at most 128 passes at once, every driver's counted.
@ -181,9 +181,10 @@ What the binary reads at run time, and how. A variable the driver only sets or s
| `CLAUDE_CODE_OAUTH_TOKEN1`..`6` (+ legacy `CLAUDE_CODE_OAUTH_TOKEN`) | `:Seats` SeatTokensFromEnv, via `:SeatPool` LoadSeats (run) and `seats` | none | The fixed names 1..6 only; `TOKEN7` does nothing. Stripped; an empty value is no seat. The bare name is seat 1 when `TOKEN1` is empty. The env file's lines override the process environment. Stripped from every child's environment. | OAuth seats `1`..`6`; a token reaches claude only as its seat's `CLAUDE_CONFIG_DIR/.credentials.json` |
| `ANTHROPIC_API_KEY1`..`6` | `:Seats` ApiKeysFromEnv (run, seats) | none | The fixed names 1..6, stripped; empty is no seat. The bare `ANTHROPIC_API_KEY` is never a seat (the env-file parser wants the digit too) and is stripped from every child. | API-credit seats `api1`..`api6`, each key in its own process's `ANTHROPIC_API_KEY` only |
| `OPENROUTER_KEY`, `OPENROUTER_KEY1`..`6` | `:Seats` OpenRouterKeysFromEnv, via LoadSeats (run; `seats` ignores them) | none | As the OAuth tokens; the bare name is `or1` when `KEY1` is empty. Never in `auto`: `--seats openrouter` or `orN` ids; mixed with other seats it is a preflight error (exit 3). | OpenRouter seats `or1`..`or6` |
| `SEATS_ENV_FILE` | `:Run` Gate0 (run), `seats` | run: `<repo>/.env` if it exists; `seats`: `./.env` if it exists | `--env-file` wins (in run even `--env-file ""`, meaning no file); empty counts as unset. Only seat lines are taken (`[export ]NAME=value`, one pair of matching quotes stripped), overriding the process environment; no knob below is ever read from the file. A file that cannot be read, or is not UTF-8: run stops at preflight (exit 3), `seats` exits 1 with the read error. | The dotenv file the seat secrets come from |
| `REVIEW_GATES_SEATS` | `:Driver` RunCommand → LoadSeats | `auto` | Only without `--seats`. Stripped, case-sensitive; unset or empty is `auto` (every api and oauth seat, else `local`); `local`, `api`, `oauth`, `openrouter` select that kind; anything else is a comma list of ids (`apiN`, `N`, `orN`, `local`; blank items skipped). An id not configured, or OpenRouter mixed with others, is a preflight error (exit 3). | The seats a run places lens processes on |
| `REVIEW_GATES_SEAT_MAX_UTIL` | `:Seats` ConfigFromEnv (`seats`; run through `:SeatPool` PoolConfig) | `85` | A finite number >= 0, in decimal or exponent notation; surrounding whitespace, `_` between digits and Unicode digits are taken. Empty, garbage, negative, `inf`, `nan` keep the default; a whole value is stored as an int. Process environment only. | Ceiling (%) of an OAuth seat's projected 5-hour utilisation |
| `SEATS_ENV_FILE` | `:SeatPool` ResolveSeatsFile (run's Gate 0, `seats`) | `<cwd>/.env` if it is a file holding a seat or a placement setting, each with a value (or no UTF-8 text: its error is the command's), else the machine's `$XDG_CONFIG_HOME/crafter-review/env` (`~/.config/crafter-review/env`) | `--env-file` wins (in run and `seats` even `--env-file ""`, meaning no file); empty counts as unset. Seat lines are taken (`[export ]NAME=value`, one pair of matching quotes stripped), overriding the process environment; the file's placement settings (`SeatPool::PlacementSettings`) go, under the process environment, into the map the placement readers take (`SeatPool::SettingsOf`, from the one read the tokens come from too), so every placement knob below may come from the file, the process environment winning; no other reader sees a line of it, and its other `REVIEW_GATES_*` lines are named (the run's log, `seats` on stderr). A file that cannot be read, or is not UTF-8: run stops at preflight (exit 3), `seats` exits 1 with the read error. | The dotenv file the seat secrets come from |
| `XDG_CONFIG_HOME` | `:SeatPool` GlobalEnvFile | `~/.config` | The directory whose `crafter-review/env` is the machine's own seats file, the last place a run or `seats` looks (2026-11-01). Runs, Commands and KernelModes, the tests that run `run` and `seats`, point it (and `HOST_XDG_CONFIG_HOME`) at a path nothing creates, so a global file on the machine stays out of them. |
| `REVIEW_GATES_SEATS` | `:Driver` RunCommand → LoadSeats | `auto` | Only without `--seats`; the process environment's alone, never from the seats file. Stripped, case-sensitive; unset or empty is `auto` (every api and oauth seat, else `local`); `local`, `api`, `oauth`, `openrouter` select that kind; anything else is a comma list of ids (`apiN`, `N`, `orN`, `local`; blank items skipped). An id not configured, or OpenRouter mixed with others, is a preflight error (exit 3). | The seats a run places lens processes on |
| `REVIEW_GATES_SEAT_MAX_UTIL` | `:Seats` ConfigFromEnv (`seats`; run through `:SeatPool` PoolConfig) | `85` | A finite number >= 0, in decimal or exponent notation; surrounding whitespace, `_` between digits and Unicode digits are taken. Empty, garbage, negative, `inf`, `nan` keep the default; a whole value is stored as an int. The process environment, else the seats file (`SeatPool::SettingsOf`). | Ceiling (%) of an OAuth seat's projected 5-hour utilisation |
| `REVIEW_GATES_LENS_COST_PCT` | `:Seats` ConfigFromEnv; `:SeatPool` PoolConfig | run: `0.5` at max and any other effort, `0.125` at xhigh; `seats`: `10` | As MAX_UTIL. PoolConfig puts in the effort's figure only when it is unset or blank, so a set but invalid value gives 10, in run too. | Percentage points of the 5-hour window a running process is projected to add (its cost on the board too) |
| `REVIEW_GATES_SEAT_POLICY` | `:Seats` ConfigFromEnv | `spread` | Exactly `pack` (case-sensitive, untrimmed) is pack; anything else spread. | pack: the first eligible OAuth seat by id; spread: the lowest projected one |
| `REVIEW_GATES_SEAT_WAIT_MIN` | `:Seats` ConfigFromEnv | `15` | As MAX_UTIL | Shown by `seats --json` as `waitMaxMin`; nothing else reads it |
@ -198,21 +199,21 @@ What the binary reads at run time, and how. A variable the driver only sets or s
| `REVIEW_GATES_OPENROUTER_BUDGET_USD` | `:SeatPool` ApiConfig | `100.0` | As API_BUDGET_USD | USD per cycle per OpenRouter key |
| `REVIEW_GATES_OPENROUTER_LENS_COST_USD` | `:SeatPool` ApiConfig | `0.1` | As API_BUDGET_USD | Reserve per process on an OpenRouter seat |
| `REVIEW_GATES_OPENROUTER_CONCURRENCY` | `:SeatPool` ApiConfig | `16` | As API_CONCURRENCY | Processes at once on one OpenRouter seat |
| `REVIEW_GATES_SCRATCH` | `:Run` ScratchRoot (run, slots); `:SeatPool` LedgerPath (run, seats) | `~/.cache/review-gates` | `--scratch` wins; empty counts as unset; used verbatim. | Where the `<hash8>-run-<n>` dirs, `memory.json` and `seat-ledger.json` live; not the board |
| `REVIEW_GATES_BOARD` | `:SeatPool` BoardPath (run, seats, stop) | `~/.cache/review-gates/seat-board.json` | Non-empty: the board file, verbatim; its directory also holds `drivers/`, the lifetime locks. | The one board per machine every driver shares; set it for tests and hand-made boards |
| `REVIEW_GATES_MEM_RESERVE_MB` | `:Memory` Reserve (run's admission, slots) | `max(2048, MemTotal / 10)` MB | Unset or empty: the default. Otherwise a number in MAX_UTIL's syntax, truncated toward zero; a negative value enlarges the budget. Garbage and `nan` exit 1 with a ValueError, `inf` with an OverflowError; a value past int64 saturates. | MB of available memory never given to passes or slots |
| `REVIEW_GATES_SCRATCH` | `:Run` ScratchRoot (run, slots); `:SeatPool` LedgerPath (run, seats) | `~/.cache/review-gates` | `--scratch` wins; empty counts as unset; used verbatim; never from the seats file. | Where the `<hash8>-run-<n>` dirs, `memory.json` and `seat-ledger.json` live; not the board |
| `REVIEW_GATES_BOARD` | `:SeatPool` BoardPath (run, seats, stop) | `~/.cache/review-gates/seat-board.json` | Non-empty: the board file, verbatim; its directory also holds `drivers/`, the lifetime locks; never from the seats file. | The one board per machine every driver shares; set it for tests and hand-made boards |
| `REVIEW_GATES_MEM_RESERVE_MB` | `:Memory` Reserve (run's admission, slots) | `max(2048, MemTotal / 10)` MB | The process environment's alone, never from the seats file. Unset or empty: the default. Otherwise a number in MAX_UTIL's syntax, truncated toward zero; a negative value enlarges the budget. Garbage and `nan` exit 1 with a ValueError, `inf` with an OverflowError; a value past int64 saturates. | MB of available memory never given to passes or slots |
| `CRAFTER_REVIEW_HOME` | `:Core` Home | `<exe>/../share/crafter-review` (the Flatpak's `/app/share/crafter-review`), else the checkout when the exe runs from `<checkout>/bin/crafter.review-exe-*/` | Natively only; ignored in the Flatpak. Empty counts as unset. A set value without `process/lenses` is an error, with no fallback. | Where `process/` (the lenses, the templates, the skill) is read from |
| `REVIEW_GATES_CLAUDE_BIN` | `:Pool` Runner (lens processes, run's probes); `:Seats` FindClaudeBin (second) | FindClaudeBin's search | Non-empty: used verbatim, unchecked. In run: `--claude-bin`, then this, then FindClaudeBin. | The claude binary |
| `REVIEW_GATES_CLAUDE_BIN` | `:Pool` Runner (lens processes, run's probes); `:Seats` FindClaudeBin (second) | FindClaudeBin's search | Non-empty: used verbatim, unchecked; never from the seats file. In run: `--claude-bin`, then this, then FindClaudeBin. | The claude binary |
| `CLAUDE_BIN` | `:Seats` FindClaudeBin (`seats`, run's last fallback) | after it: `claude` on PATH, the editor-extension bundles (`~/.vscode-oss`, `.vscode`, `.vscode-insiders`, `.cursor`; newest first), `~/.claude/local/claude`, `~/.local/bin/claude`, then the bare `claude` | Non-empty: verbatim. FindClaudeBin checks it before `REVIEW_GATES_CLAUDE_BIN`, so it wins in `seats` and loses in run. | The claude binary |
| `REVIEW_GATES_OPENCODE_BIN` | `:OpenCode` DefaultBin (`--harness opencode`) | `opencode` on PATH | `--opencode-bin` wins; empty counts as unset. | The opencode binary |
| `REVIEW_GATES_OPENCODE_BIN` | `:OpenCode` DefaultBin (`--harness opencode`) | `opencode` on PATH | `--opencode-bin` wins; empty counts as unset; never from the seats file. | The opencode binary |
| `REVIEW_GATES_DB` | `:PgWire` SqlMain → CallBroker (`crafter-review sql`, in a pass) | none: NO DATABASE | `<host>:<port>#<token>`, split at the last `#`, then at the last `:`. Unset, empty or no `#`: NO DATABASE, exit 2; a port that is no integer: NO DATABASE with the parse error, exit 2; a port outside 0..65535: exit 1 with an OverflowError. The driver sets it per pass (`""` without a database) and strips an inherited one. | The driver's SQL broker and this pass's database token |
| `REVIEW_GATES_STRUCTURED_OUT` | the opencode tool `:OpenCode` ToolSource generates (reads `process.env`) | none | A file path; unset or empty: the tool writes nothing and still reports success. Set per process by `:OpenCode` ChildEnv. | Where an opencode pass's structured result goes |
| `REVIEW_GATES_SIMULATE_FAIL` | `:Pool` Runner (`--dry-run` only) | none | Split on commas, items untrimmed, empty ones dropped: a unit id fails that unit's block pass, `whole` every whole pass. Ignored without `--dry-run`. | A test hook: "simulated failure (REVIEW_GATES_SIMULATE_FAIL)" |
| `REVIEW_GATES_SIMULATE_FAIL` | `:Pool` Runner (`--dry-run` only) | none | The process environment's alone, never from the seats file. Split on commas, items untrimmed, empty ones dropped: a unit id fails that unit's block pass, `whole` every whole pass. Ignored without `--dry-run`. | A test hook: "simulated failure (REVIEW_GATES_SIMULATE_FAIL)" |
| `PATH` | `:Process` spawn and the supervisor; `:Seats` FindClaudeBin, ProbeSeat | unset: `/usr/local/bin:/usr/bin:/bin` (spawn), `/usr/bin:/bin` (supervisor); the Flatpak sets `/app/bin:/usr/bin` | Colon-separated; an empty entry is the cwd; a name with `/` is used as is. | Where child programs (claude, git, npx …) are found |
| `HOME` | `:Platform` Home (every `~` path); `:Seats` ProbeEnv | unset or empty: the password database's, else `/` | Verbatim. | `~/.cache/review-gates`, the XDG defaults, claude's install locations, `~/.cache/ms-playwright`, init's home-directory check |
| `TZ` | `:Seats` LocalZone (the `seats` report); the C library behind `:Platform` LocalTime | the zone `/etc/localtime` points to, else the C library's `%Z` | A leading `:` stripped. `UTC`, or a name with a file under `/usr/share/zoneinfo`, is the zone (the C library converts with TZ set to it: libc++ has no tzdb); any other name only labels the report. | The zone of the `seats` report and of every local timestamp (the default `--date`, the ledger's cycle) |
| `TMPDIR` | libc++'s `temp_directory_path`, from `:Seats` ProbeSeat and `:Freeze` | `/tmp` | The library's lookup: TMPDIR, TMP, TEMP, TEMPDIR. | Where the probes' config dirs (`rg-seat-*`, removed after) and freeze's temp dirs go |
| `HOST_XDG_{CONFIG,CACHE,DATA,STATE}_HOME` | `:Platform` RestoreHostXdg (Init, the sandbox only) | `$HOME/.config`, `.cache`, `.local/share`, `.local/state` | Non-empty: copied verbatim into `XDG_<KIND>_HOME`, which is always overwritten. Never read natively. | The host's XDG dirs for git, claude, npx and Playwright, instead of `~/.var/app/…` |
| `HOST_XDG_{CONFIG,CACHE,DATA,STATE}_HOME` | `:Platform` RestoreHostXdg (Init, the sandbox only) | `$HOME/.config`, `.cache`, `.local/share`, `.local/state` | Non-empty: copied verbatim into `XDG_<KIND>_HOME`, which is always overwritten. Never read natively. | The host's XDG dirs for git, claude, npx and Playwright, and for the machine's own seats file (`$XDG_CONFIG_HOME/crafter-review/env`, `SeatPool::GlobalEnvFile`), instead of `~/.var/app/…` |
| `SHELL` | `:Platform` RestoreShell (Init, the sandbox only) | — | Kept when it ends in `/bash` or `/zsh` and is executable; else `/bin/bash` when that is executable. Never read natively. | The shell claude's Bash tool runs commands with |
| `XDG_RUNTIME_DIR` | `:Platform` Paths (at Init, the sandbox only) | `/run/user/<uid>` | Empty counts as unset; verbatim. | Where `.flatpak/<instance>/bwrapinfo.json` gives the driver its host-pid identity |
| `XDG_CACHE_HOME` | `:Driver` PlaywrightBrowsers (`browser install`) | `~/.cache` | Empty counts as unset; in the Flatpak it already holds the host's. | The parent of `ms-playwright` when PLAYWRIGHT_BROWSERS_PATH is unset |
@ -232,7 +233,7 @@ In the tests only:
| `PATH` | LensTools | `""` | Colon-separated | Every program a lens's allowlist names, bash, node and npx must be on it: a skip natively, a FAIL inside the Flatpak |
| `FAKE_*` | the claude stand-ins of Runs and Scheduler | set by the test | — | Plumbing between a test and its stand-in |
Runs and Commands drop every `REVIEW_GATES_*`, `CRAFTER_REVIEW_*`, `SEATS_ENV_FILE`, seat secret and `FAKE_*` (Commands also `CLAUDE_BIN`) from the environment they run the binary with, then set their own `CRAFTER_REVIEW_HOME` and `REVIEW_GATES_BOARD` (Commands also `REVIEW_GATES_SCRATCH` and `TZ=UTC`): a developer's own settings never reach them.
Runs and Commands drop every `REVIEW_GATES_*`, `CRAFTER_REVIEW_*`, `SEATS_ENV_FILE` and seat secret (Runs also `FAKE_*`, Commands also `CLAUDE_BIN`) from the environment they run the binary with, then set their own `CRAFTER_REVIEW_HOME` and `REVIEW_GATES_BOARD` (Commands also `REVIEW_GATES_SCRATCH` and `TZ=UTC`), and Runs, Commands and KernelModes pin `XDG_CONFIG_HOME` and `HOST_XDG_CONFIG_HOME` (the Flatpak's `Platform::Init` copies the latter over the former) to `no-config` in their own temporary directory, a path nothing creates, so a developer's own `~/.config/crafter-review/env` stays out in both modes: a developer's own settings never reach them.
## CI and publishing

View file

@ -28,7 +28,7 @@ alias crafter-review='flatpak run net.catcrafts.CrafterReview'
What the machine provides:
- **The Claude Code CLI.** It is proprietary and not bundled. crafter-review looks for it in this order: `CLAUDE_BIN` or `REVIEW_GATES_CLAUDE_BIN`, the PATH, the editor extensions' bundled binary (newest first), then `~/.claude/local` and `~/.local/bin`. It runs inside the sandbox, so every pass is measured and every pass's process tree can be killed.
- **Seats.** The lens processes run on the seats in the repository's `.env` (`CLAUDE_CODE_OAUTH_TOKEN1..6`, `ANTHROPIC_API_KEY1..6`), else this machine's own login. `crafter-review seats` probes and reports them. It never prints a token. An account whose 5-hour limit Anthropic has removed shows `no limit` in that column and is placed by its weekly window. Every account takes at most 128 passes at once, every driver's counted, and there is no machine-wide cap beyond that and free memory (`run --concurrency N` sets one).
- **Seats.** The lens processes run on the seats of a dotenv file (`CLAUDE_CODE_OAUTH_TOKEN1..6`, `ANTHROPIC_API_KEY1..6`): `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` if it is a file holding a seat or a placement setting, or one that cannot be read as UTF-8 text (its read error then stops `run` and `seats`) - a readable application `.env` is passed over, and the run says so - else the machine's own `~/.config/crafter-review/env` (`$XDG_CONFIG_HOME/crafter-review/env`: one file for every repository, a repository's seats `.env` winning over it). Its seat lines go over the process environment's; with no seat in either, this machine's own login. The placement settings among its lines - the seat order, the budgets, the concurrency, the utilisation ceilings - are read with it, under the process environment; any other `REVIEW_GATES_*` line (a path, a binary) is not, and `run` and `seats` name it. `crafter-review seats` probes and reports them. It never prints a token. An account whose 5-hour limit Anthropic has removed shows `no limit` in that column and is placed by its weekly window. Every account takes at most 128 passes at once, every driver's counted, and there is no machine-wide cap beyond that and free memory (`run --concurrency N` sets one).
- **Chromium, for a lens with the browser.** Run `crafter-review browser install` once. It installs the Chromium build the pinned `@playwright/mcp@0.0.83` drives into `~/.cache/ms-playwright`, where the MCP server looks. It runs Playwright's own installer, which also removes the browser builds no installed Playwright on the machine refers to.
- **A local Supabase stack, for a lens with the database** (`supabase start`, or the repository's own bring-up). The `sql` helper speaks the Postgres protocol itself: no `psql` and no driver to install. Every call is one transaction, rolled back.
- **gpg, if your commits are signed.** `freeze` reads each commit's signature through gnupg's keyboxd socket, which is all the sandbox is given. The agent, which signs, stays out of reach.
@ -88,7 +88,7 @@ The dump with every finding, note and coverage entry is `gate-state/commits-<has
### Reviewing another repository
A directory can review repositories other than itself: a workbench whose config, `.env` and records serve several. From it, `crafter-review freeze --repo <path> --out <dir> --worktree`, then the printed run line. The run reads the repository under review from the artifact's `freeze.json`: the prompt names it, the calibration is kept under it, and `--trees` defaults to the recorded worktree. The config, the env file and the slots are the working directory's, the lenses run in it, and `--context-paths` are relative to it.
A directory can review repositories other than itself: a workbench whose config, `.env` and records serve several. From it, `crafter-review freeze --repo <path> --out <dir> --worktree`, then the printed run line. The run reads the repository under review from the artifact's `freeze.json`: the prompt names it, the calibration is kept under it, and `--trees` defaults to the recorded worktree. The config and the slots are the working directory's, the seats file is looked up from it (Seats, above), the lenses run in it, and `--context-paths` are relative to it.
### The kernel modes: a patch series, a mailing-list reply

View file

@ -310,7 +310,7 @@ inventory unit, against a frozen commit range.)";
{"--models", true, "MODELS", models},
{"--scratch", true, "SCRATCH", ""},
{"--seats", true, "SEATS", "the seats the lenses run on: auto (every Anthropic seat in the env file, else local), local, api, oauth, openrouter (never in auto), or a list like api1,1,2,local"},
{"--env-file", true, "ENV_FILE", "dotenv file with CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6 (default $SEATS_ENV_FILE, else the repository .env)"},
{"--env-file", true, "ENV_FILE", "dotenv file with CLAUDE_CODE_OAUTH_TOKEN1..6 / ANTHROPIC_API_KEY1..6 and the placement settings (REVIEW_GATES_SEAT_ORDER, the budgets, the concurrency): default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"},
{"--no-probe", false, "", "skip the one-request probe of every seat at the start"},
{"--api-budget", true, "API_BUDGET", "USD an API-credit seat may spend per cycle (default REVIEW_GATES_API_BUDGET_USD or 200)", Kind::Float},
{"--harness", true, "{claude,opencode}",
@ -477,7 +477,7 @@ inventory unit, against a frozen commit range.)";
// ---- seats: the report, the ledger and the board beside it ------------------
constexpr std::array<OptionSpec, 5> SeatsOptions = {{
{"--env-file", true, "ENV_FILE", "dotenv file holding the seat tokens (default: $SEATS_ENV_FILE, else ./.env when it exists)"},
{"--env-file", true, "ENV_FILE", "dotenv file holding the seat tokens and the placement settings: default $SEATS_ENV_FILE, else the working directory's .env when it holds a seat or a placement setting, else $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env); an empty value: none"},
{"--json", false, "", "the probe results as JSON instead of the report"},
{"--no-fable", false, "", "probe on Haiku instead of Fable (cheaper; the Fable weekly window stays unread)"},
{"--local", false, "", "also probe the CLI's own login, shown as the seat `local`"},
@ -541,18 +541,24 @@ inventory unit, against a frozen commit range.)";
if (!parsed.error.empty()) return UsageError(UsageLine(Prog, SeatsOptions), Prog, parsed.error);
const std::optional<std::string> timeoutText = parsed.Value("timeout");
const double timeout = timeoutText ? Dyn::ParseFloat(*timeoutText).value_or(Seats::ProbeTimeoutS) : Seats::ProbeTimeoutS;
// The seats file, read once: its placement settings, under the
// process environment, for the placement readers alone
// (SeatPool::SettingsOf; any other REVIEW_GATES_* line of it is
// named on stderr), its tokens over the process environment for
// the probes (SeatPool::WithFileTokens).
const SeatPool::SeatsFile seatsFile = SeatPool::ResolveSeatsFile(parsed.Value("env-file"), fs::current_path());
const std::optional<fs::path> envPath = seatsFile.path;
const std::map<std::string, std::string> process = Platform::Environment();
const Json cfg = Seats::ConfigFromEnv(process);
std::string envFile = parsed.Value("env-file").value_or("");
if (envFile.empty()) envFile = Platform::GetEnv("SEATS_ENV_FILE").value_or("");
std::error_code error;
if (envFile.empty() && fs::exists(".env", error)) envFile = ".env";
std::map<std::string, std::string> env = process;
if (!envFile.empty()) {
// The env file, read as text: one that cannot be read, or is not
// UTF-8, ends the command.
for (const auto& [name, value] : Seats::SeatTokensFromEnvFile(Dyn::ReadText(envFile))) env[name] = value;
const SeatPool::Settings fileSettings = SeatPool::SettingsOf(seatsFile);
const std::map<std::string, std::string>& settings = fileSettings.env;
if (!fileSettings.ignored.empty() && envPath) {
std::println(std::cerr, "seats file: {} in {} - no placement setting, read from the process environment alone", Dyn::Join(fileSettings.ignored, ", "), envPath->string());
}
const Json cfg = Seats::ConfigFromEnv(settings);
std::string envFile = envPath ? envPath->string() : std::string();
// The file's one read: one that could not be read, or is not UTF-8,
// ends the command with the read's error.
std::map<std::string, std::string> env = SeatPool::WithFileTokens(process, seatsFile);
std::vector<SeatJob> jobs;
for (const auto& [seat, token] : Seats::SeatTokensFromEnv(env)) jobs.push_back({seat, token, false});
if (parsed.Flag("local")) jobs.insert(jobs.begin(), SeatJob {0, "", true});
@ -589,7 +595,7 @@ inventory unit, against a frozen commit range.)";
seats.push_back(Seats::MergeFableProbe(found != standIns.end() ? found->second : s, s));
}
}
const Json apiConfig = SeatPool::ApiConfig(process);
const Json apiConfig = SeatPool::ApiConfig(settings);
std::vector<Json> apiSeats;
if (!apiJobs.empty()) {
std::vector<SeatJob> todo;

View file

@ -696,20 +696,32 @@ namespace Crafter::Review::Run {
.sampler = sampler.get(),
.broker = broker.get(),
});
// The seats file (--env-file, else the lookup from the working
// directory), read once: its placement settings, under the process
// environment, go to the placement readers alone (SeatPool::SettingsOf:
// ApiConfig here, PoolConfig here and in the Scheduler) and its other
// REVIEW_GATES_* lines are named, its tokens over the process
// environment's into the seats below (SeatPool::WithFileTokens).
const SeatPool::SeatsFile seatsFile = SeatPool::ResolveSeatsFile(a.envFile, fs::current_path());
const SeatPool::Settings settings = SeatPool::SettingsOf(seatsFile);
settingsEnv = settings.env;
if (seatsFile.path) {
log(std::format("settings: {} from {} (under the process environment, for the placement settings)", settings.fromFile.empty() ? std::string("none") : Dyn::Join(settings.fromFile, ", "),
seatsFile.path->string()));
if (!settings.ignored.empty()) {
log(std::format("seats file: {} in {} - no placement setting, read from the process environment alone", Dyn::Join(settings.ignored, ", "), seatsFile.path->string()));
}
}
if (seatsFile.passedOver) log(std::format("seats file: {} is no seats file (no seat, no placement setting) - passed over", seatsFile.passedOver->string()));
const std::map<std::string, std::string> processEnv = Platform::Environment();
apiCfg = SeatPool::ApiConfig(processEnv);
const std::optional<std::string> envFile = seatsFile.path ? std::optional<std::string>(seatsFile.path->string()) : std::nullopt;
apiCfg = SeatPool::ApiConfig(settingsEnv);
if (a.apiBudget) apiCfg["budgetUsd"] = *a.apiBudget;
ledger = std::make_unique<SeatPool::Ledger>(SeatPool::Ledger::Load(SeatPool::LedgerPath(scratchRoot), apiCfg));
std::optional<std::string> envFile = a.envFile;
if (!envFile) {
if (const std::optional<std::string> configured = Platform::GetEnv("SEATS_ENV_FILE"); configured && !configured->empty()) {
envFile = configured;
} else if (fs::exists(repo / ".env")) {
envFile = (repo / ".env").string();
}
}
try {
seats = SeatPool::LoadSeats(processEnv, envFile && !envFile->empty() ? std::optional<fs::path>(*envFile) : std::nullopt, a.seats.value_or(""), ledger.get(), apiCfg);
// The tokens from the file's one read, over the process
// environment's; a read that failed raises its error here.
seats = SeatPool::LoadSeats(SeatPool::WithFileTokens(processEnv, seatsFile), a.seats.value_or(""), ledger.get(), apiCfg);
} catch (const std::invalid_argument& e) {
Die(e.what());
} catch (const Dyn::Error& e) {
@ -731,7 +743,7 @@ namespace Crafter::Review::Run {
} catch (const std::runtime_error& e) {
Die(e.what());
}
const Json pc = SeatPool::PoolConfig(processEnv, a.effort);
const Json pc = SeatPool::PoolConfig(settingsEnv, a.effort);
board = std::make_unique<SeatPool::Board>(SeatPool::BoardPath(), std::string(), std::nullopt, log);
std::vector<std::string> runParts = {a.mode};
if (a.subsystem && !a.subsystem->empty()) runParts.push_back(*a.subsystem);
@ -762,7 +774,7 @@ namespace Crafter::Review::Run {
const double now = Platform::Now();
board->Publish(seats, now, !a.noProbe);
board->Fold(seats, now);
for (const std::string& line : board->OthersLines(seats, now, SeatPool::PoolConfig(processEnv, a.effort), apiCfg, a.concurrency)) log(line);
for (const std::string& line : board->OthersLines(seats, now, SeatPool::PoolConfig(settingsEnv, a.effort), apiCfg, a.concurrency)) log(line);
}
if (a.harness == "opencode") {
runner->opencodeHome = OpenCode::PrepareHome(scratch, repo, modelIds);
@ -1624,7 +1636,7 @@ namespace Crafter::Review::Run {
.cacheKey = warm ? std::function<std::optional<std::string>(Json&)>([this](Json& job) { return CacheKey(job); }) : nullptr,
.warmProbe = warm ? std::function<bool(const Json&)>([this](const Json& job) { return WarmProbe(job); }) : nullptr,
.seats = &seats,
.seatConfig = SeatPool::PoolConfig(Platform::Environment(), a.effort),
.seatConfig = SeatPool::PoolConfig(settingsEnv, a.effort),
.apiConfig = apiCfg,
.ledger = ledger.get(),
.admission = admission.get(),

View file

@ -347,11 +347,97 @@ namespace Crafter::Review::SeatPool {
};
}
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, const std::optional<fs::path>& envFile, std::string_view specText, Ledger* ledger, const Json& apiConfig) {
std::map<std::string, std::string> merged = env;
if (envFile && !envFile->empty()) {
for (const auto& [key, value] : Seats::SeatTokensFromEnvFile(Dyn::ReadText(*envFile))) merged[key] = value;
namespace {
// The one read of a seats file: its text, or the read's error for
// Text() to raise where a command loads its tokens.
void ReadOnce(SeatsFile& out, const fs::path& file) {
out.path = file;
try {
out.text = Dyn::ReadText(file);
} catch (const Dyn::Error&) {
out.error = std::current_exception();
}
}
}
std::optional<fs::path> GlobalEnvFile() {
fs::path dir;
if (const std::optional<std::string> xdg = Platform::GetEnv("XDG_CONFIG_HOME"); xdg && !xdg->empty()) {
dir = fs::path(*xdg);
} else {
dir = fs::path(Platform::Home()) / ".config";
}
const fs::path file = dir / "crafter-review" / "env";
std::error_code error;
if (fs::is_regular_file(file, error)) return file;
return std::nullopt;
}
const std::string& SeatsFile::Text() const {
if (error) std::rethrow_exception(error);
return text;
}
namespace {
bool IsPlacementSetting(std::string_view name) {
return std::ranges::contains(PlacementSettings, name);
}
// A seat as the seat readers count one (a token with a value), or a
// placement setting with a value: a blank line is neither.
bool HoldsSeatOrSetting(const std::string& text) {
const std::map<std::string, std::string> tokens = Seats::SeatTokensFromEnvFile(text);
if (!Seats::SeatTokensFromEnv(tokens).empty() || !Seats::ApiKeysFromEnv(tokens).empty() || !Seats::OpenRouterKeysFromEnv(tokens).empty()) return true;
return std::ranges::any_of(Seats::SettingsFromEnvFile(text), [](const auto& entry) { return IsPlacementSetting(entry.first) && !Dyn::Strip(entry.second).empty(); });
}
}
SeatsFile ResolveSeatsFile(const std::optional<std::string>& given, const fs::path& cwd) {
SeatsFile out;
if (given) {
if (!given->empty()) ReadOnce(out, *given);
return out;
}
if (const std::optional<std::string> named = Platform::GetEnv("SEATS_ENV_FILE"); named && !named->empty()) {
ReadOnce(out, *named);
return out;
}
// The working directory's .env is the seats file when it is a file
// holding a seat or a placement setting, each with a value (a blank
// placeholder line is neither), or one that cannot be read as UTF-8
// text (its error is then the command's to report). A
// repository's application config (its Vite or Supabase lines), or a
// .env that is no file (a Python venv), is passed over for the
// machine's own file (2026-11-01, the self-review's rounds 9 and 10).
std::error_code error;
if (fs::exists(cwd / ".env", error)) {
if (fs::is_regular_file(cwd / ".env", error)) {
ReadOnce(out, cwd / ".env");
if (out.error || HoldsSeatOrSetting(out.text)) return out;
out = SeatsFile{};
}
out.passedOver = cwd / ".env";
}
if (const std::optional<fs::path> global = GlobalEnvFile()) ReadOnce(out, *global);
return out;
}
Settings SettingsOf(const SeatsFile& file) {
Settings out {Platform::Environment(), {}, {}};
for (const auto& [name, value] : Seats::SettingsFromEnvFile(file.text)) {
if (!IsPlacementSetting(name)) out.ignored.push_back(name);
else if (!Dyn::Strip(value).empty() && out.env.try_emplace(name, value).second) out.fromFile.push_back(name);
}
return out;
}
std::map<std::string, std::string> WithFileTokens(std::map<std::string, std::string> env, const SeatsFile& file) {
for (const auto& [key, value] : Seats::SeatTokensFromEnvFile(file.Text())) env[key] = value;
return env;
}
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig) {
const std::map<std::string, std::string>& merged = env;
std::vector<std::pair<std::string, Json>> pool;
for (const auto& [n, key] : Seats::ApiKeysFromEnv(merged)) pool.emplace_back(std::format("api{}", n), NewSeat(std::format("api{}", n), "api", key));
for (const auto& [n, token] : Seats::SeatTokensFromEnv(merged)) pool.emplace_back(std::to_string(n), NewSeat(std::to_string(n), "oauth", token));

View file

@ -308,39 +308,58 @@ namespace Crafter::Review::Seats {
return out;
}
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text) {
std::map<std::string, std::string> env;
std::vector<std::pair<std::string, std::string>> DotenvLines(std::string_view text) {
std::vector<std::pair<std::string, std::string>> out;
for (const std::string& raw : Dyn::Split(text, "\n")) {
// ^\s*(?:export\s+)?(NAME)\s*=\s*(.*?)\s*$
// ^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$
std::string_view line(raw);
std::size_t at = Dyn::SpaceRun(line, 0);
if (line.substr(at).starts_with("export")) {
const std::size_t gap = Dyn::SpaceRun(line, at + 6);
if (gap > 0) at += 6 + gap;
}
std::string name;
for (const std::string_view stem : {std::string_view("CLAUDE_CODE_OAUTH_TOKEN"), std::string_view("ANTHROPIC_API_KEY"), std::string_view("OPENROUTER_KEY")}) {
if (!line.substr(at).starts_with(stem)) continue;
std::size_t end = at + stem.size();
const bool digit = end < line.size() && line[end] >= '1' && line[end] <= '6';
if (digit) ++end;
if (stem == "ANTHROPIC_API_KEY" && !digit) break;
name = std::string(line.substr(at, end - at));
at = end;
break;
}
if (name.empty()) continue;
at += Dyn::SpaceRun(line, at);
auto nameChar = [](char c, bool first) {
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '_' || (!first && c >= '0' && c <= '9');
};
std::size_t end = at;
while (end < line.size() && nameChar(line[end], end == at)) ++end;
if (end == at) continue;
const std::string name(line.substr(at, end - at));
at = end + Dyn::SpaceRun(line, end);
if (at >= line.size() || line[at] != '=') continue;
++at;
at += Dyn::SpaceRun(line, at);
std::string value = Dyn::RStrip(line.substr(at));
if (value.size() >= 2 && (value.front() == '"' || value.front() == '\'') && value.back() == value.front()) value = value.substr(1, value.size() - 2);
out.emplace_back(name, std::move(value));
}
return out;
}
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text) {
std::map<std::string, std::string> env;
for (const auto& [name, value] : DotenvLines(text)) {
for (const std::string_view stem : {std::string_view("CLAUDE_CODE_OAUTH_TOKEN"), std::string_view("ANTHROPIC_API_KEY"), std::string_view("OPENROUTER_KEY")}) {
if (!name.starts_with(stem)) continue;
const std::string_view rest = std::string_view(name).substr(stem.size());
const bool digit = rest.size() == 1 && rest[0] >= '1' && rest[0] <= '6';
if (!rest.empty() && !digit) break;
if (stem == "ANTHROPIC_API_KEY" && !digit) break;
env[name] = value;
break;
}
}
return env;
}
std::map<std::string, std::string> SettingsFromEnvFile(std::string_view text) {
std::map<std::string, std::string> out;
for (const auto& [name, value] : DotenvLines(text)) {
if (name.starts_with("REVIEW_GATES_")) out[name] = value;
}
return out;
}
Json Pct(const Json& utilization) {
const std::optional<double> n = FloatOf(utilization);
if (!n || !std::isfinite(*n) || *n < 0) return 0;
@ -977,8 +996,8 @@ namespace Crafter::Review::Seats {
}
return Dyn::Join(std::vector<std::string>{
RenderTable(rows),
std::format("api seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · cycle day {} · {} processes per seat · placed at +${} each",
Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))),
std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · cycle day {} · {} processes per seat · placed at +${} each",
Dyn::Str(apiConfig.at("order")) == "api-first" ? "api" : "oauth", Dyn::Str(apiConfig.at("order")), Fmt(apiConfig.at("budgetUsd")), Dyn::Str(apiConfig.at("cycleDay")), Dyn::Str(apiConfig.at("concurrency")), Fmt(apiConfig.at("lensCostUsd"))),
"the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent",
}, "\n");
}

View file

@ -199,9 +199,9 @@ export namespace Crafter::Review::Run {
// {lens file: environment}, in the config's lens order: the dump, the
// resume file and the scratch dir's marker write it in that order.
Json environments = Json::object();
// The working directory: the config, the env file and the slots are
// its, the lens processes run in it, and every path logged is relative
// to it.
// The working directory: the config and the slots are its, the seats
// file is looked up from it (SeatPool::ResolveSeatsFile), the lens
// processes run in it, and every path logged is relative to it.
std::filesystem::path repo;
// The repository under review, from the artifact's freeze record (the
// working directory when it has none): the prompt names it, and the
@ -278,6 +278,8 @@ export namespace Crafter::Review::Run {
std::unique_ptr<Memory::Sampler> sampler;
std::unique_ptr<Pool::Runner> runner;
Json apiCfg = Json::object();
// The placement settings Gate 0 laid out (SeatPool::SettingsOf).
std::map<std::string, std::string> settingsEnv;
std::unique_ptr<SeatPool::Ledger> ledger;
std::vector<Json> seats;
std::map<std::filesystem::path, Json> envMarkers;

View file

@ -69,6 +69,67 @@ export namespace Crafter::Review::SeatPool {
bool IsPaid(const Json& seat);
// The API-seat knobs.
Json ApiConfig(const std::map<std::string, std::string>& env);
// The seats file of `run` or `seats` (2026-11-01): the --env-file given
// (an empty one: none), else $SEATS_ENV_FILE, else <cwd>/.env - the
// working directory's, the operator's, not the repository under
// review's - when it is a file holding a seat or a placement setting,
// each with a value (an application's .env, one of blank lines, or one
// that is no file, is passed over and recorded so), else the machine's
// own (GlobalEnvFile); none: this
// machine's own login. Each file is read once, and the command takes
// its settings and its tokens from that read - the tokens through
// Text() - so a file that can be read only once (a pipe, `--env-file
// <(gpg -d seats.env.gpg)`, a FIFO) gives its settings and its seats
// alike (the self-review's round 10: read twice, it lost its seats or
// hung).
struct SeatsFile {
std::optional<std::filesystem::path> path;
std::optional<std::filesystem::path> passedOver;
std::string text;
std::exception_ptr error;
// The text as read; the read's own error, raised again, when the
// read failed (a file missing or not UTF-8 ends the command).
const std::string& Text() const;
};
// The lookup and its one read.
SeatsFile ResolveSeatsFile(const std::optional<std::string>& given, const std::filesystem::path& cwd);
// The settings a seats file may carry: every name ApiConfig, PoolConfig
// and Seats::ConfigFromEnv read (2026-11-01, the self-review's round 19:
// one list for the map they take, its log and what makes a working
// directory's .env a seats file).
constexpr std::array<std::string_view, 15> PlacementSettings = {
"REVIEW_GATES_API_BUDGET_USD", "REVIEW_GATES_API_LENS_COST_USD", "REVIEW_GATES_API_CONCURRENCY", "REVIEW_GATES_API_CYCLE_DAY",
"REVIEW_GATES_SEAT_ORDER", "REVIEW_GATES_OPENROUTER_BUDGET_USD", "REVIEW_GATES_OPENROUTER_LENS_COST_USD",
"REVIEW_GATES_OPENROUTER_CONCURRENCY", "REVIEW_GATES_LENS_COST_PCT", "REVIEW_GATES_LENS_COST_7D_PCT",
"REVIEW_GATES_OAUTH_CONCURRENCY", "REVIEW_GATES_SEAT_MAX_UTIL", "REVIEW_GATES_SEAT_WAIT_MIN", "REVIEW_GATES_SEAT_HANDOVER_PCT",
"REVIEW_GATES_SEAT_POLICY"};
// The settings the placement readers take (ApiConfig, PoolConfig,
// Seats::ConfigFromEnv): the process environment, and the file's
// PlacementSettings lines where it has no such variable - the Python
// driver's config_env (2026-10-17: a setting lives beside the seats it
// orders). A map for those readers alone: the process environment is
// not touched, so no other reader sees a line of the file, and a path's
// or a binary's line reaches nothing (the self-review's round 11). The
// names the file supplied are `fromFile`; its REVIEW_GATES_* lines of
// any other name, which only the process environment sets, are
// `ignored` - named by the run and `seats`, never taken in silence.
struct Settings {
std::map<std::string, std::string> env;
std::vector<std::string> fromFile;
std::vector<std::string> ignored;
};
Settings SettingsOf(const SeatsFile& file);
// env with the seats file's seat lines over it, from the file's one read
// (Text(): a read that failed raises its error here) - the one place a
// command lays the file's tokens over an environment (the self-review's
// round 19: run and seats had each done it by hand, and LoadSeats read
// the file a second time by path).
std::map<std::string, std::string> WithFileTokens(std::map<std::string, std::string> env, const SeatsFile& file);
// The machine's own seats file, read when a run or `seats` has no
// --env-file, no $SEATS_ENV_FILE and no seats ./.env: $XDG_CONFIG_HOME/crafter-review/env,
// else ~/.config/crafter-review/env - when it exists (2026-11-01, one
// file for every repository on a machine; a repository's own .env wins).
std::optional<std::filesystem::path> GlobalEnvFile();
// The other drivers' load on a seat as the last board fold left it.
Json Foreign(const Json& seat);
std::filesystem::path LedgerPath(const std::optional<std::filesystem::path>& scratchRoot = std::nullopt);
@ -97,13 +158,13 @@ export namespace Crafter::Review::SeatPool {
};
Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret);
// The seats a run may use. spec: auto (every Anthropic seat of the env
// file and the environment, else local), local | api | oauth | openrouter,
// The seats a run may use. spec: auto (every Anthropic seat of env - the
// seats file's over the process environment's, WithFileTokens - else
// local), local | api | oauth | openrouter,
// or a list such as api1,1,2,local. std::invalid_argument with a
// ValueError message for a seat that is not configured, or OpenRouter
// mixed with the rest.
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, const std::optional<std::filesystem::path>& envFile, std::string_view spec,
Ledger* ledger = nullptr, const Json& apiConfig = nullptr);
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr);
using ProbeFunction = std::function<Json(std::int32_t seat, const std::string& token, const std::string& model, double timeout, const std::string& claudeBin, bool local, bool api)>;
// One probe per seat before the run; a seat already out is skipped.
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models,

View file

@ -47,9 +47,19 @@ export namespace Crafter::Review::Seats {
std::vector<std::pair<std::int32_t, std::string>> SeatTokensFromEnv(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> ApiKeysFromEnv(const std::map<std::string, std::string>& env);
std::vector<std::pair<std::int32_t, std::string>> OpenRouterKeysFromEnv(const std::map<std::string, std::string>& env);
// Only the seat lines of a dotenv file; `export` and quotes stripped,
// everything else ignored.
// Every `[export ]NAME=value` line of a dotenv text, in order: blanks
// around the `=` and the value dropped, one pair of matching quotes
// stripped; anything else skipped.
std::vector<std::pair<std::string, std::string>> DotenvLines(std::string_view text);
// Only the seat lines of those: the OAuth tokens, the numbered API keys
// and the OpenRouter keys; everything else ignored.
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text);
// The REVIEW_GATES_* lines of a dotenv file (quotes and `export`
// stripped). SeatPool::SettingsOf lays the placement settings among them
// (SeatPool::PlacementSettings) under the process environment in the map
// the placement readers take, where no other reader sees them, and names
// the rest.
std::map<std::string, std::string> SettingsFromEnvFile(std::string_view text);
// The CLI's fraction as a percent, past 100 for a seat over its limit.
Json Pct(const Json& utilization);

View file

@ -15,8 +15,8 @@
partitions it into units, and runs adversarial lenses over it — the
approach, the running application through the Playwright MCP server,
a bug proof and a security review — one claude process per unit, on the
seats the repository provides. A range is send-ready only when every
gate passes on the exact frozen hash.
seats the repository or the machine provides. A range is send-ready
only when every gate passes on the exact frozen hash.
</p>
</description>
<url type="homepage">https://forgejo.catcrafts.net/Catcrafts/Crafter.Review</url>

View file

@ -298,11 +298,23 @@ flatpak run net.catcrafts.CrafterReview run --artifact .review-gates/push/<slug>
[--context-paths <dirs>] --date <YYYY-MM-DD>
```
The lens processes run on the seats in the repository's `.env`
(`CLAUDE_CODE_OAUTH_TOKEN1..6` Max plans, `ANTHROPIC_API_KEY1..6` API-credit
organisations: API seats first, up to `REVIEW_GATES_API_BUDGET_USD` per seat
per cycle, then OAuth seats by 5-hour headroom), else this machine's own
login; `flatpak run net.catcrafts.CrafterReview seats` reports them. An API seat is safe only
The lens processes run on the seats of a dotenv file: `--env-file`, else
`$SEATS_ENV_FILE`, else the working directory's `.env` when it is a file
holding a seat or a placement setting, or one that cannot be read as
UTF-8 text (the run then stops at preflight with the read error; a
readable application `.env` is passed over), else this machine's own
`~/.config/crafter-review/env` (`$XDG_CONFIG_HOME/crafter-review/env`).
Its seat lines go over the process environment's; with no seat in either,
this machine's own login. The seats are `CLAUDE_CODE_OAUTH_TOKEN1..6` Max
plans, placed by 5-hour headroom (weekly headroom where Anthropic has
removed the 5-hour limit), and `ANTHROPIC_API_KEY1..6` API-credit
organisations, up to `REVIEW_GATES_API_BUDGET_USD` per seat per cycle; API
seats go first unless `REVIEW_GATES_SEAT_ORDER=oauth-first`. The placement settings among the file's
lines (the seat order, the budgets, the concurrency, the utilisation
ceilings) count where the process environment has none; any other
`REVIEW_GATES_*` line - a path, a binary - is not taken, and the run and
`seats` name it.
`flatpak run net.catcrafts.CrafterReview seats` reports them. An API seat is safe only
when its organisation holds nothing but the plan's monthly credits — no
payment method, no purchased credits, auto-reload off — so that "credit
balance is too low" is a hard stop. `--seats local|api|oauth|<list>`,

View file

@ -71,7 +71,11 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY");
if (!review && !seat) Env[k] = v;
}
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
// No seats file of the machine's own: XDG_CONFIG_HOME natively, and
// HOST_XDG_CONFIG_HOME, which the Flatpak's Platform::Init copies
// over it - a directory of this run's own, which nothing creates.
const std::string noConfig = (base / "no-config").string();
Env = With(Env, {{"XDG_CONFIG_HOME", noConfig}, {"HOST_XDG_CONFIG_HOME", noConfig}, {"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
{"REVIEW_GATES_BOARD", (base / "board.json").string()}, {"REVIEW_GATES_SCRATCH", (base / "scratch").string()}, {"TZ", "UTC"}});
auto repo = [&](std::string_view name) {
const fs::path dir = base / name;
@ -196,6 +200,22 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
Check("stop --hash: every live run of the hash on the board gets its stop file (the first 8 characters decide); another hash finds none (exit 1); the board is read, never written",
byHash.returnCode == 0 && wroteHash && otherHash.returnCode == 1 && otherHash.err.contains("no live run of 00000000 on the board") && Read(base / "board.json") == boardBefore,
byHash.out + byHash.err + otherHash.err);
// Only `run` and `seats` read the seats file, and its lines reach the
// placement readers alone (rounds 8 and 11 of the self-review): a
// `stop` with the board path only in the working directory's .env
// looks at the machine's default board, as every driver does - under
// a HOME of the test's own here.
std::map<std::string, std::string> envNoPaths = Env;
envNoPaths.erase("REVIEW_GATES_BOARD");
envNoPaths.erase("REVIEW_GATES_SCRATCH");
envNoPaths["HOME"] = (base / "home").string();
const fs::path fileRepo = base / "file-repo";
fs::create_directories(fileRepo);
Write(fileRepo / ".env", std::format("REVIEW_GATES_BOARD={}\nREVIEW_GATES_SCRATCH={}\n", (base / "file-board.json").string(), (base / "file-scratch").string()));
const Process::Captured fromFile = Process::Run({Platform::SelfExe().string(), "stop", "--hash", "00000000"}, fileRepo, std::chrono::seconds(60), {}, envNoPaths);
Check("no line of the seats file reaches `stop`: with the board's and the scratch root's paths only in the working directory's .env it reads the machine's default board", fromFile.returnCode == 1
&& fromFile.err.contains(std::format("on the board ({})", (base / "home" / ".cache" / "review-gates" / "seat-board.json").string())) && !fromFile.err.contains("file-board"),
fromFile.err);
// seats on the stand-in: a token from the env file, an API key, the
// CLI's own login.
@ -207,15 +227,41 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
const std::map<std::string, std::string> withFake = {{"CLAUDE_BIN", fake.string()}};
const Process::Captured report = Self({"seats", "--local", "--env-file", ".env"}, base / "seats", withFake);
const Process::Captured json = Self({"seats", "--json", "--no-fable"}, base / "seats", withFake);
// --env (a prefix of --env-file) names the file for the tokens and
// the settings alike, with ./.env in the way (the API seat's section
// is where the order shows).
Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nREVIEW_GATES_SEAT_ORDER=oauth-first\n");
const Process::Captured other = Self({"seats", "--no-fable", "--env", "other.env"}, base / "seats", withFake);
// An application's .env (no seat line, no setting) is passed over for
// the machine's own seats file.
Write(base / "app" / ".env", "VITE_API_URL=http://localhost:54321\n");
Write(base / "app-xdg" / "crafter-review" / "env", "CLAUDE_CODE_OAUTH_TOKEN3=tok-three\n");
const Process::Captured appSeats =
Self({"seats", "--no-fable"}, base / "app", {{"CLAUDE_BIN", fake.string()}, {"XDG_CONFIG_HOME", (base / "app-xdg").string()}, {"HOST_XDG_CONFIG_HOME", (base / "app-xdg").string()}});
// A seats file that can be read only once - a FIFO, as `--env-file
// <(gpg -d seats.env.gpg)` gives one - is read once: its settings and
// its tokens come from that one read (round 10 of the self-review).
fs::create_directories(base / "fifo");
const Process::Captured fromFifo = Process::Run(
{"sh", "-c",
std::format("mkfifo seats.fifo && (printf 'CLAUDE_CODE_OAUTH_TOKEN4=tok-four\\nANTHROPIC_API_KEY4=sk-ant-api-four\\nREVIEW_GATES_SEAT_ORDER=oauth-first\\n' > seats.fifo &) && exec '{}' seats --no-fable --env-file seats.fifo",
Platform::SelfExe().string())},
base / "fifo", std::chrono::seconds(60), {}, With(Env, withFake));
Json parsed;
try {
parsed = Json::parse(json.out);
} catch (const std::exception&) {
}
Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board",
Check("seats: every seat probed - the CLI's own login, the env file's token, the API key - its windows read, the API seat beside the driver's ledger, the drivers on the board; an application's .env "
"is passed over for the machine's own file; a FIFO is read once, its settings and its seats alike",
report.returnCode == 0 && report.out.starts_with("seat pool: 3 seat(s) (local, 1, api1) from .env and your own login, probed ")
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running"),
report.out + report.err);
&& report.out.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | $0.00 of $200") && report.out.contains("drivers: none running")
&& other.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | $0.00 of $200")
&& other.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)") && !other.out.contains("tok-two") && !other.out.contains("sk-ant-api-two")
&& appSeats.returnCode == 0 && appSeats.out.starts_with(std::format("seat pool: 1 seat(s) (3) from {}, probed ", (base / "app-xdg" / "crafter-review" / "env").string()))
&& fromFifo.returnCode == 0 && fromFifo.out.starts_with("seat pool: 2 seat(s) (4, api4) from seats.fifo, probed ")
&& fromFifo.out.contains("oauth seats run first (REVIEW_GATES_SEAT_ORDER=oauth-first)"),
report.out + report.err + other.out + other.err + appSeats.out + appSeats.err + fromFifo.out + fromFifo.err);
Check("seats --json: the probe results as JSON - the seats, the API seats, the eligible ones and the next placement; ./.env read by default; no token, no key printed",
json.returnCode == 0 && parsed.is_object() && parsed.at("seats").size() == 1 && parsed.at("seats").at(0).at("probedWith") == "haiku" && parsed.at("apiSeats").size() == 1
&& parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z")

View file

@ -150,7 +150,11 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY") || k.starts_with("FAKE_");
if (!review && !seat) Env[k] = v;
}
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()}, {"REVIEW_GATES_BOARD", (base / "board.json").string()}});
// No seats file of the machine's own: XDG_CONFIG_HOME natively, and
// HOST_XDG_CONFIG_HOME, which the Flatpak's Platform::Init copies
// over it - a directory of this run's own, which nothing creates.
const std::string noConfig = (base / "no-config").string();
Env = With(Env, {{"XDG_CONFIG_HOME", noConfig}, {"HOST_XDG_CONFIG_HOME", noConfig}, {"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"CRAFTER_REVIEW_HOME", checkout.string()}, {"REVIEW_GATES_BOARD", (base / "board.json").string()}});
// ---- the inventory --------------------------------------------------------------
const Inventory::Built draft = Inventory::Build("reply", (fix / "draft-camera.txt").string());

View file

@ -60,15 +60,18 @@ namespace {
const fs::path envFile = td / ".env";
Write(envFile, "CLAUDE_CODE_OAUTH_TOKEN1=tok\nOPENROUTER_KEY=sk-or-one\n");
// The seats file as run and seats take it: one read, its tokens over
// an environment (an empty one here).
const std::map<std::string, std::string> fileEnv = SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(envFile.string(), td));
auto ids = [](const std::vector<Json>& seats) {
std::vector<std::string> out;
for (const Json& s : seats) out.push_back(s.at("id").get<std::string>());
return out;
};
Check("OPENROUTER_KEY is a seat only on request",
ids(SeatPool::LoadSeats({}, envFile, "")) == std::vector<std::string>{"1"} && ids(SeatPool::LoadSeats({}, envFile, "openrouter")) == std::vector<std::string>{"or1"});
ids(SeatPool::LoadSeats(fileEnv, "")) == std::vector<std::string>{"1"} && ids(SeatPool::LoadSeats(fileEnv, "openrouter")) == std::vector<std::string>{"or1"});
try {
SeatPool::LoadSeats({}, envFile, "or1,1");
SeatPool::LoadSeats(fileEnv, "or1,1");
Check("an OpenRouter seat never shares a pool with Anthropic seats", false);
} catch (const std::invalid_argument& e) {
Check("an OpenRouter seat never shares a pool with Anthropic seats", std::string_view(e.what()).contains("cannot share"), e.what());

View file

@ -192,7 +192,11 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
const bool seat = k.starts_with("CLAUDE_CODE_OAUTH_TOKEN") || k.starts_with("ANTHROPIC_API_KEY") || k.starts_with("OPENROUTER_KEY") || k.starts_with("FAKE_");
if (!review && !seat) Env[k] = v;
}
Env = With(Env, {{"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"GIT_AUTHOR_NAME", "Me"}, {"GIT_AUTHOR_EMAIL", "me@example.com"},
// No seats file of the machine's own: XDG_CONFIG_HOME natively, and
// HOST_XDG_CONFIG_HOME, which the Flatpak's Platform::Init copies
// over it - a directory of this run's own, which nothing creates.
const std::string noConfig = (base / "no-config").string();
Env = With(Env, {{"XDG_CONFIG_HOME", noConfig}, {"HOST_XDG_CONFIG_HOME", noConfig}, {"GIT_CONFIG_GLOBAL", "/dev/null"}, {"GIT_CONFIG_SYSTEM", "/dev/null"}, {"GIT_AUTHOR_NAME", "Me"}, {"GIT_AUTHOR_EMAIL", "me@example.com"},
{"GIT_COMMITTER_NAME", "Me"}, {"GIT_COMMITTER_EMAIL", "me@example.com"}, {"CRAFTER_REVIEW_HOME", checkout.string()},
{"REVIEW_GATES_BOARD", (base / "board.json").string()}, {"FAKE_DIR", fakeDir.string()}});
@ -395,6 +399,38 @@ echo "{\"type\":\"result\",\"subtype\":\"success\",\"is_error\":false,\"result\"
Check("preflight: an env file that is not UTF-8 stops the run, exit 3, with the decoder's own words",
badEnv.returnCode == 3 && badEnv.err.contains("preflight: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte"), badEnv.err);
// One seats file for the settings and the tokens: the one --env-file
// names as the run's own parser reads it - a unique prefix, the last
// of a repeat - with the machine's own file as the decoy the usual
// lookup would find; the log names it with the settings put in.
const fs::path decoyXdg = base / "decoy-xdg";
Write(decoyXdg / "crafter-review" / "env", "REVIEW_GATES_API_BUDGET_USD=5\nCLAUDE_CODE_OAUTH_TOKEN1=tok-decoy\nANTHROPIC_API_KEY1=sk-ant-decoy\n");
Write(base / "other.env", "REVIEW_GATES_API_BUDGET_USD=7\nREVIEW_GATES_CLAUDE_BIN=/nowhere/claude\nCLAUDE_CODE_OAUTH_TOKEN2=tok-other\nANTHROPIC_API_KEY2=sk-ant-other\n");
const std::map<std::string, std::string> withDecoy = {{"XDG_CONFIG_HOME", decoyXdg.string()}, {"HOST_XDG_CONFIG_HOME", decoyXdg.string()}};
const std::string other = (base / "other.env").string();
const Process::Captured prefixed = run({"--env", other, "--seats", "auto", "--dry-run", "--scratch", (base / "env-prefix").string()}, withDecoy);
const Process::Captured repeated = run({"--env-file", (decoyXdg / "crafter-review" / "env").string(), "--env-file", other, "--seats", "auto", "--dry-run", "--scratch", (base / "env-repeat").string()}, withDecoy);
auto oneFile = [&](const Process::Captured& dry) {
return dry.returnCode == 2 && dry.out.contains(std::format("settings: REVIEW_GATES_API_BUDGET_USD from {} (under", other)) && dry.out.contains("api2 (api)") && dry.out.contains("2 (oauth)")
&& dry.out.contains(std::format("seats file: REVIEW_GATES_CLAUDE_BIN in {} - no placement setting, read from the process environment alone", other))
&& dry.out.contains(std::format("from {}; api-first, budget $7 per api seat per cycle", other)) && !dry.out.contains("api1") && !dry.out.contains("1 (oauth)");
};
Check("--env (a prefix) and the last of a repeated --env-file name the one file the settings are applied from and the tokens are loaded from, never the decoy",
oneFile(prefixed) && oneFile(repeated), std::format("{} {}\n{} {}", prefixed.returnCode, prefixed.out + prefixed.err, repeated.returnCode, repeated.out + repeated.err));
// A seats file that can be read only once - a FIFO, as `--env-file
// <(gpg -d seats.env.gpg)` gives one - is read once: the run's
// settings and its seats both come from that read (round 10).
const fs::path fifo = base / "seats.fifo";
std::vector<std::string> viaFifo = {
"sh", "-c",
std::format("mkfifo '{0}' && (printf 'REVIEW_GATES_API_BUDGET_USD=9\\nCLAUDE_CODE_OAUTH_TOKEN5=tok-five\\nANTHROPIC_API_KEY5=sk-ant-five\\n' > '{0}' &) && exec \"$@\"", fifo.string()), "sh"};
std::ranges::move(runArgs({"--env-file", fifo.string(), "--seats", "auto", "--dry-run", "--scratch", (base / "env-fifo").string()}), std::back_inserter(viaFifo));
const Process::Captured fromFifo = Process::Run(viaFifo, repo, std::chrono::seconds(60), {}, Env);
Check("a seats file that can be read only once (a FIFO) is read once: the run's settings and its seats both come from it",
fromFifo.returnCode == 2 && fromFifo.out.contains(std::format("settings: REVIEW_GATES_API_BUDGET_USD from {} (under", fifo.string())) && fromFifo.out.contains("api5 (api)")
&& fromFifo.out.contains("5 (oauth)") && fromFifo.out.contains(std::format("from {}; api-first, budget $9 per api seat per cycle", fifo.string())),
std::format("{} {}{}", fromFifo.returnCode, fromFifo.out, fromFifo.err));
// A filter that names nothing is refused: skipping its empty items
// must not leave no filter, which is a full run.
const Process::Captured noUnits = run({"--units", ","});

View file

@ -54,9 +54,9 @@ int main() {
const Json acfg = SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", "10"}, {"REVIEW_GATES_API_LENS_COST_USD", "1"}, {"REVIEW_GATES_API_CONCURRENCY", "2"}});
const Json cfg = Seats::ConfigFromEnv({});
SeatPool::Ledger ledger(td / "ledger.json", acfg);
std::vector<Json> seats = SeatPool::LoadSeats({}, envFile, "", &ledger, acfg);
std::vector<Json> seats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(envFile.string(), td)), "", &ledger, acfg);
Check(".env: api seats, oauth seats, never the bare ANTHROPIC_API_KEY, local only when nothing is configured",
Ids(seats) == "api1,1,2" && Ids(SeatPool::LoadSeats({}, std::nullopt, "")) == "local", Ids(seats));
Ids(seats) == "api1,1,2" && Ids(SeatPool::LoadSeats({}, "")) == "local", Ids(seats));
const std::string summary = DumpSpaced(SeatPool::Summary(seats));
Check("no secret in the summary or the table", !(summary + SeatPool::Table(seats)).contains("tok-one") && !summary.contains("sk-ant"));
Check("the 5-hour share of a pass follows the run's effort (0.125 at xhigh, 0.5 at max and any other); the env overrides every effort",
@ -208,6 +208,100 @@ int main() {
SeatPool::Describe(described) == "ok: 5h 61.5%, 7d 18%, fable 22%, fable 3% (binding five_hour)" && SeatPool::Describe(paid) == "out (budget), $12.35 spent this cycle, $3.00 left on the key",
std::format("{} | {}", SeatPool::Describe(described), SeatPool::Describe(paid)));
// The seats file (2026-11-01): one dotenv grammar for the tokens and the
// REVIEW_GATES_* settings; the settings under the process environment in
// the map the placement readers take (SettingsOf), the process
// environment itself untouched; the file resolved as --env-file, then
// $SEATS_ENV_FILE, <cwd>/.env, the machine's own under $XDG_CONFIG_HOME,
// each read once.
{
const std::vector<std::pair<std::string, std::string>> lines =
Seats::DotenvLines("export REVIEW_GATES_SEAT_ORDER='oauth-first'\n REVIEW_GATES_API_BUDGET_USD = \"50\"\nCLAUDE_CODE_OAUTH_TOKEN1=x\n# a comment\nnot a line\n=\nREVIEW_GATES=nope\n");
const std::map<std::string, std::string> settings = Seats::SettingsFromEnvFile("export REVIEW_GATES_SEAT_ORDER='oauth-first'\n REVIEW_GATES_API_BUDGET_USD = \"50\"\nCLAUDE_CODE_OAUTH_TOKEN1=x\nREVIEW_GATES=nope\n");
Check("the dotenv grammar: `[export ]NAME=value`, blanks around `=` and the value, one pair of matching quotes stripped, everything else skipped; the settings are the REVIEW_GATES_ names; the tokens as before",
lines == std::vector<std::pair<std::string, std::string>> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}, {"CLAUDE_CODE_OAUTH_TOKEN1", "x"}, {"REVIEW_GATES", "nope"}}
&& settings == std::map<std::string, std::string> {{"REVIEW_GATES_SEAT_ORDER", "oauth-first"}, {"REVIEW_GATES_API_BUDGET_USD", "50"}}
&& Seats::SeatTokensFromEnvFile("ANTHROPIC_API_KEY=bare\nANTHROPIC_API_KEY2=k\nANTHROPIC_API_KEY2_GRANT_DAY=17\nCLAUDE_CODE_OAUTH_TOKEN=t\nCLAUDE_CODE_OAUTH_TOKEN7=no\n")
== std::map<std::string, std::string> {{"ANTHROPIC_API_KEY2", "k"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}});
const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid());
fs::remove_all(envTd);
fs::create_directories(envTd / "repo");
fs::create_directories(envTd / "xdg" / "crafter-review");
std::ofstream(envTd / "settings.env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=oauth-first\nREVIEW_GATES_API_BUDGET_USD=50\nREVIEW_GATES_CLAUDE_BIN=./evil\nREVIEW_GATES_SEAT_POLICY=\n";
unsetenv("REVIEW_GATES_SEAT_ORDER");
unsetenv("REVIEW_GATES_CLAUDE_BIN");
unsetenv("REVIEW_GATES_SEAT_POLICY");
setenv("REVIEW_GATES_API_BUDGET_USD", "75", 1);
const SeatPool::Settings applied = SeatPool::SettingsOf(SeatPool::ResolveSeatsFile((envTd / "settings.env").string(), envTd / "repo"));
const Json api = SeatPool::ApiConfig(applied.env);
const SeatPool::SeatsFile missing = SeatPool::ResolveSeatsFile((envTd / "none.env").string(), envTd / "repo");
std::string raised;
try {
(void) missing.Text();
} catch (const Dyn::Error& error) {
raised = error.Kind();
}
Check("SettingsOf: the file's placement settings under the process environment - the process environment wins - in the map ApiConfig reads, a blank one no setting, its other REVIEW_GATES_* "
"lines named and never in the map, the process environment itself untouched (no line of the file reaches another reader); no file gives nothing; one it cannot read gives nothing and Text() raises the "
"read's own error",
applied.fromFile == std::vector<std::string> {"REVIEW_GATES_SEAT_ORDER"} && applied.ignored == std::vector<std::string> {"REVIEW_GATES_CLAUDE_BIN"}
&& !applied.env.contains("REVIEW_GATES_CLAUDE_BIN") && !applied.env.contains("REVIEW_GATES_SEAT_POLICY") && api.at("order") == "oauth-first" && api.at("budgetUsd") == 75.0
&& !Platform::GetEnv("REVIEW_GATES_SEAT_ORDER") && !Platform::GetEnv("REVIEW_GATES_CLAUDE_BIN") && Platform::GetEnv("REVIEW_GATES_API_BUDGET_USD") == "75"
&& SeatPool::SettingsOf(SeatPool::ResolveSeatsFile(std::string(), envTd / "repo")).fromFile.empty() && SeatPool::SettingsOf(missing).fromFile.empty() && raised == "FileNotFoundError",
Dyn::Str(api) + " " + raised);
// Every name on the list moves a placement reader (the list is the
// readers' names, not a guess at them).
const Json apiDefault = SeatPool::ApiConfig({});
const Json poolDefault = SeatPool::PoolConfig({}, "xhigh");
std::vector<std::string> idle;
for (const std::string_view name : SeatPool::PlacementSettings) {
const std::string value = name.ends_with("SEAT_ORDER") ? "oauth-first" : name.ends_with("SEAT_POLICY") ? "pack" : "3";
const std::map<std::string, std::string> one {{std::string(name), value}};
if (Dyn::Equal(SeatPool::ApiConfig(one), apiDefault) && Dyn::Equal(SeatPool::PoolConfig(one, "xhigh"), poolDefault)) idle.emplace_back(name);
}
Check("PlacementSettings: every name on the list moves ApiConfig or PoolConfig (which takes Seats::ConfigFromEnv)", idle.empty(), Dyn::Join(idle, ", "));
unsetenv("REVIEW_GATES_API_BUDGET_USD");
unsetenv("SEATS_ENV_FILE");
setenv("XDG_CONFIG_HOME", (envTd / "xdg").string().c_str(), 1);
const bool none = !SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path && !SeatPool::GlobalEnvFile();
std::ofstream(envTd / "xdg" / "crafter-review" / "env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=oauth-first\n";
const std::optional<fs::path> global = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
// An application's .env (no seat, no placement setting), one whose only
// REVIEW_GATES_* line is no placement setting, one of blank placeholder
// lines (an empty token or setting is none), and a .env that is no
// file (a Python venv) are passed over and recorded so; one with a
// placement setting alone is a seats file, and so is one that is not
// UTF-8 (its error is the command's).
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "VITE_API_URL=http://localhost:54321\nSUPABASE_ANON_KEY=anon\n";
const SeatPool::SeatsFile appFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "REVIEW_GATES_SEAT_ORDER=api-first\n";
const std::optional<fs::path> settingsOnly = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "REVIEW_GATES_SCRATCH=/elsewhere\n";
const SeatPool::SeatsFile otherSetting = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "CLAUDE_CODE_OAUTH_TOKEN1=\nANTHROPIC_API_KEY1= \nOPENROUTER_KEY=\nREVIEW_GATES_SEAT_ORDER=\n";
const SeatPool::SeatsFile blanks = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << std::string("\xff\n", 2);
const SeatPool::SeatsFile notTextFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
fs::remove(envTd / "repo" / ".env");
fs::create_directories(envTd / "repo" / ".env");
const SeatPool::SeatsFile venv = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
fs::remove_all(envTd / "repo" / ".env");
std::ofstream(envTd / "repo" / ".env", std::ios::binary) << "CLAUDE_CODE_OAUTH_TOKEN1=x\n";
const SeatPool::SeatsFile localFile = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo");
setenv("SEATS_ENV_FILE", (envTd / "settings.env").string().c_str(), 1);
const std::optional<fs::path> named = SeatPool::ResolveSeatsFile(std::nullopt, envTd / "repo").path;
const std::optional<fs::path> given = SeatPool::ResolveSeatsFile(std::string("explicit.env"), envTd / "repo").path;
const std::optional<fs::path> givenNone = SeatPool::ResolveSeatsFile(std::string(), envTd / "repo").path;
Check("the seats file resolves as --env-file (an empty one: none), else $SEATS_ENV_FILE, else <cwd>/.env when it is a file holding a seat or a placement setting with a value (an "
"application's .env, one whose only REVIEW_GATES_* line is no placement setting, one of blank lines, and a venv passed over and recorded so; one that is not UTF-8 taken, its read error kept), else $XDG_CONFIG_HOME/crafter-review/env, else none; the global file's settings reach SettingsOf",
none && global == envTd / "xdg" / "crafter-review" / "env" && appFile.path == global && appFile.passedOver == envTd / "repo" / ".env"
&& SeatPool::SettingsOf(appFile).fromFile == std::vector<std::string> {"REVIEW_GATES_SEAT_ORDER"} && settingsOnly == envTd / "repo" / ".env" && otherSetting.path == global && otherSetting.passedOver == envTd / "repo" / ".env" && blanks.path == global && blanks.passedOver == envTd / "repo" / ".env"
&& notTextFile.path == envTd / "repo" / ".env" && notTextFile.error && venv.path == global && venv.passedOver == envTd / "repo" / ".env" && !venv.error
&& localFile.path == envTd / "repo" / ".env" && !localFile.passedOver && named == envTd / "settings.env" && given == fs::path("explicit.env") && !givenNone);
unsetenv("SEATS_ENV_FILE");
unsetenv("XDG_CONFIG_HOME");
fs::remove_all(envTd);
}
fs::remove_all(td);
return Failures == 0 ? 0 : 1;
}