Crafter.Review/AGENTS.md
Jorijn van der Graaf ceb87f70ef
All checks were successful
Flatpak / check (push) Has been skipped
Flatpak / publish (push) Successful in 5m30s
feat(seats): an API seat's cycle starts on its own grant day
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

47 KiB
Raw Permalink Blame History

Crafter.Review — developer guide

This file is for changing crafter-review itself. README.md says what it is and how a repository uses it; process/prepush.md is the procedure the /prepush skill drives, and process/lenses/ holds the lens texts.

What it is

crafter-review freeze exports base..HEAD with git format-patch into .review-gates/push/<slug>/ and hashes the export. It refuses on identity, trailer, dirty-tree, merge and fast-forward failures. crafter-review run partitions the export mechanically (:Inventory: every non-blank line in exactly one unit, fixed slots for what is absent), then runs the gates the config enables, in order.

  • Each gate is a lens; Gate 3 has two, the bug-prover and the security lens; Gate 4 is the commit-truth lens, the messages and the documentation of the range (2026-10-31). Every lens is off until the consumer's config turns it on.
  • Each lens is one claude --print process per unit (block passes). Where the lens has one, a whole pass gates the blocks and hands them its summary.
  • A pass returns structured output: a verdict, findings, notes, and one coverage entry per unit. :Normalize derives the verdict from the findings and fails closed on a missing or blocking entry.
  • Clean results are stored per unit under a content key and reused across hashes (:Plan). The static lenses need three clean reads of a unit before it is reused; the functional lens needs one.
  • Processes run on the seats of the seats file (SeatPool::ResolveSeatsFile: --env-file, $SEATS_ENV_FILE, the consumer's .env, else the machine's own) and of the process environment, the file's lines over the environment's; with no seat in either, on this machine's login. A lens's processes get the environment the config names: none; browser, a Playwright MCP server per pass (:Mcp, :Pool); supabase, the local stack's database through the sql helper (:PgWire); or browser+supabase.
  • A browser pass holds a stack of its own (Pool::Stacks), and a pass launches only when it fits in free memory at its environment's measured cost (Pool::Admission, :Memory).
  • Drivers on one machine share one board, ~/.cache/review-gates/seat-board.json (SeatPool::Board). State::RunLock allows one full run per hash.
  • The kernel modes (2026-10-31): --mode series and --mode reply run the pre-send pipeline of a kernel contributor's workbench with the same machinery. Their own pieces: the inventory of a cover letter, a mail draft and the camera review's shape (:Inventory PartitionReply, PartitionMail), the ten kernel lenses with their gates (Plan::Lenses rows with kernel, Plan::Gates(kind)), text units keyed on what they cite and the anchor (Plan::TextDeps) and a series' code units on the code like the push gates, the persona gate - one maintainer-persona instance per --reviewers entry, its whole pass pruning the not-mine units - with the persona vocabulary in :Schema, :Normalize and the matrix, the references prefetched through utilities/lore.py and the persona corpora (:Refs), the kernel texts in :Prompt, the GATES CLEAN verdict, personaPass in unit-state, and the subsystem's journal in the run layer and the dispositions block. Their prompts, keys and plans were checked byte for byte against the pipeline they replace, on its own artifacts.

The rule it lives by

Anything that reaches a prompt, a reuse key, a shared file or an exit code is pinned byte for byte by the goldens (tests/Goldens) and the checks. A change to any of it is deliberate: it edits the goldens in the same commit and, where it reaches a prompt, comes with the dry-run proof below. Stored results outlive a release and every driver on the machine shares the files, so a byte that moves by accident is a bug.

Layout

project.cpp            crafter.review-lib, crafter-review, the tests, Crafter.Network (pinned; --local
                       takes ../Crafter.Network), Crafter.LintRules (pinned pragma), scripts flatpak/install
