Files
gstack/docs/ship-policy.md
T
Garry Tanandcapy-ai[bot] f375b873de v1.91.263.0 feat(ship-queue): screens read from the run, train watch/forget/prune --stale, verify --post-merge, ready --dispatch-ci, --stack-tests (#3484)
* feat(ship-queue): screens read from the run, train watch/forget/prune --stale, verify --post-merge, ready --dispatch-ci, --stack-tests

Operating merge trains on 2026-10-26 showed the gaps this closes. A dispatched
screen's verdict is read from the run's gate-binding and report-verdict artifacts
(lib/ship-queue-screen.ts) and recorded in the READY receipt: a RED screen refuses
ready under either choice and is never re-dispatched at the same merge SHA
(QUEUE_SCREEN_RED); a run that concluded success with no slice is NOT-RUN
(QUEUE_SCREEN_NOT_RUN), never followed, and counted as unscreened by train build.
When refs/pull/<n>/merge lags or is missing the screen is planned on a scratch merge
pushed as screen/<pr>-<head12> (#3465); QUEUE_SCREEN_STALE_REF means a real
conflict only. train watch --train is the resumable follow command; train prune
--stale settles a merging/green train whose PRs all merged or closed elsewhere;
train forget removes one record with an exact lease on refs/notes/gstack-trains;
verify --post-merge <sha> watches main's push workflows until they settle;
ready --dispatch-ci <wf,…> dispatches the free workflows on a conflicting PR,
waits, and clears the matching requires-remote pregate lanes with the run URLs;
train build --dry-run --stack-tests runs each PR's new test files on its integrated
prefix before anything is dispatched (QUEUE_TRAIN_STACK_RED).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Regenerate gstack-doctor-components.sh for the four new QUEUE_* result codes

The committed Bash render was four case lines behind its generator, so
test/doctor-components.test.ts failed at c75ec3a. Output of
bun run gen:skill-docs; no hand edits.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Restore the exact stderr line count in the POLICY_SOURCE_UNAVAILABLE test

RH-15: toBeGreaterThan(0) after a toMatch is vacuous; the refusal is one
deterministic line, so toBe(1) holds and is restored.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(ship-policy): a lagging merge ref is a scratch merge, not QUEUE_SCREEN_STALE_REF

Three passages still described the pre-scratch-merge refusal; they now
match screenMerge: a missing or lagging refs/pull/<n>/merge gets a
screen/<pr>-<head12> scratch merge, and QUEUE_SCREEN_STALE_REF is a real
conflict with the base only.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* stack-tests: drop the dead detail clause and assert the worktree is gone

r.detail === 'pass' can never hold when r.ok is false; the dry-run test
now checks git worktree list instead of a state-dir name that was never
written.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* ready --dispatch-ci: fail closed on fork or unreadable PRs, surface gh errors, keep screen: after READY

dispatchCi now uses prInfo (a fork head or an unreadable PR is refused
instead of dispatched), reads gh's error text when the workflow run call
fails, and calls runsAt once. The screen: line is inserted after the
READY line instead of at index 1, which with --dispatch-ci put it before
READY and after the first CI_DISPATCH line.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* verify --post-merge: honour commit statuses in the settled and red verdicts

settled and red were computed from check runs only while ci.green also
counted commit statuses, so a pending status was called settled and a
failed status was reported as 'red: ' with no name. Pending statuses now
keep the verdict pending; a red status with no failed check run is named
as such. The fixture gains a statuses knob to drive both.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* train watch: reject a malformed --free-timeout as usage instead of taking the default

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* train forget: restore the local notes ref on a rejected lease and refuse every in-flight state

forgetNotes removed the note locally before the leased push, so a
rejected lease left a local notes ref without the note that the next
plain persist pushed without a lease. It now snapshots the ref and
restores it on rejection, reporting failed removals separately. forget
refused only merging/gate-pending/free-pending; built, pushed,
free-green, dispatch-unknown and dispatch-absent are in flight too and
are refused the same way. CHANGELOG and ship-policy say so.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* train build --dry-run: never persist or push the read screen verdict

admitSlots recorded a verdict that differed from the receipt and pushed
refs/notes/gstack-trains before the dryRun branch printed 'nothing
written, pushed or dispatched'. It now takes dryRun and skips the
persist, matching trainReady's guard.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* stack-tests: only bun test files count as new tests, passed as paths

TEST_FILE matched every .ts under test/, so a PR whose only added file
was a helper or fixture ran 'bun test test/helpers/x.ts', which exits 1
(the filter matches no test file) and was reported QUEUE_TRAIN_STACK_RED.
A bare positional is also a substring filter that picks up unrelated
files. The rule is now bun's own (.test, _test_, .spec, _spec_ in the
name) and each file is passed as ./<path>, which bun treats as the exact
file. The fixture PR 3 now adds a helper and is still 'no new test
files'.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* screen: page the run's artifact list before picking the newest binding

One page of 100 artifacts can miss gate-binding/report-verdict on a
broad or rerun screen (three artifacts per slice plus reruns), which
read as 'bound no verdict' and bought a fresh screen. newestArtifact now
follows page= until a short page.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* stack-tests: match bun's _test and _spec suffixes too

Bun discovers a file whose name ends in .test, _test, .spec or _spec
before the extension. The previous rule missed the underscore forms
(b_test.ts, e_spec.ts), so a PR adding only such files got no stack
check, and it matched names bun ignores. PR 1 in the fixture now adds
test/pr-1_test.ts to prove the underscore form is run.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: ship-policy and troubleshooting match the shipped train commands

Step 14.5 documentation audit: the stack-tests naming rule is bun's own
(.test, _test, .spec, _spec); prune --stale deletes every merged or
closed PR's screen/<pr>-* branch; verify --post-merge takes the full SHA
and counts commit statuses; train watch --free-timeout and
ready --dispatch-ci --timeout defaults; PENDING/UNKNOWN/INCOMPLETE screen
verdicts; QUEUE_SCREEN_STALE_REF's Meaning no longer contradicts its Fix;
one sentence each for the new QUEUE_NOT_READY, QUEUE_TRAIN_UNTRUSTED and
QUEUE_TRAIN_PENDING triggers.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* changelog: prune --stale scope, post-merge commit statuses, STALE_REF vs POLICY_SOURCE_UNAVAILABLE

Three details the documentation audit found wrong against the code; the
entry stays under [Unreleased] (stamp-at-merge).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs: RED screens are not seated; QUEUE_PROOF_MISSING and POLICY_SOURCE_UNAVAILABLE name the train-command causes

Step 14.5 re-audit: ship-policy.md says train build does not seat a RED
screen and its NEXT names the build without that PR; troubleshooting.md
lists train forget, train watch, verify --screen and the scratch-branch
push under QUEUE_PROOF_MISSING, and the origin/<base> fetch under
POLICY_SOURCE_UNAVAILABLE.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* test(host-conformance): delete the named scope.md in the C3 broken-link case

A test repair the remote gate requires, not a review finding. The case
deleted the first .md readdirSync listed under gstack-qa/sections, and
on the remote-gate VM's ext4 that was a file setup --status does not
count, so the case was deterministically red there (#3479) while green
locally and on Actions. The same one-line change ships in #3471
(fd468eb); this copy carries no CHANGELOG line so the two merge cleanly.

Refs #3479

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* v1.91.263.0 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>
2026-10-27 02:48:59 +00:00

896 lines
60 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The repo ship policy: `.gstack/ship-policy.json`
`/ship` reads one machine-readable file per repository that says how releases
are stamped and merged: the bump level, the queue mode, the repo's own release
tool, the paths where version mentions are rewritten, the CHANGELOG, a paired
mirror, the history policy and the preregistered evals. Without the file
nothing changes: every key has a default that reproduces today's behavior.
The file is **read from the committed copy on the base branch**
(`origin/<base>`), never from the PR's working tree, so a PR cannot smuggle a
command into its own gate. **File presence never authorizes execution:**
`gstack-restamp` runs `release_tool` only under `/ship` or with
`--allow-repo-commands`; standalone it lists the commands and exits 3 with
`repo commands not executed (pass --allow-repo-commands)`. A PR that
introduces or changes the policy runs under the reviewed bootstrap path
(`--policy-from head --allow-repo-commands`) and its receipt says
`policy: head (unreviewed)`.
Tools: `gstack-ship-policy init|validate|show-effective|check-history`,
`gstack-restamp`, `gstack-tree-receipt`, `gstack-ship-receipt`,
`gstack-ship-queue` ([shared merge queue](#run-a-shared-merge-queue), [merge trains](#run-a-merge-train)),
`gstack-evidence bundle|ancestor`. They share the exit table (`0` ok, `1`
fail, `2` usage, `3` refused or needs a flag) and end every error line with a
code from [troubleshooting.md](troubleshooting.md#queue-aware-ship-policy-restamp-tree-receipt-ship-receipt).
<a id="schema-v1"></a>
## Schema v1
`gstack-ship-policy init` writes every key at its default; `init --explain`
prints this table. JSON carries no comments, so examples live in
`.gstack/ship-policy.example.json`. Unknown keys are errors, and there is no
extension mechanism: a key from a newer gstack, or a misspelled one, fails
closed (`POLICY_INVALID`) instead of being ignored. Every key has an
introducing version (`init --explain` and `validate` print it). A key newer
than 1.91.98.0 set to a non-default value requires `min_gstack_version` at
least at its introducing version, and a gstack older than `min_gstack_version`
refuses the file (`POLICY_GSTACK_TOO_OLD`). `validate` ends with
`MIN_READER: gstack <version>`, the floor every lane reading the policy needs.
| Key | Values | Default | Meaning |
|---|---|---|---|
| `bump` | `patch` \| `ask` | `ask` | `patch` skips the MINOR/MAJOR prompt in `/ship` Step 12 and `gstack-restamp --next` bumps PATCH; `ask` keeps the prompt. |
| `queue_mode` | `claim-at-open` \| `stamp-at-merge` | `claim-at-open` | See [stamp-at-merge](#stamp-at-merge). `claim-at-open` is today's queue (`gstack-next-version` claims a slot when the PR opens). |
| `release_tool` | command or `null` | `null` | The repo's own release command (`bun run release:restamp`). Runs in an isolated staging worktree under the [release-tool contract](#release-tool-contract). |
| `release_outputs` | globs | `[]` | Paths the release tool may write beyond the version files, lockfiles, CHANGELOG and `stamp_paths`. Anything else is `RESTAMP_OUTPUT_OUTSIDE_ALLOWED`. |
| `stamp_paths` | globs | `[]` | Where exact `(was X)`, `since: X` and `vX` mentions of the branch's tentative version (quoted or bare: `since: 'X'` counts) are rewritten on a restamp. |
| `changelog` | path or `null` | `CHANGELOG.md` | Re-headed on a restamp: the PR's `## [Unreleased]` (or old-version) section moves to the top as `## [<version>] - <date>`. |
| `mirror` | relative path or `null` | `null` | A paired checkout to restamp identically (tier 2: validated and confined today, not yet run). |
| `history` | `merge-only` \| `squash-ok` | `squash-ok` | `merge-only` makes `/ship` refuse a squash or rebase plan; `gstack-ship-policy check-history` reports a rewritten branch (`HISTORY_POLICY_VIOLATION`). |
| `preregistration_shas` | commit ids | `[]` | Each must be an ancestor of HEAD before push: `gstack-evidence ancestor --from-policy`; the ship receipt records the result. |
| `stamp_sensitive_gates` | evidence labels | `[]` | Gates that read VERSION or generated bytes (gstack's own `tests` lane does, through the agents-digest freshness test). The tree receipt never marks them reusable. |
| `pr_count` | `{ default_pr_count, split_requires_owner }` | `{1, true}` | The PR-count row in plan reviews. |
| `maintainers` | GitHub logins | `[]` | Maintainer set for `/review` contributor mode; empty means repository push permission. |
| `stamp_files` | globs | `[]` | The repo's stamp rule: when set, only these files may differ by a stamp (list the version file, manifests, plugin trees and the files holding the PR's own `since:` rows). It applies before every other stamp rule, so a doc `vX` mention outside it is a difference. |
| `stamp_patterns` | literal templates | `[]` | At most 16 templates of at most 128 characters, each with exactly one `{version}` slot (`"version": "{version}"`, `since: '{version}'`, `<!-- x-stamp: {version} -->`); matched literally at a version boundary, never as a regex. The restamp writer, its mention scanner and the tree receipt use them beside the built-in forms. |
| `restamp_command` | command or `null` | `null` | The repo's own restamp, run first in the staging worktree; see [restamp_command](#restamp-command). |
| `migrations` | `{ dir, id }` or `null` | `null` | The migration directory and a regex with exactly one capture group for the number in a file name (`"^v(\\d+)-"`); `gstack-pregate regate` reports `MIGRATION_COLLISION`. |
| `gate_command` | command or `null` | `null` | The repo's full pre-merge gate. `gstack-ship-queue gate-ahead` and `ready` print it for the caller to run (in the background or in CI); gstack never executes it. Needs `min_gstack_version`. |
| `full_e2e` | `true` \| `false` | `false` | `true`: every merge needs a passing full-gate result bound to a tree equal to the merging one modulo stamps; `gstack-ship-queue ready`, `stamp` and `verify` refuse without one (`QUEUE_GATE_MISSING`): a gate-ahead record, or the lane's `--gate-exit 0 --gate-log <file>` from its run on the gated head. Needs `min_gstack_version`. |
| `review_scope` | `frozen` \| `diff-plus-blocking` \| `all` | `frozen` | The finding scope of `/ship`'s review loop; see [review_scope](#review-scope). `frozen` fixes the diff on pass 1, files blocking findings in unchanged code as issues, and later passes read only the range since the previous pass. `diff-plus-blocking` is the same but fixes a blocking finding in unchanged code in this PR instead of filing it. `all` is the earlier loop: every finding is fixed on every pass. Needs `min_gstack_version` when not `frozen`. |
| `min_gstack_version` | version or `null` | `null` | The oldest gstack that may read the file; see the compatibility rule above. |
| `train` | `{ gate_workflow, free_workflow, full_gate_check, record_artifact, max_size, fill_hours, screen_selection, trial_reservation_usd }` or `null` | `null` | Turns on [merge trains](#run-a-merge-train), read from the base revision only. Every field is required and an unknown field is `POLICY_INVALID`: `max_size` 1 to 6, `fill_hours` above 0 and at most 24, `screen_selection` `gstack-paid` \| `none`, `trial_reservation_usd` above 0 and at most 100. `null` keeps the per-PR queue exactly as it is. Needs `min_gstack_version`. |
Containment, checked by `validate` and again at write time: paths and globs are
repo-relative with no `..` segment, under 256 characters, at most 64 per list,
and a glob's regex expansion under 512 characters; `release_tool` is under 512
characters with no newline; a matched file that resolves through a symlink to
outside the repository is refused (`POLICY_CONTAINMENT`). A mirror may start
with `..` (a sibling checkout) but has no interior `..`; mirror writes are
confined to that directory.
## gstack's own files
gstack's policy (`.gstack/ship-policy.json`, tracked beside `generated.json`
and `pregate.json` under the otherwise ignored `.gstack/`) runs the repo on
stamp-at-merge: the version is allocated once, when the PR is next in the queue,
every bump is PATCH, and `/review` reads the maintainer set from the file. It
names the one release tool `gstack-version-bump --regen-digest` used to
hard-code:
```json
{
"bump": "patch",
"queue_mode": "stamp-at-merge",
"release_tool": "bun scripts/gen-agents-digest.ts",
"release_outputs": ["agents-digest/gstack-AGENTS.md"],
"stamp_paths": [],
"changelog": "CHANGELOG.md",
"history": "squash-ok",
"stamp_sensitive_gates": ["tests"]
}
```
`.gstack/ship-policy.example.json` shows every key set the way a serial-merge
repo with a release script, docs mentions and a mirror would set them.
### Queue-side procedure for gstack
Who does what under `stamp-at-merge`, for the PR author and for the thread
running the merge queue:
1. **Open.** The branch carries a tentative stamp of the base's version plus
PATCH (`gstack-version-bump write --version <base+patch> --regen-digest`)
and its CHANGELOG section headed with that version directly under
`# Changelog`; `## [Unreleased]` is also accepted. Nothing is claimed and
`gstack-next-version` is not consulted; the tentative version is only the
guess that makes the common case free. CI runs on this head as usual.
2. **Register.** The queue records the PR and its intended order.
3. **Stamp when next.** With every check green on the current head and
`origin/main` at the SHA the queue assumed, the PR owner (never the queue)
runs on the branch:
`gstack-restamp --next --base main --allow-repo-commands --pr <n> --expect-base <main-sha> --expect-order <pr,...>`.
When the base has not moved past the tentative stamp it prints
`RESTAMP: X -> X (noop)` and `FILES: (none)`, and the green head is the
merge candidate with no further CI. When the base moved, it stamps base +
PATCH, re-heads the CHANGELOG above the base's top entry, reruns the
release tool, prints `STAMP_VERSION: <X>` and the stamp diff, and refuses
with `QUEUE_STALE` (nothing written) when the base or order moved after
`--expect-base` was read. The owner commits and pushes the stamp commit,
lets CI run on it, and reports `READY #<n> <sha> STAMP_VERSION=<X>`.
4. **Merge.** The queue verifies on that exact SHA: `VERSION` equals
`STAMP_VERSION`, the CHANGELOG's top heading is `[<X>]` and the heading
below it is the base's current version, every check is green on that SHA,
and, when a stamp commit was pushed, it changed only `VERSION`,
`package.json`, `CHANGELOG.md` and the release outputs
(`gstack-tree-receipt --gated <green-head> --current <sha>` prints the
per-file verdict). It then squash-merges with the merge API's expected-head
`sha` set to that SHA, so a late push is rejected by the server. "VERSION
above main" is replaced by "VERSION equals the restamp's STAMP_VERSION on
the SHA being merged, and that version is base + PATCH at merge time".
5. **Not next.** A PR waiting behind another may gate ahead locally with
`gstack-restamp --after <predecessor-pr>`; it pushes nothing. When the
predecessor lands, the waiting PR merges `origin/main` in (never a rebase),
keeping its own CHANGELOG section above the newly merged one, and step 3
runs.
Until this policy is on `origin/main`, `gstack-restamp --next` reads the base's
`bump: ask` and falls back to a MICRO bump; pass `--bump patch` on the first
PR stamped after the flip if its base predates it.
<a id="run-a-shared-merge-queue"></a>
## Run a shared merge queue
`gstack-ship-queue` strings `gstack-restamp`, `gstack-tree-receipt` and the
ship receipt into the coordinator protocol, so nobody proves a stamp diff by
hand. Lanes own their PRs and never merge; the coordinator owns the order and
every merge. Each step ends in a line the other side acts on, then
`GSTACK_RESULT: skill=ship-queue status=complete|refused run=<path>`. Nothing
here pushes.
**1. Lane: report ready (unstamped).** After the gate and pregate pass on the
pushed head:
```bash
gstack-pregate run --allow-repo-commands
git push
gstack-ship-queue ready --pr 42
```
```
READY #42 <head> STAMP_VERSION=unstamped
pr: https://github.com/<owner>/<repo>/pull/42
head: <head>
version: unstamped
base: origin/main@<sha>
ci: green at <head12> (passed=31 failed=0 pending=0 skipped=2)
PREGATE: pass tree=<tree12> head=<head12> checks=6
gate: command="<gate_command>" full_e2e=no gate-ahead=none
SHIP_RECEIPT: pr=42 head=<head12> …
```
CI's Version Gate (`scripts/compare-pr-version.ts`) accepts that head as
unstamped when `VERSION` and `package.json`'s version match its merge base, the
CHANGELOG change is confined to `[Unreleased]`, and any changed release output
is byte for byte what `release_tool` generates at the head's merge base
`VERSION` (the head's own, so a later stamp on main changes nothing), so a
generator change ships with its regenerated output and the slot's restamp
regenerates it again. A release output that differs from that (a hand edit, one generated at a
bumped version, or one the tool never writes), a `VERSION` bump or a
`package.json` version change classifies the head as stamped, and it must then
carry base + bump. The tool runs as head code in a throwaway worktree with a
bare environment and no tokens; one that fails at the head is an error, never
a pass.
**A PR that conflicts with the base on CHANGELOG.md alone.** GitHub shows every
READY PR as conflicting each time main stamps; the train merges the CHANGELOG
by section, so the lane never merges main for it. The conflict does mean GitHub
has no merge ref for the PR, so no `pull_request` CI runs on the head and
`ready` prints `ci: none at <head12>` with a `ci-hint:` line. The lane then
runs `ready … --dispatch-ci free-tests.yml,windows-free-tests.yml [--timeout
<s>]` (under a train policy): it dispatches each named workflow on the branch
(`gh workflow run <wf> --ref <branch>`, skipped when a run at this head already
concluded), waits for the runs (`CI_DISPATCH: <wf> --ref <branch> run <id> <url>
<conclusion>`), clears every requires-remote pregate lane whose workflow is one
of them with the run URL (`PREGATE_CLEARED: <lane> <url>`; a lane of another
workflow stays uncleared and is named in the refusal), and then reads the head
as usual, which passes because the dispatched check runs attach to the commit;
the `ci-hint:` line then says those non-pull_request runs count. A red run
clears no lane and refuses (`QUEUE_NOT_READY`); a run still going at `--timeout`
(default 2700 s) is `QUEUE_TRAIN_PENDING`, and rerunning the command keeps
waiting; a fork head is never dispatched here (`QUEUE_TRAIN_UNTRUSTED`). By hand the same four steps are `gh workflow
run <wf> --ref <branch>`, wait, `gstack-pregate clear --remote <lane> --run-url
<run>`, rerun `ready`. The `ci-hint:` line is advisory text for the lane;
nothing parses it. `ready --screen` on such a PR has no merge ref to screen,
so it builds the scratch merge described below (`SCREEN_MERGE:`, pushed as
`screen/<pr>-<head12>`) and screens that; only a real conflict with the base
(or a remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`. A CHANGELOG-only conflict is screenable, and a
lane whose skill's own paid cases ran focused may still READY with `--no-screen`.
`ci:` counts the latest run per check (one app's check name), as GitHub's
rollup does: a check that was re-run or superseded on the same commit (a
cancelled run beside a later pass of the same name from a second workflow run)
is judged by its newest run (latest `completed_at`, then id; a run still in
progress is newest); two apps reporting the same name stay separate; a run with
no name or no readable `completed_at` counts on its own; commit-status contexts
are counted one each.
It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD
is not the PR's pushed head, CI at that exact head is not green, or the pregate
result is not a current pass, and (`QUEUE_RECEIPT_MISSING`,
`QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that
head: /ship runs before every READY ([details](#ship-before-ready)). Send the
block as printed.
**2. Lane behind another PR: gate ahead.** Gate the tree the queue will have
once the predecessor lands, while it is still open:
```bash
git fetch origin pull/41/head
gstack-ship-queue gate-ahead --after 41 --pr 42 --predecessor-ref FETCH_HEAD --allow-repo-commands
```
prints `GATE_AHEAD: pr=42 attempt=<id> gated=<sha> tree=<tree> merge_tree=<tree>
predecessor=#41@<sha12> version=<N+1>`, the attempt's own `worktree:`, its
`record:` file, `GATE_COMMAND:` (the policy's `gate_command`) and the exact
`next:` line. Run the gate there, in the background or in CI, then bind it:
```bash
(cd <worktree> && <gate command>) > gate.log 2>&1
gstack-ship-queue gate-ahead --record --pr 42 --attempt <id> --exit $? --log gate.log
```
`GATE_RECORD: pr=42 attempt=<id> exit=0 gated_tree=<tree> log_sha256=<sha256>
current=yes`. The record keeps the log and removes only that attempt's
worktree, and refuses (`GATE_AHEAD_ATTEMPT_INVALID`) when the worktree no
longer holds the gated commit. If the predecessor moves, prepare a new
attempt; attempts never share a worktree, the newest is current, and
recording an older one later prints `current=no (superseded by <id>)`.
`gate-ahead --list --pr 42` shows them all. Send the record file with READY.
**3. Coordinator: call the slot.** Name the version, the base sha and the
order: "#42 next at 1.2.7.0, base `<sha>`, order 41,42".
**4. Lane: stamp.** A gate-ahead lane first merges `origin/main` (with the
predecessor in it) into its branch, never a rebase. Then:
```bash
gstack-ship-queue stamp --pr 42 --version 1.2.7.0 --expect-gated <gated> \
--expect-base <sha> --expect-order 41,42 --allow-repo-commands
```
`<gated>` is the head the gate ran on: the pushed head the lane gated, or the
record's `gated=` after a gate-ahead. The preflight refuses before anything is
written unless the tree to stamp equals the gated one modulo stamps, the base
is `<sha>` and, under `full_e2e`, a passing gate result is bound to the gated
head. Then it runs the restamp, commits, reruns the tree receipt and prints
```
QUEUE_STAMP: 42 1.2.7.0 head=<sha> gated=<sha> tree=same-modulo-stamps receipt=<sha256>
```
Each stage is journaled in `.gstack/tmp/ship-queue/stamp-42.json`; the
identical command resumes at the first unfinished stage, or returns the stored
`QUEUE_STAMP` when it already finished. A refusal prints
`QUEUE_STAMP_REFUSED: 42 head=<sha> committed=no|yes <sha> stage=<stage>`
and the coded line (`QUEUE_STALE` carries a `recompute:` command). Push the
stamp commit, wait for CI on it, run `ready` again
(`READY #42 <head> STAMP_VERSION=1.2.7.0`) and send it with the
`QUEUE_STAMP` line.
**5. Coordinator: verify, then merge.** From the coordinator's own clone:
```bash
gstack-ship-queue verify --pr 42 --gated <gated> --receipt <sha256> --expect-head <head> [--gate-exit 0 --gate-log <file>]
gstack-ship-queue verify --pr 42 --record <gate-ahead record> --receipt <sha256> --expect-head <head>
```
It fetches `refs/pull/42/head` and reruns the tree receipt there, so
`QUEUE_STAMP` is never a pasted claim: `VERIFY: 42 1.2.7.0 head=<sha>
gated=<sha> tree=same-modulo-stamps receipt=<same sha256>`. Under `full_e2e`,
`--gated` also needs the lane's passing gate log from its run on that head
(`--gate-exit 0 --gate-log <file>`), else `QUEUE_GATE_MISSING`. With a gate-ahead
record, whose gated commit never left the lane's machine, it first recomputes
the merge with `git merge-tree --write-tree <branch head> <predecessor head>`
and compares it with the recorded merge tree, rebuilds the gated tree from
the recorded stamp patch and compares it with the recorded `gated_tree`,
proves that patch is stamps only, checks the predecessor did not move, and
prints `merge_tree:`, `gated_tree:` and `gate:` lines. The last line is
`MERGE_CONDITION: sha=<head>`: merge with that expected head. Any
`QUEUE_VERIFY_MISMATCH` means do not merge.
**Rolling out `gate_command` and `full_e2e`.** Commit them (with
`min_gstack_version` at their introducing version, which `init --explain`
prints) only after every lane's gstack is at least that version, the crew's
install floor (`GSTACK_MIN_REVISION`). A lane on an older gstack refuses the
policy by name rather than reading it differently.
<a id="run-a-merge-train"></a>
## Run a merge train
A merge train gates several READY PRs with one full gate instead of one gate
each. The coordinator merges the pinned, unstamped PR heads in slot order onto
main in a scratch worktree, records each prefix tree (T0 is main, Tk is the
train head), runs the free tests and then one full gate on the train head, and
merges the PRs one at a time, each only while main's tree equals the prefix
the gate covered modulo stamps. A red train is bisected on its red cases only.
The surface is off unless the base revision's `.gstack/ship-policy.json`
declares a `train` block. Without one, `train …` and `verify --train` refuse
with `QUEUE_TRAIN_OFF`, and `ready --pr <n>` prints exactly the per-PR READY
block above (`--screen`, `--no-screen` and `--broad` are ignored with a
warning on stderr). A block for gstack itself would read:
```json
"train": {
"gate_workflow": "evals.yml", "free_workflow": "free-tests.yml",
"full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record",
"max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid",
"trial_reservation_usd": 1.5
},
"min_gstack_version": "<the version that introduced train>"
```
gstack's own policy carries this block. It went live after the workflow side
(the train-nonce record, the `cases` and `prefix_shas` inputs, the dispatched
screen) merged and one dry-run train passed: two unstamped scratch docs PRs on
a scratch base whose `route` step read the policy from that base instead of
the default branch. `gate_workflow` is dispatched with
`evals_all`, `expected_sha` and `nonce` (bisects add `cases` and
`prefix_shas`, screens add `screen_pr`, `screen_merge_sha` and
`screen_policy`). That run publishes `full_gate_check` as a check run on the
train head and the train record as the `record_artifact` artifact only when
its census is complete and green; gstack reads both and never writes a
check. `screen_selection: gstack-paid` computes gstack's paid case selection
for the screen recommendation; `none` leaves the choice to the lane.
`trial_reservation_usd` is what the bisect reserves for a trial without cost
data.
Every command prints its lines, the coded error line on stderr, then
`NEXT: <command>` and `GSTACK_RESULT: skill=ship-queue status=… run=<record>`.
Records live under `$GSTACK_STATE_ROOT/ship-queue/` (`trains/<nonce>/train.json`
with one attempt record per dispatch, `ready/` receipts and `freeze/`) and
are mirrored to origin as git notes on `refs/notes/gstack-trains` before any
remote effect, so a lost machine loses nothing: `train status` on a clean
state root rebuilds them. `GSTACK_SHIP_QUEUE_NOW` pins the clock for tests.
<a id="ship-before-ready"></a>
**0. Lane: /ship before READY.** Every READY follows a /ship run on the branch
at the head being READY'd, including a re-READY after a push (Garry,
2026-10-25: "we should really be using the gstack ship skill before merging
PRs"). /ship runs the tests, both reviews, the gate-integrity list and the docs
audit, and ends by writing its receipt into the PR body
(`gstack-ship-receipt write --pr <n> … --body <file>`, posted with
`gstack-post pr-body`). Under `queue_mode: stamp-at-merge` it bumps no version
and never merges or merges main into the branch; the train still stamps at the
slot. The queue enforces this at every step from the PR body alone:
- `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid
receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the
head being READY'd (or its `pr` is another PR); the informational
`SHIP_RECEIPT:` line still prints. There is no flag around it.
- `train build` refuses a pin whose PR has no current receipt with the same
codes, naming the PR, before anything is written, pushed or dispatched,
`--dry-run` included.
- `verify --train` refuses the slot with the same codes before printing
`MERGE_CONDITION`.
**The stamp keeps the pin's receipt.** The slot's stamp commit changes only
stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs
on it: the receipt written for the pinned head stays the binding one. `verify
--train` accepts a receipt whose `head` is either the slot's pinned head or
the current head, and the re-READY after the stamp push (`ready` at the stamp
commit) accepts the receipt for the pin when HEAD is the pin plus stamp files,
or (from slot 2 on) the pin merged with main plus stamp files on top of the
merge; any other head is `QUEUE_RECEIPT_STALE`, including a merge of main with
no stamp above it (a lane's own push under rule 14) and a merge whose pin side
carries an edit made after /ship.
This is a process gate, not an authenticity check: the receipt is a block in
the PR body, which the PR author writes, so the codes prove that /ship was run
and recorded for this head, not that nobody typed the block by hand. Fork heads
are already refused (`QUEUE_TRAIN_UNTRUSTED`). The `ship-run.json` that
`gstack-ship-receipt result` writes under the lane's state root is a
normalized copy of that same block bound to the head it was run at, not an
authenticity proof either.
**1. Lane: READY with a screen choice.** Under a train policy READY needs an
explicit choice:
```bash
gstack-ship-queue ready --pr 42 # prints SCREEN: lines and the recommendation, refuses
gstack-ship-queue ready --pr 42 --no-screen # READY receipt; the PR rides unscreened
gstack-ship-queue ready --pr 42 --screen [--broad] # dispatches one paid screen on the PR's merge SHA (a scratch merge when GitHub's merge ref lags the head; QUEUE_SCREEN_STALE_REF only on a real conflict)
```
Without a choice it prints the `SCREEN:` selection (with
`screen_selection: gstack-paid`, the skill's own cases and `recommend
--screen` or `--no-screen`: `--screen` when the screen selects a skill's own
cases) and refuses with `READY_SCREEN_CHOICE_REQUIRED`. The price line says
the dollar figure is pending the cost replay; until it lands, `SCREEN:` shows
case counts and the recommendation only. A lane that already ran focused
local cases says so in the READY block and may pass `--no-screen`. `--screen`
dispatches `gate_workflow` with `screen_pr`, `screen_merge_sha` (the
`refs/pull/<n>/merge` SHA) and `screen_policy` (`narrow`, or `broad` with
`--broad`, today's full PR selection). The nonce key carries the purpose and
the policy, so a repeat follows the same run and a broad screen never adopts
a narrow one. The READY block gains `screen: …`, and the receipt is written
locally and to the notes ref. `--dry-run` prints the dispatch without sending
it. Before dispatching, `ready` fetches the merge ref and requires its second
parent to be the PR head and its first parent the current base: a missing,
lagging or unfetchable merge ref (GitHub stops recomputing it while the PR
conflicts with the base, CHANGELOG-only conflicts included) is replaced by a
scratch merge of the head onto the base, pushed as `screen/<pr>-<head12>`
(the `SCREEN_MERGE:` line says which); a screen dispatched on a stale ref would
plan no cases and report success. Only a real conflict with the base (or a
remote whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`. A finished screen that planned no cases (no
`paid-plan` artifact, which a superseded run never uploads, or a plan manifest
with nothing planned) is never followed as a screen: `ready --screen`
dispatches a fresh one and `train build` prints `SCREEN: #<n> run <id> planned
no cases …; counted as not-run` and seats the slot unscreened. A plan the
queue cannot read keeps the slot screened, with `SCREEN: #<n> <reason>;
counted as screened, unverified`.
**2. Coordinator: build.** Name the pins exactly as READY reported them:
```bash
gstack-ship-queue train build --prs 41@<sha>,42@<sha>,43@<sha> --dry-run
gstack-ship-queue train build --prs 41@<sha>,42@<sha>,43@<sha>
```
It prints `ORDER:` (the slot order to announce: screened PRs first in READY
order, then unscreened; when `max_size` or more unscreened PRs wait they form
their own train and `HELD:` lists them; `--order <pr>,<pr>,...` naming every
pinned PR once replaces that rule, for a PR that must merge before another,
and the train's later `NEXT:` rebuilds keep it), one `PREFIX: T<i> <commit>
tree=<tree>` per prefix and one `SLOT:` per PR. `--dry-run` adds the
`ESTIMATE:` line and writes, pushes and dispatches nothing. Otherwise it
writes `train.json`, pushes `train/<nonce>`, dispatches `free_workflow` there
and waits (`--free-timeout <s>`, default 2700) for a green
`workflow_dispatch` run on that exact SHA, then dispatches the full gate
through eval-bg's nonce. A train fills at `max_size` READY PRs or when its
oldest READY has waited `fill_hours`, whichever comes first; until then
`QUEUE_TRAIN_WAITING` names the time, and `--urgent` dispatches a one-PR
train at once. gstack's own block fills at 3 PRs or one hour (it was 5 PRs or
three hours until 2026-10-26, which left READY PRs waiting most of a day). The same pins on the same base return the live train
instead of a second one; `train build --train <nonce>` resumes a train from
its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork
head), `QUEUE_TRAIN_MOVED` (a head moved from its pin),
`QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship
receipt for the pin; see [/ship before READY](#ship-before-ready)),
`QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed
`QUEUE_TRAIN_CONFLICT: train <id> stopped at #<pr> (conflicts: <files>) after
merging #<a>,#<b>; nothing dispatched, no branch pushed; fix: …` line names
the build without that PR) and `QUEUE_TRAIN_FREE_RED` (free tests red,
missing, cancelled or on another SHA). In a merge, version files take the
prefix's side and the CHANGELOG merges by section, since the stamp at merge
rewrites both; a PR whose CHANGELOG drops or renames a released heading (or
carries a released entry's text in a section of its own) is a CHANGELOG
conflict, never merged with the damage. READY receipts and train records are
keyed on origin's GitHub owner/repo, so lanes whose origin uses another URL
form (git.capy.ai, ssh, a token URL) share the coordinator's queue.
**3. Coordinator: status and verdict.**
```bash
gstack-ship-queue train status [--train <nonce>]
gstack-ship-queue train verdict --train <nonce>
```
`status` never spends. When a dispatch was attempted but its run never showed
up (`QUEUE_TRAIN_DISPATCH_UNKNOWN`), it lists the workflow's dispatch runs and
either adopts the run carrying the nonce or marks the dispatch `absent`; only
an absent dispatch is sent again, by `train build --train <nonce>`, under the
same nonce. `verdict` reads the run, its record and the `full_gate_check`
check: `GREEN` only for a complete census bound to the train nonce, head and
tree; `QUEUE_TRAIN_PENDING` while it runs; `QUEUE_TRAIN_INCOMPLETE` for INFRA
or INCOMPLETE panels, unbound, partial or cancelled shards, a slice killed
before upload, or no record (never green); `QUEUE_TRAIN_RED` with the red
cases.
**4. A red train: bisect, then at most one rebuild.**
```bash
gstack-ship-queue train bisect --train <nonce> --spend-cap 5 --dry-run
gstack-ship-queue train bisect --train <nonce> --spend-cap 5
gstack-ship-queue train rebuild --train <nonce>
```
The bisect dispatches the red cases (`cases`) on every prefix T0..Tk
(`prefix_shas`), one trial each, after checking every prefix is an ancestor
of `train/<nonce>` (`QUEUE_TRAIN_PREFIX_INVALID`). It reserves every
case×prefix trial (unknown costs at `trial_reservation_usd`) and launches only
the prefixes that fit the cap (`QUEUE_TRAIN_BISECT_CAPPED`; completed prefixes
are kept and a rerun with a higher cap runs only the rest). Rerun it to read
the results. The pattern decides: `ATTRIBUTED #<pr>` only when monotone
(green before, red from that PR on), and `NEXT:` builds the train without it;
red on every prefix with T0 included is `QUEUE_TRAIN_RED_ON_MAIN` (fix forward
on main first); anything else is `QUEUE_TRAIN_UNATTRIBUTED`. An all-green
bisect earns exactly one unchanged rebuild (`train rebuild`, reporting both
runs); a non-monotone pattern earns none and goes to a repair lane. Every
other rebuild is `QUEUE_TRAIN_REBUILD_REFUSED`, and a second red stays red.
An INFRA/INCOMPLETE-only census earns the single re-dispatch
EVAL_POLICY.infraRedispatch allows: `train rebuild --train <nonce> --infra`.
The rebuild-once rule lives in the train module beside that constant until
EVAL_POLICY records it. Bisect results never turn a train green.
**Stamping a slot.** When the slot is called, the lane stamps it at main plus PATCH. Slot 1's pin applies cleanly to main. From slot 2 on, main already carries the earlier slots' stamps, so the pin conflicts on CHANGELOG.md and GitHub can't merge it. The lane merges main into the pin first, then stamps:
```bash
git fetch origin main
git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only
bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);"
git add CHANGELOG.md && git commit --no-edit
gstack-restamp --next --base main --pr 42 --expect-base <main sha> --expect-order <order> --allow-repo-commands
git add CHANGELOG.md VERSION package.json <release outputs> && git commit -m "v<X> stamp" && git push
```
A conflict in any file other than CHANGELOG.md means the merge isn't the train's prefix. Stop and report it. When the helper says the CHANGELOG needs a hand merge, take main's CHANGELOG unchanged and put your own entry above it. `verify` accepts the resulting head because it contains main and its tree equals T<i> modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the `head:` line says "T<i> with main merged in".
**5. Coordinator: verify, merge, verify again.** For each slot in order:
```bash
gstack-ship-queue verify --pr 41 --train <nonce> # MERGE_CONDITION: sha=<head>
# merge #41 with that expected head; gstack-restamp stamps it at merge
gstack-ship-queue verify --pr 41 --train <nonce> # VERIFY_TRAIN: #41 merged; main's tree is T1 …
```
Before a merge, main's tree must equal the prefix before the PR modulo stamps
and the PR must be at its pin (stamps aside) or contain main and be T<i>
modulo stamps (its pin with main merged in; its CHANGELOG must be main's with
only lines from the pin's own CHANGELOG diff above it); after it, main's tree
must equal the prefix after it. Anything else is `QUEUE_TRAIN_TREE_MISMATCH`
(or `QUEUE_TRAIN_MOVED`). When nothing of the train has merged, that is a plain
refusal: build a new train on current main. When earlier slots already
merged, main holds a prefix no gate covered on its own, so verify freezes the
queue: the freeze goes to local state and the notes ref first, then the full
gate is dispatched on main's SHA at once (the backstop), and the
`QUEUE_TRAIN_FROZEN:` line lists the merged PRs, main's tree and the backstop
run. While frozen, ordinary trains and other trains' merges are refused
(`QUEUE_TRAIN_FROZEN`). Admit one repair PR at its exact head:
```bash
gstack-ship-queue train build --repair --prs 44@<sha>
```
It runs the normal free tests, full gate and verification on main plus the
repair. When verify confirms the repair merged, it prints `FREEZE: cleared`
and `NEXT:` builds a train of the PRs still unmerged.
**5b. Coordinator: after the merge.** `gstack-ship-queue verify --post-merge
<sha> [--timeout <s>]` (the merged commit's full 40-character SHA) watches
every check run attached to it (the push workflows: free tests, the Windows
subset, the main backstop) and every commit status until none is still
running, prints one `CHECK: <name> <conclusion>` per check and
`POST_MERGE: <sha12> green|red|pending|none (passed= failed= pending= skipped=)`.
Green needs at least one completed check and no failure; `neutral` and
`skipped` (the backstop superseding itself) count as skipped; a pending
commit status keeps the commit pending and a failed one makes it red. A
failure is `QUEUE_POST_MERGE_RED` naming the checks; a timeout (default 2700 s) is
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`. It keeps no process
state, so rerunning it after a machine restart continues the watch.
**6. Clean up.** `gstack-ship-queue train prune [--train <nonce>] [--dry-run]`
deletes the `train/<nonce>` branches of merged, superseded and dropped trains
(`WOULD-DELETE:`, `DELETED:`, `GONE:`). `train prune --stale` also settles a
`merging` or `green` train whose every PR is MERGED or CLOSED on GitHub (its
PRs landed through another train or by hand): it logs each PR's state on a
`STALE:` line, marks the record `stale` and deletes its branch; a train with an
OPEN PR is kept (`KEEP: … still needs the train`). The same run deletes every
`screen/<pr>-*` scratch branch whose PR is MERGED or CLOSED and keeps one whose
PR is still OPEN. `train forget --train <nonce> | --receipt
<pr>@<sha> [--dry-run]` removes one record (a settled train with its attempts,
or one READY receipt) from local state and from `refs/notes/gstack-trains`
with an exact lease on the notes ref, printing `FORGET:` then `FORGOTTEN:`; a
train that is still building, running or merging (`built`, `pushed`,
`free-pending`, `free-green`, `dispatch-unknown`, `dispatch-absent`,
`gate-pending`, `merging`) is refused; a rejected lease removes nothing and
restores the local notes ref.
**Following a train without a daemon.** `gstack-ship-queue train watch --train
<nonce> [--timeout <s>]` is the one resumable follow command: it reads the
record, runs the step its state asks for (`train build --train` to push, run
the free tests and dispatch the gate; `train status` to settle an unconfirmed
dispatch), waits for the pending run by polling gh, reads the verdict and
prints the last step's `NEXT:`. It never dispatches anything the record would
not dispatch on its own, so rerunning it after an interruption continues from
the record (`WATCH:` lines say what it did; a timeout, default 7200 s, is
`QUEUE_TRAIN_PENDING` with the same command as `NEXT:`; `--free-timeout <s>`,
default 2700, bounds the wait for the free tests).
**Screens are read from the run.** `ready --screen` and `verify --pr <n>
--screen` read a dispatched screen's verdict from the run's own artifacts
(`gate-binding-a<attempt>`, `report-verdict-a<attempt>`), never from a lane's
prose, and record it in the READY receipt (`screen.verdict`): `SCREEN_VERDICT:
#<n> run <id> GREEN|RED|NOT-RUN|INCOMPLETE [red=…] [flaky=…]` plus one
`FLAKY:` line per rule-16 flaky case. `PENDING` is a run still going,
`UNKNOWN` one the queue cannot read, and `INCOMPLETE` a finished run that bound
no verdict (its report job did not finish): `ready --screen` dispatches afresh
instead of following it, `verify --screen` refuses it (`QUEUE_PROOF_MISSING`)
and `train build` seats the pin unscreened, as for `NOT-RUN`. A RED screen refuses `ready` under
either choice, is never re-dispatched at the same merge SHA and is not seated
by `train build`, whose `NEXT:` names the build without that PR
(`QUEUE_SCREEN_RED`); a run that concluded success with no slice (its route
job found the head superseded or the plan selected nothing) is `NOT-RUN`:
`verify --screen` refuses with `QUEUE_SCREEN_NOT_RUN`, `ready --screen`
dispatches afresh instead of following it, and `train build` seats the pin
unscreened with a `SCREEN: … counted as not-run` line. When GitHub's
`refs/pull/<n>/merge` lags the PR head or is missing, `ready --screen` plans
the screen on a scratch merge of the head onto the base, pushed as
`screen/<pr>-<head12>` (`SCREEN_MERGE:` names it; `prune --stale` deletes it
once the PR merged or closed); only a real conflict with the base (or a remote
whose refs cannot be listed) is `QUEUE_SCREEN_STALE_REF`.
**Stacked tests before the gate.** `train build --prs … --dry-run --stack-tests
[--stack-tests-command "<cmd>"]` runs each PR's new test files (added relative
to the base, with a name ending in `.test`, `_test`, `.spec` or `_spec` and a
JS or TS extension, bun's own rule, so a helper or fixture under `test/` is not
one) on the integrated prefix that includes it, T<k>, in a scratch worktree, and prints
`STACK_TESTS: #<pr> T<k> <n> new test file(s) pass|FAIL (<files>)` or `no new
test files`. A failure is `QUEUE_TRAIN_STACK_RED` (nothing written, pushed or
dispatched): the tests pass on the PR head alone only because nothing before
it in the train had changed what they read, so fix them against that prefix or
reorder with `--order`. The default command is `bun --no-env-file
--config=/dev/null test` with the files appended.
**Escape hatches.** Remove the `train` block on the base branch to return
every lane to the per-PR queue at once. `--urgent` ships one PR alone,
`--no-screen` skips the paid screen, `--broad` widens it to today's selection,
and `train build --prs <list without it>` drops a PR at any step.
<a id="ship"></a>
## What `/ship` reads from it
`/ship` Step 3 runs `show-effective --base <base>` and follows its `ship:` lines,
which carry the workflow consequences the skill prose would otherwise spell out:
| Line | Effect |
|---|---|
| `ship: history merge-only: …` | never rebase, squash or force-push; `check-history --plan <merge\|squash\|rebase>` refuses a rewrite plan (`HISTORY_POLICY_VIOLATION`, exit 3) |
| `ship: queue_mode stamp-at-merge: …` | Step 12 items 1–4 are replaced by `gstack-restamp --next --base <base> --pr <n> --expect-base <sha>` when the PR is next, `--after <predecessor-pr>` when it is not; the restamp's own `next:` line says what follows: under `stamp-at-merge`, `commit and push only the stamp diff above (FILES), with no edit of your own` and the merge's expected head (the stamp commit holds restamp's output alone); under the per-PR queue, NEW_VERSION and the CHANGELOG entry text |
| `ship: shared merge queue: …` | in a coordinator-run queue, `gstack-ship-queue ready`, `gate-ahead` and `stamp` replace the restamp calls; see [Run a shared merge queue](#run-a-shared-merge-queue) |
| `ship: gate-ahead: …` | with `.gstack/tmp/gate-ahead.json` present, Step 16 reuses only the lanes `gstack-tree-receipt … --json > .gstack/tmp/tree-receipt.json` marks reusable; stamp-sensitive and not-eligible lanes run live; `--force-gate <reason>` is the coordinator's call |
| `ship: bump patch: …` | Step 12 never asks for MINOR or MAJOR; PATCH or MICRO by size |
| `ship: preregistration: …` | `gstack-evidence ancestor --from-policy --base <base>` must print only `ANCESTOR: ok` before push |
| `ship: release_tool: …` | the command runs only inside `gstack-restamp` (staging worktree, outputs confined to `release_outputs`) |
| `ship: review_scope …` | Step 9.4 item 0 runs `gstack-review-scope classify` on each pass's findings; see [review_scope](#review-scope) |
The PR body's first section is the ship receipt (`gstack-ship-receipt write … --body`),
written by the pr-body section of `/ship` after the body is composed.
<a id="review-scope"></a>
## `review_scope`: how the review loop converges
Since the release that added `review_scope` (`REVIEW_SCOPE_KEY_VERSION` in
`lib/ship-policy.ts`). `/ship` Step 9 runs the pre-landing review in passes and fixes
what it finds between them ([review-policy.md](review-policy.md) is the canonical
statement). Under `review_scope: frozen` (the default) the loop converges by
construction:
1. **Pass 1 reviews the whole diff.** Every finding goes through
`gstack-review-scope classify --base <base> --pass 1 --findings <file>`, which
gives each one a `locus` (`diff`: a hunk the PR changed, with three lines of
context; `outside`: anything else), a `kind`, `blocking` and a `scope`.
`blocking` is a CRITICAL defect: a test that would fail, a production break, a
security or data-loss defect. Everything else (an advisory, an informational
item, a test gap, a wording note) is below the floor. Pass 1 fixes findings in
the diff (`scope: fix`). A blocking finding in code the PR did not change is
`scope: defer`: `gstack-review-scope defer` files it as a GitHub issue (reusing
an open issue that carries the same `<!-- gstack-deferred-finding: <fingerprint> -->`
marker; the title and body go through the same publication scan as the PR
body, so a finding that quotes a credential stays `unfiled (publication scan
refused: …)`; `unfiled (<reason>)` without `gh`) and `render` writes it under
`## Deferred findings` in the PR body. Below-floor findings outside the diff,
and every below-floor finding from pass 2 on, are `scope: note` and go under
`## Review notes` in the PR body.
2. **Pass 2 and later review the range since the previous pass.** Each pass saves
`PASS_HEAD`; the next pass sets `RANGE_BASE` to it, diffs `RANGE_BASE..HEAD`
for every reviewer (the core checklist, specialists, Red Team, the outside
voice; the gate scan and QA probes keep the whole candidate) and classifies
with `--range-base <sha>`. Only a blocking finding in that range (`locus:
fix-range`) or in the diff is `scope: fix`; nothing below the floor earns a
pass. `NO FINDINGS` is the expected reviewer result for a clean range, never a
failed review.
3. **Three passes is the cap.** A pass that applied no edits continues to Step 10:
the loop ships with its deferred and noted items named in the PR body. A
blocking finding still in `fix` scope on pass 3 stops the run and names the
finding (path, severity, problem) for the owner instead of a fourth pass.
`gstack-review-scope summary --since <REVIEW_SINCE>` reads the branch's review-log
rows for this invocation and writes the counters the ship receipt carries
(`gstack-ship-receipt write … --review-loop @<file>`): `review_loop: { passes,
blocking_found, deferred, noted, fixed, scope }`, printed on the receipt summary
line as `passes=<n> blocking_found=<k> deferred=<m>`, so a scorecard can track
passes per PR.
The floor and the pass structure are stated once, in
[review-policy.md](review-policy.md); this section documents the knob.
`review_scope: diff-plus-blocking` is the documented fallback for a repository
where deferred issues pile up: the same passes, range and cap as `frozen`, but a
blocking finding in code the PR did not change is `scope: fix` in this PR
instead of `defer`. `review_scope: all` is the opt-back to the earlier loop:
every finding is `scope: fix` on every pass, reviewers read the whole diff each
time, and only the three-cycle fix cap stops the loop. `show-effective` prints the choice as a `ship: review_scope …`
line, and the codes a refusal ends with are
[`REVIEW_SCOPE_INPUT`](troubleshooting.md#review-scope-input) and
[`REVIEW_SCOPE_RANGE`](troubleshooting.md#review-scope-range).
<a id="receipt-carry"></a>
## Carrying a receipt across a base merge or a test-only fix
Since the release that added `review_scope`. A `/ship` receipt names the head it
reviewed, so a conflict-only merge of the base or a test-only fix used to
invalidate it and cost a full re-run on identical product code.
`gstack-ship-receipt carry --pr <n> --base <base>` (or `--body <file>`) moves
the receipt to HEAD when the working tree is clean, the receipt's head is an
ancestor of HEAD and `git patch-id --verbatim` of the product diff against the
base is the same at both heads (verbatim: whitespace counts, hunk line numbers
do not). Product means every changed path that is not test code (`test/**`,
`spec/**`, `e2e/**`, `**/*.test.<code ext>`, `**/*.spec.<code ext>` and the
other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is
product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, and `VERSION`
or `package.json` only while the change is a version stamp and nothing else: a
dependency, a script or a new key makes them product). A path the policy's
`stamp_files`, `stamp_paths` or `release_outputs` name is metadata only while
its change is a stamp, the rule the tree receipt applies to the same paths: a
manifest by its version key, any other file when its new text reads as the old
once the new release version is replaced by the old one. A logic change in a
stamped source file is product. Lockfiles are product unless the policy names
them. A base merge contributes no product
change of its own. A receipt carried twice keeps the reviewed head as
`carry.from` and adds up the merges and paths. The carried receipt records
`carry: { from, reason, product_patch_id, changed_paths, merge_commits,
range_diff, covers, reruns }` with the `git range-diff` evidence, prints
`CARRY: carried from <old> to <new> (<reason>)` and the `GSTACK_RESULT:
skill=ship status=complete` line, and its summary line ends with
`carried_from=<old12>`. `CARRY: current` means the receipt already names HEAD;
`--dry-run` prints `CARRY: would carry …` and writes nothing.
A changed product path, or a product patch-id that differs, is
[`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed)
(exit 3; the refusal names the paths), and a receipt whose head is not in HEAD's
history (a rebase or force-push) or a working tree with uncommitted changes is
[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused): run `/ship`
on the new head. `/ship` Step 1 runs the carry check itself when the branch has
an open PR, and a `CARRY: carried` result ends that run with the receipt bound
to HEAD; the queue's `ready`, `train build` and `verify --train` read the
carried receipt like any other.
## What `/land-and-deploy` reads from it
Step 3.4 runs `show-effective --base <base>` before the merge and prints one
`VERSION_CHECK:` verdict for the PR head:
| Policy | Stops when | Passes |
|---|---|---|
| `queue_mode: stamp-at-merge` | the head's VERSION is not the base's version plus one bump, or the changelog's top two headings at the head are not that version and then the base's top entry (`STOP stamp stale`: run `gstack-restamp --next` on the branch, push the stamp, rerun) | an exact stamp; other open PRs' tentative stamps are ignored |
| `queue_mode: claim-at-open` (and no policy file) | another open PR holds the same VERSION, or the base is already at or above it (`VERSION drift detected`: rerun `/ship`) | any unique VERSION above the base, including a MICRO bump or one below a sibling's claim |
An invalid policy, an unreadable claim list or a missing VERSION is `unavailable`:
the skill reports it and CI's version gate is the backstop. A base at or above the
branch VERSION still stops.
<a id="stamp-at-merge"></a>
## `queue_mode: stamp-at-merge`
Under `stamp-at-merge` nothing is claimed when a PR opens. The stamp happens
when the PR is next, from the base branch's version at that moment, through
the repo's own release tool, across every stamp location at once:
```bash
gstack-restamp --next --base main --allow-repo-commands --pr 42 \
--expect-base <sha of origin/main the queue assumed> --expect-order 40,41,42
```
prints `ship, next in queue at <version>`, `STAMP_VERSION: <version>`, one
line per stage, the diff limited to stamp files, `MERGE_CONDITION: sha=<head>`
(pass it to the merge API's expected-head `sha` so a check-to-merge race is
rejected by the server) and the `GSTACK_RESULT:` line. `claims: ignored
(stamp-at-merge)` says the legacy claim reader was not consulted. When the base
or the order moved, the result is `QUEUE_STALE` (exit 3) with the exact
`recompute` command; nothing is written.
Stages, each journaled in `.gstack/tmp/restamp-journal.json`:
0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command).
1. **version**: `gstack-version-bump write --version <X>` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version.
2. **release_tool**: the policy command, under the contract below.
3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under `<X>`, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/<base>` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `<X>` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once.
4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was <X>`, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (<file>: N moved, M kept)`), so the stamp commit shows which lines moved and why. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate.
5. **mirror**: tier 2; declared and validated, reported as not run.
6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage).
Idempotent: a second run with the same version is `noop`. `--dry-run` prints
the full diff and writes nothing (not even the journal). `--version <X>` stamps
exactly X; `--bump` overrides the level; `--queue-mode` overrides the policy.
### Gate-ahead: `--after <pr>`
A reorder is cheap only when the gate can run before the predecessor merges.
`gstack-restamp --after <pr> --predecessor-ref <ref>` merges HEAD and the
predecessor's head in a throwaway worktree (`.gstack/tmp/gate-ahead-worktree`),
resolving only version-file and CHANGELOG-ordering conflicts, stamps N+1
relative to the predecessor's version there, commits, and records
`.gstack/tmp/gate-ahead.json` (`predecessor_head`, `branch_head`,
`gated_head`, `gated_tree`, `version`, `evidence_branch_key`). The PR branch's
history is never touched, so squash-history PRs stay clean. The gate runs in
that worktree; `gstack-evidence bundle` there exports the evidence.
<a id="release-tool-contract"></a>
### The release-tool contract
The release tool runs in an isolated staging worktree seeded from the working
tree's content fingerprint (`gstack-wtree`), with the repo's ignored
`node_modules` linked in. Its outputs are enumerated: the version files,
lockfiles, the CHANGELOG, `stamp_paths` and `release_outputs`. A write outside
that set refuses the whole restamp before anything reaches the branch
(`RESTAMP_OUTPUT_OUTSIDE_ALLOWED`); a non-zero exit is
`RESTAMP_RELEASE_TOOL_FAILED`. Preimages and postimages of every write are
recorded in the journal. Pinning the command string does not freeze its
implementation (`bun run release:restamp` still runs the PR's package script),
so the receipt names the policy revision, the command, its working directory
and the trust scope (`policy: base origin/main@<sha>` or `head (unreviewed)`).
<a id="restamp-command"></a>
### `restamp_command`: the repo owns the stamp
A repo whose own tool knows its stamp locations sets `restamp_command`.
`gstack-restamp` runs it as the first stage inside its staging worktree, a
clean checkout of HEAD, with the allocated version as the last argument and in
`GSTACK_RESTAMP_VERSION`. The caller's HEAD, index and worktree must be clean
first (`RESTAMP_CONFLICT`, nothing written); the generic path keeps its
working-tree fingerprint seed. Anything the command stages or commits in the
staging worktree is folded back into plain edits; then output confinement
applies as for the release tool (`RESTAMP_OUTPUT_OUTSIDE_ALLOWED`), the
version file must hold the target version (`RESTAMP_VERSION_SOURCE`), and a
non-zero exit is `RESTAMP_RELEASE_TOOL_FAILED`. In every failure nothing
reaches the branch. gstack's version writer is skipped; the CHANGELOG and
`stamp_paths` stages still run (a no-op when the tool already did the work).
Read from the base branch and run only with `--allow-repo-commands`, like
`release_tool`. A tool that fetches, merges the base branch or needs a version
of its own choosing cannot run here; give it a mode that takes the version and
writes only stamps.
<a id="tree-receipt"></a>
## The tree receipt
```bash
gstack-tree-receipt --from .gstack/tmp/gate-ahead.json --predecessor-pr 41 --bundle evidence-bundle.json
gstack-tree-receipt --gated <sha> --current HEAD --json
```
Compares two **committed heads** (a working-tree fingerprint can name objects
unreachable once the gate VM is gone) and prints the gated and current heads,
both tree ids, the version pair, the predecessor the gate assumed, the diff
limited to stamp files, one line per file with its rule and verdict, one line
per gate, then two verdict lines:
```
TREE: same-modulo-stamps | changed (<residual paths>)
gate-reuse: eligible (reusable: tests; rerun: digest) | not-eligible (<what changed>) — fix: <rerun command> (GATE_REUSE_NOT_ELIGIBLE)
```
Hunks are classified per file type: the version file (old → new only), the
manifest (`version` key only), npm and bun lockfiles (root `version` keys
only, where `gstack-version-bump` writes; a dependency entry is `changed`),
the CHANGELOG (a heading move or re-heading, compared as an order-independent
multiset of entries), `stamp_paths` and `release_outputs` (every
removed/added pair must be an exact `(was X)` / `since: X` / `vX` rewrite (a quote around X in the first two forms included) with
X the gated version and the replacement the current one, so a dependency pin
that happens to equal the old version is `changed`; `stamp_patterns`
templates count as stamp forms too). With `stamp_files` set, a file outside it
is `changed (stamp_files: not in the repo's stamp_files allowlist)` whatever
its diff. Any other byte is a change.
Equivalence is decided per gate. A lane in `stamp_sensitive_gates` always
`rerun`s; a verified lane is `reusable` only when the tree is same-modulo-stamps
and the execution identity in the bundle equals the current one: Bun and Node
versions, OS and arch, every lockfile's identity (root version normalized
away), the `.github/workflows` tree id, runtime pin files and
`package.json#engines`, and the runner image when either side knows it. Without
a bundle the identity is unknown and the receipt is `not-eligible`. When the
recorded predecessor head is no longer the predecessor PR's head (checked
through `--predecessor-ref` or `gh pr view --predecessor-pr`) the receipt
prints `PREDECESSOR MOVED <old> -> <new> — fix: gstack-restamp --after <pr>
again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`.