v1.91.173.0 fix(ship-queue): train verify accepts a slot head with main merged in (GSTA-49) (#3327)

* fix(ship-queue): train verify accepts a slot head with main merged in (GSTA-49)

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

* v1.91.173.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>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-21 00:10:39 +00:00
1 parent e7a1df9e18
commit b616d6c53d
7 files changed
+73 -188

No files matched your search

+11
View File
@@ -1,5 +1,16 @@
# Changelog
## [1.91.173.0] - 2026-10-20
**Merge trains can merge slot 2 onward.** After slot 1 merges, main carries the new stamp, so every later pin conflicts with main on the stamp files and GitHub can't squash it. A lane makes its PR mergeable by merging main into the pin before it stamps. `verify --pr <n> --train <nonce>` refused that head as moved off its pin. Now it also accepts a head whose tree equals the slot's train prefix (T<i>) modulo stamps: exactly the tree the train's gate covered, and what main becomes once the PR squash-merges. The first live train stopped at slot 2 on this (gate.train-ac4baf961759-43d6ed28f2bf-9a1dbb). Plan GSTA-49.
### Fixed
- **`lib/ship-queue-train-verify.ts`** accepts a slot head that is either its pin plus stamps (as before) or T<i> modulo stamps, and the `head:` line says which. A head that matches neither is still `QUEUE_TRAIN_MOVED`, and the queue still freezes when earlier slots have merged.
- **`docs/ship-policy.md`** gives the per-slot lane procedure from slot 2 on: merge `origin/main` into the pin, resolve the CHANGELOG conflict with `mergeChangelogs`, then run `gstack-restamp --next`.
### Gate integrity
- `test/ship-queue-train.test.ts` builds a lane head with main merged into the pin and T2's tree stamped 1.2.5.0. It verifies, with `MERGE_CONDITION` at that head. The same head plus one file outside the train fails with `QUEUE_TRAIN_MOVED`. Without the fix, the positive case fails.
## [1.91.172.0] - 2026-10-20
**gstack now works the way Capy Captains run work: every decision goes to the Captain (or the person) in plain words, plan approvals arrive in a fixed before/after shape, each thread keeps a record of who approved what, and /ship's PR body shows before/after images of what changed.** Garry asked for this on 2026-10-20 ("make it much more obvious a/ how approvals work per thread, b/ for each plan approval i want a before/after, why it matters, what caused us to work on this, and big decisions you had to make, c/ when asking for merge guidance i want the PRs to show screenshots of what changed and why"). Everything below applies only when `GSTACK_CAPY=1` (the Capy launch line sets it). Other hosts keep their output, apart from one new `CAPY: off` STATUS line.
+1 -1
View File
@@ -1 +1 @@
1.91.172.0
1.91.173.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.172.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.173.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+16 -182
View File
@@ -401,6 +401,19 @@ 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)); 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. `verify` accepts the resulting head because its tree equals T<i> modulo stamps; the `head:` line says "T<i> with main merged in".
**5. Coordinator: verify, merge, verify again.** For each slot in order:
```bash
@@ -410,7 +423,8 @@ gstack-ship-queue verify --pr 41 --train <nonce> # VERIFY_TRAIN: #41 merged; m
```
Before a merge, main's tree must equal the prefix before the PR modulo stamps
and the PR must still be at its pin (stamps aside); after it, main's tree
and the PR must be at its pin (stamps aside) or at T<i> modulo stamps (its pin
with main merged in); 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
@@ -579,184 +593,4 @@ 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`.
`/ship` Step 16 consults the receipt; when it says `eligible`, a repeated full
gate needs `--force-gate <reason>`, and the coordinator makes the call.
### The evidence bundle
`gstack-evidence` is machine-local by design, so the portable form is a bundle:
```bash
gstack-evidence bundle --out evidence-bundle.json --label tests --label e2e --require tests,e2e \
--selection tests='bun run test' --run-url tests=https://github.com/.../actions/runs/1
```
A bundle binds to a committed head (`repo.commit`, `repo.tree`) and carries the
comparison material (version source, CHANGELOG path, `stamp_paths`,
`release_outputs`), the execution identity above, the required-lane manifest,
and per lane the command, `cmd_sha256`, exit, timestamp, executed selection,
run URL, log hash and whether it is `verified` (produced by the ledger on a
clean committed tree at this head) or not (dirty tree, other commit, or an
`--import`ed assertion). A fresh coordinator machine reads it with
`gstack-tree-receipt --bundle` after the gate VM is gone; the receipt refuses
reuse when the bundle was produced on another tree or lacks the identity
(`EVIDENCE_IDENTITY_UNKNOWN`).
<a id="ship-receipt"></a>
## The ship receipt
The handoff record is a fenced block, identifiers first, validated against
`gstack-artifact schema ship-receipt`:
````
```gstack-ship-receipt
{ "schema_version": 1, "pr": 42, "head": "<sha>", "version": "1.2.7.0", "base": "main",
"gated_tree": "<tree>", "predecessor": "<sha>", "tree": "same-modulo-stamps",
"gate_reuse": "eligible (reusable: tests; rerun: digest)", "gate": { "tests": { "pass": 10, "fail": 0, "skip": 1 } },
"ci": { "checks": 3, "failed": 0 }, "spend_usd": 0.42, "session_kind": "unattended",
"artifacts_consumed": "1/1", "run": "<run id>", "thread": "<thread id>", "policy": "base origin/main@<sha>" }
```
````
`gstack-ship-receipt write --pr <n> --receipt <tree-receipt.json> --gate '<json>' --ci '<json>' --body <pr-body.md>`
makes it the first section of the PR body (`/ship` Step 18) and prints
`SHIP_RECEIPT: pr=… head=… version=…` for the first line of the completion
message. `/land-and-deploy` reads it with `gstack-ship-receipt read --pr <n>
--json` (`RECEIPT_MISSING`, `RECEIPT_INVALID`).
### The adoption census (P7)
```bash
gstack-ship-receipt census --repo garrytan/gstack --since v1.91.70.0 --cohort threads.txt
```
reads merged PRs since the tag through `gh`, parses each receipt, deduplicates
by `run` and `thread`, and prints `CENSUS: … unattended=<n> consumed=<n>
adoption=<consumed threads>/<cohort> durable=yes`. The cohort file lists one
thread id per line (threads that never invoke gstack count in the
denominator); without it the denominator is the threads the receipts name.
## History policy and preregistration (C7)
Under `history: merge-only`, `/ship` refuses a squash or rebase plan and merges
the base branch instead: `gstack-ship-policy check-history --plan squash`
prints `HISTORY: refused (…) (HISTORY_POLICY_VIOLATION)` with exit 3, and
without `--plan` it prints `HISTORY: ok` or `HISTORY: rewritten …
(HISTORY_POLICY_VIOLATION)` (exit 1) when the pushed branch is no longer an
ancestor of HEAD. `gstack-evidence ancestor
--from-policy --base main` prints one `ANCESTOR: ok|missing <sha>` line per
`preregistration_shas` entry (`PREREGISTRATION_NOT_ANCESTOR`, exit 1), and the
ship receipt records the result in `preregistration`.
## Contributor mode
`/review` runs `gstack-contributor-mode detect` at Step 1 (decision D10). The PR
author is outside the maintainer set when `maintainers` in `.gstack/ship-policy.json`
on the base branch is non-empty and does not list the login (`source=policy`);
with an empty list, when `gh api repos/<owner>/<repo>/collaborators/<login>/permission`
reports less than push permission (`source=collaborators`, an egress receipt
`contributor-mode` precedes the call); when neither is available the mode is off
and `CONTRIBUTOR_SOURCE_UNAVAILABLE` names what was missing. `--contributor`
forces it on (`source=flag`). `CODEOWNERS` is file ownership, not maintenance
authority, and is never read. The printed line is
`CONTRIBUTOR_MODE: on|off author=<login> source=flag|policy|collaborators|off`,
followed when on by the evidence-first instructions: a table per change
(evidence value, risk surfaces, `accept` / `supersede` / `decline`), where
`accept` keeps a direct path for a correct outside implementation judged by the
same bar as a maintainer change, `supersede` rewrites from our own plan only after
a plain-language approval, and the credit lines (`Contributed by @handle`,
`Co-Authored-By:`, `Supersedes #N`) appear only when a diagnosis or a test carried
over. `gstack-contributor-mode instructions` prints the same text without detecting.
## Banned terms
`.gstack/banned-terms.json` lists terms the repository forbids outside named
locations: `terms` (a string, or `{ "term", "allowed_paths", "reason" }`) and a
top-level `allowed_paths` that every term inherits. Globs follow the same
containment rules as the ship policy (repo-relative, no `..`, under 256
characters). `gstack-banned-terms check --base <ref>` scans the lines the branch
adds since its merge base (or whole files with `--files <csv>`), reading the term
list from the committed copy on the base by default (`--policy-from head` reads
the working tree). Each finding is one line,
`BANNED_TERM: "<term>" <file>:<line> allowed: <locations> — <text>`, and the
last line is `BANNED_TERMS: ok | found=<n> | none` (`none`: the repository keeps
no list). Exit 0 for ok or none, 1 when found, 3 when the file is invalid
(`BANNED_TERMS_INVALID`, also from `validate`). `/review` turns each finding into
a Step 4 finding and `/document-release` into a documentation finding;
`gstack-banned-terms init` writes a commented example.
<a id="approval-classes"></a>
## Approval classes
`.gstack/approval-classes.json` names the changes that need the owner's
approval before they ship: upgrade-time data rewrites, trust or visibility
changes, existing-user default changes, data repairs, anything the owner wants
cited. Each entry in `classes` has a `name` (lowercase, dashes) and at least
one matcher: `paths` (globs of changed files), `patterns` (literal text in an
added line, case-sensitive) or `migrations: true` (the files the ship policy's
`migrations` key describes). `allowed_paths` exempts files, such as tests;
`description` is for people. Globs follow the ship policy's containment rules.
Classes come only from the copy committed on `origin/<base>`, so a branch can
neither declare nor drop its own; `gstack-approval-classes validate` checks the
working-tree copy before you commit it, and `init` writes an example.
`gstack-approval-classes check --base <ref>` scans the branch's changes since
the merge base and prints one `APPROVAL_CLASS: <class> <file>:<line>` line per
hit, one `APPROVAL_SCOPE: <class>@<digest12>` line per declared class with hits,
and `APPROVAL_CLASSES: ok | found=<n> | none` last (exit 0, 1 when found, 3 when
the base file is invalid or `origin/<base>` does not resolve). Built-in
detectors only report: the ship policy's migration files, when no class sets
`migrations: true`, print as `(built-in, report-only)` and need no citation.
The digest covers the class's effects: each matched file with its blob at the
scanned head, and each matched added line. The scan reads git's native diff
(`--no-ext-diff --no-textconv`), so a configured external diff driver or text
conversion cannot hide a line; when the content diff cannot be read, or its
added lines disagree with git's own count, `check` and the receipt refuse with
`APPROVAL_SOURCE_UNAVAILABLE` instead of passing. Adding, removing or editing an
effect changes the token, so an approval given earlier no longer binds.
**Enforcement.** `gstack-ship-receipt write` refuses with
`APPROVAL_CITATION_MISSING`, writing nothing, unless each declared class with
hits has a line in the `--body` file (or an `--approval` value) of the form
```
Approved by <owner> <YYYY-MM-DD> via <ref> [for <class>@<digest12>[,<class>@<digest12>]]
```
that resolves and binds:
- A plan sha (7 to 64 hex characters) must match the plan of an autoplan run
passed with `--approval-run <run dir>` whose gate is APPROVED, and the plan
must name the scope token, or the class and every effect path.
- A GitHub comment or review URL must be written by a `maintainers` login from
the base's ship policy that equals the cited owner. A review must be in
state `APPROVED`; a comment-only, dismissed or changes-requested review is
not an approval. The evidence is the body's first non-blank line only. It
must start at column 0 with `Approved` or `Approve` (any case, optionally
in `**` bold; no list marker, quote or indent) and name the token, or the
class and every effect path. Nothing later in the body counts, so a
rejection that quotes or nests an example approval approves nothing. For
example, a comment whose first line is
`Approved data-repair@0123456789ab: preview-first, never at upgrade.`
verifies; one that opens with any other line does not.
- A token is authoritative. Evidence that names the class with a different
digest approved earlier effects: it does not bind, and it never falls back
to matching the class and paths.
- Anything else (a thread code, a chat message, a URL gstack cannot read) is
recorded `citation: unverified` for the coordinator, and only when the line
attests which scope it covers with `for <class>@<digest12>` matching the
current token.
The receipt's `approval` object records the source, `status` (`checked`, or
`unavailable` when `origin/<base>` does not resolve), one record per class
(scope token, effects, `citation: verified | unverified`, owner, date, ref,
`binding: token | paths | attested`, evidence) and any report-only hits; the
`SHIP_RECEIPT:` line ends with `approval=verified`, `approval=unverified:<classes>`,
`approval=none` or `approval=unchecked`. A repository without the file gets no
`approval` object unless a built-in detector reported.
A data repair is a class to declare, and /review checks its shape: explicit,
preview-first, and never reachable from an upgrade or migration path.
again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`.
+7 -3
View File
@@ -3,7 +3,9 @@
* merge trains (docs/ship-policy.md#run-a-merge-train; plan GSTA-49 T1, P1).
*
* Each merge needs a green train record that includes the PR at its pinned
* head, with main's tree equal to the prefix before the PR (pre-merge:
* head, or at a head whose tree is the slot's prefix after it modulo stamps
* (from slot 2 on, the lane merges main into its pin so GitHub can merge it),
* with main's tree equal to the prefix before the PR (pre-merge:
* MERGE_CONDITION) or after it (post-merge confirmation), modulo stamps
* (lib/tree-receipt.ts). When a train stops partway (a mismatch or a moved
* head after earlier slots merged) the queue freezes: the freeze is written
@@ -95,8 +97,10 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str
}
const headRef = o.noFetch ? '' : (git(ctx.repoRoot, ['fetch', '--no-tags', '-q', env.remote, `+refs/pull/${o.pr}/head:refs/gstack/queue/pr-${o.pr}`]).ok ? `refs/gstack/queue/pr-${o.pr}` : '');
const head = headRef ? git(ctx.repoRoot, ['rev-parse', headRef]).out : t.slots[i]!.head;
const stampOnly = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} (${head.slice(0, 12)}: ${stampOnly.detail})`, main, lines);
const pinned = head === t.slots[i]!.head ? { same: true, detail: 'the pinned head' } : sameModuloStamps(env, t.slots[i]!.head, head);
const asPrefix = pinned.same ? null : sameModuloStamps(env, after.commit, head);
const stampOnly = asPrefix?.same ? { same: true, detail: `T${after.index} with main merged in, ${asPrefix.detail}` } : pinned;
if (!stampOnly.same) return stopped(env, t, 'QUEUE_TRAIN_MOVED', `#${o.pr} moved past its pinned head ${t.slots[i]!.head.slice(0, 12)} and is not T${after.index} modulo stamps (${head.slice(0, 12)}: ${pinned.detail})`, main, lines);
return outOk([...lines, `main: ${main} = T${before.index} (${pre.detail})`, `head: ${head} (${stampOnly.detail})`,
`VERIFY_TRAIN: #${o.pr} may merge as slot ${i + 1} of train ${t.nonce} (green run ${gate.run?.id ?? '-'}, ${env.train.full_gate_check} success)`, `MERGE_CONDITION: sha=${head}`],
`merge #${o.pr} with expected head ${head.slice(0, 12)} (gstack-restamp stamps at merge), then ${Q} verify --pr ${o.pr} --train ${t.nonce}`, run);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.172",
"version": "1.91.173",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+36
View File
@@ -521,4 +521,40 @@ describe('verify, freeze, repair and clear', () => {
expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8')).cleared_at).toBe('2026-10-18T12:00:00.000Z');
expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN');
}, 300_000);
test('from slot 2 on, a head that merges main into the pin verifies when its tree is the slot\'s prefix modulo stamps', () => {
const fx = make();
const { h2, nonce } = twoPrTrain(fx);
expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok);
const t = trainJson(fx, nonce);
const coord = coordinatorClone(fx.repo);
const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord);
const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0');
expect(v('1').status).toBe(EXIT.ok);
const { repo } = fx;
const laneHead = (extra?: { file: string; text: string }) => {
repo.git('reset', '-q', '--hard');
repo.git('checkout', '-q', '-B', 'lane2', h2);
repo.git('rm', '-rq', '.');
repo.git('checkout', t.prefixes[2].commit, '--', '.');
fs.writeFileSync(path.join(repo.work, 'VERSION'), '1.2.5.0\n');
if (extra) fs.writeFileSync(path.join(repo.work, extra.file), extra.text);
repo.git('add', '-A');
const tree = repo.git('write-tree');
const head = repo.git('commit-tree', tree, '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0');
repo.git('push', '-q', '-f', 'origin', `${head}:refs/pull/2/head`);
return head;
};
const merged = laneHead();
const ok = v('2');
expect(ok.status).toBe(EXIT.ok);
expect(ok.out).toMatch(new RegExp(`^head: ${merged} \\(T2 with main merged in, same modulo stamps`, 'm'));
expect(line(ok.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${merged}`);
const stray = laneHead({ file: 'stray.txt', text: 'not in the train\n' });
const moved = v('2');
expect(moved.status).toBe(EXIT.fail);
expect(moved.err).toMatch(new RegExp(`#2 moved past its pinned head ${h2.slice(0, 12)} and is not T2 modulo stamps \\(${stray.slice(0, 12)}: .*\\(QUEUE_TRAIN_MOVED\\)`));
}, 300_000);
});