Files
gstack/docs/pregate.md
T
Garry Tanandcapy-ai[bot] 7f53ddb1f3 v1.91.257.0 feat(pregate): clear --receipt accepts a remote_gate receipt from a stamp-only predecessor tree (gbrain gate-ahead) (#3486)
* feat(pregate): clear --receipt accepts a remote_gate receipt from a stamp-only predecessor tree (gbrain gate-ahead)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes (hex object ids, stamp_sensitive_gates and base-branch refusals, one substitution type, docs)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): harden the stamp-only diff read (fixed presentation, diff-tree reconciliation), refuse an unreadable base policy, check the receipt before git work, bind verify to the receipt tree; docs audit corrections

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(pregate): the stamp-only suite expects the Linux-host lanes verdict; skip itself on win32 like test/pregate-remote.test.ts

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-10-26 22:09:32 +00:00

925 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The pre-gate: generated files, cheap checks, issue links
`/ship` Step 5 opens with `gstack-pregate`: the free checks that catch what the
multi-agent wave kept paying remote gates to find (stale generated files, a
scratch script at the repo root, a nightly-only test still asserting an old
constant, a Windows-only change merged with its lane unrun, a `| tail`-hidden
failure). Plan: `docs/designs/MULTI_AGENT_WAVE_2026_10_10.md` C4, C6, C8.
Result codes: [docs/troubleshooting.md](troubleshooting.md#pre-gate-generated-file-registry-gstack-pregate-issue-links).
Three bins, all over `lib/`:
| Bin | Pin file (read from `origin/<base>`) | Does |
|---|---|---|
| `gstack-regen` | `.gstack/generated.json` | generated files regenerate, never hand-merge |
| `gstack-pregate` | `.gstack/pregate.json` | two-stage cheap gate, `pregate.json` record, publication check |
| `gstack-issue-links` | — | `Fixes #N` only on explicit closure intent; Related PRs and validated flake issues |
## Trust boundary
A pin file names commands. Its presence never authorizes execution: both tools
read the committed copy on `origin/<base>` (a PR cannot smuggle a command into
its own gate; `gstack-pregate run` alone also reads a user-owned file outside
the repository when the base has none, see [pregate v1](#pregate-v1)) and run commands only with `--allow-repo-commands`, which `/ship`
passes. Standalone, `gstack-regen check` lists the commands and exits 3
(`REPO_COMMANDS_NOT_ALLOWED`); `gstack-pregate` marks `regen` and `lanes`
`incomplete`, never `pass`. `--policy-from head` (a PR that introduces or
changes a pin file) is accepted only with `--allow-repo-commands` and the record
says `policy head (unreviewed)`. An unresolvable base exits 3 with
`POLICY_SOURCE_UNAVAILABLE`. Globs are repo-relative (no `..`, no absolute
paths, at most 64 per list); commands are one line under 512 bytes.
<a id="generated"></a>
## `.gstack/generated.json` (generated-v1)
```json
{ "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1",
"entries": [ { "id": "skill-docs", "command": "bun run gen:skill-docs --host all",
"outputs": ["*/SKILL.md", "*/sections/*.md"], "inputs": ["*/SKILL.md.tmpl", "hosts/**"] } ] }
```
- `gstack-regen check` regenerates every entry (or `--only <id>`, or `--touched
<paths>` for entries whose inputs or outputs intersect) in a scratch
`git worktree` seeded from the working tree, with `node_modules` linked rather
than copied, and reports stale outputs (`REGEN_STALE`, exit 1) without touching
the tree. When outputs differ but `package.json` declares dependencies and
`node_modules` is missing, the entry is `REGEN_DEPS_MISSING` (install, then
rerun) instead of stale. An entry with no `command` is tracked only (goldens regenerated by
another entry).
- `gstack-regen write` regenerates in place: what `gstack-restamp` and the
Step 3 conflict rule use. On a conflict in a registered output, take either
side, then `gstack-regen write`; the agent never hand-merges a generated file.
- `list`, `validate`, `init [--explain] [--force]`.
gstack's entries: `skill-docs` (every host render, the agents digest, llms.txt,
the doctor component table) and `ship-goldens` (`bun scripts/gen-ship-goldens.ts`,
the byte-pinned `/ship` renders in `test/fixtures/golden/`).
<a id="pregate-v1"></a>
## `.gstack/pregate.json` (pregate-v1)
| Key | Meaning |
|---|---|
| `runner` | the local test command with `{files}` (gstack: `bun test {files}`); unset, it is detected (bun, vitest, jest, pytest, `node --test`, and `flutter test`/`dart test` per Dart package root, through fvm when that package or an ancestor pins it with `.fvmrc` or `.fvm/fvm_config.json` and fvm is on PATH; `cargo test -p <name>` per Rust crate, run from the root of the workspace the crate is a member of (Cargo's `members` globs, `exclude` and `package.workspace`), else the crate directory; a JS/TS test whose nearest package.json is a nested package declaring vitest or jest (a dependency or its `test` script) runs from that package, with package-relative paths, through the package manager its `packageManager` field or the nearest lockfile up to the repo root names: `pnpm exec`, `yarn`, `bun x`, else `npx`; every other JS/TS test keeps the root detection); nothing detected, a Dart SDK, cargo or package manager missing from PATH, a `.dart` file with no `pubspec.yaml` above it, a `.rs` file outside any crate, or one in a crate whose name Cargo would reject (anything but letters, digits, `_` and `-`), is `incomplete` (the runnable groups still run, so a real failure still fails) |
| `dependencies` | glob → test files for subprocess targets, runtime reads and generated inputs the import graph cannot see; `[]` declares "no lane needed" (docs, licences) |
| `empty_lanes` | extra globs for the docs/no-tests empty lane. Any Markdown file already qualifies with no pin file when no test depends on it (the same import graph as other files: a quoted path, a `join()` of path segments, an import, or a `dependencies` entry). reStructuredText and AsciiDoc count as Markdown here. README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without `.md`, and so does licence text under any vendored name (`roboto-LICENSE.txt`, `LICENSE-MIT`, `licenses/*.txt`). Source code and files under a test directory never qualify. A test that reads the file still selects that test |
| `selection` | `workflow.yml/job` → a command printing the lane's file list, for matrices a planner computes (gstack: `scripts/test-free-shards.ts --list`) |
| `warn` | check ids downgraded to warn; `secrets` and required lanes never |
| `strays.allow` | intentional root files or new top-level directories |
| `local` | `workflow.yml/job` lanes a local run satisfies: a platform-agnostic toolchain (an `ubuntu-latest` suite that runs the same on a Mac), or a repo whose CI is off. Their tests run here instead of becoming `requires-remote`, except that on a machine under 8 cores with a `remote_gate` or a remote `test_backend` the small-machine rule still records the test tier as a `requires-remote` obligation. `--explain` notes an id that matches no workflow lane |
| `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) |
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
| `stall_timeout_s` | seconds a test command may print nothing before its process group is killed and the check is `incomplete` (`PREGATE_STALLED`); default 300, `0` disables; `--stall-timeout` overrides (see [supervision](#supervision)) |
| `remote_gate` | the repo's remote test gate command; under 8 cores the local test tier becomes a `requires-remote` obligation naming it, and `--remote` runs it (see [remote gate](#remote-gate)) |
| `allowlist` | `[{ finding, paths?, pattern?, reason }]`: benign findings the `secrets` check accepts and names as allowlisted, by gstack-redact finding id or non-secret category, path globs and a pattern anchored at the finding; never `env.value` (see [allowlist](#allowlist)) |
Only the `origin/<base>` copy or the user-owned file below is honoured, so a
downgrade is reviewed (or is the user's own) before it takes effect. A repo that
keeps `.gstack/` out of git (a shared repo where gstack is one contributor's
tool) can hold the same file at `<state root>/projects/<slug>/pregate.json`. The
state root is `GSTACK_STATE_ROOT`, else `GSTACK_HOME`, else `GSTACK_STATE_DIR`,
else a gstack plugin's data directory, else `~/.gstack` ([docs/state-root.md](state-root.md)); `<slug>` is what
`~/.claude/skills/gstack/bin/gstack-slug` prints as `SLUG=` in the repository
(`GSTACK_PROJECT_SLUG` overrides it). It is read only when the base carries none
(never under `--policy-from head`), it lives outside the repository so a PR still
cannot reach it, and the record names it (`policy user <path>`) and fingerprints
its bytes (`policy_digest`, and each check's `inputs.policy`); `verify` and the
ship receipt treat an edited or deleted user file, or one that appears (or a
link on its path that starts pointing nowhere) after a run under the defaults,
as `PREGATE_STALE`. A path that is relative, resolves inside the repository, or
reaches it through any symlink on the way (a state root linked into the
checkout, or a link hop inside it, even when the last hop points back outside)
is ignored, since a PR could commit it or choose the file; `--explain` says
so, or says `(absent)` when no file is there. The slug and state root come from
the caller's environment, never the checkout's `.env`: the bin's shebang passes
`--no-env-file`, and a bare `bun gstack-pregate` launched beside any `.env` file
refuses (`POLICY_SOURCE_UNAVAILABLE`). `verify` reads both paths from the
record, not from its own environment.
It covers `gstack-pregate run` only: `sealed` and `price_file` are read from the
base branch's copy, so the user file refuses them (`PREGATE_CONFIG_INVALID`)
rather than holding a policy nothing reads. A user file that exists but cannot
be read, or a symlink on its path that points nowhere, refuses the same way
instead of falling back to defaults. A base copy git cannot read (and
`git ls-tree` cannot confirm is absent) refuses with `POLICY_SOURCE_UNAVAILABLE`
instead of handing the run to the user file.
`gstack-pregate validate` / `init` cover the working-tree file.
<a id="no-policy"></a>
**No policy yet.** A repository with no `.gstack/pregate.json` on the base
branch and no user-owned file runs under the defaults, and a touched file that
maps to no test is then a **warning**, not the hard `PREGATE_NO_LANE`: the
`lanes` row is `warn` with `PREGATE_NO_POLICY`, the selected tests still run
(a red lane is still `fail`, an unknown platform matrix is still a
`requires-remote` obligation), and the table prints a starter policy drafted
from the repository (`PREGATE_STARTER:` lines: the detected runner, the test
directories, the CI lanes, then the JSON between `begin` and `end`, with a
`dependencies` entry per unmapped touched file mapped to the tests nearest it
or sharing its stem, never `[]`, and a `TODO` line for each file nothing
covers). `run` writes nothing; `gstack-pregate init` is the explicit write.
Once any policy exists (committed on the base, or the user-owned file), an
unmapped file is `PREGATE_NO_LANE` again: the warning is for the first /ship
in a new repository (three Captains lost their first pass to it), never a way
to hide missing coverage. `--policy-from head` with a head policy is strict too.
`gstack-pregate init` writes a draft built from the repo, not a fixed example.
It prints the languages it found and the runner each language's tests would
get, then maps every file the import graph cannot place to the tests of the
nearest directory above it that holds at most 25 of them, up to the
256-entry schema limit. The draft never
writes `[]`, which would clear a file without a test. A file with no nearby
test, and a lane whose matrix a planner computes, is printed as a `TODO`.
Review each entry before committing the file on the base branch.
## `gstack-pregate`
```
gstack-pregate [--stage preflight|tests|all] [--only <check,..>] [--allow-repo-commands] [--policy-from base|head]
[--timeout <s>] [--stall-timeout <s>] [--remote --spend-cap <usd>] [--run <id>] [--explain] [--json]
gstack-pregate verify | clear --remote <workflow.yml/job> --run-url <url> | lanes | validate | init
```
Two stages with separate clocks, both printed:
**Stage 1, mechanical preflight** (the two-minute promise; gstack measured
~2.5 s for `regen` and under 200 ms for each of the others):
- `regen`: every registry entry whose inputs or outputs are touched is fresh
(`gstack-regen check` in a scratch worktree).
- `secrets`: `gstack-redact` over the branch's added lines (merge-base to the
working tree, untracked files included). HIGH, or MEDIUM in the `secret`
category, fails; other MEDIUM findings warn. Cannot be downgraded.
- `strays`: a new file at the repository root, a new top-level directory, or a
scratch name (`zz-*`, `tmp*`, `scratch*`) anywhere.
- `literals`: for every changed exported constant or named timeout/retry
literal, the whole test tree (nightly- and Heavy-only files included) is
searched for the old value; a consumer the branch did not update fails with
`file:line`.
- `timing`: added or changed test lines that compare an elapsed wall-clock or
CPU time against a bound, or sleep and then assert, print
`TIMING_ASSERTION: <file>:<line> <pattern>` and a
`TIMING_ASSERTIONS: found=<n> escaped=<n> files=<n>` summary. It warns
(`PREGATE_TIMING_ASSERTION`); it does not block. See [timing](#timing).
- `guards`: each guard in the active policy (`.gstack/pregate.json`, or the
user-owned file) whose `when` globs match a
touched file (renames included) runs its `command` in its own process, under
the verified launch Bun, with its own `timeout_s` (default `--timeout`). A
non-zero exit fails pregate naming the guard and its `fix`
(`PREGATE_GUARD_FAILED`); a guard that could not start, or whose own binary
is off PATH (exit 127), is the machine's, `PREGATE_ENVIRONMENT`
(`incomplete`), not a guard failure; a timeout is `incomplete`; without
`--allow-repo-commands` the selected guards are listed and the check is
`incomplete`. Guards are read from the base branch's copy (or the
user-owned file when the base carries none) and cannot be
downgraded to warn. See [guards](#guards).
**Stage 2, test execution:**
- `lanes`: touched files map to tests through `lib/pregate/import-graph.ts`
(static imports ∪ repo-path string literals ∪ the `bin/<name>` →
`test/<name>*.test.ts` convention ∪ `dependencies`; Dart `import`/`export`/`part`
directives resolve `package:<name>/x` through every tracked `pubspec.yaml`, and
`*_test.dart` files under a `test/` directory are tests, except under `integration_test/` and
`test_driver/`: they need a device, so pregate never runs them, and a touched
one has no lane until you declare `"**/integration_test/**": []` (and
`"**/test_driver/**": []`) under `dependencies` and let CI run them; Dart files, device tests
included, are source code, so a `dependencies` `[]` declaration clears them and `empty_lanes`
does not); in a Rust crate (the nearest Cargo.toml with a `[package]` name) a `.rs` file is a
test when it sits under `<crate>/tests/`, holds a `#[cfg(test)]` (any cfg predicate naming
`test`) or a test attribute (`#[test]`, `#[tokio::test]`), and every `.rs` file of the crate and its Cargo.toml select the crate's tests and
those of every crate whose manifest depends on it, directly or through other crates (cargo
runs a crate's tests, not a file, so the lane is the crate; a crate with no tests still has
no lane, and a workspace-only Cargo.toml or Cargo.lock needs a `dependencies` entry),
transitively through
non-test files; deleted and renamed files select the tests that named them.
An extensionless JS/TS import (relative, a tsconfig/jsconfig `paths` alias or
`baseUrl`) tries the exact path, then `.ts`, `.tsx`, `.js`, `.jsx`, `.mjs`,
`.cjs`, `.mts`, `.cts`, then `index.ts`, `index.tsx`, `index.js`, `index.jsx`
and `index.mjs` inside the directory; the first file that exists wins.
A touched source file with no lane is `FAIL lanes — <file> has no test lane`
once any policy exists (the base copy, the user-owned file or `--policy-from
head`); with no policy anywhere it is the `warn` `PREGATE_NO_POLICY` with a
starter printed ([no policy](#no-policy)); never a pass. Markdown that no
test depends on is a docs/no-tests empty lane
instead (`docs/no-tests: N documentation file(s)`), including when the base
branch has no `.gstack/pregate.json`. The dependency is the same import graph
as every other file, so a test that reads the Markdown (a fixture or a doc)
still selects that test. `empty_lanes` adds globs for other documentation;
it cannot clear a source file or a file under a test directory.
Files no test runner loads take a second empty lane by the same rule
(`no-tests (assets and tool config): …`): images, fonts, media and PDFs
outside snapshot, golden and fixture directories, plus an allowlist of tool
configuration (`.editorconfig`, `.gitignore`, `.pre-commit-config.yaml`,
lint and format config, `.vscode/`, `.idea/`, `.impeccable/`, and GitHub
issue templates, CODEOWNERS and dependabot config). Stylesheets (they import
each other), any other dotfile (`.rspec`, `.husky/` hooks, `.env*`, version
pins), CI workflows and actions, and `.gstack/` still need a lane.
A release manifest the branch only re-stamped is waived as
version-stamp-only (`version-stamp-only (no lane needed): …`): the file's
diff against the merge base changes nothing but its version values. Three
file kinds qualify, each by its own exact rule (lib/pregate/stamp-only.ts).
A JSON file (`*.json`) whose two sides parse to objects of the same shape
(the same own keys in the same order in every object, the same length in
every array, the same type at every leaf), whose only differing leaf is the
document's top-level `version` with a bare version on both sides (`1.2.3`,
`v1.2.3.4`, `1.2.3-rc.1`; never a range), and whose text is line-for-line
identical once that line has its old quoted version replaced by the new
one. A plist whose differing lines are all `<string>` versions under
`<key>CFBundleShortVersionString</key>` or `<key>CFBundleVersion</key>`. A
`VERSION` or `VERSION.txt` file (any case) holding one bare version, with at
most a trailing LF, on both sides. Texts are compared byte for byte outside
the stamped value, so a line-ending change or a BOM is content. Nothing
nested in JSON qualifies, so a dependency pin or range, a lockfile entry, a
catalog or a package named `version` is never a stamp; nor is a changed
description, a new, removed or reordered key, a floor such as
`minHostVersion`, `schemaVersion` or `MinimumOSVersion`, a reflow, a change
the parser would erase (a big integer, a duplicate key), a file that is not
valid UTF-8 or is over 1 MiB, a mode change or a symlink on either side, an
added, deleted or renamed manifest, or any other file kind: each keeps its
`PREGATE_NO_LANE`. The rule runs only after the import graph, the
declarations, the regen registry and both empty lanes found nothing, so a
manifest a test reads still selects that test, and never under a test,
fixture, golden, snapshot, `.gstack/` or CI-workflow tree, whose JSON a test
may compare against by a path the graph cannot see.
`--explain` prints the verdict per file (`version-stamp-only against
<merge-base> (version 1.0.0 → 1.0.1)` or `not version-stamp-only
(<reason>)`).
In a Django project (a manage.py or code importing django, plus Python
code, not a string or comment, that assigns `INSTALLED_APPS` or
`ROOT_URLCONF`, or a config file names `DJANGO_SETTINGS_MODULE`), the files
Django loads by name get `framework` edges: every Python test depends on
each settings module; a dotted string in a settings module and an
`include("app.urls")` argument count as imports; an app's migrations,
`admin.py`, `templates/`, `static/`, `templatetags/`, `management/`,
`fixtures/` and `locale/` files select that app's tests and the tests that
reach its `models.py` (the settings module, so the suite, when the app has
no tests of its own); a template under a project-level `templates/`
directory selects the suite through the settings module.
Lanes and platform tiers come from `.github/workflows/*.yml`
(`gstack-pregate lanes` prints them; a test path a step names resolves against
that step's `working-directory`, else the job's or workflow's
`defaults.run.working-directory`): local ones run through the runner under
`set -o pipefail`, judged on the runner's summary line (zero tests ran is a
failure, a `| tail`-hidden exit is still an exit; cargo's `test result:` lines, one per
test binary and doc-tests included, are summed); a platform lane this
machine cannot run becomes a `requires-remote` obligation naming
`workflow.yml/job`, unless `local` names that lane. `--explain` prints each test's provenance and every
uncertain mapping (a planner-computed matrix without a `selection` manifest
selects conservatively). Test files matching `serial_patterns` (default
`**/*.serial.test.*`) run one process per file after the unit files. The
runner and every guard run with the verified launch Bun first on PATH: the
first of `$BUN_INSTALL/bin` and `~/.bun/bin` whose Bun satisfies gstack's and
the project's `engines.bun` (the row prints `runtime: bun <v> from <dir>`),
so a Capy host's older image Bun cannot flip the verdict.
- Every test-tier command (the lanes runner, each guard, the `--remote` gate)
runs supervised: see [supervision](#supervision). `--timeout` bounds each
runner process (the unit-file group, each serial file, each guard without
its own `timeout_s`), not their total; the stall timeout catches a hung or
silent one sooner.
- Under 8 cores, a `remote_gate` in the active policy or a remote `test_backend`
(`gstack-config set test_backend ubicloud`) records the local test tier as a
`requires-remote` obligation instead of running it: see [remote gate](#remote-gate).
Every check records the input identity it ran against (the working-tree
fingerprint before and after, the selection, the command hash); a tree that
moved during a check, a timeout or a missing tool is `incomplete`, never `pass`.
The record is `.gstack/tmp/pregate.json` (`gstack-artifact validate --as pregate`).
**Publication.** `gstack-ship-receipt write --pregate .gstack/tmp/pregate.json`
(Step 18) refuses unless the record is current for the tree being published
(`PREGATE_STALE`: rerun after the final stamp), green (`PREGATE_LANE_FAILED`,
`PREGATE_INCOMPLETE`, or `PREGATE_ENVIRONMENT` when every incomplete check is
the machine's) and every `requires-remote` obligation carries a receipt
(`PREGATE_REMOTE_UNCLEARED`: dispatch the job, then
`gstack-pregate clear --remote <workflow.yml/job> --run-url <url>`). The ship
receipt then carries `pregate` and `requires_remote`. `gstack-pregate verify`
runs the same check standalone.
Unknown flags exit 2 with usage on every verb (an agent told to run a flag this
gstack lacks never gets a green it did not ask for). Tier 2 checks still to come
(`ratchets`, `lint`, `links`, `hermetic`, `patches`, `integrator`) are named in
`--only`'s help.
<a id="supervision"></a>
## Supervision: progress, stalls, deadlines
GBRA-95 (gbrain, 4 cores): a core edit selected 3,683 local files, 358 of them
serial; the test stage printed nothing for 90 minutes: each runner process
ran up to its own `--timeout` (one per serial file) with its output buffered
until it returned, and nothing noticed a process that had gone quiet. Now each
test-tier command:
- runs in its own process group (`setsid`), with stdout and stderr appended to
a log file, never a pipe, so a backgrounded grandchild that keeps the output
open cannot hold the check; after the command exits, the group's leftover
members are killed;
- reports live on stderr (stdout stays the table, or one JSON document with
`--json`):
```
PREGATE_TEST: <n>/<total> <file> start
PREGATE_TEST: <n>/<total> <file> pass|fail <seconds>
PREGATE_HEARTBEAT: <label> running <s>s; last file <file>; log <bytes> bytes, last growth <s>s ago (<log>)
PREGATE: tests <verdict> in <s>s — lanes: <detail>
```
Per-file lines come from a progress adapter where the runner's reporter
provides them: `bun test` (its `<file>:` headers) is the only adapter today;
a one-file run (each serial file) reports its file from the launch for any
runner. Other runners (node --test, vitest, jest, pytest, flutter test, dart test, cargo test) get heartbeats
only. A heartbeat follows each 60 s with no progress line;
- is killed as **stalled** when its log does not grow for the stall timeout
(`--stall-timeout <s>`, else `stall_timeout_s`, else 300; `0` disables): the
check is `incomplete` with `PREGATE_STALLED` and names the last file
started; the next serial file still runs;
- is killed as **timed out** when that runner process reaches `--timeout`
(per runner process, so many serial files that each fit still pass); the
check is `incomplete` and the next serial file still runs.
A kill is SIGTERM to the group, then SIGKILL after 5 s (a group that ignores
SIGTERM still dies). SIGINT or SIGTERM to `gstack-pregate` itself SIGKILLs every
running group, writes no `pregate.json` and exits 130 or 143. Windows has no
process groups here: the kill is `taskkill /T /F` on the shell's tree, which
misses a descendant that was reparented or started detached.
<a id="remote-gate"></a>
## Small machines and the remote gate
Under 8 cores (`os.availableParallelism()`), when the active policy (the
base branch's `.gstack/pregate.json`, or the user-owned file) declares `remote_gate` or `gstack-config get
test_backend` is not `local`, the lanes check runs nothing locally: it records
the selected local files as a `requires-remote` obligation with the command to
run, `pregate.json/remote_gate` (the declared command) or
`test_backend/<backend>` (gstack's `scripts/ubicloud/ubi-runner.sh run --src
<repo> -- '<runner> <files>'`). Without either, the tier runs locally under
the supervision above. Close the obligation one of two ways:
- after running it, `gstack-pregate clear --remote pregate.json/remote_gate
--receipt <file>` with the gate's output, validated as `--remote` validates it
(a run URL is refused, `PREGATE_RECEIPT_INVALID` for a bad receipt; a receipt
for a tree that differs from this one only by the repo's stamp rule is
accepted and recorded, see [below](#remote-gate-stamp-only)), or
`gstack-pregate clear --remote test_backend/<backend> --run-url <url>`, which
has no machine-checkable receipt and is recorded as `attested:<url>`; or
- `gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands`,
which runs the declared `remote_gate` now (never under `--policy-from head`;
a `test_backend` without `remote_gate` cannot be run this way). The gate runs
in the repository root with `PREGATE_TREE` (the working-tree fingerprint
being tested), `PREGATE_FILES_FILE` (the selected files, one per line) and
`PREGATE_SPEND_CAP_USD`, and must print
`PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>]`, reporting the
tree it actually tested. The obligation clears only when that tree is
`PREGATE_TREE`, `tests` > 0, `failed` = 0 and the gate exits 0; anything else
leaves it uncleared and the check `incomplete`. The cap defaults to
`$GSTACK_SPEND_CAP_USD`; there is no default spend. Spend prints as
`spent, reserved, unknown`: a gate that prints no `spent=` is `unknown`,
counted at the cap.
<a id="remote-gate-stamp-only"></a>
### A receipt from a stamp-only predecessor tree (gate ahead of the stamp)
The receipt `clear --receipt` validates names the tree the gate tested, and
pregate.json names the tree being published. A repo whose full gate runs before
its turn in the queue ("gate ahead": the gate tests commit G, then /ship's
restamp moves the version files, so the head H it publishes is G plus the
stamp) could never clear the obligation with that run, and paid a second full
gate on H for a diff that was nothing but stamps. `clear` now accepts that
receipt for H when the two trees differ only as a stamp does:
- gstack diffs the gated tree against pregate.json's tree
(`git diff --unified=0 <gated> <head>`, both trees as objects in this
repository: the gated commit's tree once it is fetched, and the head tree
`bin/gstack-wtree` wrote) and classifies every differing file with the tree
receipt's own rule, `lib/tree-receipt.ts` under `.gstack/ship-policy.json` on
`origin/<base>` ([ship-policy](ship-policy.md#tree-receipt)). That is the rule
`gstack-tree-receipt`, `gstack-pregate regate` and the train's
`verify --train` apply: with `stamp_files` set only those files can be
stamps, each hunk in them must be the exact old → new rewrite under
`stamp_patterns` (literal `{version}` templates) or the built-in forms
(`(was X)`, `since: X`, `vX`), and the version file, `package.json`'s
`version`, a lockfile's root version keys and a CHANGELOG re-heading have
their own built-in rules. There is no second list and no looser rule here:
the file verdicts `clear` reaches on a pair are the verdicts
`gstack-tree-receipt --gated G --current H` reaches, and its gate rule holds
too: a policy whose `stamp_sensitive_gates` names the test lane (`tests`, as
gstack's own does because its tests read VERSION and the agents digest) is
refused outright, since the tree receipt never marks such a lane reusable.
The policy is the one on this repository's base branch (`origin/HEAD`, as
`regate` reads it) at its current tip, never a branch pregate.json names: a
pregate.json recorded against another base is refused. What the receipt does
not compare, exactly as a same-tree receipt does not, is the gate's execution
identity (Bun, OS, lockfiles); that stays the evidence bundle's job.
- Accepted, the obligation's receipt reads `remote-gate tree=<gated>
tests=<n> accepted-for=<head> stamp-only=<file count> receipt=<file>
sha256=<digest>`, and pregate.json records the substitution beside it:
`substitution: { gated_tree, accepted_for, policy, files: [{ path, rule }] }`,
the policy label (`origin/<base>@<sha>:.gstack/ship-policy.json`) and every
differing path with the rule that classed it (`version-file`,
`version-manifest`, `manifest-version`, `lockfile-root-version`,
`changelog-heading`, `stamp_files`, `stamp_paths`, `release_outputs`). `clear` prints the same
as `PREGATE_RECEIPT_SUBSTITUTED: …`, `verify` repeats it on its ok line, and
the ship receipt carries the receipt string. The receipt's own tests and
failed checks apply as before (its exit status is checked only when
`--remote` runs the gate itself), and `verify` refuses a recorded
substitution whose `gated_tree` is not the receipt string's `tree=`.
- Refused, with `PREGATE_RECEIPT_INVALID` and a fix naming the offending
path: any differing path outside the rule (`lib/x.ts (stamp_files: not in the
repo's stamp_files allowlist, line 1)`), a stamp path whose hunk is not the
stamp rewrite (`plugin.json (stamp_files: "…description…" -> "…", line 4)`),
a gated tree that is not a 40- or 64-hex object id or not an object here
(fetch the gated commit first), a base branch whose policy cannot be read
or is invalid, a pregate.json recorded with `--base` against another branch
than this repository's base (`origin/HEAD`), a policy whose
`stamp_sensitive_gates` names the test lane (`tests` or `remote_gate`;
gstack's own policy does, so gstack itself never substitutes), or a patch
that does not account for every path `git diff-tree` lists between the two
trees. The version rule is the tree
receipt's: a `stamp_files`, `stamp_paths` or `release_outputs` hunk is a stamp
only as the rewrite from the gated tree's version to the head's (so two trees
at the same version refuse such a hunk as `other`), while the built-in
version-file, manifest, lockfile and CHANGELOG rules compare content and do
not check the direction of the version move. The obligation stays uncleared; rerun
the gate on the head, or make the extra change a stamp under the repo's
rule.
A consuming repo declares its stamp paths once, in `.gstack/ship-policy.json`
on its base branch (`stamp_files`, `stamp_patterns`, `changelog`), the same
file the tree receipt and restamp already read; nothing is declared in
`.gstack/pregate.json` for this. gbrain's is in
[gbrain worked configs](#gbrain-worked-configs).
The gate must have run on a clean, committed checkout: `PREGATE_TREE` is the
`bin/gstack-wtree` fingerprint, which equals the commit's tree only when nothing
was uncommitted or untracked there, and `clear` can compare only trees that are
objects in this repository. A receipt whose tree is not here is refused, never
guessed. The sequence that reuses a gate-ahead run, on a machine under 8 cores:
```bash
git fetch origin <gated sha> # the gated commit's tree must be here
gstack-pregate --allow-repo-commands # on the stamped head: records pregate.json/remote_gate uncleared
gstack-pregate clear --remote pregate.json/remote_gate --receipt <the gate's output for the gated tree>
gstack-pregate verify # PREGATE_VERIFY: ok …; pregate.json/remote_gate: gated tree … accepted for tree …
```
gstack's own example: `.gstack/pregate.json` declares
`"remote_gate": "bash scripts/ubicloud/pregate-remote.sh"`. With
`UBICLOUD_API_KEY` set the script runs the selected files on one Ubicloud VM
with `bun run test:ubicloud`'s environment, refuses before creating the VM when
the cap cannot buy a 20-minute run at the size's list price (`UBI_SIZE`,
default `standard-16`), checks the tree and the planned file set on the VM
before any test runs, and reports `spent=` from billed minutes. Without a key
it runs the same files locally in parallel shards
(`test-free-shards.ts --files-from`) and reports `spent=0`, after a
`backend=local (N cores; not the CI environment)` line. Paid test files in the
selection (skill-e2e and the other paid globs in `test/helpers/paid-test-set.ts`)
are named and left to the paid PR gate; a selection of only paid files reports
`tests=0`, which never clears the obligation. The command is the same either way
(`--remote` always needs a cap; the keyless path spends nothing).
pregate kills the gate at its `--timeout` (default 600 s) with a 5 s grace, so
the script finishes before that deadline: it reads the deadline from
`PREGATE_TIMEOUT_S`, else from the `gstack-pregate` command it runs under (its
`--timeout`, else 600), reserves 120 s for teardown plus 15 s margin, and tells
the VM to stop at whichever comes first, that point or the cap's wall (at most
45 minutes, minus 3 for teardown). A deadline that leaves the VM under 300 s
refuses before any VM (`PREGATE_REMOTE_TIMEOUT_TOO_SHORT`). At create time it
prints the VM's name and the exact
`bash scripts/ubicloud/ubi-runner.sh down <name>` line to destroy it if the
run is killed before its teardown line. The whole script is parsed before it
runs, so editing it during a run changes nothing. `--timeout 2700` gives a
whole-tier run room inside the 45-minute wall:
```bash
gstack-pregate --stage tests --remote --spend-cap 2 --timeout 2700 --allow-repo-commands
```
Measured (October 2026): keyed, all 1,280 free files on standard-16 took 404 s
from start to teardown (create 24 s, setup 72 s, tests 250 s) for $0.06.
Keyless on 4 cores, 98 files took 103 s, and GSTA-35's 798-file selection took
3,550 s, so keyless is practical for small selections only. A large selection
on a small machine needs a key, or a `--timeout` longer than the run. Keyless also runs in
your environment, not CI's: a file that needs what setup-free-suite.sh installs
(Playwright's Chromium, Xvfb) fails locally until you install it.
Every refusal line starts with a reason code:
| code | exit | meaning |
|---|---|---|
| `PREGATE_REMOTE_USAGE` | 2 | an input is missing or malformed; run it through `gstack-pregate --stage tests --remote` |
| `PREGATE_REMOTE_TREE_MISMATCH` | 3 | the working tree is not `PREGATE_TREE`; nothing uploaded, nothing spent |
| `PREGATE_REMOTE_TREE_MOVED` | 3 | the tree changed during the run (the moved paths are listed); no result reported |
| `PREGATE_REMOTE_VM_TREE` | 3 | the VM's unpacked tree or planned file set differs from what was checked locally |
| `PREGATE_REMOTE_NO_RESULT` | 1 | test-free-shards printed no result (a rejected file list) |
| `PREGATE_REMOTE_PAID_ONLY` | 0 | every selected file is a paid test file; `tests=0` cannot clear |
| `PREGATE_REMOTE_CAP_TOO_LOW` | 5 | the cap cannot buy a 20-minute run at the size's price; nothing created |
| `PREGATE_REMOTE_NO_PRICE` | 5 | `UBI_SIZE` has no list price in the script |
| `PREGATE_REMOTE_TIMEOUT_TOO_SHORT` | 5 | pregate's deadline leaves the VM under 300 s after the 135 s teardown reserve; nothing created |
| `PREGATE_REMOTE_INFRA` | 4 | create, setup, SSH or the stop deadline failed; the VM, its teardown line and `spent=` follow |
| `PREGATE_REMOTE_INTERRUPTED` | 130 | TERM or INT; ubi-runner tears the VM down on its own |
<a id="guards"></a>
## Guards and the lane-done rule
A lane runs `gstack-pregate run --allow-repo-commands` on its branch before it
reports done, and its handoff carries the `PREGATE:` line (the coordinator
contract says so word for word). The unit tier covers the changed files'
tests; guards cover the repo's mechanical gates the import graph cannot see:
```json
{ "guards": [
{ "id": "serial-manifest", "when": ["test/**/*.serial.test.ts", "scripts/serial-files.tsv"],
"command": "bun test test/scripts/serial-files.test.ts",
"fix": "add the file's row to scripts/serial-files.tsv" } ] }
```
`dependencies` still maps globs to extra *tests* the lanes check selects; a
guard is a *command* with its own fix line. Older gstack refuses unknown keys
(`PREGATE_CONFIG_INVALID`), so a repo commits `guards` only after the gstack
that reads them ships. The worked gbrain configuration is in
[gbrain worked configs](#gbrain-worked-configs).
<a id="allowlist"></a>
## Allowlist: benign findings the secrets check accepts
The `secrets` check cannot be downgraded, and that is right for a key. It also
refused 1,800 per-trial dummy tokens in eval receipts (`"key": "sk-trial-<id>"`
reads as `env.kv`), an 11-digit Actions run id read as a phone number, and
excalidraw.com's public Firebase key that every rebuild of a bundle re-adds
(harness-evals #81/#83, GSTA-102, GSTA-106). `allowlist` in the policy names
those, each with its scope and its reason:
```json
{ "allowlist": [
{ "finding": "env.kv", "paths": ["evals/**/receipts/*.json"], "pattern": "sk-trial-[0-9a-f-]+", "reason": "dummy per-trial meter ids" },
{ "finding": "pii.phone.e164", "pattern": "\\d{11}", "reason": "GitHub Actions run ids in reports" },
{ "finding": "google.api_key", "paths": ["lib/render/dist/bundle.js"], "pattern": "AIza[0-9A-Za-z_-]{35}", "reason": "the vendor's public web key, restricted server-side" } ] }
```
- `finding` is a gstack-redact finding id (`env.kv`, `google.api_key`,
`pii.phone.e164`, `internal.hostname`, …) or one of the categories `pii`,
`internal`, `hygiene`, `legal`. A secret is always named by its id; the
`secret` category cannot be allowlisted whole.
- `paths` are contained globs over the added file's repo path; `pattern` is a
regular expression matched **at the finding's position in the added line**
and ending at a token boundary, so a dummy token's shape never covers a real
key that shares its line or starts the same way. One of the two is required;
`reason` always is, and the record repeats it.
- **`env.value` is never allowlisted.** It is the real value of a
credential-named variable of the environment pregate ran in (#3449), found in
the added lines whatever its shape. Validation refuses an entry naming it
(`PREGATE_CONFIG_INVALID`), and the matcher never consults the list for it,
so a stale or hand-edited policy cannot silence it either. The scanner's own
`engine.*` refusals (an oversize input fails closed) are refused and kept the
same way: they are the scanner declining its input, not a finding in it.
- A `pattern` entry needs the finding's position. When the scanner gave none,
the entry is inert for that finding and the row says so (`allowlist[0]
env.kv not tested on env.kv (<file>): the scanner gave no position for its
pattern; the finding is kept`) rather than silently keeping it.
The `secrets` row says `N allowlisted (<ids>)` and lists each allowlisted
finding with the entry (`allowlisted env.kv (evals/receipts/t1.json, added line
3) by allowlist[0] env.kv: dummy per-trial meter ids`); `inputs.allowlist`
carries the entries' digest, so a changed list is a changed input. The list is
read from the base branch's copy (reviewed) or the user-owned file, like every
other key; an unparsed scanner report keeps its exit-code verdict and is never
allowlisted.
<a id="environment"></a>
## Environment failures, apart from diff failures
A lane that could not run is not a lane that failed. When a check could not run
its command because of the machine (truther #617: a root runner with a vitest
config that found no file, a corpus fetch that stalled on one host and passed
on another), the check is `incomplete` under its own code,
**`PREGATE_ENVIRONMENT`**, with `cause: "environment"` and an
`environment: { kind, evidence, proof }` object in `pregate.json`, an
`environment: <kind> — <evidence>` table line, and a fix that says so. A
failure the branch caused carries `cause: "diff"` (`PREGATE_LANE_FAILED`,
`PREGATE_GUARD_FAILED`, `PREGATE_SECRETS`). Kinds:
| Kind | Evidence |
|---|---|
| `missing-binary` | exit 127, `command not found`, a spawn `ENOENT`, a runner tool `not on PATH` (dart, flutter, cargo, a package manager) |
| `network` | `ENOTFOUND`, `ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, `EAI_AGAIN`, `Could not resolve host`, `fetch failed`, a TLS handshake or certificate failure |
| `quota` | `quota`, `429 Too Many Requests`, `rate limit`, `insufficient_quota` (a vCPU quota on the remote backend) |
| `disk` | `ENOSPC`, `No space left on device`, `EDQUOT` |
| `runtime` | the install verdict or the pinned Bun refused (`BUN_RESOLUTION_DRIFT`, `INSTALL_UNVERIFIED`) |
| `tool` | gstack's own scanner (gstack-redact) did not finish or exited outside its contract |
The classifier is conservative. A local lane that exited non-zero is read from
its output **only when its runner reported no test summary** (nothing ran) and
the runner was not seen starting a test file; a run whose summary counts `N
ran, M failed` is the diff's failure whatever its log says, so a test that
failed on `ECONNREFUSED` is still a red test. A remote gate that exited
without a `PREGATE_REMOTE:` line is read from its **preamble only** (its first
40 lines, before any test output could stream): a VM refused for quota or an
unreachable API there never ran the tests, so it is environment and the
obligation stays uncleared, while a `quota` in a streamed test name is not. A
guard that ran and said no is the diff's. Exit 127 and `command not found`
count as `missing-binary` only when the command's **own** binary is off
PATH: a runner, guard or gate script the diff controls that exits 127 (a
package script naming a tool that is not installed) is the diff's failure. A
stall or a timeout keeps `PREGATE_STALLED` / `PREGATE_INCOMPLETE`: a hang
has no single cause, and so does a runner killed by a signal.
`proof` records how the cause was established. `launch` is certain: the
command could not start, its binary is off PATH, the runtime or a gstack tool
refused; the fix ends `the diff is not at fault`. `output` means a rule
matched the command's text before any test was counted, which the diff's own
output could have written (a test that prints `fetch failed` and exits the
process before the summary); the table line says `(inferred from output)` and
the fix asks for confirmation on another machine or with `--remote` before
the diff is treated as clear. Neither proof ever passes the check.
`gstack-pregate verify` reports `PREGATE_ENVIRONMENT: <n> check(s) blocked by
the environment (<kinds>), not by the diff` when every incomplete check is
environment-caused, naming any check whose cause was inferred from output
alone, and names the kinds beside the generic `PREGATE_INCOMPLETE` otherwise;
`--json` carries `cause` and `environment` per check. A lane that waives such
a run cites the kind, the evidence and the proof, not a test name, and clears
the lane remotely (`--remote`, or `gstack-pregate clear` with a run URL)
rather than on the broken machine. Incomplete never passes.
<a id="scratch"></a>
## Scratch: one directory per run, under the state root
Every run owns `<state root>/projects/<slug>/pregate/runs/<run id>/` (mode
0700; `<run id>` is `--run <id>` reduced to `[A-Za-z0-9._-]`, else
`<UTC stamp>-<pid>-<4 hex>`). The secrets scan's added-line copies
(`secrets/`, deleted after the scan), the lane log (`lanes/lanes.log`), each
guard's log (`guards/<id>.log`) and the remote gate's log and file list
(`remote/`) live there, never under `os.tmpdir()`: two pregates in parallel
(two lanes on one machine, a subagent beside its parent) never share a path,
and the log a `fix:` line names survives a host that wipes `/tmp` between
wakes. `pregate.json` records the directory as `scratch` and the table prints
`scratch: <dir>`. A `--run <id>` whose directory already exists (a finished run
under the same name, or a second run beside a live one) is never shared, since
a second run would truncate the first's logs: the run takes `<id>-2` … `-9`
and the record names the directory it got. Runs older than 7 days under the
same project are pruned when a new run starts (the current run never is). An
unwritable state root falls back to a private directory under `os.tmpdir()`,
still one per run, and `--explain` says so.
<a id="timing"></a>
## Timing assertions
`timing` reads only the lines the branch added or changed in test code (test
files and files under `test/`, `tests/`, `spec/` or `__tests__/`; never
fixture trees). It reports three patterns:
| Pattern | Example |
|---|---|
| `wall-time-bound` | `expect(Date.now() - t0).toBeLessThan(400)`, `expect(elapsedMs).toBeLessThanOrEqual(2_000)`, `assert elapsed < 0.5` |
| `cpu-time-bound` | `expect(cpuMs(() => parse(x))).toBeLessThan(150)`, `assert time.process_time() - c0 < 0.2` |
| `sleep-then-assert` | `await Bun.sleep(50); expect(calls).toBe(1)` (a fixed sleep, not a polling loop) |
A comparison with `0`, a named bound (`timeoutMs`, `TTL_MS`), a timestamp
ordered against a bare `Date.now()` and a `while (… Date.now() < deadline)`
poll are not reported. Changing the bound of an existing assertion is a changed
line, so it is reported.
**Escape.** When the bound is the contract (a hang check with wide headroom, a
CPU-time bound on a pure function), put `gstack-timing-ok: <reason>` in a
comment, in any comment syntax (`//`, `#`, `/* */`, `--`), on the line or
the line above. The reason is required; an empty one is still reported with
`(gstack-timing-ok needs a reason)`. `--explain` lists every escape and its
reason.
**Warn, not block (measured 2026-10-16).** The detector was run over:
- gstack's own test tree: 263 hits in 123 of 1,566 test files (176
`wall-time-bound`, 87 `sleep-then-assert`), mostly real timing assertions,
most of them generous "does not hang" bounds.
- gstack's last 51 commits on main: 9 of the 47 that touched tests would have
been flagged (29 hits; #3190 alone has 7).
- gbrain's last 100 commits on master: 37 of the 95 that touched tests would
have been flagged (147 hits).
- The five gbrain timing-flake fixes (#6050, #6493, #6726, #6746, #6862): 31 of
38 fix sites were flagged before the fix. The misses are flakes with no
timing comparison on the line (an injected deadline in #6050, a
`Promise.race` with a sleep and a deadline parameter in #6862) and ratio or
percentile checks computed far from the clock read. The correct fix in
#6726 (wall clock to thread CPU time) is still a bound and is reported 21
times.
Blocking would stop about a fifth of gstack's and two fifths of gbrain's
test-touching commits, including correct fixes, so the check warns. A repo
that wants it stricter keeps the warning in its review rule (the /review
checklist has the same rule) and /investigate's flake playbook says what to do
with a timing flake: never raise the bound.
<a id="sealed"></a>
## Sealed output: the pre-push hook and `gstack-pregate sealed`
```json
{ "sealed": {
"paths": ["heldout/**"],
"transcripts": ["runs/**/transcript*.jsonl"],
"answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"],
"local_paths": true } }
```
A repo with a `sealed` key gets a configured local check against accidental
publication whenever the managed pre-push hook (`gstack-redact
install-prepush-hook`, no reinstall needed) or the standalone preflight runs:
```
gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--json]
```
- **Boundary.** Every commit the push publishes: `git rev-list <local sha>
--not --remotes=<remote>` (and the remote tip when the ref exists); a
destination with no refs means everything reachable. `--range` names the
commits explicitly.
- **Rules.** `sealed.path` and `sealed.transcript` check every path a commit
adds, renames or copies, whatever its content (binary, empty, deleted again
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
(`/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\`) check each commit's added
lines. Findings print rule, `file[:line]` and commit, never the text.
- **Policy.** The union of the destination base branch's `sealed` and the
pushed commit's: a push can tighten it, never relax it. A malformed or
unreadable policy refuses. Waivers come only from the base branch's
`.gstack/sealed-allow.tsv` (`rule<TAB>path<TAB>sha<TAB>reason`) and are
listed on every push.
- **Visibility.** `sealed.visibility` from the base branch, else a cache under
the state root (`sealed-visibility.json`: a `public` answer for 24 hours, a
`private` one for 1 hour before it may turn a refusal into a warning), else
`gh repo view --json visibility` (5 s). Unknown, unreadable or newly created
counts as public. Public refuses (`SEALED_OUTPUT_REFUSED`, exit 1); private
warns.
Capy's pull-request tool may not run local git hooks, so an agent runs
`gstack-pregate sealed` before any push or PR creation in a repo with a
`sealed` config (the coordinator contract says so). Hosted CI on the public
repository only detects a bypass after the upload.
<a id="regate"></a>
## `gstack-pregate regate`: does the gate still cover HEAD
```
gstack-pregate regate --against <gated-head> --bundle <evidence-bundle.json> [--require <lane,...>]
[--pr <n>] [--from <gate-ahead.json>] [--predecessor-pr <n> | --predecessor-ref <ref>]
[--base <branch>] [--json]
gstack-pregate --against <gated-head> ... the same verb
```
It evaluates a tree receipt now (never a stored one) between the gated head
and HEAD under the base branch's ship policy, so the repo's `stamp_files` and
`stamp_patterns` decide what a stamp is ([ship-policy](ship-policy.md#tree-receipt)).
Reuse needs an evidence bundle produced on the gated tree, the same execution
identity, and a predecessor head that is still the recorded one (read from
`--from`, default `.gstack/tmp/gate-ahead.json` when it names this gated
head). It prints each differing file (a non-stamp difference with its first
line), then:
```
STAMP_ONLY: yes|no files=<n> [changed=<n>]
LANE: <label> reusable|rerun|not-reusable (<reason>) one per bundle lane and per --require lane
MIGRATION_COLLISION: none (...) | unknown (...) | <id> <file> vs <origin/main|PR #n> <file>
REGATE: not-needed | needed (<every reason>)
```
`REGATE: not-needed` (exit 0) only when the tree is stamp-only, every lane in
the bundle and in `--require` is reusable (a stamp-sensitive lane reruns, so it
needs a regate), the predecessor head is known and unmoved, and the migration
lookup finished with no collision. Anything else is `needed` (exit 1); no
bundle prints `REGATE: needed (no gate evidence for <head> ...)`.
Migrations come from the ship policy's `migrations` (`dir`, `id` regex with
one capture group). The numbers this branch adds under `dir` are compared with
the base branch's tree and with every other open PR
(`gh pr list --state open --limit 1000 --json number,headRefOid,files`; the PR
itself is skipped by head sha or `--pr`). A PR listing 100 files is truncated
by GitHub, so its full list is fetched with `gh api --paginate`. A failed
query, 1000 listed PRs or an unfetchable file list is
`MIGRATION_COLLISION: unknown (...)`, which blocks `not-needed`
([`MIGRATION_COLLISION`](troubleshooting.md#migration-collision)).
<a id="gbrain-worked-configs"></a>
## gbrain worked configs
gbrain commits these on master after the gstack that reads the new keys ships
(older gstack refuses unknown keys). Its `.gitignore` ignores `.gstack/*`
today, so the change also adds `!.gstack/pregate.json` and
`!.gstack/ship-policy.json`; both changes are gbrain's.
`.gstack/pregate.json`: guards for the gates the import graph cannot see.
```json
{
"dependencies": {
"test/**/*.serial.test.ts": ["test/scripts/serial-files.test.ts"],
"scripts/serial-files.tsv": ["test/scripts/serial-files.test.ts"],
"src/cli.ts": ["test/cli-goldens.test.ts"],
"src/commands/**": ["test/cli-goldens.test.ts"],
"src/core/operations*.ts": ["test/cli-goldens.test.ts"]
},
"guards": [
{ "id": "serial-manifest", "when": ["test/**/*.serial.test.ts", "scripts/serial-files.tsv", "scripts/run-serial-tests.sh"],
"command": "bun test test/scripts/serial-files.test.ts",
"fix": "add the serial file's row to scripts/serial-files.tsv" },
{ "id": "cli-goldens", "when": ["src/cli.ts", "src/cli/**", "src/commands/**", "src/core/operations*.ts", "src/core/ops/**"],
"command": "bun test --timeout=60000 test/cli-goldens.test.ts test/cli-dispatch-phase.test.ts test/export-surface-golden.test.ts",
"fix": "bun run regen:all, then justify each changed golden in the PR body" },
{ "id": "regen-check", "when": ["src/**", "skills/**", "templates/**", "plugin/**"],
"command": "bun scripts/regen-all.ts --check",
"fix": "bun run regen:all and commit the regenerated files" },
{ "id": "migration-order", "when": ["src/core/schema-migrations/**"],
"command": "bun scripts/check-schema-migration-order.ts",
"fix": "renumber the migration to the next free number on master" }
]
}
```
`.gstack/ship-policy.json`: the stamp rule and migrations. On a pure restamp
of a gbrain master tree from 0.60.188.0 to 0.60.189.0 the tree receipt
classifies all 16 changed files as stamps under this policy; an added line in
`src/core/behavior-change-notice.ts` stays a change.
```json
{
"bump": "patch",
"queue_mode": "stamp-at-merge",
"changelog": "CHANGELOG.md",
"stamp_files": [
"VERSION", "package.json", "CHANGELOG.md", "openclaw.plugin.json",
".claude-plugin/plugin.json", ".codex-plugin/plugin.json",
"plugin-variants/*/.claude-plugin/plugin.json", "plugin-variants/*/.codex-plugin/plugin.json",
"plugin-variants/*/README.md", "plugin/README.md",
"BOOTSTRAP_FOR_AGENTS.md", "templates/bootstrap/template-repo/README.md",
"src/core/behavior-change-notice.ts"
],
"stamp_patterns": [
"\"version\": \"{version}\"",
"since: '{version}'",
"<!-- gbrain-plugin-tree-stamp: {version} -->",
"<!-- gbrain-template-stamp: {version} -->",
"<!-- gbrain-runbook-stamp: {version} -->"
],
"migrations": { "dir": "src/core/schema-migrations", "id": "^v(\\d+)-" }
}
```
gbrain's `bun run release:restamp` fetches master, picks its own version,
refuses a dirty tree and commits, so it cannot be `restamp_command` as it
stands; the stamp rule above lets gstack's own restamp writer do the work.
A gbrain mode that takes the version and writes only stamps would make it one.
gbrain-evals `.gstack/pregate.json`: the sealed paths. The globs below are
placeholders in the shape the guard reads; gbrain-evals fills in its own
held-out and transcript layout.
```json
{ "sealed": {
"paths": ["heldout/**"],
"transcripts": ["runs/**/transcript*.jsonl"],
"answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"],
"local_paths": true } }
```
<a id="issue-links"></a>
## `gstack-issue-links`
```
gstack-issue-links [--base <ref>] [--issue <n>]... [--ci-run <id>]... [--supersedes <pr>]... [--credit <pr>]...
[--no-auto-links] [--author <login>] [--repo <owner/repo>] [--out issues.json] [--json]
```
Reads `origin/<base>..HEAD`. `Fixes #N` only on explicit closure intent: a
`Fixes`/`Closes`/`Resolves #N` trailer line or `--issue N`. A bare mention
(a subject's `(#N)` included), a partial fix, a deferred ask and every
cross-repo `owner/repo#N` are `Refs`. Same-repo references are printed as
promotion candidates (`reference #N ← <commit line> (promote: fix #N)` on
stderr, `candidates` in `--json`): the owner's `fix #N` reply reruns with
`--issue N`; an unattended run keeps them `Refs` and lists them in the
handoff. Each closure issue is read through `gstack-issue-guard`; one that
cannot be read is `Refs #N (unreadable …)` with `ISSUE_UNREADABLE` (exit 1),
never `Fixes`.
Each `--ci-run <id>` is searched among open issues whose body names it and
rendered `Flake: #N (run <id>)` only for a bot-opened issue of this repository
that names that run (its `flake:json` `first_seen.run_id`, or this repository's
`actions/runs/<id>` URL) and a test (`flake:json` `file › test`, or a
`Flake: <test>` title). Two issues for the same test keep the lower number;
every refused match is printed with its reason.
Open PRs by other authors (not this branch, and not this PR's author: `--author`,
else the `gh` viewer; when the viewer is an app such as `capy-ai[bot]`, the HEAD
commit's GitHub user, else the owner of a user-owned repository, else the gap is
`ISSUE_LOOKUP_FAILED`) whose closing
set overlaps this PR's `Fixes` are `Related: #N (@author)`, read page by page
from each issue's closing-PR references, in unattended runs too. `Supersedes #N`
replaces that line and `Contributed by @author` is added (the credit lines of
`lib/contributor-mode.ts`) only with `--supersedes N` / `--credit N`, after the
owner confirms.
`--no-auto-links` skips the lookup. A failed lookup prints `ISSUE_LOOKUP_FAILED`
and keeps the other lines. The stderr preview names the source of each line.
The printed lines go under `## Issues` in the PR body; `--out` writes the
object (`fixes`, `refs`, `flakes`, `related`) `gstack-ship-receipt write
--issues @file` embeds. With `--body`, that write refuses a body missing any of
those lines (`ISSUE_LINKS_MISSING`).