mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
* pregate: --policy-from head only adds lanes; timeout_s policy default; preflight forwards --policy-from (GST-419, GST-420) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (head record fingerprints the trusted user policy; remote test timeout) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: --remote under a bootstrap head policy is refused (Step 11 adversarial) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: pregate timeout order, PREGATE_TIMEOUT_S, head-policy follow-ups in TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: regenerate gstack-doctor-components.sh for PREGATE_HEAD_POLICY_LOOSENS 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>
1062 lines
69 KiB
Markdown
1062 lines
69 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)`. Under it the branch's `pregate.json` may only
|
||
add test lanes over the trusted policy (the base copy, or the user-owned file
|
||
when the base has none): a new `dependencies` glob with at least one test, or
|
||
more tests on a glob the trusted policy already maps. A removed or shrunk glob,
|
||
a new `[]` declaration, or any other key that differs (runner, guards, warn,
|
||
allowlist, timeouts, the remote gate, ...) refuses the run with
|
||
[`PREGATE_HEAD_POLICY_LOOSENS`](troubleshooting.md#pregate-head-policy-loosens).
|
||
A branch behind its base is measured from the copy at its merge base and runs
|
||
the base tip's policy plus its additions. With no trusted policy at all, the
|
||
head file is a bootstrap and runs as written. `.gstack/generated.json` is still
|
||
read from the branch under `--policy-from head`. So an allowlist entry or a
|
||
`[]` declaration a branch adds counts only once it is on the base, and a lane it
|
||
adds counts on the base policy only once it lands; `PREGATE_NO_LANE`'s fix line
|
||
says so when the branch edits the file
|
||
([troubleshooting](troubleshooting.md#pregate-no-lane-own-policy)). 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)) |
|
||
| `timeout_s` | seconds each runner process may take (the unit-file group, each serial file, each guard without its own `timeout_s`, and the remote gate's deadline) when `--timeout` is not passed; 1 to 3600, default 600. `gstack-ship-preflight` and the `finish` fallback run pregate without `--timeout`, so this is how a repo with long lanes sets theirs |
|
||
| `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
|
||
(under `--policy-from head` it is the trusted policy the head file is compared with), 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/always-run.json`
|
||
|
||
`{ "tests": [globs] }` names whole-tree tests that scan every file of a kind (tripwires, ratchets, conformance and fixture inventories). A diff-selected run never maps a touched file to them, so they used to pass the selected gate and then fail CI (GST-98). When a branch selects any test, owes a lane, or edits this file, the `lanes` selection adds every tracked test file a glob matches. `--explain` prints each one as `<test> (always_run, .gstack/always-run.json)`, and a glob that matches no test as `<glob> matches no test file`. A docs-only branch adds nothing.
|
||
|
||
The list lives in its own file, not in `pregate.json`, because older pregate readers reject unknown keys there. A repository that ignores `.gstack/` (gstack does) tracks the file with `git add -f`, as it does `pregate.json`. A missing file means an empty list. A file that is not JSON, or whose `tests` is not a list of non-empty strings, stops the check with that message. gstack's own list matches 16 test files; the 13 with a seed in `scripts/free-test-durations.json` (recorded 2026-10-19) total about 13 s there, and `bun-entry-isolation`, `launch-env-echo-ratchet` and `review-packets-removed` have no seed yet.
|
||
|
||
## `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). A fresh entry with touched
|
||
outputs then runs once more with those outputs deleted; the ones the command
|
||
recreates byte-identically are the check's `proven` paths, which
|
||
`gstack-ship-receipt carry` reads. The verdict comes from the first run: an
|
||
output the second run does not recreate, or a second run that fails, is
|
||
simply not proven.
|
||
- `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`. A version-history row, a line that starts a bracketed row with
|
||
the old value (`[45, "<digest>"]`), is skipped when the same table (a run of
|
||
consecutive lines starting with `[`) also holds a row for the new value that
|
||
the branch added (the file at the merge base, read through a rename git
|
||
detects, has no new-value row). A table the branch did not extend, a file that already held a
|
||
new-value row at the merge base, a merge base that cannot be read, and every
|
||
other line holding the old value still fail. Only the row itself is exempt:
|
||
the old value after its closing bracket on the same line still fails. Rows in object form (`{ v: 45 }`) or spread over several
|
||
lines are not recognized as history rows.
|
||
|
||
- `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` on the base branch and let CI run them
|
||
(a `[]` only on the branch is refused under `--policy-from head`); 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.
|
||
A removed test (a deleted one, or a rename's old name) that no remaining
|
||
test selects owes no lane, and prints `removed (no lane owed): <path> —
|
||
<why>` (the `lanes` detail adds `N removed path(s)`), when it is a JS, TS,
|
||
Python, Dart or bats test and no remaining file imports it (directly or
|
||
through a directory it reads; otherwise `still imported by …`). It runs
|
||
the tests of each remaining non-Markdown file that mentions its name
|
||
without the final extension anywhere (`alpha.test`, `test_alpha`:
|
||
a path with or without the extension, or a dotted module), such as a curation list (`files naming it run: …`), and fails closed when
|
||
such a file has no test (`still named by …, which no test covers`). A test
|
||
renamed to another test path then defers to its new name (`old name of
|
||
<new>`). A deleted test, or one renamed to a non-test path, also runs its
|
||
owning suite, the remaining tests that directly exercise the files its
|
||
merge-base text exercised (`owning suite runs: …`; test helpers and
|
||
fixtures are not subjects), and names each file left with no test at all
|
||
(`coverage removed: <file> has no remaining test`, and `M subject(s) lost
|
||
their last test` in the detail). Every other removal owes what an edit
|
||
owes, as before: a deleted or renamed Go or Ruby test (it may define
|
||
helpers its package still uses), a deleted test whose merge-base text
|
||
cannot be read (a symlink, invalid UTF-8, over 1 MiB), and any removed
|
||
non-test path, a renamed source file's old name included, are still
|
||
`PREGATE_NO_LANE` until a test or a `dependencies` entry covers them. A
|
||
modified file whose only test the branch deleted still has no lane, and a
|
||
remaining test that reads a deleted file or directory is selected. /review
|
||
lists a deleted test as a gate edit (RH-1) either way.
|
||
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` (else the
|
||
policy's `timeout_s`, else 600 s) 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.
|
||
|
||
**By tree.** Every record `gstack-pregate` writes is also mirrored, byte for
|
||
byte, to `<state root>/pregate/by-tree/<tree>.json` (the state root from
|
||
docs/state-root.md). `gstack-ship-queue ready` reads the checkout's
|
||
`.gstack/tmp/pregate.json` first; when that file is missing or was recorded
|
||
for another tree, or is current but still waits on a remote receipt, it looks
|
||
the current tree up in the mirror and uses that record when it verifies,
|
||
printing `PREGATE: … (from the state root's pregate/by-tree/<tree12>.json,
|
||
recorded by another checkout)`. This is for the lane whose `gstack-pregate
|
||
--remote` had to run from a separate clone (a worktree cannot host it): the
|
||
clone's record clears the lane's own checkout as long as both hold the same
|
||
tree. Only a run that holds the tests stage stands in (what `--stage tests
|
||
--remote` writes, `checks=[lanes]`; a `--only timing` or preflight-only run
|
||
is "a run without the tests stage"), and it must be recorded against
|
||
`origin/<base>` and not under `--policy-from head`;
|
||
otherwise the `PREGATE:` line says why the mirror cannot stand in. A record
|
||
whose own checks failed or stayed incomplete is final and no mirror overrides
|
||
it. An explicit `--pregate <file>` that verifies is used as before; a mirror
|
||
for a different tree changes nothing. Writing the mirror is best effort (a
|
||
temp file renamed into place) and never fails the record itself.
|
||
|
||
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 its timeout
|
||
(`--timeout <s>`, else `timeout_s`, else 600; per runner process, so many
|
||
serial files that each fit still pass): the check is `incomplete`, its detail
|
||
names which of the three set the timeout, 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 (under `--policy-from head`, still the trusted copy's gate;
|
||
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),
|
||
`PREGATE_SPEND_CAP_USD` and `PREGATE_TIMEOUT_S` (this run's per-process
|
||
timeout in whole seconds), 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 (under
|
||
`changelog_fragments`, also the fold of the fragments the head deletes) 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`, `changelog-fragments` for the CHANGELOG under the fold,
|
||
`changelog_fragments` for a folded fragment's deletion, `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 to the head's version from the gated tree's version, or
|
||
from a newer tentative version of the repo's width that the stamp moved (a
|
||
`*_KEY_VERSION` written as the next minor, #3512) (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` and `UBI_OWNER` 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 the policy's `timeout_s`, else
|
||
600 s) with a 5 s grace, so the script finishes before that deadline: it reads the
|
||
deadline from `PREGATE_TIMEOUT_S`, which pregate sets to that same timeout, 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_OWNER_REQUIRED` | 2 | `UBI_OWNER` is unset; nothing created (every VM must name its owner) |
|
||
| `PREGATE_REMOTE_INFRA` | 4 | create, setup, SSH or the stop deadline failed, or the runner's `UBICLOUD: left=` line is not `0`; 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 (one
|
||
after a run word such as `run`, `screen` or `gate` is exempt in the scanner
|
||
since the GSTA-121 follow-up; the allowlist still covers one after any other
|
||
word), 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/**", "**/sealed/**"],
|
||
"transcripts": ["runs/**/transcript*.jsonl"],
|
||
"allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"],
|
||
"answer_markers": ["SEALED ANSWER:"],
|
||
"seed_patterns": ["shuffle_seed=\\d+"],
|
||
"local_paths": true,
|
||
"case_insensitive": 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>\`, including after a shell
|
||
default such as `${VAR:-…}` or `${VAR-…}`) check each commit's added lines.
|
||
Known limit: a path glued to the text before it, by a hyphen (`docs-…`,
|
||
`$VAR-…`, `${VAR}-…`) or a letter (`-I…`, `-L…`), is not matched. Findings print rule, `file[:line]` and commit, never the text.
|
||
- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole
|
||
repo-relative path from the repository root. `*` and `?` stay inside one
|
||
path segment; `**/` spans zero or more directories; a `**` not followed by
|
||
`/` matches across directories (`heldout/**` covers every depth under
|
||
`heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`:
|
||
to guard a `sealed/` directory wherever it sits, write `**/sealed/**`.
|
||
- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case
|
||
for `paths` and `transcripts` only, so `**/heldout/**` also catches
|
||
`HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`,
|
||
`held_out/`, `heldout-v2/`) are not folded: list each as its own glob.
|
||
The effective value is the base branch's OR the pushed one (it only tightens).
|
||
- **Exceptions.** `allow_paths` exempts publishable files that sit next to
|
||
sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and
|
||
`sealed.transcript`, applied after the deny globs and before waivers. They
|
||
are read from the destination base branch only: an exception a push adds
|
||
takes effect once it lands on the base branch, so a branch can never widen
|
||
its own. They never fold case. An entry whose file name contains `**`, or
|
||
is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`,
|
||
`sealed/**.json`), is refused; a partial wildcard such as `MANIFEST*.json`
|
||
is accepted and matches every file it spells, so name the file. An exempt file is checked by the
|
||
text rules only, so configure `answer_markers` when you exempt files next to
|
||
sealed data. Each exempt path is listed on every push (`exempt <rule>
|
||
<file> commit <sha> (sealed.allow_paths <glob>)`) and counted as
|
||
`exempt=<n>` on the `SEALED:` line.
|
||
- **Version skew.** A gstack older than the release whose CHANGELOG adds
|
||
`sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as
|
||
unknown keys, so every push and `gstack-pregate run` in the repository
|
||
fail on that machine. Raise `min_gstack_version` in
|
||
`.gstack/ship-policy.json` to that release before landing either key.
|
||
- **Policy.** The union of the destination base branch's `sealed` and the
|
||
pushed commit's: a push can tighten it, never relax it (`allow_paths` come
|
||
from the base branch alone, and a pushed deny glob cannot override one). The
|
||
`SEALED:` line names both as `policy=<remote>/<base> + commit <sha12>`.
|
||
`gstack-pregate sealed` only checks and never pushes, so confirm a push with
|
||
`git ls-remote <remote> <ref>`. 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. A waiver counts only when `sha` is the finding's full
|
||
commit sha (40 hex characters, 64 in a SHA-256 repository); a shortened or
|
||
mistyped one is ignored, so the finding still refuses, and stderr names it
|
||
(`ignored waiver <rule> <path> sha <value> (...)`) with the finding's full
|
||
commit, which needs a review before anyone waives it.
|
||
`--json` lists them as `ignoredWaivers`.
|
||
- **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`. A parenthesised named reference,
|
||
`(name #N, #M)`, `(name PR #N)` or `(name issue #N)`, where the name is one
|
||
token that is not a closure, reference, filler or version word, is a
|
||
cross-repo `Refs name#N`, never a same-repo Refs line or a promotion
|
||
candidate; `(see #12)`, `(closing #12)` and `(#301)` stay bare 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`).
|