diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ae4d2e58..7320235eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.92.28.0] - 2026-11-01 + +**A PR reviewed before #3654 keeps its /ship receipt after it merges main and moves its entry into `changelog.d/` (GST-343, #3606).** #3656 taught carry that a fragment is release metadata, but the base merge also brought in `changelog.d/README.md`. Carry counted that README as a product change, dropped the fragment rule for the whole range, and refused with RECEIPT_CARRY_PRODUCT_CHANGED although the product patch-id was unchanged. + +### Fixed +- **Only the branch's own changes turn release-metadata paths back into product** (`bin/gstack-ship-receipt`). A metadata path whose change is not a stamp still counts as product when the branch changed it against the base. A change a base merge brought in no longer does, because its patch against the base is the same at both heads. A README edit the PR makes itself is still refused. Replayed on #3606 (49666bc8 to e28bb62): carry now reports "base merge + release metadata". + +### Tests +- `test/gstack-ship-receipt.test.ts`: the fragment carry case now adds `changelog.d/README.md` on main together with the policy, the way #3654 did. It failed before this fix with RECEIPT_CARRY_PRODUCT_CHANGED. + ## [1.92.27.0] - 2026-11-01 **/ship on Capy no longer spends calls reading its review sections.** In the GSTA-193 replay of PR #34 (C1-34), 13 of 42 parent calls only read part of a section (review-army.md, the core checklist, Steps 8.1-8.2, adversarial.md, documentation.md) or ran a step that belonged in the next program. Each phase program now ends by returning the next phase's sections, unchanged and split at headings, in the space its own 32 KB return has left. What reviewers see, which reviewers run, their depth and the blocking floor are unchanged. diff --git a/VERSION b/VERSION index 6ccfd1ef3..801577c9b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.27.0 +1.92.28.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index fd1305770..d6abbb5a0 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.27.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.28.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/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index 093b50196..1079fd9df 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -460,8 +460,15 @@ function productRules(repoRoot: string, base: string, outputs: readonly string[] * The product pathspec between two commits: every path but tests and release * metadata, where a metadata-listed path whose change from `from` to `to` is * not a stamp stays product (every glob that matches it leaves the exclusion). + * With `branch` (the paths the branch itself changed against the base, and its + * base..head ranges), only the branch's own non-stamp changes demote a glob: a + * touched path whose change is not a stamp on either side, or between the two + * heads. A path only a base merge brought in has the same patch against the base + * at both heads, and demoting its glob would pull a matching path the branch did + * change, such as a CHANGELOG fragment next to the base's changelog.d/README.md + * (GST-343, #3606), into the product patch-id at one head only. */ -function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { +function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string, branch?: { touched: ReadonlySet; sides: ReadonlyArray }): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { const changedRaw = git(['diff', '--name-only', from, to], repoRoot); if (changedRaw === undefined) return undefined; const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; @@ -470,11 +477,14 @@ function productSpec(repoRoot: string, rules: ProductRules, from: string, to: st if (file !== null) return file.trim() || null; try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; } }; - const oldVersion = versionAt(from); - const newVersion = versionAt(to); - const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) })); + const versions = new Map(); + const version = (ref: string) => { if (!versions.has(ref)) versions.set(ref, versionAt(ref)); return versions.get(ref)!; }; + const stamped = (p: string) => matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p); + const classify = (p: string, a: string, b: string) => classifyCarryPath(p, rules.extra, stamped(p) ? { before: blobAt(a, p), after: blobAt(b, p), oldVersion: version(a), newVersion: version(b), templates: rules.templates } : undefined); + const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classify(p, from, to) })); const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); - const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path); + const demoted = !branch ? changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path) + : [...branch.touched].filter(p => stamped(p) && (changed.some(c => c.path === p && c.class === 'product') || branch.sides.some(([a, b]) => blobAt(a, p) !== blobAt(b, p) && classify(p, a, b) === 'product'))); const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p))); return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] }; } @@ -579,7 +589,10 @@ function cmdCarry(rest: string[], json: boolean): number { } else { const fromBase = git(['merge-base', baseRef, from], repoRoot); if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); - const spec = productSpec(repoRoot, rules, from, head); + const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)]; + if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`); + const touched = new Set([...listed[0], ...listed[1]]); + const spec = productSpec(repoRoot, rules, from, head, { touched, sides: [[fromBase, from], [headBase, head]] }); if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); const { changed } = spec; const productFrom = patchId(repoRoot, fromBase, from, spec.product); @@ -610,9 +623,6 @@ function cmdCarry(rest: string[], json: boolean): number { // which can differ between the two base diffs. A path whose only difference is base context next to unchanged hunks // (lib/ship-patch.ts) needs a scoped review instead of the loop. const sides = { reviewedBase: fromBase, reviewed: from, currentBase: headBase, current: head }; - const listed = [touchedPaths(repoRoot, fromBase, from), touchedPaths(repoRoot, headBase, head)]; - if (!listed[0] || !listed[1]) return refused('RECEIPT_CARRY_REFUSED', `git could not list the paths the branch touched against ${base}; run /ship on ${head.slice(0, 12)}`); - const touched = new Set([...listed[0], ...listed[1]]); for (const p of [...touched].sort()) { if (changedProduct.length >= CARRY_NAMED_PATHS) break; const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 64cd2cf4b..99549bf1d 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1341,7 +1341,8 @@ other `CARRY_TEST_GLOBS` in `lib/ship-receipt.ts`; an `openapi.spec.yaml` is product) or release metadata (`CHANGELOG.md`, `agents-digest/**`, each `/.md` fragment when the base policy sets `changelog_fragments` (not the directory's README.md or a nested path; a README -change also makes the range's fragments product), and `VERSION` +change the branch itself makes also makes the range's fragments product, while one +a base merge brings in does not), and `VERSION` or `package.json` only while the change is a version stamp and nothing else: a dependency, a script or a new key makes them product). A path the policy's `stamp_files`, `stamp_paths` or `release_outputs` name is metadata only while diff --git a/package.json b/package.json index 32873afe0..ebecaa84c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.27", + "version": "1.92.28", "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-receipt.test.ts b/test/gstack-ship-receipt.test.ts index e18582ebe..7e0eb68c8 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -524,16 +524,17 @@ describe('gstack-ship-receipt carry (harness lane E row 3: receipt follows a hea const put = (rel: string, text: string) => { fs.mkdirSync(path.dirname(path.join(repo.work, rel)), { recursive: true }); fs.writeFileSync(path.join(repo.work, rel), text); }; const released = fs.readFileSync(path.join(repo.work, 'CHANGELOG.md'), 'utf8'); put('src/app.ts', 'export const a = 1;\n'); - put('changelog.d/README.md', '# fragments\n'); repo.git('add', '-A'); repo.git('commit', '-qm', 'seed app'); repo.git('push', '-q', 'origin', 'main'); repo.git('checkout', '-qb', 'feat'); put('src/app.ts', 'export const a = 2;\n'); put('CHANGELOG.md', released.replace('# Changelog\n', '# Changelog\n\n## [Unreleased]\n\n- a=2\n')); repo.git('add', '-A'); repo.git('commit', '-qm', 'feat: a=2'); const reviewed = repo.git('rev-parse', 'HEAD'); - // main turns on fragments (or only adds a policy without them); the merge moves the entry into changelog.d/feat.md + // main turns on fragments with their README (or only adds a policy without them), as #3654 did after #3606 was + // reviewed (GST-343); the merge moves the entry into changelog.d/feat.md repo.git('checkout', '-q', 'main'); put('.gstack/ship-policy.json', policy(fragments)); + put('changelog.d/README.md', '# fragments\n'); repo.git('add', '-A'); repo.git('commit', '-qm', 'policy'); repo.git('push', '-q', 'origin', 'main'); repo.git('checkout', '-q', 'feat'); repo.git('merge', '-q', '--no-commit', 'origin/main');