Files
gstack/docs/pregate.md
T

24 KiB
Raw Blame History

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 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. 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/).

.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)
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 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.

  • guards: each guard in .gstack/pregate.json 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 and cannot be downgraded to warn. See 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), transitively through non-test files; deleted and renamed files select the tests that named them. A touched file with no lane is FAIL lanes — <file> has no test lane, never a pass. Lanes and platform tiers come from .github/workflows/*.yml (gstack-pregate lanes prints them): 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); a platform lane this machine cannot run becomes a requires-remote obligation naming workflow.yml/job. --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. --timeout bounds the whole check (all runner processes together); a serial file that would start after the deadline is not started and the check is incomplete.
  • Under 8 cores, a base-branch remote_gate 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.

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 because each runner process had its own --timeout and its output was buffered until it returned. 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) 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 at the check's --timeout deadline.

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:

  • gstack-pregate clear --remote <lane> --run-url <url> after running it; 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.

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.

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. --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.

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 [--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 --author, default the gh viewer, which is an app identity on hosts like Capy, so pass your login there; not this branch) 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).