Compare commits

..
Author SHA1 Message Date
ceb87f70ef
feat(seats): an API seat's cycle starts on its own grant day
All checks were successful
Flatpak / check (push) Has been skipped
Flatpak / publish (push) Successful in 5m30s
The ledger reset every API seat's spend on one
REVIEW_GATES_API_CYCLE_DAY and cleared every credit-out, an OpenRouter
key's too, at that reset. Each API seat's cycle starts on its
ANTHROPIC_API_KEY<n>_GRANT_DAY now (1..31, a day past a month's end its
last), else the cycle day, also 1..31. A credit-out carries across the
rollover, and the run's probe tries a key held out on credit again, as
`seats` does an API key: an ok lifts the mark - an API key's is new
credits, its cycle restarting from the grant day, or from today when
that day is over a week back; an OpenRouter key keeps its cycle's spend,
and a key that answers with its cycle's budget spent stays out on that
budget. A probe that finds a key out of credit records it, unless the
ledger already has it out. Both are decided in one locked update on the
file's entry, a mark another driver made since the probe started
standing, and the ledger's stamps carry their UTC offset so marks are
ordered by instant.

`seats` shows the probe's reading beside the driver's verdict (the
`probe` and `driver` columns replace `state` and `ledger: out`), each
seat's grant day and when its next credits are due, and notes on what
the probe changed in the ledger or why the driver's verdict stands; the
run logs the days.
2026-11-02 18:44:02 +01:00
c6b4d78245
feat(freeze): no authors in the config, no author check
A repository that takes commits from people it cannot list has no way
to freeze a range today: a null `authors` falls back to
`git config user.email`, so every foreign author or committer fails.
Null, or no key, now means neither is checked; the freeze warns that it
did not check them and records `authors` as null. A list is checked as
before. An empty list, which no commit could pass, is a config error
that points at null.
Released as 1.1.0.
2026-11-02 17:16:07 +01:00
0d3d8ed2d2
ci: the release carries the keyed flatpakref
All checks were successful
Flatpak / check (push) Has been skipped
Flatpak / publish (push) Successful in 5m20s
The Bundle step copied the checkout's flatpakref into the release before
the publish step wrote the keyed one to the web root, so 1.0.0's asset
said GPGKey=GPG_KEY_BASE64_PLACEHOLDER and installing from it failed. A
step after publishing copies the web root's copy into the release files
and writes their checksums. Released as 1.0.1.
2026-11-02 12:53:03 +01:00
f5a674f13f
docs: the README, shorter and with every lens
Some checks failed
Flatpak / check (push) Has been skipped
Flatpak / publish (push) Failing after 39s
Half the words, the same facts: what it reviews, what the machine
provides, the config's keys, a run, and every lens of every mode in one
place - the kernel modes' ten and the persona were prose before. The
migration, sandbox and build sections stay out; AGENTS.md has them.
2026-11-02 12:01:40 +01:00
90e1725313 Update README.md
Some checks failed
Flatpak / check (push) Has been skipped
Flatpak / publish (push) Failing after 40s
2026-11-02 10:51:39 +00:00
21 changed files with 909 additions and 224 deletions

View file

@ -259,9 +259,6 @@ jobs:
run: | run: |
mkdir -p dist mkdir -p dist
flatpak build-bundle repo "dist/crafter-review-${{ steps.meta.outputs.version }}.flatpak" "$APP_ID" stable flatpak build-bundle repo "dist/crafter-review-${{ steps.meta.outputs.version }}.flatpak" "$APP_ID" stable
cp packaging/flatpak/net.catcrafts.CrafterReview.flatpakref dist/
(cd dist && sha256sum * > SHA256SUMS)
ls -la dist
- name: Upload bundle - name: Upload bundle
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@ -296,6 +293,15 @@ jobs:
sed "s|GPGKey=.*|GPGKey=$key|" packaging/flatpak/net.catcrafts.CrafterReview.flatpakref > "$WEB_DIR/net.catcrafts.CrafterReview.flatpakref" sed "s|GPGKey=.*|GPGKey=$key|" packaging/flatpak/net.catcrafts.CrafterReview.flatpakref > "$WEB_DIR/net.catcrafts.CrafterReview.flatpakref"
flock -u 9 flock -u 9
- name: Release files
# The flatpakref as published, with the repo's key - the checkout's
# carries a placeholder - and the checksums of what the release carries.
run: |
set -eu
cp "$WEB_DIR/net.catcrafts.CrafterReview.flatpakref" dist/
(cd dist && sha256sum * > SHA256SUMS)
ls -la dist
- name: Tag the release - name: Tag the release
run: | run: |
# The release's tag at the pushed commit. Never forced: a tag that # The release's tag at the pushed commit. Never forced: a tag that

View file

