From e657fa0b0db499bc9d2d48386b9bd379dfa988c8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 23:46:17 -0800 Subject: [PATCH 01/11] v1.92.63.0 fix(ship): a slot restamp after the docs audit keeps finish's docs check fresh (GST-906) (#3834) * fix(ship): a slot restamp after the docs audit keeps finish's docs check fresh (GST-906) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the GST-906 restamp carry also holds after the slot merges main (red-team, ship round 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the GST-906 merge carry may only add the base's sections; an unmoved base keeps the direct rule (scoped fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog.d: GST-906 restamp carry fragment 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> --- CHANGELOG.md | 8 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- lib/ship-release.ts | 28 ++++++++- package.json | 2 +- test/ship-rereview-triggers.test.ts | 90 +++++++++++++++++++++++++++++ 6 files changed, 127 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7b3a0a0..5e8b31011 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.92.63.0] - 2026-11-02 + +**A slot restamp after the docs audit no longer stops `finish` with `DOCS: stale (CHANGELOG.md)` (GST-906).** When a PR lost its merge slot after Step 14.5, the new slot's restamp re-heads the CHANGELOG entry and moves VERSION, and on the lost-slot path it merges main first. `gstack-ship-release finish` read all of that as a docs change, so the only ways forward were a third docs audit, which the policy forbids, or an owner-accepted named risk. Reported by GBRA-258 on gbrain #8372. + +### Fixed +- **Step 16's docs check carries a restamp.** The CHANGELOG counts as unchanged when it differs from the audited copy only by the heading move or re-heading. After a main merge, the audited copy is first merged with main's CHANGELOG by the same `mergeChangelogs` call the restamp writer makes, and that merge may only add main's sections. The version file counts as unchanged when it held a bare version at the audit and still does. +- **Anything else still stales the audit.** That includes an entry edit, a dropped or edited main section, a dropped line the audit saw, and an audited CHANGELOG that git never held. + ## [1.92.62.0] - 2026-11-02 - **`gstack-pregate clear --run-url` checks the run's workflow.** On a lane with no run profile, `clear` used to record any URL as the receipt, so a `windows-smoke.yml` run could clear `windows-free-tests.yml/windows-free-shard` on a branch cut before #3806. When the base config and the GitHub origin can be read, `clear` now reads the run and refuses `PREGATE_RECEIPT_INVALID` (`workflow_mismatch`) unless its workflow file is the lane's own; a run gh cannot read (`api_error`) or a URL that is not an Actions run on this repository (`run_ref_invalid`) refuses too, and each refusal names its own fix. A run of the lane's own workflow clears as before, including a job link copied with `?pr=` or `#step`. `test_backend/*` lanes, `--bundle-lane` and non-GitHub origins are unchanged. On main the Windows obligation is `windows-smoke.yml/windows-smoke`, and a test now checks that against the repository's own workflows. The `ready` conflict hint now says to clear each lane with a run of that lane's own workflow. `test/pregate-clear-runs.test.ts` covers the mismatch, the matching control, the unreadable run, the non-run URL and the copied job links; each failed before its fix. diff --git a/VERSION b/VERSION index 84b00ae41..18a77cfd2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.62.0 +1.92.63.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 3b68e506c..817029559 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.62.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.63.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/lib/ship-release.ts b/lib/ship-release.ts index 9bc72b8bd..bdce14414 100644 --- a/lib/ship-release.ts +++ b/lib/ship-release.ts @@ -11,6 +11,8 @@ */ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { mergeChangelogs, normalizeChangelog } from './changelog-check'; +import { versionAtRef } from './restamp'; import { classifyPath } from './review-depth'; import { comparePath, contextPacket, gateEditedPaths, isTestCode, PACKET_SECTION_CAP, readScopedReview, touchedPaths, type PatchSides } from './ship-patch'; import { parseReceiptDocs, type ReceiptDocs } from './ship-receipt-docs'; @@ -217,7 +219,12 @@ function route(cwd: string, base: string, reviewed: string, reviewedBase: string return by; } -/** Step 16 stage 3. Test code, paths only the base moved, and --docs-requested fixes the audit's own result asked for do not stale the audit (GST-391). */ +/** + * Step 16 stage 3. Test code, paths only the base or a stamp moved, a slot restamp after the audit (the CHANGELOG's heading + * move or re-heading after the restamp writer's own merge of the base's CHANGELOG into the audited one, the version file's + * version, each against the audited bytes; GST-906), and --docs-requested fixes the audit's own result asked for do not + * stale the audit (GST-391). + */ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly string[], requested: string[], resultFile: string | undefined, r: Report): boolean { const cmp = bin(cwd, 'gstack-docs-candidate', ['compare', postFile], 120_000); let stale: string[] | null = null; @@ -253,7 +260,24 @@ function docsFresh(cwd: string, base: string, postFile: string, moved: readonly if (!atAudit || (nowDiff && nowDiff.status !== 0)) { r.stop(`git could not list the paths the branch touched against ${baseRef}; documentation freshness is unknown (stage 3)`); return false; } const branchAtAudit = new Set([...atAudit, ...record.staged, ...record.unstaged, ...record.untracked]); const branchNow = new Set([...(nowDiff ? nul(nowDiff.stdout) : []), ...nul(git(cwd, ['ls-files', '-z', '--others', '--exclude-standard']).stdout)]); - const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] }; + const changelog = shipPolicy(cwd, base)?.changelog; + const versionFile = versionAtRef(cwd, 'HEAD'); + const restamped = stale.filter(p => { + const isChangelog = p === changelog; + if (!isChangelog && !('rel' in versionFile && p === versionFile.rel)) return false; + const audited = typeof record.hashes?.[p] === 'string' ? git(cwd, ['cat-file', 'blob', record.hashes[p]]) : null; + if (audited?.status !== 0) return false; + let now: string; + try { now = fs.readFileSync(path.join(cwd, p), 'utf8'); } catch { return false; } + if (!isChangelog) return !!parseVersion(audited.stdout.trim()) && !!parseVersion(now.trim()); + const [atAuditBase, atNowBase] = [auditBase, nowBase].map(b => b ? git(cwd, ['show', `${b}:${p}`]) : null); + if (atAuditBase?.status !== 0 || atNowBase?.status !== 0) return false; + if (atAuditBase.stdout === atNowBase.stdout) return normalizeChangelog(audited.stdout) === normalizeChangelog(now); + const kept = mergeChangelogs(audited.stdout, atAuditBase.stdout, atAuditBase.stdout); + const merged = mergeChangelogs(audited.stdout, atNowBase.stdout, atAuditBase.stdout); + return kept !== null && merged !== null && normalizeChangelog(kept) === normalizeChangelog(audited.stdout) && normalizeChangelog(merged) === normalizeChangelog(now); + }); + const carried = { tests: stale.filter(isTestCode), base: stale.filter(p => !isTestCode(p) && (moved.includes(p) || restamped.includes(p) || (!!auditBase && !!nowBase && !branchAtAudit.has(p) && !branchNow.has(p)))), requested: [] as string[] }; let audit: any = null; try { audit = resultFile ? JSON.parse(fs.readFileSync(resultFile, 'utf8')) : null; } catch { audit = null; } for (const p of requested.filter(q => stale!.includes(q) && !carried.tests.includes(q) && !carried.base.includes(q))) { diff --git a/package.json b/package.json index c6e235237..fcdaacd15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.62", + "version": "1.92.63", "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/ship-rereview-triggers.test.ts b/test/ship-rereview-triggers.test.ts index 8795718cb..cf28cc156 100644 --- a/test/ship-rereview-triggers.test.ts +++ b/test/ship-rereview-triggers.test.ts @@ -572,3 +572,93 @@ describe('preflight carry route for an adjacent base edit (GST-442)', () => { }); }); +describe('a CHANGELOG re-heading after the docs audit (GST-906, GBRA-258, gbrain #8372)', () => { + // Value: protects=a slot restamp after the Step 14.5 snapshot that only re-heads the branch's CHANGELOG entry + // ([Unreleased], or its old version after a lost slot, moved to `## [] - `, with the audited VERSION moved + // in the second case) keeps the audit fresh, while an entry edit beside the re-heading, or audited bytes git does not + // hold, stays stale; fails_when=finish stops with DOCS: stale (CHANGELOG.md) or (VERSION) on a pure restamp (recovery + // then needs a third audit or a named risk) or waves an entry edit through; seam=lib/ship-release.ts docsFresh; + // no_claim=the restamp writer + test('a re-heading alone keeps the audit fresh; an entry edit or unheld audited bytes stays stale', () => { + const policy = JSON.stringify({ bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'] }); + const log = (heading: string, entry = '- b ships') => `# Changelog\n\n${heading}\n\n${entry}\n\n## [1.0.0.0] - 2026-10-30\n\n- base entry\n`; + const cases = [ + ['[Unreleased] re-headed', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), true, 'fresh'], + ['old version re-headed after a lost slot', '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), '1.0.2.0', log('## [1.0.2.0] - 2026-11-02'), true, 'fresh'], + ['re-heading plus an entry edit', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01', '- b ships fast'), true, 'stale'], + ['audited bytes never committed or staged', '1.0.0.0', log('## [Unreleased]'), '1.0.1.0', log('## [1.0.1.0] - 2026-11-01'), false, 'stale'], + ] as const; + for (const [name, audited, auditedLog, stamped, stampedLog, committed, verdict] of cases) { + const f = fx({ '.gstack/ship-policy.json': policy, 'CHANGELOG.md': '# Changelog\n\n## [1.0.0.0] - 2026-10-30\n\n- base entry\n' }); + const reviewed = wtree(f); + f.write({ VERSION: `${audited}\n`, 'CHANGELOG.md': auditedLog }); + if (committed) f.commit('Steps 12-13: version and CHANGELOG'); + const post = snapshot(f); + f.write({ VERSION: `${stamped}\n`, 'CHANGELOG.md': stampedLog }); f.commit('slot restamp'); + const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]); + if (verdict === 'fresh') expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 0, expect.stringContaining('DOCS: fresh against the Step 14.5 post-audit snapshot')]); + else expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 3, 'DOCS: stale (CHANGELOG.md)']); + } + }); + + // Value: protects=the lost-slot restamp merges main first (CREW rule 6), so the CHANGELOG also gains main's new sections; + // that merge plus the re-heading keeps the audit fresh, while a merge that drops or edits a main section, or hides an + // entry edit, stays stale; fails_when=finish stops with DOCS: stale (CHANGELOG.md) after main merged a sibling's release, + // or waves a changed section through; seam=lib/ship-release.ts docsFresh (mergeChangelogs as lib/restamp.ts calls it); + // no_claim=the restamp writer + test('a main merge before the re-heading keeps the audit fresh; a dropped or edited section stays stale', () => { + const policy = JSON.stringify({ bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'] }); + const section = (heading: string, entry: string) => `${heading}\n\n${entry}\n\n`; + const log = (...sections: string[]) => `# Changelog\n\n${sections.join('')}## [1.0.0.0] - 2026-10-30\n\n- base entry\n`; + const own = section('## [1.0.1.0] - 2026-11-01', '- b ships'); + const sibling = section('## [1.0.1.0] - 2026-11-01', '- sibling ships'); + const cases = [ + ['main merged, then re-headed', log(section('## [1.0.2.0] - 2026-11-02', '- b ships'), sibling), 'fresh'], + ['merge drops a main section', log(section('## [1.0.2.0] - 2026-11-02', '- b ships')), 'stale'], + ['merge edits a main section', log(section('## [1.0.2.0] - 2026-11-02', '- b ships'), section('## [1.0.1.0] - 2026-11-01', '- sibling ships fast')), 'stale'], + ['entry edit hidden inside a merge', log(section('## [1.0.2.0] - 2026-11-02', '- b ships fast'), sibling), 'stale'], + ] as const; + for (const [name, stampedLog, verdict] of cases) { + const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'CHANGELOG.md': log() }); + const reviewed = wtree(f); + f.write({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': log(own) }); f.commit('Steps 12-13: version and CHANGELOG'); + const post = snapshot(f); + f.advanceBase({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': log(sibling) }); + mergeMain(f, { VERSION: '1.0.1.0\n', 'CHANGELOG.md': log(own, sibling) }); + f.write({ VERSION: '1.0.2.0\n', 'CHANGELOG.md': stampedLog }); f.commit('slot restamp'); + const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]); + if (verdict === 'fresh') expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 0, expect.stringContaining('DOCS: fresh against the Step 14.5 post-audit snapshot')]); + else expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 3, 'DOCS: stale (CHANGELOG.md)']); + } + }); + + // Value: protects=the base merge in the carry may only add the base's sections: dropping a header line the audit saw + // stays stale with or without a main merge, and a branch that also corrected a released entry keeps a pure re-heading + // fresh; fails_when=mergeChangelogs' base-wins resolution hides a deleted audited line (DOCS: fresh) or stales a pure + // re-heading; seam=lib/ship-release.ts docsFresh; no_claim=the restamp writer + test('the base merge only adds base sections: a dropped audited line stays stale, a corrected released entry stays fresh', () => { + const policy = JSON.stringify({ bump: 'patch', queue_mode: 'stamp-at-merge', changelog: 'CHANGELOG.md', stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'] }); + const released = (entry = '- base entry') => `## [1.0.0.0] - 2026-10-30\n\n${entry}\n`; + const log = (header: string, own: string, rest: string) => `# Changelog\n\n${header}${own}\n\n- b ships\n\n${rest}`; + const sibling = '## [1.0.1.0] - 2026-11-01\n\n- sibling ships\n\n'; + const cases = [ + ['audited header line dropped, no main merge', log('Notice: read this.\n\n', '## [1.0.1.0] - 2026-11-01', released()), false, log('', '## [1.0.2.0] - 2026-11-02', released()), 'stale'], + ['audited header line dropped in a main merge', log('Notice: read this.\n\n', '## [1.0.1.0] - 2026-11-01', released()), true, log('', '## [1.0.2.0] - 2026-11-02', sibling + released()), 'stale'], + ['released entry corrected, then re-headed', log('', '## [1.0.1.0] - 2026-11-01', released('- base entry, corrected')), false, log('', '## [1.0.2.0] - 2026-11-02', released('- base entry, corrected')), 'fresh'], + ] as const; + for (const [name, auditedLog, merges, stampedLog, verdict] of cases) { + const f = fx({ '.gstack/ship-policy.json': policy, VERSION: '1.0.0.0\n', 'CHANGELOG.md': `# Changelog\n\n${released()}` }); + const reviewed = wtree(f); + f.write({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': auditedLog }); f.commit('Steps 12-13: version and CHANGELOG'); + const post = snapshot(f); + if (merges) { + f.advanceBase({ VERSION: '1.0.1.0\n', 'CHANGELOG.md': `# Changelog\n\n${sibling}${released()}` }); + mergeMain(f, { VERSION: '1.0.1.0\n', 'CHANGELOG.md': stampedLog }); + } + f.write({ VERSION: '1.0.2.0\n', 'CHANGELOG.md': stampedLog }); f.commit('slot restamp'); + const r = finish(f, reviewed, post, ['--reviewed-base', mergeBase(f)]); + if (verdict === 'fresh') expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 0, expect.stringContaining('DOCS: fresh against the Step 14.5 post-audit snapshot')]); + else expect([name, r.status, lineOf(r.stdout, 'DOCS')]).toEqual([name, 3, 'DOCS: stale (CHANGELOG.md)']); + } + }); +}); From 5b445ae937dabc2370549c396e2a38a596b23ea1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Sun, 1 Nov 2026 23:57:39 -0800 Subject: [PATCH 02/11] v1.92.64.0 ship: pin Step 11's outside voice to the certified model, refuse same-family voices, docs audit on Sonnet 5.5 (GSTA-167 PR B) (#3698) * ship: pin the outside voice to the certified model, refuse same-family voices, docs audit on Sonnet (GSTA-167 PR B) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog.d: GSTA-167 PR B fragment Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (outside voice with no provider prefix STOPs) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: Step 11 review fixes (resume on dispatched voice models, case-insensitive family, every outside voice checked) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog.d: GSTA-167 PR B review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: outside-voice default and voice-models.json pin; Step 14.5 runs once on Sonnet (GSTA-167 PR B) Adds the prb-docs-2 auditor's docs/unattended.md paragraph and fixes capy/skill/gstack/SKILL.md:49 at the same byte count (22,365 B), so the 5,312-token context-budget ceiling is unchanged. The ship-driver test pins the new 14.5 wording instead of the dropped haiku aside. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: trim capy SKILL.md:49 so it stays under the token ceiling beside #3798 '(14.5 runs once)' -> '(14.5 once)' and 'Otherwise' -> 'Else' (22,355 B, 5,310 tokens on main; 22,371 B, about 5,314 tokens merged with #3798 against its 5,315 ceiling). 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> --- CHANGELOG.md | 15 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- capy/skill/gstack/SKILL.md | 2 +- docs/unattended.md | 8 +++ lib/capy-review-pass.js | 49 +++++++++++-- package.json | 2 +- ship/DRIVER.md | 6 +- ship/DRIVER.md.tmpl | 6 +- test/review-pass.test.ts | 126 ++++++++++++++++++++++++++++++++- test/ship-driver.test.ts | 4 +- 11 files changed, 204 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e8b31011..fe9576817 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.92.64.0] - 2026-11-02 + +**/ship's Step 11 outside voice now defaults to the model it was tested with, and a same-family outside voice is refused.** On a lane running an Anthropic model, the pass program uses `openai/gpt-6.1-sol` at medium reasoning when no outside-voice model is given. That is the pairing no-loss rounds 3 to 6 and A3668 ran. Until now the lane picked the model (gpt-6-astra in replays 6c, 7e and 8g), and the only check was that some model was given, so a Claude outside voice beside a Claude native voice would have run as "outside" (GSTA-167 plan R2 and O2). + +### Changed +- **Outside-voice default, Anthropic lanes only** (`lib/capy-review-pass.js`): with no `models['outside-voice']` and an `anthropic/` `laneModel`, the voice runs on `openai/gpt-6.1-sol` at medium. Any other lane, or no lane identity, still stops with "none was given", because an OpenAI lane's pairing is not certified. +- **Same-family STOP**: an outside voice whose provider (the part before `/`, compared without regard to case) matches the lane model or the native voice's model stops the pass before either voice is created. The check runs again on every rerun of the same manifest and before a queued voice is started. An outside voice with no provider prefix, or with no lane model and no native model to compare against, also stops. +- **Voices resume on the models they were dispatched with**: the pass records each Step 11 voice's model and the lane model in `voice-models.json` in the pass directory before creating it. A rerun with different inputs starts a queued voice on its recorded model and checks it against the recorded native model, so it can't pair two voices of one family. +- **Overrides are labeled**: each outside voice prints an `OUTSIDE_VOICE:` line when it is created or started from the queue, saying whether it is the certified configuration. A different model or reasoning level still runs, marked "outside the certified configuration". +- **Step 14.5 docs audit runs once on Sonnet 5.5** (`ship/DRIVER.md`). In replay C5 the docs audit first ran on a smaller model whose reply lacked its JSON line, then ran again on Sonnet, which wasted 1.55M tokens. To stay under the driver's 12 KB cap, one sentence that repeated Phase 4 and 5 ("A fix repeats Phases 3-4.") was removed. + +### Tests +- `test/review-pass.test.ts`: six new fake-host cases: a same-family override stops; a rerun or a queued start still refuses it; a queued voice resumes on its recorded model; a missing lane identity, an unprefixed id or an unprefixed lane stops; an Anthropic lane gets the certified default with every brief byte-identical to naming that model; an override with changed reasoning runs and is labeled uncertified. The Step 11 case now passes `laneModel: undefined` to keep its "none was given" check, since an Anthropic lane gets the default. +- `test/ship-driver.test.ts`: the audit-reader assertion names Step 14.5's Sonnet 5.5 run. + ## [1.92.63.0] - 2026-11-02 **A slot restamp after the docs audit no longer stops `finish` with `DOCS: stale (CHANGELOG.md)` (GST-906).** When a PR lost its merge slot after Step 14.5, the new slot's restamp re-heads the CHANGELOG entry and moves VERSION, and on the lost-slot path it merges main first. `gstack-ship-release finish` read all of that as a docs change, so the only ways forward were a third docs audit, which the policy forbids, or an owner-accepted named risk. Reported by GBRA-258 on gbrain #8372. diff --git a/VERSION b/VERSION index 18a77cfd2..3fa9ff907 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.63.0 +1.92.64.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 817029559..8fa12fa27 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.63.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.64.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/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index c6d85c010..bb617b0c0 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each one execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (`anthropic/claude-haiku-5-5` also fits). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Otherwise re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your jam's transcript since /ship began. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each an execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family, pinned in `voice-models.json`); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your transcript since /ship began. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/docs/unattended.md b/docs/unattended.md index 822f38e73..cb6ea320a 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -230,6 +230,14 @@ credits outside coverage. A parent reads the rows with `gstack-review-read` and treats `outside_status: unavailable` as missing coverage, not as a clean outside review. +The pass program (`lib/capy-review-pass.js`) picks the outside voice's model +when `input.models['outside-voice']` is empty and the lane model is +`anthropic/`: `openai/gpt-6.1-sol` at medium reasoning, the pairing the no-loss +rounds ran. Any other lane must name a model. An outside voice from the same +provider as the lane model or the native voice stops the pass (`STOP:`) before +either voice is created, including on a rerun, and each outside voice prints an +`OUTSIDE_VOICE:` line saying whether it is the certified configuration. + ### The carry across a base merge or a test-only fix When the parent merges the base in or fixes a test after the receipt was diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index e65aeacc0..9511695e9 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -91,8 +91,35 @@ const briefText = async (r) => { }; const roleArgs = (r) => ' --role ' + q(r.role) + (r.name ? ' --name ' + q(r.name) : ''); const REASONING = { medium: 'medium', light: 'low' }; -const modelOf = (r) => r.model || (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role] - || (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined); +const family = (id) => (typeof id === 'string' && id.includes('/') ? id.slice(0, id.indexOf('/')).toLowerCase() : ''); +const CERTIFIED_OUTSIDE = { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'medium' }; +const voiceFile = input.out.replace(/\/+$/, '') + '/voice-models.json'; +let voices = { laneModel: null, readers: {} }; +let laneFamily = family(input.laneModel); +const modelOf = (r) => r.model || (r.reader in voices.readers ? voices.readers[r.reader] || undefined + : (input.models || {})[r.role === 'specialist-batched' ? 'specialist' : r.role] + || (r.role === 'outside-voice' && laneFamily === 'anthropic' ? CERTIFIED_OUTSIDE : undefined) + || (REASONING[r.effort] && input.laneModel ? { model_id: input.laneModel, reasoning_mode: REASONING[r.effort] } : undefined)); +const outsideNote = (r, source) => { + const ov = modelOf(r); + const certified = laneFamily === 'anthropic' && ov.model_id === CERTIFIED_OUTSIDE.model_id && ov.reasoning_mode === CERTIFIED_OUTSIDE.reasoning_mode; + return 'OUTSIDE_VOICE: ' + ov.model_id + ' reasoning=' + (ov.reasoning_mode || 'default') + ' ' + source + + (certified ? ' (certified configuration)' : '; outside the certified configuration (' + CERTIFIED_OUTSIDE.model_id + ' at ' + CERTIFIED_OUTSIDE.reasoning_mode + ' beside an anthropic lane)'); +}; +const familyStop = (readers) => { + const native = readers.find((x) => x.role === 'adversarial-native'); + const nativeFamily = family(native && modelOf(native) ? modelOf(native).model_id : undefined); + for (const ov of readers.filter((x) => x.role === 'outside-voice' && !['created', 'collected', 'failed'].includes(x.state))) { + const om = modelOf(ov); + if (!om) return "the outside voice needs input.models['outside-voice'] (a model of another family); none was given"; + const lane = voices.laneModel || input.laneModel; + if (!laneFamily && !nativeFamily) return 'the outside voice ' + om.model_id + ' cannot be checked against another family: ' + (lane ? 'the lane model ' + lane + ' has no provider prefix and no native voice model was given' : 'neither input.laneModel nor a native voice model was given'); + const f = family(om.model_id); + if (!f) return 'the outside voice ' + om.model_id + ' has no provider prefix, so its family cannot be checked'; + if (f === laneFamily || f === nativeFamily) return 'the outside voice ' + om.model_id + ' is the ' + f + ' family, the same as the ' + (f === nativeFamily ? 'native voice' : 'lane model') + "; Step 11 needs input.models['outside-voice'] from another family"; + } + return null; +}; const safe = (s) => s.replace(/[^a-zA-Z0-9._-]/g, '_'); const active = input.stage === 'voices' ? ['voices'] : ['first', 'specialists', 'red-team']; const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before'; @@ -100,6 +127,11 @@ const beforeKey = input.stage === 'voices' ? 'before_voices' : 'before'; try { let m = await load(); if (input.stage !== 'voices' && m.next === 'fix-check') active.push('voices'); + if (active.includes('voices')) { + const v = await sh('cat ' + q(voiceFile) + ' 2>/dev/null || true'); + if (v.out.trim()) voices = JSON.parse(v.out); + if (voices.laneModel) laneFamily = family(voices.laneModel); + } if (input.stage === 'voices' && !m.readers.some((r) => r.stage === 'voices')) return finish(['VOICES: none (the pass plan lists no Step 11 voice)', 'NEXT: judge']); const seen = {}; for (const s of input.logStart || []) { @@ -125,12 +157,17 @@ try { await mark(r.reader, found[0].status === 'draft' ? 'queued' : 'created', { path: found[0].path }); } m = await load(); + const sameFamily = active.includes('voices') && familyStop(m.readers); + if (sameFamily) return stop(sameFamily); for (const r of m.readers.filter((x) => active.includes(x.stage) && x.state === 'queued')) { const t = byPath.get(r.path); if (t && t.status !== 'draft') { await mark(r.reader, 'created', {}); continue; } const model = modelOf(r); const s = await tools.subagent.start({ indices: [Number(r.path)], machine: 'shared', ...(model ? { model } : {}) }); - if ((s.started || []).length) await mark(r.reader, 'created', {}); + if ((s.started || []).length) { + if (r.role === 'outside-voice') lines.push(outsideNote(r, 'as dispatched, started from the queue')); + await mark(r.reader, 'created', {}); + } else if (r.state_at && (await now()) - Date.parse(r.state_at) > stuckMs) return stop(r.reader + ' (subagent ' + r.path + ') still queued after ' + Math.round(stuckMs / 60000) + ' minutes; free a subagent slot, then rerun'); } for (const r of m.readers.filter((x) => active.includes(x.stage) && x.state === 'created')) { @@ -167,7 +204,6 @@ try { lines.push(...s.out.trim().split('\n')); m = await load(); } - if (m.readers.some((x) => x.stage === stage && x.role === 'outside-voice' && x.state === 'rendered' && !modelOf(x))) return stop("the outside voice needs input.models['outside-voice'] (a model of another family); none was given"); const unmapped = m.readers.find((x) => x.stage === stage && x.state === 'rendered' && REASONING[x.effort] && !modelOf(x)); if (unmapped) return stop(unmapped.reader + ' runs at effort=' + unmapped.effort + ', which needs input.laneModel (your model id) or input.models; none was given'); for (const r of m.readers.filter((x) => x.stage === stage && x.state === 'rendered')) { @@ -184,6 +220,11 @@ try { const b = await reading(); if (b) { await sh(bin + ' mark --manifest ' + q(mf) + ' --before ' + b.tokens + ',' + b.credits + (beforeKey === 'before_voices' ? ' --voices' : '')); m[beforeKey] = b; } } + if (r.role === 'outside-voice') lines.push(outsideNote(r, r.model || (input.models || {})['outside-voice'] ? 'given' : 'default for an anthropic lane')); + if (r.stage === 'voices') { + voices = { laneModel: voices.laneModel || input.laneModel || null, readers: { ...voices.readers, [r.reader]: model || null } }; + await tools.capy.write({ path: voiceFile, content: JSON.stringify(voices) }); + } await mark(r.reader, 'intent', {}); const res = await tools.subagent.create({ tasks: [{ title: r.title, prompt: text }], start: { machine: 'shared', ...(model ? { model } : {}) } }); const idx = String(res.tasks[0].index); diff --git a/package.json b/package.json index fcdaacd15..f10ba2e02 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.63", + "version": "1.92.64", "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/ship/DRIVER.md b/ship/DRIVER.md index ac19dd0bd..8e3b4f4ca 100644 --- a/ship/DRIVER.md +++ b/ship/DRIVER.md @@ -24,11 +24,11 @@ version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** ( outside-voice and documentation brief, its severity and confidence rules and gates come from its section verbatim (placeholders filled); this file never shortens a reviewer's brief. A blocking reader's prompt is its `gstack-review-dispatch render` -file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a -small model at low effort; their findings go to the PR body. +file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs at low effort on a small model, +Step 14.5 once on Sonnet 5.5; findings go to the PR body. **Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's -section read or a fix-check, plus a keep-alive per 200 s waited. A fix repeats Phases 3-4. +section read or a fix-check, plus a keep-alive per 200 s waited. **Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends with `gstack-ship-budget note --step --reading R --credits C --record `, R and C read in that program diff --git a/ship/DRIVER.md.tmpl b/ship/DRIVER.md.tmpl index 0298fb93e..cefeda67f 100644 --- a/ship/DRIVER.md.tmpl +++ b/ship/DRIVER.md.tmpl @@ -22,11 +22,11 @@ version, CHANGELOG, commits), **Documentation** (Step 14.5) and **Next steps** ( outside-voice and documentation brief, its severity and confidence rules and gates come from its section verbatim (placeholders filled); this file never shortens a reviewer's brief. A blocking reader's prompt is its `gstack-review-dispatch render` -file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a -small model at low effort; their findings go to the PR body. +file, unchanged; its findings reach `merged.jsonl` only via `collect`; `judge` refuses a drop or downgrade. Audit readers (Steps 7, 8, 14.5) cannot block, so each runs at low effort on a small model, +Step 14.5 once on Sonnet 5.5; findings go to the PR body. **Calls.** One pass with no fixes is 9 calls: Phases 1-8 and the report; at most 12 with a STOP's -section read or a fix-check, plus a keep-alive per 200 s waited. A fix repeats Phases 3-4. +section read or a fix-check, plus a keep-alive per 200 s waited. **Budget: observe.** `gstack-ship-budget start --mode observe` records, never admits: each program ends with `gstack-ship-budget note --step --reading R --credits C --record `, R and C read in that program diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index 9d440c612..e3ed0fee3 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -182,7 +182,7 @@ function host(dir: string, env: NodeJS.ProcessEnv, o: { output?: (title: string) if (mode === 'after') throw new Error('create result lost'); return { tasks: [{ index, title: t.title }], ...(rejected ? { rejected: [{ index, error: 'cap' }] } : {}) }; }, - start: async ({ indices }: { indices: number[] }) => { starts.push(indices); for (const i of indices) subs.get(String(i))!.status = 'working'; return { started: indices.map(index => ({ index })), rejected: [] }; }, + start: async ({ indices, model }: { indices: number[]; model?: unknown }) => { starts.push(indices); for (const i of indices) { const s = subs.get(String(i))!; s.status = 'working'; if (model) s.model = model; } return { started: indices.map(index => ({ index })), rejected: [] }; }, list: async () => { advance(); return { tasks: [...subs].map(([p, s]) => ({ path: p, title: s.title, status: s.status })), total: subs.size }; }, get: async ({ path: p }: { path: string }) => ({ path: p, status: subs.get(p)!.status, summary: subs.get(p)!.summary }), }, @@ -338,7 +338,7 @@ describe('Capy pass program (fake host, real bins)', () => { const h = host(dir, env); const base = { base: 'main', out, pass: 'x7p1', planFlags: ['--depth-floor', 'full'] }; expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); - expect(await runPass(h, { ...base, stage: 'voices' })).toContain("STOP: the outside voice needs input.models['outside-voice']"); + expect(await runPass(h, { ...base, stage: 'voices', laneModel: undefined })).toContain("STOP: the outside voice needs input.models['outside-voice']"); expect(h.creates.some(c => c.title.startsWith('ship x7p1 ') && /adversarial|outside/.test(c.title))).toBe(false); const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' }, 'adversarial-native': { model_id: 'anthropic/claude-opus-5-5', reasoning_mode: 'high' } }; expect(await runPass(h, { ...base, stage: 'voices', models })).toContain('NEXT: judge the voices'); @@ -346,6 +346,128 @@ describe('Capy pass program (fake host, real bins)', () => { expect(h.creates.find(c => c.title === 'ship x7p1 adversarial-native')!.model).toEqual(models['adversarial-native']); }, 240_000); + // Value: protects=Step 11's outside voice is never the native voice's or the lane's model family; fails_when=the STOP checks only that some model was given (lib/capy-review-pass.js before GSTA-167 PR B), so a Claude outside voice beside a Claude native voice runs as "outside"; + // why_new=GSTA-167 plan R2 (autoplan-20261031135232); seam=none; no_claim=which family pairs find more + test('a same-family explicit outside voice STOPs before either voice is created', async () => { + const { dir, env } = repo(); + const h = host(dir, env); + const base = { base: 'main', out: path.join(dir, '.state/run/p1'), pass: 'x14p1', planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + expect(await runPass(h, { ...base, stage: 'voices', models: { 'outside-voice': { model_id: 'anthropic/claude-sonnet-5-5', reasoning_mode: 'medium' } } })) + .toContain("STOP: the outside voice anthropic/claude-sonnet-5-5 is the anthropic family, the same as the lane model; Step 11 needs input.models['outside-voice'] from another family"); + expect(await runPass(h, { ...base, stage: 'voices', models: { 'outside-voice': { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'medium' }, 'adversarial-native': { model_id: 'openai/gpt-6-astra', reasoning_mode: 'high' } } })) + .toContain('STOP: the outside voice openai/gpt-6.1-sol is the openai family, the same as the native voice'); + expect(h.creates.some(c => /^ship x14p1 (adversarial-native|outside-voice)$/.test(c.title))).toBe(false); + }, 240_000); + + // Value: protects=a Step 11 rerun on one manifest keeps refusing a same-family outside voice, including one queued at the subagent cap that tools.subagent.start would pick up; fails_when=the family check runs only on the first render, so a resumed or queued voice starts unchecked; + // why_new=GSTA-167 plan R2; seam=none; no_claim=the host's cap timing + test('a Step 11 rerun on one manifest still refuses a same-family outside voice, rendered or queued', async () => { + const same = { 'outside-voice': { model_id: 'anthropic/claude-sonnet-5-5', reasoning_mode: 'medium' } }; + const refused = /STOP: the outside voice anthropic\/claude-sonnet-5-5 is the anthropic family/; + const { dir, env } = repo(); + const h = host(dir, env); + const base = { base: 'main', out: path.join(dir, '.state/run/p1'), pass: 'x15p1', planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + expect(await runPass(h, { ...base, stage: 'voices', models: same })).toMatch(refused); + expect(await runPass(h, { ...base, stage: 'voices', models: same })).toMatch(refused); + expect(h.creates.some(c => /^ship x15p1 (adversarial-native|outside-voice)$/.test(c.title))).toBe(false); + + const b = repo(); + const h2 = host(b.dir, b.env, { reject: t => t.endsWith('outside-voice') }); + const base2 = { ...base, out: path.join(b.dir, '.state/run/p1'), pass: 'x18p1' }; + expect(await runPass(h2, base2)).toContain('NEXT: judge (Phase 4)'); + expect(await runPass(h2, { ...base2, stage: 'voices', budgetMs: 1 })).toMatch(/DISPATCHED: outside-voice path=\d+ queued/); + const starts = h2.starts.length; + const recorded = path.join(base2.out, 'voice-models.json'); + const kept = fs.readFileSync(recorded, 'utf8'); + fs.rmSync(recorded); + expect(await runPass(h2, { ...base2, stage: 'voices', models: same })).toMatch(refused); + expect(h2.starts.length).toBe(starts); + fs.writeFileSync(recorded, kept); + const resumed = await runPass(h2, { ...base2, stage: 'voices', models: same }); + expect(resumed).toContain('OUTSIDE_VOICE: openai/gpt-6.1-sol reasoning=medium as dispatched, started from the queue (certified configuration)'); + expect(h2.creates.find(c => c.title === 'ship x18p1 outside-voice')!.model).toEqual({ model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'medium' }); + }, 300_000); + + // Value: protects=a queued outside voice resumes on the models recorded when the voices were dispatched, so changed rerun inputs cannot pair it with a native voice of its own family; fails_when=the rerun's input decides the queued voice's model and the native family (outside-voice finding on #3698: two OpenAI voices started); + // why_new=/ship Step 11 review of #3698; seam=none; no_claim=inputs that change between reruns on purpose + test('a queued outside voice resumes on its dispatched model, checked against the dispatched native family', async () => { + const { dir, env } = repo(); + const h = host(dir, env, { reject: t => t.endsWith('outside-voice') }); + const base = { base: 'main', out: path.join(dir, '.state/run/p1'), pass: 'x19p1', planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + const first = { 'adversarial-native': { model_id: 'openai/gpt-6-astra', reasoning_mode: 'high' }, 'outside-voice': { model_id: 'google/gemini-4-argon', reasoning_mode: 'medium' } }; + expect(await runPass(h, { ...base, stage: 'voices', models: first, budgetMs: 1 })).toMatch(/DISPATCHED: outside-voice path=\d+ queued/); + const res = await runPass(h, { ...base, stage: 'voices' }); + expect(res).not.toContain('STOP:'); + expect(res).toContain('OUTSIDE_VOICE: google/gemini-4-argon reasoning=medium as dispatched, started from the queue; outside the certified configuration'); + expect(h.creates.find(c => c.title === 'ship x19p1 outside-voice')!.model).toEqual(first['outside-voice']); + expect(h.creates.find(c => c.title === 'ship x19p1 adversarial-native')!.model).toEqual(first['adversarial-native']); + }, 300_000); + + // Value: protects=a lane whose family is unknown or not Anthropic gets no default outside voice, and an explicit one cannot skip the family check; fails_when=the pin applies to a lane it was not certified for, or an outside voice runs with nothing to compare its family to; + // why_new=GSTA-167 plan R2; seam=none; no_claim=an OpenAI lane's pairing, which is uncertified + test('a missing lane identity STOPs the outside voice: none given, or given with nothing to compare its family to', async () => { + const { dir, env } = repo(); + const h = host(dir, env); + const base = { base: 'main', out: path.join(dir, '.state/run/p1'), pass: 'x16p1', planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + const none = "STOP: the outside voice needs input.models['outside-voice'] (a model of another family); none was given"; + expect(await runPass(h, { ...base, stage: 'voices', laneModel: undefined })).toContain(none); + expect(await runPass(h, { ...base, stage: 'voices', laneModel: 'openai/gpt-6.1-sol' })).toContain(none); + expect(await runPass(h, { ...base, stage: 'voices', laneModel: undefined, models: { 'outside-voice': { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'medium' } } })) + .toContain('STOP: the outside voice openai/gpt-6.1-sol cannot be checked against another family: neither input.laneModel nor a native voice model was given'); + expect(await runPass(h, { ...base, stage: 'voices', models: { 'outside-voice': { model_id: 'claude-sonnet-5-5' } } })) + .toContain('STOP: the outside voice claude-sonnet-5-5 has no provider prefix, so its family cannot be checked'); + expect(await runPass(h, { ...base, stage: 'voices', models: { 'outside-voice': { model_id: 'Anthropic/claude-sonnet-5-5' } } })) + .toContain('STOP: the outside voice Anthropic/claude-sonnet-5-5 is the anthropic family, the same as the lane model'); + expect(await runPass(h, { ...base, stage: 'voices', laneModel: 'claude-opus-5-5', models: { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' } } })) + .toContain('STOP: the outside voice openai/gpt-6.1-sol cannot be checked against another family: the lane model claude-opus-5-5 has no provider prefix and no native voice model was given'); + expect(h.creates.some(c => /^ship x16p1 (adversarial-native|outside-voice)$/.test(c.title))).toBe(false); + }, 240_000); + + const certified = { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'medium' }; + async function voicesRun(dir: string, env: NodeJS.ProcessEnv, sub: string, models?: Record) { + const out = path.join(dir, '.state/run', sub); + const h = host(dir, env); + const base = { base: 'main', out, pass: 'x17p1', planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + const res = await runPass(h, { ...base, stage: 'voices', ...(models ? { models } : {}) }); + const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8')) as Manifest; + for (const r of m.readers) expect(h.creates.find(c => c.title === r.title)!.prompt).toBe(fs.readFileSync(r.file!, 'utf8')); + const shas = Object.fromEntries(h.creates.map(c => [c.title, promptHash(c.prompt.split(out).join(''))])); + return { res, shas, models: Object.fromEntries(h.creates.map(c => [c.title, c.model])) }; + } + + // Value: protects=an Anthropic lane's Step 11 gets the certified outside voice (openai/gpt-6.1-sol at medium, as rounds 3-6 and A3668 ran) without naming it, and every brief goes out byte for byte as it does with that model named; fails_when=the lane must pick the outside voice (gpt-6-astra in 6c/7e/8g), or the pin changes any reader's brief or another reader's model; + // why_new=GSTA-167 plan R2; seam=none; no_claim=the certified pair's recall, which arm-L6 measured + test('an anthropic lane defaults the outside voice to the certified model, with every brief byte-identical to naming it', async () => { + const { dir, env } = repo(); + const pinned = await voicesRun(dir, env, 'pin'); + expect(pinned.res).toContain('OUTSIDE_VOICE: openai/gpt-6.1-sol reasoning=medium default for an anthropic lane (certified configuration)'); + expect(pinned.res).toContain('NEXT: judge the voices'); + expect(pinned.models['ship x17p1 outside-voice']).toEqual(certified); + const named = await voicesRun(dir, env, 'named', { 'outside-voice': certified }); + expect(named.res).toContain('OUTSIDE_VOICE: openai/gpt-6.1-sol reasoning=medium given (certified configuration)'); + expect(named.shas).toEqual(pinned.shas); + expect(named.models).toEqual(pinned.models); + }, 300_000); + + // Value: protects=an explicit cross-family override is accepted but its pass output says it is outside the certified configuration; fails_when=a changed outside-voice model or reasoning passes silently as if certified; + // why_new=GSTA-167 plan R2; seam=none; no_claim=the uncertified configuration's recall + test('an explicit outside-voice override with changed reasoning runs and is recorded as uncertified, other briefs unchanged', async () => { + const { dir, env } = repo(); + const high = { model_id: 'openai/gpt-6.1-sol', reasoning_mode: 'high' }; + const over = await voicesRun(dir, env, 'over', { 'outside-voice': high }); + expect(over.res).toContain('OUTSIDE_VOICE: openai/gpt-6.1-sol reasoning=high given; outside the certified configuration (openai/gpt-6.1-sol at medium beside an anthropic lane)'); + expect(over.res).toContain('NEXT: judge the voices'); + expect(over.models['ship x17p1 outside-voice']).toEqual(high); + const named = await voicesRun(dir, env, 'named', { 'outside-voice': certified }); + expect(over.shas).toEqual(named.shas); + for (const t of Object.keys(named.models)) if (t !== 'ship x17p1 outside-voice') expect(over.models[t]).toEqual(named.models[t]); + }, 300_000); + test('an audit reader marked first settles before any specialist starts; its output is returned, never merged', async () => { const { dir, env } = repo(); const out = path.join(dir, '.state/run/p1'); diff --git a/test/ship-driver.test.ts b/test/ship-driver.test.ts index 58f9dc471..2558d83d3 100644 --- a/test/ship-driver.test.ts +++ b/test/ship-driver.test.ts @@ -222,11 +222,11 @@ describe('variant G: the call plan, observe budget, waits and audit readers (GST // Value: protects=audits that cannot block (Steps 7, 8, 14.5) run on a small model at low effort with their sections' own briefs, and skip their reader when --audit decides them; fails_when=an audit reader inherits the lane's model and effort (#36: the coverage audit alone was 2.45M at Opus), or an audit brief is paraphrased; // why_new=audit model routing is new; seam=none; no_claim=the audits' recall test('audit readers run on a small model at low effort, named for Capy, and Phase 3 dispatches only what READER names', () => { - expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 14.5) cannot block, so each runs on a small model at low effort; their findings go to the PR body'); + expect(flat(doc)).toContain('Audit readers (Steps 7, 8, 14.5) cannot block, so each runs at low effort on a small model, Step 14.5 once on Sonnet 5.5; findings go to the PR body'); expect(flat(doc)).toContain('`Coverage:` and Step 8\'s line come from `--audit` when no reader ran'); const capy = read('capy/skill/gstack/SKILL.md'); expect(capy).toContain('model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }'); - expect(capy).toContain('`anthropic/claude-haiku-5-5` also fits'); + expect(capy).toContain('(14.5 once)'); }); // Value: protects=Step 9.2.1's report-only probes run in the parent's own Phase 4 program, as the section says, with its Reads in Phase 3, and From 0ee985b42d5cb8516bc4e41a63d8dd230dc2d860 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 00:12:24 -0800 Subject: [PATCH 03/11] v1.92.65.0 fix(review): every reviewer brief opens with the REVIEWED_TREE step (GST-897) (#3830) * fix(review): open every outside-voice brief with the REVIEWED_TREE step (GST-897) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: register the review-wrong-tree fixtures as free (GST-897) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: GST-897 fragment 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> --- CHANGELOG.md | 7 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/review-policy.md | 14 +++++---- lib/review-brief.ts | 21 +++++++++---- package.json | 2 +- .../gpt-6.1-sol-gst850-p1.out | 11 +++++++ .../gpt-6.1-sol-gst850-p2.out | 7 +++++ test/helpers/free-fixtures-data.ts | 1 + test/review-dispatch.test.ts | 30 +++++++++++++++++-- test/review-pass.test.ts | 5 ++-- 11 files changed, 83 insertions(+), 19 deletions(-) create mode 100644 test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p1.out create mode 100644 test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p2.out diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9576817..2dd3f6f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.92.65.0] - 2026-11-02 + +- **Every /ship reviewer brief now starts with the `REVIEWED_TREE` step instead of saving it for the last paragraph** (`lib/review-brief.ts`, GST-897). Before, the brief only asked for the fingerprint line in its closing paragraph, after the whole diff and a rule that the reply ends with its `Recommendation:` line. On GST-850, two gpt-6.1-sol outside-voice replies never ran the command, so `collect` refused both (`COLLECT_WRONG_TREE reported="none"`) and their findings were lost. Other readers misread "the line prints" and pasted the command instead of its output. Now the brief opens with "First step, before anything else: run `cd && echo "REVIEWED_TREE: …"`", says the command outputs one `REVIEWED_TREE: ` line, and asks the reader to copy that output line, not the command, as the first line of its reply. The closing paragraph repeats the request with "(the output, not the command)". `collect` is unchanged and still refuses a reply without the line, and the dispatcher never stamps the tree for a reader. + +### Tests +- `test/review-dispatch.test.ts`: the rendered outside-voice brief's first line is the step, running its command prints a line `collect` accepts, and both captured GST-850 replies (`test/fixtures/review-wrong-tree/`) are still refused with `COLLECT_WRONG_TREE`. On main this test fails because the brief's first line is the filesystem boundary paragraph. + ## [1.92.64.0] - 2026-11-02 **/ship's Step 11 outside voice now defaults to the model it was tested with, and a same-family outside voice is refused.** On a lane running an Anthropic model, the pass program uses `openai/gpt-6.1-sol` at medium reasoning when no outside-voice model is given. That is the pairing no-loss rounds 3 to 6 and A3668 ran. Until now the lane picked the model (gpt-6-astra in replays 6c, 7e and 8g), and the only check was that some model was given, so a Claude outside voice beside a Claude native voice would have run as "outside" (GSTA-167 plan R2 and O2). diff --git a/VERSION b/VERSION index 3fa9ff907..b0f02decb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.64.0 +1.92.65.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 8fa12fa27..686a682c7 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.64.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.65.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/review-policy.md b/docs/review-policy.md index 51182ed28..41c088245 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -580,12 +580,14 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: fix-check plan renders its reader from `--fix-list`, `--hunks`, `--tests` and `--callers`, `--audit-only` runs just the `--extras` readers (Step 14.5's docs audit), and the fork point is found against `origin/` first, as preflight does. -7. Every brief, audit readers' included, ends with a workspace paragraph naming the - checkout the bins run in (GST-482): a reader on a shared machine starts in the - machine's working directory, so it is told to `cd` there on every command, to give - every file tool an absolute path under it, and to start its reply with - `REVIEWED_TREE: ` measured there. The paragraph follows the - section's render, which stays byte for byte. `collect` and `mark --state collected` +7. Every brief, audit readers' included, opens with a fingerprint step and ends with a + workspace paragraph naming the checkout the bins run in (GST-482): a reader on a shared + machine starts in the machine's working directory, so it is told to `cd` there on every + command, to give every file tool an absolute path under it, and to start its reply with + the `REVIEWED_TREE: ` line the step's command outputs there. The + step comes first because a reader following a findings-only format skipped the request + when it only closed the brief (GST-897). The section's render sits between the two, + byte for byte. `collect` and `mark --state collected` compare that line with the fingerprint recorded at render: a missing or foreign line is `COLLECT_WRONG_TREE`, a later commit in the same checkout `COLLECT_TREE_MOVED`; nothing is merged, the reader becomes `failed`, and `verify` reports `NOT_COLLECTED`. An diff --git a/lib/review-brief.ts b/lib/review-brief.ts index fe1a3a5e7..b233aa40b 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -144,16 +144,27 @@ function renderRole(role: BriefRole, slots: Slots, t: SectionTexts): string { } } +const quoted = (dir: string) => `'${dir.replace(/'/g, `'\\''`)}'`; +const treeCommand = (dir: string) => `cd ${quoted(dir)} && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"`; + +/** + * The opening step every dispatched brief carries (GST-897): the fingerprint command and what to copy from it. A reader that + * follows a findings-only output format can skip a request that only closes a long brief (gpt-6.1-sol did on GST-850), and + * "the line prints" was read as the command itself, so the step comes first and names the output line. + */ +export function workspaceStep(dir: string): string { + return `First step, before anything else: run \`${treeCommand(dir)}\`. It outputs one line, \`REVIEWED_TREE: \`. Copy that output line, not the command, as the first line of your final reply, above everything else, even where this brief limits your output to findings or says how to end it; a reply without it, or from another checkout, does not count.`; +} + /** * The closing paragraph every dispatched brief carries (GST-482): a reader on a shared machine starts in that machine's working - * directory, and every command runs in a fresh shell, so the brief names the checkout under review and asks for a fingerprint - * measured there. It is appended after the section's render, so the brief before it stays byte for byte what renderBrief returns. + * directory, and every command runs in a fresh shell, so the brief names the checkout under review and asks again for the + * fingerprint workspaceStep opens with. The section's render sits between the two, byte for byte what renderBrief returns. */ export function workspaceParagraph(dir: string): string { - const q = `'${dir.replace(/'/g, `'\\''`)}'`; - return `Workspace: the checkout under review is ${dir}. Your shell and file tools may start in another checkout, and every command runs in a fresh shell: begin each command with \`cd ${q} && \`, and give every file read, search, edit or write an absolute path under ${dir}. Make the first line of your final reply the line \`cd ${q} && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"\` prints, even where this brief limits your output to findings; a reply without it, or from another checkout, does not count.`; + return `Workspace: the checkout under review is ${dir}. Your shell and file tools may start in another checkout, and every command runs in a fresh shell: begin each command with \`cd ${quoted(dir)} && \`, and give every file read, search, edit or write an absolute path under ${dir}. The first line of your final reply is the \`REVIEWED_TREE: \` line that \`${treeCommand(dir)}\` outputs (the output, not the command), even where this brief limits your output to findings; a reply without it, or from another checkout, does not count.`; } -export const withWorkspace = (text: string, dir: string) => `${text}\n\n${workspaceParagraph(dir)}`; +export const withWorkspace = (text: string, dir: string) => `${workspaceStep(dir)}\n\n${text}\n\n${workspaceParagraph(dir)}`; /** The checkout a brief names: the toplevel of the directory the bins run in, where its diff and merge base come from. */ export function checkoutRoot(): string { const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 30_000 }); diff --git a/package.json b/package.json index f10ba2e02..4afa308b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.64", + "version": "1.92.65", "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/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p1.out b/test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p1.out new file mode 100644 index 000000000..fc647463e --- /dev/null +++ b/test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p1.out @@ -0,0 +1,11 @@ +1. **Escaped executable URLs bypass the locale checks** — `lib/review-allowlist.ts:143–145,188–192`. Changing a consumed locale JSON value from `"Continue"` to `"java\nscript:alert(1)"` returns `docs` with zero reviewers. Checks inspect the escaped source, but JSON decoding followed by URL parsing produces `javascript:`. A translated `href` can therefore gain script execution without security review. Validate decoded values, including URL normalization. Severity: High | Blocks: yes + +2. **SVG attribute interpolation is still executable template content** — `lib/review-allowlist.ts:35–44`. `Label` returns `docs` with zero reviewers. AngularJS `templateUrl`/`$compile` consumers evaluate that attribute. Numeric references also bypass the text check: `{{run()}}` is accepted. Reject interpolation in attributes and inspect decoded text, or reject numeric references. Severity: High | Blocks: yes + +3. **YAML type changes qualify as string-only edits** — `lib/review-allowlist.ts:163`. `label: Save` → `label: null` or `label: false` returns `docs` with zero reviewers. Native YAML parsing produces null/boolean, not strings; the null reproduction crashes a string consumer. Require string-valued scalars on both sides. Severity: Medium | Blocks: yes + +4. **Named gettext placeholders aren't preserved** — `lib/review-allowlist.ts:115,188–192`. `msgstr "Hello %(name)s"` → `msgstr "Hello %(password)s"` is accepted as docs because neither placeholder matches `PLACEHOLDER`. Formatting with the original arguments raises `KeyError('password')`, reproduced with Python. Recognize named percent placeholders or reject unfamiliar percent syntax. Severity: Medium | Blocks: yes + +5. **The new import scanner is quadratic** — `lib/review-risk.ts:72,107`. A valid comment containing repeated `import {` forces rescanning from every occurrence. At this HEAD, 128 KB took 1.04 seconds and 524 KB took 13.35 seconds for one `matchRisk` call; the planner calls it twice. Larger attacker-controlled comments can stall review and queue validation. Use a forward-only scanner. Severity: Medium | Blocks: yes + +Recommendation: request changes because escaped locale URLs can introduce executable javascript: links while scheduling zero reviewers. \ No newline at end of file diff --git a/test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p2.out b/test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p2.out new file mode 100644 index 000000000..a0f21e15d --- /dev/null +++ b/test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-p2.out @@ -0,0 +1,7 @@ +1. **Quadratic regex lets a diff stall review planning** — `lib/review-risk.ts:60`. The trailing `\s*,?\s*$` repeatedly repartitions whitespace when an `exec` binding ends with a comment. On HEAD `c1eea4c`, passing `" exec" + " ".repeat(200000) + "// comment"` to `matchRisk()` took **15.47 seconds**, versus **0.69 ms** on the supplied base. At 50,000 and 100,000 spaces, HEAD took 0.95 and 3.94 seconds, confirming quadratic growth. This is valid inside a multiline import; larger diffs can monopolize the synchronous review classifier. Remove the overlapping whitespace repetitions and add this adversarial regression. Severity: High. Blocks: yes + +2. **A literal first argument incorrectly exempts input-controlled destinations** — `lib/review-risk.ts:50–52`. `requests.request("GET", user_url)` and `httpx.request("POST", user_url)` pass a method first, but `NOT_LITERAL` treats it as a safe URL. Node’s `http.get("http://public.example/", { hostname: req.query.target }, cb)` also lets options override the literal destination. All three reproduce `risk=[]`, `depth=light`, omitting the separate Red Team and outside voice. A local Node server confirmed that the hostname override changes the actual destination. Handle destination arguments and overriding options according to each API’s signature. Severity: Medium. Blocks: yes + +3. **One type-only specifier hides executable child-process imports** — `lib/review-risk.ts:79`. A complete multiline import containing `type ExecOptions, exec as run,` is rejected wholesale by `!/^type\b/.test(names)`. With `run(cmd)` afterward, the classifier returns no security risk and light depth. Bun’s TypeScript transpiler confirms this retains the executable `exec as run` import. Check individual value specifiers instead of excluding the entire clause when its first specifier is type-only. Severity: Medium. Blocks: yes + +Recommendation: request changes because a whitespace-padded exec binding causes quadratic CPU consumption in the synchronous review classifier. \ No newline at end of file diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index ec16a90f8..7f7f14f1b 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -208,6 +208,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/review-risk/gbrain-evals-289.json': ['test/review-risk-routing.test.ts'], 'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'], 'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'], + 'test/fixtures/review-wrong-tree/**': ['test/review-dispatch.test.ts'], 'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'], 'test/fixtures/shard-cli-parity/**': ['test/shard-cli-parity.test.ts'], 'test/fixtures/shard-equivalence/**': ['test/shard-engine-equivalence.test.ts'], diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index e1aa85d8d..f6874e886 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -3,7 +3,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { BriefError, checkReviewedTree, promptHash, renderBrief, sectionTexts, workspaceParagraph } from '../lib/review-brief'; +import { BriefError, checkReviewedTree, promptHash, renderBrief, sectionTexts, workspaceParagraph, workspaceStep } from '../lib/review-brief'; import { collect, judge, parseFindings, readJsonl, type MergedLine } from '../lib/review-ledger'; const ROOT = path.resolve(import.meta.dir, '..'); @@ -705,7 +705,7 @@ describe('the workspace paragraph (GST-482)', () => { const r = run(['render', '--role', role, '--out', out, ...(role === 'fix-check' ? [] : ['--diff-base', SHA]), ...args]); expect(r.status, role + ': ' + r.stderr).toBe(0); const main = renderBrief(role as Parameters[0], { diff_base: role === 'fix-check' ? undefined : SHA, ...slots }, t); - expect(fs.readFileSync(field(r.stdout, 'file'), 'utf8'), role).toBe(main + '\n\n' + workspaceParagraph(top)); + expect(fs.readFileSync(field(r.stdout, 'file'), 'utf8'), role).toBe(workspaceStep(top) + '\n\n' + main + '\n\n' + workspaceParagraph(top)); } expect(workspaceParagraph(top)).toContain(`begin each command with \`cd '${top}' && \``); expect(workspaceParagraph(top)).toContain(`absolute path under ${top}`); @@ -761,6 +761,32 @@ describe('reading the REVIEWED_TREE line (GST-482 review fixes)', () => { expect([u.status, u.stdout.startsWith('COLLECT_UNAVAILABLE: reader=outside-voice')]).toEqual([0, true]); }); + // Value: protects=the fingerprint request opens every brief and asks for the command's output line, so a reader that + // follows a findings-only format still binds its tree; the captured gpt-6.1-sol replies from GST-850, which never ran + // the command, stay refused; fails_when=the request only closes the brief (GST-850 p1/p2 COLLECT_WRONG_TREE) or reads + // as "copy the command", or collect relaxes for a reply with findings and no line; seam=lib/review-brief.ts + // withWorkspace, bin/gstack-review-dispatch collect; no_claim=a model's compliance (the GST-897 replay measures that) + test('the fingerprint step opens the brief and its command prints a line collect accepts; GST-850 gpt-6.1-sol replies stay refused (GST-897)', () => { + const out = tmp(); + const ledger = path.join(out, 'review-ledger.jsonl'); + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + const file = field(run(['render', '--role', 'outside-voice', '--out', out, '--diff-base', head]).stdout, 'file'); + const brief = fs.readFileSync(file, 'utf8'); + const first = brief.split('\n')[0]!; + expect(first).toStartWith(`First step, before anything else: run \`cd '${top}' && echo "REVIEWED_TREE: `); + expect(first).toContain('Copy that output line, not the command, as the first line of your final reply'); + expect(brief.slice(brief.lastIndexOf('\n\n') + 2)).toContain('(the output, not the command)'); + const command = /run `([^`]+)`/.exec(first)![1]!; + const printed = spawnSync('bash', ['-c', command], { cwd: os.tmpdir(), encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + expect(checkReviewedTree(printed + '\nNO FINDINGS', expected).ok).toBe(true); + run(['sent', '--ledger', ledger, '--role', 'outside-voice', '--sent', file]); + for (const p of ['p1', 'p2']) { + const reply = path.join(ROOT, `test/fixtures/review-wrong-tree/gpt-6.1-sol-gst850-${p}.out`); + const refused = spawnSync(BIN, ['collect', '--ledger', ledger, '--role', 'outside-voice', '--output', reply, '--merged', path.join(out, `${p}.jsonl`)], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); + expect([p, refused.status, refused.stdout.startsWith('COLLECT_WRONG_TREE: reader=outside-voice reported="none"')]).toEqual([p, 1, true]); + } + }); + test('render outside a readable checkout refuses instead of recording a fingerprint no reply can match', () => { const dir = tmp(); const r = run(['render', '--role', 'adversarial-native', '--out', path.join(dir, 'o'), '--diff-base', SHA], dir); diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index e3ed0fee3..ec2666aaf 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -7,7 +7,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { promptHash, sectionTexts, withWorkspace } from '../lib/review-brief'; +import { promptHash, sectionTexts, withWorkspace, workspaceParagraph, workspaceStep } from '../lib/review-brief'; import { CHUNK_CHARS, PARENT_ONLY_QA, buildManifest, chunkCount, chunkOf, markReader, renderStage, specialistFindings, type Manifest } from '../lib/review-pass'; import type { Dispatch } from '../lib/review-depth'; @@ -944,7 +944,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 expect(res).not.toContain('STOP:'); expect(res).toContain('NEXT: judge (Phase 4)'); expect(h.creates.length).toBeGreaterThan(2); - for (const c of h.creates) expect(c.prompt.endsWith('\n\n' + withWorkspaceTail(wt)), c.title).toBe(true); + for (const c of h.creates) expect([c.prompt.startsWith(workspaceStep(wt) + '\n\n'), c.prompt.endsWith('\n\n' + workspaceParagraph(wt))], c.title).toEqual([true, true]); expect(bin(wt, env, ['verify', '--ledger', path.join(out, 'review-ledger.jsonl')]).stdout).not.toContain('wrong_tree'); }, 240_000); @@ -1011,5 +1011,4 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 expect(v).toMatch(/^BRIEF outside-voice UNAVAILABLE /m); }, 240_000); }); -const withWorkspaceTail = (dir: string) => withWorkspace('', dir).slice(2); const briefDirOf = (h: ReturnType, title: string) => briefDir(h.creates.find(c => c.title === title)?.prompt ?? ''); From 0c84f4ee14c32734313e68b700417e0748ad1d55 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 00:23:25 -0800 Subject: [PATCH 04/11] v1.92.66.0 fix(secrets): Python/Dart string context comes from the diffed text; scoped Red Team honors --diff-base (GST-738) (#3782) * fix(secrets): Python/Dart string context comes from the diffed text and skips interpolations; pass --diff-base reaches the scoped Red Team (GST-738) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): comments inside an interpolation hold no delimiter; a Dart block comment is text (GST-738) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): a Python format spec is text, and # is a comment only in a triple-quoted f-string (GST-738) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(secrets): suppressBlankEmpty cannot shift the diffed text; f-string \{ opens a field; interpolation depth is capped (GST-738) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): interpolation code is not string text; # is a comment in every f-string field (GST-738) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GST-738 fragment covers the review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: pregate and review-policy describe the GST-738 secrets context and --diff-base Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): untracked and oversized Python/Dart files in the secrets stage (GST-738 re-audit) 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> --- CHANGELOG.md | 9 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-review-dispatch | 3 +- docs/pregate.md | 12 +- docs/review-policy.md | 3 +- lib/pregate/checks/secrets.ts | 46 +++++-- lib/redact-patterns.ts | 136 +++++++++++++++++--- package.json | 2 +- test/pregate-secrets-source-context.test.ts | 28 +++- test/redact-engine.test.ts | 40 ++++++ test/review-pass.test.ts | 13 +- 12 files changed, 253 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd3f6f2c..b6a4528ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.92.66.0] - 2026-11-02 + +**The secrets check reads Python and Dart string context from what git diffed, and a quote inside an interpolation no longer ends a string (GST-738).** env.kv's source-syntax exemption treats a `password:` line as code only when it sits outside every string. Two inputs could make the scanner place a committed string's contents outside a string. One was a working copy that no longer matched what git diffed. The other was a quote inside an f-string or Dart `${…}` expression. A scoped review pass now applies an explicit `--diff-base` to its Red Team as well. + +### Fixed +- **`lib/pregate/checks/secrets.ts`:** a changed Python or Dart file's whole text comes from a full-context `git diff`, never from the working copy. A `diff.suppressBlankEmpty` setting can't shift its lines. +- **`lib/redact-patterns.ts`:** a Python f-string's `{…}`, or t-string's, and a non-raw Dart string's `${…}` are read as code. Their nested strings count as strings, and a quote or comment inside them doesn't end the outer string. A Python format spec, such as `{n:#x}`, is text, and `\{` still opens a field. A `password:` line inside a Dart `/* */` comment is now read as text, not code. An expression left open, or nested more than 256 deep, runs to the end of the file, so the exemption fails closed. +- **`bin/gstack-review-dispatch`:** `pass --diff-base` now sets the scoped Red Team's base too. + ## [1.92.65.0] - 2026-11-02 - **Every /ship reviewer brief now starts with the `REVIEWED_TREE` step instead of saving it for the last paragraph** (`lib/review-brief.ts`, GST-897). Before, the brief only asked for the fingerprint line in its closing paragraph, after the whole diff and a rule that the reply ends with its `Recommendation:` line. On GST-850, two gpt-6.1-sol outside-voice replies never ran the command, so `collect` refused both (`COLLECT_WRONG_TREE reported="none"`) and their findings were lost. Other readers misread "the line prints" and pasted the command instead of its output. Now the brief opens with "First step, before anything else: run `cd && echo "REVIEWED_TREE: …"`", says the command outputs one `REVIEWED_TREE: ` line, and asks the reader to copy that output line, not the command, as the first line of its reply. The closing paragraph repeats the request with "(the output, not the command)". `collect` is unchanged and still refuses a reply without the line, and the dispatcher never stamps the tree for a reader. diff --git a/VERSION b/VERSION index b0f02decb..b0d5ebd0b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.65.0 +1.92.66.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 686a682c7..01116c9ae 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.65.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.66.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-review-dispatch b/bin/gstack-review-dispatch index 4a5b471e3..4697c43b9 100755 --- a/bin/gstack-review-dispatch +++ b/bin/gstack-review-dispatch @@ -65,6 +65,7 @@ const HELP = `gstack-review-dispatch — verbatim reviewer briefs and loss-free specialists (with the other --extras readers), red-team, voices (Step 11). Renders first and specialists now; prints the plan, MANIFEST: and one READER: line per reader. A plan whose NEXT is not full, scoped or fix-check prints PASS: none. A fix-check reader (stage specialists) renders from decision 3.5's --fix-list, --hunks, --tests and --callers files. + --diff-base sets every reader's base, a scoped pass's Red Team included (the targeted reader keeps the merge base). An existing manifest for the same --pass is reused (resumed). --extras: [{"name","prompt_file","first"?,"model"?}] --audit-only skips the plan: the --extras readers are the whole pass (Step 14.5's docs audit) show --manifest MANIFEST_JSON: @@ -193,7 +194,7 @@ function pass(): number { const extras = extrasFile ? JSON.parse(readFile(extrasFile, 'extras')!) as Extra[] : []; const m = buildManifest({ pass: id, out, ledger: flag('--ledger') ?? path.join(out, 'review-ledger.jsonl'), base, diffBase, root, checklistRoot: flag('--checklist-root') ?? ROOT, dispatches: step === 'fix-check' ? p.dispatches : p.dispatches.filter(d => d.role !== 'fix-check'), next: step, planLines, learnings: readFile(flag('--learnings-file'), 'learnings') ?? null, extras, - targetedClasses: p.targeted?.classes, mergeBase, rangeBase, + targetedClasses: p.targeted?.classes, mergeBase, rangeBase: flag('--diff-base') ? undefined : rangeBase, fixCheck: step === 'fix-check' && (flag('--fix-list') || flag('--hunks') || flag('--tests')) ? { fix_list: flag('--fix-list')!, hunks: flag('--hunks')!, tests: flag('--tests')!, callers: flag('--callers') } : undefined }); fs.mkdirSync(m.out, { recursive: true, mode: 0o700 }); const t = sectionTexts(ROOT); diff --git a/docs/pregate.md b/docs/pregate.md index e3b9393b6..c7238d475 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -195,7 +195,10 @@ Two stages with separate clocks, both printed: changed Python (`.py`, `.pyi`) or Dart file is scanned whole and only findings on its added lines count, so a line added inside an existing `"""` or `'''` string is still read as a literal; its rows give the file's - line number (`app.py, line 10`). + line number (`app.py, line 10`). A tracked file's whole text is the version + git diffed (a full-context `git diff`), never the working copy; an untracked + file is read whole from disk. A file over the size limit, or whose full diff + does not parse, is scanned on its added lines alone, without that context. - `strays`: a new file at the repository root, a new top-level directory, or a scratch name (`zz-*`, `tmp*`, `scratch*`) anywhere. - `literals`: for every changed exported constant or named timeout/retry @@ -939,8 +942,11 @@ those, each with its scope and its reason: (`session: HttpSession,`) and a dict entry or Dart named argument that reads a name (`"password": request.password`) are code, not credentials, as they are in TypeScript (#3630). Python's keyword form (`password=request.password`) - still reports. The same line inside a `"""` docstring is text and still - reports; allowlist it by its value. The pre-push hook sees only added lines, + still reports. The same line inside a `"""` docstring, any other string or + a Dart `/* */` comment is text and still reports; allowlist it by its value. + Code inside a Python f-string or t-string `{…}` field, or a non-raw Dart + string's `${…}`, is read as code, and its nested strings and quotes do not + end the outer string (GST-738). The pre-push hook sees only added lines, so it scans Python and Dart lines without this exemption and may still warn (MEDIUM, non-blocking) on them. diff --git a/docs/review-policy.md b/docs/review-policy.md index 41c088245..2cf6a453b 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -567,7 +567,8 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: staged manifest (`/pass-manifest.json`): audit readers marked first, then the specialists with the other audit readers, then the Red Team, then Step 11's voices. A QA extra is refused before any reader is created: Step 9.2.1's probes - are the parent's. Each reviewer keeps its plan entry and is rendered by the same code as + are the parent's. `--diff-base` sets every reader's base, a scoped pass's Red + Team included; the targeted reader keeps the merge base. Each reviewer keeps its plan entry and is rendered by the same code as `render`, once its inputs exist; the Red Team's findings file is the specialists' `merged.jsonl` lines byte for byte. A brief leaves the bin in indexed chunks of at most 65,536 characters (`chunk`), so no tool output line carries a whole brief and a diff --git a/lib/pregate/checks/secrets.ts b/lib/pregate/checks/secrets.ts index 934def71c..17572c1f4 100644 --- a/lib/pregate/checks/secrets.ts +++ b/lib/pregate/checks/secrets.ts @@ -49,6 +49,26 @@ export function parseAddedLineNumbers(diff: string): Map { return files; } +/** Each file's whole postimage from a diff with full context: one hunk starting at line 1, its context and added lines; a file with any other shape is left out. */ +export function parsePostimages(diff: string): Map { + const files = new Map(); + let file = ''; + let lines: string[] | null = null; + for (const line of diff.split('\n')) { + if (line.startsWith('diff --')) { file = ''; lines = null; continue; } + if (line.startsWith('@@')) { + const first: boolean = lines === null && file !== '' && /^@@ -\d+(?:,\d+)? \+1(?:,\d+)? @@/.test(line); + files.delete(file); + lines = first ? [] : null; + if (lines) files.set(file, lines); else file = ''; + continue; + } + if (lines === null) { if (line.startsWith('+++ ')) file = line.startsWith('+++ b/') ? line.slice(6) : ''; continue; } + if (line.startsWith('+') || line.startsWith(' ')) lines.push(line.slice(1)); + } + return files; +} + /** Files whose source-syntax exemption needs the whole file: a line added inside an existing `'''`/`"""` block is a literal (#3630). */ const LEXICAL_CONTEXT_SOURCE = /\.(?:dart|pyi?)$/i; const WHOLE_FILE_MAX_BYTES = 4 * 1024 * 1024; @@ -58,10 +78,11 @@ const WHOLE_FILE_MAX_BYTES = 4 * 1024 * 1024; * untracked files, grouped by file so gstack-redact can use each file's type * (#3211). A tracked Dart or Python file is passed whole with `keep`, its * added line numbers, so the scanner sees the strings an added line sits in - * and only findings on those lines count (#3630); an untracked one keeps every - * line. One too large, unreadable, not a regular file, or whose working copy - * no longer holds the diff's added lines at their numbers - * to read whole is scanned under a neutral name, without that exemption. External diff and textconv + * and only findings on those lines count (#3630). The whole text is the + * postimage git diffed (a full-context diff), never the working copy, which + * assume-unchanged can hide from git (GST-738); an untracked file keeps every + * line. One too large, or whose full diff does not parse as one hunk holding + * its added lines, is scanned under a neutral name, without that exemption. External diff and textconv * drivers are off: either can empty or rewrite the diff, and an empty diff * reads as clean. */ @@ -70,16 +91,15 @@ export function addedLines(ctx: PregateContext): Array<{ file: string; text: str const files = parseAddedLines(diff.stdout ?? ''); const numbers = parseAddedLineNumbers(diff.stdout ?? ''); const whole = new Map(); - for (const [file, keep] of numbers) { - if (!LEXICAL_CONTEXT_SOURCE.test(file)) continue; - try { - const st = fs.lstatSync(path.join(ctx.repoRoot, file)); - if (!st.isFile() || st.size > WHOLE_FILE_MAX_BYTES) continue; - const text = fs.readFileSync(path.join(ctx.repoRoot, file), 'utf8'); - const lines = text.split('\n'); + const sources = [...numbers.keys()].filter(file => LEXICAL_CONTEXT_SOURCE.test(file)); + if (sources.length) { + const full = spawnSync('git', ['-c', 'diff.suppressBlankEmpty=false', 'diff', '--no-color', '--no-ext-diff', '--no-textconv', '--src-prefix=a/', '--dst-prefix=b/', '--unified=100000000', '--diff-filter=ACMR', ctx.mergeBase, '--', ...sources.map(file => `:(literal)${file}`)], { cwd: ctx.repoRoot, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024, env: ctx.env }); + for (const [file, post] of parsePostimages(full.status === 0 ? full.stdout ?? '' : '')) { + const keep = numbers.get(file) ?? []; const added = files.get(file) ?? []; - if (keep.length === added.length && keep.every((n, k) => lines[n - 1] === added[k])) whole.set(file, { text, keep }); - } catch { /* unreadable: its added lines are scanned alone */ } + const text = post.join('\n'); + if (text.length <= WHOLE_FILE_MAX_BYTES && keep.length === added.length && keep.every((n, k) => post[n - 1] === added[k])) whole.set(file, { text, keep }); + } } for (const t of ctx.touched) { if (!t.uncommitted || t.status === 'D') continue; diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 66d837b71..1d7ef39b9 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -694,7 +694,8 @@ const CODE_NAME_CHAIN = /^[A-Za-z_$][\w$]*(?:\??\.[A-Za-z_$][\w$]*)*(?:<[\w$.,\s * text) records every string's content span: a `'''`/`"""` block, or a * `'`/`"` string that ends at its line unless a backslash continues it. * Comments are skipped (`#` in Python; `//` and nested `/* *\/` in Dart), so a - * delimiter inside one opens nothing; a backslash escapes the next character, except in a Dart raw + * delimiter inside one opens nothing, and a Dart block comment's own span is + * recorded too: a `password:` line in it is text, not code (GST-738); a backslash escapes the next character, except in a Dart raw * string (`r'''…'''`). The exemption needs the * whole file to see a block's opener: pregate passes Dart and Python files * whole, and the pre-push hook, which scans added lines alone, passes them @@ -708,34 +709,133 @@ export function clearStringSpanCache(): void { stringSpansCache = null; } -function stringSpans(text: string, lang: "py" | "dart"): Array<[number, number]> { +/** The index after the comment at `i`: a line comment ends at its newline, a Dart block comment at its matching `*\/` (they nest). */ +function commentEnd(text: string, i: number, lang: "py" | "dart"): number { + if (lang === "py" || !text.startsWith("/*", i)) { + const eol = text.indexOf("\n", i); + return eol < 0 ? text.length : eol; + } + let depth = 1; + let j = i + 2; + while (j < text.length && depth > 0) { + if (text.startsWith("/*", j)) { depth += 1; j += 2; } + else if (text.startsWith("*/", j)) { depth -= 1; j += 2; } + else j += 1; + } + return j; +} + +const MAX_INTERPOLATION_DEPTH = 256; + +type Spans = Array<[number, number]>; + +/** + * The index after a Dart `${…}` expression starting at `j`. Its code is not + * text; its strings and block comments add their own spans. + */ +function dartExpressionEnd(text: string, j: number, depth: number, spans: Spans): number { + if (depth > MAX_INTERPOLATION_DEPTH) return text.length; + for (let open = 1; j < text.length && open > 0; ) { + const d = text[j]!; + if (text.startsWith("//", j)) { j = commentEnd(text, j, "dart"); continue; } + if (text.startsWith("/*", j)) { const end = commentEnd(text, j, "dart"); spans.push([j, end]); j = end; continue; } + if (d === '"' || d === "'") { j = stringAt(text, j, "dart", depth, spans); continue; } + if (d === "{") open += 1; + else if (d === "}") open -= 1; + j += 1; + } + return Math.min(j, text.length); +} + +/** + * The index after a Python replacement field whose expression starts at `j`. + * The expression is code: its `#` comments are skipped and its strings add + * their own spans. A `:` or `!` outside its brackets starts the conversion and + * format spec, which is text apart from nested `{…}` fields (`{n:#x}`, `{n:'>10}`). + */ +function pythonFieldEnd(text: string, j: number, depth: number, spans: Spans): number { + const n = text.length; + if (depth > MAX_INTERPOLATION_DEPTH) return n; + let nest = 0; + while (j < n) { + const d = text[j]!; + if (d === "#") { j = commentEnd(text, j, "py"); continue; } + if (d === '"' || d === "'") { j = stringAt(text, j, "py", depth, spans); continue; } + if (d === "}" && nest === 0) return j + 1; + if (nest === 0 && (d === ":" || (d === "!" && text[j + 1] !== "="))) { + let from = j + 1; + for (j += 1; j < n && text[j] !== "}"; ) { + if (text[j] !== "{") { j += 1; continue; } + if (from < j) spans.push([from, j]); + j = pythonFieldEnd(text, j + 1, depth + 1, spans); + from = j; + } + if (from < Math.min(j, n)) spans.push([from, Math.min(j, n)]); + return Math.min(j + 1, n); + } + if ("([{".includes(d)) nest += 1; + else if (")]}".includes(d)) nest = Math.max(0, nest - 1); + j += 1; + } + return n; +} + +/** + * Adds the text spans of the string whose quote is at `i` and returns the index + * after its closing delimiter. An interpolated expression (`{…}` in a Python + * f- or t-string, `${…}` in a non-raw Dart string) is code: it is skipped + * whole, with its nested strings as their own spans, so a quote inside it never + * ends the outer string (GST-738). An expression left open, or nested past + * MAX_INTERPOLATION_DEPTH, is text to the end of the file. In a Python f-string + * `\{` still opens a field. + */ +function stringAt(text: string, i: number, lang: "py" | "dart", depth: number, spans: Spans): number { + const n = text.length; + const c = text[i]!; + const close = text.startsWith(c.repeat(3), i) ? c.repeat(3) : c; + const multiline = close.length === 3; + let k = i; + while (k > 0 && /[A-Za-z]/.test(text[k - 1]!)) k -= 1; + const prefix = k === 0 || !/[\w$]/.test(text[k - 1]!) ? text.slice(k, i) : ""; + const raw = lang === "dart" && prefix === "r"; + const interpolated = lang === "dart" ? !raw : /^(?:[rR]?[fFtT]|[fFtT][rR])$/.test(prefix); + let j = i + close.length; + let from = j; + while (j < n && !text.startsWith(close, j) && (multiline || text[j] !== "\n")) { + if (!raw && text[j] === "\\") { j += lang === "py" && interpolated && text[j + 1] === "{" ? 1 : 2; continue; } + if (!interpolated) { j += 1; continue; } + if (lang === "py" && text.startsWith("{{", j)) { j += 2; continue; } + if (lang === "py" ? text[j] !== "{" : !text.startsWith("${", j)) { j += 1; continue; } + if (from < j) spans.push([from, j]); + const field = j; + const mark = spans.length; + j = lang === "py" ? pythonFieldEnd(text, j + 1, depth + 1, spans) : dartExpressionEnd(text, j + 2, depth + 1, spans); + if (j >= n) { spans.length = mark; from = field; break; } + from = j; + } + const end = Math.min(j, n); + if (from < end) spans.push([from, end]); + return end + close.length; +} + +function stringSpans(text: string, lang: "py" | "dart"): Spans { if (stringSpansCache?.text === text && stringSpansCache.lang === lang) return stringSpansCache.spans; - const spans: Array<[number, number]> = []; + const spans: Spans = []; const n = text.length; for (let i = 0; i < n; ) { const c = text[i]!; if (lang === "py" ? c === "#" : text.startsWith("//", i)) { - const eol = text.indexOf("\n", i); - i = eol < 0 ? n : eol; + i = commentEnd(text, i, lang); continue; } if (lang === "dart" && text.startsWith("/*", i)) { - let depth = 1; - for (i += 2; i < n && depth > 0; ) { - if (text.startsWith("/*", i)) { depth += 1; i += 2; } - else if (text.startsWith("*/", i)) { depth -= 1; i += 2; } - else i += 1; - } + const end = commentEnd(text, i, lang); + spans.push([i, end]); + i = end; continue; } if (c !== '"' && c !== "'") { i += 1; continue; } - const close = text.startsWith(c.repeat(3), i) ? c.repeat(3) : c; - const multiline = close.length === 3; - const raw = lang === "dart" && text[i - 1] === "r" && !/[\w$]/.test(text[i - 2] ?? ""); - let j = i + close.length; - while (j < n && !text.startsWith(close, j) && (multiline || text[j] !== "\n")) j += !raw && text[j] === "\\" ? 2 : 1; - spans.push([i + close.length, Math.min(j, n)]); - i = j + close.length; + i = stringAt(text, i, lang, 0, spans); } stringSpansCache = { text, lang, spans }; return spans; diff --git a/package.json b/package.json index 4afa308b9..48bb0444d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.65", + "version": "1.92.66", "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/pregate-secrets-source-context.test.ts b/test/pregate-secrets-source-context.test.ts index a46e1d178..27777514c 100644 --- a/test/pregate-secrets-source-context.test.ts +++ b/test/pregate-secrets-source-context.test.ts @@ -99,7 +99,7 @@ describe('pregate secrets: source context and diff integrity', () => { } finally { cleanup(fx); } }); - test('a Python file whose working copy no longer holds the diff\'s added lines is scanned by its added lines (#3630)', () => { + test('a Python file whose working copy no longer holds the diff\'s added lines is scanned as git diffed it (#3630, GST-738)', () => { const token = 'Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx'; const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { 'app.py': 'x = 1\n' } }); try { @@ -107,7 +107,31 @@ describe('pregate secrets: source context and diff integrity', () => { fx.git('update-index', '--assume-unchanged', 'app.py'); fs.writeFileSync(path.join(fx.work, 'app.py'), 'x = 1\ny = 2\n'); const check = JSON.parse(runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']).out).checks.find((c: { id: string }) => c.id === 'secrets'); - expect({ status: check.status, kv: (check.lines as string[]).some((l) => l.includes('env.kv') && l.includes('app.py, added line 1')) }).toEqual({ status: 'fail', kv: true }); + expect({ status: check.status, kv: (check.lines as string[]).some((l) => l.includes('env.kv') && l.includes('app.py, line 2')) }).toEqual({ status: 'fail', kv: true }); + } finally { cleanup(fx); } + }); + + // Value: protects=the Python/Dart exemption reads the text git diffed, never a working copy that differs from it; fails_when=a committed credential inside a """ block passes because the working copy's other lines were edited under assume-unchanged; + // why_new=GST-738: the added lines matched, so the edited delimiters around them were trusted; seam=git diff; no_claim=a file edited after the scan + test('a committed credential in a Python string still fails when the working copy rewrites the lines around it (GST-738)', () => { + const token = 'Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx'; + const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { 'app.py': 'x = 1\nCONFIG = """\n"""\n' } }); + try { + commit(fx, { 'app.py': `x = 1\nCONFIG = """\npassword: ${token}\n"""\n` }); + fx.git('update-index', '--assume-unchanged', 'app.py'); + fs.writeFileSync(path.join(fx.work, 'app.py'), `x = 1\nCONFIG = 1 #\npassword: ${token}\n#\n`); + const check = JSON.parse(runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']).out).checks.find((c: { id: string }) => c.id === 'secrets'); + expect({ status: check.status, kv: ((check.lines ?? []) as string[]).some((l) => l.includes('env.kv') && l.includes('app.py')) }).toEqual({ status: 'fail', kv: true }); + } finally { cleanup(fx); } + }); + + test('diff.suppressBlankEmpty does not shift the diffed Python text (GST-738)', () => { + const fx: FixtureRepo = makeFixtureRepo({ pregate: BASE_PREGATE, baseFiles: { 'app.py': 'x = 1\n\ny = 2\n' } }); + try { + commit(fx, { 'app.py': 'x = 1\n\ny = 2\nrows = {\n "password": session.password,\n}\n' }); + fx.git('config', 'diff.suppressBlankEmpty', 'true'); + const check = JSON.parse(runBin(fx, 'gstack-pregate', ['--base', 'main', '--only', 'secrets', '--json']).out).checks.find((c: { id: string }) => c.id === 'secrets'); + expect(check.status).toBe('pass'); } finally { cleanup(fx); } }); diff --git a/test/redact-engine.test.ts b/test/redact-engine.test.ts index b25ef1025..0bdef1556 100644 --- a/test/redact-engine.test.ts +++ b/test/redact-engine.test.ts @@ -347,6 +347,46 @@ describe("MEDIUM demoted credential-shaped patterns (TENSION-1)", () => { [` 'password: ${vIdent} more',`, "lib/a.dart"], [` "password: ${vIdent} more",`, "app.py"], ] as const) expect({ literal, file, kv: kv(literal, file) }).toEqual({ literal, file, kv: true }); }); + // Value: protects=an interpolated expression inside a Python f-string or Dart string is skipped whole, so a quote inside it never ends the outer string; fails_when=a """/''' delimiter in a nested string closes the block early and a password: line inside it reads as code; + // why_new=GST-738 (outside voice); seam=none; no_claim=Python before 3.12, where the nested quote is a syntax error + test("env.kv: a quote inside an f-string or Dart interpolation does not end the string (GST-738)", () => { + const kv = (text: string, sourcePath: string) => scan(text, { repoVisibility: "private", sourcePath, processEnv: {} }).findings.some((f) => f.id === "env.kv"); + const v = "Fk2pQ9vXz4wL7mN3rT6yB1cD5eG0hJx"; + for (const [literal, file] of [ + [`CONFIG = f'''\n{"'''"}\npassword: ${v}\n'''\n`, "app.py"], + [`CONFIG = rf"""\n{x.replace('"""', "")}\npassword: ${v}\n"""\n`, "app.py"], + [`CONFIG = f'''\n{f"{'\'\'\''}"}\npassword: ${v}\n'''\n`, "app.py"], + [`const s = '''\${"'''"}\npassword: ${v}\n''';\n`, "lib/a.dart"], + [`const s = """\${m["k"] ?? '"""'}\npassword: ${v}\n""";\n`, "lib/a.dart"], + // A comment inside an interpolation holds no delimiter, and a Dart block comment is text. + [`CONFIG = f"""\n{1 # } """\n}\npassword: ${v}\n"""\n`, "app.py"], + [`const s = '''\${x // '''\n}\npassword: ${v}\n''';\n`, "lib/a.dart"], + [`void main() {}\n/*\npassword: ${v}\n*/\n`, "lib/a.dart"], + // A format spec is text: its # or quote opens nothing. + [`render({\n "id": f"{n:#x}",\n}, """\npassword: ${v}\n""")\n`, "app.py"], + [`render({\n "id": f"{n:'>10}",\n}, """\npassword: ${v}\n""")\n`, "app.py"], + [`render({\n "id": f"{n!r:{w}}",\n}, """\npassword: ${v}\n""")\n`, "app.py"], + // A # comment in a single-quoted f-string's multi-line field holds no delimiter either. + [`msg = f"{\n 1 # }" """\n}"\nCONFIG = """\npassword: ${v}\n"""\n`, "app.py"], + // In an f-string a backslash does not escape the brace after it. + [`CONFIG = f"""\\{"""\npassword: ${v}\n"""}"""\n`, "app.py"], + // Nesting past the depth cap runs to the end of the text instead of overflowing the stack. + ["const s = '" + "${'".repeat(20000) + `\npassword: ${v}\n`, "lib/a.dart"], + ["x = f'" + "{a:".repeat(100000) + `\npassword: ${v}\n`, "app.py"], + ] as const) expect({ literal, kv: kv(literal, file) }).toEqual({ literal, kv: true }); + for (const [code, file] of [ + ["msg = f'{a}:{b}'\ndef f(\n session: HttpSession,\n): pass\n", "app.py"], + ["msg = f'''{{literal brace'''\ndef f(\n session: HttpSession,\n): pass\n", "app.py"], + ["msg = f'{x:>{width}}'\ndef f(\n session: HttpSession,\n): pass\n", "app.py"], + ["msg = f\"{when:%b %d '%y} {d[':']} {a[1:2]} {a!=b}\"\nrows = {\n \"password\": session.password,\n}\n", "app.py"], + // Code inside a multi-line interpolation is code, not string text. + ["msg = f\"{\n {\n 'password': request.password,\n }\n}\"\n", "app.py"], + ["final s = \"${login(\n password: request.password,\n)}\";\n", "lib/a.dart"], + ["if'a' in s: pass\ndef f(\n session: HttpSession,\n): pass\n", "app.py"], + ["final s = 'hi ${user.name} and $id';\n login(password: request.password);\n", "lib/a.dart"], + ["final s = r'${raw}';\n login(password: request.password);\n", "lib/a.dart"], + ] as const) expect({ code, kv: kv(code, file) }).toEqual({ code, kv: false }); + }); test("env.kv's source-syntax check stays linear on a match-dense file (#3630)", () => { for (const file of ["app.py", "src/a.ts"]) { const text = "def f(\n" + " session: HttpSession,\n".repeat(12_000) + "): pass\n"; diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index ec2666aaf..dc367ddc0 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -841,7 +841,7 @@ describe('the Capy skill loader', () => { describe('a scoped pass after a blocker fix (GST-619)', () => { // Value: protects=the pass command accepts the scoped plan gstack-review-plan writes after a blocking fix, invoked as GEVA-2 ran it: the Red Team reviews the fix range, Step 11's voices still pending keep the whole branch, the fix-check is named outside the manifest for Step 3.5, and a resumed pass names it again; fails_when=pass refuses the scoped plan, stages the Red Team on the merge base, stages pending Step 11 voices on the range, drops the fix-check silently, or puts it in the manifest; // why_new=GEVA-82 on 1.91.379.0 hit BRIEF_RENDER_FAILED on every scoped pass and rendered briefs by hand; seam=none; no_claim=the fix-check's own brief - function scoped(flags: string[]) { + function scoped(flags: string[], explicitMergeBase = false) { const { dir, env } = repo(); const sh = (c: string) => { const r = spawnSync('bash', ['-c', c], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }); if (r.status !== 0) throw new Error(r.stderr); return r.stdout.trim(); }; const mergeBase = sh('git merge-base main HEAD'); @@ -852,7 +852,7 @@ describe('a scoped pass after a blocker fix (GST-619)', () => { fs.mkdirSync(path.join(dir, '.state'), { recursive: true }); fs.writeFileSync(path.join(dir, '.state/plan.md'), 'Check the plan items against the fix.\n'); fs.writeFileSync(extras, JSON.stringify([{ name: 'plan', prompt_file: path.join(dir, '.state/plan.md') }])); - const args = ['pass', '--base', 'main', '--out', out, '--pass', 'p2', '--extras', extras, '--', '--pass', '1', '--last', 'full', '--pass1-depth', 'standard', '--fixed-blocking', ...flags, '--range-base', rangeBase.slice(0, 7)]; + const args = ['pass', '--base', 'main', '--out', out, '--pass', 'p2', '--extras', extras, ...(explicitMergeBase ? ['--diff-base', mergeBase] : []), '--', '--pass', '1', '--last', 'full', '--pass1-depth', 'standard', '--fixed-blocking', ...flags, '--range-base', rangeBase.slice(0, 7)]; const p = bin(dir, env, args); expect(p.status, p.stdout + p.stderr).toBe(0); expect(p.stdout).toMatch(/^NEXT: scoped /m); @@ -893,6 +893,15 @@ describe('a scoped pass after a blocker fix (GST-619)', () => { expect(s.m.diff_base).toBe(s.rangeBase); expect(s.m.readers.map(r => [r.reader, r.diff_base ?? null])).toEqual([['red-team', s.rangeBase], ['adversarial-native', null], ['audit/plan', null]]); }, 120_000); + + // Value: protects=an explicit --diff-base reaches every reader, the Red Team included; fails_when=a scoped pass asked to read the whole branch still stages its Red Team on the fix range; why_new=GST-738 (outside voice); seam=none; no_claim=none + test('an explicit --diff-base widens the scoped Red Team too (GST-738)', () => { + const s = scoped([], true); + expect(s.m.diff_base).toBe(s.mergeBase); + const m = settle(s, 'specialists') && settle(s, 'red-team'); + const redTeam = fs.readFileSync(m.readers.find(r => r.reader === 'red-team')!.file!, 'utf8'); + expect({ diffBase: m.readers.find(r => r.reader === 'red-team')!.diff_base ?? null, reads: redTeam.includes(s.mergeBase) }).toEqual({ diffBase: null, reads: true }); + }, 120_000); }); describe('readers on a shared machine review the checkout the pass ships (GST-482)', () => { From 39e0148bddc584f7bd9f6311ae77763990115c39 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 01:16:58 -0800 Subject: [PATCH 05/11] v1.92.67.0 fix(setup): read skill names without a process per skill (#2120) (#3752) * fix(setup): read skill names without a process per skill (#2120) On Windows on ARM64, x64-emulated Git Bash intermittently deadlocks in MSYS2's fork() emulation, and ./setup hung in the loops that read every SKILL.md name through grep | sed | tr plus a basename (about four process starts per skill). A sourced helper, bin/gstack-skill-name.sh, reads the name with a bash read loop and no subshell; gstack-patch-names, setup's link and cleanup loops and gstack-uninstall's inventory use it and parameter expansion instead of basename. Codex links keep their directory names. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: fixtures that copy gstack-patch-names also copy its sourced helper Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: linker harnesses source the skill-name helper Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: fragment for fork-free skill names (#2120) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * changelog: name the host linkers that still run basename Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(qa-lazy-sections): the setup-install harness sources the skill-name helper 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> --- CHANGELOG.md | 8 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-patch-names | 6 +- bin/gstack-skill-name.sh | 21 +++ bin/gstack-uninstall | 7 +- package.json | 2 +- setup | 13 +- test/plan-mode-preamble-render.test.ts | 2 +- test/qa-lazy-sections.test.ts | 3 +- test/relink-disabled-router.test.ts | 2 +- test/relink.test.ts | 1 + test/render-sections-provenance.test.ts | 4 +- test/setup-alias-name-uniqueness.test.ts | 1 + test/setup-claude-code-migration.test.ts | 2 +- test/setup-claude-skill-assets.test.ts | 1 + test/setup-fork-free-skill-names.test.ts | 150 ++++++++++++++++++++++ test/setup-link-ownership.test.ts | 1 + test/setup-playwright-best-effort.test.ts | 1 + test/team-mode.test.ts | 2 +- test/user-render-out-dir-install.test.ts | 1 + 21 files changed, 212 insertions(+), 20 deletions(-) create mode 100644 bin/gstack-skill-name.sh create mode 100644 test/setup-fork-free-skill-names.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b6a4528ba..6cff9b4b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.92.67.0] - 2026-11-02 + +**`./setup` no longer starts a handful of processes per skill just to read its name (#2120).** On Windows 11 on ARM64, Git Bash runs under x64 emulation, and its `fork()` can deadlock when a script starts hundreds of short-lived processes in a row. Setup did exactly that: `gstack-patch-names` and setup's skill-linking loops ran `grep | sed | tr` and `basename` for every skill. The reporter counted about 220 process starts on a full install. Thanks to @habassa5, who traced the hang to those loops. + +### Fixed +- **Skill names are read with bash builtins.** A new sourced helper, `bin/gstack-skill-name.sh`, reads the `name:` line with a `read` loop. It gives the same result as the old pipeline on CRLF files, files with no final newline, and files with no `name:` line. `gstack-patch-names`, setup's Claude skill linker, its two cleanup loops and `gstack-uninstall` use it. They use parameter expansion instead of `basename`. `sed` and `mv` run only for a skill that is actually renamed. +- **What is not fixed yet.** Setup still starts `ln`, `mkdir` and `cp` per skill, and the linkers for Cursor, OpenCode, Copilot, Factory, Agy and Kiro still run `basename` per skill. Nobody has confirmed on a Windows ARM64 machine that the hang is gone. + ## [1.92.66.0] - 2026-11-02 **The secrets check reads Python and Dart string context from what git diffed, and a quote inside an interpolation no longer ends a string (GST-738).** env.kv's source-syntax exemption treats a `password:` line as code only when it sits outside every string. Two inputs could make the scanner place a committed string's contents outside a string. One was a working copy that no longer matched what git diffed. The other was a quote inside an f-string or Dart `${…}` expression. A scoped review pass now applies an explicit `--diff-base` to its Red Team as well. diff --git a/VERSION b/VERSION index b0d5ebd0b..61c63e5b8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.66.0 +1.92.67.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 01116c9ae..4bcbb97c3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.66.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.67.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-patch-names b/bin/gstack-patch-names index bef02aae4..d86884176 100755 --- a/bin/gstack-patch-names +++ b/bin/gstack-patch-names @@ -8,13 +8,15 @@ DO_PREFIX="$2" # Normalize prefix arg case "$DO_PREFIX" in true|1) DO_PREFIX=1 ;; *) DO_PREFIX=0 ;; esac +# Names are read with a bash builtin loop: no process per skill (#2120). +. "$(dirname "$0")/gstack-skill-name.sh" PATCHED=0 for skill_dir in "$GSTACK_DIR"/*/; do [ -f "$skill_dir/SKILL.md" ] || continue - dir_name="$(basename "$skill_dir")" + dir_name=${skill_dir%/}; dir_name=${dir_name##*/} [ "$dir_name" = "node_modules" ] && continue - cur=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true) + gstack_skill_md_name "$skill_dir/SKILL.md"; cur=$GSTACK_SKILL_NAME [ -z "$cur" ] && continue [ "$cur" = "gstack" ] && continue # never prefix root skill if [ "$DO_PREFIX" -eq 1 ]; then diff --git a/bin/gstack-skill-name.sh b/bin/gstack-skill-name.sh new file mode 100644 index 000000000..01ca61d23 --- /dev/null +++ b/bin/gstack-skill-name.sh @@ -0,0 +1,21 @@ +# shellcheck shell=bash +# gstack-skill-name.sh: sourced helper (bash 3.2). gstack_skill_md_name FILE +# sets GSTACK_SKILL_NAME to the first column-0 "name:" line of FILE with every +# whitespace character removed (CR included), or to "" when there is none or +# FILE cannot be read. Always returns 0. +# +# It starts no process: no subshell, pipe or command substitution, and callers +# must not wrap it in $( ). Setup reads ~55 names per run, and the +# grep | sed | tr pipeline it replaces deadlocked MSYS2's fork() under x64 +# emulation on Windows ARM64 (#2120). +gstack_skill_md_name() { + GSTACK_SKILL_NAME= + [ -f "$1" ] && [ -r "$1" ] || return 0 + local _gsn_line + while IFS= read -r _gsn_line || [ -n "$_gsn_line" ]; do + case "$_gsn_line" in + name:*) _gsn_line=${_gsn_line#name:}; GSTACK_SKILL_NAME=${_gsn_line//[[:space:]]/}; return 0 ;; + esac + done < "$1" + return 0 +} diff --git a/bin/gstack-uninstall b/bin/gstack-uninstall index c88a53568..6e561cf1d 100755 --- a/bin/gstack-uninstall +++ b/bin/gstack-uninstall @@ -60,6 +60,9 @@ GSTACK_DIR="${GSTACK_DIR:-$(cd "$(dirname "$0")/.." && pwd)}" STATE_DIR="$HOME/.gstack" _STATE_DOC="https://github.com/garrytan/gstack/blob/main/docs/state-root.md" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade ($_STATE_DOC)" >&2; exit 1; } +# Skill names without a process per skill (#2120); an older copy lacking the helper keeps the pipeline. +. "$(dirname "$0")/gstack-skill-name.sh" 2>/dev/null \ + || gstack_skill_md_name() { GSTACK_SKILL_NAME=$(grep -m1 '^name:' "$1" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true); } gstack_state_root_select RESOLVED_ROOT="$_gstack_sr_root" RESOLVED_VAR="$_gstack_sr_var" @@ -290,8 +293,8 @@ _INVENTORY=" _gstack-command connect-chrome gstack-connect-chrome " if [ -d "$GSTACK_DIR" ]; then for _SRC in "$GSTACK_DIR"/*/; do [ -f "$_SRC/SKILL.md" ] || continue - _SRC_NAME="$(basename "$_SRC")" - _FM_NAME=$(grep -m1 '^name:' "$_SRC/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]' || true) + _SRC_NAME=${_SRC%/}; _SRC_NAME=${_SRC_NAME##*/} + gstack_skill_md_name "$_SRC/SKILL.md"; _FM_NAME=$GSTACK_SKILL_NAME for _N in "$_SRC_NAME" "$_FM_NAME"; do [ -n "$_N" ] || continue case "$_INVENTORY" in *" $_N "*) ;; *) _INVENTORY="$_INVENTORY$_N gstack-$_N " ;; esac diff --git a/package.json b/package.json index 48bb0444d..ba562a6cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.66", + "version": "1.92.67", "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/setup b/setup index f4d247792..649c6fd5e 100755 --- a/setup +++ b/setup @@ -298,6 +298,7 @@ SOURCE_GSTACK_DIR="$(cd "$(dirname "$0")" && pwd -P)" . "$SOURCE_GSTACK_DIR/bin/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $SOURCE_GSTACK_DIR/bin/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select; GSTACK_STATE_ROOT="$_gstack_sr_root" . "$SOURCE_GSTACK_DIR/bin/gstack-path-spelling.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-path-spelling.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } +. "$SOURCE_GSTACK_DIR/bin/gstack-skill-name.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-skill-name.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } . "$SOURCE_GSTACK_DIR/bin/gstack-claude-root.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-claude-root.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } . "$SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-install-registry.sh is missing. fix: reinstall from a fresh clone (docs/ADDING_A_HOST.md)" >&2; exit 1; } . "$SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh" || { echo "$0: $SOURCE_GSTACK_DIR/bin/gstack-render-claude.sh is missing. fix: reinstall from a fresh clone" >&2; exit 1; } @@ -2578,11 +2579,11 @@ link_claude_skill_dirs() { local linked=() for skill_dir in "$gstack_dir"/*/; do if [ -f "$skill_dir/SKILL.md" ]; then - dir_name="$(basename "$skill_dir")" + dir_name=${skill_dir%/}; dir_name=${dir_name##*/} # Skip node_modules [ "$dir_name" = "node_modules" ] && continue # Use frontmatter name: if present (e.g., run-tests/ with name: test → symlink as "test") - skill_name=$(grep -m1 '^name:' "$skill_dir/SKILL.md" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]') + gstack_skill_md_name "$skill_dir/SKILL.md"; skill_name=$GSTACK_SKILL_NAME [ -z "$skill_name" ] && skill_name="$dir_name" case "${_DISABLED_SKILLS:- }" in *" $skill_name "*|*" $dir_name "*) continue ;; esac # Apply gstack- prefix unless --no-prefix or already prefixed @@ -2748,7 +2749,7 @@ cleanup_old_claude_symlinks() { # literal (empty skills_dir) is rejected by the same guard. for old_target in "$skills_dir"/*; do [ -e "$old_target" ] || [ -L "$old_target" ] || continue - skill_name="$(basename "$old_target")" + skill_name=${old_target##*/} [ "$skill_name" = "node_modules" ] && continue [ "$skill_name" = "gstack" ] && continue # Skip already-prefixed dirs (gstack-upgrade) — no old symlink to clean @@ -2788,7 +2789,7 @@ cleanup_old_claude_symlinks() { if [ "${IS_WINDOWS:-0}" -eq 1 ] && [ -d "$gstack_dir" ]; then for skill_dir in "$gstack_dir"/*/; do if [ -f "$skill_dir/SKILL.md" ]; then - skill_name="$(basename "$skill_dir")" + skill_name=${skill_dir%/}; skill_name=${skill_name##*/} [ "$skill_name" = "node_modules" ] && continue case "$skill_name" in gstack-*) continue ;; esac old_target="$skills_dir/$skill_name" @@ -2819,7 +2820,7 @@ cleanup_prefixed_claude_symlinks() { local removed=() for skill_dir in "$gstack_dir"/*/; do if [ -f "$skill_dir/SKILL.md" ]; then - skill_name="$(basename "$skill_dir")" + skill_name=${skill_dir%/}; skill_name=${skill_name##*/} [ "$skill_name" = "node_modules" ] && continue # Only clean up prefixed entries for dirs that AREN'T already prefixed # (e.g., remove gstack-qa but NOT gstack-upgrade which is the real dir name) @@ -2884,7 +2885,7 @@ link_codex_skill_dirs() { for skill_dir in "$agents_dir"/gstack*/; do if [ -f "$skill_dir/SKILL.md" ]; then - skill_name="$(basename "$skill_dir")" + skill_name=${skill_dir%/}; skill_name=${skill_name##*/} case "${_DISABLED_SKILLS:- }" in *" ${skill_name#gstack-} "*) _remove_disabled_host_entry "$skills_dir/$skill_name"; continue ;; esac # Skip the sidecar directory — it contains runtime asset symlinks (bin/, # browse/), not a skill. Linking it would overwrite the root gstack diff --git a/test/plan-mode-preamble-render.test.ts b/test/plan-mode-preamble-render.test.ts index 60d10c400..101926721 100644 --- a/test/plan-mode-preamble-render.test.ts +++ b/test/plan-mode-preamble-render.test.ts @@ -62,7 +62,7 @@ describe('every installed-skill render carries the plan-mode arg', () => { }); describe('gstack-relink router re-render', () => { - const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-state-root.sh', 'gstack-install-registry.sh']; + const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-skill-name.sh', 'gstack-state-root.sh', 'gstack-install-registry.sh']; const SAFE = '## Plan Mode Safe Operations'; function relinkFixture() { diff --git a/test/qa-lazy-sections.test.ts b/test/qa-lazy-sections.test.ts index e914dbac5..cc57bcfd2 100644 --- a/test/qa-lazy-sections.test.ts +++ b/test/qa-lazy-sections.test.ts @@ -570,7 +570,8 @@ describe('installed QA pointers', () => { 'set -e', `IS_WINDOWS=${copy ? 1 : 0}`, `SKILL_PREFIX=${prefix}`, 'QUIET=1', '_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()', '_SKILL_BACKUP_ROOT="$HOME/backups"', 'GSTACK_USER_RENDER_DIR="$HOME/absent-render"', - 'GSTACK_STATE_ROOT="$HOME/.gstack"', '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"', helpers, + 'GSTACK_STATE_ROOT="$HOME/.gstack"', '_SKILL_COPIES_FILE="$GSTACK_STATE_ROOT/skill-copies.tsv"', + `. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`, helpers, 'log() { :; }', '_browser_hint() { :; }', 'bun_cmd() { :; }', install, ].join('\n'); const runInstall = () => runBashScript(script, { diff --git a/test/relink-disabled-router.test.ts b/test/relink-disabled-router.test.ts index 4ff263cd7..4268c9a6a 100644 --- a/test/relink-disabled-router.test.ts +++ b/test/relink-disabled-router.test.ts @@ -12,7 +12,7 @@ import * as os from 'os'; import * as path from 'path'; const ROOT = path.resolve(import.meta.dir, '..'); -const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-state-root.sh', 'gstack-install-registry.sh']; +const BINS = ['gstack-config', 'gstack-relink', 'gstack-patch-names', 'gstack-skill-name.sh', 'gstack-state-root.sh', 'gstack-install-registry.sh']; let tmp: string; let install: string; diff --git a/test/relink.test.ts b/test/relink.test.ts index 87dfa51d9..b054cd274 100644 --- a/test/relink.test.ts +++ b/test/relink.test.ts @@ -58,6 +58,7 @@ function setupMockInstall(skills: string[]): void { fs.chmodSync(path.join(mockBin, 'gstack-config'), 0o755); // A real install always ships the state-root twin beside the bins that source it. fs.copyFileSync(path.join(BIN, 'gstack-state-root.sh'), path.join(mockBin, 'gstack-state-root.sh')); + fs.copyFileSync(path.join(BIN, 'gstack-skill-name.sh'), path.join(mockBin, 'gstack-skill-name.sh')); if (fs.existsSync(path.join(BIN, 'gstack-relink'))) { fs.copyFileSync(path.join(BIN, 'gstack-relink'), path.join(mockBin, 'gstack-relink')); fs.chmodSync(path.join(mockBin, 'gstack-relink'), 0o755); diff --git a/test/render-sections-provenance.test.ts b/test/render-sections-provenance.test.ts index 2573c0bcd..ee2c4e4d1 100644 --- a/test/render-sections-provenance.test.ts +++ b/test/render-sections-provenance.test.ts @@ -77,7 +77,7 @@ describe('installed skills serve rendered sections (F5, #2706)', () => { test('gstack-relink (the gbrain-refresh path) serves the render, and the checkout again once the render is gone', () => { const fx = fixture(); fs.mkdirSync(path.join(fx.install, 'bin'), { recursive: true }); - for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names']) { + for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names', 'gstack-skill-name.sh']) { fs.copyFileSync(path.join(BIN, f), path.join(fx.install, 'bin', f)); fs.chmodSync(path.join(fx.install, 'bin', f), 0o755); } @@ -106,7 +106,7 @@ describe('installed skills serve rendered sections (F5, #2706)', () => { test('a sections/ directory the user owns is left alone by relink', () => { const fx = fixture(); fs.mkdirSync(path.join(fx.install, 'bin'), { recursive: true }); - for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names']) { + for (const f of ['gstack-relink', 'gstack-config', 'gstack-state-root.sh', 'gstack-install-registry.sh', 'gstack-patch-names', 'gstack-skill-name.sh']) { fs.copyFileSync(path.join(BIN, f), path.join(fx.install, 'bin', f)); fs.chmodSync(path.join(fx.install, 'bin', f), 0o755); } diff --git a/test/setup-alias-name-uniqueness.test.ts b/test/setup-alias-name-uniqueness.test.ts index eeca36123..b2a772ba4 100644 --- a/test/setup-alias-name-uniqueness.test.ts +++ b/test/setup-alias-name-uniqueness.test.ts @@ -80,6 +80,7 @@ beforeAll(() => { extractFn('_write_owned_marker'), extractFn('_print_windows_copy_note_once'), extractFn('_link_skill_runtime_assets'), + `. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`, extractFn('link_claude_skill_dirs'), extractFn('_install_alias_skill_md'), extractFn('link_claude_root_skill_alias'), diff --git a/test/setup-claude-code-migration.test.ts b/test/setup-claude-code-migration.test.ts index cbc4ad113..85ebe5e9f 100644 --- a/test/setup-claude-code-migration.test.ts +++ b/test/setup-claude-code-migration.test.ts @@ -31,7 +31,7 @@ function fixture(copy: boolean) { put(path.join(root, 'VERSION'), '2.0.0.0\n'); put(path.join(root, 'ETHOS.md'), 'Fixture ethos\n'); put(path.join(root, 'package.json'), JSON.stringify({ type: 'module', scripts: { 'gen:skill-docs': 'bun run scripts/gen-skill-docs.ts' } })); - for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-models', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-path-spelling.sh', 'gstack-claude-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh', 'gstack-host-renders.sh']) { + for (const bin of ['gstack-config', 'gstack-patch-names', 'gstack-relink', 'gstack-migrate-claude-code', 'gstack-claude-code', 'gstack-models', 'gstack-state-root.sh', 'gstack-bun-version.sh', 'gstack-path-spelling.sh', 'gstack-skill-name.sh', 'gstack-claude-root.sh', 'gstack-install-registry.sh', 'gstack-render-claude.sh', 'gstack-host-renders.sh']) { const target = path.join(root, 'bin', bin); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'bin', bin), target); diff --git a/test/setup-claude-skill-assets.test.ts b/test/setup-claude-skill-assets.test.ts index d6cffb8a1..ca95ddc9d 100644 --- a/test/setup-claude-skill-assets.test.ts +++ b/test/setup-claude-skill-assets.test.ts @@ -82,6 +82,7 @@ beforeAll(() => { '_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`, + `. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`, extractFn('link_claude_skill_dirs'), `link_claude_skill_dirs "${ROOT}" "${installDir}"`, ].join('\n'); diff --git a/test/setup-fork-free-skill-names.test.ts b/test/setup-fork-free-skill-names.test.ts new file mode 100644 index 000000000..a42168f6d --- /dev/null +++ b/test/setup-fork-free-skill-names.test.ts @@ -0,0 +1,150 @@ +/** + * #2120: on Windows on ARM64, x64-emulated Git Bash intermittently deadlocks + * in MSYS2's fork() emulation, and ./setup hung in the per-skill loops that + * read each SKILL.md name with a grep | sed | tr pipeline plus a basename + * (about four process starts per skill, ~55 skills a run). The deadlock + * itself needs that host; these cases pin the cause they can see anywhere: + * the loops start none of those processes per skill. Every grep, sed, tr and + * basename the code under test starts goes through a logging shim on PATH. + */ +import { describe, expect, test } from 'bun:test'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { runBashScript } from './helpers/bash-script'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const SETUP_SRC = fs.readFileSync(path.join(ROOT, 'setup'), 'utf-8'); +const HELPER = path.join(ROOT, 'bin/gstack-skill-name.sh'); +const SHIMMED = ['grep', 'sed', 'tr', 'basename']; +const SKILLS = ['qa', 'review', 'ship', 'office-hours', 'investigate', 'land-and-deploy', 'retro', 'cso']; + +function extractFn(name: string): string { + const start = SETUP_SRC.indexOf(`\n${name}() {`); + const end = SETUP_SRC.indexOf('\n}\n', start); + if (start < 0 || end < 0) throw new Error(`function not found: ${name}`); + return SETUP_SRC.slice(start + 1, end + 2); +} + +/** A temp tree, a PATH whose grep/sed/tr/basename log each start to calls.log, and a reader for that log. */ +function sandbox() { + const tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fork-free-'))); + const shim = path.join(tmp, 'shim'); + const log = path.join(tmp, 'calls.log'); + fs.mkdirSync(shim); + for (const name of SHIMMED) { + const real = spawnSync('bash', ['-c', `type -P ${name}`], { encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + fs.writeFileSync(path.join(shim, name), `#!/bin/sh\necho ${name} >> "${log}"\nexec "${real}" "$@"\n`, { mode: 0o755 }); + } + return { + tmp, env: { PATH: `${shim}${path.delimiter}${process.env.PATH ?? ''}`, HOME: tmp, GSTACK_USER_RENDER_DIR: path.join(tmp, 'no-render') }, + calls: () => fs.existsSync(log) ? fs.readFileSync(log, 'utf8').split('\n').filter(Boolean) : [], + reset: () => fs.rmSync(log, { force: true }), + cleanup: () => fs.rmSync(tmp, { recursive: true, force: true }), + }; +} + +const skillMd = (name: string, eol = '\n') => ['---', `name: ${name}`, 'description: fixture', '---', `# ${name}`, ''].join(eol); + +/** A payload of SKILLS (qa in CRLF, office-hours named "oh"), then "zz-nameless" with no name: line after them. */ +function payload(dir: string) { + for (const name of SKILLS) { + fs.mkdirSync(path.join(dir, name), { recursive: true }); + fs.writeFileSync(path.join(dir, name, 'SKILL.md'), skillMd(name === 'office-hours' ? 'oh' : name, name === 'qa' ? '\r\n' : '\n')); + } + fs.mkdirSync(path.join(dir, 'zz-nameless'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'zz-nameless', 'SKILL.md'), '---\ndescription: no name\n---\n# nameless\n'); +} + +describe.skipIf(process.platform === 'win32')('setup name reads start no process per skill (#2120)', () => { + // Value: protects=the helper returns what the old grep -m1 | sed | tr pipeline returned, and never a stale name; fails_when=it keeps the + // previous value, reads a non-column-0 name, keeps a CR or space, or returns non-zero; why_new=the helper is new; seam=none + test('gstack_skill_md_name matches the old pipeline on odd inputs, resets between files and always returns 0', () => { + const s = sandbox(); + try { + const cases: Record = { + plain: '---\nname: qa\n---\n', crlf: '---\r\nname: qa\r\n---\r\n', spaced: 'name: my skill \t\n', noeol: 'name: last', + indented: ' name: no\nname: yes\n', nameless: '---\ndescription: x\n---\n', empty: '', later: '# t\nbody\nname: deep\nname: second\n', + }; + const files = Object.entries(cases).map(([k, v]) => { const f = path.join(s.tmp, k); fs.writeFileSync(f, v); return f; }); + files.push(path.join(s.tmp, 'missing')); + const old = (f: string) => spawnSync('bash', ['-c', "grep -m1 '^name:' \"$1\" 2>/dev/null | sed 's/^name:[[:space:]]*//' | tr -d '[:space:]'", '_', f], { encoding: 'utf8', timeout: 10_000 }).stdout; + const script = [`set -euo pipefail; . "${HELPER}"; GSTACK_SKILL_NAME=stale`, + ...files.map(f => `gstack_skill_md_name "${f}"; printf '%s\n' "[$GSTACK_SKILL_NAME]"`)].join('\n'); + const r = runBashScript(script, { timeout: 10_000, env: s.env }); + expect(r.status, r.stderr ?? '').toBe(0); + expect(r.stdout.trim().split('\n')).toEqual(files.map(f => `[${old(f)}]`)); + expect(s.calls()).toEqual([]); + } finally { + s.cleanup(); + } + }); + + // Value: protects=no caller re-adds a fork per skill by wrapping the helper in $( ), and the helper itself stays fork-free; + // fails_when=a call site uses $(gstack_skill_md_name ...) or the helper body gains $(, a pipe or a backquote; why_new=the shims cannot see a subshell fork; seam=none + test('the helper starts no subshell and no caller wraps it in $( )', () => { + const body = fs.readFileSync(HELPER, 'utf8').split('\n').filter(l => !/^\s*#/.test(l)).join('\n'); + expect(body).not.toMatch(/\$\(|`|(^|[^|])\|([^|]|$)/m); + for (const rel of ['setup', 'bin/gstack-patch-names', 'bin/gstack-uninstall']) { + expect(fs.readFileSync(path.join(ROOT, rel), 'utf8'), rel).not.toMatch(/\$\(\s*gstack_skill_md_name/); + } + }); + + // Value: protects=gstack-patch-names reads every name without a per-skill grep|sed|tr|basename (the first loop #2120 hung in); + // fails_when=the loop goes back to a process pipeline per SKILL.md; why_new=no case counted process starts; seam=PATH shims + test('gstack-patch-names: no process per skill when nothing needs a rename; a rename starts only its own sed', () => { + const s = sandbox(); + try { + const dir = path.join(s.tmp, 'gstack'); + payload(dir); + const off = spawnSync('bash', [path.join(ROOT, 'bin/gstack-patch-names'), dir, '0'], { encoding: 'utf8', env: s.env, timeout: 30_000 }); + expect(off.status, off.stderr).toBe(0); + expect(off.stdout).toBe(''); + expect(s.calls()).toEqual([]); + + const on = spawnSync('bash', [path.join(ROOT, 'bin/gstack-patch-names'), dir, '1'], { encoding: 'utf8', env: s.env, timeout: 30_000 }); + expect(on.status, on.stderr).toBe(0); + expect(on.stdout).toContain(`patched name: field in ${SKILLS.length} skills`); + for (const name of SKILLS) expect(fs.readFileSync(path.join(dir, name, 'SKILL.md'), 'utf8')).toContain(`name: gstack-${name === 'office-hours' ? 'oh' : name}`); + expect(fs.readFileSync(path.join(dir, 'zz-nameless', 'SKILL.md'), 'utf8')).not.toContain('name:'); + expect(s.calls()).toEqual(Array(SKILLS.length).fill('sed')); + } finally { + s.cleanup(); + } + }); + + // Value: protects=link_claude_skill_dirs and both cleanup loops run with no per-skill grep, sed, tr or basename, and a nameless SKILL.md links + // under its directory name, never the previous skill's name (#2120); fails_when=a loop goes back to a pipeline or basename, or the name leaks; + // why_new=link tests checked links, not process starts; seam=PATH shims + test('link_claude_skill_dirs and the two cleanup loops: no per-skill grep, sed, tr or basename; names come from frontmatter', () => { + const s = sandbox(); + try { + const pl = path.join(s.tmp, 'skills', 'gstack'); + payload(pl); + const script = [ + 'set -e', 'IS_WINDOWS=0', 'SKILL_PREFIX=0', '_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()', + '_link_skill_runtime_assets() { :; }', '_print_windows_copy_note_once() { :; }', + ...['_link_or_copy', '_gstack_link_target_abs', '_gstack_target_is_ours', '_claude_entry_is_ours', '_write_owned_marker', + '_gstack_generated_header', '_claude_entry_owned_strongly', '_backup_skill_md', '_cleanup_weak_dir', '_gstack_dir_only_links', + '_cleanup_linked_dir'].map(extractFn), + `. "${HELPER}"`, + ...['link_claude_skill_dirs', 'cleanup_old_claude_symlinks', 'cleanup_prefixed_claude_symlinks'].map(extractFn), + `cleanup_old_claude_symlinks "${pl}" "${path.dirname(pl)}"`, + `cleanup_prefixed_claude_symlinks "${pl}" "${path.dirname(pl)}"`, + `link_claude_skill_dirs "${pl}" "${path.dirname(pl)}"`, + ].join('\n'); + const r = runBashScript(script, { timeout: 30_000, env: s.env }); + expect(r.stderr ?? '').not.toContain('command not found'); + expect(r.status, r.stderr ?? '').toBe(0); + expect(r.stdout).toContain('linked skills:'); + for (const name of [...SKILLS.map(n => n === 'office-hours' ? 'oh' : n), 'zz-nameless']) { + expect(fs.existsSync(path.join(s.tmp, 'skills', name, 'SKILL.md')), name).toBe(true); + } + expect(fs.existsSync(path.join(s.tmp, 'skills', 'office-hours'))).toBe(false); + expect(s.calls()).toEqual([]); + } finally { + s.cleanup(); + } + }); +}); diff --git a/test/setup-link-ownership.test.ts b/test/setup-link-ownership.test.ts index ed8f5c465..745c44b39 100644 --- a/test/setup-link-ownership.test.ts +++ b/test/setup-link-ownership.test.ts @@ -28,6 +28,7 @@ const HELPERS = [ '_link_skill_runtime_assets() { :; }', '_print_windows_copy_note_once() { :; }', extractFn('_link_or_copy'), + `. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`, extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_claude_entry_is_ours'), diff --git a/test/setup-playwright-best-effort.test.ts b/test/setup-playwright-best-effort.test.ts index 689e2b67c..826bc6897 100644 --- a/test/setup-playwright-best-effort.test.ts +++ b/test/setup-playwright-best-effort.test.ts @@ -653,6 +653,7 @@ function runLinker(opts: { '_print_windows_copy_note_once() { :; }', '_FOREIGN_SKIPPED_ENTRIES=()', extractFn('_link_or_copy'), + `. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`, extractFn('_gstack_link_target_abs'), extractFn('_gstack_target_is_ours'), extractFn('_claude_entry_is_ours'), diff --git a/test/team-mode.test.ts b/test/team-mode.test.ts index ad36946a2..4bca76887 100644 --- a/test/team-mode.test.ts +++ b/test/team-mode.test.ts @@ -455,7 +455,7 @@ describe('setup --team / --no-team / -q', () => { try { for (const rel of [ 'setup', 'VERSION', 'SKILL.md', 'qa/SKILL.md', 'bin/gstack-config', 'bin/gstack-patch-names', - 'bin/gstack-state-root.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-path-spelling.sh', 'bin/gstack-claude-root.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'bin/gstack-host-renders.sh', + 'bin/gstack-state-root.sh', 'bin/gstack-bun-version.sh', 'bin/gstack-path-spelling.sh', 'bin/gstack-skill-name.sh', 'bin/gstack-claude-root.sh', 'bin/gstack-install-registry.sh', 'bin/gstack-render-claude.sh', 'bin/gstack-host-renders.sh', 'scripts/resolve-codex-generation-model.ts', 'scripts/models.ts', 'scripts/preflight-codex-overlap.ts', 'scripts/discover-skills.ts', 'scripts/external-skill-names.ts', 'scripts/host-config.ts', 'lib/claude-code-migration.ts', 'lib/model-policy.ts', 'lib/model-catalog.ts', diff --git a/test/user-render-out-dir-install.test.ts b/test/user-render-out-dir-install.test.ts index 07dd014e7..3a73176cd 100644 --- a/test/user-render-out-dir-install.test.ts +++ b/test/user-render-out-dir-install.test.ts @@ -190,6 +190,7 @@ describe('link_claude_skill_dirs prefers rendered SKILL.md (behavior)', () => { '_FOREIGN_SKIPPED_ENTRIES=()', '_BACKED_UP_SKILL_MDS=()', `_SKILL_BACKUP_ROOT="${os.tmpdir()}/gstack-harness-backups-${process.pid}"`, + `. "${path.join(ROOT, 'bin/gstack-skill-name.sh')}"`, extractFn(SETUP_SRC, 'link_claude_skill_dirs'), `link_claude_skill_dirs "${src}" "${skills}"`, ].join('\n'); From 1a22ae96520b99d3119ce9d3e5c76b43e60ccc3c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 01:26:46 -0800 Subject: [PATCH 06/11] v1.92.68.0 feat(health): Step 1 detects tools with gstack-checks, no check lost (GST-116 PR 2) (#3691) * feat(health): Step 1 detects tools with gstack-checks detect --for health, no check lost (GST-116 PR 2) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: register the frozen /health detection fixture as free Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: Health Stack normalization and --for refusals (review pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(health): keep Health Stack command bytes, honor an empty section, detect from the working directory (review pass 1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(health): a multi-line test script is reported, not printed into the line protocol Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(health): Health Stack rows stay markdown like /verify reads them; an empty section names why (fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: the PATH-stub cases of the /health no-loss test run on Linux and macOS (windows-latest cannot launch sh stubs) 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> --- CHANGELOG.md | 10 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-checks | 14 +- docs/skills.md | 2 +- health/SKILL.md | 41 ++---- health/SKILL.md.tmpl | 41 ++---- lib/checks.ts | 56 +++++++- package.json | 2 +- test/fixtures/health-detect-legacy.sh | 31 +++++ test/health-detect-no-loss.test.ts | 183 ++++++++++++++++++++++++++ test/helpers/free-fixtures-data.ts | 1 + 12 files changed, 312 insertions(+), 73 deletions(-) create mode 100644 test/fixtures/health-detect-legacy.sh create mode 100644 test/health-detect-no-loss.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cff9b4b5..654ab2b52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.92.68.0] - 2026-11-02 + +**`/health` now finds its tools with gstack's detector, the same one `/verify` uses, and a test proves it still finds every check it found before.** /health Step 1 used to carry its own shell block for detecting a type checker, linter, test runner, knip, shellcheck and gbrain, a second copy of the list in `lib/checks.ts`. It now runs `gstack-checks detect --for health`. Scoring, the gbrain sub-score and history are unchanged. GST-116, PR 2 of 2. + +### Changed +- **`gstack-checks detect --for health`** prints one `DIMENSION ()` line per tool, or `DIMENSION none ()` so Step 2 can record the category as skipped with its reason. A `## Health Stack` section in CLAUDE.md still wins row for row, test rows included. Otherwise it applies /health's own rules, including a knip or shellcheck found on PATH and gbrain when `~/.gbrain/config.json` exists. It reads files and PATH and runs nothing. + +### Tests +- `test/health-detect-no-loss.test.ts` runs the old Step 1 block, frozen verbatim in `test/fixtures/health-detect-legacy.sh`, beside the new detector on 16 fixture repos and two PATH and HOME setups. Every check the old block printed must still be found. Dropping the knip or gbrain rule fails the test. + ## [1.92.67.0] - 2026-11-02 **`./setup` no longer starts a handful of processes per skill just to read its name (#2120).** On Windows 11 on ARM64, Git Bash runs under x64 emulation, and its `fork()` can deadlock when a script starts hundreds of short-lived processes in a row. Setup did exactly that: `gstack-patch-names` and setup's skill-linking loops ran `grep | sed | tr` and `basename` for every skill. The reporter counted about 220 process starts on a full install. Thanks to @habassa5, who traced the hang to those loops. diff --git a/VERSION b/VERSION index 61c63e5b8..75a21384c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.67.0 +1.92.68.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 4bcbb97c3..f898c2f84 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.67.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.68.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-checks b/bin/gstack-checks index ffe6ff6c4..574743a6c 100755 --- a/bin/gstack-checks +++ b/bin/gstack-checks @@ -1,14 +1,16 @@ #!/usr/bin/env -S bun --no-env-file --config=/dev/null /** - * gstack-checks — the build-and-verify loop's checks for /verify. Logic: lib/checks.ts. + * gstack-checks — the build-and-verify loop's checks for /verify, and /health's tool detection. Logic: lib/checks.ts. */ import { spawnSync } from 'node:child_process'; import { cancelActive } from '../lib/pregate/supervise'; -import { MAX_FAIL_LINES, TESTS, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks'; +import { MAX_FAIL_LINES, TESTS, healthDimensions, isTrusted, ledgerPath, load, nothingToRun, readLedger, recordTrust, runChecks, status, type Loaded } from '../lib/checks'; const USAGE = `Usage: gstack-checks [options] detect list the checks run would execute and whether they are trusted (reads files only) + detect --for health /health's dimensions: DIMENSION (), or DIMENSION none + (); a CLAUDE.md Health Stack section wins (reads files and PATH only) trust trust the current command set for this repo (checks, the pregate runner and its selection manifest commands); any change to one of them needs trust again run [--only a,b] [--allow-repo-commands] [--timeout ] @@ -43,6 +45,14 @@ async function main(argv: string[]): Promise { const [verb, ...args] = argv; if (!verb || verb === '--help' || verb === '-h') { process.stdout.write(USAGE); return verb ? 0 : 64; } if (!['detect', 'trust', 'run', 'status'].includes(verb)) { process.stderr.write(USAGE); return 64; } + const mode = flag(args, '--for'); + if (args.includes('--for')) { + if (verb !== 'detect' || mode !== 'health') { process.stderr.write(USAGE); return 64; } + const d = healthDimensions(process.cwd()); + for (const c of d.checks) process.stdout.write(`DIMENSION ${c.dimension} ${c.command} (${c.source})\n`); + for (const m of d.missing) process.stdout.write(`DIMENSION ${m.dimension} none (${m.reason})\n`); + return 0; + } const root = repoRoot(); if (!root) { process.stderr.write('gstack-checks: not inside a git repository\n'); return 3; } const timeoutS = Number(flag(args, '--timeout') ?? 600); diff --git a/docs/skills.md b/docs/skills.md index c121fc89d..335da1795 100644 --- a/docs/skills.md +++ b/docs/skills.md @@ -662,7 +662,7 @@ Instead of guessing and patching, it traces data flow, matches against known bug ## `/verify` -`/verify` is the loop you run while you build: change something, run the repo's checks, fix what broke, run again. `gstack-checks detect` finds the checks the repo already declares (a `gstack:verify:` line or a `## Health Stack` section in CLAUDE.md, else package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml, go.mod) and never makes one up. `gstack-checks run` runs them plus the tests `/ship`'s pre-gate selects for your change, using pregate's own selector, so a green `/verify` predicts a green pre-gate. +`/verify` is the loop you run while you build: change something, run the repo's checks, fix what broke, run again. `gstack-checks detect` finds the checks the repo already declares (a `gstack:verify:` line or a `## Health Stack` section in CLAUDE.md, else package.json scripts, tsconfig, eslint or biome, pyproject, Cargo.toml, go.mod) and never makes one up. `gstack-checks run` runs them plus the tests `/ship`'s pre-gate selects for your change, using pregate's own selector, so a green `/verify` predicts a green pre-gate. `/health` finds its tools with the same detector (`gstack-checks detect --for health`), which adds its test, knip, shellcheck and gbrain dimensions. It asks once before running any repo command (`gstack-checks trust`) and asks again when a command changes. A touched file no test covers is `UNMAPPED`, a selection that ran zero tests is `ZERO_RUN`, and neither counts as green. A missing tool or service is reported, not worked around. It stops after five runs or when the same failure comes back twice, and sends a stubborn red to `/investigate`. When everything passes, `gstack-checks status` prints `READY_FOR_SHIP tree=` without rerunning anything, and you run `/ship`. It never reviews, bumps versions, pushes or opens a PR. diff --git a/health/SKILL.md b/health/SKILL.md index b6c84d87d..3b1ee34ca 100644 --- a/health/SKILL.md +++ b/health/SKILL.md @@ -115,45 +115,22 @@ When the user types `/health`, run this skill. ## Step 1: Detect Health Stack -Read CLAUDE.md and look for a `## Health Stack` section. If found, parse the tools -listed there and skip auto-detection. - -If no `## Health Stack` section exists, auto-detect available tools: +Detect the tools with gstack's detector: ```bash -# Type checker -[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit" - -# Linter -[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ." -setopt +o nomatch 2>/dev/null || true -ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ." -[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ." - -# Test runner -[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)" -[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest" -[ -f Cargo.toml ] && echo "TEST: cargo test" -[ -f go.mod ] && echo "TEST: go test ./..." - -# Dead code -command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" -[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip" - -# Shell linting -command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" - -# GBrain presence — only report as a dimension if gbrain is actually -# set up; otherwise skip so machines without gbrain aren't penalized. -if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then - echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" -fi +C="$HOME/.claude/skills/gstack/bin/gstack-checks"; "$C" detect --for health ``` +It reads files and PATH only and runs nothing. A `## Health Stack` section in +CLAUDE.md wins: its rows come back as they are, and auto-detection is skipped. +Each `DIMENSION ()` line is a tool to run in Step 2. +`DIMENSION none ()` means that category is unavailable: record it +as `SKIPPED` with the reason. A `gbrain` command keeps its `timeout 5s`. + Use Glob to search for shell scripts: - `**/*.sh` (shell scripts in the repo) -After auto-detection, present the detected tools via AskUserQuestion: +After auto-detection (no line's source is `CLAUDE.md Health Stack`), present the detected tools via AskUserQuestion: "I detected these health check tools for this project: diff --git a/health/SKILL.md.tmpl b/health/SKILL.md.tmpl index 47f83551e..da8c9c700 100644 --- a/health/SKILL.md.tmpl +++ b/health/SKILL.md.tmpl @@ -42,45 +42,22 @@ When the user types `/health`, run this skill. ## Step 1: Detect Health Stack -Read CLAUDE.md and look for a `## Health Stack` section. If found, parse the tools -listed there and skip auto-detection. - -If no `## Health Stack` section exists, auto-detect available tools: +Detect the tools with gstack's detector: ```bash -# Type checker -[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit" - -# Linter -[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ." -setopt +o nomatch 2>/dev/null || true -ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ." -[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ." - -# Test runner -[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)" -[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest" -[ -f Cargo.toml ] && echo "TEST: cargo test" -[ -f go.mod ] && echo "TEST: go test ./..." - -# Dead code -command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" -[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip" - -# Shell linting -command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" - -# GBrain presence — only report as a dimension if gbrain is actually -# set up; otherwise skip so machines without gbrain aren't penalized. -if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then - echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" -fi +C="{{RUNTIME_ROOT}}/bin/gstack-checks"; "$C" detect --for health ``` +It reads files and PATH only and runs nothing. A `## Health Stack` section in +CLAUDE.md wins: its rows come back as they are, and auto-detection is skipped. +Each `DIMENSION ()` line is a tool to run in Step 2. +`DIMENSION none ()` means that category is unavailable: record it +as `SKIPPED` with the reason. A `gbrain` command keeps its `timeout 5s`. + Use Glob to search for shell scripts: - `**/*.sh` (shell scripts in the repo) -After auto-detection, present the detected tools via AskUserQuestion: +After auto-detection (no line's source is `CLAUDE.md Health Stack`), present the detected tools via AskUserQuestion: "I detected these health check tools for this project: diff --git a/lib/checks.ts b/lib/checks.ts index a73c192d8..5396ba09e 100644 --- a/lib/checks.ts +++ b/lib/checks.ts @@ -53,8 +53,8 @@ export function declaredVerify(claudeMd: string): string | null { return null; } -/** The '## Health Stack' section (/health's own format): '- key: command' rows. 'test' and 'tests' are left to pregate; a repeated key gets -2, -3 so every check has its own name. */ -export function healthStack(claudeMd: string): Check[] { +/** The '## Health Stack' section (/health's own format): '- key: command' rows. 'test' and 'tests' are left to pregate unless keepTests (/health scores them); a repeated key gets -2, -3 so every check has its own name. */ +export function healthStack(claudeMd: string, keepTests = false): Check[] { const lines = claudeMd.split('\n'); const start = lines.findIndex(l => /^##\s+Health Stack\s*$/.test(l)); if (start < 0) return []; @@ -64,7 +64,7 @@ export function healthStack(claudeMd: string): Check[] { for (const line of lines.slice(start + 1)) { if (/^#{1,6}\s/.test(line)) break; const m = /^\s*[-*]\s*([A-Za-z0-9_-]+):\s*(.+?)\s*$/.exec(line); - if (!m || ['test', TESTS].includes(m[1].toLowerCase())) continue; + if (!m || (!keepTests && ['test', TESTS].includes(m[1].toLowerCase()))) continue; const key = m[1].toLowerCase(); let n = seen.get(key) ?? 0; let name = key; @@ -127,6 +127,56 @@ export function detectChecks(root: string): Check[] { }); } +export const HEALTH_DIMENSIONS = ['typecheck', 'lint', 'test', 'deadcode', 'shell', 'gbrain'] as const; +export interface HealthDimensions { checks: Array<{ dimension: string; command: string; source: string }>; missing: Array<{ dimension: string; reason: string }> } + +/** + * /health Step 1, in the directory /health runs from: a Health Stack section row for row (test rows kept; a section with no + * rows still turns auto-detection off), else /health's own presence rules, + * which may use a tool found on PATH (unlike /verify's declared-only manifestChecks). Reads files and PATH; runs nothing. + * test/health-detect-no-loss.test.ts holds these rules to the shell block they replaced. + */ +export function healthDimensions(root: string, env: NodeJS.ProcessEnv = process.env): HealthDimensions { + const claude = read(path.join(root, 'CLAUDE.md')) ?? ''; + const stack = healthStack(claude, true); + if (/^##\s+Health Stack\s*$/m.test(claude)) { + const keys = new Set(stack.map(c => c.name.replace(/-\d+$/, '').replace(/^tests$/, 'test'))); + return { checks: stack.map(c => ({ dimension: c.name, command: c.command, source: c.source })), missing: HEALTH_DIMENSIONS.filter(d => !keys.has(d)).map(dimension => ({ dimension, reason: stack.length ? 'not in the CLAUDE.md Health Stack section' : 'the CLAUDE.md Health Stack section has no "- key: command" rows' })) }; + } + const has = (file: string) => fs.existsSync(path.join(root, file)); + const which = (bin: string) => Bun.which(bin, { PATH: env.PATH ?? '' }) !== null; + const pkgText = read(path.join(root, 'package.json')); + const pyproject = read(path.join(root, 'pyproject.toml')); + let testScript: unknown; + try { testScript = pkgText?.includes('"test"') ? JSON.parse(pkgText).scripts?.test : undefined; } catch { /* an unreadable package.json declares nothing */ } + const listDir = (dir: string) => { try { return fs.readdirSync(dir); } catch { return []; } }; + const shellFiles = ['.', 'scripts', 'bin'].some(dir => listDir(path.join(root, dir)).some(f => f.endsWith('.sh'))); + const found: Array<[string, string, string] | false> = [ + has('tsconfig.json') && ['typecheck', 'tsc --noEmit', 'tsconfig.json'], + (has('biome.json') || has('biome.jsonc')) && ['lint', 'biome check .', 'biome.json'], + listDir(root).some(f => /^(eslint\.config\..+|\.eslintrc(\..+)?)$/.test(f)) && ['lint', 'eslint .', 'eslint config'], + !!pyproject && /pylint|ruff/.test(pyproject) && ['lint', 'ruff check .', 'pyproject.toml'], + typeof testScript === 'string' && testScript !== '' && !/[\r\n]/.test(testScript) && ['test', testScript, 'package.json'], + !!pyproject && pyproject.includes('pytest') && ['test', 'pytest', 'pyproject.toml'], + has('Cargo.toml') && ['test', 'cargo test', 'Cargo.toml'], + has('go.mod') && ['test', 'go test ./...', 'go.mod'], + which('knip') && ['deadcode', 'knip', 'PATH'], + !!pkgText?.includes('"knip"') && ['deadcode', 'npx knip', 'package.json'], + which('shellcheck') && shellFiles && ['shell', 'shellcheck', 'PATH, *.sh in the root, scripts/ or bin/'], + which('gbrain') && !!env.HOME && fs.existsSync(path.join(env.HOME, '.gbrain', 'config.json')) && ['gbrain', 'timeout 5s gbrain doctor --json', 'PATH, ~/.gbrain/config.json'], + ]; + const checks = found.filter((c): c is [string, string, string] => !!c).map(([dimension, command, source]) => ({ dimension, command, source })); + const reasons: Record = { + typecheck: 'no tsconfig.json', + lint: 'no biome.json, eslint config, or pyproject.toml naming ruff or pylint', + test: typeof testScript === 'string' && /[\r\n]/.test(testScript) ? 'the package.json test script spans lines; list it in a CLAUDE.md Health Stack section' : 'no package.json test script, pytest in pyproject.toml, Cargo.toml or go.mod', + deadcode: 'knip is not on PATH or in package.json', + shell: which('shellcheck') ? 'no *.sh file in the root, scripts/ or bin/' : 'shellcheck is not on PATH', + gbrain: 'gbrain is not on PATH or ~/.gbrain/config.json is absent', + }; + return { checks, missing: HEALTH_DIMENSIONS.filter(d => !checks.some(c => c.dimension === d)).map(dimension => ({ dimension, reason: reasons[dimension] })) }; +} + /** Every command a run could execute, read without executing any: the checks, the pregate runner and each selection manifest command. */ export function commandSet(root: string, checks: readonly Check[], config: Pick): string { return sha256(JSON.stringify({ checks: checks.map(c => [c.name, c.command, c.body ?? '']), scripts: packageScripts(root), runner: config.runner ?? detectedRunnerInputs(root), selection: Object.entries(config.selection ?? {}).sort(([a], [b]) => a.localeCompare(b)) })); diff --git a/package.json b/package.json index ba562a6cd..883d131b3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.67", + "version": "1.92.68", "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/fixtures/health-detect-legacy.sh b/test/fixtures/health-detect-legacy.sh new file mode 100644 index 000000000..6d925861b --- /dev/null +++ b/test/fixtures/health-detect-legacy.sh @@ -0,0 +1,31 @@ +# /health Step 1 auto-detection, frozen verbatim from health/SKILL.md.tmpl at a305302 +# (v1.91.365.0) before /health moved onto gstack-checks detect --for health (GST-116 PR 2). +# test/health-detect-no-loss.test.ts runs it beside the new detector: every line it prints +# must still be found. Do not edit; it is the baseline, not live code. +# Type checker +[ -f tsconfig.json ] && echo "TYPECHECK: tsc --noEmit" + +# Linter +[ -f biome.json ] || [ -f biome.jsonc ] && echo "LINT: biome check ." +setopt +o nomatch 2>/dev/null || true +ls eslint.config.* .eslintrc.* .eslintrc 2>/dev/null | head -1 | xargs -I{} echo "LINT: eslint ." +[ -f .pylintrc ] || [ -f pyproject.toml ] && grep -q "pylint\|ruff" pyproject.toml 2>/dev/null && echo "LINT: ruff check ." + +# Test runner +[ -f package.json ] && grep -q '"test"' package.json 2>/dev/null && echo "TEST: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json','utf8')).scripts.test)" 2>/dev/null)" +[ -f pyproject.toml ] && grep -q "pytest" pyproject.toml 2>/dev/null && echo "TEST: pytest" +[ -f Cargo.toml ] && echo "TEST: cargo test" +[ -f go.mod ] && echo "TEST: go test ./..." + +# Dead code +command -v knip >/dev/null 2>&1 && echo "DEADCODE: knip" +[ -f package.json ] && grep -q '"knip"' package.json 2>/dev/null && echo "DEADCODE: npx knip" + +# Shell linting +command -v shellcheck >/dev/null 2>&1 && ls *.sh scripts/*.sh bin/*.sh 2>/dev/null | head -1 | xargs -I{} echo "SHELL: shellcheck" + +# GBrain presence — only report as a dimension if gbrain is actually +# set up; otherwise skip so machines without gbrain aren't penalized. +if command -v gbrain >/dev/null 2>&1 && [ -f "$HOME/.gbrain/config.json" ]; then + echo "GBRAIN: gbrain doctor --json (wrapped in timeout 5s)" +fi diff --git a/test/health-detect-no-loss.test.ts b/test/health-detect-no-loss.test.ts new file mode 100644 index 000000000..9e6f11c59 --- /dev/null +++ b/test/health-detect-no-loss.test.ts @@ -0,0 +1,183 @@ +/** + * /health Step 1 moved onto gstack-checks detect --for health (GST-116 PR 2). The acceptance bar: every check the old + * Step 1 found is still found. The old shell block is frozen verbatim in test/fixtures/health-detect-legacy.sh; each + * fixture repo runs both under the same PATH and HOME, and every legacy (category, command) pair must appear in the + * detector's DIMENSION lines. + */ +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { HEALTH_DIMENSIONS, healthDimensions } from '../lib/checks'; +import { runScriptSync } from '../lib/script-invocation'; +import { BIN } from './helpers/pregate-fixture'; + +const LEGACY = path.join(import.meta.dir, 'fixtures', 'health-detect-legacy.sh'); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'health-detect-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +const stubs = path.join(tmp, 'stubs'); +fs.mkdirSync(stubs); +for (const tool of ['knip', 'shellcheck', 'gbrain']) { + fs.writeFileSync(path.join(stubs, tool), '#!/bin/sh\nexit 0\n'); + fs.chmodSync(path.join(stubs, tool), 0o755); +} +const gbrainHome = path.join(tmp, 'home-gbrain'); +fs.mkdirSync(path.join(gbrainHome, '.gbrain'), { recursive: true }); +fs.writeFileSync(path.join(gbrainHome, '.gbrain', 'config.json'), '{}\n'); +const bareHome = path.join(tmp, 'home-bare'); +fs.mkdirSync(bareHome); +// The knip, shellcheck and gbrain stubs are extensionless #!/bin/sh files, which windows-latest cannot find or launch +// (windows-free-shard 3, run 41204425554); the cases that need them run on Linux and macOS. +const stubbed = test.skipIf(process.platform === 'win32'); + +const PKG = JSON.stringify({ name: 'fx', scripts: { test: 'vitest run', lint: 'eslint .' }, devDependencies: { knip: '^5.0.0' } }); +const FIXTURES: Record> = { + empty: {}, + tsconfig: { 'tsconfig.json': '{}' }, + 'biome-json': { 'biome.json': '{}' }, + 'biome-jsonc': { 'biome.jsonc': '{}' }, + 'eslint-flat': { 'eslint.config.mjs': 'export default [];' }, + 'eslintrc-json': { '.eslintrc.json': '{}' }, + 'eslintrc-bare': { '.eslintrc': '{}' }, + 'pyproject-ruff-pytest': { 'pyproject.toml': '[tool.ruff]\n[tool.pytest.ini_options]\n' }, + 'pyproject-pylint': { 'pyproject.toml': '[tool.pylint]\n' }, + 'pylintrc-only': { '.pylintrc': '[MASTER]\n' }, + 'package-test-knip': { 'package.json': PKG }, + cargo: { 'Cargo.toml': '[package]\nname = "fx"\n' }, + go: { 'go.mod': 'module fx\n' }, + 'shell-scripts-dir': { 'scripts/build.sh': 'echo hi\n' }, + 'shell-root-and-bin': { 'run.sh': 'echo hi\n', 'bin/tool.sh': 'echo hi\n' }, + everything: { 'tsconfig.json': '{}', 'biome.json': '{}', 'eslint.config.js': '', 'pyproject.toml': 'ruff pytest\n', 'package.json': PKG, 'Cargo.toml': '', 'go.mod': 'module fx\n', 'scripts/a.sh': '' }, +}; + +function repo(name: string, files: Record): string { + const dir = path.join(tmp, 'repos', name); + for (const [file, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + fs.writeFileSync(path.join(dir, file), body); + } + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +const NORMALIZE: Record = { 'gbrain doctor --json (wrapped in timeout 5s)': 'timeout 5s gbrain doctor --json' }; +const pairs = (lines: Array<[string, string]>) => lines.map(([d, c]) => `${d}: ${c}`).sort(); + +function legacy(dir: string, env: NodeJS.ProcessEnv): string[] { + const r = spawnSync('bash', [LEGACY], { cwd: dir, env, encoding: 'utf8', timeout: 30_000 }); + expect(r.status).toBe(0); + return pairs(r.stdout.split('\n').flatMap(line => { + const m = /^(TYPECHECK|LINT|TEST|DEADCODE|SHELL|GBRAIN): (.+)$/.exec(line); + return m ? [[m[1].toLowerCase(), NORMALIZE[m[2]] ?? m[2]] as [string, string]] : []; + })); +} + +function detector(dir: string, env: NodeJS.ProcessEnv): { found: string[]; none: string[] } { + const r = runScriptSync(BIN('gstack-checks'), ['detect', '--for', 'health'], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }); + expect(r.status).toBe(0); + const lines = (r.stdout ?? '').split('\n').filter(Boolean).map(line => /^DIMENSION (\S+) (.+?) \((.*)\)$/.exec(line)!); + expect(lines.every(Boolean)).toBe(true); + return { found: pairs(lines.filter(m => m[2] !== 'none').map(m => [m[1], m[2]])), none: lines.filter(m => m[2] === 'none').map(m => m[1]) }; +} + +const ENVS = { + 'tools on PATH, gbrain configured': { PATH: `${stubs}${path.delimiter}${process.env.PATH}`, HOME: gbrainHome }, + 'machine PATH, no gbrain config': { PATH: process.env.PATH, HOME: bareHome }, +}; + +describe('/health detection: no check lost (GST-116 PR 2)', () => { + // Value: protects=/health still finds every tool its old Step 1 shell block found; fails_when=a ported rule drops a + // category or command (knip, shellcheck, gbrain, a lint or test rule) for any fixture; why_new=/health moved onto the + // detector in this change; seam=the frozen legacy block run beside the bin; no_claim=that the found tools succeed + for (const [envName, envVars] of Object.entries(ENVS)) { + (envName.startsWith('tools on PATH') ? stubbed : test)(`every legacy line is found: ${envName}`, () => { + const env = { ...process.env, ...envVars }; + let legacyTotal = 0; + for (const [name, files] of Object.entries(FIXTURES)) { + const dir = repo(name, files); + const old = legacy(dir, env); + const now = detector(dir, env); + legacyTotal += old.length; + expect({ name, lost: old.filter(p => !now.found.includes(p)) }).toEqual({ name, lost: [] }); + expect(new Set([...now.found.map(p => p.split(':')[0]), ...now.none])).toEqual(new Set(HEALTH_DIMENSIONS)); + } + expect(legacyTotal).toBeGreaterThan(20); + }); + } + + // Value: protects=the matrix exercises knip, shellcheck and gbrain, not just the manifest rules; fails_when=a stub stops + // being found so the no-loss comparison above runs with those categories empty; why_new=new fixture; seam=none + stubbed('the stubbed run covers every category on both sides', () => { + const env = { ...process.env, ...ENVS['tools on PATH, gbrain configured'] }; + const dir = repo('everything', FIXTURES.everything); + expect(new Set(legacy(dir, env).map(p => p.split(':')[0]))).toEqual(new Set(HEALTH_DIMENSIONS)); + expect(detector(dir, env).none).toEqual([]); + }); + + // Value: protects=a Health Stack section wins row for row, test rows included, and nothing is auto-detected beside it; + // fails_when=health mode drops the test row /verify leaves to pregate, or mixes manifest detection in; why_new=keepTests + // is new; seam=none + test('a Health Stack section is returned as written', () => { + const dir = repo('health-stack', { 'tsconfig.json': '{}', 'CLAUDE.md': '# x\n\n## Health Stack\n\n- typecheck: tsc --noEmit -p app\n- test: bun test\n- deadcode: knip --production\n- shell: shellcheck *.sh scripts/*.sh\n\n## Other\n' }); + const d = healthDimensions(dir, { PATH: stubs, HOME: gbrainHome }); + expect(d.checks.map(c => [c.dimension, c.command])).toEqual([['typecheck', 'tsc --noEmit -p app'], ['test', 'bun test'], ['deadcode', 'knip --production'], ['shell', 'shellcheck *.sh scripts/*.sh']]); + expect(d.missing).toEqual([{ dimension: 'lint', reason: 'not in the CLAUDE.md Health Stack section' }, { dimension: 'gbrain', reason: 'not in the CLAUDE.md Health Stack section' }]); + }); + + // Value: protects=a 'tests' row and repeated keys count as present, so Step 2 never skips a category it runs; + // fails_when=the -N suffix or tests→test normalization regresses and a listed category also prints none; why_new=new; seam=none + test('a tests row and repeated keys are not reported missing', () => { + const dir = repo('health-stack-dupes', { 'CLAUDE.md': '## Health Stack\n\n- tests: bun test\n- lint: eslint .\n- lint: biome check .\n' }); + const d = healthDimensions(dir, { PATH: '', HOME: bareHome }); + expect(d.checks.map(c => c.dimension)).toEqual(['tests', 'lint', 'lint-2']); + expect(d.missing.map(m => m.dimension)).toEqual(['typecheck', 'deadcode', 'shell', 'gbrain']); + }); + + // Value: protects=--for is accepted only as detect --for health; fails_when=run --for health falls through into the run + // path, or a missing or unknown mode is accepted; why_new=new flag; seam=the bin + test('--for is refused outside detect --for health', () => { + for (const args of [['run', '--for', 'health'], ['detect', '--for'], ['detect', '--for', 'verify']]) { + const r = runScriptSync(BIN('gstack-checks'), args, { cwd: tmp, encoding: 'utf8', timeout: 30_000 }); + expect(r.status).toBe(64); + expect(r.stderr ?? '').toContain('Usage: gstack-checks'); + } + }); + + // Value: protects=Health Stack rows read as markdown like gstack:verify: (code-span backticks dropped), an empty section + // still turns auto-detection off and says so, and detection runs where /health runs (a monorepo package); + // fails_when=a section with no rows falls through to auto-detection, or the bin detects from the git top-level; + // why_new=review pass 1 voices; seam=the bin from a subdirectory + test('Health Stack spans, an empty section, and the working directory', () => { + const tick = String.fromCharCode(96); + const spans = healthDimensions(repo('hs-spans', { 'CLAUDE.md': `## Health Stack\n\n- lint: ${tick}eslint .${tick} && ${tick}prettier --check .${tick}\n` }), { PATH: '', HOME: bareHome }); + expect(spans.checks.map(c => c.command)).toEqual(['eslint . && prettier --check .']); + const empty = healthDimensions(repo('hs-empty', { 'tsconfig.json': '{}', 'CLAUDE.md': '## Health Stack\n\nNo health tools are configured.\n' }), { PATH: stubs, HOME: gbrainHome }); + expect(empty.checks).toEqual([]); + expect(empty.missing.map(m => m.dimension)).toEqual([...HEALTH_DIMENSIONS]); + expect(empty.missing[0].reason).toBe('the CLAUDE.md Health Stack section has no "- key: command" rows'); + const mono = repo('monorepo', { 'tsconfig.json': '{}', 'packages/app/package.json': JSON.stringify({ scripts: { test: 'vitest run' } }) }); + expect(spawnSync('git', ['init', '-q'], { cwd: mono, timeout: 30_000 }).status).toBe(0); + const env = { ...process.env, PATH: process.env.PATH, HOME: bareHome }; + const pkg = detector(path.join(mono, 'packages', 'app'), env); + expect(pkg.found).toContain('test: vitest run'); + expect(pkg.none).toContain('typecheck'); + const multi = healthDimensions(repo('multiline-test', { 'package.json': JSON.stringify({ scripts: { test: 'vitest\nDIMENSION lint x (CLAUDE.md Health Stack)' } }) }), { PATH: '', HOME: bareHome }); + expect(multi.checks).toEqual([]); + expect(multi.missing.find(m => m.dimension === 'test')?.reason).toBe('the package.json test script spans lines; list it in a CLAUDE.md Health Stack section'); + const noHome = repo('no-home', { '.gbrain/config.json': '{}' }); + expect(healthDimensions(noHome, { PATH: stubs, HOME: '' }).checks.map(c => c.dimension)).not.toContain('gbrain'); + }); + + // Value: protects=an unavailable category says why, so Step 2 records SKIPPED with a reason; fails_when=a none line + // loses its reason or shellcheck's two causes collapse; why_new=new output; seam=none + stubbed('none lines name the reason', () => { + const empty = healthDimensions(repo('empty', {}), { PATH: '', HOME: bareHome }); + expect(empty.checks).toEqual([]); + expect(empty.missing.find(m => m.dimension === 'shell')?.reason).toBe('shellcheck is not on PATH'); + const noScripts = healthDimensions(repo('tsconfig', FIXTURES.tsconfig), { PATH: stubs, HOME: bareHome }); + expect(noScripts.missing.find(m => m.dimension === 'shell')?.reason).toBe('no *.sh file in the root, scripts/ or bin/'); + expect(noScripts.missing.find(m => m.dimension === 'gbrain')?.reason).toBe('gbrain is not on PATH or ~/.gbrain/config.json is absent'); + }); +}); diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 7f7f14f1b..9d9131d47 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -150,6 +150,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/factory-scorecard/**': ['test/factory-scorecard.test.ts'], 'test/fixtures/free-shard-capture.ts': ['test/test-free-shards-capture.test.ts'], 'test/fixtures/golden/**': ['test/host-config.test.ts', 'test/ship-review-loop.test.ts'], + 'test/fixtures/health-detect-legacy.sh': ['test/health-detect-no-loss.test.ts'], 'test/fixtures/impeccable-antipatterns.json': ['test/design-catalog.test.ts', 'test/impeccable-fixtures.test.ts'], 'test/fixtures/impeccable-captures.meta.json': ['test/design-detect-contract.test.ts', 'test/impeccable-fixtures.test.ts'], 'test/fixtures/impeccable-detect-dom-sample.json': ['test/impeccable-fixtures.test.ts'], From 7762c39a4ecdba8268c1ffc2b0384bccc04b3fa6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 01:37:00 -0800 Subject: [PATCH 07/11] v1.92.69.0 fix: lock developer-profile writers so overlapping derives keep every session (#3437, #3435) (#3738) * fix(plan-tune): lock developer-profile writes and give derive a unique temp file (#3437) Concurrent background --derive runs shared developer-profile.json.tmp and rewrote the whole profile unlocked, so renames failed with ENOENT and a concurrent --log-session was dropped. Every read-modify-write now holds developer-profile.json.lock and writes a mktemp file. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore(plan-tune): remove applySignal and newDimensionTotals (#3435) Both lost their last production caller when --derive moved onto accumulateSignals; only three unit tests referenced them. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Use the PID in the lock token, anchor a relative state root once so do_derive's cd cannot leak the lock, and note the stale-takeover tradeoff. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (pass 2) Keep Windows drive-letter state roots absolute when anchoring a relative root, and say which writers do not take the lock yet. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: take over a lock only from a dead holder, one taker at a time Step 11 voices: a late stale-lock mv could evict a fresh owner, a failed mv spun past the timeout, and the 10 s give-up sat under the 30 s stale age. Takeover now checks the holder's pid, re-reads the owner under a takeover lock, falls through to the give-up on failure, and reports an unwritable state directory at once. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: take over an owned lock older than 5 minutes (pid reuse) A crashed holder whose pid now belongs to a live process kept its lock forever. An owned lock past 5 minutes now counts as abandoned, and cleanup removes a takeover lock this writer holds. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog fragment for the profile lock (#3437) and dead-helper removal (#3435) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: say the 5-minute takeover applies whatever the pid Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the give-up test inside the type ratchet 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> --- CHANGELOG.md | 15 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-developer-profile | 146 ++++++++++++++++++++----- package.json | 2 +- scripts/psychographic-signals.ts | 34 ------ test/gstack-developer-profile.test.ts | 149 ++++++++++++++++++++++++++ test/plan-tune.test.ts | 22 ---- 8 files changed, 288 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 654ab2b52..07112e9ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.92.69.0] - 2026-11-02 + +**Background `--derive` runs no longer drop /office-hours sessions or fail with ENOENT when they overlap (#3437).** `gstack-question-log` starts a background `gstack-developer-profile --derive` after every append, and a multi-question AskUserQuestion logs several rows at once. Every derive wrote the same `developer-profile.json.tmp` and rewrote the whole profile without a lock. On main at 3bfe566, 30 rounds of 8 overlapping derives plus one `--log-session` saw 9 of 270 runs fail with `ENOENT` on rename, and 2 of the 30 sessions disappeared. With this change the same run has 0 failures and keeps 30 of 30 sessions. + +### Fixed +- **`bin/gstack-developer-profile`**: `--derive`, `--log-session`, `--reconcile` and `--migrate` hold `developer-profile.json.lock` from before they read the profile until after they rename it, and each writes its own `mktemp` file. The owner file names the holder's process ID, and the lock is ownership-checked on release. A lock is taken over when its holder process is gone, or once it is 5 minutes old even if a process with that ID is running, since no writer takes that long and the ID may have been reused. Takeovers go one at a time under `developer-profile.json.lock.takeover`, so two waiters can't both take over. A writer that can't get the lock within 10 seconds writes nothing and exits 5. An unwritable state directory is reported right away instead of after the wait. A relative state root is anchored once, so the lock is released under the same path it was taken. Windows drive-letter roots stay as they are. +### Removed +- **`scripts/psychographic-signals.ts`**: `applySignal` and `newDimensionTotals`, which lost their last production caller when `--derive` moved onto `accumulateSignals` (#3435), and their three unit tests. + +### Not covered yet +- `gstack-distill-apply` and /plan-tune's declared edits still write the profile without the lock. + +### Tests +- `test/gstack-developer-profile.test.ts`: a directory at the old fixed temp path; a held lock blocking `--derive` and `--log-session`; takeover of an ownerless stale lock, of a dead holder's lock, and of a 5-minute-old lock whose process ID is alive; a takeover already in progress; a takeover that can't remove the lock still giving up; a relative state root; and 6 rounds of 6 derives plus a `--log-session` keeping every session. Each test failed on the code before its fix. + ## [1.92.68.0] - 2026-11-02 **`/health` now finds its tools with gstack's detector, the same one `/verify` uses, and a test proves it still finds every check it found before.** /health Step 1 used to carry its own shell block for detecting a type checker, linter, test runner, knip, shellcheck and gbrain, a second copy of the list in `lib/checks.ts`. It now runs `gstack-checks detect --for health`. Scoring, the gbrain sub-score and history are unchanged. GST-116, PR 2 of 2. diff --git a/VERSION b/VERSION index 75a21384c..d44ae96df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.68.0 +1.92.69.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index f898c2f84..72187e385 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.68.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.69.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-developer-profile b/bin/gstack-developer-profile index f64178e2b..bc489c5c1 100755 --- a/bin/gstack-developer-profile +++ b/bin/gstack-developer-profile @@ -25,6 +25,10 @@ # `completed` events in projects/*/timeline.jsonl. # Idempotent; backfilled rows count for tenure only. # +# Writers (--derive, --log-session, --reconcile, --migrate) hold +# developer-profile.json.lock for their read-modify-write and exit 5 when +# another writer keeps it past the give-up window (#3437). +# # Profile file: ~/.gstack/developer-profile.json (unified schema — see # docs/designs/PLAN_TUNING_V0.md). Event file: ~/.gstack/projects/{SLUG}/ # question-events.jsonl. @@ -37,6 +41,10 @@ ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" . "$(dirname "$0")/gstack-state-root.sh" 2>/dev/null || { echo "$0: cannot resolve the gstack state root: $(dirname "$0")/gstack-state-root.sh is missing. fix: reinstall with ./setup or /gstack-upgrade (docs/state-root.md)" >&2; exit 1; } gstack_state_root_select GSTACK_HOME="$_gstack_sr_root" +# A relative state root is anchored to the starting directory once, here: +# do_derive changes directory, and the profile lock must name the same path +# when it is taken and when it is released. +case "$GSTACK_HOME" in /*|[A-Za-z]:[\\/]*) ;; *) GSTACK_HOME="$PWD/$GSTACK_HOME" ;; esac # Windows git-bash: GSTACK_HOME resolves to an MSYS path (/c/Users/...), which # Bun on Windows cannot open as a filesystem path (bites --derive). Normalize # once, here, before PROFILE_FILE/LEGACY_FILE are derived from it below — @@ -52,10 +60,100 @@ SLUG="${SLUG:-unknown}" CMD="${1:---read}" shift || true +# ----------------------------------------------------------------------- +# Profile lock (#3437). Every subcommand that rewrites the profile reads, +# changes and renames the whole file, and gstack-question-log starts a +# background --derive after every append, so writers overlap: a shared temp +# name made renames fail and an unlocked rewrite dropped a concurrent +# --log-session. Writers hold a mkdir lock (.lock/, whose owner file +# names the holder's pid and a random token; release is ownership-checked) +# from before their read until after their rename, and write a unique temp +# file. A lock whose holder process is gone is taken over at once; one older +# than 5 minutes is taken over even when its pid is alive, since no writer +# runs that long and the pid may have been reused. Takeovers are serialized by a second mkdir lock +# (.lock.takeover/) and re-read the owner under it, so two waiters +# cannot both take over or remove a lock a third writer just took. A lock or +# takeover directory older than 30 s with no owner belongs to a writer that +# died before writing one; cleanup removes a takeover lock this writer holds. A writer that cannot get the lock within the +# give-up window writes nothing and exits 5. Writers outside this binary +# (gstack-distill-apply, /plan-tune's declared edits) do not take it yet. +# ----------------------------------------------------------------------- +LOCK_DIR="$PROFILE_FILE.lock" +TAKEOVER_DIR="$LOCK_DIR.takeover" +LOCK_TOKEN="" +HOLDS_TAKEOVER="" +PROFILE_TMP="" + +cleanup() { + if [ -n "$PROFILE_TMP" ]; then rm -f "$PROFILE_TMP"; fi + if [ -n "$HOLDS_TAKEOVER" ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi + if [ -n "$LOCK_TOKEN" ] && [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$LOCK_TOKEN" ]; then + rm -rf "$LOCK_DIR" + fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +dir_age_s() { + local mtime + # GNU stat first: BSD-style stat -f on Linux prints a filesystem block. + mtime=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo "") + case "$mtime" in ""|*[!0-9]*) echo 0 ;; *) echo $(( $(date +%s) - mtime )) ;; esac +} + +lock_abandoned() { + if [ -n "$1" ]; then + ! kill -0 "${1%%-*}" 2>/dev/null || [ "$(dir_age_s "$LOCK_DIR")" -gt 300 ] + else + [ "$(dir_age_s "$LOCK_DIR")" -gt 30 ] + fi +} + +acquire_lock() { + if [ -n "$LOCK_TOKEN" ]; then return 0; fi + mkdir -p "$GSTACK_HOME" + # GSTACK_PROFILE_LOCK_TIMEOUT_MS: test-only override for the give-up. + local give_up_ms="${GSTACK_PROFILE_LOCK_TIMEOUT_MS:-10000}" waited_ms=0 started=$SECONDS vanished=0 owner + local token="$$-$RANDOM$RANDOM" + until mkdir "$LOCK_DIR" 2>/dev/null; do + # mkdir failed with no lock to wait for: retry once in case a holder just + # released it, then report the directory instead of waiting out the window. + if [ ! -e "$LOCK_DIR" ]; then + if [ "$vanished" -eq 0 ]; then vanished=1; continue; fi + echo "gstack-developer-profile: cannot create $LOCK_DIR (is $GSTACK_HOME writable?), so nothing was written (exit 5)." >&2 + exit 5 + fi + vanished=0 + owner=$(cat "$LOCK_DIR/owner" 2>/dev/null || true) + if [ -d "$LOCK_DIR" ] && lock_abandoned "$owner"; then + if [ -d "$TAKEOVER_DIR" ] && [ "$(dir_age_s "$TAKEOVER_DIR")" -gt 30 ]; then rmdir "$TAKEOVER_DIR" 2>/dev/null || true; fi + if mkdir "$TAKEOVER_DIR" 2>/dev/null; then + HOLDS_TAKEOVER=1 + if [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$owner" ] && lock_abandoned "$owner"; then + rm -rf "$LOCK_DIR" 2>/dev/null || true + fi + rmdir "$TAKEOVER_DIR" 2>/dev/null || true + HOLDS_TAKEOVER="" + if [ ! -e "$LOCK_DIR" ]; then continue; fi + fi + fi + if [ "$waited_ms" -ge "$give_up_ms" ] || [ $(( SECONDS - started )) -gt $(( give_up_ms / 1000 )) ]; then + echo "gstack-developer-profile: another writer held $LOCK_DIR for ${give_up_ms} ms, so nothing was written (exit 5)." >&2 + exit 5 + fi + sleep 0.05 + waited_ms=$(( waited_ms + 50 )) + done + printf '%s\n' "$token" > "$LOCK_DIR/owner" + LOCK_TOKEN="$token" +} + # ----------------------------------------------------------------------- # Migration: builder-profile.jsonl → developer-profile.json # ----------------------------------------------------------------------- do_migrate() { + acquire_lock if [ ! -f "$LEGACY_FILE" ]; then echo "MIGRATE: no legacy file to migrate" return 0 @@ -68,9 +166,7 @@ do_migrate() { fi # Run migration in a temp file, then atomic rename. - local TMPOUT - TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") - trap 'rm -f "$TMPOUT"' EXIT + PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") cat "$LEGACY_FILE" | bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); @@ -113,11 +209,11 @@ do_migrate() { schema_version: 1, }; console.log(JSON.stringify(profile, null, 2)); - " > "$TMPOUT" + " > "$PROFILE_TMP" # Atomic rename. - mv "$TMPOUT" "$PROFILE_FILE" - trap - EXIT + mv "$PROFILE_TMP" "$PROFILE_FILE" + PROFILE_TMP="" # gbrain-sync: enqueue the migrated file for cross-machine sync (no-op if off). SCRIPT_DIR_E="$(cd "$(dirname "$0")" && pwd)" @@ -205,13 +301,12 @@ do_log_session() { *) echo "gstack-developer-profile: bun printed no verdict while validating the session; nothing was logged" >&2; return 1 ;; esac + acquire_lock ensure_profile - local TMPOUT - TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") - trap 'rm -f "$TMPOUT"' EXIT + PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") - PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " + PROFILE_FILE_PATH="$PROFILE_FILE" RECORD_INPUT="$INPUT" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const entry = JSON.parse(process.env.RECORD_INPUT); if (!entry.ts) entry.ts = new Date().toISOString(); @@ -238,8 +333,8 @@ do_log_session() { fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2)); " - mv "$TMPOUT" "$PROFILE_FILE" - trap - EXIT + mv "$PROFILE_TMP" "$PROFILE_FILE" + PROFILE_TMP="" "$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null & } @@ -252,13 +347,12 @@ do_log_session() { # rewritten only when something was added. # ----------------------------------------------------------------------- do_reconcile() { + acquire_lock ensure_profile - local TMPOUT - TMPOUT=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") - trap 'rm -f "$TMPOUT"' EXIT + PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") - PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$TMPOUT" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " + PROFILE_FILE_PATH="$PROFILE_FILE" PROJECTS_DIR="$GSTACK_HOME/projects" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " const fs = require('fs'); const path = require('path'); const profile = JSON.parse(fs.readFileSync(process.env.PROFILE_FILE_PATH, 'utf-8')); @@ -313,13 +407,13 @@ do_reconcile() { console.log('RECONCILE: ' + added + ' backfilled from ' + runs.length + ' office-hours run(s)'); " - if [ -s "$TMPOUT" ]; then - mv "$TMPOUT" "$PROFILE_FILE" - trap - EXIT + if [ -s "$PROFILE_TMP" ]; then + mv "$PROFILE_TMP" "$PROFILE_FILE" + PROFILE_TMP="" "$SCRIPT_DIR/gstack-brain-enqueue" "developer-profile.json" 2>/dev/null & else - rm -f "$TMPOUT" - trap - EXIT + rm -f "$PROFILE_TMP" + PROFILE_TMP="" fi } @@ -506,6 +600,7 @@ function accumulateSignals(lines, QUESTIONS, SIGNAL_MAP, dims) { # Derive: recompute inferred dimensions from question-events.jsonl # ----------------------------------------------------------------------- do_derive() { + acquire_lock ensure_profile local EVENTS="$GSTACK_HOME/projects/$SLUG/question-log.jsonl" local REGISTRY="$ROOT_DIR/scripts/question-registry.ts" @@ -515,8 +610,9 @@ do_derive() { exit 1 fi + PROFILE_TMP=$(mktemp "$GSTACK_HOME/developer-profile.json.tmp.XXXXXX") cd "$ROOT_DIR" - PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " + PROFILE_FILE_PATH="$PROFILE_FILE" EVENTS_PATH="$EVENTS" TMPOUT_PATH="$PROFILE_TMP" bun --no-env-file --config="${GSTACK_BUN_CONFIG:-/dev/null}" -e " import('./scripts/question-registry.ts').then(async (regmod) => { const sigmod = await import('./scripts/psychographic-signals.ts'); const fs = require('fs'); @@ -568,12 +664,12 @@ do_derive() { profile.gap = gap; profile.derived_at = new Date().toISOString(); - const tmp = profilePath + '.tmp'; - fs.writeFileSync(tmp, JSON.stringify(profile, null, 2)); - fs.renameSync(tmp, profilePath); + fs.writeFileSync(process.env.TMPOUT_PATH, JSON.stringify(profile, null, 2)); console.log('DERIVE: ok — ' + count + ' events, ' + skills.size + ' skills, ' + qids.size + ' questions'); }).catch(err => { console.error('DERIVE:', err.message); process.exit(1); }); " + mv "$PROFILE_TMP" "$PROFILE_FILE" + PROFILE_TMP="" } # ----------------------------------------------------------------------- diff --git a/package.json b/package.json index 883d131b3..766188b87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.68", + "version": "1.92.69", "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/scripts/psychographic-signals.ts b/scripts/psychographic-signals.ts index 43d4a89ab..d6a479497 100644 --- a/scripts/psychographic-signals.ts +++ b/scripts/psychographic-signals.ts @@ -306,29 +306,6 @@ export function resolveOptionKey(question_id: string, choice: string): string | return tie ? undefined : best; } -/** - * Apply a user choice for a question to the running dimension totals. - * - * @param dims - running total of dimension nudges (mutated) - * @param signal_key - from the question registry entry - * @param user_choice - the option key the user selected - * @returns list of dimension deltas applied (empty if no mapping) - */ -export function applySignal( - dims: Record, - signal_key: string, - user_choice: string, -): DimensionDelta[] { - const subMap = SIGNAL_MAP[signal_key]; - if (!subMap) return []; - const deltas = subMap[user_choice]; - if (!deltas) return []; - for (const { dim, delta } of deltas) { - dims[dim] = (dims[dim] ?? 0) + delta; - } - return deltas; -} - /** * Validate that every signal_key referenced in the registry has a matching * entry in SIGNAL_MAP. Called by tests to catch drift. @@ -353,17 +330,6 @@ export function validateRegistrySignalKeys(): { return { missing, extra }; } -/** Empty dimension totals — starting point for derivation. */ -export function newDimensionTotals(): Record { - return { - scope_appetite: 0, - risk_tolerance: 0, - detail_preference: 0, - autonomy: 0, - architecture_care: 0, - }; -} - /** Sigmoid clamp: map accumulated delta total to [0, 1]. */ export function normalizeToDimensionValue(total: number): number { // Simple sigmoid: each 1.0 of accumulated delta approaches saturation. diff --git a/test/gstack-developer-profile.test.ts b/test/gstack-developer-profile.test.ts index 46a945508..df23a9c3b 100644 --- a/test/gstack-developer-profile.test.ts +++ b/test/gstack-developer-profile.test.ts @@ -770,6 +770,155 @@ describe('gstack-developer-profile --log-session (#1671 fix)', () => { }); }); +// ----------------------------------------------------------------------- +// Concurrent writers (#3437). gstack-question-log starts a background +// --derive after every append, and a multi-question AskUserQuestion logs +// several rows at once, so derives overlap each other and /office-hours' +// --log-session. Each subcommand rewrites the whole profile, so every +// read-modify-write holds the profile lock and writes a unique temp file. +// ----------------------------------------------------------------------- + +async function runDevAsync(...args: string[]): Promise<{ stdout: string; stderr: string; status: number }> { + const proc = Bun.spawn([BIN_DEV, ...args], { + env: { ...process.env, GSTACK_HOME: tmpHome }, + cwd: ROOT, + stdout: 'pipe', + stderr: 'pipe', + }); + const timer = setTimeout(() => proc.kill(), 60_000); + const [stdout, stderr, status] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + clearTimeout(timer); + return { stdout, stderr, status }; +} + +describe('gstack-developer-profile concurrent writers (#3437)', () => { + const lockDir = () => path.join(tmpHome, 'developer-profile.json.lock'); + + test('--derive does not write through a fixed temp path', () => { + fs.mkdirSync(path.join(tmpHome, 'developer-profile.json.tmp')); + const r = runDev('--derive'); + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + expect(typeof (readProfile() as { derived_at?: string }).derived_at).toBe('string'); + }); + + test('a held profile lock keeps --derive and --log-session from writing', () => { + expect(runDev('--derive').status).toBe(0); + const before = fs.readFileSync(path.join(tmpHome, 'developer-profile.json'), 'utf-8'); + fs.mkdirSync(lockDir()); + fs.writeFileSync(path.join(lockDir(), 'owner'), process.pid + '-held\n'); + const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' }; + for (const args of [['--derive'], ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder' })]]) { + const res = spawnSync(BIN_DEV, args, { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 }); + expect(res.status).toBe(5); + expect(res.stderr).toContain('developer-profile.json.lock'); + } + expect(fs.readFileSync(path.join(tmpHome, 'developer-profile.json'), 'utf-8')).toBe(before); + expect(fs.readFileSync(path.join(lockDir(), 'owner'), 'utf-8')).toBe(process.pid + '-held\n'); + }); + + test('a stale lock left by a crashed writer is taken over', () => { + fs.mkdirSync(lockDir()); + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lockDir(), old, old); + const r = runDev('--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' })); + expect(r.status).toBe(0); + expect((readProfile() as { sessions: unknown[] }).sessions.length).toBe(1); + expect(fs.existsSync(lockDir())).toBe(false); + }); + + test('a lock whose holder process is gone is taken over at once', () => { + const gone = spawnSync('true', [], { timeout: 10_000 }).pid; + fs.mkdirSync(lockDir()); + fs.writeFileSync(path.join(lockDir(), 'owner'), gone + '-crashed\n'); + const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' }; + const res = spawnSync(BIN_DEV, ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' })], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 }); + expect(res.stderr).toBe(''); + expect(res.status).toBe(0); + expect((readProfile() as { sessions: unknown[] }).sessions.length).toBe(1); + expect(fs.existsSync(lockDir())).toBe(false); + }); + + test('a lock older than 5 minutes is taken over even when its pid is alive (pid reuse)', () => { + fs.mkdirSync(lockDir()); + fs.writeFileSync(path.join(lockDir(), 'owner'), process.pid + '-reused\n'); + const old = new Date(Date.now() - 10 * 60_000); + fs.utimesSync(lockDir(), old, old); + const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' }; + const res = spawnSync(BIN_DEV, ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' })], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 }); + expect(res.stderr).toBe(''); + expect(res.status).toBe(0); + expect((readProfile() as { sessions: unknown[] }).sessions.length).toBe(1); + expect(fs.existsSync(lockDir())).toBe(false); + }); + + test('a takeover already in progress is not repeated', () => { + const gone = spawnSync('true', [], { timeout: 10_000 }).pid; + fs.mkdirSync(lockDir()); + fs.writeFileSync(path.join(lockDir(), 'owner'), gone + '-crashed\n'); + fs.mkdirSync(lockDir() + '.takeover'); + const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' }; + const res = spawnSync(BIN_DEV, ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder' })], { env, encoding: 'utf-8', cwd: ROOT, timeout: 30_000 }); + expect(res.status).toBe(5); + expect(fs.readFileSync(path.join(lockDir(), 'owner'), 'utf-8')).toBe(gone + '-crashed\n'); + }); + + test('a takeover that cannot remove the lock still gives up', () => { + expect(runDev('--derive').status).toBe(0); + fs.mkdirSync(lockDir()); + const old = new Date(Date.now() - 120_000); + fs.utimesSync(lockDir(), old, old); + fs.chmodSync(tmpHome, 0o555); + try { + const env = { ...process.env, GSTACK_HOME: tmpHome, GSTACK_PROFILE_LOCK_TIMEOUT_MS: '300' }; + const res = spawnSync(BIN_DEV, ['--derive'], { env, encoding: 'utf-8', cwd: ROOT, timeout: 10_000 }); + expect(res.signal).toBeNull(); + expect([0, 5]).toContain(res.status ?? -1); + } finally { + fs.chmodSync(tmpHome, 0o755); + } + }, 20_000); + + test('a relative state root does not leave the lock behind', () => { + const env = { ...process.env, GSTACK_HOME: 'state' }; + for (const args of [['--derive'], ['--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'p' })]]) { + const res = spawnSync(BIN_DEV, args, { env, encoding: 'utf-8', cwd: tmpHome, timeout: 30_000 }); + expect(res.stderr).toBe(''); + expect(res.status).toBe(0); + } + const p = JSON.parse(fs.readFileSync(path.join(tmpHome, 'state', 'developer-profile.json'), 'utf-8')); + expect(p.sessions.length).toBe(1); + expect(typeof p.derived_at).toBe('string'); + expect(fs.existsSync(path.join(tmpHome, 'state', 'developer-profile.json.lock'))).toBe(false); + }); + + test('overlapping derives and log-sessions keep every session and leave valid JSON', async () => { + for (let i = 0; i < 5; i++) { + logQuestion({ skill: 'plan-ceo-review', question_id: 'plan-ceo-review-mode', question_summary: 'mode', user_choice: 'expand', session_id: 's' + i }); + } + expect(runDev('--derive').status).toBe(0); + const rounds = 6; + for (let round = 0; round < rounds; round++) { + const runs = await Promise.all([ + ...Array.from({ length: 6 }, () => runDevAsync('--derive')), + runDevAsync('--log-session', JSON.stringify({ date: '2026-10-31', mode: 'builder', project_slug: 'round-' + round })), + ]); + for (const r of runs) { + expect(r.stderr).toBe(''); + expect(r.status).toBe(0); + } + } + const p = readProfile() as { sessions: Array<{ project_slug: string }>; inferred: { sample_size: number } }; + expect(p.sessions.map((s) => s.project_slug).sort()).toEqual(Array.from({ length: rounds }, (_, i) => 'round-' + i).sort()); + expect(p.inferred.sample_size).toBe(5); + expect(fs.readdirSync(tmpHome).filter((f) => f.startsWith('developer-profile.json.'))).toEqual([]); + }, 120_000); +}); + // ----------------------------------------------------------------------- // SESSION_COUNT / TIER / NUDGE_ELIGIBLE must ignore mode:resources entries. // diff --git a/test/plan-tune.test.ts b/test/plan-tune.test.ts index 413ed07f9..467ec8b91 100644 --- a/test/plan-tune.test.ts +++ b/test/plan-tune.test.ts @@ -29,9 +29,7 @@ import { } from '../scripts/one-way-doors'; import { SIGNAL_MAP, - applySignal, validateRegistrySignalKeys, - newDimensionTotals, normalizeToDimensionValue, resolveOptionKey, ALL_DIMENSIONS, @@ -246,26 +244,6 @@ describe('psychographic signal map', () => { expect(missing).toEqual([]); }); - test('applySignal mutates dimension totals per mapping', () => { - const dims = newDimensionTotals(); - const applied = applySignal(dims, 'scope-appetite', 'expand'); - expect(applied.length).toBeGreaterThan(0); - expect(dims.scope_appetite).toBeCloseTo(0.06, 5); - }); - - test('applySignal returns [] for unknown signal_key', () => { - const dims = newDimensionTotals(); - const applied = applySignal(dims, 'no-such-signal', 'anything'); - expect(applied).toEqual([]); - expect(dims.scope_appetite).toBe(0); - }); - - test('applySignal returns [] for unknown user_choice', () => { - const dims = newDimensionTotals(); - const applied = applySignal(dims, 'scope-appetite', 'definitely-not-a-real-choice'); - expect(applied).toEqual([]); - }); - test('normalizeToDimensionValue maps 0 → 0.5 (neutral)', () => { expect(normalizeToDimensionValue(0)).toBeCloseTo(0.5, 5); }); From cd788faec718b60fefe38e1694fccc8b17775a43 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 01:46:47 -0800 Subject: [PATCH 08/11] v1.92.70.0 test(free): build git fixtures once per file and copy them per test (GSTA-151 C) (#3586) * test(free): build git fixtures once per file and copy them per test test/helpers/git-fixture-template.ts builds a template once, copies it per test, rebinds every path-bearing file and fails on a leaked template path. Adopted by release-cut, train-backstop, restamp-mentions, pregate-user-policy and held-install-recovery; restamp-fixture's origin turns off auto-maintenance. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(free): baseline the git fixture template regression test in the test-of-test ratchet Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): unreleased entry for the git fixture templates (GSTA-151 C) --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 10 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- test/git-fixture-template.test.ts | 97 +++++++++++++++++++++++++ test/held-install-recovery.test.ts | 33 ++++++++- test/helpers/git-fixture-template.ts | 105 +++++++++++++++++++++++++++ test/helpers/pregate-fixture.ts | 36 +++++++-- test/helpers/restamp-fixture.ts | 40 ++++++++-- test/pregate-user-policy.test.ts | 8 +- test/release-cut.test.ts | 59 ++++++++++----- test/restamp-mentions.test.ts | 14 +++- test/test-of-test-ratchet.test.ts | 1 + test/train-backstop.test.ts | 22 +++++- 14 files changed, 385 insertions(+), 46 deletions(-) create mode 100644 test/git-fixture-template.test.ts create mode 100644 test/helpers/git-fixture-template.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 07112e9ea..09fff87eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.92.70.0] - 2026-11-02 + +**Five free-test files stop rebuilding the same git repositories for every test: they spend 17–24 s less in summed per-file runtime, with every assertion intact.** Each test still gets its own private copy of the fixture. + +### Tests +- `test/helpers/git-fixture-template.ts` (new): builds a git fixture once per test file and copies it per test with `fs.cpSync`. The adopter rewrites every path-bearing file (remote URLs, alternates, state roots), and the copy is then scanned for any remaining reference to the template, so a test can never push into or read from the shared template. `test/git-fixture-template.test.ts` pins two-copy isolation and the leak scan. +- `test/release-cut.test.ts` (~10 s to ~6 s), `test/train-backstop.test.ts` (~4 s to ~1.6 s), `test/restamp-mentions.test.ts` (via `test/helpers/restamp-fixture.ts`), `test/pregate-user-policy.test.ts` (via `test/helpers/pregate-fixture.ts`) and `test/held-install-recovery.test.ts` (~33 s to ~22 s) use the template. Test and `expect()` counts are unchanged in every file. +- `test/helpers/restamp-fixture.ts` turns off auto-maintenance (`maintenance.auto=false`, `gc.auto=0`) in the bare origin the ship-queue train tests push to. +- `test/test-of-test-ratchet.test.ts`: one BASELINE row for the new helper test, which tests a test helper by design. + ## [1.92.69.0] - 2026-11-02 **Background `--derive` runs no longer drop /office-hours sessions or fail with ENOENT when they overlap (#3437).** `gstack-question-log` starts a background `gstack-developer-profile --derive` after every append, and a multi-question AskUserQuestion logs several rows at once. Every derive wrote the same `developer-profile.json.tmp` and rewrote the whole profile without a lock. On main at 3bfe566, 30 rounds of 8 overlapping derives plus one `--log-session` saw 9 of 270 runs fail with `ENOENT` on rename, and 2 of the 30 sessions disappeared. With this change the same run has 0 failures and keeps 30 of 30 sessions. diff --git a/VERSION b/VERSION index d44ae96df..9dc23d34a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.69.0 +1.92.70.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 72187e385..1fe114a87 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.69.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.70.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/package.json b/package.json index 766188b87..15a0b5f64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.69", + "version": "1.92.70", "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/git-fixture-template.test.ts b/test/git-fixture-template.test.ts new file mode 100644 index 000000000..76375466c --- /dev/null +++ b/test/git-fixture-template.test.ts @@ -0,0 +1,97 @@ +/** + * test/helpers/git-fixture-template.ts: copies made from one template are + * independent repositories (a push in one never reaches the other or the + * template), and a copy whose rebind misses a path that names the template + * root, in any of its spellings, throws instead of reaching a test. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { gitFixtureTemplate, pathSpellings, rebindFile, rebindGitDir, templateReferences } from './helpers/git-fixture-template'; + +const roots: string[] = []; +afterEach(() => { for (const r of roots.splice(0)) fs.rmSync(r, { recursive: true, force: true }); }); + +function git(cwd: string, ...args: string[]): string { + return execFileSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8', timeout: 30_000 }).trim(); +} + +function build(root: string): { head: string } { + const origin = path.join(root, 'origin.git'); + const work = path.join(root, 'work'); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + fs.mkdirSync(work); + git(work, 'init', '-q', '-b', 'main'); + git(work, 'remote', 'add', 'origin', origin); + fs.writeFileSync(path.join(work, 'a.txt'), 'base\n'); + git(work, 'add', '-A'); + git(work, 'commit', '-q', '-m', 'base'); + git(work, 'push', '-q', 'origin', 'main'); + return { head: git(work, 'rev-parse', 'HEAD') }; +} + +const template = gitFixtureTemplate({ prefix: 'gstack-fixture-template-', build, rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) }); +const leaky = gitFixtureTemplate({ prefix: 'gstack-fixture-template-leak-', build, rebind: () => {} }); +afterAll(() => { template.dispose(); leaky.dispose(); }); + +function copy() { + const c = template.copy(); + roots.push(c.root); + return { ...c, work: path.join(c.root, 'work'), origin: path.join(c.root, 'origin.git') }; +} +function pushChange(c: ReturnType, text: string): string { + fs.writeFileSync(path.join(c.work, 'a.txt'), text); + git(c.work, 'commit', '-q', '-am', text); + git(c.work, 'push', '-q', 'origin', 'main'); + return git(c.work, 'rev-parse', 'HEAD'); +} + +describe('git fixture template', () => { + test('two copies from one template mutated independently stay isolated from each other and from the template', () => { + const a = copy(); + const b = copy(); + expect(git(a.work, 'remote', 'get-url', 'origin')).toBe(a.origin); + expect(git(b.work, 'remote', 'get-url', 'origin')).toBe(b.origin); + const fromA = pushChange(a, 'from a\n'); + expect(git(a.origin, 'rev-parse', 'main')).toBe(fromA); + expect(git(b.origin, 'rev-parse', 'main')).toBe(b.meta.head); + const fromB = pushChange(b, 'from b\n'); + expect(fromB).not.toBe(fromA); + expect(git(a.origin, 'rev-parse', 'main')).toBe(fromA); + expect(git(b.origin, 'rev-parse', 'main')).toBe(fromB); + expect(fs.readFileSync(path.join(a.work, 'a.txt'), 'utf8')).toBe('from a\n'); + const c = copy(); + expect(git(c.origin, 'rev-parse', 'main')).toBe(c.meta.head); + expect(fs.readFileSync(path.join(c.work, 'a.txt'), 'utf8')).toBe('base\n'); + }); + + test('a copy whose rebind leaves the remote URL on the template throws and names the file', () => { + expect(() => leaky.copy()).toThrow(/still references its template [^\n]*\n.*config/); + }); + + test('native, forward-slash, backslash-escaped and Git Bash spellings are each rewritten, and each one left behind is found', () => { + const to = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fixture-spellings-')); + roots.push(to); + const from = 'C:\\Users\\RUNNER~1\\AppData\\Local\\Temp\\gstack-fixture-tpl-AbC123'; + const spellings = pathSpellings(from); + expect(spellings).toEqual([ + 'C:\\\\Users\\\\RUNNER~1\\\\AppData\\\\Local\\\\Temp\\\\gstack-fixture-tpl-AbC123', + from, + 'C:/Users/RUNNER~1/AppData/Local/Temp/gstack-fixture-tpl-AbC123', + '/c/Users/RUNNER~1/AppData/Local/Temp/gstack-fixture-tpl-AbC123', + ]); + const file = path.join(to, 'config'); + fs.writeFileSync(file, spellings.map(s => 'url = ' + s + '/origin.git\n').join('')); + expect(templateReferences(to, from)).toEqual([file]); + rebindFile(file, from, to); + expect(fs.readFileSync(file, 'utf8')).toBe(spellings.map((_, i) => 'url = ' + (pathSpellings(to)[i] ?? to) + '/origin.git\n').join('')); + expect(templateReferences(to, from)).toEqual([]); + for (const s of spellings) { + fs.writeFileSync(file, 'url = ' + s + '\n'); + expect(templateReferences(to, from)).toEqual([file]); + } + expect(templateReferences(to, from, { origin: from + '\\origin.git' })).toEqual([file, '(template meta)']); + }); +}); diff --git a/test/held-install-recovery.test.ts b/test/held-install-recovery.test.ts index 3604370e2..5e16f97be 100644 --- a/test/held-install-recovery.test.ts +++ b/test/held-install-recovery.test.ts @@ -8,11 +8,12 @@ * already-unmerged), all in prefix mode; the PowerShell forms run under * powershell.exe in .github/workflows/windows-setup-e2e.yml. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { execFileSync, spawn, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { gitFixtureTemplate } from './helpers/git-fixture-template'; const ROOT = path.resolve(import.meta.dir, '..'); const DOC = fs.readFileSync(path.join(ROOT, 'docs', 'troubleshooting.md'), 'utf8'); @@ -37,8 +38,33 @@ const CONFLICT_LINE = 'THROTTLE_SECONDS=3600 # 1 hour'; * bin/gstack-ff-update (an install that predates it), with VERSION 1.91.101.0. */ function makeFixture(source = ROOT) { - const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-held-'))); + if (source !== ROOT) { + const base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-held-'))); + bases.push(base); + const { src, ...fx } = buildFixture(base, source); + expect(src).toContain(CONFLICT_LINE); + return { base, ...fx }; + } + const { root: base, meta: { src, ...fx } } = rootTemplate.copy(); bases.push(base); + expect(src).toContain(CONFLICT_LINE); + return { base, origin: path.join(base, 'origin.git'), ...fx }; +} + +/** makeFixture's origin.git for this checkout, built once per file; the per-test source checkouts build their own. */ +const rootTemplate = gitFixtureTemplate({ + prefix: 'gstack-held-', + realpath: true, + build: (base) => { + const { origin, ...meta } = buildFixture(base, ROOT); + fs.rmSync(path.join(base, 'work'), { recursive: true, force: true }); + return meta; + }, + rebind: () => {}, +}); +afterAll(() => rootTemplate.dispose()); + +function buildFixture(base: string, source: string) { const origin = path.join(base, 'origin.git'); const work = path.join(base, 'work'); execFileSync('git', ['clone', '-q', '--shared', source, work], { timeout: 60_000 }); @@ -53,7 +79,6 @@ function makeFixture(source = ROOT) { const held = git(work, 'rev-parse', 'HEAD'); fs.copyFileSync(path.join(ROOT, 'bin', 'gstack-ff-update'), path.join(work, 'bin', 'gstack-ff-update')); const src = fs.readFileSync(path.join(work, EDITED), 'utf8'); - expect(src).toContain(CONFLICT_LINE); fs.writeFileSync(path.join(work, EDITED), src.replace(CONFLICT_LINE, 'THROTTLE_SECONDS=3600 # 1 hour (upstream)')); fs.writeFileSync(path.join(work, 'VERSION'), '1.91.102.0\n'); git(work, 'add', '-A'); @@ -61,7 +86,7 @@ function makeFixture(source = ROOT) { const fixed = git(work, 'rev-parse', 'HEAD'); execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 60_000 }); git(work, 'push', '-q', origin, 'HEAD:refs/heads/main'); - return { base, origin, held, fixed }; + return { origin, held, fixed, src }; } type Fx = ReturnType; diff --git a/test/helpers/git-fixture-template.ts b/test/helpers/git-fixture-template.ts new file mode 100644 index 000000000..a646747ab --- /dev/null +++ b/test/helpers/git-fixture-template.ts @@ -0,0 +1,105 @@ +/** + * Build a git fixture once per test file and hand each test its own copy. + * + * build(root) runs once, on the first copy(), into a template directory under + * os.tmpdir(). Every copy() makes a fresh sibling directory, copies the + * template into it with fs.cpSync (symlinks kept verbatim) and calls + * rebind(copyRoot, templateRoot), which must rewrite every path-bearing file + * the fixture wrote: remote URLs and pushurls in .git/config, reflog messages, + * FETCH_HEAD, objects/info/alternates, state roots, install records. The copy + * is then scanned for any remaining reference to the template root (native, + * forward-slash and backslash-escaped spellings, plus the realpath and Git + * Bash forms where they differ); a hit throws, so a test can never push into, + * fetch from or read state out of the shared template. dispose() removes the + * template; call it from afterAll. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +export interface GitFixtureCopy { root: string; meta: T } +export interface GitFixtureTemplate { copy: (prefix?: string) => GitFixtureCopy; dispose: () => void } + +/** The spellings a path takes inside files git and gstack write, escaped form first so a rewrite never splits it. */ +export function pathSpellings(root: string): string[] { + const forms = (p: string) => { + const forward = p.replace(/\\/g, '/'); + const drive = /^([A-Za-z]):\//.exec(forward); + return [p.replace(/\\/g, '\\\\'), p, forward, ...(drive ? ['/' + drive[1]!.toLowerCase() + '/' + forward.slice(3)] : [])]; + }; + let real = root; + try { real = fs.realpathSync.native(root); } catch {} + return [...new Set([...forms(root), ...forms(real)])]; +} + +/** Rewrite every spelling of from to the same spelling of to in a text file, when the file exists. */ +export function rebindFile(file: string, from: string, to: string): void { + if (!fs.existsSync(file)) return; + const targets = pathSpellings(to); + let text = fs.readFileSync(file, 'utf8'); + const before = text; + pathSpellings(from).forEach((spelling, i) => { text = text.split(spelling).join(targets[i] ?? to); }); + if (text !== before) fs.writeFileSync(file, text); +} + +/** Rebind the path-bearing files git writes in a git directory: config, FETCH_HEAD, alternates and every reflog. */ +export function rebindGitDir(gitDir: string, from: string, to: string): void { + for (const rel of ['config', 'FETCH_HEAD', path.join('objects', 'info', 'alternates')]) rebindFile(path.join(gitDir, rel), from, to); + const logs = path.join(gitDir, 'logs'); + if (!fs.existsSync(logs)) return; + for (const entry of fs.readdirSync(logs, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) rebindFile(path.join(entry.parentPath, entry.name), from, to); + } +} + +/** Files and symlinks under dir (and the template's meta value) that still name templateRoot; zlib-compressed object storage is skipped. */ +export function templateReferences(dir: string, templateRoot: string, meta?: unknown): string[] { + const needles = pathSpellings(templateRoot).map(s => Buffer.from(s)); + const names = (buf: Buffer) => needles.some(n => buf.includes(n)); + const hits: string[] = []; + const walk = (d: string) => { + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const p = path.join(d, entry.name); + if (entry.isSymbolicLink()) { if (names(Buffer.from(fs.readlinkSync(p)))) hits.push(p); } + else if (entry.isDirectory()) { if (!(path.basename(d) === 'objects' && /^(pack|[0-9a-f]{2})$/.test(entry.name))) walk(p); } + else if (entry.isFile() && names(fs.readFileSync(p))) hits.push(p); + } + }; + walk(dir); + if (meta !== undefined && names(Buffer.from(JSON.stringify(meta)))) hits.push('(template meta)'); + return hits; +} + +export function gitFixtureTemplate(opts: { + prefix: string; + build: (root: string) => T; + rebind: (copyRoot: string, templateRoot: string) => void; + realpath?: boolean; +}): GitFixtureTemplate { + let template: GitFixtureCopy | null = null; + const mkroot = (prefix: string) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return opts.realpath ? fs.realpathSync(dir) : dir; + }; + return { + copy(prefix = opts.prefix) { + if (!template) { + const root = mkroot(opts.prefix + 'tpl-'); + template = { root, meta: opts.build(root) }; + } + const { root: from, meta } = template; + const root = mkroot(prefix); + if (pathSpellings(root).some(s => pathSpellings(from).some(t => s.startsWith(t)))) throw new Error('git fixture copy ' + root + ' lies under its template ' + from); + fs.cpSync(from, root, { recursive: true, verbatimSymlinks: true }); + opts.rebind(root, from); + const leaks = templateReferences(root, from, meta); + if (leaks.length) fs.rmSync(root, { recursive: true, force: true }); + if (leaks.length) throw new Error('git fixture copy still references its template ' + from + ' (rebind missed them):\n' + leaks.join('\n')); + return { root, meta }; + }, + dispose() { + if (template) fs.rmSync(template.root, { recursive: true, force: true }); + template = null; + }, + }; +} diff --git a/test/helpers/pregate-fixture.ts b/test/helpers/pregate-fixture.ts index 94d0b3ad3..baef6855e 100644 --- a/test/helpers/pregate-fixture.ts +++ b/test/helpers/pregate-fixture.ts @@ -14,6 +14,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { runScriptSync } from '../../lib/script-invocation'; +import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './git-fixture-template'; import { gitArgvIn } from './scratch-repo'; export const ROOT = path.resolve(import.meta.dir, '..', '..'); @@ -80,20 +81,46 @@ jobs: run: node --test \${{ matrix.files }} `; -export function makeFixtureRepo(opts: { registry?: Record | null; pregate?: Record | null; prefix?: string; baseFiles?: Record } = {}): FixtureRepo { +export type FixtureOptions = { registry?: Record | null; pregate?: Record | null; prefix?: string; baseFiles?: Record }; + +export function makeFixtureRepo(opts: FixtureOptions = {}): FixtureRepo { const root = fs.mkdtempSync(path.join(os.tmpdir(), opts.prefix ?? 'gstack-pregate-fx-')); + seedFixtureRepo(root, opts); + return fixtureRepoAt(root); +} + +/** A template of makeFixtureRepo(opts), built once and copied per test (helpers/git-fixture-template.ts). */ +export function fixtureTemplate(opts: FixtureOptions = {}): GitFixtureTemplate { + return gitFixtureTemplate({ + prefix: opts.prefix ?? 'gstack-pregate-fx-', + build: root => { seedFixtureRepo(root, opts); return null; }, + rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy), + }); +} + +/** A fresh copy of a fixtureTemplate as its own FixtureRepo (cleanup() removes it like any other). */ +export function cloneFixture(template: GitFixtureTemplate, prefix?: string): FixtureRepo { + return fixtureRepoAt(template.copy(prefix).root); +} + +function fixtureRepoAt(root: string): FixtureRepo { const origin = path.join(root, 'origin.git'); const work = path.join(root, 'work'); const home = path.join(root, 'home'); - fs.mkdirSync(home, { recursive: true }); - fs.mkdirSync(work, { recursive: true }); const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; - fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); const git = (...args: string[]): string => { const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env }); if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: status=${r.status} signal=${r.signal} error=${r.error?.message ?? ''} stderr=${r.stderr?.toString() ?? ''}`); return r.stdout?.toString().trim() ?? ''; }; + return { root, work, origin, home, git, env }; +} + +function seedFixtureRepo(root: string, opts: FixtureOptions): void { + const { origin, work, home, env, git } = fixtureRepoAt(root); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(work, { recursive: true }); + fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } }); git('init', '-q', '-b', 'main'); for (const dir of ['src', 'lib', 'bin', 'test', 'test/golden', 'test/nightly', 'generated', 'docs', 'scripts', '.gstack', '.github/workflows']) fs.mkdirSync(path.join(work, dir), { recursive: true }); @@ -130,7 +157,6 @@ export function makeFixtureRepo(opts: { registry?: Record | nul git('push', '-q', 'origin', 'main'); git('remote', 'set-head', 'origin', 'main'); git('checkout', '-q', '-b', 'feature'); - return { root, work, origin, home, git, env }; } export function write(work: string, rel: string, text: string): void { diff --git a/test/helpers/restamp-fixture.ts b/test/helpers/restamp-fixture.ts index 2b6366c99..6ef5b188f 100644 --- a/test/helpers/restamp-fixture.ts +++ b/test/helpers/restamp-fixture.ts @@ -11,6 +11,7 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './git-fixture-template'; import { gitArgvIn } from './scratch-repo'; import { mergeChangelogs } from '../../lib/changelog-check'; import { runScriptSync } from '../../lib/script-invocation'; @@ -32,21 +33,51 @@ export const BASE_POLICY = { stamp_sensitive_gates: ['digest'], }; -export function makeFixtureRepo(opts: { policy?: Record | null; version?: string; prefix?: string } = {}): FixtureRepo { +export type FixtureOptions = { policy?: Record | null; version?: string; prefix?: string }; + +export function makeFixtureRepo(opts: FixtureOptions = {}): FixtureRepo { const root = fs.mkdtempSync(path.join(os.tmpdir(), opts.prefix ?? 'gstack-restamp-fx-')); + seedFixtureRepo(root, opts); + return fixtureRepoAt(root); +} + +/** The FixtureRepo handle for a root seedFixtureRepo filled, directly or through a template copy. */ +export function fixtureRepoAt(root: string): FixtureRepo { const origin = path.join(root, 'origin.git'); const work = path.join(root, 'work'); const home = path.join(root, 'home'); - fs.mkdirSync(home, { recursive: true }); - fs.mkdirSync(work, { recursive: true }); const env = { GSTACK_HOME: home, GSTACK_STATE_DIR: home, HOME: home, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; - fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); const git = (...args: string[]): string => { const r = gitArgvIn(work, args, 30_000, { ...process.env, ...env }); if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr?.toString()}`); return r.stdout?.toString().trim() ?? ''; }; + return { root, work, origin, home, git, env }; +} + +/** A template of makeFixtureRepo(opts), built once and copied per test (helpers/git-fixture-template.ts). */ +export function fixtureTemplate(opts: FixtureOptions = {}): GitFixtureTemplate { + return gitFixtureTemplate({ + prefix: opts.prefix ?? 'gstack-restamp-fx-', + build: root => { seedFixtureRepo(root, opts); return null; }, + rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy), + }); +} + +/** A fresh copy of a fixtureTemplate as its own FixtureRepo (cleanup() removes it like any other). */ +export function cloneFixture(template: GitFixtureTemplate, prefix?: string): FixtureRepo { + return fixtureRepoAt(template.copy(prefix).root); +} + +/** makeFixtureRepo's repository, written into an existing root. */ +export function seedFixtureRepo(root: string, opts: FixtureOptions): void { + const { origin, work, home, env, git } = fixtureRepoAt(root); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(work, { recursive: true }); + fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@test\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); spawnSync('git', ['init', '-q', '--bare', origin], { timeout: 30_000, env: { ...process.env, ...env } }); + git('--git-dir', origin, 'config', 'maintenance.auto', 'false'); + git('--git-dir', origin, 'config', 'gc.auto', '0'); git('init', '-q', '-b', 'main'); const version = opts.version ?? '1.2.3.0'; const npm = version.split('.').slice(0, 3).join('.'); @@ -66,7 +97,6 @@ export function makeFixtureRepo(opts: { policy?: Record | null; git('remote', 'add', 'origin', origin); git('push', '-q', 'origin', 'main'); git('remote', 'set-head', 'origin', 'main'); - return { root, work, origin, home, git, env }; } /** A PR branch from origin/main with an `[Unreleased]` CHANGELOG entry and a doc mentioning the version it expects. */ diff --git a/test/pregate-user-policy.test.ts b/test/pregate-user-policy.test.ts index aad41a5be..6cb2b2667 100644 --- a/test/pregate-user-policy.test.ts +++ b/test/pregate-user-policy.test.ts @@ -6,7 +6,7 @@ * requires-remote. Every assertion holds on any host platform: each lane the * fixture selects is either named in `local` or asserted only by label. */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as os from 'node:os'; @@ -15,18 +15,20 @@ import { EXIT } from '../lib/headless-artifacts'; import { loadConfigFromUser, validateConfig } from '../lib/pregate/config'; import { buildContext, sha256, userConfigRejection } from '../lib/pregate/context'; import { runPregate, verifyPregate } from '../lib/pregate/index'; -import { BASE_PREGATE, BIN, cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; +import { BASE_PREGATE, BIN, cleanup, cloneFixture, commit, fixtureTemplate, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; import { runScriptSync } from '../lib/script-invocation'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const noBasePolicy = fixtureTemplate({ pregate: null }); +afterAll(() => noBasePolicy.dispose()); const SLUG = 'userpolicy'; const LOCAL_ALL = ['free-tests.yml/unit', 'windows.yml/windows-shard']; /** A fixture whose base carries no `.gstack/pregate.json`, a feature commit touching lib/util.js, and the user-file path for its slug. */ function setup(): { repo: FixtureRepo; userPolicy: string; run: (extra?: string[]) => ReturnType } { - const repo = makeFixtureRepo({ pregate: null }); + const repo = cloneFixture(noBasePolicy); repos.push(repo); commit(repo, { 'lib/util.js': 'exports.add = (a, b) => b + a;\n' }); const userPolicy = path.join(repo.home, 'projects', SLUG, 'pregate.json'); diff --git a/test/release-cut.test.ts b/test/release-cut.test.ts index f9b45efeb..6986b5630 100644 --- a/test/release-cut.test.ts +++ b/test/release-cut.test.ts @@ -6,12 +6,12 @@ * check run or a red one keeps a commit out; a cut that stopped half-way is * finished by the next run (docs/releases.md). */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; import { execFileSync, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; -import * as os from 'node:os'; import * as path from 'node:path'; import { bunIsolationArgs } from '../lib/bun-isolation'; +import { gitFixtureTemplate, rebindGitDir } from './helpers/git-fixture-template'; import { BACKSTOP_CHECK, FLOOR_HOURS, NOTES_CAP, NOTES_REF, REQUIRED_CHECK, SCHEDULED_GRACE_HOURS, changelogEntries, compareVersions, cutRelease, describePlan, floorStatus, gateVerdict, gitIn, planRelease, readTrainNotes, releaseNotes, sanitizeReason, stableMovedAt, verifyRegression, verifySecurity, type Gh, type Git, @@ -55,19 +55,10 @@ function fakeGh(state: State): Gh { }; } -function fixture() { - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-release-cut-')); - bases.push(base); - const origin = path.join(base, 'origin.git'); - const seed = path.join(base, 'seed'); - const work = path.join(base, 'work'); - execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); - fs.mkdirSync(seed); - git(seed, 'init', '-q', '-b', 'main'); - git(seed, 'remote', 'add', 'origin', origin); - const shas: Record = {}; - let changelog = '# Changelog\n'; - const release = (version: string, headline: string, o: { message?: string; files?: Record } = {}) => { +/** Commits a release (VERSION, a CHANGELOG headline, optional files) in seed and pushes it to origin. */ +function releaser(seed: string, shas: Record) { + let changelog = fs.existsSync(path.join(seed, 'CHANGELOG.md')) ? fs.readFileSync(path.join(seed, 'CHANGELOG.md'), 'utf8') : '# Changelog\n'; + return (version: string, headline: string, o: { message?: string; files?: Record } = {}) => { changelog = changelog.replace('# Changelog\n', '# Changelog\n\n## [' + version + '] - 2026-10-2' + version.slice(-1) + '\n\n**' + headline + '**\n\n### Fixed\n- a thing\n'); fs.writeFileSync(path.join(seed, 'VERSION'), version + '\n'); fs.writeFileSync(path.join(seed, 'CHANGELOG.md'), changelog); @@ -78,11 +69,39 @@ function fixture() { shas[version] = git(seed, 'rev-parse', 'HEAD'); return shas[version]!; }; - release('1.0.0', 'First one.'); - release('1.0.1', 'Second: the updater no longer skips.'); - release('1.0.2', 'Third fixes Windows.'); - release('1.0.3', 'Fourth trims the preamble.'); - execFileSync('git', ['clone', '-q', origin, work], { timeout: 30_000 }); +} + +/** origin with releases 1.0.0..1.0.3 pushed from seed, and work cloned from it; built once for this file, copied per test. */ +const template = gitFixtureTemplate({ + prefix: 'gstack-release-cut-', + build: (base) => { + const origin = path.join(base, 'origin.git'); + const seed = path.join(base, 'seed'); + execFileSync('git', ['init', '-q', '--bare', '-b', 'main', origin], { timeout: 30_000 }); + fs.mkdirSync(seed); + git(seed, 'init', '-q', '-b', 'main'); + git(seed, 'remote', 'add', 'origin', origin); + const shas: Record = {}; + const release = releaser(seed, shas); + release('1.0.0', 'First one.'); + release('1.0.1', 'Second: the updater no longer skips.'); + release('1.0.2', 'Third fixes Windows.'); + release('1.0.3', 'Fourth trims the preamble.'); + execFileSync('git', ['clone', '-q', origin, path.join(base, 'work')], { timeout: 30_000 }); + return shas; + }, + rebind: (copy, tpl) => { for (const repo of ['seed', 'work']) rebindGitDir(path.join(copy, repo, '.git'), tpl, copy); }, +}); +afterAll(() => template.dispose()); + +function fixture() { + const { root: base, meta } = template.copy(); + bases.push(base); + const origin = path.join(base, 'origin.git'); + const seed = path.join(base, 'seed'); + const work = path.join(base, 'work'); + const shas: Record = { ...meta }; + const release = releaser(seed, shas); const refresh = () => { git(work, 'fetch', '-q', 'origin', '+refs/heads/*:refs/remotes/origin/*'); git(work, 'fetch', '-q', '--tags', 'origin'); }; const state: State = { checks: {}, released: new Set(), calls: [] }; return { base, origin, seed, work, shas, release, refresh, state, gh: fakeGh(state), git: gitIn(work), repo: 'o/r' }; diff --git a/test/restamp-mentions.test.ts b/test/restamp-mentions.test.ts index 0fc839989..a59c0e9c4 100644 --- a/test/restamp-mentions.test.ts +++ b/test/restamp-mentions.test.ts @@ -9,16 +9,26 @@ * version moves only on lines the branch added), and the negative control * (a base-side quoted row carrying that version stays). */ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterAll, 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 { rewriteStamps, tentativeMentions } from '../lib/restamp'; -import { BASE_POLICY, cleanup, makeFixtureRepo, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; +import type { GitFixtureTemplate } from './helpers/git-fixture-template'; +import { BASE_POLICY, cleanup, cloneFixture, fixtureTemplate, makePr, mergeMain, mergeToMain, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; const repos: FixtureRepo[] = []; afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +/** The helper's makeFixtureRepo, built once per policy for this file and copied per test. */ +const templates = new Map>(); +afterAll(() => { for (const t of templates.values()) t.dispose(); }); +function makeFixtureRepo(opts: { prefix: string; policy: Record }): FixtureRepo { + const key = JSON.stringify(opts.policy); + if (!templates.has(key)) templates.set(key, fixtureTemplate({ policy: opts.policy, prefix: 'restamp-mentions-' })); + return cloneFixture(templates.get(key)!, opts.prefix); +} + const stageDetail = (out: string, name: string) => new RegExp(`^stage ${name}: (\\w+)(?: \\((.*)\\))?$`, 'm').exec(out); describe('quoted stamp forms (pure)', () => { diff --git a/test/test-of-test-ratchet.test.ts b/test/test-of-test-ratchet.test.ts index cad251bda..4a0b48977 100644 --- a/test/test-of-test-ratchet.test.ts +++ b/test/test-of-test-ratchet.test.ts @@ -104,6 +104,7 @@ const BASELINE = [ 'test/extension-pty-inject-invariant.test.ts', 'test/gbrain-source-worktree-advance.test.ts', 'test/gbrain-sync-voyage-code-3-integration.test.ts', + 'test/git-fixture-template.test.ts', // GSTA-151: pins the copy isolation and leak scan of test/helpers/git-fixture-template.ts, which the templated git fixtures in release-cut, train-backstop, restamp-mentions, pregate-user-policy and held-install-recovery rely on 'test/gstack-gbrain-source-wireup.test.ts', 'test/gstack-home-module-scope.test.ts', 'test/gstack-upgrade-migration-v1_17_0_0.test.ts', diff --git a/test/train-backstop.test.ts b/test/train-backstop.test.ts index ecdf6aeed..2c2ca79ca 100644 --- a/test/train-backstop.test.ts +++ b/test/train-backstop.test.ts @@ -17,7 +17,8 @@ import type { GhRunner } from '../scripts/recover-receipts'; import { runBackstop } from '../scripts/train-backstop'; import { gitIn, trainPrefixes, type TrainRecord } from '../scripts/train-gate'; import { E2E_TOUCHFILES } from './helpers/touchfiles-data'; -import { BASE_POLICY, cleanup, makeFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; +import { gitFixtureTemplate, rebindGitDir, type GitFixtureTemplate } from './helpers/git-fixture-template'; +import { BASE_POLICY, cleanup, fixtureRepoAt, seedFixtureRepo, type FixtureRepo } from './helpers/restamp-fixture'; import { TRAIN_BLOCK, TRAIN_POLICY } from './helpers/ship-queue-train-fixture'; const REPO = 'fixture/repo'; @@ -30,11 +31,24 @@ afterEach(() => { if (savedStateRoot === undefined) delete process.env.GSTACK_ST interface Fixture { repo: FixtureRepo; main0: string; prs: string[]; train: string[]; record: TrainRecord } -/** Five PR branches from main and the train T1..T5 gstack-ship-queue would build from them (lib/ship-queue-train.ts buildChain). */ +/** Five PR branches from main and the train T1..T5 gstack-ship-queue would build from them, built once per policy and copied per test. */ +const templates = new Map>>(); +afterAll(() => { for (const t of templates.values()) t.dispose(); }); function trainFixture(policy: Record = TRAIN_POLICY): Fixture { - const repo = makeFixtureRepo({ policy, prefix: 'train-backstop-fx-' }); + const key = JSON.stringify(policy); + if (!templates.has(key)) templates.set(key, gitFixtureTemplate({ prefix: 'train-backstop-fx-', build: root => buildTrain(root, policy), rebind: (copy, tpl) => rebindGitDir(path.join(copy, 'work', '.git'), tpl, copy) })); + const { root, meta } = templates.get(key)!.copy(); + const repo = fixtureRepoAt(root); repos.push(repo); process.env.GSTACK_STATE_ROOT = path.join(repo.root, 'state'); + return { repo, ...structuredClone(meta) }; +} + +/** lib/ship-queue-train.ts buildChain's T1..T5 over five PR branches from main. */ +function buildTrain(root: string, policy: Record): Omit { + seedFixtureRepo(root, { policy }); + const repo = fixtureRepoAt(root); + process.env.GSTACK_STATE_ROOT = path.join(repo.root, 'state'); const main0 = repo.git('rev-parse', 'HEAD'); const prs = [1, 2, 3, 4, 5].map(i => { repo.git('checkout', '-q', '-B', `pr${i}`, main0); @@ -56,7 +70,7 @@ function trainFixture(policy: Record = TRAIN_POLICY): Fixture { const record: TrainRecord = { schema: 1, kind: 'train-gate', nonce: TRAIN_NONCE, head_sha: parent, tree: repo.git('rev-parse', `${parent}^{tree}`), verdict: 'GREEN', census: { complete: true, planned: { rule: 108, judge: 24 }, executed: { rule: 108, judge: 24 }, unbound: 0, partial_shards: 0, cancelled_shards: 0, killed_before_upload: 0 }, panels: [], run: { id: 900, attempt: 1, url: 'https://example.invalid/runs/900' }, prefixes }; - return { repo, main0, prs, train, record }; + return { main0, prs, train, record }; } /** The coordinator's squash merge of PR i onto main, stamped at merge (VERSION, package.json, the release output). */ From 69c14b17d57d5eef537dacbd533ee88865a0a00a Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 01:57:10 -0800 Subject: [PATCH 09/11] v1.92.71.0 test(free): end four non-subject waits early and drop a duplicated test block (GSTA-151 D) (#3582) * test(free): end four non-subject waits early and drop a duplicated test block setup's _wait_with_deadline polls every 0.1 s on the SECONDS clock; pregate-supervise and gstack-detach wait on pids and go-files instead of fixed sleeps; qa-probe-gates loses its duplicated C2 block; context-identity runs one restore. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(setup): mark the sub-second wait bound as the contract (gstack-timing-ok) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): unreleased entry for the free-suite wait pass (GSTA-151 D) 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> --- CHANGELOG.md | 14 ++++++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- setup | 8 ++--- test/context-identity.test.ts | 5 +-- test/gstack-detach.test.ts | 14 +++++--- test/pregate-supervise.test.ts | 6 ++-- test/qa-probe-gates.test.ts | 39 ----------------------- test/setup-playwright-best-effort.test.ts | 12 +++++++ 10 files changed, 50 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09fff87eb..2946d21bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.92.71.0] - 2026-11-02 + +**Four free-suite waits now end when the thing they wait for happens: the six files involved spend 18–29 s less in summed per-file runtime, with every assertion intact.** `./setup` returns from a finished child up to ~0.9 s sooner too. + +### Changed +- **`setup` `_wait_with_deadline`**: polls every 0.1 s and tracks the deadline on bash's `SECONDS` clock instead of sleeping a whole second per poll. A child that exits at once no longer costs a second. The kill still lands no earlier than the deadline, the exit codes (the child's status, or 124 after the kill) are unchanged, and it runs under bash 3.2. + +### Tests +- `test/setup-playwright-best-effort.test.ts`: a child that exits with 3 after 50 ms returns 3 in under 500 ms (the old loop took 1,008 ms); the deadline and kill cases are unchanged. The file drops from ~20.8 s to ~5.9 s on a CI-mirror VM. +- `test/pregate-supervise.test.ts`: the leftover-child case waits for that child's pid to exit (bounded at 3 s) instead of a fixed 2.5 s sleep. +- `test/gstack-detach.test.ts`: the detach and lock bodies wait on files the test writes (bounded at ~10 s, also written in `finally`) instead of `sleep 2` and `sleep 3`. +- `test/qa-probe-gates.test.ts`: the duplicated "judge-named QA workflow gaps (C2)" block is gone (3 tests that repeated the first block). +- `test/context-identity.test.ts`: one `restore(mono)` call serves both assertions. + ## [1.92.70.0] - 2026-11-02 **Five free-test files stop rebuilding the same git repositories for every test: they spend 17–24 s less in summed per-file runtime, with every assertion intact.** Each test still gets its own private copy of the fixture. diff --git a/VERSION b/VERSION index 9dc23d34a..2d527513b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.70.0 +1.92.71.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 1fe114a87..181ad1acd 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.70.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.71.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/package.json b/package.json index 15a0b5f64..4f5009fa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.70", + "version": "1.92.71", "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/setup b/setup index 649c6fd5e..1eb823634 100755 --- a/setup +++ b/setup @@ -1539,15 +1539,15 @@ _kill_tree() { # poll the PID and SIGKILL the whole probe tree past the deadline. Returns # the probe's exit code, or 124 on timeout. _wait_with_deadline() { - local pid="$1" deadline_s="$2" waited=0 + local pid="$1" deadline_s="$2" end + end=$((SECONDS + deadline_s)) while kill -0 "$pid" 2>/dev/null; do - if [ "$waited" -ge "$deadline_s" ]; then + if [ "$SECONDS" -gt "$end" ]; then _kill_tree "$pid" wait "$pid" 2>/dev/null || true return 124 fi - sleep 1 - waited=$((waited + 1)) + sleep 0.1 done wait "$pid" } diff --git a/test/context-identity.test.ts b/test/context-identity.test.ts index 1ce221d29..a73f7b11a 100644 --- a/test/context-identity.test.ts +++ b/test/context-identity.test.ts @@ -353,8 +353,9 @@ describe('context-restore offers newer checkpoints it used to skip (#3065)', () const sibling = repo('elsewhere', 'git@github.com:acme/elsewhere.git'); const outside = save(sibling, 'sibling work', '20261002-100000'); const below = save(pkg, 'pkg feature', '20261001-100000'); - expect(restore(mono).out).not.toContain(outside); - expect(restore(mono).lines).toContain(`BELOW_CWD ${below}`); + const r = restore(mono); + expect(r.out).not.toContain(outside); + expect(r.lines).toContain(`BELOW_CWD ${below}`); // 200 newer checkpoints of another project push the below-cwd one out of the read window. const dir = path.dirname(outside); diff --git a/test/gstack-detach.test.ts b/test/gstack-detach.test.ts index 1b8e8295b..914c9a605 100644 --- a/test/gstack-detach.test.ts +++ b/test/gstack-detach.test.ts @@ -25,6 +25,7 @@ function waitFor(pred: () => boolean, ms: number): boolean { } return pred(); } +const GO_FILE_WAIT = 'i=0; while [ ! -e "$1" ] && [ "$i" -lt 100 ]; do sleep 0.1; i=$((i + 1)); done; [ -e "$1" ]'; function logHas(p: string, needle: string): boolean { try { return fs.readFileSync(p, 'utf-8').includes(needle); } catch { return false; } } @@ -33,19 +34,21 @@ describe('gstack-detach', () => { test('detaches (different pgid), returns immediately, completes, writes EXIT sentinel', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gd-')); const log = path.join(dir, 'run.log'); + const go = path.join(dir, 'go'); try { const t0 = Date.now(); - const r = spawnSync(DETACH, ['--log', log, '--', 'bash', '-c', 'sleep 2; echo body-ran'], { encoding: 'utf-8', timeout: 10000 }); + const r = spawnSync(DETACH, ['--log', log, '--', 'bash', '-c', GO_FILE_WAIT + ' && echo body-ran', 'body', go], { encoding: 'utf-8', timeout: 10000 }); const elapsed = Date.now() - t0; expect(r.status).toBe(0); expect(r.stdout).toContain(`gstack-detach LOG ${log}`); expect(elapsed).toBeLessThan(1500); // non-blocking + fs.writeFileSync(go, ''); expect(waitFor(() => logHas(log, '### gstack-detach EXIT=0 ###'), 8000)).toBe(true); expect(logHas(log, 'body-ran')).toBe(true); // ran to completion after launcher returned const m = fs.readFileSync(log, 'utf-8').match(/pgid=(\d+)/); expect(m).not.toBeNull(); expect(m![1]).not.toBe(ownPgid()); // detached into its own group - } finally { fs.rmSync(dir, { recursive: true, force: true }); } + } finally { fs.writeFileSync(go, ''); fs.rmSync(dir, { recursive: true, force: true }); } }, 15000); test('default log is run-scoped under ~/.gstack-dev/eval-runs (no shared /tmp)', () => { @@ -127,17 +130,20 @@ describe('gstack-detach', () => { const lock = `gstack-detach-test-${process.pid}`; const logA = path.join(dir, 'a.log'); const logB = path.join(dir, 'b.log'); + const release = path.join(dir, 'release'); try { - // First holds the lock for ~3s; second must wait then acquire. - spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'sleep', '3'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } }); + // First holds the lock until the test writes the release file; second must wait then acquire. + spawnSync(DETACH, ['--log', logA, '--lock', lock, '--', 'bash', '-c', GO_FILE_WAIT, 'holder', release], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } }); waitFor(() => logHas(logA, "ACQUIRED"), 4000); spawnSync(DETACH, ['--log', logB, '--lock', lock, '--', 'echo', 'second-ran'], { encoding: 'utf-8', timeout: 10000, env: { ...process.env, HOME: dir } }); // Second should report WAITING (first still holds it) then ACQUIRE after release. expect(waitFor(() => logHas(logB, 'WAITING for lock'), 4000)).toBe(true); + fs.writeFileSync(release, ''); expect(waitFor(() => logHas(logB, '### gstack-detach EXIT=0 ###'), 12000)).toBe(true); expect(logHas(logB, 'second-ran')).toBe(true); expect(fs.existsSync(path.join(dir, '.gstack', 'locks', `${lock}.lock`))).toBe(true); } finally { + fs.writeFileSync(release, ''); fs.rmSync(dir, { recursive: true, force: true }); } }, 20000); diff --git a/test/pregate-supervise.test.ts b/test/pregate-supervise.test.ts index 70921184a..900f4f890 100644 --- a/test/pregate-supervise.test.ts +++ b/test/pregate-supervise.test.ts @@ -226,9 +226,11 @@ describe('gstack-pregate: the test stage never hangs silently', () => { expect(pregate(during, []).status).toBe(EXIT.fail); expect(lanes(during).detail).toContain('working tree changed during the check'); const after = makeFixtureRepo(); repos.push(after); - commit(after, { 'test/leftover.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('leaves a child', () => { spawn('sh', ['-c', 'sleep 2; echo changed >> README.md'], { stdio: 'ignore' }).unref(); });\n" }); + commit(after, { 'test/leftover.test.js': "const test = require('node:test');\nconst { spawn } = require('node:child_process');\ntest('leaves a child', () => { const c = spawn('sh', ['-c', 'sleep 2; echo changed >> README.md'], { stdio: 'ignore' }); require('node:fs').writeFileSync(require('node:path').join(__dirname, '..', '..', 'leftover.pid'), String(c.pid)); c.unref(); });\n" }); expect(pregate(after, []).status).toBe(EXIT.ok); - await Bun.sleep(2500); + const until = Date.now() + 3000; + const leftover = await pidFrom(path.join(after.root, 'leftover.pid'), 3000).catch(() => 0); + if (leftover) await goneSoon(leftover, Math.max(0, until - Date.now())); expect(fs.readFileSync(path.join(after.work, 'README.md'), 'utf8')).toBe('# fixture\n'); }, 90_000); diff --git a/test/qa-probe-gates.test.ts b/test/qa-probe-gates.test.ts index 1478b8d84..2fbd20f75 100644 --- a/test/qa-probe-gates.test.ts +++ b/test/qa-probe-gates.test.ts @@ -344,42 +344,3 @@ describe('judge-named QA workflow gaps (C2)', () => { } }); }); - -// Lane S C2: gaps the qa-only/review/ship workflow judges named in their rationales. -describe('judge-named QA workflow gaps (C2)', () => { - const ctx = (skillName: string) => ({ host: 'claude' as const, skillName, tmplPath: '', paths: HOST_PATHS.claude }); - - test('Browser Quick leaves time for its six page probes, and the method states the same budget', () => { - for (const skillName of ['qa', 'qa-only']) { - const seconds = Number(/Browser Quick: SECONDS=(\d+)\./.exec(generateQAExploratory(ctx(skillName)))?.[1]); - // homepage + top 5 targets, each a checkpoint write plus one guarded command: 30 s left ~5 s per probe. - expect(seconds / 6).toBeGreaterThanOrEqual(20); - const patterns = fs.readFileSync(path.join(import.meta.dir, '..', 'qa', 'sections', 'qa-patterns.md'), 'utf8'); - expect(patterns).toContain(`${seconds / 60} minutes: homepage + top 5 navigation targets`); - } - }); - - test('PROBE_DIR names its source line, and stopping goes to the final report steps', () => { - for (const skillName of ['qa', 'qa-only']) { - const text = generateQAExploratory(ctx(skillName)); - const layout = text.indexOf('mixed standalone runs use REPORT_DIR/browser and REPORT_DIR/functional'); - const probeDir = text.indexOf("PROBE_DIR: this surface's owned probe directory per the line above."); - expect(layout).toBeGreaterThan(-1); - expect(probeDir).toBeGreaterThan(layout); - expect(text.slice(layout, probeDir).split('\n')).toHaveLength(3); - expect(text).toContain('write the report (§4), not a checkpoint.'); - const final = text.indexOf('## 4. Final report'); - expect(final).toBeGreaterThan(-1); - expect(text.indexOf('annotations.json', final)).toBeLessThan(text.indexOf('materialize PROBE_DIR', final)); - } - }); - - test('/review and /ship: smoke rechecks after smoke expiry are not-run, before step 4c revalidation', () => { - for (const skillName of ['review', 'ship']) { - const text = generateQAReview(ctx(skillName)); - const rule = text.indexOf('Post-expiry smoke rechecks are not-run.'); - expect(rule).toBeGreaterThan(-1); - expect(rule).toBeLessThan(text.indexOf('c. Re-review changed or uncertain coverage and repeat step 3 for affected checks.')); - } - }); -}); diff --git a/test/setup-playwright-best-effort.test.ts b/test/setup-playwright-best-effort.test.ts index 826bc6897..22bf15a19 100644 --- a/test/setup-playwright-best-effort.test.ts +++ b/test/setup-playwright-best-effort.test.ts @@ -271,6 +271,18 @@ describe('setup: Chromium bootstrap block executes best-effort', () => { expect(fs.readFileSync(path.join(r.tmp, 'mark'), 'utf-8')).toContain('killed'); }, 15_000); + test('_wait_with_deadline returns a quickly exiting child\'s status without waiting out a whole second', () => { + const t0 = Date.now(); + const r = runBashScript([ + extractFn('_kill_tree'), extractFn('_wait_with_deadline'), + '(sleep 0.05; exit 3) &', + '_wait_with_deadline $! 90 || echo "RC=$?"', + ].join('\n'), { timeout: 10_000 }); + expect(r.stdout).toBe('RC=3\n'); + // gstack-timing-ok: a sub-second return is the contract; the old 1 s poll took 1,008 ms, this path takes ~0.2 s + expect(Date.now() - t0).toBeLessThan(500); + }); + test('non-numeric timeout knob falls back to the default instead of erroring', () => { const r = runBlock({ probe: 'fail', bunx: 'exit 3', env: { GSTACK_PLAYWRIGHT_INSTALL_TIMEOUT: 'soon' } }); expect(r.status).toBe(0); From 0eb9c0d88b56f9862183d9f0e562b3ba009953a9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 03:36:03 -0800 Subject: [PATCH 10/11] v1.92.72.0 fix(sealed): refuse published records that put a label next to an identifier (GST-620) (#3747) * feat(sealed): refuse published records that put a label next to an identifier (GST-620) sealed.label_keys turns on sealed.label_join: a published JSON, JSONL, CSV, TSV or Markdown-table record that holds a label field and an identifier (an identifier_keys glob or a hex value) is refused when its fingerprint is new against every parent. Unreadable supported files are sealed.label_join_unchecked. Without label_keys the guard reads no blobs and its output matches main byte for byte (golden in test/fixtures/sealed-guard-off-golden.json). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes sealed.label_join: a hex object key is an identifier of the object it holds; label_keys and identifier_keys refuse dotted names; register the off golden as a free fixture. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: adversarial review fixes (GST-620) A field may count as both a label and an identifier (a pushed label_keys can no longer hide a join); TSV cells may be quoted; a file that never names a label key is skipped before parsing; blobs are parsed as they are read; label_join refusals no longer suggest allow_paths. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(sealed): strip quotes before the label-key prefilter so a quoted header fragment still joins Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(sealed): the label-key prefilter ignores double quotes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(sealed): hex lists count as identifiers; a \u escape always parses (Step 14.5 audit) 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> --- CHANGELOG.md | 16 ++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-pregate | 3 +- docs/pregate.md | 62 +++++- docs/troubleshooting.md | 11 +- lib/pregate/config.ts | 2 +- lib/sealed-guard.ts | 32 +-- lib/sealed-join.ts | 227 ++++++++++++++++++++ package.json | 2 +- test/fixtures/module-size-ratchet.json | 1 + test/fixtures/sealed-guard-off-golden.json | 65 ++++++ test/helpers/free-fixtures-data.ts | 1 + test/sealed-guard.test.ts | 61 ++++++ test/sealed-join.test.ts | 237 +++++++++++++++++++++ 15 files changed, 697 insertions(+), 27 deletions(-) create mode 100644 lib/sealed-join.ts create mode 100644 test/fixtures/sealed-guard-off-golden.json create mode 100644 test/sealed-join.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2946d21bc..237f5c19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.92.72.0] - 2026-11-02 + +**The sealed guard can now refuse a published record that puts a label next to an identifier.** gstack-evals hides which test units are decoys. Its /ship on garrytan/gstack-evals#99 published a panel where each unit's `stratum`, `tags` and `decoy_set` sat next to its `head`, `tree_sha256`, `plan_bytes` and `files`, values a model under test can compute from its own copy of the work. A model could look itself up and learn it was the decoy. No answer leaked, so `gstack-pregate sealed` said `clear` (GSTA-2, GST-620). The new rule is opt-in. A repo that sets `sealed.label_keys` gets it, and every other repo sees the same output as before, byte for byte. + +### Added +- **`sealed.label_keys` and `sealed.identifier_keys` in `.gstack/pregate.json`.** With `label_keys` set, `sealed.label_join` checks every .json, .jsonl, .ndjson, .csv, .tsv and Markdown pipe table a published commit adds, modifies, renames or copies. A record that holds a label field and an identifier (a field matching an `identifier_keys` glob, by default `head`, `*_sha256`, `*_bytes`, `files` and similar names, or any 7 to 64 character hex string, as a value, in a list, or as the key of an object) is a join. The guard fingerprints each join in memory and compares it with the parent's version of the file, so unchanged rows and reformatting stay clear while a replaced, added or widened row refuses even when the row count stays the same. Findings name the file, the commit and the field names (`keys=stratum,tags,head,plan_bytes`), never the values, on stderr, in `--json` and in the pre-push hook. +- **`sealed.label_join_unchecked`.** A file whose text never names a label key, ignoring double quotes, cannot hold a join and is skipped without parsing, so a commented `tsconfig.json` passes. A file with a `\u` escape is always parsed. A supported file over 4 MiB, one that names a label key and does not parse, one nested deeper than 64 levels, or a blob git cannot read refuses instead of passing unread. The `SEALED:` line counts these as `unchecked=`. Like every sealed finding, it can be waived through the base branch's `.gstack/sealed-allow.tsv`; `allow_paths` never exempt either rule. +- **The policy only tightens.** A field can count as both a label and an identifier, so adding identifier names to `label_keys` never hides a join. Both lists are the union of the base branch's and the pushed policy's, each resolved to its default first, so a pushed config can turn the rule on or widen it but cannot turn it off or narrow it. + +### Changed +- **docs/pregate.md#sealed** documents the rule, its normalization (`Tree SHA-256` matches as `tree_sha_256`) and its known limits: joins across files, joins by reference, columnar JSON, labels in file names and prose. Its version-skew note now names the new keys. A gstack older than this release refuses them as unknown, so raise `min_gstack_version` before adopting them. The unknown-key error now lists `label_keys` and `identifier_keys` among the valid keys. + +### Tests +- `test/sealed-guard.test.ts`: the GSTA-2 panel refuses through `gstack-pregate sealed`, `--json` and the pre-push hook, and warns on a private destination, with no value in any output. It failed on main. A golden captured from main 2d1b55d76 covers seven pushes (refused, clear, exempt, waived, warned, error, no policy) without `label_keys`; stdout, stderr, `--json`, the hook's stderr and every exit code match it byte for byte. +- `test/sealed-join.test.ts`: each format, human table headers, hex values under unlisted keys, maps keyed by sha, quoted TSV, a CSV header split by quotes (`"Strat"um`), a commented tsconfig.json that stays clear, dotted key names refused in the config, identifier names added to a pushed `label_keys`, decimal strings that stay clear, replacement at the same count, delete plus add, an added identifier field, labels then identifiers a commit later, renames, merges, five unchecked cases, waivers, `allow_paths`, pushed attempts to narrow or disable the rule, and `label_keys: []` matching an absent key. + ## [1.92.71.0] - 2026-11-02 **Four free-suite waits now end when the thing they wait for happens: the six files involved spend 18–29 s less in summed per-file runtime, with every assertion intact.** `./setup` returns from a finished child up to ~0.9 s sooner too. diff --git a/VERSION b/VERSION index 2d527513b..8d5fb4735 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.71.0 +1.92.72.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 181ad1acd..5c5850c4e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.71.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.72.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-pregate b/bin/gstack-pregate index 772bec1b1..cfee7e707 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -274,8 +274,9 @@ function cmdSealed(root: string, rest: string[], json: boolean): number { const blocks = report.status === 'refused' || report.status === 'error'; if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; } process.stderr.write(renderSealed(report, 'gstack-pregate sealed')); + const unchecked = report.findings.filter(f => f.rule === 'sealed.label_join_unchecked').length; const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`; - process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`); + process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length}${unchecked ? ` unchecked=${unchecked}` : ''} policy=${policy}\n`); return blocks ? EXIT.fail : EXIT.ok; } diff --git a/docs/pregate.md b/docs/pregate.md index c7238d475..e82fb15a0 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -1093,7 +1093,8 @@ with a timing flake: never raise the bound. "answer_markers": ["SEALED ANSWER:"], "seed_patterns": ["shuffle_seed=\\d+"], "local_paths": true, - "case_insensitive": true } } + "case_insensitive": true, + "label_keys": ["stratum", "decoy_set"] } } ``` A repo with a `sealed` key gets a configured local check against accidental @@ -1139,11 +1140,60 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js sealed data. Each exempt path is listed on every push (`exempt commit (sealed.allow_paths )`) and counted as `exempt=` on the `SEALED:` line. -- **Version skew.** A gstack older than the release whose CHANGELOG adds - `sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as - unknown keys, so every push and `gstack-pregate run` in the repository - fail on that machine. Raise `min_gstack_version` in - `.gstack/ship-policy.json` to that release before landing either key. +- **Label joins.** Off unless `label_keys` names at least one field. Then + `sealed.label_join` refuses a published record that puts a label field next + to an identifier, the way a panel row with `stratum` beside `head` and + `plan_bytes` tells a model under test which unit it is. A record is a JSON + object at any depth (its own fields plus one level of nested objects, named + like `meta.head`), a JSONL or NDJSON line, a CSV or TSV row, or a row of a + Markdown pipe table (CSV and TSV cells may be quoted). Field names and table headers are compared after + trimming, lowercasing and turning spaces and hyphens into underscores + (`Tree SHA-256` is `tree_sha_256`); this is the rule's own normalization, + separate from `case_insensitive`. An identifier is a field whose name + matches an `identifier_keys` glob (`*` is the only wildcard; the default + is `head`, `head_sha`, `sha`, `commit`, `tree`, `tree_sha*`, `*_sha`, + `*_sha256`, `sha256`, `digest`, `*_digest`, `hash`, `*_hash`, + `*_bytes`, `bytes`, `size`, `files`, `lines`), or any string value (or a + list holding one) of 7 to 64 hex characters with at least one digit and + one letter a to f. An + object key of that shape counts as an identifier of the object it holds + (shown as `(key)`), so a map keyed by sha joins too. Numbers count only + through the field name. A field can be both a label and an identifier, and + a record joins when it has a label field and a different identifier field, + so adding identifier names to `label_keys` never hides a join. Both lists take bare field names: a nested field + matches by its own name (`stratum`, never `meta.stratum`). For each supported file a commit adds, + modifies, renames or copies, the guard fingerprints every joined record + (sha256 over its label and identifier fields and their values, kept in + memory) and compares the set with the parent's version of the file (the + rename or copy source; every parent for a merge). A fingerprint no parent + has is a finding, so unchanged records and formatting-only edits stay clear, + and a replaced, added or widened record refuses even when the count stays + the same. A file whose text never names a label key, ignoring double + quotes (so `"Strat"um` still names `stratum`), cannot hold a join, so + it is skipped without parsing (a commented `tsconfig.json` passes). A file + with a `\u` escape, which could spell a label key, is always parsed, so a + commented one is `sealed.label_join_unchecked`. A + supported file over 4 MiB, one that names a label key and does not parse (one bad + JSONL line is enough), one nested deeper than 64 levels, or a blob git + cannot read is `sealed.label_join_unchecked`. It refuses too, and the + `SEALED:` line counts it as `unchecked=`. Findings print field names + (`keys=`, label fields first), never values; `--json` carries them as + `keys`. `allow_paths` never exempt either rule; a base-branch waiver does. + Both lists are the union of the base branch's and the pushed policy's, each + resolved to its default first, so a push can turn the rule on or widen it + but never turn it off or narrow it. Without `label_keys` the guard reads no + file contents and its output is unchanged. Known limits: joins across files, + joins by reference inside a file (a `decoy_set` list naming units described + elsewhere), a map from an identifier straight to a label value with no + label field name, columnar JSON (`columns` plus `rows`), labels encoded in + file names, and prose. +- **Version skew.** A gstack older than the release that adds a `sealed` key + refuses it as unknown, so every push and `gstack-pregate run` in the + repository fail on that machine. `allow_paths` and `case_insensitive` came + with the release whose CHANGELOG adds `sealed.allow_paths`; `label_keys` + and `identifier_keys` came with the release whose CHANGELOG adds + `sealed.label_join`. Raise `min_gstack_version` in + `.gstack/ship-policy.json` to that release before landing the key. - **Policy.** The union of the destination base branch's `sealed` and the pushed commit's: a push can tighten it, never relax it (`allow_paths` come from the base branch alone, and a pushed deny glob cannot override one). The diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d73967bb1..5cd386160 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3566,7 +3566,7 @@ base branch's copy or the user-owned file. For the user-owned file, edit it directly at the path the message names. A user file inside the repository, or reached through a symlink into it, is ignored rather than read (`--explain` names it). An unknown `sealed` key that a newer gstack defines (such as -`allow_paths` or `case_insensitive`) means this machine's gstack is older than +`allow_paths`, `case_insensitive`, `label_keys` or `identifier_keys`) means this machine's gstack is older than the policy: upgrade gstack here, and raise `min_gstack_version` in the repository's `.gstack/ship-policy.json` before landing such a key. @@ -4175,7 +4175,10 @@ migration is a code change, so the gate reruns. sealed path or transcript (binary, empty and later-deleted files included), or adds a line with a sealed-answer marker, a seed or a local home path, and the destination is public or its visibility is unknown (a new or unreadable -repository counts as public). An unreadable or malformed sealed policy refuses +repository counts as public). With `sealed.label_keys` set, a new record that +puts a label field next to an identifier (`sealed.label_join`), or a supported +file the guard could not read (`sealed.label_join_unchecked`), refuses the +same way. An unreadable or malformed sealed policy refuses too. The managed pre-push hook and `gstack-pregate sealed` print one line per finding: rule, `file[:line]`, commit. They never print the matched text. @@ -4184,7 +4187,9 @@ reaches the public repository (a later commit that deletes it still publishes it). A publishable file kept next to sealed data (a `MANIFEST.json` of hashes and counts) can be exempted from the path rules with a `sealed.allow_paths` glob in the base branch's `.gstack/pregate.json`; the text rules still read it -([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets +([docs/pregate.md#sealed](pregate.md#sealed)). For `sealed.label_join`, drop +the identifier fields (or the labels) from the published file, or keep the file +out of the public repository; `allow_paths` never exempt it. A reviewed false positive gets one line in the base branch's `.gstack/sealed-allow.tsv`: `rulepathshareason`, where `sha` is the full commit sha (a shortened one is ignored and named on stderr). A private diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index ac2a511d1..72fd16c11 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -348,7 +348,7 @@ export function explainConfig(): string { 'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote', 'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable', `serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`, - 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)', + 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, label_keys, identifier_keys, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs; label_keys turns on sealed.label_join, which refuses a published record that puts one of those fields next to an identifier (identifier_keys globs, or a hex value) (docs/pregate.md#sealed)', 'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)', 'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)', 'timeout_s: seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate\'s deadline) when --timeout is not passed; 1 to 3600, default 600', diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index 7ba8b261d..05875c249 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -11,6 +11,8 @@ * local_paths true: a local home path (/home//, /Users//) in added text * allow_paths globs exempt from the path rules (read from the base branch only; never folds case) * case_insensitive true: `paths` and `transcripts` match regardless of case + * label_keys field names whose records may not also carry an identifier (lib/sealed-join.ts; off when empty) + * identifier_keys field-name globs that count as identifiers (default DEFAULT_IDENTIFIER_KEYS) * visibility "public" | "private": the destination override (reviewed on the base branch) * * The publication boundary is every commit the push publishes: the local sha @@ -32,19 +34,20 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { containedRelative, globToRegExp } from './ship-policy'; +import { DEFAULT_IDENTIFIER_KEYS, identifierKeyError, joinFindings, labelKeyError } from './sealed-join'; import { resolveStateRoot } from './state-root'; export const SEALED_CONFIG_PATH = '.gstack/pregate.json'; export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv'; export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility'; -export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path'; -export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' } -export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string } +export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path' | 'sealed.label_join' | 'sealed.label_join_unchecked'; +export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; label_keys: string[]; identifier_keys: string[]; visibility?: 'public' | 'private' } +export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string; keys?: string[] } export type ExemptFinding = SealedFinding & { glob: string }; export interface AllowEntry { rule: string; path: string; sha: string; reason: string } export type Visibility = 'public' | 'private' | 'unknown'; -const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility']; +const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'label_keys', 'identifier_keys', 'visibility']; const MAX_ENTRIES = 64; const MAX_REGEX = 256; const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-]|\$\{!?(?:[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\s]{0,64}\])?|[0-9]+|[@*#?$!-]):?-)(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; @@ -68,6 +71,7 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } }; const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); }; list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception); + list('label_keys', labelKeyError); list('identifier_keys', identifierKeyError); for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' }); if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' }); return errors; @@ -93,15 +97,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string) const errors = sealedErrors(raw.sealed); if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label }; const s = raw.sealed; - return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label }; + return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, label_keys: s.label_keys ?? [], identifier_keys: s.identifier_keys ?? DEFAULT_IDENTIFIER_KEYS, visibility: s.visibility }, label }; } -/** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */ +/** Base ∪ pushed: deny lists, `label_keys` and `identifier_keys` (each resolved to its default per policy first) concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */ export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null { if (!base && !pushed) return null; - const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])]; + const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns' | 'label_keys' | 'identifier_keys') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])]; const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined; - return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility }; + return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), label_keys: u('label_keys'), identifier_keys: u('identifier_keys'), visibility }; } /** Reviewed waivers: `rulepathshareason` lines in the base branch's allow file; a waiver counts only when its sha equals the finding's full commit sha. */ @@ -190,7 +194,7 @@ function textFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy): SealedFinding[] { if (!commits.length) return []; const seen = new Set(); - return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy)].filter(f => { + return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy), ...joinFindings(repoRoot, commits, policy)].filter(f => { const key = `${f.rule}\0${f.file}\0${f.line ?? ''}\0${f.commit}`; if (seen.has(key)) return false; seen.add(key); @@ -299,15 +303,17 @@ export function renderSealed(r: SealedReport, context: string): string { lines.push(`${context}: ignored waiver ${i.rule} ${i.path} sha ${i.sha} (${why}; this finding is in commit ${i.commit}, review it before waiving)`); } const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`; - const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`); + const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}${f.keys ? ` keys=${f.keys.join(',')}` : ''}`); + const join = r.findings.some(f => f.rule.startsWith('sealed.label_join')) ? [`sealed.label_join: a record puts a sealed.label_keys field next to an identifier (keys= lists the label fields, then the identifier fields); drop one side from the published file or keep the file out of the public repository. sealed.label_join_unchecked: a supported file the guard could not read (over 4 MiB, unparseable, or nested deeper than 64); allow_paths never exempt either rule`] : []; if (r.status === 'error') { lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} — ${r.error}.`); lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`); } else if (r.status === 'refused') { - lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body); - lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rulepathfull-commit-shareason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`); + lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body, ...join); + const exemptable = r.findings.some(f => !f.rule.startsWith('sealed.label_join')) ? ` exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH},` : ''; + lines.push(`Remove it from every published commit before this branch reaches a public repository,${exemptable} or waive one reviewed finding with a \`rulepathfull-commit-shareason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`); } else if (r.status === 'warned') { - lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body); + lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body, ...join); } return lines.length ? lines.join('\n') + '\n' : ''; } diff --git a/lib/sealed-join.ts b/lib/sealed-join.ts new file mode 100644 index 000000000..3c048ddf4 --- /dev/null +++ b/lib/sealed-join.ts @@ -0,0 +1,227 @@ +/** + * sealed-join — the `sealed.label_join` rule of the sealed-output guard + * (lib/sealed-guard.ts), on only when the policy names `sealed.label_keys`. + * + * A record that puts a label field (a decoy stratum, a flaw tag) next to an + * identifier the model under test can compute from its own copy of the work + * (a head sha, a tree hash, a byte count) tells that model which label it + * carries. For every .json, .jsonl, .ndjson, .csv, .tsv or .md (pipe table) + * file a published commit adds, modifies, renames or copies, each joined + * record is fingerprinted (sha256 over its label and identifier key/value + * pairs, in memory only) at the commit and at every parent's version (the + * rename or copy source); a fingerprint no parent has is a finding. A file + * over 4 MiB, one that does not parse, nests deeper than 64 or cannot be read + * is `sealed.label_join_unchecked`. Findings carry field names, never values. + */ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import type { SealedFinding } from './sealed-guard'; + +export const DEFAULT_IDENTIFIER_KEYS = ['head', 'head_sha', 'sha', 'commit', 'tree', 'tree_sha*', '*_sha', '*_sha256', 'sha256', 'digest', '*_digest', 'hash', '*_hash', '*_bytes', 'bytes', 'size', 'files', 'lines']; +export const JOIN_MAX_BYTES = 4 * 1024 * 1024; +const MAX_DEPTH = 64; +const MAX_KEY = 128; +const SUPPORTED = /\.(json|jsonl|ndjson|csv|tsv|md)$/i; +const HEX_ID = /^(?=[0-9a-f]*[0-9])(?=[0-9a-f]*[a-f])[0-9a-f]{7,64}$/i; +const ZERO = /^0+$/; +const TABLE_DELIMITER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/; + +export interface JoinPolicy { label_keys: string[]; identifier_keys: string[] } +type Field = { name: string; key: string; value: unknown }; +type Joins = Map; + +/** The rule's own key normalization: trimmed, lowercased, spaces and hyphens as underscores. */ +export const normalizeKey = (k: string) => k.trim().toLowerCase().replace(/[ -]/g, '_'); + +export function labelKeyError(s: string): string | null { + return s.trim() && s.length <= MAX_KEY && !/[*?[\]{}.]/.test(s) ? null : `must be a bare field name of 1-${MAX_KEY} characters with no wildcard or dot (meta.stratum matches as stratum)`; +} +export function identifierKeyError(s: string): string | null { + return s.trim() && s.length <= MAX_KEY && !/[?[\]{}.]/.test(s) ? null : `must be a bare field name of 1-${MAX_KEY} characters with no dot; * is the only wildcard`; +} + +const present = (v: unknown) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '') || (Array.isArray(v) && v.length > 0); +const hexValue = (v: unknown): boolean => typeof v === 'string' ? HEX_ID.test(v.trim()) : Array.isArray(v) && v.some(e => typeof e === 'string' && HEX_ID.test(e.trim())); +const shown = (name: string) => name.split('.').map(s => HEX_ID.test(s) ? '(hex)' : s).join('.').slice(0, 64); + +function classifier(p: JoinPolicy): (fields: Field[]) => { fp: string; labels: string[]; ids: string[] } | null { + const labels = new Set(p.label_keys.map(normalizeKey)); + const ids = p.identifier_keys.map(g => new RegExp(`^${normalizeKey(g).replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '.*')}$`)); + return fields => { + const l = fields.filter(f => labels.has(f.key)); + const i = fields.filter(f => (present(f.value) && ids.some(re => re.test(f.key))) || hexValue(f.value)); + if (!l.some(a => i.some(b => b !== a))) return null; + const fp = createHash('sha256').update([...new Set([...l, ...i])].map(f => `${f.name}=${JSON.stringify(f.value)}`).sort().join('\0')).digest('hex'); + return { fp, labels: l.map(f => shown(f.name)), ids: i.map(f => shown(f.name)) }; + }; +} + +/** Every JSON object at any depth as one record: its own fields plus one level of nested object fields (`meta.head`), and the hex key that holds it as `(key)`; false past the depth cap. */ +function jsonRecords(root: unknown, out: Field[][]): boolean { + const field = (name: string, value: unknown, key: string): Field => ({ name, key: normalizeKey(key), value }); + const stack: Array<[unknown, number, string?]> = [[root, 0]]; + while (stack.length) { + const [v, depth, heldBy] = stack.pop()!; + if (!v || typeof v !== 'object') continue; + if (depth > MAX_DEPTH) return false; + if (Array.isArray(v)) { for (const e of v) stack.push([e, depth + 1]); continue; } + const fields: Field[] = heldBy && HEX_ID.test(heldBy) ? [field('(key)', heldBy, '(key)')] : []; + for (const [k, val] of Object.entries(v)) { + const name = normalizeKey(k); + fields.push(field(name, val, k)); + if (val && typeof val === 'object' && !Array.isArray(val)) for (const [k2, v2] of Object.entries(val)) fields.push(field(`${name}.${normalizeKey(k2)}`, v2, k2)); + stack.push([val, depth + 1, k]); + } + out.push(fields); + } + return true; +} + +function delimitedRows(text: string, sep: string): string[][] | null { + const rows: string[][] = []; let row: string[] = []; let cell = ''; let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (quoted) { + if (c !== '"') cell += c; + else if (text[i + 1] === '"') { cell += '"'; i++; } + else quoted = false; + } else if (c === '"' && cell.trim() === '') { quoted = true; cell = ''; } + else if (c === sep) { row.push(cell); cell = ''; } + else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(cell); rows.push(row); row = []; cell = ''; } + else cell += c; + } + if (quoted) return null; + if (cell !== '' || row.length) { row.push(cell); rows.push(row); } + return rows; +} + +function markdownTables(text: string): string[][][] { + const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/(? c.trim().replace(/^[`*]+|[`*]+$/g, '')); + const lines = text.split(/\r?\n/); + const tables: string[][][] = []; + for (let i = 0; i + 1 < lines.length; i++) { + if (!lines[i]!.includes('|') || !lines[i + 1]!.includes('|') || !TABLE_DELIMITER.test(lines[i + 1]!)) continue; + const table = [cells(lines[i]!)]; + let j = i + 2; + for (; j < lines.length && lines[j]!.includes('|'); j++) table.push(cells(lines[j]!)); + tables.push(table); + i = j - 1; + } + return tables; +} + +function tableRecords(rows: string[][], out: Field[][]): void { + const header = (rows[0] ?? []).map(normalizeKey); + for (const row of rows.slice(1)) if (row.some(c => c.trim())) out.push(header.map((h, i) => ({ name: h, key: h, value: (row[i] ?? '').trim() }))); +} + +/** The records of one supported file's text, or null when it cannot be checked (parse failure or depth cap). */ +export function fileRecords(file: string, text: string): Field[][] | null { + const body = text.replace(/^\uFEFF/, ''); + const ext = (/\.([a-z]+)$/i.exec(file)?.[1] ?? '').toLowerCase(); + const out: Field[][] = []; + try { + if (ext === 'json') return !body.trim() || jsonRecords(JSON.parse(body), out) ? out : null; + if (ext === 'jsonl' || ext === 'ndjson') { + for (const line of body.split(/\r?\n/)) if (line.trim() && !jsonRecords(JSON.parse(line), out)) return null; + return out; + } + } catch { return null; } + if (ext === 'md') { for (const t of markdownTables(body)) tableRecords(t, out); return out; } + const rows = delimitedRows(body, ext === 'tsv' ? '\t' : ','); + if (!rows) return null; + tableRecords(rows.filter(r => r.some(c => c.trim())), out); + return out; +} + +/** Joined-record fingerprints of one file's text; null when it cannot be checked. */ +export function fileJoins(file: string, text: string, p: JoinPolicy): Joins | null { + const folded = normalizeKey(text.replace(/"/g, '')); + if (!text.includes('\\u') && !p.label_keys.some(k => folded.includes(normalizeKey(k)))) return new Map(); + const records = fileRecords(file, text); + if (!records) return null; + const classify = classifier(p); + const joins: Joins = new Map(); + for (const r of records) { const j = classify(r); if (j) joins.set(j.fp, { labels: j.labels, ids: j.ids }); } + return joins; +} + +function gitBuffer(repoRoot: string, args: string[], input: string, label: string): Buffer { + const r = spawnSync('git', args, { cwd: repoRoot, input, timeout: 120_000, maxBuffer: 512 * 1024 * 1024 }); + if (r.status !== 0) throw new Error(`git ${label} failed: ${(r.stderr ?? '').toString().trim().slice(0, 200)}`); + return r.stdout; +} + +interface Change { commit: string; file: string; blob: string; parents: string[] } + +/** Supported regular files a commit adds, modifies, renames or copies against every parent (`-m`: a merge's file must differ from each parent). */ +function changedFiles(repoRoot: string, commits: string[]): Change[] { + const parentCount = new Map(gitBuffer(repoRoot, ['rev-list', '--no-walk', '--parents', '--stdin'], commits.join('\n') + '\n', 'rev-list --parents').toString('utf8').split('\n').filter(Boolean).map(l => { const s = l.split(' '); return [s[0]!, Math.max(1, s.length - 1)] as const; })); + const tokens = gitBuffer(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-m', '-M', '-C', '--raw', '-z', '--no-abbrev'], commits.join('\n') + '\n', 'diff-tree -m').toString('utf8').split('\0'); + const sections = new Map>>(); + let current: Map | null = null; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!; + if (!t) continue; + if (!t.startsWith(':')) { current = new Map(); sections.set(t, [...(sections.get(t) ?? []), current]); continue; } + const [, dstMode, src, dst, status] = t.slice(1).split(' ') as [string, string, string, string, string]; + const file = /^[RC]/.test(status) ? tokens[i += 2]! : tokens[++i]!; + if (status.startsWith('D') || !/^100(644|755)$/.test(dstMode) || !SUPPORTED.test(file)) continue; + current?.set(file, { src, dst }); + } + const out: Change[] = []; + for (const [commit, parts] of sections) { + if (parts.length < (parentCount.get(commit) ?? 1)) continue; + for (const [file, { dst }] of parts[0]!) { + if (parts.every(p => p.has(file))) out.push({ commit, file, blob: dst, parents: parts.map(p => p.get(file)!.src).filter(s => !ZERO.test(s)) }); + } + } + return out; +} + +/** Each blob's text by sha, handed to `use` as it is read (one `cat-file --batch-check`, then chunked `--batch` reads); null over the cap or when unreadable. */ +function readBlobs(repoRoot: string, shas: string[], use: (sha: string, text: string | null) => void): void { + if (!shas.length) return; + const small: Array<[string, number]> = []; + for (const line of gitBuffer(repoRoot, ['cat-file', '--batch-check'], shas.join('\n') + '\n', 'cat-file --batch-check').toString('utf8').split('\n').filter(Boolean)) { + const [sha, type, size] = line.split(' '); + if (type !== 'blob' || Number(size) > JOIN_MAX_BYTES) use(sha!, null); + else small.push([sha!, Number(size)]); + } + const decoder = new TextDecoder(); + for (let start = 0; start < small.length;) { + let end = start; let bytes = 0; + while (end < small.length && (end === start || bytes + small[end]![1] <= 64 * 1024 * 1024)) bytes += small[end++]![1]; + const buf = gitBuffer(repoRoot, ['cat-file', '--batch'], small.slice(start, end).map(s => s[0]).join('\n') + '\n', 'cat-file --batch'); + let pos = 0; + for (let k = start; k < end; k++) { + const nl = buf.indexOf(0x0a, pos); + const size = Number(buf.subarray(pos, nl).toString('utf8').split(' ')[2]); + use(small[k]![0], decoder.decode(buf.subarray(nl + 1, nl + 1 + size))); + pos = nl + 1 + size + 1; + } + start = end; + } +} + +/** `sealed.label_join` and `sealed.label_join_unchecked` findings for the published commits; [] without label_keys (no git reads). */ +export function joinFindings(repoRoot: string, commits: string[], p: JoinPolicy): SealedFinding[] { + if (!p.label_keys.length || !commits.length) return []; + const changes = changedFiles(repoRoot, commits); + const ext = (file: string) => (/\.[a-z]+$/i.exec(file)?.[0] ?? '').toLowerCase(); + const wanted = new Map>(); + for (const c of changes) for (const sha of [c.blob, ...c.parents]) wanted.set(sha, (wanted.get(sha) ?? new Set()).add(ext(c.file))); + const cache = new Map(); + readBlobs(repoRoot, [...wanted.keys()], (sha, text) => { for (const e of wanted.get(sha)!) cache.set(`${sha}\0${e}`, text === null ? null : fileJoins(`f${e}`, text, p)); }); + const joinsOf = (sha: string, file: string) => cache.get(`${sha}\0${ext(file)}`) ?? null; + const out: SealedFinding[] = []; + for (const c of changes) { + const now = joinsOf(c.blob, c.file); + if (!now) { out.push({ rule: 'sealed.label_join_unchecked', file: c.file, commit: c.commit }); continue; } + const before = new Set(c.parents.flatMap(s => [...(joinsOf(s, c.file)?.keys() ?? [])])); + const labels = new Set(); const ids = new Set(); + for (const [fp, j] of now) if (!before.has(fp)) { j.labels.forEach(k => labels.add(k)); j.ids.forEach(k => ids.add(k)); } + if (labels.size) out.push({ rule: 'sealed.label_join', file: c.file, commit: c.commit, keys: [...[...labels].sort(), ...[...ids].sort()] }); + } + return out; +} diff --git a/package.json b/package.json index 4f5009fa6..2a2d10ae8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.71", + "version": "1.92.72", "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/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 1bbc21be7..da9804538 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -161,6 +161,7 @@ { "file": "lib/pregate/checks/timing.ts", "movedFrom": "(new: pre-gate timing-assertion check, field list 2 W4)" }, { "file": "lib/pregate/runtime.ts", "movedFrom": "(new: the verified launch Bun for repo commands, field wave CD1)" }, { "file": "lib/sealed-guard.ts", "movedFrom": "(new: the sealed-output guard shared by the pre-push hook and gstack-pregate sealed, field wave CD4)" }, + { "file": "lib/sealed-join.ts", "movedFrom": "(new: the sealed.label_join rule, label fields published beside identifiers, GST-620)" }, { "file": "lib/pregate/regate.ts", "movedFrom": "(new: the regate verdict from a fresh tree receipt plus migration collisions, field wave CD3)" }, { "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" }, { "file": "lib/source-root.ts", "movedFrom": "(new: the source checkout behind an install root, from its recorded .source-path, field wave B2e)" }, diff --git a/test/fixtures/sealed-guard-off-golden.json b/test/fixtures/sealed-guard-off-golden.json new file mode 100644 index 000000000..ebe386370 --- /dev/null +++ b/test/fixtures/sealed-guard-off-golden.json @@ -0,0 +1,65 @@ +{ + "refused": { + "code": 1, + "stdout": "SEALED: refused findings=2 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of /origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit \n sealed.answer runs/c1/summary.md:1 commit \nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rulepathfull-commit-shareason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n", + "jsonCode": 1, + "json": "{\"status\":\"refused\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\"},{\"rule\":\"sealed.answer\",\"file\":\"runs/c1/summary.md\",\"line\":1,\"commit\":\"\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":false}\n", + "hookCode": 1, + "hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of /origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit \n sealed.answer runs/c1/summary.md:1 commit \nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rulepathfull-commit-shareason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n" + }, + "clear": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "" + }, + "exempt": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=1 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: exempt sealed.path sealed/s1/MANIFEST.json commit (sealed.allow_paths **/sealed/*/MANIFEST.json)\n", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[{\"rule\":\"sealed.path\",\"file\":\"sealed/s1/MANIFEST.json\",\"commit\":\"\",\"glob\":\"**/sealed/*/MANIFEST.json\"}],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: exempt sealed.path sealed/s1/MANIFEST.json commit (sealed.allow_paths **/sealed/*/MANIFEST.json)\n" + }, + "waived": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=1 exempt=0 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: waived sealed.transcript runs/c1/transcript.jsonl commit (.gstack/sealed-allow.tsv: synthetic fixture)\n", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\",\"reason\":\"synthetic fixture\"}],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: waived sealed.transcript runs/c1/transcript.jsonl commit (.gstack/sealed-allow.tsv: synthetic fixture)\n" + }, + "warned": { + "code": 0, + "stdout": "SEALED: warned findings=1 commits=1 visibility=private waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit \n", + "jsonCode": 0, + "json": "{\"status\":\"warned\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"private\",\"visibilitySource\":\"sealed.visibility override\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit \n" + }, + "error": { + "code": 1, + "stdout": "SEALED: error findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n", + "stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of /origin.git — the sealed policy in commit is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n", + "jsonCode": 1, + "json": "{\"status\":\"error\",\"error\":\"the sealed policy in commit is unreadable: sealed.answer_markers[0]: \\\"(\\\" is not a valid regex (Invalid regular expression: missing ))\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"/origin.git\",\"ok\":false}\n", + "hookCode": 1, + "hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of /origin.git — the sealed policy in commit is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n" + }, + "no-policy": { + "code": 0, + "stdout": "SEALED: no-policy findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n", + "stderr": "", + "jsonCode": 0, + "json": "{\"status\":\"no-policy\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "" + } +} diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 9d9131d47..fe6bb9e66 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -210,6 +210,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/review-design-lite-reports-ci-36633323521.json': ['test/review-finalization-budget.test.ts'], 'test/fixtures/review-gate-integrity-opus-reports.json': ['test/gate-integrity-templates.test.ts'], 'test/fixtures/review-wrong-tree/**': ['test/review-dispatch.test.ts'], + 'test/fixtures/sealed-guard-off-golden.json': ['test/sealed-guard.test.ts'], 'test/fixtures/session-update-heartbeat.sh': ['test/session-update-heartbeat.test.ts'], 'test/fixtures/shard-cli-parity/**': ['test/shard-cli-parity.test.ts'], 'test/fixtures/shard-equivalence/**': ['test/shard-engine-equivalence.test.ts'], diff --git a/test/sealed-guard.test.ts b/test/sealed-guard.test.ts index 878f36dc0..f93e1eaac 100644 --- a/test/sealed-guard.test.ts +++ b/test/sealed-guard.test.ts @@ -506,3 +506,64 @@ describe('waivers name the full commit sha (GST-268)', () => { expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).ignoredWaivers).toEqual([]); }); }); + +const PANEL = JSON.stringify({ units: [ + { stratum: 'decoy', tags: ['W:T1'], plan_bytes: 3760, files: 5, head: '5b7db3aa' + '1'.repeat(32), tree_sha256: '15762a' + '0'.repeat(56) + 'aa' }, + { stratum: 'seeded', tags: ['B:T2'], plan_bytes: 4112, files: 7, head: '9c1e0bbb' + '2'.repeat(32), tree_sha256: '88aa11' + '0'.repeat(56) + 'bb' }, +] }, null, 2) + '\n'; +const PANEL_VALUES = [/5b7db3aa/, /15762a0/, /9c1e0bbb/, /88aa110/, /\b3760\b/, /\b4112\b/, /W:T1/, /B:T2/, /\bdecoy\b/, /\bseeded\b/]; + +describe('sealed.label_join: labels published beside self-computable identifiers (GST-620)', () => { + test('the GSTA-2 panel (stratum and tags beside head, tree_sha256, plan_bytes, files) refuses on a public destination and warns on a private one, naming fields, never values', () => { + const labeled = { paths: ['**/heldout*/**'], label_keys: ['stratum', 'tags', 'decoy_set'] }; + const s = scratch({ '.gstack/pregate.json': policy(labeled) }); + const sha = commit(s, { 'wiki/board/panel/panel.json': PANEL }); + const keys = ['stratum', 'tags', 'files', 'head', 'plan_bytes', 'tree_sha256']; + const text = preflight(s); + expect(text.code).toBe(1); + expect(text.err).toContain(`sealed.label_join wiki/board/panel/panel.json commit ${sha.slice(0, 12)} keys=${keys.join(',')}`); + expect(text.out).toMatch(/^SEALED: refused findings=1 commits=1 /m); + const json = preflight(s, ['--json']); + expect(JSON.parse(json.out).findings).toEqual([{ rule: 'sealed.label_join', file: 'wiki/board/panel/panel.json', commit: sha, keys }]); + const h = hook(s); + expect(h.code).toBe(1); + expect(h.err).toContain(`sealed.label_join wiki/board/panel/panel.json commit ${sha.slice(0, 12)} keys=${keys.join(',')}`); + for (const out of [text.out, text.err, json.out, h.err]) for (const value of PANEL_VALUES) expect(out).not.toMatch(value); + const priv = scratch({ '.gstack/pregate.json': policy({ ...labeled, visibility: 'private' }) }); + commit(priv, { 'wiki/board/panel/panel.json': PANEL }); + const p = preflight(priv); + expect(p.code).toBe(0); + expect(p.err).toMatch(/warning.*sealed\.label_join\s+wiki\/board\/panel\/panel\.json/s); + }); +}); + +const OFF_GOLDEN = path.join(ROOT, 'test', 'fixtures', 'sealed-guard-off-golden.json'); +/** Representative pushes under policies without label_keys, each read three ways: the preflight's text and --json, and the pre-push hook. */ +function offRuns(): Record { + const table = '| unit | stratum | head |\n|---|---|---|\n| u1 | decoy | 5b7db3a |\n'; + const fixtures: Record Scratch> = { + refused: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); commit(s, { 'wiki/board/panel/panel.json': PANEL, 'runs/c1/transcript.jsonl': '{}\n', 'runs/c1/summary.md': 'SEALED ANSWER: x\n' + table }); return s; }, + clear: () => { const s = scratch({ '.gstack/pregate.json': policy({ paths: ['heldout/**'] }) }); commit(s, { 'wiki/board/panel/panel.json': PANEL, 'wiki/board.md': table, 'wiki/units.csv': 'stratum,head\ndecoy,5b7db3a\n' }); return s; }, + exempt: () => { const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); commit(s, { 'sealed/s1/MANIFEST.json': PANEL, 'wiki/board/panel/panel.json': PANEL }); return s; }, + waived: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); const sha = commit(s, { 'runs/c1/transcript.jsonl': '{}\n', 'wiki/panel.json': PANEL }); landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\truns/c1/transcript.jsonl\t${sha}\tsynthetic fixture\n` }); return s; }, + warned: () => { const s = scratch({ '.gstack/pregate.json': policy({ ...SEALED, visibility: 'private' }) }); commit(s, { 'runs/c1/transcript.jsonl': '{}\n', 'wiki/panel.json': PANEL }); return s; }, + error: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); commit(s, { '.gstack/pregate.json': policy({ ...SEALED, answer_markers: ['('] }), 'wiki/panel.json': PANEL }); return s; }, + 'no-policy': () => { const s = scratch({}); commit(s, { 'wiki/panel.json': PANEL }); return s; }, + }; + const runs: Record = {}; + for (const [name, make] of Object.entries(fixtures)) { + const s = make(); + const norm = (t: string) => t.split(s.root).join('').replace(/[0-9a-f]{12,64}/g, ''); + const text = preflight(s); const json = preflight(s, ['--json']); const h = hook(s); + runs[name] = { code: text.code, stdout: norm(text.out), stderr: norm(text.err), jsonCode: json.code, json: norm(json.out), hookCode: h.code, hookStderr: norm(h.err) }; + } + return runs; +} + +describe('off unless configured (GST-620 item 13)', () => { + test('without sealed.label_keys, stdout, stderr, --json, the hook and every exit code match the capture from main 2d1b55d76 byte for byte', () => { + const runs = offRuns(); + if (process.env.SEALED_GUARD_GOLDEN_CAPTURE) { fs.writeFileSync(process.env.SEALED_GUARD_GOLDEN_CAPTURE, JSON.stringify(runs, null, 2) + '\n'); return; } + expect(runs).toEqual(JSON.parse(fs.readFileSync(OFF_GOLDEN, 'utf8'))); + }, 120_000); +}); diff --git a/test/sealed-join.test.ts b/test/sealed-join.test.ts new file mode 100644 index 000000000..3b92ce1a8 --- /dev/null +++ b/test/sealed-join.test.ts @@ -0,0 +1,237 @@ +/** + * GST-620: `sealed.label_join`, the sealed guard's rule for a published + * record that puts a label field (`sealed.label_keys`) next to an identifier + * the model under test can compute from its own copy of the work. The repro + * and the byte-for-byte "off" golden live in test/sealed-guard.test.ts; this + * file covers formats, the per-record content diff against every parent, + * fail-closed unchecked files, waivers, allow_paths and the monotonic policy. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { sealedErrors } from '../lib/sealed-guard'; +import { DEFAULT_IDENTIFIER_KEYS, joinFindings } from '../lib/sealed-join'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const PREGATE = path.join(ROOT, 'bin', 'gstack-pregate'); +const HEAD_A = '5b7db3aa' + '1'.repeat(32); +const HEAD_B = '9c1e0bbb' + '2'.repeat(32); +const LABELED = { label_keys: ['stratum', 'tags', 'decoy_set'] }; +const P = { label_keys: ['stratum', 'tags'], identifier_keys: DEFAULT_IDENTIFIER_KEYS }; + +interface Scratch { root: string; work: string; env: Record } +const scratches: Scratch[] = []; +afterEach(() => { while (scratches.length) fs.rmSync(scratches.pop()!.root, { recursive: true, force: true }); }); + +function git(s: Scratch, args: string[], ok = true): string { + const r = spawnSync('git', args, { cwd: s.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...s.env } }); + if (ok && r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return (r.stdout ?? '').trim(); +} +function write(s: Scratch, files: Record): void { + for (const [rel, body] of Object.entries(files)) { + const abs = path.join(s.work, rel); + if (body === null) { fs.rmSync(abs, { force: true }); continue; } + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + } +} +function commit(s: Scratch, files: Record, msg = 'results'): string { + write(s, files); + git(s, ['add', '-A']); git(s, ['commit', '-q', '-m', msg]); + return git(s, ['rev-parse', 'HEAD']); +} +/** A bare origin whose main carries the base files, and a clone on branch `results`. */ +function scratch(baseFiles: Record): Scratch { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-join-'))); + const work = path.join(root, 'work'); const origin = path.join(root, 'origin.git'); const state = path.join(root, 'state'); + fs.mkdirSync(work); fs.mkdirSync(state); + const env = { HOME: root, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; + fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); + const s = { root, work, env }; + scratches.push(s); + spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env }, timeout: 30_000 }); + git(s, ['init', '-q', '-b', 'main']); + commit(s, { 'README.md': '# evals\n', ...baseFiles }, 'base'); + git(s, ['remote', 'add', 'origin', origin]); git(s, ['push', '-q', 'origin', 'main']); + git(s, ['checkout', '-q', '-b', 'results']); + return s; +} +function landOnMain(s: Scratch, files: Record): void { + git(s, ['checkout', '-q', 'main']); commit(s, files, 'base change'); git(s, ['push', '-q', 'origin', 'main']); git(s, ['checkout', '-q', 'results']); +} +const policy = (sealed: unknown) => JSON.stringify({ sealed }, null, 2) + '\n'; +const panel = (units: object[]) => JSON.stringify({ units }, null, 2) + '\n'; +const unit = (name: string, head: string, extra: object = {}) => ({ name, stratum: name === 'u1' ? 'decoy' : 'seeded', tags: ['W:T1'], head, plan_bytes: 3760, ...extra }); +const found = (s: Scratch) => joinFindings(s.work, git(s, ['rev-list', 'origin/main..HEAD']).split('\n').filter(Boolean), P); +function preflight(s: Scratch, args: string[] = []) { + const r = spawnSync(PREGATE, ['sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } }); + return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' }; +} + +describe('formats and matching', () => { + test('a JSONL line, a nested meta.head, CSV, TSV, a markdown table with human headers and a hex value under an unlisted key each join; decimal-only strings, labels alone and identifiers alone do not', () => { + const s = scratch({}); + const sha = commit(s, { + 'a.jsonl': '{"x":1}\n{"stratum":"decoy","meta":{"head":"5b7db3a"}}\n', + 'b.csv': 'unit,Stratum,Plan Bytes\nu1,decoy,"3,760"\n', + 'c.tsv': 'stratum\ttree_sha256\ndecoy\tabc\n', + 'd.md': '# board\n\n| Unit | Stratum | `head` | Tree SHA-256 |\n|---|:---:|---|---|\n| u1 | decoy | x | y |\n', + 'e.json': '[{"tags":["B:T2"],"id":"5b7db3a"}]\n', + 'f.json': '[{"stratum":"decoy","ts":"202610311200","name":"unit-1","count":12}]\n', + 'g.json': '[{"stratum":"decoy"},{"head":"5b7db3aa"}]\n', + 'h.md': '| head | size |\n|---|---|\n| 5b7db3a | 3 |\n', + 'i.txt': '{"stratum":"decoy","head":"5b7db3a"}\n', + 'j.json': `{"units":{"${HEAD_A}":{"stratum":"decoy","tags":["t"]}}}\n`, + 'k.jsonl': `{"${HEAD_B}":{"stratum":"seeded"}}\n`, + 'l.json': '{"units":{"unit-one":{"stratum":"decoy"}}}\n', + 'm.tsv': '"Stratum"\t"head"\n"decoy"\t"5b7db3a"\n', + 'n.csv': '"Strat"um,head\ndecoy,5b7db3a\n', + 'tsconfig.json': '{\n // a comment, so not strict JSON\n "compilerOptions": { "strict": true }\n}\n', + }); + expect(found(s).sort((a, b) => a.file.localeCompare(b.file))).toEqual([ + { rule: 'sealed.label_join', file: 'a.jsonl', commit: sha, keys: ['stratum', 'meta.head'] }, + { rule: 'sealed.label_join', file: 'b.csv', commit: sha, keys: ['stratum', 'plan_bytes'] }, + { rule: 'sealed.label_join', file: 'c.tsv', commit: sha, keys: ['stratum', 'tree_sha256'] }, + { rule: 'sealed.label_join', file: 'd.md', commit: sha, keys: ['stratum', 'head', 'tree_sha_256'] }, + { rule: 'sealed.label_join', file: 'e.json', commit: sha, keys: ['tags', 'id'] }, + { rule: 'sealed.label_join', file: 'j.json', commit: sha, keys: ['stratum', 'tags', '(key)'] }, + { rule: 'sealed.label_join', file: 'k.jsonl', commit: sha, keys: ['stratum', '(key)'] }, + { rule: 'sealed.label_join', file: 'm.tsv', commit: sha, keys: ['stratum', 'head'] }, + { rule: 'sealed.label_join', file: 'n.csv', commit: sha, keys: ['stratum', 'head'] }, + ]); + }); + + test('without label_keys the rule reads nothing: no git call, even outside a repository', () => { + expect(joinFindings(os.tmpdir(), ['0'.repeat(40)], { label_keys: [], identifier_keys: DEFAULT_IDENTIFIER_KEYS })).toEqual([]); + }); + + test('label_keys must be bare field names and identifier_keys allow only *; neither takes a dotted path; both must be string arrays', () => { + expect(sealedErrors({ label_keys: ['stratum'], identifier_keys: ['*_sha', 'head'] })).toEqual([]); + expect(sealedErrors({ label_keys: ['strat*'] }).map(e => e.path)).toEqual(['sealed.label_keys[0]']); + expect(sealedErrors({ identifier_keys: ['he?d', ''] }).map(e => e.path)).toEqual(['sealed.identifier_keys[0]', 'sealed.identifier_keys[1]']); + expect(sealedErrors({ label_keys: 'stratum' })).toEqual([{ path: 'sealed.label_keys', message: 'must be an array of strings' }]); + expect(sealedErrors({ label_keys: ['meta.stratum'], identifier_keys: ['meta.head'] }).map(e => e.path)).toEqual(['sealed.label_keys[0]', 'sealed.identifier_keys[0]']); + }); +}); + +describe('content diff against every parent', () => { + test('an unchanged record and a formatting-only edit stay clear; a replacement at the same count, a delete plus add and an added identifier field refuse', () => { + const s = scratch({ 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', HEAD_B)]) }); + const reformat = commit(s, { 'wiki/panel.json': JSON.stringify({ units: [unit('u1', HEAD_A), unit('u2', HEAD_B), { note: 'no labels here' }] }) + '\n' }); + const replaced = commit(s, { 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', 'c'.repeat(39) + '3')]) }); + const swapped = commit(s, { 'wiki/panel.json': panel([unit('u2', 'c'.repeat(39) + '3'), unit('u3', 'd'.repeat(39) + '4')]) }); + const widened = commit(s, { 'wiki/panel.json': panel([unit('u2', 'c'.repeat(39) + '3'), unit('u3', 'd'.repeat(39) + '4', { tree_sha256: 'e'.repeat(63) + '5' })]) }); + const commits = found(s).map(f => f.commit); + expect(commits).not.toContain(reformat); + expect(commits.sort()).toEqual([replaced, swapped, widened].sort()); + }); + + test('labels in one commit and identifiers added to the same records in the next: the second commit refuses', () => { + const s = scratch({}); + const labels = commit(s, { 'units.jsonl': '{"name":"u1","stratum":"decoy"}\n' }); + const ids = commit(s, { 'units.jsonl': '{"name":"u1","stratum":"decoy","head":"5b7db3a"}\n' }); + expect(found(s).map(f => f.commit)).toEqual([ids]); + expect(found(s).map(f => f.commit)).not.toContain(labels); + }); + + test('a pure rename is clear and a rename that adds a unit refuses; a clean merge of two published sides is clear and a resolution that adds a joined row refuses', () => { + const units = ['u1', 'u2', 'u3', 'u4', 'u5', 'u6'].map((n, i) => unit(n, String(i + 1).repeat(4) + 'ab' + 'f'.repeat(34))); + const s = scratch({ 'wiki/panel.json': panel(units) }); + git(s, ['mv', 'wiki/panel.json', 'wiki/board.json']); git(s, ['commit', '-q', '-m', 'rename']); + write(s, { 'wiki/board.json': panel([...units, unit('u7', HEAD_A)]) }); + git(s, ['mv', 'wiki/board.json', 'wiki/board2.json']); git(s, ['add', '-A']); git(s, ['commit', '-q', '-m', 'rename and add']); + const renamedAndAdded = git(s, ['rev-parse', 'HEAD']); + expect(found(s).map(f => f.commit)).toEqual([renamedAndAdded]); + + const m = scratch({ 'wiki/panel.json': panel([unit('u1', HEAD_A)]) }); + git(m, ['checkout', '-q', '-b', 'side']); + const side = commit(m, { 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', HEAD_B)]) }); + git(m, ['checkout', '-q', 'results']); + const ours = commit(m, { 'wiki/panel.json': panel([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A)]) }); + const merge = (resolution: object[]) => { + git(m, ['merge', '-q', '--no-ff', '--no-commit', 'side'], false); + write(m, { 'wiki/panel.json': panel(resolution) }); + git(m, ['add', '-A']); git(m, ['commit', '-q', '--no-edit', '-m', 'merge']); + return git(m, ['rev-parse', 'HEAD']); + }; + const clean = merge([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A), unit('u2', HEAD_B)]); + expect(found(m).map(f => f.commit).sort()).toEqual([side, ours].sort()); + expect(found(m).map(f => f.commit)).not.toContain(clean); + git(m, ['reset', '-q', '--hard', ours]); + const added = merge([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A), unit('u2', HEAD_B), unit('u4', 'd'.repeat(39) + '4')]); + const atMerge = found(m).filter(f => f.commit === added); + expect(atMerge).toEqual([{ rule: 'sealed.label_join', file: 'wiki/panel.json', commit: added, keys: ['stratum', 'tags', 'head', 'plan_bytes'] }]); + }); +}); + +describe('fail closed, waivers, allow_paths and the policy union (gstack-pregate sealed)', () => { + test('an oversized valid JSON, malformed JSON, a malformed JSONL line, nesting deeper than 64 and an unreadable blob are each sealed.label_join_unchecked; an empty file is clear', () => { + const s = scratch({ '.gstack/pregate.json': policy(LABELED) }); + let deep: unknown = { stratum: 'decoy' }; + for (let i = 0; i < 70; i++) deep = { next: deep }; + const sha = commit(s, { + 'big.json': JSON.stringify({ pad: 'x'.repeat(4 * 1024 * 1024 + 10), units: [] }), + 'bad.json': '{"stratum": "decoy",', + 'bad.jsonl': '{"stratum":"decoy"}\nnot json\n', + 'deep.json': JSON.stringify(deep), + 'gone.json': '{"note":"this blob is removed after the commit"}\n', + 'empty.json': '', + }); + const blob = git(s, ['rev-parse', 'HEAD:gone.json']); + fs.rmSync(path.join(s.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2))); + const r = preflight(s); + expect(r.code).toBe(1); + for (const f of ['big.json', 'bad.json', 'bad.jsonl', 'deep.json', 'gone.json']) expect(r.err).toContain(`sealed.label_join_unchecked ${f} commit ${sha.slice(0, 12)}`); + expect(r.err).not.toContain('empty.json'); + expect(r.out).toMatch(/^SEALED: refused findings=5 commits=1 .* exempt=0 unchecked=5 policy=/m); + const json = JSON.parse(preflight(s, ['--json']).out); + expect(json.findings.map((f: { rule: string; file: string }) => `${f.rule} ${f.file}`).sort()).toEqual(['bad.json', 'bad.jsonl', 'big.json', 'deep.json', 'gone.json'].map(f => `sealed.label_join_unchecked ${f}`)); + }); + + test('allow_paths never exempt the rule; a short-sha waiver is ignored and the full sha waives', () => { + const s = scratch({ '.gstack/pregate.json': policy({ ...LABELED, paths: ['heldout/**'], allow_paths: ['wiki/board/panel.json'] }) }); + const sha = commit(s, { 'wiki/board/panel.json': panel([unit('u1', HEAD_A)]) }); + const r = preflight(s); + expect(r.code).toBe(1); + expect(r.out).toMatch(/findings=1 .* exempt=0 policy=/); + expect(r.err).not.toContain('sealed.allow_paths glob'); + landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.label_join\twiki/board/panel.json\t${sha.slice(0, 12)}\tsynthetic fixture\n` }); + const short = preflight(s); + expect(short.code).toBe(1); + expect(short.err).toContain('ignored waiver sealed.label_join wiki/board/panel.json'); + landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.label_join\twiki/board/panel.json\t${sha}\tsynthetic fixture\n` }); + const full = preflight(s); + expect(full.code).toBe(0); + expect(full.err).toContain('waived sealed.label_join wiki/board/panel.json'); + }); + + test('a push can enable or widen the rule but never disable or narrow it', () => { + const leak = { 'wiki/panel.json': panel([unit('u1', HEAD_A)]) }; + for (const pushed of [{ paths: ['heldout/**'] }, { ...LABELED, identifier_keys: ['zzz'] }, { label_keys: [] as string[], identifier_keys: [] as string[] }, { label_keys: [...LABELED.label_keys, 'head', 'plan_bytes'] }]) { + const s = scratch({ '.gstack/pregate.json': policy(LABELED) }); + commit(s, { '.gstack/pregate.json': policy(pushed), ...leak }); + const r = preflight(s); + expect(r.code).toBe(1); + expect(r.err).toContain('sealed.label_join wiki/panel.json'); + expect(r.err).toContain('head,plan_bytes'); + } + const enable = scratch({ '.gstack/pregate.json': policy({ paths: ['heldout/**'] }) }); + commit(enable, { '.gstack/pregate.json': policy(LABELED), ...leak }); + expect(preflight(enable).err).toContain('sealed.label_join wiki/panel.json'); + }); + + test('label_keys: [] reports exactly what an absent label_keys reports', () => { + const run = (sealed: object) => { + const s = scratch({ '.gstack/pregate.json': policy(sealed) }); + commit(s, { 'wiki/panel.json': panel([unit('u1', HEAD_A)]), 'runs/c1/transcript.jsonl': '{}\n' }); + const norm = (t: string) => t.split(s.root).join('').replace(/[0-9a-f]{12,64}/g, ''); + const text = preflight(s); const json = preflight(s, ['--json']); + return [text.code, norm(text.out), norm(text.err), json.code, norm(json.out)]; + }; + const base = { transcripts: ['runs/**/transcript*.jsonl'] }; + expect(run({ ...base, label_keys: [] })).toEqual(run(base)); + }); +}); From a00149e86eddf0f5f5387185c9214ef285a468b9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 2 Nov 2026 03:45:58 -0800 Subject: [PATCH 11/11] v1.92.73.0 fix(review): read printed: fingerprint lines, stop on a foreign start checkout, give Step 11 its start token (GST-776) (#3798) * fix(review): readers start their reply with the bare REVIEWED_TREE line; a printed: label is read (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the Capy pass program refuses a foreign start checkout and takes Step 11's adversarial-review start (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): a start directory outside any checkout still dispatches; the Capy skill's Step 11 note fits its context budget (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the start probe stops on an unreadable start directory, runs once per stage, and logStart is validated; brief wording stays main's (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): GST-776 review dispatch fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): a wholly bold printed: REVIEWED_TREE line is read (GST-776 fix-check) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): the GBRA-218 one-line shape (echoed command, then printed: REVIEWED_TREE) is read; a wrong tree is still refused (GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship): the logStart STOP text no longer names gstack-review-log --start ahead of the pass call (reader-drift order test, GST-776) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(budget): capy/skill/gstack ceiling 5316 after the main merge Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Revert "test(budget): capy/skill/gstack ceiling 5316 after the main merge" This reverts commit 61a9051b4ac1165d36e1a0aa2266ecc10674725a. * docs(capy): trim the /ship paragraph to stay under the 5315 context ceiling after the main merge 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> --- CHANGELOG.md | 8 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- capy/skill/gstack/SKILL.md | 2 +- docs/review-policy.md | 9 +++- lib/capy-review-pass.js | 14 +++++- lib/review-brief.ts | 7 ++- package.json | 2 +- test/fixtures/context-budget.json | 2 +- test/review-dispatch.test.ts | 39 ++++++++++++++++ test/review-pass.test.ts | 76 ++++++++++++++++++++++++++----- 11 files changed, 144 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237f5c19e..0d90faced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.92.73.0] - 2026-11-02 + +### Fixed + +- **A reviewer that wrote `printed: REVIEWED_TREE: …` is no longer failed.** Two readers in GBRA-218 echoed the command and then wrote the fingerprint after a `printed:` label, so /ship read no line, failed them and restarted the pass twice. The label is now read, plain, bold or inside the line's backticks, and the tree and checkout it names are still compared exactly. A wrong tree or checkout is still refused. +- **The Capy pass program stops before it sends a reader into the wrong checkout.** Capy starts every shared reader in the thread's working directory and cannot start one anywhere else. In GBRA-218 an outside voice ignored its `cd` and reviewed that checkout, so Step 11 lost its outside coverage. Now, before a stage takes a start token or sends its first reader, the program compares that directory with the checkout /ship ships. It stops when they differ or the directory can't be read. **Run /ship in the thread's own checkout**, not in a worktree beside it. A start directory outside any checkout, such as a multi-repo workspace root, still dispatches. +- **Step 11 takes the right start token.** Reusing the pass input's `logStart: ["review"]` for `stage: "voices"` used to run the voices with no `adversarial-review` start, which left Step 11.5 nothing to bind. Step 11 now takes `adversarial-review` by default and stops before anything runs when given any other list. The gstack Capy skill says Step 11 swaps `logStart` for `stage: "voices"`. + ## [1.92.72.0] - 2026-11-02 **The sealed guard can now refuse a published record that puts a label next to an identifier.** gstack-evals hides which test units are decoys. Its /ship on garrytan/gstack-evals#99 published a panel where each unit's `stratum`, `tags` and `decoy_set` sat next to its `head`, `tree_sha256`, `plan_bytes` and `files`, values a model under test can compute from its own copy of the work. A model could look itself up and learn it was the decoy. No answer leaked, so `gstack-pregate sealed` said `clear` (GSTA-2, GST-620). The new rule is opt-in. A repo that sets `sealed.label_keys` gets it, and every other repo sees the same output as before, byte for byte. diff --git a/VERSION b/VERSION index 8d5fb4735..4633cc70e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.92.72.0 +1.92.73.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 5c5850c4e..a86825dff 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.92.72.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.92.73.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/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index bb617b0c0..1e3263109 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -46,7 +46,7 @@ Capy checklist for `/autoplan`: drive `gstack-autoplan`, never a hand adaptation Token caps on /ship: pass a brief's cap as `--token-cap `. /ship reads `cost.tokens` and `cost.credits` from `tools.thread.get({ thread_id: "" })` (its own jam; a subagent reading root `"this"` starts `--subagent --reading-from this --meter-scope tree`) inside the same execute program as the `gstack-ship-budget` command that takes them as `--reading --credits `, never alone. `ship/DRIVER.md` starts `--mode observe`: each program ends with `note`, and only a note past the cap stops (`docs/ship-budget.md`). -/ship on Capy is about a dozen calls, each an execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family, pinned in `voice-models.json`); Step 11 adds `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your transcript since /ship began. A small waiting child saves nothing. +/ship on Capy is about a dozen calls, each an execute program (`ship/DRIVER.md`). Each review pass and Step 11 is one call to the checked-in pass program (fill only `input`): `const t0 = await now(); const p = (await tools.capy.bash({ command: ". '' && gstack-review-dispatch program", description: "Pass" })).output.split("\n"); return p[0] + "\n" + (await new (Object.getPrototypeOf(async () => {}).constructor)("tools", "sleep", "now", "input", JSON.parse(p[1]))(tools, sleep, now, { startedAt: t0, launch: "", cwd: "", base: "", out: "/p", pass: "-p", laneModel: "", planFlags: [], logStart: ["review"], jam: "", record: "", step: "9.2" }));` It plans, logs `logStart` (`reservation` mode from `RESERVATION_ID:`), sends each brief on `machine: "shared"`, then polls: one `tools.subagent.list` per ~20 s tick, returning 200 s after the program's first line, where a reader is settled only at status `done` or `failed` or with a `summary` (a just-created `idle` reader has not run); it starts readers under `rejected` with `tools.subagent.start` as slots free and stops on one stuck 20 minutes. It ends on `NEXT:` (`poll`: rerun) or `STOP:`. `models` maps roles to models (`outside-voice`: another family, pinned in `voice-models.json`); Step 11 swaps `logStart` for `stage: "voices"`; a full repeat pass, `diffBase: ""`. Steps 7, 8 and 14.5 (`step: "14.5"`) use `extras`, JSON `{name, prompt_file, first, model}` (prompts verbatim from their sections, Step 7 `first`); they cannot block, so `model: { model_id: "anthropic/claude-sonnet-5-5", reasoning_mode: "low" }` (14.5 once). Waits: never let 5 minutes pass between calls. While readers run, never end the turn on `wait`; rerun the loader. Else re-issue `wait` with `duration_seconds: 200` and operation ids. At Step 22 report the `CALLS:` line of `gstack-ship-budget calls --events ` on your transcript since /ship began. A small waiting child saves nothing. ## Plan, approve, ship diff --git a/docs/review-policy.md b/docs/review-policy.md index 2cf6a453b..c8238d7d6 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -593,7 +593,14 @@ ones, and the arm lost blockers. `bin/gstack-review-dispatch` closes both ends: `COLLECT_WRONG_TREE`, a later commit in the same checkout `COLLECT_TREE_MOVED`; nothing is merged, the reader becomes `failed`, and `verify` reports `NOT_COLLECTED`. An outside voice's tree failure is recorded `unavailable` only when its reply holds no finding. The line catches a reader that - worked elsewhere by accident; it is not proof of reading, since diff text can predict it. + worked elsewhere by accident; it is not proof of reading, since diff text can predict it. A `printed:` label + before the line (plain, bold or inside its backticks) is still read, and its tree and toplevel are still compared + exactly (GST-776). Capy cannot start a subagent in a chosen directory, so before a stage takes a start token or sends + its first reader, the Capy pass program compares the checkout with the one shared readers start in (the thread's + working directory) and stops when that is another checkout or cannot be read: run /ship in the thread's own checkout. + A start directory outside any checkout, such as a multi-repo workspace root, holds no tree to review by mistake and + dispatches. Step 11's voices take the `adversarial-review` start + token by default, and a `logStart` list without it stops before anything runs. ## Where this is enforced diff --git a/lib/capy-review-pass.js b/lib/capy-review-pass.js index 9511695e9..886976f34 100644 --- a/lib/capy-review-pass.js +++ b/lib/capy-review-pass.js @@ -30,6 +30,9 @@ const finish = (tail) => { return all.slice(0, -1).join('\n').slice(0, Math.max(0, RETURN_BYTES - last.length)) + last; }; const stop = (why) => finish(['STOP: ' + why]); +const logStart = input.stage === 'voices' ? (input.logStart ?? ['adversarial-review']) : (input.logStart || []); +if (!Array.isArray(logStart) || !logStart.every((s) => typeof s === 'string' && s.trim())) return stop('input.logStart must be a list of review-log start skills (strings), got ' + JSON.stringify(input.logStart) + '; nothing ran'); +if (input.stage === 'voices' && !(logStart.length && logStart.every((s) => s.split(' ')[0] === 'adversarial-review'))) return stop('Step 11 (stage "voices") takes the adversarial-review start token, which Step 11.5 binds, so its logStart is ["adversarial-review"] (with any --reservation in input.reservation) or omitted, never ' + JSON.stringify(input.logStart) + '; nothing ran'); const runDir = input.out.replace(/\/+$/, '').replace(/\/[^/]+$/, ''); const nextPhase = input.stage === 'voices' ? 5 : input.step === '14.5' ? 7 : 4; const withParts = async (tail) => { @@ -133,8 +136,17 @@ try { if (voices.laneModel) laneFamily = family(voices.laneModel); } if (input.stage === 'voices' && !m.readers.some((r) => r.stage === 'voices')) return finish(['VOICES: none (the pass plan lists no Step 11 voice)', 'NEXT: judge']); + if (input.cwd && !m.readers.some((r) => active.includes(r.stage) && !['planned', 'rendered'].includes(r.state))) { + const top = 'if d=$(git rev-parse --show-toplevel 2>&1); then cd "$d" && pwd -P; else case "$d" in *"not a git repository"*) echo NOT_A_CHECKOUT ;; *) echo "$d"; exit 1 ;; esac; fi'; + const ship = await sh(top); + const start = await tools.capy.bash({ command: top, description: 'Ship review pass ' + input.pass + ' reader start checkout', timeout: 300 }); + const want = ship.out.trim(), got = (start.output || '').trim(); + if (ship.code !== 0 || want === 'NOT_A_CHECKOUT') return stop('cannot read the checkout input.cwd names (' + input.cwd + '): ' + want.slice(-300)); + if (start.exit_code !== 0) return stop('cannot read the directory readers on the shared machine start in: ' + got.slice(-300) + '; no start token was taken and no reader was dispatched'); + if (got !== 'NOT_A_CHECKOUT' && got !== want) return stop('readers on the shared machine start in ' + got + ', not in ' + want + ', the checkout this pass ships, and Capy cannot start a subagent in another directory; no start token was taken and no reader was dispatched. Run /ship in ' + got + " (the thread's working directory) on this branch, with a fresh --out"); + } const seen = {}; - for (const s of input.logStart || []) { + for (const s of logStart) { const nth = (seen[s] = (seen[s] || 0) + 1); const key = (input.stage === 'voices' ? 'voices:' : 'pass:') + s + (nth > 1 ? '#' + nth : ''); if ((m.log_starts || {})[key]) continue; diff --git a/lib/review-brief.ts b/lib/review-brief.ts index b233aa40b..30a9565f3 100644 --- a/lib/review-brief.ts +++ b/lib/review-brief.ts @@ -172,12 +172,17 @@ export function checkoutRoot(): string { } /** - * One REVIEWED_TREE line as " ", or null. A list marker or heading mark may lead it, and a whole-line `**…**` or + * One REVIEWED_TREE line as " ", or null. A list marker, heading mark or `printed:` label (GST-776, also after the echoed `cd … && echo …` command on the same line) may lead it, and a whole-line `**…**` or * backtick wrapper, a bold label and backticks around the sha or the whole path are removed; the path's own characters are kept. */ function parseTreeLine(line: string): string | null { + const label = /^(?:\*\*[Pp]rinted:\*\*|[Pp]rinted:)\s+/; let t = line.trim().replace(/^(?:[-*>]\s+|\d+[.)]\s+|#+\s+)/, ''); + const echoed = /\s(?:\*\*[Pp]rinted:\*\*|[Pp]rinted:)\s+(?=\S)/.exec(t); + if (echoed && t.startsWith('cd ')) t = t.slice(echoed.index + 1); + t = t.replace(label, ''); for (const w of ['**', '`']) if (t.length > 2 * w.length && t.startsWith(w) && t.endsWith(w)) t = t.slice(w.length, -w.length).trim(); + t = t.replace(label, ''); const m = /^(?:\*\*REVIEWED_TREE:\*\*|REVIEWED_TREE:)\s+`?([0-9a-f]{40,64})`?\s+(.+)$/.exec(t); if (!m) return null; const p = m[2]!.length > 2 && m[2]!.startsWith('`') && m[2]!.endsWith('`') ? m[2]!.slice(1, -1) : m[2]!; diff --git a/package.json b/package.json index 2a2d10ae8..ad4e3a294 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.92.72", + "version": "1.92.73", "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/fixtures/context-budget.json b/test/fixtures/context-budget.json index 5f0d85790..619885589 100644 --- a/test/fixtures/context-budget.json +++ b/test/fixtures/context-budget.json @@ -9,7 +9,7 @@ "browser-skills/hackernews-frontpage": 371, "canary": 7738, "captain": 6551, - "capy/skill/gstack": 5312, + "capy/skill/gstack": 5315, "careful": 1239, "codex": 9890, "context-restore": 6287, diff --git a/test/review-dispatch.test.ts b/test/review-dispatch.test.ts index f6874e886..d0c01e345 100644 --- a/test/review-dispatch.test.ts +++ b/test/review-dispatch.test.ts @@ -794,3 +794,42 @@ describe('reading the REVIEWED_TREE line (GST-482 review fixes)', () => { expect(r.stderr).toContain('BRIEF_RENDER_FAILED: cannot read HEAD^{tree} and the toplevel of'); }); }); + +describe('the REVIEWED_TREE line as GBRA-218 readers wrote it (GST-776)', () => { + const top = fs.realpathSync(spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim()); + const tree = spawnSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: ROOT, encoding: 'utf8', timeout: 30_000 }).stdout.trim(); + const command = `cd '${top}' && echo "REVIEWED_TREE: $(git rev-parse 'HEAD^{tree}') $(git rev-parse --show-toplevel)"`; + // Value: protects=a reader that reviewed the right checkout is not failed for how it wrote its fingerprint; fails_when=a reply that echoes the command and then writes `printed: REVIEWED_TREE: …` (two Sonnet 5.5 low readers) is failed as reported="none", or a `printed:` label lets a wrong tree, toplevel or other label through; + // why_new=GBRA-218 pass restarts on gstack 1.92.25.0; seam=lib/review-brief.ts checkReviewedTree via gstack-review-dispatch collect; no_claim=the brief's wording, which stays main's until a no-loss replay + test('the echoed command then a printed: line is read; the same shape with a wrong tree or checkout is still refused', () => { + const reply = (fingerprint: string) => `${command}\nprinted: REVIEWED_TREE: ${fingerprint}\nNO FINDINGS\n`; + const oneLine = (fingerprint: string) => `${command} printed: REVIEWED_TREE: ${fingerprint}\n\nNO FINDINGS\n`; + expect(checkReviewedTree(oneLine(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); + expect(checkReviewedTree(oneLine(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true }); + expect(checkReviewedTree(oneLine(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false }); + expect(checkReviewedTree(`${command} Note: REVIEWED_TREE: ${tree} ${top}`, `${tree} ${top}`).reported).toBe('none'); + expect(checkReviewedTree(reply(`${tree} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: true }); + expect(checkReviewedTree(reply(`${'b'.repeat(40)} ${top}`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: true, reported: `${'b'.repeat(40)} ${top}` }); + expect(checkReviewedTree(reply(`${tree} /elsewhere`), `${tree} ${top}`)).toMatchObject({ ok: false, moved: false, reported: `${tree} /elsewhere` }); + expect(checkReviewedTree(`Note: REVIEWED_TREE: ${tree} ${top}\nNO FINDINGS`, `${tree} ${top}`).reported).toBe('none'); + for (const line of ['`printed: REVIEWED_TREE: ' + tree + ' ' + top + '`', `**Printed:** REVIEWED_TREE: ${tree} ${top}`, `**printed: REVIEWED_TREE: ${tree} ${top}**`]) { + expect(checkReviewedTree(line, `${tree} ${top}`).ok, line).toBe(true); + expect(checkReviewedTree(line.replace(tree, 'c'.repeat(40)), `${tree} ${top}`).ok, line).toBe(false); + } + + const out = tmp(); + const ledger = path.join(out, 'review-ledger.jsonl'); + const merged = path.join(out, 'merged.jsonl'); + const file = field(run(['render', '--role', 'specialist', '--name', 'testing', '--out', out, '--diff-base', SHA]).stdout, 'file'); + run(['sent', '--ledger', ledger, '--role', 'specialist', '--name', 'testing', '--sent', file]); + const collectReply = (text: string) => { + const f = path.join(out, 'reply.out'); + fs.writeFileSync(f, text); + return spawnSync(BIN, ['collect', '--ledger', ledger, '--role', 'specialist', '--name', 'testing', '--output', f, '--merged', merged], { cwd: ROOT, encoding: 'utf8', timeout: 60_000 }); + }; + const wrong = collectReply(reply(`${'b'.repeat(40)} ${top}`)); + expect([wrong.status, wrong.stdout.startsWith('COLLECT_TREE_MOVED: reader=specialist/testing')]).toEqual([1, true]); + const right = collectReply(reply(`${tree} ${top}`)); + expect(right.stdout).toStartWith('COLLECT: reader=specialist/testing findings=0 '); + }); +}); diff --git a/test/review-pass.test.ts b/test/review-pass.test.ts index dc367ddc0..10ffa4f8d 100644 --- a/test/review-pass.test.ts +++ b/test/review-pass.test.ts @@ -332,6 +332,27 @@ describe('Capy pass program (fake host, real bins)', () => { expect(h2.starts).toEqual([[1]]); }, 240_000); + // Value: protects=Step 11.5 always has an adversarial-review start token to bind; fails_when=Step 11 reuses the pass input's logStart: ["review"] and runs the voices with no adversarial-review start, or a missing token is found only after the voices ran; + // why_new=GBRA-218 reran a whole pass after the loader example's logStart was reused for stage voices; seam=lib/capy-review-pass.js log starts; no_claim=gstack-review-read's binding itself + test('Step 11 takes the adversarial-review start token by default and refuses a list without it before anything runs', async () => { + const { dir, env } = repo(); + const out = path.join(dir, '.state/run/p1'); + const h = host(dir, env); + const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' } }; + const base = { base: 'main', out, pass: 'x12p1', planFlags: ['--depth-floor', 'full'], logStart: ['review'] }; + expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); + const created = h.creates.length; + const reused = await runPass(h, { ...base, stage: 'voices', models }); + expect(reused).toStartWith('STOP: Step 11 (stage "voices") takes the adversarial-review start token'); + expect(h.creates.length).toBe(created); + const voices = await runPass(h, { ...base, logStart: undefined, stage: 'voices', models }); + expect(voices).toMatch(/REVIEW_LOG_START: adversarial-review token=\S+/); + expect(voices.indexOf('REVIEW_LOG_START: adversarial-review')).toBeLessThan(voices.indexOf('DISPATCHED:')); + expect(voices).toContain('NEXT: judge the voices'); + const m = JSON.parse(fs.readFileSync(path.join(out, 'pass-manifest.json'), 'utf8')); + expect(Object.keys(m.log_starts ?? {}).sort()).toEqual(['pass:review', 'voices:adversarial-review']); + }, 240_000); + test('Step 11 voices run from the same manifest; the outside voice needs a model of another family', async () => { const { dir, env } = repo(); const out = path.join(dir, '.state/run/p1'); @@ -630,11 +651,10 @@ describe('Capy pass program (fake host, real bins)', () => { // why_new=replay C2-34 (GSTA-193) on PR 2; seam=none; no_claim=the host's status timing beyond one late summary test('extras as JSON, the checkout as cwd, and a late summary all run through without a STOP', async () => { const { dir, env } = repo(); - const elsewhere = tmp('rp-cwd-'); const out = path.join(dir, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'plan.prompt.md'), 'Plan completion brief'); - const h = host(elsewhere, env, { lateSummary: true }); + const h = host(dir, env, { lateSummary: true }); const res = await runPass(h, { base: 'main', out, pass: 'x13p1', cwd: dir, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'plan', prompt_file: path.join(out, 'plan.prompt.md'), model: { model_id: 'anthropic/claude-haiku-5-5', reasoning_mode: 'low' } }] }); expect(res).not.toContain('STOP:'); @@ -914,12 +934,46 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 sh('for i in $(seq 1 20); do echo "export const w$i = $i;" >> src/a.ts; done && git commit -qam allow', wt); return { dir, env, wt: fs.realpathSync(wt) }; } + // Value: protects=no reader is started where Capy will put it (the shared machine's start checkout) when /ship ships another checkout; fails_when=the program dispatches readers that start in the primary checkout while the pass ships a worktree (GBRA-218's outside voice ignored `cd &&` and reviewed the primary checkout, losing Step 11 outside coverage); + // why_new=GBRA-218 on gstack 1.92.25.0; Capy subagents take no working directory; seam=lib/capy-review-pass.js before any log start or create; no_claim=that Capy can start a reader in a chosen directory + test('a shared machine whose start checkout is not the worktree refuses before any start token or reader; a start directory outside any checkout dispatches', async () => { + const { dir, env, wt } = worktree(); + const out = path.join(wt, '.state/run/p1'); + const h = host(dir, env); + const res = await runPass(h, { base: 'main', out, pass: 'w0p1', cwd: wt, planFlags: ['--depth-floor', 'full'], logStart: ['review'] }); + expect(res).toContain(`STOP: readers on the shared machine start in ${fs.realpathSync(dir)}, not in ${wt}`); + expect(res).not.toContain('REVIEW_LOG_START:'); + expect(h.creates.length).toBe(0); + const voices = await runPass(h, { base: 'main', out, pass: 'w0p1', cwd: wt, planFlags: ['--depth-floor', 'full'], stage: 'voices', models: { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' } } }); + expect(voices).toContain('STOP: readers on the shared machine start in '); + expect(h.creates.length).toBe(0); + const started = host(wt, env); + expect(await runPass(started, { base: 'main', out: path.join(wt, '.state/run/p2'), pass: 'w0p2', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); + expect(started.creates.length).toBeGreaterThan(0); + const outside = host(path.dirname(wt), env); + expect(await runPass(outside, { base: 'main', out: path.join(wt, '.state/run/p3'), pass: 'w0p3', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('NEXT: judge (Phase 4)'); + const broken = tmp('rp-broken-'); + fs.writeFileSync(path.join(broken, '.git'), 'garbage\n'); + const unreadable = host(broken, env); + expect(await runPass(unreadable, { base: 'main', out: path.join(wt, '.state/run/p4'), pass: 'w0p4', cwd: wt, planFlags: ['--depth-floor', 'full'] })).toContain('STOP: cannot read the directory readers on the shared machine start in: fatal: invalid gitfile format'); + expect(unreadable.creates.length).toBe(0); + const slow = host(wt, env, { pollsToFinish: 30 }); + let probes = 0; + const bash = slow.tools.capy.bash; + slow.tools.capy.bash = async (a) => { if (a.command.startsWith('if d=$(git rev-parse')) probes++; return bash(a); }; + const input = { base: 'main', out: path.join(wt, '.state/run/p5'), pass: 'w0p5', cwd: wt, planFlags: ['--depth-floor', 'full'] }; + expect(await runPass(slow, input)).toContain('NEXT: poll'); + expect(await runPass(slow, input)).toContain('NEXT: poll'); + expect(probes).toBe(1); + expect(await runPass(host(wt, env), { ...input, logStart: [7] })).toStartWith('STOP: input.logStart must be a list'); + }, 240_000); + // Value: protects=a reader that never left the machine's default checkout cannot hand /ship a review of another tree; fails_when=collect merges a reply whose REVIEWED_TREE names the primary checkout while the pass ships a worktree, or a reply with no such line; // why_new=GBRA-218: pass 1's specialist reviewed /workspace/gbrain while /ship shipped ~/.capy/work/wt-allow; seam=gstack-review-dispatch collect; no_claim=a reader that fingerprints the right checkout but reads another test('a reader that reviews the primary checkout instead of the worktree is refused and nothing it said is merged', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: () => dir, output: t => (t.includes('specialist') ? '[CRITICAL] (confidence: 9/10) src/other.ts:3 a file outside this PR' : cleanReply(t)) }); + const h = host(wt, env, { reportFrom: () => dir, output: t => (t.includes('specialist') ? '[CRITICAL] (confidence: 9/10) src/other.ts:3 a file outside this PR' : cleanReply(t)) }); const res = await runPass(h, { base: 'main', out, pass: 'w1p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).toContain(`reported="${treeLine(dir).slice(15).trim()}"`); @@ -934,20 +988,20 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('a reply that names no checkout is refused the same way', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const res = await runPass(host(dir, env, { reportFrom: () => '' }), { base: 'main', out, pass: 'w2p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); + const res = await runPass(host(wt, env, { reportFrom: () => '' }), { base: 'main', out, pass: 'w2p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).toContain('reported="none"'); expect(res).not.toContain('NEXT: judge'); }, 240_000); - // Value: protects=/ship from a worktree on a shared machine works when readers follow their brief, though they start in another checkout; fails_when=a brief does not name the shipped checkout, or a correct reply is refused; + // Value: protects=/ship from a worktree that is the shared machine's start checkout works, its readers' briefs naming it (GST-776 refuses any other start checkout up front); fails_when=a brief does not name the shipped checkout, or a correct reply is refused; // why_new=GBRA-218 (no brief named a directory); seam=lib/review-pass.ts renderStage and the Capy pass program; no_claim=model compliance with the paragraph test('a reader that works in the checkout its brief names passes, and every brief names the worktree', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'coverage.prompt.md'), 'Run Step 7 as written.\n'); - const h = host(dir, env); + const h = host(wt, env); const res = await runPass(h, { base: 'main', out, pass: 'w3p1', cwd: wt, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'coverage', prompt_file: path.join(out, 'coverage.prompt.md'), first: true }] }); expect(res).not.toContain('STOP:'); @@ -962,7 +1016,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const out = path.join(wt, '.state/run/p1'); fs.mkdirSync(out, { recursive: true }); fs.writeFileSync(path.join(out, 'docs.prompt.md'), 'Run the Step 14.5 audit as written.\n'); - const h = host(dir, env, { reportFrom: t => (t.endsWith('audit/docs') ? dir : briefDirOf(h, t)) }); + const h = host(wt, env, { reportFrom: t => (t.endsWith('audit/docs') ? dir : briefDirOf(h, t)) }); const res = await runPass(h, { base: 'main', out, pass: 'w4p1', cwd: wt, planFlags: ['--depth-floor', 'full'], extras: [{ name: 'docs', prompt_file: path.join(out, 'docs.prompt.md'), first: true }] }); expect(res).toContain('COLLECT_WRONG_TREE: reader=audit/docs'); @@ -973,7 +1027,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('a rerun after a wrong-tree STOP stops on the failed reader and never collects its reply twice', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: () => dir }); + const h = host(wt, env, { reportFrom: () => dir }); const input = { base: 'main', out, pass: 'w5p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }; expect(await runPass(h, input)).toContain('COLLECT_WRONG_TREE: reader='); const again = await runPass(h, input); @@ -987,7 +1041,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const wt = path.join(tmp('rp-wt-'), 'same'); expect(spawnSync('git', ['worktree', 'add', '-q', '--detach', wt, 'feat'], { cwd: dir, env, encoding: 'utf8', timeout: 60_000 }).status).toBe(0); const out = path.join(wt, '.state/run/p1'); - const res = await runPass(host(dir, env, { reportFrom: () => dir }), { base: 'main', out, pass: 'w6p1', cwd: fs.realpathSync(wt), planFlags: ['--depth-floor', 'full'] }); + const res = await runPass(host(wt, env, { reportFrom: () => dir }), { base: 'main', out, pass: 'w6p1', cwd: fs.realpathSync(wt), planFlags: ['--depth-floor', 'full'] }); expect(res).toContain('COLLECT_WRONG_TREE: reader='); expect(res).not.toContain('COLLECT_TREE_MOVED'); }, 240_000); @@ -996,7 +1050,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); let moved = false; - const h = host(dir, env, { reportFrom: t => { + const h = host(wt, env, { reportFrom: t => { if (!moved) { moved = true; spawnSync('bash', ['-c', 'echo "export const late = 1;" >> src/a.ts && git commit -qam late'], { cwd: wt, env, encoding: 'utf8', timeout: 60_000 }); } return briefDirOf(h, t); } }); @@ -1008,7 +1062,7 @@ describe('readers on a shared machine review the checkout the pass ships (GST-48 test('an outside voice that reviewed another tree is recorded as unavailable coverage, never clean, and the voices still finish', async () => { const { dir, env, wt } = worktree(); const out = path.join(wt, '.state/run/p1'); - const h = host(dir, env, { reportFrom: t => (t.endsWith('outside-voice') ? dir : briefDirOf(h, t)) }); + const h = host(wt, env, { reportFrom: t => (t.endsWith('outside-voice') ? dir : briefDirOf(h, t)) }); const base = { base: 'main', out, pass: 'w8p1', cwd: wt, planFlags: ['--depth-floor', 'full'] }; expect(await runPass(h, base)).toContain('NEXT: judge (Phase 4)'); const models = { 'outside-voice': { model_id: 'openai/gpt-6.1-sol' }, 'adversarial-native': { model_id: 'anthropic/claude-opus-5-5', reasoning_mode: 'high' } };