mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
* feat(pregate): user-owned policy file and local lanes
A repo that keeps .gstack/ out of git can hold its pregate policy at
<GSTACK_HOME>/projects/<slug>/pregate.json. gstack-pregate run reads it only
under --policy-from base when the base carries no .gstack/pregate.json, so a
reviewed base copy always wins and a PR still cannot reach the policy. A
missing file falls back; any other read error, and a non-null sealed or
price_file (read only from the git copies), refuse with PREGATE_CONFIG_INVALID
and a fix that names the file.
A new `local` key lists <workflow.yml>/<job> lanes a local run satisfies: a
platform lane named there runs here (tier unit) instead of becoming a
requires-remote obligation. Lane ids share one LANE_ID check with selection.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(pregate): the user-owned policy file and local lanes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): policy fixes name the user-owned file when it is the active policy
With no .gstack/pregate.json on the base, an edit to that path is never read,
so the no-lane, runner, stall_timeout_s, remote_gate and strays fixes now name
ctx.configFile: the user-owned path when it is the active policy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(pregate): the remote_gate, strays and stall fixes name the active policy file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): pre-landing review fixes for the user-owned policy
Ignore a user policy path that is relative or inside the checkout, fingerprint
the active user file into check inputs, name the user file in its JSON errors
and the refusal lead, add --explain lines for an absent user file and an
unknown local id, and document the state-root location and slug lookup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): containment edges and explain wording for the user policy
A state root under an in-repo directory named ..something passed the
inside-the-repo check, and realpath kept the caller's casing on macOS.
The unknown-local explain line now says workflow lane, and the state-root
chain in explain and docs names every source.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): a base copy git cannot read, or a dangling user symlink, refuses
Any git show failure read as no base policy, which now hands the run to
the unreviewed user file; it is absent only when cat-file says so, and a
spawn error (stdout cut at the buffer limit) counts as a failure. A user
pregate.json that is a dangling symlink refuses instead of reading as
absent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): decide a missing base policy from the tree, and catch dangling parent links
cat-file -e checks the blob, so a missing or corrupt object still read as
no base copy; ls-tree reads the tree entry, and any failure refuses. The
dangling-symlink check now covers every path component, not just the file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(result-codes): POLICY_SOURCE_UNAVAILABLE names an unreadable committed pin file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(pregate): the active policy may be the user-owned file; new refusals
Guards, remote_gate, stall timeouts and lane fixes name the user-owned file
when it is the active policy; the trust boundary says run alone reads it;
the dangling-symlink and unreadable-base-copy refusals are documented, and
state-root lists the bucket's pregate.json.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): a checkout's .env cannot pick the user policy; verify re-checks it
Bun loads .env from the working directory, so a branch could set
GSTACK_PROJECT_SLUG or a state root and choose which pregate.json governs
it. The shebang now passes --no-env-file, scriptInvocation carries that
flag over when it launches the bin as bun <script>, and a bare bun launch
beside any dotenv file refuses (POLICY_SOURCE_UNAVAILABLE).
A recorded pass also outlived its policy: pregate.json now records the
user file's digest (or, under the defaults, the path that was absent), and
verify and the ship receipt treat an edited, deleted or newly present user
file as PREGATE_STALE, reading both paths from the record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): keep the POLICY_SOURCE_UNAVAILABLE summary's pin-files wording
The added '(or .gstack/pregate.json)' broke describeRefusal's rewrite to
'the reviewed pin files cannot be read', which pregate-wtree-refusal pins.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(pregate): pick the host-foreign lane in the user-policy local-lane test
On a Windows host the windows lane is native, so it never became
requires-remote and the test failed on the Windows shards.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): Rust crate lanes and nested JS package runners
lanes reported PREGATE_NO_LANE for every touched .rs file and ran a nested
pnpm+vitest package's tests as `bun test` from the repo root.
- Rust: a .rs file of a crate (nearest Cargo.toml with [package]) is a test
under <crate>/tests/ or when it holds a #[cfg(test)]; every .rs file of a
crate and its Cargo.toml select the crate's tests and those of every crate
depending on it (lib/pregate/rust.ts). Selected tests run per crate as
`cargo test -p <name>` from the workspace root, judged on the summed
`test result:` lines (doc-tests included); zero tests stays ZERO-RUN and a
missing cargo is incomplete, like a missing Dart SDK.
- JS/TS: a test owned by a nested package.json that declares vitest or jest
runs from that package with package-relative paths, through the package
manager its packageManager field or nearest lockfile names (pnpm exec,
yarn, bun x, else npx). Root detection and a declared runner are unchanged.
- init notes that Rust tests run per crate; docs/pregate.md updated.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(pregate): parse Cargo manifests as TOML; follow workspace membership
The line scan of Cargo.toml missed dependents (a false green) and picked the
wrong workspace:
- an inherited renamed dependency (`base = { workspace = true }` with
`[workspace.dependencies] base = { package = "alpha", path = ... }`);
- dotted keys (`alpha.path = "../a"`), `[dependencies.<name>]` tables and
target-specific tables, matched by key name only;
- a crate the workspace excludes ran `cargo test -p` from the workspace root.
Manifests are now parsed with Bun.TOML. A dependency resolves by path
(relative to the manifest declaring it, or to the workspace root when
inherited), else by package name. A crate runs from the workspace it is a
member of under Cargo's rules (package.workspace, the nearest [workspace],
members globs, exclude, literal members, path dependencies of members),
else from its own directory. Nothing runs cargo.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* test(pregate): the Rust fixture's cargo shim wins PATH on Windows and beside a real cargo
windows-free-shard (5) failed 'a touched src file runs its crate...' and
'zero tests is ZERO-RUN...' deterministically: the shim PATH was joined with
':' so Git Bash never saw the shim dir, and the runner's real cargo ran
against the fixture (exit 1, no test result lines). On POSIX hosts where
Bun and cargo share a directory (/usr/local/bin on Capy's image), the lane
puts Bun's directory first and the real cargo wins the same way.
Join with path.delimiter, add cargo.cmd for Bun.which on Windows, and on
POSIX put the shim first again in the lane's own bash through BASH_ENV.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): quote the crate name in cargoRunner
The crate name is read from the repo's Cargo.toml and was interpolated into
'cargo test -p <name>', which the lane runs through bash. A name like
'evil;touch pwned' ran the second command. Quote it like every file and cwd
the lane already passes; a new end-to-end case checks the name reaches cargo
as one argument and runs nothing.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(pregate): the nested-package pnpm shim wins PATH beside a real pnpm
'a touched file in a nested pnpm+vitest package runs vitest there' ran the
host's real pnpm wherever Bun and pnpm share a directory (/usr/local/bin on
Capy's image): the lane puts Bun's directory first, so pnpm installed vitest
from the network, dirtied the fixture tree and the check came back
incomplete. Put the shim first again in the lane's bash through BASH_ENV,
as the Rust fixture does.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): pregate Rust crate lanes and nested package runners (#3344)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): a crate name Cargo would reject never reaches the lane's shell
Port review (outside voice): quoting alone was not enough, because the lane
expands {files} after the runner string is built, so a crate named
'evil{files}' spliced the quoted file list into the quoted name and a file
name like tests/$(cmd).rs then ran. A name outside Cargo's grammar
(letters, digits, _ and -) now gets no runner: its files are unclaimed and
the lane is incomplete, as Cargo itself would refuse the manifest. A valid
name, Unicode included, still runs quoted.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): match Cargo members globs per path component, without a backtracking regex
Port review (outside voice): members globs went through ship-policy's
matchesAny, which escapes [...] classes (crates/[ab] matched nothing, so the
crate was no member, lost its inherited [workspace.dependencies] path and its
dependents' tests were never selected: a false green) and backtracks
exponentially on a hostile glob such as '*a*a...*ab' (over 5 s for one crate,
before any runner deadline). A small dynamic-programming matcher handles *,
?, [..] with negation and ranges, and ** across components, in
O(pattern x path).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): a plain #[test] in src is a Rust test; bound the cfg attribute scan
Port review (outside voice): only #[cfg(test)] modules and tests/ files
counted, so a dependent crate whose tests are plain #[test] or #[tokio::test]
functions in src never ran when its dependency changed (a false green). The
cfg scan also went quadratic on a run of unclosed '#[cfg(' (about 3 s on
120 KB). Test attributes now count, and the cfg predicate and its strings are
length-bounded so the scan stays linear.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(pregate): Rust test attributes, workspace membership and rejected crate names; changelog for the port review fixes
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): match members globs by code point in one pass, and admit every crate past a size cap
Second port review round (outside voice): the matcher counted UTF-16 units, so
'crates/?' missed a crate directory named with a supplementary character and
dropped its inherited dependencies (a false green); and each unmatched '['
rescanned the rest of the glob (5 s for one crate with a million of them).
Tokenize by code point with a precomputed next-']' table, and past 4096
characters of members globs admit every crate under the workspace: a wrong
member fails loudly in cargo, a dropped one is silent. The new supervised
probe kills a super-linear matcher at 30 s instead of hanging the file.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): scan cfg attributes once without truncating them; an unclosable one counts as a test
Second port review round (outside voice): the length bound from the first
round dropped a valid cfg(any(test, feature = "<257 chars>")), so its crate's
tests were no longer selected (a false green). Read each attribute once to
its closing ']' and resume after it, which keeps the scan linear without a
bound; an attribute the scan cannot close (an unterminated string, no ']')
counts as a test, which selects more, never fewer.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(changelog): second port review round (code-point member globs, unbounded linear cfg scan)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): past the members size cap, a crate's run root is undecided and its tests unclaimed
Third port review round (outside voice): admitting every crate past the cap
made a non-member run as 'cargo test -p <name>' from the workspace root,
where a same-named member answered and its passing tests stood in for the
orphan's failing one (a false green). Keep the conservative membership for
dependency edges, but leave such a crate's workspace undecided (null):
planRuns leaves its files unclaimed and the lane incomplete.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(pregate): the Rust end-to-end runs put the cargo shim under Windows' own Path key
windows-free-shard (5) still failed both end-to-end cases at fe99b200 with
the same symptom (the runner's real cargo, exit 1, no test result lines).
runBin merges process.env, whose Windows key is 'Path', with the shim's
'PATH', so the child got two path variables and read the original. Write the
shim under the key process.env actually uses, as test/helpers/unreachable-gbrain.ts
and sync-gbrain-readiness-fixture.ts do.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(pregate): run the cargo stand-in cases on POSIX only
windows-free-shard (5) still failed both end-to-end cargo-lane cases after the
delimiter, cargo.cmd and Path-key repairs: the lane's Git Bash exits 1 with an
empty log, and that log is not visible from CI. Skip the two cases that run the
bash stand-in on win32 (test.skipIf, as other POSIX-shim pregate fixtures are
handled) and drop the unproven cargo.cmd and Path-key changes. Selection,
planning and the hostile-name case still run on Windows.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(pregate): TODO(#3380) on the two win32-skipped cargo-lane cases
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): refuse a user policy whose link chain passes through the checkout
userConfigRejection checked the lexical path and the final realpath only. A
state root linked into the checkout, or a link hop inside it, let a branch pick
which existing out-of-repo policy governs it. Every hop of the resolution chain
is now checked. Found by the security review of #3286.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pregate): verify goes stale when a link on the way to the absent user policy dangles
A fresh run refuses a user policy path with a dangling parent link, but verify
looked only at the leaf, so the parent's ENOENT read as continued absence and
an old defaults pass stayed publishable. The loader and verify now share one
absence check. Found by the security review of #3286.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(pregate): CHANGELOG entry for #3286 and the link-chain and dangling-parent rules
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.206.0 stamp
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: arlophoenix <arlo.phoenix@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: vilano93 <244097767+vilano93@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
661 lines
40 KiB
Markdown
661 lines
40 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)) |
|
||
|
||
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.
|
||
|
||
`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>] [--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 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`,
|
||
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.
|
||
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`) 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), 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.
|
||
|
||
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="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`).
|