diff --git a/CHANGELOG.md b/CHANGELOG.md index 4363f3d7b..02ee78b58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.91.282.0] - 2026-10-27 + +**A merge the pre-merge verify accepted no longer freezes the queue, and a slot stamp touches only the stamp files.** Train 24 (2026-10-27) merged #3434 and #3453, then froze on #3453's post-merge check and paid a $31 backstop: the pre-merge verify judged the lane's CHANGELOG as main's plus the pin's own entry and said MERGE_CONDITION, the post-merge check compared main to T2 section by section, and T2's one `[Unreleased]` (both stacked entries) had become two released sections. In both slots `gstack-restamp` also listed `lib/ship-policy.ts` as a fifth stamp file, because the lane's uncommitted base merge made #3511's `DOCS_CAPS_KEY_VERSION` look like the branch's own tentative key. And `mergeChangelogs` refused the stacked child outright, so the lane's hand resolve dropped #3453's two wording fixes to the released 1.91.276.0 entry. + +### Fixed +- **`gstack-ship-queue verify --train`** (`lib/ship-queue-train-verify.ts`): one CHANGELOG rule before and after the merge. Post-merge, main confirms when its tree is T modulo stamps, or differs from T in the CHANGELOG alone while being its own first parent's CHANGELOG (the main the pre-merge verify saw) plus the PR's own entry, the rule `mergedIn` applied pre-merge; the `main:` line says "except CHANGELOG.md, main 's CHANGELOG.md plus #'s own entry". The own lines are the pin's authored CHANGELOG lines or the lines `mergeChangelogs` writes for that pin against main, so a tool-written correction bullet passes. A freeze lists a slot GitHub reports MERGED as merged (its reason says "merged on GitHub without a verified tree"), never under `unmerged`, so the rebuild after the repair does not seat it again; the record is built from the one GitHub read that decided to freeze, so a slot that read MERGED stays merged when GitHub stops answering a moment later, and a slot unreadable beside a merged one is left out of `unmerged` and named in the reason ("unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers"), so the `NEXT:` after the repair omits it; and a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue instead of answering "nothing of the train merged" while main already holds the unverified squash; when GitHub's answer for a slot cannot be read, the verify refuses (`QUEUE_PROOF_MISSING`, "whether # merged on GitHub could not be read") and asks to be rerun, never "nothing merged". A main whose CHANGELOG equals its parent's is not "the parent's plus the PR's own entry" (`ownEntryAboveMain`: "no entry of the PR's own above it"), so a squash that lost the entry the gate carried is refused. +- **`gstack-restamp` stamp_paths** (`lib/restamp.ts`): a line the base added since the pin's fork point is the base's, whether the base merge is committed or still staged; `branchAddedLines` leaves it out and the stage detail says "N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)". #3514's receipt bound (`stampOnlyLine`) refuses such a move after the fact only while the moved version is older than the base's at the build; a key at the base version passes it, so the restamp is the guard. A base diff git cannot run is a refusal ("cannot diff .. for the stamp_paths stage"), never an empty "nothing added" set that would move the base's lines again. +- **`mergeChangelogs`** (`lib/changelog-check.ts`): a stacked child whose parent squash-merged and was released since the merge base (`carryReleasedParent`) is a merge, not a hand resolve. The parent's lines leave the child's `[Unreleased]`; each line the child changed inside them becomes `- Correction to the entry below: "" should read "".` (a dropped line "no longer applies", a line added between the parent's "Addition to the entry below") under the child's own `### Gate integrity`, appended to one it already has. The released entry stays main's byte for byte. Without the merge base the shape is GSTA-67's dropped heading and stays null. `mergeChangelogsWithNotes` returns the carried corrections; the slot one-liner in docs/ship-policy.md prints them. `mergeStep` (train build) passes the merge base, so a child of an already-released parent seats; from slot 2 on, where the prefix already holds an earlier slot's `[Unreleased]` and the sections are unioned, the union takes the carried child, so the gated tree never repeats the released parent's text under the child's heading. + +### Tests +- `test/ship-queue-train.test.ts`: a stacked child's merged-in head the pre-merge verify accepted confirms after its squash merge (froze before), with the correction bullet accepted pre-merge; a post-merge CHANGELOG holding a line the pin never wrote still freezes, and the freeze lists the slot GitHub reports MERGED under `merged`; a slot-1 squash GitHub reports MERGED that fails the post-merge check freezes, and an unreadable GitHub state refuses; a stacked child behind another slot is gated on the carried CHANGELOG (the released parent's text appears once, under its own heading, with the child's corrections in the child's entry). `test/restamp-base-merge.test.ts` (new): train 24's shape (pin forked before the base's key, base merge staged), for a key naming an older release and for one at the base version; the dry run lists the four stamp files plus the branch's own key file only, committed or staged, and the receipt bound is asserted for both keys. `test/changelog-merge-damage.test.ts`: the real a78ce08a/3e4240a7/d87eec95 CHANGELOGs (`test/fixtures/merge-train-3453/`) merge to main's sections unchanged plus #3453's entry with the two corrections; without the base they stay null; shape controls for an unedited, a dropped, an added and an empty child. + +### Docs +- docs/ship-policy.md § Run a merge train: the one CHANGELOG rule, the stamp-files rule (never a fifth), the stacked-child-after-squash resolution, and that an unstamped head changes a release output by regenerating it at its own VERSION (the Version Gate has accepted that since 1.91.237.0; #3434 reverted its digest change against the older gate). docs/troubleshooting.md: `QUEUE_TRAIN_TREE_MISMATCH`, `QUEUE_PROOF_MISSING`, `QUEUE_TRAIN_FROZEN` and `POLICY_SOURCE_UNAVAILABLE` describe the post-merge CHANGELOG exception, the unreadable-state refusal, the GitHub-MERGED freeze record and the restamp base-diff refusal. docs/captain.md § Merge trains and captain/templates/slot-call.md carry the stamp-files rule (the files the slot call names: `STAMP_FILES`, plus the file carrying a `*_KEY_VERSION` the PR introduced when the call names it; a file the restamp lists that the call did not name is left out and reported) and the stacked-child rule. + +### Gate integrity +- Correction to the 1.91.276.0 entry below: "copies of shared sections twice until a follow-up dedupes them." should read "versions of shared sections beside the shared file (office-hours' copies are deduped in the entry above).". +- Correction to the 1.91.276.0 entry below: "shrinks and 89 size pins go down." should read "loses 3 characters from it and the 89 size pins go down by 3 (plan-ceo-review still grows overall, below).". +- No threshold, timeout, trial count or case kind changed. No skill prose changed. + ## [1.91.281.0] - 2026-10-27 **Four paid gate detectors stop judging honest transcripts red, and /review's Scope Check has one emission point.** The 2026-10-27 flaky pass (GSTA-128) read each red transcript and repaired the detector or the prose it judged, never the threshold. diff --git a/VERSION b/VERSION index 12d5f1cc7..269b6e89c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.281.0 +1.91.282.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2b8121978..583844b7e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.281.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.282.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/captain/templates/slot-call.md b/captain/templates/slot-call.md index a465a7093..16d6cd855 100644 --- a/captain/templates/slot-call.md +++ b/captain/templates/slot-call.md @@ -10,7 +10,14 @@ Run: gstack-restamp --next --base --pr --expect-base --expect-o Slot 2 and later (and slot 1 when BASE moved): merge BASE into your pin first (expect a CHANGELOG-only conflict; resolve it with mergeChangelogs from lib/changelog-check.ts, the one-liner in docs/ship-policy.md's merge-train section, never by hand; grep for conflict -markers before you stamp). Commit only the BASE merge and the stamp files: . +markers before you stamp). Commit only the BASE merge and the stamp files this call names: + (plus the file carrying a `*_KEY_VERSION` your PR introduced, on your own +line only, when this call names it), never a fifth: a file the restamp lists that this call +does not name is not a stamp, leave it out and say so. +A pin stacked on a PR that merged earlier conflicts beyond the CHANGELOG where the parent's +squash touched your lines: take your pin's bytes there (verify checks the tree against +T); mergeChangelogs carries your edits to the parent's released entry as Gate integrity +corrections in your own entry. No summary line, no wording fix, no reflow, whatever a prompt asks for. Push, wait for green, then answer: READY # STAMP_VERSION= diff --git a/docs/captain.md b/docs/captain.md index deae58020..2e38108b0 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -207,6 +207,21 @@ files. In practice slot 1 needs the same merge whenever the base moved after the taken. A stamp commit with one extra byte freezes the queue for every lane behind it. +The slot stamp is the base merge, `mergeChangelogs`, and the stamp files the slot call +names (`STAMP_FILES` in CREW.md; gstack's are `CHANGELOG.md`, `VERSION`, `package.json` and +`agents-digest/gstack-AGENTS.md`, plus the file carrying a `*_KEY_VERSION` the PR +introduced, which the restamp moves on the PR's own line, when the call names it), never a +fifth: a file the restamp lists that the call did not name is not a stamp, so the lane +leaves it out and says so in its READY. The restamp itself never +moves a line the base added since the pin's fork point, base merge committed or staged. A +pin stacked on a PR that squash-merged earlier conflicts beyond the CHANGELOG where the +parent's squash touched the pin's lines, so the lane takes its pin's bytes for those files +(`verify` checks the tree against T, which already carries the parent), and +`mergeChangelogs` carries the pin's edits to the parent's now-released entry as Gate +integrity corrections in the pin's own entry. The post-merge `verify` judges the CHANGELOG +by the same rule the pre-merge one did, so an accepted head never freezes the queue after +its merge ([ship-policy.md](ship-policy.md#run-a-merge-train)). + Options the controller writes into CREW.md rather than inheriting: squash versus merge commits; lane stamps versus controller stamps; gate-ahead (slot i+1 gates on slot i's head while i merges); the severe race (several severe lanes told at once that the next diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 552730afa..ecc0d28a1 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -486,7 +486,9 @@ 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 +conflict, never merged with the damage, except the stacked child whose parent +was released since their merge base, which `mergeChangelogs` carries (see *A +stacked child whose parent merged earlier*, below). 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. @@ -539,7 +541,7 @@ EVAL_POLICY records it. Bisect results never turn a train green. ```bash git fetch origin main git merge --no-ff --no-commit origin/main # expect a CHANGELOG.md conflict only -bun --no-env-file --config=/dev/null -e "import { mergeChangelogs } from './lib/changelog-check'; import { spawnSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; const s = (n) => spawnSync('git', ['show', ':' + n + ':CHANGELOG.md'], { encoding: 'utf8', maxBuffer: 1 << 26, timeout: 30000 }).stdout; const m = mergeChangelogs(s(2), s(3), s(1)); if (!m) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m);" +bun --no-env-file --config=/dev/null -e "import { mergeChangelogsWithNotes } 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 = mergeChangelogsWithNotes(s(2), s(3), s(1)); for (const n of m.notes) console.error('CHANGELOG: carried as a Gate integrity correction: ' + n); if (!m.text) { console.error('CHANGELOG needs a hand merge'); process.exit(1); } writeFileSync('CHANGELOG.md', m.text);" 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 @@ -547,6 +549,47 @@ git add CHANGELOG.md VERSION package.json && git commit -m "v< 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". +**The stamp commit is the base merge plus the stamp files, never a fifth.** The +restamp writes the version files, the CHANGELOG, the `release_outputs` and the +`stamp_paths` lines the branch itself added (gstack: `CHANGELOG.md`, `VERSION`, +`package.json`, `agents-digest/gstack-AGENTS.md`, and a `*_KEY_VERSION` the PR +introduced). A line the base added since the pin's fork point is the base's, +whether the base merge is committed or still staged (`stage stamp_paths` says +"N line(s) the base added since the fork point are the base's"; train 24, +2026-10-27, moved main's `DOCS_CAPS_KEY_VERSION` in both slots before this +rule). A file the restamp lists that the slot call did not name is not a stamp: +leave it out and report it. The tree receipt refuses such a move after the fact +only while the moved version is older than the base's at the build, so the +restamp, not the receipt, is what keeps the base's key in place. + +**A stacked child whose parent merged earlier.** When the pin was stacked on a +PR that squash-merged in an earlier slot or train, the base merge conflicts +beyond the CHANGELOG wherever the parent's squash and the pin touched the same +lines. Take the pin's bytes for those files: `verify` checks the tree against +T, the train's own merge of that pin, so the parent's content is already in +the prefix. The CHANGELOG is `mergeChangelogs`' job and needs no hand merge for +this shape: the pin's `[Unreleased]` still holds the parent's entry (as forked, +maybe edited) while main carries it under a released heading the merge base +lacks, so the parent's lines leave the child's section and each edit the child +made to them becomes a `- Correction to the entry below: "" +should read "".` bullet under the child's own `### Gate integrity` (the +one-liner prints each carried correction). The released entry stays main's byte +for byte, and `verify` accepts those bullets as the pin's own lines because +the tool writes them. A pin that does not carry base can still be refused here +(`ours's Unreleased section holds released text`): that is the +GSTA-67 shape, a released heading dropped by hand, and stays a hand merge. + +**A generator change on an unstamped head.** A release output such as +`agents-digest/gstack-AGENTS.md` is stamped at the slot, and an unstamped PR +changes it the ordinary way, which is to edit the generator, regenerate at the +branch's own `VERSION` (its merge base's), and commit both. CI's Version Gate +regenerates the output at that VERSION and reads a byte-identical result as +unstamped ([what /ship reads](#ship), since 1.91.237.0); the freshness test +compares against the checkout's VERSION, so it passes before and after the +slot's restamp regenerates the file at the new version. Never revert a +regenerated output to the base's bytes to get past the gate (#3434 did, on a +gate older than that rule, and shipped a stale digest line until its merge). + **5. Coordinator: verify, merge, verify again.** For each slot in order: ```bash @@ -558,15 +601,36 @@ 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 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 +only lines from the pin's own CHANGELOG diff, or lines `mergeChangelogs` +writes for that pin against main, above it); after it, main's tree must equal +the prefix after it modulo stamps, or differ from it in the CHANGELOG alone +while being its own first parent's CHANGELOG (the main the pre-merge verify +saw, itself the prefix before modulo stamps) plus the PR's own entry under the +same rule. One CHANGELOG rule applies before and after, so a head the pre-merge +verify accepted confirms after its squash merge with the same CHANGELOG, and a +stacked child whose one `[Unreleased]` became two released sections is not a +mismatch (train 24, 2026-10-27, froze on exactly that). Anything else is +`QUEUE_TRAIN_TREE_MISMATCH` (or `QUEUE_TRAIN_MOVED`). When nothing of the train +has merged, that is a plain refusal, and a new train is built 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. A slot GitHub reports MERGED counts as merged in that record, +verified tree or not, so the rebuild after the repair never seats it again +(the reason says "merged on GitHub without a verified tree"); a first slot +whose squash GitHub reports MERGED but whose tree fails the post-merge check +freezes the same way, since main already holds the unverified squash. The +record comes from the one GitHub read that decided to freeze, never a second +one: a slot that read MERGED stays merged even when GitHub stops answering +before the record is written, and a slot unreadable beside a merged one is +left out of `unmerged` and named in the reason; nothing reads it again, so +the `NEXT:` after the repair omits it and the operator seats it in a train by +hand once `gh pr view` answers. When +whether a slot merged on GitHub cannot be read, verify refuses +(`QUEUE_PROOF_MISSING`, "whether # merged on GitHub could not be read") +and asks to be rerun once `gh pr view` answers; an unreadable state is never +"nothing merged". While frozen, ordinary trains and other trains' merges are refused (`QUEUE_TRAIN_FROZEN`). Admit one repair PR at its exact head: ```bash @@ -884,7 +948,7 @@ Stages, each journaled in `.gstack/tmp/restamp-journal.json`: 1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. 2. **release_tool**: the policy command, under the contract below. 3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under ``, 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/` 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 `` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once. -4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, 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 (: 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. +4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, 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 (: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. 5. **mirror**: tier 2; declared and validated, reported as not run. 6. **apply**: every changed file is written to the branch only after all stages validated, each against the fingerprinted preimage; a file edited meanwhile is `RESTAMP_CONFLICT` and the applied writes roll back (rollback refuses to overwrite a file whose bytes differ from the recorded postimage). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a1089de32..0b1fd6a91 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2653,7 +2653,11 @@ from the working directory (a bare `bun gstack-pregate`), since a branch could then pick the user policy: run `gstack-pregate` directly or as `bun --no-env-file`. `gstack-ship-queue ready` under a train policy fetches `origin/` first and refuses with this code, quoting git's error, when -that fetch fails. +that fetch fails. `gstack-restamp`'s `stamp_paths` stage refuses with it when +the diff that tells the base's added lines from the branch's own cannot run +(`cannot diff .. for the stamp_paths stage (); nothing written`), since an empty diff would read as "the base added +nothing" and move the base's lines again. **Fix.** `git fetch origin --depth=1`, then rerun. To run against the branch's own file deliberately, pass `--policy-from head --allow-repo-commands`; @@ -4089,7 +4093,10 @@ gate-ahead merge could not be recomputed with `git merge-tree`. The train commands use it too: `train forget` on a record that does not exist or on a train still building, running or merging, `train watch` on an unknown record, `verify --pr --screen` on a run with no verdict to read (an INCOMPLETE or -unreadable run), and `ready --screen` when the scratch merge branch cannot be +unreadable run), `verify --pr --train ` when main's tree mismatches +and whether a slot merged on GitHub cannot be read (so whether the queue must +freeze is unknown; it never answers "nothing merged" from an unreadable +state), and `ready --screen` when the scratch merge branch cannot be pushed. Nothing was written unless the `QUEUE_STAMP_REFUSED:` line says `committed=yes`. @@ -4327,17 +4334,34 @@ the repair merges (a changed train). **Meaning.** Main's tree is neither the train's prefix before this PR nor the one after it, modulo stamps, so the merge would not publish what the gate -tested. +tested. After a merge, a main that differs from the prefix after the PR in +the CHANGELOG alone, while being its own first parent's CHANGELOG plus the +PR's own entry (the rule the pre-merge verify applied to the head), is not a +mismatch ([one CHANGELOG rule, before and after](ship-policy.md#run-a-merge-train)). -**Fix.** Stop merging. If earlier PRs of the train merged, the queue is now -frozen (`QUEUE_TRAIN_FROZEN`); otherwise build a new train on current main. +**Fix.** Stop merging. If a PR of the train merged (verified, or reported +MERGED by GitHub without a verified tree, slot 1's own squash included), the +queue is now frozen (`QUEUE_TRAIN_FROZEN`); otherwise build a new train on +current main. When whether a slot merged on GitHub cannot be read, verify +refuses with `QUEUE_PROOF_MISSING` instead of either answer; rerun it once +`gh pr view` answers. ### `QUEUE_TRAIN_FROZEN` **Meaning.** A train stopped partway through publication, so main holds a prefix no gate covered on its own. The `QUEUE_TRAIN_FROZEN:` line lists the -merged PRs, main's tree and the backstop run dispatched on main's SHA. The +merged PRs (a slot GitHub reports MERGED counts as merged, verified tree or +not, and the reason then says `merged on GitHub without a verified tree`, so +the repair's rebuild never seats it again), main's tree and the backstop run +dispatched on main's SHA. The record is built from the one GitHub read that +decided to freeze: a slot that read MERGED stays merged even when `gh pr view` +stops answering a moment later, and a slot whose state could not be read when +another slot had merged is left out of `unmerged` and named in the reason +(`# unreadable on GitHub, left out of unmerged; seat it in a train by hand +once gh pr view answers`), so the rebuild after the repair never seats a PR +that may already be in; nothing reads that slot again, so the `NEXT:` after +the repair omits it and the operator seats it by hand. The freeze lives in local state and on `refs/notes/gstack-trains`. Ordinary trains and other trains' merges are refused until a repair lands. diff --git a/lib/changelog-check.ts b/lib/changelog-check.ts index 72f1ee2a7..98cdc72ae 100644 --- a/lib/changelog-check.ts +++ b/lib/changelog-check.ts @@ -140,8 +140,9 @@ export function normalizeChangelog(text: string): string { /** * A merged-in head's CHANGELOG, however its conflict was resolved: why it is * not main's CHANGELOG with the PR's own entry above it, or null. Main's - * header and every main section must follow unchanged and in order, and the - * sections above them may hold only lines the PR itself added (`authored`, + * header and every main section must follow unchanged and in order, at least + * one section must sit above them (a CHANGELOG equal to main's has lost the + * entry the gate carried), and those sections may hold only lines the PR itself added (`authored`, * the + lines of its pin's CHANGELOG diff), so a hand resolve can drop * damage but never add text the train gate did not carry. */ @@ -154,6 +155,7 @@ export function ownEntryAboveMain(head: string, main: string, authored: Readonly if (own < 0) return `it has ${h.sections.length} sections, main has ${m.sections.length}`; const moved = m.sections.findIndex((s, i) => trim(s.lines) !== trim(h.sections[own + i]!.lines)); if (moved >= 0) return `main's ${m.sections[moved]!.heading.trim()} is not carried unchanged below the PR's entry`; + if (own === 0) return "it is main's CHANGELOG unchanged, with no entry of the PR's own above it"; const mainKeys = new Set(m.sections.map(s => s.version ?? 'Unreleased')); const clash = h.sections.slice(0, own).find(s => mainKeys.has(s.version ?? 'Unreleased')); if (clash) return `${clash.heading.trim()} is headed twice`; @@ -207,6 +209,132 @@ export function changelogDamage(ours: string, theirs: string, base?: string): st return null; } +const SIMILAR = 0.5; +const GATE_INTEGRITY_RE = /^###\s+Gate integrity\s*$/; + +/** The characters two lines share at their start and end (together never more than the shorter line). */ +function commonEnds(a: string, b: string): { p: number; s: number } { + let p = 0; + while (p < a.length && p < b.length && a[p] === b[p]) p++; + let s = 0; + while (s < Math.min(a.length, b.length) - p && a[a.length - 1 - s] === b[b.length - 1 - s]) s++; + return { p, s }; +} + +/** Whether one line is an edit of the other: half or more of the longer one is shared at its ends, or the shorter is wholly kept (an extension). */ +function isEdit(a: string, b: string): boolean { + const strip = (l: string) => l.trim().replace(/^[-*]\s+/, ''); + const [x, y] = [strip(a), strip(b)]; + const { p, s } = commonEnds(x, y); + const min = Math.min(x.length, y.length); + return p + s >= Math.max(x.length, y.length) * SIMILAR || (min >= 3 && p + s === min); +} + +/** The fragments an edit replaced, widened to word boundaries and to at least one word on each side. */ +function fragments(removed: string, added: string): { old: string; new: string } { + const { p, s } = commonEnds(removed, added); + let from = removed.slice(0, p).search(/\S*$/); + let keep = s - (/^\S*/.exec(removed.slice(removed.length - s))?.[0].length ?? 0); + const cut = () => ({ old: removed.slice(from, removed.length - keep).trim(), new: added.slice(from, added.length - keep).trim() }); + let f = cut(); + while ((!f.old || !f.new) && (from > 0 || keep > 0)) { + from = Math.max(0, removed.slice(0, from).search(/\S+\s*$/)); + keep -= /^\s*\S*/.exec(removed.slice(removed.length - keep))?.[0].length ?? keep; + f = cut(); + } + return f.old && f.new ? f : { old: removed.trim(), new: added.trim() }; +} + +/** + * A stacked child whose parent squash-merged and was released since the fork + * (GSTA-96, train 24: #3453 after #3434 became 1.91.276.0). `ours`' + * `[Unreleased]` still holds the parent's entry as the child forked it, maybe + * edited, while `theirs` carries that entry under a released heading the + * merge base lacks. The parent's lines leave the child's section; each line + * the child changed inside them becomes a correction bullet under the child's + * own `### Gate integrity` (the convention #3453's entry used for its parent), + * so no wording the gate carried is lost and the released entry below stays + * byte for byte main's. Null when the shape is something else: no single + * `[Unreleased]` on our side, no released entry of theirs the base lacks whose + * text our section holds, or nothing of the child's own left. + */ +export function carryReleasedParent(ours: string, theirs: string, base: string): { text: string; notes: string[] } | null { + const a = parseChangelog(ours); + const unreleased = a.sections.filter(s => s.unreleased); + if (unreleased.length !== 1) return null; + const baseKeys = new Set(parseChangelog(base).sections.map(s => s.version ?? 'Unreleased')); + const content = (l: string) => Boolean(l.trim()) && !l.startsWith('#'); + let lines = unreleased[0]!.lines.slice(1); + const notes: string[] = []; + const bullets: string[] = []; + let carried = false; + for (const parent of parseChangelog(theirs).sections.filter(s => !s.unreleased && !baseKeys.has(s.version!))) { + const pl = parent.lines.slice(1); + const ownBlocks = entryBlocks({ ...unreleased[0]!, lines: ['', ...lines] }); + if (!entryBlocks(parent).some(b => ownBlocks.includes(b))) continue; + carried = true; + // LCS over content lines only: blanks and sub-headings recur in both entries and would anchor wrongly. + const lcs = Array.from({ length: pl.length + 1 }, () => new Uint32Array(lines.length + 1)); + for (let i = pl.length - 1; i >= 0; i--) for (let j = lines.length - 1; j >= 0; j--) { + lcs[i]![j] = pl[i] === lines[j] && content(pl[i]!) ? lcs[i + 1]![j + 1]! + 1 : Math.max(lcs[i + 1]![j]!, lcs[i]![j + 1]!); + } + const parentAt = new Set(); + const hunks: Array<{ removed: string[]; added: number[] }> = [{ removed: [], added: [] }]; + for (let i = 0, j = 0; i < pl.length || j < lines.length;) { + if (i < pl.length && j < lines.length && pl[i] === lines[j] && content(pl[i]!)) { parentAt.add(j); i++; j++; hunks.push({ removed: [], added: [] }); } + else if (i < pl.length && (j === lines.length || lcs[i + 1]![j]! >= lcs[i]![j + 1]!)) { if (content(pl[i]!)) hunks.at(-1)!.removed.push(pl[i]!); i++; } + else { if (content(lines[j]!)) hunks.at(-1)!.added.push(j); j++; } + } + const v = parent.version!; + const strip = (l: string) => l.trim().replace(/^[-*]\s+/, ''); + hunks.forEach((h, k) => { + for (const removed of h.removed) { + const best = h.added.find(j => isEdit(removed, lines[j]!)); + if (best !== undefined) { + h.added.splice(h.added.indexOf(best), 1); + parentAt.add(best); + const f = fragments(removed, lines[best]!); + bullets.push(`- Correction to the ${v} entry below: "${f.old}" should read "${f.new}".`); + notes.push(`${v}: "${f.old}" -> "${f.new}"`); + } else { + bullets.push(`- Correction to the ${v} entry below: "${strip(removed)}" no longer applies.`); + notes.push(`${v}: dropped "${strip(removed).slice(0, 60)}"`); + } + } + // Lines added between two of the parent's own lines, or after its last beside an edit there, belong to its entry; the rest are the child's. + if (k > 0 && (k < hunks.length - 1 || h.removed.length)) for (const j of h.added) { + parentAt.add(j); + bullets.push(`- Addition to the ${v} entry below: ${strip(lines[j]!)}`); + notes.push(`${v}: added "${strip(lines[j]!).slice(0, 60)}"`); + } + }); + lines = lines.filter((_, j) => !parentAt.has(j)); + } + if (!carried) return null; + // Sub-headings left without content, then runs of blank lines, then the edges. + const kept: string[] = []; + lines.forEach((l, j) => { + if (l.startsWith('#')) { const next = lines.slice(j + 1).find(x => x.trim()); if (!next || next.startsWith('#')) return; } + if (!l.trim() && (!kept.length || !kept.at(-1)!.trim())) return; + kept.push(l); + }); + while (kept.length && !kept.at(-1)!.trim()) kept.pop(); + if (!kept.some(content)) return null; + if (bullets.length) { + const at = kept.findIndex(l => GATE_INTEGRITY_RE.test(l)); + if (at < 0) kept.push('', '### Gate integrity', ...bullets); + else { + let end = kept.findIndex((l, j) => j > at && l.startsWith('#')); + if (end < 0) end = kept.length; + while (end > at + 1 && !kept[end - 1]!.trim()) end--; + kept.splice(end, 0, ...bullets); + } + } + const section = { ...unreleased[0]!, lines: [unreleased[0]!.heading, '', ...kept, ''] }; + const out = [...a.header, ...a.sections.flatMap(s => (s === unreleased[0] ? section.lines : s.lines))].join('\n'); + return { text: out.endsWith('\n') ? out : out + '\n', notes }; +} + /** * Gate-ahead merge of two CHANGELOGs that conflict textually but not * semantically: the predecessor's header and sections, with the branch's own @@ -216,10 +344,20 @@ export function changelogDamage(ours: string, theirs: string, base?: string): st * branch's entry is kept under `[Unreleased]` so the restamp re-heads it. * Both sides carrying `[Unreleased]` is a real conflict and returns null, and * so is damage to released history (`changelogDamage`): null means a hand - * resolve, never a merge that carries the damage through. + * resolve, never a merge that carries the damage through. With `base`, a + * stacked child whose parent was released since the fork is first rewritten + * by `carryReleasedParent`; `mergeChangelogsWithNotes` returns what it + * carried. */ export function mergeChangelogs(ours: string, theirs: string, base?: string): string | null { - if (changelogDamage(ours, theirs, base)) return null; + return mergeChangelogsWithNotes(ours, theirs, base).text; +} + +export function mergeChangelogsWithNotes(ours: string, theirs: string, base?: string): { text: string | null; notes: string[] } { + const carry = base !== undefined ? carryReleasedParent(ours, theirs, base) : null; + if (carry) ours = carry.text; + const notes = carry?.notes ?? []; + if (changelogDamage(ours, theirs, base)) return { text: null, notes }; const a = parseChangelog(ours); const b = parseChangelog(theirs); const key = (s: ChangelogSection) => (s.unreleased ? 'Unreleased' : s.version!); @@ -231,10 +369,10 @@ export function mergeChangelogs(ours: string, theirs: string, base?: string): st } const theirKeys = new Set(b.sections.map(key)); const ownOnly = a.sections.filter(s => !theirKeys.has(key(s))); - if (a.sections.filter(s => s.unreleased).length > 1) return null; - if (a.sections.some(s => s.unreleased) && b.sections.some(s => s.unreleased)) return null; + if (a.sections.filter(s => s.unreleased).length > 1) return { text: null, notes }; + if (a.sections.some(s => s.unreleased) && b.sections.some(s => s.unreleased)) return { text: null, notes }; const header = [...b.header]; while (header.length && header[header.length - 1] === '') header.pop(); const body = [...ownOnly, ...b.sections].flatMap(s => { const lines = [...s.lines]; while (lines.length && lines[lines.length - 1] === '') lines.pop(); return [...lines, '']; }); - return [...header, ...(header.length ? [''] : []), ...body].join('\n').replace(/\n+$/, '\n'); + return { text: [...header, ...(header.length ? [''] : []), ...body].join('\n').replace(/\n+$/, '\n'), notes }; } diff --git a/lib/restamp.ts b/lib/restamp.ts index 666d8c73f..e0d473061 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -266,22 +266,29 @@ export function parseUnifiedDiff(text: string): FileDiff[] { } /** The lines a branch itself added to one file: their text, and their 1-based positions in the working-tree file. */ -export interface AddedLines { text: Set; at: Set } +export interface AddedLines { text: Set; at: Set; /** Lines the diff shows that `notOwn` names: the base's, left out. */ notOwn: number } /** * The lines a branch itself added, per stamp file: `git diff --unified=0 -- ` in `cwd` - * against its working tree. Positions are the parser's per-line count on the new side (context - * lines included, and `--inter-hunk-context=0` keeps a `diff.interHunkContext` setting from joining - * hunks), so a base line whose text equals an added line, or a CRLF checkout whose diff is - * LF-normalized, is told apart by position. + * against its working tree (or against `to` when given). Positions are the parser's per-line count + * on the new side (context lines included, and `--inter-hunk-context=0` keeps a + * `diff.interHunkContext` setting from joining hunks), so a base line whose text equals an added + * line, or a CRLF checkout whose diff is LF-normalized, is told apart by position. A line in + * `notOwn` for the file (the base's own additions since `since`) is never the branch's: when the + * base is merged into the working tree uncommitted, HEAD still forks before that merge and the + * diff from the fork carries every line the base added since, main's own release keys included + * (train 24, 2026-10-27: `DOCS_CAPS_KEY_VERSION` from #3511 moved in both slots). */ -export function branchAddedLines(cwd: string, since: string, files: string[]): Map { +export function branchAddedLines(cwd: string, since: string, files: string[], to?: string, notOwn?: Map): Map { const out = new Map(); if (files.length === 0) return out; - const diff = git(cwd, ['diff', '--unified=0', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', since, '--', ...files]); + const diff = git(cwd, ['diff', '--unified=0', '--inter-hunk-context=0', '--no-color', '--no-ext-diff', since, ...(to ? [to] : []), '--', ...files]); + // A failed diff must not read as "the branch added nothing" (or "the base added nothing"), which would move the base's lines again. + if (!diff.ok) throw new Refusal('POLICY_SOURCE_UNAVAILABLE', `cannot diff ${since.slice(0, 12)}..${to ? to.slice(0, 12) : 'the worktree'} for the stamp_paths stage (${(diff.err || diff.out).split('\n')[0]}); nothing written`); for (const f of parseUnifiedDiff(diff.out)) { - const own = out.get(f.path) ?? { text: new Set(), at: new Set() }; - for (const h of f.hunks) h.added.forEach((a, i) => { own.text.add(a); own.at.add(h.addedAt[i]!); }); + const own = out.get(f.path) ?? { text: new Set(), at: new Set(), notOwn: 0 }; + const base = notOwn?.get(f.path)?.text; + for (const h of f.hunks) h.added.forEach((a, i) => { if (base?.has(a)) { own.notOwn += 1; return; } own.text.add(a); own.at.add(h.addedAt[i]!); }); out.set(f.path, own); } return out; @@ -516,7 +523,8 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since const why = assertInsideRoot(staging, rel); if (why) throw new Refusal('POLICY_CONTAINMENT', `${why}; nothing written`); } - const added = branchAddedLines(staging, since, files); + const baseAdded = branchAddedLines(o.repoRoot, since, files, plan.baseSha); + const added = branchAddedLines(staging, since, files, undefined, baseAdded); const explicit = new Set([...(o.was ? [o.was] : []), ...(plan.branchStamp ? [plan.branchStamp] : [])].filter(v => v !== plan.newVersion)); const tentative = new Set(tentativeMentions(new Map([...added].map(([rel, own]) => [rel, own.text])), plan.forkVersion, plan.newVersion, templates).filter(v => !explicit.has(v))); const equalBase = !o.was && !plan.branchStamp && plan.baseVersion !== plan.newVersion ? plan.baseVersion : null; @@ -559,9 +567,11 @@ function rewriteStampPaths(o: RestampOptions, plan: Plan, staging: string, since ? `; VERSION equals base ${equalBase}, so ${equalBase} moved only on lines this branch added and stayed on the base's own lines (${baseSide.join('; ')})` : ''; const searched = explicit.size ? `no mention of ${[...explicit].join(', ')} in the stamp files and ` : ''; - sp.detail = olds.length + const baseOwn = [...added].filter(([, own]) => own.notOwn); + const baseOwnNote = baseOwn.length ? `; ${baseOwn.reduce((n, [, own]) => n + own.notOwn, 0)} line(s) the base added since the fork point are the base's, not this branch's, and stay (${baseOwn.map(([rel, own]) => `${rel}: ${own.notOwn}`).join('; ')})` : ''; + sp.detail = (olds.length ? `${total} mention(s) in ${touched} file(s) (${olds.join(', ')} -> ${plan.newVersion})${equalNote}` - : `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`; + : `${searched}no tentative mention newer than the fork version ${plan.forkVersion}${equalBase ? ` and none of base ${equalBase} on a line this branch added` : ''}${equalNote}`) + baseOwnNote; } function applyToBranch(o: RestampOptions, staging: string, wtree: string, files: string[], journal: Journal, journalPath: string): JournalWrite[] { diff --git a/lib/ship-queue-train-verify.ts b/lib/ship-queue-train-verify.ts index 6504d036e..cb206c1f7 100644 --- a/lib/ship-queue-train-verify.ts +++ b/lib/ship-queue-train-verify.ts @@ -21,9 +21,9 @@ import { bindReceipt } from './ship-receipt'; import { checkRun, execFor, prInfo, repoSlug } from './ship-queue-train-github'; import { activeFreeze, attemptsOf, nowIso, persist, trainFile, type FreezeRecord, type TrainRecord } from './ship-queue-train-record'; import { - effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prsArgs, save, trainEnv, type TrainEnv, type TrainOut, + effect, fetchBase, fetchTrainBranch, findTrain, frozenLines, nextFor, outFailed, outOk, outRefused, pinList, prState, prsArgs, save, trainEnv, type TrainEnv, type TrainOut, } from './ship-queue-train'; -import { ownEntryAboveMain } from './changelog-check'; +import { mergeChangelogs, ownEntryAboveMain, parseChangelog } from './changelog-check'; import { buildReceipt } from './tree-receipt'; const Q = 'gstack-ship-queue'; @@ -34,13 +34,41 @@ function sameModuloStamps(env: TrainEnv, gated: string, current: string): { same return { same: r.tree === 'same-modulo-stamps', detail: r.tree === 'same-modulo-stamps' ? `same modulo stamps (${r.files.length} stamp file(s))` : `differs: ${r.residual.slice(0, 8).join(', ')}` }; } +/** + * The one CHANGELOG rule of a slot, applied before and after the merge: + * `current`'s CHANGELOG must be `mainBefore`'s with the PR's own entry above it + * (ownEntryAboveMain), the own lines being those the pin's CHANGELOG diff from + * its fork point added, or those `mergeChangelogs` itself writes for the pin + * against that main (a released parent's carried corrections, GSTA-96). Pre-merge + * `current` is the lane's head and `mainBefore` is main; post-merge `current` is + * main and `mainBefore` its first parent, the main the pre-merge verify saw. + * Null when it holds, else why not. + */ +function changelogIsMainPlusOwn(env: TrainEnv, t: TrainRecord, i: number, current: string, mainBefore: string): string | null { + const cl = env.policy.policy.changelog!; + const root = env.ctx.repoRoot; + const pin = t.slots[i]!.head; + const forkPoint = git(root, ['merge-base', pin, t.prefixes[0]!.commit]).out; + const diff = git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', forkPoint, pin, '--', cl]); + const show = (rev: string) => git(root, ['show', `${rev}:${cl}`]); + const [currentCl, mainCl, pinCl, forkCl] = [show(current), show(mainBefore), show(pin), show(forkPoint)]; + if (!forkPoint || !diff.ok || !currentCl.ok || !mainCl.ok) return `cannot read ${cl} at the pin, ${current.slice(0, 12)} or main ${mainBefore.slice(0, 12)}`; + const authored = new Set(diff.out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1))); + const tool = pinCl.ok ? mergeChangelogs(pinCl.out + '\n', mainCl.out + '\n', forkCl.ok ? forkCl.out + '\n' : undefined) : null; + if (tool) { + const mainKeys = new Set(parseChangelog(mainCl.out).sections.map(s => s.version ?? 'Unreleased')); + for (const s of parseChangelog(tool).sections.filter(s => !mainKeys.has(s.version ?? 'Unreleased'))) for (const l of s.lines.slice(1)) authored.add(l); + } + return ownEntryAboveMain(currentCl.out, mainCl.out, authored); +} + /** * A slot head with main merged in (from slot 2 on the lane merges main into * its pin so GitHub can merge it): it must contain main, and its tree must be * the slot's prefix T modulo stamps. The CHANGELOG is judged against main - * instead of T's mechanical merge, so a hand resolve is accepted when it - * is main's CHANGELOG plus the PR's own entry (ownEntryAboveMain); any other - * difference is refused. + * instead of T's mechanical merge (changelogIsMainPlusOwn), so a hand + * resolve is accepted when it is main's CHANGELOG plus the PR's own entry; + * any other difference is refused. */ function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: string): { same: boolean; detail: string } { const after = t.prefixes[i + 1]!; @@ -53,28 +81,55 @@ function mergedIn(env: TrainEnv, t: TrainRecord, i: number, head: string, main: const cl = env.policy.policy.changelog; const beyond = r.residual.filter(f => f !== cl); if (beyond.length || !cl) return { same: false, detail: `differs from T${after.index}: ${r.residual.slice(0, 8).join(', ')}` }; - const pin = t.slots[i]!.head; - const forkPoint = git(root, ['merge-base', pin, t.prefixes[0]!.commit]).out; - const diff = git(root, ['diff', '--unified=0', '--no-color', '--no-ext-diff', forkPoint, pin, '--', cl]); - const show = (rev: string) => git(root, ['show', `${rev}:${cl}`]); - const [headCl, mainCl] = [show(head), show(main)]; - if (!forkPoint || !diff.ok || !headCl.ok || !mainCl.ok) return { same: false, detail: `cannot read ${cl} at the pin, the head or main` }; - const authored = new Set(diff.out.split('\n').filter(l => l.startsWith('+') && !l.startsWith('+++')).map(l => l.slice(1))); - const why = ownEntryAboveMain(headCl.out, mainCl.out, authored); + const why = changelogIsMainPlusOwn(env, t, i, head, main); if (why) return { same: false, detail: `differs from T${after.index} in ${cl}, which is not main's plus #${t.slots[i]!.pr}'s own entry: ${why}` }; return { same: true, detail: `T${after.index} with main merged in, same modulo stamps (${stamps}) except ${cl}, resolved as main's ${cl} plus #${t.slots[i]!.pr}'s own entry` }; } +/** + * Post-merge, the same rule the pre-merge verify applied: main is T modulo + * stamps, or differs from it in the CHANGELOG alone while being its own first + * parent (the main the pre-merge verify saw, itself T modulo stamps) plus + * the PR's own entry. A merge the pre-merge verify accepted therefore confirms + * here with the same CHANGELOG (train 24, 2026-10-27: #3453's accepted head + * froze the queue post-merge because T2's one [Unreleased] had become two + * released sections). + */ +function published(env: TrainEnv, t: TrainRecord, i: number, main: string): { same: boolean; detail: string } { + const after = t.prefixes[i + 1]!; + const post = sameModuloStamps(env, after.commit, main); + if (post.same) return post; + const cl = env.policy.policy.changelog; + const r = buildReceipt({ repoRoot: env.ctx.repoRoot, gated: after.commit, current: main, policy: env.policy.policy, policyLabel: env.policy.source.label }); + if ('error' in r || !cl || r.residual.some(f => f !== cl)) return post; + const parent = git(env.ctx.repoRoot, ['rev-parse', '--verify', '-q', `${main}^`]).out; + if (!parent || !sameModuloStamps(env, t.prefixes[i]!.commit, parent).same) return { same: false, detail: `${post.detail}; main's parent ${parent.slice(0, 12) || '(none)'} is not T${t.prefixes[i]!.index} modulo stamps` }; + const why = changelogIsMainPlusOwn(env, t, i, main, parent); + if (why) return { same: false, detail: `${post.detail}, which is not main ${parent.slice(0, 12)}'s plus #${t.slots[i]!.pr}'s own entry: ${why}` }; + return { same: true, detail: `same modulo stamps (${r.files.filter(f => f.verdict === 'stamp').length} stamp file(s)) except ${cl}, main ${parent.slice(0, 12)}'s ${cl} plus #${t.slots[i]!.pr}'s own entry` }; +} + /** * Freeze the queue: the record goes to local state and the notes ref before * anything remote, then the backstop gate is dispatched on main's sha. + * `states` is the one GitHub read of every unverified slot, made by the + * caller: a slot that read MERGED is merged, verified tree or not, and a slot + * that could not be read is left out of `unmerged` and named in the reason + * (nothing reads it again; the operator seats it by hand once gh answers), so + * the rebuild after the repair never seats a PR that may already be in. */ -async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSha: string): Promise { +async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSha: string, states: ReadonlyMap): Promise { const now = nowIso(); + const unverified = t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)); + const landed = unverified.filter(s => states.get(s.pr) === 'MERGED').map(s => s.pr); + const unreadable = unverified.filter(s => states.get(s.pr) === 'unreadable').map(s => s.pr); + const prs = (list: string[]) => list.map(p => '#' + p).join(', '); const f: FreezeRecord = { - schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: t.merged.map(m => m.pr), - unmerged: t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => ({ pr: s.pr, head: s.head })), - main: { sha: mainSha, tree: git(env.ctx.repoRoot, ['rev-parse', `${mainSha}^{tree}`]).out }, reason, backstop: null, repair: null, + schema: 1, kind: 'freeze', repo_key: env.repo.key, train: t.nonce, merged: [...t.merged.map(m => m.pr), ...landed], + unmerged: unverified.filter(s => !landed.includes(s.pr) && !unreadable.includes(s.pr)).map(s => ({ pr: s.pr, head: s.head })), + main: { sha: mainSha, tree: git(env.ctx.repoRoot, ['rev-parse', `${mainSha}^{tree}`]).out }, + reason: [reason, landed.length ? `${prs(landed)} merged on GitHub without a verified tree` : '', unreadable.length ? `${prs(unreadable)} unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers` : ''].filter(Boolean).join('; '), + backstop: null, repair: null, created_at: now, cleared_at: null, cleared_by: null, updated_at: now, }; persist(env.ctx.repoRoot, [f], { remote: env.remote }); @@ -91,11 +146,22 @@ async function freezeQueue(env: TrainEnv, t: TrainRecord, reason: string, mainSh return f; } -/** Interrupted publication: freeze when earlier slots merged; otherwise a plain refusal (nothing was published). */ +/** + * Interrupted publication: freeze when a slot merged, verified or merged on + * GitHub without a verified tree (slot 1's own squash failing the post-merge + * check leaves main unverified with nothing in `merged` yet); otherwise a + * plain refusal (nothing was published). A GitHub state that cannot be read + * is neither: the verify refuses and asks to be rerun, never "nothing merged". + */ async function stopped(env: TrainEnv, t: TrainRecord, code: 'QUEUE_TRAIN_TREE_MISMATCH' | 'QUEUE_TRAIN_MOVED', message: string, mainSha: string, lines: string[]): Promise { const run = trainFile(t.nonce); - if (!t.merged.length) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run); - const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha); + // One GitHub read per unverified slot decides both the freeze and its record. + const states = new Map(t.slots.filter(s => !t.merged.some(m => m.pr === s.pr)).map(s => [s.pr, prState(env.ctx, s.pr)] as const)); + const landed = t.merged.length > 0 || [...states.values()].includes('MERGED'); + const unreadable = landed ? [] : [...states].filter(([, state]) => state === 'unreadable').map(([pr]) => `#${pr}`); + if (unreadable.length) return outRefused({ code: 'QUEUE_PROOF_MISSING', message: `${message}; whether ${unreadable.join(', ')} merged on GitHub could not be read, so whether the queue must freeze is unknown` }, lines, `${Q} verify --pr ${t.slots[0]!.pr} --train ${t.nonce} (once gh pr view answers)`, run); + if (!landed) return outFailed({ code, message: `${message}; nothing of train ${t.nonce} merged, so the queue is not frozen` }, lines, `${Q} train build ${prsArgs(t.slots, t.explicit_order)} (a new train on current main)`, run); + const f = activeFreeze(env.repo.keys) ?? await freezeQueue(env, t, message, mainSha, states); return outFailed({ code, message: `${message}; the queue is frozen` }, [...lines, ...frozenLines(f)], `${Q} train build --repair --prs @`, run); } @@ -121,7 +187,7 @@ export async function verifyTrain(ctx: QueueContext, o: { pr: string; train: str if (freeze && freeze.repair?.train !== t.nonce) return outRefused({ code: 'QUEUE_TRAIN_FROZEN', message: `the queue is frozen (${freeze.reason}); only the admitted repair train merges` }, frozenLines(freeze), `${Q} train build --repair --prs @`, run); const after = t.prefixes[i + 1]!; const before = t.prefixes[i]!; - const post = sameModuloStamps(env, after.commit, main); + const post = published(env, t, i, main); const lines = [`TRAIN: ${t.nonce} slot ${i + 1}/${t.slots.length} #${o.pr} pinned ${t.slots[i]!.head}`]; if (post.same) return confirmMerged(env, t, o.pr, main, after.index, [...lines, `main: ${main} = T${after.index} (${post.detail})`]); const pre = sameModuloStamps(env, before.commit, main); diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index bc610c261..8786d22f1 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -16,7 +16,7 @@ */ import { randomBytes } from 'node:crypto'; import { DISPATCH_INPUTS, awaitNonceRun, dispatchWorkflow, listDispatchRuns, makeNonce, runForNonce, type WorkflowDispatch } from '../scripts/eval-bg'; -import { changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check'; +import { carryReleasedParent, changelogDamage, mergeChangelogs, parseChangelog, type ChangelogSection } from './changelog-check'; import type { ResultStatus } from './headless-artifacts'; import { baseOverlapLine } from './pr-wait'; import { versionAtRef, versionFiles } from './restamp'; @@ -144,10 +144,13 @@ function unionChangelog(ours: string, theirs: string): string { /** * One train merge, in object storage: the PR head onto the previous prefix. * Version files take the prefix's side; the CHANGELOG merges by section (the - * gate-ahead rule, else a union of sections, since each heading is restamped - * at merge), unless a side damaged released history (changelogDamage), which - * is a conflict the lane resolves. Any other conflict is reported with every - * conflicting path. + * gate-ahead rule with the merge base, which also carries a stacked child + * past its released parent, else a union of sections, since each heading is + * restamped at merge; the union takes the carried side too, so a prefix that + * already holds an earlier slot's [Unreleased] never duplicates the released + * parent's text under the child's heading), unless a side damaged released history + * (changelogDamage), which is a conflict the lane resolves. Any other + * conflict is reported with every conflicting path. */ export function mergeStep(repoRoot: string, pred: string, head: string, policy: ShipPolicy): { tree: string } | { conflicts: string[] } { const r = git(repoRoot, ['merge-tree', '--write-tree', head, pred]); @@ -175,10 +178,11 @@ export function mergeStep(repoRoot: string, pred: string, head: string, policy: else if (file === policy.changelog) { const text = (sha: string) => git(repoRoot, ['cat-file', 'blob', sha]).out + '\n'; const base = entry.get(1); - const damage = changelogDamage(text(ours), text(theirs), base ? text(base) : undefined); + const merged = mergeChangelogs(text(ours), text(theirs), base ? text(base) : undefined); + const damage = merged ? null : changelogDamage(text(ours), text(theirs), base ? text(base) : undefined); if (damage) { conflicts.push(`${file} (${damage})`); continue; } - const merged = mergeChangelogs(text(ours), text(theirs)) ?? unionChangelog(text(ours), text(theirs)); - const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged); + const own = (base && carryReleasedParent(text(ours), text(theirs), text(base))?.text) || text(ours); + const blob = git(repoRoot, ['hash-object', '-w', '--stdin'], undefined, merged ?? unionChangelog(own, text(theirs))); if (blob.ok) resolved.push({ path: file, blob: blob.out }); else conflicts.push(file); } else if (stampFiles.includes(file)) resolved.push({ path: file, blob: theirs }); else conflicts.push(file); @@ -605,7 +609,7 @@ export function trainStatus(ctx: QueueContext, o: { train?: string }): TrainOut } /** A PR's state on GitHub for the stale check: MERGED, CLOSED, OPEN, or unreadable. */ -function prState(ctx: QueueContext, pr: string): string { +export function prState(ctx: QueueContext, pr: string): string { const info = prInfo(ctx, pr); return 'error' in info ? 'unreadable' : info.state.toUpperCase(); } diff --git a/package.json b/package.json index 4a7a5d6cb..66ec462ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.281", + "version": "1.91.282", "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/changelog-merge-damage.test.ts b/test/changelog-merge-damage.test.ts index 5fe107512..7a8451c64 100644 --- a/test/changelog-merge-damage.test.ts +++ b/test/changelog-merge-damage.test.ts @@ -11,7 +11,7 @@ import { describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { changelogDamage, mergeChangelogs, ownEntryAboveMain, parseChangelog } from '../lib/changelog-check'; +import { carryReleasedParent, changelogDamage, mergeChangelogs, mergeChangelogsWithNotes, ownEntryAboveMain, parseChangelog } from '../lib/changelog-check'; const fixture = (name: string) => fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'merge-train-3345', name), 'utf8'); const base = fixture('base-a59b5e4.md'); @@ -78,5 +78,75 @@ describe("ownEntryAboveMain: a merged-in head's CHANGELOG however it was resolve expect(ownEntryAboveMain(stamped.replace('## [1.91.178.0]', '## [1.91.178.0]\n\n- smuggled'), main, authored)).toBe("main's ## [1.91.178.0] - 2026-10-22 is not carried unchanged below the PR's entry"); expect(ownEntryAboveMain(stamped.replace('# Changelog', '# Change log'), main, authored)).toBe('the header differs from main'); expect(ownEntryAboveMain(stamped.replace('## [1.91.182.0]', '## [1.91.181.0]'), main, authored)).toMatch(/is headed twice$/); + // Main's CHANGELOG unchanged is not main's plus the PR's entry: the entry the gate carried is missing (state-machines review of GSTA-129). + expect(ownEntryAboveMain(main, main, authored)).toBe("it is main's CHANGELOG unchanged, with no entry of the PR's own above it"); + }); +}); + +describe("a stacked child whose parent was released since the fork (GSTA-96, train 24: #3453 after #3434's squash)", () => { + // The real CHANGELOGs, cut below 1.91.274.0: the pin a78ce08a (one [Unreleased] holding #3434's entry with + // two wording edits plus #3453's own), main 3e4240a7 (#3434 released as 1.91.276.0), the fork d87eec95 and + // main 6ff98c66 (what the hand resolve shipped as 1.91.277.0, without the two edits). + const f24 = (name: string) => fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'merge-train-3453', name), 'utf8'); + const pin24 = f24('pin-a78ce08a.md'); + const main24 = f24('main-3e4240a7.md'); + const base24 = f24('base-d87eec95.md'); + const body = (text: string, i = 0) => parseChangelog(text).sections[i]!.lines.slice(1).join('\n').replace(/\n+$/, ''); + + test("the slot's merge is no longer null: main's sections stay byte for byte, the child's [Unreleased] keeps its own entry, and the two edits to the released entry become Gate integrity corrections", () => { + expect(changelogDamage(pin24, main24, base24)).toMatch(/^ours's Unreleased section holds released 1\.91\.276\.0 text/); + const r = mergeChangelogsWithNotes(pin24, main24, base24); + expect(r.text).not.toBeNull(); + expect(r.notes).toEqual([ + '1.91.276.0: "copies of shared sections twice until a follow-up dedupes them." -> "versions of shared sections beside the shared file (office-hours\' copies are deduped in the entry above)."', + '1.91.276.0: "shrinks and 89 size pins go down." -> "loses 3 characters from it and the 89 size pins go down by 3 (plan-ceo-review still grows overall, below)."', + ]); + const merged = parseChangelog(r.text!); + expect(keys(r.text!)).toEqual(['Unreleased', '1.91.276.0', '1.91.275.0', '1.91.274.0']); + const main = parseChangelog(main24); + expect(merged.header).toEqual(main.header); + const trimmed = (lines: string[]) => lines.join('\n').replace(/\n+$/, ''); + main.sections.forEach((s, i) => expect(trimmed(merged.sections[i + 1]!.lines)).toBe(trimmed(s.lines))); + // The child's section is what the hand resolve shipped as 1.91.277.0, plus the two corrections under its own Gate integrity. + const shipped = body(f24('main-6ff98c66.md')); + expect(body(r.text!)).toBe(`${shipped} +- Correction to the 1.91.276.0 entry below: "copies of shared sections twice until a follow-up dedupes them." should read "versions of shared sections beside the shared file (office-hours' copies are deduped in the entry above).". +- Correction to the 1.91.276.0 entry below: "shrinks and 89 size pins go down." should read "loses 3 characters from it and the 89 size pins go down by 3 (plan-ceo-review still grows overall, below).".`); + expect(mergeChangelogs(pin24, main24, base24)).toBe(r.text); + // The merge is idempotent and passes the train's own checks: no damage, and the released entry is unchanged. + expect(changelogDamage(r.text!, main24, base24)).toBeNull(); + }); + + test('without the merge base the shape is indistinguishable from a dropped heading and stays null; with the base carrying the released heading it is damage, not a carry', () => { + expect(mergeChangelogs(pin24, main24)).toBeNull(); + expect(carryReleasedParent(pin24, main24, main24)).toBeNull(); + expect(mergeChangelogs(pin24, main24, main24)).toBeNull(); + }); + + test('shapes: an unedited parent leaves no correction; a dropped line and a line added inside the parent are carried; a child with nothing of its own, or two [Unreleased] blocks, is null', () => { + const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n'; + const theirs = `# C\n\n${released}\n## [1.0.0] - d\n\n- base\n`; + const base = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n\n## [1.0.0] - d\n\n- base\n'; + const child = (parentBody: string) => `# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n${parentBody}\n## [1.0.0] - d\n\n- base\n`; + const unedited = carryReleasedParent(child('**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n'), theirs, base)!; + expect(unedited.notes).toEqual([]); + expect(unedited.text).toBe('# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n## [1.0.0] - d\n\n- base\n'); + const edited = carryReleasedParent(child('**Parent paragraph.**\n\n### Changed\n- one\n- two, now better\n- four\n'), theirs, base)!; + expect(edited.notes).toEqual(['1.0.1: "two" -> "two, now better"', '1.0.1: dropped "three"', '1.0.1: added "four"']); + expect(edited.text).toBe('# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- Correction to the 1.0.1 entry below: "two" should read "two, now better".\n- Correction to the 1.0.1 entry below: "three" no longer applies.\n- Addition to the 1.0.1 entry below: four\n\n## [1.0.0] - d\n\n- base\n'); + expect(mergeChangelogs(child('**Parent paragraph.**\n\n### Changed\n- one\n- two, now better\n- four\n'), theirs, base)).toBe(`# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- Correction to the 1.0.1 entry below: "two" should read "two, now better".\n- Correction to the 1.0.1 entry below: "three" no longer applies.\n- Addition to the 1.0.1 entry below: four\n\n${released}\n## [1.0.0] - d\n\n- base\n`); + expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull(); + expect(carryReleasedParent(`# C\n\n## [Unreleased]\n\n- a\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n\n## [1.0.0] - d\n\n- base\n`, theirs, base)).toBeNull(); + }); + + // Value: protects=carryReleasedParent rewrites only a child that still holds the released parent's entry and keeps an existing Gate integrity section's neighbours in place; fails_when=an unrelated [Unreleased] or an already-stamped head is rewritten or the correction lands outside Gate integrity; why_new=the shapes test above never has a child without the parent's entry, a head without [Unreleased], or a Gate integrity section followed by another heading; seam=none; no_claim=the wording of corrections beyond one edited line + test('shapes: a child that does not hold the released parent, or a head already stamped, is null; a correction joins the child\'s existing Gate integrity section without crossing the next heading', () => { + const released = '## [1.0.1] - d\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n'; + const theirs = `# C\n\n${released}\n## [1.0.0] - d\n\n- base\n`; + const base = '# C\n\n## [Unreleased]\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two\n- three\n\n## [1.0.0] - d\n\n- base\n'; + expect(carryReleasedParent('# C\n\n## [Unreleased]\n\n- totally other\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull(); + expect(carryReleasedParent('# C\n\n## [1.0.2] - d\n\n- mine\n\n## [1.0.0] - d\n\n- base\n', theirs, base)).toBeNull(); + const child = '# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- child gate\n\n### Tests\n- child test\n\n**Parent paragraph.**\n\n### Changed\n- one\n- two edited\n- three\n\n## [1.0.0] - d\n\n- base\n'; + expect(carryReleasedParent(child, theirs, base)!.text).toBe('# C\n\n## [Unreleased]\n\n**Child.**\n\n### Changed\n- mine\n\n### Gate integrity\n- child gate\n- Correction to the 1.0.1 entry below: "two" should read "two edited".\n\n### Tests\n- child test\n\n## [1.0.0] - d\n\n- base\n'); }); }); diff --git a/test/fixtures/merge-train-3453/base-d87eec95.md b/test/fixtures/merge-train-3453/base-d87eec95.md new file mode 100644 index 000000000..4320b9c99 --- /dev/null +++ b/test/fixtures/merge-train-3453/base-d87eec95.md @@ -0,0 +1,23 @@ +# Changelog + +## [1.91.274.0] - 2026-10-27 + +**/design-html and /design-review now run the design lint, and its copy tier reports every content and ship-floor tell at its line (hallmark phase 2, GSTA-92; Garry 2026-10-26 "Do it", option a).** The two design skills still called `gstack-design-craft`, which counted tells without positions and kept accepted exceptions in finalized.json. The lint already carried the same ids, so the skills now call it, the copy tier has its own patterns with line numbers, and a page answers a tell with a waiver comment beside it instead of a side record. + +### Changed +- **/design-html** Step 4 runs `gstack-design-lint --render` after the Slop Gate and reads its exit code: 2 fixes every `blocks` line, 1 fixes or waives each `ask` line, 0 lists `polish` as notes. A missing `LINT_SUMMARY`, a `render` line or `render=skipped` is said plainly and the static lines stand. +- **Waivers live in the page.** A real customer named Nexus or a supplied 50% figure gets ``, written only for what the user supplied or confirmed. This supersedes #3422's persisted craft exceptions: `--accepted` and finalized.json's `craft_exceptions` are gone, and the hand-off lists every waiver. +- **Copy-tier tiers**: `placeholder-name`, `placeholder-brand`, `lorem`, `round-number` and `needs-proof` move from blocks to ask, so the waiver above can answer them. /design-review still files a waived `needs-proof` or `lorem`, which a live page cannot carry. +- **/design-review's craft audit** runs the lint on each saved page: `blocks` and `ask` lines are category 9 findings at their line, `waived` lines are listed with their reason, `empty-mount-node` sends the audit to the saved DOM. A saved live page has lost its linked CSS, so the audit files only its copy-tier (`craft`) lines there and judges the CSS rules by eye. +- **`lib/design-lint/rules/copy.ts`** holds the copy tier's own patterns (moved from `lib/design-craft.ts`), reporting each tell at its line with what matched. It reads visible text, script bodies and inline handlers (single-quoted included) and the parsed tags, so a hydration payload, commented-out markup or a `` string inside a script no longer counts. + +### Added +- **`empty-mount-node`** (ask, page only): a body with no text, a `<noscript>` notice aside, that loads a script is a client-rendered shell; the lint says it read none of its content instead of passing it. + +### Removed +- `bin/gstack-design-craft`, `lib/design-craft.ts` and `test/design-craft.test.ts`. Their checks are in the lint's copy tier and its tests; the archetype handoff tests moved to `test/design-craft-resolvers.test.ts`. + +### Tests +- `test/design-lint.test.ts`: the copy tier at its lines with the catalog's fixes, the clean page with inlined Pretext, the skip link and `aria-current`, visible-text reading, the four checker limits #3433 documented, and the shell check (noscript, image-only page, component scope). `test/design-lint-recorded.test.ts` reads the recorded page's buzzwords at lines 32 and 37. +- The `design-html-slop-gate` paid case looks for a `gstack-design-lint` call, and its touchfiles select on `lib/design-lint/**` and `bin/gstack-design-lint`. + diff --git a/test/fixtures/merge-train-3453/main-3e4240a7.md b/test/fixtures/merge-train-3453/main-3e4240a7.md new file mode 100644 index 000000000..7ee3648ff --- /dev/null +++ b/test/fixtures/merge-train-3453/main-3e4240a7.md @@ -0,0 +1,78 @@ +# Changelog + +## [1.91.276.0] - 2026-10-27 + +**Tier 2+ skills load the shared preamble once per session instead of once per invocation.** Every tier 2+ SKILL.md carried the same ~23 KB of preamble (AskUserQuestion format, voice, writing style, completeness, completion protocol and more), so each skill invocation re-read text the conversation already held. That text now renders once per host into `sections/preamble.md` beside each tier 2+ SKILL.md. The SKILL.md keeps a short "Shared preamble (load first)" pointer naming the file and its id (a hash of the shared text before host rewrites), and asks for the Read unless the skill's own earlier Read of that file, headed by that id, is still in context, so other text that merely carries the id cannot stand in for the shared rules. Measured with Anthropic token counting: review's SKILL.md drops from 21,884 to 16,103 tokens, ship from 22,688 to 16,907, office-hours from 27,100 to 23,085 and design-review from 39,040 to 33,261. The shared file is 5,953 tokens, so a skill's first invocation in a session costs about 170 tokens more than before (about 1,900 for office-hours, about 3,800 for plan-eng-review) and every later invocation of that skill saves about 5,800 (4,000 for office-hours, 2,200 for plan-eng-review). The skip is tied to the skill's own copy of the file, so the first invocation of each different skill pays the Read again. A /autoplan run's four review-skill loads carry 17,941 fewer SKILL.md tokens (191,871 → 173,930), and /autoplan and the inline /office-hours loader skip each loaded skill's "Shared preamble (load first)" section, because the parent already holds the shared preamble. + +### Changed +- **`scripts/resolvers/preamble-hoist.ts`** (new) renders `{{PREAMBLE}}` for tier 2+ skills: a section moves to the shared file only when this skill's render of it is byte-identical to the shared render, so no skill's instructions change. Sections that name the skill (the start command, Question Tuning, Repo Ownership, Search Before Building, Telemetry), text without its own heading, and skill-specific variants stay inline; a variant that keeps the shared subsection headings carries only its differing `###` subsections. Tier 1 skills keep their short preamble inline. Across all 41 hoisted skills, the SKILL.md with its shared file applied holds exactly the paragraphs the pre-hoist SKILL.md held. +- **`scripts/gen-skill-docs.ts`** emits `sections/preamble.md` per tier 2+ skill and host as a new `preamble` artifact kind, with the same host rewrites, install-root rewrite and runtime preludes as SKILL.md. `setup` already installs every skill's `sections/`, and gstack-built overlay and per-install renders carry the file the same way. +- **`setup --status`**: a bare `sections/<file>` pointer counts as carved only when the skill installs a section other than the shared `preamble.md`, so the new file does not turn on checks for skills that inline their sections on that host. +- **`gstack-doctor --check`**: the `patch` row finds the Codex render's model patch in the review skill's `sections/preamble.md` too, where the hoist moves it; without this the required row failed on every hoisted Codex install. +- **Loaded-skill skip lists** (`/autoplan`'s section skip list and the `{{INVOKE_SKILL}}` loader's defaults in `scripts/resolvers/composition.ts`) skip `Shared preamble (load first)`, so a loaded plan-review or /office-hours skill no longer Reads its own shared file and re-imports the sections the parent already handles. `Completeness Principle — Boil the Ocean` and `Completion Status Protocol` leave both lists, because no loaded skill carries them inline any more, only in the shared file. Each list is 40 characters shorter, so 30 size pins go down (plan-eng-review's by 26, after main's +14 pointer growth). +- **PR paid planner** (`scripts/lib/paid-select.ts`): a generated `<skill>/sections/preamble.md` takes its skill's `SKILL.md.tmpl` identity, as that skill's SKILL.md already does, so a skill whose only paid coverage is periodic defers it instead of failing `screen-summary` with "requires full validation". + +### Tests +- `test/helpers/docsync-fixture.ts`: `fixtureDocs` copies the generated `sections/preamble.md` into the document-release and ship fixtures when the skill has one, because the hoisted SKILL.md names it as a required asset and a spawned docs audit blocks without it (hunk taken from #3472). #3472's companion test in `test/ship-document-release-dispatch.test.ts` removes ship's generated `sections/preamble.md` from its copied tree before checking that a skill without one gets none, because under the hoist every generated tier 2+ skill has one. +- `test/helpers/skill-startup.ts` (new): `readSkillStartup` (SKILL.md with the shared file spliced in at its pointer, the reading order a skill sees) and `readSkillEffective` (each preamble section once, with this skill's variant applied). Tests that assert preamble rules are in context, E2E fixture extractors, workflow-judge bundles and install checks read these instead of SKILL.md alone; judge bundles keep their pre-hoist content. +- `test/helpers/carve-guards.ts`: parity caps for ship, review, autoplan, spec, land-and-deploy, design-consultation, plan-devex-review, plan-ceo-review, office-hours and plan-eng-review raised to their measured unions (owner-approved, Garry 2026-10-25: "do it"), each with its reason. The union now includes the shared file; plan-ceo-review's cap (1.1006) also covers the 106-byte COMPLETENESS IS COVERAGE bullet from the completeness rewording below. office-hours and plan-eng-review count their own copies of shared sections twice until a follow-up dedupes them. +- `test/fixtures/skill-md-size-allowlist.json`: 63 pins lowered and 26 deleted (now within the 100,000-character limit). None raised. The deferral note (owner Garry, revisit 2026-11-15) is retired here and in `test/skill-md-size-guard.test.ts`. +- `test/section-manifest-consistency.test.ts`: the generated-orphan check exempts the shared `sections/preamble.md` only when the skill's SKILL.md carries the "Shared preamble (load first)" pointer, because that file renders from `scripts/resolvers/preamble-hoist.ts` rather than a section template; it still must carry the AUTO-GENERATED header, and the generation freshness check covers its content. `test/paid-pr-profile.test.ts` covers the planner change. +- `test/preamble-hoist.test.ts` (new): tier 1 output is unchanged, and a tier 2+ SKILL.md with its `sections/preamble.md` applied holds exactly the pre-hoist preamble; the pointer opens the preamble, precedes the start command and names the shared file's id. `test/gen-skill-docs-checks.test.ts` checks that each host renders one shared preamble, beside every pointer and under the id the pointer names. `test/host-conformance.test.ts` checks that `setup --status` still finds a broken bare `sections/` pointer when a carved Claude skill's installed `sections/` is a directory symlink. A second `test/preamble-hoist.test.ts` case checks, on every host, tier and render option, that the pointer allows the skip only when the skill's own Read of the file is in context, never on the id alone. +- Ship goldens regenerated. 27 paid-test touchfile keys gain `test/helpers/skill-startup.ts`, which is also a global touchfile pinned in `scripts/harness-version.json` (recorded non-behavioral) because `test/helpers/skill-fixture.ts` builds its fixtures through it, and every key that names `scripts/resolvers/preamble.ts` also names `scripts/resolvers/preamble-hoist.ts`, so a hoist change selects the same paid cases. The existing TS2769 test-typecheck baseline entry for `test/gen-skill-docs-checks.test.ts` is re-keyed for the new `preamble` artifact kind, its count unchanged. +- `test/skill-llm-eval.test.ts`: the `voice directive tone` judge reads review's Voice section through `readSkillEffective` (SKILL.md plus its shared preamble), because the section now lives in `review/sections/preamble.md`. The text it judges is byte-identical to main's; rubric and thresholds are unchanged. +- `test/helpers/touchfiles-data.ts`: `ETHOS.md` is registered on the 41 paid cases whose touchfiles cover `plan-ceo-review` or `office-hours`, the two skills that tell the agent to read it, so an ETHOS.md change selects those cases instead of restoring the full gate. The unknown-dependency examples in `test/paid-pr-profile.test.ts`, `test/paid-free-boundary.test.ts` and `test/pr-dependencies.test.ts` now use `SECURITY.md` and `LICENSE`, and the first checks that ETHOS.md maps to its reader cases. + +### Gate integrity +- Raised: the ten parity caps above, by owner decision. No other threshold, budget, timeout, trial count, case kind or assertion weakened. The size floor, the growth ceiling and the total-corpus budget in `test/skill-size-budget.test.ts` now measure SKILL.md plus its shared file, which is what a skill loads at startup (corpus 3,114,885 → 2,590,484 bytes, ×0.832). `test/auq-format-always-loaded.test.ts`'s carve-safety guard counts a skill as carved only when `sections/` holds a file other than the shared `preamble.md`, the rule `setup --status` uses; the skills that leaves out keep the same AskUserQuestion checks in its per-skill loop. +- Lowered: the hoist shrank every tier 2+ SKILL.md by about 22 KB, so the 20 hoisted `maxSkeletonBytes` caps in `test/helpers/carve-guards.ts` drop to the new skeleton plus each one's pre-hoist headroom (ship 81,600 → 59,771), and 42 `eagerPerInvocation` ceilings in `test/fixtures/context-budget.json` ratchet down to the recaptured figures, and after main's v1.91.255.0 merge the new `captain` skill's ceiling goes from 12,011 to 6,551 (hoisted) and five loaded-skill ceilings drop by 2 to 22. No ceiling went up; the four the recapture would have raised keep their old values. +- Raised after main's v1.91.260.0 merge (#3433 GSTA-92 phase 2 and #3481 review convergence on top of the hoist): each value is the hoisted figure plus the measured growth those PRs added, with no template trim (owner-approved under the parity-caps rule, GSTA-92 Garry "Do it" and GSTA-96 Garry "do it"). Parity caps: ship 1.7172 (322,312 / 187,706), review 1.3136 (142,550 / 108,523), design-review 1.3758 (145,613 / 105,842). Skeleton caps lowered to the hoisted size: ship 82,700 → 60,913 (measured 60,848), design-review 137,767 → 115,947. `eagerPerInvocation` ceilings: ship 15,601 → 15,899, review 15,352 → 15,417, design-review 30,143 → 30,295, cso 4,660 → 4,902, gstack 5,326 → 5,346, capy/skill/gstack 3,498 → 3,899, alwaysOnTotal 7,458 → 7,546, and the new hoisted `ubicloud` row at 3,594. All stay below main's pre-hoist values. The size allowlist keeps the hoisted pins plus the 30 raises #3433 and #3481 made. +- After main's v1.91.265.0 merge (#3430 /design-motion, #3493 ETHOS principles): `context-budget.json` keeps main's new `design-motion` row at main's value (10,409), raises `eval-plan` by the growth #3493 measured (3,996 → 4,442 tokens; ceiling 4,396 → 4,842, main's own 9,811), and lowers `gstack` back to main's 5,326 (measured 4,863) and `alwaysOnTotal` to 7,542 (measured 7,542). +- After main's v1.91.270.0 merge (#3494, #3496, #3487): ship's parity cap goes from main's 1.7400 to 1.7432 (327,203 / 187,706), the hoist's raise plus #3487's with no trim. `context-budget.json` is recaptured whole with `bun test/helpers/capture-context-budget.ts`, which replaces the hand-kept rows in the bullet above. Every row that rises matches main's own recapture at v1.91.270.0 (`alwaysOnTotal` 7,769, `eval-plan` 4,887, `gstack` 5,350), and `design-motion` drops to 4,724 now that it loads the shared file. +- After main's v1.91.273.0 merge (#3509, #3500, #3501): every cap the hoist touches is re-measured on the merged tree. Parity caps: ship 1.7500 → 1.7524 (328,918 / 187,706) and plan-eng-review 1.2400 → 1.3532 (168,601 / 124,597), main's raises plus the hoist's with no trim. Skeleton caps for skills main carved or raised drop to the hoisted skeleton plus main's headroom: investigate 52,000 → 30,210, context-restore 46,300 → 24,514, design-motion 39,900 → 18,112. Every other cap holds at its measurement. The size allowlist keeps the same 63 pins re-measured (11 plan-eng-review pins rise by #3500's 2,896 characters), and `context-budget.json` is recaptured: the non-hoisted rows that rise (`benchmark` 8,043, `capy/skill/gstack` 4,120) equal main's own recapture, and the hoisted `context-restore` (6,287) and `investigate` (7,795) sit below it. +- After main's v1.91.274.0 merge (#3507): design-review's parity cap goes from main's 1.3681 to 1.3739 (145,410 / 105,842, the union with the shared file), and its skeleton cap drops from 137,735 to the hoisted 115,947 with main's zero headroom kept. design-html's skeleton cap drops from 56,200 to 34,410 (hoisted 34,044 plus main's 366 bytes of headroom), and its `context-budget.json` row from 8,926 to 8,896. Every other cap and pin holds at its measurement. + +**The Completeness Principle now asks for complete coverage with minimal code.** Every tier-2+ skill's preamble said "the complete thing is the goal", and ETHOS.md told models to "always prefer A" because "the 70-line delta costs seconds". Models read that as permission to write more code, which is the "50-line job arriving as 400" complaint (GSTA-93). Coverage of the requirement is still non-negotiable; code beyond it is no longer a virtue. + +### Changed +- **`scripts/resolvers/preamble/generate-completeness-section.ts`**: the default section reads "Complete coverage, minimal code": recommend full coverage of the requirement (tests, edge cases, error paths) and no code beyond it, prefer the smaller diff at equal coverage, and add no checks for impossible states. Unrelated work stays separate scope. The `Completeness: X/10` scoring rule and the GPT-5.6 Sol variant are unchanged. The section is 3 characters shorter, so every regenerated skill shrinks and 89 size pins go down. +- **`ETHOS.md`**: "Completeness is cheap ... always prefer A" becomes "Complete coverage, minimal code": completeness is measured in coverage, not lines; at equal coverage choose the smaller diff; no speculative abstractions, impossible-state checks or unneeded refactors. A new anti-pattern names the impossible-state fallback. Section 1's opener now says "When covering the whole requirement costs minutes more than the shortcut, cover it. Every time. Complete means complete coverage, not more code.", and "How They Work Together" says Boil the Ocean means "cover the whole requirement with the least code" instead of "do the complete thing". +- **`plan-ceo-review/SKILL.md.tmpl`**: the Philosophy bullet "COMPLETENESS IS CHEAP: AI makes 70 LOC seconds. Prefer complete ~150 LOC over 90% ~80 LOC. Boil the ocean." becomes "COMPLETENESS IS COVERAGE": cover the whole requirement (tests, edge cases, error paths) rather than a 90% shortcut, add no code beyond the accepted scope (so SCOPE EXPANSION and SELECTIVE EXPANSION still build what the user accepted), and choose the smaller diff at equal coverage. plan-ceo-review is under the 100,000-character limit on every host and has no size pin, so it grows about 100 characters without a pin change. +- **First-run intro**: the one-time Boil the Ocean intro in `bin/gstack-skill-start` says "complete coverage, minimal code" instead of "do the complete thing when AI makes marginal cost near-zero". The agents digest's Boil the Ocean bullet (`scripts/gen-agents-digest.ts`, regenerated `agents-digest/gstack-AGENTS.md`) says the same: "complete coverage, minimal code: cover the whole requirement (tests, edge cases, error paths) and write no code beyond it". +- `review/specialists/simplification.md` quotes the new house rule instead of "If A is 70 lines more, choose A", and `CLAUDE.md` restates it in place of "Completeness is cheap ... the complete thing is the goal". + +### Tests +- `test/model-overlays.test.ts` asserts the new default wording ("Complete coverage, minimal code.", "prefer the smaller diff") and checks, one sentence per rule, that the section still states full coverage of the requirement with no code beyond it, the smaller diff only at equal coverage, no checks for impossible states, and that unrelated work is separate scope that never excuses a gap. +- `test/ceo-workflow-clarity.test.ts` checks that the plan-ceo-review Philosophy bullet still asks for the whole requirement over a 90% shortcut, no code beyond the accepted scope and the smaller diff at equal coverage, and that "COMPLETENESS IS CHEAP" and "~150 LOC" stay gone. +- `test/gen-skill-docs.test.ts` checks that the house rule `review/specialists/simplification.md` quotes is still stated in ETHOS.md. `test/gstack-skill-start.test.ts` checks that the first-run intro says complete coverage, minimal code and never "do the complete thing". +- `test/fixtures/skill-md-size-allowlist.json`: 89 pins lowered by 3 characters each. None raised; the plan-ceo-review rewording touches no pinned skill. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion weakened. The section heading "Completeness Principle — Boil the Ocean" is unchanged. + +## [1.91.275.0] - 2026-10-27 + +### Changed +- **Policy key versions are stamped, not hand-moved** (`lib/ship-policy.ts`, `.gstack/ship-policy.json`): `DOCS_CAPS_KEY_VERSION` is 1.91.270.0, the release that shipped `docs.caps` (#3487 was written naming 1.91.258.0, which main stamped for another PR; a slot call never permits an edit beyond the four stamp files, so the constant could not move at the slot). gstack's own policy now lists `lib/ship-policy.ts` under `stamp_paths` with the `_KEY_VERSION = '{version}'` template in `stamp_patterns`: a new key's constant is written as a tentative future version clear of main's pace (the next minor) and `gstack-restamp`'s stamp_paths stage moves it to the allocated version at the slot stamp, reading the base branch's policy, so the stage is live for the PRs after this one; the tree receipt's stamp-only check still accepts that move only when the tentative value equals the base version at the slot, so a PR that adds a key cannot clear the train until #3512 lands. `test/gstack-ship-policy.test.ts` adds the check the incident lacked: a released key's version must be a CHANGELOG heading whose entry names the key, so a tentative value no stamp moved fails (keys from before `review_scope` are exempt). + +## [1.91.274.0] - 2026-10-27 + +**/design-html and /design-review now run the design lint, and its copy tier reports every content and ship-floor tell at its line (hallmark phase 2, GSTA-92; Garry 2026-10-26 "Do it", option a).** The two design skills still called `gstack-design-craft`, which counted tells without positions and kept accepted exceptions in finalized.json. The lint already carried the same ids, so the skills now call it, the copy tier has its own patterns with line numbers, and a page answers a tell with a waiver comment beside it instead of a side record. + +### Changed +- **/design-html** Step 4 runs `gstack-design-lint <page> --render` after the Slop Gate and reads its exit code: 2 fixes every `blocks` line, 1 fixes or waives each `ask` line, 0 lists `polish` as notes. A missing `LINT_SUMMARY`, a `render` line or `render=skipped` is said plainly and the static lines stand. +- **Waivers live in the page.** A real customer named Nexus or a supplied 50% figure gets `<!-- gstack-lint-disable <rule-id>: <reason about this page, 24+ chars> -->`, written only for what the user supplied or confirmed. This supersedes #3422's persisted craft exceptions: `--accepted` and finalized.json's `craft_exceptions` are gone, and the hand-off lists every waiver. +- **Copy-tier tiers**: `placeholder-name`, `placeholder-brand`, `lorem`, `round-number` and `needs-proof` move from blocks to ask, so the waiver above can answer them. /design-review still files a waived `needs-proof` or `lorem`, which a live page cannot carry. +- **/design-review's craft audit** runs the lint on each saved page: `blocks` and `ask` lines are category 9 findings at their line, `waived` lines are listed with their reason, `empty-mount-node` sends the audit to the saved DOM. A saved live page has lost its linked CSS, so the audit files only its copy-tier (`craft`) lines there and judges the CSS rules by eye. +- **`lib/design-lint/rules/copy.ts`** holds the copy tier's own patterns (moved from `lib/design-craft.ts`), reporting each tell at its line with what matched. It reads visible text, script bodies and inline handlers (single-quoted included) and the parsed tags, so a hydration payload, commented-out markup or a `<title>` string inside a script no longer counts. + +### Added +- **`empty-mount-node`** (ask, page only): a body with no text, a `<noscript>` notice aside, that loads a script is a client-rendered shell; the lint says it read none of its content instead of passing it. + +### Removed +- `bin/gstack-design-craft`, `lib/design-craft.ts` and `test/design-craft.test.ts`. Their checks are in the lint's copy tier and its tests; the archetype handoff tests moved to `test/design-craft-resolvers.test.ts`. + +### Tests +- `test/design-lint.test.ts`: the copy tier at its lines with the catalog's fixes, the clean page with inlined Pretext, the skip link and `aria-current`, visible-text reading, the four checker limits #3433 documented, and the shell check (noscript, image-only page, component scope). `test/design-lint-recorded.test.ts` reads the recorded page's buzzwords at lines 32 and 37. +- The `design-html-slop-gate` paid case looks for a `gstack-design-lint` call, and its touchfiles select on `lib/design-lint/**` and `bin/gstack-design-lint`. + diff --git a/test/fixtures/merge-train-3453/main-6ff98c66.md b/test/fixtures/merge-train-3453/main-6ff98c66.md new file mode 100644 index 000000000..9ac8d3f27 --- /dev/null +++ b/test/fixtures/merge-train-3453/main-6ff98c66.md @@ -0,0 +1,95 @@ +# Changelog + +## [1.91.277.0] - 2026-10-27 + +**office-hours stops carrying its own copy of the shared AskUserQuestion subsections.** After the shared-preamble hoist, office-hours still inlined whole copies of the shared `### When AskUserQuestion is unavailable or a call fails` and `### Format` subsections, because each held one office-hours addition: the `Q<N>` open-question form. A session's first office-hours invocation read those subsections twice, once from the shared file and once from SKILL.md. The `Q<N>` form and its routing sentence now render as office-hours' own `### Open-ended questions` subsection at the end of the AskUserQuestion section, so the shared subsections apply as they are and office-hours' SKILL.md drops 6,328 bytes (its parity union goes from 160,952 to 154,624, under the pre-hoist 1.18 cap). + +### Changed +- **`scripts/resolvers/preamble/generate-ask-user-format.ts`**: office-hours' `Q<N>` form and "a free-text reply answers the most recent unanswered `Q<N>`" move into one `### Open-ended questions` subsection appended to the AskUserQuestion section. The form's template and Q-numbering text are unchanged; its lead-in now also carries what the Layout line used to say, "use this form instead of a `D<N>` brief" and the free-text routing rule. Other skills render byte-identically. +- **`scripts/resolvers/preamble-hoist.ts`**: a skill variant may add subsections after the shared ones. It keeps only its changed and added subsections, and its note says "Only these subsections replace the shared ones or, under a new heading, add to its end". Variants without additions keep the existing note. +- **plan-eng-review keeps its versions** of the shared Tool resolution, fallback, Format, Self-check, Voice, plan-mode invocation and Plan Status Footer text, because each differs on purpose (the Scope gate, D-numbering after the target menu, the em-dash rule for composed prose, the selected-report footer, its own self-check). Its cap is unchanged. + +### Tests +- `test/helpers/skill-startup.ts` applies added subsections when it builds the effective text, so the hoisted-equals-inline check covers office-hours' new layout. Its `scripts/harness-version.json` pin moves to the new blob, recorded non-behavioral at HARNESS_VERSION 6 with no pass-rate reset: `readSkillStartup`, `readSharedSections` and `readSkillEffective` return the same bytes as the pinned helper for every tier 2+ render without added subsections (735 across all hosts) and for office-hours' SKILL.md before this change, so office-hours' fixture input changes only because its prompt did. +- `test/preamble-hoist.test.ts` checks, on every host and tier, that a cut-down variant carries the "add to its end" note exactly when it adds a subsection, and that `readSkillEffective` puts a changed subsection in place and appends added ones in order. +- `test/helpers/carve-guards.ts`: office-hours' parity cap goes down from 1.2279 to 1.1796, its union as measured before the main merge (154,653 bytes; 154,624 now, 1.1793). After the v1.91.270.0 merge, `test/fixtures/context-budget.json` is recaptured and office-hours' ceiling goes down from 20,792 to 19,139. No cap or pin goes up. + +### Gate integrity +- Corrections to the shared-preamble entry below: its post-v1.91.260.0 bullet should say the size allowlist carries 31 pin raises from #3433 and #3481 (23 and 8), not 30, and that `alwaysOnTotal` sits above main's unchanged ceiling (7,458) by the measured growth, so "all stay below main's pre-hoist values" holds for the per-skill rows only (`gstack` is back at main's 5,326 since the v1.91.265.0 merge). Its post-v1.91.265.0 bullet's "measured 7,542" for `alwaysOnTotal` is the raw bill; the ratchet's bill, which excludes test fixtures and aliases, measures 7,398.5 against the 7,542 ceiling. Its post-v1.91.270.0 bullet's "every row that rises matches main's own recapture" holds for the non-hoisted rows only (`alwaysOnTotal`, `capy/skill/gstack`, `diagram`, `gstack`, `gstack-upgrade`); the hoisted rows that rose (`eval-plan` 4,887, `landing-report`, `plan-tune`, `retro`, `ship`) are that branch's own recapture with main's growth included, and main measures them unhoisted (`eval-plan` 10,581). + +## [1.91.276.0] - 2026-10-27 + +**Tier 2+ skills load the shared preamble once per session instead of once per invocation.** Every tier 2+ SKILL.md carried the same ~23 KB of preamble (AskUserQuestion format, voice, writing style, completeness, completion protocol and more), so each skill invocation re-read text the conversation already held. That text now renders once per host into `sections/preamble.md` beside each tier 2+ SKILL.md. The SKILL.md keeps a short "Shared preamble (load first)" pointer naming the file and its id (a hash of the shared text before host rewrites), and asks for the Read unless the skill's own earlier Read of that file, headed by that id, is still in context, so other text that merely carries the id cannot stand in for the shared rules. Measured with Anthropic token counting: review's SKILL.md drops from 21,884 to 16,103 tokens, ship from 22,688 to 16,907, office-hours from 27,100 to 23,085 and design-review from 39,040 to 33,261. The shared file is 5,953 tokens, so a skill's first invocation in a session costs about 170 tokens more than before (about 1,900 for office-hours, about 3,800 for plan-eng-review) and every later invocation of that skill saves about 5,800 (4,000 for office-hours, 2,200 for plan-eng-review). The skip is tied to the skill's own copy of the file, so the first invocation of each different skill pays the Read again. A /autoplan run's four review-skill loads carry 17,941 fewer SKILL.md tokens (191,871 → 173,930), and /autoplan and the inline /office-hours loader skip each loaded skill's "Shared preamble (load first)" section, because the parent already holds the shared preamble. + +### Changed +- **`scripts/resolvers/preamble-hoist.ts`** (new) renders `{{PREAMBLE}}` for tier 2+ skills: a section moves to the shared file only when this skill's render of it is byte-identical to the shared render, so no skill's instructions change. Sections that name the skill (the start command, Question Tuning, Repo Ownership, Search Before Building, Telemetry), text without its own heading, and skill-specific variants stay inline; a variant that keeps the shared subsection headings carries only its differing `###` subsections. Tier 1 skills keep their short preamble inline. Across all 41 hoisted skills, the SKILL.md with its shared file applied holds exactly the paragraphs the pre-hoist SKILL.md held. +- **`scripts/gen-skill-docs.ts`** emits `sections/preamble.md` per tier 2+ skill and host as a new `preamble` artifact kind, with the same host rewrites, install-root rewrite and runtime preludes as SKILL.md. `setup` already installs every skill's `sections/`, and gstack-built overlay and per-install renders carry the file the same way. +- **`setup --status`**: a bare `sections/<file>` pointer counts as carved only when the skill installs a section other than the shared `preamble.md`, so the new file does not turn on checks for skills that inline their sections on that host. +- **`gstack-doctor --check`**: the `patch` row finds the Codex render's model patch in the review skill's `sections/preamble.md` too, where the hoist moves it; without this the required row failed on every hoisted Codex install. +- **Loaded-skill skip lists** (`/autoplan`'s section skip list and the `{{INVOKE_SKILL}}` loader's defaults in `scripts/resolvers/composition.ts`) skip `Shared preamble (load first)`, so a loaded plan-review or /office-hours skill no longer Reads its own shared file and re-imports the sections the parent already handles. `Completeness Principle — Boil the Ocean` and `Completion Status Protocol` leave both lists, because no loaded skill carries them inline any more, only in the shared file. Each list is 40 characters shorter, so 30 size pins go down (plan-eng-review's by 26, after main's +14 pointer growth). +- **PR paid planner** (`scripts/lib/paid-select.ts`): a generated `<skill>/sections/preamble.md` takes its skill's `SKILL.md.tmpl` identity, as that skill's SKILL.md already does, so a skill whose only paid coverage is periodic defers it instead of failing `screen-summary` with "requires full validation". + +### Tests +- `test/helpers/docsync-fixture.ts`: `fixtureDocs` copies the generated `sections/preamble.md` into the document-release and ship fixtures when the skill has one, because the hoisted SKILL.md names it as a required asset and a spawned docs audit blocks without it (hunk taken from #3472). #3472's companion test in `test/ship-document-release-dispatch.test.ts` removes ship's generated `sections/preamble.md` from its copied tree before checking that a skill without one gets none, because under the hoist every generated tier 2+ skill has one. +- `test/helpers/skill-startup.ts` (new): `readSkillStartup` (SKILL.md with the shared file spliced in at its pointer, the reading order a skill sees) and `readSkillEffective` (each preamble section once, with this skill's variant applied). Tests that assert preamble rules are in context, E2E fixture extractors, workflow-judge bundles and install checks read these instead of SKILL.md alone; judge bundles keep their pre-hoist content. +- `test/helpers/carve-guards.ts`: parity caps for ship, review, autoplan, spec, land-and-deploy, design-consultation, plan-devex-review, plan-ceo-review, office-hours and plan-eng-review raised to their measured unions (owner-approved, Garry 2026-10-25: "do it"), each with its reason. The union now includes the shared file; plan-ceo-review's cap (1.1006) also covers the 106-byte COMPLETENESS IS COVERAGE bullet from the completeness rewording below. office-hours and plan-eng-review count their own copies of shared sections twice until a follow-up dedupes them. +- `test/fixtures/skill-md-size-allowlist.json`: 63 pins lowered and 26 deleted (now within the 100,000-character limit). None raised. The deferral note (owner Garry, revisit 2026-11-15) is retired here and in `test/skill-md-size-guard.test.ts`. +- `test/section-manifest-consistency.test.ts`: the generated-orphan check exempts the shared `sections/preamble.md` only when the skill's SKILL.md carries the "Shared preamble (load first)" pointer, because that file renders from `scripts/resolvers/preamble-hoist.ts` rather than a section template; it still must carry the AUTO-GENERATED header, and the generation freshness check covers its content. `test/paid-pr-profile.test.ts` covers the planner change. +- `test/preamble-hoist.test.ts` (new): tier 1 output is unchanged, and a tier 2+ SKILL.md with its `sections/preamble.md` applied holds exactly the pre-hoist preamble; the pointer opens the preamble, precedes the start command and names the shared file's id. `test/gen-skill-docs-checks.test.ts` checks that each host renders one shared preamble, beside every pointer and under the id the pointer names. `test/host-conformance.test.ts` checks that `setup --status` still finds a broken bare `sections/` pointer when a carved Claude skill's installed `sections/` is a directory symlink. A second `test/preamble-hoist.test.ts` case checks, on every host, tier and render option, that the pointer allows the skip only when the skill's own Read of the file is in context, never on the id alone. +- Ship goldens regenerated. 27 paid-test touchfile keys gain `test/helpers/skill-startup.ts`, which is also a global touchfile pinned in `scripts/harness-version.json` (recorded non-behavioral) because `test/helpers/skill-fixture.ts` builds its fixtures through it, and every key that names `scripts/resolvers/preamble.ts` also names `scripts/resolvers/preamble-hoist.ts`, so a hoist change selects the same paid cases. The existing TS2769 test-typecheck baseline entry for `test/gen-skill-docs-checks.test.ts` is re-keyed for the new `preamble` artifact kind, its count unchanged. +- `test/skill-llm-eval.test.ts`: the `voice directive tone` judge reads review's Voice section through `readSkillEffective` (SKILL.md plus its shared preamble), because the section now lives in `review/sections/preamble.md`. The text it judges is byte-identical to main's; rubric and thresholds are unchanged. +- `test/helpers/touchfiles-data.ts`: `ETHOS.md` is registered on the 41 paid cases whose touchfiles cover `plan-ceo-review` or `office-hours`, the two skills that tell the agent to read it, so an ETHOS.md change selects those cases instead of restoring the full gate. The unknown-dependency examples in `test/paid-pr-profile.test.ts`, `test/paid-free-boundary.test.ts` and `test/pr-dependencies.test.ts` now use `SECURITY.md` and `LICENSE`, and the first checks that ETHOS.md maps to its reader cases. + +### Gate integrity +- Raised: the ten parity caps above, by owner decision. No other threshold, budget, timeout, trial count, case kind or assertion weakened. The size floor, the growth ceiling and the total-corpus budget in `test/skill-size-budget.test.ts` now measure SKILL.md plus its shared file, which is what a skill loads at startup (corpus 3,114,885 → 2,590,484 bytes, ×0.832). `test/auq-format-always-loaded.test.ts`'s carve-safety guard counts a skill as carved only when `sections/` holds a file other than the shared `preamble.md`, the rule `setup --status` uses; the skills that leaves out keep the same AskUserQuestion checks in its per-skill loop. +- Lowered: the hoist shrank every tier 2+ SKILL.md by about 22 KB, so the 20 hoisted `maxSkeletonBytes` caps in `test/helpers/carve-guards.ts` drop to the new skeleton plus each one's pre-hoist headroom (ship 81,600 → 59,771), and 42 `eagerPerInvocation` ceilings in `test/fixtures/context-budget.json` ratchet down to the recaptured figures, and after main's v1.91.255.0 merge the new `captain` skill's ceiling goes from 12,011 to 6,551 (hoisted) and five loaded-skill ceilings drop by 2 to 22. No ceiling went up; the four the recapture would have raised keep their old values. +- Raised after main's v1.91.260.0 merge (#3433 GSTA-92 phase 2 and #3481 review convergence on top of the hoist): each value is the hoisted figure plus the measured growth those PRs added, with no template trim (owner-approved under the parity-caps rule, GSTA-92 Garry "Do it" and GSTA-96 Garry "do it"). Parity caps: ship 1.7172 (322,312 / 187,706), review 1.3136 (142,550 / 108,523), design-review 1.3758 (145,613 / 105,842). Skeleton caps lowered to the hoisted size: ship 82,700 → 60,913 (measured 60,848), design-review 137,767 → 115,947. `eagerPerInvocation` ceilings: ship 15,601 → 15,899, review 15,352 → 15,417, design-review 30,143 → 30,295, cso 4,660 → 4,902, gstack 5,326 → 5,346, capy/skill/gstack 3,498 → 3,899, alwaysOnTotal 7,458 → 7,546, and the new hoisted `ubicloud` row at 3,594. All stay below main's pre-hoist values. The size allowlist keeps the hoisted pins plus the 30 raises #3433 and #3481 made. +- After main's v1.91.265.0 merge (#3430 /design-motion, #3493 ETHOS principles): `context-budget.json` keeps main's new `design-motion` row at main's value (10,409), raises `eval-plan` by the growth #3493 measured (3,996 → 4,442 tokens; ceiling 4,396 → 4,842, main's own 9,811), and lowers `gstack` back to main's 5,326 (measured 4,863) and `alwaysOnTotal` to 7,542 (measured 7,542). +- After main's v1.91.270.0 merge (#3494, #3496, #3487): ship's parity cap goes from main's 1.7400 to 1.7432 (327,203 / 187,706), the hoist's raise plus #3487's with no trim. `context-budget.json` is recaptured whole with `bun test/helpers/capture-context-budget.ts`, which replaces the hand-kept rows in the bullet above. Every row that rises matches main's own recapture at v1.91.270.0 (`alwaysOnTotal` 7,769, `eval-plan` 4,887, `gstack` 5,350), and `design-motion` drops to 4,724 now that it loads the shared file. +- After main's v1.91.273.0 merge (#3509, #3500, #3501): every cap the hoist touches is re-measured on the merged tree. Parity caps: ship 1.7500 → 1.7524 (328,918 / 187,706) and plan-eng-review 1.2400 → 1.3532 (168,601 / 124,597), main's raises plus the hoist's with no trim. Skeleton caps for skills main carved or raised drop to the hoisted skeleton plus main's headroom: investigate 52,000 → 30,210, context-restore 46,300 → 24,514, design-motion 39,900 → 18,112. Every other cap holds at its measurement. The size allowlist keeps the same 63 pins re-measured (11 plan-eng-review pins rise by #3500's 2,896 characters), and `context-budget.json` is recaptured: the non-hoisted rows that rise (`benchmark` 8,043, `capy/skill/gstack` 4,120) equal main's own recapture, and the hoisted `context-restore` (6,287) and `investigate` (7,795) sit below it. +- After main's v1.91.274.0 merge (#3507): design-review's parity cap goes from main's 1.3681 to 1.3739 (145,410 / 105,842, the union with the shared file), and its skeleton cap drops from 137,735 to the hoisted 115,947 with main's zero headroom kept. design-html's skeleton cap drops from 56,200 to 34,410 (hoisted 34,044 plus main's 366 bytes of headroom), and its `context-budget.json` row from 8,926 to 8,896. Every other cap and pin holds at its measurement. + +**The Completeness Principle now asks for complete coverage with minimal code.** Every tier-2+ skill's preamble said "the complete thing is the goal", and ETHOS.md told models to "always prefer A" because "the 70-line delta costs seconds". Models read that as permission to write more code, which is the "50-line job arriving as 400" complaint (GSTA-93). Coverage of the requirement is still non-negotiable; code beyond it is no longer a virtue. + +### Changed +- **`scripts/resolvers/preamble/generate-completeness-section.ts`**: the default section reads "Complete coverage, minimal code": recommend full coverage of the requirement (tests, edge cases, error paths) and no code beyond it, prefer the smaller diff at equal coverage, and add no checks for impossible states. Unrelated work stays separate scope. The `Completeness: X/10` scoring rule and the GPT-5.6 Sol variant are unchanged. The section is 3 characters shorter, so every regenerated skill shrinks and 89 size pins go down. +- **`ETHOS.md`**: "Completeness is cheap ... always prefer A" becomes "Complete coverage, minimal code": completeness is measured in coverage, not lines; at equal coverage choose the smaller diff; no speculative abstractions, impossible-state checks or unneeded refactors. A new anti-pattern names the impossible-state fallback. Section 1's opener now says "When covering the whole requirement costs minutes more than the shortcut, cover it. Every time. Complete means complete coverage, not more code.", and "How They Work Together" says Boil the Ocean means "cover the whole requirement with the least code" instead of "do the complete thing". +- **`plan-ceo-review/SKILL.md.tmpl`**: the Philosophy bullet "COMPLETENESS IS CHEAP: AI makes 70 LOC seconds. Prefer complete ~150 LOC over 90% ~80 LOC. Boil the ocean." becomes "COMPLETENESS IS COVERAGE": cover the whole requirement (tests, edge cases, error paths) rather than a 90% shortcut, add no code beyond the accepted scope (so SCOPE EXPANSION and SELECTIVE EXPANSION still build what the user accepted), and choose the smaller diff at equal coverage. plan-ceo-review is under the 100,000-character limit on every host and has no size pin, so it grows about 100 characters without a pin change. +- **First-run intro**: the one-time Boil the Ocean intro in `bin/gstack-skill-start` says "complete coverage, minimal code" instead of "do the complete thing when AI makes marginal cost near-zero". The agents digest's Boil the Ocean bullet (`scripts/gen-agents-digest.ts`, regenerated `agents-digest/gstack-AGENTS.md`) says the same: "complete coverage, minimal code: cover the whole requirement (tests, edge cases, error paths) and write no code beyond it". +- `review/specialists/simplification.md` quotes the new house rule instead of "If A is 70 lines more, choose A", and `CLAUDE.md` restates it in place of "Completeness is cheap ... the complete thing is the goal". + +### Tests +- `test/model-overlays.test.ts` asserts the new default wording ("Complete coverage, minimal code.", "prefer the smaller diff") and checks, one sentence per rule, that the section still states full coverage of the requirement with no code beyond it, the smaller diff only at equal coverage, no checks for impossible states, and that unrelated work is separate scope that never excuses a gap. +- `test/ceo-workflow-clarity.test.ts` checks that the plan-ceo-review Philosophy bullet still asks for the whole requirement over a 90% shortcut, no code beyond the accepted scope and the smaller diff at equal coverage, and that "COMPLETENESS IS CHEAP" and "~150 LOC" stay gone. +- `test/gen-skill-docs.test.ts` checks that the house rule `review/specialists/simplification.md` quotes is still stated in ETHOS.md. `test/gstack-skill-start.test.ts` checks that the first-run intro says complete coverage, minimal code and never "do the complete thing". +- `test/fixtures/skill-md-size-allowlist.json`: 89 pins lowered by 3 characters each. None raised; the plan-ceo-review rewording touches no pinned skill. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion weakened. The section heading "Completeness Principle — Boil the Ocean" is unchanged. + +## [1.91.275.0] - 2026-10-27 + +### Changed +- **Policy key versions are stamped, not hand-moved** (`lib/ship-policy.ts`, `.gstack/ship-policy.json`): `DOCS_CAPS_KEY_VERSION` is 1.91.270.0, the release that shipped `docs.caps` (#3487 was written naming 1.91.258.0, which main stamped for another PR; a slot call never permits an edit beyond the four stamp files, so the constant could not move at the slot). gstack's own policy now lists `lib/ship-policy.ts` under `stamp_paths` with the `_KEY_VERSION = '{version}'` template in `stamp_patterns`: a new key's constant is written as a tentative future version clear of main's pace (the next minor) and `gstack-restamp`'s stamp_paths stage moves it to the allocated version at the slot stamp, reading the base branch's policy, so the stage is live for the PRs after this one; the tree receipt's stamp-only check still accepts that move only when the tentative value equals the base version at the slot, so a PR that adds a key cannot clear the train until #3512 lands. `test/gstack-ship-policy.test.ts` adds the check the incident lacked: a released key's version must be a CHANGELOG heading whose entry names the key, so a tentative value no stamp moved fails (keys from before `review_scope` are exempt). + +## [1.91.274.0] - 2026-10-27 + +**/design-html and /design-review now run the design lint, and its copy tier reports every content and ship-floor tell at its line (hallmark phase 2, GSTA-92; Garry 2026-10-26 "Do it", option a).** The two design skills still called `gstack-design-craft`, which counted tells without positions and kept accepted exceptions in finalized.json. The lint already carried the same ids, so the skills now call it, the copy tier has its own patterns with line numbers, and a page answers a tell with a waiver comment beside it instead of a side record. + +### Changed +- **/design-html** Step 4 runs `gstack-design-lint <page> --render` after the Slop Gate and reads its exit code: 2 fixes every `blocks` line, 1 fixes or waives each `ask` line, 0 lists `polish` as notes. A missing `LINT_SUMMARY`, a `render` line or `render=skipped` is said plainly and the static lines stand. +- **Waivers live in the page.** A real customer named Nexus or a supplied 50% figure gets `<!-- gstack-lint-disable <rule-id>: <reason about this page, 24+ chars> -->`, written only for what the user supplied or confirmed. This supersedes #3422's persisted craft exceptions: `--accepted` and finalized.json's `craft_exceptions` are gone, and the hand-off lists every waiver. +- **Copy-tier tiers**: `placeholder-name`, `placeholder-brand`, `lorem`, `round-number` and `needs-proof` move from blocks to ask, so the waiver above can answer them. /design-review still files a waived `needs-proof` or `lorem`, which a live page cannot carry. +- **/design-review's craft audit** runs the lint on each saved page: `blocks` and `ask` lines are category 9 findings at their line, `waived` lines are listed with their reason, `empty-mount-node` sends the audit to the saved DOM. A saved live page has lost its linked CSS, so the audit files only its copy-tier (`craft`) lines there and judges the CSS rules by eye. +- **`lib/design-lint/rules/copy.ts`** holds the copy tier's own patterns (moved from `lib/design-craft.ts`), reporting each tell at its line with what matched. It reads visible text, script bodies and inline handlers (single-quoted included) and the parsed tags, so a hydration payload, commented-out markup or a `<title>` string inside a script no longer counts. + +### Added +- **`empty-mount-node`** (ask, page only): a body with no text, a `<noscript>` notice aside, that loads a script is a client-rendered shell; the lint says it read none of its content instead of passing it. + +### Removed +- `bin/gstack-design-craft`, `lib/design-craft.ts` and `test/design-craft.test.ts`. Their checks are in the lint's copy tier and its tests; the archetype handoff tests moved to `test/design-craft-resolvers.test.ts`. + +### Tests +- `test/design-lint.test.ts`: the copy tier at its lines with the catalog's fixes, the clean page with inlined Pretext, the skip link and `aria-current`, visible-text reading, the four checker limits #3433 documented, and the shell check (noscript, image-only page, component scope). `test/design-lint-recorded.test.ts` reads the recorded page's buzzwords at lines 32 and 37. +- The `design-html-slop-gate` paid case looks for a `gstack-design-lint` call, and its touchfiles select on `lib/design-lint/**` and `bin/gstack-design-lint`. + diff --git a/test/fixtures/merge-train-3453/pin-a78ce08a.md b/test/fixtures/merge-train-3453/pin-a78ce08a.md new file mode 100644 index 000000000..ca5e5176e --- /dev/null +++ b/test/fixtures/merge-train-3453/pin-a78ce08a.md @@ -0,0 +1,88 @@ +# Changelog + +## [Unreleased] + +**office-hours stops carrying its own copy of the shared AskUserQuestion subsections.** After the shared-preamble hoist, office-hours still inlined whole copies of the shared `### When AskUserQuestion is unavailable or a call fails` and `### Format` subsections, because each held one office-hours addition: the `Q<N>` open-question form. A session's first office-hours invocation read those subsections twice, once from the shared file and once from SKILL.md. The `Q<N>` form and its routing sentence now render as office-hours' own `### Open-ended questions` subsection at the end of the AskUserQuestion section, so the shared subsections apply as they are and office-hours' SKILL.md drops 6,328 bytes (its parity union goes from 160,952 to 154,624, under the pre-hoist 1.18 cap). + +### Changed +- **`scripts/resolvers/preamble/generate-ask-user-format.ts`**: office-hours' `Q<N>` form and "a free-text reply answers the most recent unanswered `Q<N>`" move into one `### Open-ended questions` subsection appended to the AskUserQuestion section. The form's template and Q-numbering text are unchanged; its lead-in now also carries what the Layout line used to say, "use this form instead of a `D<N>` brief" and the free-text routing rule. Other skills render byte-identically. +- **`scripts/resolvers/preamble-hoist.ts`**: a skill variant may add subsections after the shared ones. It keeps only its changed and added subsections, and its note says "Only these subsections replace the shared ones or, under a new heading, add to its end". Variants without additions keep the existing note. +- **plan-eng-review keeps its versions** of the shared Tool resolution, fallback, Format, Self-check, Voice, plan-mode invocation and Plan Status Footer text, because each differs on purpose (the Scope gate, D-numbering after the target menu, the em-dash rule for composed prose, the selected-report footer, its own self-check). Its cap is unchanged. + +### Tests +- `test/helpers/skill-startup.ts` applies added subsections when it builds the effective text, so the hoisted-equals-inline check covers office-hours' new layout. Its `scripts/harness-version.json` pin moves to the new blob, recorded non-behavioral at HARNESS_VERSION 6 with no pass-rate reset: `readSkillStartup`, `readSharedSections` and `readSkillEffective` return the same bytes as the pinned helper for every tier 2+ render without added subsections (735 across all hosts) and for office-hours' SKILL.md before this change, so office-hours' fixture input changes only because its prompt did. +- `test/preamble-hoist.test.ts` checks, on every host and tier, that a cut-down variant carries the "add to its end" note exactly when it adds a subsection, and that `readSkillEffective` puts a changed subsection in place and appends added ones in order. +- `test/helpers/carve-guards.ts`: office-hours' parity cap goes down from 1.2279 to 1.1796, its union as measured before the main merge (154,653 bytes; 154,624 now, 1.1793). After the v1.91.270.0 merge, `test/fixtures/context-budget.json` is recaptured and office-hours' ceiling goes down from 20,792 to 19,139. No cap or pin goes up. + +### Gate integrity +- Corrections to the shared-preamble entry below: its post-v1.91.260.0 bullet should say the size allowlist carries 31 pin raises from #3433 and #3481 (23 and 8), not 30, and that `alwaysOnTotal` sits above main's unchanged ceiling (7,458) by the measured growth, so "all stay below main's pre-hoist values" holds for the per-skill rows only (`gstack` is back at main's 5,326 since the v1.91.265.0 merge). Its post-v1.91.265.0 bullet's "measured 7,542" for `alwaysOnTotal` is the raw bill; the ratchet's bill, which excludes test fixtures and aliases, measures 7,398.5 against the 7,542 ceiling. Its post-v1.91.270.0 bullet's "every row that rises matches main's own recapture" holds for the non-hoisted rows only (`alwaysOnTotal`, `capy/skill/gstack`, `diagram`, `gstack`, `gstack-upgrade`); the hoisted rows that rose (`eval-plan` 4,887, `landing-report`, `plan-tune`, `retro`, `ship`) are that branch's own recapture with main's growth included, and main measures them unhoisted (`eval-plan` 10,581). + +**Tier 2+ skills load the shared preamble once per session instead of once per invocation.** Every tier 2+ SKILL.md carried the same ~23 KB of preamble (AskUserQuestion format, voice, writing style, completeness, completion protocol and more), so each skill invocation re-read text the conversation already held. That text now renders once per host into `sections/preamble.md` beside each tier 2+ SKILL.md. The SKILL.md keeps a short "Shared preamble (load first)" pointer naming the file and its id (a hash of the shared text before host rewrites), and asks for the Read unless the skill's own earlier Read of that file, headed by that id, is still in context, so other text that merely carries the id cannot stand in for the shared rules. Measured with Anthropic token counting: review's SKILL.md drops from 21,884 to 16,103 tokens, ship from 22,688 to 16,907, office-hours from 27,100 to 23,085 and design-review from 39,040 to 33,261. The shared file is 5,953 tokens, so a skill's first invocation in a session costs about 170 tokens more than before (about 1,900 for office-hours, about 3,800 for plan-eng-review) and every later invocation of that skill saves about 5,800 (4,000 for office-hours, 2,200 for plan-eng-review). The skip is tied to the skill's own copy of the file, so the first invocation of each different skill pays the Read again. A /autoplan run's four review-skill loads carry 17,941 fewer SKILL.md tokens (191,871 → 173,930), and /autoplan and the inline /office-hours loader skip each loaded skill's "Shared preamble (load first)" section, because the parent already holds the shared preamble. + +### Changed +- **`scripts/resolvers/preamble-hoist.ts`** (new) renders `{{PREAMBLE}}` for tier 2+ skills: a section moves to the shared file only when this skill's render of it is byte-identical to the shared render, so no skill's instructions change. Sections that name the skill (the start command, Question Tuning, Repo Ownership, Search Before Building, Telemetry), text without its own heading, and skill-specific variants stay inline; a variant that keeps the shared subsection headings carries only its differing `###` subsections. Tier 1 skills keep their short preamble inline. Across all 41 hoisted skills, the SKILL.md with its shared file applied holds exactly the paragraphs the pre-hoist SKILL.md held. +- **`scripts/gen-skill-docs.ts`** emits `sections/preamble.md` per tier 2+ skill and host as a new `preamble` artifact kind, with the same host rewrites, install-root rewrite and runtime preludes as SKILL.md. `setup` already installs every skill's `sections/`, and gstack-built overlay and per-install renders carry the file the same way. +- **`setup --status`**: a bare `sections/<file>` pointer counts as carved only when the skill installs a section other than the shared `preamble.md`, so the new file does not turn on checks for skills that inline their sections on that host. +- **`gstack-doctor --check`**: the `patch` row finds the Codex render's model patch in the review skill's `sections/preamble.md` too, where the hoist moves it; without this the required row failed on every hoisted Codex install. +- **Loaded-skill skip lists** (`/autoplan`'s section skip list and the `{{INVOKE_SKILL}}` loader's defaults in `scripts/resolvers/composition.ts`) skip `Shared preamble (load first)`, so a loaded plan-review or /office-hours skill no longer Reads its own shared file and re-imports the sections the parent already handles. `Completeness Principle — Boil the Ocean` and `Completion Status Protocol` leave both lists, because no loaded skill carries them inline any more, only in the shared file. Each list is 40 characters shorter, so 30 size pins go down (plan-eng-review's by 26, after main's +14 pointer growth). +- **PR paid planner** (`scripts/lib/paid-select.ts`): a generated `<skill>/sections/preamble.md` takes its skill's `SKILL.md.tmpl` identity, as that skill's SKILL.md already does, so a skill whose only paid coverage is periodic defers it instead of failing `screen-summary` with "requires full validation". + +### Tests +- `test/helpers/docsync-fixture.ts`: `fixtureDocs` copies the generated `sections/preamble.md` into the document-release and ship fixtures when the skill has one, because the hoisted SKILL.md names it as a required asset and a spawned docs audit blocks without it (hunk taken from #3472). #3472's companion test in `test/ship-document-release-dispatch.test.ts` removes ship's generated `sections/preamble.md` from its copied tree before checking that a skill without one gets none, because under the hoist every generated tier 2+ skill has one. +- `test/helpers/skill-startup.ts` (new): `readSkillStartup` (SKILL.md with the shared file spliced in at its pointer, the reading order a skill sees) and `readSkillEffective` (each preamble section once, with this skill's variant applied). Tests that assert preamble rules are in context, E2E fixture extractors, workflow-judge bundles and install checks read these instead of SKILL.md alone; judge bundles keep their pre-hoist content. +- `test/helpers/carve-guards.ts`: parity caps for ship, review, autoplan, spec, land-and-deploy, design-consultation, plan-devex-review, plan-ceo-review, office-hours and plan-eng-review raised to their measured unions (owner-approved, Garry 2026-10-25: "do it"), each with its reason. The union now includes the shared file; plan-ceo-review's cap (1.1006) also covers the 106-byte COMPLETENESS IS COVERAGE bullet from the completeness rewording below. office-hours and plan-eng-review count their own versions of shared sections beside the shared file (office-hours' copies are deduped in the entry above). +- `test/fixtures/skill-md-size-allowlist.json`: 63 pins lowered and 26 deleted (now within the 100,000-character limit). None raised. The deferral note (owner Garry, revisit 2026-11-15) is retired here and in `test/skill-md-size-guard.test.ts`. +- `test/section-manifest-consistency.test.ts`: the generated-orphan check exempts the shared `sections/preamble.md` only when the skill's SKILL.md carries the "Shared preamble (load first)" pointer, because that file renders from `scripts/resolvers/preamble-hoist.ts` rather than a section template; it still must carry the AUTO-GENERATED header, and the generation freshness check covers its content. `test/paid-pr-profile.test.ts` covers the planner change. +- `test/preamble-hoist.test.ts` (new): tier 1 output is unchanged, and a tier 2+ SKILL.md with its `sections/preamble.md` applied holds exactly the pre-hoist preamble; the pointer opens the preamble, precedes the start command and names the shared file's id. `test/gen-skill-docs-checks.test.ts` checks that each host renders one shared preamble, beside every pointer and under the id the pointer names. `test/host-conformance.test.ts` checks that `setup --status` still finds a broken bare `sections/` pointer when a carved Claude skill's installed `sections/` is a directory symlink. A second `test/preamble-hoist.test.ts` case checks, on every host, tier and render option, that the pointer allows the skip only when the skill's own Read of the file is in context, never on the id alone. +- Ship goldens regenerated. 27 paid-test touchfile keys gain `test/helpers/skill-startup.ts`, which is also a global touchfile pinned in `scripts/harness-version.json` (recorded non-behavioral) because `test/helpers/skill-fixture.ts` builds its fixtures through it, and every key that names `scripts/resolvers/preamble.ts` also names `scripts/resolvers/preamble-hoist.ts`, so a hoist change selects the same paid cases. The existing TS2769 test-typecheck baseline entry for `test/gen-skill-docs-checks.test.ts` is re-keyed for the new `preamble` artifact kind, its count unchanged. +- `test/skill-llm-eval.test.ts`: the `voice directive tone` judge reads review's Voice section through `readSkillEffective` (SKILL.md plus its shared preamble), because the section now lives in `review/sections/preamble.md`. The text it judges is byte-identical to main's; rubric and thresholds are unchanged. +- `test/helpers/touchfiles-data.ts`: `ETHOS.md` is registered on the 41 paid cases whose touchfiles cover `plan-ceo-review` or `office-hours`, the two skills that tell the agent to read it, so an ETHOS.md change selects those cases instead of restoring the full gate. The unknown-dependency examples in `test/paid-pr-profile.test.ts`, `test/paid-free-boundary.test.ts` and `test/pr-dependencies.test.ts` now use `SECURITY.md` and `LICENSE`, and the first checks that ETHOS.md maps to its reader cases. + +### Gate integrity +- Raised: the ten parity caps above, by owner decision. No other threshold, budget, timeout, trial count, case kind or assertion weakened. The size floor, the growth ceiling and the total-corpus budget in `test/skill-size-budget.test.ts` now measure SKILL.md plus its shared file, which is what a skill loads at startup (corpus 3,114,885 → 2,590,484 bytes, ×0.832). `test/auq-format-always-loaded.test.ts`'s carve-safety guard counts a skill as carved only when `sections/` holds a file other than the shared `preamble.md`, the rule `setup --status` uses; the skills that leaves out keep the same AskUserQuestion checks in its per-skill loop. +- Lowered: the hoist shrank every tier 2+ SKILL.md by about 22 KB, so the 20 hoisted `maxSkeletonBytes` caps in `test/helpers/carve-guards.ts` drop to the new skeleton plus each one's pre-hoist headroom (ship 81,600 → 59,771), and 42 `eagerPerInvocation` ceilings in `test/fixtures/context-budget.json` ratchet down to the recaptured figures, and after main's v1.91.255.0 merge the new `captain` skill's ceiling goes from 12,011 to 6,551 (hoisted) and five loaded-skill ceilings drop by 2 to 22. No ceiling went up; the four the recapture would have raised keep their old values. +- Raised after main's v1.91.260.0 merge (#3433 GSTA-92 phase 2 and #3481 review convergence on top of the hoist): each value is the hoisted figure plus the measured growth those PRs added, with no template trim (owner-approved under the parity-caps rule, GSTA-92 Garry "Do it" and GSTA-96 Garry "do it"). Parity caps: ship 1.7172 (322,312 / 187,706), review 1.3136 (142,550 / 108,523), design-review 1.3758 (145,613 / 105,842). Skeleton caps lowered to the hoisted size: ship 82,700 → 60,913 (measured 60,848), design-review 137,767 → 115,947. `eagerPerInvocation` ceilings: ship 15,601 → 15,899, review 15,352 → 15,417, design-review 30,143 → 30,295, cso 4,660 → 4,902, gstack 5,326 → 5,346, capy/skill/gstack 3,498 → 3,899, alwaysOnTotal 7,458 → 7,546, and the new hoisted `ubicloud` row at 3,594. All stay below main's pre-hoist values. The size allowlist keeps the hoisted pins plus the 30 raises #3433 and #3481 made. +- After main's v1.91.265.0 merge (#3430 /design-motion, #3493 ETHOS principles): `context-budget.json` keeps main's new `design-motion` row at main's value (10,409), raises `eval-plan` by the growth #3493 measured (3,996 → 4,442 tokens; ceiling 4,396 → 4,842, main's own 9,811), and lowers `gstack` back to main's 5,326 (measured 4,863) and `alwaysOnTotal` to 7,542 (measured 7,542). +- After main's v1.91.270.0 merge (#3494, #3496, #3487): ship's parity cap goes from main's 1.7400 to 1.7432 (327,203 / 187,706), the hoist's raise plus #3487's with no trim. `context-budget.json` is recaptured whole with `bun test/helpers/capture-context-budget.ts`, which replaces the hand-kept rows in the bullet above. Every row that rises matches main's own recapture at v1.91.270.0 (`alwaysOnTotal` 7,769, `eval-plan` 4,887, `gstack` 5,350), and `design-motion` drops to 4,724 now that it loads the shared file. +- After main's v1.91.273.0 merge (#3509, #3500, #3501): every cap the hoist touches is re-measured on the merged tree. Parity caps: ship 1.7500 → 1.7524 (328,918 / 187,706) and plan-eng-review 1.2400 → 1.3532 (168,601 / 124,597), main's raises plus the hoist's with no trim. Skeleton caps for skills main carved or raised drop to the hoisted skeleton plus main's headroom: investigate 52,000 → 30,210, context-restore 46,300 → 24,514, design-motion 39,900 → 18,112. Every other cap holds at its measurement. The size allowlist keeps the same 63 pins re-measured (11 plan-eng-review pins rise by #3500's 2,896 characters), and `context-budget.json` is recaptured: the non-hoisted rows that rise (`benchmark` 8,043, `capy/skill/gstack` 4,120) equal main's own recapture, and the hoisted `context-restore` (6,287) and `investigate` (7,795) sit below it. +- After main's v1.91.274.0 merge (#3507): design-review's parity cap goes from main's 1.3681 to 1.3739 (145,410 / 105,842, the union with the shared file), and its skeleton cap drops from 137,735 to the hoisted 115,947 with main's zero headroom kept. design-html's skeleton cap drops from 56,200 to 34,410 (hoisted 34,044 plus main's 366 bytes of headroom), and its `context-budget.json` row from 8,926 to 8,896. Every other cap and pin holds at its measurement. + +**The Completeness Principle now asks for complete coverage with minimal code.** Every tier-2+ skill's preamble said "the complete thing is the goal", and ETHOS.md told models to "always prefer A" because "the 70-line delta costs seconds". Models read that as permission to write more code, which is the "50-line job arriving as 400" complaint (GSTA-93). Coverage of the requirement is still non-negotiable; code beyond it is no longer a virtue. + +### Changed +- **`scripts/resolvers/preamble/generate-completeness-section.ts`**: the default section reads "Complete coverage, minimal code": recommend full coverage of the requirement (tests, edge cases, error paths) and no code beyond it, prefer the smaller diff at equal coverage, and add no checks for impossible states. Unrelated work stays separate scope. The `Completeness: X/10` scoring rule and the GPT-5.6 Sol variant are unchanged. The section is 3 characters shorter, so every regenerated skill loses 3 characters from it and the 89 size pins go down by 3 (plan-ceo-review still grows overall, below). +- **`ETHOS.md`**: "Completeness is cheap ... always prefer A" becomes "Complete coverage, minimal code": completeness is measured in coverage, not lines; at equal coverage choose the smaller diff; no speculative abstractions, impossible-state checks or unneeded refactors. A new anti-pattern names the impossible-state fallback. Section 1's opener now says "When covering the whole requirement costs minutes more than the shortcut, cover it. Every time. Complete means complete coverage, not more code.", and "How They Work Together" says Boil the Ocean means "cover the whole requirement with the least code" instead of "do the complete thing". +- **`plan-ceo-review/SKILL.md.tmpl`**: the Philosophy bullet "COMPLETENESS IS CHEAP: AI makes 70 LOC seconds. Prefer complete ~150 LOC over 90% ~80 LOC. Boil the ocean." becomes "COMPLETENESS IS COVERAGE": cover the whole requirement (tests, edge cases, error paths) rather than a 90% shortcut, add no code beyond the accepted scope (so SCOPE EXPANSION and SELECTIVE EXPANSION still build what the user accepted), and choose the smaller diff at equal coverage. plan-ceo-review is under the 100,000-character limit on every host and has no size pin, so it grows about 100 characters without a pin change. +- **First-run intro**: the one-time Boil the Ocean intro in `bin/gstack-skill-start` says "complete coverage, minimal code" instead of "do the complete thing when AI makes marginal cost near-zero". The agents digest's Boil the Ocean bullet (`scripts/gen-agents-digest.ts`, regenerated `agents-digest/gstack-AGENTS.md`) says the same: "complete coverage, minimal code: cover the whole requirement (tests, edge cases, error paths) and write no code beyond it". +- `review/specialists/simplification.md` quotes the new house rule instead of "If A is 70 lines more, choose A", and `CLAUDE.md` restates it in place of "Completeness is cheap ... the complete thing is the goal". + +### Tests +- `test/model-overlays.test.ts` asserts the new default wording ("Complete coverage, minimal code.", "prefer the smaller diff") and checks, one sentence per rule, that the section still states full coverage of the requirement with no code beyond it, the smaller diff only at equal coverage, no checks for impossible states, and that unrelated work is separate scope that never excuses a gap. +- `test/ceo-workflow-clarity.test.ts` checks that the plan-ceo-review Philosophy bullet still asks for the whole requirement over a 90% shortcut, no code beyond the accepted scope and the smaller diff at equal coverage, and that "COMPLETENESS IS CHEAP" and "~150 LOC" stay gone. +- `test/gen-skill-docs.test.ts` checks that the house rule `review/specialists/simplification.md` quotes is still stated in ETHOS.md. `test/gstack-skill-start.test.ts` checks that the first-run intro says complete coverage, minimal code and never "do the complete thing". +- `test/fixtures/skill-md-size-allowlist.json`: 89 pins lowered by 3 characters each. None raised; the plan-ceo-review rewording touches no pinned skill. + +### Gate integrity +- No threshold, budget, timeout, trial count, case kind or assertion weakened. The section heading "Completeness Principle — Boil the Ocean" is unchanged. + +## [1.91.274.0] - 2026-10-27 + +**/design-html and /design-review now run the design lint, and its copy tier reports every content and ship-floor tell at its line (hallmark phase 2, GSTA-92; Garry 2026-10-26 "Do it", option a).** The two design skills still called `gstack-design-craft`, which counted tells without positions and kept accepted exceptions in finalized.json. The lint already carried the same ids, so the skills now call it, the copy tier has its own patterns with line numbers, and a page answers a tell with a waiver comment beside it instead of a side record. + +### Changed +- **/design-html** Step 4 runs `gstack-design-lint <page> --render` after the Slop Gate and reads its exit code: 2 fixes every `blocks` line, 1 fixes or waives each `ask` line, 0 lists `polish` as notes. A missing `LINT_SUMMARY`, a `render` line or `render=skipped` is said plainly and the static lines stand. +- **Waivers live in the page.** A real customer named Nexus or a supplied 50% figure gets `<!-- gstack-lint-disable <rule-id>: <reason about this page, 24+ chars> -->`, written only for what the user supplied or confirmed. This supersedes #3422's persisted craft exceptions: `--accepted` and finalized.json's `craft_exceptions` are gone, and the hand-off lists every waiver. +- **Copy-tier tiers**: `placeholder-name`, `placeholder-brand`, `lorem`, `round-number` and `needs-proof` move from blocks to ask, so the waiver above can answer them. /design-review still files a waived `needs-proof` or `lorem`, which a live page cannot carry. +- **/design-review's craft audit** runs the lint on each saved page: `blocks` and `ask` lines are category 9 findings at their line, `waived` lines are listed with their reason, `empty-mount-node` sends the audit to the saved DOM. A saved live page has lost its linked CSS, so the audit files only its copy-tier (`craft`) lines there and judges the CSS rules by eye. +- **`lib/design-lint/rules/copy.ts`** holds the copy tier's own patterns (moved from `lib/design-craft.ts`), reporting each tell at its line with what matched. It reads visible text, script bodies and inline handlers (single-quoted included) and the parsed tags, so a hydration payload, commented-out markup or a `<title>` string inside a script no longer counts. + +### Added +- **`empty-mount-node`** (ask, page only): a body with no text, a `<noscript>` notice aside, that loads a script is a client-rendered shell; the lint says it read none of its content instead of passing it. + +### Removed +- `bin/gstack-design-craft`, `lib/design-craft.ts` and `test/design-craft.test.ts`. Their checks are in the lint's copy tier and its tests; the archetype handoff tests moved to `test/design-craft-resolvers.test.ts`. + +### Tests +- `test/design-lint.test.ts`: the copy tier at its lines with the catalog's fixes, the clean page with inlined Pretext, the skip link and `aria-current`, visible-text reading, the four checker limits #3433 documented, and the shell check (noscript, image-only page, component scope). `test/design-lint-recorded.test.ts` reads the recorded page's buzzwords at lines 32 and 37. +- The `design-html-slop-gate` paid case looks for a `gstack-design-lint` call, and its touchfiles select on `lib/design-lint/**` and `bin/gstack-design-lint`. + diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 694ba58c0..8cdfaeaf4 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -150,6 +150,7 @@ export const FREE_FIXTURES: Readonly<Record<string, readonly string[]>> = { 'test/fixtures/judge-panel-replay-2026-10-05.json': ['test/judge-panel-median.test.ts'], 'test/fixtures/launch-env-echo/**': ['test/launch-env-echo-ratchet.test.ts'], 'test/fixtures/merge-train-3345/**': ['test/changelog-merge-damage.test.ts', 'test/compare-pr-version.test.ts'], + 'test/fixtures/merge-train-3453/**': ['test/changelog-merge-damage.test.ts'], 'test/fixtures/module-size-ratchet.json': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], 'test/fixtures/module-size/**': ['test/helpers/module-size.ts', 'test/module-size-ratchet.test.ts'], 'test/fixtures/multi-agent-wave/**': ['test/headless-artifacts.test.ts', 'test/helpers/autoplan-run-fixture.ts', 'test/plan-reality.test.ts'], diff --git a/test/helpers/ship-queue-train-fixture.ts b/test/helpers/ship-queue-train-fixture.ts index 04d85ef65..7a7ffd589 100644 --- a/test/helpers/ship-queue-train-fixture.ts +++ b/test/helpers/ship-queue-train-fixture.ts @@ -47,6 +47,8 @@ export interface ShimData { ci: Record<string, unknown[]>; rules: Record<string, Rule>; artifact: string; log: Array<Record<string, unknown>>; /** Commit statuses (the legacy status API) at a sha, beside the check runs in `ci`. */ statuses?: Record<string, Array<{ state: string; context: string }>>; + /** Per PR, how many more `gh pr view` calls answer before the PR reads as not found (a GitHub answer that changes between two reads). */ + vanish?: Record<string, number>; } export interface TrainFixture { repo: FixtureRepo; state: string; dataFile: string; env: Record<string, string>; read: () => ShimData; write: (fn: (d: ShimData) => void) => void } @@ -156,7 +158,7 @@ function shimSource(): string { "const rev = r => { const x = cp.spawnSync('git', ['--git-dir', d.origin, 'rev-parse', '--verify', '--quiet', r], { encoding: 'utf8', timeout: 30000 }); return x.status === 0 ? x.stdout.trim() : ''; };", "d.log.push({ cmd: a.slice(0, 2).join(' '), args: a });", "if (a[0] === 'repo' && a[1] === 'view') out({ nameWithOwner: d.repo });", - "if (a[0] === 'pr' && a[1] === 'view') { const p = d.prs[a[2]]; if (!p) fail('no pull request found'); out(p); }", + "if (a[0] === 'pr' && a[1] === 'view') { const p = d.prs[a[2]]; const left = d.vanish && d.vanish[a[2]]; if (left !== undefined) { if (left <= 0) fail('no pull request found'); d.vanish[a[2]] = left - 1; } if (!p) fail('no pull request found'); out(p); }", "if (a[0] === 'workflow' && a[1] === 'run') {", " const w = a[2]; const ref = opt('--ref'); const inputs = {};", " a.forEach((x, i) => { if (x === '-f') { const kv = a[i + 1]; const k = kv.slice(0, kv.indexOf('=')); inputs[k] = kv.slice(kv.indexOf('=') + 1); } });", diff --git a/test/restamp-base-merge.test.ts b/test/restamp-base-merge.test.ts new file mode 100644 index 000000000..d8d6944d7 --- /dev/null +++ b/test/restamp-base-merge.test.ts @@ -0,0 +1,116 @@ +/** + * Train 24 (2026-10-27, gate.train-6d8c948d5409): in both slots `gstack-restamp + * --next` listed lib/ship-policy.ts as a fifth stamp file and moved main's own + * `DOCS_CAPS_KEY_VERSION = '1.91.270.0'` (#3511, released at 1.91.275.0) to the + * slot's version. The pin (234227fc4) had forked from 1.91.274.0 and the lane had + * merged base f8d0ab03 into it uncommitted, so HEAD still forked before #3511 and + * the stamp_paths stage, which counts the branch's own lines from + * merge-base(base, HEAD), read the base's key line as the branch's tentative + * mention. The stage now leaves out every line the base added since that fork + * point. #3514's receipt bound (`stampOnlyLine`: a moved version not older than + * the gated tree's VERSION) refuses that move after the fact only while the key + * names a release older than the base at the build; a key at the base version + * passes it, so the restamp itself must not make the move. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { mergeChangelogs } from '../lib/changelog-check'; +import { branchAddedLines, stampOnlyLine } from '../lib/restamp'; +import { gitArgvIn } from './helpers/scratch-repo'; +import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const TEMPLATES = ["_KEY_VERSION = '{version}'"]; +const POLICY = { ...BASE_POLICY, release_tool: null, release_outputs: [], stamp_paths: ['docs/**/*.md', 'src/**/*.ts'], stamp_patterns: TEMPLATES }; +const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out); +const key = (name: string, v: string) => `export const ${name}_KEY_VERSION = '${v}';\n`; + +function write(repo: FixtureRepo, rel: string, text: string): void { + fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); + fs.writeFileSync(path.join(repo.work, rel), text); +} + +/** A release landed on main by hand (what a slot stamp leaves behind): VERSION, the manifest, a CHANGELOG heading, plus any files. */ +function releaseOnMain(repo: FixtureRepo, v: string, files: Record<string, string> = {}): string { + repo.git('checkout', '-q', 'main'); + write(repo, 'VERSION', v + '\n'); + const pkg = JSON.parse(readFile(repo, 'package.json')); + pkg.version = v.split('.').slice(0, 3).join('.'); + write(repo, 'package.json', JSON.stringify(pkg, null, 2) + '\n'); + write(repo, 'CHANGELOG.md', readFile(repo, 'CHANGELOG.md').replace('# Changelog\n', `# Changelog\n\n## [${v}] - 2026-10-27\n\n- release ${v}\n`)); + for (const [rel, text] of Object.entries(files)) write(repo, rel, text); + repo.git('add', '-A'); + repo.git('commit', '-qm', `v${v}`); + repo.git('push', '-q', 'origin', 'main'); + return repo.git('rev-parse', 'HEAD'); +} + +/** The lane's slot state: origin/main merged into the pin and left uncommitted, the CHANGELOG resolved with mergeChangelogs. */ +function mergeMainUncommitted(repo: FixtureRepo): void { + const r = gitArgvIn(repo.work, ['merge', '--no-ff', '--no-commit', 'origin/main'], 30_000, { ...process.env, ...repo.env }); + const conflicts = repo.git('diff', '--name-only', '--diff-filter=U').split('\n').filter(Boolean); + expect([r.status === 0 ? [] : conflicts]).toEqual([r.status === 0 ? [] : ['CHANGELOG.md']]); + if (conflicts.length) { + const merged = mergeChangelogs(repo.git('show', ':2:CHANGELOG.md') + '\n', repo.git('show', ':3:CHANGELOG.md') + '\n', repo.git('show', ':1:CHANGELOG.md') + '\n'); + expect(merged).not.toBeNull(); + write(repo, 'CHANGELOG.md', merged!); + repo.git('add', 'CHANGELOG.md'); + } + expect(fs.existsSync(path.join(repo.work, '.git', 'MERGE_HEAD'))).toBe(true); +} + +describe('stamp_paths: a key the base introduced after the fork stays, base merge committed or not (train 24)', () => { + // fails_when=the stamp_paths stage moves a *_KEY_VERSION line that came in with the uncommitted base merge, or lists its file; why_new=every earlier fixture committed the base merge or never merged it, and both read the branch's own lines correctly; seam=none (real git, real restamp); no_claim=what the receipt says about a stamp that was already committed + test.each([ + ['a key naming an older release (train 24: 1.91.270.0 under base 1.91.275.0), which #3514\'s receipt bound also refuses after the fact', '1.2.4.0', false], + ['a key at the base version itself, which the receipt bound alone would accept as a stamp', '1.2.5.0', true], + ])('%s', (_, keyVersion, receiptAccepts) => { + const repo = makeFixtureRepo({ prefix: 'restamp-train24-', policy: POLICY }); repos.push(repo); + // The pin: forked at 1.2.3.0, with its own tentative key (newer than its fork) that the stamp must move. + makePr(repo, 'pr-mine'); + write(repo, 'src/mine.ts', key('MINE', '1.2.9.0')); + repo.git('add', '-A'); repo.git('commit', '-qm', 'my key'); + // Main moves on: 1.2.4.0, then 1.2.5.0 carrying the base's own key (#3511's shape). + releaseOnMain(repo, '1.2.4.0'); + releaseOnMain(repo, '1.2.5.0', { 'src/policy.ts': key('DOCS_CAPS', keyVersion) }); + repo.git('checkout', '-q', 'pr-mine'); + mergeMainUncommitted(repo); + expect(readFile(repo, 'src/policy.ts')).toBe(key('DOCS_CAPS', keyVersion)); + + const dry = restamp(repo, ['--next', '--dry-run']); + expect([dry.status, dry.err]).toEqual([EXIT.ok, '']); + expect(dry.out).toContain('RESTAMP: 1.2.5.0 -> 1.2.6.0 (dry-run;'); + expect(dry.out).toMatch(/^FILES: CHANGELOG.md VERSION package-lock.json package.json src\/mine.ts$/m); + expect(dry.out).not.toMatch(/^[-+].*DOCS_CAPS_KEY_VERSION/m); + expect(dry.out).toMatch(/^-export const MINE_KEY_VERSION = '1\.2\.9\.0';$/m); + expect(dry.out).toMatch(/^\+export const MINE_KEY_VERSION = '1\.2\.6\.0';$/m); + const sp = stageDetail(dry.out, 'stamp_paths')!; + expect(sp[1]).toBe('done'); + expect(sp[2]).toContain('1 mention(s) in 1 file(s) (1.2.9.0 -> 1.2.6.0)'); + expect(sp[2]).toContain("1 line(s) the base added since the fork point are the base's, not this branch's, and stay (src/policy.ts: 1)"); + + // The same merge committed (the documented slot sequence) reads the same way. + repo.git('commit', '-qm', 'merge main'); + const committed = restamp(repo, ['--next', '--dry-run']); + expect([committed.status, committed.err]).toEqual([EXIT.ok, '']); + expect(committed.out).toMatch(/^FILES: CHANGELOG.md VERSION package-lock.json package.json src\/mine.ts$/m); + expect(committed.out).toMatch(/^\+export const MINE_KEY_VERSION = '1\.2\.6\.0';$/m); + expect(stageDetail(committed.out, 'stamp_paths')![2]).not.toContain('the base added since the fork point'); + + // #3514's claim, checked against this shape: the receipt (gated tree at the base version 1.2.5.0) refuses the + // moved older key, but a key at the base version passes its bound, so only the restamp fix keeps it in place. + const moved = stampOnlyLine(key('DOCS_CAPS', keyVersion).trim(), key('DOCS_CAPS', '1.2.6.0').trim(), '1.2.5.0', '1.2.6.0', TEMPLATES); + expect(moved).toBe(receiptAccepts); + }, 120_000); + + // Value: protects=the base-added read fails closed; fails_when=a diff git cannot run reads as "nothing added" and the base's key line moves again (red-team review of GSTA-129); why_new=the test above only runs the stage with a readable base; seam=none; no_claim=which git errors occur in practice + test('a base diff git cannot run is a refusal, never an empty "nothing added" set', () => { + const repo = makeFixtureRepo({ prefix: 'restamp-train24-', policy: POLICY }); repos.push(repo); + expect(branchAddedLines(repo.work, 'HEAD', ['VERSION'], 'HEAD').size).toBe(0); + expect(() => branchAddedLines(repo.work, 'HEAD', ['VERSION'], 'f'.repeat(40))).toThrow(/^cannot diff HEAD\.\.ffffffffffff for the stamp_paths stage \(.+\); nothing written$/); + }); +}); diff --git a/test/ship-queue-train.test.ts b/test/ship-queue-train.test.ts index 295c280e0..7a2a8538f 100644 --- a/test/ship-queue-train.test.ts +++ b/test/ship-queue-train.test.ts @@ -15,6 +15,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { mergeChangelogs, parseChangelog } from '../lib/changelog-check'; import { EXIT } from '../lib/headless-artifacts'; import { baseOverlap } from '../lib/ship-queue-train'; import { TRAIN_HELP } from '../lib/ship-queue-train-cli'; @@ -403,6 +404,33 @@ describe('train build', () => { expect(dispatches(fx)).toEqual([]); }, 120_000); + // Value: protects=a stacked child in a slot after the first is gated on the carried CHANGELOG, its released parent's text never duplicated under a second [Unreleased]; fails_when=mergeStep unions the uncarried child when the prefix already holds an earlier slot's [Unreleased] (the two-[Unreleased] guard returns null, so the carry is lost); why_new=the train 24 test below releases the parent after the train is built and the slot-1 chain never reaches the union; seam=none; no_claim=how the post-merge verify reads a two-[Unreleased] prefix + test("a stacked child behind another slot: the gated tree carries the child past its released parent instead of repeating the parent's text under its own [Unreleased]", () => { + const fx = make(); + const { repo } = fx; + const h1 = openPr(fx, '1', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n**Parent entry of #1.**\n\n### Changed\n- #1 change, the first wording\n- #1 second bullet, unchanged\n') }); + const h2 = openPr(fx, '2', { from: 'pr-1', changelog: cl => cl.replace('## [Unreleased]\n\n**Parent entry of #1.**', '## [Unreleased]\n\n**Child entry of #2.**\n\n### Changed\n- #2 change\n\n**Parent entry of #1.**').replace('- #1 change, the first wording', '- #1 change, the better wording') }); + // #1 is released as 1.2.4.0 before the train; #3 forks from that main and takes the first slot, #2 the second. + const main1 = publishMain(fx, h1, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${h1}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' }); + const h3 = openPr(fx, '3'); + readyReceipt(fx, '3', h3); + readyReceipt(fx, '2', h2); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const r = queue(fx, ['train', 'build', '--prs', `3@${h3},2@${h2}`, '--order', '3,2']); + expect([r.status, r.err]).toEqual([EXIT.ok, '']); + expect(line(r.out, 'ORDER:')).toBe('ORDER: 1:#3(unscreened) 2:#2(unscreened) (explicit --order)'); + const t = trainJson(fx, nonceOf(r.out)); + const gated = parseChangelog(repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`) + '\n'); + expect(gated.sections.map(s => s.version ?? 'Unreleased')).toEqual(['Unreleased', 'Unreleased', '1.2.4.0', '1.2.3.0', '1.2.2.0']); + const own = gated.sections[0]!.lines.join('\n'); + expect(own).toContain('**Child entry of #2.**'); + expect(own).toContain('- Correction to the 1.2.4.0 entry below: "first" should read "better".'); + expect(own).not.toContain('Parent entry of #1'); + expect(gated.sections[1]!.lines.join('\n')).toContain('- #3 change'); + const released = parseChangelog(repo.git('show', `${main1}:CHANGELOG.md`) + '\n').sections[0]!; + expect(gated.sections[2]!.lines.join('\n').replace(/\n+$/, '')).toBe(released.lines.join('\n').replace(/\n+$/, '')); + }, 120_000); + test("GSTA-67: READY receipts under any URL form of the repository admit (#3331's receipt was keyed on git.capy.ai, the coordinator's origin on github.com)", () => { const fx = make(); githubOrigin(fx, 'https://github.com/fixture/repo.git'); @@ -920,4 +948,162 @@ describe('verify, freeze, repair and clear', () => { expect(noMain.status).toBe(EXIT.fail); expect(noMain.err).toContain(`(${orphan.slice(0, 12)}: does not contain main ${main1.slice(0, 12)}; against the pin it differs: `); }, 300_000); + // fails_when=the post-merge verify of a merged-in head the pre-merge verify accepted freezes the queue because T<i>'s single [Unreleased] became two released sections, or the pre-merge verify refuses the correction bullet mergeChangelogs itself wrote; why_new=the GSTA-67 test above never publishes the accepted head and never stacks a child on a released parent; seam=none (real git, the fixture's gh); no_claim=what the squash commit's message says + test("train 24 (GSTA-96): a stacked child's head the pre-merge verify accepted confirms after its squash merge under the same CHANGELOG rule, corrections to the released parent included", () => { + const fx = make(); + const { repo } = fx; + const h1 = openPr(fx, '1', { changelog: cl => cl.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n**Parent entry of #1.**\n\n### Changed\n- #1 change, the first wording\n- #1 second bullet, unchanged\n') }); + const h2 = openPr(fx, '2', { from: 'pr-1', changelog: cl => cl.replace('## [Unreleased]\n\n**Parent entry of #1.**', '## [Unreleased]\n\n**Child entry of #2.**\n\n### Changed\n- #2 change\n\n**Parent entry of #1.**').replace('- #1 change, the first wording', '- #1 change, the better wording') }); + readyReceipt(fx, '1', h1); + readyReceipt(fx, '2', h2); + fx.write(d => { d.rules['evals.yml'] = GREEN_GATE; }); + const nonce = nonceOf(queue(fx, ['train', 'build', '--prs', `1@${h1},2@${h2}`]).out); + expect(queue(fx, ['train', 'verdict', '--train', nonce]).status).toBe(EXIT.ok); + const t = trainJson(fx, nonce); + expect(parseChangelog(repo.git('show', `${t.prefixes[2].commit}:CHANGELOG.md`)).sections.map(s => s.version ?? 'Unreleased')).toEqual(['Unreleased', '1.2.3.0', '1.2.2.0']); + const coord = coordinatorClone(fx.repo); + const v = (pr: string) => queue(fx, ['verify', '--pr', pr, '--train', nonce], {}, coord); + + // Slot 1: #1 squash-merges and is released as 1.2.4.0 (its [Unreleased] re-headed). + expect(line(v('1').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h1}`); + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n' }); + expect(v('1').out).toContain(`VERIFY_TRAIN: #1 merged; main's tree is T1 of train ${nonce} (1/2 merged)`); + + // Slot 2: the lane merges main into its pin; mergeChangelogs carries the child past its released parent. + const merged = mergeChangelogs(repo.git('show', `${h2}:CHANGELOG.md`) + '\n', repo.git('show', `${main1}:CHANGELOG.md`) + '\n', repo.git('show', `${h1}:CHANGELOG.md`) + '\n')!; + expect(merged).toContain('- Correction to the 1.2.4.0 entry below: "first" should read "better".'); + 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'); + fs.writeFileSync(path.join(repo.work, 'CHANGELOG.md'), merged.replace('## [Unreleased]', '## [1.2.5.0] - 2026-10-18')); + repo.git('add', '-A'); + const laneHead = repo.git('commit-tree', repo.git('write-tree'), '-p', h2, '-p', main1, '-m', 'merge main into #2, stamp 1.2.5.0'); + repo.git('push', '-q', '-f', 'origin', `${laneHead}:refs/pull/2/head`); + repo.git('reset', '-q', '--hard'); + const pre = v('2'); + expect([pre.status, pre.err]).toEqual([EXIT.ok, '']); + expect(pre.out).toContain(`head: ${laneHead} (T2 with main merged in, same modulo stamps (1 stamp file(s)) except CHANGELOG.md, resolved as main's CHANGELOG.md plus #2's own entry)`); + expect(line(pre.out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${laneHead}`); + + // The squash merge publishes exactly that tree; the post-merge verify confirms it instead of freezing the queue. + const main2 = publishMain(fx, laneHead, '1.2.5.0'); + expect(repo.git('rev-parse', `${main2}^{tree}`)).toBe(repo.git('rev-parse', `${laneHead}^{tree}`)); + const post = v('2'); + expect([post.status, post.err]).toEqual([EXIT.ok, '']); + expect(line(post.out, 'main:')).toBe(`main: ${main2} = T2 (same modulo stamps (1 stamp file(s)) except CHANGELOG.md, main ${main1.slice(0, 12)}'s CHANGELOG.md plus #2's own entry)`); + expect(post.out).toContain(`VERIFY_TRAIN: #2 merged; main's tree is T2 of train ${nonce} (2/2 merged)`); + expect(queue(fx, ['train', 'status']).out).not.toContain('QUEUE_TRAIN_FROZEN'); + }, 300_000); + + // fails_when=a freeze lists a PR GitHub reports MERGED as unmerged (train 24's freeze named #3453), or a post-merge CHANGELOG with a line the pin never wrote confirms; why_new=the freeze test above merges nothing of the slot it freezes on; seam=the fixture's gh pr view state; no_claim=the backstop's outcome + test('a post-merge CHANGELOG beyond the rule still freezes, and the freeze lists the slot GitHub reports MERGED as merged, not unmerged', () => { + 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 released = (prefix: string, version: string) => fx.repo.git('show', `${prefix}:CHANGELOG.md`).replace('## [Unreleased]', `## [${version}] - 2026-10-18`) + '\n'; + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released(t.prefixes[1].commit, '1.2.4.0') }); + expect(v('1').status).toBe(EXIT.ok); + expect(line(v('2').out, 'MERGE_CONDITION:')).toBe(`MERGE_CONDITION: sha=${h2}`); + // Main's CHANGELOG plus #2's stamped entry, with one line the pin never wrote. + const smuggled = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; + const broken = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text: smuggled }); + fx.write(d => { (d.prs['2'] as any).state = 'MERGED'; }); + const frozen = v('2'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T1 nor T2 modulo stamps (vs T2: differs: CHANGELOG.md, which is not main ${main1.slice(0, 12)}'s plus #2's own entry: the PR's entry holds a line its pin never added: "- added after the gate")`); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1,#2; main /); + expect(line(frozen.out, 'freeze:')).toMatch(/; #2 merged on GitHub without a verified tree; unmerged \(none\); repair none admitted$/); + const freezeFile = path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`); + expect(JSON.parse(fs.readFileSync(freezeFile, 'utf8'))).toMatchObject({ merged: ['1', '2'], unmerged: [] }); + }, 300_000); + + // Value: protects=a first slot GitHub reports MERGED whose squash fails the post-merge check freezes the queue, and a GitHub state that cannot be read refuses instead of reading as not merged; fails_when=stopped() reads an empty merged list, or an unreadable gh answer, as "nothing merged" while main already holds the unverified squash (review of GSTA-129, passes 1 and 2); why_new=the test above freezes on slot 2 with slot 1 verified; seam=the fixture's gh pr view state; no_claim=the backstop's outcome + test("slot 1 merged on GitHub but not confirmed: an unreadable GitHub state refuses, and a MERGED one freezes with #1 merged and #2 unmerged instead of saying nothing merged", () => { + 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 smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; + const broken = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); + // While GitHub's answer for #1 cannot be read, the verify neither freezes nor says "nothing merged": it refuses and asks to be rerun. + const row = fx.read().prs['1'] as any; + fx.write(d => { delete d.prs['1']; }); + const unreadable = v('1'); + expect(unreadable.status).toBe(EXIT.refused); + expect(unreadable.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`); + expect(unreadable.err).toMatch(/; whether #1 merged on GitHub could not be read, so whether the queue must freeze is unknown; .*\(QUEUE_PROOF_MISSING\)/); + expect(unreadable.out).toContain(`NEXT: gstack-ship-queue verify --pr 1 --train ${nonce} (once gh pr view answers)`); + expect(fs.existsSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`))).toBe(false); + fx.write(d => { d.prs['1'] = { ...row, state: 'MERGED' }; }); + const frozen = v('1'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${broken.slice(0, 12)} is neither T0 nor T1 modulo stamps`); + expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;'); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] }); + }, 300_000); + + // Value: protects=the freeze record is built from the same GitHub read that decided to freeze, so a slot that read MERGED stays merged even when GitHub stops answering a moment later; fails_when=freezeQueue reads GitHub again and files anything but an exact MERGED under unmerged (the misfile that listed train 24's merged #3453 as unmerged, GSTA-129 defect 1); why_new=the test above holds GitHub's answer fixed across the verify; seam=the fixture's gh pr view vanishing after one answer; no_claim=the backstop's outcome + test("a slot that read MERGED when the verify decided to freeze is recorded as merged even when GitHub stops answering before the record is written", () => { + 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 smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; + publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); + fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; d.vanish = { '1': 1 }; }); + const frozen = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain('"- added after the gate"); the queue is frozen;'); + expect(fx.read().vanish).toEqual({ '1': 0 }); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; unmerged #2; repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [{ pr: '2', head: h2 }] }); + }, 300_000); + + // Value: protects=a slot unreadable on GitHub beside a merged one is left out of unmerged and named in the reason, so the rebuild after the repair never seats a PR that may already be in; fails_when=freezeQueue files an unreadable slot under unmerged, or drops it without naming it; why_new=the test above keeps slot 2 readable; seam=the fixture's gh pr view vanishing for slot 2; no_claim=the backstop's outcome + test("a slot unreadable on GitHub beside a merged one is left out of unmerged and named in the freeze reason", () => { + const fx = make(); + const { 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 smuggled = fx.repo.git('show', `${t.prefixes[0].commit}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.4.0] - 2026-10-18\n\n- #1 change\n- added after the gate\n') + '\n'; + publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: smuggled }); + fx.write(d => { (d.prs['1'] as any).state = 'MERGED'; d.vanish = { '2': 0 }; }); + const frozen = queue(fx, ['verify', '--pr', '1', '--train', nonce], {}, coord); + expect(frozen.status).toBe(EXIT.fail); + expect(line(frozen.out, 'freeze:')).toMatch(/; #1 merged on GitHub without a verified tree; #2 unreadable on GitHub, left out of unmerged; seat it in a train by hand once gh pr view answers; unmerged \(none\); repair none admitted$/); + expect(JSON.parse(fs.readFileSync(path.join(fx.state, 'ship-queue', 'freeze', `${repoKey(fx)}.json`), 'utf8'))).toMatchObject({ merged: ['1'], unmerged: [] }); + }, 300_000); + + // Value: protects=a post-merge CHANGELOG difference is excused only when main's parent is T<i-1> modulo stamps, so a foreign commit between the verified head and the squash freezes; fails_when=published() skips or inverts the parent check; why_new=the two tests above reach the CHANGELOG rule only with a parent that is T1 modulo stamps; seam=none; no_claim=a parent differing from T1 in the CHANGELOG alone + test("a post-merge CHANGELOG difference is not excused when main's parent is not the verified prefix modulo stamps: the queue freezes naming the parent", () => { + const fx = make(); + const { 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 released = fx.repo.git('show', `${t.prefixes[1].commit}:CHANGELOG.md`).replace('## [Unreleased]', '## [1.2.4.0] - 2026-10-18') + '\n'; + const main1 = publishMain(fx, t.prefixes[1].commit, '1.2.4.0', { file: 'CHANGELOG.md', text: released }); + expect(v('1').status).toBe(EXIT.ok); + const foreign = publishMain(fx, t.prefixes[1].commit, '1.2.4.1', { file: 'stray.txt', text: 'landed between the two slots\n' }); + const text = fx.repo.git('show', `${main1}:CHANGELOG.md`).replace('# Changelog\n', '# Changelog\n\n## [1.2.5.0] - 2026-10-18\n\n- #2 change\n- added after the gate\n') + '\n'; + const main2 = publishMain(fx, t.prefixes[2].commit, '1.2.5.0', { file: 'CHANGELOG.md', text }); + expect(fx.repo.git('rev-parse', `${main2}^`)).toBe(foreign); + const frozen = v('2'); + expect(frozen.status).toBe(EXIT.fail); + expect(frozen.err).toContain(`main ${main2.slice(0, 12)} is neither T1 nor T2 modulo stamps`); + expect(frozen.err).toContain(`main's parent ${foreign.slice(0, 12)} is not T1 modulo stamps`); + expect(line(frozen.out, 'QUEUE_TRAIN_FROZEN:')).toMatch(/^QUEUE_TRAIN_FROZEN: train \S+ merged #1; main /); + }, 300_000); });