* feat(test-free): --files-from runs exactly the listed planned files Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(pregate): declare scripts/ubicloud/pregate-remote.sh as gstack's remote gate (keyed Ubicloud VM, keyless local shards) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * perf(next-version): fetch PR VERSIONs concurrently; a timed-out fetch is an unknown claim Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): gstack remote-gate example and the small-machine pregate row Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): remote gate leaves paid test files to the paid gate, adds reason codes and the keyless backend line Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(next-version): unknown claims make the pick provisional (complete: false) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): remote-gate reason codes, paid-file handling and measured keyed/keyless timing Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): remote gate finishes before pregate's deadline pregate kills the remote gate at --timeout with a 5 s grace, which could cut a VM teardown short. pregate-remote.sh now reads the enclosing deadline (PREGATE_TIMEOUT_S, else the gstack-pregate --timeout it runs under, else 600), reserves 120 s for teardown plus 15 s margin, and tells the VM to stop at the earlier of that point and the cap's wall; a deadline that leaves under 300 s refuses before any VM (PREGATE_REMOTE_TIMEOUT_TOO_SHORT). It names the VM up front and prints the exact ubi-runner.sh down line, and the body is one braced block so an edit to the script mid-run cannot change what the run executes (keyed run 5 died after teardown with '$2: unbound variable' for that reason). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(restamp): an incomplete claim queue is never reported as read gstack-next-version now returns complete: false with unknown_claims when a PR's VERSION fetch timed out or failed. restamp's claim-at-open path took any usable JSON as 'read'; it now says 'incomplete', names the unknown PRs and calls the version provisional, the same warn-not-refuse stance as the existing 'unverified' fallback. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): only a gstack-pregate process sets the remote gate's deadline The ancestor walk matched any command line that mentioned gstack-pregate, so a shell line running pregate beside the tests (bash -c '...; bin/gstack-pregate ...') gave the stub gate a 600 s deadline. It now matches only a process whose program, or the script its interpreter runs, is gstack-pregate. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): A3 remote gate, --files-from, concurrent next-version Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.132.0 chore: stamp 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>
33 KiB
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.
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) 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.
.gstack/generated.json (generated-v1)
{ "$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 checkregenerates every entry (or--only <id>, or--touched <paths>for entries whose inputs or outputs intersect) in a scratchgit worktreeseeded from the working tree, withnode_moduleslinked rather than copied, and reports stale outputs (REGEN_STALE, exit 1) without touching the tree. When outputs differ butpackage.jsondeclares dependencies andnode_modulesis missing, the entry isREGEN_DEPS_MISSING(install, then rerun) instead of stale. An entry with nocommandis tracked only (goldens regenerated by another entry).gstack-regen writeregenerates in place: whatgstack-restampand the Step 3 conflict rule use. On a conflict in a registered output, take either side, thengstack-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/).
.gstack/pregate.json (pregate-v1)
| Key | Meaning |
|---|---|
runner |
the local test command with {files} (gstack: bun test {files}); without it lanes is incomplete |
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). README, CHANGELOG, CONTRIBUTING, LICENSE, AGENTS and CLAUDE qualify even without .md. 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 |
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 |
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) |
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) |
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) |
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) |
Only the origin/<base> copy is honoured, so a downgrade is reviewed before it
takes effect. gstack-pregate validate / init cover the working-tree file.
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 checkin a scratch worktree). -
secrets:gstack-redactover the branch's added lines (merge-base to the working tree, untracked files included). HIGH, or MEDIUM in thesecretcategory, 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 withfile:line. -
timing: added or changed test lines that compare an elapsed wall-clock or CPU time against a bound, or sleep and then assert, printTIMING_ASSERTION: <file>:<line> <pattern>and aTIMING_ASSERTIONS: found=<n> escaped=<n> files=<n>summary. It warns (PREGATE_TIMING_ASSERTION); it does not block. See timing. -
guards: each guard in.gstack/pregate.jsonwhosewhenglobs match a touched file (renames included) runs itscommandin its own process, under the verified launch Bun, with its owntimeout_s(default--timeout). A non-zero exit fails pregate naming the guard and itsfix(PREGATE_GUARD_FAILED); a timeout isincomplete; without--allow-repo-commandsthe selected guards are listed and the check isincomplete. Guards are read from the base branch's copy and cannot be downgraded to warn. See guards.
Stage 2, test execution:
lanes: touched files map to tests throughlib/pregate/import-graph.ts(static imports ∪ repo-path string literals ∪ thebin/<name>→test/<name>*.test.tsconvention ∪dependencies), transitively through non-test files; deleted and renamed files select the tests that named them. A touched source file with no lane isFAIL 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_lanesadds globs for other documentation; it cannot clear a source file or a file under a test directory. Lanes and platform tiers come from.github/workflows/*.yml(gstack-pregate lanesprints them): local ones run through the runner underset -o pipefail, judged on the runner's summary line (zero tests ran is a failure, a| tail-hidden exit is still an exit); a platform lane this machine cannot run becomes arequires-remoteobligation namingworkflow.yml/job.--explainprints each test's provenance and every uncertain mapping (a planner-computed matrix without aselectionmanifest selects conservatively). Test files matchingserial_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/binand~/.bun/binwhose Bun satisfies gstack's and the project'sengines.bun(the row printsruntime: 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
--remotegate) runs supervised: see supervision.--timeoutbounds each runner process (the unit-file group, each serial file, each guard without its owntimeout_s), not their total; the stall timeout catches a hung or silent one sooner. - Under 8 cores, a base-branch
remote_gateor a remotetest_backend(gstack-config set test_backend ubicloud) records the local test tier as arequires-remoteobligation instead of running it: see 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.
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):Per-file lines come from a progress adapter where the runner's reporter provides them: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>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) 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>, elsestall_timeout_s, else 300;0disables): the check isincompletewithPREGATE_STALLEDand 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 isincompleteand 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.
Small machines and the remote gate
Under 8 cores (os.availableParallelism()), when the base branch's
.gstack/pregate.json 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--remotevalidates it (a run URL is refused,PREGATE_RECEIPT_INVALIDfor a bad receipt), orgstack-pregate clear --remote test_backend/<backend> --run-url <url>, which has no machine-checkable receipt and is recorded asattested:<url>; or gstack-pregate --stage tests --remote --spend-cap <usd> --allow-repo-commands, which runs the declaredremote_gatenow (never under--policy-from head; atest_backendwithoutremote_gatecannot be run this way). The gate runs in the repository root withPREGATE_TREE(the working-tree fingerprint being tested),PREGATE_FILES_FILE(the selected files, one per line) andPREGATE_SPEND_CAP_USD, and must printPREGATE_REMOTE: tree=<tree> tests=<n> failed=<n> [spent=<usd>], reporting the tree it actually tested. The obligation clears only when that tree isPREGATE_TREE,tests> 0,failed= 0 and the gate exits 0; anything else leaves it uncleared and the checkincomplete. The cap defaults to$GSTACK_SPEND_CAP_USD; there is no default spend. Spend prints asspent, reserved, unknown: a gate that prints nospent=isunknown, 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:
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 |
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:
{ "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.
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, 87sleep-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.racewith 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.
Sealed output: the pre-push hook and gstack-pregate sealed
{ "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.--rangenames the commits explicitly. - Rules.
sealed.pathandsealed.transcriptcheck every path a commit adds, renames or copies, whatever its content (binary, empty, deleted again in a later commit);sealed.answer,sealed.seedandsealed.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
sealedand 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.visibilityfrom the base branch, else a cache under the state root (sealed-visibility.json: apublicanswer for 24 hours, aprivateone for 1 hour before it may turn a refusal into a warning), elsegh 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.
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).
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).
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.
{
"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.
{
"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.
{ "sealed": {
"paths": ["heldout/**"],
"transcripts": ["runs/**/transcript*.jsonl"],
"answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"],
"local_paths": true } }
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).