lib/json.hpp           nlohmann/json 3.12.0, vendored, never edited
interfaces/            Crafter.Review.cppm (the umbrella) and one partition per concern
implementations/       the partitions; main.cpp hands argv to Crafter::Review::Main
process/               prepush.md, lenses/*.md, templates/{config.json,manifest.md}, skill/prepush/SKILL.md
tests/<Name>/main.cpp  one test each; tests/Common/FakePg.hpp is the Postgres stand-in two of them share
packaging/flatpak/     the manifest, the prune of the base, metainfo, flatpakref
.forgejo/workflows/    ci.yaml

The partitions, in import order:

Partition What it holds Notes
:Unicode, :Text, :Json Unicode 16.0.0's character tables; the text, number, URL and path semantics the rest relies on (Dyn::); JSON the tables are checked-in data; Json is nlohmann::ordered_json, so key order and unknown fields survive a rewrite
:Crypto, :Platform, :Process, :Http hashes, HMAC, PBKDF2 and the encodings; files, locks and signals; child processes; HTTP spawn, reaper, supervisor, signal and stop threads; flock and locked read-modify-write; the sandbox
:Core the version, PromptVersion, the home, the invocation
:Schema, :Tools, :Plan, :Artifact, :Inventory, :Dispositions, :State, :Normalize the structured-output schema, the tool lists, planning and the reuse keys, the frozen artifact, the inventory, the disposition register, run state, normalisation and merge :Tools is a leaf, so :Config and :Pool both read it without importing each other
:PgWire, :Totp the Postgres client, the sql helper and its broker, the totp helper the client, the splitter, the refusals, the broker
:Stream, :Prompt, :Config, :Memory, :Mcp claude's stream, the prompts, the consumer config, memory admission, the Playwright MCP config a prompt literal changes only with a dry-run proof
:Seats, :SeatPool, :OpenCode seat usage and its report, the seat pool and the board, the OpenCode harness :Seats is the library; the report's glue is in :Driver
:Pool, :Freeze, :Run, :Driver the pass pool, freeze, a run's gates, the commands :Run holds Run, slots and app_ready; :Driver the command line

Every seam a test needs is a member that starts as the real one: Run has appReady, probe, query, bypassesRls, migrationsAtHead, migrationsDrift, treeDrift, promptFor, runPass, resumable and instrumentPreflight. SlotsPlan takes a Machine. A regex on unbounded input is a hand-written scanner, never std::regex.

Rules for a change

  • crafter-build --local test passes before and after; add a check for what you changed. It needs no network and no seat. Run test before lint: the four AST rules of Crafter.LintRules read the PCMs a test build leaves. format and lint are clean.
  • A prompt change is proven with a --dry-run on a frozen range before and after (tests/Goldens/fixture/commits, or a scratch repository frozen with freeze). The prompts under <scratch>/<hash8>-run-<n>-dry/prompts/ and the system files must differ exactly where intended, and the goldens change with them. A text change a lens reads bumps PromptVersion (:Core) only when earlier clean results must NOT carry over (2026-09-27: a layout change that left the text a lens reads the same did not).
  • The lens texts and _common-repo.md carry no header. A charter's sha256 - with the repository's own section appended, when lenses.<name>.section gives the lens one - is part of every reuse key, and the charter and its mode's common text (_common-repo.md for a commit range, _common.md in the kernel modes) are part of every prompt; --version prints the shipped texts' sha256s. Editing one is a prompt change.
  • A reuse-key change ships with an attack on its STORE side: "two units, one key, different verdicts" and "a FIX FIRST round, then a text-only re-freeze" (2026-10-06; the StoreRules and CodeKeys tests).
  • A gate change ships with a firing proof: a scratch repository with one planted defect per gate, each lens run with --only <lens> --units <unit> (PARTIAL by design) on the consumer's .env seats. It must give FIX FIRST on the planted range and SEND-READY on the corrected one. Record it in the lens's calibration section with the date, the units, the turns and the cost. It costs money: only with the owner's go.
  • The driver never runs a consumer's build, tests, stack or package manager; the skill does that, by hand, from the config's commands. The functional lens observes a running application it did not start.
  • Secrets never reach a log, a dump or a prompt.
    • Seat tokens go into a seat's own config dir, and API keys into that process's environment alone (:SeatPool).
    • The database URL stays in the driver: a db pass gets the address of the driver's SQL broker and its stack's token (PgWire::Broker, Runner::ProcessEnv). The URL is masked in the dump, the log and config.
    • INPUTS prints the login fixture's path, never its content.
  • A shared file keeps its path, shape and locking. These are the board, the ledger, the memory record, unit-state.json, the run lock, the scratch markers, progress.json, log.txt, resume-<h8>.json and the label-to-filename encoding in out/. Every driver on the machine reads and writes them, so a field another driver wrote survives a rewrite.
  • Every child process goes through Process::Start; popen and system are not used. A claude pass and a seat probe run under crafter-review __supervise, which ends the pass's whole tree.

Tests

One directory per test under tests/, each an ordinary main() linking the library: exit 0 passes, nonzero fails, 77 skips.

  • the pipeline's pure parts: Stream, Normalize, Merge, Dispositions, Config, CommitsMode, CodeKeys, StoreRules, PgWire, StacksMemory, ReviewFixes and Freeze;
  • the pool and the seats: Scheduler, SeatPool, Runner, Drivers, OpenCode and Seats;
  • the foundations: Crypto, Text, Platform and Process;
  • Goldens: the pure pipeline against the recorded answers in tests/Goldens/fixture;
  • Runs: full runs end to end on a claude stand-in;
  • Commands: init, doc, stop, seats, browser;
  • LensTools: every allowlisted program on PATH, strict inside the Flatpak build.

Conventions:

  • Git, temp dirs and timeouts. Every git call runs with GIT_CONFIG_GLOBAL=/dev/null and GIT_CONFIG_SYSTEM=/dev/null; the owner's global config signs commits. Tests work under their own temp dirs, with REVIEW_GATES_BOARD and --scratch pointing there, and each stays under crafter-build's 60 s.
  • Subprocesses. A test that needs one re-executes its own binary. The test binaries of Runs and Commands are crafter-review itself when given arguments, in native mode whatever builds them.
  • Stand-ins. The claude stand-in is a /bin/sh script, and the Postgres stand-in an in-process thread (tests/Common/FakePg.hpp).
  • Goldens. tests/Goldens/fixture/ holds the recorded answers the suite compares against: the inventory, the plans, the keys, the prompts, the normalised results, the stream, the config, the SQL helper's output. A deliberate change edits them in the same commit, by hand; so do :Unicode's tables (Unicode 16.0.0) and tests/Text/fixture/vectors.json. The entries that follow from the lens table and the config defaults - the config cases through config, the commit-truth lens's plans, the not-reviewed lists, the estimates and the gates - are re-derived by python3 -I tests/Goldens/derive.py <checkout> <crafter-review binary>, which asserts that the recorded table still reproduces the recorded entries before it writes; review its diff.
  • The suite runs inside the Flatpak build too (crafter-build test is a build command of the manifest), where /.flatpak-info exists: a test pins Platform::SetMode(Native).

Behaviour the checks hold

Pinned byte for byte, by the goldens or a check:

  • the inventory: its ids, lines, excerpts and inventory-<h8>.json;
  • freeze's checks, messages, freeze.json and the worktree layout. The artifact hash depends on the git version, because format-patch ends each patch with it; the Sdk's git is 2.56.0 today, the host's too;
  • the schema bytes;
  • the block and whole reuse keys, which drop the git signature, so a re-freeze under another git keeps every key;
  • the system files and user prompts, apart from the helper command strings, which are in no key;
  • the SQL and TOTP helpers' output, refusals and exit codes, and the broker protocol;
  • the stream classification, normalize and merge, the dump's keys and their order, the stdout summary, and the exit codes of run and freeze;
  • every shared file (Rules, above). The opencode meta line is one line with ", " and ": " separators (DumpSpaced), because salvage matches on that prefix.

Worth knowing, each held by a test:

  • The helpers are subcommands. Prompts and the Bash(…:*) allowlist name <exe> sql and <exe> totp <fixture>. <exe> is the running binary's absolute path, /app/bin/crafter-review in the Flatpak. These strings are in no reuse key. The stream's SQL-call detection matches the helper with any path before it (Stream, Runner).

  • Printed command lines name the invocation: flatpak run net.catcrafts.CrafterReview in the Flatpak, the binary's absolute path natively (Freeze, StacksMemory, Goldens).

  • Messages that point at a pid. In the sandbox, the run-lock message says "wait for it or stop it (<invocation> stop <its scratch dir>)", and seats adds a line naming stop; natively it says "wait for it or kill it". The pid inside a sandbox ignores SIGTERM.

  • The dump's driver field and the first log line read crafter-review <version> (prompt v1). Nothing reads them.

  • freeze.json records gitVersion. When the hashes differ, freeze --verify's MISMATCH names a git version change as the likely cause (Freeze).

  • A repository's own lens sections (lenses.<name>.section in the config, Config::LensSectionsOf, SectionProblems): Prompt::Charter appends the file the config names for the lens to the shipped charter after one blank line, so charterSha and every reuse key cover it; the path is relative and inside the repository (the check slots.dir shares: an absolute path or a .. part is a config problem, and LensSectionsOf resolves no other), a file missing or one the run could not use (Prompt::ReadSection, the reader Charter uses too: a dangling link, no regular file, unreadable, not UTF-8) is a config problem (the run refuses), a name that is no lens is Merge's unknown key, and the run logs which charters got one. Nothing lists a directory (2026-11-01, Jorijn: a path per lens, where a listed directory had to tolerate or refuse every foreign entry) (Config, CommitsMode, KernelModes).

  • A reply's gate-state is beside the draft, a directory artifact's inside it; the run lock and the dumps are named by the kind (reply-<h8>-run-N.json), the resume file by the hash alone (resume-<h8>.json); a mode of another name shares them all, and the dump's mode says which ran. A kernel mode requires --subsystem; commits refuses --reviewers; --reads is 1 for a reply. The kernel modes check no tree (the lenses read several), excuse no unit from the holes, and their lensesOff and environments in the dump list the kind's lenses alone (Plan::KindLenses; lensesOff those of them the mode does not list) (KernelModes).

  • The repository under review is the freeze record's, not the working directory's: Run::reviewed is named in the prompt and keys the calibration, and --trees defaults to the record's worktree, else its repository when that is not the working directory, else . (Freeze::ReviewedBy, TreeOf). The working directory stays Run::repo: the config, .env, the slots, the lens processes' cwd and every logged path. That is how a workbench reviews other repositories with freeze --repo (Freeze, Runs).

  • config reports an absolute functional.mcp.command that is no program here, such as /usr/bin/npx inside the Flatpak, whose /usr is the Sdk's. config then exits 1, and a run stops at preflight (Config).

  • JSON edge cases.

    • NaN and Infinity are refused when read: a claude stream, opencode or seat-probe line that holds one is skipped. They are written as null.
    • A lone surrogate is replaced.
    • A float from 1e15 up to 1e16 prints with an exponent.
    • An integer beyond 64 bits becomes a float.

    No file the drivers write holds these in practice, and a file that fails to parse is treated as a damaged one.

  • HTTP. ClientHTTP1::Fetch ignores *_proxy variables. The OpenRouter requests send User-Agent: crafter-review; app_ready sends review-gates. Reason phrases are the standard ones, not the server's.

  • Seat placement (SeatPool, Seats, Drivers): 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.
    • 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.
    • There is no machine-wide width (2026-10-31, Jorijn: "there is no need to do a global cap anymore, the cap should be 128 per available account"). --concurrency caps only when given, and the dump's args.concurrency is then null. The board's driver settings read 128 per account, which every driver's seats shows as that driver's width (Scheduler, Runs).
    • 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.
    • 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".

  • 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.
    • --only and --units skip empty items, and one that names nothing is a usage error rather than no filter (Runs).
    • Salvage tries a hash's earlier scratch dirs newest first by run number.
    • A dry run clears the prompts a dry run before it left in the directory they share (Runs).
    • A run never takes the number of a dump on record, so it cannot write its dump over another run's (Runs).
    • A stop or a SIGTERM between gates, or while a gate's pool is set up, stays in force: the next gate does not run (Runs).
    • The stop file <scratch>/stop, checked every 2 s and handled as SIGTERM; a claimed scratch dir drops a stale one (Runs).
    • A patch that is not UTF-8 stops run at preflight, exit 3 (Runs). An exception no command catches ends it with its message and exit 1, never an abort (Commands).
    • The SQL helper's connect retry does not sleep after its last attempt, and TOTP reads the clock once per code.
    • A broker call whose token is not a string gets NO DATABASE and the exit mark (PgWire).
    • An API seat's .claude.json is created whole or not at all, never over another writer's.
    • claude's stream-json is split on newlines only, after universal newlines: \v, \f, \x1c–\x1e, \x85, U+2028 and U+2029 do not end a line inside one (Stream).
    • progress.json is written atomically, through a temporary file and a rename.
    • The seats API report says the ledger counts what "the review drivers" spent: every driver writes it.
    • In the Flatpak, a pass's SHELL is /bin/bash (Platform).
  • Commands beyond the gates: init, doc, browser install, stop, and --version, which prints the home and the charters' sha256s (Commands).

  • Order, not value: estimate.cacheWrite's last digit depends on the order the passes ran, because it is a float sum.

  • A mode is an artifact kind plus the lenses it runs and its effort (2026-11-01: modes.<name> in the config, Config::ModeOf, ModeNames, LensesOff(config, mode)). The three kinds (Plan::Kinds: reply, series, commits) are modes of their own name; a mode of another name states its kind. The kind decides the partition and hash, the common text and run layer, the schemas, the disposition and reuse-key rules and what the CLI requires (Run::Arguments::kind, Plan::IsKernelKind); the mode decides which of the kind's lenses run (Plan::KindLenses, in the kind's gate order, Plan::Gates) and the effort (modes.<name>.effort, else the kind's default). Every lens is off until a mode lists it (2026-10-31, Jorijn: "all lenses off by default and opt in only"): Config::Defaults() runs no lens, while the template lists every lens of each kind at the kind's effort, to trim (Jorijn, 2026-11-01, on the populated template as the example: "looks good"; init says to trim); lenses.<name>.run is retired with a warning that says so; a lens of another kind, a name that is no lens, a lens listed twice and an unknown effort are config problems; --mode names any configured mode, the dump records mode and kind, and a reuse key carries no mode name, so a clean read bought in one mode counts in another of the same kind. The run lock, the run numbers and the dump names are the kind's (<kind>-<h8>-run-N.json): one full run per hash across the modes of a kind, their rounds counting on together; the dump's mode says which ran (Config, CommitsMode, KernelModes).

  • The commit-truth lens (repo-facts.md, 2026-10-31): Gate 4, after the proofs, so the messages and documents are read once the code they describe has settled. Its block kinds are text, doc and slot: the message units keyed on their text, the base and the code up to their commit (MessageDeps); a documentation unit on its file, its normalised text, the base and the code of the whole range (DocKeyParts); a diffstat on its text alone; whole:order and whole:absent in slot passes of its own (a lens with no whole pass asks the slots named in its row; never stored). A documentation file's diff header and a part the commit writes nothing in are mechanical n/a. Budgets 60/40 for a text or doc block, 50/35 for a slot; one read. The owner's gate is 5. The other lenses' prompts, keys and plans are untouched (CommitsMode, CodeKeys, Runs, the Goldens' series and gates).

Known quirks, kept:

  • the label-to-filename collision in out/;
  • code_norm strips the patch tail only when the signature is present;
  • the two different rules for "has a body";
  • resume ignores contests (this fails closed);
  • a round is marked complete even on FIX FIRST;
  • the SQL helper's "statements 4-4" wording and its edge case at exactly 40000 characters.

Inside the Flatpak

Platform::Init decides once whether the binary runs sandboxed: /.flatpak-info exists. Tests pin the mode, the identity's /proc root and the paths.

  • Environment. Flatpak points XDG_{CONFIG,CACHE,DATA,STATE}_HOME at ~/.var/app/<id>. The binary restores the host's (HOST_XDG_*, else the defaults under $HOME) for itself and every child, and a child's SHELL becomes /bin/bash where flatpak set /bin/sh (RestoreShell). CRAFTER_REVIEW_HOME is honoured natively only, so a host setting cannot swap the Flatpak's lens texts for a checkout's.
  • Board identity. A full run publishes <bwrapinfo child-pid>:<starttime of /proc/1>: the host pid of the sandbox's init and its host start time. That is what flatpak ps shows as child-pid, so a driver outside that checks the pid sees the sandbox alive exactly as long as it runs. Without bwrapinfo.json (as in flatpak build) a full run refuses to start. Dry runs and every other command never join the board.
  • Lifetime lock. Every full run holds LOCK_EX on <board dir>/drivers/<pid>-<start>.lock. It is created under a temporary name, locked, then renamed into place, so a prober can never lock a fresh file. Probers try LOCK_SH|LOCK_NB, and whoever finds it free unlinks it under the board lock; Board::View never does.
  • Liveness.
    • A lock file exists: the lock decides.
    • No lock file, natively: the pid and its start time decide.
    • No lock file, in the sandbox: always alive. The sandbox's /proc is never consulted for other keys: its pids count up from 1 and can equal a live host pid.
  • Stop. A SIGTERM to the flatpak run pid kills only the outer bwrap, and the published pid ignores SIGTERM. So a watcher thread checks <scratch>/stop every 2 s, and crafter-review stop writes it.
    • A stop is handled exactly like SIGTERM, and it stays in force across gates.
    • Claiming a scratch dir drops a stale stop.
    • A process-group SIGTERM to the setsid launch reaches the driver; flatpak kill is the hard stop.
  • gpg. Only xdg-run/gnupg/S.keyboxd is exposed, so %G? verifies against the public keys and the agent stays out of reach.

Process lifetime.

  • Spawning. Every child is spawned from one long-lived spawner thread, because PR_SET_PDEATHSIG fires when the forking thread ends. The child gets PDEATHSIG, a getppid() check, fds 3 and above closed, the signal mask cleared, and SIGPIPE and SIGXFSZ back at their defaults.
  • The driver. It ignores SIGPIPE, is a child subreaper, has one reaper thread owning waitpid(-1), and handles signals on a thread of their own.
  • The supervisor. crafter-review __supervise is single-threaded, a subreaper, and holds PDEATHSIG SIGTERM. It forks claude with PDEATHSIG SIGKILL, waits for it, and then kills and reaps everything left in its subtree: the MCP server and Chromium go with it, even though Playwright starts Chromium detached. On SIGTERM it SIGSTOPs its subtree, rescans until the set is stable, SIGKILLs it and exits 128+9, so a timed-out pass reports -9, a SIGKILLed child's code. That SIGTERM comes from a timeout, an abort, or the driver's death, SIGKILL included.
  • At exit run and freeze kill their own remaining descendants, such as a gpg daemon, and never a process they did not start.

Environment variables

What the binary reads at run time, and how. A variable the driver only sets or strips for its children (CLAUDE_CONFIG_DIR, the bare ANTHROPIC_API_KEY, CLAUDE_CODE_ENTRYPOINT, MAX_THINKING_TOKENS, the opencode settings) is not read and not listed.

Name Read by Default Parsing rule What it does
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_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
SEATS_ENV_FILE :SeatPool ResolveSeatsFile (run's Gate 0, seats) <cwd>/.env if it is a file holding a seat or a placement setting, each with a value (or no UTF-8 text: its error is the command's), else the machine's $XDG_CONFIG_HOME/crafter-review/env (~/.config/crafter-review/env) --env-file wins (in run and seats even --env-file "", meaning no file); empty counts as unset. Seat lines are taken ([export ]NAME=value, one pair of matching quotes stripped), overriding the process environment; the file's placement settings (SeatPool::PlacementSettings) go, under the process environment, into the map the placement readers take (SeatPool::SettingsOf, from the one read the tokens come from too), so every placement knob below may come from the file, the process environment winning; no other reader sees a line of it, and its other REVIEW_GATES_* lines are named (the run's log, seats on stderr). A file that cannot be read, or is not UTF-8: run stops at preflight (exit 3), seats exits 1 with the read error. The dotenv file the seat secrets come from
XDG_CONFIG_HOME :SeatPool GlobalEnvFile ~/.config The directory whose crafter-review/env is the machine's own seats file, the last place a run or seats looks (2026-11-01). Runs, Commands and KernelModes, the tests that run run and seats, point it (and HOST_XDG_CONFIG_HOME) at a path nothing creates, so a global file on the machine stays out of them.
REVIEW_GATES_SEATS :Driver RunCommand → LoadSeats auto Only without --seats; the process environment's alone, never from the seats file. Stripped, case-sensitive; unset or empty is auto (every api and oauth seat, else local); local, api, oauth, openrouter select that kind; anything else is a comma list of ids (apiN, N, orN, local; blank items skipped). An id not configured, or OpenRouter mixed with others, is a preflight error (exit 3). The seats a run places lens processes on
REVIEW_GATES_SEAT_MAX_UTIL :Seats ConfigFromEnv (seats; run through :SeatPool PoolConfig) 85 A finite number >= 0, in decimal or exponent notation; surrounding whitespace, _ between digits and Unicode digits are taken. Empty, garbage, negative, inf, nan keep the default; a whole value is stored as an int. The process environment, else the seats file (SeatPool::SettingsOf). Ceiling (%) of an OAuth seat's projected 5-hour utilisation
REVIEW_GATES_LENS_COST_PCT :Seats ConfigFromEnv; :SeatPool PoolConfig run: 0.5 at max and any other effort, 0.125 at xhigh; seats: 10 As MAX_UTIL. PoolConfig puts in the effort's figure only when it is unset or blank, so a set but invalid value gives 10, in run too. Percentage points of the 5-hour window a running process is projected to add (its cost on the board too)
REVIEW_GATES_SEAT_POLICY :Seats ConfigFromEnv spread Exactly pack (case-sensitive, untrimmed) is pack; anything else spread. pack: the first eligible OAuth seat by id; spread: the lowest projected one
REVIEW_GATES_SEAT_WAIT_MIN :Seats ConfigFromEnv 15 As MAX_UTIL Shown by seats --json as waitMaxMin; nothing else reads it
REVIEW_GATES_SEAT_HANDOVER_PCT :Seats ConfigFromEnv 90 As MAX_UTIL Shown in the seats report and --json only
REVIEW_GATES_OAUTH_CONCURRENCY :SeatPool PoolConfig 128 As MAX_UTIL, truncated toward zero. Passes at once on one OAuth account or the local login, every driver's counted
REVIEW_GATES_LENS_COST_7D_PCT :SeatPool PoolConfig 0.04 at xhigh, 0.125 otherwise As MAX_UTIL. Percentage points of the weekly window a running pass is projected to add: the placement of an account without a 5-hour limit, and cost7d on the board
REVIEW_GATES_SEAT_ORDER :SeatPool ApiConfig api-first Exactly oauth-first is oauth-first; anything else api-first. Whether the paid seats (API, OpenRouter) or the OAuth/local ones rank first
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_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..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_LENS_COST_USD :SeatPool ApiConfig 0.1 As API_BUDGET_USD Reserve per process on an OpenRouter seat
REVIEW_GATES_OPENROUTER_CONCURRENCY :SeatPool ApiConfig 16 As API_CONCURRENCY Processes at once on one OpenRouter seat
REVIEW_GATES_SCRATCH :Run ScratchRoot (run, slots); :SeatPool LedgerPath (run, seats) ~/.cache/review-gates --scratch wins; empty counts as unset; used verbatim; never from the seats file. Where the <hash8>-run-<n> dirs, memory.json and seat-ledger.json live; not the board
REVIEW_GATES_BOARD :SeatPool BoardPath (run, seats, stop) ~/.cache/review-gates/seat-board.json Non-empty: the board file, verbatim; its directory also holds drivers/, the lifetime locks; never from the seats file. The one board per machine every driver shares; set it for tests and hand-made boards
REVIEW_GATES_MEM_RESERVE_MB :Memory Reserve (run's admission, slots) max(2048, MemTotal / 10) MB The process environment's alone, never from the seats file. Unset or empty: the default. Otherwise a number in MAX_UTIL's syntax, truncated toward zero; a negative value enlarges the budget. Garbage and nan exit 1 with a ValueError, inf with an OverflowError; a value past int64 saturates. MB of available memory never given to passes or slots
CRAFTER_REVIEW_HOME :Core Home <exe>/../share/crafter-review (the Flatpak's /app/share/crafter-review), else the checkout when the exe runs from <checkout>/bin/crafter.review-exe-*/ Natively only; ignored in the Flatpak. Empty counts as unset. A set value without process/lenses is an error, with no fallback. Where process/ (the lenses, the templates, the skill) is read from
REVIEW_GATES_CLAUDE_BIN :Pool Runner (lens processes, run's probes); :Seats FindClaudeBin (second) FindClaudeBin's search Non-empty: used verbatim, unchecked; never from the seats file. In run: --claude-bin, then this, then FindClaudeBin. The claude binary
CLAUDE_BIN :Seats FindClaudeBin (seats, run's last fallback) after it: claude on PATH, the editor-extension bundles (~/.vscode-oss, .vscode, .vscode-insiders, .cursor; newest first), ~/.claude/local/claude, ~/.local/bin/claude, then the bare claude Non-empty: verbatim. FindClaudeBin checks it before REVIEW_GATES_CLAUDE_BIN, so it wins in seats and loses in run. The claude binary
REVIEW_GATES_OPENCODE_BIN :OpenCode DefaultBin (--harness opencode) opencode on PATH --opencode-bin wins; empty counts as unset; never from the seats file. The opencode binary
REVIEW_GATES_DB :PgWire SqlMain → CallBroker (crafter-review sql, in a pass) none: NO DATABASE <host>:<port>#<token>, split at the last #, then at the last :. Unset, empty or no #: NO DATABASE, exit 2; a port that is no integer: NO DATABASE with the parse error, exit 2; a port outside 0..65535: exit 1 with an OverflowError. The driver sets it per pass ("" without a database) and strips an inherited one. The driver's SQL broker and this pass's database token
REVIEW_GATES_STRUCTURED_OUT the opencode tool :OpenCode ToolSource generates (reads process.env) none A file path; unset or empty: the tool writes nothing and still reports success. Set per process by :OpenCode ChildEnv. Where an opencode pass's structured result goes
REVIEW_GATES_SIMULATE_FAIL :Pool Runner (--dry-run only) none The process environment's alone, never from the seats file. Split on commas, items untrimmed, empty ones dropped: a unit id fails that unit's block pass, whole every whole pass. Ignored without --dry-run. A test hook: "simulated failure (REVIEW_GATES_SIMULATE_FAIL)"
PATH :Process spawn and the supervisor; :Seats FindClaudeBin, ProbeSeat unset: /usr/local/bin:/usr/bin:/bin (spawn), /usr/bin:/bin (supervisor); the Flatpak sets /app/bin:/usr/bin Colon-separated; an empty entry is the cwd; a name with / is used as is. Where child programs (claude, git, npx …) are found
HOME :Platform Home (every ~ path); :Seats ProbeEnv unset or empty: the password database's, else / Verbatim. ~/.cache/review-gates, the XDG defaults, claude's install locations, ~/.cache/ms-playwright, init's home-directory check
TZ :Seats LocalZone (the seats report); the C library behind :Platform LocalTime the zone /etc/localtime points to, else the C library's %Z A leading : stripped. UTC, or a name with a file under /usr/share/zoneinfo, is the zone (the C library converts with TZ set to it: libc++ has no tzdb); any other name only labels the report. The zone of the seats report and of every local timestamp (the default --date, the ledger's cycle)
TMPDIR libc++'s temp_directory_path, from :Seats ProbeSeat and :Freeze /tmp The library's lookup: TMPDIR, TMP, TEMP, TEMPDIR. Where the probes' config dirs (rg-seat-*, removed after) and freeze's temp dirs go
HOST_XDG_{CONFIG,CACHE,DATA,STATE}_HOME :Platform RestoreHostXdg (Init, the sandbox only) $HOME/.config, .cache, .local/share, .local/state Non-empty: copied verbatim into XDG_<KIND>_HOME, which is always overwritten. Never read natively. The host's XDG dirs for git, claude, npx and Playwright, and for the machine's own seats file ($XDG_CONFIG_HOME/crafter-review/env, SeatPool::GlobalEnvFile), instead of ~/.var/app/…
SHELL :Platform RestoreShell (Init, the sandbox only) — Kept when it ends in /bash or /zsh and is executable; else /bin/bash when that is executable. Never read natively. The shell claude's Bash tool runs commands with
XDG_RUNTIME_DIR :Platform Paths (at Init, the sandbox only) /run/user/<uid> Empty counts as unset; verbatim. Where .flatpak/<instance>/bwrapinfo.json gives the driver its host-pid identity
XDG_CACHE_HOME :Driver PlaywrightBrowsers (browser install) ~/.cache Empty counts as unset; in the Flatpak it already holds the host's. The parent of ms-playwright when PLAYWRIGHT_BROWSERS_PATH is unset
PLAYWRIGHT_BROWSERS_PATH :Driver PlaywrightBrowsers (browser install) $XDG_CACHE_HOME/ms-playwright, else ~/.cache/ms-playwright Non-empty and not 0: verbatim; 0 or empty: the default. Where browser install checks that chromium-<rev> landed

At build time:

Name Read by Default Parsing rule What it does
CRAFTER_REVIEW_VERSION project.cpp Version(), when crafter-build configures git describe --tags --always --dirty --exclude=latest, else 0.0.0-dev Non-empty: verbatim. It becomes the library's CRAFTER_REVIEW_VERSION define, so each value is a library variant of its own. The version --version and the driver line print; the manifest sets 0.0.0-dev, CI the tag's
CRAFTER_BUILD_MARCH, CRAFTER_BUILD_MTUNE Crafter.Build's ApplyStandardArgs native Non-empty: -march / -mtune; a --march= / --mtune= argument wins. The manifest sets x86-64-v2 and generic, so the shipped binary runs on any x86_64 of the last decade

In the tests only:

Name Read by Default Parsing rule What it does
PATH LensTools "" Colon-separated Every program a lens's allowlist names, bash, node and npx must be on it: a skip natively, a FAIL inside the Flatpak
FAKE_* the claude stand-ins of Runs and Scheduler set by the test — Plumbing between a test and its stand-in

Runs and Commands drop every REVIEW_GATES_*, CRAFTER_REVIEW_*, SEATS_ENV_FILE and seat secret (Runs also FAKE_*, Commands also CLAUDE_BIN) from the environment they run the binary with, then set their own CRAFTER_REVIEW_HOME and REVIEW_GATES_BOARD (Commands also REVIEW_GATES_SCRATCH and TZ=UTC), and Runs, Commands and KernelModes pin XDG_CONFIG_HOME and HOST_XDG_CONFIG_HOME (the Flatpak's Platform::Init copies the latter over the former) to no-config in their own temporary directory, a path nothing creates, so a developer's own ~/.config/crafter-review/env stays out in both modes: a developer's own settings never reach them.

CI and publishing

.forgejo/workflows/ci.yaml is Crafter.Build's workflow for this app.

  • Builds. A pull request builds the Flatpak, runs the suite inside it and smoke-tests the app as it ships: the version and home, every library resolved and no RUNPATH outside /app, the bundled tools, and a freeze and a dry run on a scratch repository.
  • Publishing. Every push to master is a release (2026-11-02, Jorijn: no rolling beta or latest builds): CI publishes stable, tags the pushed commit with the version of the metainfo's newest <release> and creates the release with its notes. A push to master therefore adds a new <release>; one whose version is released already fails the job before anything is built or published.
  • The runner. It runs on the Catcrafts organisation's privileged flatpak runner, in two jobs by trust, as imsd-messages' workflow does: check (a pull request, a run by hand on another branch) builds cold with no host volume; publish (master) mounts the crafter-build installation the publish jobs share (crafter-flatpak-org-system), the published repository and its signing key. flatpak-builder's state stays in the workspace, so every run builds cold. The workflow is unverified on the forge until its first run; its smoke test passes when run by hand against a local build.
  • The shared repo. Both apps publish into one repo. The publish step holds /srv/flatpak/.publish.lock, which protects anything only once Crafter.Build's workflow takes the same lock. This one writes its own flatpakref and never the shared catcrafts.flatpakrepo.

Pins, moved together:

  • the Flatpak's base, net.catcrafts.CrafterBuild stable (Crafter.Build 3.0.0 today), whose crafter-build and LLVM compile the project, with the manifest's runtime-version;
  • the Crafter.LintRules pragma in project.cpp, a commit that builds with the base's crafter-build;
  • the Crafter.Network commit in project.cpp. While Crafter.Network commits are not on forgejo, crafter-build --local run flatpak --deps=local builds against the checkouts next to this one.

run flatpak and run install mirror Crafter.Build's scripts. --base=<branch> and --deps=local write a gitignored copy of the manifest, and flatpak-builder keys its cache on the manifest's path. The cleanup is a keep-list prune (packaging/flatpak/prune-base.sh), because top-level cleanup does not reach the base's files.

Licence

crafter-review is AGPL-3.0-only, Copyright (C) 2026 Catcrafts®. nlohmann/json is MIT, vendored unmodified.