Crafter.Review/process/prepush.md
Jorijn van der Graaf ddf32a8c02
fix(init): the skill it writes ignores itself, as a generated directory
A repository that ignores .claude wholesale, as Crafter.Build does,
never sees in git the prepush skill init writes, and the question was
whether it should: it should not. The skill is generated
from the shipped text with init's marker and refreshed by every init, a
tracked copy lags the installed binary whenever the shipped skill
changes, and every refresh would be a diff in the consumer's own ranges.
Jorijn's ruling: every consumer leaves it untracked, one init per
checkout.

When init owns the skill - wrote or refreshed it - it writes a
.gitignore of `*` beside it, as Python's venv and pytest's cache do for
theirs: the directory ignores itself, that file included, on every
checkout that runs init, whatever the repository's own rules or one
machine's say, and nothing of the repository's is edited for it. A copy
init wrote that the repository tracks gets the step that untracks it, in
the output and in the README's migration. A skill the repository wrote
itself is kept and not ignored - the ignore file an earlier init wrote
beside it goes - and an ignore file init did not write is kept unless
--force; a link there, dangling or not, is refused like the skill's
other links unless --force, which replaces it (the old setup's exemption
- a link that resolves into the review-gates submodule at the toplevel,
not one that merely names it - is the skill directory's and SKILL.md's
alone), and nothing is written through it: the links go before anything
is written, and one init cannot remove refuses. The procedure's log
records the ruling.
2026-11-02 00:31:47 +01:00

43 KiB
Raw Permalink Blame History

Pre-push procedure

Scope: every push of a commit range from a repository set up for this pipeline (crafter-review init) — a release channel with real users, where a push (or the pull request it opens) is the next release. The /prepush skill drives this file. The mechanics: freeze → hash → gates → the owner; five lenses, the functional one as Gate 2 (Jorijn, 2026-10-15), the security lens beside the bug-prover in Gate 3 (2026-10-22), the commit-truth lens as Gate 4 (2026-10-31), every one of them off until the repository's config turns it on (2026-10-31).

Nothing is pushed without the owner's explicit approval of the exact frozen hash. Local commits parked unpushed are fine and normal.

Terms: the pipeline is crafter-review, the Flatpak net.catcrafts.CrafterReview; every command below is spelled flatpak run net.catcrafts.CrafterReview <command>, because the Bash tool sees no alias (a native build is run by its path instead). The repository is the consumer checkout the driver runs in; the owner is whoever approves the hash at Gate 5.

