diff --git a/bin/gstack-version-bump b/bin/gstack-version-bump index 4cc22501f..5779bf311 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -31,9 +31,13 @@ // file. No bump. Validates the VERSION pattern first. // // Contract: classify NEVER writes. write/repair mutate VERSION + the manifest -// + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present) -// only. No git mutation, no network. Mirrors gstack-next-version's -// reader/writer split so /ship composes them. +// + npm lockfiles (package-lock.json / npm-shrinkwrap.json, when present) — +// plus, in the gstack repo only, the committed agents digest (whose first +// line embeds VERSION and is byte-freshness-gated by test/agents-digest.test.ts +// and the Skill Docs Freshness CI check, so the write that changes VERSION +// must regenerate it or every release commit goes red). No git mutation, no +// network. Mirrors gstack-next-version's reader/writer split so /ship +// composes them. // // Manifest resolution (all three subcommands accept --package-json-path): // --package-json-path

→ .gstack/package-json-path → ./package.json @@ -335,6 +339,31 @@ function cmdClassify(args: string[], cwd: string): void { // the only exit-2 cases.) } +/** + * gstack-repo-only side effect: agents-digest/gstack-AGENTS.md embeds VERSION + * in its first line and is freshness-gated (committed bytes must equal the + * generated bytes), so the bump that changes VERSION regenerates it in the + * same write. Any repo without the generator + committed digest skips this. + * Regen failure warns rather than failing the bump — the deterministic + * freshness test stays red until it's rerun, so nothing rots silently. + */ +function regenAgentsDigest(versionPath: string): boolean | null { + const root = dirname(versionPath); + const gen = join(root, "scripts", "gen-agents-digest.ts"); + if (!existsSync(gen) || !existsSync(join(root, "agents-digest", "gstack-AGENTS.md"))) return null; + try { + execFileSync("bun", [gen], { cwd: root, stdio: "pipe", timeout: 30_000 }); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write( + `write: VERSION updated but the agents digest regen failed (${msg.slice(0, 200)}). ` + + "Run 'bun scripts/gen-agents-digest.ts' manually — test/agents-digest.test.ts stays red until then.\n", + ); + return false; + } +} + function cmdWrite(args: string[], cwd: string): void { const version = argVal(args, "--version"); if (!version) fail("write requires --version ", 2); @@ -398,6 +427,7 @@ function cmdWrite(args: string[], cwd: string): void { const pkgPath = resolvePkgPath(cwd, argVal(args, "--package-json-path")); const hasPkg = existsSync(pkgPath); writeFileSync(versionPath, version + "\n"); + const digestRegen = regenAgentsDigest(versionPath); let lockSynced: string[] = []; // Decision 11: the manifest (and its lockfiles) carry the npm-valid // 3-digit translation — npm rejects a fourth component, so mirroring the @@ -432,6 +462,8 @@ function cmdWrite(args: string[], cwd: string): void { packageJsonPath: hasPkg ? relative(cwd, pkgPath) : null, packageJsonVersion: hasPkg ? manifestV : null, packageLock: lockSynced.length > 0, + // null = not the gstack repo (no digest to regen); true/false = regen outcome + agentsDigest: digestRegen, }) + "\n", ); } diff --git a/ship/SKILL.md b/ship/SKILL.md index 6b4b6468b..ba352afeb 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -898,15 +898,17 @@ EOF The evidence ledger is the mechanical arm of this law. Check it FIRST: ```bash -~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json +~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md ``` Pass each `--expect-cmd` the exact command string the wrapped Step 5 lane ran — that binds FRESH to the real suite (a green `echo ok` recorded under the label can never satisfy the check). Residual risk, accepted: `package.json` sits on the allow-list because Step 12's version bump writes its version field between -the test run and this gate; a behavior-changing package.json edit in that -window would not invalidate evidence. The check is advisory either way. +the test run and this gate (and, in the gstack repo, regenerates the +version-stamped `agents-digest/gstack-AGENTS.md`); a behavior-changing +package.json edit in that window would not invalidate evidence. The check is +advisory either way. - **Every line FRESH (exit 0):** the recorded runs were green and the working-tree content is identical to what was tested, modulo the allow-listed release files diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index ac41e2c5a..0b4e93660 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -378,15 +378,17 @@ EOF The evidence ledger is the mechanical arm of this law. Check it FIRST: ```bash -~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json +~/.claude/skills/gstack/bin/gstack-evidence check --label tests --expect-cmd '' --label vitest --expect-cmd '' --max-age 24 --allow-paths CHANGELOG.md,VERSION,package.json,agents-digest/gstack-AGENTS.md ``` Pass each `--expect-cmd` the exact command string the wrapped Step 5 lane ran — that binds FRESH to the real suite (a green `echo ok` recorded under the label can never satisfy the check). Residual risk, accepted: `package.json` sits on the allow-list because Step 12's version bump writes its version field between -the test run and this gate; a behavior-changing package.json edit in that -window would not invalidate evidence. The check is advisory either way. +the test run and this gate (and, in the gstack repo, regenerates the +version-stamped `agents-digest/gstack-AGENTS.md`); a behavior-changing +package.json edit in that window would not invalidate evidence. The check is +advisory either way. - **Every line FRESH (exit 0):** the recorded runs were green and the working-tree content is identical to what was tested, modulo the allow-listed release files diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index 8e1706e82..920247447 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -64,7 +64,7 @@ describe('write (FRESH bump)', () => { const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString(); expect(JSON.parse(out)).toEqual({ wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json', - packageJsonVersion: '1.1.0', packageLock: false, + packageJsonVersion: '1.1.0', packageLock: false, agentsDigest: null, }); expect(fs.readFileSync(path.join(dir, 'VERSION'), 'utf-8').trim()).toBe('1.1.0.0'); const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')); @@ -87,7 +87,7 @@ describe('write (FRESH bump)', () => { const out = execFileSync('bun', [BIN, 'write', '--version', '0.2.0.0'], { cwd: d2 }).toString(); expect(JSON.parse(out)).toEqual({ wrote: '0.2.0.0', packageJson: false, packageJsonPath: null, - packageJsonVersion: null, packageLock: false, + packageJsonVersion: null, packageLock: false, agentsDigest: null, }); expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.2.0.0'); fs.rmSync(d2, { recursive: true, force: true }); @@ -134,7 +134,7 @@ describe('write/repair sync npm lockfiles (both version fields, #2567)', () => { const out = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString(); expect(JSON.parse(out)).toEqual({ wrote: '1.1.0.0', packageJson: true, packageJsonPath: 'package.json', - packageJsonVersion: '1.1.0', packageLock: true, + packageJsonVersion: '1.1.0', packageLock: true, agentsDigest: null, }); const l = JSON.parse(fs.readFileSync(path.join(dir, 'package-lock.json'), 'utf-8')); expect(l.version).toBe('1.1.0'); @@ -756,3 +756,57 @@ describe('#2600: classify must surface versionFileExists=false when VERSION is m expect(result.state).toBe('ALREADY_BUMPED'); // base is 0.0.0.0, current is 0.2.0.0, pkg in sync }); }); + +describe('write regenerates the gstack agents digest (gstack repo only)', () => { + // The committed agents-digest/gstack-AGENTS.md embeds VERSION in its first + // line and is byte-freshness-gated (test/agents-digest.test.ts + the Skill + // Docs Freshness CI check). The write that changes VERSION must regenerate + // it in the same mutation or every release commit of THIS repo goes red. + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-')); + afterAll(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* noop */ } }); + + test('a repo with the generator + committed digest gets a fresh digest on write', () => { + fs.writeFileSync(path.join(dir, 'VERSION'), '1.0.0.0\n'); + fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'agents-digest'), { recursive: true }); + // Stub generator with the same shape as scripts/gen-agents-digest.ts: + // read VERSION, write the version-stamped digest. + fs.writeFileSync(path.join(dir, 'scripts', 'gen-agents-digest.ts'), [ + "import * as fs from 'fs';", + "import * as path from 'path';", + "const root = path.resolve(import.meta.dir, '..');", + "const v = fs.readFileSync(path.join(root, 'VERSION'), 'utf-8').trim();", + "fs.writeFileSync(path.join(root, 'agents-digest', 'gstack-AGENTS.md'), `# gstack digest v${v}\\n`);", + ].join('\n')); + fs.writeFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), '# gstack digest v1.0.0.0\n'); + + const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: dir }).toString()); + expect(out.agentsDigest).toBe(true); + expect(fs.readFileSync(path.join(dir, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8')) + .toBe('# gstack digest v1.1.0.0\n'); + }); + + test('a generator failure warns and reports agentsDigest:false without failing the bump', () => { + const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-digest-fail-')); + fs.writeFileSync(path.join(d2, 'VERSION'), '1.0.0.0\n'); + fs.mkdirSync(path.join(d2, 'scripts'), { recursive: true }); + fs.mkdirSync(path.join(d2, 'agents-digest'), { recursive: true }); + fs.writeFileSync(path.join(d2, 'scripts', 'gen-agents-digest.ts'), 'process.exit(1);\n'); + fs.writeFileSync(path.join(d2, 'agents-digest', 'gstack-AGENTS.md'), '# gstack digest v1.0.0.0\n'); + + const res = execFileSync('bun', [BIN, 'write', '--version', '1.1.0.0'], { cwd: d2, stdio: 'pipe' }); + const out = JSON.parse(res.toString()); + expect(out.wrote).toBe('1.1.0.0'); // the bump itself still lands + expect(out.agentsDigest).toBe(false); + fs.rmSync(d2, { recursive: true, force: true }); + }); + + test('the REAL generator + real digest stay in lockstep through a bump round-trip', () => { + // Belt and braces for the actual repo wiring (not a stub): bump a copy of + // the real VERSION in place, regen, and confirm the first line tracks it. + const root = path.join(import.meta.dir, '..'); + const committed = fs.readFileSync(path.join(root, 'agents-digest', 'gstack-AGENTS.md'), 'utf-8'); + const version = fs.readFileSync(path.join(root, 'VERSION'), 'utf-8').trim(); + expect(committed.split('\n')[0]).toContain(`v${version}`); + }); +});