Files
gstack/docs/ship-policy.md
T
Garry Tanandcapy-ai[bot] f27a7605d4 v1.91.270.0 feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups (#3487)
* feat(ship): version-less repos, docs-audit record and caps, the frozen-scope paid case, and the review-loop follow-ups

Follow-up to the review-loop convergence PR: Step 12 and the CHANGELOG step skip cleanly in a repository with no version source (receipt version "none"); the receipt records the docs audit outcome and the owner exception (DOCS_AUDIT_BLOCKED without one) and a policy may declare doc caps (gstack-ship-receipt docs-caps); docs/unattended.md names the READY evidence and the cross-family outside voice; the ship-frozen-scope-convergence behaviour case runs the generated Step 9.4 under review_scope frozen through the real binaries; carry probes git >= 2.39 (RECEIPT_GIT_TOO_OLD); defer passes the repository to gh repo view positionally and names its search limit; late defers count without being passes; reviewers diff from RANGE_BASE on pass 2+; the cap check runs before edits; --review-fanout batches specialists by free slots; read --check-prose compares quoted counters with the receipt (RECEIPT_PROSE_MISMATCH); checklist rule ids prereg-with-results and test-keyboard-count-loop; gstack-coauthor-trailers.

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

* test: repair the CI reds the retarget onto main surfaced (test-only)

Seven test files, no product change. The pull_request run at 2d73fadd
failed 5 Linux shards and 4 Windows shards:

- test/ship-frozen-scope-actor.test.ts imported only test code, so the
  test-of-test ratchet refused it; the first case now recomputes the loop
  summary with summarizeReviewLoop/reviewLoopLine from lib/review-scope and
  asserts the exact REVIEW_LOOP line. Its three cases replay the bash
  workflow through bash -c and the shebang bins, so they are POSIX-only.
- test/codex-host.test.ts: the new gate case is Claude-specific (33 -> 34).
- test/paid-retry-supervision.test.ts: the gate census fixture now carries a
  trial record for the one planned behavior-kind gate case, the way
  test/ci-paid-coordination.test.ts already does.
- test/review-fix-range.test.ts asserted the pre-#3487 DIFF_BASE sentence;
  it now asserts the ${RANGE_BASE:-merge-base} clause that carries the same
  contract (every reviewer reads the range from pass 2 on).
- test/gstack-coauthor-trailers.test.ts: the fixture sets its own git
  identity; a CI runner has none to auto-detect ("Committer identity
  unknown").
- test/review-scope-followups.test.ts and test/gstack-ship-receipt-docs.test.ts:
  the gh and old-git stand-ins are sh scripts on a colon-joined PATH, which
  CreateProcess cannot run, so those three cases skip on win32.

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

* fix: pre-landing review fixes (pass 1)

Twelve informational findings from the /ship pass-1 review, all below the
blocking floor, fixed in place:

- docs-caps reads docs.caps from origin/<base>'s policy (--base <ref>), never
  the branch's copy; an unavailable base policy refuses with
  POLICY_SOURCE_UNAVAILABLE instead of substituting DEFAULT_POLICY
- receiptVersion returns "none" only when the stamp file is absent; other
  errors surface as undefined
- checkReceiptProse groups counters by leaf key and uses word boundaries that
  cover hyphenated names, so shared leaf keys and hyphenated words no longer
  report RECEIPT_PROSE_MISMATCH
- gstack-coauthor-trailers refuses a --range/--base starting with "-",
  prefixes origin/ when origin/<ref> resolves (slash-named branches), and
  passes --end-of-options to git log
- REVIEW_SCOPE_KEY_VERSION and DOCS_CAPS_KEY_VERSION name the releases that
  first read their keys
- the Step 11 late-defer row logs under skill review-late-defer so a tokenless
  review row never reads UNVERIFIED; summarizeReviewLoop counts both
- the "already filed" literal is one constant shared by bin and renderer
- receiptDocs (dead) removed
- Step 14.5 clears .gstack/tmp/docs-audit.json on first entry
- dispatched review prompts carry a literal diff-base sha (<diff-base>) instead
  of a shell variable the reviewer process never sees
- the coauthor-trailers tests launch the bin through runScriptSync so the
  Windows lane can run them

Tests extended for each change; SKILL.md, sections and goldens regenerated.

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

* test: cover the carry patch-id refusal (Step 7 coverage gap)

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

* test: derive the too-old review_scope minimum from the constant

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

* fix: keep co-author trailers in one trailer block

gstack-coauthor-trailers --message appended the new Co-authored-by lines as a
separate paragraph after existing trailers, so git interpret-trailers (and
GitHub attribution) read only the last paragraph and the existing co-author
lost credit. The new lines now join the existing trailer block; the test
checks the result with git interpret-trailers --parse.

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

* fix: let git place co-author trailers; name the docs key release

gstack-coauthor-trailers --message now runs git interpret-trailers --in-place,
so the appended lines join the existing trailer block whatever its shape
(CRLF, comment lines, folded continuations) and the existing co-author keeps
attribution; the interpret-trailers check in the test carries a timeout for
the spawnSync tripwire. DOCS_CAPS_KEY_VERSION names the release after the
newest CHANGELOG heading on main (1.91.258.0), and a test ties every
unreleased policy key to that next release so a stamp elsewhere fails loudly.

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

* fix: hold unreleased policy keys newer than any visible heading; pin trailer placement

The CHANGELOG-heading test asked for the exact next release after the newest
heading, which a depth-1 CI clone (no origin/main) computes from the branch
alone, so it went red there. It now requires an unreleased key's version to be
newer than every heading the checkout can see, which holds at depth 1, on the
stamped train tree and on main; the ship-policy comment says who moves the
constant when the stamp lands under another number.

gstack-coauthor-trailers pins git interpret-trailers' placement on the command
line (--no-divider so a --- body line is not a divider, --where end,
--if-exists addIfDifferentNeighbor, trailer.separators=:, -- before the path)
so a user's trailer.* config cannot replace or drop the existing co-author.

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

* docs: describe the shipped contracts (Step 13 and 14.5)

CHANGELOG [Unreleased]: docs-caps --base <base> reading origin/<base>'s
policy, the review-late-defer row, the literal <diff-base> prompt rule, the
coauthor-trailers placement and refusals, the policy key versions (review_scope
1.91.256.0 re-pin, docs.caps at the stamping release), the version-less
conditions split between Step 12 and the receipt, and the carry refusal wording.
docs/ship-policy.md, docs/review-policy.md and docs/unattended.md follow the
code: docs-caps --base, the literal diff-base sha, the trailer block, the
gstack-outside-voice run binding and the Step 11 row fields.

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

* docs: scope the literal diff-base claim to the review-army prompts; name where the outside-voice cause lives

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

* fix(docsync): declare Step 14.5's own-tree commands to the fixture; exclude .gstack/tmp at the Entry

The ship-docsync-completion and ship-docsync-current paid cases failed twice at
775d9804: Step 14.5's continue path now runs gstack-ship-receipt docs-caps and
writes .gstack/tmp/docs-audit.json, and the docsync fixture's closed interface
declared neither, while its repository had no origin/main for docs-caps to read.
The actor stopped correctly (blocked, no exception) and never reached the
publication stand-in.

- test/helpers/docsync-interface.ts: the three housekeeping commands (the shared
  exclude line, rm -f of the record, docs-caps --base main) and the record write
  are declared, byte for byte with the generated section; DOCS_AUDIT_RECORD_PATHS
  names what the kernel observer may see.
- test/helpers/docsync-observer.ts: accepts exactly those commands and the record
  write; every neighbour (another base, --json, write --docs, another .gstack file)
  stays outside the interface.
- test/helpers/docsync-fixture.ts: every scenario pushes main to the fixture
  remote, as Steps 0-14 would have.
- ship/sections/documentation.md.tmpl: the Entry runs the shared .gstack/tmp
  exclude line before rm -f, so the record never surfaces as an untracked file
  when no earlier step added it; the caps paragraph tightened to hold the ship
  parity ratio at 1.7400.
- test/docsync-command-grammar.test.ts: pins the literals against the generated
  section and the refusals around them.

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

* fix(docsync): the receipt record is housekeeping for read-only audits and the fault cases too

Pass 9's outside voice found two gaps in the fixture change (2524ec4): a
read-only audit (ship-docsync-current) must still write .gstack/tmp/docs-audit.json,
but docsToolFailures flagged every product write under readOnly before the
record allowance; and the unseeded fault cases (missing-asset, legacy-completion)
run the Entry too, so their kernel-write check saw .git/info/exclude as a
forbidden write because only the lifecycle test named the housekeeping paths.

- docsToolFailures: the record write is permitted under readOnly; the document
  and every other product file stay refused.
- docsWriteFailures: DOCS_AUDIT_RECORD_PATHS are permitted for every caller, so
  the fault evaluator needs no per-scenario list; anything else under .git or
  .gstack stays forbidden.
- test/docsync-command-grammar.test.ts pins both: the record under readOnly, the
  document refused there, the observed housekeeping paths accepted with an empty
  allowance and two neighbours refused.

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

* test(docsync): accept the cap gate's ~/.claude/skills/gstack form the section prints (pass 9 Red Team note)

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

* test(docsync): name the admitted composed line precisely (pass 9 maintainability note)

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

* fix(docsync): install lib/ beside bin/ in the fixture so the declared cap gate can load (2524ec4 rerun: module error, not a verdict)

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

* fix(docsync): admit the Write tool's temp sibling of the receipt record (0230fc2 rerun: both lifecycle cases red on it)

The paid rerun at 0230fc2 cleared every earlier refusal and both cases then
failed on one path: forbidden docs write: .gstack/tmp/docs-audit.json.tmp.<pid>.<12 hex>.
Claude Code's Write and Edit tools land every file, new ones included, through
that sibling plus fsync and rename, and the fixture's kernel observer proved
such a landing for the document only (atomicTargets: [DOC_PATH]).

The observer now treats the record's sibling as a transient file too, and
docsWriteFailures admits it only as the cookie-paired MOVED_FROM source of a
MOVED_TO on the record whose other events are create/modify/attrib/close_write
and which is left in neither snapshot nor in changed. A lingering sibling, an
unpaired move, a move onto another path or a sibling of the document moved onto
the record stay forbidden (grammar test). A live check on this machine saw the
second landing's create/modify/close_write/moved_from events admitted and a
stray sibling refused.

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 08:16:11 +00:00

65 KiB
Raw Blame History

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, merge trains), 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.

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. 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_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.
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. 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.
docs { caps: [{ path, max_bytes }] } or null null Documentation files with a byte cap. /ship Step 14.5 runs gstack-ship-receipt docs-caps --base <base> (the caps come from origin/<base>'s policy, never the branch's copy; --policy <file> is for a repository bootstrapping its policy) before the verification gate and prints one DOCS_CAP: <path> <bytes>/<max> ok|over line per cap; any over (an absent file counts) exits 1 and blocks the ship until the file is trimmed or the cap raised on the base branch (DOCS_AUDIT_BLOCKED). At most 64 caps, each path contained. See the ship receipt. Needs min_gstack_version.
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, 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:

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

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:

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

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:

(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:

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:

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.

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:

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

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:

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:

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

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.

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:

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 modulo stamps, with its CHANGELOG judged as main's plus lines your pin added, however the conflict was resolved; the head: line says "T with main merged in".

5. Coordinator: verify, merge, verify again. For each slot in order:

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

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

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

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.

The ship receipt

The ```gstack-ship-receipt block is the handoff record: identifiers first (pr, head, version, base), then the evidence (gate, ci, pregate, issues, approval, docs, review_loop, carry), validated against the ship-receipt schema (gstack-artifact schema ship-receipt) and read back by gstack-ship-receipt read, result, carry and the queue.

version. The version source at HEAD: the path .gstack/version-path pins, else VERSION, else package.json#version. A repository with none of those, none of the policy's stamp_files and no CHANGELOG (the policy's changelog, or CHANGELOG.md) at HEAD has no release version to record, and the receipt says so with "version": "none": /ship Step 12 printed Version bump: skipped (no version source: <what was looked for>) and Step 13 CHANGELOG: skipped (none at <path>), the PR title carries no version prefix, and read, result, gstack-ship-queue ready, train build and verify --train accept the receipt like any other. A repository that has a CHANGELOG but no version source records no version at all (the audit still says what it looked for); pass --version to name one.

docs. Step 14.5's outcome, from gstack-ship-receipt write --docs <json|@file>: { status: updated | current | blocked, scope, exception? }, where scope names the docs roots the audit covered and exception is { accepted_by, date, via, reason }, the owner's acceptance of a risk the audit could not clear. A blocked status with no exception is DOCS_AUDIT_BLOCKED and nothing is written: only the owner accepts a documentation risk, so an unattended run stops there. With --body, the body's ## Documentation section must carry the exception word for word (Documentation exception: accepted by <owner> <date> via <ref>: <reason>), or the write refuses with the same code. Doc caps: a repository declares docs.caps: [{ path, max_bytes }] in its policy, and Step 14.5 runs gstack-ship-receipt docs-caps --base <base> before the verification gate, reading the caps from origin/<base>'s policy so the branch cannot raise its own (--policy <file> only while bootstrapping a policy; an unreadable base policy is POLICY_SOURCE_UNAVAILABLE); one DOCS_CAP: <path> <bytes>/<max> ok|over line per cap (absent/<max> over for a path the tree lacks), exit 1 on any over, whose repair is trimming the file, never raising the cap in the same PR.

Prose against the receipt. gstack-ship-receipt read --pr <n> --check-prose (or --body <file>) compares every counter the body quotes with the receipt's integers under review_loop, gate and ci: passes=2, passes: 2 and 2 passes are claims about review_loop.passes, 1 deferred about review_loop.deferred, passed: 41 about gate.passed (a leaf key shared by several counters, passed under gate and ci or pass under every gate lane, matches when the number equals any of them; an underscore in a key matches _, a space or -; a name inside a longer word, 3 pass-through, is not a claim). A body that quotes a different number is RECEIPT_PROSE_MISMATCH (exit 1) and the PROSE: line names each field with both values; prose that quotes no counter is not a claim. Data PRs (evals, measurements, sweeps) run it before READY so the body's numbers are the run's.

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 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; 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 and REVIEW_SCOPE_RANGE.

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). --verbatim needs git 2.39 or later: the carry probes git --version first and refuses an older git by name (RECEIPT_GIT_TOO_OLD) instead of falling back to the whitespace-stripping default, and a git diff or patch-id failure is reported with git's own first stderr line. When the patch-ids differ, the refusal names up to CARRY_NAMED_PATHS (20) product paths whose patch against the base changed, walking only the paths the branch itself touched at either head (a base merge brings in every path the base changed, none of which can differ between the two base diffs). 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 (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: 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.

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:

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:

  1. restamp_command (only when the policy sets it): the repo's own restamp, see restamp_command.
  2. version: gstack-version-bump write --version <X> in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when restamp_command owns the version.
  3. release_tool: the policy command, under the contract below.
  4. 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.
  5. 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.
  6. mirror: tier 2; declared and validated, reported as not run.
  7. 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.

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

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.

The tree receipt

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