Inputs — what the lenses see, and nothing else

  • The frozen artifact: .review-gates/push/<slug>/*.patch — git format-patch --base=<base> <base>..HEAD of the repository, cut by freeze, inlined into every prompt.
  • The repository at the range HEAD: the checkout itself (--trees .; the driver verifies HEAD and a clean tree before every launch) or a detached worktree under .review-gates/trees/<repo>-<hash8>/ (freeze --worktree; gitignored; --trees defaults to what the freeze record says, so a workbench that froze another repository with --repo reads its worktree) — content, history and blame; the state before the range via git show <base>:<path>.
  • --context-paths for the evidence dirs the commit messages' claims rest on.
  • The lens charter (process/lenses/repo-*.md, with the repository's own section, lenses.<name>.section, appended; system text _common-repo.md) and the dispositions in force.
  • A lens with the browser or browser+supabase environment (the functional lens by default; lenses.<name>.environment in the config): the running application (.review-gates/config.json functional.*: its URL, the local origins, the login fixture, the owner's notes), through the Playwright MCP server the driver starts for each of its passes, and the exact one-time-code command the prompt names (totp).
  • A lens with the supabase or browser+supabase environment: the local stack's database (supabase.dbUrl, this machine only), through the exact command the prompt names (sql) — every call one transaction rolled back at its end. The helper hands the SQL to the driver's broker, which runs it: the URL, its password included, stays in the driver, and the pass's process gets only the broker's address and its stack's token.

Never: .review-gates/push/ (dumps, manifests, gate-state, freeze.json), the pipeline's own files, the network beyond the application, a build tool, a test runner, a package manager, a stack command or a database client of its own (psql is denied: it commits to the database every pass shares) — the bug-prover and the security lens prove statically unless their environment gives them the database or the application, where a proof may also be run; the functional lens observes, it does not build.

How the passes share the stacks

A stack is an application with its API, its database and a login. It is either the one the config names (functional.*, supabase.dbUrl, the first login fixture) or one of several slots the operator brings up side by side (slots.*). Each instrument is shared in its own way:

  • The application (the browser environments): a pass holds a stack of its own from its launch to its end, and no other pass with the browser gets that stack meanwhile. So as many such passes run at once as there are stacks up — one on the config's single stack — and a sign-out, a setting or a row of one pass reaches no pass running beside it. A stack is not reset between passes: what keeps a pass apart from the ones before and after it is the rule in the APPLICATION UNDER TEST block. Create only what you need, name it with your unit id, never change what you did not create, and leave the account as you found it. The application's writes are real; they last until the stack is reset. With slots, the pass's own stack — its URL, origins and login — is named in its user message, so the system prompt every pass shares stays the same.
  • The database (the supabase environments): every SQL call is its own transaction and is rolled back, so a pass never sees another pass's writes. The application never sees them either, and nothing outlives the call. That is why a pass with the database alone needs no stack of its own: it uses the first. A lock a call takes (DDL, an UPDATE) holds until that call ends; other passes wait for it, up to the lock_timeout (PgWire::LockTimeoutS, 3 s). The guard is against interference and leftovers, not against a lens set on escaping: a second connection opened from inside the database (dblink) commits on its own, and a lens may open a connection of its own with the local stack's well-known credentials.
  • Memory: there is no per-lens number. Before a gate, and before every launch, the driver reads the machine's free memory (MemAvailable). It launches a pass only while the pass fits: free memory at the gate's start less the reserve (REVIEW_GATES_MEM_RESERVE_MB, by default 10 % of the machine), less what the passes in flight were admitted at, with the pass's cost still free right now. A pass costs its environment's measured peak: the driver samples every pass's own process tree (claude, the Playwright server, Chromium), keeps the recent peaks per repository and environment (~/.cache/review-gates/memory.json), and admits at the highest. Before any is recorded it uses the defaults measured on 2026-10-22. With nothing in flight a pass runs whatever the memory says.
  • Other drivers on this machine (2026-10-31): every driver registers its passes in flight on one board, ~/.cache/review-gates/seat-board.json (SeatPool::Board), each with the account it runs on, the memory it was admitted at and the stack it holds. A launch is decided and registered in one locked section, so: a stack another driver's pass holds is not free here (a stack is its application's address, Pool::StackKey, so two configs that name one URL share it); the other drivers' passes count against the memory budget at what they were admitted at, and the budget's free memory is the reading from before they ran (Board::MemoryBase) — "nothing in flight" means nothing on the machine; their passes count in each seat's projection, in an account's 128 and in an API key's slots; and --concurrency, when given, is the machine's width, every driver's passes counted. A driver waiting on what another holds re-checks every 30 s. A driver that died is swept by its lifetime lock under the board's drivers/, a pass by its deadline. A driver takes its passes with it however it ends: each pass runs under a supervisor that kills the pass's whole tree — the Playwright server and Chromium included — when the driver goes, SIGKILL included.
  • Stopping a run: flatpak run net.catcrafts.CrafterReview stop <its scratch dir>, or --hash <hash8> for every live run of that hash (the scratch dir is the last word of the run's gate-0 log). It works from any sandbox: the driver looks for the stop file every 2 s and handles it as SIGTERM — its running passes killed, no later gate started, the dump written. From outside the sandbox a SIGTERM reaches the driver only through the process group it was launched in (kill -TERM -<pgid> of the setsid launch); a SIGTERM to the flatpak run pid alone does not. flatpak kill net.catcrafts.CrafterReview is the hard stop: it ends every run of the app at once, without a dump.

The consumer's configuration

.review-gates/config.json at the repository root, committed; the template is what init writes (flatpak run net.catcrafts.CrafterReview doc config prints it) and flatpak run net.catcrafts.CrafterReview config prints the merged result with its problems. What it holds: base (the integration branch a range is measured against) and rangeFrom (upstream: the push 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 for a repository whose branches become PRs — their commits must be linear, rebased, never a merge of the integration branch), authors (the emails the freeze accepts; null = git config user.email), aiTrailers (forbid or allow), requireSigned, allowDirty (tracked 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 kind of artifact the mode reviews — reply, series or commits, the three shipped partitions, a kind being a mode of its own name — the lenses it runs, in its kind's gate order (the table below for commits, the README's kernel modes for a series or a reply), and its effort, else the kind's default; every lens is off until a mode lists it (opt-in only, 2026-10-31; lenses.<name>.run is retired, 2026-11-01); a lens no mode lists is out of every run of this repository: its passes leave its gate, which disappears when none of its lenses runs; in a commit range the units only it reads are listed as not reviewed, never as holes, and the run stays a full run (the ruling of record for a repository with nothing to exercise, where --without is a per-run ruling that leaves a hole), while the reply and series kinds excuse no unit), lenses (per lens, by its name: environment, what its processes get beyond the read-only tools: none; browser = a Playwright MCP server per pass, the browser tools and curl, the APPLICATION UNDER TEST block, the probe before its gate, a stack of its own per pass and the fail-closed server check; supabase = the database under test: the sql helper as its exact command, the DATABASE UNDER TEST block, the probe before its gate (the database answers and its applied migrations are the range HEAD's — by version, and by content for the migrations the range adds or changes) and the fail-closed check that a pass whose SQL calls never reached the database is no evidence; browser+supabase = both. The functional lens is empirical and needs one with the browser while it runs; the static lenses default to none and may take any. A verdict bought in one environment is never reused, resumed or salvaged in another, and a defect found in one drops the clean verdicts the lens holds for the same code in the others; section, a file of the repository's own — a relative path inside it — appended to that lens's shipped charter after one blank line: the calibration entries and conventions that are this repository's rather than the lens's, which has the shipped charter for what is general to it; the reuse keys carry the combined text's hash; a path outside the repository and a file missing, unreadable or not UTF-8 (a dangling link included) are config problems, a name that is no lens an unknown key), supabase, the database under test for every lens with supabase or browser+supabase: dbUrl (the local stack's Postgres — this machine only; masked in the dump and the log), start / stop (how the operator brings the stack up when no lens drives the application; functional.start covers it when one does), notes (the schemas that matter, the seeded users, what to leave alone), slots, stacks side by side: dir (where the bring-up writes <n>/slot.json once slot n is up — {appUrl, apiUrls, dbUrl, login}; with it set, the driver uses the slots that are up and refuses a gate with none), start / stop (the commands for slot {n}; the skill runs them), memMb (one slot's measured memory while a pass uses it: its containers' and processes' own, never the machine's — slots sizes the slots by it), and functional, the application under test for every lens with the browser or browser+supabase environment when no slots are configured: appUrl and readyPath (what the driver probes before that lens's gate), apiUrls, start / stop (how the operator brings the application up from the frozen tree — the skill runs them, the driver only probes), login.fixtures (JSON files with email, password, totpSecret; the first that exists is the stack's login; a slot names its own in slot.json), exclude (path globs whose hunks carry no runtime behaviour: tests, CI, docs — mechanical n/a for the functional lens; it holds with slots too), extraTools (Bash(<prefix>:*) or mcp__<server>[__<tool>] entries a lens with the browser may also use; an entry the deny list (Tools::Deny) overrides — psql, a package manager, a stack command — is a config problem, never a silent no-op), mcp (the Playwright MCP command; null args = the pipeline's pinned default), notes.

Gate 0 — mechanical preflight (no agents)

  1. Build and tests green on the exact HEAD, the repository's own way (the gate0 commands of the config, run by hand from the checkout). Keep the output where a commit message's "tested" claim can point at it (--context-paths).
  2. Freeze — freeze checks, all of them, before it exports anything: author AND committer are in authors; AI attribution trailers (Assisted-by:, Co-Authored-By:) by the aiTrailers rule; the tracked tree is clean (the allowDirty paths aside, warned); no merge 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 @{upstream} when there is one; an unsigned commit warns (requireSigned makes it fail). The base: --base, else by rangeFrom: @{upstream} (the push delta) or the merge-base of HEAD and the configured base branch (the whole pull request). Then the export, the hash, an empty gate-state/dispositions.json when none exists, freeze.json, run-line.txt, and with --worktree the detached checkout.
flatpak run net.catcrafts.CrafterReview freeze --slug <branch-or-release> [--worktree] [--base <ref>]

The hash is cat $(ls *.patch | sort) | sha256sum over the export; every pass binds to it. An amend or a rebase changes the From <sha> lines and therefore the hash: re-freeze with --replace into the same dir (gate-state/ survives, so clean unit results are reused from unit-state.json — what a re-freeze re-runs is under Gates 1–3).

  1. The application and the database under test (before a gate whose lens has an environment with either can run — Gate 2 by default; nothing to start when no lens that runs has one): start them from the tree the lenses read, at the frozen HEAD — the checkout itself when --trees ., the worktree when --worktree — with the config's functional.start (or supabase.start when no lens drives the application). With slots.*, bring up slot 1 first, then run flatpak run net.catcrafts.CrafterReview slots --artifact <dir>: it prints how many slots fit in free memory — no more than the range's browser passes can use at once — and the slots.start commands for the missing ones, which may run side by side. The driver refuses a gate while a stack's application or database does not answer, while its applied migrations are not HEAD's (a migration of HEAD not applied, one applied that HEAD lacks, or one of the range applied with other content — an amend without a reset), or while its login is not in its database; and it aborts the run if the tree moves or gets dirty beyond allowDirty while it runs. After freeze --replace, bring every stack up again from the new HEAD before the rerun. A fix during a run happens in another worktree, never in the tree the lenses read.

Gates 1–4 (the driver) and Gate 5 (the owner)

Gate Lens Question
1 Approach repo-better-solution (whole pass gating code blocks) an existing mechanism? the right layer? the right commit shape? compatible with what users have?
2 Functional repo-functional (whole pass gating code blocks; the browser or browser+supabase environment; absent when the config turns it off) 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 Proof repo-bug-prover (code blocks, static; with the supabase environment a proof may also be run in the database) a demonstrated functional defect in the range's code
3 Proof repo-security (code blocks, static, beside the bug-prover; with the supabase or a browser environment a demonstration may also be run in the database or the application) a demonstrated gap in a protection the repository promises: a check, policy or grant the range's code lacks, weakens or removes
4 Commit truth 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 this exact hash, then the push happens

Every lens runs on Opus 5.5 at the mode's effort (modes.<name>.effort in the config, else xhigh for a commit range); --effort overrides it for one run. There is no width of the machine by default (2026-10-31, Jorijn: "there is no need to do a global cap anymore, the cap should be 128 per available account"): every OAuth account takes at most 128 passes at once, every driver's counted (REVIEW_GATES_OAUTH_CONCURRENCY), an API seat its REVIEW_GATES_API_CONCURRENCY, and free memory the rest; --concurrency N still caps the machine when given (it was 128 by default from 2026-10-15, the width two drivers running at once shared). One full run per hash: a second on the same hash is refused while the first is live (State::RunLock; a test run takes a run number of its own), and no two drivers share a scratch dir. flatpak run net.catcrafts.CrafterReview seats shows the drivers running and what they hold. The seat pool still places every process by its seat's projected 5-hour headroom (REVIEW_GATES_LENS_COST_PCT, REVIEW_GATES_SEAT_MAX_UTIL) — or, on an account whose 5-hour limit Anthropic has removed, by its weekly window under the 99 % cutoff — at most 128 passes per account, every driver's counted (REVIEW_GATES_OAUTH_CONCURRENCY), and a pass launches only when its environment's instrument is free and it fits in free memory (How the passes share the stacks): the driver logs per gate how many passes of each environment memory and the stacks allow. Run from the repository root in a detached background shell (setsid nohup … & — a launch bound to a tool call's timeout dies with it) and poll ~/.cache/review-gates/<hash8>-run-<n>/progress.json; freeze prints the exact line and writes it to run-line.txt — launch from that file verbatim, never retyped (2026-09-29: a hand-copied base cut at 120 columns did not resolve and nobody noticed for a round).

flatpak run net.catcrafts.CrafterReview run --artifact .review-gates/push/<slug>/ --hash <sha256> \
  --base <base sha> --trees . --dispositions .review-gates/push/<slug>/gate-state/dispositions.json \
  [--context-paths <dirs>] --date <YYYY-MM-DD>

The lens processes run on the seats of a dotenv file: --env-file, else $SEATS_ENV_FILE, else the working directory's .env when it is a file holding a seat or a placement setting, or one that cannot be read as UTF-8 text (the run then stops at preflight with the read error; a readable application .env is passed over), else this machine's own ~/.config/crafter-review/env ($XDG_CONFIG_HOME/crafter-review/env). Its seat lines go over the process environment's; with no seat in either, this machine's own login. The seats are CLAUDE_CODE_OAUTH_TOKEN1..6 Max plans, placed by 5-hour headroom (weekly headroom where Anthropic has removed the 5-hour limit), and ANTHROPIC_API_KEY1..6 API-credit organisations, up to REVIEW_GATES_API_BUDGET_USD per seat per cycle; API seats go first unless REVIEW_GATES_SEAT_ORDER=oauth-first. The placement settings among the file's lines (the seat order, the budgets, the concurrency, the utilisation ceilings) count where the process environment has none; any other REVIEW_GATES_* line - a path, a binary - is not taken, and the run and seats name it. flatpak run net.catcrafts.CrafterReview seats reports them. An API seat is safe only when its organisation holds nothing but the plan's monthly credits — no payment method, no purchased credits, auto-reload off — so that "credit balance is too low" is a hard stop. --seats local|api|oauth|<list>, --no-probe, --api-budget override. OpenRouter seats and the opencode harness are carried for the hours when every seat is out (--seats openrouter --harness opencode); a lens whose environment has the browser or the database cannot run there (no MCP server; the database check reads the claude stream) — --without it, and the round is PARTIAL in spirit even though the driver calls it full: say so in the manifest.

Exit 0 SEND-READY = push-ready; 1 FIX FIRST; 2 INCOMPLETE / PARTIAL / CONFIRMING (gates clean, code units still owe clean reads — run the same line again, see "Three clean reads"); 3 preflight. A whole pass gates its block passes; coverage is fail-closed per inventory unit (inventory commits: pK:subject, pK:msg, pK:diffstat, pK:<file>#hdr, pK:<file>#H, pK:outside, pK:absent, whole:order, whole:absent, whole:behaviour); per-unit reuse across hashes, salvage of finished outputs after a kill, budgets, --only / --units test-only runs (PARTIAL, never a pass), the dump at gate-state/commits-<hash8>-run-<n>.json.

Who reads what. The four code lenses read code: hunks, file headers and (the static lenses) the per-commit slots pK:outside and pK:absent. The commit-truth lens reads the prose: the text units — subjects, messages, diffstats — and the units of documentation files (*.md), each heading-delimited part of a hunk on its own, and it asks whole:order and whole:absent in slot passes of its own; a documentation file's diff header and a part the commit writes nothing in are a mechanical n/a. A lens the run's mode does not list (in modes.<mode>.lenses of the mode --mode names: every lens is off until it does, and another mode's list turns nothing on in this run) reads nothing: the units only it would have read (its whole slot — whole:behaviour for the functional lens; the prose for the commit-truth lens) are listed under coverage.notReviewed, never as holes, and the summary says so. The functional lens plans no pass on a diff header, on a unit of a file matching functional.exclude, or on the per-commit slots (its whole pass judges adjacent flows and missing deliveries range-wide): each is a mechanical n/a with its basis.

What a re-freeze re-runs (the rules of 2026-10-06, with one addition for the functional lens):

  • a hunk or a file header, for the static lenses (the approach lens, the bug-prover, the security lens): its file and its own text, with the blob ids of the index line, the line numbers of the @@ header and format-patch's tail taken out. Not in the key: the commit id, the message, the unit's number, the rest of its commit. A reworded message, a new set of commit ids, a hunk or a commit added before it re-run nothing; a changed line in the hunk, context lines included, re-runs that hunk;
  • a hunk, for the functional lens: the same, plus the code of the whole range — what the application shows for one hunk depends on every other hunk (a later commit changes the screen it renders), so any code change in the range exercises every functional unit again. The static lenses' blocks stay self-contained by the owner's ruling (2026-10-06: "blocks fully own the change that results from their block even if its in other blocks") — the browser cannot;
  • pK:outside and pK:absent (static lenses) have no text and stand for one commit in the repository: they are asked again when any code of the range changed, or when a documentation file of their commit is added, deleted or renamed (absent also when the commit gains or loses its body). The commit's position is in the key;
  • a message (pK:subject, pK:msg), for the commit-truth lens: its own text, the base and the code of the range up to and including its commit — a fix to the code a message describes asks the message again; a diffstat its own text alone;
  • a unit of a documentation file, for the commit-truth lens: its file and its own text (normalised like a hunk), the base and the code of the whole range — a sentence must hold for the code as its commit leaves it and as the range leaves it;
  • the commit-truth lens's slot passes (whole:order, whole:absent): never stored, bought every run;
  • both whole passes: the code of every commit of the range in order, which commits have a body and which documentation files each touches. No message text and no documentation text. A whole pass that returned a finding is never stored, so it runs on the next hash whatever changed;
  • every code unit and both whole passes: the range base (a rebase re-runs all of them);
  • as always: the lens charter (the shipped text with the repository's section), the prompt version, the model, the dispositions in force. An edit to a repo-*.md charter, or to the repository's section of it, re-runs that lens on the next hash; a re-run on the SAME hash resumes and salvages by hash and model alone (use --no-reuse --no-salvage there).

What is stored as reusable: a pass that itself returned no undischarged finding, on a unit that is clean in the merged result; and where several units share a key (the same file header in two commits), only when all of them are. A reused whole pass gates its block passes like a fresh one and hands them the summary it stored. --no-reuse buys the full round.

Three clean reads (2026-10-06.5). A static lens is not deterministic: over one release's fifty rounds, of 58 defect verdicts on unchanged code at most 14 were in the block when a first read called it clean. The owner's rule: "per round each block gets run once, then in future rounds the blocks from the previous rounds get run again until they reach x"; x is 3 (--reads).

  • It applies to the code units of Gates 1 and 3: hunks, file headers and the per-commit slots. Both whole passes need one read; the functional lens's units need one read (their key already holds the whole range's code, so every code change exercises them again; flip reads in :Plan's lens table to make them three — three browser reads per hunk per round).
  • A round reads every such unit below three clean reads ONCE. A clean read counts; a finding drops the count (and its fix changes the key anyway); a changed key starts at zero. A unit at three is reused.
  • A round is the runs on one hash up to the first that finished. The relaunch of a killed or paused run continues its round; the same line run again after a finished run is the next round.
  • All gates clean with reads still owed is CONFIRMING — NOT SEND-READY (exit 2); run the same line again, on the same hash, until it says SEND-READY. Gate 5 is asked for a hash only then.
  • Every finding in the dump carries read, the number of the read that found it.

Verdicts, findings, dispositions

A lens reports findings (defects) and notes (everything else worth the record). It never grades a finding. What makes something a defect is the lens's charter question — objective wrongness: a better mechanism that exists, a demonstrated bug, a behaviour the application does not deliver, a protection the code lacks. "No reviewer would notice" is no defence. Every finding blocks the push until it is fixed or discharged by a disposition in force. Notes never affect the verdict and are never waived. Note kinds: reserve-answer, invariant, pre-existing, residual-risk, hold, info, immaterial, send-time-check, refutation-verified, unverified.

verdict — FIX FIRST iff any finding is undischarged. The driver derives it: SEND-READY declared over an undischarged finding fails closed; FIX FIRST declared with everything discharged and no contest is a silent veto — the derived pass stands and the violation is logged.

Dispositions discharge ONE named finding each and are never the lens's. Two kinds: waiver-owner (the owner's ruling — every owner ask of a gate stop batched into one message: finding, evidence, proposed grounds, cost of fixing instead; the round blocks until they rule) and refuted (a FACT that falsifies the finding's premise; a competing preference is an owner ask). Each entry is dated, names what it supersedes, binds to a hash, records grounds, and lists the artifact anchors its grounds depend on with the count of occurrences at binding. The register is <out>/gate-state/dispositions.json — the authority and the record; the manifest's rounds table names each discharge with its grounds for Gate 5. Carry across a hash change is mechanical: an entry is in force when bound to the current hash, carried when carry is true and every anchor still occurs its recorded count of times, lapsed otherwise — a lapsed entry discharges nothing and the finding re-blocks. The lens receives the entries in force, verifies the grounds of any it discharges, still reports the finding marked dischargedBy, and may contest one only with evidence the grounds do not survive; a contest is itself a claim and is verified before it reaches the owner; a reaffirmed or rejected contest is final for that binding. Every undischarged finding is fixed (amend → freeze --replace → rerun), refuted, or an owner ask. Notes never block: an invariant is recorded in the manifest, a send-time-check is a push-time check, an unverified names what a manual check must cover before the push, the rest are banked per kind.

A new disposition in force re-keys every unit, so a waiver costs every static code unit its three reads again.

Gate 5 — the owner, then the push

Present the frozen patches themselves (never summaries), the rounds table, every discharged finding with its grounds, every contest, every unverified note and every other note — then STOP. On their approval of the hash:

  1. flatpak run net.catcrafts.CrafterReview freeze --slug <slug> --verify must print MATCH (HEAD, a clean tree and a fresh export still equal freeze.json); a MISMATCH means the range moved and the review is void.
  2. git push (and open the pull request, where the repository's flow is a PR into its integration branch).
  3. git worktree remove .review-gates/trees/<repo>-<hash8> when one was used; stop the application (functional.stop).
  4. Record in <out>/manifest.md (flatpak run net.catcrafts.CrafterReview doc manifest prints the template): the rounds, approved: <date> @ <hash>, pushed: <date> <remote>/<branch> @ <sha>. .review-gates/push/ is gitignored; paste the rounds table into the pull request when the repository keeps its record there.

Driver changes and test-only runs

--only / --units runs are PARTIAL and never a pass. After any driver change: its suite passes (crafter-build test in a checkout of Crafter.Review), a --dry-run prompt comparison before and after on a frozen fixture shows exactly the intended differences, and a run proves each gate can still reject (a fixture repository with one planted defect per gate; AGENTS.md of Crafter.Review says how).

Feedback loop

A defect that reaches a pushed release, or a review comment on the repository nobody's lens predicted, is a process bug: post-mortem it and append a dated calibration entry where the lesson belongs: a lesson of this repository's in its own section of the lens that should have caught it (lenses.<name>.section), one general to the lens in the shipped repo-*.md charter, one of the mechanics here. The entries dated before 2026-10-15 were carried from the pipeline's earlier home because they are rules of the mechanics, not of one repository.

Calibration (append dated entries at the bottom)

  • 2026-09-25 — the pipeline's first form (Jorijn: "a slim version with better solution, bug prover and facts"). Decided: the bug-prover stays static; the author reads the frozen patches at Gate 4, there is no author sign-off gate before the lenses.
  • 2026-09-29 (round 13 of a release) — launch from the freeze's run line verbatim, never retyped. A round ran with a base hand-copied from a display cut at 120 columns; it did not resolve and the lenses fell back to the artifact's base-commit line. Copy from run-line.txt or build the command from freeze.json.
  • 2026-10-01 (round 15 of a release) — a scripted rebase must never commit at a stop it did not verify. One stop per command; before any --amend at a stop assert the subject of HEAD and git status free of UU entries. A chain rebuild runs as a sh -e script file, never typed into the tool shell (2026-10-14).
  • 2026-10-06 — a review pass is paid for again only when what it judged changed; measure what a round re-runs before the tenth round, not after the fiftieth. At every gate stop read the dump's passes run / reused per lens beside the findings, and say so when a round re-ran code that had not changed; a fix pass that touches only messages should cost nothing in Gates 1 and 3.
  • 2026-10-06 — a reuse rule is reviewed by someone who did not write it, and its STORE side is tested as hard as its key side. For every new key ask "two units, one key, different verdicts" and "a FIX FIRST round, then a text-only re-freeze". A prompt for a reviewer names the files and what was already verified, never the conclusion.
  • 2026-10-06 — a fix pass is where the next round's findings come from: delete, use the finding's own words, and sweep the fix's neighbours. A sentence added to pre-empt a finding is new surface; take the finding's own minimal proposedAction.
  • 2026-10-06 — agree the exit with the owner early. Once the code gates are SEND-READY and what remains is sentence-level or taste, put the stopping rule to the owner at THAT gate stop, with the trend; waiver-owner is the designed exit. Before offering "a re-run on the same hash, minutes": the driver RESUMES a finished lens result with its undischarged findings and SALVAGES old outputs by job label — re-running only the waived lens needs the checkpoint entry removed plus --no-salvage; the documented alternative is --no-reuse --no-salvage, a full round.
  • 2026-10-14 (a CI run of an image build) — when a push depends on an external mutable resource (a registry, a mirror, a tag, a hosted environment), the send-time check names every reader of it in the run and verifies the resource at the level those readers see; a precondition of step N is never repaired by step N+k of the same run.
  • 2026-10-15 — created as the submodule pipeline for the owner's repositories (Jorijn: "a repo that can be included as a git submodule and carries the prepush review pipeline … remove the facts lens and add a functional lens that is whole and block scoped that has a playwright mcp; this should be gate 2 and the bug prover should be gate 3"). First consumer: an application repository. Decided here, open to the owner's ruling: the functional lens's blocks are keyed on the whole range's code (above) and read once; its passes run four at a time by default on one shared application and database; the text and documentation units are listed as not reviewed rather than read by a lens the owner removed. The consumer's .env already carries six OAuth seats; the driver reads it from the consumer's root. Proofs at creation: the tests, a --dry-run on the pipeline's fixture range and on a real consumer range, and firing proofs.
  • 2026-10-15 — a lens that says it had no tools is not a pass; the driver must know what a pass had, not only what it returned. The first firing run of the functional lens (the planted greeter range, --only repo-functional.md) came back SEND-READY: all four hunks n/a with unverified notes saying "no mcp__playwright__* tools in this process" and hold notes saying "this SEND-READY is a formality". The charter's fail-closed wording worked — the lens refused to call anything clear — but the verdict rule (FIX FIRST iff a finding) still read it as a pass. Cause: the CLI's --safe-mode, which the driver passed to every lens process, drops every MCP server (mcp_servers: [] with it, connected without; CLI 2.1.300). Fixed in the driver: a functional pass runs without --safe-mode, and a pass whose init line shows no connected Playwright server is an error (retried once, then NOT RETURNED). Rule for any lens that depends on a capability the driver provides: assert the capability from the process's own init line, never from the lens's report of it.
  • 2026-10-22 — the database under test: two environments, supabase and browser+supabase (Jorijn: "can you make a browser+supabase envoirement and a supabase only envoirement? … can we give each agent its own supabase, or make them share one correctly?"). Decided here: the passes share the one stack the operator brings up. The database's sharing is made correct by the helper rather than by instruction: every call is a transaction it rolls back. A statement that would end the transaction is refused before anything connects, every statement goes to the server as its own Parse (two commands in one are refused), and a transaction found closed after a statement stops the call. Not chosen: Supabase's own MCP server (/mcp is served by Studio, which the throwaway bring-up leaves out, and its execute_sql commits), and one stack per pass. A stack per pass is possible on a machine that runs several side by side (a minimal scratch stack started in 33 s), but every stack also needs its own build of an application that bakes the API URL in, and its own seed. Also decided: raw psql is denied, the database URL never reaches a prompt, the preflight compares the applied migrations with HEAD's, and a defect found in one environment drops the clean verdicts the other environments hold for the same code. The firing proofs are in this pipeline's AGENTS.md.
  • 2026-10-22 — a stack per pass; the limit from free memory, by environment (Jorijn: "ideally i would like to remove the cap … by not making the database shared"; "make the concurrency limit from free memory, instead of just guessing … the concurrency limit should be on the environment"). functional.concurrency (4, never measured) and the per-lens cap are gone. A pass with the browser holds a stack of its own; every pass is admitted against free memory at its environment's measured cost. Costs are measured from a pass's or a stack's own processes, not from the machine's free memory, which other work moves — that number is the budget only. Not reset between passes: a slot keeps what earlier passes created, so Method 5's rule stays. The driver still never starts a stack: slots sizes them, the skill runs the commands. Numbers and proofs in this pipeline's AGENTS.md.
  • 2026-10-22 — the security lens, beside the bug-prover in Gate 3 (Jorijn: "please write the security lens", and written so that no process reads it as a request to do harm — a charter that reads so is declined, and a declined pass is NOT RETURNED). Decided here: the bug-prover's shape (static code blocks keyed on the code, three clean reads, none by default, the database or the browser when the config gives them), in the bug-prover's gate rather than one of its own — one gate stop for both proofs, and the owner stays Gate 4. The charter is the owner's own defensive review: the promise of the repository first, every decision point the hunk touches, the classes settled one by one, a gap shown in the smallest step on the throwaway local copy and never a step past it, refutations by the control that closes the class. A charter edit re-keys the lens alone. Its firing proof is in this pipeline's AGENTS.md.
  • 2026-10-31 — the driver as a Flatpak (Jorijn: "… make it an portable flatpak executable like Crafter.Build"). The helpers are subcommands (sql, totp). The submodule goes: crafter-review init sets a repository up and ships the skill, doc the texts, browser install the Chromium, stop ends a run from any sandbox. A driver takes its passes with it however it ends.
  • 2026-10-31 — accounts without a 5-hour limit (Jorijn: "antrophic is gradually removing the 5h limits"). Anthropic removes the 5-hour limit in stages, and an account without it reports the weekly window alone. Such an account is ranked and admitted by its weekly window with the passes in flight projected on it, never taken for an unprobed seat; every OAuth account takes at most 128 passes at once (every run measured with 149 or more in flight on one account drew usage-limit rejections); a 'concurrents' rejection backs the seat off for seconds, not until a window's reset; seats shows no limit in such an account's 5-hour column. Then (Jorijn: "there is no need to do a global cap anymore, the cap should be 128 per available account") the machine-wide width went: --concurrency caps only when given.
  • 2026-10-31 — every lens off by default, opt-in only; the commit-truth lens as Gate 4 (Jorijn: "make it so all lenses are off by default and it's opt in only"; and of the commit-truth lens, "fix that in Crafter.Review"). A config that turns on no lens is a problem config names and freeze and run stop on; init says to turn lenses on; the owner's gate is 5. The commit-truth lens's firing proof in this pipeline is owed (the owner's go).
  • 2026-11-01 — a mode is an artifact kind plus the lenses it runs and its effort (Jorijn: "instead of defining lenses in the config you define the mode and each mode enables the lenses"; "make effort configurable per mode"): modes.<name> in the config, the three kinds as modes of their own name, lenses.<name>.run retired with a warning, --mode naming any configured mode. Config::Defaults() runs no lens; 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). The first self-review - this pipeline on its own range - found the retirement not carried to the CI smoke config, this file, the shipped skill and the manifest template: every reader of a setting must agree (2026-10-12), and the run lock still keyed on the mode's name where the gate-state is the kind's.
  • 2026-11-01 — the generated skill stays untracked (Jorijn, as Crafter.Build was set up: a consumer that ignored .claude wholesale never saw the skill in git, and whether it should was the question): every consumer leaves .claude/skills/prepush/SKILL.md out of git, one init per checkout. init writes a .gitignore of * beside the skill, as a generated directory does, and edits nothing of the repository's for it: the first self-review's gate on the repository's own ignore rules was reworked twice in one range (a tracked path, one machine's rule) before the gate itself went (2026-10-31's rule).