diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index f5b84ce2c3..d188eced9d 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -4,7 +4,8 @@ "queue_mode": "stamp-at-merge", "release_tool": "bun scripts/gen-agents-digest.ts", "release_outputs": ["agents-digest/gstack-AGENTS.md"], - "stamp_paths": [], + "stamp_paths": ["lib/ship-policy.ts"], + "stamp_patterns": ["_KEY_VERSION = '{version}'"], "changelog": "CHANGELOG.md", "mirror": null, "history": "squash-ok", diff --git a/CHANGELOG.md b/CHANGELOG.md index 5817c53c83..bc2cae79b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [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. diff --git a/VERSION b/VERSION index 345857c33d..4fd2939179 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.274.0 +1.91.275.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 14424b2af7..4aba02adf6 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.274.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.275.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 3ed2f34572..72306e5a0e 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -85,7 +85,8 @@ hard-code: "queue_mode": "stamp-at-merge", "release_tool": "bun scripts/gen-agents-digest.ts", "release_outputs": ["agents-digest/gstack-AGENTS.md"], - "stamp_paths": [], + "stamp_paths": ["lib/ship-policy.ts"], + "stamp_patterns": ["_KEY_VERSION = '{version}'"], "changelog": "CHANGELOG.md", "history": "squash-ok", "stamp_sensitive_gates": ["tests"] diff --git a/lib/ship-policy.ts b/lib/ship-policy.ts index 4710c5d1f9..8575cbdbd0 100644 --- a/lib/ship-policy.ts +++ b/lib/ship-policy.ts @@ -161,11 +161,21 @@ export const SHIP_QUEUE_KEYS_VERSION = '1.91.108.0'; export const TRAIN_KEYS_VERSION = '1.91.146.0'; // Each constant is the release that first reads its key: a policy declaring the key must name at least // that version as min_gstack_version, so an older gstack refuses the policy by name instead of failing on -// an unknown key. review_scope shipped in 1.91.256.0 (#3481). docs.caps is unreleased: its constant names the -// release expected to stamp it (test/gstack-ship-policy.test.ts holds it newer than every CHANGELOG heading the -// checkout sees); whoever stamps that release under another number moves the constant to the stamped version. +// an unknown key. review_scope shipped in 1.91.256.0 (#3481), docs.caps in 1.91.270.0 (#3487). A new key's +// constant is written as a tentative future version clear of main's pace (the next minor, never the next patch, +// which main takes first and the released-key test below then reds at the pin), in this exact +// `_KEY_VERSION = ''` form: this file is in .gstack/ship-policy.json stamp_paths with that form as a +// stamp_patterns template, so the slot stamp's gstack-restamp stamp_paths stage moves the tentative value to the +// allocated version. The stage reads lines the branch added: a tentative value newer than the fork version moves, +// an untouched released constant stays, and a branch-added line naming the base's own version is re-read as a +// stale stamp and moves too, so never rewrite a released constant's line. Until the tree receipt accepts a +// stamp-form move whose old value is not the base version (#3512), the train's stamp-only check passes such a +// move only when the tentative value equals the base version at the slot, which a next-minor value never does: +// a PR that adds a key cannot clear the train until #3512 lands. test/gstack-ship-policy.test.ts holds +// an unreleased constant newer than every CHANGELOG heading and a released one on a heading whose entry names +// its key, so a value a stamp did not move fails there. export const REVIEW_SCOPE_KEY_VERSION = '1.91.256.0'; -export const DOCS_CAPS_KEY_VERSION = '1.91.258.0'; +export const DOCS_CAPS_KEY_VERSION = '1.91.270.0'; export const POLICY_KEY_INTRODUCED: Record = { bump: '1.91.73.0', queue_mode: '1.91.73.0', release_tool: '1.91.73.0', release_outputs: '1.91.73.0', stamp_paths: '1.91.73.0', changelog: '1.91.73.0', mirror: '1.91.73.0', history: '1.91.73.0', preregistration_shas: '1.91.73.0', stamp_sensitive_gates: '1.91.73.0', diff --git a/package.json b/package.json index 2318094fde..1094c75ca5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.274", + "version": "1.91.275", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-ship-policy.test.ts b/test/gstack-ship-policy.test.ts index ec9f4f76e4..983491aa5d 100644 --- a/test/gstack-ship-policy.test.ts +++ b/test/gstack-ship-policy.test.ts @@ -76,11 +76,12 @@ describe('validatePolicy (schema + containment, pure)', () => { describe('policy key versions against the CHANGELOG', () => { test('an unreleased key names a release newer than every heading the checkout can see', () => { - // The base branch's CHANGELOG counts when the checkout has it (a depth-1 CI clone does not): a stamp-at-merge + // The base branch's CHANGELOG counts when the checkout has it (a depth-1 CI clone does not; the file is past 1 MiB, + // so spawnSync needs the explicit maxBuffer or it drops the read silently): a stamp-at-merge // branch carries main's headings only after the slot merge, and a release stamped under another number // leaves the constant naming a release that never read the key, which the stamper moves by hand. const root = path.join(import.meta.dir, '..'); - const base = spawnSync('git', ['show', 'origin/main:CHANGELOG.md'], { cwd: root, encoding: 'utf8', timeout: 30_000 }); + const base = spawnSync('git', ['show', 'origin/main:CHANGELOG.md'], { cwd: root, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); const text = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8') + (base.status === 0 ? base.stdout : ''); const parse = (v: string) => v.split('.').map(Number); const cmp = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]! || a[3]! - b[3]!; @@ -91,6 +92,29 @@ describe('policy key versions against the CHANGELOG', () => { expect(cmp(parse(version), newest), `${key} is introduced in ${version}, which has no CHANGELOG heading and is not newer than the newest heading ${newest.join('.')}; an unreleased key names the release that will stamp it`).toBeGreaterThan(0); } }); + + test('a released key names the heading whose entry introduces it, so a tentative version no stamp moved fails', () => { + // #3487 was written naming the next version and main stamped that number for another PR first, so the constant + // pointed at a release that never read `docs` while the heading existed and the test above skipped it. A key version that is + // a heading must be the one whose entry names the key (`\`` at a word boundary in the section body, so `\`docs/` paths do not count); lib/ship-policy.ts sits in + // stamp_paths so the slot stamp moves a tentative value there itself. Keys from before the convention are exempt. + const root = path.join(import.meta.dir, '..'); + const base = spawnSync('git', ['show', 'origin/main:CHANGELOG.md'], { cwd: root, encoding: 'utf8', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); + const text = fs.readFileSync(path.join(root, 'CHANGELOG.md'), 'utf8') + (base.status === 0 ? base.stdout : ''); + const sections = new Map(); + for (const part of text.split(/^(?=## \[)/m)) { + const heading = /^## \[(\d+\.\d+\.\d+\.\d+)\]/.exec(part); + if (heading && !sections.has(heading[1]!)) sections.set(heading[1]!, part); + } + const parse = (v: string) => v.split('.').map(Number); + const cmp = (a: number[], b: number[]) => a[0]! - b[0]! || a[1]! - b[1]! || a[2]! - b[2]! || a[3]! - b[3]!; + for (const [key, version] of Object.entries(POLICY_KEY_INTRODUCED)) { + if (cmp(parse(version), parse(REVIEW_SCOPE_KEY_VERSION)) < 0) continue; + const section = sections.get(version); + if (section === undefined) continue; + expect(new RegExp('`' + key + '(?![A-Za-z0-9_/-])').test(section), `${key} is introduced in ${version}, but that release's CHANGELOG entry never names \`${key}: the constant still holds the tentative version the branch was written with; the slot stamp moves it through stamp_paths, or move it to the release that shipped the key`).toBe(true); + } + }); }); describe("gstack's own .gstack/ship-policy.json", () => {