diff --git a/CHANGELOG.md b/CHANGELOG.md index 619fa5e6a..70d184b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --train ` refused that head as moved off its pin. Now it also accepts a head whose tree equals the slot's train prefix (T) 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 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. diff --git a/VERSION b/VERSION index 9fd1b6616..991c78144 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.172.0 +1.91.173.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 49e96a177..ebe427bf3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -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, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 53aff4126..316018092 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -401,6 +401,19 @@ EVAL_POLICY.infraRedispatch allows: `train rebuild --train --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
--expect-order --allow-repo-commands +git add CHANGELOG.md VERSION package.json && git commit -m "v 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 modulo stamps; the `head:` line says "T 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 # 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 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 -> — fix: gstack-restamp --after -again (RESTAMP_PREDECESSOR_MOVED)` and refuses `eligible`. - -`/ship` Step 16 consults the receipt; when it says `eligible`, a repeated full -gate needs `--force-gate `, 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`). - - -## 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": "", "version": "1.2.7.0", "base": "main", - "gated_tree": "", "predecessor": "", "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": "", "thread": "", "policy": "base origin/main@" } -``` -```` - -`gstack-ship-receipt write --pr --receipt --gate '' --ci '' --body ` -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 ---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= consumed= -adoption=/ 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 ` 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///collaborators//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= 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 ` scans the lines the branch -adds since its merge base (or whole files with `--files `), 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: "" : allowed: — `, and the -last line is `BANNED_TERMS: ok | found= | 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. - - -## 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/`, 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 ` scans the branch's changes since -the merge base and prints one `APPROVAL_CLASS: :` line per -hit, one `APPROVAL_SCOPE: @` line per declared class with hits, -and `APPROVAL_CLASSES: ok | found= | none` last (exit 0, 1 when found, 3 when -the base file is invalid or `origin/` 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 via [for @[,@]] -``` - -that resolves and binds: - -- A plan sha (7 to 64 hex characters) must match the plan of an autoplan run - passed with `--approval-run ` 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 @` matching the - current token. - -The receipt's `approval` object records the source, `status` (`checked`, or -`unavailable` when `origin/` 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:`, -`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`. \ No newline at end of file diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 7261ffa14..8c797eba3 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -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); diff --git a/package.json b/package.json index d79a1c19a..b439eaa39 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 061548b44..40cf32cb8 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -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); });