@ -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. 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. - **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): 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). - **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) (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. - 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. - 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. - Every OAuth account and the local login takes at most 128 passes at once, every driver's counted.
@ -119,6 +119,7 @@ Conventions:
- A 'concurrents' rejection pauses the seat for 20 s or its retry-after, not until a window's reset. - A 'concurrents' rejection pauses the seat for 20 s or its retry-after, not until a window's reset.
- Board entries and the folded `foreign` record carry `cost7d`, and so does the dump's seat summary. - Board entries and the folded `foreign` record carry `cost7d`, and so does the dump's seat summary.
- The `seats` report and the log table say `no limit` in such an account's 5-hour column. - The `seats` report and the log table say `no limit` in such an account's 5-hour column.
- **The ledger's cycles and credit-outs** (SeatPool, Seats, Drivers): an API seat's cycle starts on its grant day, `ANTHROPIC_API_KEY<n>_GRANT_DAY` (`Seats::ApiGrantDays`; the seat token reader takes the line), else `REVIEW_GATES_API_CYCLE_DAY`, the day every OpenRouter key's starts on; `LoadSeats` puts an API seat's day in the seat and the ledger (`Ledger::SetDay`). A new cycle starts the spend over (`Ledger::Entry`) and carries a credit-out, with the time the key was last seen answering again. `Probe` re-probes a paid key the ledger holds out on credit (`ledgerOut` `credit`; one out on its budget it skips) - an API key with a request, an OpenRouter key through `ProbeOpenRouter` - and `seats` every API key; `Ledger::Reconcile` has the ledger follow an ok or credit-error reading in one locked update on the file's entry: an ok on a key out since before the probe started lifts the mark - an API key's is new credits (`Ledger::GrantEntry`: a cycle from the grant day when that is within the week, else from today), an OpenRouter key keeps its cycle's spend - a credit error on a key the file has as fine, with no lift seen since the probe started, is recorded, and anything else writes nothing. A mark is local time with its UTC offset (`2026-10-25T02:00:05+01:00`) and marks are ordered by instant, across the hour that repeats when DST ends and between drivers in different zones; a stamp without an offset, as older drivers wrote, is read as local time. `seats` stamps what it writes after its probe, not at the start its probes share. An ok reading over a credit error recorded since the probe started leaves the seat out, and a key that answers with its cycle's budget spent is out on that budget. `seats` shows the probe's reading beside the driver's verdict (`LedgerOut`, the one `LoadSeats` holds a seat out by), the grant day and the next credits (`NextGrant`).
- **Board identity and liveness, process lifetime, stop:** see "Inside the Flatpak". - **Board identity and liveness, process lifetime, stop:** see "Inside the Flatpak".
- **Edge cases:** - **Edge cases:**
- Usage errors of `run`, `freeze`, `slots`, `config` and the other commands exit 3, the preflight's code, and so does an INVARIANT failure of `run` and `slots`. The helpers exit 64. - Usage errors of `run`, `freeze`, `slots`, `config` and the other commands exit 3, the preflight's code, and so does an INVARIANT failure of `run` and `slots`. The helpers exit 64.
@ -180,6 +181,7 @@ 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` | | `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 | | `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 |
| `ANTHROPIC_API_KEY1_GRANT_DAY`..`6` | `:Seats` ApiGrantDays, via LoadSeats (run) and `seats` | `REVIEW_GATES_API_CYCLE_DAY` | Stripped decimal digits of any script, 1..31; anything else is ignored. The env file's lines override the process environment. | The day of the month seat n's ledger cycle starts on |
| `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` | | `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` | `: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 | | `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. | | `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. |
@ -195,7 +197,7 @@ What the binary reads at run time, and how. A variable the driver only sets or s
| `REVIEW_GATES_API_BUDGET_USD` | `:SeatPool` ApiConfig (run, seats) | `200.0` | MAX_UTIL's syntax, kept if >= 0 (`inf` means no cap); empty, garbage, negative, `nan` give the default. `--api-budget` overrides it in run. | USD one API key may spend per cycle; at it the key is out | | `REVIEW_GATES_API_BUDGET_USD` | `:SeatPool` ApiConfig (run, seats) | `200.0` | MAX_UTIL's syntax, kept if >= 0 (`inf` means no cap); empty, garbage, negative, `nan` give the default. `--api-budget` overrides it in run. | USD one API key may spend per cycle; at it the key is out |
| `REVIEW_GATES_API_LENS_COST_USD` | `:SeatPool` ApiConfig | `1.0` | As API_BUDGET_USD | USD reserved per running process: a pass fits while spent + (running+1) × this + other drivers' cost ≤ budget | | `REVIEW_GATES_API_LENS_COST_USD` | `:SeatPool` ApiConfig | `1.0` | As API_BUDGET_USD | USD reserved per running process: a pass fits while spent + (running+1) × this + other drivers' cost ≤ budget |
| `REVIEW_GATES_API_CONCURRENCY` | `:SeatPool` ApiConfig | `8` | As API_BUDGET_USD, truncated toward zero; `0` keeps every pass off the API seats; `inf` exits 1 with an OverflowError. | Processes at once on one API seat, every driver's counted | | `REVIEW_GATES_API_CONCURRENCY` | `:SeatPool` ApiConfig | `8` | As API_BUDGET_USD, truncated toward zero; `0` keeps every pass off the API seats; `inf` exits 1 with an OverflowError. | Processes at once on one API seat, every driver's counted |
| `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..28. | The day of the month the ledger's spend resets | | `REVIEW_GATES_API_CYCLE_DAY` | `:SeatPool` ApiConfig | `1` | As API_CONCURRENCY, then clamped to 1..31; a day past a month's end is its last day. | The grant day of an API seat without its own `_GRANT_DAY`, and the day the driver's spend on every OpenRouter key starts over |
| `REVIEW_GATES_OPENROUTER_BUDGET_USD` | `:SeatPool` ApiConfig | `100.0` | As API_BUDGET_USD | USD per cycle per OpenRouter key | | `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_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_OPENROUTER_CONCURRENCY` | `:SeatPool` ApiConfig | `16` | As API_CONCURRENCY | Processes at once on one OpenRouter seat |

159
README.md
View file

@ -1,21 +1,16 @@
# Crafter Review # Crafter Review
The pre-push review pipeline: adversarial lenses review a frozen commit range, one `claude --print` process per inventory unit, before the range is pushed. Five lenses in a fixed order: the approach, the range exercised in the running application through the Playwright MCP server, side by side the static bug proof and the security review, and last the commit truth of the messages and the documentation. Every lens is off until the repository's config turns it on, and the config says what each gets: the browser, the local Supabase database, both, or neither. Adversarial review before a push or a send. A range of commits is frozen, cut into units, and every unit is read by review lenses - one `claude --print` process each - in a fixed order of gates. Each lens asks one question and reports defects with their evidence; a gate passes when its lenses find nothing, and the last gate is the owner, who approves the exact frozen hash. A static lens's clean read of a unit is kept and reused only once three rounds have read it clean.
| Gate | Lens | Question | It reviews three kinds of artifact:
|---|---|---|
| 1 | `repo-better-solution` (a whole pass gating code blocks) | the right mechanism, layer and commit shape? compatible with what users have? |
| 2 | `repo-functional` (a whole pass gating code blocks; the `browser` or `browser+supabase` environment) | in the running application, does the range do what its code and messages say, and does it leave the flows next to it working? |
| 3 | `repo-bug-prover` (code blocks, static; with the database a proof may also run there) | a demonstrated defect in the range's code |
| 3 | `repo-security` (code blocks, static, beside the bug-prover) | a demonstrated gap in a protection the repository promises |
| 4 | `repo-facts` (message blocks, documentation blocks and the `whole:order` / `whole:absent` slots, static) | every claim in every commit message, and in the documentation the range adds or changes, proven or gone |
| 5 | the owner | approves the exact frozen hash, then the push happens |
crafter-review's prompts, reuse keys, exit codes and shared files are pinned byte for byte by its goldens (`tests/Goldens`): a review one driver started, another resumes and reuses, and every driver on a machine sees the others' passes on its seat board. - `commits`: a commit range of one of your own repositories, before the push;
- `series`: a kernel patch series as `git format-patch` exports it, with its cover letter, before the send;
- `reply`: a mailing-list reply draft.
## Install ## Install
crafter-review ships as a Flatpak with its own git, Node (for the Playwright MCP server) and ripgrep, so every machine reviews with the same tools. Inside it the lenses see the host's filesystem, with the host's `/usr` at `/run/host/usr`, but not the user and system Flatpak installations (`~/.local/share/flatpak` is empty in the sandbox, `/var/lib/flatpak` absent, by Flatpak's rule): a repository whose dependencies live in another Flatpak points its lens sections at the host's copies. crafter-review ships as a Flatpak with its own git, Node and ripgrep, so every machine reviews with the same tools.
```bash ```bash
flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo flatpak remote-add --if-not-exists catcrafts https://flatpak.catcrafts.net/catcrafts.flatpakrepo
@ -23,17 +18,17 @@ flatpak install catcrafts net.catcrafts.CrafterReview
alias crafter-review='flatpak run net.catcrafts.CrafterReview' alias crafter-review='flatpak run net.catcrafts.CrafterReview'
``` ```
Every push to `master` is a release, published as `stable`. The alias is for people: the `/prepush` skill spells every command as `flatpak run net.catcrafts.CrafterReview …`, because Claude's Bash tool sees no aliases. The machine provides the rest:
What the machine provides: | | |
|---|---|
| **The Claude Code CLI** | Proprietary, not bundled. Found through `CLAUDE_BIN` or `REVIEW_GATES_CLAUDE_BIN`, the PATH, an editor extension's bundled binary, `~/.claude/local` or `~/.local/bin`. |
| **Seats** | A dotenv file with `CLAUDE_CODE_OAUTH_TOKEN1..6` or `ANTHROPIC_API_KEY1..6`: `--env-file`, else `$SEATS_ENV_FILE`, else the working directory's `.env` when it holds a seat or a placement setting, else `~/.config/crafter-review/env`. Its seats go over the environment's; with none, your own login. The file's `REVIEW_GATES_*` placement settings count too; `ANTHROPIC_API_KEY<n>_GRANT_DAY` sets the day an API seat's cycle starts. `crafter-review seats` probes them; every driver on the machine shares one seat board. |
| **Chromium** | For a lens with the browser: `crafter-review browser install`, once. |
| **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; every call is one transaction, rolled back. |
| **gpg** | Only for signed commits: `freeze` reads the signatures through gnupg's keyboxd socket. |
- **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. ## Set a repository up
- **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.
## Setting a repository up
From the repository's toplevel: From the repository's toplevel:
@ -42,97 +37,79 @@ crafter-review init
crafter-review config # edit .review-gates/config.json until it prints no problem crafter-review config # edit .review-gates/config.json until it prints no problem
``` ```
`init` does four things: `init` writes `.review-gates/config.json` from the template when there is none, adds `.review-gates/push/` and `.review-gates/trees/` to `.gitignore`, and writes the `/prepush` skill to `.claude/skills/prepush/`. The skill is generated: it ignores itself in git, and a later `init` refreshes it. `init` writes nothing it did not make, unless `--force`.
- it writes `.review-gates/config.json` from the template when there is none - the three artifact kinds as modes with every lens of their kind: trim each to what this repository runs;
- it adds `.review-gates/push/` and `.review-gates/trees/` to `.gitignore`;
- it writes the `/prepush` skill to `.claude/skills/prepush/SKILL.md`, which a later `init` refreshes (the line after its frontmatter marks it as init's), with a `.gitignore` of `*` beside it: the skill is generated from the shipped text, so it stays out of git and every checkout runs `init` once (a tracked copy would lag the installed binary); a skill the repository wrote itself, without that line, is left alone and not ignored, unless `--force`;
- it prints the next steps.
It writes nothing in a subdirectory, in your home directory or over a `SKILL.md`, a `.gitignore` beside it or a link there that it did not make (the old link into the review-gates submodule aside, which it removes); `--force` allows all but the first. `.review-gates/config.json` is committed. It says:
`.review-gates/config.json` is committed. It says what the pipeline must know about the repository: - what `freeze` checks: `authors` (null: not checked), `aiTrailers`, `requireSigned`, and the branch a range is measured against (`base`, `rangeFrom`);
- who may author its commits, and which branch a range is measured against; - `modes.<name>`: the kind of artifact, the lenses it runs and their effort. Every lens is off until a mode lists it; `run --mode` picks one;
- its modes (`modes.<name>`): each an artifact kind (`reply`, `series`, `commits`; a kind is a mode of its own name), the lenses it runs (every lens is off until a mode lists it; they run in their kind's gate order - the table above for commits, the kernel modes' gates below for a series or a reply) and the effort it runs at; `run --mode` names one; - `lenses.<name>`: the environment a lens gets (`none`, `browser`, `supabase` or `browser+supabase`) and, if the repository has one, its `section` - a file of this repository's own appended to the lens's charter;
- the environment each lens gets (`none`, `browser`, `supabase` or `browser+supabase`); - `functional`, `slots`, `supabase`: how the application is started and reached, one stack or several, and where its database answers;
- each lens's own section (`lenses.<name>.section`), a file in the repository appended to that lens's shipped charter, for the calibration entries and conventions that are this repository's rather than the lens's (the reuse keys carry the combined text's hash); a lesson general to the lens goes into the shipped charter; - `allowDirty`, `gate0`: the tracked paths the stack rewrites, and the commands Gate 0 runs by hand.
- how its application is started and reached: one stack, or several side by side (`slots`);
- where its local database answers;
- which paths carry no runtime behaviour.
`crafter-review doc config` prints the template; `crafter-review doc prepush` prints the procedure, `process/prepush.md`. `crafter-review doc config` prints the template; `crafter-review doc prepush` prints the procedure the `/prepush` skill follows.
### Moving a repository off the review-gates submodule ## Run a review
1. Re-freeze every review in flight with crafter-review. The Flatpak's git may be another version than the one that froze it, and a different git version gives a different hash. The old `run-line.txt` also points into the submodule.
2. `git submodule deinit -f review-gates && git rm review-gates`
3. `crafter-review init`. If `.claude/skills/prepush` is the old link into `review-gates/`, it removes the link, never writing through it, and makes a real directory with the skill.
4. If a copy of the skill that an earlier `init` wrote is tracked, `git rm --cached .claude/skills/prepush/SKILL.md` (init says so when it sees one): the skill is generated, and the `.gitignore` init writes beside it keeps it out of git from then on.
An existing `config.json` needs one change since 2026-11-01: `lenses.<name>.run` is retired, so list the lenses in `modes.commits.lenses` (`config` names what is missing, and `freeze`, `run` and `slots` refuse until it is there). The reuse keys keep working: a range reviewed before the move is reused, not bought again.
## Running a review
```bash ```bash
crafter-review freeze --slug <branch> # Gate 0: checks, export, hash, run line crafter-review freeze --slug <branch> # Gate 0: the checks, the export, the hash - and the run line
# start the application / the stack from the checkout at HEAD, when a lens has the browser / the database crafter-review run … # the line freeze printed (run-line.txt), from a detached shell
crafter-review run --artifact .review-gates/push/<branch>/ --hash <sha256> \
--base <sha> --trees . --dispositions .review-gates/push/<branch>/gate-state/dispositions.json --date <date>
``` ```
`freeze` prints the exact run line and writes it to `run-line.txt`; launch from there in a detached shell (`setsid nohup … &`). The exit codes: Exit codes: 0 SEND-READY, 1 FIX FIRST, 2 run the same line again (INCOMPLETE, PARTIAL or CONFIRMING), 3 preflight. Every finding, note and coverage entry is in the dump, `gate-state/commits-<hash8>-run-<n>.json`. `crafter-review stop --hash <hash8>` kills a run's passes and writes its dump.
- 0: SEND-READY (push-ready);
- 1: FIX FIRST;
- 2: INCOMPLETE, PARTIAL or CONFIRMING (run the same line again);
- 3: preflight.
The dump with every finding, note and coverage entry is `gate-state/commits-<hash8>-run-<n>.json`. A directory can review other repositories: `freeze --repo <path> --out <dir> --worktree`, then the printed run line. The config, the seats and the records are the reviewing directory's.
### Reviewing another repository The kernel modes take `--subsystem <name>`, whose journal (`ledger.md`, `captures/`) is the lenses' evidence, and the trees `--trees` names; lore.kernel.org is read through `utilities/lore.py` in the working directory. `--reviewers "A B,C D"` adds one maintainer persona per reviewer, calibrated on their own lore mail; clean gates without a persona pass are GATES CLEAN, not SEND-READY.
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 lenses
### The kernel modes: a patch series, a mailing-list reply Gates run in order, and each must pass before the next starts. The owner is the last gate of every mode.
Beside the push gates, crafter-review runs the pre-send review a kernel contributor's workbench keeps: `--mode series` over a `git format-patch` export with its cover letter, and `--mode reply` over a mail draft (a reply's gate-state sits beside the draft). Both take `--subsystem <name>`, whose journal - `journal/<name>/ledger.md`, `captures/`, `captures/threads/` - is the lenses' evidence, and the trees `--trees` names (default `work/linux,work/milos-linux`; history only in the full clone). lore.kernel.org is reached through `python3 utilities/lore.py` in the working directory, which also prefetches every reference the artifact cites into `gate-state/refs/` before the gates run. The series gates are structure (better-solution), code (code-quality, minimalism, bug-prover) and artifact truth (facts, consistency, process-docs, etiquette, conciseness); a reply runs an artifact truth of its own alone (facts, etiquette, consistency, conciseness). `--reviewers "A B,C D"` adds the persona gate after them: one instance of the maintainer-persona lens per reviewer, calibrated on their own lore mail, its whole pass gating its blocks and pruning the units that are not theirs (`--persona-blocks all` keeps them). Gates clean without a persona pass is GATES CLEAN, not SEND-READY. These lenses are off until the config turns them on, like every other; the effort defaults to max, and a reply's units are read once. **`commits`** - a commit range:
To stop a run, run `crafter-review stop <its scratch dir>`, or `crafter-review stop --hash <hash8>` for every live run of a hash. Its running passes are killed, no later gate starts and the dump is written. A run takes its passes with it however it ends, SIGKILL included: each pass runs under a supervisor that kills its whole tree, the MCP server and Chromium included. `flatpak kill net.catcrafts.CrafterReview` is the hard stop. | Gate | Lens | Asks |
|---|---|---|
| 1 Approach | `repo-better-solution` | the right mechanism, layer and commit shape? compatible with what users have? |
| 2 Functional | `repo-functional` (needs the browser) | in the running application, does the range do what its code and messages say, and leave the flows next to it working? |
| 3 Proof | `repo-bug-prover` | a demonstrated defect in the range's code |
| 3 Proof | `repo-security` | a demonstrated gap in a protection the repository promises |
| 4 Commit truth | `repo-facts` | every claim in every commit message and changed document, proven or gone |
| Command | What it does | **`series`** - a kernel patch series:
| Gate | Lens | Asks |
|---|---|---|
| 1 Structure | `better-solution` | should the series be replaced: another mechanism, layer or shape? |
| 2 Code | `code-quality` | the defects a careful subsystem maintainer finds: interactions, error and teardown paths, style |
| 2 Code | `minimalism` | can it be done with less: fewer lines, hunks, patches, files? |
| 2 Code | `bug-prover` | a demonstrable defect in the code the series produces |
| 3 Artifact truth | `facts` | every claim in the cover letter, the messages and the notes, disproved or proven |
| 3 Artifact truth | `consistency` | contradictions between the artifacts, the ledger and what was said on-list |
| 3 Artifact truth | `process-docs` | the series against the kernel's own in-tree process documentation |
| 3 Artifact truth | `etiquette` | how it lands with the humans on the thread: tone, attribution, conventions |
| 3 Artifact truth | `conciseness` | the shorter text, every dropped clause accounted for |
| 4 Persona | `maintainer-persona` | one named reviewer's reading of the final artifact (`--reviewers`) |
**`reply`** - a mailing-list reply: Gate 3, artifact truth, with `facts`, `etiquette`, `consistency` and `conciseness`, then the persona gate.
## Commands
| Command | |
|---|---| |---|---|
| `init`, `config`, `doc` | set a repository up; the merged config and its problems; the shipped texts |
| `freeze` | Gate 0: the checks, the export, the hash, the run line; `--verify` at push time | | `freeze` | Gate 0: the checks, the export, the hash, the run line; `--verify` at push time |
| `run` | the gates of the mode (`--mode`: `commits`, `series`, `reply` or one of the repository's own, as the config defines it): the lenses, the dump; `--dry-run` launches nothing | | `run` | the gates of a mode (`--mode`); `--dry-run` launches nothing |
| `slots` | how many stacks fit in free memory, and the commands for the missing ones | | `stop` | stop a run: its passes are killed, the dump written |
| `config` | the merged config and its problems | | `seats`, `slots` | every seat probed; how many stacks fit in free memory |
| `seats` | every seat probed: both windows, their resets, the drivers running | | `browser install` | the Chromium the Playwright MCP server drives |
| `init`, `doc`, `browser install`, `stop` | the setup, the shipped texts, the Chromium, a stop | | `sql`, `totp`, `inventory` | the helpers a lens runs; the partition of an artifact into units |
| `sql`, `totp`, `inventory` | the helpers a lens runs, and the partition of an export, a series or a reply |
## Inside the Flatpak
The sandbox gets the network, the whole host filesystem (`--filesystem=host`), the host's `/tmp` and gnupg's keyboxd socket. `--filesystem=host` is a convenience for a developer tool, **not an isolation boundary**: a lens's tool allowlist is what keeps a pass to its job, as it was outside the Flatpak.
The driver hands its children the host's XDG directories. git then reads `~/.config/git/config`, Playwright finds `~/.cache/ms-playwright`, and claude and npx behave as on the host. A pass's shell is bash. A few things do differ from a native run:
- a lens reading `/usr` sees the Sdk's; the host's is at `/run/host/usr`, which the commit-range lenses' common text (`_common-repo.md`) tells them;
- git hooks run with the Sdk's tools;
- `FLATPAK_ID` reaches every child;
- a runtime update can change git between `freeze` and `--verify`, which the MISMATCH message then names.
**Several drivers on one machine.** Every driver registers its passes on one board, `~/.cache/review-gates/seat-board.json`, and holds a lifetime lock beside it while it runs. A driver in the Flatpak publishes the host pid of its sandbox, so a driver outside it sees it alive exactly as long as it runs and sweeps it the moment it is gone.
## Building from source
```bash
crafter-build --local test # the suite; --local takes ../Crafter.Network instead of the pinned commit
crafter-build --local lint
crafter-build run flatpak # the Flatpak on the stable Crafter.Build base (flatpak-builder or org.flatpak.Builder)
crafter-build run install # install that build as net.catcrafts.CrafterReview//local
```
`run flatpak --base=<branch>` builds on another branch of the base app, and `--deps=local` takes Crafter.Network and Crafter.Thread from the checkouts next to this one, for commits not yet on forgejo. `AGENTS.md` is the guide for changing the pipeline.
## License ## License
crafter-review is licensed under the GNU Affero General Public License v3.0 only (`LICENSE`). It vendors [nlohmann/json](https://github.com/nlohmann/json) 3.12.0 (`lib/json.hpp`, MIT License, © 2013-2025 Niels Lohmann). crafter-review is licensed under the GNU Affero General Public License v3.0 only (`LICENSE`).
Vendored files in the lib directory maintain their own license.
## Copyright ## Copyright

View file

@ -9,7 +9,6 @@ import :Core;
import :Plan; import :Plan;
import :Tools; import :Tools;
import :PgWire; import :PgWire;
import :Process;
import :Platform; import :Platform;
import :Prompt; import :Prompt;
import std; import std;
@ -151,8 +150,8 @@ namespace Crafter::Review::Config {
// when HEAD tracks nothing; base: always merge-base HEAD // when HEAD tracks nothing; base: always merge-base HEAD
// <base>..HEAD, the whole pull request. // <base>..HEAD, the whole pull request.
{"rangeFrom", "upstream"}, {"rangeFrom", "upstream"},
// Author and committer emails the freeze accepts; null: the // Author and committer emails the freeze accepts; null: neither
// repository's `git config user.email`. // is checked.
{"authors", nullptr}, {"authors", nullptr},
// forbid | allow: Assisted-by / Co-Authored-By trailers in the // forbid | allow: Assisted-by / Co-Authored-By trailers in the
// range. // range.
@ -266,6 +265,8 @@ namespace Crafter::Review::Config {
return a.is_string() && a.get_ref<const std::string&>().contains('@'); return a.is_string() && a.get_ref<const std::string&>().contains('@');
}))) { }))) {
out.emplace_back("authors must be null or a list of email addresses"); out.emplace_back("authors must be null or a list of email addresses");
} else if (authors.is_array() && authors.empty()) {
out.emplace_back("authors is an empty list, which no commit passes - null leaves author and committer unchecked");
} }
for (const std::string_view key : {"allowDirty", "gate0"}) { for (const std::string_view key : {"allowDirty", "gate0"}) {
if (!AllStrings(Dyn::At(config, key))) out.push_back(std::format("{} must be a list of strings", key)); if (!AllStrings(Dyn::At(config, key))) out.push_back(std::format("{} must be a list of strings", key));
@ -453,22 +454,13 @@ namespace Crafter::Review::Config {
return copy; return copy;
} }
std::string GitUserEmail(const fs::path& repo) { std::optional<std::vector<std::string>> Authors(const Json& config) {
const Process::Captured run = Process::Run({"git", "-C", repo.string(), "config", "user.email"});
return run.returnCode == 0 ? Dyn::Strip(Dyn::UniversalNewlines(Dyn::DecodeReplace(run.out))) : std::string();
}
std::vector<std::string> Authors(const Json& config, const fs::path& repo) {
const Json& authors = Dyn::Get(config, "authors"); const Json& authors = Dyn::Get(config, "authors");
if (Dyn::Truthy(authors)) { if (!authors.is_array()) return std::nullopt;
std::vector<std::string> out; std::vector<std::string> out;
for (const Json& author : authors) out.push_back(Dyn::Str(author)); for (const Json& author : authors) out.push_back(Dyn::Str(author));
return out; return out;
} }
const std::string email = GitUserEmail(repo);
if (email.empty()) return {};
return {email};
}
std::map<std::string, fs::path> LensSectionsOf(const Json& config, const fs::path& repo) { std::map<std::string, fs::path> LensSectionsOf(const Json& config, const fs::path& repo) {
// Only a file inside the repository, by the rule Validate refuses the // Only a file inside the repository, by the rule Validate refuses the

View file

@ -534,7 +534,8 @@ inventory unit, against a frozen commit range.)";
constexpr std::string_view Prog = "crafter-review seats"; constexpr std::string_view Prog = "crafter-review seats";
const Parsed parsed = ParseOptions(args, SeatsOptions); const Parsed parsed = ParseOptions(args, SeatsOptions);
if (parsed.help) { if (parsed.help) {
std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 from the environment and/or the dotenv file. Never prints a token.\n", std::print("{}\nSeats: CLAUDE_CODE_OAUTH_TOKEN1..6 and ANTHROPIC_API_KEY1..6 from the environment and/or the dotenv file. Never prints a token. An API key's "
"probe is recorded in the seat ledger: new credits, or the credit error.\n",
Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions)); Help(Prog, "How much usage each Claude seat has left: every seat probed, both windows and their resets.", SeatsOptions));
return 0; return 0;
} }
@ -603,9 +604,23 @@ inventory unit, against a frozen commit range.)";
Seats::ProbeOptions api = haiku; Seats::ProbeOptions api = haiku;
api.api = true; api.api = true;
apiSeats = ProbeAll(todo, api); apiSeats = ProbeAll(todo, api);
// The driver's ledger: what it spent on each key this cycle. // The driver's ledger: what it spent on each key this cycle,
SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig); // from each seat's grant day. It follows the probe: a key that
for (Json& s : apiSeats) s["ledger"] = ledger.Entry(std::format("api{}", s.at("seat").get<std::int64_t>())); // answers again after the credit error is new credits, the
// credit error on a key it has as fine is recorded.
std::map<std::string, std::int32_t> days;
for (const auto& [n, day] : Seats::ApiGrantDays(env)) days[std::format("api{}", n)] = day;
SeatPool::Ledger ledger = SeatPool::Ledger::Load(SeatPool::LedgerPath(), apiConfig, days);
for (Json& s : apiSeats) {
const std::string id = std::format("api{}", s.at("seat").get<std::int64_t>());
s["grantDay"] = ledger.Day(id);
// `now`, taken before the probes started, is their start;
// what Reconcile writes is stamped when it is written.
s["event"] = ledger.Reconcile(id, s.at("state"), now);
s["ledger"] = ledger.Entry(id, now);
s["ledgerOut"] = SeatPool::LedgerOut(s.at("ledger"), apiConfig.at("budgetUsd").get<double>());
s["nextCredits"] = SeatPool::NextGrant(now, ledger.Day(id), Dyn::Get(s.at("ledger"), "outSince"));
}
} }
// The board: the drivers running now and what they hold on each // The board: the drivers running now and what they hold on each
// account - read, never written. // account - read, never written.
@ -631,7 +646,7 @@ inventory unit, against a frozen commit range.)";
for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get<std::int64_t>())); for (const Json& s : apiSeats) ids.push_back(std::format("api{}", s.at("seat").get<std::int64_t>()));
std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin); std::println("seat pool: {} seat(s) ({}) from {}, probed {} {}, claude: {}\n", ids.size(), Dyn::Join(ids, ", "), source, Seats::Clock(now, zone), zone.label, claudeBin);
if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys)); if (!seats.empty()) std::println("{}", Seats::RenderStatusReport(seats, cfg, now, zone, board, keys));
if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, board, apiKeys)); if (!apiSeats.empty()) std::println("{}{}", seats.empty() ? "" : "\n", Seats::RenderApiReport(apiSeats, apiConfig, now, board, apiKeys));
std::println("\n{}", Seats::RenderDrivers(board, now, zone)); std::println("\n{}", Seats::RenderDrivers(board, now, zone));
// The pids above are no use from inside the sandbox: the way to // The pids above are no use from inside the sandbox: the way to
// stop a run instead. // stop a run instead.

View file

@ -211,20 +211,18 @@ namespace Crafter::Review::Freeze {
fail(std::format("{} is not the toplevel of a git checkout", repo)); fail(std::format("{} is not the toplevel of a git checkout", repo));
return report; return report;
} }
std::vector<std::string> authors = options.authors; const std::optional<std::vector<std::string>>& authors = options.authors;
if (authors.empty()) authors = Config::Authors({{"authors", nullptr}}, repo);
if (authors.empty()) {
fail("no author identity: set `authors` in .review-gates/config.json or git config user.email");
return report;
}
const Base base = ResolveBase(repo, options.base, options.configBase, options.rangeFrom); const Base base = ResolveBase(repo, options.base, options.configBase, options.rangeFrom);
if (!base.failure.empty()) { if (!base.failure.empty()) {
fail(base.failure); fail(base.failure);
return report; return report;
} }
const std::string head = Artifact::TreeHead(repo); const std::string head = Artifact::TreeHead(repo);
Json authorList = Json::array(); Json authorList;
for (const std::string& author : authors) authorList.push_back(author); if (authors) {
authorList = Json::array();
for (const std::string& author : *authors) authorList.push_back(author);
}
report["base"] = base.sha; report["base"] = base.sha;
report["baseFrom"] = base.how; report["baseFrom"] = base.how;
report["upstream"] = base.upstream ? Json(*base.upstream) : Json(); report["upstream"] = base.upstream ? Json(*base.upstream) : Json();
@ -261,11 +259,12 @@ namespace Crafter::Review::Freeze {
commitList.push_back({{"sha", c.sha}, {"subject", c.subject}, {"author", c.author}, {"committer", c.committer}, {"sig", c.sig}}); commitList.push_back({{"sha", c.sha}, {"subject", c.subject}, {"author", c.author}, {"committer", c.committer}, {"sig", c.sig}});
} }
report["commits"] = commitList; report["commits"] = commitList;
const std::string authorText = Dyn::Join(authors, ", "); if (!authors) warn("no authors in .review-gates/config.json: author and committer not checked");
const std::string authorText = authors ? Dyn::Join(*authors, ", ") : std::string();
for (const Commit& c : commits) { for (const Commit& c : commits) {
const std::string who = std::format("{} {}", Head12(c.sha), Dyn::Repr(Dyn::Slice(c.subject, 0, 50))); const std::string who = std::format("{} {}", Head12(c.sha), Dyn::Repr(Dyn::Slice(c.subject, 0, 50)));
if (!std::ranges::contains(authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText)); if (authors && !std::ranges::contains(*authors, c.author)) fail(std::format("{}: author {} not in {}", who, c.author, authorText));
if (!std::ranges::contains(authors, c.committer)) fail(std::format("{}: committer {} not in {}", who, c.committer, authorText)); if (authors && !std::ranges::contains(*authors, c.committer)) fail(std::format("{}: committer {} not in {}", who, c.committer, authorText));
if (const std::optional<std::string> trailer = AiTrailer(c.body)) { if (const std::optional<std::string> trailer = AiTrailer(c.body)) {
const bool forbid = options.aiTrailers == "forbid"; const bool forbid = options.aiTrailers == "forbid";
const std::string message = std::format("{}: AI attribution trailer {}{}", who, Dyn::Repr(Dyn::Strip(*trailer)), const std::string message = std::format("{}: AI attribution trailer {}{}", who, Dyn::Repr(Dyn::Strip(*trailer)),
@ -472,7 +471,7 @@ namespace Crafter::Review::Freeze {
options.replace = arguments.replace; options.replace = arguments.replace;
options.worktree = arguments.worktree; options.worktree = arguments.worktree;
options.requireSigned = arguments.requireSigned || Dyn::Truthy(Dyn::Get(config, "requireSigned")); options.requireSigned = arguments.requireSigned || Dyn::Truthy(Dyn::Get(config, "requireSigned"));
options.authors = Config::Authors(config, repo); options.authors = Config::Authors(config);
options.aiTrailers = Dyn::Str(Dyn::Get(config, "aiTrailers")); options.aiTrailers = Dyn::Str(Dyn::Get(config, "aiTrailers"));
options.allowDirty = allowDirty; options.allowDirty = allowDirty;
const Json& configBase = Dyn::Get(config, "base"); const Json& configBase = Dyn::Get(config, "base");

View file

@ -767,7 +767,7 @@ namespace Crafter::Review::Run {
std::ranges::sort(sorted); std::ranges::sort(sorted);
Die(std::format("OpenRouter seats need OpenRouter model ids (vendor/model): --models opus=...,fable=... (now {})", Dyn::Join(sorted, ", "))); Die(std::format("OpenRouter seats need OpenRouter model ids (vendor/model): --models opus=...,fable=... (now {})", Dyn::Join(sorted, ", ")));
} }
if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log); if (!a.dryRun && !a.noProbe) SeatPool::Probe(seats, runner->Bin(), 30, modelIds, log, {}, ledger.get());
if (board) { if (board) {
// Our readings onto the board (a probed-ok seat clears a verdict // Our readings onto the board (a probed-ok seat clears a verdict
// another driver left), theirs into our seats. // another driver left), theirs into our seats.
@ -793,7 +793,12 @@ namespace Crafter::Review::Run {
for (const Json& s : seats) names.push_back(std::format("{} ({})", Dyn::Str(s.at("id")), Dyn::Str(s.at("kind")))); for (const Json& s : seats) names.push_back(std::format("{} ({})", Dyn::Str(s.at("id")), Dyn::Str(s.at("kind"))));
std::string line = std::format("seats: {} from {}", Dyn::Join(names, ", "), envFile && !envFile->empty() ? *envFile : std::string("this machine")); std::string line = std::format("seats: {} from {}", Dyn::Join(names, ", "), envFile && !envFile->empty() ? *envFile : std::string("this machine"));
if (std::ranges::any_of(seats, [](const Json& s) { return s.at("kind") == "api"; })) { if (std::ranges::any_of(seats, [](const Json& s) { return s.at("kind") == "api"; })) {
line += std::format("; {}, budget ${:g} per api seat per cycle (ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), ledger->Path().string()); std::vector<std::string> days;
for (const Json& s : seats) {
if (s.at("kind") == "api") days.push_back(std::format("{} {}", Dyn::Str(s.at("id")), Dyn::Str(Dyn::Get(s, "grantDay"))));
}
line += std::format("; {}, budget ${:g} per api seat per cycle from its grant day ({}; ledger {})", Dyn::Str(apiCfg.at("order")), Number(apiCfg.at("budgetUsd")), Dyn::Join(days, ", "),
ledger->Path().string());
} }
if (openrouter) { if (openrouter) {
line += std::format("; budget ${:g} per OpenRouter seat per cycle, {} concurrent (ledger {})", Number(apiCfg.at("orBudgetUsd")), Dyn::Str(apiCfg.at("orConcurrency")), ledger->Path().string()); line += std::format("; budget ${:g} per OpenRouter seat per cycle, {} concurrent (ledger {})", Number(apiCfg.at("orBudgetUsd")), Dyn::Str(apiCfg.at("orConcurrency")), ledger->Path().string());

View file

@ -206,7 +206,8 @@ namespace Crafter::Review::SeatPool {
{"budgetUsd", num("REVIEW_GATES_API_BUDGET_USD", 200.0)}, {"budgetUsd", num("REVIEW_GATES_API_BUDGET_USD", 200.0)},
{"lensCostUsd", num("REVIEW_GATES_API_LENS_COST_USD", 1.0)}, {"lensCostUsd", num("REVIEW_GATES_API_LENS_COST_USD", 1.0)},
{"concurrency", Int(num("REVIEW_GATES_API_CONCURRENCY", 8))}, {"concurrency", Int(num("REVIEW_GATES_API_CONCURRENCY", 8))},
{"cycleDay", std::max<std::int64_t>(1, std::min<std::int64_t>(28, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))}, // A day past a month's end is its last day (CycleStart).
{"cycleDay", std::max<std::int64_t>(1, std::min<std::int64_t>(31, Int(num("REVIEW_GATES_API_CYCLE_DAY", 1))))},
{"order", EnvOf(env, "REVIEW_GATES_SEAT_ORDER") == "oauth-first" ? "oauth-first" : "api-first"}, {"order", EnvOf(env, "REVIEW_GATES_SEAT_ORDER") == "oauth-first" ? "oauth-first" : "api-first"},
// OpenRouter seats: a prepaid key, cheap models - a smaller reserve // OpenRouter seats: a prepaid key, cheap models - a smaller reserve
// per running process. // per running process.
@ -233,47 +234,153 @@ namespace Crafter::Review::SeatPool {
return root / "seat-ledger.json"; return root / "seat-ledger.json";
} }
std::string CycleStart(double now, std::int32_t day) { namespace {
std::chrono::year_month_day LocalDate(double now) {
using namespace std::chrono; using namespace std::chrono;
const std::string today = Platform::LocalTime("%Y-%m-%d", now); const std::string today = Platform::LocalTime("%Y-%m-%d", now);
const year_month_day date(year(std::stoi(today.substr(0, 4))), month(static_cast<unsigned>(std::stoi(today.substr(5, 2)))), return {year(std::stoi(today.substr(0, 4))), month(static_cast<unsigned>(std::stoi(today.substr(5, 2)))), day(static_cast<unsigned>(std::stoi(today.substr(8, 2))))};
std::chrono::day(static_cast<unsigned>(std::stoi(today.substr(8, 2)))));
auto iso = [](const year_month_day& d) {
return std::format("{:04d}-{:02d}-{:02d}", static_cast<std::int32_t>(d.year()), static_cast<std::uint32_t>(d.month()), static_cast<std::uint32_t>(d.day()));
};
if (static_cast<std::int32_t>(static_cast<std::uint32_t>(date.day())) >= day) {
return iso(year_month_day(date.year(), date.month(), std::chrono::day(static_cast<std::uint32_t>(day))));
} }
const year_month_day previous(sys_days(year_month_day(date.year(), date.month(), std::chrono::day(1))) - days(1));
const auto last = static_cast<std::int32_t>(static_cast<std::uint32_t>(previous.day())); std::chrono::year_month_day ParseIso(std::string_view iso) {
return iso(year_month_day(previous.year(), previous.month(), std::chrono::day(static_cast<std::uint32_t>(std::min(day, last))))); using namespace std::chrono;
const std::string text(iso);
return {year(std::stoi(text.substr(0, 4))), month(static_cast<unsigned>(std::stoi(text.substr(5, 2)))), day(static_cast<unsigned>(std::stoi(text.substr(8, 2))))};
}
std::string Iso(const std::chrono::year_month_day& d) {
return std::format("{:04d}-{:02d}-{:02d}", static_cast<std::int32_t>(d.year()), static_cast<std::uint32_t>(d.month()), static_cast<std::uint32_t>(d.day()));
}
// A ledger stamp: local time with its UTC offset,
// 2026-10-25T02:00:05+01:00, so any driver orders it by instant across
// the hour that repeats when DST ends, or from another zone.
std::string Stamp(double at) {
std::string text = Platform::LocalTime("%Y-%m-%dT%H:%M:%S%z", at);
if (text.size() == 24) text.insert(22, ":");
return text;
}
// A stamp's instant, epoch seconds: by its offset, else (a stamp
// written before 1.1.0) as local time; none when it cannot be read.
std::optional<double> Instant(std::string_view stamp) {
auto number = [&](std::size_t from, std::size_t width) -> std::optional<std::int32_t> {
if (from + width > stamp.size()) return std::nullopt;
std::int32_t value = 0;
for (std::size_t i = from; i < from + width; ++i) {
if (stamp[i] < '0' || stamp[i] > '9') return std::nullopt;
value = value * 10 + (stamp[i] - '0');
}
return value;
};
if (stamp.size() < 19 || stamp[4] != '-' || stamp[7] != '-' || stamp[10] != 'T' || stamp[13] != ':' || stamp[16] != ':') return std::nullopt;
const std::optional<std::int32_t> y = number(0, 4), mo = number(5, 2), d = number(8, 2), h = number(11, 2), mi = number(14, 2), s = number(17, 2);
if (!y || !mo || !d || !h || !mi || !s) return std::nullopt;
const double clock = *h * 3600.0 + *mi * 60.0 + *s;
if (stamp.size() == 25 && (stamp[19] == '+' || stamp[19] == '-') && stamp[22] == ':') {
const std::optional<std::int32_t> oh = number(20, 2), om = number(23, 2);
const std::chrono::year_month_day date {std::chrono::year(*y), std::chrono::month(static_cast<unsigned>(*mo)), std::chrono::day(static_cast<unsigned>(*d))};
if (!oh || !om || !date.ok()) return std::nullopt;
const double offset = (stamp[19] == '-' ? -1.0 : 1.0) * (*oh * 3600.0 + *om * 60.0);
return static_cast<double>(std::chrono::sys_days(date).time_since_epoch().count()) * 86400.0 + clock - offset;
}
if (stamp.size() != 19) return std::nullopt;
std::tm local {};
local.tm_year = *y - 1900;
local.tm_mon = *mo - 1;
local.tm_mday = *d;
local.tm_hour = *h;
local.tm_min = *mi;
local.tm_sec = *s;
local.tm_isdst = -1;
const std::time_t seconds = std::mktime(&local);
if (seconds == static_cast<std::time_t>(-1)) return std::nullopt;
return static_cast<double>(seconds);
}
// That day of the month, a day past its end its last day.
std::chrono::year_month_day OnDay(const std::chrono::year_month& ym, std::int32_t day) {
using namespace std::chrono;
const auto last = static_cast<std::int32_t>(static_cast<unsigned>(year_month_day_last(ym.year(), month_day_last(ym.month())).day()));
return {ym.year(), ym.month(), std::chrono::day(static_cast<unsigned>(std::clamp(day, 1, last)))};
}
}
std::string CycleStart(double now, std::int32_t day) {
using namespace std::chrono;
const year_month_day date = LocalDate(now);
const year_month ym(date.year(), date.month());
const year_month_day thisMonth = OnDay(ym, day);
return Iso(date >= thisMonth ? thisMonth : OnDay(ym - months(1), day));
}
std::string NextGrant(double now, std::int32_t day, const Json& outSince) {
using namespace std::chrono;
const std::string cycle = CycleStart(now, day);
if (Dyn::Truthy(outSince) && Dyn::Slice(Dyn::Str(outSince), 0, 10) < cycle) return cycle;
const year_month_day start = ParseIso(cycle);
return Iso(OnDay(year_month(start.year(), start.month()) + months(1), day));
}
std::pair<bool, std::string> LedgerVerdict(const Json& entry, double budget) {
const Json& outSince = Dyn::Get(entry, "outSince");
if (Dyn::Truthy(outSince)) return {true, std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(entry, "outReason"), "out"), Dyn::Slice(Dyn::Str(outSince), 0, 16))};
const double spent = Num(Dyn::Get(entry, "spentUsd"));
if (spent >= budget) return {true, std::format("ledger: ${:.2f} spent this cycle, budget ${}", spent, G(budget))};
return {false, std::string()};
}
Json LedgerOut(const Json& entry, double budget) {
if (!LedgerVerdict(entry, budget).first) return nullptr;
return Dyn::Truthy(Dyn::Get(entry, "outSince")) ? "credit" : "budget";
} }
// ---- the ledger ------------------------------------------------------------------- // ---- the ledger -------------------------------------------------------------------
Ledger::Ledger(fs::path path, Json apiConfig, Json data) : path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()) {} Ledger::Ledger(fs::path path, Json apiConfig, Json data, std::map<std::string, std::int32_t> days)
: path(std::move(path)), apiConfig(std::move(apiConfig)), data(Dyn::Truthy(data) ? std::move(data) : Json::object()), days(std::move(days)) {}
Ledger Ledger::Load(const fs::path& path, const Json& apiConfig) { Ledger Ledger::Load(const fs::path& path, const Json& apiConfig, std::map<std::string, std::int32_t> days) {
Json data; Json data;
try { try {
data = Dyn::LoadJsonFile(path); data = Dyn::LoadJsonFile(path);
} catch (const std::exception&) { } catch (const std::exception&) {
data = Json::object(); data = Json::object();
} }
return Ledger(path, apiConfig, data.is_object() ? data : Json::object()); return Ledger(path, apiConfig, data.is_object() ? data : Json::object(), std::move(days));
}
std::int32_t Ledger::Day(const std::string& seatId) const {
const auto found = days.find(seatId);
if (found != days.end() && found->second > 0) return found->second;
return static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay"))));
}
void Ledger::SetDay(const std::string& seatId, std::int32_t day) {
days[seatId] = day;
} }
Json& Ledger::Entry(const std::string& seatId, std::optional<double> now) { Json& Ledger::Entry(const std::string& seatId, std::optional<double> now) {
const std::string cycle = CycleStart(NowOr(now), static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay"))))); const std::string cycle = CycleStart(NowOr(now), Day(seatId));
const Json& e = Dyn::Get(data, seatId); const Json& e = Dyn::Get(data, seatId);
// Only a newer cycle starts over: an entry of a later one (another if (!Dyn::Truthy(e)) {
// driver's cycle day) is not wiped.
if (!Dyn::Truthy(e) || cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) {
data[seatId] = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}}; data[seatId] = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}};
} else if (cycle > Dyn::StrOr(Dyn::Get(e, "cycle"), "")) {
// Only a newer cycle starts over: an entry of a later one (another
// driver's cycle day, or a grant seen early) is not wiped. A
// credit-out carries: the cycle's day is a configured date, the
// credit error was observed (2026-10-17: the calendar cycle held
// api3 out for the first week of its new credits; an OpenRouter
// key's turn puts nothing on it), and only a probe that sees the
// key answer again lifts it (Reconcile). When the key was last seen
// answering again carries too: Reconcile records no credit error
// read before it.
Json next = {{"cycle", cycle}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", Dyn::Get(e, "outSince")}, {"outReason", Dyn::Get(e, "outReason")}};
if (Dyn::Truthy(Dyn::Get(e, "grantSeen"))) next["grantSeen"] = Dyn::Get(e, "grantSeen");
data[seatId] = std::move(next);
} }
return data[seatId]; return data[seatId];
} }
Json Ledger::Update(const std::string& seatId, const std::function<void(Json&)>& change, std::optional<double> now) { Json Ledger::Update(const std::string& seatId, const std::function<bool(Json&)>& change, std::optional<double> now) {
// The file is the record: re-read under its lock, this process only // The file is the record: re-read under its lock, this process only
// ever adds to it (2026-10-15: each driver saved the copy it had loaded // ever adds to it (2026-10-15: each driver saved the copy it had loaded
// and the last writer won - api4 reached the credit error with $157.54 // and the last writer won - api4 reached the credit error with $157.54
@ -285,10 +392,10 @@ namespace Crafter::Review::SeatPool {
for (auto it = file.begin(); it != file.end(); ++it) merged[it.key()] = it.value(); for (auto it = file.begin(); it != file.end(); ++it) merged[it.key()] = it.value();
data = std::move(merged); data = std::move(merged);
Json& e = Entry(seatId, now); Json& e = Entry(seatId, now);
change(e); const bool write = change(e);
entry = e; entry = e;
file = data; if (write) file = data;
return true; return write;
}, data, 0600, {}); }, data, 0600, {});
return entry; return entry;
} }
@ -297,16 +404,70 @@ namespace Crafter::Review::SeatPool {
return Update(seatId, [&](Json& e) { return Update(seatId, [&](Json& e) {
e["spentUsd"] = Dyn::Round(AsDouble(Dyn::At(e, "spentUsd")) + usd, 4); e["spentUsd"] = Dyn::Round(AsDouble(Dyn::At(e, "spentUsd")) + usd, 4);
e["procs"] = Dyn::Add(Dyn::At(e, "procs"), Json(1)); e["procs"] = Dyn::Add(Dyn::At(e, "procs"), Json(1));
return true;
}, now); }, now);
} }
Json Ledger::MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now) { Json Ledger::MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now) {
return Update(seatId, [&](Json& e) { return Update(seatId, [&](Json& e) {
e["outSince"] = Platform::LocalTime("%Y-%m-%dT%H:%M:%S", NowOr(now)); e["outSince"] = Stamp(NowOr(now));
e["outReason"] = Dyn::Slice(reason, 0, 160); e["outReason"] = Dyn::Slice(reason, 0, 160);
return true;
}, now); }, now);
} }
Json Ledger::GrantEntry(const std::string& seatId, double at) const {
using namespace std::chrono;
const std::string cycle = CycleStart(at, Day(seatId));
const std::string weekAgo = Iso(year_month_day(sys_days(LocalDate(at)) - std::chrono::days(7)));
const std::string start = cycle >= weekAgo ? cycle : Iso(LocalDate(at));
return {{"cycle", start}, {"spentUsd", 0.0}, {"procs", 0}, {"outSince", nullptr}, {"outReason", nullptr}, {"grantSeen", Stamp(at)}};
}
std::string Ledger::Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional<double> now) {
if (state != "ok" && state != "out") return {};
const double at = NowOr(now);
// A mark made before the probe started, by instant to the second the
// stamps hold; one made since may postdate what the probe read, and
// one that cannot be read counts as made since.
const double started = std::floor(probedAt);
auto before = [&](const Json& stamp) {
const std::optional<double> instant = Instant(Dyn::Str(stamp));
return instant && *instant < started;
};
const Json fresh = GrantEntry(seatId, at);
std::string event;
// Decided on the file's entry under the lock, not on this process's
// copy: another driver may have recorded the grant and spent on it,
// or the credit error, since this copy was read.
// An OpenRouter key (or<n>) is prepaid: its lift is no grant, no new
// cycle - the mark goes, the cycle's spend stays.
const bool openRouter = seatId.starts_with("or");
Update(seatId, [&](Json& e) {
const Json& outSince = Dyn::Get(e, "outSince");
if (state == "ok") {
if (!Dyn::Truthy(outSince) || !before(outSince)) return false;
if (openRouter) {
e["outSince"] = nullptr;
e["outReason"] = nullptr;
e["grantSeen"] = Stamp(at);
event = "credit-back";
} else {
e = fresh;
event = "new-grant";
}
return true;
}
const Json& seen = Dyn::Get(e, "grantSeen");
if (Dyn::Truthy(outSince) || (Dyn::Truthy(seen) && !before(seen))) return false;
e["outSince"] = Stamp(at);
e["outReason"] = openRouter ? "key limit reached" : "credit balance too low";
event = "credit-gone";
return true;
}, at);
return event;
}
void Ledger::Save() { void Ledger::Save() {
try { try {
Platform::WriteAtomic(path, DumpFile(data), 0600); Platform::WriteAtomic(path, DumpFile(data), 0600);
@ -319,6 +480,10 @@ namespace Crafter::Review::SeatPool {
return data; return data;
} }
const Json& Ledger::ApiConfig() const {
return apiConfig;
}
const fs::path& Ledger::Path() const { const fs::path& Ledger::Path() const {
return path; return path;
} }
@ -436,7 +601,7 @@ namespace Crafter::Review::SeatPool {
return env; return env;
} }
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig) { std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view specText, Ledger* ledger, const Json& apiConfig, std::optional<double> now) {
const std::map<std::string, std::string>& merged = env; const std::map<std::string, std::string>& merged = env;
std::vector<std::pair<std::string, Json>> pool; 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, key] : Seats::ApiKeysFromEnv(merged)) pool.emplace_back(std::format("api{}", n), NewSeat(std::format("api{}", n), "api", key));
@ -482,18 +647,31 @@ namespace Crafter::Review::SeatPool {
if (std::ranges::any_of(seats, IsOpenRouter) && !std::ranges::all_of(seats, IsOpenRouter)) { if (std::ranges::any_of(seats, IsOpenRouter) && !std::ranges::all_of(seats, IsOpenRouter)) {
throw std::invalid_argument("--seats: an OpenRouter seat cannot share a pool with Anthropic seats or the local login - a model name means a different model per endpoint"); throw std::invalid_argument("--seats: an OpenRouter seat cannot share a pool with Anthropic seats or the local login - a model name means a different model per endpoint");
} }
// An API seat's cycle starts on its grant day (2026-10-17): the day
// its organization receives the plan's credits, the Console's
// Promotional-credits expiry minus three days.
const std::map<std::int32_t, std::int32_t> grantDays = Seats::ApiGrantDays(env);
for (Json& s : seats) {
if (Kind(s) != "api") continue;
const auto found = grantDays.find(static_cast<std::int32_t>(Dyn::ParseInt(Dyn::Slice(Id(s), 3)).value_or(0)));
const std::int32_t day = found != grantDays.end() ? found->second : (Dyn::Truthy(apiConfig) ? static_cast<std::int32_t>(Int(AsDouble(Dyn::At(apiConfig, "cycleDay")))) : 1);
s["grantDay"] = day;
if (ledger) ledger->SetDay(Id(s), day);
}
if (ledger) { if (ledger) {
const double now = Platform::Now(); const double at = NowOr(now);
for (Json& s : seats) { for (Json& s : seats) {
if (!IsPaid(s)) continue; if (!IsPaid(s)) continue;
const Json& e = ledger->Entry(Id(s), now); const Json& e = ledger->Entry(Id(s), at);
s["spentUsd"] = AsDouble(Dyn::At(e, "spentUsd")); s["spentUsd"] = AsDouble(Dyn::At(e, "spentUsd"));
if (Dyn::Truthy(Dyn::Get(e, "outSince"))) { const double budget = Dyn::Truthy(apiConfig) ? Budget(s, apiConfig) : std::numeric_limits<double>::infinity();
const auto [out, why] = LedgerVerdict(e, budget);
if (out) {
s["state"] = "out"; s["state"] = "out";
s["error"] = std::format("ledger: {} since {}", Dyn::StrOr(Dyn::Get(e, "outReason"), "out"), Dyn::Slice(Dyn::Str(e.at("outSince")), 0, 16)); s["error"] = why;
} else if (Dyn::Truthy(apiConfig) && AsDouble(s.at("spentUsd")) >= Budget(s, apiConfig)) { // Why the ledger holds the seat out: a credit-out is
s["state"] = "out"; // probed again, a spent budget is not.
s["error"] = std::format("ledger: ${:.2f} spent this cycle, budget ${}", AsDouble(s.at("spentUsd")), G(Budget(s, apiConfig))); s["ledgerOut"] = LedgerOut(e, budget);
} }
} }
} }
@ -541,7 +719,62 @@ namespace Crafter::Review::SeatPool {
return std::format("{}: {}{}", Dyn::Str(s.at("state")), Dyn::Join(parts, ", "), Dyn::Truthy(binding) ? std::format(" (binding {})", Dyn::Str(binding)) : std::string()); return std::format("{}: {}{}", Dyn::Str(s.at("state")), Dyn::Join(parts, ", "), Dyn::Truthy(binding) ? std::format(" (binding {})", Dyn::Str(binding)) : std::string());
} }
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models, const Log& log, const ProbeFunction& probe) { namespace {
// A paid key after its probe (started at probedAt): the seat takes
// the spend the file holds, every driver's; a lift puts a held-out
// seat back (an API key's with its cycle restarted), a credit error
// recorded since the probe started stands over its answer, the credit
// error takes it out (an API key's with the ledger's record and the
// next credits' date in the reason), an unreadable probe leaves a
// held-out seat held out.
void AfterPaidProbe(Json& s, const Json& held, Ledger* ledger, double probedAt, double now, const Log& log) {
const std::string event = ledger ? ledger->Reconcile(Id(s), s.at("state"), probedAt, now) : std::string();
const Json entry = ledger ? ledger->Entry(Id(s), now) : Json::object();
if (ledger) s["spentUsd"] = Num(Dyn::Get(entry, "spentUsd"));
if (event == "new-grant") {
s["ledgerOut"] = nullptr;
s["error"] = nullptr;
log(std::format("seat {}: the key answers again - new credits; the ledger cycle restarts (from {})", Id(s), Dyn::Str(entry.at("cycle"))));
} else if (event == "credit-back") {
s["ledgerOut"] = nullptr;
s["error"] = nullptr;
log(std::format("seat {}: the key's limit has room again - back, its cycle's spend kept", Id(s)));
} else if (s.at("state") == "ok" && Dyn::Truthy(Dyn::Get(entry, "outSince"))) {
s["state"] = "out";
s["ledgerOut"] = "credit";
s["error"] = LedgerVerdict(entry, std::numeric_limits<double>::infinity()).second;
} else if (s.at("state") == "ok") {
s["ledgerOut"] = nullptr;
} else if (s.at("state") == "out" && IsOpenRouter(s)) {
// The probe's error says why.
s["ledgerOut"] = "credit";
} else if (s.at("state") == "out") {
const Json e = ledger ? ledger->Entry(Id(s), now) : Json::object();
const Json& outSince = Dyn::Get(e, "outSince");
const Json& day = Dyn::Get(s, "grantDay");
s["ledgerOut"] = "credit";
s["error"] = std::format("credit balance too low{}; next credits due {}", Dyn::Truthy(outSince) ? std::format(" since {}", Dyn::Slice(Dyn::Str(outSince), 0, 16)) : std::string(),
NextGrant(now, Dyn::Truthy(day) ? static_cast<std::int32_t>(Int(AsDouble(day))) : 1, outSince));
} else if (s.at("state") == "unknown" && Dyn::Truthy(held)) {
s["state"] = "out";
s["error"] = std::format("{} (probe unreadable: {})", Dyn::Str(held), Dyn::Str(Dyn::Get(s, "error")));
}
// A key that answers with its cycle's budget spent is out on that
// budget, as LoadSeats holds such a seat out: an OpenRouter key's
// lift keeps its spend, and another driver may have spent on a
// grant it saw. Left ok, the seat would be neither placed nor out.
if (ledger && s.at("state") == "ok") {
if (const auto [out, why] = LedgerVerdict(entry, Budget(s, ledger->ApiConfig())); out) {
s["state"] = "out";
s["ledgerOut"] = "budget";
s["error"] = why;
}
}
}
}
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models, const Log& log, const ProbeFunction& probe, Ledger* ledger,
std::optional<double> now, const OpenRouterProbeFunction& probeKey) {
const ProbeFunction run = probe ? probe : ProbeFunction([](std::int32_t seat, const std::string& token, const std::string& model, double t, const std::string& bin, bool local, bool api) { const ProbeFunction run = probe ? probe : ProbeFunction([](std::int32_t seat, const std::string& token, const std::string& model, double t, const std::string& bin, bool local, bool api) {
return Seats::ProbeSeat(seat, token, {.model = model, .timeoutS = t, .claudeBin = bin, .local = local, .api = api}); return Seats::ProbeSeat(seat, token, {.model = model, .timeoutS = t, .claudeBin = bin, .local = local, .api = api});
}); });
@ -555,7 +788,7 @@ namespace Crafter::Review::SeatPool {
break; break;
} }
} }
if (std::ranges::any_of(seats, IsOpenRouter)) { if (!models.empty() && std::ranges::any_of(seats, IsOpenRouter)) {
const Json prices = FetchOpenRouterPrices(models, log); const Json prices = FetchOpenRouterPrices(models, log);
for (auto it = prices.begin(); it != prices.end(); ++it) { for (auto it = prices.begin(); it != prices.end(); ++it) {
const Json& p = it.value(); const Json& p = it.value();
@ -569,22 +802,33 @@ namespace Crafter::Review::SeatPool {
} }
const std::string lensModel = fable.value_or(std::string(Seats::ProbeModel)); const std::string lensModel = fable.value_or(std::string(Seats::ProbeModel));
for (Json& s : seats) { for (Json& s : seats) {
if (s.at("state") == "out") continue;
const std::string kind = Kind(s); const std::string kind = Kind(s);
if (s.at("state") == "out" && !(IsPaid(s) && Dyn::Get(s, "ledgerOut") == "credit")) continue;
const std::string id = Id(s); const std::string id = Id(s);
const std::string secret = Dyn::StrOr(Dyn::Get(s, "secret"), ""); const std::string secret = Dyn::StrOr(Dyn::Get(s, "secret"), "");
if (kind == "openrouter") { if (kind == "openrouter") {
const Json r = ProbeOpenRouter(secret, timeout); // The ledger's verdict before the probe.
const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json();
const double probedAt = NowOr(now);
const Json r = probeKey ? probeKey(secret, timeout) : ProbeOpenRouter(secret, timeout);
ApplyProbe(s, r); ApplyProbe(s, r);
s["remainingUsd"] = Dyn::Get(r, "remaining"); s["remainingUsd"] = Dyn::Get(r, "remaining");
AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log);
log(std::format("seat {} ({}): {}", id, kind, Describe(s))); log(std::format("seat {} ({}): {}", id, kind, Describe(s)));
continue; continue;
} }
const std::int32_t n = kind == "api" ? static_cast<std::int32_t>(Dyn::ParseInt(Dyn::Slice(id, 3)).value_or(0)) : (kind == "local" ? 0 : static_cast<std::int32_t>(Dyn::ParseInt(id).value_or(0))); const std::int32_t n = kind == "api" ? static_cast<std::int32_t>(Dyn::ParseInt(Dyn::Slice(id, 3)).value_or(0)) : (kind == "local" ? 0 : static_cast<std::int32_t>(Dyn::ParseInt(id).value_or(0)));
Json r; Json r;
if (kind == "api") { if (kind == "api") {
r = run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true); // The ledger's verdict before the probe.
} else if (kind == "local") { const Json held = s.at("state") == "out" ? Dyn::Get(s, "error") : Json();
const double probedAt = NowOr(now);
ApplyProbe(s, run(n, secret, std::string(Seats::ProbeModel), timeout, claudeBin, false, true));
AfterPaidProbe(s, held, ledger, probedAt, NowOr(now), log);
log(std::format("seat {} ({}): {}", id, kind, Describe(s)));
continue;
}
if (kind == "local") {
r = run(n, "", lensModel, timeout, claudeBin, true, false); r = run(n, "", lensModel, timeout, claudeBin, true, false);
} else { } else {
r = run(n, secret, lensModel, timeout, claudeBin, false, false); r = run(n, secret, lensModel, timeout, claudeBin, false, false);

View file

@ -298,6 +298,20 @@ namespace Crafter::Review::Seats {
return out; return out;
} }
std::map<std::int32_t, std::int32_t> ApiGrantDays(const std::map<std::string, std::string>& env) {
std::map<std::int32_t, std::int32_t> out;
for (std::int32_t i = 1; i <= MaxSeats; ++i) {
// Decimal digits of any script; past 31 the value stays out of
// range.
const std::u32string value = Dyn::Decode(Dyn::Strip(EnvOf(env, std::format("ANTHROPIC_API_KEY{}_GRANT_DAY", i))));
if (value.empty() || !std::ranges::all_of(value, [](char32_t c) { return Dyn::DecimalValue(c) >= 0; })) continue;
std::int32_t day = 0;
for (const char32_t c : value) day = std::min(day * 10 + Dyn::DecimalValue(c), 32);
if (day >= 1 && day <= 31) out[i] = day;
}
return out;
}
std::vector<std::pair<std::int32_t, std::string>> OpenRouterKeysFromEnv(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) {
std::vector<std::pair<std::int32_t, std::string>> out; std::vector<std::pair<std::int32_t, std::string>> out;
for (std::int32_t i = 1; i <= MaxSeats; ++i) { for (std::int32_t i = 1; i <= MaxSeats; ++i) {
@ -341,7 +355,9 @@ namespace Crafter::Review::Seats {
for (const auto& [name, value] : DotenvLines(text)) { 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")}) { 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; if (!name.starts_with(stem)) continue;
const std::string_view rest = std::string_view(name).substr(stem.size()); std::string_view rest = std::string_view(name).substr(stem.size());
// ANTHROPIC_API_KEY<n>_GRANT_DAY: the seat's grant day.
if (stem == "ANTHROPIC_API_KEY" && rest.size() == 11 && rest.ends_with("_GRANT_DAY")) rest = rest.substr(0, 1);
const bool digit = rest.size() == 1 && rest[0] >= '1' && rest[0] <= '6'; const bool digit = rest.size() == 1 && rest[0] >= '1' && rest[0] <= '6';
if (!rest.empty() && !digit) break; if (!rest.empty() && !digit) break;
if (stem == "ANTHROPIC_API_KEY" && !digit) break; if (stem == "ANTHROPIC_API_KEY" && !digit) break;
@ -980,26 +996,54 @@ namespace Crafter::Review::Seats {
return Dyn::Join(lines, "\n"); return Dyn::Join(lines, "\n");
} }
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, const Json& board, const std::map<std::int32_t, std::string>& keys) { std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, double now, const Json& board, const std::map<std::int32_t, std::string>& keys) {
std::vector<std::vector<std::string>> rows = {{"seat", "state", "driver spend this cycle", "procs", "cycle from", "ledger: out", "in flight"}}; const std::string today = Platform::LocalTime("%Y-%m-%d", now);
// "17 Sept"
auto date = [](const std::string& iso) {
return std::format("{} {}", std::stoi(iso.substr(8, 2)), Months[static_cast<std::size_t>(std::stoi(iso.substr(5, 2)) - 1)]);
};
std::vector<std::vector<std::string>> rows = {{"seat", "probe", "driver", "driver spend this cycle", "procs", "cycle from", "grant day", "next credits", "in flight"}};
std::vector<std::string> notes;
for (const Json& s : SortedBySeat(apiSeats)) { for (const Json& s : SortedBySeat(apiSeats)) {
const std::string id = std::format("api{}", Dyn::Str(Dyn::At(s, "seat")));
const Json& ledger = Dyn::Or(Dyn::Get(s, "ledger"), Dyn::EmptyDict()); const Json& ledger = Dyn::Or(Dyn::Get(s, "ledger"), Dyn::EmptyDict());
const double spent = NumberOr0(Dyn::Get(ledger, "spentUsd")); const double spent = NumberOr0(Dyn::Get(ledger, "spentUsd"));
const std::string state = Dyn::Str(Dyn::At(s, "state")) + (Dyn::Truthy(Dyn::Get(s, "error")) && Dyn::At(s, "state") != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string()); const std::string state = Dyn::Str(Dyn::At(s, "state"));
const Json& outSince = Dyn::Get(ledger, "outSince"); const std::string probe = state + (Dyn::Truthy(Dyn::Get(s, "error")) && state != "ok" ? std::format(" ({})", Dyn::Str(s.at("error"))) : std::string());
// The driver's verdict, SeatPool::LedgerOut's.
const Json& ledgerOut = Dyn::Get(s, "ledgerOut");
const bool out = !ledgerOut.is_null();
const std::string driver = !out ? std::string("ok")
: ledgerOut == "credit" ? std::format("out (credit since {})", Dyn::Replace(Dyn::Slice(Dyn::Str(Dyn::Get(ledger, "outSince")), 0, 16), "T", " "))
: std::string("out (budget spent)");
const std::string due = Dyn::StrOr(Dyn::Get(s, "nextCredits"), "");
const std::string dueText = due.size() < 10 ? std::string("—") : due == today ? std::string("today") : due < today ? std::format("overdue, {}", date(due)) : date(due);
const std::int64_t n = Inflight(board, keys, s); const std::int64_t n = Inflight(board, keys, s);
rows.push_back({ rows.push_back({id, probe, driver, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"),
std::format("api{}", Dyn::Str(Dyn::At(s, "seat"))), state, std::format("${:.2f} of ${}", spent, Fmt(apiConfig.at("budgetUsd"))), Dyn::StrOr(Dyn::Get(ledger, "procs"), "0"), Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::StrOr(Dyn::Get(s, "grantDay"), "—"), dueText,
Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—"), Dyn::Truthy(outSince) ? std::format("since {}", Dyn::Replace(Dyn::Slice(Dyn::Str(outSince), 0, 16), "T", " ")) : std::string("—"), n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—")});
n ? std::format("{} of {}", n, Dyn::Str(apiConfig.at("concurrency"))) : std::string("—"), const Json& event = Dyn::Get(s, "event");
}); if (event == "new-grant") {
notes.push_back(std::format("{}: the key answers again - new credits; the ledger cycle restarted (from {})", id, Dyn::StrOr(Dyn::Get(ledger, "cycle"), "—")));
} else if (event == "credit-gone") {
notes.push_back(std::format("{}: the credit error, now recorded in the ledger", id));
} else if (state == "ok" && ledgerOut == "credit") {
notes.push_back(std::format("{}: the key answered, but the credit error was recorded since the probe started - the ledger's verdict stands", id));
} else if (state == "ok" && out) {
notes.push_back(std::format("{}: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)", id));
} else if (state != "ok" && state != "out" && out) {
notes.push_back(std::format("{}: probe {} - the ledger's verdict stands", id, state));
} }
return Dyn::Join(std::vector<std::string>{ }
RenderTable(rows), std::vector<std::string> lines = {RenderTable(rows)};
std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · cycle day {} · {} processes per seat · placed at +${} each", lines.insert(lines.end(), notes.begin(), notes.end());
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"))), lines.push_back(std::format("{} seats run first (REVIEW_GATES_SEAT_ORDER={}) · budget ${} per seat per cycle · a cycle starts on the seat's grant day (ANTHROPIC_API_KEY<n>_GRANT_DAY, else "
"the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent", "REVIEW_GATES_API_CYCLE_DAY={}) · {} processes per seat · placed at +${} each",
}, "\n"); 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"))));
lines.emplace_back("the balance itself is in the Console (Settings > Billing > Promotional credits); the ledger counts only what the review drivers spent, and \"overdue\" means the grant day "
"is wrong");
return Dyn::Join(lines, "\n");
} }
std::string RenderDrivers(const Json& board, double now, const Zone& zone) { std::string RenderDrivers(const Json& board, double now, const Zone& zone) {

View file

@ -36,10 +36,9 @@ export namespace Crafter::Review::Config {
// The config for a dump: the database URL with its password masked, no // The config for a dump: the database URL with its password masked, no
// `_` bookkeeping keys. // `_` bookkeeping keys.
Json Redacted(const Json& config); Json Redacted(const Json& config);
// The emails the freeze accepts: the configured list, else the // The emails the freeze accepts as author and committer: the configured
// repository's git identity. // list; none (null, or no `authors` key) - the freeze checks neither.
std::vector<std::string> Authors(const Json& config, const std::filesystem::path& repo); std::optional<std::vector<std::string>> Authors(const Json& config);
std::string GitUserEmail(const std::filesystem::path& repo);
// The lens files no mode lists (what runs nowhere: the slots and init). // The lens files no mode lists (what runs nowhere: the slots and init).
std::vector<std::string> LensesOff(const Json& config); std::vector<std::string> LensesOff(const Json& config);
// {lens file: environment} for every lens of the table, as the config // {lens file: environment} for every lens of the table, as the config

View file

@ -56,7 +56,8 @@ export namespace Crafter::Review::Freeze {
bool replace = false; bool replace = false;
bool worktree = false; bool worktree = false;
bool requireSigned = false; bool requireSigned = false;
std::vector<std::string> authors; // The emails author and committer must be among; none: not checked.
std::optional<std::vector<std::string>> authors;
std::string aiTrailers = "forbid"; std::string aiTrailers = "forbid";
std::vector<std::string> allowDirty; std::vector<std::string> allowDirty;
std::optional<std::string> configBase; std::optional<std::string> configBase;

View file

@ -133,28 +133,66 @@ export namespace Crafter::Review::SeatPool {
// The other drivers' load on a seat as the last board fold left it. // The other drivers' load on a seat as the last board fold left it.
Json Foreign(const Json& seat); Json Foreign(const Json& seat);
std::filesystem::path LedgerPath(const std::optional<std::filesystem::path>& scratchRoot = std::nullopt); std::filesystem::path LedgerPath(const std::optional<std::filesystem::path>& scratchRoot = std::nullopt);
// The most recent date with that day of the month, as YYYY-MM-DD. // The most recent date with that day of the month, as YYYY-MM-DD; a day
// past a month's end is its last day.
std::string CycleStart(double now, std::int32_t day); std::string CycleStart(double now, std::int32_t day);
// When a seat's next credits are due, as YYYY-MM-DD: the grant day after
// the current cycle's, or the current cycle's own when the seat went out
// on credit before it (outSince) and is still out - due today, or overdue:
// the grant day is wrong.
std::string NextGrant(double now, std::int32_t day, const Json& outSince = nullptr);
// The driver's verdict on a paid seat from its ledger entry alone: out
// with the reason, or ok with none.
std::pair<bool, std::string> LedgerVerdict(const Json& entry, double budget);
// Why that verdict holds a seat out: "credit" (the key answered the
// credit error), "budget", or null when it does not.
Json LedgerOut(const Json& entry, double budget);
// What the driver spent on each API seat this cycle, and whether the key // What the driver spent on each API seat this cycle, and whether the key
// answered the credit error; one file for every driver, each write a delta // answered the credit error; one file for every driver, each write a delta
// applied under its lock. // applied under its lock. A seat's cycle starts on its grant day (days,
// else the config's cycleDay).
class Ledger { class Ledger {
public: public:
Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object()); Ledger(std::filesystem::path path, Json apiConfig, Json data = Json::object(), std::map<std::string, std::int32_t> days = {});
static Ledger Load(const std::filesystem::path& path, const Json& apiConfig); static Ledger Load(const std::filesystem::path& path, const Json& apiConfig, std::map<std::string, std::int32_t> days = {});
std::int32_t Day(const std::string& seatId) const;
void SetDay(const std::string& seatId, std::int32_t day);
// The seat's entry of the current cycle. A new cycle starts the spend
// over; a credit-out carries over it - only a probe that sees the key
// answer again lifts it (Reconcile) - with the time the key was last
// seen answering again. This process's copy: only an Update
// re-reads the file.
Json& Entry(const std::string& seatId, std::optional<double> now = std::nullopt); Json& Entry(const std::string& seatId, std::optional<double> now = std::nullopt);
Json Add(const std::string& seatId, double usd, std::optional<double> now = std::nullopt); Json Add(const std::string& seatId, double usd, std::optional<double> now = std::nullopt);
Json MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now = std::nullopt); Json MarkOut(const std::string& seatId, std::string_view reason, std::optional<double> now = std::nullopt);
// The probe's reading of an API key (probedAt: when the probe
// started), and the ledger follows it, decided under the lock on the
// entry as the file has it: "ok" on a key held out on credit since
// before the probe started lifts the mark - an API key's is new
// credits, GrantEntry's entry, "new-grant"; an OpenRouter key's keeps
// its cycle's spend, "credit-back" -; the credit error on a key the file
// has as fine, no grant seen since the probe started, is recorded,
// "credit-gone". Anything else writes nothing, "": a mark another
// driver made after the probe started may postdate its reading, and
// a grant another driver already recorded keeps its spend.
std::string Reconcile(const std::string& seatId, const Json& state, double probedAt, std::optional<double> now = std::nullopt);
void Save(); void Save();
const Json& Data() const; const Json& Data() const;
const std::filesystem::path& Path() const; const std::filesystem::path& Path() const;
// The config the ledger was loaded with (ApiConfig's).
const Json& ApiConfig() const;
private: private:
std::filesystem::path path; std::filesystem::path path;
Json apiConfig; Json apiConfig;
Json data; Json data;
Json Update(const std::string& seatId, const std::function<void(Json&)>& change, std::optional<double> now); std::map<std::string, std::int32_t> days;
// change returns whether to write the file.
Json Update(const std::string& seatId, const std::function<bool(Json&)>& change, std::optional<double> now);
// A grant's entry: the cycle from the seat's grant day when that is
// within the week, else from today; no spend, no out mark.
Json GrantEntry(const std::string& seatId, double at) const;
}; };
Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret); Json NewSeat(const std::string& id, const std::string& kind, const std::string& secret);
@ -163,12 +201,23 @@ export namespace Crafter::Review::SeatPool {
// local), local | api | oauth | openrouter, // local), local | api | oauth | openrouter,
// or a list such as api1,1,2,local. std::invalid_argument with a // 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 // ValueError message for a seat that is not configured, or OpenRouter
// mixed with the rest. // mixed with the rest. An API seat gets its grantDay
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr); // (ANTHROPIC_API_KEY<n>_GRANT_DAY of env, else the cycleDay) into the
// seat and the ledger; a seat the ledger holds out is out, ledgerOut
// "credit" or "budget".
std::vector<Json> LoadSeats(const std::map<std::string, std::string>& env, std::string_view spec, Ledger* ledger = nullptr, const Json& apiConfig = nullptr,
std::optional<double> now = std::nullopt);
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)>; 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. // An OpenRouter key's probe, ProbeOpenRouter's shape.
using OpenRouterProbeFunction = std::function<Json(const std::string& secret, double timeout)>;
// One probe per seat before the run; a seat already out is skipped,
// except a paid key the ledger holds out on credit: probed again
// (Reconcile). A probed key that answers with its cycle's budget spent
// (an OpenRouter key's lift keeps its spend) is out on that budget. The
// OpenRouter price catalogue is fetched when there are models to price.
void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models, void Probe(std::vector<Json>& seats, const std::string& claudeBin, double timeout, const std::vector<std::string>& models,
const std::function<void(const std::string&)>& log, const ProbeFunction& probe = {}); const std::function<void(const std::string&)>& log, const ProbeFunction& probe = {}, Ledger* ledger = nullptr, std::optional<double> now = std::nullopt,
const OpenRouterProbeFunction& probeKey = {});
void ApplyProbe(Json& seat, const Json& result); void ApplyProbe(Json& seat, const Json& result);
std::string Describe(const Json& seat); std::string Describe(const Json& seat);

View file

@ -46,13 +46,18 @@ export namespace Crafter::Review::Seats {
Json ConfigFromEnv(const std::map<std::string, std::string>& env); Json ConfigFromEnv(const std::map<std::string, std::string>& env);
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>> 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>> ApiKeysFromEnv(const std::map<std::string, std::string>& env);
// {n: day}: ANTHROPIC_API_KEY<n>_GRANT_DAY, the day of the month seat n's
// organization receives its plan's credits (the Console's
// Promotional-credits expiry minus three days), 1..31; anything else is
// ignored.
std::map<std::int32_t, std::int32_t> ApiGrantDays(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); std::vector<std::pair<std::int32_t, std::string>> OpenRouterKeysFromEnv(const std::map<std::string, std::string>& env);
// Every `[export ]NAME=value` line of a dotenv text, in order: blanks // Every `[export ]NAME=value` line of a dotenv text, in order: blanks
// around the `=` and the value dropped, one pair of matching quotes // around the `=` and the value dropped, one pair of matching quotes
// stripped; anything else skipped. // stripped; anything else skipped.
std::vector<std::pair<std::string, std::string>> DotenvLines(std::string_view text); 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 // Only the seat lines of those: the OAuth tokens, the numbered API keys
// and the OpenRouter keys; everything else ignored. // with their grant days and the OpenRouter keys; everything else ignored.
std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text); std::map<std::string, std::string> SeatTokensFromEnvFile(std::string_view text);
// The REVIEW_GATES_* lines of a dotenv file (quotes and `export` // The REVIEW_GATES_* lines of a dotenv file (quotes and `export`
// stripped). SeatPool::SettingsOf lays the placement settings among them // stripped). SeatPool::SettingsOf lays the placement settings among them
@ -142,7 +147,11 @@ export namespace Crafter::Review::Seats {
std::string RenderTable(const std::vector<std::vector<std::string>>& rows); std::string RenderTable(const std::vector<std::vector<std::string>>& rows);
std::string RenderStatusReport(const std::vector<Json>& seats, const Json& config, double now, const Zone& zone, const Json& board = nullptr, std::string RenderStatusReport(const std::vector<Json>& seats, const Json& config, double now, const Zone& zone, const Json& board = nullptr,
const std::map<std::int32_t, std::string>& keys = {}); const std::map<std::int32_t, std::string>& keys = {});
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, const Json& board = nullptr, // API-credit seats: the probe's verdict beside the driver's (its ledger
// against the budget), the cycle, the grant day and when the next
// credits are due - each seat's ledger, ledgerOut, grantDay, nextCredits
// and the probe's ledger event as the seats command set them.
std::string RenderApiReport(const std::vector<Json>& apiSeats, const Json& apiConfig, double now, const Json& board = nullptr,
const std::map<std::int32_t, std::string>& keys = {}); const std::map<std::int32_t, std::string>& keys = {});
std::string RenderDrivers(const Json& board, double now, const Zone& zone); std::string RenderDrivers(const Json& board, double now, const Zone& zone);
} }

View file

@ -27,6 +27,27 @@
</provides> </provides>
<content_rating type="oars-1.1"/> <content_rating type="oars-1.1"/>
<releases> <releases>
<release version="1.1.0" date="2026-11-02">
<description>
<p>New</p>
<ul>
<li>Each API seat's credit cycle starts on its own grant day, <code>ANTHROPIC_API_KEY&lt;n&gt;_GRANT_DAY</code> in the seats file. A seat out of credit stays out across the rollover until a probe sees its key answer again, and <code>seats</code> shows each API seat's grant day, when its next credits are due and whether the driver holds it out.</li>
<li>Leaving <code>authors</code> out of the config turns the freeze's author and committer check off, where it used to check <code>git config user.email</code>. An empty list is a config error.</li>
</ul>
<p>Fixes</p>
<ul>
<li>An OpenRouter key that ran out of credit is probed again at every run instead of waiting for the month's turn.</li>
</ul>
</description>
</release>
<release version="1.0.1" date="2026-11-02">
<description>
<p>Fixes</p>
<ul>
<li>The <code>.flatpakref</code> attached to a release carries the Catcrafts repository's key, so installing from that file works; 1.0.0's had a placeholder.</li>
</ul>
</description>
</release>
<release version="1.0.0" date="2026-11-02"> <release version="1.0.0" date="2026-11-02">
<description> <description>
<p>New</p> <p>New</p>

View file

@ -136,7 +136,7 @@ delta `@{upstream}..HEAD`, the rule for a repository that pushes to its
own trunk; `base`: always the merge-base with `base`, the whole pull request own trunk; `base`: always the merge-base with `base`, the whole pull request
for a repository whose branches become PRs — their commits must be linear, for a repository whose branches become PRs — their commits must be linear,
rebased, never a merge of the integration branch), `authors` (the emails the rebased, never a merge of the integration branch), `authors` (the emails the
freeze accepts; null = `git config user.email`), `aiTrailers` (`forbid` or freeze accepts; null = neither author nor committer is checked), `aiTrailers` (`forbid` or
`allow`), `requireSigned`, `allowDirty` (tracked `allow`), `requireSigned`, `allowDirty` (tracked
files the local stack rewrites in place while the lenses run), `gate0` (the files the local stack rewrites in place while the lenses run), `gate0` (the
commands Gate 0 runs by hand, for the skill), `modes` (`modes.<name>`: the commands Gate 0 runs by hand, for the skill), `modes` (`modes.<name>`: the
@ -209,8 +209,9 @@ default), `notes`.
output where a commit message's "tested" claim can point at it output where a commit message's "tested" claim can point at it
(`--context-paths`). (`--context-paths`).
2. **Freeze** — `freeze` checks, all of them, before it 2. **Freeze** — `freeze` checks, all of them, before it
exports anything: author AND committer are in `authors`; AI attribution exports anything: author AND committer are in `authors` (when it is
trailers (`Assisted-by:`, `Co-Authored-By:`) by the `aiTrailers` rule; the set); AI attribution trailers (`Assisted-by:`, `Co-Authored-By:`) by the
`aiTrailers` rule; the
tracked tree is clean (the `allowDirty` paths aside, warned); no merge tracked tree is clean (the `allowDirty` paths aside, warned); no merge
commit in the range (`git format-patch` drops them silently, so the commit in the range (`git format-patch` drops them silently, so the
artifact would not be the push); the range is a fast-forward of artifact would not be the push); the range is a fast-forward of

View file

@ -1,6 +1,6 @@
--- ---
name: prepush name: prepush
description: Pre-push gates for a repository set up for crafter-review (`crafter-review init`). Runs the pipeline's procedure end to end — Gate 0 build/tests + freeze (identity, trailers by the config's rule, clean tree, no merges, fast-forward), the application and the database under test brought up from the frozen tree, the driver run of the lenses the config turns on - every lens is off until it does (repo-better-solution → repo-functional with the Playwright MCP → repo-bug-prover and repo-security side by side → repo-facts, the commit truth) via crafter-review, the fix/rerun loop, the owner's approval of the hash, verify, push. Nothing is pushed unreviewed or unapproved. description: Pre-push gates for a repository set up for crafter-review (`crafter-review init`). Runs the pipeline's procedure end to end — Gate 0 build/tests + freeze (identity when `authors` is set, trailers by the config's rule, clean tree, no merges, fast-forward), the application and the database under test brought up from the frozen tree, the driver run of the lenses the config turns on - every lens is off until it does (repo-better-solution → repo-functional with the Playwright MCP → repo-bug-prover and repo-security side by side → repo-facts, the commit truth) via crafter-review, the fix/rerun loop, the owner's approval of the hash, verify, push. Nothing is pushed unreviewed or unapproved.
--- ---
Follow the pipeline's procedure, `prepush.md`, EXACTLY — read it now, in Follow the pipeline's procedure, `prepush.md`, EXACTLY — read it now, in

View file

@ -37,6 +37,12 @@ model=
prev= prev=
for a in "$@"; do [ "$prev" = --model ] && model=$a; prev=$a; done for a in "$@"; do [ "$prev" = --model ] && model=$a; prev=$a; done
cat > /dev/null cat > /dev/null
# A Console key whose credit is gone, answering after two seconds.
if [ "$ANTHROPIC_API_KEY" = sk-ant-api-spent ]; then
sleep 2
echo '{"type":"result","subtype":"success","is_error":true,"result":"Credit balance is too low","total_cost_usd":0}'
exit 1
fi
now=$(date +%s) now=$(date +%s)
fable= fable=
[ "$model" = claude-fable-5-1 ] && fable=",\"seven_day_overage_included\":{\"utilization\":0.3,\"resetsAt\":$((now+172800))}" [ "$model" = claude-fable-5-1 ] && fable=",\"seven_day_overage_included\":{\"utilization\":0.3,\"resetsAt\":$((now+172800))}"
@ -340,7 +346,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
// --env (a prefix of --env-file) names the file for the tokens and // --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 // the settings alike, with ./.env in the way (the API seat's section
// is where the order shows). // 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"); Write(base / "seats" / "other.env", "CLAUDE_CODE_OAUTH_TOKEN2=tok-two\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=16\nREVIEW_GATES_SEAT_ORDER=oauth-first\n");
const Process::Captured other = Self({"seats", "--no-fable", "--env", "other.env"}, base / "seats", withFake); 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 // An application's .env (no seat line, no setting) is passed over for
// the machine's own seats file. // the machine's own seats file.
@ -365,8 +371,8 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
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 " 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", "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.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.contains("| local | 42% |") && report.out.contains("| 1 | 42% |") && report.out.contains("| api1 | ok | 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.returnCode == 0 && other.out.starts_with("seat pool: 2 seat(s) (2, api2) from other.env, probed ") && other.out.contains("| api2 | ok | ok | $0.00 of $200") && other.out.contains("| 16 |")
&& 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") && 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())) && 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.returnCode == 0 && fromFifo.out.starts_with("seat pool: 2 seat(s) (4, api4) from seats.fifo, probed ")
@ -377,6 +383,29 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"ok","total
&& parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z") && parsed.at("eligible") == Json::array({1}) && parsed.at("next").at("seat") == 1 && Dyn::Str(parsed.at("probedAt")).ends_with("Z")
&& !(report.out + json.out).contains("tok-one") && !(report.out + json.out).contains("sk-ant-api-one"), && !(report.out + json.out).contains("tok-one") && !(report.out + json.out).contains("sk-ant-api-one"),
json.out.substr(0, 600) + json.err); json.out.substr(0, 600) + json.err);
// The credit error a probe reads is recorded in the ledger stamped
// when it is written, not at the start the probes share (1.1.0 round
// 3): the spent key answers two seconds after the command began.
Write(base / "spent" / ".env", "ANTHROPIC_API_KEY3=sk-ant-api-spent\n");
const double spentStart = Platform::Now();
const Process::Captured spent = Self({"seats", "--json"}, base / "spent", withFake);
Json spentParsed;
try {
spentParsed = Json::parse(spent.out);
} catch (const std::exception&) {
}
// The child's TZ is UTC (Env): the stamp is compared in UTC.
const auto utc = [](double epoch) {
return std::format("{:%Y-%m-%dT%H:%M:%S}", std::chrono::floor<std::chrono::seconds>(std::chrono::sys_seconds(std::chrono::seconds(static_cast<std::int64_t>(epoch)))));
};
Json spentEntry;
if (spentParsed.is_object() && spentParsed.contains("apiSeats") && !spentParsed.at("apiSeats").empty()) {
spentEntry = Dyn::Get(Dyn::Get(spentParsed.at("apiSeats").at(0), "ledger"), "outSince");
}
Check("seats: a credit error the probe read is recorded in the shared ledger stamped when written, after the probe, never at the probes' common start",
spent.returnCode == 0 && spentParsed.is_object() && spentParsed.at("apiSeats").at(0).at("event") == "credit-gone" && spentEntry.is_string()
&& Dyn::Slice(spentEntry.get<std::string>(), 0, 19) >= utc(spentStart + 2),
spent.out.substr(0, 800) + spent.err);
const Process::Captured none = Self({"seats"}, base / "plain", withFake); const Process::Captured none = Self({"seats"}, base / "plain", withFake);
const Process::Captured noFile = Self({"seats", "--env-file", "missing.env"}, base / "plain", withFake); const Process::Captured noFile = Self({"seats", "--env-file", "missing.env"}, base / "plain", withFake);
Write(base / "plain" / "bad.env", std::string("\xff\n", 2)); Write(base / "plain" / "bad.env", std::string("\xff\n", 2));

View file

@ -233,12 +233,11 @@ int main() {
&& ex.at("modes").at("commits").at("lenses").size() == 5 && ex.at("modes").at("series").at("lenses").size() == 10 && ex.at("modes").at("reply").at("lenses").size() == 5 && ex.at("modes").at("commits").at("lenses").size() == 5 && ex.at("modes").at("series").at("lenses").size() == 10 && ex.at("modes").at("reply").at("lenses").size() == 5
&& ex.at("modes").at("commits").at("effort") == "xhigh" && ex.at("modes").at("series").at("effort") == "max" && ex.at("modes").at("reply").at("effort") == "max" && ex.at("modes").at("commits").at("effort") == "xhigh" && ex.at("modes").at("series").at("effort") == "max" && ex.at("modes").at("reply").at("effort") == "max"
&& std::ranges::none_of(ex.at("lenses"), [](const Json& entry) { return entry.contains("run"); }), Show(Config::Validate(ex))); && std::ranges::none_of(ex.at("lenses"), [](const Json& entry) { return entry.contains("run"); }), Show(Config::Validate(ex)));
const fs::path repo = td / "r"; Check("authors: the configured list; null or no key - none, the freeze checks neither author nor committer (2026-11-02, not the git identity)",
fs::create_directories(repo); Config::Authors({{"authors", {"a@b.c"}}}) == std::vector<std::string>{"a@b.c"} && !Config::Authors({{"authors", nullptr}}) && !Config::Authors(Json::object())
Process::Run({"git", "-C", repo.string(), "init", "-q"}); && !Config::Authors(Config::Defaults()));
Process::Run({"git", "-C", repo.string(), "config", "user.email", "me@example.com"}); problems = bad({{"authors", Json::array()}});
Check("authors: the configured list, else git config user.email", Config::Authors({{"authors", {"a@b.c"}}}, repo) == std::vector<std::string>{"a@b.c"} Check("an empty authors list is the problem that says null turns the check off", problems.size() == 1 && problems[0].contains("empty list") && problems[0].contains("null"), Show(problems));
&& Config::Authors({{"authors", nullptr}}, repo) == std::vector<std::string>{"me@example.com"});
std::ofstream(td / "u.json", std::ios::binary) << "{}"; std::ofstream(td / "u.json", std::ios::binary) << "{}";
Check("login_fixture: the first candidate that exists", Config::LoginFixture({{"functional", {{"login", {{"fixtures", {"nope.json", "u.json"}}}}}}}, td) == "u.json" Check("login_fixture: the first candidate that exists", Config::LoginFixture({{"functional", {{"login", {{"fixtures", {"nope.json", "u.json"}}}}}}}, td) == "u.json"
&& !Config::LoginFixture({{"functional", {{"login", {{"fixtures", Json::array()}}}}}}, td)); && !Config::LoginFixture({{"functional", {{"login", {{"fixtures", Json::array()}}}}}}, td));

View file

@ -79,14 +79,18 @@ int main() {
if (options.date.empty()) options.date = "2026-10-31"; if (options.date.empty()) options.date = "2026-10-31";
return Freeze::Run(repo, out, options); return Freeze::Run(repo, out, options);
}; };
Json r = freeze({.base = base}); Json r = freeze({.base = base, .authors = std::vector<std::string> {me}});
std::string failures = Joined(r.at("failures")); std::string failures = Joined(r.at("failures"));
Check("trailer (forbid) + foreign author both named, nothing exported; the author list came from git config", !r.at("ok").get<bool>() && failures.contains("Co-Authored-By") Check("trailer (forbid) + foreign author both named, nothing exported", !r.at("ok").get<bool>() && failures.contains("Co-Authored-By")
&& failures.contains("catbot@bot.local") && !fs::exists(out) && r.at("authors") == Json::array({me}), failures); && failures.contains("catbot@bot.local") && !fs::exists(out) && r.at("authors") == Json::array({me}), failures);
r = freeze({.base = base, .aiTrailers = "allow"}); r = freeze({.base = base, .authors = std::vector<std::string> {me}, .aiTrailers = "allow"});
Check("trailer (allow): a warning, the foreign author still fails", !r.at("ok").get<bool>() && Joined(r.at("failures")).contains("catbot") Check("trailer (allow): a warning, the foreign author still fails", !r.at("ok").get<bool>() && Joined(r.at("failures")).contains("catbot")
&& !Joined(r.at("failures")).contains("Co-Authored-By") && AnyContains(r.at("warnings"), "Co-Authored-By")); && !Joined(r.at("failures")).contains("Co-Authored-By") && AnyContains(r.at("warnings"), "Co-Authored-By"));
r = freeze({.base = base, .authors = {me, "catbot@bot.local"}, .aiTrailers = "allow"}); r = freeze({.base = base});
Check("no authors (null in the config): author and committer unchecked, said in a warning - only the trailer fails", !r.at("ok").get<bool>()
&& Joined(r.at("failures")).contains("Co-Authored-By") && !Joined(r.at("failures")).contains("catbot") && r.at("authors").is_null()
&& AnyContains(r.at("warnings"), "author and committer not checked"), Joined(r.at("failures")));
r = freeze({.base = base, .authors = std::vector<std::string> {me, "catbot@bot.local"}, .aiTrailers = "allow"});
Check("both emails in authors + allow -> the range freezes (3 commits)", r.at("ok").get<bool>() && r.at("files").size() == 3, Joined(r.at("failures"))); Check("both emails in authors + allow -> the range freezes (3 commits)", r.at("ok").get<bool>() && r.at("files").size() == 3, Joined(r.at("failures")));
g({"reset", "-q", "--hard", clean}); g({"reset", "-q", "--hard", clean});
r = freeze({.base = base, .replace = true}); r = freeze({.base = base, .replace = true});

View file

@ -187,6 +187,251 @@ int main() {
if (SeatPool::CycleStart(at, day) != want) missed.push_back(std::format("{} {} -> {}", at, day, SeatPool::CycleStart(at, day))); if (SeatPool::CycleStart(at, day) != want) missed.push_back(std::format("{} {} -> {}", at, day, SeatPool::CycleStart(at, day)));
} }
Check("cycle start: this month's billing day once reached, else last month's, clamped to its length", missed.empty(), Dyn::Join(missed, "; ")); Check("cycle start: this month's billing day once reached, else last month's, clamped to its length", missed.empty(), Dyn::Join(missed, "; "));
// 2026-10-17 (the Python driver's, ported 2026-11-02): the cycle is the
// seat's grant day - the credits' own, from the Console's expiry minus
// three days - and a key that answered the credit error stays out across
// the rollover until a probe sees it answer again (then: new credits, a
// new cycle). The Python driver's vectors.
{
auto at = [](std::int32_t y, unsigned m, unsigned d) {
using namespace std::chrono;
return static_cast<double>(duration_cast<seconds>((sys_days(year_month_day(year(y), month(m), day(d))) + hours(12)).time_since_epoch()).count());
};
Check("cycle start: a day past the month's end is its last day",
SeatPool::CycleStart(at(2026, 4, 30), 31) == "2026-04-30" && SeatPool::CycleStart(at(2026, 3, 1), 31) == "2026-02-28" && SeatPool::CycleStart(at(2026, 3, 30), 28) == "2026-03-28");
constexpr double t17 = at(2026, 10, 17);
Check("next credits: the grant day after the cycle's; the cycle's own when the seat went out on credit before it",
SeatPool::NextGrant(t17, 17) == "2026-11-17" && SeatPool::NextGrant(t17, 5) == "2026-11-05" && SeatPool::NextGrant(t17, 17, "2026-10-08T20:19:06") == "2026-10-17"
&& SeatPool::NextGrant(t17, 5, "2026-10-13T22:46:02") == "2026-11-05" && SeatPool::NextGrant(at(2026, 12, 20), 31) == "2026-12-31");
Check("ledger verdict: a credit-out, the budget, else ok",
SeatPool::LedgerVerdict({{"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}, {"spentUsd", 0}}, 200)
== std::pair<bool, std::string>(true, "ledger: credit balance too low since 2026-10-08T20:19")
&& SeatPool::LedgerVerdict({{"spentUsd", 200.0}}, 200) == std::pair<bool, std::string>(true, "ledger: $200.00 spent this cycle, budget $200")
&& SeatPool::LedgerVerdict({{"spentUsd", 1}}, 200) == std::pair<bool, std::string>(false, ""));
const std::string text2 = "ANTHROPIC_API_KEY3=sk-x\nANTHROPIC_API_KEY3_GRANT_DAY=16\nANTHROPIC_API_KEY4_GRANT_DAY = 5\nANTHROPIC_API_KEY1_GRANT_DAY=32\nANTHROPIC_API_KEY2_GRANT_DAY=x\nANTHROPIC_API_KEY_GRANT_DAY=9\n";
Check("a grant day: decimal digits of any script, leading zeros of any length; anything else ignored",
Seats::ApiGrantDays({{"ANTHROPIC_API_KEY1_GRANT_DAY", "0000000016"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "\uFF11\uFF16"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "1_6"},
{"ANTHROPIC_API_KEY4_GRANT_DAY", "99999999999999999999"}, {"ANTHROPIC_API_KEY5_GRANT_DAY", "\u00B2"}, {"ANTHROPIC_API_KEY6_GRANT_DAY", "0"}})
== std::map<std::int32_t, std::int32_t> {{1, 16}, {2, 16}});
const std::map<std::string, std::string> tokens2 = Seats::SeatTokensFromEnvFile(text2);
Check("the grant day per seat from the dotenv file, 1..31 only, never a key",
tokens2 == std::map<std::string, std::string> {{"ANTHROPIC_API_KEY3", "sk-x"}, {"ANTHROPIC_API_KEY3_GRANT_DAY", "16"}, {"ANTHROPIC_API_KEY4_GRANT_DAY", "5"},
{"ANTHROPIC_API_KEY1_GRANT_DAY", "32"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "x"}}
&& Seats::ApiGrantDays(tokens2) == std::map<std::int32_t, std::int32_t> {{3, 16}, {4, 5}}
&& Seats::ApiKeysFromEnv(tokens2) == std::vector<std::pair<std::int32_t, std::string>> {{3, "sk-x"}});
SeatPool::Ledger old(td / "old.json", acfg,
{{"api1", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}},
{"api2", {{"cycle", "1999-01-01"}, {"spentUsd", 500}, {"procs", 9}, {"outSince", nullptr}, {"outReason", nullptr}}},
{"or1", {{"cycle", "1999-01-01"}, {"spentUsd", 50}, {"procs", 9}, {"outSince", "x"}, {"outReason", "y"}}}});
Check("a new cycle resets the spend; a credit-out carries over until a probe lifts it, an OpenRouter key's too (the month's turn puts nothing on it)",
old.Entry("api1").at("spentUsd") == 0 && old.Entry("api1").at("procs") == 0 && old.Entry("api1").at("outSince") == "x" && old.Entry("api2").at("outSince").is_null()
&& old.Entry("api2").at("spentUsd") == 0 && old.Entry("or1").at("outSince") == "x" && old.Entry("or1").at("outReason") == "y" && old.Entry("or1").at("spentUsd") == 0);
Check("ledger out: credit, budget, else null",
SeatPool::LedgerOut({{"outSince", "2026-10-08T20:19:06"}, {"spentUsd", 0}}, 200) == "credit" && SeatPool::LedgerOut({{"spentUsd", 200.0}}, 200) == "budget"
&& SeatPool::LedgerOut({{"spentUsd", 1}}, 200).is_null());
const fs::path daysEnv = td / "days.env";
Write(daysEnv, "ANTHROPIC_API_KEY1=sk-ant-api-one\nANTHROPIC_API_KEY1_GRANT_DAY=17\nANTHROPIC_API_KEY2=sk-ant-api-two\nANTHROPIC_API_KEY2_GRANT_DAY=0\n");
SeatPool::Ledger lg(td / "days.json", acfg,
{{"api1", {{"cycle", "2026-10-01"}, {"spentUsd", 200.18}, {"procs", 112}, {"outSince", "2026-10-08T20:19:06"}, {"outReason", "credit balance too low"}}},
{"api2", {{"cycle", "2026-09-01"}, {"spentUsd", 150.0}, {"procs", 90}, {"outSince", nullptr}, {"outReason", nullptr}}}});
std::vector<Json> ds = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api", &lg, acfg, t17);
Check("the grant day per seat from the seats file (0 is ignored: the global day), into the ledger",
Ids(ds) == "api1,api2" && ds[0].at("grantDay") == 17 && ds[1].at("grantDay") == 1 && lg.Day("api1") == 17 && lg.Day("api2") == 1, Ids(ds));
Check("rollover on the grant day: the spend starts over, a credit-out carries - the seat stays out",
ds[0].at("state") == "out" && ds[0].at("ledgerOut") == "credit" && ds[0].at("spentUsd") == 0 && lg.Entry("api1", t17).at("cycle") == "2026-10-17"
&& lg.Entry("api1", t17).at("procs") == 0 && ds[0].at("error").get<std::string>().contains("since 2026-10-08"),
DumpSpaced(ds[0]));
Check("...a budget-out resets with its cycle",
ds[1].at("state") == "unknown" && Dyn::Get(ds[1], "ledgerOut").is_null() && ds[1].at("spentUsd") == 0 && lg.Entry("api2", t17).at("cycle") == "2026-10-01");
std::vector<std::string> calls;
std::map<std::string, std::string> answers = {{"api1", "ok"}, {"api2", "out"}};
const SeatPool::ProbeFunction fake = [&](std::int32_t n, const std::string&, const std::string& model, double, const std::string&, bool, bool) {
const std::string id = std::format("api{}", n);
calls.push_back(id);
const std::string state = answers.at(id);
return Json {{"seat", n}, {"state", state}, {"windows", Json::object()}, {"probedWith", model},
{"error", state == "ok" ? Json() : Json(state == "out" ? "Your credit balance is too low" : "probe timeout after 1000 ms")}};
};
std::vector<std::string> plog;
auto plogf = [&](const std::string& line) { plog.push_back(line); };
SeatPool::Probe(ds, "claude", 1, {"claude-opus-5-5"}, plogf, fake, &lg, t17);
const Json e1 = lg.Entry("api1", t17);
Check("the run-start probe re-probes a credit-out api seat; a clean answer is new credits: the seat is back, its cycle restarted on its grant day",
calls == std::vector<std::string> {"api1", "api2"} && ds[0].at("state") == "ok" && ds[0].at("ledgerOut").is_null() && ds[0].at("error").is_null() && e1.at("outSince").is_null()
&& e1.at("cycle") == "2026-10-17" && e1.at("grantSeen") == "2026-10-17T12:00:00+00:00"
&& std::ranges::any_of(plog, [](const std::string& line) { return line.contains("new credits"); }),
Dyn::Join(plog, " | "));
const Json e2 = lg.Entry("api2", t17);
Check("...the credit error on a seat the ledger had as fine is recorded, with the next credits in the reason",
ds[1].at("state") == "out" && ds[1].at("ledgerOut") == "credit" && Dyn::Truthy(e2.at("outSince"))
&& e2.at("outSince") == "2026-10-17T12:00:00+00:00" && ds[1].at("error") == "credit balance too low since 2026-10-17T12:00; next credits due 2026-11-01",
Dyn::Str(ds[1].at("error")));
Check("reconcile: nothing to do when the probe and the ledger agree", lg.Reconcile("api1", "ok", t17, t17).empty() && lg.Reconcile("api2", "out", t17, t17).empty());
// Two drivers on one ledger (1.1.0 round 2): the decision is made on
// the file's entry under the lock, never on a copy read before the
// probe, and a mark made since the probe started stands.
const std::string outLine = R"("cycle": "2026-10-17", "spentUsd": 0, "procs": 0, "outSince": "2026-10-08T20:19:06", "outReason": "credit balance too low")";
Write(td / "race1.json", std::format("{{\"api1\": {{{}}}}}", outLine));
SeatPool::Ledger la = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}});
SeatPool::Ledger lb = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}});
const std::string firstGrant = la.Reconcile("api1", "ok", t17, t17);
la.Add("api1", 5.0, t17);
const std::string secondGrant = lb.Reconcile("api1", "ok", t17 + 60, t17 + 60);
const Json race1 = SeatPool::Ledger::Load(td / "race1.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60);
Check("two drivers: the second's copy, read before the first saw the grant, lifts nothing - the first's grant and its spend stand",
firstGrant == "new-grant" && secondGrant.empty() && race1.at("spentUsd") == 5.0 && race1.at("procs") == 1 && race1.at("outSince").is_null(), DumpSpaced(race1));
Write(td / "race2.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 199.4, "procs": 180, "outSince": "2026-10-17T12:00:06", "outReason": "credit balance too low"}})");
SeatPool::Ledger lc = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}});
const std::string late = lc.Reconcile("api1", "ok", t17, t17 + 30);
const Json race2 = SeatPool::Ledger::Load(td / "race2.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 30);
Check("a credit error recorded after the probe started outlives its answer: no grant, the spend kept",
late.empty() && race2.at("outSince") == "2026-10-17T12:00:06" && race2.at("spentUsd") == 199.4 && race2.at("procs") == 180, DumpSpaced(race2));
Write(td / "race3.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-10-17T12:00:10"}})");
SeatPool::Ledger ld = SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}});
const std::string stale = ld.Reconcile("api1", "out", t17, t17 + 30);
const std::string fresh = ld.Reconcile("api1", "out", t17 + 60, t17 + 60);
Check("...and a credit error read before a grant another driver saw since records nothing; one read after it does",
stale.empty() && fresh == "credit-gone" && SeatPool::Ledger::Load(td / "race3.json", acfg, {{"api1", 17}}).Entry("api1", t17 + 60).at("spentUsd") == 2);
// The grant seen at 23:59:55, the cycle turning at midnight (grant
// day 17) before a credit error read at 23:59:40 is reconciled.
Write(td / "race6.json", R"({"api1": {"cycle": "2026-11-16", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null, "grantSeen": "2026-11-16T23:59:55"}})");
SeatPool::Ledger le = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}});
const std::string acrossMidnight = le.Reconcile("api1", "out", at(2026, 11, 16) + 43180, at(2026, 11, 17) - 43198);
const Json race6 = SeatPool::Ledger::Load(td / "race6.json", acfg, {{"api1", 17}}).Entry("api1", at(2026, 11, 17) - 43198);
// The stamps carry their UTC offset and Reconcile orders marks by
// instant (1.1.0 round 4). A POSIX rule, not a zone file: Central
// European time, whose summer time ends 2026-10-25 03:00, 02:00-03:00
// then passing twice.
setenv("TZ", "CET-1CEST,M3.5.0,M10.5.0/3", 1);
tzset();
constexpr double fallBack = at(2026, 10, 25) - 39610;
Write(td / "race7.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 150, "procs": 120, "outSince": null, "outReason": null}})");
SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", fallBack + 15);
const std::string afterFallBack = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", fallBack, fallBack + 16);
const Json race7 = SeatPool::Ledger::Load(td / "race7.json", acfg, {{"api1", 17}}).Entry("api1", fallBack + 16);
// A stamp without an offset, as older drivers wrote, is local time:
// 13:30 CEST is 11:30 UTC, before a probe started at 12:00 UTC (14:00
// CEST) - new credits; read as UTC it would be after the probe.
Write(td / "race9.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 3, "procs": 1, "outSince": "2026-10-17T13:30:00", "outReason": "credit balance too low"}})");
const std::string legacy = SeatPool::Ledger::Load(td / "race9.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30);
setenv("TZ", "UTC", 1);
tzset();
Check("a credit error recorded after the probe started outlives its answer in the hour DST repeats: 02:00:05 CET is after 02:59:50 CEST",
afterFallBack.empty() && race7.at("outSince") == "2026-10-25T02:00:05+01:00" && race7.at("spentUsd") == 150, DumpSpaced(race7));
Write(td / "race8.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": "2026-10-17T11:00:06-01:00", "outReason": "credit balance too low"}})");
const std::string otherZone = SeatPool::Ledger::Load(td / "race8.json", acfg, {{"api1", 17}}).Reconcile("api1", "ok", t17, t17 + 30);
Check("...and from a driver in another zone: its 11:00:06-01:00 is after a probe started at 12:00:00 UTC", otherZone.empty());
Check("a stamp without an offset, as older drivers wrote, is read as local time: 13:30 CEST is before a probe started at 14:00 CEST", legacy == "new-grant", legacy);
Write(td / "race10.json", std::format("{{\"api1\": {{{}}}}}", outLine));
const std::string race10Before = Platform::ReadFile(td / "race10.json").value_or("");
const std::string unread = SeatPool::Ledger::Load(td / "race10.json", acfg, {{"api1", 17}}).Reconcile("api1", "unknown", t17, t17);
Check("a reading neither ok nor the credit error writes nothing", unread.empty() && Platform::ReadFile(td / "race10.json").value_or("") == race10Before);
// OpenRouter keys (1.1.0, Jorijn: a prepaid key has no cycle of its
// own): one the ledger holds out on credit is probed again through
// the key probe, and room on its limit lifts the mark with its
// cycle's spend kept; the limit reached is recorded; one out on its
// budget is not probed. Their cycle is the cycle day's.
Write(td / "or.env", "OPENROUTER_KEY1=or-one\nOPENROUTER_KEY2=or-two\nOPENROUTER_KEY3=or-three\n");
Write(td / "or-ledger.json", R"({"or1": {"cycle": "2026-10-01", "spentUsd": 7.5, "procs": 3, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"},
"or2": {"cycle": "2026-10-01", "spentUsd": 1, "procs": 1, "outSince": null, "outReason": null},
"or3": {"cycle": "2026-10-01", "spentUsd": 150, "procs": 90, "outSince": null, "outReason": null}})");
SeatPool::Ledger orLedger = SeatPool::Ledger::Load(td / "or-ledger.json", acfg);
std::vector<Json> orSeats = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or.env").string(), td)), "openrouter", &orLedger, acfg, t17);
std::vector<std::string> keyCalls;
const SeatPool::OpenRouterProbeFunction fakeKey = [&](const std::string& secret, double) {
keyCalls.push_back(secret);
return secret == "or-one" ? Json {{"state", "ok"}, {"remaining", 3.0}} : Json {{"state", "out"}, {"error", "key limit reached ($10.00 of $10 used)"}, {"remaining", 0}};
};
plog.clear();
SeatPool::Probe(orSeats, "claude", 1, {}, plogf, fake, &orLedger, t17, fakeKey);
SeatPool::Ledger orFile = SeatPool::Ledger::Load(td / "or-ledger.json", acfg);
const Json or1 = orFile.Entry("or1", t17);
const Json or2 = orFile.Entry("or2", t17);
Check("an OpenRouter key held out on credit is probed again, one out on its budget is not; room on its limit lifts the mark and keeps its cycle's spend",
Ids(orSeats) == "or1,or2,or3" && keyCalls == std::vector<std::string> {"or-one", "or-two"} && orSeats[0].at("state") == "ok" && orSeats[0].at("ledgerOut").is_null()
&& orSeats[0].at("spentUsd") == 7.5 && or1.at("outSince").is_null() && or1.at("spentUsd") == 7.5 && or1.at("procs") == 3 && or1.at("cycle") == "2026-10-01"
&& or1.at("grantSeen") == "2026-10-17T12:00:00+00:00" && std::ranges::any_of(plog, [](const std::string& line) { return line.contains("limit has room again"); })
&& orSeats[2].at("state") == "out" && orSeats[2].at("ledgerOut") == "budget",
std::format("{} {}", DumpSpaced(or1), Dyn::Join(keyCalls, ",")));
Check("...and the limit reached is recorded as the key's credit-out, the probe's error the seat's reason",
orSeats[1].at("state") == "out" && orSeats[1].at("ledgerOut") == "credit" && orSeats[1].at("error") == "key limit reached ($10.00 of $10 used)"
&& or2.at("outSince") == "2026-10-17T12:00:00+00:00" && or2.at("outReason") == "key limit reached",
DumpSpaced(or2));
// A key that answers with its cycle's budget spent is out on that
// budget, not left ok - neither placed nor out, a pool of it waiting
// forever (1.1.0 round 9): an OpenRouter key held out on credit with
// its spend over the budget, and an API key another driver lifted and
// spent the budget on since this copy was read.
Write(td / "or-spent.env", "OPENROUTER_KEY1=or-one\n");
Write(td / "or-spent.json",
R"({"or1": {"cycle": "2026-10-01", "spentUsd": 100.4, "procs": 40, "outSince": "2026-10-10T09:00:00+00:00", "outReason": "credit balance too low"}})");
SeatPool::Ledger spentLedger = SeatPool::Ledger::Load(td / "or-spent.json", acfg);
std::vector<Json> spentSeats =
SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile((td / "or-spent.env").string(), td)), "openrouter", &spentLedger, acfg, t17);
SeatPool::Probe(spentSeats, "claude", 1, {}, plogf, fake, &spentLedger, t17, fakeKey);
Check("an OpenRouter key held out on credit with its budget spent too: the mark lifted, the seat out on the budget, a pool of it all out",
spentSeats[0].at("state") == "out" && spentSeats[0].at("ledgerOut") == "budget" && SeatPool::AllOut(spentSeats)
&& SeatPool::Ledger::Load(td / "or-spent.json", acfg).Entry("or1", t17).at("outSince").is_null(),
DumpSpaced(spentSeats[0]));
Write(td / "race11.json", std::format("{{\"api1\": {{{}}}}}", outLine));
SeatPool::Ledger stale11(SeatPool::Ledger::Load(td / "race11.json", acfg));
std::vector<Json> mine11 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale11, acfg, t17);
SeatPool::Ledger other11 = SeatPool::Ledger::Load(td / "race11.json", acfg, {{"api1", 17}});
other11.Reconcile("api1", "ok", t17, t17);
other11.Add("api1", 12.0, t17);
answers["api1"] = "ok";
SeatPool::Probe(mine11, "claude", 1, {}, plogf, fake, &stale11, t17 + 60);
Check("...and an API key another driver lifted and spent its budget on since: out on the budget",
mine11[0].at("state") == "out" && mine11[0].at("ledgerOut") == "budget" && mine11[0].at("spentUsd") == 12.0 && SeatPool::AllOut(mine11), DumpSpaced(mine11[0]));
Check("an OpenRouter key's cycle starts on the cycle day", orLedger.Day("or1") == 1 && SeatPool::Ledger::Load(td / "or-ledger.json", acfg).Entry("or1", at(2026, 11, 2)).at("cycle") == "2026-11-01");
Check("...across the cycle's turn too: the grant's time carries over the rollover with an API seat's entry",
acrossMidnight.empty() && race6.at("outSince").is_null() && race6.at("grantSeen") == "2026-11-16T23:59:55" && race6.at("cycle") == "2026-11-17", DumpSpaced(race6));
Write(td / "race4.json", std::format("{{\"api1\": {{{}}}}}", outLine));
SeatPool::Ledger stale4(SeatPool::Ledger::Load(td / "race4.json", acfg));
std::vector<Json> mine = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &stale4, acfg, t17);
SeatPool::Ledger other4 = SeatPool::Ledger::Load(td / "race4.json", acfg, {{"api1", 17}});
other4.Reconcile("api1", "ok", t17, t17);
other4.Add("api1", 5.0, t17);
answers["api1"] = "ok";
plog.clear();
SeatPool::Probe(mine, "claude", 1, {}, plogf, fake, &stale4, t17 + 60);
Check("the run's probe after another driver's grant: the seat is back with that driver's spend, no second grant",
mine[0].at("state") == "ok" && mine[0].at("spentUsd") == 5.0 && mine[0].at("ledgerOut").is_null()
&& std::ranges::none_of(plog, [](const std::string& line) { return line.contains("new credits"); })
&& SeatPool::Ledger::Load(td / "race4.json", acfg).Entry("api1", t17 + 60).at("spentUsd") == 5.0,
DumpSpaced(mine[0]));
Write(td / "race5.json", R"({"api1": {"cycle": "2026-10-17", "spentUsd": 2, "procs": 1, "outSince": null, "outReason": null}})");
SeatPool::Ledger mine5(SeatPool::Ledger::Load(td / "race5.json", acfg));
std::vector<Json> seats5 = SeatPool::LoadSeats(SeatPool::WithFileTokens({}, SeatPool::ResolveSeatsFile(daysEnv.string(), td)), "api1", &mine5, acfg, t17);
SeatPool::Ledger::Load(td / "race5.json", acfg, {{"api1", 17}}).MarkOut("api1", "credit balance too low", t17 + 6);
SeatPool::Probe(seats5, "claude", 1, {}, plogf, fake, &mine5, t17);
Check("...and the run's probe that answered before another driver's credit error: the seat is out on the ledger's verdict",
seats5[0].at("state") == "out" && seats5[0].at("ledgerOut") == "credit" && seats5[0].at("error") == "ledger: credit balance too low since 2026-10-17T12:00",
DumpSpaced(seats5[0]));
calls.clear();
answers["api1"] = "unknown";
Json apiB = With(ds[1], {{"state", "out"}, {"ledgerOut", "budget"}, {"error", "ledger: $10.00 spent this cycle, budget $10"}});
Json apiU = With(ds[0], {{"state", "out"}, {"ledgerOut", "credit"}, {"error", "ledger: credit balance too low since 2026-10-08T20:19"}});
std::vector<Json> pair2 = {apiB, apiU};
SeatPool::Probe(pair2, "claude", 1, {}, plogf, fake, nullptr, t17);
Check("a budget-out seat is not probed; a credit-out seat whose probe is unreadable stays out on the ledger's verdict",
calls == std::vector<std::string> {"api1"} && pair2[0].at("state") == "out" && pair2[1].at("state") == "out"
&& pair2[1].at("error") == "ledger: credit balance too low since 2026-10-08T20:19 (probe unreadable: probe timeout after 1000 ms)",
Dyn::Str(pair2[1].at("error")));
// A grant's cycle, through the production path: the credit error
// recorded, then a probe that started after it answers.
lg.SetDay("api3", 5);
lg.MarkOut("api3", "credit balance too low", t17 - 60);
const std::string grant3 = lg.Reconcile("api3", "ok", t17, t17);
Check("new credits seen 12 days after the configured day: the cycle starts today, not on that day",
grant3 == "new-grant" && lg.Entry("api3", t17).at("cycle") == "2026-10-17" && lg.Entry("api3", t17).at("spentUsd") == 0);
lg.SetDay("api4", 16);
lg.MarkOut("api4", "credit balance too low", t17 - 60);
const std::string grant4 = lg.Reconcile("api4", "ok", t17, t17);
Check("...and on the day when it is within the week", grant4 == "new-grant" && lg.Entry("api4", t17).at("cycle") == "2026-10-16"
&& lg.Entry("api4", at(2026, 11, 15)).at("cycle") == "2026-10-16"
&& lg.Entry("api4", at(2026, 11, 16)).at("cycle") == "2026-11-16");
}
const std::string knobs = DumpSpaced(SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", " 1_0 "}, const std::string knobs = DumpSpaced(SeatPool::ApiConfig({{"REVIEW_GATES_API_BUDGET_USD", " 1_0 "},
{"REVIEW_GATES_API_CONCURRENCY", "3.9"}, {"REVIEW_GATES_API_CONCURRENCY", "3.9"},
{"REVIEW_GATES_API_CYCLE_DAY", "40"}, {"REVIEW_GATES_API_CYCLE_DAY", "40"},
@ -194,7 +439,7 @@ int main() {
{"REVIEW_GATES_OPENROUTER_BUDGET_USD", "-1"}, {"REVIEW_GATES_OPENROUTER_BUDGET_USD", "-1"},
{"REVIEW_GATES_API_LENS_COST_USD", "nan"}})); {"REVIEW_GATES_API_LENS_COST_USD", "nan"}}));
Check("api config: float() parsing, int() truncation, the cycle day clamped, a negative or NaN value the default", Check("api config: float() parsing, int() truncation, the cycle day clamped, a negative or NaN value the default",
knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 28, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs); knobs == R"({"budgetUsd": 10.0, "lensCostUsd": 1.0, "concurrency": 3, "cycleDay": 31, "order": "oauth-first", "orBudgetUsd": 100.0, "orLensCostUsd": 0.1, "orConcurrency": 16})", knobs);
const double cost = SeatPool::OpenRouterCost("openrouter/xiaomi/mimo-v2.6-pro", {{"input", 1234}, {"cacheCreation", 10}, {"cacheRead", 99999}, {"output", 777}}); const double cost = SeatPool::OpenRouterCost("openrouter/xiaomi/mimo-v2.6-pro", {{"input", 1234}, {"cacheCreation", 10}, {"cacheRead", 99999}, {"output", 777}});
Check("openrouter cost: tokens x the built-in list prices, an unknown model free", cost == 0.001577 && SeatPool::OpenRouterCost("nope", {{"input", 1}}) == 0.0, Dyn::FloatRepr(cost)); Check("openrouter cost: tokens x the built-in list prices, an unknown model free", cost == 0.001577 && SeatPool::OpenRouterCost("nope", {{"input", 1}}) == 0.0, Dyn::FloatRepr(cost));
Json described = SeatPool::NewSeat("1", "oauth", "t"); Json described = SeatPool::NewSeat("1", "oauth", "t");
@ -222,7 +467,7 @@ int main() {
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"}} 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"}} && 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") && 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"}}); == std::map<std::string, std::string> {{"ANTHROPIC_API_KEY2", "k"}, {"ANTHROPIC_API_KEY2_GRANT_DAY", "17"}, {"CLAUDE_CODE_OAUTH_TOKEN", "t"}});
const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid()); const fs::path envTd = fs::temp_directory_path() / std::format("crafter-review-envfile-{}", Platform::PublishedPid());
fs::remove_all(envTd); fs::remove_all(envTd);
fs::create_directories(envTd / "repo"); fs::create_directories(envTd / "repo");
@ -248,7 +493,7 @@ int main() {
&& !applied.env.contains("REVIEW_GATES_CLAUDE_BIN") && !applied.env.contains("REVIEW_GATES_SEAT_POLICY") && api.at("order") == "oauth-first" && api.at("budgetUsd") == 75.0 && !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" && !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", && SeatPool::SettingsOf(SeatPool::ResolveSeatsFile(std::string(), envTd / "repo")).fromFile.empty() && SeatPool::SettingsOf(missing).fromFile.empty() && raised == "FileNotFoundError",
Dyn::Str(api) + " " + raised); std::format("{} {}", Dyn::Str(api), raised));
// Every name on the list moves a placement reader (the list is the // Every name on the list moves a placement reader (the list is the
// readers' names, not a guess at them). // readers' names, not a guess at them).
const Json apiDefault = SeatPool::ApiConfig({}); const Json apiDefault = SeatPool::ApiConfig({});

View file

@ -4,6 +4,8 @@
// Seat usage: the pure functions against reference vectors, numbered in // Seat usage: the pure functions against reference vectors, numbered in
// order, so "check 17" always names the same vector. // order, so "check 17" always names the same vector.
#include <stdlib.h>
#include <time.h>
import std; import std;
import Crafter.Review; import Crafter.Review;
namespace fs = std::filesystem; namespace fs = std::filesystem;
@ -45,6 +47,10 @@ namespace {
} }
int main() { int main() {
// The API report's dates (NextGrant, its "today") are the process zone's,
// as for dt.date.fromtimestamp: the vectors below are UTC's.
setenv("TZ", "UTC", 1);
tzset();
const Zone utc = ZoneNamed("UTC"); const Zone utc = ZoneNamed("UTC");
const Zone ams = ZoneNamed("Europe/Amsterdam"); const Zone ams = ZoneNamed("Europe/Amsterdam");
// 2026-09-07 10:00 UTC // 2026-09-07 10:00 UTC
@ -232,6 +238,44 @@ int main() {
Eq(ClipReason("token sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123 and jorijn@example.com x\n y", secret), "token [redacted:token] and [redacted:email] x y"); Eq(ClipReason("token sk-ant-oat01-abcdefghijklmnopqrstuvwxyz0123 and jorijn@example.com x\n y", secret), "token [redacted:token] and [redacted:email] x y");
Eq(ClipReason("bad lit-secret here", secret), "bad [redacted:token] here"); Eq(ClipReason("bad lit-secret here", secret), "bad [redacted:token] here");
Eq(LooksLikeAuthFailure({{"api_error_status", 401}}, ""), true); Eq(LooksLikeAuthFailure({{"api_error_status", 401}}, ""), true);
// The API report (2026-10-17, ported 2026-11-02): the probe beside the
// driver's verdict, the cycle, the grant day and the next credits (today,
// a date, overdue) - the Python driver's vectors.
{
const Json acfg = SeatPool::ApiConfig({});
auto api = [&](std::int32_t n, const std::string& state, const Json& error, std::int32_t day, const Json& ledger, const Json& event = nullptr) {
Json s = {{"seat", n}, {"state", state}, {"error", error}, {"grantDay", day}, {"ledger", ledger}, {"ledgerOut", SeatPool::LedgerOut(ledger, acfg.at("budgetUsd").get<double>())},
{"nextCredits", SeatPool::NextGrant(now, day, Dyn::Get(ledger, "outSince"))}};
if (!event.is_null()) s["event"] = event;
return s;
};
auto entry = [](const std::string& cycle, double spent, std::int32_t procs, const Json& outSince) {
return Json {{"cycle", cycle}, {"spentUsd", spent}, {"procs", procs}, {"outSince", outSince}, {"outReason", outSince.is_null() ? Json() : Json("credit balance too low")}};
};
const std::string report = RenderApiReport({api(1, "out", "Your credit balance is too low", 7, entry("2026-09-07", 0.0, 0, "2026-08-20T20:19:06")),
api(3, "ok", nullptr, 7, entry("2026-09-07", 0.0, 0, nullptr), "new-grant"), api(4, "ok", nullptr, 5, entry("2026-09-05", 200.0, 100, nullptr)),
api(2, "unknown", "probe timeout after 30000 ms", 17, entry("2026-08-17", 12.5, 9, "2026-09-01T10:00:00")),
api(5, "out", "Your credit balance is too low", 3, entry("2026-09-03", 0.0, 0, "2026-08-10T10:00:00"))},
acfg, now);
std::string flat;
for (const char c : report) {
if (c != ' ' || !flat.ends_with(' ')) flat += c;
}
Eq(flat.contains("| seat | probe | driver | driver spend this cycle | procs | cycle from | grant day | next credits |"), true);
Eq(flat.contains("| api1 | out (Your credit balance is too low) | out (credit since 2026-08-20 20:19) | $0.00 of $200 | 0 | 2026-09-07 | 7 | today |"), true);
Eq(flat.contains("| api2 | unknown (probe timeout after 30000 ms) | out (credit since 2026-09-01 10:00) | $12.50 of $200 | 9 | 2026-08-17 | 17 | 17 Sept |"), true);
Eq(flat.contains("| api3 | ok | ok | $0.00 of $200 | 0 | 2026-09-07 | 7 | 7 Oct |"), true);
Eq(flat.contains("| api4 | ok | out (budget spent) | $200.00 of $200 | 100 | 2026-09-05 | 5 | 5 Oct |"), true);
Eq(flat.contains("| api5 | out (Your credit balance is too low) | out (credit since 2026-08-10 10:00) | $0.00 of $200 | 0 | 2026-09-03 | 3 | overdue, 3 Sept |"), true);
std::vector<std::string> notes;
for (const std::string& line : Lines(flat)) {
if (line.starts_with("api") && notes.size() < 4) notes.push_back(line);
}
EqJson(Json(notes), Json::array({"api2: probe unknown - the ledger's verdict stands", "api3: the key answers again - new credits; the ledger cycle restarted (from 2026-09-07)",
"api4: the key answers, but the driver's budget for this cycle is spent (REVIEW_GATES_API_BUDGET_USD)",
"api seats run first (REVIEW_GATES_SEAT_ORDER=api-first) · budget $200 per seat per cycle · a cycle starts on the seat's grant day "
"(ANTHROPIC_API_KEY<n>_GRANT_DAY, else REVIEW_GATES_API_CYCLE_DAY=1) · 8 processes per seat · placed at +$1 each"}));
}
Eq(LooksLikeAuthFailure(nullptr, "Invalid API key · Please run /login"), true); Eq(LooksLikeAuthFailure(nullptr, "Invalid API key · Please run /login"), true);
Eq(LooksLikeAuthFailure({{"result", "ok"}}, ""), false); Eq(LooksLikeAuthFailure({{"result", "ok"}}, ""), false);
// find_claude_bin: CLAUDE_BIN, then PATH, then the newest editor-bundled // find_claude_bin: CLAUDE_BIN, then PATH, then the newest editor-bundled