# 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/`) | 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/` (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) ```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 `, or `--touched ` 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/`). ## `.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); nothing detected, a Dart SDK missing from PATH, or a `.dart` file with no `pubspec.yaml` above it 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 | | `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/` copy is honoured, so a downgrade is reviewed before it takes effect. `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 ] [--allow-repo-commands] [--policy-from base|head] [--timeout ] [--stall-timeout ] [--remote --spend-cap ] [--explain] [--json] gstack-pregate verify | clear --remote --run-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: : ` and a `TIMING_ASSERTIONS: found= escaped= files=` summary. It warns (`PREGATE_TIMING_ASSERTION`); it does not block. See [timing](#timing). - `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](#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/` → `test/*.test.ts` convention ∪ `dependencies`; Dart `import`/`export`/`part` directives resolve `package:/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), transitively through non-test files; deleted and renamed files select the tests that named them. A touched source file with no lane is `FAIL lanes — 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); 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 from `), 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 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](#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 --run-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`): ``` PREGATE_TEST: / start PREGATE_TEST: / pass|fail PREGATE_HEARTBEAT: