mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
v1.91.282.0 fix(ship-queue): one CHANGELOG rule before and after the merge, base keys stay in slot stamps, a stacked child carries past its released parent (#3518)
* Ship-queue train tooling: one CHANGELOG rule, base keys stay, stacked-child carry (GSTA-129) Four train-24 (GSTA-96) defects in one change. verify --train applies the same CHANGELOG rule before and after the merge: main's CHANGELOG plus the PR's own lines (its authored diff plus what mergeChangelogs writes for it). A head the pre-merge verify accepted no longer freezes the queue after its squash merge, and a freeze record lists a slot GitHub reports MERGED as merged. restamp's stamp_paths stage leaves a *_KEY_VERSION line the base introduced after the fork point alone, base merge committed or staged, and says so. mergeChangelogs with a base carries a stacked child's edits to its released parent entry as "Correction to the <version> entry below" bullets under the child's own Gate integrity section instead of returning null; the slot one-liner prints each carried correction. #3453's two 1.91.276.0 corrections are restored that way here. The digest-freshness defect is already closed by #3451 (regeneratedAtBase); docs/ship-policy.md now says how an unstamped generator change ships. Docs: ship-policy § Run a merge train, captain.md § Merge trains and the slot-call template carry the stamp-files and stacked-child rules. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Review fixes: carried union from slot 2, GitHub-merged slot-1 freeze, no-own-entry refusal, fail-closed base diff (GSTA-129) Pre-landing review pass 1 (six specialists and the Red Team, all INFORMATIONAL): - mergeStep unions the carried child from slot 2 on, so a stacked child behind another slot never repeats its released parent's text under a second [Unreleased] (Red Team, lib/ship-queue-train.ts). - stopped() freezes the queue when any slot is MERGED on GitHub, so a slot-1 squash that fails the post-merge check no longer answers "nothing merged" (Red Team, lib/ship-queue-train-verify.ts). - ownEntryAboveMain refuses a CHANGELOG equal to main's: no entry of the PR's own (state-machines, lib/changelog-check.ts). - branchAddedLines refuses when the base diff cannot run instead of reading it as "nothing added" (Red Team, lib/restamp.ts). - slot-call.md and docs/captain.md name the stamp files as the ones the slot call names, STAMP_FILES plus a *_KEY_VERSION file the PR introduced, matching docs/ship-policy.md (maintainability). Also: the coverage tests Step 7 added (post-merge parent check, carry shapes), and two dependency-map rows (test/helpers/free-fixtures-data.ts for the merge-train-3453 fixtures, scripts/test-pr-profile.ts for captain/templates/**) so the paid-gate selector and the FREE_FIXTURES inventory accept the new files. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Review pass 2: an unreadable GitHub state at a failed slot refuses, never "nothing merged" (GSTA-129) stopped() read prState's 'unreadable' as "not merged": with nothing in t.merged, a slot-1 squash already on main while gh pr view failed answered "nothing of the train merged, so the queue is not frozen". It now refuses with QUEUE_PROOF_MISSING ("whether #<n> merged on GitHub could not be read, so whether the queue must freeze is unknown") and asks for the verify to be rerun; no freeze, no backstop. Raised independently by five pass-2 reviewers. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Docs audit: troubleshooting codes and ship-policy sections match the shipped train rules (GSTA-129) The /document-release audit (edit mode) corrected two docs the PR had not reached: docs/troubleshooting.md's QUEUE_TRAIN_TREE_MISMATCH, QUEUE_PROOF_MISSING, QUEUE_TRAIN_FROZEN and POLICY_SOURCE_UNAVAILABLE entries, and docs/ship-policy.md's train build conflict rule (the carried stacked child is the exception), the verify freeze paragraph (slot-1 GitHub-MERGED freeze, unreadable-state refusal) and the restamp stamp_paths stage (base-added lines stay; an unrunnable base diff refuses). Five mid-sentence colons in the PR's own paragraphs are rewritten for the prose lint, and the CHANGELOG Docs bullet names docs/troubleshooting.md. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * HARNESS_VERSION: scripts/test-pr-profile.ts row recorded non-behavioral at version 6 (GSTA-129) The remote free tier's only red was the HARNESS_VERSION guard: scripts/test-pr-profile.ts is a pinned shared-harness file and gained the captain/templates/** FREE_ONLY_PR_FILES row. Recorded with bump-harness-version.ts --non-behavioral: no paid case reads the crew templates, so no selection, input or verdict changes and no pass-rate series resets. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: type the saved fixture PR row for the test-code ratchet (GSTA-129) CI's typecheck:test ratchet refused one new TS2698 (spread of a loosely typed fixture row) in the slot-1 freeze test; the row is read as any, as the neighbouring state writes already do. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-queue verify: build the freeze record from the one GitHub read that decided to freeze freezeQueue took a second prState read per unverified slot and filed anything but an exact MERGED under unmerged, so a slot that read MERGED at stopped() and was unreadable a moment later was recorded as unmerged (the record half of train 24's #3453 misfile). stopped() now reads each unverified slot once and passes those states in; a MERGED slot stays merged, and a slot unreadable beside a merged one is kept out of unmerged and named in the reason. The fixture's gh shim gains a per-PR vanish count so a test can change GitHub's answer between two reads. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * ship-queue verify: say what happens to a slot unreadable beside a merged one The freeze reason, docs and CHANGELOG said the slot was kept out of unmerged 'until read', but nothing reads it again: the NEXT after the repair is built from unmerged alone. Say so (the operator seats it in a train by hand once gh answers) and pin the record shape with a test. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.282.0 stamp Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
392f50d47b
commit
9ccb5233f3
21 files changed
+1080
-73
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
+23
@@ -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 <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`.
|
||||
|
||||
+78
@@ -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`.
|
||||
|
||||
+95
@@ -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`.
|
||||
|
||||
+88
@@ -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`.
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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); } });",
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user