mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* 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>
925 lines
58 KiB
Markdown
925 lines
58 KiB
Markdown
# 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`).
|