From 8b4f5aaf5c8792d9cf9cbc48ff7aed674d4df7a0 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 18:58:13 -0700 Subject: [PATCH 01/19] v1.91.302.0 fix: repair three flaky gate cases from the 2026-10-28 flaky pass (index flags, exploratory QA, cookie auth) (#3553) * fix(review): shared-code evidence gathering is read-only (index-flags flaky, gate 40619718717) The failing assume-unchanged trial ran git update-index --really-refresh while gathering evidence, which cleared the flag the checker reads. Step 1 now says the read is read-only and names that command; nearby prose tightened so the section stays byte-identical in size (no pin or ledger move). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(qa-callers): admit ship Step 9's review-plan helper and place its Step 11 voices (late-input 40664336141, plan-checks 40707142223) Gate edit: the caller interface admits the gstack-review-plan forms Step 9 names at the exact installed path; the ship prompt says the plan's adversarial-native and outside-voice lines are Step 11's, outside the Steps 8-9 excerpt. Free replays of both 40707142223 captures and the real plan for all four ship cases. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(browse): real-Chromium cookie-auth positive and early-return rejection cases use the product default timeout Gate edit approved by GStack Minder. The shared 400 ms came from a84b0b5b6 (#2964) as a fixture value no product path uses; --verify-auth callers run under MAX_TIMEOUT_MS 15000. Identity mismatch/missing/ambiguous cases resolve only at the deadline, so they keep the 400 ms bound; explicit timeout tests are unchanged. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(review): read-only evidence test exercises the resolver on every host (test-of-test ratchet) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: curate shared-libs-index-flags-readonly off the Windows subset Its fixture launches bin/gstack-review-log, a bash script, directly; windows-latest reports it not found in PATH (Windows Free Tests run 40727825859, shard 5). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: changelog and test portfolio for the 2026-10-28 S6 flaky repairs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: keep the credential-rejection curation comment above its own entry Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Revert "test: keep the credential-rejection curation comment above its own entry" The swap changed an input the s6-docs-2 documentation audit hashed, and /ship allows no third audit in one invocation. The misplaced comment is listed as a review note instead. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(qa-callers): admit review-army's specialist diff at the fixture merge base (screen 40737741454 ship-exploratory-unavailable) The review plan dispatches the state-machines specialist, and review-army tells the parent to write the merge-base sha into its diff command. Both screen trials failed on that child's git diff . Also: cite the deadline loop beside the identity_* cookie-auth cases (GStack Minder accepted the 400 ms narrowing). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(qa-callers): the prompt's chaining rules name both DIFF_BASE forms 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 +- browse/test/cookie-auth-verification.test.ts | 24 +-- docs/TEST_PORTFOLIO.md | 2 +- package.json | 2 +- review/sections/shared-code-reuse.md | 21 +-- scripts/lib/windows-curation.ts | 1 + scripts/resolvers/review-scope.ts | 21 +-- ship/sections/shared-code-reuse.md | 21 +-- ...qa-caller-merge-base-diff-40737741454.json | 101 +++++++++++++ .../qa-caller-review-plan-40707142223.json | 139 ++++++++++++++++++ test/helpers/free-fixtures-data.ts | 2 + test/helpers/qa-callers-fixture.ts | 21 ++- test/qa-caller-review-plan.test.ts | 137 +++++++++++++++++ test/shared-libs-index-flags-readonly.test.ts | 41 ++++++ 16 files changed, 506 insertions(+), 46 deletions(-) create mode 100644 test/fixtures/qa-caller-merge-base-diff-40737741454.json create mode 100644 test/fixtures/qa-caller-review-plan-40707142223.json create mode 100644 test/qa-caller-review-plan.test.ts create mode 100644 test/shared-libs-index-flags-readonly.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a99c739..d78f0464a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.91.302.0] - 2026-10-29 + +**Three flaky gate cases from the 2026-10-28 daily flaky pass are repaired: shared-libs-review-index-flags, ship-exploratory-late-input (with ship-exploratory-plan-checks, which failed the same way), and the real-Chromium cookie-auth positive case.** Each red was diagnosed from its captured run before the fix, and each repair has a free test that fails without it. + +### Fixed +- **Shared-code reuse no longer lets a review rewrite the index flags it checks.** In train gate 40619718717, the reviewer ran `git update-index --really-refresh` while gathering evidence. That cleared the assume-unchanged flag the checker reads, so the trial's prerequisites went stale. Step 1 of the /review and /ship shared-code reuse section now says evidence gathering is read-only and names that command. Nearby wording was tightened so the section keeps its exact size. +- **The /ship exploratory-QA cases follow Step 9's review plan again.** Since #3527, Step 9 runs `gstack-review-plan --base main`. The fixture refused that command (paid screen 40707142223, both ship-exploratory-plan-checks trials) and never said that the plan's adversarial-native and outside-voice lines belong to Step 11, which is outside the fixture's Steps 8–9 excerpt. In gate 40664336141, ship-exploratory-late-input reported pass while listing that Step 11 pass as remaining work. Once the plan ran, it dispatched the state-machines specialist, and review-army tells the parent to write the merge-base sha into that specialist's diff command. The fixture refused `git diff `, which failed both ship-exploratory-unavailable trials in this PR's screen 40737741454. +- **The real-browser cookie-auth checks no longer time out under load.** The positive case and the rejection cases that return early now use the product's default 15 s limit instead of a 400 ms test-only value. That value timed out twice on Windows and once on a Ubicloud VM. + +### Gate integrity +- `test/helpers/qa-callers-fixture.ts`: the caller command interface now admits the `gstack-review-plan` forms ship Step 9 names (`--base main` with its `--pass`, `--last` and finding flags, or `--fix-check-prompt` with literal file arguments), only at the fixture's installed helper path. Pipes, other arguments, other binaries and calls without the fixture's runtime are refused, and `test/qa-caller-review-plan.test.ts` checks these as negative controls. The ship prompt names the Step 11 boundary. The interface also admits the fixture's own merge base (at least 7 hex characters) as a literal diff base, bare or in review-army's `DIFF_BASE= && git diff "$DIFF_BASE"` specialist form, and the prompt declares it. Any other sha, a shorter prefix, other Git options and composition are still refused. No threshold, trial count, case kind or timeout changed. +- `browse/test/cookie-auth-verification.test.ts`: GStack Minder approved dropping the shared `timeoutMs: 400`, which came from a84b0b5b6 (#2964) as a fixture value no product path uses (`--verify-auth` callers run under `MAX_TIMEOUT_MS` 15000). The identity mismatch, missing and ambiguous cases keep 400 ms because they resolve only at the deadline, and the 15 s default would exceed bun's 5 s test timeout. The explicit 100 ms, 30 ms, 60 s-cap and invalid-timeout tests are unchanged. +- New free tests: `test/qa-caller-review-plan.test.ts` (replays both 40707142223 captures and both 40737741454 ship-exploratory-unavailable captures, and runs the real plan for all four ship cases) and `test/shared-libs-index-flags-readonly.test.ts` (replays the captured refresh against the assume-unchanged fixture, with a read-only control). +- `scripts/lib/windows-curation.ts`: `test/shared-libs-index-flags-readonly.test.ts` is curated off the Windows subset. Its fixture starts the review through `bin/gstack-review-log`, a bash script, which windows-latest cannot launch (Windows Free Tests run 40727825859, shard 5). + ## [1.91.301.0] - 2026-10-29 **Four paid gate cases that went red on 2026-10-28 without a product defect now pass on the runs that failed them.** Each repair replays the failing run as a free test that fails without it. diff --git a/VERSION b/VERSION index bd148f120..79fda26f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.301.0 +1.91.302.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 1c74abbf3..0b685966d 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.301.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.302.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/browse/test/cookie-auth-verification.test.ts b/browse/test/cookie-auth-verification.test.ts index 742c1c2c7..4e073cfcc 100644 --- a/browse/test/cookie-auth-verification.test.ts +++ b/browse/test/cookie-auth-verification.test.ts @@ -423,6 +423,7 @@ describe('cookie auth configuration and bounded operations', () => { }); describe('cookie authentication and storage with isolated Chromium profiles', () => { + const browserOptions = { identitySelector: options.identitySelector, expectedIdentity: options.expectedIdentity }; let browser: Browser; let context: BrowserContext; let page: Page; @@ -486,13 +487,13 @@ describe('cookie authentication and storage with isolated Chromium profiles', () test('verifies a reloaded protected page only with an exact visible identity and synthetic session', async () => { await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]); await page.goto(`${origin}/protected`); - expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: true, reason: 'verified', status: 200 }); + expect(await verifyCookieAuthentication(page, browserOptions, origin)).toEqual({ verified: true, reason: 'verified', status: 200 }); }); for (const path of ['/normalized', '/one-visible', '/hidden-child', '/delayed']) { test(`accepts one normalized visible identity at ${path}`, async () => { await page.goto(`${origin}${path}`); - const result = await verifyCookieAuthentication(page, { ...options, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin); + const result = await verifyCookieAuthentication(page, { ...browserOptions, expectedIdentity: ` \n${identity} `, timeoutMs: 1000 }, origin); expect(result).toEqual({ verified: true, reason: 'verified', status: 200 }); expect(JSON.stringify(result)).not.toContain(identity); }); @@ -506,7 +507,8 @@ describe('cookie authentication and storage with isolated Chromium profiles', () test(`rejects ${path} with a safe ${reason} result`, async () => { await page.goto(`${origin}${path === '/login-hop' ? '/identity' : path}`); if (path === '/login-hop') await page.evaluate(() => history.replaceState({}, '', '/login-hop')); - const result = await verifyCookieAuthentication(page, options, origin); + // identity_mismatch/missing/ambiguous keep the 400 ms options (GStack Minder, 2026-10-28): they resolve only when the deadline fires (browse/src/cookie-auth-verification.ts:105-120), so the 15 s default would exceed bun's 5 s test timeout. + const result = await verifyCookieAuthentication(page, reason.startsWith('identity_') ? options : browserOptions, origin); expect(result.verified).toBe(false); expect(result.reason).toBe(reason); expect(JSON.stringify(result)).not.toMatch(/Synthetic Account|127\.0\.0\.1/); @@ -517,13 +519,13 @@ describe('cookie authentication and storage with isolated Chromium profiles', () await context.addCookies([{ name: 'fixture_session', value: 'approved', url: origin }]); await page.goto(`${origin}/protected`); await context.clearCookies(); - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('login_redirect'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('login_redirect'); }); for (const status of [401, 403, 500, 503]) { test(`rejects HTTP ${status} even with a positive identity`, async () => { await page.goto(`${origin}/status/${status}`); - expect(await verifyCookieAuthentication(page, options, origin)).toEqual({ verified: false, reason: 'http_error', status }); + expect(await verifyCookieAuthentication(page, browserOptions, origin)).toEqual({ verified: false, reason: 'http_error', status }); }); } @@ -531,7 +533,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () for (const destination of ['/redirect-cross', '/roundtrip']) { await page.goto(`${origin}/identity`); await page.evaluate(path => history.replaceState({}, '', path), destination); - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_changed'); } }); @@ -553,7 +555,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () return value; }); try { - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_changed'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_changed'); } finally { locator.mockRestore(); } @@ -563,7 +565,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () await page.goto(`${origin}/identity`); await page.evaluate(() => history.replaceState({}, '', '/slow')); try { - expect(await verifyCookieAuthentication(page, { ...options, timeoutMs: 100 }, origin)) + expect(await verifyCookieAuthentication(page, { ...browserOptions, timeoutMs: 100 }, origin)) .toEqual({ verified: false, reason: 'timeout' }); } finally { releaseSlowResponse?.(); @@ -572,11 +574,11 @@ describe('cookie authentication and storage with isolated Chromium profiles', () test('sanitizes invalid selector errors and handles a closed target', async () => { await page.goto(`${origin}/identity`); - const result = await verifyCookieAuthentication(page, { ...options, identitySelector: '[synthetic-token' }, origin); + const result = await verifyCookieAuthentication(page, { ...browserOptions, identitySelector: '[synthetic-token' }, origin); expect(result.reason).toBe('verification_failed'); expect(JSON.stringify(result)).not.toContain('synthetic-token'); await page.close(); - expect((await verifyCookieAuthentication(page, options, origin)).reason).toBe('target_closed'); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).reason).toBe('target_closed'); expect(await clearCookieTargetStorage(page, origin).then(() => null, error => error)).toMatchObject({ code: 'target_closed' }); }); @@ -586,7 +588,7 @@ describe('cookie authentication and storage with isolated Chromium profiles', () localStorage.setItem('local', 'original'); sessionStorage.setItem('session', 'original'); }); - expect((await verifyCookieAuthentication(page, options, origin)).verified).toBe(true); + expect((await verifyCookieAuthentication(page, browserOptions, origin)).verified).toBe(true); expect(await page.evaluate(() => [localStorage.getItem('local'), sessionStorage.getItem('session')])) .toEqual(['original', 'original']); }); diff --git a/docs/TEST_PORTFOLIO.md b/docs/TEST_PORTFOLIO.md index ef393a88b..78510a41e 100644 --- a/docs/TEST_PORTFOLIO.md +++ b/docs/TEST_PORTFOLIO.md @@ -60,7 +60,7 @@ depends on measured duration or an explicit `QUICK_CORE` entry, not this table. | CLI/API/webhook QA without browser setup | `qa-functional-fixture`, `qa-functional-evidence`, `qa-lazy-sections` | `skill-e2e-qa-functional`: CLI and webhook report sessions | Linux/macOS free; selected PR gate; Windows only where curated | | Report-only preserves local and remote authority | `qa-only-capability`, `qa-functional-observer`, `qa-functional-observer-atomic`, `qa-caller-authority` | Independent report-only sessions with synthetic owned endpoints/auth | Linux kernel observation; free callback controls plus selected PR gate | | Repair reproduces the defect, adds a failing regression and rechecks adjacent behavior | `qa-fix-loop-fixture`, `qa-functional-evidence` | `skill-e2e-qa-functional-fix`: CLI and webhook repair sessions | Free controls plus selected PR gate | -| Review and Ship actually explore | `qa-exploratory-callers`, `qa-caller-report-observer`, `qa-checkpoint-evidence` | `skill-e2e-qa-callers`: actual Review/Ship callers | Free captures plus selected PR gate | +| Review and Ship actually explore | `qa-exploratory-callers`, `qa-caller-report-observer`, `qa-caller-review-plan`, `qa-checkpoint-evidence` | `skill-e2e-qa-callers`: actual Review/Ship callers | Free captures plus selected PR gate | | Smoke expiry preserves required plan checks | `qa-deadline`, `qa-deadline-selection`, `qa-browser-deadline-evidence` | `ship-exploratory-plan-checks` | Free deadline/dispatch controls plus selected PR gate | | Late changes invalidate affected results | `qa-caller-freshness-order`, `qa-deadline-publication-observer`, `shared-libs-revalidation-prompt` | `ship-exploratory-late-input` and the existing late-input documentation handoff | Free stale-input controls plus selected PR gate | | Documentation completes before publication and respects protected files | `docsync-authority`, `docsync-atomic-writes`, `docsync-report-interface`, `docsync-lifecycle-interface` | `skill-e2e-ship-docsync`, `skill-e2e-docsync-spawned` | Free state/permission controls; registered gate/periodic scenarios retain their tiers | diff --git a/package.json b/package.json index 7cd518b7a..1485ec75c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.301", + "version": "1.91.302", "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/review/sections/shared-code-reuse.md b/review/sections/shared-code-reuse.md index 4e4d6ba10..9aca55812 100644 --- a/review/sections/shared-code-reuse.md +++ b/review/sections/shared-code-reuse.md @@ -2,11 +2,12 @@ **Reuse a skipped shared-code advisory only with complete structural evidence:** -1. **Read the evidence.** Read all supporting callers and the helper destination. - Establish first-party authored provenance and whether the current extraction - is worthwhile; the checker cannot decide that. Retain `evidence_paths`/`helper_target`. +1. **Read the evidence.** Read all supporting callers and the helper target + read-only: `git update-index`, even `--really-refresh`, clears the index flags + the checker reads. Establish first-party authored provenance and whether this + extraction is worthwhile; the checker cannot. Retain `evidence_paths`/`helper_target`. 2. **Run the checker.** From the repository root, pass the current finding as - literal JSON on stdin. Replace REVIEW_START with this pass's captured token + literal JSON on stdin. Replace REVIEW_START with this pass's token and the example paths/symbol with actual evidence. Keep the quoted delimiter. ```bash @@ -15,17 +16,17 @@ GSTACK_SHARED_LIBS_REUSE_JSON ``` -3. **Act on its result.** Read the JSON. Only `reusable: true` permits suppression. +3. **Act on its result.** Only `reusable: true` permits suppression. False, command failure or unreadable output requires fresh source review and a new decision, never suppression. Do not supply your own snapshot, prior record or coverage. -4. **Persist through the logger.** The logger recomputes final coverage; never - supply proof yourself. Real defects retain normal Fix-First handling independently. +4. **Persist through the logger.** The logger recomputes coverage; never supply + proof. Real defects retain normal Fix-First handling. -**What a reusable result proves (do not reconstruct these checks yourself):** -- Identity: `sharedLibsFingerprint` plus the actual repo, raw branch and current snapshot. +**What a reusable result proves (never reconstruct these checks):** +- Identity: `sharedLibsFingerprint` plus the repo, raw branch and current snapshot. The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity. - Prior decision: completed/converged review, verified binding, explicit Skip and - logger-versioned `snapshot_covered_paths`; older unversioned coverage needs a fresh decision. + logger-versioned `snapshot_covered_paths`; unversioned coverage needs a fresh decision. - Source: `canReuseSharedLibsAdvisory` requires every supporting path's raw file byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index entries; symlinks/ancestors, submodules, ignored/outside or unreadable files; diff --git a/scripts/lib/windows-curation.ts b/scripts/lib/windows-curation.ts index cf9787d22..c884f2358 100644 --- a/scripts/lib/windows-curation.ts +++ b/scripts/lib/windows-curation.ts @@ -52,6 +52,7 @@ const WINDOWS_FRAGILE_PATTERNS: Array<{ pattern: RegExp; reason: string }> = [ // the failure mode is structural rather than detectable via source-file scan. export const KNOWN_WINDOWS_INCOMPATIBLE: Array<{ file: string; reason: string }> = [ // Like test/paid-rerun.test.ts (excluded by pattern), it replays a gate capture through the paid lane's --report and --rerun-plan CLI. + { file: 'test/shared-libs-index-flags-readonly.test.ts', reason: 'its assume-unchanged replay seeds the review through test/helpers/shared-libs-path-fixture.ts, which launches bin/gstack-review-log (a bash script) directly; windows-latest refuses it as not found in $PATH (windows-free-shard 5, run 40727825859)' }, { file: 'test/credential-rejection.test.ts', reason: 'replays run 39696775546 through the --report and --rerun-plan CLI of the paid lane, which runs only on Linux CI; on windows-latest that CLI exits 1 with EINVAL in open() (windows-free-shard run 39982914852)' }, // Field list 2, W11: the review-voice install is a POSIX launcher contract, exercised end to end on Linux and macOS. { file: 'test/capy-review-voice.test.ts', reason: 'copies the checkout with tar -C into the temp root (GNU tar reads a D: path as a remote host) and launches the bash --for review-voice installer with a colon-joined GSTACK_LAUNCH PATH' }, diff --git a/scripts/resolvers/review-scope.ts b/scripts/resolvers/review-scope.ts index ff5688c59..40a94a5dd 100644 --- a/scripts/resolvers/review-scope.ts +++ b/scripts/resolvers/review-scope.ts @@ -133,11 +133,12 @@ or missing-reviewer rules.`; export function generateSharedCodeReuse(ctx: TemplateContext): string { return `**Reuse a skipped shared-code advisory only with complete structural evidence:** -1. **Read the evidence.** Read all supporting callers and the helper destination. - Establish first-party authored provenance and whether the current extraction - is worthwhile; the checker cannot decide that. Retain \`evidence_paths\`/\`helper_target\`. +1. **Read the evidence.** Read all supporting callers and the helper target + read-only: \`git update-index\`, even \`--really-refresh\`, clears the index flags + the checker reads. Establish first-party authored provenance and whether this + extraction is worthwhile; the checker cannot. Retain \`evidence_paths\`/\`helper_target\`. 2. **Run the checker.** From the repository root, pass the current finding as - literal JSON on stdin. Replace REVIEW_START with this pass's captured token + literal JSON on stdin. Replace REVIEW_START with this pass's token and the example paths/symbol with actual evidence. Keep the quoted delimiter. \`\`\`bash @@ -146,17 +147,17 @@ export function generateSharedCodeReuse(ctx: TemplateContext): string { GSTACK_SHARED_LIBS_REUSE_JSON \`\`\` -3. **Act on its result.** Read the JSON. Only \`reusable: true\` permits suppression. +3. **Act on its result.** Only \`reusable: true\` permits suppression. False, command failure or unreadable output requires fresh source review and a new decision, never suppression. Do not supply your own snapshot, prior record or coverage. -4. **Persist through the logger.** The logger recomputes final coverage; never - supply proof yourself. Real defects retain normal Fix-First handling independently. +4. **Persist through the logger.** The logger recomputes coverage; never supply + proof. Real defects retain normal Fix-First handling. -**What a reusable result proves (do not reconstruct these checks yourself):** -- Identity: \`sharedLibsFingerprint\` plus the actual repo, raw branch and current snapshot. +**What a reusable result proves (never reconstruct these checks):** +- Identity: \`sharedLibsFingerprint\` plus the repo, raw branch and current snapshot. The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity. - Prior decision: completed/converged review, verified binding, explicit Skip and - logger-versioned \`snapshot_covered_paths\`; older unversioned coverage needs a fresh decision. + logger-versioned \`snapshot_covered_paths\`; unversioned coverage needs a fresh decision. - Source: \`canReuseSharedLibsAdvisory\` requires every supporting path's raw file byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index entries; symlinks/ancestors, submodules, ignored/outside or unreadable files; diff --git a/ship/sections/shared-code-reuse.md b/ship/sections/shared-code-reuse.md index 4e4d6ba10..9aca55812 100644 --- a/ship/sections/shared-code-reuse.md +++ b/ship/sections/shared-code-reuse.md @@ -2,11 +2,12 @@ **Reuse a skipped shared-code advisory only with complete structural evidence:** -1. **Read the evidence.** Read all supporting callers and the helper destination. - Establish first-party authored provenance and whether the current extraction - is worthwhile; the checker cannot decide that. Retain `evidence_paths`/`helper_target`. +1. **Read the evidence.** Read all supporting callers and the helper target + read-only: `git update-index`, even `--really-refresh`, clears the index flags + the checker reads. Establish first-party authored provenance and whether this + extraction is worthwhile; the checker cannot. Retain `evidence_paths`/`helper_target`. 2. **Run the checker.** From the repository root, pass the current finding as - literal JSON on stdin. Replace REVIEW_START with this pass's captured token + literal JSON on stdin. Replace REVIEW_START with this pass's token and the example paths/symbol with actual evidence. Keep the quoted delimiter. ```bash @@ -15,17 +16,17 @@ GSTACK_SHARED_LIBS_REUSE_JSON ``` -3. **Act on its result.** Read the JSON. Only `reusable: true` permits suppression. +3. **Act on its result.** Only `reusable: true` permits suppression. False, command failure or unreadable output requires fresh source review and a new decision, never suppression. Do not supply your own snapshot, prior record or coverage. -4. **Persist through the logger.** The logger recomputes final coverage; never - supply proof yourself. Real defects retain normal Fix-First handling independently. +4. **Persist through the logger.** The logger recomputes coverage; never supply + proof. Real defects retain normal Fix-First handling. -**What a reusable result proves (do not reconstruct these checks yourself):** -- Identity: `sharedLibsFingerprint` plus the actual repo, raw branch and current snapshot. +**What a reusable result proves (never reconstruct these checks):** +- Identity: `sharedLibsFingerprint` plus the repo, raw branch and current snapshot. The checker reads REVIEW_START without consuming/replacing it. Sanitized branch names are not identity. - Prior decision: completed/converged review, verified binding, explicit Skip and - logger-versioned `snapshot_covered_paths`; older unversioned coverage needs a fresh decision. + logger-versioned `snapshot_covered_paths`; unversioned coverage needs a fresh decision. - Source: `canReuseSharedLibsAdvisory` requires every supporting path's raw file byte-for-byte with its blob. Exclude assume-unchanged, skip-worktree and sparse index entries; symlinks/ancestors, submodules, ignored/outside or unreadable files; diff --git a/test/fixtures/qa-caller-merge-base-diff-40737741454.json b/test/fixtures/qa-caller-merge-base-diff-40737741454.json new file mode 100644 index 000000000..0115de2f8 --- /dev/null +++ b/test/fixtures/qa-caller-merge-base-diff-40737741454.json @@ -0,0 +1,101 @@ +{ + "source": "Paid screen 40737741454 (#3553 head 4cd0777) ship-exploratory-unavailable: paid-slice-11-a1 and paid-rerun-1-a1 native-events.json, every Bash command in order (parent and child). In both trials the review plan dispatched the state-machines specialist, and the parent told it to run git diff with the literal merge-base sha.", + "runs": { + "40737741454-slice-11": { + "root": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs", + "mergeBase": "8c9a9c5", + "commands": [ + { + "parent": false, + "command": "date -u +%Y-%m-%dT%H:%M:%SZ" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json 300 2026-10-28T18:00:28.179Z" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json -- bun scripts/probe.ts 4" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-deadline status /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports/deadline.json --after 001 --hypothesis 'Riskiest changed edge: out-of-range 10 must exit 2 with the declared stderr; also confirms whether the missing native-engine prerequisite blocks every CLI path' -- bun scripts/probe.ts 10" + }, + { + "parent": false, + "command": "bun run test" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-review-plan --base main" + }, + { + "parent": true, + "command": "git diff 8c9a9c5" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/product/reports annotations.json" + }, + { + "parent": false, + "command": "git status --short" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-hEauF9/tmp/qc-qBCQGs/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"issues_found\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":false,\"converged\":false,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish bbf15eec-7962-4aee-bcfd-88233b0375fe" + } + ] + }, + "40737741454-rerun-1": { + "root": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK", + "mergeBase": "0f7b81c", + "commands": [ + { + "parent": false, + "command": "date -u +%Y-%m-%dT%H:%M:%SZ" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json 300 2026-10-28T18:18:29.666Z" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json -- bun scripts/probe.ts 3" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports/deadline.json --after 001 --hypothesis 'Success path blocked by missing vendor/native-engine.ts; check whether the riskiest changed edge (out-of-range 10) is equally blocked before reaching scale().' -- bun scripts/probe.ts 10" + }, + { + "parent": false, + "command": "bun run test" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-review-plan --base main" + }, + { + "parent": true, + "command": "git diff 0f7b81c" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/product/reports annotations.json" + }, + { + "parent": false, + "command": "git status --short" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-2u70Ma/tmp/qc-lV9GKK/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"issues_found\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"testing\":{\"dispatched\":false,\"reason\":\"scope\"},\"maintainability\":{\"dispatched\":false,\"reason\":\"scope\"},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":false,\"converged\":false,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 4b1c9f3b-d67b-4919-8237-21634b5b4c0b" + } + ] + } + } +} \ No newline at end of file diff --git a/test/fixtures/qa-caller-review-plan-40707142223.json b/test/fixtures/qa-caller-review-plan-40707142223.json new file mode 100644 index 000000000..740f5a351 --- /dev/null +++ b/test/fixtures/qa-caller-review-plan-40707142223.json @@ -0,0 +1,139 @@ +{ + "source": "Paid screen 40707142223 (#3544 head) ship-exploratory-plan-checks: paid-slice-10-a1 and paid-rerun-1-a1 native-events.json, every Bash command in order (parent and child); late-input receipt from train 35 rebuild gate 40664336141 paid-slice-9-a1", + "runs": { + "40707142223-slice-10": { + "root": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE", + "commands": [ + { + "parent": false, + "command": "date -u +%Y-%m-%dT%H:%M:%SZ" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json 300 2026-10-28T14:53:05.952Z" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json -- bun scripts/probe.ts 4" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-deadline status /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports/deadline.json --after 001 --hypothesis 'Success holds for 4; riskiest changed edge is just past the upper bound, so 10 must exit 2 with the exact stderr message.' -- bun scripts/probe.ts 10" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports 003 --public --timeout-ms 10000 --after 002 --hypothesis 'Rejection of 10 holds; PLAN.md requires the upper boundary check: 9 must exit 0 with stdout 18 and empty stderr.' -- bun scripts/probe.ts 9" + }, + { + "parent": false, + "command": "bun run test" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/product/reports annotations.json" + }, + { + "parent": false, + "command": "if GATE_OUT=$(/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-gate-diff main 2>&1); then\n printf '%s\\n' \"$GATE_OUT\"\nelse\n GATE_EXIT=$?; printf '%s\\n' \"$GATE_OUT\"\n case \"$GATE_EXIT\" in\n 2) echo \"Gate integrity: UNAVAILABLE \u2014 $(printf '%s\\n' \"$GATE_OUT\" | grep -m1 '^GATE_ERROR=')\" ;;\n *) echo \"Gate integrity: UNAVAILABLE \u2014 helper exit $GATE_EXIT (stale install? run /gstack-upgrade)\" ;;\n esac\nfi" + }, + { + "parent": false, + "command": "date -u +%Y-%m-%dT%H:%M:%SZ" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-review-plan --base main" + }, + { + "parent": true, + "command": "git diff origin/main" + }, + { + "parent": false, + "command": "git status --short" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-T57Isu/tmp/qc-9kHKwE/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0},\"red-team\":{\"dispatched\":false,\"reason\":\"scope\"}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":true,\"converged\":true,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 5534d8f6-4d67-4b6f-b691-c6e83c659df2" + }, + { + "parent": false, + "command": "date -u +%Y-%m-%dT%H:%M:%SZ" + } + ] + }, + "40707142223-rerun-1": { + "root": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP", + "commands": [ + { + "parent": false, + "command": "date -u +%Y-%m-%dT%H:%M:%SZ" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-deadline start /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json 300 2026-10-28T15:06:29.695Z" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports 001 --public --deadline /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json -- bun scripts/probe.ts 9" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence capture /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports 002 --public --deadline /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports/deadline.json --after 001 --hypothesis 'Range rule: 10 breaks only the 0..9 bound; changed return path must not alter rejection (exit 2, exact stderr, empty stdout).' -- bun scripts/probe.ts 10" + }, + { + "parent": false, + "command": "bun run test" + }, + { + "parent": false, + "command": "if GATE_OUT=$(/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-gate-diff main 2>&1); then\n printf '%s\\n' \"$GATE_OUT\"\nelse\n GATE_EXIT=$?; printf '%s\\n' \"$GATE_OUT\"\n case \"$GATE_EXIT\" in\n 2) echo \"Gate integrity: UNAVAILABLE \u2014 $(printf '%s\\n' \"$GATE_OUT\" | grep -m1 '^GATE_ERROR=')\" ;;\n *) echo \"Gate integrity: UNAVAILABLE \u2014 helper exit $GATE_EXIT (stale install? run /gstack-upgrade)\" ;;\n esac\nfi" + }, + { + "parent": false, + "command": "bun /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-qa-evidence materialize /home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/product/reports annotations.json" + }, + { + "parent": false, + "command": "git status --short" + }, + { + "parent": false, + "command": "date -u +%Y-%m-%dT%H:%M:%SZ" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-review-plan --base main" + }, + { + "parent": true, + "command": "git diff origin/main" + }, + { + "parent": false, + "command": "/home/runner/.cache/gstack-paid-shard-pi2bIV/tmp/qc-kMsNBP/host/runtime/bin/gstack-review-log '{\"skill\":\"review\",\"timestamp\":\"'\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"'\",\"status\":\"clean\",\"issues_found\":0,\"critical\":0,\"informational\":0,\"quality_score\":10.0,\"specialists\":{\"state-machines\":{\"dispatched\":true,\"findings\":0,\"critical\":0,\"informational\":0}},\"findings\":[],\"commit\":\"'\"$(git rev-parse --short HEAD)\"'\",\"via\":\"ship\",\"completed\":true,\"converged\":true,\"cycles\":0,\"pass\":1,\"review_scope\":\"frozen\",\"range_base\":null}' --finish 94e80914-bf02-4c55-84a9-a9ea9e1adbac" + }, + { + "parent": false, + "command": "git status --short" + } + ] + } + }, + "lateInputReceipt": { + "status": "pass", + "probes": [ + "probe-1cd02598-4151-4346-9556-b245a37e9a44", + "probe-11857dac-c540-41de-8e13-ca6181ec88fc", + "probe-a8572671-a2ec-4b2d-b5bb-aee043898de1" + ], + "remaining": [ + "Step 11 native adversarial review (not dispatched)", + "review record completed:false" + ] + } +} \ No newline at end of file diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 8e3b9ffaf..3b62728c3 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -179,7 +179,9 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/plan-floor-edit-wrap-37176835584.json': ['test/plan-floor-edit-wrap.test.ts'], 'test/fixtures/plan-format-kind-notes.json': ['test/plan-format-kind-note.test.ts'], 'test/fixtures/qa-caller-handoff-order-public.json': ['test/qa-exploratory-callers.test.ts'], + 'test/fixtures/qa-caller-merge-base-diff-40737741454.json': ['test/qa-caller-review-plan.test.ts'], 'test/fixtures/qa-caller-read-probe-mtb1007a.json': ['test/qa-exploratory-callers.test.ts'], + 'test/fixtures/qa-caller-review-plan-40707142223.json': ['test/qa-caller-review-plan.test.ts'], 'test/fixtures/qa-caller-section-skip-ship-small-cli.json': ['test/qa-exploratory-callers.test.ts'], 'test/fixtures/plans/autoplan-dashboard.md': ['test/autoplan-init.test.ts', 'test/autoplan-preconfigured-onboarding-ar.test.ts', 'test/autoplan-snapshot.test.ts'], 'test/fixtures/plans/autoplan-focus-appearance-design.md': [], diff --git a/test/helpers/qa-callers-fixture.ts b/test/helpers/qa-callers-fixture.ts index 9ec7a6cdd..0329b8e2c 100644 --- a/test/helpers/qa-callers-fixture.ts +++ b/test/helpers/qa-callers-fixture.ts @@ -140,6 +140,14 @@ const literalCallerListing = new RegExp(`^ls(?: -[la]+)?(?: --)?(?: ${literalCal const callerDiffMode = '(--stat|--numstat|--name-only|--name-status)'; const literalCallerDiff = new RegExp(`^git diff(?: ${callerDiffMode})?(?: (origin/main|"\\$DIFF_BASE"))?(?: ${callerDiffMode})?(?: --(?: ${literalCallerArgument})+)?$`); const callerDiffPreface = 'DIFF_BASE=$(git merge-base origin/main HEAD) && '; +const literalMergeBaseDiff = new RegExp(`^(?:DIFF_BASE=([0-9a-f]{7,40}) && )?git diff(?: ${callerDiffMode})? ([0-9a-f]{7,40}|"\\$DIFF_BASE")(?: ${callerDiffMode})?(?: --(?: ${literalCallerArgument})+)?$`); + +function mergeBaseDiffAllowed(text: string, context?: CallerDeadlineContext): boolean { + const diff = literalMergeBaseDiff.exec(text); + if (!diff || !context || (diff[2] && diff[4]) || !!diff[1] !== (diff[3] === '"$DIFF_BASE"')) return false; + const base = spawnSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: context.fixtureRoot, encoding: 'utf8', timeout: 5000 }); + return base.status === 0 && base.stdout.trim().startsWith(diff[1] ?? diff[3]); +} function literalCallerDiffAllowed(text: string): boolean { const recomputedBase = text.startsWith(callerDiffPreface); @@ -217,9 +225,19 @@ function callerDeadlineCommand(command: string, context?: CallerDeadlineContext) return { action, stateFile }; } +const reviewPlanFlags = '(?: --pass \\d+)?(?: --last (?:none|full|fix-check))?(?: --(?:fixed-blocking|fixed-below|blocking-open|touched-gate|critical-found|adversarial-ran))*(?: --reviewed-numbers [\\w:.-]+)?'; +const reviewPlanForms = new RegExp(`^(?:--base main${reviewPlanFlags}|--fix-check-prompt --fix-list ${literalCallerArgument} --hunks ${literalCallerArgument} --tests ${literalCallerArgument}(?: --callers ${literalCallerArgument})?)$`); + +/** The review-plan helper forms ship Step 9 names, run by the fixture's exact installed path. */ +function reviewPlanCommandAllowed(text: string, runtime?: string): boolean { + const helper = runtime && `${runtime}/bin/gstack-review-plan `; + return !!helper && text.startsWith(helper) && reviewPlanForms.test(text.slice(helper.length)); +} + export function qaCallerCommandAllowed(command: string, workflowCommands: string[] = [], deadline?: CallerDeadlineContext): boolean { const text = command.trim(); if (qaHelperUsageCommand(text)) return true; + if (reviewPlanCommandAllowed(text, deadline?.runtime)) return true; if (callerEvidenceCommand(text, deadline)) return true; if (callerDeadlineCommand(text, deadline)) return true; if (/\bgstack-qa-(?:deadline|evidence)\b/.test(text) && !literalCallerCLI.test(text) @@ -227,6 +245,7 @@ export function qaCallerCommandAllowed(command: string, workflowCommands: string if (workflowCommands.includes(text)) return true; if (literalCallerCLI.test(text)) return true; if (literalCallerDiffAllowed(text)) return true; + if (mergeBaseDiffAllowed(text, deadline)) return true; if (literalCallerListingAllowed(text)) return true; let normalizedRecord = text; const substitutedFields: string[] = []; @@ -699,7 +718,7 @@ export function callerReviewRecordTemplate(fixture: Pick[0] { return { - prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts \` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Handoff freshness check: Read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await those results, and compare evidence with current inputs; rerun any probe whose input changed. Run the check before each of these, and dispatch each only in a later message than the check's Read results: the evidence helper's materialize, which runs once per report root and freezes the verdict; every gstack-review-log completed:true record, adversarial-review included, since a record is a completion, not preliminary bookkeeping; and the completion report. A later handoff read cannot validate an earlier materialize or completion. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change.\n\nQA fixture bindings: the deadline guard, capture, checkpoint and materialize command forms are in the installed qa/sections/exploratory.md this phase requires you to Read; use those forms verbatim with these bindings substituted literally. DEADLINE_TOOL = ${fixture.runtime}/bin/gstack-qa-deadline. EVIDENCE_TOOL = ${fixture.runtime}/bin/gstack-qa-evidence. PROBE_DIR = ${fixture.cwd}/reports. DEADLINE_FILE = ${fixture.cwd}/reports/deadline.json. SECONDS must be positive and at most 300. EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The only native COMMAND is the existing diagnostic client, bun scripts/probe.ts, with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Its diagnostic receipts are declared public/synthetic, so --public is approved. Only an explicit plan check this phase requires may use --timeout-ms 10000 in place of the deadline; every other capture, including a rerun or revalidation of an earlier probe, uses the deadline. This does not reset or bypass the smoke deadline. Only the deadline helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it. Quote arguments literally. Sources stay inside reports. Decide to execute the next probe before publishing its checkpoint, then await successful publication and dispatch that exact probe. If you defer an optional idea or stop exploration, do not publish a checkpoint for it; describe it as not run in Markdown. An unused checkpoint requires an actual authenticated expired-capture result; nearing the deadline or choosing to stop is not enough. A complete capture can truthfully retain a nonzero domain exit (including expected rejection or unavailable dependency); it is not automatically a pass. Wrapper-failed, interrupted or incomplete captures are not observations or passes; an authenticated expired capture can retain an unused checkpoint as not-run evidence. No new native child command or shell authority is granted.\n\nThe supported Bash interface is one literal documented native command or one exact generated workflow shell block with main substituted for and PLAN.md for ; even read-only commands must not be chained except for the exact DIFF_BASE preface below. Native CLI commands accept no argument or one literal argument: an unquoted shell-safe word, single-quoted text, or double-quoted text without expansion or escapes; no multiline arguments or shell composition. Inventory commands are pwd, bun --version, and ls with an optional combined -l/-a flag, optional -- separator, and literal path operands using the same quoting rules; operands beginning with - require --. Listing never permits other options, glob expansion, substitution, redirection or composition. The supported Git forms are git status --short, git status --porcelain, git branch --show-current, git rev-parse HEAD, git rev-parse --short HEAD, git merge-base origin/main HEAD, the plan-completion section's git log origin/main..HEAD --oneline, git diff (optional origin/main base and at most one output mode: --stat, --numstat, --name-only, or --name-status, before or after the base; optional literal pathspec arguments after --), git ls-files, and git ls-files --others --exclude-standard. Diff pathspecs use the same literal argument syntax as the CLI; quote globs so Git, not the shell, interprets them. The only variable-base form is DIFF_BASE=$(git merge-base origin/main HEAD) && git diff with exactly the double-quoted "$DIFF_BASE" base, the same optional output mode and literal pathspecs. That exact preface recomputes the base in the same command; other assignments and variable expansions are forbidden; the only additional command substitutions are the two installed bookkeeping fields described below. No other Git options, configuration overrides, --output, --no-index, --ext-diff, --textconv, external helpers or mutations are authorized. Exact generated workflow blocks remain allowed. Review bookkeeping permits the installed gstack-review-log start commands and single-quoted JSON records for review/adversarial-review (the installed quote-spliced double-quoted $(date -u +%Y-%m-%dT%H:%M:%SZ) is permitted only as the complete top-level timestamp value, and $(git rev-parse --short HEAD) only as the complete top-level commit value; no other substitutions, field placements or shell evaluation), gstack-review-read, gstack-specialist-stats, plus date -u +%Y-%m-%dT%H:%M:%SZ; use an original start token for a started attempt and completed:false/converged:false without a token for an unstarted one. These installed helpers are shell scripts: run them by exact installed path, never through bun or another interpreter. A review record fills this installed template, keeping its keys and adding none: \`${callerReviewRecordTemplate(fixture)}\`. A review record's status is clean only for a completed review with zero unresolved defects, otherwise issues_found${fixture.caller === 'ship' ? ' (unavailable for missing dispatched reviewer output)' : ''}; a review stopped at a gate records completed:false, never a receipt status such as blocked. Bookkeeping has an owned state directory and private Git object store; the real product, index, refs and config must stay unchanged. Outside providers are unavailable in this isolated fixture; the native reviewer is still required.${fixture.child ? ` Subagent dispatch uses a deterministic child transport instead of Agent/Task: when the workflow dispatches ${fixture.caller === 'review' ? 'the native adversarial reviewer (Step 4.8)' : 'the plan completion audit (Step 8)'}, complete its dispatch prerequisites, do not invoke Agent or Task, and Read ${fixture.child} as that child's complete final output; apply the parent's normal handling to it.` : ''} Other interpreters, shell composition outside those generated blocks or the exact DIFF_BASE preface, global setup and external operations are not authorized. Use Read/Glob/Grep for discovery instead of cat/head/tail; use native Write/Edit tools for any authorized file output. Pass these same command and write boundaries to any child; generated shell fragments remain verbatim apart from the declared substitutions.\n\nWrite the phase report to reports/review.md. Keep it compact (aim under 300 words): retain each required heading and field, link to reports/evidence.json and the checkpoints for details already recorded there, and do not restate the diff, the evidence rows or the review record. Also save reports/receipt.json with this machine-readable result shape: {\"status\":\"pass|fail|blocked|inconclusive\",\"probes\":[\"observed diagnostic receipt ids\"],\"remaining\":[\"names of incomplete contracts\"]}. The status is the overall supplied phase gate, not whether some probes passed: copy it from the verdict.status that materialize prints and writes to reports/evidence.json, and if that verdict is not pass, rerun what it lists as open before finishing when a pass is required. The probes array contains the exact id values from the captured child JSON (probe-...), never the helper's three-digit capture IDs or QA_EVIDENCE.id. Capture IDs select stored observations for checkpoint/materialize; child diagnostic IDs identify the actual probes in this final receipt. Read the child JSON's id for each probe used as evidence; do not derive it from a filename or execution counter. remaining names unresolved required contracts or gates in this phase. Pass requires no remaining required contracts or gates. Optional unavailable providers and later stages outside this excerpt are not required remainder. This format does not establish that any work succeeded. Stop at the end of this phase, or at its first unresolved approval gate.`, + prompt: `Load gstack's /${fixture.caller} supplied parent phase from caller-${fixture.caller}.md and resume it on the selected working-tree diff against origin/main. This excerpt comes from ${fixture.runtime}/${fixture.caller}/SKILL.md; resolve installed-relative references there, not from the excerpt file or product directory. That path identifies the asset base, not another entrypoint: do not read or invoke the full parent SKILL.md or rerun its preamble. Earlier preamble/branch/base setup is complete; use the existing local origin/main ref without fetch. Earlier-phase asset locators are ${fixture.runtime}/review/checklist.md and ${fixture.runtime}/qa/templates/functional-report-template.md. Read those files directly when referenced; recursive Glob does not follow the installed asset symlinks. Cross-project learnings are configured off in this owned fixture. ${fixture.reviewStart ? `The actual review-start helper already captured REVIEW_START=${fixture.reviewStart} for this unchanged core pass; retain that token. ` : ''} This fixture evaluates only the supplied parent phase, not later publication stages. The fixture owner recorded these read-only observations when this phase began; they are current until an input changes, so use them instead of re-running those commands, and recheck freshness before completion outputs:\n${fixture.startObservations}\n Read README.md for the project contract and commands. Use diagnostic-client commands such as \`bun scripts/probe.ts \` for exploratory discoveries and their checkpoint evidence. A required \`bun run test\` is separate suite verification: report it as verification, never as a diagnostic observation or checkpoint anchor/target. Use the production evidence helper to publish each diagnostic checkpoint as \`reports/exploration-NNN.json\`, not inside a nested directory; do not transcribe its observed payload. ${fixture.caseId === 'ship-exploratory-plan-checks' ? 'The previously discovered plan is PLAN.md.' : 'No plan file was found.'} There is no remote service and no release publication is authorized. There is no interactive approver; do not invent answers or permission. Keep normal parent decision gates. Handoff freshness check: Read HANDOFF.md and reports/HANDOFF.md if present for any concurrent collaborator update, await those results, and compare evidence with current inputs; rerun any probe whose input changed. Run the check before each of these, and dispatch each only in a later message than the check's Read results: the evidence helper's materialize, which runs once per report root and freezes the verdict; every gstack-review-log completed:true record, adversarial-review included, since a record is a completion, not preliminary bookkeeping; and the completion report. A later handoff read cannot validate an earlier materialize or completion. If a probe's snapshot differs from an earlier probe's, read reports/HANDOFF.md first; it names any collaborator change.\n\nQA fixture bindings: the deadline guard, capture, checkpoint and materialize command forms are in the installed qa/sections/exploratory.md this phase requires you to Read; use those forms verbatim with these bindings substituted literally. DEADLINE_TOOL = ${fixture.runtime}/bin/gstack-qa-deadline. EVIDENCE_TOOL = ${fixture.runtime}/bin/gstack-qa-evidence. PROBE_DIR = ${fixture.cwd}/reports. DEADLINE_FILE = ${fixture.cwd}/reports/deadline.json. SECONDS must be positive and at most 300. EARLIER_UTC is the optional caller absolute deadline: use the section clock's Hard deadline UTC, never its Runner entry UTC, reserve-start time or a clock-read time. The only native COMMAND is the existing diagnostic client, bun scripts/probe.ts, with zero or one literal argument. Resolve these exact helper and state paths; do not use variables, another helper, another state file, nested wrappers, scripts, operators or substitutions. Its diagnostic receipts are declared public/synthetic, so --public is approved. Only an explicit plan check this phase requires may use --timeout-ms 10000 in place of the deadline; every other capture, including a rerun or revalidation of an earlier probe, uses the deadline. This does not reset or bypass the smoke deadline. Only the deadline helper may create or change reports/deadline.json and its .qa-deadline- temporary files; never use Write/Edit/MultiEdit on those paths. Record the full outer command in checkpoints and evidence; keep the unchanged child JSON as observed, separate from prefixed guard diagnostics. A completed expired guard-run is not a probe or a pass: retain its unused checkpoint, report not-run coverage and do not restart the deadline. Keep the 12-probe smoke limit. Required suites and explicit plan checks are outside the bounded smoke budget, not permission to reset it. Quote arguments literally. Sources stay inside reports. Decide to execute the next probe before publishing its checkpoint, then await successful publication and dispatch that exact probe. If you defer an optional idea or stop exploration, do not publish a checkpoint for it; describe it as not run in Markdown. An unused checkpoint requires an actual authenticated expired-capture result; nearing the deadline or choosing to stop is not enough. A complete capture can truthfully retain a nonzero domain exit (including expected rejection or unavailable dependency); it is not automatically a pass. Wrapper-failed, interrupted or incomplete captures are not observations or passes; an authenticated expired capture can retain an unused checkpoint as not-run evidence. No new native child command or shell authority is granted.\n\nThe supported Bash interface is one literal documented native command or one exact generated workflow shell block with main substituted for and PLAN.md for ; even read-only commands must not be chained except for the two DIFF_BASE forms below. Native CLI commands accept no argument or one literal argument: an unquoted shell-safe word, single-quoted text, or double-quoted text without expansion or escapes; no multiline arguments or shell composition. Inventory commands are pwd, bun --version, and ls with an optional combined -l/-a flag, optional -- separator, and literal path operands using the same quoting rules; operands beginning with - require --. Listing never permits other options, glob expansion, substitution, redirection or composition. The supported Git forms are git status --short, git status --porcelain, git branch --show-current, git rev-parse HEAD, git rev-parse --short HEAD, git merge-base origin/main HEAD, the plan-completion section's git log origin/main..HEAD --oneline, git diff (optional origin/main base and at most one output mode: --stat, --numstat, --name-only, or --name-status, before or after the base; optional literal pathspec arguments after --), git ls-files, and git ls-files --others --exclude-standard. Diff pathspecs use the same literal argument syntax as the CLI; quote globs so Git, not the shell, interprets them. The only variable-base form is DIFF_BASE=$(git merge-base origin/main HEAD) && git diff with exactly the double-quoted "$DIFF_BASE" base, the same optional output mode and literal pathspecs. That exact preface recomputes the base in the same command. The merge base itself, the sha git merge-base origin/main HEAD prints (at least 7 characters), may replace origin/main as a literal diff base, and the review army's specialist form DIFF_BASE= && git diff "$DIFF_BASE" is the one other permitted assignment; both take the same optional output mode and literal pathspecs. Other assignments and variable expansions are forbidden; the only additional command substitutions are the two installed bookkeeping fields described below. No other Git options, configuration overrides, --output, --no-index, --ext-diff, --textconv, external helpers or mutations are authorized. Exact generated workflow blocks remain allowed. Review bookkeeping permits the installed gstack-review-log start commands and single-quoted JSON records for review/adversarial-review (the installed quote-spliced double-quoted $(date -u +%Y-%m-%dT%H:%M:%SZ) is permitted only as the complete top-level timestamp value, and $(git rev-parse --short HEAD) only as the complete top-level commit value; no other substitutions, field placements or shell evaluation), gstack-review-read, gstack-specialist-stats, ${fixture.caller === 'ship' ? 'the installed gstack-review-plan forms Step 9 names (--base main with its --pass, --last and finding flags, or --fix-check-prompt with literal --fix-list, --hunks and --tests files), ' : ''}plus date -u +%Y-%m-%dT%H:%M:%SZ; use an original start token for a started attempt and completed:false/converged:false without a token for an unstarted one. These installed helpers are shell scripts: run them by exact installed path, never through bun or another interpreter. A review record fills this installed template, keeping its keys and adding none: \`${callerReviewRecordTemplate(fixture)}\`. A review record's status is clean only for a completed review with zero unresolved defects, otherwise issues_found${fixture.caller === 'ship' ? ' (unavailable for missing dispatched reviewer output)' : ''}; a review stopped at a gate records completed:false, never a receipt status such as blocked. Bookkeeping has an owned state directory and private Git object store; the real product, index, refs and config must stay unchanged. Outside providers are unavailable in this isolated fixture; the native reviewer is still required.${fixture.caller === 'ship' ? " Step 9 dispatches its review plan's specialist and Red Team lines. The plan's adversarial-native and outside-voice lines are Step 11's voices, which Step 11 runs in a later stage outside this excerpt; they are not remaining work of this phase." : ''}${fixture.child ? ` Subagent dispatch uses a deterministic child transport instead of Agent/Task: when the workflow dispatches ${fixture.caller === 'review' ? 'the native adversarial reviewer (Step 4.8)' : 'the plan completion audit (Step 8)'}, complete its dispatch prerequisites, do not invoke Agent or Task, and Read ${fixture.child} as that child's complete final output; apply the parent's normal handling to it.` : ''} Other interpreters, shell composition outside those generated blocks or the two DIFF_BASE forms, global setup and external operations are not authorized. Use Read/Glob/Grep for discovery instead of cat/head/tail; use native Write/Edit tools for any authorized file output. Pass these same command and write boundaries to any child; generated shell fragments remain verbatim apart from the declared substitutions.\n\nWrite the phase report to reports/review.md. Keep it compact (aim under 300 words): retain each required heading and field, link to reports/evidence.json and the checkpoints for details already recorded there, and do not restate the diff, the evidence rows or the review record. Also save reports/receipt.json with this machine-readable result shape: {\"status\":\"pass|fail|blocked|inconclusive\",\"probes\":[\"observed diagnostic receipt ids\"],\"remaining\":[\"names of incomplete contracts\"]}. The status is the overall supplied phase gate, not whether some probes passed: copy it from the verdict.status that materialize prints and writes to reports/evidence.json, and if that verdict is not pass, rerun what it lists as open before finishing when a pass is required. The probes array contains the exact id values from the captured child JSON (probe-...), never the helper's three-digit capture IDs or QA_EVIDENCE.id. Capture IDs select stored observations for checkpoint/materialize; child diagnostic IDs identify the actual probes in this final receipt. Read the child JSON's id for each probe used as evidence; do not derive it from a filename or execution counter. remaining names unresolved required contracts or gates in this phase. Pass requires no remaining required contracts or gates. Optional unavailable providers and later stages outside this excerpt are not required remainder. This format does not establish that any work succeeded. Stop at the end of this phase, or at its first unresolved approval gate.`, appendSystemPrompt: `Caller execution scheduling (fixture contract): This session has at most 25 assistant turns, including required verification and final artifacts. The command boundary applies to each Bash call, not to the number of independent tool calls in an assistant turn. After required clock and approval prerequisites settle, issue independent source Reads and read-only discovery together as separate native tool calls once their paths and inputs are known. Read-only discovery is Read, Glob, Grep and the declared inventory, Git and review-read forms; a capture, a deadline guard start or run, or a diagnostic-client run is a probe, never discovery. For example, after the first clock read, one response can Read the caller excerpt, README.md, every product file and the referenced checklist and templates. Wait for their results before decisions that depend on them. diff --git a/test/qa-caller-review-plan.test.ts b/test/qa-caller-review-plan.test.ts new file mode 100644 index 000000000..0db0b6890 --- /dev/null +++ b/test/qa-caller-review-plan.test.ts @@ -0,0 +1,137 @@ +/** + * #3527 put Step 9's review plan into ship/sections/review-army.md as prose, so the qa-callers transport + * neither admitted the helper nor said where the plan's Step 11 voices run. + * - Paid screen 40707142223 (ship-exploratory-plan-checks, slice 10 and its rerun): the only refused call in + * both trials was `/bin/gstack-review-plan --base main`. + * - Train 35 rebuild gate 40664336141 (ship-exploratory-late-input, slice 9): the actor never ran the plan, + * read Step 11's native adversarial pass as owed by this Steps 8-9 excerpt, and reported pass with it remaining. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { bunIsolationArgs } from '../lib/bun-isolation'; +import { + createQaCallerFixture, qaCallerCommandAllowed, qaCallerSessionOptions, validateCallerEvidence, + type QaCallerCase, type QaCallerFixture, +} from './helpers/qa-callers-fixture'; + +const captured = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/qa-caller-review-plan-40707142223.json'), 'utf8')); +const specialistDiff = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures/qa-caller-merge-base-diff-40737741454.json'), 'utf8')); +const SHIP_CASES: QaCallerCase[] = ['ship-exploratory-small-cli', 'ship-exploratory-unavailable', 'ship-exploratory-plan-checks', 'ship-exploratory-late-input']; +const fixtures: QaCallerFixture[] = []; +afterEach(async () => { + for (const fixture of fixtures.splice(0)) { await fixture.close(); fs.rmSync(fixture.root, { recursive: true, force: true }); } +}); +const fixtureFor = (caseId: QaCallerCase, installRuntime = false) => { + const fixture = createQaCallerFixture(caseId, { installRuntime }); + fixtures.push(fixture); + return fixture; +}; +const context = (fixture: QaCallerFixture) => ({ runtime: fixture.runtime, fixtureRoot: fixture.cwd }); + +describe('ship Step 9 review plan through the qa-callers transport', () => { + for (const [run, trial] of Object.entries(captured.runs) as [string, { root: string; commands: { command: string }[] }][]) { + test(`captured ${run}: every Bash call the actor made is inside the declared interface`, () => { + const fixture = fixtureFor('ship-exploratory-plan-checks', true); + const commands = trial.commands.map(({ command }) => command.replaceAll(trial.root, fixture.root)); + expect(commands.filter(command => command.includes('/bin/gstack-review-plan '))).toEqual([`${fixture.runtime}/bin/gstack-review-plan --base main`]); + expect(commands.filter(command => !qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)))).toEqual([]); + }); + } + + test('the helper forms Step 9 names are admitted at the exact installed path, and nothing else is', () => { + const fixture = fixtureFor('ship-exploratory-late-input'); + const helper = `${fixture.runtime}/bin/gstack-review-plan`; + const allowed = (command: string) => qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)); + for (const args of ['--base main', '--base main --pass 1 --last full', '--base main --pass 2 --last fix-check --fixed-below --adversarial-ran', + '--base main --pass 1 --last full --blocking-open --touched-gate --critical-found --reviewed-numbers fp:0a1b', + '--fix-check-prompt --fix-list reports/fix-list.md --hunks reports/hunks.diff --tests reports/tests.txt', + "--fix-check-prompt --fix-list 'reports/fix list.md' --hunks reports/h.diff --tests reports/t.txt --callers reports/c.txt"]) { + expect(allowed(`${helper} ${args}`), args).toBe(true); + } + for (const command of [`${helper} --base origin/main`, `${helper} --base main --json`, `${helper} --base main --force all`, + `${helper} --base main --last full --pass 1`, helper, `${helper} --base main | tee reports/plan.txt`, `${helper} --base main; rm -rf reports`, + `${helper} --base main && git reset --hard`, `${helper} --base "$(git rev-parse HEAD)"`, `${helper} --fix-check-prompt --fix-list $HOME/x --hunks a --tests b`, + `bun ${helper} --base main`, `${helper}x --base main`, `${fixture.runtime}/bin/gstack-review-planner --base main`, + '/tmp/elsewhere/bin/gstack-review-plan --base main', '~/.claude/skills/gstack/bin/gstack-review-plan --base main']) { + expect(allowed(command), command).toBe(false); + } + expect(qaCallerCommandAllowed(`${helper} --base main`, fixture.workflowCommands)).toBe(false); + }); + + test.each(SHIP_CASES)('%s: every reviewer the real plan dispatches has a declared route in the transport', caseId => { + const fixture = fixtureFor(caseId, true); + const options = qaCallerSessionOptions(fixture, 'free-review-plan'); + expect(qaCallerCommandAllowed(`${fixture.runtime}/bin/gstack-review-plan --base main`, fixture.workflowCommands, context(fixture))).toBe(true); + const plan = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(fixture.runtime, 'bin/gstack-review-plan'), '--base', 'main'], + { cwd: fixture.cwd, env: { ...process.env, ...options.env }, encoding: 'utf8', timeout: 60_000 }); + expect(plan.status, plan.stderr).toBe(0); + const roles = [...plan.stdout.matchAll(/^DISPATCH: (\S+)/gm)].map(match => match[1]); + expect(roles).toContain('adversarial-native'); + const prompt = options.prompt.toLowerCase(); + for (const role of roles) expect(prompt.includes(role) || prompt.includes(role.replace('-', ' ')), role).toBe(true); + expect(options.prompt).toContain("The plan's adversarial-native and outside-voice lines are Step 11's voices, which Step 11 runs in a later stage outside this excerpt; they are not remaining work of this phase."); + }); + + test('review keeps its own Step 4.8 transport and is not told about ship Step 11', () => { + const prompt = qaCallerSessionOptions(fixtureFor('review-exploratory-small-cli'), 'free-review-plan').prompt; + expect(prompt).toContain('the native adversarial reviewer (Step 4.8)'); + expect(prompt).not.toContain('gstack-review-plan'); + expect(prompt).not.toContain("Step 11's voices"); + }); + + test('captured 40664336141 late-input receipt: pass with remaining work is still a false green', () => { + const receipt = captured.lateInputReceipt; + expect(receipt.remaining).toContain('Step 11 native adversarial review (not dispatched)'); + const reportRoot = fs.mkdtempSync(path.join(fs.realpathSync(os.tmpdir()), 'qc-late-')); + const validate = (remaining: string[]) => validateCallerEvidence({ caller: 'ship', result: { exitReason: 'success', transcript: [] }, probes: [], + receipt: { status: receipt.status, probes: [], remaining }, currentSnapshot: 'none', requiredCharters: [], mutations: [], observerComplete: true, + reportRoot, checkpointFiles: {}, reportMarkdown: '' }); + try { + expect(validate(receipt.remaining)).toContain('blocked, failing or incomplete coverage reported green'); + expect(validate([])).not.toContain('blocked, failing or incomplete coverage reported green'); + } finally { fs.rmSync(reportRoot, { recursive: true, force: true }); } + }); +}); + +/** + * Paid screen 40737741454 (ship-exploratory-unavailable, slice 11 and its rerun): the plan dispatched the + * state-machines specialist, review-army tells the parent to write the merge-base sha into the specialist's + * diff command, and the only refused call in both trials was that child's `git diff `. + */ +describe('review-army specialist diff at the literal merge base', () => { + const mergeBase = (fixture: QaCallerFixture) => spawnSync('git', ['merge-base', 'origin/main', 'HEAD'], { cwd: fixture.cwd, encoding: 'utf8', timeout: 5000 }).stdout.trim(); + + for (const [run, trial] of Object.entries(specialistDiff.runs) as [string, { root: string; mergeBase: string; commands: { command: string }[] }][]) { + test(`captured ${run}: every Bash call, the specialist child's included, is inside the declared interface`, () => { + const fixture = fixtureFor('ship-exploratory-unavailable', true); + const base = mergeBase(fixture).slice(0, trial.mergeBase.length); + const commands = trial.commands.map(({ command }) => command.replaceAll(trial.root, fixture.root).replaceAll(trial.mergeBase, base)); + expect(commands).toContain(`git diff ${base}`); + expect(commands.filter(command => !qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)))).toEqual([]); + }); + } + + test('only the fixture merge base is admitted, bare or in the specialist DIFF_BASE form, and the prompt declares it', () => { + const fixture = fixtureFor('ship-exploratory-unavailable'); + const base = mergeBase(fixture); + const allowed = (command: string) => qaCallerCommandAllowed(command, fixture.workflowCommands, context(fixture)); + for (const command of [`git diff ${base}`, `git diff ${base.slice(0, 7)}`, `git diff --stat ${base.slice(0, 12)}`, `git diff ${base} -- scale.ts`, + `DIFF_BASE=${base} && git diff "$DIFF_BASE"`, `DIFF_BASE=${base.slice(0, 9)} && git diff --name-status "$DIFF_BASE"`]) { + expect(allowed(command), command).toBe(true); + } + const other = base.replace(/^./, base[0] === '0' ? '1' : '0'); + for (const command of [`git diff ${other}`, `git diff ${base.slice(0, 6)}`, `git diff ${base} --output=reports/x`, `git diff --stat ${base} --numstat`, + `DIFF_BASE=${other} && git diff "$DIFF_BASE"`, `DIFF_BASE=${base} && git diff ${base}`, `DIFF_BASE=${base}; git diff "$DIFF_BASE"`, + `git diff "$DIFF_BASE"`, `DIFF_BASE=${base} && git diff "$DIFF_BASE" && git status --short`, `git diff ${base.toUpperCase()}`]) { + expect(allowed(command), command).toBe(false); + } + expect(qaCallerCommandAllowed(`git diff ${base}`, fixture.workflowCommands)).toBe(false); + for (const caseId of ['ship-exploratory-unavailable', 'review-exploratory-small-cli'] as QaCallerCase[]) { + expect(qaCallerSessionOptions(fixtureFor(caseId), 'free-review-plan').prompt).toContain( + 'The merge base itself, the sha git merge-base origin/main HEAD prints (at least 7 characters), may replace origin/main as a literal diff base'); + } + }); +}); diff --git a/test/shared-libs-index-flags-readonly.test.ts b/test/shared-libs-index-flags-readonly.test.ts new file mode 100644 index 000000000..c2e0c2608 --- /dev/null +++ b/test/shared-libs-index-flags-readonly.test.ts @@ -0,0 +1,41 @@ +/** + * Train gate 40619718717 (798a37d8) slice 14, shared-libs-review-index-flags, assume-unchanged trial: while + * gathering evidence the actor ran `git update-index --really-refresh`, which cleared the assume-unchanged flag + * the checker reads, so the prerequisite binding went stale. The skill's evidence step now says it is read-only. + */ +import { afterAll, expect, test } from 'bun:test'; +import { execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { generateSharedCodeReuse } from '../scripts/resolvers/review-scope'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; +import { fixtureGit, type SharedLibsFixture } from './helpers/shared-libs-eval-fixture'; +import { checkPathReviewPrerequisites, preparePathEligibilityFixture } from './helpers/shared-libs-path-fixture'; + +const fixtures: SharedLibsFixture[] = []; +afterAll(() => { for (const f of fixtures) fs.rmSync(f.root, { recursive: true, force: true }); }); + +test.each((['review', 'ship'] as const).flatMap(skill => (Object.keys(HOST_PATHS) as TemplateContext['host'][]).map(host => [skill, host] as const)))('%s on %s: the shared-code evidence step forbids the index refresh the captured actor ran', (skillName, host) => { + const section = generateSharedCodeReuse({ skillName, tmplPath: '', host, paths: HOST_PATHS[host] }); + const step = section.slice(section.indexOf('1. **Read the evidence.**'), section.indexOf('2. **Run the checker.**')).replace(/\s+/g, ' '); + expect(step).toContain('read-only: `git update-index`, even `--really-refresh`, clears the index flags the checker reads'); +}); + +test.each([ + ['captured refresh', 'git update-index --really-refresh >/dev/null 2>&1; echo "(no index mutation above intended)"', false], + ['captured read-only inspection', 'git ls-files -v; git status --porcelain=v1; git hash-object --no-filters -- src/retry-route.ts; git ls-files --debug src/retry-route.ts', true], +] as const)('%s against the assume-unchanged fixture: the prerequisite detector sees it (%s)', (_, command, unchanged) => { + const prepared = preparePathEligibilityFixture('assume-unchanged'); + const f = prepared.fixture; + fixtures.push(f); + const before = checkPathReviewPrerequisites(f, prepared.resumed.input); + expect(before.settled).toBe(true); + expect(fixtureGit(f, 'ls-files', '-v', 'src/retry-route.ts')).toMatch(/^h /); + execFileSync('bash', ['-c', command], { cwd: f.repo, env: { ...process.env, ...f.env }, encoding: 'utf8', timeout: 30_000 }); + const after = checkPathReviewPrerequisites(f, prepared.resumed.input); + if (unchanged) expect(after).toEqual(before); + else { + expect(fixtureGit(f, 'ls-files', '-v', 'src/retry-route.ts')).toMatch(/^H /); + expect({ current: after.current, settled: after.settled }).toEqual({ current: false, settled: false }); + } +}); From 128dd06f09efa708dece3b55db7611b76b6fa776 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 19:20:13 -0700 Subject: [PATCH 02/19] v1.91.303.0 fix(browse): pr-shots no longer edits the tracked .gitignore mid-/ship (#3560) (#3562) * fix(browse): record .gstack/ in info/exclude, never the tracked .gitignore (#3560) The browse daemon's state-dir setup appended .gstack/ to the worktree's .gitignore when the repository tracks files under .gstack/, so gstack-pr-shots changed the tree after pregate and /ship's receipt refused with PREGATE_STALE. The ignore line now goes to the repository's info/exclude (git rev-parse --git-path), which is outside the tree. Fixes #3560 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): pr-shots no longer edits .gitignore (#3560) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(pr-shots): clear BROWSE_STATE_FILE so the #3560 case runs under the shard runner 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 | 11 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- browse/src/config.ts | 57 ++++++++++++--------- browse/test/config.test.ts | 92 ++++++++++++++++++---------------- package.json | 2 +- test/pr-shots.test.ts | 21 ++++++++ 7 files changed, 117 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d78f0464a..4f8090db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.303.0] - 2026-10-29 + +**`gstack-pr-shots` no longer edits your `.gitignore` in the middle of /ship, so the pregate stays fresh and the ship receipt gets written.** When a repository tracks files under `.gstack/` (`pregate.json`, `ship-policy.json`), git does not read `.gstack/` as ignored, and the browse daemon that `gstack-pr-shots` starts appended `.gstack/` to the tracked `.gitignore`. The tree changed after pregate and the receipt writer refused with PREGATE_STALE (#3560, seen on gstack-evals#13). + +### Fixed +- **`browse/src/config.ts`**: the browse state dir still writes its own `.gstack/.gitignore` (`*`), and when git does not already ignore `.gstack/` the line goes to the repository's `info/exclude` (resolved with `git rev-parse --git-path`, so linked worktrees share it). The project's `.gitignore` is never written. Outside a git repository nothing is written beyond the state dir. + +### Tests +- `test/pr-shots.test.ts`: a repository that tracks `.gstack/pregate.json` runs `gstack-pr-shots` with a browse stub that runs the daemon's real state-dir setup; the `gstack-wtree` fingerprint and the `.gitignore` are unchanged afterwards. It failed before this change. +- `browse/test/config.test.ts`: the cases that pinned the `.gitignore` append now pin `info/exclude`: written once, no duplicate, a missing trailing newline added, `info/` created when absent, a warning in `browse-server.log` when it cannot be written, and nothing written outside a repository. + ## [1.91.302.0] - 2026-10-29 **Three flaky gate cases from the 2026-10-28 daily flaky pass are repaired: shared-libs-review-index-flags, ship-exploratory-late-input (with ship-exploratory-plan-checks, which failed the same way), and the real-Chromium cookie-auth positive case.** Each red was diagnosed from its captured run before the fix, and each repair has a free test that fails without it. diff --git a/VERSION b/VERSION index 79fda26f5..184cc85e7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.302.0 +1.91.303.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 0b685966d..0afaf21a3 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.302.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.303.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/browse/src/config.ts b/browse/src/config.ts index 89cd21418..17f29dd82 100644 --- a/browse/src/config.ts +++ b/browse/src/config.ts @@ -100,6 +100,21 @@ function isIgnoredByGit(projectDir: string, relPath: string): boolean { } } +function gitExcludePath(projectDir: string): string | null { + try { + const proc = Bun.spawnSync(['git', 'rev-parse', '--git-path', 'info/exclude'], { + windowsHide: true, + cwd: projectDir, stdout: 'pipe', stderr: 'pipe', + timeout: 2_000, + }); + if (proc.exitCode !== 0) return null; + const rel = proc.stdout.toString().trim(); + return rel ? path.resolve(projectDir, rel) : null; + } catch { + return null; + } +} + /** * Create the .gstack/ state directory if it doesn't exist. * Throws with a clear message on permission errors. @@ -119,40 +134,36 @@ export function ensureStateDir(config: BrowseConfig): void { // Load-bearing guard: a self-contained ignore INSIDE the state dir so its // contents can NEVER be `git add`-ed, regardless of the project's own - // .gitignore (which may be absent, or the append below may silently fail). - // The state dir holds session-state.json (live cookies + localStorage/ - // sessionStorage tokens) and browse-network.log / browse-audit.jsonl - // (captured request headers can carry bearer tokens). Written unconditionally, - // synchronously, before return — the project-.gitignore dance below is now - // redundant safety, kept so `.gstack/` still reads as ignored in git status. + // ignore rules. The state dir holds session-state.json (live cookies + + // localStorage/sessionStorage tokens) and browse-network.log / + // browse-audit.jsonl (captured request headers can carry bearer tokens). + // Written unconditionally, synchronously, before return. try { fs.writeFileSync(path.join(config.stateDir, '.gitignore'), '*\n'); } catch { - // Best-effort; the project-.gitignore path below is the fallback. + // Best-effort; the repository's info/exclude below is the fallback. } - // Ensure .gstack/ is in the project's .gitignore - // First, check if git already ignores .gstack/ (via global excludes, .git/info/exclude, or parent .gitignore) + // Ensure git reads .gstack/ as ignored through the repository's own + // info/exclude, never the project's .gitignore: that file is tracked, and + // editing it mid-/ship changes the tree after pregate (PREGATE_STALE, #3560). if (isIgnoredByGit(config.projectDir, '.gstack/')) return; - - const gitignorePath = path.join(config.projectDir, '.gitignore'); + const excludePath = gitExcludePath(config.projectDir); + if (!excludePath) return; try { - const content = fs.readFileSync(gitignorePath, 'utf-8'); + const content = fs.existsSync(excludePath) ? fs.readFileSync(excludePath, 'utf-8') : ''; if (!content.match(/^\.gstack\/?$/m)) { - const separator = content.endsWith('\n') ? '' : '\n'; - fs.appendFileSync(gitignorePath, `${separator}.gstack/\n`); + const separator = content === '' || content.endsWith('\n') ? '' : '\n'; + fs.mkdirSync(path.dirname(excludePath), { recursive: true }); + fs.appendFileSync(excludePath, `${separator}.gstack/\n`); } } catch (err: any) { - if (err.code !== 'ENOENT') { - // Write warning to server log (visible even in daemon mode) - const logPath = path.join(config.stateDir, 'browse-server.log'); - try { - fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update .gitignore at ${gitignorePath}: ${err.message}\n`); - } catch { - // stateDir write failed too — nothing more we can do - } + const logPath = path.join(config.stateDir, 'browse-server.log'); + try { + fs.appendFileSync(logPath, `[${new Date().toISOString()}] Warning: could not update ${excludePath}: ${err.message}\n`); + } catch { + // stateDir write failed too — nothing more we can do } - // ENOENT (no .gitignore) — skip silently } } diff --git a/browse/test/config.test.ts b/browse/test/config.test.ts index 53a2081a6..f93a802cf 100644 --- a/browse/test/config.test.ts +++ b/browse/test/config.test.ts @@ -95,68 +95,72 @@ describe('config', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('adds .gstack/ to .gitignore if not present', () => { - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n'); + // A repository that tracks files under .gstack/ (pregate.json, ship-policy.json) is the case + // where git does not already read .gstack/ as ignored, so the ignore line must land somewhere. + const trackedGstackRepo = (prefix: string) => { + const { spawnSync } = require('child_process'); + const tmpDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + const git = (...args: string[]) => spawnSync('git', args, { cwd: tmpDir, timeout: 30_000, encoding: 'utf-8' }); + git('init', '-q'); + fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/tmp/\n'); + fs.mkdirSync(path.join(tmpDir, '.gstack')); + fs.writeFileSync(path.join(tmpDir, '.gstack', 'pregate.json'), '{}\n'); + git('add', '-A'); + git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'); + const exclude = path.resolve(tmpDir, git('rev-parse', '--git-path', 'info/exclude').stdout.trim()); + return { tmpDir, git, exclude }; + }; + + test('records .gstack/ in .git/info/exclude and leaves the tracked .gitignore unchanged (#3560)', () => { + const { tmpDir, git, exclude } = trackedGstackRepo('browse-exclude-test-'); + fs.writeFileSync(exclude, '# local\n'); const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); - const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(content).toContain('.gstack/'); - expect(content).toBe('node_modules/\n.gstack/\n'); + expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n'); + expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n.gstack/\n'); + expect(git('status', '--porcelain').stdout).toBe(''); fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('does not duplicate .gstack/ in .gitignore', () => { - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n.gstack/\n'); - const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); + test('does not duplicate .gstack/ in info/exclude, adds a missing trailing newline, and creates info/ when absent', () => { + const dup = trackedGstackRepo('browse-exclude-dup-'); + fs.writeFileSync(dup.exclude, 'build/'); + const config = resolveConfig({ BROWSE_STATE_FILE: path.join(dup.tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); - const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(content).toBe('node_modules/\n.gstack/\n'); - fs.rmSync(tmpDir, { recursive: true, force: true }); + ensureStateDir(config); + expect(fs.readFileSync(dup.exclude, 'utf-8')).toBe('build/\n.gstack/\n'); + fs.rmSync(dup.tmpDir, { recursive: true, force: true }); + + const bare = trackedGstackRepo('browse-exclude-noinfo-'); + fs.rmSync(path.dirname(bare.exclude), { recursive: true, force: true }); + ensureStateDir(resolveConfig({ BROWSE_STATE_FILE: path.join(bare.tmpDir, '.gstack', 'browse.json') })); + expect(fs.readFileSync(bare.exclude, 'utf-8')).toBe('.gstack/\n'); + fs.rmSync(bare.tmpDir, { recursive: true, force: true }); }); - test('handles .gitignore without trailing newline', () => { - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules'); - const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); - ensureStateDir(config); - const content = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(content).toBe('node_modules\n.gstack/\n'); - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - test('logs warning to browse-server.log on non-ENOENT gitignore error', () => { + test('logs warning to browse-server.log when info/exclude cannot be written', () => { if (!canRevokeWrites()) return; // chmod is advisory here (win32, root, DAC-override containers) - const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); - fs.mkdirSync(tmpDir, { recursive: true }); - // Create a read-only .gitignore (no .gstack/ entry → would try to append) - fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n'); - fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o444); + const { tmpDir, exclude } = trackedGstackRepo('browse-exclude-ro-'); + fs.writeFileSync(exclude, '# local\n'); + fs.chmodSync(exclude, 0o444); const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); // should not throw - // Verify warning was written to server log - const logPath = path.join(config.stateDir, 'browse-server.log'); - expect(fs.existsSync(logPath)).toBe(true); - const logContent = fs.readFileSync(logPath, 'utf-8'); - expect(logContent).toContain('Warning: could not update .gitignore'); - // .gitignore should remain unchanged - const gitignoreContent = fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8'); - expect(gitignoreContent).toBe('node_modules/\n'); - // Cleanup - fs.chmodSync(path.join(tmpDir, '.gitignore'), 0o644); + const logContent = fs.readFileSync(path.join(config.stateDir, 'browse-server.log'), 'utf-8'); + expect(logContent).toContain(`Warning: could not update ${exclude}`); + expect(fs.readFileSync(exclude, 'utf-8')).toBe('# local\n'); + expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n.gstack/tmp/\n'); + fs.chmodSync(exclude, 0o644); fs.rmSync(tmpDir, { recursive: true, force: true }); }); - test('skips if no .gitignore exists', () => { + test('outside a git repository nothing is written beyond the state dir', () => { const tmpDir = path.join(os.tmpdir(), `browse-gitignore-test-${Date.now()}`); fs.mkdirSync(tmpDir, { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.gitignore'), 'node_modules/\n'); const config = resolveConfig({ BROWSE_STATE_FILE: path.join(tmpDir, '.gstack', 'browse.json') }); ensureStateDir(config); - expect(fs.existsSync(path.join(tmpDir, '.gitignore'))).toBe(false); + expect(fs.readFileSync(path.join(tmpDir, '.gitignore'), 'utf-8')).toBe('node_modules/\n'); + expect(fs.readdirSync(tmpDir).sort()).toEqual(['.gitignore', '.gstack']); fs.rmSync(tmpDir, { recursive: true, force: true }); }); diff --git a/package.json b/package.json index 1485ec75c..75e44f893 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.302", + "version": "1.91.303", "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/pr-shots.test.ts b/test/pr-shots.test.ts index d142521e0..90d2459ec 100644 --- a/test/pr-shots.test.ts +++ b/test/pr-shots.test.ts @@ -10,6 +10,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { buildPage, cleanText, diffLines, hunks, pathSides, resolveCommit } from '../lib/pr-shots'; +import { wtreeFingerprint } from '../lib/script-invocation'; const ROOT = path.resolve(import.meta.dir, '..'); const dirs: string[] = []; @@ -120,6 +121,26 @@ describe('gstack-pr-shots CLI', () => { expect(text).toStartWith('
Before/after as text: The record renders as a table'); }); + // #3560: pr-shots starts the browse daemon from the worktree; the daemon's state-dir setup + // appended .gstack/ to the tracked .gitignore in a repository that tracks .gstack/ config, so the + // tree changed after pregate and the /ship receipt refused with PREGATE_STALE. The stub browse + // runs the daemon's real state-dir setup (browse/src/config.ts), then fails like a browser-less host. + test.skipIf(process.platform === 'win32')('the working-tree fingerprint is unchanged after a run that starts browse (#3560)', () => { + const { dir } = repo({ '.gitignore': 'node_modules/\n.gstack/tmp/\n', '.gstack/pregate.json': '{}\n', 'doc.md': '# Doc\n\nold\n' }); + fs.writeFileSync(path.join(dir, 'doc.md'), '# Doc\n\nnew\n'); + const stubDir = tmp('shots-browse-'); + fs.writeFileSync(path.join(stubDir, 'state-dir.ts'), `import { ensureStateDir, resolveConfig } from ${JSON.stringify(path.join(ROOT, 'browse/src/config.ts'))};\nensureStateDir(resolveConfig());\nprocess.exit(1);\n`); + const browse = path.join(stubDir, 'browse'); + fs.writeFileSync(browse, `#!/usr/bin/env bash\nexec ${JSON.stringify(process.execPath)} --no-env-file --config=/dev/null ${JSON.stringify(path.join(stubDir, 'state-dir.ts'))}\n`, { mode: 0o755 }); + const before = wtreeFingerprint({ cwd: dir }); + expect('tree' in before).toBe(true); + const r = cli(['--path', 'doc.md', '--caption', 'c'], dir, { GSTACK_STATE_ROOT: tmp('shots-state-'), GSTACK_BROWSE_BIN: browse, BROWSE_STATE_FILE: '' }); + expect(r.out).toMatch(/^SHOT_PNG: failed /m); + expect(fs.existsSync(path.join(dir, '.gstack', '.gitignore'))).toBe(true); + expect(fs.readFileSync(path.join(dir, '.gitignore'), 'utf8')).toBe('node_modules/\n.gstack/tmp/\n'); + expect(wtreeFingerprint({ cwd: dir })).toEqual(before); + }); + test('a secret in a capture is refused before anything is written, without echoing it', () => { const { dir } = repo({ 'x.txt': 'x' }); const secret = 'ghp_' + 'b'.repeat(36); From 4729f4752ba6aa6524f52fa49f51c28779a3ea24 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 19:33:42 -0700 Subject: [PATCH 03/19] v1.91.304.0 fix(capy-install): an install for one workflow keeps the other current verdicts (#3561) (#3563) * fix(capy-install): an install for one workflow keeps the other current verdicts (#3561) The install verdict is one file per checkout and project, and an ok --for document-release run replaced the --for ship verdict with its narrower required list, so /ship's docs step left the session without a ship admission. An ok run at the same checkout, HEAD and Bun now merges the earlier ok verdict: required is the union, pins are planning-only only when both runs skipped them, and for= lists every workflow. A failing run, another HEAD or another Bun still replaces it. Fixes #3561 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): installs for two workflows keep both verdicts (#3561) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(capy-install): an ok install keeps the other current verdicts (#3561) 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-capy-install | 21 ++++++++++++++++++-- bin/gstack-install-verdict.sh | 4 +++- docs/capy-install.md | 11 ++++++++++- package.json | 2 +- test/gstack-capy-install.test.ts | 33 +++++++++++++++++++++++++++++++- 8 files changed, 77 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f8090db2..f6cfab56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.304.0] - 2026-10-29 + +**Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13). + +### Fixed +- **`bin/gstack-capy-install`**: an ok run keeps what an earlier ok install certified for the same checkout, project, HEAD and Bun. The verdict's `required` list becomes the union, pins count as planning-only only when both runs skipped them, and `for` lists every workflow (`for=ship,document-release`). The installer says so in one line. A failing run, a new HEAD or another Bun still replaces the verdict, and the review-voice profile still never narrows a fuller one. + +### Tests +- `test/gstack-capy-install.test.ts`: `--for ship` then `--for document-release` leaves one ok verdict that admits both workflows through `verdictProblem`; a `--check --for ship` keeps it; after a new commit, `--for document-release` replaces it and ship is refused again. It failed before this change. + ## [1.91.303.0] - 2026-10-29 **`gstack-pr-shots` no longer edits your `.gitignore` in the middle of /ship, so the pregate stays fresh and the ship receipt gets written.** When a repository tracks files under `.gstack/` (`pregate.json`, `ship-policy.json`), git does not read `.gstack/` as ignored, and the browse daemon that `gstack-pr-shots` starts appended `.gstack/` to the tracked `.gitignore`. The tree changed after pregate and the receipt writer refused with PREGATE_STALE (#3560, seen on gstack-evals#13). diff --git a/VERSION b/VERSION index 184cc85e7..d38dd36b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.303.0 +1.91.304.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 0afaf21a3..0196bed74 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.303.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.304.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-capy-install b/bin/gstack-capy-install index 62975197f..93b51b75c 100755 --- a/bin/gstack-capy-install +++ b/bin/gstack-capy-install @@ -407,15 +407,32 @@ verdict_kept() { [ "$PROFILE" -eq 1 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && ! gstack_doctor_install_profile "$(gstack_install_verdict_field "$VERDICT" for)" } +# An ok run keeps what an earlier ok install certified for this checkout, project, HEAD and Bun, +# so one machine serving /ship and its docs step stays admitted for both (#3561): required is +# the union, pins count as planning-only only when both runs skipped them, and for= lists every +# workflow (comma-separated). A failing run, another HEAD or another Bun replaces the verdict. record_verdict() { # TRAILER OK — --check keeps the recorded resolution (drift is reported, never re-recorded) + local v_for="${FOR:-}" v_req v_plan e_for id if verdict_kept; then say "kept the install verdict for=$(gstack_install_verdict_field "$VERDICT" for) at $VERDICT (it covers more than $FOR; the trailer below is this check's)" return 0 fi if [ "$CHECK_ONLY" -eq 1 ] && [ -n "$REC_BUN" ]; then set -- "$1" "$2" "$REC_BUN" "$REC_VER" "$REC_RULE" "$REC_BUN_INSTALL" else set -- "$1" "$2" "$RES_BIN" "$RES_VER" "$RES_RULE" "${BUN_INSTALL:-}"; fi - gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "${FOR:-}" "$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1)" \ - "$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0)" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \ + v_req=$(printf '%s\n' "${OUT:-}" | sed -n 's/^required: //p' | head -1) + v_plan=$(printf '%s\n' "${OUT:-}" | grep -q '^SKIP pins (planning-only' && echo 1 || echo 0) + if [ "$2" = 1 ] && [ "$PROFILE" -eq 0 ] && [ -f "$VERDICT" ] && [ "$(gstack_install_verdict_field "$VERDICT" ok)" = true ] \ + && [ "$(gstack_install_verdict_field "$VERDICT" head)" = "$HEAD_SHA" ] && [ "$(gstack_install_verdict_field "$VERDICT" checkout)" = "$ROOT_REAL" ] \ + && [ "$(gstack_install_verdict_field "$VERDICT" bun_path)" = "$3" ] && e_for=$(gstack_install_verdict_field "$VERDICT" for) \ + && ! gstack_doctor_install_profile "$e_for"; then + for id in $(gstack_install_verdict_field "$VERDICT" required); do + case " $v_req " in *" $id "*) ;; *) v_req="$v_req $id" ;; esac + done + [ "$(gstack_install_verdict_field "$VERDICT" pins_planning_only)" = true ] || v_plan=0 + case ",$e_for," in *",$v_for,"*) v_for="$e_for" ;; *) v_for="${e_for:+$e_for${v_for:+,}}$v_for" ;; esac + [ "$v_for" = "$e_for" ] || [ -z "$e_for" ] || say "the install verdict at $VERDICT keeps for=$e_for and adds ${FOR:-$REQUIRE} (for=$v_for)" + fi + gstack_install_verdict_write "$VERDICT" "$ROOT_REAL" "$HEAD_SHA" "$v_for" "$v_req" "$v_plan" "$2" "$1" "${MIN_REVISION:-}" "$LAUNCH_DIR" \ "$PROJECT_REAL" "$PIN_EXACT" "$3" "$4" "$5" "$6" "$NODE_BIN" "$NODE_VER" "$NODE_RULE" \ || say "could not write the install verdict $VERDICT" } diff --git a/bin/gstack-install-verdict.sh b/bin/gstack-install-verdict.sh index 5f553376d..73ad365cf 100644 --- a/bin/gstack-install-verdict.sh +++ b/bin/gstack-install-verdict.sh @@ -8,7 +8,9 @@ # one checkout and one project (the caller's git toplevel, or none): # /install-verdicts//.json # written atomically by bin/gstack-capy-install (install and --check) with -# schema_version 2, checkout, head, for, required[], pins_planning_only, ok, +# schema_version 2, checkout, head, for (comma-separated: an ok run at the same +# HEAD and Bun keeps the workflows an earlier ok install certified, #3561), +# required[] (their union), pins_planning_only, ok, # trailer, min_revision, launch_path, time, project, pin (the exact Bun pin), # bun_path (realpath), bun_version, bun_rule (gstack_bun_resolve), bun_install, # node_path (realpath), node_version, node_rule (gstack_node_resolve; empty diff --git a/docs/capy-install.md b/docs/capy-install.md index 1bed678f9..f98f87d6e 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -274,7 +274,16 @@ keeps the lease. `bin/gstack-lease list` shows `alive|dead|stale`, and honoured, the resolved Bun (`bun_path`, `bun_version`, `bun_rule`, and `bun_install` when this run downloaded it), the resolved Node when the project pins one (`node_path`, `node_version`, `node_rule`), the trailer, the remote - minimum, the launch PATH and the time. Admission refuses a workflow whose + minimum, the launch PATH and the time. An ok run (install or `--check`) at + the same checkout, project, HEAD and Bun as an earlier ok verdict keeps what + that verdict certified, so one machine stays admitted for /ship and its + `--for document-release` docs step (#3561): `required` becomes the union, + pins count as planning-only only when both runs skipped them, and the + verdict's `for` lists every workflow, comma-separated (`for=ship,document-release`; + the installer prints `the install verdict at keeps for= and adds + (for=)`, and the trailer still names this run's workflow). A + failing run, a new HEAD or another Bun replaces the verdict, and so does any + workflow install over a review-voice verdict. Admission refuses a workflow whose running Bun is not the one the verdict recorded ([BUN_RESOLUTION_DRIFT](troubleshooting.md#bun-resolution-drift)), and so do the pregate's lanes and guards, which also refuse an invalid verdict diff --git a/package.json b/package.json index 75e44f893..da4c43471 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.303", + "version": "1.91.304", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-capy-install.test.ts b/test/gstack-capy-install.test.ts index d40b86ed4..937eaf57c 100644 --- a/test/gstack-capy-install.test.ts +++ b/test/gstack-capy-install.test.ts @@ -13,6 +13,7 @@ import { spawnSync } from 'child_process'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { verdictProblem } from '../lib/install-verdict'; const REPO = path.resolve(import.meta.dir, '..'); const BUN_DEFAULT = fs.readFileSync(path.join(REPO, 'bin/gstack-bun-version.sh'), 'utf8').match(/^GSTACK_BUN_DEFAULT="([^"]+)"$/m)![1]; @@ -58,7 +59,7 @@ function fixture(opts: { bun?: string; jq?: boolean } = {}) { script(path.join(root, 'setup'), ['echo "setup $*" >> "$CAPY_LOG"', 'echo "gstack ready ($2)"', 'echo "env GSTACK_SKIP_PLAYWRIGHT=${GSTACK_SKIP_PLAYWRIGHT:-} GSTACK_SKIP_FONTS=${GSTACK_SKIP_FONTS:-} bun=$(bun --version)" >> "$CAPY_LOG"', 'exit "${STUB_SETUP_EXIT:-0}"']); script(path.join(root, 'bin/gstack-doctor'), [ 'echo "doctor $* GSTACK_EPHEMERAL=${GSTACK_EPHEMERAL:-}" >> "$CAPY_LOG"', 'echo "doctor-cwd $(pwd -P)" >> "$CAPY_LOG"', 'echo "doctor-node $(node --version 2>/dev/null)" >> "$CAPY_LOG"', - 'echo "required: claude codex patch"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"', + 'echo "required: ${STUB_REQUIRED:-claude codex patch}"', 'echo "PASS claude ok"', 'echo "SKIP browser (lazy)"', '[ -n "${STUB_PINS_SKIP:-}" ] && echo "SKIP pins ($STUB_PINS_SKIP)"', '[ -n "${STUB_DOCTOR_EXTRA:-}" ] && echo "$STUB_DOCTOR_EXTRA"', '[ "${STUB_DOCTOR_FAIL:-}" = 1 ] && { echo "FAIL codex — missing; fix: x (COMPONENT_MISSING; d)"; echo "gstack: fail codex"; exit 1; }', '[ -n "${GSTACK_DOCTOR_RUNTIME_DRIFT:-}" ] && { echo "FAIL runtime — bun resolution drifted: $GSTACK_DOCTOR_RUNTIME_DRIFT; fix: $GSTACK_DOCTOR_RUNTIME_DRIFT_FIX (BUN_RESOLUTION_DRIFT; d)"; echo "gstack: fail runtime"; exit 1; }', 'echo "gstack: ok $(cat "$(dirname "$0")/../VERSION") for=autoplan"', @@ -351,6 +352,36 @@ describe('gstack-capy-install', () => { expect(run(f, ['--for', 'review-voice', '--project', project]).stdout).not.toMatch(upgrading); }); + // #3561: on a shared machine /ship's docs step runs the installer --for document-release, whose + // narrower verdict replaced the --for ship one, so /ship lost admission mid-run. + test('--for ship then --for document-release: the verdict keeps both and admits both workflows; another HEAD replaces it', () => { + const f = fixture(); + const env = { HOME: f.home, GSTACK_HOME: path.join(f.home, '.gstack-state') }; + const admits = (skill: string) => verdictProblem(skill, f.root, env, '', '1.4.2', f.home); + const ship = run(f, ['--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' }); + expect(ship.status, ship.stdout + ship.stderr).toBe(0); + const file = ship.stdout.match(/GSTACK_INSTALL_VERDICT=(\S+)/)![1]!; + expect(admits('ship')).toBeNull(); + const docs = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' }); + expect(docs.status, docs.stdout + docs.stderr).toBe(0); + expect(docs.stdout).toContain(`gstack-capy-install: the install verdict at ${file} keeps for=ship and adds document-release (for=ship,document-release)`); + const v = JSON.parse(fs.readFileSync(file, 'utf8')); + expect(v).toMatchObject({ ok: true, for: 'ship,document-release', pins_planning_only: false }); + expect([...v.required].sort()).toEqual(['claude', 'codex', 'patch', 'pins', 'runtime']); + expect(admits('ship')).toBeNull(); + expect(admits('document-release')).toBeNull(); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual(v); + const again = run(f, ['--check', '--for', 'ship'], { STUB_REQUIRED: 'claude codex patch runtime pins' }); + expect(again.status, again.stdout + again.stderr).toBe(0); + expect(again.stdout).not.toContain('keeps for='); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'ship,document-release' }); + sh('git -c user.email=t@t -c user.name=t commit -q --allow-empty -m next', f.root); + const moved = run(f, ['--for', 'document-release'], { STUB_REQUIRED: 'claude runtime pins' }); + expect(moved.status, moved.stdout + moved.stderr).toBe(0); + expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toMatchObject({ for: 'document-release', required: ['claude', 'runtime', 'pins'] }); + expect(admits('ship')?.detail).toBe('the install verdict certified for=document-release, which does not cover codex that ship needs'); + }); + test('--root clones when the directory is absent and refuses a non-gstack directory', () => { const f = fixture(); const clone = path.join(f.base, 'clone'); From f4f16e83e06ebdb7ab9337fbee64af4e925221b6 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 21:22:41 -0700 Subject: [PATCH 04/19] v1.91.305.0 feat: render gstack per model with modules left out, measured before any model gets less (GSTA-140 PR 1a) (#3554) * feat(modules): module catalog, per-model loadouts manifest and gstack-modules render (GSTA-140 PR 1a) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(modules): cover bin/gstack-modules and the loadouts schema; allow modules/ in pregate strays Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(modules): gen:skill-docs refuses an exclusion render without --out-dir; render tests set up their own arm Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(modules): skill exclusions honor pinned sections and section references; own-property loadout lookups; llms.txt follows the selection; ownership rechecked before swap Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: bump version and changelog (v1.91.296.0) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: loadout render cost and the modules/ directory in PROJECT_STRUCTURE Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: unstamp #3554 (slot calls stamp); CHANGELOG entry back under [Unreleased] 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> --- .gstack/pregate.json | 2 +- CHANGELOG.md | 17 ++ CONTRIBUTING.md | 1 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-modules | 8 + docs/PROJECT_STRUCTURE.md | 1 + docs/loadouts.md | 40 ++++ docs/troubleshooting.md | 44 ++++ modules/loadouts.json | 62 +++++ modules/loadouts.schema.json | 159 +++++++++++++ office-hours/SKILL.md.tmpl | 8 +- package.json | 2 +- plan-design-review/SKILL.md.tmpl | 4 +- scripts/gen-llms-txt.ts | 3 + scripts/gen-skill-docs.ts | 39 ++- scripts/modules-cli.ts | 86 +++++++ scripts/modules-render.ts | 176 ++++++++++++++ scripts/modules.ts | 236 +++++++++++++++++++ scripts/resolvers/model-overlay.ts | 7 +- scripts/resolvers/preamble.ts | 89 ++++--- scripts/resolvers/sections.ts | 20 +- scripts/resolvers/types.ts | 15 ++ test/fixtures/module-size-ratchet.json | 3 + test/gen-skill-docs-prune-stale.test.ts | 2 +- test/module-loadouts.test.ts | 301 ++++++++++++++++++++++++ 26 files changed, 1272 insertions(+), 57 deletions(-) create mode 100755 bin/gstack-modules create mode 100644 docs/loadouts.md create mode 100644 modules/loadouts.json create mode 100644 modules/loadouts.schema.json create mode 100644 scripts/modules-cli.ts create mode 100644 scripts/modules-render.ts create mode 100644 scripts/modules.ts create mode 100644 test/module-loadouts.test.ts diff --git a/.gstack/pregate.json b/.gstack/pregate.json index f9cb2832c..0454e74f3 100644 --- a/.gstack/pregate.json +++ b/.gstack/pregate.json @@ -20,5 +20,5 @@ "windows-free-tests.yml/windows-free-shard": "bun run scripts/test-free-shards.ts --windows-only --list" }, "warn": [], - "strays": { "allow": [] } + "strays": { "allow": ["modules/loadouts.json", "modules/loadouts.schema.json"] } } diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cfab56f..054f7ea5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [1.91.305.0] - 2026-10-29 + +**You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it. + +### Added +- **`bin/gstack-modules`**: `list` prints every module id (`skill:`, `section:`, `overlay:`, `preamble:`) with its source and pin; `validate` checks `modules/loadouts.json`; `render --host (--loadout | --modules full,-,+) --out-dir ` renders one loadout with the same `--model`, `--install-root` and `--plan-mode` setup uses, and writes `loadout.lock.json` (spec sha256, excluded ids, per-file sha256 and bytes). +- **`modules/loadouts.json`** (with a JSON schema): rungs `frontier`, `strong`, `mid`, `light`, the model-id table, pinned modules and named loadouts. Every rung and `unknown` map to `full`. +- **Safety pins**: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade` can never be excluded, directly or by excluding the skill that holds them (`MODULE_PINNED`). +- **Renders refuse instead of guessing**: an unknown loadout or module, a malformed spec, a rendered file that still points at an excluded skill or section (`MODULE_EXCLUSION_UNSAFE`), or an `--out-dir` that an earlier render did not write (`RENDER_DEST_NOT_OWNED`) each exit 2 with a fix line and a `docs/troubleshooting.md` anchor, and never fall back to `full`. A failed render leaves the earlier one in place. +- **`docs/loadouts.md`**: terms, commands, how to build an eval arm, and how a loadout changes (only with a cited GSTA-2 run). + +### Changed +- `gen:skill-docs` accepts `--loadout` and `--modules` with an `--out-dir` outside the checkout; a selection with exclusions aimed at the committed tree is refused. Excluded sections render a one-line `Not loaded in the loadout` stub, and `{{IF_SECTION:}}` guards remove skeleton text that belongs to a section (office-hours' second-opinion offer and visual-design phase, plan-design-review's review-sections self-check). `gstack/llms.txt` lists only the skills a render includes. + +### For contributors +- New `test/module-loadouts.test.ts` covers the manifest, selection, pins, the render filter, the CLI and the byte-identical `full` render. `.gstack/pregate.json` allows the two new `modules/` files as strays. `test/gen-skill-docs-prune-stale.test.ts`'s copied fixture now links `scripts/modules.ts`; no assertion, threshold or budget changed. + ## [1.91.304.0] - 2026-10-29 **Running the Capy installer for a second workflow on the same machine no longer takes away the first one's admission.** /ship's docs step runs `scripts/install.sh --for document-release`, and that run replaced the `--for ship` install verdict with its narrower one, so the rest of /ship refused with "the install verdict certified for=document-release, which does not cover codex that ship needs" until someone reran `--for ship` (#3561, seen on gstack-evals#13). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f3ba07335..d2c84855a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -205,6 +205,7 @@ the new defaults. | Validate periodic cases on a branch in CI | `gh workflow run evals-periodic.yml --ref ` (periodic lane only) | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Validate periodic cases plus the weekly gate census on a branch | `gh workflow run evals-periodic.yml --ref -f include_gate_census=true` | CI runners + API spend | Pushed branch, `gh` with workflow rights | | Run the opt-in ML, gitleaks and Swift checks | `gh workflow run platform-qualification.yml --ref ` | Free CI runners | Pushed branch, `gh` with workflow rights | +| Render one module loadout as an eval arm ([loadouts](docs/loadouts.md)) | `bin/gstack-modules render --host claude --modules full,-section:/ --out-dir ` (`--loadout full` for the control) | Free, about a second per host | Bun 1.4.2 | | Look at past local eval runs | `bun run eval:list` / `eval:compare` / `eval:summary` | Free | Local eval history | | See a case's pass rate across recent weekly runs | `bun run eval:pass-rates --case ` | Free | `gh` with repo read access | | Measure one red case alone against the ship bar (MEETS 9/10) | `bun run scripts/ship-measure.ts measure --case --round baseline` ([bar](docs/TESTING_INTERNALS.md#ship-measure)) | API spend for 10 trials (12 for behavior); asks above $2/trial, stops at $25/case | Same as paid coverage | diff --git a/VERSION b/VERSION index d38dd36b0..70487a8f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.304.0 +1.91.305.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 0196bed74..17ae3b266 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.304.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.305.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-modules b/bin/gstack-modules new file mode 100755 index 000000000..fa3f98d8d --- /dev/null +++ b/bin/gstack-modules @@ -0,0 +1,8 @@ +#!/usr/bin/env -S bun --no-env-file --config=/dev/null +import { modulesMain } from '../scripts/modules-cli.ts'; + +void modulesMain(process.argv.slice(2)).then(result => { + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + process.exitCode = result.code; +}); diff --git a/docs/PROJECT_STRUCTURE.md b/docs/PROJECT_STRUCTURE.md index 4b0e01ca4..6c99766c2 100644 --- a/docs/PROJECT_STRUCTURE.md +++ b/docs/PROJECT_STRUCTURE.md @@ -92,6 +92,7 @@ gstack/ │ ├── test/ # Integration tests │ └── dist/ # Compiled binary ├── agents-digest/ # Committed 2KB instruction-tier rules digest (gstack-AGENTS.md) for rules-reading hosts +├── modules/ # loadouts.json (+ loadouts.schema.json): model rungs, pinned modules and named loadouts; read by scripts/modules.ts, rendered by bin/gstack-modules (docs/loadouts.md) ├── extension/ # Chrome extension (side panel + activity feed + CSS inspector) ├── lib/ # Shared libraries (aside-render.ts = local-HTML rendering, Aside first, engine fallback; design-catalog.ts = the typed design anti-pattern catalog every design skill renders from; design-lint/ = the static and render tiers behind bin/gstack-design-lint, ported from hallmark's sloplint; design-detect-contract.ts = detector sentinel vocabulary; design-md.ts = open DESIGN.md reader/writer; dom-dump-script.ts + generated dom-dump.js = rendered-DOM dump for the detector; review-evidence.ts = review-start receipt binding, computed freshness and shared-code snapshot eligibility; frontend-scope.ts; claude-bin.ts, error-handling.ts, worktree.ts, egress-receipt.ts, context-bill.ts, redact-engine.ts, tracker-guard.ts, version-source.ts, code-intelligence/) │ └── diagram-render/ # Vendored mermaid + excalidraw runtimes, built into one offline bundle the renderer loads diff --git a/docs/loadouts.md b/docs/loadouts.md new file mode 100644 index 000000000..f065842d5 --- /dev/null +++ b/docs/loadouts.md @@ -0,0 +1,40 @@ +# Module loadouts + +gstack can render a smaller or larger set of its instructions for a given model. Each toggleable piece is a **module** with an id. A **loadout** is a named set of modules, and `modules/loadouts.json` maps each model **rung** to a loadout. Today every rung loads `full`, which is the same render gstack has always shipped. A loadout changes only when the shared skill eval (GSTA-2) measures a smaller one that matches `full` within its preregistered margin. + +Install-time model detection, which picks a loadout for the model you run, ships with the first loadout that is not `full`. Until then, loadouts are an eval tool: `bin/gstack-modules render` produces any loadout as a byte-exact tree with a lock file. + +## Terms + +| Term | Meaning | +|---|---| +| Module | One of four kinds. `skill:` is a whole skill. `section:/` is one entry of `/sections/manifest.json`. `overlay:` is one `model-overlays/.md`; excluding a base overlay also removes the base's text from every overlay that inherits it; the inheriting overlay's own text stays. `preamble:` is one piece of the shared preamble (`PREAMBLE_PIECES` in `scripts/resolvers/preamble.ts`). | +| Pinned | A module no loadout can remove where `full` renders it: the start command, plan-mode rules, the question format, completion status, the unattended profile, /ship's review army and adversarial pass, the router and `/gstack-upgrade`. A skill's `preamble-tier` still applies, so a tier-1 skill has no question-format section in any loadout. | +| Rung | How much scaffolding a model gets: `frontier`, `strong`, `mid`, `light`, or `unknown`. Model ids are matched after lowercasing and stripping a `provider/` prefix (`anthropic/claude-haiku-5-5` is `mid`). Rungs are separate from the frontier/smart tiers in `lib/model-catalog.ts`, which choose which model runs a role. | +| Loadout | `{ base, exclude, include, preamble_tier_max, note }`. `full` (include `*`) is always present. An unknown model gets `default.unknown`, which is `full`. | + +## Commands + +```bash +bin/gstack-modules list [--json] [--pinned] # every module id, kind, source, pin, source bytes and sha256 +bin/gstack-modules validate # check modules/loadouts.json against the catalog +bin/gstack-modules render --host claude --loadout full --out-dir ~/arms/control +bin/gstack-modules render --host claude \ + --modules 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design' \ + --out-dir ~/arms/oh-lean +``` + +`render` writes the whole host tree into a staging directory beside `--out-dir`. It checks that no rendered file still points at an excluded module, writes `loadout.lock.json` last (loadout name or `adhoc`, the normalized spec and its sha256, host, model, every excluded id, and the sha256 and byte count of every file), then swaps the result into place. A failure leaves an earlier render at `--out-dir` untouched. Claude section links name `--out-dir`, so point the eval harness at that directory. Pass the same `--model`, `--install-root` and `--plan-mode` the install under test uses, so the arms differ only by the modules. Codex renders default to the host's generic `gpt` overlay, while `./setup` uses `gpt-6-astra`. + +`bun run gen:skill-docs --loadout --out-dir ` and `--modules --out-dir ` apply the same filter to a generation run without the staging, dangling-reference check or lock file; a selection with exclusions and no `--out-dir` is refused with `MODULE_SPEC_INVALID`, because the committed tree is always the `full` render. A non-`full` render adds `` to each SKILL.md header. + +## Building an eval arm + +1. Render the control with `--loadout full` and each arm with `--modules full,-,…`. Every refusal names its code and fix ([troubleshooting](troubleshooting.md#module-loadouts-gstack-modules)). +2. Preregister the lock files' `spec_sha256` and per-file sha256 values with the eval. +3. Excluding a section removes its STOP pointer, its Section-index row and its file. The step becomes `> Not loaded in the loadout: skip . Do not offer it, and treat it as not run.` Skeleton text that belongs to the section, such as office-hours' Phase 3.5 offer, sits inside `{{IF_SECTION:}}…{{/IF_SECTION}}` in the template and goes with it. +4. The autoplan runner (`gstack-autoplan`) reads review methodology from the source tree, not from a rendered loadout, so autoplan-runner arms always measure `full` methodology. + +## Changing a loadout + +Add or edit an entry under `loadouts` and cite the GSTA-2 run (preregistration, margin, per-rung result) in its `note` and in the PR body. A model-specific loadout that wins a preregistered comparison is allowed even if it breaks the expected nesting (light ⊇ mid ⊇ strong). Committed SKILL.md files are always the `full` render, so the parity suite, the SKILL.md size guard, the size ledger and the catalog and context budgets keep measuring `full`. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ffa23542..353f367b4 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -5141,3 +5141,47 @@ QM's admin API refused or did not answer. gstack lists packs before it creates o QM registration does not work yet. QM's skill-pack import clones the whole repository and reads every file before it picks the skills, and by default it refuses a pack over 32 MiB or 5,000 files. The gstack repository is larger (about 73 MiB in 4,300 files), so gstack checks the size first and sends nothing. When QM itself refuses (a QM with other limits), the pack it created stays in the org without skills: `gstack-harness unregister qm --remote` archives it. Meanwhile, give a QM agent gstack by pasting the output of `gstack-harness register generic --print` into its instructions. + +## Module loadouts (gstack-modules) + +These codes come from `bin/gstack-modules` and `gen-skill-docs --loadout/--modules` (guide: [loadouts.md](loadouts.md)). Each one exits 2 before anything is written, and none falls back to the `full` loadout: an eval arm that silently rendered `full` would measure the control. + + +### `LOADOUTS_INVALID: modules/loadouts.json …` + +**Meaning.** `modules/loadouts.json` is unreadable, or names a module, base loadout, rung or default that does not exist, excludes a pinned module, or maps one model id to two rungs. **Fix.** Correct the named entries, then run `bin/gstack-modules validate` until it prints `LOADOUTS: ok`; `git checkout -- modules/loadouts.json` restores the committed file. + + +### `LOADOUT_UNKNOWN: "" is not a loadout (valid: …)` + +**Meaning.** `--loadout` or the first token of `--modules` names no loadout in `modules/loadouts.json`. **Fix.** Use a name from the `valid:` list (`bin/gstack-modules list` prints them); `full` is always present. + + +### `MODULE_SPEC_INVALID: …` + +**Meaning.** A `--modules` token is not `-` or `+`, both `--loadout` and `--modules` were given, `gen:skill-docs` got a selection with exclusions but no `--out-dir`, or a render flag is missing or malformed. **Fix.** Write the spec as `,-,+` (for example `full,-section:office-hours/second-opinion`) and pass the flags `bin/gstack-modules --help` lists. + + +### `MODULE_UNKNOWN: "" is not a module` + +**Meaning.** The id names no skill, section, overlay or preamble piece. **Fix.** Copy the id from `bin/gstack-modules list`. + + +### `MODULE_PINNED: is pinned and can never be excluded` + +**Meaning.** The spec removes a module the safety contract needs: the start command (`preamble:bash`), plan-mode rules, the question format, completion status, the unattended profile, /ship's review gates, the router or `/gstack-upgrade`. **Fix.** Drop `-` from the spec; `bin/gstack-modules list --pinned` prints every pinned id. + + +### `MODULE_EXCLUSION_UNSAFE: : referenced by :` + +**Meaning.** The rendered tree still points at a module the loadout left out, so an agent following it would read a file that is not there. **Fix.** Exclude the referencing module as well, or wrap the referencing skeleton text in `{{IF_SECTION:}}…{{/IF_SECTION}}` in its template (the text renders unchanged when the section loads). Nothing was published; rerun after the change. + + +### `RENDER_FAILED: gen-skill-docs failed: …` + +**Meaning.** Generation itself failed for this loadout (an unresolved placeholder, an oversized skill, a missing section). **Fix.** Fix the named template or module and rerun. The staging directory was removed and `--out-dir` is unchanged. + + +### `RENDER_DEST_NOT_OWNED: exists and holds no loadout.lock.json` + +**Meaning.** `--out-dir` is a non-empty directory that an earlier `gstack-modules render` did not write, so replacing it could delete someone's files. **Fix.** Pass a new or empty directory, or the directory of an earlier render. diff --git a/modules/loadouts.json b/modules/loadouts.json new file mode 100644 index 000000000..9208dfa47 --- /dev/null +++ b/modules/loadouts.json @@ -0,0 +1,62 @@ +{ + "$schema": "./loadouts.schema.json", + "version": 1, + "note": "Per-model loadouts (GSTA-140). Every rung loads full until a GSTA-2 measurement shows a smaller loadout matches full within its preregistered margin; each change cites that run in the loadout's note. Module ids: bin/gstack-modules list. Guide: docs/loadouts.md.", + "aliases": { + "opus": "strong", + "sonnet": "strong", + "haiku": "mid" + }, + "rungs": { + "frontier": [ + "claude-fable-5*", + "gpt-6-astra*" + ], + "strong": [ + "claude-opus-5*", + "claude-sonnet-5*", + "gpt-6.1-sol*", + "gpt-6-sol*", + "gpt-5.6-sol*", + "gemini-4-argon*" + ], + "mid": [ + "claude-haiku-5*", + "glm-5.3", + "gpt-6.1-terra*", + "gemini-3.8-flash*", + "grok-4*", + "kimi-k3*", + "deepseek-v4-pro*", + "qwen3.8-max*" + ], + "light": [ + "deepseek-v4.1-flash*", + "glm-5.3-flash*", + "gpt-6-luna*", + "qwen3.8-flash*", + "minimax-m3*" + ] + }, + "pinned": [ + "preamble:plan-mode-info", + "preamble:ask-user-format", + "preamble:completion-status", + "section:autoplan/unattended", + "section:ship/review-army", + "section:ship/adversarial" + ], + "loadouts": { + "full": { + "include": "*", + "note": "Every module: today's render." + } + }, + "default": { + "frontier": "full", + "strong": "full", + "mid": "full", + "light": "full", + "unknown": "full" + } +} diff --git a/modules/loadouts.schema.json b/modules/loadouts.schema.json new file mode 100644 index 000000000..75024a25c --- /dev/null +++ b/modules/loadouts.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "gstack module loadouts", + "description": "Rungs, the model-id to rung table, pinned module ids and named loadouts. Validated by scripts/modules.ts validateLoadouts (bin/gstack-modules validate, test/module-loadouts.test.ts).", + "type": "object", + "required": [ + "version", + "aliases", + "rungs", + "pinned", + "loadouts", + "default" + ], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "const": 1 + }, + "note": { + "type": "string" + }, + "aliases": { + "type": "object", + "additionalProperties": { + "enum": [ + "frontier", + "strong", + "mid", + "light" + ] + } + }, + "rungs": { + "type": "object", + "additionalProperties": false, + "required": [ + "frontier", + "strong", + "mid", + "light" + ], + "properties": { + "frontier": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + }, + "strong": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + }, + "mid": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + }, + "light": { + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9.-]+\\*?$" + } + } + } + }, + "pinned": { + "type": "array", + "items": { + "type": "string" + } + }, + "loadouts": { + "type": "object", + "required": [ + "full" + ], + "propertyNames": { + "pattern": "^[a-z0-9-]+$" + }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "base": { + "type": "string" + }, + "include": { + "oneOf": [ + { + "const": "*" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + } + }, + "preamble_tier_max": { + "enum": [ + 1, + 2, + 3, + 4 + ] + }, + "note": { + "type": "string" + } + } + } + }, + "default": { + "type": "object", + "additionalProperties": false, + "required": [ + "frontier", + "strong", + "mid", + "light", + "unknown" + ], + "properties": { + "frontier": { + "type": "string" + }, + "strong": { + "type": "string" + }, + "mid": { + "type": "string" + }, + "light": { + "type": "string" + }, + "unknown": { + "type": "string" + } + } + } + } +} diff --git a/office-hours/SKILL.md.tmpl b/office-hours/SKILL.md.tmpl index 7cda00564..de7f761c0 100644 --- a/office-hours/SKILL.md.tmpl +++ b/office-hours/SKILL.md.tmpl @@ -228,13 +228,13 @@ Use AskUserQuestion to confirm. If the user disagrees with a premise, revise und --- -## Phase 3.5: Cross-Model Second Opinion (optional) +{{IF_SECTION:second-opinion}}## Phase 3.5: Cross-Model Second Opinion (optional) Use AskUserQuestion: "Want a second opinion from an independent AI perspective? It reviews your problem statement, key answers, premises and landscape findings from a structured summary, without this conversation. Usually 2-5 minutes." A) Yes, get a second opinion. B) No, proceed to alternatives. If B: skip Phase 3.5 and remember the second opinion did NOT run (the design doc, founder signals and Phase 4 depend on it). If A: -{{SECTION:second-opinion}} +{{/IF_SECTION}}{{SECTION:second-opinion}} --- @@ -274,10 +274,10 @@ Emit ONE AskUserQuestion that lists every alternative (A/B and optionally C) as --- -## Phase 4.25: Visual design (UI ideas only) +{{IF_SECTION:visual-design}}## Phase 4.25: Visual design (UI ideas only) If the approach approved in Phase 4 has no user-facing UI (screens, pages, forms, dashboards, interactive elements), skip to Phase 4.5. -{{SECTION:visual-design}} +{{/IF_SECTION}}{{SECTION:visual-design}} --- diff --git a/package.json b/package.json index da4c43471..66271567f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.304", + "version": "1.91.305", "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/plan-design-review/SKILL.md.tmpl b/plan-design-review/SKILL.md.tmpl index b9258d176..52ce41d51 100644 --- a/plan-design-review/SKILL.md.tmpl +++ b/plan-design-review/SKILL.md.tmpl @@ -288,10 +288,10 @@ Exit 0: show the image at the printed `outputPath` via the Read tool. Exit 2 or {{SECTION:review-sections}} -## Section self-check (before you finish) +{{IF_SECTION:review-sections}}## Section self-check (before you finish) Confirm you Read the review section the Section index named, and executed all 7 design passes, the required outputs, and the review report in full. If you produced findings or the review report from memory without Reading `sections/review-sections.md`, stop and Read it now. -Before summaries, review logs or next-step menus, run approval check 0 below. +{{/IF_SECTION}}Before summaries, review logs or next-step menus, run approval check 0 below. {{EXIT_PLAN_MODE_GATE}} diff --git a/scripts/gen-llms-txt.ts b/scripts/gen-llms-txt.ts index cb8766f7b..176e720f6 100644 --- a/scripts/gen-llms-txt.ts +++ b/scripts/gen-llms-txt.ts @@ -125,6 +125,8 @@ interface GenerateOptions { root?: string; /** When true, missing skill description should fail the build. */ strict?: boolean; + /** Skill directories a module loadout leaves out (scripts/modules.ts). */ + excludedSkills?: string[]; } export interface GenerateResult { @@ -143,6 +145,7 @@ export async function generateLlmsTxt(opts: GenerateOptions = {}): Promise void; planMode?: boolean; + /** Module loadout (scripts/modules.ts selectModules); null/undefined = `full`. */ + modules?: ModuleFilter | null; } interface RenderOptions { @@ -66,6 +70,7 @@ interface RenderOptions { explainLevel: 'default' | 'terse'; gbrainDetected: boolean; planMode: boolean; + modules: ModuleFilter | null; } export interface GeneratedArtifact { @@ -144,6 +149,10 @@ function parseGenerationArgs(args: string[]): GenerationOptions { const disabledSkills = (disabledValue ?? '').split(/[\s,]+/).filter(Boolean); const badDisabled = disabledSkills.find(n => !/^\/?[a-z0-9-]+$/.test(n)); if (badDisabled) throw new Error(`--disabled-skills takes skill names (got ${JSON.stringify(badDisabled)})`); + const modules = selectModules({ loadout: value('--loadout'), modules: value('--modules') }); + if (modules && (outDir === undefined || path.resolve(outDir) === ROOT)) { + throw new ModuleError('MODULE_SPEC_INVALID', `--${value('--modules') === undefined ? 'loadout' : 'modules'} with exclusions needs an --out-dir outside the checkout root; the committed tree is always the full render`, 'bin/gstack-modules render --host --modules --out-dir '); + } const installRoot = value('--install-root'); if (installRoot !== undefined && !INSTALL_ROOT_PATTERN.test(installRoot)) { throw new Error(`--install-root must be an absolute path of letters, digits and . _ - + @ / (got ${JSON.stringify(installRoot)})`); @@ -159,9 +168,21 @@ function parseGenerationArgs(args: string[]): GenerationOptions { : outDir !== undefined ? path.resolve(outDir) : null, installRoot: installRoot ?? null, disabledSkills, + modules, }; } +/** A section file is left out when its skill or its manifest entry is excluded. */ +function excludesSection(filter: ModuleFilter | null, skillDir: string, tmpl: string): boolean { + if (!filter) return false; + if (filter.excluded.has(`skill:${skillDir}`)) return true; + const file = path.basename(tmpl).replace(/\.tmpl$/, ''); + const manifest = path.join(ROOT, skillDir, 'sections', 'manifest.json'); + if (!fs.existsSync(manifest)) return false; + const entry = (JSON.parse(fs.readFileSync(manifest, 'utf-8')).sections ?? []).find((s: { file: string }) => s.file === file); + return entry ? filter.excluded.has(`section:${skillDir}/${entry.id}`) : false; +} + /** Install roots are spliced into shell lines unquoted, so only plain absolute paths. */ const INSTALL_ROOT_PATTERN = /^\/[A-Za-z0-9_.@+\/-]*$/; @@ -657,7 +678,7 @@ function resolvePlaceholders( // section generation so both paths get the same gbrain-aware behavior. const suppressed = effectiveSuppressedResolvers(hostConfig, options); const onePass = (input: string): string => - input.replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => { + applySectionGuards(input, ctx).replace(/\{\{(\w+(?::[^}]+)?)\}\}/g, (_match, fullKey) => { const parts = fullKey.split(':'); const resolverName = parts[0]; const args = parts.slice(1); @@ -713,6 +734,7 @@ function buildContext( return { skillName, tmplPath, benefitsFrom, host, paths: HOST_PATHS[host], preambleTier, model: options.model ?? getHostConfig(host).defaultModel, interactive, explainLevel: options.explainLevel, installRoot: options.installRoot, planMode: options.planMode, + ...(options.modules ? { modules: options.modules } : {}), }; } @@ -842,7 +864,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions): } // Prepend generated header (after frontmatter) - const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath)); + const header = GENERATED_HEADER.replace('{{SOURCE}}', path.basename(tmplPath)) + (options.modules ? `\n` : ''); const fmEnd = content.indexOf('---', content.indexOf('---') + 3); if (fmEnd !== -1) { const insertAt = content.indexOf('\n', fmEnd) + 1; @@ -859,7 +881,7 @@ function processTemplate(tmplPath: string, host: Host, options: RenderOptions): // --out-dir: repoint section-base paths to the out-dir (no-op otherwise). if (host === 'claude') content = rewriteSectionBase(content, options.contentLinkRoot); - if (skillDir === '' || skillDir === '.') content = omitDisabledSkills(content, options.disabledSkills); + if (skillDir === '' || skillDir === '.') content = omitDisabledSkills(content, [...options.disabledSkills, ...excludedSkillDirs(options.modules)]); content = rewriteInstallRoot(insertRuntimePreludes(rewriteCarvedSectionRefs(content, ctx), ctx), currentHostConfig, options.installRoot); const preamble = hoistsPreamble(ctx) && tmplContent.includes('{{PREAMBLE}}') && !effectiveSuppressedResolvers(currentHostConfig, options).has('PREAMBLE') @@ -958,6 +980,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise {}); @@ -1023,6 +1046,7 @@ export async function runGeneration(settings: GenerationOptions = {}): Promise { } return result.exitCode; } catch (error) { + if (error instanceof ModuleError) { + console.error(`${error.code}: ${error.message}`); + return 2; + } console.error(`ERROR: ${(error as Error).message}`); return 1; } diff --git a/scripts/modules-cli.ts b/scripts/modules-cli.ts new file mode 100644 index 000000000..6ddc3f292 --- /dev/null +++ b/scripts/modules-cli.ts @@ -0,0 +1,86 @@ +/** + * bin/gstack-modules (GSTA-140, PR 1a): the module catalog and loadout renders. + * + * gstack-modules list [--json] [--pinned] + * gstack-modules validate + * gstack-modules render --host (--loadout | --modules ,-id,+id) --out-dir + * [--install-root ] [--model ] [--plan-mode on|off] + * + * Exit 0 on success, 2 on a refused input (ModuleError, printed with its code, + * fix and docs anchor), 1 on an unexpected failure. + */ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolveHostArg } from '../hosts/index'; +import { resolveModel } from './models'; +import { LOADOUTS_FILE, ModuleError, loadLoadouts, moduleCatalog, selectModules, validateLoadouts } from './modules'; +import { renderLoadout } from './modules-render'; +import type { Host } from './resolvers/types'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const USAGE = `Usage: + gstack-modules list [--json] [--pinned] every module id with its kind, source and pin + gstack-modules validate check ${LOADOUTS_FILE} against the catalog + gstack-modules render --host (--loadout | --modules ,-id,+id) --out-dir + [--install-root ] [--model ] [--plan-mode on|off] + render one loadout and write loadout.lock.json +Guide: docs/loadouts.md`; + +export interface CliResult { code: number; stdout: string; stderr: string } + +function flag(args: string[], name: string): string | undefined { + const index = args.findIndex(a => a === name || a.startsWith(`${name}=`)); + if (index < 0) return undefined; + const value = args[index].startsWith(`${name}=`) ? args[index].slice(name.length + 1) : args[index + 1]; + if (!value || value.startsWith('--')) throw new ModuleError('MODULE_SPEC_INVALID', `${name} requires a value`, `gstack-modules --help`); + return value; +} + +export async function modulesMain(args: string[]): Promise { + const [command, ...rest] = args; + try { + if (!command || command === '--help' || command === '-h' || command === 'help') return { code: 0, stdout: USAGE + '\n', stderr: '' }; + if (command === 'list') { + const manifest = loadLoadouts(); + const catalog = moduleCatalog(manifest).filter(e => !rest.includes('--pinned') || e.pinned); + const rows = catalog.map(e => { + const bytes = fs.readFileSync(path.join(ROOT, e.source)); + return { ...e, source_bytes: bytes.length, source_sha256: crypto.createHash('sha256').update(bytes).digest('hex') }; + }); + if (rest.includes('--json')) return { code: 0, stdout: JSON.stringify({ loadouts: Object.keys(manifest.loadouts), modules: rows }, null, 2) + '\n', stderr: '' }; + const lines = rows.map(r => `${r.id.padEnd(48)} ${r.kind.padEnd(8)} ${r.pinned ? 'pinned' : ' '} ${r.source}`); + return { code: 0, stdout: [`loadouts: ${Object.keys(manifest.loadouts).join(', ')}`, ...lines].join('\n') + '\n', stderr: '' }; + } + if (command === 'validate') { + const manifest = loadLoadouts(); + const problems = validateLoadouts(manifest, moduleCatalog(manifest)); + if (problems.length) return { code: 2, stdout: '', stderr: problems.map(p => `LOADOUTS_INVALID: ${p}`).join('\n') + '\n' }; + return { code: 0, stdout: `LOADOUTS: ok (${Object.keys(manifest.loadouts).length} loadouts)\n`, stderr: '' }; + } + if (command === 'render') { + const hostValue = flag(rest, '--host'); + const outDir = flag(rest, '--out-dir'); + if (!hostValue || !outDir) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --host and --out-dir', 'gstack-modules render --host claude --loadout full --out-dir '); + let host: Host; + try { host = resolveHostArg(hostValue) as Host; } catch (error) { throw new ModuleError('MODULE_SPEC_INVALID', (error as Error).message, 'pass a host from docs/hosts.md, for example --host claude'); } + const loadout = flag(rest, '--loadout'); + const modules = flag(rest, '--modules'); + if (loadout === undefined && modules === undefined) throw new ModuleError('MODULE_SPEC_INVALID', 'render needs --loadout or --modules', 'add --loadout full for the control arm'); + const modelValue = flag(rest, '--model'); + const model = modelValue === undefined ? null : resolveModel(modelValue); + if (modelValue !== undefined && !model) throw new ModuleError('MODULE_SPEC_INVALID', `--model ${JSON.stringify(modelValue.slice(0, 80))} is not a model gstack knows`, 'pass the same --model setup uses, for example gpt-6-astra'); + const planMode = flag(rest, '--plan-mode'); + if (planMode !== undefined && planMode !== 'on' && planMode !== 'off') throw new ModuleError('MODULE_SPEC_INVALID', '--plan-mode takes on or off', 'drop it, or pass --plan-mode off'); + const installRoot = flag(rest, '--install-root'); + if (installRoot !== undefined && !/^\/[A-Za-z0-9_.@+\/-]*$/.test(installRoot)) throw new ModuleError('MODULE_SPEC_INVALID', '--install-root must be a plain absolute path', 'pass the absolute install root setup would use'); + const selection = selectModules({ loadout, modules }); + const lock = await renderLoadout({ host, selection, outDir, installRoot: installRoot ?? null, model, planMode: planMode !== 'off' }); + return { code: 0, stdout: `RENDERED: loadout=${lock.loadout} host=${host} files=${lock.files.length} bytes=${lock.total_bytes} excluded=${lock.excluded.length} lock=${path.join(path.resolve(outDir), 'loadout.lock.json')}\n`, stderr: '' }; + } + return { code: 2, stdout: '', stderr: `Unknown command ${JSON.stringify(command.slice(0, 40))}.\n${USAGE}\n` }; + } catch (error) { + if (error instanceof ModuleError) return { code: 2, stdout: '', stderr: `${error.code}: ${error.message}\n` }; + return { code: 1, stdout: '', stderr: `gstack-modules: unexpected failure: ${(error as Error).message}\n` }; + } +} diff --git a/scripts/modules-render.ts b/scripts/modules-render.ts new file mode 100644 index 000000000..f12164960 --- /dev/null +++ b/scripts/modules-render.ts @@ -0,0 +1,176 @@ +/** + * gstack-modules render (GSTA-140, PR 1a): one loadout rendered into a + * directory GSTA-2 can preregister, published only when complete. + * + * Order: render into an owned staging directory beside --out-dir (Claude + * section links already name the final path), refuse dangling references to + * an excluded module, hash every file into loadout.lock.json (written last), + * then swap the staging directory into place. Any failure leaves the previous + * --out-dir untouched and removes the staging directory. + */ +import { createHash, randomBytes } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { runGeneration } from './gen-skill-docs'; +import { ModuleError, type ModuleSelection } from './modules'; +import type { Host } from './resolvers/types'; +import { getHostConfig } from '../hosts/index'; +import type { Model } from './models'; + +export const LOCK_FILE = 'loadout.lock.json'; +const ROOT = path.resolve(import.meta.dir, '..'); + +export interface RenderRequest { + host: Host; + /** null = the full loadout. */ + selection: ModuleSelection | null; + outDir: string; + installRoot?: string | null; + model?: Model | null; + planMode?: boolean; +} + +export interface LoadoutLock { + schema_version: 1; + loadout: string; + spec: string; + spec_sha256: string | null; + host: Host; + model: string | null; + install_root: string | null; + plan_mode: boolean; + gstack_version: string; + excluded: string[]; + total_bytes: number; + files: Array<{ path: string; bytes: number; sha256: string }>; +} + +function walk(dir: string, base = dir): string[] { + return fs.readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) return walk(full, base); + return entry.isFile() ? [path.relative(base, full).split(path.sep).join('/')] : []; + }).sort(); +} + +/** + * References an excluded module leaves behind in the rendered text. A section + * counts as referenced by: its absolute path (`//sections/`, any + * file, so resolver-inlined cross-skill pointers count), an installed-relative + * pointer naming its skill, or, inside its own skill's directory, a bare + * `sections/` or the inline-host "section inlined" phrase. An excluded + * skill counts as referenced by another skill naming its SKILL.md or one of + * its section files. + */ +export function danglingReferences(stage: string, selection: ModuleSelection): string[] { + const checks: Array<{ id: string; scope: string | null; test: (line: string) => boolean }> = []; + for (const id of selection.excluded) { + if (id.startsWith('section:')) { + const [skill, sectionId] = id.slice('section:'.length).split('/'); + const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, skill, 'sections', 'manifest.json'), 'utf-8')); + const file = (manifest.sections as Array<{ id: string; file: string }>).find(entry => entry.id === sectionId)!.file; + const stem = file.replace(/\.md$/, ''); + checks.push( + { id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) }, + { id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) }, + { id, scope: skill, test: line => line.includes(`sections/${file}`) || line.includes(`the \`${stem}\` section inlined`) }, + ); + } else if (id.startsWith('skill:')) { + const skill = id.slice('skill:'.length); + checks.push({ id, scope: null, test: line => line.includes(`/${skill}/SKILL.md`) || line.includes(`gstack-${skill}/SKILL.md`) }); + const manifestPath = path.join(ROOT, skill, 'sections', 'manifest.json'); + if (fs.existsSync(manifestPath)) { + for (const { file } of JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections as Array<{ file: string }>) { + checks.push( + { id, scope: null, test: line => line.includes(`/${skill}/sections/${file}`) }, + { id, scope: null, test: line => line.includes(`sections/${file}\` relative to the installed`) && line.includes(`${skill}\``) }, + ); + } + } + } + } + if (!checks.length) return []; + const found = new Set(); + for (const rel of walk(stage)) { + if (!rel.endsWith('.md')) continue; + const dirs = rel.split('/'); + const lines = fs.readFileSync(path.join(stage, rel), 'utf-8').split('\n'); + lines.forEach((line, index) => { + for (const check of checks) { + if (check.scope && !dirs.includes(check.scope) && !dirs.includes(`gstack-${check.scope}`)) continue; + if (check.test(line)) found.add(`${check.id}: referenced by ${rel}:${index + 1}`); + } + }); + } + return [...found]; +} + +function owned(dir: string): boolean { + if (!fs.existsSync(dir)) return true; + return fs.readdirSync(dir).length === 0 || fs.existsSync(path.join(dir, LOCK_FILE)); +} + +export async function renderLoadout(request: RenderRequest): Promise { + const final = path.resolve(request.outDir); + if (!owned(final)) { + throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} exists and holds no ${LOCK_FILE}, so it is not a gstack-modules render`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote'); + } + const tag = `${process.pid}-${randomBytes(4).toString('hex')}`; + const stage = `${final}.stage-${tag}`; + const retired = `${final}.old-${tag}`; + fs.mkdirSync(path.dirname(final), { recursive: true }); + try { + const result = await runGeneration({ + host: request.host, + outputRoot: stage, + contentLinkRoot: final, + installRoot: request.installRoot ?? null, + model: request.model ?? null, + planMode: request.planMode ?? true, + modules: request.selection, + }); + const errors = result.diagnostics.filter(d => d.kind === 'error').map(d => d.message); + if (result.exitCode !== 0 || errors.length) { + throw new ModuleError('RENDER_FAILED', `gen-skill-docs failed: ${errors.join('; ') || 'exit 1'}`, 'fix the named template or module, then rerun; side effects: none, safe to retry'); + } + if (request.selection) { + const dangling = danglingReferences(stage, request.selection); + if (dangling.length) { + throw new ModuleError('MODULE_EXCLUSION_UNSAFE', dangling.slice(0, 5).join('; ') + (dangling.length > 5 ? ` (+${dangling.length - 5} more)` : ''), 'exclude the referencing module too, or guard the reference with {{IF_SECTION:id}} in its template; side effects: none, safe to retry'); + } + } + const files = walk(stage).map(rel => { + const bytes = fs.readFileSync(path.join(stage, rel)); + return { path: rel, bytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; + }); + const lock: LoadoutLock = { + schema_version: 1, + loadout: request.selection?.name ?? 'full', + spec: request.selection?.spec ?? 'full', + spec_sha256: request.selection?.specSha256 ?? null, + host: request.host, + model: request.model ?? getHostConfig(request.host).defaultModel ?? null, + install_root: request.installRoot ?? null, + plan_mode: request.planMode ?? true, + gstack_version: fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf-8').trim(), + excluded: [...(request.selection?.excluded ?? [])].sort(), + total_bytes: files.reduce((sum, f) => sum + f.bytes, 0), + files, + }; + fs.writeFileSync(path.join(stage, LOCK_FILE), JSON.stringify(lock, null, 2) + '\n'); + if (!owned(final)) { + throw new ModuleError('RENDER_DEST_NOT_OWNED', `${final} changed during the render and now holds no ${LOCK_FILE}`, 'pass an empty or new --out-dir, or one an earlier gstack-modules render wrote'); + } + if (fs.existsSync(final)) fs.renameSync(final, retired); + try { + fs.renameSync(stage, final); + } catch (error) { + if (fs.existsSync(retired)) fs.renameSync(retired, final); + throw error; + } + fs.rmSync(retired, { recursive: true, force: true }); + return lock; + } finally { + fs.rmSync(stage, { recursive: true, force: true }); + } +} diff --git a/scripts/modules.ts b/scripts/modules.ts new file mode 100644 index 000000000..8d0131340 --- /dev/null +++ b/scripts/modules.ts @@ -0,0 +1,236 @@ +/** + * Module loadouts (GSTA-140, PR 1a): the toggleable units of gstack and the + * named sets of them a render can load. + * + * A module is a skill (`skill:`), one carved section + * (`section:/`, from /sections/manifest.json), one model + * overlay (`overlay:`, from model-overlays/) or one preamble piece + * (`preamble:`, from PREAMBLE_PIECES). The catalog is derived from those + * sources; modules/loadouts.json is the only hand-written file and holds the + * rungs, the model-id to rung table, the pinned ids and the loadouts. + * + * Every error is a ModuleError with a stable code, a fix line and a + * docs/troubleshooting.md anchor. Nothing here ever falls back to `full` on + * bad input: an eval arm that silently rendered `full` would measure the + * control. + */ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { discoverTemplates } from './discover-skills'; +import { PREAMBLE_MODULE_IDS } from './resolvers/preamble'; +import type { ModuleFilter } from './resolvers/types'; + +const ROOT = path.resolve(import.meta.dir, '..'); +export const LOADOUTS_FILE = 'modules/loadouts.json'; +export const RUNGS = ['frontier', 'strong', 'mid', 'light'] as const; +export type Rung = (typeof RUNGS)[number] | 'unknown'; +export const FULL = 'full'; +/** The router and the way back can never be left out (same rule as --disabled-skills). */ +export const IMPLICIT_PINS = ['skill:gstack', 'skill:gstack-upgrade', 'preamble:bash']; +const NAME_RE = /^[a-z0-9-]+$/; + +export type ModuleKind = 'skill' | 'section' | 'overlay' | 'preamble'; +export interface ModuleEntry { + id: string; + kind: ModuleKind; + /** Repository-relative source file. */ + source: string; + pinned: boolean; +} + +export interface LoadoutDef { + base?: string; + include?: '*' | string[]; + exclude?: string[]; + preamble_tier_max?: number; + note?: string; +} + +export interface LoadoutsManifest { + version: 1; + aliases: Record; + rungs: Record<(typeof RUNGS)[number], string[]>; + pinned: string[]; + loadouts: Record; + default: Record; +} + +export type ModuleErrorCode = + | 'LOADOUTS_INVALID' + | 'LOADOUT_UNKNOWN' + | 'MODULE_SPEC_INVALID' + | 'MODULE_UNKNOWN' + | 'MODULE_PINNED' + | 'MODULE_EXCLUSION_UNSAFE' + | 'RENDER_FAILED' + | 'RENDER_DEST_NOT_OWNED'; + +export class ModuleError extends Error { + constructor(readonly code: ModuleErrorCode, problem: string, readonly fix: string) { + super(`${problem}; fix: ${fix} (${code}; docs/troubleshooting.md#${code.toLowerCase().replaceAll('_', '-')})`); + } +} + +const clamp = (value: string) => JSON.stringify(value.replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 120)); + +export function loadLoadouts(root = ROOT): LoadoutsManifest { + const file = path.join(root, LOADOUTS_FILE); + try { + return JSON.parse(fs.readFileSync(file, 'utf-8')) as LoadoutsManifest; + } catch (error) { + throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE} is unreadable or not JSON (${(error as Error).message})`, `restore it with git checkout -- ${LOADOUTS_FILE}`); + } +} + +/** Every module id with its kind and source, derived from the files that define them. */ +export function moduleCatalog(manifest: LoadoutsManifest, root = ROOT): ModuleEntry[] { + const pinned = new Set([...manifest.pinned, ...IMPLICIT_PINS]); + const entries: Omit[] = []; + for (const { tmpl } of discoverTemplates(root)) { + const dir = path.dirname(tmpl); + entries.push({ id: `skill:${dir === '.' ? 'gstack' : dir}`, kind: 'skill', source: tmpl }); + const manifestPath = path.join(root, dir, 'sections', 'manifest.json'); + if (dir === '.' || !fs.existsSync(manifestPath)) continue; + const sections = (JSON.parse(fs.readFileSync(manifestPath, 'utf-8')).sections ?? []) as Array<{ id: string; file: string }>; + for (const s of sections) entries.push({ id: `section:${dir}/${s.id}`, kind: 'section', source: `${dir}/sections/${s.file}.tmpl` }); + } + for (const file of fs.readdirSync(path.join(root, 'model-overlays')).filter(f => f.endsWith('.md')).sort()) { + entries.push({ id: `overlay:${file.slice(0, -3)}`, kind: 'overlay', source: `model-overlays/${file}` }); + } + for (const id of PREAMBLE_MODULE_IDS) entries.push({ id, kind: 'preamble', source: 'scripts/resolvers/preamble.ts' }); + return entries.map(e => ({ ...e, pinned: pinned.has(e.id) })); +} + +export function normalizeModelId(raw: string): string { + return raw.trim().toLowerCase().replace(/\[[^\]]*\]$/, '').trim().replace(/^[a-z0-9-]+\//, ''); +} + +const matches = (pattern: string, id: string) => (pattern.endsWith('*') ? id.startsWith(pattern.slice(0, -1)) : id === pattern); + +/** The rung a model id belongs to: aliases first, then the first rung whose pattern matches. */ +export function rungFor(manifest: LoadoutsManifest, raw: string): Rung { + const id = normalizeModelId(raw); + if (Object.hasOwn(manifest.aliases, id)) return manifest.aliases[id]; + for (const rung of RUNGS) if (manifest.rungs[rung].some(p => matches(p, id))) return rung; + return 'unknown'; +} + +/** Every problem with the manifest, or [] (free test and gstack-modules validate). */ +export function validateLoadouts(manifest: LoadoutsManifest, catalog: ModuleEntry[], corpus: string[] = []): string[] { + const problems: string[] = []; + const ids = new Set(catalog.map(e => e.id)); + const pinned = new Set([...(manifest.pinned ?? []), ...IMPLICIT_PINS]); + if (manifest.version !== 1) problems.push(`version must be 1 (got ${clamp(String(manifest.version))})`); + for (const rung of RUNGS) if (!Array.isArray(manifest.rungs?.[rung])) problems.push(`rungs.${rung} must be a list of model-id patterns`); + for (const [alias, rung] of Object.entries(manifest.aliases ?? {})) { + if (!(RUNGS as readonly string[]).includes(rung)) problems.push(`aliases.${alias} names no rung (${clamp(rung)})`); + } + for (const id of manifest.pinned ?? []) if (!ids.has(id)) problems.push(`pinned id ${clamp(id)} is not a module`); + const loadouts = manifest.loadouts ?? {}; + const has = (name: string | undefined) => name !== undefined && Object.hasOwn(loadouts, name); + if (!has(FULL) || loadouts[FULL].include !== '*') problems.push('loadouts.full must exist with include "*"'); + for (const [name, def] of Object.entries(loadouts)) { + if (!NAME_RE.test(name)) problems.push(`loadout name ${clamp(name)} must match ^[a-z0-9-]+$`); + if (def.base !== undefined && !has(def.base)) problems.push(`loadouts.${name}.base names no loadout (${clamp(def.base)})`); + for (const id of [...(def.exclude ?? []), ...(Array.isArray(def.include) ? def.include : [])]) { + if (!ids.has(id)) problems.push(`loadouts.${name} names ${clamp(id)}, which is not a module`); + } + for (const id of def.exclude ?? []) { + if (pinned.has(id)) problems.push(`loadouts.${name} excludes pinned ${id}`); + const child = id.startsWith('skill:') ? [...pinned].find(p => p.startsWith(`section:${id.slice('skill:'.length)}/`)) : undefined; + if (child) problems.push(`loadouts.${name} excludes ${id}, which holds pinned ${child}`); + } + if (def.preamble_tier_max !== undefined && ![1, 2, 3, 4].includes(def.preamble_tier_max)) problems.push(`loadouts.${name}.preamble_tier_max must be 1-4`); + const seen = new Set(); + for (let at: string | undefined = name; at && has(at); at = loadouts[at].base) { + if (seen.has(at)) { problems.push(`loadouts.${name} has a base cycle through ${at}`); break; } + seen.add(at); + } + } + for (const rung of [...RUNGS, 'unknown'] as const) { + if (!has(manifest.default?.[rung])) problems.push(`default.${rung} names no loadout (${clamp(String(manifest.default?.[rung]))})`); + } + for (const raw of corpus) { + const id = normalizeModelId(raw); + const hits = RUNGS.filter(rung => manifest.rungs[rung]?.some(p => matches(p, id))); + if (hits.length > 1) problems.push(`model ${id} matches more than one rung (${hits.join(', ')})`); + } + return problems; +} + +function assertValid(manifest: LoadoutsManifest, catalog: ModuleEntry[]): void { + const problems = validateLoadouts(manifest, catalog); + if (problems.length) throw new ModuleError('LOADOUTS_INVALID', `${LOADOUTS_FILE}: ${problems.join('; ')}`, 'fix the named entries, then run gstack-modules validate'); +} + +function excludedBy(manifest: LoadoutsManifest, name: string): { excluded: Set; tierMax?: number } { + const def = manifest.loadouts[name]; + const inherited = def.base ? excludedBy(manifest, def.base) : { excluded: new Set() }; + const excluded = new Set([...inherited.excluded, ...(def.exclude ?? [])]); + if (Array.isArray(def.include)) for (const id of def.include) excluded.delete(id); + return { excluded, tierMax: def.preamble_tier_max ?? inherited.tierMax }; +} + +export interface ModuleSelection extends ModuleFilter { + /** The normalized spec (`` or `,-id,+id`) and its sha256, for the lock file. */ + spec: string; + specSha256: string; +} + +/** + * Resolve `--loadout ` or `--modules ,-id,+id` to a filter. + * Returns null for the plain `full` loadout, which renders today's bytes. + */ +export function selectModules(input: { loadout?: string; modules?: string }, root = ROOT): ModuleSelection | null { + if (input.loadout !== undefined && input.modules !== undefined) { + throw new ModuleError('MODULE_SPEC_INVALID', '--loadout and --modules were both given', 'pass one: --loadout or --modules ,-id,+id'); + } + const text = (input.modules ?? input.loadout ?? '').trim(); + if (!text) return null; + const manifest = loadLoadouts(root); + const catalog = moduleCatalog(manifest, root); + assertValid(manifest, catalog); + const [base, ...edits] = text.split(',').map(t => t.trim()); + const names = Object.keys(manifest.loadouts).sort().join(', '); + if (!Object.hasOwn(manifest.loadouts, base)) { + throw new ModuleError('LOADOUT_UNKNOWN', `${clamp(base)} is not a loadout (valid: ${names})`, 'gstack-modules list'); + } + if (input.loadout !== undefined && edits.length) { + throw new ModuleError('MODULE_SPEC_INVALID', `--loadout takes one name (got ${clamp(text)})`, 'use --modules ,-id,+id for edits'); + } + const { excluded, tierMax } = excludedBy(manifest, base); + const ids = new Map(catalog.map(e => [e.id, e])); + for (const edit of edits) { + const op = edit[0]; + const id = edit.slice(1); + if ((op !== '-' && op !== '+') || !id) { + throw new ModuleError('MODULE_SPEC_INVALID', `${clamp(edit)} is not - or +`, 'write the spec as ,-,+; list ids with gstack-modules list'); + } + const entry = ids.get(id); + if (!entry) throw new ModuleError('MODULE_UNKNOWN', `${clamp(id)} is not a module`, 'gstack-modules list'); + if (op === '-' && entry.pinned) { + throw new ModuleError('MODULE_PINNED', `${id} is pinned and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`); + } + const pinnedChild = op === '-' && id.startsWith('skill:') ? catalog.find(e => e.pinned && e.id.startsWith(`section:${id.slice('skill:'.length)}/`)) : undefined; + if (pinnedChild) { + throw new ModuleError('MODULE_PINNED', `${id} holds pinned ${pinnedChild.id} and can never be excluded`, `drop -${id} from the spec (pinned ids: gstack-modules list --pinned)`); + } + if (op === '-') excluded.add(id); else excluded.delete(id); + } + if (excluded.size === 0 && tierMax === undefined && base === FULL) return null; + const spec = edits.length ? [base, ...edits].join(',') : base; + return { + name: edits.length ? 'adhoc' : base, + excluded, + pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)), + preambleTierMax: tierMax, + spec, + specSha256: createHash('sha256').update(spec).digest('hex'), + }; +} + +/** Skill directories a selection leaves out (`skill:gstack` is the root template). */ +export function excludedSkillDirs(filter: ModuleFilter | null | undefined): string[] { + return [...(filter?.excluded ?? [])].filter(id => id.startsWith('skill:')).map(id => id.slice('skill:'.length)); +} diff --git a/scripts/resolvers/model-overlay.ts b/scripts/resolvers/model-overlay.ts index 2a6242e2c..7933dffac 100644 --- a/scripts/resolvers/model-overlay.ts +++ b/scripts/resolvers/model-overlay.ts @@ -24,9 +24,10 @@ const OVERLAY_DIR = path.resolve(import.meta.dir, '../../model-overlays'); const INHERIT_RE = /^\s*\{\{INHERIT:([a-z0-9-]+(?:\.[0-9]+)*)\}\}\s*\n/; -export function readOverlay(model: string, seen: Set = new Set()): string { +export function readOverlay(model: string, seen: Set = new Set(), excluded: ReadonlySet = new Set()): string { if (seen.has(model)) return ''; // cycle guard seen.add(model); + if (excluded.has(`overlay:${model}`)) return ''; const filePath = path.join(OVERLAY_DIR, `${model}.md`); if (!fs.existsSync(filePath)) return ''; @@ -36,7 +37,7 @@ export function readOverlay(model: string, seen: Set = new Set()): strin if (!match) return raw.trim(); const baseModel = match[1]; - const base = readOverlay(baseModel, seen); + const base = readOverlay(baseModel, seen, excluded); const rest = raw.replace(INHERIT_RE, '').trim(); if (!base) return rest; @@ -46,7 +47,7 @@ export function readOverlay(model: string, seen: Set = new Set()): strin export function generateModelOverlay(ctx: TemplateContext): string { if (!ctx.model) return ''; - const content = readOverlay(ctx.model); + const content = readOverlay(ctx.model, new Set(), ctx.modules?.excluded); if (!content) return ''; const precedence = ctx.model === 'gpt-5.6-sol' diff --git a/scripts/resolvers/preamble.ts b/scripts/resolvers/preamble.ts index c39da2f2b..19575f9e6 100644 --- a/scripts/resolvers/preamble.ts +++ b/scripts/resolvers/preamble.ts @@ -72,6 +72,47 @@ export function generatePreamble(ctx: TemplateContext): string { return preambleSections(ctx).join('\n\n'); } +interface PreamblePiece { + /** Module id suffix (`preamble:`); null for the model overlay, whose module id is `overlay:`. */ + id: string | null; + minTier: number; + when?: (ctx: TemplateContext) => boolean; + render: (ctx: TemplateContext, tier: number) => string; +} + +/** + * The preamble's pieces in render order. AskUserQuestion Format renders BEFORE + * the model overlay so the pacing rule is the ambient default; the overlay's + * nudges land as subordinate patches (Opus 4.7 absorbs the first pacing + * directive it hits; reversing this regresses plan-review cadence, v1.6.4.0). + * Plan-mode semantics stays right after bash (so _SESSION_ID / _BRANCH / _TEL + * are live) and before every onboarding gate. The 8 one-time onboarding + * generators live in bin/gstack-skill-start's instruction layer (plan Q2/OV6/F5). + */ +export const PREAMBLE_PIECES: readonly PreamblePiece[] = [ + { id: 'bash', minTier: 1, render: generatePreambleBash }, + { id: 'make-pdf-setup', minTier: 1, when: ctx => ctx.skillName === 'make-pdf', render: generateMakePdfSetup }, + { id: 'plan-mode-info', minTier: 1, render: generatePlanModeInfo }, + { id: 'upgrade-check', minTier: 1, render: generateUpgradeCheck }, + { id: 'brain-health', minTier: 1, render: generateBrainHealthInstruction }, + { id: 'ask-user-format', minTier: 2, render: generateAskUserFormat }, + { id: 'brain-sync', minTier: 1, render: generateBrainSyncBlock }, + { id: null, minTier: 1, render: generateModelOverlay }, + { id: 'voice', minTier: 1, render: generateVoiceDirective }, + { id: 'context-recovery', minTier: 2, render: generateContextRecovery }, + { id: 'writing-style', minTier: 2, render: generateWritingStyle }, + { id: 'completeness', minTier: 2, render: generateCompletenessSection }, + { id: 'confusion', minTier: 2, render: generateConfusionProtocol }, + { id: 'evidence', minTier: 2, render: generateEvidenceDirective }, + { id: 'context-health', minTier: 2, render: generateContextHealth }, + { id: 'question-tuning', minTier: 2, render: generateQuestionTuning }, + { id: 'repo-mode', minTier: 3, render: () => generateRepoModeSection() }, + { id: 'search-before-building', minTier: 3, render: generateSearchBeforeBuildingSection }, + { id: 'completion-status', minTier: 1, render: generateCompletionStatus }, +]; + +export const PREAMBLE_MODULE_IDS: readonly string[] = PREAMBLE_PIECES.flatMap(p => (p.id ? [`preamble:${p.id}`] : [])); + /** The preamble's generator outputs in render order, empty ones dropped. */ export function preambleSections(ctx: TemplateContext): string[] { const tier = ctx.preambleTier; @@ -84,41 +125,15 @@ export function preambleSections(ctx: TemplateContext): string[] { if (tier < 1 || tier > 4) { throw new Error(`Invalid preamble-tier: ${tier} in ${ctx.tmplPath}. Must be 1-4.`); } - const sections = [ - generatePreambleBash(ctx), - ...(ctx.skillName === 'make-pdf' ? [generateMakePdfSetup(ctx)] : []), - // Plan-mode-skill semantics stays near the top: after bash (so _SESSION_ID / - // _BRANCH / _TEL env vars are live) and before all onboarding gates so - // models read the authoritative "AskUserQuestion satisfies plan mode's - // end-of-turn" rule before any other instruction. Renders for all skills - // (not interactive-gated) on hosts whose capabilities declare planMode. - generatePlanModeInfo(ctx), - generateUpgradeCheck(ctx), - // Phase 2: the 8 one-time onboarding generators (lake-intro, telemetry- - // prompt, proactive-prompt, first-run-guidance, routing-injection, - // vendoring-deprecation, spawned-session-check, writing-style-migration) - // moved into bin/gstack-skill-start's instruction-emission layer — their - // text appears at runtime only when its gate fires (plan Q2/OV6/F5). - generateBrainHealthInstruction(ctx), - // AskUserQuestion Format renders BEFORE the model overlay so the pacing rule - // is the ambient default; the overlay's behavioral nudges land as subordinate - // patches. Opus 4.7 reads top-to-bottom and absorbs the first pacing directive - // it hits; reversing this order regresses plan-review cadence (v1.6.4.0 bug). - ...(tier >= 2 ? [generateAskUserFormat(ctx)] : []), - generateBrainSyncBlock(ctx), - generateModelOverlay(ctx), - generateVoiceDirective(ctx, tier), - ...(tier >= 2 ? [ - generateContextRecovery(ctx), - generateWritingStyle(ctx), - generateCompletenessSection(ctx), - generateConfusionProtocol(ctx), - generateEvidenceDirective(ctx), - generateContextHealth(ctx), - generateQuestionTuning(ctx), - ] : []), - ...(tier >= 3 ? [generateRepoModeSection(), generateSearchBeforeBuildingSection(ctx)] : []), - generateCompletionStatus(ctx), - ]; - return sections.filter(s => s && s.trim().length > 0); + const filter = ctx.modules; + const pieceTier = (piece: PreamblePiece): number => { + if (!filter || piece.id === null || filter.pinned.has(`preamble:${piece.id}`)) return tier; + return Math.min(tier, filter.preambleTierMax ?? tier); + }; + return PREAMBLE_PIECES + .filter(piece => (piece.when?.(ctx) ?? true) + && !(piece.id && filter?.excluded.has(`preamble:${piece.id}`)) + && pieceTier(piece) >= piece.minTier) + .map(piece => piece.render(ctx, pieceTier(piece))) + .filter(s => s && s.trim().length > 0); } diff --git a/scripts/resolvers/sections.ts b/scripts/resolvers/sections.ts index a210b8e8a..ea6fd4df0 100644 --- a/scripts/resolvers/sections.ts +++ b/scripts/resolvers/sections.ts @@ -108,6 +108,9 @@ export const SECTION: ResolverFn = (ctx: TemplateContext, args?: string[]): stri const id = args?.[0]; if (!id) throw new Error('{{SECTION:id}} requires a section id'); const entry = findSection(ctx.skillName, id); + if (ctx.modules?.excluded.has(`section:${ctx.skillName}/${id}`)) { + return `> Not loaded in the ${ctx.modules!.name} loadout: skip ${entry.trigger}. Do not offer it, and treat it as not run.`; + } if (usesLazySections(ctx.host, ctx.skillName)) { if (ctx.skillName === 'qa' || ctx.skillName === 'qa-only') { @@ -129,6 +132,21 @@ export const SECTION: ResolverFn = (ctx: TemplateContext, args?: string[]): stri return fs.readFileSync(tmplPath, 'utf-8').trimEnd(); }; +const SECTION_GUARD_RE = /\{\{IF_SECTION:([a-z0-9-]+)\}\}([\s\S]*?)\{\{\/IF_SECTION\}\}/g; + +/** + * {{IF_SECTION:id}}…{{/IF_SECTION}}: skeleton text that belongs to one of this + * skill's sections (an offer, a self-check naming it). The markers vanish and + * the text stays when the section loads, so `full` bytes are unchanged; a + * loadout that excludes the section drops the text with it. + */ +export function applySectionGuards(content: string, ctx: TemplateContext): string { + return content.replace(SECTION_GUARD_RE, (_match, id: string, inner: string) => { + findSection(ctx.skillName, id); + return ctx.modules?.excluded.has(`section:${ctx.skillName}/${id}`) ? '' : inner; + }); +} + /** * {{SECTION_INDEX:skill}} — situation→section table from the passive manifest. * Lazy skills only; an index would be noise for inlined skills. @@ -149,7 +167,7 @@ export const SECTION_INDEX: ResolverFn = (ctx: TemplateContext, args?: string[]) '|------|-------------------|', ]; for (const s of manifest.sections) { - if (s.runner_only) continue; + if (s.runner_only || ctx.modules?.excluded.has(`section:${skill}/${s.id}`)) continue; const reference = skill === 'qa' || skill === 'qa-only' ? sectionPath(ctx, skill, s.id) : `\`sections/${s.file}\``; if (skill === 'review' && s.id === 'review-army') { lines.push(`| Select surfaces and read QA methods | Inline in [Step 4](#step-4-critical-pass-core-review); setup and probes run in Step 4.7 |`); diff --git a/scripts/resolvers/types.ts b/scripts/resolvers/types.ts index e23c156da..de6dd8c58 100644 --- a/scripts/resolvers/types.ts +++ b/scripts/resolvers/types.ts @@ -125,6 +125,21 @@ export interface TemplateContext { * still apply. */ planMode?: boolean; + /** + * Module loadout filter (modules/loadouts.json, gen-skill-docs --loadout/--modules). + * Undefined = the `full` loadout: every module renders and committed bytes are unchanged. + */ + modules?: ModuleFilter; +} + +/** A resolved loadout: which module ids it leaves out, and which ids can never be left out. */ +export interface ModuleFilter { + /** Loadout name, or `adhoc` for a --modules spec. */ + name: string; + excluded: ReadonlySet; + pinned: ReadonlySet; + /** Unpinned preamble pieces render as if the skill's preamble-tier were at most this. */ + preambleTierMax?: number; } /** Resolver function signature. args is populated for parameterized placeholders like {{INVOKE_SKILL:name}}. */ diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index 40d430758..c01582fed 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -1,5 +1,8 @@ { "newModules": [ + { "file": "scripts/modules.ts", "movedFrom": "(new: module loadouts catalog and selection, GSTA-140)" }, + { "file": "scripts/modules-render.ts", "movedFrom": "(new: module loadout render and lock, GSTA-140)" }, + { "file": "scripts/modules-cli.ts", "movedFrom": "(new: bin/gstack-modules CLI, GSTA-140)" }, { "file": "lib/harness-register.ts", "movedFrom": "(new: gstack-harness registrar, GSTA-135)" }, { "file": "lib/harness-qm.ts", "movedFrom": "(new: gstack-harness QM skill-pack target, GSTA-135)" }, { "file": "lib/design-lint/render.ts", "movedFrom": "(new: the bundled design lint, GSTA-105; ported from hallmark sloplint.mjs)" }, diff --git a/test/gen-skill-docs-prune-stale.test.ts b/test/gen-skill-docs-prune-stale.test.ts index 1089dcfde..15879072a 100644 --- a/test/gen-skill-docs-prune-stale.test.ts +++ b/test/gen-skill-docs-prune-stale.test.ts @@ -39,7 +39,7 @@ describe('gen-skill-docs stale-render prune', () => { const source = path.join(out, 'source'); fs.mkdirSync(path.join(source, 'scripts'), { recursive: true }); fs.copyFileSync(path.join(ROOT, 'scripts', 'gen-skill-docs.ts'), path.join(source, 'scripts', 'gen-skill-docs.ts')); - for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts']) { + for (const file of ['discover-skills.ts', 'external-skill-names.ts', 'gen-llms-txt.ts', 'gen-agents-digest.ts', 'gen-doctor-components.ts', 'models.ts', 'modules.ts']) { fs.symlinkSync(path.join(ROOT, 'scripts', file), path.join(source, 'scripts', file), 'file'); } fs.symlinkSync(path.join(ROOT, 'scripts', 'resolvers'), path.join(source, 'scripts', 'resolvers'), 'dir'); diff --git a/test/module-loadouts.test.ts b/test/module-loadouts.test.ts new file mode 100644 index 000000000..e85986947 --- /dev/null +++ b/test/module-loadouts.test.ts @@ -0,0 +1,301 @@ +/** + * Module loadouts (GSTA-140, PR 1a): modules/loadouts.json, the derived + * catalog, --modules selection, the render filter, and gstack-modules render. + * Every rung loads `full` today, so the synthetic ad-hoc arms below are the + * only exclusion coverage; committed bytes are covered by Skill Docs Freshness. + */ +import { afterAll, 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 { bunIsolationArgs } from '../lib/bun-isolation'; +import { MODEL_CATALOG } from '../lib/model-catalog'; +import { runGeneration } from '../scripts/gen-skill-docs'; +import { ModuleError, excludedSkillDirs, loadLoadouts, moduleCatalog, normalizeModelId, rungFor, selectModules, validateLoadouts, type ModuleErrorCode } from '../scripts/modules'; +import { LOCK_FILE, renderLoadout, type LoadoutLock } from '../scripts/modules-render'; +import { applySectionGuards } from '../scripts/resolvers/sections'; +import { PREAMBLE_MODULE_IDS, preambleSections } from '../scripts/resolvers/preamble'; +import { HOST_PATHS, type TemplateContext } from '../scripts/resolvers/types'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const manifest = loadLoadouts(); +const catalog = moduleCatalog(manifest); +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'loadouts-')); +afterAll(() => fs.rmSync(tmp, { recursive: true, force: true })); + +/** Model ids gstack runs on or GSTA-2 measures (Capy's model list, 2026-10-28). */ +const CORPUS = [ + ...MODEL_CATALOG.map(e => e.model), 'gpt-6-astra', + 'anthropic/claude-opus-5-5', 'anthropic/claude-haiku-5-5', 'anthropic/claude-sonnet-5-5', 'anthropic/claude-fable-5-1', + 'openai/gpt-6.1-sol', 'openai/gpt-6-luna', 'openai/gpt-6.1-terra', 'deepseek/deepseek-v4.1-flash-1017', 'deepseek/deepseek-v4-pro', + 'zai/glm-5.3', 'zai/glm-5.3-flash', 'google/gemini-4-argon', 'google/gemini-3.8-flash', 'xai/grok-4.7', + 'moonshotai/kimi-k3.1-pro', 'qwen/qwen3.8-max', 'qwen/qwen3.8-flash', 'minimax/minimax-m3', +]; + +function codeOf(fn: () => unknown): ModuleErrorCode | 'none' { + try { fn(); return 'none'; } catch (error) { if (error instanceof ModuleError) return error.code; throw error; } +} + +describe('modules/loadouts.json', () => { + test('validates against the derived catalog, with disjoint rungs over the model corpus', () => { + expect(validateLoadouts(manifest, catalog, CORPUS)).toEqual([]); + }); + + test('every rung and unknown loads full until GSTA-2 measures a smaller loadout', () => { + expect(Object.values(manifest.default)).toEqual(['full', 'full', 'full', 'full', 'full']); + }); + + test('the catalog derives every module kind from its source files', () => { + const ids = new Set(catalog.map(e => e.id)); + expect(ids.has('skill:gstack')).toBe(true); + expect(ids.has('section:office-hours/second-opinion')).toBe(true); + expect(ids.has('overlay:opus-4-8')).toBe(true); + for (const id of PREAMBLE_MODULE_IDS) expect(ids.has(id)).toBe(true); + for (const entry of catalog) expect(fs.existsSync(path.join(ROOT, entry.source))).toBe(true); + expect(catalog.filter(e => e.pinned).map(e => e.id)).toEqual(expect.arrayContaining(['preamble:bash', 'skill:gstack', 'skill:gstack-upgrade', ...manifest.pinned])); + }); + + test('model ids normalize and map to rungs', () => { + expect(normalizeModelId(' Anthropic/Claude-Haiku-5-5[1m] ')).toBe('claude-haiku-5-5'); + expect(rungFor(manifest, 'anthropic/claude-opus-5-5')).toBe('strong'); + expect(rungFor(manifest, 'anthropic/claude-haiku-5-5')).toBe('mid'); + expect(rungFor(manifest, 'deepseek/deepseek-v4.1-flash-1017')).toBe('light'); + expect(rungFor(manifest, 'zai/glm-5.3-flash')).toBe('light'); + expect(rungFor(manifest, 'zai/glm-5.3')).toBe('mid'); + expect(rungFor(manifest, 'haiku')).toBe('mid'); + expect(rungFor(manifest, 'some-new-model')).toBe('unknown'); + expect(rungFor(manifest, 'constructor')).toBe('unknown'); + for (const model of MODEL_CATALOG) expect(rungFor(manifest, model.model)).not.toBe('unknown'); + }); + + test('a broken manifest names each problem', () => { + const bad = structuredClone(manifest); + bad.loadouts.lean = { base: 'missing', exclude: ['preamble:bash', 'section:nope/none'] }; + bad.default.mid = 'ghost'; + bad.default.light = 'toString'; + bad.loadouts.noship = { base: 'constructor', exclude: ['skill:ship'] }; + bad.rungs.light.push('claude-opus-5*'); + const problems = validateLoadouts(bad, catalog, CORPUS).join('\n'); + expect(problems).toContain('loadouts.lean.base names no loadout'); + expect(problems).toContain('excludes pinned preamble:bash'); + expect(problems).toContain('"section:nope/none", which is not a module'); + expect(problems).toContain('default.mid names no loadout'); + expect(problems).toContain('default.light names no loadout'); + expect(problems).toContain('loadouts.noship.base names no loadout'); + expect(problems).toContain('excludes skill:ship, which holds pinned section:ship/'); + expect(problems).toContain('matches more than one rung'); + }); +}); + +describe('selectModules', () => { + test('full and an empty spec render today\'s bytes (no filter)', () => { + expect(selectModules({ loadout: 'full' })).toBeNull(); + expect(selectModules({})).toBeNull(); + expect(selectModules({ modules: 'full,-section:office-hours/second-opinion,+section:office-hours/second-opinion' })).toBeNull(); + }); + + test('an ad-hoc spec records its name, normalized spec and hash', () => { + const s = selectModules({ modules: ' full , -skill:design-shotgun ' })!; + expect(s.name).toBe('adhoc'); + expect(s.spec).toBe('full,-skill:design-shotgun'); + expect(s.specSha256).toMatch(/^[0-9a-f]{64}$/); + expect(excludedSkillDirs(s)).toEqual(['design-shotgun']); + }); + + test('bad input is refused with a code, never a fallback to full', () => { + expect(codeOf(() => selectModules({ loadout: 'nope' }))).toBe('LOADOUT_UNKNOWN'); + expect(codeOf(() => selectModules({ modules: 'full,skill:qa' }))).toBe('MODULE_SPEC_INVALID'); + expect(codeOf(() => selectModules({ loadout: 'full', modules: 'full' }))).toBe('MODULE_SPEC_INVALID'); + expect(codeOf(() => selectModules({ loadout: 'full,-skill:qa' }))).toBe('MODULE_SPEC_INVALID'); + expect(codeOf(() => selectModules({ modules: 'full,-skill:nope' }))).toBe('MODULE_UNKNOWN'); + expect(codeOf(() => selectModules({ modules: 'full,-preamble:bash' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ modules: 'full,-section:ship/adversarial' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ modules: 'full,-skill:ship' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ modules: 'full,-skill:autoplan' }))).toBe('MODULE_PINNED'); + expect(codeOf(() => selectModules({ loadout: 'constructor' }))).toBe('LOADOUT_UNKNOWN'); + }); + + test('every error code has a troubleshooting section', () => { + const doc = fs.readFileSync(path.join(ROOT, 'docs/troubleshooting.md'), 'utf-8'); + const codes: ModuleErrorCode[] = ['LOADOUTS_INVALID', 'LOADOUT_UNKNOWN', 'MODULE_SPEC_INVALID', 'MODULE_UNKNOWN', 'MODULE_PINNED', 'MODULE_EXCLUSION_UNSAFE', 'RENDER_FAILED', 'RENDER_DEST_NOT_OWNED']; + for (const code of codes) { + const anchor = code.toLowerCase().replaceAll('_', '-'); + const at = doc.indexOf(``); + expect(at).toBeGreaterThan(-1); + expect(doc.slice(at).split('\n')[1]).toMatch(new RegExp(`^### \`${code}`)); + } + }); +}); + +describe('render filter units', () => { + const ctx = (extra: Partial = {}): TemplateContext => ({ + skillName: 'office-hours', tmplPath: 'office-hours/SKILL.md.tmpl', host: 'claude', paths: HOST_PATHS.claude, preambleTier: 3, model: 'claude', ...extra, + }); + const filter = (excluded: string[], preambleTierMax?: number) => ({ + name: 'test', excluded: new Set(excluded), pinned: new Set(catalog.filter(e => e.pinned).map(e => e.id)), preambleTierMax, + }); + + test('section guards vanish under full and drop their text when the section is excluded', () => { + const text = 'a\n{{IF_SECTION:second-opinion}}offer\n{{/IF_SECTION}}b'; + expect(applySectionGuards(text, ctx())).toBe('a\noffer\nb'); + expect(applySectionGuards(text, ctx({ modules: filter(['section:office-hours/second-opinion']) }))).toBe('a\nb'); + expect(() => applySectionGuards('{{IF_SECTION:nope}}x{{/IF_SECTION}}', ctx())).toThrow('no section "nope"'); + }); + + test('a tier cap drops unpinned pieces; pinned pieces follow full', () => { + const full = preambleSections(ctx()).join('\n'); + const capped = preambleSections(ctx({ modules: filter([], 1) })).join('\n'); + expect(full).toContain('## Writing Style'); + expect(capped).not.toContain('## Writing Style'); + expect(capped).toContain('## AskUserQuestion Format'); + const tier1 = ctx({ preambleTier: 1 }); + expect(preambleSections({ ...tier1, modules: filter([], 1) })).toEqual(preambleSections(tier1)); + }); + + test('excluding a base overlay drops its text from the overlay that inherits it', () => { + const astra = ctx({ model: 'gpt-6-astra' as TemplateContext['model'] }); + const withBase = preambleSections(astra).join('\n'); + const withoutBase = preambleSections({ ...astra, modules: filter(['overlay:gpt']) }).join('\n'); + expect(withBase.length).toBeGreaterThan(withoutBase.length); + expect(preambleSections({ ...astra, modules: filter(['overlay:gpt-6-astra']) }).join('\n')).not.toContain('Model-Specific Behavioral Patch'); + }); +}); + +describe('gstack-modules render', () => { + const spec = 'full,-section:office-hours/second-opinion,-section:office-hours/visual-design,-preamble:writing-style,-overlay:claude,-skill:design-shotgun'; + const readLock = (dir: string) => JSON.parse(fs.readFileSync(path.join(dir, LOCK_FILE), 'utf-8')) as LoadoutLock; + const ensureArm = async (dir: string) => { if (!fs.existsSync(path.join(dir, LOCK_FILE))) await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: dir }); }; + + test('claude: one arm excludes one module of each kind and locks every file', async () => { + const out = path.join(tmp, 'claude-arm'); + const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: spec }), outDir: out }); + const skill = fs.readFileSync(path.join(out, 'office-hours/SKILL.md'), 'utf-8'); + expect(skill).toContain(''); + expect(skill).not.toContain('Want a second opinion'); + expect(skill).not.toContain('## Phase 4.25: Visual design'); + expect(skill).toContain('> Not loaded in the adhoc loadout: skip running the Phase 3.5 second opinion'); + expect(skill).not.toContain('sections/second-opinion.md'); + expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(false); + expect(fs.existsSync(path.join(out, 'office-hours/sections/design-and-handoff.md'))).toBe(true); + expect(skill).toContain(`${out}/office-hours/sections/design-and-handoff.md`); + const preamble = fs.readFileSync(path.join(out, 'office-hours/sections/preamble.md'), 'utf-8'); + expect(preamble).not.toContain('## Writing Style'); + expect(preamble).not.toContain('Model-Specific Behavioral Patch'); + expect(preamble).toContain('## AskUserQuestion Format'); + expect(fs.existsSync(path.join(out, 'design-shotgun'))).toBe(false); + expect(fs.readFileSync(path.join(out, 'gstack/llms.txt'), 'utf-8')).not.toContain('design-shotgun/SKILL.md'); + expect(fs.readFileSync(path.join(out, 'SKILL.md'), 'utf-8')).toContain('Disabled on this install (never invoke or suggest them): `/design-shotgun`'); + expect(lock).toMatchObject({ loadout: 'adhoc', spec, host: 'claude', model: 'claude' }); + expect(lock.excluded).toEqual(spec.split(',').slice(1).map(t => t.slice(1)).sort()); + expect(readLock(out).files.length).toBe(lock.files.length); + expect(lock.files.find(f => f.path === 'office-hours/SKILL.md')!.bytes).toBe(Buffer.byteLength(skill)); + expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-') || n.includes('.old-'))).toEqual([]); + }); + + test('codex: the same arm inlines nothing it excluded', async () => { + const out = path.join(tmp, 'codex-arm'); + await renderLoadout({ host: 'codex', selection: selectModules({ modules: spec }), outDir: out, model: 'gpt-6-astra' }); + const skill = fs.readFileSync(path.join(out, '.agents/skills/gstack-office-hours/SKILL.md'), 'utf-8'); + expect(skill).not.toContain('Want a second opinion'); + expect(skill).toContain('> Not loaded in the adhoc loadout'); + expect(fs.existsSync(path.join(out, '.agents/skills/gstack-design-shotgun'))).toBe(false); + expect(readLock(out).model).toBe('gpt-6-astra'); + }); + + test('a dangling reference is refused and the earlier render survives', async () => { + const out = path.join(tmp, 'claude-arm'); + await ensureArm(out); + const before = readLock(out); + const unsafe = renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-section:plan-design-review/review-sections' }), outDir: out }); + await expect(unsafe).rejects.toMatchObject({ code: 'MODULE_EXCLUSION_UNSAFE' }); + await expect(unsafe).rejects.toThrow('referenced by plan-design-review/sections/checklist.md'); + await expect(renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:qa' }), outDir: out })).rejects.toThrow(/skill:qa: referenced by qa-only\//); + expect(readLock(out)).toEqual(before); + expect(fs.readdirSync(tmp).filter(n => n.includes('.stage-'))).toEqual([]); + }); + + test('a later render replaces an earlier one; a foreign directory is never replaced', async () => { + const out = path.join(tmp, 'claude-arm'); + await ensureArm(out); + expect(readLock(out).loadout).toBe('adhoc'); + const lock = await renderLoadout({ host: 'claude', selection: selectModules({ modules: 'full,-skill:design-shotgun' }), outDir: out }); + expect(readLock(out).spec_sha256).toBe(lock.spec_sha256); + expect(fs.existsSync(path.join(out, 'office-hours/sections/second-opinion.md'))).toBe(true); + const foreign = path.join(tmp, 'foreign'); + fs.mkdirSync(foreign); + fs.writeFileSync(path.join(foreign, 'notes.txt'), 'mine'); + await expect(renderLoadout({ host: 'claude', selection: null, outDir: foreign })).rejects.toMatchObject({ code: 'RENDER_DEST_NOT_OWNED' }); + expect(fs.readFileSync(path.join(foreign, 'notes.txt'), 'utf-8')).toBe('mine'); + }); + + test('the full loadout regenerates the committed claude tree byte for byte', async () => { + const result = await runGeneration({ host: 'claude', dryRun: true, modules: selectModules({ loadout: 'full' }) }); + expect(result.diagnostics.filter(d => d.kind === 'stale' || d.kind === 'error')).toEqual([]); + }); +}); + +describe('modules/loadouts.schema.json', () => { + const schema = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.schema.json'), 'utf-8')); + const raw = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/loadouts.json'), 'utf-8')); + + test('the manifest carries every required key and no key the schema lacks', () => { + for (const key of schema.required) expect(raw).toHaveProperty(key); + for (const key of Object.keys(raw)) expect(Object.keys(schema.properties)).toContain(key); + expect(raw.version).toBe(schema.properties.version.const); + }); + + test('rung names and patterns agree between the schema and the manifest', () => { + const rungs = schema.properties.rungs.required as string[]; + expect(Object.keys(raw.rungs).sort()).toEqual([...rungs].sort()); + expect([...schema.properties.aliases.additionalProperties.enum].sort()).toEqual([...rungs].sort()); + const pattern = new RegExp(schema.properties.rungs.properties.frontier.items.pattern); + for (const rung of rungs) for (const glob of raw.rungs[rung]) expect(glob).toMatch(pattern); + }); +}); + +describe('bin/gstack-modules', () => { + const run = (...args: string[]) => { + const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'bin/gstack-modules'), ...args], { cwd: ROOT, encoding: 'utf-8', timeout: 60_000 }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; + }; + + test('validate and list read the committed manifest', () => { + expect(run('validate')).toMatchObject({ code: 0, stdout: expect.stringContaining('LOADOUTS: ok') }); + const listed = JSON.parse(run('list', '--json').stdout); + expect(listed.modules.map((m: { id: string }) => m.id)).toEqual(catalog.map(e => e.id)); + const pinned = run('list', '--pinned').stdout; + expect(pinned).toContain('preamble:bash'); + expect(pinned).not.toContain('skill:design-shotgun'); + }); + + test('refused inputs exit 2 with their code; unknown commands print usage', () => { + const out = path.join(tmp, 'cli-refused'); + expect(run('render', '--host', 'claude', '--modules', 'full,-skill:nope', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_UNKNOWN') }); + expect(run('render', '--host', 'claude', '--modules', 'full,-preamble:bash', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_PINNED') }); + expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--plan-mode', 'maybe')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(run('render', '--host', 'claude', '--loadout', 'full', '--out-dir', out, '--model', 'no-such-model')).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(run('render', '--host', 'claude', '--out-dir', out)).toMatchObject({ code: 2, stderr: expect.stringContaining('MODULE_SPEC_INVALID') }); + expect(fs.existsSync(out)).toBe(false); + expect(run('bogus')).toMatchObject({ code: 2, stderr: expect.stringContaining('Usage:') }); + expect(run('--help')).toMatchObject({ code: 0, stdout: expect.stringContaining('gstack-modules render') }); + }); + + test('gen:skill-docs refuses an exclusion render into the committed tree', () => { + const before = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout; + for (const extra of [[], ['--out-dir', '.']]) { + const result = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(ROOT, 'scripts/gen-skill-docs.ts'), '--host', 'claude', '--modules', 'full,-skill:design-shotgun', ...extra], { cwd: ROOT, encoding: 'utf-8', timeout: 120_000 }); + expect(result.status).toBe(2); + expect(result.stderr).toContain('MODULE_SPEC_INVALID'); + } + expect(spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf-8', timeout: 30_000 }).stdout).toBe(before); + }); + + test('render prints one RENDERED line and writes the lock', () => { + const out = path.join(tmp, 'cli-render'); + const result = run('render', '--host', 'claude', '--modules', 'full,-skill:design-shotgun', '--out-dir', out); + expect(result.code).toBe(0); + expect(result.stdout).toMatch(/^RENDERED: loadout=adhoc host=claude files=\d+ bytes=\d+ excluded=1 lock=/); + expect(JSON.parse(fs.readFileSync(path.join(out, LOCK_FILE), 'utf-8')).excluded).toEqual(['skill:design-shotgun']); + }); +}); From de4c694d91cc1ff137b7391a8a93a6b614858027 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 21:37:43 -0700 Subject: [PATCH 05/19] v1.91.306.0 ship-receipt: carry across regenerated outputs and onto a reviewed tree after a rebase (#3564) * feat(ship-receipt): carry across regenerated outputs and onto a reviewed tree after a rebase A base merge whose only product-side change is a registered output the pregate regen check proved fresh now carries the receipt. A rebased head carries when its tree is one the receipt covers and its product patch-id against the current base matches (RECEIPT_CARRY_TREE_MISMATCH otherwise). Docs: merge main into a reviewed branch, never rebase it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: regenerate doctor anchors; type the head_tree assert Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry sets aside only outputs the regen check proved it recreates The regen check deletes each touched output in its scratch worktree before the command runs and records the ones recreated byte-identically as proven. gstack-ship-receipt carry exempts only those paths, and only when the check ran origin/'s registry at the commit the carry reads. A file under an output glob that the generator never writes stays product (pre-landing review, #3564 pass 1). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: prove generated outputs in a second regen run; literal names only The regen verdict comes from the unmodified run again, so a generator that reads its own registered output (gen-skill-docs and qa/templates/functional-report-template.md) passes as before; the proof is a second run with the touched outputs deleted, and its failure only means not proven. The carry ignores a proven path with glob syntax, since it uses the paths as pathspec globs (pre-landing review, #3564 pass 2). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: receipt evidence keys and carry record fields; carry refusal fix line Documentation audit for #3564 (/document-release, audit ship3564-docs-1). 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 +- bin/gstack-doctor-components.sh | 1 + bin/gstack-ship-receipt | 192 ++++++++++++++++-------- captain/templates/CREW.md | 5 +- docs/captain.md | 8 + docs/pregate.md | 7 +- docs/ship-policy.md | 53 ++++++- docs/troubleshooting.md | 48 ++++-- docs/unattended.md | 7 +- lib/headless-artifacts.ts | 11 +- lib/pregate/checks/regen.ts | 5 +- lib/regen.ts | 22 ++- lib/result-codes-release.ts | 13 +- lib/ship-receipt.ts | 56 +++++-- package.json | 2 +- test/gstack-regen.test.ts | 17 ++- test/gstack-ship-receipt.test.ts | 4 +- test/ship-receipt-carry-rewrite.test.ts | 159 ++++++++++++++++++++ 20 files changed, 515 insertions(+), 113 deletions(-) create mode 100644 test/ship-receipt-carry-rewrite.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 054f7ea5a..b490efc99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [1.91.306.0] - 2026-10-29 + +**A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it. + +### Changed +- **`gstack-ship-receipt carry`**: an output of an entry with a `command` in origin/'s `.gstack/generated.json` counts as `generated`, not product, when the `--pregate` result for HEAD lists it under the `regen` check's new `proven` paths and that check ran the same base registry. Without that evidence, for a file that matches an output glob but that the command never writes, and for an entry with no command or one only the branch registers, the output stays product. +- **`gstack-pregate` `regen` check**: a fresh entry with touched outputs runs its command a second time in the scratch worktree with those outputs deleted, and records the ones it recreates byte-identically as `proven`. The verdict still comes from the first run, so a generator that reads its own output passes as before and proves nothing. +- **Carry across rewritten history**: `write` records `head_tree` and `product_patch_id`, and each carry rebinds both and keeps earlier trees in `carry.trees`. A head that is no longer an ancestor carries only when its tree is one the receipt covers and its product patch-id against the current base matches; it records `carry.ancestry: rewritten` and needs no old commit, so a fresh clone carries too. A different tree is the new `RECEIPT_CARRY_TREE_MISMATCH`. The reviewed tree on a base that moved under it is `RECEIPT_CARRY_PRODUCT_CHANGED`. +- **Docs**: docs/ship-policy.md#receipt-carry opens with a four-row table: merge with the product unchanged, merge that changes product, rebase onto a covered tree, and any other rebase. docs/captain.md and captain/templates/CREW.md say to merge main in and never rebase a reviewed branch, and the troubleshooting entries for the carry refusals match the new rules. + +### Tests +- `test/ship-receipt-carry-rewrite.test.ts`: a rebase onto the covered tree carries from a fresh clone that never fetched the old head, a different tree refuses with `RECEIPT_CARRY_TREE_MISMATCH`, the reviewed tree on a moved base refuses, and a merge that changes only a registered output's diff carries with a pregate whose regen check proved it and refuses without one or with a pregate from another registry, and a hand-written file under an output glob that the generator never writes stays product. +- `test/gstack-ship-receipt.test.ts`: the written receipt's key order includes `head_tree` and `product_patch_id`. + ## [1.91.305.0] - 2026-10-29 **You can now render gstack with whole skills, skill sections, model overlays or preamble pieces left out, as a byte-exact tree with a lock file, so an eval can measure which instructions a model actually needs.** Garry asked for "95% of the outcome but with 50% of the token use", and the free ceiling measurement on train gate run 40705984271 found skill text is about 21% of billed eval cost (35 of 103 tests), so trimming it per model is worth measuring. Nothing changes for anyone today. Every model rung and unknown models load `full`, and the committed SKILL.md files are byte-identical to the previous release. A smaller loadout ships only after GSTA-2 measures it. diff --git a/VERSION b/VERSION index 70487a8f5..98b9266b3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.305.0 +1.91.306.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 17ae3b266..74c007ef9 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.305.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.306.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-doctor-components.sh b/bin/gstack-doctor-components.sh index 221e5c993..af0815ec9 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -306,6 +306,7 @@ gstack_result_anchor() { REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;; + RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; RECEIPT_PROSE_MISMATCH) printf '%s' 'receipt-prose-mismatch' ;; diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index d7d3940a0..d3e2c342b 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -40,12 +40,13 @@ import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, r import { approvalForReceipt } from '../lib/approval-citations'; import { CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS, - bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, + bindReceipt, carryReceipt, classifyCarryPath, computeCensus, extractReceipt, missingIssueLines, orderReceipt, parseReviewLoop, placeReceiptFirst, planCarry, planTreeCarry, receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; +import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block (docs/ship-policy.md#ship-receipt) @@ -61,10 +62,12 @@ const HELP = `gstack-ship-receipt — the \`\`\`gstack-ship-receipt\`\`\` block census --repo --since [--cohort ] the P7 adoption number over merged PRs (deduplicated by run and thread) carry --pr [--repo ] | --body [--base ] [--head ] [--pregate ] [--dry-run] [--out ] - carry the receipt to HEAD when only a base merge, tests or release metadata changed - since its head (product patch-id unchanged): rewrites the receipt with a \`carry\` record, - posts the body (gh pr edit) and prints GSTACK_RESULT: skill=ship status=complete; - RECEIPT_CARRY_PRODUCT_CHANGED / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3); + carry the receipt to HEAD when only a base merge, tests, release metadata or registered + outputs the --pregate regen check proved (recreated them) changed since its head (product patch-id + unchanged), or after a rebase onto a tree the receipt covers with the recorded product + patch-id: rewrites the receipt with a \`carry\` record, posts the body (gh pr edit) and + prints GSTACK_RESULT: skill=ship status=complete; RECEIPT_CARRY_PRODUCT_CHANGED / + RECEIPT_CARRY_TREE_MISMATCH / RECEIPT_CARRY_REFUSED leave the body unchanged (exit 3); a receipt that records a pregate needs --pregate current for HEAD (RECEIPT_CARRY_PREGATE_STALE); needs git >= 2.39 (RECEIPT_GIT_TOO_OLD) docs-caps [--base ] /ship Step 14.5: one DOCS_CAP: / ok|over line per docs.caps entry of the @@ -134,6 +137,20 @@ function receiptVersion(repoRoot: string, head: string, base: string): string | return 'none'; } +/** + * What a later carry across rewritten history compares: the head's tree and its + * product patch-id against the base. Both are evidence, never a gate: a head + * outside a checkout (or a base that does not resolve) records neither, and + * such a receipt then carries only along its own history. + */ +function reviewedProduct(repoRoot: string, base: string, head: string, pregateFile: string | undefined): { head_tree?: string; product_patch_id?: string } { + const tree = git(['rev-parse', '--verify', '--quiet', `${head}^{tree}`], repoRoot); + if (!tree) return {}; + const baseRef = `origin/${base.replace(/^origin\//, '')}`; + const id = productPatchAgainstBase(repoRoot, productRules(repoRoot, base, verifiedOutputs(repoRoot, base, pregateFile)), baseRef, head); + return typeof id === 'string' ? { head_tree: tree, product_patch_id: id || 'none' } : { head_tree: tree }; +} + function cmdWrite(rest: string[], json: boolean): number { const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd(); const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot); @@ -194,7 +211,7 @@ function cmdWrite(rest: string[], json: boolean): number { session_kind: sessionKind, artifacts_consumed: flag(rest, '--artifacts-consumed'), run: flag(rest, '--run'), thread: flag(rest, '--thread'), policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, - review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), + review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile), }; } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; } @@ -367,13 +384,73 @@ function pregateFieldFor(repoRoot: string, file: string, head: string): { ok: tr return { ok: true, field: { pregate: `${read.file.verdict ?? 'pass'} @${read.file.tree.slice(0, 12)} (${read.file.checks.length} checks, current for ${head.slice(0, 12)})`, requires_remote: remote.length ? remote.map(r => `${r.lane}: ${r.receipt}`) : undefined } }; } +/** + * Generated outputs the carry may set aside as metadata: the paths the pregate + * passed for HEAD proved (its regen check deleted each touched output and the + * entry's command recreated it byte-identically), when that check ran + * origin/'s `.gstack/generated.json` at the commit this carry reads and the + * path is an output of an entry with a `command` there. A proven name with glob + * syntax is product too: the carry uses these paths as pathspec globs. + */ +function verifiedOutputs(repoRoot: string, base: string, pregateFile: string | undefined): string[] { + if (!pregateFile) return []; + const regen = readPregateFile(pregateFile).file?.checks.find(c => c.id === 'regen'); + if (!regen || regen.status !== 'pass' || !regen.proven?.length) return []; + const load = loadRegistryFromBase(repoRoot, base); + if (isRegistryUnavailable(load) || load.errors.length || regen.inputs?.registry !== load.source.label) return []; + const owned = load.registry.entries.filter(e => e.command).flatMap(e => e.outputs); + return regen.proven.filter(p => !/[*?[\]\\]/.test(p) && matchesAny(owned, p)); +} + +interface ProductRules { extra: { metadata: string[]; outputs: readonly string[] }; metadata: string[]; templates: readonly string[] } +function productRules(repoRoot: string, base: string, outputs: readonly string[]): ProductRules { + const load = loadPolicyFromBase(repoRoot, base); + const policy: ShipPolicy | null = isUnavailable(load) ? null : load.policy; + const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])], outputs }; + return { extra, metadata: [...CARRY_METADATA_GLOBS, ...extra.metadata, ...outputs], templates: policy?.stamp_patterns ?? [] }; +} + +/** + * The product pathspec between two commits: every path but tests and release + * metadata, where a metadata-listed path whose change from `from` to `to` is + * not a stamp stays product (every glob that matches it leaves the exclusion). + */ +function productSpec(repoRoot: string, rules: ProductRules, from: string, to: string): { changed: Array<{ path: string; class: ReturnType }>; product: string[]; tests: string[] } | undefined { + const changedRaw = git(['diff', '--name-only', from, to], repoRoot); + if (changedRaw === undefined) return undefined; + const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; + const versionAt = (ref: string): string | null => { + const file = blobAt(ref, 'VERSION'); + if (file !== null) return file.trim() || null; + try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; } + }; + const oldVersion = versionAt(from); + const newVersion = versionAt(to); + const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, rules.extra, matchesAny(rules.metadata, p) && !matchesAny(rules.extra.outputs, p) ? { before: blobAt(from, p), after: blobAt(to, p), oldVersion, newVersion, templates: rules.templates } : undefined) })); + const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); + const demoted = changed.filter(c => c.class === 'product' && matchesAny(rules.metadata, c.path)).map(c => c.path); + const metadataGlobs = rules.metadata.filter(g => !demoted.some(p => matchesAny([g], p))); + return { changed, product: ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)], tests: [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)] }; +} + +/** A commit's product patch-id against its merge base with the base ('' for no product diff), as write records it and a rewritten-history carry recomputes it. */ +function productPatchAgainstBase(repoRoot: string, rules: ProductRules, baseRef: string, commit: string): string | { error: string } { + const mergeBase = git(['merge-base', baseRef, commit], repoRoot); + if (!mergeBase) return { error: `cannot resolve the merge base of ${baseRef} and ${commit.slice(0, 12)}` }; + const spec = productSpec(repoRoot, rules, mergeBase, commit); + if (!spec) return { error: `git diff ${mergeBase.slice(0, 12)} ${commit.slice(0, 12)} failed` }; + return patchId(repoRoot, mergeBase, commit, spec.product); +} + /** * The receipt follows HEAD when the reviewed product is unchanged * (docs/unattended.md#the-terminal-line; harness lane E row 3). Four Captains * reran a full /ship on identical product code after a base merge or a * test-only fix; the carry compares the product diff against the base at the * receipt's head and at HEAD by patch-id, classifies every changed path, and - * records the range-diff as evidence in the receipt it rewrites. + * records the range-diff as evidence in the receipt it rewrites. After a rebase + * or force-push it carries only onto a tree the receipt covers whose product + * patch-id against the current base is the recorded one (planTreeCarry). */ function cmdCarry(rest: string[], json: boolean): number { const bodyFile = flag(rest, '--body'); @@ -384,7 +461,7 @@ function cmdCarry(rest: string[], json: boolean): number { if (!repoRoot) { process.stderr.write('gstack-ship-receipt carry: run it inside the git checkout whose head carries the receipt\n'); return EXIT.usage; } const head = (flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot))?.toLowerCase(); if (!head || !/^[0-9a-f]{40,64}$/.test(head)) { process.stderr.write('gstack-ship-receipt carry: cannot resolve HEAD\n'); return EXIT.usage; } - const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { + const refused = (code: 'QUEUE_RECEIPT_MISSING' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_TREE_MISMATCH' | 'RECEIPT_GIT_TOO_OLD' | 'RECEIPT_CARRY_PREGATE_STALE', message: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt carry', code, message) + '\n'); process.stdout.write(json ? JSON.stringify({ skill: 'ship', status: 'refused', run: '-', code, message }) + '\n' : `CARRY: refused (${code})\nGSTACK_RESULT: skill=ship status=refused run=-\n`); return EXIT.refused; @@ -425,66 +502,63 @@ function cmdCarry(rest: string[], json: boolean): number { } const oldGit = gitVersionBelow(repoRoot, CARRY_MIN_GIT); if (oldGit) return refused('RECEIPT_GIT_TOO_OLD', `git ${oldGit} cannot run patch-id --verbatim; the carry needs git ${CARRY_MIN_GIT} or later`); - const ancestor = spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }); - if (ancestor.status !== 0) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed); merge the base in next time, and run /ship now`); // The carry binds HEAD alone: pending edits are not in the commits it compares, and a parent that stops at CARRY: carried would report them shipped. const status = spawnSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); if (status.status !== 0) return refused('RECEIPT_CARRY_REFUSED', 'git status failed; the carry needs a readable working tree'); const dirty = status.stdout.split('\n').filter(Boolean).map(l => l.slice(3).replace(/^.* -> /, '')); if (dirty.length) return refused('RECEIPT_CARRY_REFUSED', `the working tree has uncommitted changes (${dirty.slice(0, 5).join(', ')}${dirty.length > 5 ? ` +${dirty.length - 5} more` : ''}); commit them and carry at the new head, or run /ship`); + const fromKnown = git(['cat-file', '-e', `${from}^{commit}`], repoRoot) !== undefined; + const ancestor = fromKnown && spawnSync('git', ['merge-base', '--is-ancestor', from, head], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000 }).status === 0; + const fromTree = fromKnown ? git(['rev-parse', `${from}^{tree}`], repoRoot) : undefined; + const covered = [...(receipt.head_tree ? [receipt.head_tree] : fromTree ? [fromTree] : []), ...(receipt.carry?.trees ?? [])]; + if (!ancestor && !covered.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's head ${from.slice(0, 12)} is not an ancestor of HEAD ${head.slice(0, 12)} (rebased or force-pushed), and the receipt records no tree to compare; merge the base in next time, and run /ship now`); const base = flag(rest, '--base') ?? receipt.base ?? resolveBaseBranch(repoRoot); const baseRef = `origin/${base.replace(/^origin\//, '')}`; - const fromBase = git(['merge-base', baseRef, from], repoRoot); const headBase = git(['merge-base', baseRef, head], repoRoot); - if (!fromBase || !headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); - const load = loadPolicyFromBase(repoRoot, base); - const policy = isUnavailable(load) ? null : load.policy; - const extra = { metadata: [...(policy?.stamp_files ?? []), ...(policy?.stamp_paths ?? []), ...(policy?.release_outputs ?? []), ...(policy?.changelog ? [policy.changelog] : [])] }; - const changedRaw = git(['diff', '--name-only', from, head], repoRoot); - if (changedRaw === undefined) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); - // Every metadata-listed path is read at both heads: only a stamp keeps it out of the product patch. - const blobAt = (ref: string, p: string): string | null => { const r = spawnSync('git', ['show', `${ref}:${p}`], { cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); return r.status === 0 ? r.stdout : null; }; - const versionAt = (ref: string): string | null => { - const file = blobAt(ref, 'VERSION'); - if (file !== null) return file.trim() || null; - try { const v = JSON.parse(blobAt(ref, 'package.json') ?? 'null')?.version; return typeof v === 'string' ? v : null; } catch { return null; } - }; - const oldVersion = versionAt(from); - const newVersion = versionAt(head); - const allMetadataGlobs = [...CARRY_METADATA_GLOBS, ...extra.metadata]; - const changed = changedRaw.split('\n').filter(Boolean).map(p => ({ path: p, class: classifyCarryPath(p, extra, matchesAny(allMetadataGlobs, p) ? { before: blobAt(from, p), after: blobAt(head, p), oldVersion, newVersion, templates: policy?.stamp_patterns ?? [] } : undefined) })); - // Pathspecs by class: tests and metadata are excluded from the product patch; the test patch is the tests alone. - // A metadata-listed path whose change was not a stamp stays in the product patch: every glob that matches it is dropped from the exclusion. - const excludeGlobs = (globs: readonly string[]) => globs.map(g => `:(exclude,glob)${g}`); - const demoted = changed.filter(c => c.class === 'product' && matchesAny(allMetadataGlobs, c.path)).map(c => c.path); - const metadataGlobs = allMetadataGlobs.filter(g => !demoted.some(p => matchesAny([g], p))); - const productSpec = ['.', ...excludeGlobs(CARRY_TEST_GLOBS), ...excludeGlobs(metadataGlobs)]; - const testSpec = [...CARRY_TEST_GLOBS.map(g => `:(glob)${g}`), ...excludeGlobs(metadataGlobs)]; - const productFrom = patchId(repoRoot, fromBase, from, productSpec); - const productHead = patchId(repoRoot, headBase, head, productSpec); - const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : ''); - const testFrom = asId(patchId(repoRoot, fromBase, from, testSpec)); - const testHead = asId(patchId(repoRoot, headBase, head, testSpec)); - for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`); - // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, - // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every - // path the base changed, none of which can differ between the two base diffs. - const changedProduct: string[] = []; - if (productFrom !== productHead) { - const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); - for (const p of [...touched].sort()) { - if (changedProduct.length >= CARRY_NAMED_PATHS) break; - const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, extra); - if (cls !== 'product') continue; - if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p); + if (!headBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); + const rules = productRules(repoRoot, base, verifiedOutputs(repoRoot, base, flag(rest, '--pregate'))); + const headTree = git(['rev-parse', `${head}^{tree}`], repoRoot); + if (!headTree) return refused('RECEIPT_CARRY_REFUSED', `git could not read the tree of HEAD ${head.slice(0, 12)}`); + const headPatch = productPatchAgainstBase(repoRoot, rules, baseRef, head); + if (typeof headPatch !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute HEAD's product patch-id: ${headPatch.error}`); + let plan: ReturnType; + if (!ancestor) { + // Rewritten history: the receipt follows only onto a tree it already covers, with the product diff it recorded. + const recorded = receipt.product_patch_id !== undefined ? (receipt.product_patch_id === 'none' ? '' : receipt.product_patch_id) : fromKnown ? productPatchAgainstBase(repoRoot, rules, baseRef, from) : { error: 'the receipt records no product patch-id and its head is not in this clone' }; + if (typeof recorded !== 'string') return refused('RECEIPT_CARRY_REFUSED', `the reviewed product patch-id is unavailable: ${recorded.error}; run /ship on ${head.slice(0, 12)}`); + plan = planTreeCarry({ from, head, headTree, coveredTrees: covered, productPatchId: { recorded, head: headPatch } }); + } else { + const fromBase = git(['merge-base', baseRef, from], repoRoot); + if (!fromBase) return refused('RECEIPT_CARRY_REFUSED', `cannot resolve the merge base of ${baseRef} (fetch it first)`); + const spec = productSpec(repoRoot, rules, from, head); + if (!spec) return refused('RECEIPT_CARRY_REFUSED', `git diff ${from.slice(0, 12)} ${head.slice(0, 12)} failed`); + const { changed } = spec; + const productFrom = patchId(repoRoot, fromBase, from, spec.product); + const productHead = patchId(repoRoot, headBase, head, spec.product); + const asId = (r: string | { error: string }) => (typeof r === 'string' ? r : ''); + const testFrom = asId(patchId(repoRoot, fromBase, from, spec.tests)); + const testHead = asId(patchId(repoRoot, headBase, head, spec.tests)); + for (const r of [productFrom, productHead]) if (typeof r !== 'string') return refused('RECEIPT_CARRY_REFUSED', `git could not compute the product patch-id at one of the heads: ${r.error}`); + // planCarry names at most CARRY_NAMED_PATHS differing paths, so the per-path comparison (two patch-ids each) stops there, + // and it walks only the paths the branch itself touched against the base at either head: a base merge brings in every + // path the base changed, none of which can differ between the two base diffs. + const changedProduct: string[] = []; + if (productFrom !== productHead) { + const touched = new Set([...(git(['diff', '--name-only', fromBase, from], repoRoot) ?? '').split('\n'), ...(git(['diff', '--name-only', headBase, head], repoRoot) ?? '').split('\n')].filter(Boolean)); + for (const p of [...touched].sort()) { + if (changedProduct.length >= CARRY_NAMED_PATHS) break; + const cls = changed.find(c => c.path === p)?.class ?? classifyCarryPath(p, rules.extra); + if (cls !== 'product') continue; + if (asId(patchId(repoRoot, fromBase, from, [p])) !== asId(patchId(repoRoot, headBase, head, [p]))) changedProduct.push(p); + } + if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); } - if (!changedProduct.length) changedProduct.push('(a product path outside the branch\'s touched files; compare the two base diffs)'); + const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); + // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. + const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); + const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; + plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff }); } - const mergeCommits = (git(['rev-list', '--merges', '--count', `${from}..${head}`], repoRoot) ?? '0').trim(); - // Evidence for the record, never the decision: a slow range-diff is reported unavailable rather than waited for. - const rangeRaw = spawnSync('git', ['range-diff', '--no-color', `${fromBase}..${from}`, `${headBase}..${head}`], { cwd: repoRoot, encoding: 'utf8', timeout: 15_000, maxBuffer: 64 * 1024 * 1024 }); - const rangeDiff = rangeRaw.status === 0 ? (rangeRaw.stdout.trim().split('\n').slice(0, 20).join('\n') || '(empty: identical ranges)') : `unavailable: ${(rangeRaw.stderr ?? '').trim().split('\n')[0] || (rangeRaw.signal ? `range-diff killed by ${rangeRaw.signal} after 15 s` : 'range-diff failed')}`; - const plan = planCarry({ from, head, changed, productPatchId: { from: productFrom, head: productHead }, testPatchId: { from: testFrom, head: testHead }, changedProduct, mergeCommits: Number(mergeCommits) || 0, rangeDiff }); if (!plan.ok) return refused(plan.code, plan.message); // Eligibility is not publication: a receipt that recorded a pregate is rebound only with a pregate current for HEAD, // so a carried receipt never claims the old tree's checks (docs/ship-policy.md#receipt-carry). @@ -499,7 +573,7 @@ function cmdCarry(rest: string[], json: boolean): number { } else if (receipt.pregate && !rest.includes('--dry-run')) { return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate `); } - const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry); + const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); const newBody = placeReceiptFirst(body, carried); diff --git a/captain/templates/CREW.md b/captain/templates/CREW.md index 64a01fc96..e6bdf2806 100644 --- a/captain/templates/CREW.md +++ b/captain/templates/CREW.md @@ -90,8 +90,9 @@ Homes are paths in the gstack checkout the lane checked at session start. 14. **Don't merge `BASE` into a READY PR.** Only on the controller's word after a real-code train conflict (merge, never rebase, free suite, READY again) or as part of the stamp - commit. Home: `docs/captain.md` (Merge trains), `docs/ship-policy.md` (Run a merge - train). + commit. A merge whose product diff is unchanged carries the /ship receipt; a rebase + onto a new tree costs a full /ship. Home: `docs/captain.md` (Merge trains), + `docs/ship-policy.md` (Run a merge train, Carrying a receipt). 15. **CHANGELOG entry under `## [Unreleased]` before READY;** version files stay at `BASE`'s version; the stamp touches only `STAMP_FILES`. Home: `docs/ship-policy.md` diff --git a/docs/captain.md b/docs/captain.md index b81988be2..7c0b7aadf 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -231,6 +231,14 @@ a push included. The sequence that works unattended on Capy: 10. After the slot call, the stamp commit keeps the pin's receipt; re-READY with the same receipt, no second /ship. +When the base moves under a reviewed branch, merge it in; never rebase. A merge that +leaves the product diff unchanged carries the receipt with `gstack-ship-receipt carry` +(pregate at HEAD, no review rerun), and a merge whose resolution changed product code +needs a fresh /ship. A rebase carries only onto a tree the receipt already covers with +the same product diff; any other rebase is a full /ship +([ship-policy.md](ship-policy.md#receipt-carry)). A controller never asks a lane to +rebase a reviewed branch. + ## Merge trains gstack's queue tool drives trains when the repository's `.gstack/ship-policy.json` carries diff --git a/docs/pregate.md b/docs/pregate.md index a42b37831..11740fcea 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -146,7 +146,12 @@ Two stages with separate clocks, both printed: ~2.5 s for `regen` and under 200 ms for each of the others): - `regen`: every registry entry whose inputs or outputs are touched is fresh - (`gstack-regen check` in a scratch worktree). + (`gstack-regen check` in a scratch worktree). A fresh entry with touched + outputs then runs once more with those outputs deleted; the ones the command + recreates byte-identically are the check's `proven` paths, which + `gstack-ship-receipt carry` reads. The verdict comes from the first run: an + output the second run does not recreate, or a second run that fails, is + simply not proven. - `secrets`: `gstack-redact` over the branch's added lines (merge-base to the working tree, untracked files included). HIGH, or MEDIUM in the `secret` category, fails; other MEDIUM findings warn. Cannot be downgraded. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index e2cd9f208..61e0059ef 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -777,7 +777,8 @@ written by the pr-body section of `/ship` after the body is composed. The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`, -`pregate`, `issues`, `approval`, `docs`, `review_loop`, `carry`), validated +`head_tree`, `product_patch_id`, `pregate`, `issues`, `approval`, `docs`, +`review_loop`, `carry`), validated against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and read back by `gstack-ship-receipt read`, `result`, `carry` and the queue. @@ -891,6 +892,16 @@ line, and the codes a refusal ends with are ## Carrying a receipt across a base merge or a test-only fix +**Merge main into a reviewed branch; never rebase it.** What happens to the +receipt when the base moves: + +| What changed since /ship | The receipt | +|---|---| +| a merge of the base, product diff unchanged (tests, release metadata, registered outputs the pregate's regen check proved) | carries; no review reruns | +| a merge of the base whose resolution changed product code | a fresh /ship (`RECEIPT_CARRY_PRODUCT_CHANGED`) | +| a rebase or force-push onto a tree the receipt covers, product diff against the new base unchanged | carries (`carry.ancestry: rewritten`) | +| any other rebase or force-push | a fresh /ship (`RECEIPT_CARRY_TREE_MISMATCH`) | + Since the release that added `review_scope`. A `/ship` receipt names the head it reviewed, so a conflict-only merge of the base or a test-only fix used to invalidate it and cost a full re-run on identical product code. @@ -921,11 +932,22 @@ newer tentative one the stamp moved (a `*_KEY_VERSION` written as the next minor, #3512; a version older than the old one or of another width is a hand edit). A logic change in a stamped source file is product. Lockfiles are product unless the policy names -them. A base merge contributes no product -change of its own. A receipt carried twice keeps the reviewed head as +them. An output of an entry with a `command` in origin/'s +`.gstack/generated.json` is `generated`, not product, only when the +`--pregate` result passed for HEAD lists it under the `regen` check's +`proven` paths (the check deleted the touched output in its scratch worktree +and the command recreated it byte-identically) and that check ran the same +registry, origin/ at the commit the carry reads. A file that matches an +output glob but that the command never writes is not proven. Without that +evidence, or for an entry with no command or one only the branch registers, +the output is product (U1 on gstack-evals paid a +full rerun because a merge changed its generated index's diff). A base merge +contributes no product change of its own. A receipt carried twice keeps the reviewed head as `carry.from` and adds up the merges and paths. The carried receipt records `carry: { from, reason, product_patch_id, changed_paths, merge_commits, -range_diff, covers, reruns }` with the `git range-diff` evidence, prints +range_diff, covers, reruns, ancestry, trees }` (`ancestry` is `linear` +along the receipt's own history, `rewritten` once any hop was a rebase; +`trees` lists the earlier covered trees) with the `git range-diff` evidence, prints `CARRY: carried from to ()` and the `GSTACK_RESULT: skill=ship status=complete` line, and its summary line ends with `carried_from=`. `CARRY: current` means the receipt already names HEAD; @@ -933,10 +955,25 @@ skill=ship status=complete` line, and its summary line ends with A changed product path, or a product patch-id that differs, is [`RECEIPT_CARRY_PRODUCT_CHANGED`](troubleshooting.md#receipt-carry-product-changed) -(exit 3; the refusal names the paths), and a receipt whose head is not in HEAD's -history (a rebase or force-push) or a working tree with uncommitted changes is -[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused): run `/ship` -on the new head. The queue's `ready`, `train build` and `verify --train` read the +(exit 3; the refusal names the paths), and a working tree with uncommitted +changes is [`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). + +**Rewritten history.** `write` records `head_tree` (the reviewed head's tree) +and `product_patch_id` (its product patch against the base), and every carry +rebinds both to the new head and keeps the earlier trees in `carry.trees`. +When the receipt's head is no longer in HEAD's history, the carry follows only +when HEAD's tree is one of those trees and HEAD's product patch-id against the +current base equals the recorded one; it records `carry.ancestry: rewritten` +and `range_diff: tree-equal`, and needs neither the old commit nor its +history, so a fresh clone carries too. The tree alone is not enough: a stacked +branch rebased onto main after its base closed unmerged keeps its tree while +its diff grows by the base's code, and that is `RECEIPT_CARRY_PRODUCT_CHANGED`. +Any other tree is +[`RECEIPT_CARRY_TREE_MISMATCH`](troubleshooting.md#receipt-carry-tree-mismatch), +which names HEAD's tree and the covered trees. A receipt from before +`head_tree` existed, whose head is not in this clone, is +`RECEIPT_CARRY_REFUSED`: run `/ship` on the new head. A refusal never +rewrites the body. The queue's `ready`, `train build` and `verify --train` read the carried receipt like any other. **Delta /ship: eligibility, then checks, then publication.** `/ship` Step 1 runs diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 353f367b4..64688a386 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -4694,29 +4694,49 @@ rebased; /ship's own rule is to merge the base in, never rebase a reviewed head. ### `RECEIPT_CARRY_REFUSED` **Meaning.** `gstack-ship-receipt carry` could not carry the PR's /ship receipt -to HEAD: the PR body has no valid receipt, the receipt's head is not an ancestor -of HEAD (a rebase or force-push dropped it), the working tree has uncommitted -or untracked changes (a carry certifies a commit, never a tree that differs from -it), or git could not compare the two product diffs or read the working tree's -status. The receipt in the body is unchanged and still names the old head, so -`gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. +to HEAD: the PR body has no valid receipt, the working tree has uncommitted or +untracked changes (a carry certifies a commit, never a tree that differs from +it), the receipt's head left HEAD's history and the receipt records no tree to +compare (a receipt written before `head_tree`, whose head is not in this +clone), or git could not compare the two product diffs or read the working +tree's status. The receipt in the body is unchanged and still names the old +head, so `gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. -**Fix.** Keep the reviewed head in HEAD's history (merge the base in instead of -rebasing), commit or stash every working-tree change, then rerun +**Fix.** Commit or stash every working-tree change, then rerun `gstack-ship-receipt carry --pr ` at the new head. When the history is -already rewritten, run /ship on the new head. +already rewritten and the receipt predates `head_tree`, run /ship on the new +head; next time merge the base in instead of rebasing. + + +### `RECEIPT_CARRY_TREE_MISMATCH` + +**Meaning.** The branch was rebased or force-pushed after /ship, so the +receipt's head is no longer in HEAD's history, and HEAD's tree is not a tree +the receipt covers (its `head_tree` or a tree in `carry.trees`). No review saw +this code. The refusal names HEAD's tree and the covered trees; the body is +unchanged. + +**Fix.** Run /ship on the new head. A rebase carries only onto a reviewed tree +whose product diff against the new base is unchanged; merging the base in +instead keeps every later carry available +([ship-policy.md](ship-policy.md#receipt-carry)). ### `RECEIPT_CARRY_PRODUCT_CHANGED` **Meaning.** Between the receipt's head and HEAD the product diff against the -base changed: at least one path that is neither a test, release metadata nor -part of the base merge has a different patch than the reviewed head had. The -reviewed verdict does not cover HEAD, so the receipt is not carried. The -refusal names the changed product paths. +base changed: at least one path that is neither a test, release metadata, a +registered output the pregate's regen check proved (listed under `proven`, +checked against the same base registry), nor part of the base merge has +a different patch than the reviewed head had. After a rebase it means HEAD has +the reviewed tree but the base moved under it, so the diff a reviewer would see +is not the reviewed one. The reviewed verdict does not cover HEAD, so the +receipt is not carried. The refusal names the changed product paths. **Fix.** Run /ship on the new head. The receipt carries only across a -conflict-only base merge, a test-only change or release metadata, which the +conflict-only base merge, a test-only change, release metadata, a registered +output the pregate's regen check proved, or a rebase onto a covered tree with +the recorded product diff, which the `carry` object in the carried receipt records with the product patch-id and the range-diff evidence ([unattended.md](unattended.md#the-terminal-line)). diff --git a/docs/unattended.md b/docs/unattended.md index 7418fceb4..8ebbc5791 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -225,9 +225,10 @@ and reruns carry with `--pregate .gstack/tmp/pregate.json`. `CARRY: carried from now names HEAD with a `carry` object holding the `git range-diff` evidence, and `ship-run.json` gains `carried_from` and `carry_reason`; the `review_loop` counters are kept, because the reviewed product diff is unchanged. `CARRY: -current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)` -or `(RECEIPT_CARRY_REFUSED)` with the refused result line means the product -diff changed or the reviewed head left HEAD's history, so /ship runs again; +current` means nothing moved. `CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)`, +`(RECEIPT_CARRY_TREE_MISMATCH)` or `(RECEIPT_CARRY_REFUSED)` with the refused +result line means the product diff changed, or history was rewritten onto a +tree no review saw, so /ship runs again; `(RECEIPT_GIT_TOO_OLD)` means the machine's git predates 2.39 and cannot run `patch-id --verbatim` ([ship-policy.md](ship-policy.md#receipt-carry)). diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index 4bd39e3c7..a42338611 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -69,6 +69,8 @@ export interface PregateCheck { stage?: 'preflight' | 'tests'; inputs?: Record; fix?: string; code?: string; lines?: string[]; // Lane D (GSTA-112): a non-pass names its cause; an environment cause carries its kind and evidence. cause?: 'diff' | 'environment'; environment?: { kind: string; evidence: string; proof?: 'launch' | 'output' }; + /** `regen`: the touched outputs the generator recreated byte-identically from a deleted copy (what a receipt carry may set aside as generated). */ + proven?: string[]; } /** A remote_gate receipt accepted from a stamp-only predecessor tree (lib/pregate/remote-receipt.ts): the gated tree, the tree it was accepted for, the policy, and every differing path with its stamp rule. */ export interface PregateReceiptSubstitution { gated_tree: string; accepted_for: string; policy: string; files: Array<{ path: string; rule: string }> } @@ -99,7 +101,9 @@ export interface ShipReceipt { issues?: { fixes: number[]; refs: number[]; flakes: Array<{ issue: number; run: string }>; related?: Array<{ pr: number; repo?: string; author: string; closes: number[]; supersedes?: boolean; credit?: boolean }> }; /** Harness lane E: the review loop's counters (gstack-review-scope summary) and the carry record when the receipt followed a head whose product diff was unchanged (gstack-ship-receipt carry). */ review_loop?: { passes: number; blocking_found: number; deferred: number; noted?: number; fixed?: number; scope?: string; fix_checks?: number; dispatches?: Record; est_input_tokens?: number }; - carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] }; + carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }; + /** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */ + head_tree?: string; product_patch_id?: string; } export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; @@ -203,7 +207,7 @@ export const ARTIFACT_SCHEMAS = { type: 'object', required: ['schema_version', 'tree', 'checks'], properties: { schema_version: { const: 1 }, run: str, tree: str, stage1_ms: num, stage2_ms: num, - checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } } } } }, + checks: { type: 'array', items: { type: 'object', required: ['id', 'status'], properties: { id: str, status: { enum: ['pass', 'fail', 'warn', 'incomplete', 'requires-remote', 'not_installed'] }, ms: num, detail: str, stage: { enum: ['preflight', 'tests'] }, inputs: { type: 'object' }, fix: str, code: str, lines: strList, cause: { enum: ['diff', 'environment'] }, environment: { type: 'object', required: ['kind', 'evidence'], properties: { kind: str, evidence: str } }, proven: strList } } }, head: str, base: str, generated_at: str, policy: str, policy_digest: str, policy_user_path: str, registry: str, verdict: str, scratch: str, requires_remote: { type: 'array', items: { type: 'object', required: ['lane', 'platform', 'tests', 'receipt'], properties: { lane: str, platform: str, workflow: str, job: str, tests: strList, receipt: nullableStr, cleared_by: str, command: str, substitution: { type: 'object', required: ['gated_tree', 'accepted_for', 'policy', 'files'], properties: { gated_tree: str, accepted_for: str, policy: str, files: { type: 'array', items: { type: 'object', required: ['path', 'rule'], properties: { path: str, rule: str } } } } } } } }, @@ -219,7 +223,8 @@ export const ARTIFACT_SCHEMAS = { pregate: { ...str, description: 'pass|warn|requires-remote cleared … @ from pregate.json, current for this head' }, requires_remote: strList, approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' }, review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } }, - carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList } }, + carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, + head_tree: str, product_patch_id: str, issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, }, }, diff --git a/lib/pregate/checks/regen.ts b/lib/pregate/checks/regen.ts index dd63dd003..4aef9c75e 100644 --- a/lib/pregate/checks/regen.ts +++ b/lib/pregate/checks/regen.ts @@ -23,7 +23,7 @@ function run(ctx: PregateContext): CheckResult { fix: 'rerun with --allow-repo-commands (what /ship passes)', lines: commands.map(c => `COMMAND: ${c.id}=${JSON.stringify(c.command)} outputs=${c.outputs.join(',')}`), }; } - const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), env: ctx.env, timeoutMs: ctx.timeoutMs }); + const { results } = runRegen({ repoRoot: ctx.repoRoot, registry: ctx.registry, mode: 'check', only: selected.map(e => e.id), prove: ctx.touched.map(t => t.path), env: ctx.env, timeoutMs: ctx.timeoutMs }); const lines = results.map(renderEntry); const stale = results.filter(r => r.status === 'stale'); const failed = results.filter(r => r.status === 'failed'); @@ -32,7 +32,8 @@ function run(ctx: PregateContext): CheckResult { if (depsMissing.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_DEPS_MISSING', detail: `dependencies missing; ${depsMissing[0]!.fix} (${depsMissing.length} entr${depsMissing.length === 1 ? 'y' : 'ies'} unverified: ${depsMissing.map(d => d.id).join(', ')})`, fix: depsMissing[0]!.fix }; if (failed.length) return { id: 'regen', stage: 'preflight', status: 'incomplete', inputs, lines, code: 'REGEN_COMMAND_FAILED', detail: `${failed.length} regen command(s) failed: ${failed.map(f => f.id).join(', ')}`, fix: failed.map(f => f.fix).join('; ') }; if (stale.length) return { id: 'regen', stage: 'preflight', status: 'fail', inputs, lines, code: 'REGEN_STALE', detail: `${stale.length} stale: ${stale.flatMap(s => s.changed.concat(s.outside)).join(' ')}`, fix: stale.map(s => s.fix).join('; ') }; - return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` }; + const proven = results.flatMap(r => r.proven ?? []).sort(); + return { id: 'regen', stage: 'preflight', status: 'pass', inputs, lines, ...(proven.length ? { proven } : {}), detail: `${results.length} entr${results.length === 1 ? 'y' : 'ies'} fresh${results.some(r => r.status === 'listed') ? ' (hand-regenerated outputs listed)' : ''}` }; } export const regenCheck: CheckDef = { id: 'regen', stage: 'preflight', tier: 1, downgradable: true, needsRepoCommands: true, run }; diff --git a/lib/regen.ts b/lib/regen.ts index 2a8538647..4760e5573 100644 --- a/lib/regen.ts +++ b/lib/regen.ts @@ -164,6 +164,8 @@ export interface EntryResult { exit?: number | null; stderr?: string; ms: number; /** The exact command an author runs to refresh the entry. */ fix: string; + /** `check` with `prove`, on a fresh entry: the deleted outputs the proof run recreated byte-identically (an output it never writes, or a proof run that failed, proves nothing). */ + proven?: string[]; } /** Working tree vs the seeded index in a scratch worktree: modified tracked files plus new untracked ones. */ @@ -214,6 +216,8 @@ export interface RunRegenOptions { only?: string[]; /** Restrict to entries whose inputs or outputs intersect these paths. */ touched?: string[]; + /** `check`: outputs to prove on a fresh entry: a second run with each one deleted first, the ones it recreates byte-identically come back as `proven`. The verdict comes from the first run alone. */ + prove?: readonly string[]; env?: NodeJS.ProcessEnv; timeoutMs?: number; } @@ -248,10 +252,22 @@ export function runRegen(o: RunRegenOptions): { results: EntryResult[]; wtree: s if (run.exit !== 0) { results.push({ id: e.id, command: e.command, status: 'failed', changed, outside, exit: run.exit, stderr: run.stderr, ms, fix: fixFor(e) }); continue; } if (o.mode === 'check') { const differs = changed.length > 0 || outside.length > 0; - if (differs && install) results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` }); - else results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) }); // Reset the scratch so the next entry's diff is its own. - git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']); + const reset = () => { git(cwd, ['checkout', '-q', '--', '.']); git(cwd, ['clean', '-fdq']); }; + reset(); + if (differs && install) { results.push({ id: e.id, command: e.command, status: 'deps-missing', changed, outside, exit: 0, ms, fix: `run ${install}, then rerun the check` }); continue; } + // The proof is a second run apart from the verdict: each touched output deleted first, the ones recreated + // byte-identically are proven, and a failure (a generator that reads its own output) proves nothing. + const probe = differs ? [] : (o.prove ?? []).filter(p => matchesAny(e.outputs, p) && fs.existsSync(path.join(cwd, p))); + let proven: string[] | undefined; + if (probe.length) { + for (const p of probe) fs.rmSync(path.join(cwd, p), { force: true }); + const proof = runCommand(cwd, e.command, o.env ?? process.env, timeout); + const redo = changedPaths(cwd); + proven = proof.exit === 0 ? probe.filter(p => !redo.includes(p)) : []; + reset(); + } + results.push({ id: e.id, command: e.command, status: differs ? 'stale' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e), ...(proven ? { proven } : {}) }); } else results.push({ id: e.id, command: e.command, status: changed.length ? 'written' : 'fresh', changed, outside, exit: 0, ms, fix: fixFor(e) }); } } finally { scratch?.remove(); } diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 8c7e752ea..51eccaa22 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -246,18 +246,23 @@ export const RELEASE_RESULT_CODES = { }, RECEIPT_CARRY_REFUSED: { anchor: 'receipt-carry-refused', - summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the receipt head is not an ancestor of HEAD (rebase or force-push), or the product diff could not be compared', - fix: 'merge the base in instead of rebasing, keep the reviewed head in HEAD\'s history, then rerun carry; otherwise run /ship on the new head', + summary: 'gstack-ship-receipt carry could not carry the /ship receipt to HEAD: the PR body has no receipt, the working tree has uncommitted changes, the receipt head is not an ancestor of HEAD and the receipt records no tree to compare, or the product diff could not be compared', + fix: 'commit or stash working-tree changes and rerun carry; when history was rewritten and the receipt predates head_tree, run /ship on the new head (merge the base in instead of rebasing next time)', }, PROSE_LINT_FOUND: { anchor: 'prose-lint-found', summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide', fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file', }, + RECEIPT_CARRY_TREE_MISMATCH: { + anchor: 'receipt-carry-tree-mismatch', + summary: 'the branch was rebased or force-pushed since its /ship receipt, and HEAD\'s tree is not a tree the receipt covers, so no review saw this code', + fix: 'run /ship on the new head; next time merge the base in instead of rebasing a reviewed branch (a rebase carries only onto a reviewed tree with the reviewed product diff)', + }, RECEIPT_CARRY_PRODUCT_CHANGED: { anchor: 'receipt-carry-product-changed', - summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix or release metadata), so the reviewed verdict does not cover HEAD', - fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata or the base merge and the product patch-id is unchanged', + summary: 'the product diff against the base changed between the receipt\'s head and HEAD (a product file differs beyond a base merge, a test-only fix, release metadata or a regenerated output), so the reviewed verdict does not cover HEAD', + fix: 'run /ship on the new head; the receipt carries only when every changed path is a test, release metadata, a registered output the pregate regen check proved, or the base merge, and the product patch-id is unchanged', }, DOCS_AUDIT_BLOCKED: { anchor: 'docs-audit-blocked', diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index 3dd216d86..aa86abf11 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -17,7 +17,7 @@ export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array = [ 'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', - 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry', + 'head_tree', 'product_patch_id', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry', ]; /** Keys in the fixed order so identifiers lead; undefined values are dropped. */ @@ -150,7 +150,8 @@ export const CARRY_METADATA_GLOBS: readonly string[] = ['VERSION', 'CHANGELOG.md /** How many differing product paths a RECEIPT_CARRY_PRODUCT_CHANGED refusal names, and how far the per-path patch-id walk goes. */ export const CARRY_NAMED_PATHS = 20; -export type CarryClass = 'product' | 'test' | 'metadata'; +/** `generated`: an output of a base-registry entry with a command, which the pregate for HEAD proved its command recreates (its inputs are product). */ +export type CarryClass = 'product' | 'test' | 'metadata' | 'generated'; export interface CarryPlanInput { /** The reviewed head the receipt names. */ from: string; head: string; @@ -164,8 +165,10 @@ export interface CarryPlanInput { mergeCommits: number; rangeDiff: string; } -export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[] } -export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED'; message: string }; +export interface CarryRecord { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' } +export type CarryPlan = { ok: true; carry: CarryRecord } | { ok: false; code: 'RECEIPT_CARRY_PRODUCT_CHANGED' | 'RECEIPT_CARRY_REFUSED' | 'RECEIPT_CARRY_TREE_MISMATCH'; message: string }; +const CARRY_COVERS = ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit']; +const CARRY_RERUNS = ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed']; /** * The file's text at the reviewed head and at HEAD, with the release version each head carries (from `VERSION`, else @@ -206,9 +209,10 @@ export function stampOnlyText(change: CarryChange): boolean { * the tree receipt. `change` carries the texts; a caller without them gets the * list-only answer. */ -export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[] } = {}, change?: CarryChange): CarryClass { +export function classifyCarryPath(rel: string, extra: { metadata?: readonly string[]; outputs?: readonly string[] } = {}, change?: CarryChange): CarryClass { const stamp = () => !change || (stampFileKind(rel) ? versionStampOnly(rel, change.before, change.after).stampOnly : stampOnlyText(change)); if (matchesAny(CARRY_METADATA_GLOBS, rel)) return !change || !stampFileKind(rel) || versionStampOnly(rel, change.before, change.after).stampOnly ? 'metadata' : 'product'; + if (matchesAny(extra.outputs ?? [], rel)) return 'generated'; if (matchesAny(extra.metadata ?? [], rel)) return stamp() ? 'metadata' : 'product'; if (matchesAny(CARRY_TEST_GLOBS, rel)) return 'test'; return 'product'; @@ -230,14 +234,45 @@ export function planCarry(input: CarryPlanInput): CarryPlan { if (input.mergeCommits > 0) reasons.push(input.mergeCommits === 1 ? 'base merge' : `${input.mergeCommits} base merges`); if (input.testPatchId.from !== input.testPatchId.head) reasons.push('test-only change'); if (input.changed.some(c => c.class === 'metadata')) reasons.push('release metadata'); + if (input.changed.some(c => c.class === 'generated')) reasons.push('regenerated outputs'); if (!reasons.length) reasons.push(input.changed.length ? 'no product change' : 'no content change'); return { ok: true, carry: { from: input.from, reason: reasons.join(' + '), product_patch_id: input.productPatchId.head || 'none', changed_paths: input.changed.slice(0, 50).map(c => `${c.class}:${c.path}`), merge_commits: input.mergeCommits, range_diff: input.rangeDiff, - covers: ['pre-landing review', 'adversarial review', 'gate integrity', 'documentation audit'], - reruns: ['CI at head (gstack-ship-queue ready requires it); test and helper code changed since the reviewed head runs there unreviewed'], + covers: CARRY_COVERS, reruns: CARRY_RERUNS, ancestry: 'linear', + }, + }; +} + +export interface TreeCarryInput { + from: string; head: string; + /** HEAD^{tree}, and every tree the receipt already covers (its head_tree plus earlier carries' trees). */ + headTree: string; coveredTrees: readonly string[]; + /** The product patch-id against the base the receipt recorded, and the one HEAD has against the current base. */ + productPatchId: { recorded: string; head: string }; +} + +/** + * The carry across rewritten history (a rebase or force-push): the receipt + * follows HEAD only when HEAD's tree is one the receipt already covers and the + * product diff against the current base has the patch-id the receipt recorded. + * The tree alone is not enough: a stacked branch rebased onto main after its + * base closed unmerged keeps its tree while its diff grows by the base's code. + */ +export function planTreeCarry(input: TreeCarryInput): CarryPlan { + if (!input.coveredTrees.includes(input.headTree)) { + return { ok: false, code: 'RECEIPT_CARRY_TREE_MISMATCH', message: `the receipt's head ${input.from.slice(0, 12)} is not an ancestor of HEAD ${input.head.slice(0, 12)} (rebased or force-pushed), and HEAD's tree ${input.headTree.slice(0, 12)} is not a tree the receipt covers (${input.coveredTrees.map(t => t.slice(0, 12)).join(', ') || 'none recorded'}); the receipt is unchanged: run /ship on ${input.head.slice(0, 12)}` }; + } + if (input.productPatchId.recorded !== input.productPatchId.head) { + return { ok: false, code: 'RECEIPT_CARRY_PRODUCT_CHANGED', message: `HEAD ${input.head.slice(0, 12)} has the reviewed tree, but its product diff against the current base differs from the one reviewed at ${input.from.slice(0, 12)} (the base itself moved under it); the receipt is unchanged: run /ship on ${input.head.slice(0, 12)}` }; + } + return { + ok: true, + carry: { + from: input.from, reason: 'rebase onto a reviewed tree', product_patch_id: input.productPatchId.head || 'none', + changed_paths: [], merge_commits: 0, range_diff: 'tree-equal', covers: CARRY_COVERS, reruns: CARRY_RERUNS, ancestry: 'rewritten', }, }; } @@ -247,14 +282,17 @@ export function planCarry(input: CarryPlanInput): CarryPlan { * recorded. A receipt carried before keeps the reviewed head as `from` and adds * this hop's merges and paths, so the record always names the head a review saw. */ -export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord): ShipReceipt { +export function carryReceipt(receipt: ShipReceipt, head: string, carry: CarryRecord, at: { tree: string; fromTree?: string; productPatchId: string }): ShipReceipt { const prior = receipt.carry; const merged: CarryRecord = !prior ? carry : { ...carry, from: prior.from, reason: prior.reason === carry.reason ? carry.reason : `${prior.reason} + ${carry.reason}`, merge_commits: prior.merge_commits + carry.merge_commits, changed_paths: [...prior.changed_paths, ...carry.changed_paths.filter(p => !prior.changed_paths.includes(p))].slice(0, 50), + ancestry: prior.ancestry === 'rewritten' ? 'rewritten' : carry.ancestry, }; - return orderReceipt({ ...receipt, head, carry: merged }); + const fromTree = receipt.head_tree ?? at.fromTree; + const trees = [...new Set([...(prior?.trees ?? []), ...(fromTree ? [fromTree] : [])])].filter(t => t !== at.tree).slice(-20); + return orderReceipt({ ...receipt, head, head_tree: at.tree, product_patch_id: at.productPatchId || 'none', carry: { ...merged, ...(trees.length ? { trees } : {}) } }); } /** The loop counters the factory scorecard reads (passes, blocking_found, deferred), as `write --review-loop` records them. */ diff --git a/package.json b/package.json index 66271567f..522b6b09a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.305", + "version": "1.91.306", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-regen.test.ts b/test/gstack-regen.test.ts index f718d6ec9..3f36b5084 100644 --- a/test/gstack-regen.test.ts +++ b/test/gstack-regen.test.ts @@ -9,7 +9,7 @@ import { afterEach, describe, expect, test } from 'bun:test'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { EXIT } from '../lib/headless-artifacts'; -import { missingDependencies, parseRegistry, selectEntries, validateRegistry } from '../lib/regen'; +import { missingDependencies, parseRegistry, runRegen, selectEntries, validateRegistry } from '../lib/regen'; import { BASE_REGISTRY, cleanup, commit, makeFixtureRepo, readFile, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; const repos: FixtureRepo[] = []; @@ -135,6 +135,21 @@ describe('gstack-regen (bin)', () => { expect(parsed.wtree).toMatch(/^[0-9a-f]{40}$/); }); + // Value: protects=the receipt carry's generated class (only outputs a generator recreates) and a generator that reads + // its own registered output (gen-skill-docs and qa/templates/functional-report-template.md); fails_when=the proof + // deletes outputs before the verdict run, or proves a path the command never wrote; seam=runRegen; no_claim=- + test('prove: a writer proves its touched output; a generator that reads its own output stays fresh and proves nothing', () => { + const registryOf = (repo: FixtureRepo) => parseRegistry(fs.readFileSync(path.join(repo.work, '.gstack/generated.json'), 'utf8')).registry; + const writer = fixture(); + commit(writer, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n", 'generated/digest.md': '# digest v1.1.0\n', 'generated/notes.md': '# hand-written\n' }); + expect(runRegen({ repoRoot: writer.work, registry: registryOf(writer), mode: 'check', only: ['digest'], prove: ['generated/digest.md', 'generated/notes.md', 'src/version.js'] }).results) + .toMatchObject([{ id: 'digest', status: 'fresh', proven: ['generated/digest.md'] }]); + const selfRead = fixture({ registry: { entries: [{ id: 'self', command: 'node selfread.js', outputs: ['generated/**'], inputs: ['selfread.js'] }] }, baseFiles: { 'selfread.js': "const fs=require('fs');fs.writeFileSync('generated/digest.md',fs.readFileSync('generated/digest.md','utf8'));\n" } }); + commit(selfRead, { 'generated/digest.md': '# hand edit\n' }); + expect(runRegen({ repoRoot: selfRead.work, registry: registryOf(selfRead), mode: 'check', prove: ['generated/digest.md'] }).results) + .toMatchObject([{ id: 'self', status: 'fresh', proven: [] }]); + }); + test('a failing command is failed (exit 1, REGEN_COMMAND_FAILED, status incomplete); an output outside the declared globs is stale', () => { const repo = fixture({ registry: { entries: [{ id: 'boom', command: 'node -e "process.exit(7)"', outputs: ['generated/**'] }, { id: 'leak', command: 'node -e "require(\'fs\').writeFileSync(\'docs/extra.md\',\'x\')"', outputs: ['generated/**'] }] } }); const r = runBin(repo, 'gstack-regen', ['check', '--base', 'main', '--allow-repo-commands']); diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index e3b96d77e..b9fa23a3b 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -48,8 +48,10 @@ describe('gstack-ship-receipt write', () => { const parsed = extractReceipt(ok.out); expect(parsed.found).toBe(true); expect(parsed.errors).toEqual([]); - expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history']); + expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id']); expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } }); + expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}')); + expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/); expect(parsed.receipt!.policy).toMatch(/^origin\/main@[0-9a-f]{12}:\.gstack\/ship-policy\.json$/); const written = fs.readFileSync(body, 'utf8'); expect(written.startsWith('```gstack-ship-receipt\n{\n "schema_version": 1,\n "pr": 42,\n "head": "' + head + '"')).toBe(true); diff --git a/test/ship-receipt-carry-rewrite.test.ts b/test/ship-receipt-carry-rewrite.test.ts new file mode 100644 index 000000000..c98a8ebf1 --- /dev/null +++ b/test/ship-receipt-carry-rewrite.test.ts @@ -0,0 +1,159 @@ +/** + * gstack-ship-receipt carry across rewritten history and regenerated outputs (GSTA-147 PR 1, + * docs/ship-policy.md#receipt-carry): a rebase carries only onto a tree the receipt covers with the + * product diff it recorded, and a base merge that changes only a registered output's diff carries when + * the pregate for HEAD regenerated that output fresh. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { EXIT } from '../lib/headless-artifacts'; +import { extractReceipt, planTreeCarry } from '../lib/ship-receipt'; +import { cleanup, commit, makeFixtureRepo, runBin, write, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; +const writeReceipt = (repo: FixtureRepo, body: string, extra: string[] = []) => + runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]); +const carry = (repo: FixtureRepo, body: string, extra: string[] = [], cwd = repo.work) => + runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', ...extra], {}, cwd); +function moveMain(repo: FixtureRepo, files: Record, message = 'main moves'): void { + repo.git('checkout', '-q', 'main'); + for (const [rel, text] of Object.entries(files)) write(repo.work, rel, text); + repo.git('add', '-A'); repo.git('commit', '-qm', message); repo.git('push', '-q', 'origin', 'main'); + repo.git('checkout', '-q', 'feature'); +} + +describe('carry across a rebase', () => { + // Value: protects=a rebase onto the reviewed tree keeps its receipt (Harbor paid ~60M for a byte-identical rebase); + // fails_when=carry demands ancestry, or needs the old commit object; seam=none; no_claim=gh pr edit (--body stands in) + test('a rebase onto the tree the receipt covers carries, from a fresh clone that never saw the old head', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + const reviewed = receiptIn(body); + expect(reviewed.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}')); + moveMain(repo, { 'docs/README.md': '# docs moved on main\n' }); + repo.git('merge', '-q', '--no-edit', 'origin/main'); + const merged = repo.git('rev-parse', 'HEAD'); + expect(carry(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body)).toMatchObject({ head: merged, head_tree: repo.git('rev-parse', 'HEAD^{tree}'), carry: { ancestry: 'linear', trees: [reviewed.head_tree] } }); + + repo.git('checkout', '-q', '-B', 'feature', 'origin/main'); + repo.git('cherry-pick', reviewed.head); + const rebased = repo.git('rev-parse', 'HEAD'); + expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(repo.git('rev-parse', `${merged}^{tree}`)); + repo.git('push', '-q', '-f', 'origin', 'feature'); + const clone = path.join(repo.root, 'clone'); + expect(spawnSync('git', ['clone', '-q', '--single-branch', '--branch', 'main', repo.origin, clone], { env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0); + expect(spawnSync('git', ['fetch', '-q', 'origin', 'feature:feature'], { cwd: clone, env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0); + expect(spawnSync('git', ['checkout', '-q', 'feature'], { cwd: clone, env: { ...process.env, ...repo.env }, timeout: 30_000 }).status).toBe(0); + expect(spawnSync('git', ['cat-file', '-e', `${merged}^{commit}`], { cwd: clone, timeout: 30_000 }).status).not.toBe(0); + + const out = carry(repo, body, [], clone); + expect(out.status).toBe(EXIT.ok); + expect(out.out).toContain(`CARRY: carried from ${merged.slice(0, 12)} to ${rebased.slice(0, 12)} (rebase onto a reviewed tree)`); + expect(receiptIn(body)).toMatchObject({ head: rebased, carry: { from: reviewed.head, reason: 'base merge + rebase onto a reviewed tree', ancestry: 'rewritten', range_diff: 'tree-equal' } }); + }, 90_000); + + // Value: protects=a rebase that changed the code takes a fresh /ship; fails_when=any non-ancestor head with a receipt + // carries; seam=none; no_claim=- + test('a rebase onto a different tree is RECEIPT_CARRY_TREE_MISMATCH and leaves the receipt unchanged', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + repo.git('checkout', '-q', '-B', 'feature', 'origin/main'); + const head = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 1;\n" }); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_TREE_MISMATCH)'); + expect(out.err).toContain(`HEAD's tree ${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} is not a tree the receipt covers`); + expect(out.err).toContain(`run /ship on ${head.slice(0, 12)}`); + expect(receiptIn(body).head).toBe(reviewed); + }, 60_000); + + // Value: protects=an identical tree on a moved base is not the reviewed diff (a stacked base closed unmerged); + // fails_when=tree equality alone carries; seam=planTreeCarry; no_claim=- + test('the reviewed tree on a base that moved under it is RECEIPT_CARRY_PRODUCT_CHANGED', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + moveMain(repo, { 'lib/retry.js': "exports.MAX_RETRIES = 4;\nexports.TIMEOUT_MS = 5000;\nexports.retry = (fn) => fn();\n" }); + repo.git('checkout', '-q', '-B', 'feature', 'origin/main'); + repo.git('checkout', reviewed, '--', '.'); + repo.git('commit', '-qam', 'same tree, new base'); + expect(repo.git('rev-parse', 'HEAD^{tree}')).toBe(receiptIn(body).head_tree ?? ''); + const out = carry(repo, body); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('CARRY: refused (RECEIPT_CARRY_PRODUCT_CHANGED)'); + expect(out.err).toContain('has the reviewed tree, but its product diff against the current base differs'); + expect(receiptIn(body).head).toBe(reviewed); + expect(planTreeCarry({ from: 'a'.repeat(40), head: 'b'.repeat(40), headTree: 't1', coveredTrees: ['t1'], productPatchId: { recorded: 'p1', head: 'p1' } })).toMatchObject({ ok: true, carry: { ancestry: 'rewritten' } }); + }, 60_000); +}); + +describe('carry across a base merge that changes a registered output', () => { + // Value: protects=U1's rerun (+28M): a base merge that changes only a generated output's diff carries once the regen + // check proves the output fresh; fails_when=outputs carry without regen evidence, or never carry; seam=none; no_claim=gh + test('the output is product without a regen-fresh pregate and metadata with one', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'src/version.js': "module.exports = { VERSION: '1.1.0' };\n", 'generated/digest.md': '# digest v1.1.0\n' }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + moveMain(repo, { + 'gen.js': "const fs=require('fs');const {VERSION}=require('./src/version.js');fs.writeFileSync('generated/digest.md',`# Digest v${VERSION}\\n`);\n", + 'generated/digest.md': '# Digest v1.0.0\n', + }); + spawnSync('git', ['merge', '-q', '--no-edit', 'origin/main'], { cwd: repo.work, env: { ...process.env, ...repo.env }, timeout: 30_000 }); + write(repo.work, 'generated/digest.md', '# Digest v1.1.0\n'); + repo.git('add', '-A'); repo.git('commit', '-qm', 'merge main; regenerate digest'); + + const bare = carry(repo, body, ['--dry-run']); + expect(bare.status).toBe(EXIT.refused); + expect(bare.err).toContain('generated/digest.md'); + expect(bare.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + + expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok); + const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json'); + const pregate = JSON.parse(fs.readFileSync(pregateJson, 'utf8')); + const regen = pregate.checks.find((c: { id: string }) => c.id === 'regen'); + expect(regen).toMatchObject({ status: 'pass', proven: ['generated/digest.md'] }); + const tampered = path.join(repo.root, 'pregate-tampered.json'); + const label = regen.inputs.registry; + for (const [registry, proven] of [['head (unreviewed)', ['generated/digest.md']], [label, ['generated/*']]]) { + Object.assign(regen, { proven, inputs: { ...regen.inputs, registry } }); + fs.writeFileSync(tampered, JSON.stringify(pregate)); + const refused = carry(repo, body, ['--pregate', tampered]); + expect(refused.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(refused.err).toContain('generated/digest.md'); + } + const out = carry(repo, body, ['--pregate', pregateJson]); + expect(out.status).toBe(EXIT.ok); + expect(receiptIn(body)).toMatchObject({ head: repo.git('rev-parse', 'HEAD'), carry: { from: reviewed } }); + }, 120_000); + + // Value: protects=a hand-written file under an output glob is reviewed code (red team, #3564 pass 1); fails_when=a + // regen pass that never wrote the path sets it aside as generated; seam=none; no_claim=- + test('a file that matches an output glob but that the generator never writes stays product', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': "exports.add = (a, b) => a + b + 0;\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + commit(repo, { 'generated/extra.md': '# hand-written after review\n' }); + expect(runBin(repo, 'gstack-pregate', ['--base', 'main', '--allow-repo-commands']).status).toBe(EXIT.ok); + const pregateJson = path.join(repo.work, '.gstack/tmp/pregate.json'); + const regen = JSON.parse(fs.readFileSync(pregateJson, 'utf8')).checks.find((c: { id: string }) => c.id === 'regen'); + expect(regen.status).toBe('pass'); + expect(regen.proven ?? []).not.toContain('generated/extra.md'); + const out = carry(repo, body, ['--pregate', pregateJson]); + expect(out.status).toBe(EXIT.refused); + expect(out.out).toContain('RECEIPT_CARRY_PRODUCT_CHANGED'); + expect(out.err).toContain('generated/extra.md'); + expect(receiptIn(body).head).toBe(reviewed); + }, 120_000); +}); From edf4c2e581e6e05b04545b15df00e2e4cedab23f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 21:55:01 -0700 Subject: [PATCH 06/19] v1.91.307.0 ship: proportional review depth (light, standard, full) with depth-checked receipts (#3567) * feat(ship-receipt): carry across regenerated outputs and onto a reviewed tree after a rebase A base merge whose only product-side change is a registered output the pregate regen check proved fresh now carries the receipt. A rebased head carries when its tree is one the receipt covers and its product patch-id against the current base matches (RECEIPT_CARRY_TREE_MISMATCH otherwise). Docs: merge main into a reviewed branch, never rebase it. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: regenerate doctor anchors; type the head_tree assert Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(review): proportional /ship review depth (light, standard, full) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: carry sets aside only outputs the regen check proved it recreates The regen check deletes each touched output in its scratch worktree before the command runs and records the ones recreated byte-identically as proven. gstack-ship-receipt carry exempts only those paths, and only when the check ran origin/'s registry at the commit the carry reads. A file under an output glob that the generator never writes stays product (pre-landing review, #3564 pass 1). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(ship-receipt): record review depth, check it in the queue, structured review-loop exception (#3559) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: prove generated outputs in a second regen run; literal names only The regen verdict comes from the unmodified run again, so a generator that reads its own registered output (gen-skill-docs and qa/templates/functional-report-template.md) passes as before; the proof is a second run with the touched outputs deleted, and its failure only means not proven. The carry ignores a proven path with glob syntax, since it uses the paths as pathspec globs (pre-landing review, #3564 pass 2). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: receipt evidence keys and carry record fields; carry refusal fix line Documentation audit for #3564 (/document-release, audit ship3564-docs-1). Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (depth scan binding, queue scope, carry checks) - carry refuses a --head other than the checked-out HEAD, a receipt with gstack_version but no review_depth, and an owner exception that no longer stands; a dirty or foreign checkout makes the write-time gate scan unavailable (full) - the queue binds a gate scan only to the full bound commit and recomputes the scope signals with gstack-diff-scope --head , the same range the writer uses - the queue checks the review-loop exception's fix-check ancestry - a package.json version line counts as stamp-only only when it is a bare version key - docs and help name what review_depth is and which registry the queue reads Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: CHANGELOG for the review fixes; defer two plan items to TODOS Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: queue and carry refusal lists name the depth and exception codes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: approval-classes summary-line regexes allow the receipt's depth field Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(ship-receipt): summary line prints depth before approval Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * Revert "test: approval-classes summary-line regexes allow the receipt's depth field" This reverts commit e9ec264358f0043feff6bf4522895c63769a1fe2. --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .gstack/ship-policy.json | 2 +- CHANGELOG.md | 19 ++ TODOS.md | 5 + VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-diff-scope | 21 ++ bin/gstack-doctor-components.sh | 3 + bin/gstack-review-plan | 79 ++++--- bin/gstack-ship-receipt | 44 +++- docs/review-policy.md | 106 +++++++-- docs/ship-policy.md | 66 +++++- docs/troubleshooting.md | 66 +++++- lib/headless-artifacts.ts | 9 +- lib/result-codes-release.ts | 15 ++ lib/review-collect.ts | 137 +++++++++++ lib/review-depth.ts | 219 ++++++++++++++---- lib/ship-queue-train.ts | 6 +- lib/ship-queue.ts | 6 +- lib/ship-receipt-depth.ts | 164 +++++++++++++ lib/ship-receipt.ts | 4 +- package.json | 2 +- scripts/resolvers/review-army.ts | 2 +- scripts/review-dispatch-compare.ts | 23 +- ship/SKILL.md | 10 +- ship/SKILL.md.tmpl | 10 +- ship/sections/adversarial.md | 3 +- ship/sections/adversarial.md.tmpl | 3 +- ship/sections/pr-body.md | 3 + ship/sections/pr-body.md.tmpl | 3 + ship/sections/review-army.md | 25 +- ship/sections/review-army.md.tmpl | 23 +- test/diff-scope.test.ts | 21 ++ test/fixtures/golden/agy-ship-SKILL.md | 10 +- test/fixtures/golden/claude-ship-SKILL.md | 10 +- test/fixtures/golden/codex-ship-SKILL.md | 10 +- test/fixtures/golden/factory-ship-SKILL.md | 10 +- test/gstack-ship-receipt.test.ts | 4 +- test/review-collect.test.ts | 119 ++++++++++ test/review-depth.test.ts | 130 ++++++++++- test/review-dispatch-compare.test.ts | 2 +- test/review-plan-cli.test.ts | 48 ++++ test/ship-receipt-depth.test.ts | 207 +++++++++++++++++ .../ship/gsta147-review-depth.json | 4 + 43 files changed, 1464 insertions(+), 193 deletions(-) create mode 100644 lib/review-collect.ts create mode 100644 lib/ship-receipt-depth.ts create mode 100644 test/review-collect.test.ts create mode 100644 test/ship-receipt-depth.test.ts create mode 100644 test/size-ledger/ship/gsta147-review-depth.json diff --git a/.gstack/ship-policy.json b/.gstack/ship-policy.json index d188eced9..d50acbc38 100644 --- a/.gstack/ship-policy.json +++ b/.gstack/ship-policy.json @@ -14,5 +14,5 @@ "pr_count": { "default_pr_count": 1, "split_requires_owner": true }, "maintainers": ["garrytan"], "train": { "gate_workflow": "evals.yml", "free_workflow": "free-tests.yml", "full_gate_check": "gstack/full-gate", "record_artifact": "gstack-train-record", "max_size": 3, "fill_hours": 1, "screen_selection": "gstack-paid", "trial_reservation_usd": 1.5 }, - "min_gstack_version": "1.91.146.0" + "min_gstack_version": "1.91.299.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index b490efc99..6a2cf2153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [1.91.307.0] - 2026-10-29 + +**/ship now reviews in proportion to what the diff can break: light, standard or full depth.** A diff with 50 or fewer runtime lines gets one batched specialist plus the native adversarial pass, a larger runtime diff gets the class's specialists scoped to their hunks, and gate code or any classifier error gets the full review as before. The receipt records the depth and the queue refuses a receipt below what the head needs. Before this, a 12-line fix paid for the same reviewer fan-out as a change to the merge gate. The backtest that would have let light depth drop the adversarial pass was inconclusive (9 light-eligible PRs, 5 with attributable findings, below the 15 the plan required), so light keeps it. + +### Added +- **Review depth** (`lib/review-depth.ts`, `lib/review-collect.ts`, `gstack-review-plan`): `REVIEW_DEPTH:` (depth, runtime lines, the rules hash, reasons), `GATE_SCAN:` bound to HEAD, one `DEPTH_ERROR:` per input that fails closed to full (with the input, the cause and the repair), and per-reviewer `DIFF_CMD:` pathspecs. Runtime lines leave out docs, tests and outputs registered with a command in the base branch's `.gstack/generated.json`, read at the base commit. `--depth-floor standard|full` only raises the depth. A fixed or open blocker, or a CRITICAL, raises light to standard for the rest of the invocation. +- **Receipt fields**: `gstack_version` and `review_depth` (`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`), computed at the checked-out head. `write --head` must be HEAD. `review_loop_exception` (`write --review-loop-exception`) records an owner-accepted stop after a fix-check edit with the fix-check's head and findings, and the PR body repeats its line word for word (Fixes #3559). +- **Queue checks** (`gstack-ship-queue ready`, `train build`): the depth the head needs is recomputed from git and a lower receipt is `QUEUE_RECEIPT_DEPTH_RAISED`, with the receipt's depth, the required depth, reasons and inputs on a `REVIEW_DEPTH:` line. A malformed owner exception, a body that dropped its line, or a fix-check head that is not the bound head or its ancestor is `QUEUE_RECEIPT_EXCEPTION_INVALID`. The queue reads the same committed range as the writer and trusts a recorded gate scan only when it names the full bound commit. A receipt with neither new field predates depth receipts and is accepted as legacy. + +### Changed +- **Carry and depth**: a carry recomputes the depth at the new head, rebinds the gate scan, and refuses when the new head needs more (a test change that skips a test is gate class). It also refuses a `--head` other than the checked-out HEAD, a receipt with `gstack_version` but no `review_depth`, and an owner exception that no longer stands, since the queue would refuse each of them. Tracked edits in the checkout make the write-time gate scan unavailable, which records `full`. +- **`gstack-diff-scope --head `**: the scope signals for the committed range only, with no working-tree or untracked files; an unknown sha is `SCOPE_ERROR=no_head` and a missing merge base `SCOPE_ERROR=no_merge_base`. +- **`.gstack/ship-policy.json`**: `min_gstack_version` rises from 1.91.146.0 to 1.91.299.0. + +### Tests +- `test/ship-receipt-depth.test.ts`: write records the depth bound to HEAD and refuses another `--head`. The queue refuses light on a standard head, a current receipt with no depth and an unbound gate scan, and accepts a legacy receipt. A plain test change carries and rebinds the scan, while a skipped test refuses the carry. The review-loop exception is refused without a fix-check, with a foreign head or without its body line, and the queue refuses a body that dropped the line. +- `test/review-depth.test.ts`, `test/review-collect.test.ts`, `test/review-plan-cli.test.ts`: the depth matrix, the fixed light set, fail-closed errors and the registry read at the base commit. A `package.json` version line that also carries other keys is not stamp-only. +- `test/diff-scope.test.ts`: `--head` reads the committed range and ignores working-tree edits, and refuses an unknown sha. + ## [1.91.306.0] - 2026-10-29 **A /ship receipt now survives a base merge that only regenerates outputs, and a rebase onto the tree it reviewed.** On gstack-evals, two full /ship reruns came from a merge of main that changed a generated file's diff (U1 paid about 28M tokens for its rerun), and one came from a rebase whose tree was byte-identical to the reviewed one (about 60M). Each re-reviewed code the receipt already covered. For crews the rule is now a single line. Merge main into a reviewed branch, and never rebase it. diff --git a/TODOS.md b/TODOS.md index d2be7ab44..edcf57447 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,11 @@ ## NEXT PRIORITY +### P1: review-depth receipt follow-ups (filed 2026-10-28, from the #3567 /ship plan audit) + +- **Derive `carry.covers` from depth.** A carried receipt's `covers` is the fixed `CARRY_COVERS` list (lib/ship-receipt.ts); derive it from the recorded `review_depth` so a light review's carry does not claim the checks a full review ran (plan item 5, deferred at /ship). **Effort:** S. **Priority:** P1. +- **Bound the queue's recompute time.** `gstack-ship-queue` recomputes each receipt's needed depth at READY and at train build; add a test that a five-PR train recomputes within a fixed budget, so a large diff cannot stall the train. **Effort:** S. **Priority:** P1. + ### P3: `/codex --effort` follow-ups (filed 2026-10-11, from the #3146 autoplan review) - **Show the effort that ran.** Step 0.5 prints `CODEX_EFFORT: (source: /codex --effort)` once, but the result footers do not repeat the effort, so it scrolls away. Add an `Effort:` line to the review, challenge and consult result footers. Blocked on skeleton bytes in `codex/SKILL.md` (cap 60,200). **Effort:** S. **Priority:** P3. diff --git a/VERSION b/VERSION index 98b9266b3..019ebe885 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.306.0 +1.91.307.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 74c007ef9..d30192102 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.306.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.307.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-diff-scope b/bin/gstack-diff-scope index 2fa10b41d..3ec81cf09 100755 --- a/bin/gstack-diff-scope +++ b/bin/gstack-diff-scope @@ -2,6 +2,9 @@ # gstack-diff-scope — categorize what changed in the diff against a base branch # Usage: source <(gstack-diff-scope main) → sets SCOPE_FRONTEND=true SCOPE_BACKEND=false ... # Or: gstack-diff-scope main → prints SCOPE_*=... lines +# Or: gstack-diff-scope --head → the committed range ... only +# (no working tree, no untracked files): what a receipt writer and the +# merge queue compute for one commit, from any checkout (GSTA-147). # # Output contract (#2526 — all-false must be distinguishable from "we could # not look" and from "nothing matched"): @@ -31,6 +34,8 @@ _default_base() { echo "main" } BASE="${1:-$(_default_base)}" +HEAD_REF="" +if [ "${2:-}" = "--head" ]; then HEAD_REF="${3:-}"; fi FRONTEND=false BACKEND=false @@ -79,6 +84,21 @@ _collect() { [ -n "$f" ] && FILES_LIST+=("$f") done } +if [ -n "$HEAD_REF" ] || [ "${2:-}" = "--head" ]; then + if ! git rev-parse --verify -q "${HEAD_REF}^{commit}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_head" + echo "# --head '${HEAD_REF}' is not a commit in this repository" + exit 2 + fi + if ! git merge-base "${BASE}" "${HEAD_REF}" >/dev/null 2>&1; then + _print_flags + echo "SCOPE_ERROR=no_merge_base" + echo "# '${BASE}' and '${HEAD_REF}' share no merge base" + exit 2 + fi + _collect < <(git diff -z "${BASE}...${HEAD_REF}" --name-only 2>/dev/null) +else # Committed diff vs base (merge-base form; two-dot fallback when no merge base). _collect < <(git diff -z "${BASE}...HEAD" --name-only 2>/dev/null || git diff -z "${BASE}" --name-only 2>/dev/null || true) # Working-tree changes (staged + unstaged). `git diff HEAD` fails on a repo @@ -87,6 +107,7 @@ _collect < <(git diff -z HEAD --name-only 2>/dev/null || true) # Untracked files: a brand-new component/migration/test is exactly what a # reviewer should see, and /ship commits it in Step 15 regardless. _collect < <(git ls-files -z --others --exclude-standard 2>/dev/null || true) +fi if [ "${#FILES_LIST[@]}" -eq 0 ]; then _print_flags diff --git a/bin/gstack-doctor-components.sh b/bin/gstack-doctor-components.sh index af0815ec9..dda793216 100644 --- a/bin/gstack-doctor-components.sh +++ b/bin/gstack-doctor-components.sh @@ -286,6 +286,8 @@ gstack_result_anchor() { QUEUE_TRAIN_FROZEN) printf '%s' 'queue-train-frozen' ;; QUEUE_RECEIPT_MISSING) printf '%s' 'queue-receipt-missing' ;; QUEUE_RECEIPT_STALE) printf '%s' 'queue-receipt-stale' ;; + QUEUE_RECEIPT_DEPTH_RAISED) printf '%s' 'queue-receipt-depth-raised' ;; + QUEUE_RECEIPT_EXCEPTION_INVALID) printf '%s' 'queue-receipt-exception-invalid' ;; QUEUE_SCREEN_STALE_REF) printf '%s' 'queue-screen-stale-ref' ;; QUEUE_SCREEN_NOT_RUN) printf '%s' 'queue-screen-not-run' ;; QUEUE_TRAIN_STACK_RED) printf '%s' 'queue-train-stack-red' ;; @@ -306,6 +308,7 @@ gstack_result_anchor() { REVIEW_SCOPE_RANGE) printf '%s' 'review-scope-range' ;; RECEIPT_CARRY_REFUSED) printf '%s' 'receipt-carry-refused' ;; PROSE_LINT_FOUND) printf '%s' 'prose-lint-found' ;; + REVIEW_LOOP_EXCEPTION_INVALID) printf '%s' 'review-loop-exception-invalid' ;; RECEIPT_CARRY_TREE_MISMATCH) printf '%s' 'receipt-carry-tree-mismatch' ;; RECEIPT_CARRY_PRODUCT_CHANGED) printf '%s' 'receipt-carry-product-changed' ;; DOCS_AUDIT_BLOCKED) printf '%s' 'docs-audit-blocked' ;; diff --git a/bin/gstack-review-plan b/bin/gstack-review-plan index a5424c25a..06f3e3976 100755 --- a/bin/gstack-review-plan +++ b/bin/gstack-review-plan @@ -4,20 +4,25 @@ * * gstack-review-plan --base [--pass ] [--last none|full|fix-check] [--fixed-blocking] [--fixed-below] * [--blocking-open] [--touched-gate] [--critical-found] [--adversarial-ran] [--gate-tagged ] [--gated a,b] [--force a,b|all] - * [--reviewed-numbers ] [--no-tiers] [--json] + * [--reviewed-numbers ] [--depth-floor standard|full] [--no-tiers] [--json] * gstack-review-plan --fix-check-prompt --fix-list --hunks --tests [--callers ] * - * Reads the diff against the merge base with (committed, working tree and - * untracked), runs gstack-diff-scope and, unless --gate-tagged is given, - * gstack-gate-diff; prints REVIEW_CLASS:, REVIEW_PLAN:, one DISPATCH: line per - * reviewer and one SKIP: line per reviewer left out. Logic: lib/review-depth.ts. + * Reads the diff against the merge base with (committed, tracked uncommitted + * edits and untracked files, through lib/review-collect.ts collectLive), runs + * gstack-diff-scope and, unless --gate-tagged is given, gstack-gate-diff; prints + * REVIEW_CLASS:, REVIEW_DEPTH: (depth, runtime lines, the rules hash, reasons and + * result codes), GATE_SCAN:, REVIEW_PLAN:, one DEPTH_ERROR: per input that failed + * the depth closed, one DISPATCH: plus DIFF_CMD: (and DIFF_EXCLUDE: none) per + * reviewer and one SKIP: per reviewer left out. A plan that fails closed to full + * exits 0; a diff git cannot list exits 1. Logic: lib/review-depth.ts. */ -import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { spawnSync } from 'node:child_process'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; import { runScriptSync } from '../lib/script-invocation'; -import { planReview, reviewPlanLine, scanNumbers, type History } from '../lib/review-depth'; +import { collectLive } from '../lib/review-collect'; +import { diffCommand, planReview, reviewDepthLine, reviewPlanLine, scanNumbers, type Depth, type History } from '../lib/review-depth'; import { renderFixCheckPrompt } from '../scripts/resolvers/fix-check'; const HELP = `gstack-review-plan — the review depth of the next /ship step (docs/review-policy.md#review-depth) @@ -25,19 +30,23 @@ const HELP = `gstack-review-plan — the review depth of the next /ship step (do --base the base branch (merge base with HEAD is the diff base); required --pass full review passes already run in this invocation (default 0) --last none|full|fix-check the step that just finished (default none: plan pass 1) - --fixed-blocking the finished step fixed a blocking finding + --fixed-blocking the finished step fixed a blocking finding (raises light to standard) --fixed-below the finished step fixed findings below the floor --blocking-open a blocking finding is still open in fix scope --touched-gate a fix touched a gate-class path - --critical-found a specialist reported a CRITICAL finding (Red Team on code class) + --critical-found a specialist reported a CRITICAL finding (Red Team on code class; raises light to standard) --adversarial-ran Step 11's native adversarial pass already ran (a repeat skips it) --gate-tagged gate hunks the gate scan tagged (default: run gstack-gate-diff; failure means unavailable) --gated a,b specialists the hit-rate stats tag GATE_CANDIDATE --force a,b|all specialists forced by prompt flags (--all-specialists is all) --reviewed-numbers the numeric-claim fingerprint the outside voice already reviewed + --depth-floor standard|full raise the depth to at least this (never lowers it); /ship passes its prompt flag on every run --no-tiers audits and watchers at the host's default effort too (as gstack-config review_effort_tiers off does) - --json the plan as JSON + --json the plan as JSON (depth, runtime_lines, rules, errors with input and repair, gate_scan, per-dispatch exclude) --fix-check-prompt print the fix-check reviewer's prompt from --fix-list, --hunks, --tests and --callers files + +Depth: light (runtime lines <= 50), standard (above 50), full (gate class or any DEPTH_ERROR). A full plan +from an error exits 0; exit 1 means no plan could be built (git could not list the diff). ${exitTableHelp()}`; const args = process.argv.slice(2); @@ -65,34 +74,17 @@ function main(): number { if (!['none', 'full', 'fix-check'].includes(last)) { process.stderr.write('gstack-review-plan: --last must be none, full or fix-check\n'); return EXIT.usage; } const pass = Number(flag('--pass') ?? '0'); if (!Number.isInteger(pass) || pass < 0) { process.stderr.write('gstack-review-plan: --pass needs an integer >= 0\n'); return EXIT.usage; } + const floor = flag('--depth-floor') as Depth | undefined; + if (has('--depth-floor') && floor !== 'standard' && floor !== 'full') { process.stderr.write('gstack-review-plan: --depth-floor must be standard or full (a floor only raises the depth)\n'); return EXIT.usage; } const root = git(['rev-parse', '--show-toplevel'])?.trim(); if (!root) { process.stderr.write('gstack-review-plan: run it inside a git checkout\n'); return EXIT.fail; } process.chdir(root); - const ref = git(['rev-parse', '--verify', '-q', `origin/${base}`]) !== null ? `origin/${base}` : base; - const mergeBase = git(['merge-base', ref, 'HEAD'])?.trim(); - if (!mergeBase) { process.stderr.write(`gstack-review-plan: no merge base with ${ref} (fetch the base first)\n`); return EXIT.fail; } - const nameStatusRaw = git(['diff', '--name-status', '--no-renames', mergeBase]); - const untrackedRaw = git(['ls-files', '--others', '--exclude-standard']); - const numstatRaw = git(['diff', '--numstat', mergeBase]); - if (nameStatusRaw === null || untrackedRaw === null || numstatRaw === null) { process.stderr.write('gstack-review-plan: git could not list the diff; no plan is safer than a plan for an empty diff\n'); return EXIT.fail; } - const scratch = (p: string) => p.startsWith('.gstack/tmp/'); - const nameStatus = nameStatusRaw.split('\n').filter(Boolean).map(l => l.split('\t')).filter(c => !scratch(c[1] ?? '')); - const untracked = untrackedRaw.split('\n').filter(Boolean).filter(p => !scratch(p)); - const paths = [...new Set([...nameStatus.map(c => c[1]!).filter(Boolean), ...untracked])].sort(); - const deleted = nameStatus.filter(c => c[0] === 'D').map(c => c[1]!); - const stampOnly = paths.filter(p => p.endsWith('package.json') && !untracked.includes(p)).filter(p => { - const changed = (git(['diff', '-U0', '--no-color', mergeBase, '--', p]) ?? '').split('\n').filter(l => /^[+-]/.test(l) && !/^(\+\+\+|---) /.test(l)); - return changed.length > 0 && changed.every(l => /^[+-]\s*"version"\s*:/.test(l)); - }); - const diffLines = numstatRaw.split('\n').reduce((n, l) => { const [a, d] = l.split('\t'); return n + (Number(a) || 0) + (Number(d) || 0); }, 0) - + untracked.reduce((n, p) => { try { return n + fs.readFileSync(p, 'utf8').split('\n').length; } catch { return n; } }, 0); - const added: Record = {}; - let current = ''; - for (const line of (git(['diff', '-U0', '--no-color', mergeBase]) ?? '').split('\n')) { - if (line.startsWith('+++ b/')) { current = line.slice(6); continue; } - if (line.startsWith('+') && !line.startsWith('+++') && current) (added[current] ??= []).push(line.slice(1)); + const collected = collectLive(root, base); + if (collected.errors.length) { + for (const e of collected.errors) process.stderr.write(`gstack-review-plan: git could not list the diff (${e.input}: ${e.detail}); fix: ${e.repair}; no plan is safer than a plan for an empty diff\n`); + return EXIT.fail; } - for (const p of untracked) { try { added[p] = fs.readFileSync(p, 'utf8').split('\n'); } catch { /* unreadable: no claims */ } } + const head = git(['rev-parse', 'HEAD'])?.trim() ?? 'unknown'; const binDir = path.dirname(Bun.fileURLToPath(import.meta.url)); const scopeRun = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base], { encoding: 'utf8', timeout: 60_000 }); const scope: Record = {}; @@ -109,14 +101,25 @@ function main(): number { const force = flag('--force') === 'all' ? 'all' as const : list('--force'); const tiersSetting = runScriptSync(path.join(binDir, 'gstack-config'), ['get', 'review_effort_tiers'], { encoding: 'utf8', timeout: 15_000 }); const auditEffort = has('--no-tiers') || (tiersSetting.stdout ?? '').trim() === 'off' ? 'host' : 'light'; + const { added, diffLines, paths, deleted, stampOnly, perPath, registry } = collected; const plan = planReview({ - paths, deleted, stampOnly, root, scope, gateTagged, diffLines, numbers: scanNumbers(added), force, gated: list('--gated'), + ...collected, scope, gateTagged, numbers: scanNumbers(added), force, gated: list('--gated'), floor, criticalFound: has('--critical-found'), adversarialRan: has('--adversarial-ran'), reviewedNumbers: flag('--reviewed-numbers'), history: { fullPasses: pass, last, fixedBlocking: has('--fixed-blocking'), fixedBelow: has('--fixed-below'), blockingOpen: has('--blocking-open'), touchedGate: has('--touched-gate') }, }); - if (has('--json')) { process.stdout.write(JSON.stringify({ ...plan, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); return EXIT.ok; } - const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`]; - for (const d of plan.dispatches) out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}`); + const gateScan = { tagged: gateTagged, head }; + if (has('--json')) { + const { runtimeLines, stepDepth, depthReasons, ...rest } = plan; + process.stdout.write(JSON.stringify({ ...rest, step_depth: stepDepth, runtime_lines: runtimeLines, depth_reasons: depthReasons, depth_floor: floor ?? null, gate_scan: gateScan, registry: { sha: registry.sha, outputs: registry.outputs }, per_path: perPath, diff_lines: diffLines, pass, audit_effort: auditEffort, scope, gate_tagged: gateTagged, paths, deleted, stamp_only: stampOnly }) + '\n'); + return EXIT.ok; + } + const out = [`REVIEW_CLASS: ${plan.class} (${plan.reasons.slice(0, 3).join('; ') || 'no signals'})`, reviewDepthLine(plan), `GATE_SCAN: ${gateTagged === null ? 'unavailable' : `tagged=${gateTagged}`} head=${head}`, reviewPlanLine(plan, pass + (plan.next.step === 'full' ? 1 : 0)), `NEXT: ${plan.next.step} (${plan.next.reason})`]; + for (const e of plan.errors) out.push(`DEPTH_ERROR: ${e.code} input=${e.input} detail=${e.detail}; fix: ${e.repair}${e.result ? ` (${e.result})` : ''}`); + for (const d of plan.dispatches) { + const key = d.name.split(' ')[0]; + out.push(`DISPATCH: ${d.role} ${d.name} effort=${d.effort}${d.checklists ? ` checklists=${d.checklists.join(',')}` : ''}${d.reason ? ` reason=${d.reason}` : ''}`, `DIFF_CMD: ${key} ${diffCommand(d)}`); + if (!d.exclude.length) out.push(`DIFF_EXCLUDE: ${key} none`); + } for (const s of plan.skipped) out.push(`SKIP: ${s}`); out.push(`AUDIT_EFFORT: ${auditEffort} (coverage, plan-completion and docs audits, decision-trail reviewers, watchers; reviewers above stay full)`); process.stdout.write(out.join('\n') + '\n'); diff --git a/bin/gstack-ship-receipt b/bin/gstack-ship-receipt index d3e2c342b..6d50425b1 100755 --- a/bin/gstack-ship-receipt +++ b/bin/gstack-ship-receipt @@ -36,7 +36,7 @@ import { EXIT, exitTableHelp, type ShipReceipt } from '../lib/headless-artifacts import { resultLine, resultLineWithDocs } from '../lib/result-codes'; import { versionAtRef } from '../lib/restamp'; import { readPregateFile, verifyPregate } from '../lib/pregate/index'; -import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; +import { isUnavailable, loadPolicyFromBase, matchesAny, policyUnavailableLine, resolveBaseBranch, runningGstackVersion, stampFilesInTree, validatePolicy, type ShipPolicy } from '../lib/ship-policy'; import { approvalForReceipt } from '../lib/approval-citations'; import { CARRY_METADATA_GLOBS, CARRY_NAMED_PATHS, CARRY_TEST_GLOBS, @@ -44,6 +44,7 @@ import { receiptIssuesFrom, receiptSummaryLine, renderCensus, renderReceiptBlock, validateReceipt, type CensusPr, } from '../lib/ship-receipt'; import { checkDocsCaps, docsCapLine, docsExceptionLine, parseReceiptDocs } from '../lib/ship-receipt-docs'; +import { deeper, depthBelow, exceptionProblems, parseReviewLoopException, receiptDepthAtHead, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; import { checkReceiptProse, proseLine } from '../lib/ship-receipt-prose'; import { resolveStateRoot } from '../lib/state-root'; import { isRegistryUnavailable, loadRegistryFromBase } from '../lib/regen'; @@ -83,6 +84,11 @@ write flags: --pr --head --base --version --gated-tree --predecessor --gate ({passes, blocking_found, deferred[, noted, fixed, scope]}: gstack-review-scope summary --out) --docs ({status: updated|current|blocked, scope[, exception: {accepted_by, date, via, reason}]}: Step 14.5's outcome; blocked without an exception is DOCS_AUDIT_BLOCKED, and with --body the body must carry the exception line verbatim) + --review-loop-exception ({accepted_by, date, via, reason, fix_check: {head, findings[, files]}}: the owner accepted a + stop after a fix-check edit; needs --review-loop with fix_checks >= 1, fix_check.head an ancestor of the head, and with --body the + "Review-loop exception: …" line verbatim; else REVIEW_LOOP_EXCEPTION_INVALID). Every receipt records gstack_version and + review_depth: the depth HEAD needs, recomputed from the committed range (--head must be HEAD; a dirty checkout or an + unreadable input records full). --approval-run (repeatable; resolves plan-sha citations) (W2: a class declared in origin/'s .gstack/approval-classes.json with hits needs a citation that resolves and binds to its effects: APPROVAL_CITATION_MISSING) --out --body . Defaults: head=HEAD, base=origin/HEAD's branch, version=the @@ -153,8 +159,14 @@ function reviewedProduct(repoRoot: string, base: string, head: string, pregateFi function cmdWrite(rest: string[], json: boolean): number { const repoRoot = git(['rev-parse', '--show-toplevel']) ?? process.cwd(); - const head = flag(rest, '--head') ?? git(['rev-parse', 'HEAD'], repoRoot); + const checkedOut = git(['rev-parse', 'HEAD'], repoRoot); + const head = flag(rest, '--head') ?? checkedOut; if (!head) { process.stderr.write('gstack-ship-receipt write: --head is required outside a git repository\n'); return EXIT.usage; } + const headSha = git(['rev-parse', '--verify', '--quiet', `${head}^{commit}`], repoRoot); + if (checkedOut && headSha !== checkedOut) { + process.stderr.write(resultLine('gstack-ship-receipt write', 'RECEIPT_INVALID', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${checkedOut.slice(0, 12)}; the receipt binds the reviewed tree, so check out ${head.slice(0, 12)} and write there; nothing written`) + '\n'); + return EXIT.fail; + } const receiptFile = flag(rest, '--receipt'); const tree = receiptFile ? JSON.parse(fs.readFileSync(receiptFile, 'utf8')) : null; let sessionKind = flag(rest, '--session-kind') ?? process.env.GSTACK_SESSION_KIND; @@ -182,6 +194,19 @@ function cmdWrite(rest: string[], json: boolean): number { return EXIT.refused; } } + let loopException: ReturnType | undefined; + if (flag(rest, '--review-loop-exception') !== undefined) { + const refuse = (why: string) => { process.stderr.write(resultLineWithDocs('gstack-ship-receipt write', 'REVIEW_LOOP_EXCEPTION_INVALID', `${why}; nothing written`) + '\n'); return EXIT.fail; }; + try { loopException = parseReviewLoopException(jsonArg(flag(rest, '--review-loop-exception'))); } catch (e: any) { return refuse(`--review-loop-exception: ${e.message}`); } + if (!loopException.ok) return refuse(loopException.message); + let loop: unknown; try { loop = jsonArg(flag(rest, '--review-loop')); } catch {} + if (!parseReviewLoop(loop)?.fix_checks) return refuse('--review-loop-exception needs --review-loop with fix_checks >= 1: the stop it accepts follows a fix-check'); + const fc = loopException.exception.fix_check.head; + if (spawnSync('git', ['merge-base', '--is-ancestor', fc, head], { cwd: repoRoot }).status !== 0) return refuse(`fix_check.head ${fc.slice(0, 12)} is not ${head.slice(0, 12)} or an ancestor of it`); + const line = reviewLoopExceptionLine(loopException.exception); + if (bodyFile && !bodyText.split(/\r?\n/).some(l => l.trim() === line)) return refuse(`${bodyFile} does not carry the exception line verbatim: ${JSON.stringify(line)}`); + } + const reviewDepth = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), headSha ?? head); const approval = approvalForReceipt({ repoRoot, base, head, citationText: [bodyText, ...flags(rest, '--approval')].join('\n'), runDirs: flags(rest, '--approval-run') }); if (approval.kind === 'invalid') { for (const line of approval.lines) process.stderr.write(resultLine('gstack-ship-receipt write', 'APPROVAL_CLASSES_INVALID', line) + '\n'); @@ -212,6 +237,7 @@ function cmdWrite(rest: string[], json: boolean): number { policy: flag(rest, '--policy') ?? tree?.policy, queue_mode: flag(rest, '--queue-mode'), preregistration: flag(rest, '--preregistration'), history: flag(rest, '--history'), ...pregate, issues: issuesFile ? receiptIssuesFrom(issuesFile) : undefined, approval: approval.kind === 'ok' ? approval.approval : undefined, review_loop: reviewLoop, ...(docs?.ok ? { docs: docs.docs } : {}), ...reviewedProduct(repoRoot, base, head, pregateFile), + gstack_version: runningGstackVersion(), review_depth: reviewDepth, review_loop_exception: loopException?.ok ? loopException.exception : undefined, }; } catch (e: any) { process.stderr.write(`gstack-ship-receipt write: ${e.message}\n`); return EXIT.usage; } if (input.pr !== undefined && !Number.isInteger(input.pr)) { process.stderr.write('gstack-ship-receipt write: --pr must be an integer\n'); return EXIT.usage; } @@ -573,7 +599,19 @@ function cmdCarry(rest: string[], json: boolean): number { } else if (receipt.pregate && !rest.includes('--dry-run')) { return refused('RECEIPT_CARRY_PREGATE_STALE', `the receipt records a pregate for ${receipt.head.slice(0, 12)}; run gstack-pregate at HEAD ${head.slice(0, 12)} and pass --pregate `); } - const carried = carryReceipt(pregateField ? { ...receipt, ...pregateField } : receipt, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); + let depthField: Partial = {}; + const isAncestor = (commit: string) => !ancestor || spawnSync('git', ['merge-base', '--is-ancestor', commit, head], { cwd: repoRoot, timeout: 30_000 }).status === 0; + const problems = exceptionProblems(body, receipt, isAncestor); + if (problems.length) return refused('RECEIPT_CARRY_REFUSED', `the receipt's owner exception does not stand: ${problems.join('; ')}; run /ship on ${head.slice(0, 12)}`); + if (receipt.review_depth || receipt.gstack_version) { + if (!receipt.review_depth) return refused('RECEIPT_CARRY_REFUSED', `the receipt records gstack_version but no review_depth, so the queue would refuse it; run /ship on ${head.slice(0, 12)}`); + const checkedOut = git(['rev-parse', 'HEAD'], repoRoot)?.toLowerCase(); + if (checkedOut !== head) return refused('RECEIPT_CARRY_REFUSED', `--head ${head.slice(0, 12)} is not the checked-out HEAD ${(checkedOut ?? 'none').slice(0, 12)}; the gate scan reads the checkout, so check out ${head.slice(0, 12)} and carry there`); + const now = receiptDepthAtHead(repoRoot, base, path.dirname(Bun.fileURLToPath(import.meta.url)), head); + if (depthBelow(receipt.review_depth.depth, now.depth)) return refused('RECEIPT_CARRY_REFUSED', `HEAD ${head.slice(0, 12)} needs ${now.depth} review depth (${now.reasons.join(', ')}), above the receipt's ${receipt.review_depth.depth}; run /ship on ${head.slice(0, 12)}`); + depthField = { review_depth: { ...now, depth: deeper(receipt.review_depth.depth, now.depth) } }; + } + const carried = carryReceipt({ ...receipt, ...pregateField, ...depthField }, head, plan.carry, { tree: headTree, fromTree, productPatchId: headPatch }); const errors = validateReceipt(carried); if (errors.length) return refused('RECEIPT_CARRY_REFUSED', `the carried receipt does not validate: ${errors[0]!.message} at ${errors[0]!.path}`); const newBody = placeReceiptFirst(body, carried); diff --git a/docs/review-policy.md b/docs/review-policy.md index 149abc89b..bafc25869 100644 --- a/docs/review-policy.md +++ b/docs/review-policy.md @@ -128,26 +128,75 @@ applies no edits is its exit too. ## Review depth by diff class -`gstack-review-plan --base ` classifies the diff and prints the reviewers -the next step dispatches (`REVIEW_CLASS:`, `REVIEW_PLAN:`, one `DISPATCH:` per -reviewer with its effort, one `SKIP:` per reviewer left out, `--json`). A mixed -diff takes its most restrictive class; a `gstack-diff-scope` error fails closed -to code, and code with the gate scan unavailable fails closed to gate. +`gstack-review-plan --base ` classifies the diff, picks a **depth** and prints +the reviewers the next step dispatches: `REVIEW_CLASS:`, `REVIEW_DEPTH: +runtime_lines= rules= reasons=<…> codes=<…>`, `GATE_SCAN: tagged=|unavailable +head=`, one `DEPTH_ERROR:` per input that failed closed, one `DISPATCH:` per reviewer +with its effort and a shell-quoted `DIFF_CMD:` (plus `DIFF_EXCLUDE: none` when it +reads everything), one `SKIP:` per reviewer left out, and `--json` with the same fields. +A plan that fails closed to full exits 0; a diff git cannot list exits 1. -| Class | What it is | Pass 1 dispatches | +**Classes** are unchanged. A mixed diff takes its most restrictive class: **docs** +(markdown no agent or gate reads), **tests** (test files and fixtures), **code** +(everything else, including every `SKILL.md` and template, markdown in a skill +directory, `*/sections/**`, `scripts/resolvers/**`, rule docs, checklists and bins) and +**gate** (code plus auth or migration scope, a hunk the gate scan tags, a deleted code +or test path, CI workflows, credential, state-root and the paths below). + +**Runtime lines** are the changed lines of code-class paths, minus outputs the base +commit's `.gstack/generated.json` registers. Docs, tests and release metadata are never +runtime; a `.tmpl` edit plus its regenerated `SKILL.md` counts the template once. + +| Depth | When | Pass 1 dispatches | |---|---|---| -| **docs** | markdown no agent or gate reads: README, CHANGELOG, TODOS, how-to and explanation docs | the parent's core checklist only | -| **tests** | test files and fixtures only | core plus the testing specialist | -| **code** | everything else, including every `SKILL.md` and template, any markdown in a skill directory, `*/sections/**`, `scripts/resolvers/**`, `docs/*-policy.md`, `docs/captain.md`, `docs/unattended.md`, `docs/pregate.md`, checklists, specialists and bins | specialists by scope (as before), the native adversarial pass, the outside voice; the Red Team past 200 lines or after a CRITICAL | -| **gate** | code plus auth or migration scope, a hunk the gate scan tags (an RH rule: a relaxed threshold, a skipped test), a deleted code file, CI workflows, credential or state-root paths | code's set, plus the Red Team always, at full effort, and no hit-rate gating | +| **light**, runtime 0 | docs and tests only, or only registered outputs | the core checklist only; the outside voice when the diff publishes numbers | +| **light**, runtime 1–50 | `SMALL_DIFF_LINES` or fewer runtime lines | core, one **batched** specialist (testing, maintainability and security checklists, the specialists scope selects and the Red Team prompt in one context), the native adversarial pass (kept: backtest inconclusive, GSTA-147 D5) and the outside voice on numbers | +| **standard** | more than 50 runtime lines | the code set with every threshold (specialists at 50, simplification at 100, Red Team at 200) read on runtime lines; specialists leave docs and registered outputs out of their diff, and every specialist but Testing leaves tests out too; adversarial and outside voice read everything | +| **full** | gate class, or any classifier error | the gate set, unchanged: every reviewer on the whole diff, the Red Team always, no hit-rate gating | -**Numbers keep the outside voice.** On a docs or tests diff the outside voice is -skipped unless the diff publishes numeric claims: `gstack-review-plan` scans the -added lines of every path for percentages, money, multipliers, durations, token -or credit counts and score, rate or speed claims (`NUMBERS: yes`), and a -claim always dispatches it. The claims' fingerprint (`fingerprint=`) is passed -back as `--reviewed-numbers`; when a later fix changes a claim, only the -outside voice reruns, on the numbers. +A fixed blocking finding, a CRITICAL or an open blocker raises a light diff to +standard for the next step (`step=standard`): its specialists read at least 50 lines +and the Red Team runs, so a raise never dispatches less than light did. A fix-check +is the same at every depth. + +**Fail closed.** Each of these makes the depth full and is listed in `errors[]` with +the input it could not read, a sanitized cause and the repair: a `gstack-diff-scope` +error, a gate scan that is unavailable, a base registry that cannot be read or is +invalid (`REGEN_REGISTRY_INVALID` or `REGEN_CONTAINMENT` in `codes=`), a numstat git +could not produce, and a binary code-class path (it is never counted as 0 lines). + +**The registry rule.** Only the base commit's registry counts (the merge base's +`origin/` commit for live /ship and the current base ref's commit for the queue: `origin/` in `ready`, `/` in `train build`), and only +entries with a `command`, which pregate's regen check proves for that tree. An entry +added on the branch never excludes anything; `command: null` entries, vendored code +and lockfiles stay runtime. To adopt it, commit an entry like this on the base branch: + +```json +{ + "$schema": "https://github.com/garrytan/gstack/docs/pregate.md#generated-v1", + "entries": [ + { "id": "skill-docs", "command": "bun run gen:skill-docs --host all", "outputs": ["*/SKILL.md", "*/sections/*.md"], "inputs": ["*/SKILL.md.tmpl", "*/sections/*.tmpl"] } + ] +} +``` + +**Gate paths for the machinery itself.** An edit to `lib/review-depth.ts`, +`lib/review-collect.ts`, `bin/gstack-review-plan`, `lib/review-scope.ts`, +`lib/ship-queue*.ts`, `lib/regen.ts`, `.gstack/generated.json`, +`ship/sections/review-army.md.tmpl`, `ship/sections/adversarial.md.tmpl` or +`scripts/resolvers/review-army.ts` is gate class: changing how much review runs gets +the most review. `rules=` (`DEPTH_RULES`) hashes the deciding constants and patterns, +not file bytes, so a comment edit keeps it. + +**`--depth-floor standard|full`** (a /ship prompt flag, passed to every plan run) +raises the depth and never lowers it. + +**Numbers keep the outside voice.** At light depth the outside voice runs only when +the diff publishes numeric claims: `gstack-review-plan` scans the added lines of +prose and data files for percentages, money, multipliers, durations, token or credit +counts and score, rate or speed claims (`NUMBERS: yes`). The claims' fingerprint +(`fingerprint=`) is passed back as `--reviewed-numbers`; when a later fix changes a +claim, only the outside voice reruns, on the numbers. It always reads the whole diff. **The adversarial and outside voices run once per invocation** (Step 11, on the final tree), as the last full-pass plan lists them; after they ran, decision 2 @@ -166,6 +215,15 @@ default. `gstack-config set review_effort_tiers off` (or `--no-tiers`) runs the audits and watchers at the host default too ([model-policy.md](model-policy.md#reasoning-effort)). +### Behavior changes + +- A tests-only diff (runtime 0) no longer dispatches the Testing specialist: the + core checklist and the gate scan review it. +- Light no longer runs a separate Red Team: the batched specialist carries its + prompt. At runtime 0 light runs no adversarial pass either. +- Standard reads thresholds on runtime lines, not the whole diff, so a test-heavy + diff with few runtime lines dispatches fewer specialists. + ## What the receipt records `gstack-review-scope summary --since ` reads this invocation's @@ -190,6 +248,13 @@ specialist stats they record). The summary line appends `fix_checks=`, `dispatches=` and `est_input_tokens=` only when they were recorded, so an old loop prints the old line. +Beside the counters, `write` records `review_depth` (the depth, runtime lines, +rules hash, base and registry commits, and the gate scan bound to the head) and +the queue refuses a receipt below the depth its head needs +([ship-policy.md#ship-receipt](ship-policy.md#ship-receipt)). A stop the owner +accepted after a fix-check edit goes in `review_loop_exception`, never in prose +under `REVIEW_LOOP:`. + The receipt's summary line prints `passes= blocking_found= deferred=`, which is what a scorecard tracks (target: a median of 1 pass per PR). A receipt carried across a base merge or a test-only fix @@ -268,9 +333,10 @@ trailers, appended }`. ## Where this is enforced -- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on), because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above. -- `ship/sections/review-army.md` Step 9: passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way. -- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`). +- `scripts/resolvers/review-army.ts`: the floor and `NO FINDINGS` in every specialist and Red Team prompt (rendered into `review/sections/review-army.md` and `ship/sections/review-army.md`), and the lead judgment's "On record" line that turns a [ruled class](#rulings-below-the-floor) into a note; in `/ship` every specialist and Red Team prompt it renders carries its diff base as a literal sha the parent writes in (`DIFF_BASE=`: the merge base on pass 1, `RANGE_BASE` from pass 2 on) beside its plan's `DIFF_CMD` excludes, because a reviewer is its own process and never sees the parent's shell variables; the fan-out batches above. +- `ship/sections/review-army.md` Step 9: the plan's `REVIEW_DEPTH`, `GATE_SCAN` and `--depth-floor` on every run, the depth fields of the review-log row, passes, `RANGE_BASE`, classify, defer, render, the cap check before any edit, the Step 11 late-defer row, scratch files under `.gstack/tmp/`, the cap decisions; `ship/sections/adversarial.md` Step 11: the outside voice reads the same range and scopes queued fixes the same way. +- `lib/review-depth.ts`, `bin/gstack-review-plan`: the diff class, the depth (`depthFor`, `DEPTH_RULES`, `depthOfCommitInput`), each dispatch's `exclude` pathspecs and `DIFF_CMD`, the transition table, the numeric-claim scan and each role's effort tier (`test/review-depth.test.ts`, `test/review-plan-cli.test.ts`). +- `lib/review-collect.ts`: `collectCommit` (git objects only, from any checkout) and `collectLive` (the working tree), per-path lines and the base commit's registry (`test/review-collect.test.ts`). - `lib/review-scope.ts`, `bin/gstack-review-scope`: `classify`, `defer`, `render`, `summary` (with `fix_checks`, `dispatches`, `est_input_tokens`); refusals are `REVIEW_SCOPE_INPUT` and `REVIEW_SCOPE_RANGE` ([troubleshooting.md](troubleshooting.md#review-scope-input)). - `lib/ship-policy.ts`: the `review_scope` key and its `ship: review_scope …` consequence line. - `review/checklist.md`: `prereg-with-results` under Gate Integrity and `test-keyboard-count-loop` under Pass 2; `bin/gstack-coauthor-trailers` for the contributor-credit trailers of a port. diff --git a/docs/ship-policy.md b/docs/ship-policy.md index 61e0059ef..bd848b5a2 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -221,8 +221,11 @@ It refuses (`QUEUE_NOT_READY`) when the working tree has tracked edits, HEAD is not the PR's pushed head, CI at that exact head is not green, or the pregate result is not a current pass, and (`QUEUE_RECEIPT_MISSING`, `QUEUE_RECEIPT_STALE`) when the PR body carries no /ship receipt for that -head: /ship runs before every READY ([details](#ship-before-ready)). Send the -block as printed. +head: /ship runs before every READY ([details](#ship-before-ready)). It also +refuses `QUEUE_RECEIPT_DEPTH_RAISED` when the receipt's review depth is below +what the head needs and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner +exception the receipt records does not stand ([the ship receipt](#ship-receipt)). +Send the block as printed. **2. Lane behind another PR: gate ahead.** Gate the tree the queue will have once the predecessor lands, while it is still open: @@ -370,12 +373,17 @@ slot. The queue enforces this at every step from the PR body alone: - `ready` refuses with `QUEUE_RECEIPT_MISSING` when the body has no valid receipt and with `QUEUE_RECEIPT_STALE` when the receipt's `head` is not the head being READY'd (or its `pr` is another PR); the informational - `SHIP_RECEIPT:` line still prints. There is no flag around it. + `SHIP_RECEIPT:` line still prints. It refuses `QUEUE_RECEIPT_DEPTH_RAISED` + (with a `REVIEW_DEPTH:` line) when the receipt records a review depth below + what the head needs, and `QUEUE_RECEIPT_EXCEPTION_INVALID` when an owner + exception it records does not stand ([the ship receipt](#ship-receipt)). + There is no flag around it. - `train build` refuses a pin whose PR has no current receipt with the same codes, naming the PR, before anything is written, pushed or dispatched, `--dry-run` included. -- `verify --train` refuses the slot with the same codes before printing - `MERGE_CONDITION`. +- `verify --train` refuses the slot with `QUEUE_RECEIPT_MISSING` or + `QUEUE_RECEIPT_STALE` before printing `MERGE_CONDITION`; it does not + recompute the review depth. **The stamp keeps the pin's receipt.** The slot's stamp commit changes only stamp files (and, from slot 2 on, merges main into the pin), so no /ship runs @@ -478,6 +486,9 @@ its record. Refusals, all before any spend: `QUEUE_TRAIN_UNTRUSTED` (a fork head), `QUEUE_TRAIN_MOVED` (a head moved from its pin), `QUEUE_RECEIPT_MISSING` and `QUEUE_RECEIPT_STALE` (the PR body has no /ship receipt for the pin; see [/ship before READY](#ship-before-ready)), +`QUEUE_RECEIPT_DEPTH_RAISED` and `QUEUE_RECEIPT_EXCEPTION_INVALID` (the +receipt's review depth is below what the pin needs, or an owner exception it +records does not stand; see [the ship receipt](#ship-receipt)), `QUEUE_NOT_READY` (no READY receipt at that head), `QUEUE_TRAIN_CONFLICT` (the printed `QUEUE_TRAIN_CONFLICT: train stopped at # (conflicts: ) after merging #,#; nothing dispatched, no branch pushed; fix: …` line names @@ -777,8 +788,9 @@ written by the pr-body section of `/ship` after the body is composed. The ```` ```gstack-ship-receipt ```` block is the handoff record: identifiers first (`pr`, `head`, `version`, `base`), then the evidence (`gate`, `ci`, -`head_tree`, `product_patch_id`, `pregate`, `issues`, `approval`, `docs`, -`review_loop`, `carry`), validated +`head_tree`, `product_patch_id`, `gstack_version`, `review_depth`, `pregate`, +`issues`, `approval`, `docs`, `review_loop`, `review_loop_exception`, `carry`), +validated against the `ship-receipt` schema (`gstack-artifact schema ship-receipt`) and read back by `gstack-ship-receipt read`, `result`, `carry` and the queue. @@ -813,6 +825,46 @@ unreadable base policy is `POLICY_SOURCE_UNAVAILABLE`); one (`absent/ over` for a path the tree lacks), exit 1 on any `over`, whose repair is trimming the file, never raising the cap in the same PR. +**`review_depth` and `gstack_version`.** `write` records the gstack that +wrote the receipt and the review depth at the checked-out head +([review-policy.md#review-depth](review-policy.md#review-depth)): +`{ depth, class, runtime_lines, rules, base, registry, gate_scan: { tagged, head }, reasons }`, +computed from git objects for the range against `origin/` +(`gstack-diff-scope --head ` for the scope signals), with the gate +scan run on that head. `--head` must be the checked-out HEAD +(`RECEIPT_INVALID` otherwise), so the record and `head_tree` describe the +tree the review read; tracked edits in the checkout make the scan unavailable, +which records `full`. `gstack-ship-queue ready` and `train build` recompute +the depth the head needs from the same range and scope and print `REVIEW_DEPTH: receipt= required= +runtime_lines= class= reasons=<…> inputs=base@,registry@,gate_scan@`; +a recorded gate scan counts only when its `head` is the full bound commit, +otherwise the scan is unavailable and the head needs `full`. A receipt below the required +depth, or one whose writer records `gstack_version` but no `review_depth`, is +[`QUEUE_RECEIPT_DEPTH_RAISED`](troubleshooting.md#queue-receipt-depth-raised): +the receipt is left unchanged and the fix is /ship on that head. A receipt with +neither field predates depth receipts and is accepted as legacy. A carry +recomputes the depth at the new head, rebinds the gate scan, and refuses when +the new head needs a deeper review than the receipt records (a test change that +skips a test raises the diff to gate class), when `--head` is not the +checked-out HEAD, when the receipt records `gstack_version` without +`review_depth`, or when an owner exception no longer stands, each as +[`RECEIPT_CARRY_REFUSED`](troubleshooting.md#receipt-carry-refused). + +**`review_loop_exception`.** The owner's acceptance of a stop after a +fix-check edit under frozen scope (#3559), from `write --review-loop-exception +`: `{ accepted_by, date, via, reason, fix_check: { head, findings[, files] } }`. +It needs `--review-loop` with `fix_checks` of 1 or more, a `fix_check.head` +that is the receipt head or its ancestor, and, with `--body`, the line +`Review-loop exception: accepted by via : (fix-check at , findings)` +word for word; otherwise +[`REVIEW_LOOP_EXCEPTION_INVALID`](troubleshooting.md#review-loop-exception-invalid) +and nothing is written. The queue checks both owner exceptions the same way +before READY and train build: the record must parse, the body must still +carry its line, and the fix-check head must be the bound head or its ancestor +(after a carry across rewritten history the old fix-check commit is off the +branch, so that check is skipped), or the receipt is +[`QUEUE_RECEIPT_EXCEPTION_INVALID`](troubleshooting.md#queue-receipt-exception-invalid). + **Prose against the receipt.** `gstack-ship-receipt read --pr --check-prose` (or `--body `) compares every counter the body quotes with the receipt's integers under `review_loop`, `gate` and `ci`: `passes=2`, `passes: 2` and diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 64688a386..e531ff7b8 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2900,10 +2900,12 @@ for an older PR, write it once by hand with the same command. ### `RECEIPT_INVALID` **Meaning.** The block exists but does not validate against the `ship-receipt` -schema (`gstack-artifact schema ship-receipt`). +schema (`gstack-artifact schema ship-receipt`). `gstack-ship-receipt write` +also refuses with this code, and writes nothing, when `--head` is not the +checked-out HEAD: the receipt binds the tree the review read. **Fix.** Regenerate it with `gstack-ship-receipt write`; never hand-edit the -block. +block. For the `--head` refusal, check out that commit and write there. ### `EXECUTION_UNKNOWN` @@ -4434,6 +4436,39 @@ written for the pinned head is the binding one and `verify --train` accepts it. `gstack-ship-queue ready --pr --screen|--no-screen`. A lane never stamps to fix this: the stamp comes only when the slot is called. + +### `QUEUE_RECEIPT_DEPTH_RAISED` + +**Meaning.** `ready` or `train build` recomputed the review depth the head +needs from git (the range against the base ref, `origin/` in `ready` +and `/` in `train build`, the base commit's +generated-output registry, and the receipt's gate scan when it names this +head) and the receipt's `review_depth.depth` is below it, or the receipt comes +from a gstack that records `gstack_version` but carries no `review_depth`. The +`REVIEW_DEPTH:` line prints the receipt's depth, the required depth, runtime +lines, the class, the reasons and the inputs (`base@`, `registry@`, +`gate_scan@` or `missing`). A gate scan recorded for another head counts as +missing, which makes the head need `full`. The receipt is unchanged. + +**Fix.** Run /ship on the head the READY names; it reviews at the depth +`gstack-review-plan` plans for that head and writes a receipt that records it. +Never edit `review_depth` by hand ([review-policy.md](review-policy.md#review-depth)). + + +### `QUEUE_RECEIPT_EXCEPTION_INVALID` + +**Meaning.** The receipt records an owner exception (`docs.exception` or +`review_loop_exception`) that the queue cannot accept: the record does not +parse, a review-loop exception names no fix-check (`review_loop.fix_checks` +is 0 or missing), its `fix_check.head` is neither the bound head nor an +ancestor of it (not checked after a carry across rewritten history), or the PR +body no longer carries the exception's line word for word +(`Documentation exception: …` or `Review-loop exception: …`). + +**Fix.** Restore the line in the PR body exactly as `write` printed it, or run +/ship again and record the owner's acceptance with `--docs` or +`--review-loop-exception` ([ship-policy.md](ship-policy.md#ship-receipt)). + ### `QUEUE_SCREEN_STALE_REF` @@ -4699,13 +4734,34 @@ untracked changes (a carry certifies a commit, never a tree that differs from it), the receipt's head left HEAD's history and the receipt records no tree to compare (a receipt written before `head_tree`, whose head is not in this clone), or git could not compare the two product diffs or read the working -tree's status. The receipt in the body is unchanged and still names the old -head, so `gstack-ship-queue ready` refuses `QUEUE_RECEIPT_STALE`. +tree's status. It also refuses when an owner exception the receipt records +no longer stands, and, for a receipt from a gstack that records review +depth, when HEAD needs a deeper review than the receipt records, `--head` is +not the checked-out HEAD, or the receipt records `gstack_version` without +`review_depth` ([ship-policy.md](ship-policy.md#ship-receipt)). The receipt in +the body is unchanged and still names the old head, so `gstack-ship-queue +ready` refuses `QUEUE_RECEIPT_STALE`. **Fix.** Commit or stash every working-tree change, then rerun `gstack-ship-receipt carry --pr ` at the new head. When the history is already rewritten and the receipt predates `head_tree`, run /ship on the new -head; next time merge the base in instead of rebasing. +head; next time merge the base in instead of rebasing. For a depth or +exception refusal, run /ship on the new head (or check out `--head` and carry +there). + + +### `REVIEW_LOOP_EXCEPTION_INVALID` + +**Meaning.** `gstack-ship-receipt write --review-loop-exception` refused the +record and wrote nothing: it is not `{ accepted_by, date, via, reason, +fix_check: { head, findings[, files] } }`, `--review-loop` is missing or counts +no fix-check, `fix_check.head` is neither the receipt head nor its ancestor, +or the `--body` file lacks the `Review-loop exception: …` line. + +**Fix.** Record the owner's exact words and the fix-check the stop followed, +pass the `gstack-review-scope summary` file as `--review-loop`, put the printed +line in the PR body, and write again +([ship-policy.md](ship-policy.md#ship-receipt)). ### `RECEIPT_CARRY_TREE_MISMATCH` diff --git a/lib/headless-artifacts.ts b/lib/headless-artifacts.ts index a42338611..c82ba790c 100644 --- a/lib/headless-artifacts.ts +++ b/lib/headless-artifacts.ts @@ -104,6 +104,11 @@ export interface ShipReceipt { carry?: { from: string; reason: string; product_patch_id: string; changed_paths: string[]; merge_commits: number; range_diff: string; covers: string[]; reruns: string[]; trees?: string[]; ancestry?: 'linear' | 'rewritten' }; /** The reviewed head's tree and its product patch-id against the base (gstack-ship-receipt write; carry rebinds both): what a carry across rewritten history compares. */ head_tree?: string; product_patch_id?: string; + /** The gstack that wrote the receipt, and the review depth the reviewed head needs, recomputed at write (lib/ship-receipt-depth.ts receiptDepthAtHead). */ + gstack_version?: string; + review_depth?: { depth: 'light' | 'standard' | 'full'; class: 'docs' | 'tests' | 'code' | 'gate'; runtime_lines: number; rules: string; base: string; registry: string | null; gate_scan: { tagged: number | null; head: string }; reasons: string[] }; + /** The owner's acceptance of a stop after a fix-check edit under frozen scope (#3559); the PR body repeats it word for word. */ + review_loop_exception?: { accepted_by: string; date: string; via: string; reason: string; fix_check: { head: string; findings: number; files?: string[] } }; } export const RESULT_STATUSES: readonly ResultStatus[] = ['complete', 'gate_pending', 'amend_pending', 'incomplete', 'interrupted', 'refused']; @@ -224,7 +229,9 @@ export const ARTIFACT_SCHEMAS = { approval: { type: 'object', description: 'approval classes: { source, status: checked|unavailable, classes: [{ class, scope, effects, citation: verified|unverified, approved_by, date, via, binding, evidence }], report_only }' }, review_loop: { type: 'object', required: ['passes', 'blocking_found', 'deferred'], properties: { passes: { type: 'integer', minimum: 0 }, blocking_found: { type: 'integer', minimum: 0 }, deferred: { type: 'integer', minimum: 0 }, noted: { type: 'integer', minimum: 0 }, fixed: { type: 'integer', minimum: 0 }, scope: str, fix_checks: { type: 'integer', minimum: 0 }, dispatches: { type: 'object', description: 'reviewer dispatches by role' }, est_input_tokens: { type: 'integer', minimum: 0 } } }, carry: { type: 'object', required: ['from', 'reason', 'product_patch_id'], properties: { from: str, reason: str, product_patch_id: str, changed_paths: strList, merge_commits: { type: 'integer', minimum: 0 }, range_diff: str, covers: strList, reruns: strList, trees: strList, ancestry: { enum: ['linear', 'rewritten'] } } }, - head_tree: str, product_patch_id: str, + head_tree: str, product_patch_id: str, gstack_version: str, + review_depth: { type: 'object', required: ['depth', 'class', 'runtime_lines', 'rules', 'base', 'gate_scan'], properties: { depth: { enum: ['light', 'standard', 'full'] }, class: { enum: ['docs', 'tests', 'code', 'gate'] }, runtime_lines: { type: 'integer', minimum: 0 }, rules: str, base: str, registry: nullableStr, gate_scan: { type: 'object', required: ['tagged', 'head'], properties: { tagged: { type: ['integer', 'null'], minimum: 0 }, head: str } }, reasons: strList } }, + review_loop_exception: { type: 'object', required: ['accepted_by', 'date', 'via', 'reason', 'fix_check'], properties: { accepted_by: str, date: str, via: str, reason: str, fix_check: { type: 'object', required: ['head', 'findings'], properties: { head: str, findings: { type: 'integer', minimum: 0 }, files: strList } } } }, issues: { type: 'object', properties: { fixes: { type: 'array', items: { type: 'integer' } }, refs: { type: 'array', items: { type: 'integer' } }, flakes: { type: 'array', items: { type: 'object', required: ['issue', 'run'], properties: { issue: { type: 'integer' }, run: str } } }, related: { type: 'array', items: { type: 'object', required: ['pr', 'author', 'closes'], properties: { pr: { type: 'integer' }, repo: str, author: str, closes: { type: 'array', items: { type: 'integer' } }, supersedes: { type: 'boolean' }, credit: { type: 'boolean' } } } } } }, }, }, diff --git a/lib/result-codes-release.ts b/lib/result-codes-release.ts index 51eccaa22..ee4004adb 100644 --- a/lib/result-codes-release.ts +++ b/lib/result-codes-release.ts @@ -148,6 +148,16 @@ export const RELEASE_RESULT_CODES = { summary: 'the PR body’s /ship receipt is for another head than the one being READY’d or merged (a push after /ship, or a receipt copied from another PR)', fix: 'rerun /ship on the current head so the receipt names it, then gstack-ship-queue ready again; a slot’s stamp commit keeps the receipt written for its pinned head', }, + QUEUE_RECEIPT_DEPTH_RAISED: { + anchor: 'queue-receipt-depth-raised', + summary: 'the PR body’s /ship receipt records a review depth below the one its head needs (the queue recomputed it from git), or a current receipt records no review_depth at all', + fix: 'run /ship on the head the READY names; the REVIEW_DEPTH line shows the receipt’s depth, the required depth, its reasons and inputs, and the receipt is left unchanged', + }, + QUEUE_RECEIPT_EXCEPTION_INVALID: { + anchor: 'queue-receipt-exception-invalid', + summary: 'the PR body’s /ship receipt records an owner exception (docs or review loop) that is malformed, lacks the fix-check it follows, or whose line the body no longer carries verbatim', + fix: 'restore the exception line in the PR body word for word, or run /ship again and record the owner’s acceptance with --docs or --review-loop-exception', + }, QUEUE_SCREEN_STALE_REF: { anchor: 'queue-screen-stale-ref', summary: 'GitHub’s refs/pull//merge is missing or still merges an older head than the PR’s: the PR conflicts with the base (a CHANGELOG-only conflict counts), so a screen dispatched on it would plan no cases and report success', @@ -254,6 +264,11 @@ export const RELEASE_RESULT_CODES = { summary: 'the prose /ship is about to publish (a CHANGELOG entry or a PR body) carries an AI tell a regular expression can decide', fix: 'rewrite each PROSE: line in the draft the way its hint says, then rerun gstack-prose-lint on the same file', }, + REVIEW_LOOP_EXCEPTION_INVALID: { + anchor: 'review-loop-exception-invalid', + summary: 'write --review-loop-exception got an exception that is malformed, follows no fix-check (review_loop.fix_checks is 0 or missing), names a fix-check head that is not the receipt head or its ancestor, or is missing from the PR body', + fix: 'pass { accepted_by, date, via, reason, fix_check: { head, findings } } with the owner’s words, the --review-loop summary that counts the fix-check, and the printed "Review-loop exception: …" line in the body', + }, RECEIPT_CARRY_TREE_MISMATCH: { anchor: 'receipt-carry-tree-mismatch', summary: 'the branch was rebased or force-pushed since its /ship receipt, and HEAD\'s tree is not a tree the receipt covers, so no review saw this code', diff --git a/lib/review-collect.ts b/lib/review-collect.ts new file mode 100644 index 000000000..3846df0f3 --- /dev/null +++ b/lib/review-collect.ts @@ -0,0 +1,137 @@ +/** + * review-collect — the git reads behind a review depth (docs/review-policy.md#review-depth), + * kept out of lib/review-depth.ts so the policy stays pure. collectCommit reads git + * objects only (a range between two commits, from any checkout, dirty or not); + * collectLive reads the working tree as /ship reviews it (committed, tracked + * uncommitted edits and untracked files). Both return a ClassInput with per-path + * line counts, the base commit's generated-output registry and collector errors; + * the caller adds the gate-scan result. + */ +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { GENERATED_PATH, parseRegistry } from './regen'; +import { RESULT_CODES } from './result-codes'; +import type { ClassInput, DepthError, PathLines, RegistryInfo } from './review-depth'; + +export interface Collected extends ClassInput { + perPath: PathLines[]; + registry: RegistryInfo; + errors: DepthError[]; + deleted: string[]; + stampOnly: string[]; + /** Added lines per path, for scanNumbers. */ + added: Record; + diffLines: number; + mergeBase: string | null; + untracked: string[]; +} + +function git(root: string, args: string[]): { ok: boolean; out: string; err: string } { + const r = spawnSync('git', ['-c', 'core.quotePath=false', ...args], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 256 * 1024 * 1024 }); + return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() }; +} +const scratch = (p: string) => p.startsWith('.gstack/tmp/'); +const oneLine = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').trim().slice(0, 200); +const unavailable = (input: string, detail: string): RegistryInfo => ({ sha: null, outputs: [], error: { code: 'registry_unavailable', detail: oneLine(detail), input, repair: 'fetch the base branch (git fetch origin ) so its commit and registry resolve' } }); + +/** The registry as committed at `sha`: outputs of entries with a command (a `command: null` entry never excludes); a missing file is an empty registry. */ +export function loadRegistryAt(root: string, sha: string): RegistryInfo { + const commit = git(root, ['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]); + if (!commit.ok) return unavailable(`${sha}:${GENERATED_PATH}`, `${sha} is not a commit in this repository`); + const resolved = commit.out.trim(); + const input = `${resolved.slice(0, 12)}:${GENERATED_PATH}`; + if (!git(root, ['cat-file', '-e', `${resolved}:${GENERATED_PATH}`]).ok) return { sha: resolved, outputs: [] }; + const show = git(root, ['show', `${resolved}:${GENERATED_PATH}`]); + if (!show.ok) return unavailable(input, show.err || 'git show failed'); + const { registry, errors } = parseRegistry(show.out); + const e = errors[0]; + if (e) return { sha: resolved, outputs: [], error: { code: 'registry_invalid', detail: oneLine(`${e.path || GENERATED_PATH}: ${e.message}`), input, repair: RESULT_CODES[e.code].fix, result: e.code } }; + return { sha: resolved, outputs: registry.entries.filter(x => x.command !== null).flatMap(x => x.outputs) }; +} + +/** Numstat, deletions and the -U0 patch for `range` (`[mergeBase, head]` or `[mergeBase]` for the working tree). */ +function readDiff(root: string, range: string[], errors: DepthError[]): Pick { + const numstat = git(root, ['diff', '--numstat', '-z', '--no-renames', ...range]); + const status = git(root, ['diff', '--name-status', '-z', '--no-renames', '--diff-filter=D', ...range]); + const patch = git(root, ['diff', '-U0', '--no-color', '--no-ext-diff', '--no-renames', ...range]); + const empty = { perPath: [], deleted: [], stampOnly: [], added: {} }; + for (const [r, what] of [[numstat, '--numstat'], [status, '--name-status'], [patch, '-U0']] as const) { + if (!r.ok) { errors.push({ code: 'numstat_failed', detail: oneLine(r.err || 'git diff failed'), input: `git diff ${what} ${range.join(' ')}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); return empty; } + } + const perPath: PathLines[] = []; + for (const rec of numstat.out.split('\0').filter(Boolean)) { + const [a, d, ...rest] = rec.split('\t'); + const p = rest.join('\t'); + if (!scratch(p)) perPath.push({ path: p, lines: (Number(a) || 0) + (Number(d) || 0), binary: a === '-' && d === '-' }); + } + const statusParts = status.out.split('\0'); + const deleted = statusParts.flatMap((s, k) => (s === 'D' && statusParts[k + 1] ? [statusParts[k + 1]!] : [])).filter(p => !scratch(p)); + const added: Record = {}; + const changed: Record = {}; + let cur = ''; + let minus = ''; + let inHunk = false; + for (const line of patch.out.split('\n')) { + if (line.startsWith('diff --git ')) { inHunk = false; cur = ''; minus = ''; continue; } + if (!inHunk) { + if (line.startsWith('--- ')) minus = line === '--- /dev/null' ? '' : line.slice(6).replace(/\t$/, ''); + else if (line.startsWith('+++ ')) cur = line === '+++ /dev/null' ? minus : line.slice(6).replace(/\t$/, ''); + else if (line.startsWith('@@')) inHunk = true; + continue; + } + if (!cur || scratch(cur) || line.startsWith('@@')) continue; + if (line[0] === '+') (added[cur] ??= []).push(line.slice(1)); + if (line[0] === '+' || line[0] === '-') (changed[cur] ??= []).push(line); + } + const stampOnly = perPath.map(e => e.path).filter(p => p.endsWith('package.json') && !!changed[p]?.length && changed[p]!.every(l => /^[+-]\s*"version"\s*:\s*"[^"]*"\s*,?\s*$/.test(l))); + return { perPath, deleted, stampOnly, added }; +} + +function result(diff: ReturnType, extra: Pick): Collected { + const paths = diff.perPath.map(e => e.path).sort(); + return { ...diff, ...extra, paths, gateTagged: null, diffLines: diff.perPath.reduce((n, e) => n + e.lines, 0) }; +} + +/** The range `baseSha`...`headSha` from git objects alone; the registry is the base commit's, never the head's. */ +export function collectCommit(root: string, baseSha: string, headSha: string): Collected { + const errors: DepthError[] = []; + const registry = loadRegistryAt(root, baseSha); + const mb = git(root, ['merge-base', baseSha, headSha]); + if (!mb.ok) { + errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || 'no merge base'), input: `git merge-base ${baseSha} ${headSha}`, repair: 'check that both commits exist locally (git fetch origin ) and rerun' }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root: [], registry, errors, mergeBase: null, untracked: [] }); + } + const mergeBase = mb.out.trim(); + const diff = readDiff(root, [mergeBase, headSha], errors); + const tree = git(root, ['ls-tree', '-r', '--name-only', '-z', headSha]); + const skillDirs = [...new Set(tree.out.split('\0').flatMap(p => /^([^/]+)\/SKILL\.md(\.tmpl)?$/.exec(p)?.[1] ?? []))].sort(); + return result(diff, { root: skillDirs, registry, errors, mergeBase, untracked: [] }); +} + +/** The working tree against the merge base with origin/ (or ), untracked files counted per path; the registry is the base ref's commit. */ +export function collectLive(root: string, base: string): Collected { + const errors: DepthError[] = []; + const ref = git(root, ['rev-parse', '--verify', '-q', `origin/${base}^{commit}`]).ok ? `origin/${base}` : base; + const registry = loadRegistryAt(root, ref); + const mb = git(root, ['merge-base', ref, 'HEAD']); + if (!mb.ok) { + errors.push({ code: 'numstat_failed', detail: oneLine(mb.err || `no merge base with ${ref}`), input: `git merge-base ${ref} HEAD`, repair: `fetch the base first (git fetch origin ${base})` }); + return result({ perPath: [], deleted: [], stampOnly: [], added: {} }, { root, registry, errors, mergeBase: null, untracked: [] }); + } + const mergeBase = mb.out.trim(); + const diff = readDiff(root, [mergeBase], errors); + const others = git(root, ['ls-files', '--others', '--exclude-standard', '-z']); + if (!others.ok) errors.push({ code: 'numstat_failed', detail: oneLine(others.err || 'git ls-files failed'), input: 'git ls-files --others --exclude-standard', repair: 'run the command by hand and fix what git reports' }); + const untracked = others.out.split('\0').filter(p => p && !scratch(p)).sort(); + for (const p of untracked) { + let buf: Buffer; + try { buf = fs.readFileSync(path.join(root, p)); } catch { diff.perPath.push({ path: p, lines: 0, binary: true }); continue; } + const binary = buf.subarray(0, 8000).includes(0); + const text = binary ? '' : buf.toString('utf8'); + const lines = text ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0; + diff.perPath.push({ path: p, lines, binary }); + if (!binary) diff.added[p] = text.split('\n'); + } + return result(diff, { root, registry, errors, mergeBase, untracked }); +} diff --git a/lib/review-depth.ts b/lib/review-depth.ts index f7331dbba..0cae65d45 100644 --- a/lib/review-depth.ts +++ b/lib/review-depth.ts @@ -1,17 +1,22 @@ /** * review-depth — the one policy owner for how much review a /ship run dispatches - * (docs/review-policy.md#review-depth). A pure function from the changed paths, - * gstack-diff-scope flags, the diff size, the gate scan and the pass history to a - * diff class, the next step of the loop and the reviewers that step dispatches, - * each with its reasoning-effort tier. bin/gstack-review-plan prints it; the + * (docs/review-policy.md#review-depth). A pure function from the changed paths and + * their line counts, gstack-diff-scope flags, the base registry's generated outputs, + * the gate scan and the pass history to a diff class, a review depth (light, + * standard, full), the next step of the loop and the reviewers that step + * dispatches, each with its effort tier and the pathspecs it leaves out. Git reads + * live in lib/review-collect.ts; bin/gstack-review-plan prints the plan; the * review-army resolver and the skills read the same definitions. */ import { createHash } from 'node:crypto'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import type { ResultCodeName } from './result-codes'; +import { matchesAny } from './ship-policy'; export type DiffClass = 'docs' | 'tests' | 'code' | 'gate'; export type PathClass = 'docs' | 'tests' | 'code'; +export type Depth = 'light' | 'standard' | 'full'; export type Effort = 'light' | 'full' | 'host'; export type Role = 'specialist' | 'red-team' | 'adversarial-native' | 'outside-voice' | 'fix-check'; export type Step = 'full' | 'fix-check' | 'done' | 'stop'; @@ -22,7 +27,12 @@ export const FULL_PASS_CAP = 2; export const SMALL_DIFF_LINES = 50; export const RED_TEAM_LINES = 200; export const LARGE_DIFF_LINES = 100; +/** The checklists light's batched specialist always reads, whatever the scope. */ +export const LIGHT_FIXED: readonly Specialist[] = ['testing', 'maintainability', 'security']; +/** Why light with runtime above 0 keeps the native adversarial pass as its own dispatch. */ +export const LIGHT_ADVERSARIAL_REASON = 'backtest inconclusive (GSTA-147 D5)'; const CLASS_RANK: Record = { docs: 0, tests: 1, code: 2, gate: 3 }; +const DEPTH_RANK: Record = { light: 0, standard: 1, full: 2 }; const AGENT_PROSE = [/(^|\/)SKILL\.md(\.tmpl)?$/, /(^|\/)sections\//, /^scripts\/resolvers\//, /^docs\/[^/]*-policy\.md$/, /^docs\/(captain|unattended|pregate)\.md$/, /(^|\/)DRIVER\.md(\.tmpl)?$/, /(^|\/)templates\//, /(^|\/)(AGENTS|CLAUDE|ETHOS)\.md$/, /(^|\/)specialists\//, /(^|\/)checklist\.md$/, /^\.gstack\//]; /** gstack-diff-scope's TEST_PATH_RE (the test-bootstrap rule), plus fixture directories. */ @@ -30,19 +40,41 @@ const TEST_PATH = [/(^|\/)(tests?|spec|__tests__)\/|(^|\/)tests?\.py$|(^|\/)test const DOC_PATH = /\.(md|mdx|markdown|rst|txt|adoc)$/i; /** Release metadata a version stamp writes: never a reason for review depth on its own. */ const RELEASE_METADATA = [/^CHANGELOG\.md$/, /^VERSION$/, /^agents-digest\//]; -const GATE_PATH = [/(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/]; +/** Gate paths, the review-depth machinery included: an edit to how much review runs gets the most review. */ +const GATE_PATH = [ + /(^|\/)\.github\/workflows\//, /(^|\/)(secrets?|credentials?|keychain|token[s]?)([._/-]|$)/i, /state-root/, /(^|\/)(pregate|gate|ship-receipt|ship-policy)[^/]*\.(ts|sh|json)$/, /(^|\/)bin\/gstack-(pregate|gate|ship-receipt|verify-gate|ci-gate)$/, + /^lib\/review-(depth|collect|scope)\.ts$/, /^bin\/gstack-review-plan$/, /^lib\/ship-queue[^/]*\.ts$/, /^lib\/regen\.ts$/, /^\.gstack\/generated\.json$/, /^ship\/sections\/(review-army|adversarial)\.md\.tmpl$/, /^scripts\/resolvers\/review-army\.ts$/, +]; -/** One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs, templates, any markdown in a skill directory under `root`) is code: it is gstack's product. */ -export function classifyPath(p: string, root?: string): PathClass { +/** A hash of the constants and patterns that decide a depth, not of this file's bytes: a comment edit keeps it, a threshold edit changes it. */ +export const DEPTH_RULES = createHash('sha256').update(JSON.stringify({ + SMALL_DIFF_LINES, RED_TEAM_LINES, LARGE_DIFF_LINES, FULL_PASS_CAP, LIGHT_FIXED, + patterns: Object.entries({ GATE_PATH, AGENT_PROSE, TEST_PATH, RELEASE_METADATA, DOC_PATH: [DOC_PATH] }).map(([k, v]) => [k, v.map(r => `/${r.source}/${r.flags}`)]), +})).digest('hex').slice(0, 16); + +/** + * One changed path's class. Markdown an agent or a gate reads (skills, sections, resolvers, policy and rule docs, + * templates, any markdown in a skill directory) is code: it is gstack's product. `root` is the repository root + * (skill directories probed on disk) or the top-level skill directory names read from a commit. + */ +export function classifyPath(p: string, root?: string | readonly string[]): PathClass { if (RELEASE_METADATA.some(r => r.test(p))) return 'docs'; if (AGENT_PROSE.some(r => r.test(p))) return 'code'; if (TEST_PATH.some(r => r.test(p))) return 'tests'; if (!DOC_PATH.test(p)) return 'code'; const top = p.includes('/') ? p.split('/')[0]! : ''; - if (root && top && ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f)))) return 'code'; - return 'docs'; + if (!top || !root) return 'docs'; + if (typeof root === 'string') return ['SKILL.md', 'SKILL.md.tmpl'].some(f => fs.existsSync(path.join(root, top, f))) ? 'code' : 'docs'; + return root.includes(top) ? 'code' : 'docs'; } +export type DepthErrorCode = 'scope_error' | 'gate_scan_unavailable' | 'registry_unavailable' | 'registry_invalid' | 'numstat_failed' | 'binary_code_path'; +/** Why a depth failed closed to full: the input it could not read, a sanitized cause and the repair; `result` is the result code behind it when one exists. */ +export interface DepthError { code: DepthErrorCode; detail: string; input: string; repair: string; result?: ResultCodeName } +export interface PathLines { path: string; lines: number; binary: boolean } +/** The base commit's `.gstack/generated.json`: outputs of entries with a command only; `error` when it could not be read or validated. */ +export interface RegistryInfo { sha: string | null; outputs: readonly string[]; error?: DepthError } + export interface ClassInput { paths: readonly string[]; /** gstack-diff-scope output (SCOPE_* keys). */ @@ -53,23 +85,38 @@ export interface ClassInput { deleted?: readonly string[]; /** Paths whose whole change is a version stamp (package.json's version key): classified as docs. */ stampOnly?: readonly string[]; - /** The repository root: markdown inside a skill directory there is code. */ - root?: string; + /** The repository root, or the skill directory names at the reviewed commit: markdown inside a skill directory is code. */ + root?: string | readonly string[]; + /** Changed lines per path; without it a code diff counts every line as runtime. */ + perPath?: readonly PathLines[]; + registry?: RegistryInfo; + /** Collector failures (a diff git could not list): each fails the depth closed. */ + errors?: readonly DepthError[]; } -export interface ClassResult { class: DiffClass; reasons: string[] } +export interface ClassResult { class: DiffClass; reasons: string[]; errors: DepthError[]; runtimeLines: number | null } -/** The diff's class: the most restrictive of its paths, raised to gate by any gate signal; errors fail closed. */ +const sanitize = (s: string) => s.replace(/[\0-\x1f]+/g, ' ').slice(0, 200); +const isOutput = (i: ClassInput, p: string) => !!i.registry?.outputs.length && matchesAny(i.registry.outputs, p); +const pathClass = (i: ClassInput, p: string): PathClass => ((i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root)); + +/** The diff's class, raised to gate by any gate signal; `errors` lists every input that fails the depth closed; `runtimeLines` counts code-class lines outside the base registry's outputs. */ export function classifyDiff(i: ClassInput): ClassResult { const reasons: string[] = []; + const errors: DepthError[] = [...(i.errors ?? [])]; let cls: DiffClass = 'docs'; const raise = (c: DiffClass, why: string) => { if (CLASS_RANK[c] > CLASS_RANK[cls]) cls = c; reasons.push(why); }; if (!i.paths.length) raise('docs', 'no changed paths'); - for (const p of i.paths) { const c = (i.stampOnly ?? []).includes(p) ? 'docs' : classifyPath(p, i.root); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); } + for (const p of i.paths) { const c = pathClass(i, p); if (CLASS_RANK[c] > CLASS_RANK[cls]) raise(c, `${c}: ${p}`); } const s = i.scope ?? {}; - if (s.SCOPE_ERROR) raise('code', `scope error: ${s.SCOPE_ERROR}`); + if (s.SCOPE_ERROR) { + raise('code', `scope error: ${s.SCOPE_ERROR}`); + errors.push({ code: 'scope_error', detail: sanitize(s.SCOPE_ERROR), input: 'gstack-diff-scope ', repair: 'run gstack-diff-scope by hand and fix what it reports' }); + } if (CLASS_RANK[cls] >= CLASS_RANK.tests) { - if (i.gateTagged === null) raise('gate', 'gate scan unavailable'); - else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); + if (i.gateTagged === null) { + raise('gate', 'gate scan unavailable'); + errors.push({ code: 'gate_scan_unavailable', detail: 'gstack-gate-diff reported no GATE_SUMMARY', input: 'gstack-gate-diff ', repair: 'run gstack-gate-diff and read its GATE_ERROR line, or pass the recorded scan as --gate-tagged ' }); + } else if (i.gateTagged > 0) raise('gate', `gate scan tagged ${i.gateTagged} hunk(s)`); const deletedGuarded = (i.deleted ?? []).filter(p => classifyPath(p, i.root) !== 'docs'); if (deletedGuarded.length) raise('gate', `deletes ${deletedGuarded.length} code or test path(s)`); } @@ -77,8 +124,38 @@ export function classifyDiff(i: ClassInput): ClassResult { if (s.SCOPE_AUTH === 'true') raise('gate', 'auth paths'); if (s.SCOPE_MIGRATIONS === 'true') raise('gate', 'migrations'); for (const p of i.paths) if (GATE_PATH.some(r => r.test(p))) { raise('gate', `gate path: ${p}`); break; } + if (i.registry?.error) errors.push(i.registry.error); } - return { class: cls, reasons }; + if (!i.perPath) return { class: cls, reasons, errors, runtimeLines: null }; + let runtimeLines = 0; + for (const e of i.perPath) { + if (pathClass(i, e.path) !== 'code' || isOutput(i, e.path)) continue; + if (e.binary) errors.push({ code: 'binary_code_path', detail: 'git reports a binary change, so its lines cannot be counted', input: e.path, repair: "register it as an output of an entry with a command in the base branch's .gstack/generated.json, or accept the full review" }); + else runtimeLines += e.lines; + } + return { class: cls, reasons, errors, runtimeLines }; +} + +/** Any classifier error or gate class is full; runtime above SMALL_DIFF_LINES is standard; otherwise light. A floor only raises. */ +export function depthFor(o: { cls: DiffClass; runtimeLines: number; errors: readonly DepthError[]; floor?: Depth }): Depth { + const d: Depth = o.errors.length || o.cls === 'gate' ? 'full' : o.runtimeLines > SMALL_DIFF_LINES ? 'standard' : 'light'; + return o.floor && DEPTH_RANK[o.floor] > DEPTH_RANK[d] ? o.floor : d; +} + +export interface DepthResult { depth: Depth; class: DiffClass; runtimeLines: number; reasons: string[]; errors: DepthError[]; codes: ResultCodeName[]; rules: string } + +function decideDepth(c: ClassResult, runtimeLines: number, floor?: Depth): DepthResult { + const unfloored = depthFor({ cls: c.class, runtimeLines, errors: c.errors }); + const reasons = [...c.errors.map(e => `error:${e.code}`), ...(c.class === 'gate' ? ['gate-class'] : []), `runtime=${runtimeLines}${runtimeLines > SMALL_DIFF_LINES ? '>' : '<='}${SMALL_DIFF_LINES}`]; + if (floor && DEPTH_RANK[floor] > DEPTH_RANK[unfloored]) reasons.push(`floor:${floor}`); + const codes = [...new Set(c.errors.flatMap(e => (e.result ? [e.result] : [])))]; + return { depth: depthFor({ cls: c.class, runtimeLines, errors: c.errors, floor }), class: c.class, runtimeLines, reasons, errors: c.errors, codes, rules: DEPTH_RULES }; +} + +/** The depth of a collected range (lib/review-collect.ts) plus a recorded gate-scan result: what the queue recomputes at a head. */ +export function depthOfCommitInput(input: ClassInput & { floor?: Depth }): DepthResult { + const c = classifyDiff(input); + return decideDepth(c, c.runtimeLines ?? 0, input.floor); } // --------------------------------------------------------------------------- @@ -153,34 +230,48 @@ export interface PlanInput extends ClassInput { force?: readonly string[] | 'all'; /** Specialists the hit-rate stats tag GATE_CANDIDATE. */ gated?: readonly string[]; - /** A specialist produced a CRITICAL finding (Red Team activation on code class). */ + /** A specialist produced a CRITICAL finding (Red Team activation on code class; raises light to standard). */ criticalFound?: boolean; /** Step 11's native adversarial pass already ran in this invocation. */ adversarialRan?: boolean; /** The numeric-claim fingerprint the outside voice last reviewed; a change reruns it. */ reviewedNumbers?: string; + /** --depth-floor: raises the depth, never lowers it. */ + floor?: Depth; } -export interface Dispatch { role: Role; name: string; effort: Effort } -export interface ReviewPlan { class: DiffClass; reasons: string[]; next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[] } +/** One reviewer. `exclude` holds git pathspec argv items the reviewer's diff leaves out; `checklists` names what light's batched specialist reads. */ +export interface Dispatch { role: Role; name: string; effort: Effort; exclude: string[]; checklists?: string[]; reason?: string } +export interface ReviewPlan extends DepthResult { + /** The depth this step runs at: light raised to standard by a fixed blocker, a CRITICAL or an open blocker. */ + stepDepth: Depth; + depthReasons: string[]; + next: NextStep; numbers: NumbersResult; dispatches: Dispatch[]; skipped: string[]; +} + +const scopeWants: Partial) => boolean>> = { + performance: s => s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true', + 'data-migration': s => s.SCOPE_MIGRATIONS === 'true', + 'api-contract': s => s.SCOPE_API === 'true', + design: s => s.SCOPE_FRONTEND === 'true', + 'state-machines': s => s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined, +}; +const forcedBy = (i: PlanInput) => (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n)); /** The specialists a full pass selects for a class: class sets the eligible set, hit-rate gating applies inside it, gate class ignores gating. */ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = []): Specialist[] { const s = i.scope ?? {}; - const forced = (n: string) => i.force === 'all' || (Array.isArray(i.force) && i.force.includes(n)); + const forced = forcedBy(i); if (cls === 'docs') { skipped.push('specialists (docs class)'); return SPECIALISTS.filter(forced); } if (cls === 'tests') { skipped.push('specialists except testing (tests class)'); return SPECIALISTS.filter(n => n === 'testing' || forced(n)); } const want = (n: Specialist): boolean => { if (forced(n)) return true; - if (n === 'state-machines') return s.SCOPE_STATE_MACHINE === 'true' || s.SCOPE_STATE_MACHINE === 'unknown' || s.SCOPE_STATE_MACHINE === undefined; + if (n === 'state-machines') return scopeWants[n]!(s); if (i.diffLines < SMALL_DIFF_LINES) return false; switch (n) { case 'testing': case 'maintainability': return true; case 'security': return s.SCOPE_AUTH === 'true' || (s.SCOPE_BACKEND === 'true' && i.diffLines > LARGE_DIFF_LINES); - case 'performance': return s.SCOPE_BACKEND === 'true' || s.SCOPE_FRONTEND === 'true'; - case 'data-migration': return s.SCOPE_MIGRATIONS === 'true'; - case 'api-contract': return s.SCOPE_API === 'true'; - case 'design': return s.SCOPE_FRONTEND === 'true'; case 'simplification': return i.diffLines > LARGE_DIFF_LINES; + default: return scopeWants[n]!(s); } }; return SPECIALISTS.filter(n => { @@ -190,34 +281,76 @@ export function specialistsFor(cls: DiffClass, i: PlanInput, skipped: string[] = }); } -/** The reviewers the next step dispatches, with each role's effort tier. */ +/** A path as one git exclude pathspec; a glob character or a leading colon makes it literal. */ +const excludeSpec = (p: string) => `:(exclude${/[*?[\]\\]/.test(p) || p.startsWith(':') ? ',literal' : ''})${p}`; +const shellQuote = (s: string) => `'${s.replace(/'/g, "'\\''")}'`; + +/** The diff command a dispatched reviewer runs; `` is the literal sha the parent writes in. */ +export function diffCommand(d: Dispatch): string { + return d.exclude.length ? `git diff -- . ${d.exclude.map(shellQuote).join(' ')}` : 'git diff '; +} + +/** The reviewers the next step dispatches at its depth, with each role's effort tier and excluded pathspecs. */ export function planReview(i: PlanInput): ReviewPlan { - const { class: cls, reasons } = classifyDiff(i); + const c = classifyDiff(i); + const depth = decideDepth(c, c.runtimeLines ?? (CLASS_RANK[c.class] >= CLASS_RANK.code ? i.diffLines : 0), i.floor); + const { class: cls, runtimeLines } = depth; const next = nextStep(i.history); + const h = i.history; + const raise = depth.depth === 'light' && (h.fixedBlocking || h.blockingOpen || i.criticalFound); + const stepDepth: Depth = raise ? 'standard' : depth.depth; + const depthReasons = [...depth.reasons, ...(raise ? [`history:${h.fixedBlocking ? 'fixed-blocking' : h.blockingOpen ? 'blocking-open' : 'critical-found'}`] : [])]; const skipped: string[] = []; const dispatches: Dispatch[] = []; + const add = (role: Role, name: string, effort: Effort, exclude: string[] = [], extra: Partial = {}) => dispatches.push({ role, name, effort, exclude, ...extra }); + const docsAndOutputs = i.paths.filter(p => pathClass(i, p) === 'docs' || isOutput(i, p)).map(excludeSpec); + const tests = i.paths.filter(p => pathClass(i, p) === 'tests' && !isOutput(i, p)).map(excludeSpec); const numbersChanged = i.numbers.publishes && i.reviewedNumbers !== undefined && i.reviewedNumbers !== i.numbers.fingerprint; - if (next.step === 'full') { - for (const n of specialistsFor(cls, i, skipped)) dispatches.push({ role: 'specialist', name: n, effort: 'full' }); - const redTeam = cls === 'gate' || (cls === 'code' && (i.diffLines > RED_TEAM_LINES || !!i.criticalFound)); - if (redTeam) dispatches.push({ role: 'red-team', name: 'red-team', effort: 'full' }); - else skipped.push(`red-team (${cls} class${cls === 'code' ? `, ${i.diffLines} lines, no critical` : ''})`); - if (i.history.fullPasses === 0 || (!i.adversarialRan && (cls === 'code' || cls === 'gate'))) { - if (cls === 'docs' || cls === 'tests') skipped.push(`adversarial-native (${cls} class)`); - else dispatches.push({ role: 'adversarial-native', name: 'adversarial', effort: 'full' }); - if (cls === 'code' || cls === 'gate' || i.numbers.publishes) dispatches.push({ role: 'outside-voice', name: 'outside-voice', effort: 'host' }); - else skipped.push(`outside-voice (${cls} class, no numeric claims)`); + const firstVoices = h.fullPasses === 0 || !i.adversarialRan; + if (next.step === 'full' && stepDepth === 'light') { + const forced = forcedBy(i); + if (runtimeLines > 0) { + const scoped = SPECIALISTS.filter(n => !LIGHT_FIXED.includes(n) && (forced(n) || (scopeWants[n]?.(i.scope ?? {}) && !(i.gated ?? []).includes(n)))); + add('specialist', 'batched', 'full', docsAndOutputs, { checklists: [...LIGHT_FIXED, ...scoped, 'red-team'] }); + skipped.push('red-team (light depth: the batched specialist carries the Red Team prompt)'); + if (firstVoices) add('adversarial-native', 'adversarial', 'full', [], { reason: LIGHT_ADVERSARIAL_REASON }); + } else { + for (const n of SPECIALISTS.filter(forced)) add('specialist', n, 'full'); + skipped.push('specialists, red-team and adversarial-native (light depth, runtime 0)'); + } + if (h.fullPasses === 0 && i.numbers.publishes) add('outside-voice', 'outside-voice', 'host'); + else if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); + else skipped.push(`outside-voice (${cls} class, no numeric claims)`); + } else if (next.step === 'full') { + const full = stepDepth === 'full'; + const dcls: DiffClass = full ? 'gate' : cls; + const raised = !full && runtimeLines <= SMALL_DIFF_LINES; + const lines = full ? i.diffLines : Math.max(runtimeLines, raised ? SMALL_DIFF_LINES : 0); + for (const n of specialistsFor(dcls, { ...i, diffLines: lines }, skipped)) add('specialist', n, 'full', full ? [] : n === 'testing' ? docsAndOutputs : [...docsAndOutputs, ...tests]); + const redTeam = dcls === 'gate' || (dcls === 'code' && (lines > RED_TEAM_LINES || !!i.criticalFound || raised)); + if (redTeam) add('red-team', 'red-team', 'full'); + else skipped.push(`red-team (${dcls} class${dcls === 'code' ? `, ${lines} runtime lines, no critical` : ''})`); + const codeLike = dcls === 'code' || dcls === 'gate'; + if (h.fullPasses === 0 || (!i.adversarialRan && codeLike)) { + if (codeLike) add('adversarial-native', 'adversarial', 'full'); + else skipped.push(`adversarial-native (${dcls} class)`); + if (codeLike || i.numbers.publishes) add('outside-voice', 'outside-voice', 'host'); + else skipped.push(`outside-voice (${dcls} class, no numeric claims)`); } else { skipped.push('adversarial-native and outside-voice (run once per invocation)'); - if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' }); + if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); } } else if (next.step === 'fix-check') { - dispatches.push({ role: 'fix-check', name: 'fix-check', effort: 'full' }); - if (numbersChanged) dispatches.push({ role: 'outside-voice', name: 'outside-voice (numbers changed)', effort: 'host' }); + add('fix-check', 'fix-check', 'full'); + if (numbersChanged) add('outside-voice', 'outside-voice (numbers changed)', 'host'); } - return { class: cls, reasons, next, numbers: i.numbers, dispatches, skipped }; + return { ...depth, stepDepth, depthReasons, reasons: c.reasons, next, numbers: i.numbers, dispatches, skipped }; } export function reviewPlanLine(p: ReviewPlan, pass: number): string { return `REVIEW_PLAN: class=${p.class} pass=${pass} next=${p.next.step} dispatches=${p.dispatches.length} numbers=${p.numbers.publishes ? `yes(${p.numbers.claims})` : 'no'} fingerprint=${p.numbers.fingerprint}`; } + +export function reviewDepthLine(p: ReviewPlan): string { + return `REVIEW_DEPTH: ${p.depth} runtime_lines=${p.runtimeLines} rules=${p.rules} reasons=${p.depthReasons.join(',')} codes=${p.codes.join(',') || 'none'}${p.stepDepth !== p.depth ? ` step=${p.stepDepth}` : ''}`; +} diff --git a/lib/ship-queue-train.ts b/lib/ship-queue-train.ts index e44c46a5b..5485f9b20 100644 --- a/lib/ship-queue-train.ts +++ b/lib/ship-queue-train.ts @@ -22,7 +22,7 @@ import { baseOverlapLine } from './pr-wait'; import { versionAtRef, versionFiles } from './restamp'; import type { LoadedPolicy, ShipPolicy, TrainPolicy } from './ship-policy'; import { loadBasePolicy, type QueueContext } from './ship-queue'; -import { bindReceipt } from './ship-receipt'; +import { bindShippedReceipt } from './ship-receipt-depth'; import { git, sha256, withIndex, type QueueFailure } from './ship-queue-gate'; import { recordVerdict, screenVerdict, verdictLines } from './ship-queue-screen'; import { stackTestLine, stackTests } from './ship-queue-stack-tests'; @@ -351,8 +351,8 @@ function admitSlots(env: TrainEnv, pins: Array<{ pr: string; sha: string }>, dry if (!info.headRefOid.startsWith(p.sha) || fetched !== info.headRefOid) { return outRefused({ code: 'QUEUE_TRAIN_MOVED', message: `PR #${p.pr} moved: pinned ${p.sha.slice(0, 12)}, head is now ${info.headRefOid.slice(0, 12)}` }, [], `ask #${p.pr}'s lane for READY on ${info.headRefOid.slice(0, 12)}, then ${Q} train build --prs `, 'none'); } - const shipped = bindReceipt(info.body, p.pr, [info.headRefOid]); - if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); + const shipped = bindShippedReceipt(ctx.repoRoot, `refs/remotes/${env.remote}/${ctx.base}`, info.body, p.pr, [info.headRefOid]); + if (!shipped.ok) return outRefused({ code: shipped.code, message: shipped.message }, [shipped.line, ...(shipped.depthLine ? [shipped.depthLine] : [])], `#${p.pr}'s lane runs /ship, then ${Q} ready --pr ${p.pr} --screen|--no-screen; then ${Q} train build --prs `, 'none'); const ready = loadReady(env.repo.keys, p.pr, info.headRefOid); if (!ready) return outRefused({ code: 'QUEUE_NOT_READY', message: `no READY receipt for #${p.pr} at ${info.headRefOid.slice(0, 12)} (local state or ${'refs/notes/gstack-trains'})` }, [], `the lane runs ${Q} ready --pr ${p.pr} --screen|--no-screen`, 'none'); let screen = ready.screen; diff --git a/lib/ship-queue.ts b/lib/ship-queue.ts index 166a9a24b..e4588ea13 100644 --- a/lib/ship-queue.ts +++ b/lib/ship-queue.ts @@ -31,7 +31,8 @@ import type { PregateFile } from './headless-artifacts'; import { CHECKS, pregateMirrorPath, readPregateFile, verifyPregate, PREGATE_OUT_REL } from './pregate/index'; import { ensureTmpExcluded, restamp, versionAtRef } from './restamp'; import type { ResultCodeName } from './result-codes'; -import { bindReceipt, extractReceipt } from './ship-receipt'; +import { extractReceipt } from './ship-receipt'; +import { bindShippedReceipt } from './ship-receipt-depth'; import { isUnavailable, loadPolicyFromBase, type LoadedPolicy } from './ship-policy'; import { QUEUE_DIR_REL, applyStampPatch, commitForTree, currentAttempt, git, listAttempts, recomputeMergeTree, sha256, writeJsonAtomic, @@ -254,7 +255,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate pregateLine = `PREGATE: ${read.file.verdict} tree=${read.file.tree.slice(0, 12)} head=${(read.file.head ?? '').slice(0, 12)} checks=${read.file.checks.length} (from the state root's pregate/by-tree/${treeNow.tree.slice(0, 12)}.json, recorded by another checkout)`; } else if (gap) pregateLine += `; the state root's mirror for this tree cannot stand in: ${gap}`; } - const receipt = bindReceipt(info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? '')); + const receipt = bindShippedReceipt(ctx.repoRoot, `origin/${ctx.base}`, info.body ?? '', o.pr, acceptedReceiptHeads(ctx, policy, head, info.body ?? '')); const attempt = currentAttempt(ctx.repoRoot, o.pr); const hint = conflictHint(info.mergeable, ci, info.headRefName, info.isCrossRepository === true, head); const lines = [ @@ -268,6 +269,7 @@ export function ready(ctx: QueueContext, o: { pr: string; pregate?: string; gate pregateLine, gateLine(policy, attempt), receipt.line, + ...(receipt.depthLine ? [receipt.depthLine] : []), ]; const gate = policy.policy.full_e2e ? gateEvidence(ctx, o.pr, head, attempt, o.gateExit, o.gateLog) : null; if (gate) lines.push(gate.line); diff --git a/lib/ship-receipt-depth.ts b/lib/ship-receipt-depth.ts new file mode 100644 index 000000000..8cff3b954 --- /dev/null +++ b/lib/ship-receipt-depth.ts @@ -0,0 +1,164 @@ +/** + * ship-receipt-depth — what the /ship receipt says about how deeply the + * review read the head, and what the queue checks against it + * (docs/review-policy.md#review-depth, docs/ship-policy.md#ship-receipt). + * + * `review_depth`: the depth the reviewed head needs, recomputed by + * `gstack-ship-receipt write` (receiptDepthAtHead: the committed range from + * git objects, gstack-diff-scope on that range and the gate scan of the clean + * checkout at that head), with the runtime lines, the rules hash, the base and + * registry commits and the gate scan bound to the head. + * `review_loop_exception`: the owner's acceptance of a stop after a + * fix-check edit under frozen scope (#3559), shaped like the docs exception. + * The queue recomputes the depth the head needs from git objects + * (lib/review-collect.ts) and refuses a receipt below it. Kept beside + * lib/ship-receipt.ts so that module stays under the size ratchet. + */ +import { spawnSync } from 'node:child_process'; +import * as path from 'node:path'; +import type { ShipReceipt } from './headless-artifacts'; +import { collectCommit } from './review-collect'; +import { classifyPath, depthOfCommitInput, type Depth } from './review-depth'; +import { runScriptSync } from './script-invocation'; +import { bindReceipt, type ReceiptBindingCode } from './ship-receipt'; +import { docsExceptionLine, parseReceiptDocs } from './ship-receipt-docs'; + +export type ReceiptDepth = NonNullable; +export type ReviewLoopException = NonNullable; +const RANK: Record = { light: 0, standard: 1, full: 2 }; +const text = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0 && !/[\x00-\x1f\x7f]/.test(v); +const sha = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{40}$/.test(v); +const BIN_DIR = path.join(import.meta.dir, '..', 'bin'); +const short = (s: string | null | undefined) => (s ? s.slice(0, 12) : 'none'); + +function git(root: string, args: string[]): { ok: boolean; out: string } { + const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 60_000 }); + return { ok: r.status === 0, out: (r.stdout ?? '').trim() }; +} + +/** gstack-diff-scope on the committed range `base`...`head` (`--head`): the same signals at write, carry and the queue. */ +function scopeOfRange(root: string, binDir: string, base: string, head: string): Record { + const sr = runScriptSync(path.join(binDir, 'gstack-diff-scope'), [base, '--head', head], { cwd: root, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + const scope: Record = {}; + for (const l of (sr.stdout ?? '').split('\n')) { const m = /^(SCOPE_[A-Z_]+)=(.*)$/.exec(l); if (m) scope[m[1]!] = m[2]!; } + if (sr.status !== 0 && !scope.SCOPE_ERROR) scope.SCOPE_ERROR = `exit ${sr.status}`; + return scope; +} + +/** + * The depth record for `head`: the range from git objects (collectCommit) and + * its diff-scope, as the queue recomputes them, plus, for a tests or code diff, + * the gate scan of this checkout. The scan reads the working tree, so it counts + * only when the checkout is `head` with no tracked edits; otherwise it is + * unavailable and the depth fails closed to full. + */ +export function receiptDepthAtHead(repoRoot: string, base: string, binDir: string, head: string): ReceiptDepth { + const branch = base.replace(/^origin\//, ''); + const baseSha = git(repoRoot, ['rev-parse', '--verify', '--quiet', `origin/${branch}^{commit}`]); + const baseRev = baseSha.ok ? baseSha.out : branch; + const c = collectCommit(repoRoot, baseRev, head); + const scope = scopeOfRange(repoRoot, binDir, baseRev, head); + let tagged: number | null = 0; + if (c.paths.some(p => classifyPath(p, c.root) !== 'docs')) { + const atHead = git(repoRoot, ['rev-parse', 'HEAD']).out === head && git(repoRoot, ['diff', '--quiet', 'HEAD', '--']).ok; + const g = atHead ? runScriptSync(path.join(binDir, 'gstack-gate-diff'), [baseRev], { cwd: repoRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 }) : null; + const m = /GATE_SUMMARY: .*?tagged=\{([^}]*)\}/.exec(g?.stdout ?? ''); + tagged = g?.status === 0 && m ? m[1]!.split(',').reduce((n, kv) => n + (Number(kv.split(':')[1]) || 0), 0) : null; + } + const d = depthOfCommitInput({ ...c, scope, gateTagged: tagged }); + return { depth: d.depth, class: d.class, runtime_lines: d.runtimeLines, rules: d.rules, base: baseRev, registry: c.registry.sha, gate_scan: { tagged, head }, reasons: d.reasons.slice(0, 12) }; +} + +export const depthBelow = (have: Depth, need: Depth) => RANK[have] < RANK[need]; +export const deeper = (a: Depth, b: Depth): Depth => (RANK[a] >= RANK[b] ? a : b); + +/** `write --review-loop-exception` input and the queue's check: who accepted the stop, when, where, why, and the fix-check it followed. */ +export function parseReviewLoopException(value: unknown): { ok: true; exception: ReviewLoopException } | { ok: false; message: string } { + const bad = (message: string) => ({ ok: false as const, message }); + if (!value || typeof value !== 'object' || Array.isArray(value)) return bad('review_loop_exception must be { accepted_by, date, via, reason, fix_check: { head, findings[, files] } }'); + const v = value as Record; + for (const key of Object.keys(v)) if (!['accepted_by', 'date', 'via', 'reason', 'fix_check'].includes(key)) return bad(`review_loop_exception: unknown key ${JSON.stringify(key)}`); + for (const key of ['accepted_by', 'date', 'via', 'reason']) if (!text(v[key])) return bad(`review_loop_exception.${key} must be a non-empty one-line string`); + if (!/^\d{4}-\d{2}-\d{2}$/.test(v.date as string)) return bad('review_loop_exception.date must be YYYY-MM-DD'); + const f = v.fix_check as Record | null; + if (!f || typeof f !== 'object' || Array.isArray(f)) return bad('review_loop_exception.fix_check must be { head, findings[, files] }'); + for (const key of Object.keys(f)) if (!['head', 'findings', 'files'].includes(key)) return bad(`review_loop_exception.fix_check: unknown key ${JSON.stringify(key)}`); + if (!sha(f.head)) return bad('review_loop_exception.fix_check.head must be the full commit the fix-check reviewed'); + if (typeof f.findings !== 'number' || !Number.isInteger(f.findings) || f.findings < 0) return bad('review_loop_exception.fix_check.findings must be an integer >= 0 (what the fix-check reported)'); + if (f.files !== undefined && (!Array.isArray(f.files) || !f.files.every(text))) return bad('review_loop_exception.fix_check.files must be a list of paths'); + const files = (f.files as string[] | undefined)?.slice(0, 50); + return { ok: true, exception: { accepted_by: (v.accepted_by as string).trim(), date: v.date as string, via: (v.via as string).trim(), reason: (v.reason as string).trim(), fix_check: { head: f.head, findings: f.findings, ...(files?.length ? { files } : {}) } } }; +} + +/** The line the PR body must carry, word for word, while the exception stands. */ +export function reviewLoopExceptionLine(e: ReviewLoopException): string { + return `Review-loop exception: accepted by ${e.accepted_by} ${e.date} via ${e.via}: ${e.reason} (fix-check at ${e.fix_check.head.slice(0, 12)}, ${e.fix_check.findings} finding${e.fix_check.findings === 1 ? '' : 's'})`; +} + +const carries = (body: string, line: string) => body.split(/\r?\n/).some(l => l.trim() === line); + +/** + * The owner exceptions a receipt records, checked the same way: valid shape, and the body carries each line verbatim. + * `isAncestor`, when given, also requires the review-loop exception's fix-check head to be the receipt head or its ancestor. + */ +export function exceptionProblems(body: string, r: ShipReceipt, isAncestor?: (commit: string) => boolean): string[] { + const out: string[] = []; + const raw = r as ShipReceipt & { docs?: unknown }; + if (raw.docs !== undefined) { + const d = parseReceiptDocs(raw.docs); + if (!d.ok) out.push(`docs: ${d.message}`); + else { const line = docsExceptionLine(d.docs); if (line && !carries(body, line)) out.push(`the body does not carry the documentation exception line verbatim: ${JSON.stringify(line)}`); } + } + if (r.review_loop_exception !== undefined) { + const e = parseReviewLoopException(r.review_loop_exception); + if (!e.ok) out.push(e.message); + else { + if (!r.review_loop?.fix_checks) out.push('review_loop_exception needs review_loop.fix_checks >= 1 (the stop it accepts follows a fix-check)'); + if (isAncestor && !isAncestor(e.exception.fix_check.head)) out.push(`review_loop_exception.fix_check.head ${e.exception.fix_check.head.slice(0, 12)} is not the receipt head or an ancestor of it`); + const line = reviewLoopExceptionLine(e.exception); + if (!carries(body, line)) out.push(`the body does not carry the review-loop exception line verbatim: ${JSON.stringify(line)}`); + } + } + return out; +} + +export interface DepthVerdict { ok: boolean; line: string; message?: string } + +/** + * The depth the bound head needs against `baseRef`, recomputed from git + * objects; the recorded gate scan counts only when it names the receipt's + * head, otherwise the scan is unavailable and the head needs full. A receipt + * with no review_depth and no gstack_version predates depth receipts and is + * accepted as legacy; one from a writer that records depth but lacks it is not. + */ +export function receiptDepthVerdict(root: string, baseRef: string, r: ShipReceipt, head: string): DepthVerdict { + const rd = r.review_depth; + if (!rd && !r.gstack_version) return { ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' }; + const baseSha = git(root, ['rev-parse', '--verify', '--quiet', `${baseRef}^{commit}`]); + const baseRev = baseSha.ok ? baseSha.out : baseRef; + const c = collectCommit(root, baseRev, head); + const scanBound = !!rd && rd.gate_scan.tagged !== null && /^[0-9a-f]{40,64}$/.test(rd.gate_scan.head) && rd.gate_scan.head === head.toLowerCase(); + const need = depthOfCommitInput({ ...c, scope: scopeOfRange(root, BIN_DIR, baseRev, head), gateTagged: scanBound ? rd!.gate_scan.tagged : null }); + const have = rd?.depth; + const inputs = `base@${short(baseSha.ok ? baseSha.out : null)},registry@${short(c.registry.sha)},gate_scan@${scanBound ? short(rd!.gate_scan.head) : 'missing'}`; + const line = `REVIEW_DEPTH: receipt=${have ?? 'none'} required=${need.depth} runtime_lines=${need.runtimeLines} class=${need.class} reasons=${need.reasons.join(',')} inputs=${inputs}`; + if (have && !depthBelow(have, need.depth)) return { ok: true, line }; + const why = have ? `was reviewed at ${have} depth` : 'records no review_depth though its writer records one'; + const errs = need.errors.map(e => `${e.code} (${e.input})`).join('; '); + return { ok: false, line, message: `the receipt ${why}, but head ${head.slice(0, 12)} needs ${need.depth} (${need.reasons.join(', ')}${errs ? `; ${errs}` : ''}); the receipt is unchanged; run /ship on ${head.slice(0, 12)}` }; +} + +/** bindReceipt plus the owner exceptions and the review depth: what READY and train build accept. */ +export function bindShippedReceipt(root: string, baseRef: string, body: string, pr: string | undefined, accepted: readonly string[]): + | { ok: true; receipt: ShipReceipt; head: string; line: string; depthLine: string } + | { ok: false; code: ReceiptBindingCode | 'QUEUE_RECEIPT_DEPTH_RAISED' | 'QUEUE_RECEIPT_EXCEPTION_INVALID'; message: string; line: string; depthLine?: string } { + const b = bindReceipt(body, pr, accepted); + if (!b.ok) return b; + const ancestor = (commit: string) => b.receipt.carry?.ancestry === 'rewritten' || git(root, ['merge-base', '--is-ancestor', commit, b.head]).ok; + const problems = exceptionProblems(body, b.receipt, ancestor); + if (problems.length) return { ok: false, code: 'QUEUE_RECEIPT_EXCEPTION_INVALID', message: `the receipt's owner exception does not stand: ${problems.join('; ')}`, line: b.line }; + const d = receiptDepthVerdict(root, baseRef, b.receipt, b.head); + if (!d.ok) return { ok: false, code: 'QUEUE_RECEIPT_DEPTH_RAISED', message: d.message!, line: b.line, depthLine: d.line }; + return { ...b, depthLine: d.line }; +} + diff --git a/lib/ship-receipt.ts b/lib/ship-receipt.ts index aa86abf11..25ba416e4 100644 --- a/lib/ship-receipt.ts +++ b/lib/ship-receipt.ts @@ -17,7 +17,8 @@ export const RECEIPT_FENCE = 'gstack-ship-receipt'; const KEY_ORDER: Array = [ 'schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', - 'head_tree', 'product_patch_id', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', 'carry', + 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth', 'pregate', 'requires_remote', 'issues', 'approval', 'docs', 'review_loop', + 'review_loop_exception', 'carry', ]; /** Keys in the fixed order so identifiers lead; undefined values are dropped. */ @@ -118,6 +119,7 @@ export function receiptSummaryLine(r: ShipReceipt): string { if (r.predecessor) parts.push(`predecessor=${r.predecessor.slice(0, 12)}`); if (r.session_kind) parts.push(`session_kind=${r.session_kind}`); if (r.artifacts_consumed) parts.push(`artifacts_consumed=${r.artifacts_consumed}`); + if (r.review_depth && typeof r.review_depth.depth === 'string') parts.push(`depth=${r.review_depth.depth}`); if (r.approval) parts.push(`approval=${approvalSummary(r.approval)}`); const loop = parseReviewLoop(r.review_loop); if (loop) parts.push(`passes=${loop.passes} blocking_found=${loop.blocking_found} deferred=${loop.deferred}` diff --git a/package.json b/package.json index 522b6b09a..8176ec25d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.306", + "version": "1.91.307", "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/resolvers/review-army.ts b/scripts/resolvers/review-army.ts index 1c174ac94..f4142ee0e 100644 --- a/scripts/resolvers/review-army.ts +++ b/scripts/resolvers/review-army.ts @@ -139,7 +139,7 @@ Every other specialist, including Testing, reads the full diff with fixtures. 5. Instructions: "You are a specialist code reviewer. Read the checklist at {checklist path}, then run -\`${diffBase(ctx)} && git diff "$DIFF_BASE"\` to get the ${ctx.skillName === 'ship' ? 'diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable)' : 'full diff'}. Apply the checklist against the diff. +\`${diffBase(ctx)} && git diff "$DIFF_BASE"${ctx.skillName === 'ship' ? ' ' : ''}\` to get the ${ctx.skillName === 'ship' ? 'diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable; ``: everything after `` on this reviewer\'s `DIFF_CMD:` line, verbatim; empty on `DIFF_EXCLUDE: none`)' : 'full diff'}. Apply the checklist against the diff. \`NO FINDINGS\` alone is the expected, complete result for a clean diff; never invent an item. Otherwise output one JSON object per finding per line, 400 words at most: {\\"severity\\":\\"CRITICAL|INFORMATIONAL\\",\\"confidence\\":N,\\"path\\":\\"file\\",\\"line\\":N,\\"category\\":\\"category\\",\\"summary\\":\\"description\\",\\"fix\\":\\"recommended fix\\",\\"fingerprint\\":\\"path:line:category\\",\\"specialist\\":\\"name\\"} diff --git a/scripts/review-dispatch-compare.ts b/scripts/review-dispatch-compare.ts index 966e4e1e6..d38198e64 100755 --- a/scripts/review-dispatch-compare.ts +++ b/scripts/review-dispatch-compare.ts @@ -17,7 +17,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 { planReview, specialistsFor, type DiffClass, type Dispatch, type History, type NumbersResult } from '../lib/review-depth'; +import { planReview, specialistsFor, type DiffClass, type History, type NumbersResult, type PathLines } from '../lib/review-depth'; const ROOT = path.resolve(import.meta.dir, '..'); const BYTES_PER_TOKEN = 4; @@ -30,7 +30,8 @@ const DEFAULT_OUT: Record = { 'outside-voice': { full: 6000, light: 6000 }, 'fix-check': { full: 4000, light: 2000 }, audit: { full: 5000, light: 2500 }, 'autoplan-voice': { full: 9000, light: 4500 }, }; -export interface Fixture { name: string; commit: string; paths: string[]; deleted: string[]; stamp_only?: string[]; scope: Record; gate_tagged: number | null; diff_lines: number; diff_bytes: number; numbers: NumbersResult } +/** per_path and registry_outputs (captured since GSTA-147) feed the runtime-line count; an older fixture counts every code-class line as runtime. */ +export interface Fixture { name: string; commit: string; paths: string[]; deleted: string[]; stamp_only?: string[]; scope: Record; gate_tagged: number | null; diff_lines: number; diff_bytes: number; numbers: NumbersResult; per_path?: PathLines[]; registry_outputs?: string[] } /** clean: pass 1 finds nothing; nits: pass 1 fixes below-floor findings; blocker: pass 1 fixes a blocker; adversarial-fix: Step 11 finds a should that is fixed; fix-regression: a pass-1 fix introduces a blocker the next step finds. */ type Scenario = 'clean' | 'nits' | 'blocker' | 'adversarial-fix' | 'fix-regression'; const SCENARIOS: Scenario[] = ['clean', 'nits', 'blocker', 'adversarial-fix', 'fix-regression']; @@ -62,7 +63,7 @@ function legacyShip(f: Fixture, sc: Scenario): Call[] { } function base(f: Fixture) { - return { paths: f.paths, deleted: f.deleted, stampOnly: f.stamp_only ?? [], scope: f.scope, gateTagged: f.gate_tagged, diffLines: f.diff_lines, numbers: f.numbers }; + return { paths: f.paths, deleted: f.deleted, stampOnly: f.stamp_only ?? [], scope: f.scope, gateTagged: f.gate_tagged, diffLines: f.diff_lines, numbers: f.numbers, perPath: f.per_path, registry: f.registry_outputs ? { sha: null, outputs: f.registry_outputs } : undefined }; } /** The new policy, step by step through lib/review-depth's own transition table. */ @@ -121,10 +122,10 @@ function capture(sha: string, name: string, out: string): number { if (r.status !== 0) throw new Error(`gstack-review-plan failed: ${r.stderr}`); const plan = JSON.parse(r.stdout); const diffBytes = Buffer.byteLength(git(['diff', `${sha}^`, sha], dir).stdout ?? ''); - const fixture: Fixture = { name, commit: sha, paths: plan.paths, deleted: plan.deleted, stamp_only: plan.stamp_only, scope: plan.scope, gate_tagged: plan.gate_tagged, diff_lines: plan.diff_lines, diff_bytes: diffBytes, numbers: plan.numbers }; + const fixture: Fixture = { name, commit: sha, paths: plan.paths, deleted: plan.deleted, stamp_only: plan.stamp_only, scope: plan.scope, gate_tagged: plan.gate_tagged, diff_lines: plan.diff_lines, diff_bytes: diffBytes, numbers: plan.numbers, per_path: plan.per_path, registry_outputs: plan.registry.outputs }; fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, JSON.stringify(fixture, null, 2) + '\n'); - process.stdout.write(`CAPTURED: ${name} ${sha} class=${plan.class} paths=${plan.paths.length} lines=${plan.diff_lines} -> ${out}\n`); + process.stdout.write(`CAPTURED: ${name} ${sha} class=${plan.class} depth=${plan.depth} runtime=${plan.runtime_lines} paths=${plan.paths.length} lines=${plan.diff_lines} -> ${out}\n`); return 0; } finally { git(['worktree', 'remove', '--force', dir]); @@ -135,12 +136,13 @@ function capture(sha: string, name: string, out: string): number { export function compare(fixtures: Fixture[], out: typeof DEFAULT_OUT) { const rows: Array> = []; for (const f of fixtures) { - const cls: DiffClass = planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }).class; + const first = planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }); + const cls: DiffClass = first.class; for (const sc of SCENARIOS) { const legacy = legacyShip(f, sc); const next = newShip(f, sc); const lt = tokens(legacy, out); const nt = tokens(next.calls, out); - rows.push({ fixture: f.name, class: cls, scenario: sc, legacy_dispatches: legacy.length, new_dispatches: next.calls.length, legacy_tokens: lt, new_tokens: nt, ratio: Math.round((nt / lt) * 100) / 100, ending: next.ending }); + rows.push({ fixture: f.name, class: cls, depth: first.depth, runtime_lines: first.runtimeLines, scenario: sc, legacy_dispatches: legacy.length, new_dispatches: next.calls.length, legacy_tokens: lt, new_tokens: nt, ratio: Math.round((nt / lt) * 100) / 100, ending: next.ending }); } } return rows; @@ -158,8 +160,8 @@ function main(argv: string[]): number { const rows = compare(fixtures, out); const ap = fixtures.map(f => ({ fixture: f.name, runs: autoplanRuns(planReview({ ...base(f), history: { fullPasses: 0, last: 'none' } }).class === 'docs') })); if (argv.includes('--json')) { process.stdout.write(JSON.stringify({ tokens_source: tokFile ?? 'DEFAULT', ship: rows, autoplan: ap }) + '\n'); return 0; } - const lines = [`TOKENS: ${tokFile ? `measured (${tokFile})` : 'DEFAULT estimates (pass --tokens for measured output)'}`, '', '| fixture | class | scenario | /ship dispatches legacy → new | est. tokens legacy → new | ratio | ending |', '|---|---|---|---|---|---|---|']; - for (const r of rows) lines.push(`| ${r.fixture} | ${r.class} | ${r.scenario} | ${r.legacy_dispatches} → ${r.new_dispatches} | ${r.legacy_tokens} → ${r.new_tokens} | ${r.ratio} | ${r.ending} |`); + const lines = [`TOKENS: ${tokFile ? `measured (${tokFile})` : 'DEFAULT estimates (pass --tokens for measured output)'}`, '', '| fixture | class | depth (runtime lines) | scenario | /ship dispatches legacy → new | est. tokens legacy → new | ratio | ending |', '|---|---|---|---|---|---|---|---|']; + for (const r of rows) lines.push(`| ${r.fixture} | ${r.class} | ${r.depth} (${r.runtime_lines}) | ${r.scenario} | ${r.legacy_dispatches} → ${r.new_dispatches} | ${r.legacy_tokens} → ${r.new_tokens} | ${r.ratio} | ${r.ending} |`); lines.push('', '| fixture | /autoplan scenario | voice dispatches legacy → new |', '|---|---|---|'); for (const a of ap) for (const r of a.runs) lines.push(`| ${a.fixture} | ${r.scenario} | ${r.legacy} → ${r.next} |`); const median = (v: number[]) => { const m = Math.floor(v.length / 2); return v.length % 2 ? v[m]! : Math.round(((v[m - 1]! + v[m]!) / 2) * 100) / 100; }; @@ -168,6 +170,9 @@ function main(argv: string[]): number { const byClass = new Map(); for (const r of rows) byClass.set(String(r.class), [...(byClass.get(String(r.class)) ?? []), Number(r.ratio)].sort((x, y) => x - y)); lines.push(`SHIP_TOKEN_RATIO_BY_CLASS: ${[...byClass].map(([c, v]) => `${c} median=${median(v)} (n=${v.length})`).join('; ')}`); + const byDepth = new Map(); + for (const r of rows) byDepth.set(String(r.depth), [...(byDepth.get(String(r.depth)) ?? []), Number(r.ratio)].sort((x, y) => x - y)); + lines.push(`SHIP_TOKEN_RATIO_BY_DEPTH: ${[...byDepth].map(([d, v]) => `${d} median=${median(v)} (n=${v.length})`).join('; ')}`); process.stdout.write(lines.join('\n') + '\n'); return 0; } diff --git a/ship/SKILL.md b/ship/SKILL.md index 881cd3934..5846b96d3 100644 --- a/ship/SKILL.md +++ b/ship/SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/ship/SKILL.md.tmpl b/ship/SKILL.md.tmpl index 16f65656e..810bbd990 100644 --- a/ship/SKILL.md.tmpl +++ b/ship/SKILL.md.tmpl @@ -68,11 +68,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/ship/sections/adversarial.md b/ship/sections/adversarial.md index 3436b5c24..620f17248 100644 --- a/ship/sections/adversarial.md +++ b/ship/sections/adversarial.md @@ -2,7 +2,8 @@ **Step 9's last full-pass plan governs this step:** run the native pass only on `DISPATCH: adversarial-native` and the outside voice only on -`DISPATCH: outside-voice`. Without the native line, print `ADVERSARIAL: skipped (review plan)`; +`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; +above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; with neither line, take decision 3 of Finish the adversarial phase. ## Step 11: Adversarial review diff --git a/ship/sections/adversarial.md.tmpl b/ship/sections/adversarial.md.tmpl index abb9615ae..df982e205 100644 --- a/ship/sections/adversarial.md.tmpl +++ b/ship/sections/adversarial.md.tmpl @@ -1,6 +1,7 @@ **Step 9's last full-pass plan governs this step:** run the native pass only on `DISPATCH: adversarial-native` and the outside voice only on -`DISPATCH: outside-voice`. Without the native line, print `ADVERSARIAL: skipped (review plan)`; +`DISPATCH: outside-voice`. Without the native line (light depth at runtime 0; +above 0 the plan lists it beside the batched specialist), print `ADVERSARIAL: skipped (review plan)`; with neither line, take decision 3 of Finish the adversarial phase. {{ADVERSARIAL_STEP}} diff --git a/ship/sections/pr-body.md b/ship/sections/pr-body.md index 414abef37..a19f4aee7 100644 --- a/ship/sections/pr-body.md +++ b/ship/sections/pr-body.md @@ -227,6 +227,9 @@ rm -f .gstack/tmp/review-loop.json `--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the receipt carries it as `docs`, and a `blocked` status without the owner's exception, or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing written. +When the owner accepted a stop after a fix-check edit, add +`--review-loop-exception @` (`{accepted_by, date, via, reason, fix_check: {head, findings}}`) +and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its `REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&` diff --git a/ship/sections/pr-body.md.tmpl b/ship/sections/pr-body.md.tmpl index 55103556d..c9c727af0 100644 --- a/ship/sections/pr-body.md.tmpl +++ b/ship/sections/pr-body.md.tmpl @@ -210,6 +210,9 @@ rm -f .gstack/tmp/review-loop.json `--docs` reads the record Step 14.5 saved (`{status, scope[, exception]}`): the receipt carries it as `docs`, and a `blocked` status without the owner's exception, or a body missing the exception line, is `DOCS_AUDIT_BLOCKED` with nothing written. +When the owner accepted a stop after a fix-check edit, add +`--review-loop-exception @` (`{accepted_by, date, via, reason, fix_check: {head, findings}}`) +and put its printed `Review-loop exception:` line in the body; never a prose line under `REVIEW_LOOP:`. The summary reads this branch's review log for the invocation's passes (its `REVIEW_LOOP:` line is the one the Pre-Landing Review section carries). The `&&` diff --git a/ship/sections/review-army.md b/ship/sections/review-army.md index 32f18e440..06419d047 100644 --- a/ship/sections/review-army.md +++ b/ship/sections/review-army.md @@ -14,13 +14,19 @@ Every repeat starts before the checklist read and captures a fresh REVIEW_START. Finish the complete review and QA before applying any fix in Step 9.4. **Review plan.** Pass 1 runs `~/.claude/skills/gstack/bin/gstack-review-plan ---base `; a repeat uses the plan run that picked it. Save its `REVIEW_PLAN:` line and -fingerprint. Dispatch exactly its `DISPATCH:` lines (specialists, Red Team, Step -11's voices), each at its `effort=` on the same model family, in place of 9.1's and -9.2's own selection rules; a `SKIP:` reviewer -logs `{"dispatched":false,"reason":"scope"}`. Audits run at `AUDIT_EFFORT:`. The -core checklist, gate scan and QA always run. Item 6's row adds -`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`. +--base `, plus `--depth-floor ` on every plan run when the /ship prompt +gave `--depth-floor ` (it only raises); a repeat uses the plan run that picked it. +Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth, +`runtime_lines`, `rules`) and `GATE_SCAN:`. Dispatch exactly its `DISPATCH:` lines +(specialists, Red Team, Step 11's voices), each at its `effort=` on the same model +family and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules; +`specialist batched` (light depth) is one subagent reading every checklist its +`checklists=` names, Red Team included, tagging each finding with its checklist. +A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at +`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds +`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`, +`"runtime_lines"`, `"depth_rules"` (the `rules` hash) and +`"gate_scan":{"tagged":N|null,"head":""}` from those lines. **Pass 2+ reads the range, not the tree** (`review_scope frozen`): the core read in item 2 becomes `git diff $RANGE_BASE`, and every dispatched prompt (specialists, @@ -391,7 +397,7 @@ Every other specialist, including Testing, reads the full diff with fixtures. 5. Instructions: "You are a specialist code reviewer. Read the checklist at {checklist path}, then run -`DIFF_BASE= && git diff "$DIFF_BASE"` to get the diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable). Apply the checklist against the diff. +`DIFF_BASE= && git diff "$DIFF_BASE" ` to get the diff (``: write the sha in before dispatch, the merge base on pass 1 and `RANGE_BASE` from pass 2 on; never a shell variable; ``: everything after `` on this reviewer's `DIFF_CMD:` line, verbatim; empty on `DIFF_EXCLUDE: none`). Apply the checklist against the diff. `NO FINDINGS` alone is the expected, complete result for a clean diff; never invent an item. Otherwise output one JSON object per finding per line, 400 words at most: {\"severity\":\"CRITICAL|INFORMATIONAL\",\"confidence\":N,\"path\":\"file\",\"line\":N,\"category\":\"category\",\"summary\":\"description\",\"fix\":\"recommended fix\",\"fingerprint\":\"path:line:category\",\"specialist\":\"name\"} @@ -802,7 +808,8 @@ After persistence, record missing output, CYCLES and fixes in the invocation rec Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan --base --pass $PASS --last full` with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass -logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `); +logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, +`--depth-floor ` when given); its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. Other scopes apply decisions 2–4 in order. diff --git a/ship/sections/review-army.md.tmpl b/ship/sections/review-army.md.tmpl index 6317b79c6..704bfb6b1 100644 --- a/ship/sections/review-army.md.tmpl +++ b/ship/sections/review-army.md.tmpl @@ -12,13 +12,19 @@ Every repeat starts before the checklist read and captures a fresh REVIEW_START. Finish the complete review and QA before applying any fix in Step 9.4. **Review plan.** Pass 1 runs `~/.claude/skills/gstack/bin/gstack-review-plan ---base `; a repeat uses the plan run that picked it. Save its `REVIEW_PLAN:` line and -fingerprint. Dispatch exactly its `DISPATCH:` lines (specialists, Red Team, Step -11's voices), each at its `effort=` on the same model family, in place of 9.1's and -9.2's own selection rules; a `SKIP:` reviewer -logs `{"dispatched":false,"reason":"scope"}`. Audits run at `AUDIT_EFFORT:`. The -core checklist, gate scan and QA always run. Item 6's row adds -`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`. +--base `, plus `--depth-floor ` on every plan run when the /ship prompt +gave `--depth-floor ` (it only raises); a repeat uses the plan run that picked it. +Save its `REVIEW_PLAN:` line and fingerprint, `REVIEW_DEPTH:` (depth, +`runtime_lines`, `rules`) and `GATE_SCAN:`. Dispatch exactly its `DISPATCH:` lines +(specialists, Red Team, Step 11's voices), each at its `effort=` on the same model +family and reading its `DIFF_CMD:`, in place of 9.1's and 9.2's own selection rules; +`specialist batched` (light depth) is one subagent reading every checklist its +`checklists=` names, Red Team included, tagging each finding with its checklist. +A `SKIP:` reviewer logs `{"dispatched":false,"reason":"scope"}`. Audits run at +`AUDIT_EFFORT:`. The core checklist, gate scan and QA always run. Item 6's row adds +`"dispatches":[{"role":R,"prompt_bytes":N,"diff_bytes":N}]`, `"depth"`, +`"runtime_lines"`, `"depth_rules"` (the `rules` hash) and +`"gate_scan":{"tagged":N|null,"head":""}` from those lines. **Pass 2+ reads the range, not the tree** (`review_scope frozen`): the core read in item 2 becomes `git diff $RANGE_BASE`, and every dispatched prompt (specialists, @@ -216,7 +222,8 @@ After persistence, record missing output, CYCLES and fixes in the invocation rec Decision 1 always applies first. Then, under `frozen`, run `gstack-review-plan --base --pass $PASS --last full` with each flag that holds (`--fixed-blocking`, `--fixed-below`, `--blocking-open`, `--touched-gate` for a fix to an item 2.5 path, `--critical-found` when this pass -logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `); +logged a CRITICAL, `--adversarial-ran` once Step 11's native pass ran, `--reviewed-numbers `, +`--depth-floor ` when given); its `NEXT:` alone picks the next decision: `stop` 2, `full` 3, `fix-check` 3.5, `done` 4. Other scopes apply decisions 2–4 in order. diff --git a/test/diff-scope.test.ts b/test/diff-scope.test.ts index 9597362a9..e33469c1a 100644 --- a/test/diff-scope.test.ts +++ b/test/diff-scope.test.ts @@ -367,6 +367,27 @@ describe('uncommitted work is visible (#2299)', () => { expect(vars.SCOPE_BACKEND).toBe('true'); }); + // Value: protects=--head scopes one committed range from any checkout, the input the receipt writer and the merge queue + // share; fails_when=--head still reads the working tree or untracked files, or an unknown head reads as all-false; seam=none + test('--head scopes only the committed range: working-tree and untracked files are ignored, an unknown head is an error', () => { + const dir = createRepo(['lib/auth.ts']); + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: dir, stdio: 'pipe' }).stdout.toString().trim(); + mkdirSync(join(dir, 'db', 'migrate'), { recursive: true }); + writeFileSync(join(dir, 'db', 'migrate', '001.rb'), '# untracked\n'); + writeFileSync(join(dir, 'styles.css'), 'a{}\n'); + const scope = (args: string[]) => spawnSync('bash', [SCRIPT, 'main', ...args], { cwd: dir, stdio: 'pipe', timeout: 5000 }); + const ranged = scope(['--head', head]); + expect(ranged.status).toBe(0); + const out = ranged.stdout.toString(); + expect(out).toContain('SCOPE_AUTH=true'); + expect(out).toContain('SCOPE_MIGRATIONS=false'); + expect(out).toContain('SCOPE_FRONTEND=false'); + expect(runScopeFull(dir).vars.SCOPE_MIGRATIONS).toBe('true'); + const unknown = scope(['--head', 'f'.repeat(40)]); + expect(unknown.status).toBe(2); + expect(unknown.stdout.toString()).toContain('SCOPE_ERROR=no_head'); + }); + test('non-ASCII path still matches extension globs (NUL-safe file listing, #2526)', () => { const dir = createRepo(['docs/M2 — Notes.md']); const { vars, status } = runScopeFull(dir); diff --git a/test/fixtures/golden/agy-ship-SKILL.md b/test/fixtures/golden/agy-ship-SKILL.md index 7494fe0c4..59c5baac2 100644 --- a/test/fixtures/golden/agy-ship-SKILL.md +++ b/test/fixtures/golden/agy-ship-SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/claude-ship-SKILL.md b/test/fixtures/golden/claude-ship-SKILL.md index 881cd3934..5846b96d3 100644 --- a/test/fixtures/golden/claude-ship-SKILL.md +++ b/test/fixtures/golden/claude-ship-SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/codex-ship-SKILL.md b/test/fixtures/golden/codex-ship-SKILL.md index c4d937f91..afd639848 100644 --- a/test/fixtures/golden/codex-ship-SKILL.md +++ b/test/fixtures/golden/codex-ship-SKILL.md @@ -200,11 +200,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/fixtures/golden/factory-ship-SKILL.md b/test/fixtures/golden/factory-ship-SKILL.md index 374d879a4..e2c3e30bc 100644 --- a/test/fixtures/golden/factory-ship-SKILL.md +++ b/test/fixtures/golden/factory-ship-SKILL.md @@ -202,11 +202,11 @@ token; `--finish` stamps the binding fields automatically. Never borrow or repla `gstack-wtree` prints a Git tree hash covering tracked and non-ignored untracked files, not a commit ID. Use `git diff ` to compare these snapshots. -**Review policy** (docs/review-policy.md): a finding blocks another review pass -only when it would fail a test, break production, or lose data or secrets; -everything below that floor ships as a named note in the PR body. Pass 1 reviews -the diff; a later pass reads only the range; two full passes plus one fix-check -is the cap. `NO FINDINGS` is a complete reviewer result. `ship: review_scope` (Step 3) +**Review policy** (docs/review-policy.md): only a finding that would fail a test, +break production, or lose data or secrets blocks a pass; the rest is a PR-body +note. Step 9's plan sets both reviews' depth (`--depth-floor standard|full` only +raises it). Pass 1 reads the diff, later passes the range; the cap is two full +passes and a fix-check. `NO FINDINGS` is complete. `ship: review_scope` (Step 3) is the knob; the receipt's `review_loop` records the outcome. ### Ship control flow diff --git a/test/gstack-ship-receipt.test.ts b/test/gstack-ship-receipt.test.ts index b9fa23a3b..15ac078b2 100644 --- a/test/gstack-ship-receipt.test.ts +++ b/test/gstack-ship-receipt.test.ts @@ -44,11 +44,11 @@ describe('gstack-ship-receipt write', () => { '--session-kind', 'unattended', '--artifacts-consumed', '2/3', '--run', 'run-1', '--thread', 'jam_1', '--queue-mode', 'stamp-at-merge', '--preregistration', 'none (no preregistration_shas)', '--history', 'squash-ok', '--body', body, '--out', path.join(repo.root, 'receipt.md')]); expect(ok.status).toBe(EXIT.ok); - expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3`); + expect(ok.err.trim()).toBe(`SHIP_RECEIPT: pr=42 head=${head.slice(0, 12)} version=1.2.4.0 base=main gated_tree=${repo.git('rev-parse', 'HEAD^{tree}').slice(0, 12)} tree=same-modulo-stamps gate-reuse=eligible (reusable: tests; rerun: -) predecessor=${'b'.repeat(12)} session_kind=unattended artifacts_consumed=2/3 depth=light`); const parsed = extractReceipt(ok.out); expect(parsed.found).toBe(true); expect(parsed.errors).toEqual([]); - expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id']); + expect(Object.keys(parsed.receipt!)).toEqual(['schema_version', 'pr', 'head', 'version', 'base', 'gated_tree', 'predecessor', 'tree', 'gate_reuse', 'gate', 'ci', 'spend_usd', 'session_kind', 'artifacts_consumed', 'run', 'thread', 'policy', 'queue_mode', 'preregistration', 'history', 'head_tree', 'product_patch_id', 'gstack_version', 'review_depth']); expect(parsed.receipt).toMatchObject({ schema_version: 1, pr: 42, head, version: '1.2.4.0', base: 'main', tree: 'same-modulo-stamps', spend_usd: 0.42, ci: { checks: { pass: 3 } } }); expect(parsed.receipt!.head_tree).toBe(repo.git('rev-parse', 'HEAD^{tree}')); expect(parsed.receipt!.product_patch_id).toMatch(/^([0-9a-f]{40}|none)$/); diff --git a/test/review-collect.test.ts b/test/review-collect.test.ts new file mode 100644 index 000000000..0c86ac6ef --- /dev/null +++ b/test/review-collect.test.ts @@ -0,0 +1,119 @@ +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 { collectCommit, collectLive, loadRegistryAt } from '../lib/review-collect'; +import { depthOfCommitInput } from '../lib/review-depth'; + +const dirs: string[] = []; +afterEach(() => { while (dirs.length) fs.rmSync(dirs.pop()!, { recursive: true, force: true }); }); +const lines = (n: number, tag = 'v') => Array.from({ length: n }, (_, i) => `export const ${tag}${i} = ${i};`).join('\n') + '\n'; +const REGISTRY = JSON.stringify({ entries: [{ id: 'docs', command: 'bun run gen', outputs: ['*/SKILL.md'] }, { id: 'manual', command: null, outputs: ['src/hand.ts'] }] }); + +function repo(baseFiles: Record) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'review-collect-')); + dirs.push(dir); + const git = (...a: string[]) => { const r = spawnSync('git', a, { cwd: dir, encoding: 'utf8', timeout: 10_000 }); if (r.status !== 0) throw new Error(`git ${a.join(' ')}: ${r.stderr}`); return r.stdout.trim(); }; + const write = (files: Record) => { for (const [p, text] of Object.entries(files)) { const f = path.join(dir, p); if (text === null) { fs.rmSync(f); continue; } fs.mkdirSync(path.dirname(f), { recursive: true }); fs.writeFileSync(f, text); } }; + git('init', '-q', '-b', 'main'); git('config', 'user.email', 't@t'); git('config', 'user.name', 't'); git('config', 'commit.gpgsign', 'false'); + write({ 'README.md': '# x\n', ...baseFiles }); git('add', '-A'); git('commit', '-qm', 'base'); + const baseSha = git('rev-parse', 'HEAD'); + git('checkout', '-qb', 'feat'); + const commit = (files: Record) => { write(files); git('add', '-A'); git('commit', '-qm', 'feat'); return git('rev-parse', 'HEAD'); }; + return { dir, git, write, commit, baseSha }; +} + +describe('collectCommit: git objects only', () => { + // Value: protects=only a bare version bump in package.json is release metadata; fails_when=a version line that also + // carries scripts counts as stamp-only (docs class, light); seam=none + test('a package.json version line that also carries scripts is not stamp-only; a bare version bump is', () => { + const r = repo({ 'package.json': '{\n "name": "x",\n "version": "1.0.0"\n}\n' }); + const bare = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.1"\n}\n' }); + expect(collectCommit(r.dir, r.baseSha, bare).stampOnly).toEqual(['package.json']); + const riding = r.commit({ 'package.json': '{\n "name": "x",\n "version": "1.0.2", "scripts": { "postinstall": "node x.js" }\n}\n' }); + expect(collectCommit(r.dir, r.baseSha, riding).stampOnly).toEqual([]); + }); + test('a registered glob output is excluded, a command:null entry is not, and a .tmpl edit counts once', () => { + const r = repo({ '.gstack/generated.json': REGISTRY, 'ship/SKILL.md.tmpl': 'a\n', 'ship/SKILL.md': 'a\n' }); + const head = r.commit({ 'ship/SKILL.md.tmpl': 'a\nb\nc\n', 'ship/SKILL.md': lines(120), 'src/hand.ts': lines(5) }); + const c = collectCommit(r.dir, r.baseSha, head); + expect(c.errors).toEqual([]); + expect(c.registry).toEqual({ sha: r.baseSha, outputs: ['*/SKILL.md'] }); + const d = depthOfCommitInput({ ...c, gateTagged: 0 }); + expect([d.depth, d.runtimeLines]).toEqual(['light', 2 + 5]); + }); + test('a head-only registry entry never excludes: the registry is the base commit\'s', () => { + const r = repo({ 'src/keep.ts': lines(1) }); + const head = r.commit({ '.gstack/generated.json': JSON.stringify({ entries: [{ id: 'gen', command: 'gen', outputs: ['src/gen.ts'] }] }), 'src/gen.ts': lines(60) }); + const c = collectCommit(r.dir, r.baseSha, head); + expect(c.registry).toEqual({ sha: r.baseSha, outputs: [] }); + const d = depthOfCommitInput({ ...c, gateTagged: 0 }); + expect(d.runtimeLines).toBeGreaterThanOrEqual(60); + expect(d.depth).toBe('full'); + }); + test('a rename into src/ counts the new path as runtime', () => { + const r = repo({ 'notes/a.txt': lines(30) }); + fs.mkdirSync(path.join(r.dir, 'src')); + r.git('mv', 'notes/a.txt', 'src/a.ts'); + r.git('commit', '-qm', 'mv'); + const c = collectCommit(r.dir, r.baseSha, r.git('rev-parse', 'HEAD')); + expect(c.deleted).toEqual(['notes/a.txt']); + expect(c.perPath.find(e => e.path === 'src/a.ts')).toEqual({ path: 'src/a.ts', lines: 30, binary: false }); + expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 30 }); + }); + test('a binary code-class path is an error (full), never 0 lines; a deleted test is full', () => { + const r = repo({ 'test/old.test.ts': 'x\n' }); + const head = r.commit({ 'lib/x.wasm': Buffer.from([0, 97, 115, 109, 0, 1, 2]) }); + const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 }); + expect(d.depth).toBe('full'); + expect(d.errors).toEqual([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]); + const t = repo({ 'test/old.test.ts': 'x\n', 'test/keep.test.ts': 'x\n' }); + const del = t.commit({ 'test/old.test.ts': null }); + const deleted = depthOfCommitInput({ ...collectCommit(t.dir, t.baseSha, del), gateTagged: 0 }); + expect([deleted.depth, deleted.runtimeLines, deleted.errors]).toEqual(['full', 0, []]); + }); + test('an invalid base registry is an error with its result code; an unresolvable sha is unavailable', () => { + const r = repo({ '.gstack/generated.json': '{"entries": [{"id": "x"}]}' }); + const head = r.commit({ 'src/a.ts': lines(3) }); + const d = depthOfCommitInput({ ...collectCommit(r.dir, r.baseSha, head), gateTagged: 0 }); + expect(d.depth).toBe('full'); + expect(d.codes).toEqual(['REGEN_REGISTRY_INVALID']); + expect(d.errors[0]).toMatchObject({ code: 'registry_invalid', result: 'REGEN_REGISTRY_INVALID' }); + expect(d.errors[0]!.repair).toContain('gstack-regen validate'); + expect(loadRegistryAt(r.dir, 'f'.repeat(40)).error?.code).toBe('registry_unavailable'); + const missing = collectCommit(r.dir, 'f'.repeat(40), head); + expect(missing.errors.map(e => e.code)).toContain('numstat_failed'); + expect(depthOfCommitInput({ ...missing, gateTagged: 0 }).depth).toBe('full'); + }); + test('from a checkout at another HEAD with a dirty tree it equals the clean result at head', () => { + const r = repo({ '.gstack/generated.json': REGISTRY, 'src/a.ts': lines(2), 'ship/SKILL.md': 'a\n', 'autoplan/SKILL.md.tmpl': 'x\n' }); + const head = r.commit({ 'src/a.ts': lines(40), 'ship/SKILL.md': lines(9), 'docs/b.md': '# b\n12% faster\n', 'autoplan/notes.md': 'n\n', 'package.json': '{"version":"1.0.1"}\n' }); + const clean = collectCommit(r.dir, r.baseSha, head); + r.git('checkout', '-q', r.baseSha); + r.write({ 'src/a.ts': lines(500, 'dirty'), 'src/untracked.ts': lines(80), 'autoplan/SKILL.md.tmpl': null }); + const dirty = collectCommit(r.dir, r.baseSha, head); + expect(dirty).toEqual(clean); + expect(depthOfCommitInput({ ...dirty, gateTagged: 0 })).toEqual(depthOfCommitInput({ ...clean, gateTagged: 0 })); + expect(clean.root).toEqual(['autoplan', 'ship']); + expect(clean.added['docs/b.md']).toEqual(['# b', '12% faster']); + }); +}); + +describe('collectLive: the working tree', () => { + test('tracked uncommitted edits and untracked files count per path; scratch under .gstack/tmp/ does not', () => { + const r = repo({ 'src/a.ts': lines(1) }); + r.commit({ 'docs/a.md': '# a\n' }); + r.write({ 'src/a.ts': lines(11), 'src/new.ts': lines(20), '.gstack/tmp/x.ts': lines(500) }); + const c = collectLive(r.dir, 'main'); + expect(c.errors).toEqual([]); + expect(c.untracked).toEqual(['src/new.ts']); + expect(c.perPath.find(e => e.path === 'src/new.ts')).toEqual({ path: 'src/new.ts', lines: 20, binary: false }); + expect(c.paths).toEqual(['docs/a.md', 'src/a.ts', 'src/new.ts']); + expect(depthOfCommitInput({ ...c, gateTagged: 0 })).toMatchObject({ depth: 'light', runtimeLines: 20 + 10 }); + }); + test('no merge base is a collector error the CLI refuses on', () => { + const r = repo({}); + expect(collectLive(r.dir, 'no-such-branch').errors.map(e => e.code)).toEqual(['numstat_failed']); + }); +}); diff --git a/test/review-depth.test.ts b/test/review-depth.test.ts index d8c0ab4fa..db2ea9b05 100644 --- a/test/review-depth.test.ts +++ b/test/review-depth.test.ts @@ -1,6 +1,9 @@ import { describe, test, expect } 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 { classifyDiff, classifyPath, nextStep, planReview, scanNumbers, FULL_PASS_CAP, type PlanInput } from '../lib/review-depth'; +import { classifyDiff, classifyPath, depthFor, depthOfCommitInput, diffCommand, nextStep, planReview, scanNumbers, DEPTH_RULES, FULL_PASS_CAP, type DepthError, type PlanInput } from '../lib/review-depth'; const ROOT = path.join(import.meta.dir, '..'); const none = scanNumbers({}); @@ -73,9 +76,10 @@ describe('planReview', () => { const p = planReview(base({ paths: ['docs/bench.md'], scope: {}, numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) })); expect(roles(p)).toEqual(['outside-voice:outside-voice:host']); }); - test('tests class: testing specialist only', () => { + test('tests class: runtime 0 is light, core only (Behavior changes: no Testing specialist)', () => { const p = planReview(base({ paths: ['test/a.test.ts'], scope: { SCOPE_TESTS: 'true' } })); - expect(roles(p)).toEqual(['specialist:testing:full']); + expect(p.depth).toBe('light'); + expect(roles(p)).toEqual([]); }); test('code class pass 1: every reviewer at full effort, the outside voice at its own', () => { const p = planReview(base({})); @@ -88,7 +92,7 @@ describe('planReview', () => { }); test('code class honours hit-rate gating and small-diff skip', () => { expect(roles(planReview(base({ gated: ['simplification'] })))).not.toContain('specialist:simplification:full'); - expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist')).toEqual([]); + expect(planReview(base({ diffLines: 20 })).dispatches.filter(d => d.role === 'specialist').map(d => d.name)).toEqual(['batched']); }); test('pass 2 reruns the army on the range but not the adversarial or outside voice once Step 11 ran', () => { const p = planReview(base({ adversarialRan: true, history: { fullPasses: 1, last: 'full', fixedBlocking: true } })); @@ -167,3 +171,121 @@ describe('GSTA-131 coverage: Red Team and specialist arms', () => { expect(roles(planReview(base({ paths: ['docs/a.md'], force: ['security'] })))).toEqual(['specialist:security']); }); }); + +const per = (o: Record) => Object.entries(o).map(([p, lines]) => ({ path: p, lines, binary: false })); +const mixed = (o: Partial = {}) => base({ paths: Object.keys(o.perPath ? Object.fromEntries(o.perPath.map(e => [e.path, 1])) : {}), ...o }); +const err = (code: DepthError['code'], result?: DepthError['result']): DepthError => ({ code, detail: 'd', input: 'i', repair: 'r', ...(result ? { result } : {}) }); + +describe('review depth: the matrix', () => { + test('runtime 0 (tests and docs only) is light: core only, nothing dispatched', () => { + const p = planReview(mixed({ perPath: per({ 'test/a.test.ts': 300, 'docs/a.md': 40 }), diffLines: 340 })); + expect([p.depth, p.runtimeLines, p.dispatches]).toEqual(['light', 0, []]); + }); + test('runtime 1-50 is light plus one batched specialist: the fixed set, scope picks and the Red Team in one context', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 48, 'test/a.test.ts': 190, 'docs/a.md': 9 }), diffLines: 247 })); + expect([p.depth, p.runtimeLines]).toEqual(['light', 48]); + expect(p.dispatches).toEqual([ + { role: 'specialist', name: 'batched', effort: 'full', exclude: [':(exclude)docs/a.md'], checklists: ['testing', 'maintainability', 'security', 'performance', 'red-team'] }, + { role: 'adversarial-native', name: 'adversarial', effort: 'full', exclude: [], reason: 'backtest inconclusive (GSTA-147 D5)' }, + ]); + expect(p.skipped.join('; ')).toContain('red-team (light depth'); + expect(planReview(mixed({ perPath: per({ 'src/app.ts': 48 }), adversarialRan: true, history: { fullPasses: 1, last: 'full', touchedGate: true, fixedBelow: true } })).dispatches.some(d => d.role === 'adversarial-native')).toBe(false); + }); + test('runtime 51 is standard: thresholds read runtime lines, reviewers read their own hunks', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 400, 'README.md': 3 }), diffLines: 454 })); + expect([p.depth, p.runtimeLines]).toEqual(['standard', 51]); + expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']); + const ex = Object.fromEntries(p.dispatches.map(d => [d.name, d.exclude])); + expect(ex.testing).toEqual([':(exclude)README.md']); + expect(ex.maintainability).toEqual([':(exclude)README.md', ':(exclude)test/a.test.ts']); + expect([ex.adversarial, ex['outside-voice']]).toEqual([[], []]); + }); + test.each([ + ['scope error', { scope: { SCOPE_ERROR: 'boom' } }, 'scope_error', []], + ['gate scan unavailable', { gateTagged: null }, 'gate_scan_unavailable', []], + ['base registry invalid', { registry: { sha: 'abc', outputs: [], error: err('registry_invalid', 'REGEN_REGISTRY_INVALID') } }, 'registry_invalid', ['REGEN_REGISTRY_INVALID']], + ['base registry unavailable', { registry: { sha: null, outputs: [], error: err('registry_unavailable') } }, 'registry_unavailable', []], + ['numstat failure', { errors: [err('numstat_failed')] }, 'numstat_failed', []], + ['binary code path', { perPath: [...per({ 'src/app.ts': 5 }), { path: 'lib/x.wasm', lines: 0, binary: true }], paths: ['src/app.ts', 'lib/x.wasm'] }, 'binary_code_path', []], + ] as const)('%s fails closed to full with its code', (_n, o, code, codes) => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 5 }), ...(o as Partial) })); + expect(p.depth).toBe('full'); + expect(p.errors.map(e => e.code)).toContain(code); + expect(p.codes).toEqual([...codes]); + expect(roles(p)).toContain('red-team:red-team:full'); + }); + test.each([ + ['a gate path', { paths: ['src/app.ts', '.github/workflows/ci.yml'] }], + ['a gate tag', { gateTagged: 1 }], + ['a deleted test', { deleted: ['test/old.test.ts'] }], + ['an edit to the registry', { paths: ['.gstack/generated.json'] }], + ['an edit to SMALL_DIFF_LINES', { paths: ['lib/review-depth.ts'] }], + ['the collector', { paths: ['lib/review-collect.ts'] }], + ['the plan CLI', { paths: ['bin/gstack-review-plan'] }], + ['the queue', { paths: ['lib/ship-queue-train.ts'] }], + ['the Step 9 template', { paths: ['ship/sections/review-army.md.tmpl'] }], + ] as const)('%s is full', (_n, o) => { + const paths = (o as any).paths ?? ['src/app.ts']; + expect(planReview(mixed({ perPath: per(Object.fromEntries(paths.map((p: string) => [p, 2]))), ...(o as Partial) })).depth).toBe('full'); + }); + test('a registered glob output is excluded from runtime; a .tmpl edit plus its generated SKILL.md counts the tmpl only', () => { + const p = planReview(mixed({ perPath: per({ 'ship/SKILL.md.tmpl': 30, 'ship/SKILL.md': 200 }), registry: { sha: 'abc', outputs: ['*/SKILL.md'] } })); + expect([p.depth, p.runtimeLines]).toEqual(['light', 30]); + expect(p.dispatches[0]!.exclude).toEqual([':(exclude)ship/SKILL.md']); + }); + test('a path no base-registry entry names stays runtime', () => { + expect(planReview(mixed({ perPath: per({ 'src/gen.ts': 60 }), registry: { sha: 'abc', outputs: ['lib/dom.js'] } })).runtimeLines).toBe(60); + }); + test('numbers in light dispatch the outside voice with an empty exclude', () => { + const p = planReview(mixed({ perPath: per({ 'docs/bench.md': 4 }), numbers: scanNumbers({ 'docs/bench.md': ['3x faster'] }) })); + expect(p.depth).toBe('light'); + expect(p.dispatches).toEqual([{ role: 'outside-voice', name: 'outside-voice', effort: 'host', exclude: [] }]); + }); + test('a fixed blocker in light raises the next full step to the standard set, Red Team included', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 30 }), history: { fullPasses: 1, last: 'full', fixedBlocking: true } })); + expect([p.depth, p.stepDepth]).toEqual(['light', 'standard']); + expect(roles(p)).toEqual(['specialist:testing:full', 'specialist:maintainability:full', 'specialist:performance:full', 'red-team:red-team:full', 'adversarial-native:adversarial:full', 'outside-voice:outside-voice:host']); + }); + test('--depth-floor raises and never lowers', () => { + expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'full' })).toBe('full'); + expect(depthFor({ cls: 'code', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('standard'); + expect(depthFor({ cls: 'gate', runtimeLines: 10, errors: [], floor: 'standard' })).toBe('full'); + expect(depthFor({ cls: 'code', runtimeLines: 80, errors: [] })).toBe('standard'); + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10 }), floor: 'full' })); + expect(p.depth).toBe('full'); + expect(p.depthReasons).toContain('floor:full'); + }); + test('depthOfCommitInput gives the same depth as the plan for the same input', () => { + const i = { paths: ['src/app.ts', 'test/a.test.ts'], perPath: per({ 'src/app.ts': 51, 'test/a.test.ts': 9 }), gateTagged: 0 }; + expect(depthOfCommitInput(i)).toMatchObject({ depth: 'standard', runtimeLines: 51, rules: DEPTH_RULES, errors: [] }); + expect(depthOfCommitInput({ ...i, gateTagged: null }).depth).toBe('full'); + }); +}); + +describe('review depth: diff commands and the rules hash', () => { + test('DIFF_CMD shell-quotes each pathspec; a glob character makes the pathspec literal', () => { + const p = planReview(mixed({ perPath: per({ 'src/app.ts': 10, "docs/my notes*.md": 3, "docs/it's.md": 1 }) })); + const ex = p.dispatches[0]!.exclude; + expect(ex).toEqual([':(exclude,literal)docs/my notes*.md', ":(exclude)docs/it's.md"]); + expect(diffCommand(p.dispatches[0]!)).toBe("git diff -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)docs/it'\\''s.md'"); + expect(diffCommand({ role: 'red-team', name: 'red-team', effort: 'full', exclude: [] })).toBe('git diff '); + }); + test('DEPTH_RULES hashes the deciding constants: a comment-only edit keeps it, a threshold edit changes it', () => { + expect(DEPTH_RULES).toMatch(/^[0-9a-f]{16}$/); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'depth-rules-')); + try { + const src = fs.readFileSync(path.join(ROOT, 'lib/review-depth.ts'), 'utf8'); + fs.symlinkSync(path.join(ROOT, 'lib/ship-policy.ts'), path.join(dir, 'ship-policy.ts')); + const hashOf = (text: string) => { + fs.writeFileSync(path.join(dir, 'review-depth.ts'), text); + fs.writeFileSync(path.join(dir, 'print.ts'), "import { DEPTH_RULES } from './review-depth';\nconsole.log(DEPTH_RULES);\n"); + const r = spawnSync(process.execPath, ['--no-env-file', '--config=/dev/null', path.join(dir, 'print.ts')], { encoding: 'utf8', timeout: 30_000 }); + expect(r.status, r.stderr).toBe(0); + return r.stdout.trim(); + }; + expect(hashOf('// a comment-only edit\n' + src)).toBe(DEPTH_RULES); + expect(hashOf(src.replace('SMALL_DIFF_LINES = 50', 'SMALL_DIFF_LINES = 60'))).not.toBe(DEPTH_RULES); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }); +}); + diff --git a/test/review-dispatch-compare.test.ts b/test/review-dispatch-compare.test.ts index 804d9e0cf..2771d0a95 100644 --- a/test/review-dispatch-compare.test.ts +++ b/test/review-dispatch-compare.test.ts @@ -34,7 +34,7 @@ describe('scripts/review-dispatch-compare.ts', () => { const table = run(['--tokens', tok]); expect(table).toContain(`TOKENS: measured (${tok})`); expect(table).toMatch(/^SHIP_TOKEN_RATIO: median=0\.\d+ min=0\.\d+ max=\d\.\d+ \(n=10\)$/m); - expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); code median=0\.\d+ \(n=5\)$/m); + expect(table).toMatch(/^SHIP_TOKEN_RATIO_BY_CLASS: docs median=0\.\d+ \(n=5\); gate median=0\.\d+ \(n=5\)$/m); } finally { fs.rmSync(dir, { recursive: true, force: true }); } diff --git a/test/review-plan-cli.test.ts b/test/review-plan-cli.test.ts index c8aedb90e..ba8d77d9b 100644 --- a/test/review-plan-cli.test.ts +++ b/test/review-plan-cli.test.ts @@ -109,3 +109,51 @@ describe('gstack-review-plan', () => { expect(ran).toContain('SKIP: adversarial-native and outside-voice (run once per invocation)'); }); }); + +describe('gstack-review-plan: review depth', () => { + const code = (n: number) => Array.from({ length: n }, (_, i) => `export const v${i} = ${i};`).join('\n') + '\n'; + test('prints REVIEW_DEPTH with runtime lines and the rules hash, and GATE_SCAN bound to the head', () => { + const r = repo({ 'src/a.ts': code(10), 'test/a.test.ts': code(200) }); + const out = run(r, []).stdout; + const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: r.dir, encoding: 'utf8', timeout: 10_000 }).stdout.trim(); + expect(out).toMatch(/^REVIEW_DEPTH: light runtime_lines=10 rules=[0-9a-f]{16} reasons=runtime=10<=50 codes=none$/m); + expect(out).toContain(`GATE_SCAN: tagged=0 head=${head}`); + expect(out).toContain('DISPATCH: specialist batched effort=full checklists=testing,maintainability,security,performance,state-machines,red-team'); + expect(out).toContain('DISPATCH: adversarial-native adversarial effort=full reason=backtest inconclusive (GSTA-147 D5)'); + }); + test('DIFF_CMD is shell-quoted per dispatch and excludes what the reviewer does not read; DIFF_EXCLUDE: none when empty', () => { + const r = repo({ 'src/a.ts': code(80), 'docs/my notes*.md': '# n\n', 'test/a.test.ts': code(3) }); + const out = run(r, []).stdout; + expect(out).toContain("DIFF_CMD: testing git diff -- . ':(exclude,literal)docs/my notes*.md'"); + expect(out).toContain("DIFF_CMD: maintainability git diff -- . ':(exclude,literal)docs/my notes*.md' ':(exclude)test/a.test.ts'"); + expect(out).toContain('DIFF_CMD: adversarial git diff \nDIFF_EXCLUDE: adversarial none'); + const cmd = /^DIFF_CMD: maintainability (.*)$/m.exec(out)![1]!.replace('', 'main'); + const diff = spawnSync('bash', ['-c', cmd.replace('git diff main', 'git diff --name-only main')], { cwd: r.dir, encoding: 'utf8' }); + expect(diff.stdout.trim().split('\n')).toEqual(['src/a.ts']); + }); + test('--json carries depth, runtime_lines, rules, errors, gate_scan and every dispatch\'s exclude array', () => { + const r = repo({ 'src/a.ts': code(80), 'README.md': '# y\n' }); + const plan = JSON.parse(run(r, ['--json']).stdout); + expect(plan).toMatchObject({ depth: 'standard', step_depth: 'standard', runtime_lines: 80, errors: [], codes: [], gate_scan: { tagged: 0 } }); + expect(plan.rules).toMatch(/^[0-9a-f]{16}$/); + expect(plan.dispatches.find((d: any) => d.name === 'maintainability').exclude).toEqual([':(exclude)README.md']); + expect(plan.dispatches.find((d: any) => d.role === 'outside-voice').exclude).toEqual([]); + }); + test('--depth-floor full raises a light diff, never lowers a full one, and an invalid floor is a usage error', () => { + const r = repo({ 'src/a.ts': code(5) }); + expect(run(r, ['--depth-floor', 'full']).stdout).toMatch(/^REVIEW_DEPTH: full runtime_lines=5 .*reasons=runtime=5<=50,floor:full /m); + const gate = repo({ 'src/a.ts': code(5), '.github/workflows/ci.yml': 'on: push\n' }); + expect(run(gate, ['--depth-floor', 'standard']).stdout).toMatch(/^REVIEW_DEPTH: full /m); + expect(run(r, ['--depth-floor', 'light']).status).toBe(2); + }); + test('a conservative full plan exits 0 with its errors; a plan that cannot be built exits 1', () => { + const r = repo({ 'lib/x.wasm': '\u0000asm\u0000' }); + const res = run(r, ['--json']); + expect(res.status).toBe(0); + expect(JSON.parse(res.stdout)).toMatchObject({ depth: 'full', errors: expect.arrayContaining([expect.objectContaining({ code: 'binary_code_path', input: 'lib/x.wasm' })]) }); + expect(run(r, []).stdout).toContain('DEPTH_ERROR: binary_code_path input=lib/x.wasm'); + const none = spawnSync(BIN, ['--base', 'no-such-branch', '--gate-tagged', '0'], { cwd: r.dir, encoding: 'utf8', timeout: 60_000, env: { ...process.env, GSTACK_STATE_ROOT: r.state } }); + expect(none.status).toBe(1); + }); +}); + diff --git a/test/ship-receipt-depth.test.ts b/test/ship-receipt-depth.test.ts new file mode 100644 index 000000000..1a7e2e8fd --- /dev/null +++ b/test/ship-receipt-depth.test.ts @@ -0,0 +1,207 @@ +/** + * The /ship receipt's review depth and review-loop exception (GSTA-147 PR 2, + * docs/review-policy.md#review-depth, #3559): write records the depth gstack + * computes at the checked-out head, the queue recomputes the depth the head + * needs and refuses a receipt below it, a carry rebinds the gate scan or + * refuses when the head needs more, and the review-loop exception is checked + * like the documentation exception. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { extractReceipt, renderReceiptBlock } from '../lib/ship-receipt'; +import { bindShippedReceipt, receiptDepthVerdict, reviewLoopExceptionLine } from '../lib/ship-receipt-depth'; +import { cleanup, commit, makeFixtureRepo, runBin, type FixtureRepo } from './helpers/pregate-fixture'; + +const repos: FixtureRepo[] = []; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); +const receiptIn = (body: string) => extractReceipt(fs.readFileSync(body, 'utf8')).receipt!; +const writeReceipt = (repo: FixtureRepo, body: string, extra: string[] = []) => + runBin(repo, 'gstack-ship-receipt', ['write', '--pr', '7', '--base', 'main', '--version', '1.0.0', '--session-kind', 'unattended', '--body', body, ...extra]); +const lines = (n: number) => Array.from({ length: n }, (_, i) => `exports.f${i} = () => ${i};`).join('\n') + '\n'; +const rewrite = (body: string, edit: (r: Record) => void) => { + const text = fs.readFileSync(body, 'utf8'); + const r = extractReceipt(text).receipt as Record; + edit(r); + fs.writeFileSync(body, text.replace(/```gstack-ship-receipt[\s\S]*?\n```\n/, renderReceiptBlock(r as any))); + return fs.readFileSync(body, 'utf8'); +}; + +describe('write records the review depth at the checked-out head', () => { + // Value: protects=the receipt names the depth and a gate scan bound to the head it is written for, and never binds a + // tree it did not review; fails_when=write records no depth, or --head accepts a commit other than HEAD; seam=none + test('a light code diff records depth, runtime lines, the base and the gate scan at HEAD; --head of another commit writes nothing', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const older = commit(repo, { 'lib/util.js': lines(3) }); + const head = commit(repo, { 'lib/more.js': lines(2) }); + const body = path.join(repo.root, 'body.md'); + const ok = writeReceipt(repo, body); + expect(ok.status).toBe(EXIT.ok); + expect(ok.err).toContain('depth=light'); + const r = receiptIn(body); + expect(r.gstack_version).toMatch(/^\d+\.\d+\.\d+\.\d+$/); + const base = repo.git('rev-parse', 'origin/main'); + expect(r.review_depth).toMatchObject({ depth: 'light', class: 'code', base, registry: base, gate_scan: { tagged: 0, head } }); + expect(r.review_depth!.runtime_lines).toBeGreaterThanOrEqual(5); + expect(r.review_depth!.runtime_lines).toBeLessThanOrEqual(50); + const other = path.join(repo.root, 'other.md'); + const bad = writeReceipt(repo, other, ['--head', older]); + expect(bad.status).toBe(EXIT.fail); + expect(bad.err).toContain(`--head ${older.slice(0, 12)} is not the checked-out HEAD ${head.slice(0, 12)}`); + expect(bad.err).toContain('(RECEIPT_INVALID)'); + expect(fs.existsSync(other)).toBe(false); + }, 60_000); +}); + +describe('the queue refuses a receipt below the depth its head needs', () => { + // Value: protects=a receipt reviewed lighter than its head needs never READYs or seats, and the refusal says what to do; + // fails_when=the queue trusts the recorded depth, or prints no required depth, reasons and inputs; seam=bindShippedReceipt + test('light on a standard head, a missing depth from a current writer and an unbound gate scan refuse; a legacy receipt is accepted', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const head = commit(repo, { 'lib/big.js': lines(80) }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth).toMatchObject({ depth: 'standard', runtime_lines: 80 }); + const base = repo.git('rev-parse', 'origin/main').slice(0, 12); + const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]); + const good = bind(fs.readFileSync(body, 'utf8')); + expect(good.ok).toBe(true); + expect(good.ok && good.depthLine).toContain('REVIEW_DEPTH: receipt=standard required=standard runtime_lines=80 class=code'); + + const low = bind(rewrite(body, r => { r.review_depth.depth = 'light'; })); + expect(low.ok).toBe(false); + if (low.ok) return; + expect(low.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED'); + expect(low.depthLine).toContain('receipt=light required=standard runtime_lines=80'); + expect(low.depthLine).toContain(`inputs=base@${base},registry@${base},gate_scan@${head.slice(0, 12)}`); + expect(low.message).toContain('runtime=80>50'); + expect(low.message).toContain(`the receipt is unchanged; run /ship on ${head.slice(0, 12)}`); + + const unbound = bind(rewrite(body, r => { r.review_depth.depth = 'standard'; r.review_depth.gate_scan.head = 'a'.repeat(40); })); + expect(unbound.ok).toBe(false); + expect(!unbound.ok && unbound.depthLine).toContain('required=full'); + expect(!unbound.ok && unbound.message).toContain('gate_scan_unavailable'); + + const none = bind(rewrite(body, r => { delete r.review_depth; })); + expect(!none.ok && none.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED'); + expect(!none.ok && none.message).toContain('records no review_depth though its writer records one'); + + const legacy = rewrite(body, r => { delete r.gstack_version; }); + const verdict = receiptDepthVerdict(repo.work, 'origin/main', extractReceipt(legacy).receipt!, head); + expect(verdict).toEqual({ ok: true, line: 'REVIEW_DEPTH: legacy receipt (written before receipts recorded review_depth); accepted' }); + }, 60_000); + + // Value: protects=the queue's required depth keeps the scope signals the writer used, and a gate scan binds only the full + // bound commit; fails_when=the queue recomputes without diff-scope (auth reads light) or binds an empty or short scan head + // by prefix; seam=bindShippedReceipt + test('an auth path needs full at the queue though the receipt says light; an empty or short gate scan head binds nothing', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const head = commit(repo, { 'lib/auth.js': lines(3) }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth).toMatchObject({ depth: 'full', class: 'gate' }); + const bind = (text: string) => bindShippedReceipt(repo.work, 'origin/main', text, '7', [head]); + const light = bind(rewrite(body, r => { r.review_depth.depth = 'light'; })); + expect(!light.ok && light.code).toBe('QUEUE_RECEIPT_DEPTH_RAISED'); + expect(!light.ok && light.depthLine).toContain('receipt=light required=full'); + expect(!light.ok && light.depthLine).toContain('class=gate'); + + const plain = makeFixtureRepo(); repos.push(plain); + const plainHead = commit(plain, { 'lib/util.js': lines(3) }); + const plainBody = path.join(plain.root, 'body.md'); + expect(writeReceipt(plain, plainBody).status).toBe(EXIT.ok); + for (const scanHead of ['', plainHead.slice(0, 12)]) { + const r = bindShippedReceipt(plain.work, 'origin/main', rewrite(plainBody, x => { x.review_depth.gate_scan.head = scanHead; }), '7', [plainHead]); + expect(r.ok).toBe(false); + expect(!r.ok && r.depthLine).toContain('required=full'); + expect(!r.ok && r.depthLine).toContain('gate_scan@missing'); + } + }, 90_000); +}); + +describe('carry and the review depth', () => { + // Value: protects=a carried receipt keeps a gate scan bound to the head it now names, and a carry whose new head needs a + // deeper review (a skipped test) is refused; fails_when=carry keeps the old scan, or carries a gate-tagged test change; seam=none + test('a plain test change carries and rebinds the gate scan; a skipped test raises the depth and refuses the carry', () => { + const repo = makeFixtureRepo(); repos.push(repo); + commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth!.depth).toBe('light'); + const plain = commit(repo, { 'test/util.test.js': "test('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" }); + const carried = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(carried.status).toBe(EXIT.ok); + expect(receiptIn(body).review_depth).toMatchObject({ depth: 'light', gate_scan: { tagged: 0, head: plain } }); + const skipped = commit(repo, { 'test/util.test.js': "test.skip('adds', () => { expect(1).toBe(1); });\ntest('more', () => { expect(2).toBe(2); });\n" }); + const refused = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(refused.status).toBe(EXIT.refused); + expect(refused.out).toContain('CARRY: refused (RECEIPT_CARRY_REFUSED)'); + expect(refused.err).toContain(`HEAD ${skipped.slice(0, 12)} needs full review depth`); + expect(refused.err).toContain("above the receipt's light"); + expect(receiptIn(body).head).toBe(plain); + }, 90_000); + + // Value: protects=a carry never binds a scan of one checkout to another commit, and never rebinds a receipt the queue would + // refuse; fails_when=carry --head from another checkout carries a skipped test at light, or a current receipt without + // review_depth carries; seam=none + test('carry --head from another checkout refuses; a receipt with gstack_version but no review_depth refuses', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const reviewed = commit(repo, { 'lib/util.js': lines(3), 'test/util.test.js': "test('a', () => {});\n" }); + const body = path.join(repo.root, 'body.md'); + expect(writeReceipt(repo, body).status).toBe(EXIT.ok); + const skipped = commit(repo, { 'test/util.test.js': "test.skip('a', () => {});\n" }); + repo.git('checkout', '-q', reviewed); + const r = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main', '--head', skipped]); + expect(r.status).toBe(EXIT.refused); + expect(r.err).toContain(`--head ${skipped.slice(0, 12)} is not the checked-out HEAD ${reviewed.slice(0, 12)}`); + expect(receiptIn(body).head).toBe(reviewed); + repo.git('checkout', '-q', skipped); + rewrite(body, x => { delete x.review_depth; }); + const noDepth = runBin(repo, 'gstack-ship-receipt', ['carry', '--body', body, '--base', 'main']); + expect(noDepth.status).toBe(EXIT.refused); + expect(noDepth.err).toContain('records gstack_version but no review_depth'); + }, 90_000); +}); + +describe('the review-loop exception (#3559)', () => { + // Value: protects=an accepted stop after a fix-check is a structured record the queue checks, not prose under REVIEW_LOOP; + // fails_when=write accepts it without a fix-check, with a foreign head or without the body line, or the queue ignores a + // body that dropped the line; seam=bindShippedReceipt + test('write refuses an exception with no fix-check, a foreign head or no body line, records it otherwise, and the queue checks the line', () => { + const repo = makeFixtureRepo(); repos.push(repo); + const head = commit(repo, { 'lib/util.js': lines(3) }); + const body = path.join(repo.root, 'body.md'); + const exception = { accepted_by: 'Garry', date: '2026-10-28', via: 'GSTA-147 relay', reason: 'fix-check edit is a comment; scope frozen', fix_check: { head, findings: 1 } }; + const arg = (e: unknown) => ['--review-loop-exception', JSON.stringify(e)]; + const loop = (n: number) => ['--review-loop', JSON.stringify({ passes: 2, blocking_found: 1, deferred: 0, fix_checks: n })]; + fs.writeFileSync(body, '## What changed\n'); + for (const [extra, why] of [ + [[...arg(exception)], 'needs --review-loop with fix_checks >= 1'], + [[...arg(exception), ...loop(0)], 'needs --review-loop with fix_checks >= 1'], + [[...arg({ ...exception, fix_check: { head: 'a'.repeat(40), findings: 1 } }), ...loop(1)], 'is not ' + head.slice(0, 12) + ' or an ancestor of it'], + [[...arg({ ...exception, extra: 1 }), ...loop(1)], 'unknown key "extra"'], + [[...arg(exception), ...loop(1)], 'does not carry the exception line verbatim'], + ] as Array<[string[], string]>) { + const out = writeReceipt(repo, body, extra); + expect(out.status).toBe(EXIT.fail); + expect(out.err).toContain('(REVIEW_LOOP_EXCEPTION_INVALID;'); + expect(out.err).toContain(why); + } + expect(extractReceipt(fs.readFileSync(body, 'utf8')).found).toBe(false); + const line = reviewLoopExceptionLine(exception); + expect(line).toBe(`Review-loop exception: accepted by Garry 2026-10-28 via GSTA-147 relay: fix-check edit is a comment; scope frozen (fix-check at ${head.slice(0, 12)}, 1 finding)`); + fs.writeFileSync(body, `## What changed\n\n${line}\n`); + expect(writeReceipt(repo, body, [...arg(exception), ...loop(1)]).status).toBe(EXIT.ok); + expect(receiptIn(body).review_loop_exception).toEqual(exception); + expect(bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8'), '7', [head]).ok).toBe(true); + const dropped = bindShippedReceipt(repo.work, 'origin/main', fs.readFileSync(body, 'utf8').replace(line, ''), '7', [head]); + expect(!dropped.ok && dropped.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID'); + expect(!dropped.ok && dropped.message).toContain('does not carry the review-loop exception line verbatim'); + const foreign = { ...exception, fix_check: { head: 'f'.repeat(40), findings: 1 } }; + const forged = rewrite(body, x => { x.review_loop_exception = foreign; }).replace(line, reviewLoopExceptionLine(foreign)); + const offBranch = bindShippedReceipt(repo.work, 'origin/main', forged, '7', [head]); + expect(!offBranch.ok && offBranch.code).toBe('QUEUE_RECEIPT_EXCEPTION_INVALID'); + expect(!offBranch.ok && offBranch.message).toContain('is not the receipt head or an ancestor of it'); + }, 90_000); +}); diff --git a/test/size-ledger/ship/gsta147-review-depth.json b/test/size-ledger/ship/gsta147-review-depth.json new file mode 100644 index 000000000..f583f9ecc --- /dev/null +++ b/test/size-ledger/ship/gsta147-review-depth.json @@ -0,0 +1,4 @@ +{ + "bytes": 1008, + "why": "GSTA-147 review depth: Step 9 saves REVIEW_DEPTH/GATE_SCAN, passes --depth-floor, records depth fields and reads each reviewer's DIFF_CMD; the batched light specialist; Step 11's light note; the skeleton's Review policy paragraph names the depth and --depth-floor (trimmed to hold the skeleton cap); the pr-body section's --review-loop-exception instruction (#3559)" +} From 256874a109cb480a0b04ba9eb0d659765be7f522 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 22:35:56 -0700 Subject: [PATCH 07/19] v1.91.308.0 fix(restamp): keep a four-part package.json version in repos that declare it (#3557) * fix(restamp): keep a four-part package.json version in repos that declare it Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): restamp keeps a declared four-part manifest Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes (base manifest sets the form, flag validated first, stamp_paths test) 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 | 12 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-version-bump | 13 ++- docs/ship-policy.md | 2 +- lib/restamp.ts | 18 +++- package.json | 2 +- test/gstack-version-bump.test.ts | 16 ++++ test/restamp-four-part-manifest.test.ts | 111 ++++++++++++++++++++++++ 9 files changed, 170 insertions(+), 8 deletions(-) create mode 100644 test/restamp-four-part-manifest.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a2cf2153..9905c51f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [1.91.308.0] - 2026-10-29 + +**`gstack-restamp` no longer cuts a four-part package.json version to three parts in a repo whose convention is four.** On garrytan/gbrain#7878, `/ship` restamped gbrain's `package.json` to `"0.60.483"` beside `VERSION` `0.60.483.0`; gbrain requires the two to match and its CI version gate fails when they drift, so the stamp had to be reverted by hand. The version stage called `gstack-version-bump write`, which always stores the npm-valid three-part form in the manifest (decision 11, which is why gstack's own `package.json` reads `1.91.298`). + +### Fixed +- **`lib/restamp.ts`**: the version stage keeps the full version in the manifest when the policy lists it in `stamp_paths` or `stamp_files` and the base's copy holds four parts, and the stage detail says so (`wrote 0.60.483.0 (package.json keeps four parts: …)`). The form is read at the base, like the policy, so a branch an earlier restamp already cut to three parts goes back to four. Every other repo, gstack included, still gets the npm-valid form, and a four-part manifest the policy does not list still migrates to three parts as decision 11 says. +- **`bin/gstack-version-bump write`**: new `--manifest-form npm|full` (default `npm`); `full` writes the version as given to the manifest and its npm lockfiles. It has no effect when the version path is itself a JSON manifest. + +### Tests +- `test/restamp-four-part-manifest.test.ts`: a gbrain-shaped fixture (four-part `VERSION` and `package.json`, the manifest in `stamp_files`, bun, no npm lockfile) restamped from an unstamped branch and from a branch already carrying its stamp (both failed before the fix with `"0.60.483"`), from a branch an older restamp cut to three parts, and with the manifest listed through `stamp_paths`. gstack's shape (three-part manifest and lockfile) and an unlisted four-part manifest keep their current results. +- `test/gstack-version-bump.test.ts`: `--manifest-form full` keeps four parts in the manifest and lockfile; an unknown form exits 2 before anything is written. + ## [1.91.307.0] - 2026-10-29 **/ship now reviews in proportion to what the diff can break: light, standard or full depth.** A diff with 50 or fewer runtime lines gets one batched specialist plus the native adversarial pass, a larger runtime diff gets the class's specialists scoped to their hunks, and gate code or any classifier error gets the full review as before. The receipt records the depth and the queue refuses a receipt below what the head needs. Before this, a 12-line fix paid for the same reviewer fan-out as a change to the merge gate. The backtest that would have let light depth drop the adversarial pass was inconclusive (9 light-eligible PRs, 5 with attributable findings, below the 15 the plan required), so light keeps it. diff --git a/VERSION b/VERSION index 019ebe885..47f003f7e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.307.0 +1.91.308.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index d30192102..880a47072 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.307.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.308.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-version-bump b/bin/gstack-version-bump index ba6a51254..c9b0d71f1 100755 --- a/bin/gstack-version-bump +++ b/bin/gstack-version-bump @@ -25,8 +25,14 @@ // the caller must handle), exit 2 on bad args / unresolvable base / // broken version source. // -// write --version [--version-path

] [--regen-digest] [--changelog

] +// write --version [--version-path

] [--regen-digest] [--changelog

] [--manifest-form npm|full] // Validates the 4-digit pattern, writes VERSION + package.json.version. +// --manifest-form full: the manifest and its npm lockfiles carry the +// version as written instead of the npm-valid translation (default +// npm). For repos whose declared convention is a four-part manifest +// (gbrain); gstack-restamp passes it only when the policy lists the +// manifest as a stamp file and the base's copy holds four parts. No +// effect when the version path is itself a JSON manifest. // --changelog

(opt-in): re-head the branch's `## [Unreleased]` // entry in

as `## [] - ` at the top, with // lib/changelog-check's reheadChangelog — the same function @@ -455,6 +461,8 @@ function cmdWrite(args: string[], cwd: string): void { if (!VERSION_RE.test(version!)) { fail(`NEW_VERSION (${version}) does not match MAJOR.MINOR.PATCH[.MICRO]. Aborting.`, 2); } + const manifestForm = argVal(args, "--manifest-form") ?? "npm"; + if (manifestForm !== "npm" && manifestForm !== "full") fail("write: --manifest-form needs npm|full", 2); const src = versionSource(cwd, argVal(args, "--version-path")); if (src.outcome === "broken") failBroken(src, "write"); if (src.outcome !== "valid") { @@ -528,7 +536,8 @@ function cmdWrite(args: string[], cwd: string): void { // 3-digit translation — npm rejects a fourth component, so mirroring the // raw 4-digit form breaks `npm ci` in any repo npm actually manages. // VERSION keeps the full 4-digit form; it stays the source of truth. - const manifestV = npmVersion(version!); + // --manifest-form full keeps a repo's declared four-part manifest as is. + const manifestV = manifestForm === "full" ? version! : npmVersion(version!); if (hasPkg) { let pkgWritten = false; try { diff --git a/docs/ship-policy.md b/docs/ship-policy.md index bd848b5a2..c08973c92 100644 --- a/docs/ship-policy.md +++ b/docs/ship-policy.md @@ -1084,7 +1084,7 @@ or the order moved, the result is `QUEUE_STALE` (exit 3) with the exact Stages, each journaled in `.gstack/tmp/restamp-journal.json`: 0. **restamp_command** (only when the policy sets it): the repo's own restamp, see [restamp_command](#restamp-command). -1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. +1. **version**: `gstack-version-bump write --version ` in the staging worktree (VERSION, the manifest, npm lockfiles); skipped when `restamp_command` owns the version. The manifest gets the npm-valid three-part form, except when the policy lists it in `stamp_paths` or `stamp_files` and its copy at the base holds four parts (gbrain's convention): then it keeps the full version (`--manifest-form full`) and the stage detail says so. 2. **release_tool**: the policy command, under the contract below. 3. **changelog**: the PR's `[Unreleased]` or old-version section moves to the top under ``, above its predecessor's entry. A duplicate heading the PR introduces (`CHANGELOG_DUPLICATE_HEADING`: the headings are counted against the base's CHANGELOG at `origin/` and at the fork point, so a version the base already heads twice passes as long as the PR adds no further copy) and a top entry that is not `` (`CHANGELOG_TOP_MISMATCH`) are errors; the section being re-headed itself may exist only once. 4. **stamp_paths**: exact `(was X)`, `since: X` and `vX` rewrites, plus every `stamp_patterns` template, within `stamp_paths` and `stamp_files`. A quote around X in the first two forms is part of the form and is kept as written: `since: 'X'`, `since: "X"`, `(was 'X')` and `(was "X")` move like the bare ones (the shape of a quoted source row such as gbrain's `{ since: '0.60.468.0', … }`), an opened quote must close right after X (`since: '1.2.3-beta'` and `since: '1.2.3"` are not the form, in the writer and in the tree receipt alike), and `vX` already accepts a quote before the `v`. Which X moves is decided by provenance. The branch's own tentative stamp (its VERSION when it moved past base) and `--was `, when given, are each rewritten on every line of every stamp file. Beyond that, only lines the branch itself added are read, by their position in the file (`git diff --unified=0` from the fork point), so a base line with the same text, or a CRLF checkout whose diff is LF-normalized, is told apart: a stamp-form mention there of a version newer than the version at the branch's fork point is a tentative stamp and moves (this is what finds a quoted row after a lost slot and a merge of the base, since the base's release is newer than the fork point), and when the branch has no stamp of its own because its VERSION equals the base's and its fork point is the base itself (a branch rebased onto the base after it reached X, or one never stamped) a mention of the base version on a line it added is its stale stamp and moves too, while the same version on a line the base already carries is the base's release and stays. The stage detail names both counts per file (`VERSION equals base X, so X moved only on lines this branch added and stayed on the base's own lines (: N moved, M kept)`), so the stamp commit shows which lines moved and why. A line the base added since the fork point is the base's even when that diff shows it (a merge of the base on the branch, committed or still staged, brings every line the base added since the fork into the diff from the fork point, main's own release keys included): it is left out, the detail says `N line(s) the base added since the fork point are the base's, not this branch's, and stay (: N)`, and a base diff git cannot run refuses the stage (`POLICY_SOURCE_UNAVAILABLE`, `cannot diff .. for the stamp_paths stage`) rather than reading as an empty set that would move the base's lines again. The rule reads line ownership, not meaning: a branch-added line inside `stamp_paths` that means the base's own release in a stamp form (`Requires vX or later` written on a branch forked at X) moves with it, which the `--dry-run` diff and the per-file counts show before anything is written; write such a reference outside a stamp form, or keep that file out of `stamp_paths`. `--was X` is the opposite tool: it rewrites X everywhere, the base's own lines included, and re-heads the CHANGELOG section `## [X]`. Only the repo's own version width counts, so a 3-digit dependency pin in a 4-digit repo is never a candidate. diff --git a/lib/restamp.ts b/lib/restamp.ts index e0d473061..1a4d18e23 100644 --- a/lib/restamp.ts +++ b/lib/restamp.ts @@ -223,6 +223,19 @@ export function versionFiles(root: string, versionRel: string): string[] { const dir = path.dirname(pkgRel) === '.' ? '' : path.dirname(pkgRel) + '/'; return [versionRel, pkgRel, ...NPM_LOCKFILES.map(l => dir + l)]; } +/** + * The manifest's path when the repo's convention is a four-part manifest: the policy lists it as a + * stamp file and the base's copy holds a four-part version (gbrain). Read at the base, like the + * policy, so a branch an older restamp cut to three parts heals. Otherwise null, and the writer + * stores the npm-valid three-part form (decision 11; gstack's own manifest). + */ +function manifestKeepsFourParts(plan: Plan, root: string): string | null { + const pkgRel = versionFiles(root, plan.versionRel)[1]!; + if (pkgRel === plan.versionRel || !matchesAny([...plan.policy.stamp_paths, ...plan.policy.stamp_files], pkgRel)) return null; + const atBase = git(root, ['show', `${plan.baseSha}:${pkgRel}`]); + const version = atBase.ok ? extractVersion(atBase.out, pkgRel) : ''; + return version && versionWidth(version) === 4 ? pkgRel : null; +} function allowedOutput(plan: Plan, root: string, rel: string): boolean { const p = plan.policy; if (versionFiles(root, plan.versionRel).includes(rel)) return true; @@ -455,9 +468,10 @@ function runStages(o: RestampOptions, plan: Plan, staging: string, since: string const v = stage('version'); if (command) { v.status = 'skipped'; v.detail = 'restamp_command owns the version'; } else { - const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion], { cwd: staging, encoding: 'utf8', timeout: 120_000, env }); + const full = manifestKeepsFourParts(plan, staging); + const w = spawnSync(process.execPath, [...bunIsolationArgs(), path.join(BIN_DIR, 'gstack-version-bump'), 'write', '--version', plan.newVersion, ...(full ? ['--manifest-form', 'full'] : [])], { cwd: staging, encoding: 'utf8', timeout: 120_000, env }); if (w.status !== 0) { v.status = 'failed'; v.detail = (w.stderr ?? '').trim().slice(0, 300); throw new Refusal('RESTAMP_VERSION_SOURCE', `gstack-version-bump write failed: ${v.detail}; nothing written`); } - v.status = 'done'; v.detail = `wrote ${plan.newVersion}`; + v.status = 'done'; v.detail = `wrote ${plan.newVersion}` + (full ? ` (${full} keeps four parts: a policy stamp file the base holds in four)` : ''); } injected('version'); diff --git a/package.json b/package.json index 8176ec25d..b25d537f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.307", + "version": "1.91.308", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/gstack-version-bump.test.ts b/test/gstack-version-bump.test.ts index b90077dac..0fbc3855b 100644 --- a/test/gstack-version-bump.test.ts +++ b/test/gstack-version-bump.test.ts @@ -84,6 +84,22 @@ describe('write (FRESH bump)', () => { expect(code).toBe(2); }); + test('--manifest-form full keeps a four-part manifest and its lockfile as written', () => { + const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-full-')); + fs.writeFileSync(path.join(d2, 'VERSION'), '0.60.482.0\n'); + fs.writeFileSync(path.join(d2, 'package.json'), JSON.stringify({ name: 'g', version: '0.60.482.0' }, null, 2) + '\n'); + fs.writeFileSync(path.join(d2, 'package-lock.json'), JSON.stringify({ name: 'g', version: '0.60.482.0', lockfileVersion: 3, packages: { '': { version: '0.60.482.0' } } }, null, 2) + '\n'); + const out = JSON.parse(execFileSync('bun', [BIN, 'write', '--version', '0.60.483.0', '--manifest-form', 'full'], { cwd: d2, timeout: 30_000 }).toString()); + expect(out.packageJsonVersion).toBe('0.60.483.0'); + expect(JSON.parse(fs.readFileSync(path.join(d2, 'package.json'), 'utf-8')).version).toBe('0.60.483.0'); + const lock = JSON.parse(fs.readFileSync(path.join(d2, 'package-lock.json'), 'utf-8')); + expect([lock.version, lock.packages[''].version]).toEqual(['0.60.483.0', '0.60.483.0']); + const bad = spawnSync('bun', [BIN, 'write', '--version', '0.60.484.0', '--manifest-form', 'wide'], { cwd: d2, timeout: 30_000 }); + expect(bad.status).toBe(2); + expect(fs.readFileSync(path.join(d2, 'VERSION'), 'utf-8').trim()).toBe('0.60.483.0'); + fs.rmSync(d2, { recursive: true, force: true }); + }); + test('VERSION-only repo (no package.json) writes just VERSION', () => { const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'vbump-noPkg-')); fs.writeFileSync(path.join(d2, 'VERSION'), '0.1.0.0\n'); diff --git a/test/restamp-four-part-manifest.test.ts b/test/restamp-four-part-manifest.test.ts new file mode 100644 index 000000000..104d4f9b2 --- /dev/null +++ b/test/restamp-four-part-manifest.test.ts @@ -0,0 +1,111 @@ +/** + * A four-part package.json version survives gstack-restamp in a repo whose + * convention is a four-part manifest (gbrain: VERSION 0.60.483.0 and + * package.json "0.60.483.0", cross-checked by its CI version gate). The + * writer used to store the npm-valid three-part form there ("0.60.483"). + * gstack's own shape (a three-part manifest outside the policy's stamp files) + * and the decision-11 migration of an unlisted four-part manifest are unchanged. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EXIT } from '../lib/headless-artifacts'; +import { cleanup, makeFixtureRepo, makePr, readFile, restamp, type FixtureRepo } from './helpers/restamp-fixture'; + +const repos: FixtureRepo[] = []; +const fixture = (opts?: Parameters[0]) => { const r = makeFixtureRepo(opts); repos.push(r); return r; }; +afterEach(() => { while (repos.length) cleanup(repos.pop()!); }); + +const GBRAIN_POLICY = { + $schema: 'https://github.com/garrytan/gstack/docs/ship-policy.md#schema-v1', + bump: 'patch', + queue_mode: 'stamp-at-merge', + changelog: 'CHANGELOG.md', + stamp_files: ['VERSION', 'package.json', 'CHANGELOG.md'], + stamp_patterns: ['"version": "{version}"'], +}; + +const pkgVersion = (repo: FixtureRepo) => JSON.parse(readFile(repo, 'package.json')).version; +const setPkgVersion = (repo: FixtureRepo, version: string) => { + const pkg = JSON.parse(readFile(repo, 'package.json')); + fs.writeFileSync(path.join(repo.work, 'package.json'), JSON.stringify({ ...pkg, version }, null, 2) + '\n'); +}; + +/** Rewrite the fixture's base as gbrain ships it: a four-part manifest mirroring VERSION, and bun, so no npm lockfile. */ +function fourPartManifest(repo: FixtureRepo, version: string): void { + setPkgVersion(repo, version); + fs.rmSync(path.join(repo.work, 'package-lock.json')); + repo.git('add', '-A'); + repo.git('commit', '-q', '-m', 'four-part manifest'); + repo.git('push', '-q', 'origin', 'main'); +} + +describe('gstack-restamp keeps the repo\'s manifest width', () => { + test('gbrain-shaped repo: package.json keeps four parts and agrees with VERSION', () => { + const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' }); + fourPartManifest(repo, '0.60.482.0'); + makePr(repo, 'feat-a'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('STAMP_VERSION: 0.60.483.0'); + expect(r.out).toContain('stage version: done (wrote 0.60.483.0 (package.json keeps four parts'); + expect(readFile(repo, 'VERSION').trim()).toBe('0.60.483.0'); + expect(pkgVersion(repo)).toBe('0.60.483.0'); + }); + + test('gbrain-shaped branch that already carries its stamp: the restamp leaves the four-part manifest as is', () => { + const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' }); + fourPartManifest(repo, '0.60.482.0'); + makePr(repo, 'feat-a'); + fs.writeFileSync(path.join(repo.work, 'VERSION'), '0.60.483.0\n'); + setPkgVersion(repo, '0.60.483.0'); + repo.git('commit', '-q', '-am', 'stamp 0.60.483.0'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('STAMP_VERSION: 0.60.483.0'); + expect(r.out).not.toContain('diff --git a/package.json'); + expect(pkgVersion(repo)).toBe('0.60.483.0'); + }); + + test('a branch an older restamp cut to three parts heals: the base\'s four-part manifest sets the form', () => { + const repo = fixture({ policy: GBRAIN_POLICY, version: '0.60.482.0' }); + fourPartManifest(repo, '0.60.482.0'); + makePr(repo, 'feat-a'); + setPkgVersion(repo, '0.60.482'); + repo.git('commit', '-q', '-am', 'old restamp cut the manifest'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(pkgVersion(repo)).toBe('0.60.483.0'); + }); + + test('a manifest listed through stamp_paths keeps four parts too', () => { + const repo = fixture({ policy: { ...GBRAIN_POLICY, stamp_files: [], stamp_paths: ['package.json'] }, version: '0.60.482.0' }); + fourPartManifest(repo, '0.60.482.0'); + makePr(repo, 'feat-a'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(pkgVersion(repo)).toBe('0.60.483.0'); + }); + + test('gstack-shaped repo: the three-part manifest and its lockfile still get the npm-valid form', () => { + const repo = fixture(); + makePr(repo, 'feat-a'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('stage version: done (wrote 1.2.4.0)'); + expect(readFile(repo, 'VERSION').trim()).toBe('1.2.4.0'); + expect(pkgVersion(repo)).toBe('1.2.4'); + const lock = JSON.parse(readFile(repo, 'package-lock.json')); + expect([lock.version, lock.packages[''].version]).toEqual(['1.2.4', '1.2.4']); + }); + + test('a four-part manifest the policy does not list as a stamp file still migrates to the npm-valid form (decision 11)', () => { + const repo = fixture({ policy: { ...GBRAIN_POLICY, stamp_files: ['VERSION', 'CHANGELOG.md'] }, version: '0.60.482.0' }); + fourPartManifest(repo, '0.60.482.0'); + makePr(repo, 'feat-a'); + const r = restamp(repo, ['--next']); + expect(r.status).toBe(EXIT.ok); + expect(r.out).toContain('stage version: done (wrote 0.60.483.0)'); + expect(pkgVersion(repo)).toBe('0.60.483'); + }); +}); From 043feff8b31dcf9e913bf9aba5918c60e16704a9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 22:50:41 -0700 Subject: [PATCH 08/19] v1.91.309.0 fix(flaky): one rule each in the judged review and Eng prose; a cancelled judge names what it was waiting on (GSTA-139 S3) (#3544) * fix(flaky): give the judged review and Eng prose one rule each, and name what a cancelled judge received (GSTA-139 S3) - review: Step 5.8 records an unresolved non-gate defect as open, and a repeat pass's reruns are revalidation that run after smoke expiry in the same PROBE_DIR with materialize once on the final pass (every sample of #3527's red panel 40617200857 and its rerun named one of these). Nearby prose trimmed so union, skeleton and host pins do not grow. - plan-eng-review: drop the Eng-only em-dash Voice variant so the shared Voice hoists; the Scope gate's Format precedence names em dashes (5 of 6 samples of 40634787316 named the near-copy Voice). Host size pins lowered. - llm-judge: a cancelled judge request prints llm-judge-aborted with timing, 429 waits and stream event counts (no content), so the next workflow-judge deadline (train 35 gate 40660626349) names its cause. - benchmark: the Phase 6 example no longer lazy-loads the hero image. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(review): earlier passes link checkpoints; only the final pass materializes (all 3 samples of the focused judge run at 1bdc4ce) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): Unreleased entry for the GSTA-139 S3 judge prose repairs Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: record llm-judge abort diagnostic as non-behavioral; abort tests wait on events, not sleeps Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: name the llm-judge-aborted line in TESTING_INTERNALS 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 | 20 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- benchmark/SKILL.md | 2 +- benchmark/SKILL.md.tmpl | 2 +- docs/TESTING_INTERNALS.md | 8 ++ package.json | 2 +- plan-eng-review/SKILL.md | 24 +--- plan-eng-review/SKILL.md.tmpl | 2 +- review/SKILL.md | 26 ++-- review/SKILL.md.tmpl | 26 ++-- scripts/harness-version.json | 2 +- .../preamble/generate-voice-directive.ts | 10 +- test/eng-scope-entry-ap.test.ts | 13 ++ test/fixtures/skill-md-size-allowlist.json | 38 +++--- test/helpers/llm-judge.ts | 84 +++++++++--- test/llm-judge-abort.test.ts | 19 +++ test/llm-judge-stream.test.ts | 120 ++++++++++++++++++ test/review-workflow-clarity.test.ts | 15 +++ test/workflow-judge-cache.test.ts | 4 +- 20 files changed, 318 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9905c51f0..fcc27719c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.91.309.0] - 2026-10-29 + +**The judged review and Eng prose each give one rule where the paid judges kept finding two, and a judge that runs out of time now says what it was waiting on.** Every sample of two red panels on #3527's screens (review 40617200857, plan-eng-review 40634787316), attempt and rerun, named the same contradictions; a third red (ship, train 35 gate 40660626349) hit the 120 s judge deadline with nothing logged but "No completed model response". + +### Fixed +- **`/review` Step 5.8**: an unresolved non-gate defect (an unanswered question) is recorded as `open`, so every finding has a record action. A repeat pass's reruns of superseded probes are revalidation, so they run after smoke expiry, in the same probe directory; earlier provisional QA sections link checkpoints and only the final pass runs the once-only `materialize`. +- **`/plan-eng-review`**: the shared Voice now loads once. Eng no longer carries a near-copy whose only difference was its em-dash clause; the Scope gate's format precedence says required formats keep their em dashes. +- **`/benchmark` Phase 6 example**: the hero image recommendation no longer suggests lazy-loading (that delays the largest paint) and names one remedy, as the rule under it asks. + +### Changed +- **Judge cancellations leave evidence.** When a judge request is cancelled (the workflow judge deadline), `callJudge` prints one `llm-judge-aborted` line: elapsed time, wrapper attempts and 429 waits, HTTP requests and statuses (so the SDK's own retries show), connection, first and last stream event times, the open block type, thinking and text character counts, message and request ids, and the reason (at most 200 characters). Counts only, never content; an observer that throws cannot change a judge result. Deadlines, effort, samples and thresholds are unchanged. + +### Tests +- `test/review-workflow-clarity.test.ts` and `test/eng-scope-entry-ap.test.ts` pin the repaired rules; both fail with the prose change stashed. +- `test/llm-judge-stream.test.ts` and `test/llm-judge-abort.test.ts` replay a stream cancelled mid-thinking, a stream with no events, one cancelled after headers, an SDK-retried 429, a cancelled 429 wait, an event the observer cannot read and an overlong reason, with no-abort controls; each fails with the helper change stashed. `test/workflow-judge-cache.test.ts`'s SDK stream stand-ins gain the real stream's `on()`. +- Focused paid judge runs: review 4/4/4 at 1bdc4ce and again at 3463250, plan-eng-review 3/5/4 at 1bdc4ce ($0.18). + +### Gate integrity +- No threshold, sample, trial, deadline or case-kind change. `scripts/harness-version.json` records the `llm-judge.ts` change as non-behavioral (no series reset). Generated SKILL.md size pins for review and plan-eng-review were lowered to their new sizes on every host. + ## [1.91.308.0] - 2026-10-29 **`gstack-restamp` no longer cuts a four-part package.json version to three parts in a repo whose convention is four.** On garrytan/gbrain#7878, `/ship` restamped gbrain's `package.json` to `"0.60.483"` beside `VERSION` `0.60.483.0`; gbrain requires the two to match and its CI version gate fails when they drift, so the stamp had to be reverted by hand. The version stage called `gstack-version-bump write`, which always stores the npm-valid three-part form in the manifest (decision 11, which is why gstack's own `package.json` reads `1.91.298`). diff --git a/VERSION b/VERSION index 47f003f7e..818f7c7f7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.308.0 +1.91.309.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 880a47072..efc567eae 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.308.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.309.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/benchmark/SKILL.md b/benchmark/SKILL.md index 56c76e733..ee09bbba4 100644 --- a/benchmark/SKILL.md +++ b/benchmark/SKILL.md @@ -412,7 +412,7 @@ TOP 10 SLOWEST RESOURCES RECOMMENDATIONS: - vendor.chunk.js: code-split — 320KB on initial load - analytics.js: load async/defer — blocks rendering for 250ms -- hero-image.webp: add width/height (CLS), lazy-load +- hero-image.webp: add width/height (CLS) ``` Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache). diff --git a/benchmark/SKILL.md.tmpl b/benchmark/SKILL.md.tmpl index 12a805eec..0a7448a29 100644 --- a/benchmark/SKILL.md.tmpl +++ b/benchmark/SKILL.md.tmpl @@ -201,7 +201,7 @@ TOP 10 SLOWEST RESOURCES RECOMMENDATIONS: - vendor.chunk.js: code-split — 320KB on initial load - analytics.js: load async/defer — blocks rendering for 250ms -- hero-image.webp: add width/height (CLS), lazy-load +- hero-image.webp: add width/height (CLS) ``` Third-party = any host but the page's origin. Each recommendation names a resource here or in a Phase 5 regression, the metric it moves and one remedy (split, defer, compress, lazy-load, cache). diff --git a/docs/TESTING_INTERNALS.md b/docs/TESTING_INTERNALS.md index 31fefa201..13cb03aa8 100644 --- a/docs/TESTING_INTERNALS.md +++ b/docs/TESTING_INTERNALS.md @@ -783,6 +783,14 @@ once, plus cleanup. Each still has 120 seconds of model work. Its 17 workflow judges own their deadline and abort signal, with five seconds for terminal recording inside a ten-second Bun grace; the other 11 retain their existing 120-second Bun timeout. Late responses cannot create records or cache passes. +When a judge's abort signal cancels its request, `callJudge` +(`test/helpers/llm-judge.ts`) prints one `llm-judge-aborted` JSON line to +stderr: elapsed time, wrapper `attempts` and `retryWaitsMs` (429 waits), +`httpRequests` and `httpStatuses` (so SDK retries show), `connectedMs`, +`firstEventMs` and `lastEventMs`, the open `block` type, `thinkingChars` and +`textChars`, `messageId`, `requestId` and the `reason` (at most 200 +characters). It holds counts and ids only, never content, and changes no +request, score or verdict. The ship documentation file reserves 4,920 seconds for four 600-second cases and eight 300-second fault cases, run once, plus cleanup; in CI each case runs as its diff --git a/package.json b/package.json index b25d537f9..6fc1a3a00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.308", + "version": "1.91.309", "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/plan-eng-review/SKILL.md b/plan-eng-review/SKILL.md index a35ce149f..295c9eef5 100644 --- a/plan-eng-review/SKILL.md +++ b/plan-eng-review/SKILL.md @@ -50,7 +50,7 @@ Recommendation: A when a branch diff exists, otherwise B. Reply with A, B, or C. After target selection, use the preamble's full decision brief, transport and continuous D-numbering. Setup questions approve no engineering remedies. -**Format precedence:** Copy required command, output and question formats exactly. Apply Voice to newly composed prose. +**Format precedence:** Copy required command, output and question formats exactly, em dashes included. Apply Voice to newly composed prose. **Startup sequence** (after target selection): 1. Run the Preamble command and its startup instructions (Context Recovery and the preamble's own one-time setup prompts; the Prior Learnings cross-project question waits for Step 0). Defer Operational Self-Improvement, Telemetry and Plan Status Footer to finish; format/transport rules apply throughout. @@ -187,28 +187,6 @@ Before emitting a tool or prose decision brief, verify: - [ ] Follow Tool resolution: tool call unless Conductor or documented prose fallback; prose includes the mandatory triad + explicit reply selectors, then STOP. Spawned sessions follow their auto-choice rule. - [ ] Write non-ASCII directly, not \u-escaped. For 5+ options, split/batch into ≤4 without dropping; check dependencies and stop the chain immediately on Hold. -## Voice - -GStack voice: Garry-shaped product and engineering judgment. - -- Lead with the point. Say what it does, why it matters, and what changes for the builder. -- Be concrete. Name files, functions, line numbers, commands, outputs, evals, and real numbers. -- Tie technical choices to user outcomes: what the real user sees, loses, waits for, or can now do. -- Be direct about quality. Bugs matter. Edge cases matter. Fix the whole thing, not the demo path. -- Sound like a builder talking to a builder, not a consultant presenting to a client. -- Never corporate, academic, PR, or hype. Avoid filler, throat-clearing, generic optimism, and founder cosplay. -- Do not add em dashes in prose you compose during the review. Existing templates, quoted text, command output, and required copied labels may contain them. No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, load-bearing. -- Reply in the language of the user's latest message unless asked otherwise. Code, commands, paths, identifiers, quoted output and question markers (`D`, option letters, `(recommended)`) stay verbatim. -- The user has context you do not: domain knowledge, timing, relationships, taste. Cross-model agreement is a recommendation, not a decision. The user decides. - -Good: "auth.ts:47 returns undefined when the session cookie expires. Users hit a white screen. Fix: add a null check and redirect to /login. Two lines." -Bad: "I've identified a potential issue in the authentication flow that may cause problems under certain conditions." - -**Bounded closer.** After completing work, report in at most a few short lines: what changed, what was skipped, what to watch. No feature tours or unrequested design notes. Exempt: decision briefs, completion-status blocks, requested explanations, and a skill's mandated report (/qa-only, /plan-*-review, /retro, /document-generate). The rule limits prose around the deliverable, never the deliverable. - -Good closer: "Renamed the flag in 3 files, regenerated docs, tests green. Skipped the CLI alias (unused since v1.2); watch the Windows job." -Bad closer: a tour of every edit, a restatement of the plan, and three paragraphs justifying choices nobody questioned. - ## Question Tuning (skip entirely if `QUESTION_TUNING: false`) Before each decision brief (AskUserQuestion or Conductor/fallback prose), choose `question_id` from `~/.claude/skills/gstack/scripts/question-registry.ts` or `{skill}-{slug}`, then run `~/.claude/skills/gstack/bin/gstack-question-preference --check ""`; for an unregistered id, write the question summary to `.gstack/tmp/qt.txt` (file-write tool) and append `--summary-file .gstack/tmp/qt.txt` (one-way keyword check). `AUTO_DECIDE` means choose the recommended option and say "Auto-decided [summary] → [option] (your preference). Change with /plan-tune." `ASK_NORMALLY` means ask. diff --git a/plan-eng-review/SKILL.md.tmpl b/plan-eng-review/SKILL.md.tmpl index 66d1efa95..3bda37347 100644 --- a/plan-eng-review/SKILL.md.tmpl +++ b/plan-eng-review/SKILL.md.tmpl @@ -60,7 +60,7 @@ Recommendation: A when a branch diff exists, otherwise B. Reply with A, B, or C. After target selection, use the preamble's full decision brief, transport and continuous D-numbering. Setup questions approve no engineering remedies. -**Format precedence:** Copy required command, output and question formats exactly. Apply Voice to newly composed prose. +**Format precedence:** Copy required command, output and question formats exactly, em dashes included. Apply Voice to newly composed prose. **Startup sequence** (after target selection): 1. Run the Preamble command and its startup instructions (Context Recovery and the preamble's own one-time setup prompts; the Prior Learnings cross-project question waits for Step 0). Defer Operational Self-Improvement, Telemetry and Plan Status Footer to finish; format/transport rules apply throughout. diff --git a/review/SKILL.md b/review/SKILL.md index 9c54f19aa..d8aba2c97 100644 --- a/review/SKILL.md +++ b/review/SKILL.md @@ -650,9 +650,9 @@ findings and decides completion. ## Step 5: Fix-First Review -Before edits, confirm every dispatched reader has returned or is confirmed stopped. -For an active or unknown reader/writer, wait or confirm it is stopped; if that -cannot be confirmed, persist incomplete at Step 5.8 and STOP without edits. +Before edits, every dispatched reader/writer must have returned or be confirmed +stopped: wait, and if that cannot be confirmed, persist incomplete at Step 5.8 and +STOP without edits. Terminal failure does not block fixes from independent evidence. Missing required output still makes the pass incomplete, even after the reader is stopped. @@ -806,7 +806,7 @@ After outputting your own findings, if Greptile comments were classified in Step Before replying to any comment, run greptile-triage.md's **Escalation Detection** to choose Tier 1 (friendly) or Tier 2 (firm) reply templates. -1. **VALID & ACTIONABLE comments:** Use their Step 5a–5d disposition; do not ask a second fix question. Step 5c alone supplies A) Fix / B) Skip for ASK items. After a completed fix, use the **Fix reply template** with diff and explanation; cite the current diff if uncommitted, never invent a commit SHA. A Skip leaves the defect unresolved and grants no new fix permission. If evidence disproves the finding, reclassify it below. +1. **VALID & ACTIONABLE comments:** Use their Step 5a–5d disposition; do not ask a second fix question. After a completed fix, use the **Fix reply template** with diff and explanation; cite the current diff if uncommitted, never invent a commit SHA. A Skip leaves the defect unresolved and grants no new fix permission. If evidence disproves the finding, reclassify it below. 2. **FALSE POSITIVE comments:** These are reply decisions, not code approval. Show file:line (or [top-level]), summary, permalink and evidence, then ask: - A) Reply explaining why this is incorrect (recommended if clearly wrong) @@ -835,6 +835,9 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection* final: repeat Steps 3–5 with a full start. 2. On a repeat, execute Steps 3–5 in order. At Step 4.7, an edited pass supersedes every earlier QA capture (exploratory §4): rerun each probe the verdict relies on. + These reruns are revalidation, so they run even after smoke expiry; keep the + PROBE_DIR. Earlier provisional sections link checkpoints; only the final pass + runs its once-only `materialize`. A zero-edit pass reuses only this invocation's unchanged-input QA evidence, rerunning any probe whose inputs changed or are uncertain. Reusing never skips a review step. 3. **Verify completed actions.** On the final zero-edit pass, reconcile this @@ -846,9 +849,8 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection* defects and unresolved counts; earlier fixes do not suppress them. 4. **Recheck skipped advice.** Re-read its final-snapshot supporting source and reconfirm the decision; otherwise report its history without a reusable skip. - The logger computes `snapshot_covered_paths` from eligible paths whose raw bytes - equal the bound snapshot blobs (`[]` if none). Never carry prior-cycle, supplied - or prior-record coverage forward or build this proof yourself. Fixed advice + The logger computes `snapshot_covered_paths` itself; never carry prior-cycle, + supplied or prior-record coverage forward or build this proof yourself. Fixed advice needs no skip coverage. ### 2. Fill the record @@ -878,14 +880,14 @@ pass: report it with its reason. `helper_target`. Recheck source after fixes. The logger uses `sharedLibsFingerprint`, never supplied/model hashes. Actions: `auto-fixed` (Step 5b), `fixed` (approved **and completed** in Step 5d), - `skipped` (explicit Skip in Step 5c). Advice is never `auto-fixed`; pending + `skipped` (explicit Skip in Step 5c), + `open` (any other defect, e.g. unanswered). Advice is never `auto-fixed`; pending advice stays in the response, not the record. Exclude prior Step 5.0 suppressions; include this invocation's revalidated decisions. - Gate findings additionally carry `gate` (the RH tag, e.g. `"RH-15"`), `gate_id` (the tool's hunk identity), `reason` (for `kept`) and one of the actions `kept`, `restored` or `open`. Never store hunk text, test names or commit - messages in the record (it is brain-synced). `open` findings count in - `issues_found` and in `critical`/`informational` by severity. The logger stamps + messages in the record (it is brain-synced). The logger stamps `actor` from `gstack-session-kind` itself and discards any `actor` you supply; for a non-interactive session it also forces `action: "open"` on every gate finding. Dispositions are per invocation: `/ship`'s own review pass asks again. @@ -897,8 +899,8 @@ pass: report it with its reason. `TIMESTAMP` is ISO 8601; `COMMIT` is `git rev-parse --short HEAD`. `quality_score` is Step 4.6's specialist score (`10.0` when small-diff specialists were skipped or this host omits Review Army). This default is not completion evidence; -unresolved non-advisory core defects still count in `issues_found`, -`critical`, `informational`. The logger builds trusted `review_binding` from the +unresolved defects, `open` ones included, still count in `issues_found` and +by severity in `critical`/`informational`. The logger builds trusted `review_binding` from the validated captured branch digest, discarding caller bindings. Never invent a binding or replace REVIEW_START at log time; finish only this pass's own token. diff --git a/review/SKILL.md.tmpl b/review/SKILL.md.tmpl index c613dae8c..b22eaf442 100644 --- a/review/SKILL.md.tmpl +++ b/review/SKILL.md.tmpl @@ -251,9 +251,9 @@ critical finding or another writer during collection. Skip silently if no docs e ## Step 5: Fix-First Review -Before edits, confirm every dispatched reader has returned or is confirmed stopped. -For an active or unknown reader/writer, wait or confirm it is stopped; if that -cannot be confirmed, persist incomplete at Step 5.8 and STOP without edits. +Before edits, every dispatched reader/writer must have returned or be confirmed +stopped: wait, and if that cannot be confirmed, persist incomplete at Step 5.8 and +STOP without edits. Terminal failure does not block fixes from independent evidence. Missing required output still makes the pass incomplete, even after the reader is stopped. @@ -357,7 +357,7 @@ After outputting your own findings, if Greptile comments were classified in Step Before replying to any comment, run greptile-triage.md's **Escalation Detection** to choose Tier 1 (friendly) or Tier 2 (firm) reply templates. -1. **VALID & ACTIONABLE comments:** Use their Step 5a–5d disposition; do not ask a second fix question. Step 5c alone supplies A) Fix / B) Skip for ASK items. After a completed fix, use the **Fix reply template** with diff and explanation; cite the current diff if uncommitted, never invent a commit SHA. A Skip leaves the defect unresolved and grants no new fix permission. If evidence disproves the finding, reclassify it below. +1. **VALID & ACTIONABLE comments:** Use their Step 5a–5d disposition; do not ask a second fix question. After a completed fix, use the **Fix reply template** with diff and explanation; cite the current diff if uncommitted, never invent a commit SHA. A Skip leaves the defect unresolved and grants no new fix permission. If evidence disproves the finding, reclassify it below. 2. **FALSE POSITIVE comments:** These are reply decisions, not code approval. Show file:line (or [top-level]), summary, permalink and evidence, then ask: - A) Reply explaining why this is incorrect (recommended if clearly wrong) @@ -386,6 +386,9 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection* final: repeat Steps 3–5 with a full start. 2. On a repeat, execute Steps 3–5 in order. At Step 4.7, an edited pass supersedes every earlier QA capture (exploratory §4): rerun each probe the verdict relies on. + These reruns are revalidation, so they run even after smoke expiry; keep the + PROBE_DIR. Earlier provisional sections link checkpoints; only the final pass + runs its once-only `materialize`. A zero-edit pass reuses only this invocation's unchanged-input QA evidence, rerunning any probe whose inputs changed or are uncertain. Reusing never skips a review step. 3. **Verify completed actions.** On the final zero-edit pass, reconcile this @@ -397,9 +400,8 @@ Before replying to any comment, run greptile-triage.md's **Escalation Detection* defects and unresolved counts; earlier fixes do not suppress them. 4. **Recheck skipped advice.** Re-read its final-snapshot supporting source and reconfirm the decision; otherwise report its history without a reusable skip. - The logger computes `snapshot_covered_paths` from eligible paths whose raw bytes - equal the bound snapshot blobs (`[]` if none). Never carry prior-cycle, supplied - or prior-record coverage forward or build this proof yourself. Fixed advice + The logger computes `snapshot_covered_paths` itself; never carry prior-cycle, + supplied or prior-record coverage forward or build this proof yourself. Fixed advice needs no skip coverage. ### 2. Fill the record @@ -429,14 +431,14 @@ pass: report it with its reason. `helper_target`. Recheck source after fixes. The logger uses `sharedLibsFingerprint`, never supplied/model hashes. Actions: `auto-fixed` (Step 5b), `fixed` (approved **and completed** in Step 5d), - `skipped` (explicit Skip in Step 5c). Advice is never `auto-fixed`; pending + `skipped` (explicit Skip in Step 5c), + `open` (any other defect, e.g. unanswered). Advice is never `auto-fixed`; pending advice stays in the response, not the record. Exclude prior Step 5.0 suppressions; include this invocation's revalidated decisions. - Gate findings additionally carry `gate` (the RH tag, e.g. `"RH-15"`), `gate_id` (the tool's hunk identity), `reason` (for `kept`) and one of the actions `kept`, `restored` or `open`. Never store hunk text, test names or commit - messages in the record (it is brain-synced). `open` findings count in - `issues_found` and in `critical`/`informational` by severity. The logger stamps + messages in the record (it is brain-synced). The logger stamps `actor` from `gstack-session-kind` itself and discards any `actor` you supply; for a non-interactive session it also forces `action: "open"` on every gate finding. Dispositions are per invocation: `/ship`'s own review pass asks again. @@ -448,8 +450,8 @@ pass: report it with its reason. `TIMESTAMP` is ISO 8601; `COMMIT` is `git rev-parse --short HEAD`. `quality_score` is Step 4.6's specialist score (`10.0` when small-diff specialists were skipped or this host omits Review Army). This default is not completion evidence; -unresolved non-advisory core defects still count in `issues_found`, -`critical`, `informational`. The logger builds trusted `review_binding` from the +unresolved defects, `open` ones included, still count in `issues_found` and +by severity in `critical`/`informational`. The logger builds trusted `review_binding` from the validated captured branch digest, discarding caller bindings. Never invent a binding or replace REVIEW_START at log time; finish only this pass's own token. diff --git a/scripts/harness-version.json b/scripts/harness-version.json index f71515581..4ad1e964c 100644 --- a/scripts/harness-version.json +++ b/scripts/harness-version.json @@ -29,7 +29,7 @@ "test/helpers/eval-budgets.ts": {"blob":"667d0e09b20fe5d7366f57b77b5fb9b04baa37ef","since":6}, "test/helpers/eval-store.ts": {"blob":"a50ea2c28f508ad9f35ea9353dd2f502ee0dcfc2","since":6,"nonBehavioral":"credential rejections are classed infra instead of assertion: no case's pass or fail changes; only failed trials whose provider rejected the run's key move class, so no rerun spends on a rejected key"}, "test/helpers/hermetic-env.ts": {"blob":"cc072895461dc52ea317d1beff72121342420d30","since":6}, - "test/helpers/llm-judge.ts": {"blob":"a3177665b662932ce485ccc976c2b36a1531a0bb","since":6,"nonBehavioral":"callJudge rewrites only the message of a JSON parse failure (names the judge output, stop reason, length and the retained llm-judge-response-parse-error event); it still throws a SyntaxError before any score is parsed, the request is unchanged, and no case's pass or fail changes"}, + "test/helpers/llm-judge.ts": {"blob":"095f9c85dd0ae29da8ba10fed91510313a4442a4","since":6,"nonBehavioral":"callJudge emits one llm-judge-aborted diagnostic line (wrapper attempts, HTTP request and status counts seen through a pass-through fetch, 429 waits, connect and event timings, block kind, character counts, message and request ids, a capped reason; no content) when its caller's signal aborts it; observers swallow their own errors, and the request, retries, parsing, scores and the thrown abort reason are unchanged, so no case's pass or fail changes"}, "test/helpers/paid-test-set.ts": {"blob":"ff9bfe43c43ec2bdd11114f7b8ec7c5736116f75","since":6}, "test/helpers/pty-screen.ts": {"blob":"4f77569f9ce11f9cee9f347216e468cd7c956820","since":6}, "test/helpers/pty/auq.ts": {"blob":"16e5ea2bb5b9f28f44808a1a09024dbd3e6f28c4","since":6}, diff --git a/scripts/resolvers/preamble/generate-voice-directive.ts b/scripts/resolvers/preamble/generate-voice-directive.ts index 13813bf4c..07b3e0279 100644 --- a/scripts/resolvers/preamble/generate-voice-directive.ts +++ b/scripts/resolvers/preamble/generate-voice-directive.ts @@ -1,17 +1,13 @@ import type { TemplateContext } from '../types'; -export function generateVoiceDirective(ctx: TemplateContext, tier: number): string { - const emDashRule = ctx.skillName === 'plan-eng-review' - ? 'Do not add em dashes in prose you compose during the review. Existing templates, quoted text, command output, and required copied labels may contain them.' - : 'No em dashes.'; - +export function generateVoiceDirective(_ctx: TemplateContext, tier: number): string { if (tier <= 1) { return `## Voice Direct, concrete, builder-to-builder. Name the file, function, command, and user-visible impact. No filler. -${emDashRule} No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted, load-bearing. Never corporate or academic. Short paragraphs. End with what to do. +No em dashes. No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted, load-bearing. Never corporate or academic. Short paragraphs. End with what to do. Reply in the language of the user's latest message unless asked otherwise. Code, commands, paths, identifiers and quoted output stay verbatim. @@ -28,7 +24,7 @@ GStack voice: Garry-shaped product and engineering judgment. - Be direct about quality. Bugs matter. Edge cases matter. Fix the whole thing, not the demo path. - Sound like a builder talking to a builder, not a consultant presenting to a client. - Never corporate, academic, PR, or hype. Avoid filler, throat-clearing, generic optimism, and founder cosplay. -- ${emDashRule} No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, load-bearing. +- No em dashes. No AI vocabulary: delve, crucial, robust, comprehensive, nuanced, multifaceted, furthermore, moreover, additionally, pivotal, landscape, tapestry, underscore, foster, showcase, intricate, vibrant, fundamental, significant, load-bearing. - Reply in the language of the user's latest message unless asked otherwise. Code, commands, paths, identifiers, quoted output and question markers (\`D\`, option letters, \`(recommended)\`) stay verbatim. - The user has context you do not: domain knowledge, timing, relationships, taste. Cross-model agreement is a recommendation, not a decision. The user decides. diff --git a/test/eng-scope-entry-ap.test.ts b/test/eng-scope-entry-ap.test.ts index 9989b2f67..7ffc8ef4f 100644 --- a/test/eng-scope-entry-ap.test.ts +++ b/test/eng-scope-entry-ap.test.ts @@ -163,3 +163,16 @@ test('calibration keeps its future hook but no shipped source enables the absent } expect(mentions).toEqual(['scripts/resolvers/gbrain.ts']); }); + +test('the judged Eng bundle carries the shared Voice once and states its em-dash precedence in the Scope gate (paid screen 40634787316)', () => { + // Five of six samples (red panel and rerun) named Eng's near-copy of the shared Voice, whose only difference was + // its em-dash clause, as a rule the agent must reconcile; the Scope gate's Format precedence already owned it. + // fails_when=a second Voice section returns to the judged bundle or the precedence stops naming em dashes. + const input = readWorkflowJudgeInput({root: path.join(import.meta.dir, '..'), skillPath: 'plan-eng-review/SKILL.md', + startMarker: '# Plan Review Mode', endMarker: null}); + expect(input.text.match(/^## Voice$/gm)).toHaveLength(1); + expect(input.text).not.toContain('Do not add em dashes in prose you compose'); + expect(scope).toContain('**Format precedence:** Copy required command, output and question formats exactly, em dashes included. Apply Voice to newly composed prose.'); + const startup = input.files[0]!.content; + expect(startup.indexOf('**Format precedence:**')).toBeLessThan(startup.indexOf('## Shared preamble (load first)')); +}); diff --git a/test/fixtures/skill-md-size-allowlist.json b/test/fixtures/skill-md-size-allowlist.json index 686b6b63d..0caed9b6a 100644 --- a/test/fixtures/skill-md-size-allowlist.json +++ b/test/fixtures/skill-md-size-allowlist.json @@ -5,50 +5,50 @@ "agy/gstack-autoplan": 109810, "agy/gstack-plan-design-review": 110044, "agy/gstack-plan-devex-review": 116496, - "agy/gstack-plan-eng-review": 147241, - "agy/gstack-review": 103755, + "agy/gstack-plan-eng-review": 144907, + "agy/gstack-review": 103743, "codex/gstack-autoplan": 108553, "codex/gstack-land-and-deploy": 100284, "codex/gstack-plan-design-review": 111116, "codex/gstack-plan-devex-review": 118004, - "codex/gstack-plan-eng-review": 148406, - "codex/gstack-review": 103278, + "codex/gstack-plan-eng-review": 146072, + "codex/gstack-review": 103266, "copilot/gstack-autoplan": 109253, "copilot/gstack-plan-design-review": 110355, "copilot/gstack-plan-devex-review": 115958, - "copilot/gstack-plan-eng-review": 147406, - "copilot/gstack-review": 103336, + "copilot/gstack-plan-eng-review": 145072, + "copilot/gstack-review": 103324, "cursor/gstack-autoplan": 108485, "cursor/gstack-plan-design-review": 108900, "cursor/gstack-plan-devex-review": 115228, - "cursor/gstack-plan-eng-review": 145996, - "cursor/gstack-review": 121626, + "cursor/gstack-plan-eng-review": 143662, + "cursor/gstack-review": 121614, "factory/gstack-autoplan": 110763, "factory/gstack-land-and-deploy": 100105, "factory/gstack-plan-design-review": 111675, "factory/gstack-plan-devex-review": 118591, - "factory/gstack-plan-eng-review": 149104, - "factory/gstack-review": 125399, + "factory/gstack-plan-eng-review": 146770, + "factory/gstack-review": 125387, "gbrain/gstack-autoplan": 108892, - "gbrain/gstack-plan-eng-review": 128623, + "gbrain/gstack-plan-eng-review": 126289, "hermes/gstack-autoplan": 108982, - "hermes/gstack-plan-eng-review": 128708, + "hermes/gstack-plan-eng-review": 126374, "kiro/gstack-autoplan": 108259, "kiro/gstack-plan-design-review": 108752, "kiro/gstack-plan-devex-review": 115080, - "kiro/gstack-plan-eng-review": 145858, - "kiro/gstack-review": 121450, + "kiro/gstack-plan-eng-review": 143524, + "kiro/gstack-review": 121438, "openclaw/gstack-autoplan": 108715, - "openclaw/gstack-plan-eng-review": 127138, + "openclaw/gstack-plan-eng-review": 124804, "opencode/gstack-autoplan": 109234, "opencode/gstack-plan-design-review": 110187, "opencode/gstack-plan-devex-review": 115836, - "opencode/gstack-plan-eng-review": 147265, - "opencode/gstack-review": 122248, + "opencode/gstack-plan-eng-review": 144931, + "opencode/gstack-review": 122236, "slate/gstack-autoplan": 108372, "slate/gstack-plan-design-review": 108826, "slate/gstack-plan-devex-review": 115154, - "slate/gstack-plan-eng-review": 145927, - "slate/gstack-review": 121538 + "slate/gstack-plan-eng-review": 143593, + "slate/gstack-review": 121526 } } diff --git a/test/helpers/llm-judge.ts b/test/helpers/llm-judge.ts index a3177665b..095f9c85d 100644 --- a/test/helpers/llm-judge.ts +++ b/test/helpers/llm-judge.ts @@ -135,7 +135,21 @@ export async function callJudge( // could be exhausted before a frontier judge emitted any JSON. const resolvedModel = resolveEvalModel('judge', model); const maxTokens = opts?.max_tokens ?? DEFAULT_JUDGE_MAX_TOKENS; - const client = new Anthropic(); + // What a cancelled request had received, so a judge deadline names rate-limit + // waits, SDK retries, a stream with no events, or a long thinking phase. Counts + // only, never content; an observer that throws never changes the judge outcome. + const started = performance.now(); + const elapsedMs = () => Math.round(performance.now() - started); + const progress = { httpRequests: 0, httpStatuses: [] as number[], retryWaitsMs: [] as number[], connectedMs: null as number | null, + firstEventMs: null as number | null, lastEventMs: null as number | null, block: null as string | null, thinkingChars: 0, + textChars: 0, messageId: null as string | null, requestId: null as string | null }; + const observe = (record: () => void) => { try { record(); } catch {} }; + const client = new Anthropic({ fetch: async (...args: Parameters) => { + progress.httpRequests += 1; + const response = await globalThis.fetch(...args); + progress.httpStatuses.push(response.status); + return response; + } }); const request = { model: resolvedModel, @@ -146,34 +160,62 @@ export async function callJudge( ...(opts?.effort === undefined ? {} : { effort: opts.effort }) } }), messages: [{ role: 'user' as const, content: prompt }], }; - const makeRequest = () => opts?.stream - ? client.messages.stream(request, signal ? { signal } : undefined).finalMessage() - : client.messages.create(request, signal ? { signal } : undefined); + const makeRequest = () => { + if (!opts?.stream) return client.messages.create(request, signal ? { signal } : undefined); + const stream = client.messages.stream(request, signal ? { signal } : undefined); + stream.on('connect', () => observe(() => { + progress.connectedMs ??= elapsedMs(); + progress.requestId ??= stream.request_id ?? null; + })); + stream.on('streamEvent', event => observe(() => { + progress.firstEventMs ??= elapsedMs(); + progress.lastEventMs = elapsedMs(); + progress.requestId ??= stream.request_id ?? null; + if (event.type === 'message_start') progress.messageId = event.message.id; + if (event.type === 'content_block_start') progress.block = event.content_block.type; + if (event.type === 'content_block_delta' && event.delta.type === 'thinking_delta') progress.thinkingChars += event.delta.thinking.length; + if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') progress.textChars += event.delta.text.length; + })); + return stream.finalMessage(); + }; // 429s under CI concurrency: jittered exponential backoff over 3 retries // (~1s/4s/16s + jitter), honoring the server's retry-after when present. // The old single fixed 1s retry lost races reliably at 40-way concurrency. let response; let attempt = 0; - for (;;) { - try { - signal?.throwIfAborted(); - response = await makeRequest(); - signal?.throwIfAborted(); - break; - } catch (err: any) { - signal?.throwIfAborted(); - if (err?.status !== 429 || attempt >= 3) throw err; - const retryAfterSecs = Number(err?.headers?.['retry-after']); - const baseMs = Number.isFinite(retryAfterSecs) && retryAfterSecs > 0 - ? retryAfterSecs * 1000 - : 1000 * 4 ** attempt; - await delay(baseMs + Math.random() * 500, undefined, { signal }).catch(error => { + try { + for (;;) { + try { signal?.throwIfAborted(); - throw error; - }); - attempt += 1; + response = await makeRequest(); + signal?.throwIfAborted(); + break; + } catch (err: any) { + signal?.throwIfAborted(); + if (err?.status !== 429 || attempt >= 3) throw err; + const retryAfterSecs = Number(err?.headers?.['retry-after']); + const baseMs = Number.isFinite(retryAfterSecs) && retryAfterSecs > 0 + ? retryAfterSecs * 1000 + : 1000 * 4 ** attempt; + const waitMs = baseMs + Math.random() * 500; + progress.retryWaitsMs.push(Math.round(waitMs)); + await delay(waitMs, undefined, { signal }).catch(error => { + signal?.throwIfAborted(); + throw error; + }); + attempt += 1; + } } + } catch (error) { + if (signal?.aborted) { + console.error(JSON.stringify({ + type: 'llm-judge-aborted', model: resolvedModel, stream: opts?.stream === true, elapsedMs: elapsedMs(), + attempts: attempt + 1, ...progress, + reason: (signal.reason instanceof Error ? signal.reason.message : String(signal.reason)).slice(0, 200), + })); + } + throw error; } opts?.onResponse?.({ diff --git a/test/llm-judge-abort.test.ts b/test/llm-judge-abort.test.ts index ea3303d88..2cc1cabbc 100644 --- a/test/llm-judge-abort.test.ts +++ b/test/llm-judge-abort.test.ts @@ -85,6 +85,25 @@ describe('callJudge cancellation', () => { expect(create).toHaveBeenCalledTimes(1); }, 3000); + test('an abort during a 429 wait retains the wait it interrupted', async () => { + const diagnostics = spyOn(console, 'error').mockImplementation(() => {}); + try { + const controller = new AbortController(); + create.mockRejectedValue({ status: 429, headers: { 'retry-after': '30' } }); + const pending = callJudge('Rate-limited request.', 'test-judge-model', { signal: controller.signal }); + await new Promise(resolve => setImmediate(resolve)); + controller.abort(new Error('Expired during rate-limit backoff.')); + await expect(pending).rejects.toThrow('Expired during rate-limit backoff.'); + expect(diagnostics).toHaveBeenCalledTimes(1); + expect(JSON.parse(diagnostics.mock.calls[0]![0])).toMatchObject({ type: 'llm-judge-aborted', model: 'test-judge-model', + stream: false, attempts: 1, httpRequests: 0, retryWaitsMs: [30000], firstEventMs: null, reason: 'Expired during rate-limit backoff.' }); + create.mockResolvedValue(response as any); + diagnostics.mockClear(); + expect(await callJudge('Judge this.', undefined, { signal: new AbortController().signal })).toEqual({ passed: true }); + expect(diagnostics).not.toHaveBeenCalled(); + } finally { diagnostics.mockRestore(); } + }, 3000); + test('keeps three 429 retries for callers without a signal', async () => { const rateLimit = { status: 429, headers: { 'retry-after': '0.001' } }; create.mockRejectedValue(rateLimit); diff --git a/test/llm-judge-stream.test.ts b/test/llm-judge-stream.test.ts index 589571e75..8abc3eae9 100644 --- a/test/llm-judge-stream.test.ts +++ b/test/llm-judge-stream.test.ts @@ -103,3 +103,123 @@ test('streaming retains the caller abort signal instead of extending its deadlin expect(await rejected).toBe(failure); expect(transport).toHaveBeenCalledTimes(1); }); + +test('a judge aborted mid-stream retains what it had received, as counts without content', async () => { + const encoder = new TextEncoder(); + const partial = [ + { type: 'message_start', message: { id: 'msg_stalled', type: 'message', role: 'assistant', model: 'claude-fable-5-1', + content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 99023, output_tokens: 1 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '', signature: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'PRIVATE_THINKING' } }, + ]; + let delivered!: () => void; + const received = new Promise(resolve => { delivered = resolve; }); + transport.mockImplementation(async (_url: string, init: RequestInit) => { + const chunks = partial.map(event => encoder.encode(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`)); + return new Response(new ReadableStream({ + start(stream) { + init.signal!.addEventListener('abort', () => stream.error(init.signal!.reason), { once: true }); + }, + pull(stream) { + const chunk = chunks.shift(); + if (chunk) stream.enqueue(chunk); + else delivered(); + }, + }, { highWaterMark: 0 }), { headers: { 'content-type': 'text/event-stream', 'request-id': 'req_stalled' } }); + }); + const controller = new AbortController(); + const rejected = callJudge('score the whole bundle', 'claude-fable-5-1', { + max_tokens: 65_536, stream: true, jsonSchema: WORKFLOW_JUDGE_RESPONSE_SCHEMA, signal: controller.signal, + }).catch(error => error); + await received; + const failure = new Error('ship/SKILL.md workflow exceeded its workflow judge work deadline'); + controller.abort(failure); + expect(await rejected).toBe(failure); + expect(diagnostics).toHaveBeenCalledTimes(1); + const diagnostic = diagnostics.mock.calls[0]![0]; + expect(diagnostic).not.toContain('PRIVATE_'); + const event = JSON.parse(diagnostic); + expect(event).toMatchObject({ type: 'llm-judge-aborted', model: 'claude-fable-5-1', stream: true, attempts: 1, + httpRequests: 1, httpStatuses: [200], retryWaitsMs: [], block: 'thinking', thinkingChars: 'PRIVATE_THINKING'.length, textChars: 0, + messageId: 'msg_stalled', requestId: 'req_stalled', reason: failure.message }); + expect(event.firstEventMs).toBeGreaterThanOrEqual(0); + expect(event.lastEventMs).toBeGreaterThanOrEqual(event.firstEventMs); // gstack-timing-ok: ordering of two recorded offsets, not an elapsed-time bound + expect(event.elapsedMs).toBeGreaterThanOrEqual(event.lastEventMs); // gstack-timing-ok: ordering of two recorded offsets, not an elapsed-time bound +}); + +test('a stream that never sent an event is retained as such, and provider errors without an abort are not', async () => { + let sent!: () => void; + const requested = new Promise(resolve => { sent = resolve; }); + transport.mockImplementation((_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); + sent(); + })); + const controller = new AbortController(); + const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error); + await requested; + controller.abort(new Error('deadline')); + await rejected; + expect(JSON.parse(diagnostics.mock.calls[0]![0])).toMatchObject({ type: 'llm-judge-aborted', httpRequests: 1, + httpStatuses: [], connectedMs: null, firstEventMs: null, lastEventMs: null, block: null, thinkingChars: 0, messageId: null, requestId: null, reason: 'deadline' }); + + diagnostics.mockClear(); + transport.mockResolvedValue(new Response(JSON.stringify({ type: 'error', error: { type: 'api_error', message: 'boom' } }), + { status: 500, headers: { 'content-type': 'application/json', 'x-should-retry': 'false' } })); + await expect(callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: new AbortController().signal })).rejects.toThrow(); + expect(diagnostics).not.toHaveBeenCalled(); +}); + +test('a deadline after headers but before any event retains the connection and its request id', async () => { + let reading!: () => void; + const connected = new Promise(resolve => { reading = resolve; }); + transport.mockImplementation(async (_url: string, init: RequestInit) => new Response(new ReadableStream({ + start(stream) { init.signal!.addEventListener('abort', () => stream.error(init.signal!.reason), { once: true }); }, + pull() { reading(); return new Promise(() => {}); }, + }, { highWaterMark: 0 }), { headers: { 'content-type': 'text/event-stream', 'request-id': 'req_quiet' } })); + const controller = new AbortController(); + const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error); + await connected; + controller.abort(new Error('deadline')); + await rejected; + const event = JSON.parse(diagnostics.mock.calls[0]![0]); + expect(event).toMatchObject({ httpRequests: 1, httpStatuses: [200], requestId: 'req_quiet', firstEventMs: null, block: null }); + expect(event.connectedMs).toBeGreaterThanOrEqual(0); +}); + +test('SDK-internal 429 retries are counted, so throttling never reads as a silent stream', async () => { + let resent!: () => void; + const retried = new Promise(resolve => { resent = resolve; }); + transport.mockResolvedValueOnce(new Response(JSON.stringify({ type: 'error', error: { type: 'rate_limit_error', message: 'slow down' } }), + { status: 429, headers: { 'content-type': 'application/json', 'retry-after-ms': '1' } })); + transport.mockImplementationOnce((_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); + resent(); + })); + const controller = new AbortController(); + const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error); + await retried; + controller.abort(new Error('deadline')); + await rejected; + expect(JSON.parse(diagnostics.mock.calls[0]![0])).toMatchObject({ attempts: 1, httpRequests: 2, httpStatuses: [429], + retryWaitsMs: [], connectedMs: null, firstEventMs: null }); +}); + +test('an event the diagnostic cannot read never changes the judge result', async () => { + const unreadable = response(); + const body = (await unreadable.text()).replace('"thinking":"PRIVATE_THINKING"', '"thinking":null'); + transport.mockResolvedValue(new Response(body, { headers: { 'content-type': 'text/event-stream' } })); + expect(await callJudge('score the whole bundle', 'claude-fable-5-1', { + max_tokens: 65_536, stream: true, jsonSchema: WORKFLOW_JUDGE_RESPONSE_SCHEMA, + })).toEqual(scores); +}); + +test('an abort reason is retained at most 200 characters long', async () => { + transport.mockImplementation((_url: string, init: RequestInit) => new Promise((_resolve, reject) => { + init.signal!.addEventListener('abort', () => reject(init.signal!.reason), { once: true }); + })); + const controller = new AbortController(); + const rejected = callJudge('score', 'claude-fable-5-1', { max_tokens: 65_536, stream: true, signal: controller.signal }).catch(error => error); + controller.abort(new Error('x'.repeat(500))); + await rejected; + expect(JSON.parse(diagnostics.mock.calls[0]![0]).reason).toBe('x'.repeat(200)); +}); diff --git a/test/review-workflow-clarity.test.ts b/test/review-workflow-clarity.test.ts index 4144d3e25..5637fbb41 100644 --- a/test/review-workflow-clarity.test.ts +++ b/test/review-workflow-clarity.test.ts @@ -684,3 +684,18 @@ test('review suppressions allow a clean result and skip common false positives b expectTokens(suppressions, ['"Function too long"', '"Possible null dereference"', '"N+1 query"', '"Hardcoded value"', '`Math.random()`'], 'suppressions'); expectAbsent(suppressions, [/missing input validation/i, /missing await/i], 'suppressions'); }); + +test('judged repeat-pass QA reruns and unresolved record actions each have one rule (paid screen 40617200857)', () => { + // Every sample of the red panel and its rerun named one of these: "post-expiry smoke rechecks are not-run" vs + // Step 5.8's rerun of superseded probes, materialize's once-per-PROBE_DIR limit vs reruns into the same report, + // and no record action for an unresolved non-gate defect (an unanswered question or an unproven repair). + // fails_when=the rerun stops being revalidation, materialize may run per pass, or such a defect has no action. + const flat = skill.replace(/\s+/g, ' '); + const repeat = flat.slice(flat.indexOf('2. On a repeat, execute Steps 3–5 in order.'), flat.indexOf('3. **Verify completed actions.**')); + expect(repeat).toContain('These reruns are revalidation, so they run even after smoke expiry; keep the PROBE_DIR. Earlier provisional sections link checkpoints; only the final pass runs its once-only `materialize`.'); + expect(repeat.indexOf('rerun each probe the verdict relies on')).toBeLessThan(repeat.indexOf('These reruns are revalidation')); + const actions = flat.slice(flat.indexOf('Actions: `auto-fixed` (Step 5b)'), flat.indexOf('Advice is never `auto-fixed`')); + expect(actions).toContain('`fixed` (approved **and completed** in Step 5d)'); + expect(actions).toContain('`skipped` (explicit Skip in Step 5c), `open` (any other defect, e.g. unanswered).'); + expect(flat).toContain('Do not record an unanswered question as skipped'); +}); diff --git a/test/workflow-judge-cache.test.ts b/test/workflow-judge-cache.test.ts index f71a95421..5ad942656 100644 --- a/test/workflow-judge-cache.test.ts +++ b/test/workflow-judge-cache.test.ts @@ -410,7 +410,7 @@ test('Ship sends its authorized 64k cap and compact response contract through th expect(WORKFLOW_JUDGE_RESPONSE_SCHEMA.properties.reasoning).not.toHaveProperty('maxLength'); expect(source.match(/compactReasoning: true/g)).toHaveLength(1); const f = fixture(); - const stream = spyOn(Messages.prototype, 'stream').mockReturnValue({ finalMessage: async () => ({ + const stream = spyOn(Messages.prototype, 'stream').mockReturnValue({ on() { return this; }, finalMessage: async () => ({ stop_reason: 'end_turn', content: [{ type: 'text', text: JSON.stringify(scores) }], }) } as any); try { @@ -516,7 +516,7 @@ test('the structured callback rejects incomplete, schema-invalid and below-thres try { for (const response of responses) { const f = fixture(); - stream.mockReturnValue({ finalMessage: async () => ({ stop_reason: response.stop_reason, + stream.mockReturnValue({ on() { return this; }, finalMessage: async () => ({ stop_reason: response.stop_reason, content: [{ type: 'text', text: JSON.stringify(response.value) }] }) } as any); const h = actualCallback(f, { judge: (prompt, model, request) => callJudge(prompt, model, request) }); await expect(h.run({ ...h.options, schemaTransport: true, compactReasoning: true, maxTokens: 65_536, stream: true })).rejects.toThrow(); From ff2f191b476aaf76c82f6027998ec3eb09fd8110 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 28 Oct 2026 23:03:55 -0700 Subject: [PATCH 09/19] v1.91.310.0 fix(flaky): the cookie workflow judge sends the response schema, released on GStack Minder's ruling (GSTA-139 S3) (#3581) * fix(flaky): cookie workflow judge sends the response schema, released on GStack Minder's 2026-10-29 ruling Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test: register the train-42 replies fixture; keep a run URL and a low-entropy fake key out of the secrets scan 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 | 13 +++++ TODOS.md | 6 +-- VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- package.json | 2 +- test/cookie-workflow-judge-input.test.ts | 52 +++++++++++++++++-- .../cookie-judge-train42-replies.json | 17 ++++++ test/fixtures/judge-calibration/README.md | 6 ++- .../cookie-workflow/release.json | 16 ++++++ test/helpers/free-fixtures-data.ts | 1 + test/judge-calibration.test.ts | 8 ++- test/skill-llm-eval.test.ts | 1 + 12 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 test/fixtures/cookie-judge-train42-replies.json create mode 100644 test/fixtures/judge-calibration/cookie-workflow/release.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fcc27719c..3c001a70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [1.91.310.0] - 2026-10-29 + +**The cookie workflow judge now asks for structured output, so a quote the model forgets to escape can no longer fail the gate.** Train 42 went red because both of its `setup-browser-cookies/SKILL.md workflow` samples came back as free text with a bare quote inside `reasoning` ("one --domain per run"), and neither parsed. The GSTA-91 comparison had measured that failure: 2 of 144 free-text samples failed to parse, against 0 of 72 with the schema. + +### Changed +- **Cookie workflow judge transport:** `setup-browser-cookies/SKILL.md workflow` now sends `WORKFLOW_JUDGE_RESPONSE_SCHEMA`, released on GStack Minder's ruling: "GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is matched by old-b; free-text parse errors cost train 42". Its model, thresholds (4/3/4), sample count and case kind are unchanged. The ruling is recorded in `test/fixtures/judge-calibration/cookie-workflow/release.json`, and the held comparison stays recorded as held. + +### Tests +- `test/cookie-workflow-judge-input.test.ts` runs the registered cookie callback through the real `callJudge` and pins its request to the schema. It replays train 42's two replies (msg_011CgVnRtVj5vyLvb56m6KJH, msg_011CgVne5GStZ6f9pRoCvgWE): their free-text bytes do not parse, and the same judgment returned as schema output scores and passes. `test/judge-calibration.test.ts` reads the release record. Both fail without the transport change. + +### Gate integrity +- Gate edit: the cookie judge's response transport moves from free text to the JSON schema, on the ruling above. No threshold, sample, trial, deadline or case-kind change. The Haiku free-text failure pin in `test/llm-judge-frontier.test.ts` is untouched. + ## [1.91.309.0] - 2026-10-29 **The judged review and Eng prose each give one rule where the paid judges kept finding two, and a judge that runs out of time now says what it was waiting on.** Every sample of two red panels on #3527's screens (review 40617200857, plan-eng-review 40634787316), attempt and rerun, named the same contradictions; a third red (ship, train 35 gate 40660626349) hit the 120 s judge deadline with nothing logged but "No completed model response". diff --git a/TODOS.md b/TODOS.md index edcf57447..7d7aa202d 100644 --- a/TODOS.md +++ b/TODOS.md @@ -788,15 +788,15 @@ Each item was deferred with a reason during the `/claude-api prompt-audit` clean format errors, so it stays on today's request), the default workflow judge (landed, but one provider refusal on corpus item f03 under the new prompt, matched by one under the old prompt) and the cookie workflow judge (GSTA-91: - held on one new false pass on a07, zero new-request format errors, so it stays - on today's request; today's request itself false-passes a07 in `old-b` and on + held on one new false pass on a07, zero new-request format errors; the hold was + released on GStack Minder's 2026-10-29 ruling and it now sends the schema; today's request itself false-passes a07 in `old-b` and on both valid `old-a` samples, and a08 in `old-a`, so the prose step targets those). Reuse `test/fixtures/judge-calibration` and `scripts/judge-calibration.ts`. Priority P3. Effort M. - **Uncalibrated workflow-judge configurations** — build a corpus and calibrate schema transport for the frontier configuration (review/SKILL.md workflow); it stays on today's request. The cookie configuration (setup-browser-cookies/SKILL.md workflow) has a corpus and a held comparison-1 - (GSTA-91), so it also stays on today's request. After any transport change, + (GSTA-91); it moved to the schema on GStack Minder's 2026-10-29 release ruling. After any transport change, re-run the review and cookie workflow judges. Priority P3. Effort M. - **Claude model auto-detection for the skill overlay** — when no `claude_overlay_model` is set, detect the Claude Code model (`ANTHROPIC_MODEL`, diff --git a/VERSION b/VERSION index 818f7c7f7..7a470c3f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.309.0 +1.91.310.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index efc567eae..a91df72cb 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.309.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.310.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 6fc1a3a00..d30024e4e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.309", + "version": "1.91.310", "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/cookie-workflow-judge-input.test.ts b/test/cookie-workflow-judge-input.test.ts index a8a268a3b..c79a67918 100644 --- a/test/cookie-workflow-judge-input.test.ts +++ b/test/cookie-workflow-judge-input.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'bun:test'; +import { afterEach, describe, expect, spyOn, test } from 'bun:test'; import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -11,7 +11,8 @@ import { selectTests } from './helpers/test-selection'; import { E2E_TOUCHFILES, LLM_JUDGE_TOUCHFILES } from './helpers/touchfiles-data'; import { selectPrProfile } from '../scripts/test-pr-profile'; import { JUDGE_MS } from './helpers/eval-budgets'; -import { JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS, judgePanel, judgePanelMean, judgePanelMedian, judgePanelReasoning, JUDGE_SCORE_DIMENSIONS, JUDGE_PANEL_SAMPLES } from './helpers/llm-judge'; +import { Messages } from '@anthropic-ai/sdk/resources/messages'; +import { callJudge, type CallJudgeOptions, JudgeRefusalError, DEFAULT_JUDGE_MAX_TOKENS, judgePanel, judgePanelMean, judgePanelMedian, judgePanelReasoning, JUDGE_SCORE_DIMENSIONS, JUDGE_PANEL_SAMPLES } from './helpers/llm-judge'; import { COOKIE_MANUAL_REVIEW_FILE, getCookieWorkflowManualReview, isManualReviewEntry } from './helpers/cookie-workflow-manual-review'; const ROOT = resolve(import.meta.dir, '..'); @@ -51,6 +52,7 @@ const passingScore: FixtureScore = { ...COOKIE_WORKFLOW_JUDGE.thresholds, reason function actualCookieCallback(root: string, overrides: { judge?: () => Promise; + callJudge?: (prompt: string, model: string | undefined, options: CallJudgeOptions) => Promise; clock?: () => number; budget?: number; setTimer?: typeof setTimeout; @@ -74,7 +76,11 @@ function actualCookieCallback(root: string, overrides: { (_suite: string, names: string[], run: () => void) => { expect(names).toEqual([NAME]); run(); }, (name: string, run: () => Promise, budget: number) => { expect(name).toBe(NAME); expect(budget).toBe(JUDGE_MS + 10_000); callback = run; }, root, buildCookieWorkflowJudgeInput, (_kind: string, explicit?: string) => explicit ?? 'fixture-model', - async (prompt: string, model: string | undefined, options: { signal: AbortSignal }) => { requests.push({ prompt, model, signal: options.signal }); return overrides.judge ? overrides.judge() : passingScore; }, + async (prompt: string, model: string | undefined, options: CallJudgeOptions & { signal: AbortSignal }) => { + requests.push({ prompt, model, signal: options.signal }); + if (overrides.callJudge) return overrides.callJudge(prompt, model, options); + return overrides.judge ? overrides.judge() : passingScore; + }, COOKIE_WORKFLOW_JUDGE, overrides.budget ?? JUDGE_MS, JUDGE_MS + 10_000, 5_000, { addTest: (record: EvalTestEntry) => records.push(record) }, expect, { log() {} }, readWorkflowJudgeInput, buildWorkflowJudgePrompt, @@ -269,6 +275,46 @@ describe('cookie workflow judge input', () => { expect(selectTests(['README.md'], LLM_JUDGE_TOUCHFILES).selected).not.toContain(NAME); }); + test('the registered cookie judge sends the response schema, so a free-text reply like train 42\'s is not what gets parsed', async () => { + const { replies } = JSON.parse(readFileSync(join(ROOT, 'test/fixtures/cookie-judge-train42-replies.json'), 'utf8')) as { + replies: Array<{ id: string; model: string; stop_reason: string; text: string }> }; + expect(replies.map(reply => reply.id)).toEqual(['msg_011CgVnRtVj5vyLvb56m6KJH', 'msg_011CgVne5GStZ6f9pRoCvgWE']); + const original = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'fake'; + const create = spyOn(Messages.prototype, 'create'); + try { + for (const reply of replies) { + expect(() => JSON.parse(reply.text)).toThrow(); + const marker = '"reasoning": "'; + const judgment: FixtureScore = { + clarity: Number(/"clarity": (\d)/.exec(reply.text)![1]), + completeness: Number(/"completeness": (\d)/.exec(reply.text)![1]), + actionability: Number(/"actionability": (\d)/.exec(reply.text)![1]), + reasoning: reply.text.slice(reply.text.indexOf(marker) + marker.length, reply.text.lastIndexOf('"}')), + }; + expect(judgment.reasoning).toContain('"one --domain per run"'); + create.mockReset(); + create.mockResolvedValue({ id: reply.id, model: reply.model, stop_reason: 'end_turn', + content: [{ type: 'text', text: JSON.stringify(judgment) }], usage: { input_tokens: 1, output_tokens: 1 } } as never); + const root = fixture(); + const h = actualCookieCallback(root, { callJudge: (prompt, model, options) => callJudge(prompt, model, options) }); + await h.run(); + expect(create).toHaveBeenCalledTimes(JUDGE_PANEL_SAMPLES); + for (const [request, options] of create.mock.calls as unknown as Array<[Record, { signal: AbortSignal }]>) { + expect(request).toEqual({ model: COOKIE_WORKFLOW_JUDGE.model, max_tokens: DEFAULT_JUDGE_MAX_TOKENS, + output_config: { format: { type: 'json_schema', schema: WORKFLOW_JUDGE_RESPONSE_SCHEMA } }, + messages: [{ role: 'user', content: buildCookieWorkflowJudgeInput(root).prompt }] }); + expect(options.signal).toBeInstanceOf(AbortSignal); + } + expect(h.records[0]).toMatchObject({ name: NAME, passed: true, execution: 'executed', + judge_scores: { clarity: judgment.clarity, completeness: judgment.completeness, actionability: judgment.actionability } }); + } + } finally { + create.mockRestore(); + if (original === undefined) delete process.env.ANTHROPIC_API_KEY; else process.env.ANTHROPIC_API_KEY = original; + } + }); + test('the managed callback sends and records exact custom input without borrowing a normal cache identity', async () => { const root = fixture(); const input = buildCookieWorkflowJudgeInput(root); diff --git a/test/fixtures/cookie-judge-train42-replies.json b/test/fixtures/cookie-judge-train42-replies.json new file mode 100644 index 000000000..ea3043176 --- /dev/null +++ b/test/fixtures/cookie-judge-train42-replies.json @@ -0,0 +1,17 @@ +{ + "source": "train 42 gate run 40809051510 (paid-logs-slice-5-a1, rerun-logs-1-a1): llm-judge-response-parse-error events", + "replies": [ + { + "id": "msg_011CgVnRtVj5vyLvb56m6KJH", + "model": "claude-sonnet-5-5", + "stop_reason": "end_turn", + "text": "{\"clarity\": 4, \"completeness\": 4, \"actionability\": 4, \"reasoning\": \"The workflow is well ordered: check Aside, collect decisions up front, prepare the browser, import, and report. Consent for storage reset and --all is explicit, and the guidance separates copied cookies from verified sign-in. Error tables, platform limits, and the privacy rules are clear. Weaknesses: (1) the picker-versus-direct choice is underspecified, because the picker is used when browser, profile or domains are open and direct import when they are named, but the target-URL, `--all`, and verify-auth flows under each are not fully mapped. (2) The `--all` handling is thin. SKILL.md does not give direct-import syntax for it and does not say whether `$B goto` is still needed. (3) Verification setup has an ordering wrinkle: the env vars must be set before the daemon starts, while the Aside and `$B status` checks come first, and the instructions only partly say when to stop and restart the daemon. (4) A few edge cases are not defined, such as what to do when the reset or verification fails, how to count partial results, and how to proceed after the failure-reason handoff. (5) The multi-domain instruction is \"one --domain per run\", but it is unclear whether to repeat runs or how to aggregate the receipts. (6) The \"Windows App-Bound Encryption\" recovery depends on a display, with no alternative if none is available. These gaps are minor, and the workflow is mostly executable.\"}" + }, + { + "id": "msg_011CgVne5GStZ6f9pRoCvgWE", + "model": "claude-sonnet-5-5", + "stop_reason": "end_turn", + "text": "{\"clarity\": 4, \"completeness\": 4, \"actionability\": 4, \"reasoning\": \"The workflow is well ordered: probe Aside, collect all decisions up front, set up the browser, import, then report three honest lines. Consent boundaries for verification, storage clearing and --all are explicit, the sign-in-versus-cookie-copy distinction is stated repeatedly, and the error and receipt tables give recovery paths. Gaps: (1) Step 2 asks for decisions before `$B` exists, yet the picker-versus-direct choice is tied to browser, profile and domains rather than to the user's wording, and the step 3.1 env vars come before the daemon starts. (2) The conditional step 3.3 (Mode: cdp) and the `$B stop` guidance could be cleaner. (3) The direct-import syntax for `--all` is not shown, and the \"one --domain per run\" rule is stated without saying what to do for several domains (loop, with the tab open on each). (4) The verification-missing return path says to return to step 3 but doesn't say how to handle the already-running daemon beyond `$B stop`. (5) Handling of partial and failed receipts, and the handoff for non-Windows cases, is thin. (6) How the agent learns the profile directory when the user only knows a display name is left to the picker. (7) The root-selection logic with \"never a generated host stub\" is a bit dense. These are minor; the workflow is mostly executable.\"}" + } + ] +} diff --git a/test/fixtures/judge-calibration/README.md b/test/fixtures/judge-calibration/README.md index c8c223cb2..7fe8c221f 100644 --- a/test/fixtures/judge-calibration/README.md +++ b/test/fixtures/judge-calibration/README.md @@ -51,7 +51,11 @@ reservation; its 14 cases now send `WORKFLOW_JUDGE_RESPONSE_SCHEMA` without the compact-reasoning validator (still only on `ship`). The frontier (`review`) configuration has no corpus and stays on today's request. A $0.12 two-call pilot sized output tokens before the manifest was priced. Total W2 spend: $90.47 of $115. -`cookie-workflow` (GSTA-91, separately funded) is held and stays on today's request. Its one +`cookie-workflow` (GSTA-91, separately funded) was held, and the hold was released on +GStack Minder's ruling of 2026-10-29: "GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is +matched by old-b; free-text parse errors cost train 42" (`cookie-workflow/release.json`). The +cookie workflow judge now sends `WORKFLOW_JUDGE_RESPONSE_SCHEMA`, with its thresholds, model and +samples unchanged; the comparison below stays recorded as held. Its one new false pass, a07, scored 4/4/4 on all three new samples and on all three `old-b` samples; `old-a`'s a07 panel is incomplete because one old sample returned invalid JSON (the defect the schema transport removes), so the comparison counts it against the new request only. Both diff --git a/test/fixtures/judge-calibration/cookie-workflow/release.json b/test/fixtures/judge-calibration/cookie-workflow/release.json new file mode 100644 index 000000000..c567e9f8e --- /dev/null +++ b/test/fixtures/judge-calibration/cookie-workflow/release.json @@ -0,0 +1,16 @@ +{ + "config": "cookie-workflow", + "released": true, + "date": "2026-10-29", + "by": "GStack Minder", + "ruling": "GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is matched by old-b; free-text parse errors cost train 42", + "scope": "cookie workflow judge transport only", + "recorded_result": "runs/comparison-1.result.json", + "train_red": { + "gate_run": "https://github.com/garrytan/gstack/actions/runs/40809051510", + "responses": [ + "msg_011CgVnRtVj5vyLvb56m6KJH", + "msg_011CgVne5GStZ6f9pRoCvgWE" + ] + } +} diff --git a/test/helpers/free-fixtures-data.ts b/test/helpers/free-fixtures-data.ts index 3b62728c3..997b1aea9 100644 --- a/test/helpers/free-fixtures-data.ts +++ b/test/helpers/free-fixtures-data.ts @@ -62,6 +62,7 @@ export const FREE_FIXTURES: Readonly> = { 'test/fixtures/context-bill/**': ['test/context-bill.test.ts', 'test/context-budget-ratchet.test.ts'], 'test/fixtures/context-budget.json': ['test/helpers/capture-context-budget.ts', 'test/test-value-bar.test.ts'], 'test/fixtures/context-restore-fragment-match-captures.json': ['test/context-restore-fragment-verdict.test.ts'], + 'test/fixtures/cookie-judge-train42-replies.json': ['test/cookie-workflow-judge-input.test.ts'], 'test/fixtures/coverage-audit-cd-chain.json': ['test/coverage-audit-evidence.test.ts'], 'test/fixtures/coverage-audit-parallel-column.json': ['test/coverage-audit-evidence.test.ts'], 'test/fixtures/coverage-audit-separator-collision.json': ['test/coverage-audit-evidence.test.ts'], diff --git a/test/judge-calibration.test.ts b/test/judge-calibration.test.ts index 9cc7f13b2..4eca85c0d 100644 --- a/test/judge-calibration.test.ts +++ b/test/judge-calibration.test.ts @@ -321,17 +321,21 @@ describe('landed schemas match the recorded calibration', () => { expect(recorded.complete).toBe(true); const cookie = result('cookie-workflow'); expect(cookie.complete).toBe(true); + expect(cookie.landing.lands).toBe(false); + const release = JSON.parse(fs.readFileSync(path.join(import.meta.dir, 'fixtures', 'judge-calibration', 'cookie-workflow', 'release.json'), 'utf8')); + expect(release).toMatchObject({ config: 'cookie-workflow', released: true, by: 'GStack Minder', scope: 'cookie workflow judge transport only', + ruling: 'GStack Minder, 2026-10-29: release the GSTA-91 hold; a07 is matched by old-b; free-text parse errors cost train 42' }); const registrations = [...evalSource.matchAll(/await runWorkflowJudge\(\{([\s\S]*?)\n \}\);/g)].map(match => match[1]!); expect(registrations.length).toBeGreaterThan(14); for (const body of registrations) { const testName = /testName: '([^']+)'/.exec(body)![1]; const defaultConfig = !/^\s+(?:agentCapability|model|readInput|thresholds|maxTokens|stream|effort):|\.\.\.COOKIE_WORKFLOW_JUDGE/m.test(body); const expected = testName === 'ship/SKILL.md workflow' || (defaultConfig && recorded.landing.lands) - || (testName === 'setup-browser-cookies/SKILL.md workflow' && cookie.landing.lands); + || testName === 'setup-browser-cookies/SKILL.md workflow'; expect(body.includes('schemaTransport: true'), testName).toBe(expected); expect(body.includes('compactReasoning: true'), testName).toBe(testName === 'ship/SKILL.md workflow'); } - expect(registrations.filter(body => body.includes('schemaTransport: true'))).toHaveLength(cookie.landing.lands ? 16 : 15); + expect(registrations.filter(body => body.includes('schemaTransport: true'))).toHaveLength(16); const cookieBody = registrations.find(body => body.includes("testName: 'setup-browser-cookies/SKILL.md workflow'"))!; expect(cookieBody).toContain('...COOKIE_WORKFLOW_JUDGE'); expect(cookieBody).not.toMatch(/^\s+(?:agentCapability|model|thresholds|maxTokens|stream|effort):/m); diff --git a/test/skill-llm-eval.test.ts b/test/skill-llm-eval.test.ts index 551ce4d21..403dccded 100644 --- a/test/skill-llm-eval.test.ts +++ b/test/skill-llm-eval.test.ts @@ -792,6 +792,7 @@ describeIfSelected('Cookie setup workflow quality', ['setup-browser-cookies/SKIL startMarker: '# Setup Browser Cookies', endMarker: null, ...COOKIE_WORKFLOW_JUDGE, + schemaTransport: true, readInput: () => buildCookieWorkflowJudgeInput(ROOT), }); }, WORKFLOW_JUDGE_TEST_MS); From 94165fff393d608fe0b6ad05fc90cfc25624ede1 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 29 Oct 2026 01:26:30 -0700 Subject: [PATCH 10/19] v1.91.311.0 feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145) (#3556) * feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): sealed allow_paths and case_insensitive (Unreleased) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): state exactly which allow_paths file names are refused Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(troubleshooting): sealed allow_paths exemption and newer-gstack sealed keys 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 | 6 +- bin/gstack-redact-prepush | 4 +- docs/pregate.md | 38 ++++++++- docs/troubleshooting.md | 11 ++- lib/pregate/config.ts | 2 +- lib/sealed-guard.ts | 62 ++++++++++----- package.json | 2 +- test/pregate-guards.test.ts | 6 ++ test/sealed-guard.test.ts | 137 +++++++++++++++++++++++++++++++++ 12 files changed, 256 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c001a70f..874f26aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [1.91.311.0] - 2026-10-29 + +**A sealed policy can now refuse `sealed//tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks//sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants. + +### Added +- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose file name contains `**` or is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` on every push. +- **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs. + +### Changed +- `gstack-pregate sealed` prints `exempt=` after `waived=` on its `SEALED:` line (`policy=` stays last), and `--json` carries an `exempt` array. Both bins build their error report through one `sealedErrorReport`. +- An unknown `sealed` key now says to upgrade gstack if the key comes from a newer one. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key. +- `docs/pregate.md` § Sealed output explains glob anchoring: patterns match from the repository root, so `sealed/**` misses `tasks/x/sealed/a.json` and the docs say to write `**/sealed/**`. It also documents case handling, exceptions and the version skew. + +### Gate integrity +- This is a gate edit: `allow_paths` lets the sealed guard pass files it refused before, but only from the reviewed base branch. No existing test, threshold or allowlist changed. New cases in `test/sealed-guard.test.ts` (10) and `test/pregate-guards.test.ts` cover an allowed exception, a non-matching one, an exception added on the pushed head, a text rule inside an exempt file, transcripts, the waiver order, the error report, case folding in both directions and the root-anchoring example. + ## [1.91.310.0] - 2026-10-29 **The cookie workflow judge now asks for structured output, so a quote the model forgets to escape can no longer fail the gate.** Train 42 went red because both of its `setup-browser-cookies/SKILL.md workflow` samples came back as free text with a bare quote inside `reasoning` ("one --domain per run"), and neither parsed. The GSTA-91 comparison had measured that failure: 2 of 144 free-text samples failed to parse, against 0 of 72 with the schema. diff --git a/VERSION b/VERSION index 7a470c3f1..71dfc4396 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.310.0 +1.91.311.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index a91df72cb..2ea2fcfa8 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.310.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.311.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 8613150ed..446e4be7a 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -40,7 +40,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context'; import { cancelActive } from '../lib/pregate/supervise'; import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate'; import { GATE_AHEAD_REL } from '../lib/restamp'; -import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard'; +import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard'; import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy'; import { fromGateAhead } from '../lib/tree-receipt'; @@ -244,13 +244,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number { const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null; const remoteUrl = gitOut(root, ['remote', 'get-url', remote]); const report = 'error' in listed - ? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote } + ? sealedErrorReport(listed.error, remoteUrl ?? remote) : evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote }); 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 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} 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} policy=${policy}\n`); return blocks ? EXIT.fail : EXIT.ok; } diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 252dc4389..80ebddeed 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -43,7 +43,7 @@ import * as path from "path"; import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine"; import { mkdirpSync } from "../lib/fs-utils"; import { resolveStateRoot } from "../lib/state-root"; -import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard"; +import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard"; const ZERO = /^0+$/; let emptyTree: string | undefined; @@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se const targets: Array<{ commits: string[]; pushedSha: string }> = []; for (const ref of refs) { const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName }); - if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" }; + if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target"); targets.push({ commits: published.commits, pushedSha: ref.localSha }); } return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" }); diff --git a/docs/pregate.md b/docs/pregate.md index 11740fcea..1e8698852 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -768,11 +768,13 @@ with a timing flake: never raise the bound. ```json { "sealed": { - "paths": ["heldout/**"], + "paths": ["heldout/**", "**/sealed/**"], "transcripts": ["runs/**/transcript*.jsonl"], + "allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"], "answer_markers": ["SEALED ANSWER:"], "seed_patterns": ["shuffle_seed=\\d+"], - "local_paths": true } } + "local_paths": true, + "case_insensitive": true } } ``` A repo with a `sealed` key gets a configured local check against accidental @@ -792,8 +794,38 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path` (`/home//`, `/Users//`, `C:\Users\\`) check each commit's added lines. Findings print rule, `file[:line]` and commit, never the text. +- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole + repo-relative path from the repository root. `*` and `?` stay inside one + path segment; `**/` spans zero or more directories; a `**` not followed by + `/` matches across directories (`heldout/**` covers every depth under + `heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`: + to guard a `sealed/` directory wherever it sits, write `**/sealed/**`. +- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case + for `paths` and `transcripts` only, so `**/heldout/**` also catches + `HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`, + `held_out/`, `heldout-v2/`) are not folded: list each as its own glob. + The effective value is the base branch's OR the pushed one (it only tightens). +- **Exceptions.** `allow_paths` exempts publishable files that sit next to + sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and + `sealed.transcript`, applied after the deny globs and before waivers. They + are read from the destination base branch only: an exception a push adds + takes effect once it lands on the base branch, so a branch can never widen + its own. They never fold case. An entry whose file name contains `**`, or + is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`, + `sealed/**.json`), is refused; a partial wildcard such as `MANIFEST*.json` + is accepted and matches every file it spells, so name the file. An exempt file is checked by the + text rules only, so configure `answer_markers` when you exempt files next to + 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. - **Policy.** The union of the destination base branch's `sealed` and the - pushed commit's: a push can tighten it, never relax it. A malformed or + 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). A malformed or unreadable policy refuses. Waivers come only from the base branch's `.gstack/sealed-allow.tsv` (`rulepathshareason`) and are listed on every push. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index e531ff7b8..85c5d1543 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3152,7 +3152,10 @@ cannot be downgraded; every other check id in `warn` is honoured only from the 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). +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 +the policy: upgrade gstack here, and raise `min_gstack_version` in the +repository's `.gstack/ship-policy.json` before landing such a key. ### `PREGATE_SECRETS` @@ -3658,7 +3661,11 @@ finding: rule, `file[:line]`, commit. They never print the matched text. **Fix.** Remove the output from every published commit before the branch reaches the public repository (a later commit that deletes it still publishes -it). A reviewed false positive gets one line in the base branch's +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 +one line in the base branch's `.gstack/sealed-allow.tsv`: `rulepathshareason`. A private destination sets `"visibility": "private"` under `sealed` in the base branch's `.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index 8e20540fa..bbd409ee7 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -321,7 +321,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, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)', + '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)', '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)', 'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree= tests= failed=) (docs/pregate.md#remote-gate)', diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index e10c08a6a..6dd1572fa 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -9,6 +9,8 @@ * answer_markers regexes for a quoted sealed answer in added text * seed_patterns regexes for a sampling seed in added text * 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 * visibility "public" | "private": the destination override (reviewed on the base branch) * * The publication boundary is every commit the push publishes: the local sha @@ -18,8 +20,9 @@ * copies, independent of content (binary, empty, add-then-delete); text rules * see each commit's added lines. The effective policy is the union of the * destination base branch's `sealed` and the pushed one (a push can tighten, - * never relax); waivers come only from the base branch's - * `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail + * never relax); `allow_paths` exceptions come only from the base branch's + * policy and remove path findings before waivers, which come only from the + * base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail * closed). Visibility: the base override, else a cache under the state root * (a `public` answer for 24 h, a `private` one for 1 h before it may * downgrade a refusal), else `gh repo view` (5 s); unknown counts as public. @@ -28,19 +31,20 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { containedRelative, globToRegExp, matchesAny } from './ship-policy'; +import { containedRelative, globToRegExp } from './ship-policy'; 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; visibility?: 'public' | 'private' } +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 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', 'visibility']; +const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility']; const MAX_ENTRIES = 64; const MAX_REGEX = 256; const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; @@ -50,9 +54,9 @@ const PRIVATE_TTL_MS = 3600_000; /** `: ` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */ export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> { const errors: Array<{ path: string; message: string }> = []; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }]; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }]; const o = raw as Record; - for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` }); + for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` }); const list = (key: string, check: (s: string) => string | null) => { if (!(key in o)) return; const v = o[key]; @@ -62,8 +66,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin }; const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } }; 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})`; } }; - list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); - if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' }); + 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); + 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; } @@ -88,15 +93,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, 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, visibility: s.visibility }, label }; } -/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */ +/** 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. */ 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 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), 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), visibility }; } /** Reviewed waivers: `rulepathshareason` lines in the base branch's allow file. */ @@ -126,12 +131,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string const HEX = /^[0-9a-f]{40,64}$/; +/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */ +function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null { + const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; }); + return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; }; +} + /** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] { if (!p.paths.length && !p.transcripts.length) return []; const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n'); if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`); const out: SealedFinding[] = []; + const transcript = globMatcher(p.transcripts, p.case_insensitive); + const sealedPath = globMatcher(p.paths, p.case_insensitive); const tokens = r.out.split('\0'); let commit = ''; for (let i = 0; i < tokens.length; i++) { @@ -141,8 +154,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea const renamed = /^[RC]\d*$/.test(t); const file = renamed ? tokens[i += 2]! : tokens[++i]!; if (!renamed && !t.includes('A')) continue; - if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit }); - else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit }); + if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit }); + else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit }); } return out; } @@ -216,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {}) export interface SealedReport { status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error'; - policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array; + policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array; visibility: Visibility; visibilitySource: string; target: string; error?: string; } @@ -225,9 +238,14 @@ export interface EvaluateOptions { env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh']; } +/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */ +export function sealedErrorReport(error: string, target: string): SealedReport { + return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target }; +} + /** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */ export function evaluateSealed(o: EvaluateOptions): SealedReport { - const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target }; + const blank = { ...sealedErrorReport('', o.target), error: undefined }; const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' }; if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` }; let policy: SealedPolicy | null = base.policy; @@ -243,6 +261,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport { let found: SealedFinding[]; try { found = scanCommits(o.repoRoot, commits, policy); } catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; } + const exception = globMatcher(policy.allow_paths, false); + const exempt: ExemptFinding[] = []; + found = found.filter(f => { + const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null; + if (glob) exempt.push({ ...f, glob }); + return !glob; + }); const allow = allowEntriesAt(o.repoRoot, o.baseRef); const waived: SealedReport['waived'] = []; const findings = found.filter(f => { @@ -252,7 +277,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport { }); const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh }); const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused'; - return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target }; + return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target }; } const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`; @@ -260,6 +285,7 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$ /** stderr text for a report: findings one per line (` commit `), never the matched text. */ export function renderSealed(r: SealedReport, context: string): string { const lines: string[] = []; + for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`); for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`); 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)}`); @@ -268,7 +294,7 @@ export function renderSealed(r: SealedReport, context: string): string { 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, or waive one reviewed finding with a \`rulepathshareason\` 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(`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 \`rulepathshareason\` 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); } diff --git a/package.json b/package.json index d30024e4e..c224f2616 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.310", + "version": "1.91.311", "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-guards.test.ts b/test/pregate-guards.test.ts index 7719b9ec9..bf0e65bf4 100644 --- a/test/pregate-guards.test.ts +++ b/test/pregate-guards.test.ts @@ -193,6 +193,12 @@ describe('pregate config: sealed (CD4)', () => { expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']); expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']); expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']); + expect(codes({ sealed: { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json', '**/protected/MANIFEST.json'], case_insensitive: true } })).toEqual([]); + expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']); + expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']); + expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']); + expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/*.json', 'sealed/*.*', '**/sealed/**.json', '**/sealed/*/MANIFEST.json', '**/sealed/*/transcript-*.jsonl', '.hidden'] } })).toEqual([0, 1, 2, 3, 4, 5].map(i => `PREGATE_CONFIG_INVALID:sealed.allow_paths[${i}]`)); + expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']); }); }); diff --git a/test/sealed-guard.test.ts b/test/sealed-guard.test.ts index 1fff7c24f..a9f044f6d 100644 --- a/test/sealed-guard.test.ts +++ b/test/sealed-guard.test.ts @@ -195,6 +195,143 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR }); }); +const MANIFEST = { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json'], answer_markers: ['SEALED ANSWER:'] }; +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 ?? '' }; +} +function landOnMain(s: Scratch, files: Record): void { + sh(s.work, s.env, ['checkout', '-q', 'main']); + write(s.work, files); + sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'base change']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']); + sh(s.work, s.env, ['checkout', '-q', 'results']); +} + +describe('sealed.allow_paths: base-branch exceptions applied after the deny globs', () => { + test('a deny with an allowed exception refuses the sealed file, lists the manifest as exempt, and a manifest-only push passes', () => { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { 'sealed/s1/tasks.json': '{}\n', 'sealed/s1/MANIFEST.json': '{"n":1}\n', 'tasks/x/sealed/s2/MANIFEST.json': '{"n":2}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/tasks\.json/m); + expect(r.err).not.toMatch(/^ sealed\.path\s+\S*MANIFEST\.json/m); + expect(r.err).toMatch(/exempt sealed\.path sealed\/s1\/MANIFEST\.json commit [0-9a-f]{12} \(sealed\.allow_paths \*\*\/sealed\/\*\/MANIFEST\.json\)/); + expect(r.err).toContain('exempt sealed.path tasks/x/sealed/s2/MANIFEST.json'); + const only = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(only, { 'sealed/s1/MANIFEST.json': '{"n":1}\n' }); + expect(hook(only).code).toBe(0); + const pre = preflight(only); + expect(pre.code).toBe(0); + expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m); + const json = JSON.parse(preflight(only, ['--json']).out); + expect(json.exempt).toHaveLength(1); + expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' }); + }); + + test('an exception that does not match still refuses: another case and another depth', () => { + for (const file of ['sealed/s1/manifest.json', 'sealed/s1/x/MANIFEST.json']) { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { [file]: '{}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toContain(`sealed.path ${file}`); + expect(r.err).not.toContain('exempt sealed.'); + } + }); + + test('an exception added on the pushed head is ignored, on top of a base list too; a malformed pushed allow_paths refuses', () => { + const fresh = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) }); + commit(fresh, { '.gstack/pregate.json': policy(MANIFEST), 'sealed/s1/MANIFEST.json': '{}\n' }); + const f = hook(fresh); + expect(f.code).toBe(1); + expect(f.err).toContain('sealed.path sealed/s1/MANIFEST.json'); + const extra = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(extra, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: [...MANIFEST.allow_paths, '**/sealed/*/notes.md'] }), 'sealed/s1/notes.md': 'n\n' }); + expect(hook(extra).err).toContain('sealed.path sealed/s1/notes.md'); + const bad = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(bad, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: '**/sealed/*/MANIFEST.json' }), 'docs/x.md': 'x\n' }); + const b = hook(bad); + expect(b.code).toBe(1); + expect(b.err).toContain('sealed.allow_paths'); + }); + + test('an allowed manifest that quotes a sealed answer still refuses on the text rule', () => { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { 'sealed/s1/MANIFEST.json': '{"note":"SEALED ANSWER: b"}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toMatch(/sealed\.answer\s+sealed\/s1\/MANIFEST\.json:1/); + expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json'); + }); + + test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => { + const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) }); + const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' }); + landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` }); + const pre = preflight(s); + expect(pre.code).toBe(0); + expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl'); + expect(pre.out).toMatch(/findings=0 .* waived=0 exempt=1 /); + }); + + test('a preflight error carries the same report shape: exit 1, SEALED: error with exempt=0, no stack trace', () => { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { 'docs/x.md': 'x\n' }); + const r = preflight(s, ['--range', 'nosuch..HEAD']); + expect(r.code).toBe(1); + expect(r.out).toMatch(/^SEALED: error findings=0 commits=0 .* waived=0 exempt=0 /m); + expect(r.err).not.toMatch(/\n\s+at /); + expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).exempt).toEqual([]); + }); +}); + +describe('sealed.case_insensitive: deny globs fold case, exceptions never do', () => { + test('off (the default), HELDOUT/ passes a lowercase deny glob; on, HELDOUT/, HeldOut/ and a Transcript refuse, and hold-out/ still passes', () => { + const off = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) }); + commit(off, { 'HELDOUT/q.json': '{}\n' }); + expect(hook(off).code).toBe(0); + const on = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], case_insensitive: true }) }); + commit(on, { 'HELDOUT/q.json': '{}\n', 'HeldOut/q.json': '{}\n', 'Runs/c1/Transcript.jsonl': '{}\n', 'hold-out/q.json': '{}\n' }); + const r = hook(on); + expect(r.code).toBe(1); + expect(r.err).toContain('sealed.path HELDOUT/q.json'); + expect(r.err).toContain('sealed.path HeldOut/q.json'); + expect(r.err).toContain('sealed.transcript Runs/c1/Transcript.jsonl'); + expect(r.err).not.toContain('hold-out/q.json'); + }); + + test('on, allow_paths stay case-sensitive: MANIFEST.json is exempt and manifest.json refuses', () => { + const s = scratch({ '.gstack/pregate.json': policy({ ...MANIFEST, case_insensitive: true }) }); + commit(s, { 'sealed/s1/MANIFEST.json': '{}\n', 'sealed/s1/manifest.json': '{}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json'); + expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/manifest\.json/m); + }); + + test('set only on the pushed head, it still tightens (base OR pushed)', () => { + const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) }); + commit(s, { '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], case_insensitive: true }), 'HELDOUT/q.json': '{}\n' }); + expect(hook(s).err).toContain('sealed.path HELDOUT/q.json'); + }); +}); + +describe('root-anchoring (the docs/pregate.md example)', () => { + test('globs match from the repo root: sealed/** misses a nested sealed/ directory and **/sealed/** catches it at any depth', () => { + const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8'); + expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`'); + expect(docs).toContain('write `**/sealed/**`'); + const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) }); + commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' }); + expect(hook(anchored).code).toBe(0); + const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) }); + commit(any, { 'tasks/x/sealed/a.json': '{}\n', 'sealed/b.json': '{}\n' }); + const a = hook(any); + expect(a.err).toContain('sealed.path tasks/x/sealed/a.json'); + expect(a.err).toContain('sealed.path sealed/b.json'); + }); +}); + describe('visibility (cache under the state root; unknown counts as public)', () => { test('a public answer is cached; a cached private answer older than an hour is re-queried before it can downgrade; a failed lookup is unknown', () => { const state = fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-vis-')); From 16fd7a7dbd0bbf2f4fce51b6836391c7373b658c Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 29 Oct 2026 01:39:43 -0700 Subject: [PATCH 11/19] v1.91.312.0 fix(capy-session): an install inside /ship no longer upgrades gstack under it (#3571) (#3573) * test(capy-session): an install inside a leased run must not upgrade (#3571) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy-session): defer the self-upgrade while this session's own run holds the lease (#3571) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): an install inside /ship no longer upgrades gstack under it (#3571) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(capy-session): bound the own-run deferral by the lease's age so Capy machines still upgrade (#3571) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(changelog): prose lint, no mid-sentence colon (#3571) 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-capy-session | 20 +++++++++++++++++++- docs/capy-install.md | 11 ++++++++++- docs/troubleshooting.md | 12 ++++++++++-- lib/result-codes.ts | 2 +- package.json | 2 +- test/capy-skill-wrapper.test.ts | 26 ++++++++++++++++++++++++++ 9 files changed, 79 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 874f26aaf..7263d2c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.91.312.0] - 2026-10-29 + +**An install check run from inside /ship no longer upgrades gstack under it.** /ship's docs step runs `scripts/install.sh --for document-release`. That run fast-forwarded the checkout from 1.91.298 to 1.91.299 in the middle of a lane's /ship, so the receipt writer's rules changed partway through the run (#3571). + +### Fixed +- **`bin/gstack-capy-session`**: in install mode, a live workflow lease under 120 minutes old held by the caller's own process chain now skips the self-upgrade: `SELF_UPGRADE: skipped (a gstack run in this session holds a workflow lease on this installation: pid

(m ), so the upgrade waits until the lease is 120 minutes old; …(UPGRADE_BUSY))`. The install still certifies the installed revision and the trailer stays ok with `behind=`. `bin/gstack-upgrade` deliberately ignores the caller's own lease, and an install started from inside the run passed that check. Each `gstack-skill-start` renews the lease. The age bound matters on Capy, where the leased pid is the machine's long-lived `capy-server`, so a lease is never released by its process exiting. An older lease, or none, upgrades as before, and `GSTACK_UPGRADE_FORCE=1` or a hand-run `bin/gstack-upgrade` still moves the checkout. + +### Tests +- `test/capy-skill-wrapper.test.ts`: an install run under a shell that holds a fresh lease leaves HEAD where it was and stays ok. Once that lease is 121 minutes old, with its holder still alive, the same install upgrades. Before this change the first run printed `SELF_UPGRADE: done 1.0.1 -> 1.0.2`. + ## [1.91.311.0] - 2026-10-29 **A sealed policy can now refuse `sealed//tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks//sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants. diff --git a/VERSION b/VERSION index 71dfc4396..c8aff509c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.311.0 +1.91.312.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 2ea2fcfa8..e31ad205b 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.311.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.312.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-capy-session b/bin/gstack-capy-session index 4296bdec8..3df25b12e 100755 --- a/bin/gstack-capy-session +++ b/bin/gstack-capy-session @@ -23,7 +23,13 @@ # applies: dirty, diverged, pinned, Bun hold, a live workflow lease) and on # success re-execs the NEW checkout's copy of this script with the same # arguments (GSTACK_CAPY_SELF_UPGRADED=1 stops a second attempt) before the -# installer runs. Lines: `SELF_UPGRADE: done -> ` | `skipped ()` +# installer runs. A live lease under 120 minutes old (the lease prune window) +# held by this session's own process chain also skips it (#3571): +# gstack-upgrade never counts the caller's own session, but an install run from +# inside a workflow (/ship's docs step) must not change gstack under it. The age +# bound matters on Capy, where the leased harness pid is the machine's +# long-lived capy-server and a live lease alone would never end the deferral; +# each gstack-skill-start renews it (UPGRADE_BUSY; GSTACK_UPGRADE_FORCE=1 overrides). Lines: `SELF_UPGRADE: done -> ` | `skipped ()` # | `none (current)` | `disabled ()`. # --check never moves the checkout; behind origin it prints @@ -107,6 +113,18 @@ elif [ "${GSTACK_CAPY_SELF_UPGRADED:-}" != 1 ]; then elif ! behind; then echo "SELF_UPGRADE: none (current)" elif [ ! -x "$ROOT/bin/gstack-upgrade" ]; then echo "SELF_UPGRADE: skipped ($ROOT/bin/gstack-upgrade is missing)"; report_behind "$RERUN" export GSTACK_CAPY_BEHIND_BLOCKED="$ROOT/bin/gstack-upgrade is missing; fix: $RERUN (UPGRADE_REFUSED)" + elif [ "${GSTACK_UPGRADE_FORCE:-}" != 1 ] && [ -x "$ROOT/bin/gstack-lease" ] \ + && OWN=$("$ROOT/bin/gstack-lease" live --root "$ROOT" 2>/dev/null | awk '{ a = $2; sub(/m$/, "", a); if (a + 0 < 120) print }') \ + && [ -n "$OWN" ] \ + && ! "$ROOT/bin/gstack-lease" live --root "$ROOT" --exclude-ancestry-of $$ >/dev/null 2>&1; then + HOLDERS="" + while read -r _pid _age; do HOLDERS="$HOLDERS${HOLDERS:+, }pid $_pid ($_age)"; done </dev/null) UP_OUT=$("$ROOT/bin/gstack-upgrade" --ff-only-no-reinstall 2>&1); UP_RC=$? diff --git a/docs/capy-install.md b/docs/capy-install.md index f98f87d6e..7de24710e 100644 --- a/docs/capy-install.md +++ b/docs/capy-install.md @@ -155,7 +155,16 @@ and `/proc` start time; elsewhere `ps -o lstart`), because a pid is reused after a reboot or a machine wake. `bin/gstack-upgrade`, and so the session-start self-upgrade, refuses with [UPGRADE_BUSY](troubleshooting.md#upgrade-busy) while a lease held by a live process outside its own chain exists, naming each -holder as `pid

(m )`. The team-mode SessionStart hook +holder as `pid

(m )`. The install-mode self-upgrade also +defers while its own chain holds a live lease under 120 minutes old. An install +run from inside a workflow (/ship's docs step running `install.sh --for +document-release`, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this +session holds a workflow lease on this installation: ... (UPGRADE_BUSY))`, +leaves the checkout where the run started and stays ok with `behind=`. Each +`gstack-skill-start` renews the lease. On Capy the leased pid is the machine's +long-lived `capy-server`, so the deferral ends when the lease turns 120 +minutes old, not when the process exits; `bin/gstack-upgrade` run by hand +moves the checkout sooner. The team-mode SessionStart hook (`bin/gstack-session-update`) honors the same leases: it logs `SKIP leased pid=

/m` to `analytics/session-update.log` and tries again at the next session start, without writing its hourly throttle stamp. A lease whose diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 85c5d1543..1d20996f2 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1566,11 +1566,19 @@ process has exited never blocks, and neither does a stale one: each lease records its process's start identity, so a pid reused after a reboot or a machine wake (GBRA-105's lease named pid 379, which had become dbus-daemon) is reclaimed instead of blocking. The upgrader's own session (the pid chain it -runs under) never counts. +runs under) never counts for `bin/gstack-upgrade` run by hand. A Capy install +in install mode (`bin/gstack-capy-session`) counts it while that lease is under +120 minutes old: an install run from inside a workflow that holds the lease +(/ship's docs step, #3571) prints `SELF_UPGRADE: skipped (a gstack run in this +session holds a workflow lease on this installation: pid

(m +), so the upgrade waits until the lease is 120 minutes old; fix: +/bin/gstack-upgrade once the run ends (UPGRADE_BUSY))` and leaves the +checkout where the run started. Each `gstack-skill-start` renews the lease. **Fix.** Wait for those sessions to finish (`bin/gstack-lease list` shows every lease with its age and `alive|dead|stale`; a holder is named as -`pid

(m )`), then re-run `bin/gstack-upgrade`. +`pid

(m )`), then re-run `bin/gstack-upgrade`. For the +session's own run, run the printed `bin/gstack-upgrade` after the run ends. When you know they are idle, `GSTACK_UPGRADE_FORCE=1 bin/gstack-upgrade` proceeds. At a Capy session start the same refusal appears as `SELF_UPGRADE: skipped (live gstack sessions hold this installation: ...)`, diff --git a/lib/result-codes.ts b/lib/result-codes.ts index d4e1cf5b4..46ca94bd7 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -597,7 +597,7 @@ export const RESULT_CODES = { }, UPGRADE_BUSY: { anchor: 'upgrade-busy', - summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), so moving the checkout would change gstack under it', + summary: 'bin/gstack-upgrade changed nothing: another live session holds a workflow lease on this installation (bin/gstack-lease live), or, at a Capy install, the caller\'s own run does, so moving the checkout would change gstack under it', fix: 'wait for those sessions to finish (bin/gstack-lease list names each pid and age), then re-run bin/gstack-upgrade; GSTACK_UPGRADE_FORCE=1 overrides when you know they are idle', }, UPGRADE_FETCH_FAILED: { diff --git a/package.json b/package.json index c224f2616..652d5a31f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.311", + "version": "1.91.312", "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/capy-skill-wrapper.test.ts b/test/capy-skill-wrapper.test.ts index a2f9d0506..9b041ba7d 100644 --- a/test/capy-skill-wrapper.test.ts +++ b/test/capy-skill-wrapper.test.ts @@ -393,6 +393,32 @@ describe('capy skill: the session hands the installer GSTACK_CAPY_BEHIND', () => } }); + // #3571: /ship's docs step ran install.sh --for document-release from inside the run that + // holds the lease, the upgrader excluded its own ancestry, and the checkout moved mid-/ship. + // On Capy the leased pid is the long-lived capy-server, so the deferral ends by the lease's age. + test('install mode inside a run that holds a fresh workflow lease: the self-upgrade is deferred, HEAD stays and the trailer stays ok; once that lease is 120 minutes old the same install upgrades', () => { + const f = fixture({ cloneAt: 'B' }); + write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); + f.git(['update-index', '--assume-unchanged', 'bin/gstack-capy-install']); + const leaseDir = path.join(f.host.base, 'leases'); + const script = [ + 'bash "$1" touch --pid $$ --root "$2" || exit 9', + 'bash "$3" --for document-release; echo "exit=$?"', + 'git -C "$2" rev-parse HEAD', + '"$5" -e "const t = Date.now() / 1000 - 121 * 60; require(\'fs\').utimesSync(process.argv[1], t, t)" "$4/$$"', + 'bash "$3" --for document-release; echo "exit=$?"', + ].join('\n'); + const r = spawnSync('bash', ['-c', script, 'run', path.join(f.root, 'bin/gstack-lease'), f.root, BOOT, leaseDir, process.execPath], { cwd: f.host.home, encoding: 'utf8', env: { ...f.host.env, GSTACK_CAPY_ROOT: f.root, GSTACK_CAPY_REPO: `file://${f.bare}`, WRAP_LOG: path.join(f.host.base, 'wrap.log'), GSTACK_LEASE_DIR: leaseDir }, timeout: 120_000 }); + const [inRun, aged] = r.stdout.split(/^exit=0$/m); + expect(r.stdout.match(/^exit=0$/gm), r.stdout + r.stderr).toHaveLength(2); + expect(inRun).toMatch(/^SELF_UPGRADE: skipped \(a gstack run in this session holds a workflow lease on this installation: pid \d+ \(0m bash\), so the upgrade waits until the lease is 120 minutes old; fix: .+bin\/gstack-upgrade once the run ends \(UPGRADE_BUSY\)\)$/m); + expect(inRun).not.toContain('SELF_UPGRADE: done'); + expect(inRun).toMatch(/blocked=a gstack run in this session holds a workflow lease .+ \(UPGRADE_BUSY\)$/m); + expect(aged).toContain(f.b); + expect(aged).toMatch(/^SELF_UPGRADE: done 1\.0\.1 -> 1\.0\.2$/m); + expect(f.git(['rev-parse', 'HEAD'])).toBe(f.c); + }); + test('an older bootstrapper handing --for none to the session in install mode: the session certifies nothing and moves nothing (no self-upgrade, HEAD unchanged), and reports behind like --check', () => { const f = fixture({ cloneAt: 'B' }); write(path.join(f.root, 'bin/gstack-capy-install'), ENV_INSTALLER, 0o755); From 81a82727b38590c90b34f4c326af021f31d9774f Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 29 Oct 2026 01:53:23 -0700 Subject: [PATCH 12/19] v1.91.313.0 fix(redact): pii.phone skips bare numeric constants; pii.email skips package@version filenames (#3578) * fix(redact): pii.phone skips bare numeric constants; pii.email skips package@version filenames Fixes #3577 Fixes #3576 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): pii.email reports a file named after an address looksLikeAtFilename exempted any span whose domain ended in a non-TLD extension, so an address inside a filename (an export named after the address plus .json) went unreported. Strip the trailing extension run and exempt only when what precedes it cannot be a domain (one dotless label or a dotted version). The dotted-version branch now requires an extension that doubles as a TLD, so an address at an all-digit subdomain still reports. Regression rows pin both. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: note the constant and @-filename redact exemptions; CHANGELOG under Unreleased 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 | 11 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/troubleshooting.md | 17 ++++ lib/redact-patterns.ts | 61 +++++++++++- package.json | 2 +- test/redact-constant-and-at-filename.test.ts | 97 ++++++++++++++++++++ 7 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 test/redact-constant-and-at-filename.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7263d2c3a..472e3a1fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [1.91.313.0] - 2026-10-29 + +**The PII scanner stops flagging two kinds of ordinary text: a bare power-of-two or hash constant in code, and a package@version-style filename.** Eval artifacts and PR bodies that print a PRNG range or name a versioned results file no longer raise a MEDIUM phone or email finding, and real phone numbers and addresses next to them still report. + +### Fixed +- **`lib/redact-patterns.ts`**: `pii.phone.e164` skips a bare number that is exactly a power of two from 2^30 through 2^49, one less than one, or one of eleven well-known 32-bit hash and PRNG constants. The same digits written with a `+`, spaces, dashes, dots or parentheses still report (#3577). +- **`lib/redact-patterns.ts`**: `pii.email` skips a filename whose part after the `@` is a version or tag followed by file extensions no top-level domain uses, or a dotted version followed by an extension that doubles as one. A file named after a real address, an address at a numeric domain such as 163.com, and an address under a top-level domain that doubles as an extension (`.sh`, `.md`) still report (#3576). + +### Tests +- `test/redact-constant-and-at-filename.test.ts`: pins both exemptions in both directions, including files named after a real address. + ## [1.91.312.0] - 2026-10-29 **An install check run from inside /ship no longer upgrades gstack under it.** /ship's docs step runs `scripts/install.sh --for document-release`. That run fast-forwarded the checkout from 1.91.298 to 1.91.299 in the middle of a lane's /ship, so the receipt writer's rules changed partway through the run (#3571). diff --git a/VERSION b/VERSION index c8aff509c..f64064728 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.312.0 +1.91.313.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index e31ad205b..a043d5f41 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.312.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.313.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/troubleshooting.md b/docs/troubleshooting.md index 1d20996f2..aec8622f1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -2396,6 +2396,14 @@ and an E.164 `+14155551234` still report, so the finding means the id stands without a run word (`the id is 40269992077`, `40269992077 failed`) or follows a word that is not one (`see 40323989742`). +Separately, the scanner skips a bare number that is exactly a power of two +from 2^30 through 2^49, one less than such a power, or one of eleven 32-bit +hash and PRNG constants (Knuth's multiplicative hash, the golden-ratio +constant, the glibc and Numerical Recipes LCGs, FNV-1a, MurmurHash2 and 3, the +MT19937 seeder and the largest 32-bit prime; `isNumericConstant` in +`lib/redact-patterns.ts`). Only the exact digits with no separator are exempt: +the same digits with a `+`, spaces, dashes, dots or parentheses still report. + **Fix.** Write the id next to a run word (`run 40269992077`) or as an `/actions/runs/` link. For anything else, rerun with `--confirm ` using the `TOKEN:` line the refusal printed; MEDIUM findings do not block @@ -2436,6 +2444,15 @@ An address only another remote knows still reports. If the hook also printed `existing-email suppression was limited for this push`, it could not read the history in 5 seconds, so known authors may be listed too. +A filename with an `@` in the package@version style is not an address and +never reports: the part after the `@` is a version or tag (one dotless label +or a dotted version, never a domain name) followed by file extensions no +top-level domain uses, or a dotted all-digit version followed by +an extension that doubles as a top-level domain (`looksLikeAtFilename` in +`lib/redact-patterns.ts`). A file named after a real address, an address at an +all-digit domain and an address whose top-level domain doubles as a file +extension still report. + **Fix.** Remove the address, or allow it for this repo: `git config --add gstack.redact.allowEmail

`. If it is your own address, check `git config user.email`. The allowlist only covers `pii.email`; diff --git a/lib/redact-patterns.ts b/lib/redact-patterns.ts index 9c820900c..330db6130 100644 --- a/lib/redact-patterns.ts +++ b/lib/redact-patterns.ts @@ -201,6 +201,35 @@ function looksLikeCompactTimestamp(span: string): boolean { ); } +/** + * Bare integers that programs print as constants: 2^30 through 2^49 and each + * one minus one (`4294967296`, `2147483647`), and the 32-bit multipliers, + * offsets and primes of common hashes and PRNGs (Knuth's multiplicative hash, + * the golden-ratio constant, the glibc and Numerical Recipes LCGs, FNV-1a, + * MurmurHash2 and 3, the MT19937 seeder, the largest 32-bit prime). pii.phone.e164 + * reads each as a bare 10-15 digit number. Only the exact separator-free value + * is exempt: the same digits with a `+`, spaces, dashes or dots still report, + * so an E.164 number, which carries its `+`, is never exempt. + */ +const NUMERIC_CONSTANTS: ReadonlySet = new Set([ + ...Array.from({ length: 20 }, (_, i) => 2n ** BigInt(30 + i)).flatMap((p) => [String(p), String(p - 1n)]), + "2654435761", + "2654435769", + "1103515245", + "1013904223", + "2166136261", + "1540483477", + "3432918353", + "2246822507", + "3266489909", + "1812433253", + "4294967291", +]); + +export function isNumericConstant(span: string): boolean { + return NUMERIC_CONSTANTS.has(span); +} + /** Context window for pairing a normalized parcel ID with its punctuated form. */ const PARCEL_CONTEXT_CHARS = 400; @@ -446,6 +475,31 @@ export function isDotenvFilename(match: RegExpExecArray): boolean { return spanStart > 0 && input[spanStart - 1] === "."; } +/** A trailing run of file extensions that are not top-level domains (checked + * against the IANA root zone list, 2026-10-28). Extensions that are also TLDs + * (`.md`, `.py`, `.sh`, `.rs`, `.so`, `.zip`, `.mov`, `.map`) are left out on + * purpose. */ +const NON_TLD_EXTENSIONS = /(?:\.(?:json|jsonc|jsonl|ndjson|ya?ml|toml|lock|txt|csv|tsv|log|ini|cfg|conf|xml|html?|[mc]?js|jsx|tsx?|tgz|gz|tar|bz2|xz|zst|whl|jar|gem|nupkg|deb|rpm|wasm|svg|png|jpe?g|pdf|patch|diff))+$/i; + +/** + * True when a pii.email span is a filename with an `@` in it rather than an + * address: `terminal-bench@2.0.json`, `lodash@4.17.21.tgz`, + * `eslint-config@next.yaml`. The package@version convention puts a version or + * tag after the `@`, so either sign decides: the "domain" ends in file + * extensions no TLD uses and what precedes them cannot be a domain (one dotless + * label or a dotted version), or it is a dotted version (two or more all-digit + * labels) followed by an extension that doubles as a TLD (`a@1.2.md`). A file + * named after an address (`jane@acme.com.json`), `user@163.com`, + * `alice@1.2.com` and an address at a TLD that doubles as an extension + * (`dev@acme.sh`, `ops@acme.md`) still report. + */ +export function looksLikeAtFilename(span: string): boolean { + const domain = span.slice(span.lastIndexOf("@") + 1); + const stem = domain.replace(NON_TLD_EXTENSIONS, ""); + if (stem !== domain) return !/\.[A-Za-z]{2,}$/.test(stem); + return /^\d+(?:\.\d+)+\.(?:md|py|sh|rs|so|zip|mov|map)$/i.test(domain); +} + /** Extensions that mark a `.local.` span as a file. Closed on * purpose: `.md` is also Moldova's TLD, but no internal suffix sits in front * of a public one, so after `.local` it can only be an extension. */ @@ -955,6 +1009,7 @@ export const PATTERNS: RedactPattern[] = [ regex: /\b([A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,})\b/, autoRedactable: true, redactToken: "", + validate: (span) => !looksLikeAtFilename(span), // Engine layers the email allowlist (example.com, noreply@, user's own, // repo-public authors) on top of this — see redact-engine.ts. }, @@ -969,12 +1024,14 @@ export const PATTERNS: RedactPattern[] = [ // A digit-only UUID's hyphen groups read as national phone formatting, and // so do a county tax-map parcel ID (see looksLikeParcelId), vector // coordinates (looksLikeDecimalCoordinates) and seed/nonce/timestamp JSON - // values (isNumericMetadataValue), and GitHub Actions run and job ids in - // their id positions (isCiRunIdentifier). + // values (isNumericMetadataValue), GitHub Actions run and job ids in + // their id positions (isCiRunIdentifier), and bare powers of two and + // hash/PRNG constants (isNumericConstant). validate: (span, match) => !insideUuid(match) && span.replace(/\D/g, "").length >= 10 && !looksLikeCompactTimestamp(span) && + !isNumericConstant(span) && !looksLikeParcelId(span, match) && !looksLikeDecimalCoordinates(span) && !isNumericMetadataValue(span, match) && diff --git a/package.json b/package.json index 652d5a31f..c7ed1288e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.312", + "version": "1.91.313", "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/redact-constant-and-at-filename.test.ts b/test/redact-constant-and-at-filename.test.ts new file mode 100644 index 000000000..794e6c778 --- /dev/null +++ b/test/redact-constant-and-at-filename.test.ts @@ -0,0 +1,97 @@ +/** + * Two MEDIUM false positives from the gstack-evals runs (gstack 1.91.299-301): + * pii.phone.e164 on `4294967296` (2^32, a PRNG constant) and pii.email on + * `terminal-bench@2.0.json` (a package@version filename). Each guard is + * pinned in both directions: the false positive is clean, and the real shape + * next to it still reports. + */ +import { describe, test, expect } from "bun:test"; +import { scan } from "../lib/redact-engine"; +import { isNumericConstant, looksLikeAtFilename } from "../lib/redact-patterns"; + +const flags = (id: string, s: string): boolean => + scan(s, { repoVisibility: "private" }).findings.some((f) => f.id === id); + +describe("pii.phone.e164 and bare numeric constants", () => { + // Value: protects=a bare power of two (or one less) and the common 32-bit hash/PRNG constants raise no phone finding; fails_when=the guard is + // removed and `4294967296` reports pii.phone.e164 again; why_new=no constant guard existed; seam=none (scan); no_claim=other constants not listed + test("powers of two, their max values and hash/PRNG constants are not phone numbers", () => { + for (const s of [ + "const RANGE = 4294967296;", + "mask = 4294967295", + "INT32_MAX 2147483647", + "x % 2147483648", + "1073741824 bytes", + "2^49 is 562949953421312", + "h *= 2654435761", + "seed ^= 2654435769", + "a = 1103515245", + "basis 2166136261", + "mt init 1812433253", + "p = 4294967291", + ]) { + expect([s, flags("pii.phone.e164", s)]).toEqual([s, false]); + } + expect(isNumericConstant("4294967296")).toBe(true); + expect(isNumericConstant("4294967297")).toBe(false); + }); + + // Value: protects=the same digits written as a phone number (a +, spaces, dashes, parens) and ordinary bare numbers still report; fails_when=the + // guard exempts by digits alone or by length, so +14294967296 or 429-496-7296 goes quiet; why_new=pins the narrowness of the new guard; + // seam=none (scan); no_claim=international formats beyond the ones listed + test("real phone numbers still report, including the constant's digits in phone form", () => { + for (const s of [ + "tel: +14294967296", + "call 429-496-7296", + "reach +1 429 496 7296", + "phone (429) 496-7296", + "p 429.496.7296", + "call 4294967297 now", + "ring +44 20 7946 0958", + "office 4155550123", + ]) { + expect([s, flags("pii.phone.e164", s)]).toEqual([s, true]); + } + }); +}); + +describe("pii.email and package@version filenames", () => { + // Value: protects=a filename with an @ (package@version, or a name ending in a file extension that is not a TLD) raises no email finding; + // fails_when=the guard is removed and terminal-bench@2.0.json reports pii.email again; why_new=no filename guard existed; seam=none (scan); + // no_claim=extensions outside the closed list + test("package@version and @-named files are not email addresses", () => { + for (const s of [ + "results in terminal-bench@2.0.json", + "npm pack wrote lodash@4.17.21.tgz", + "config eslint-config@next.yaml", + "cache react-dom@18.2.0.tar.gz", + "pin left-pad@1.3.0.lock", + "notes a@1.2.md", + ]) { + expect([s, flags("pii.email", s)]).toEqual([s, false]); + } + expect(looksLikeAtFilename("terminal-bench@2.0.json")).toBe(true); + }); + + // Value: protects=real addresses still report, including numeric domain labels (163.com, 1.2.com), TLDs that double as file extensions (.sh, .md, + // .py, .zip) and a file named after an address; fails_when=the guard treats any digit label, any extension-like TLD or any trailing extension as a + // filename; why_new=pins the narrowness of the new guard; seam=none (scan); no_claim=every IANA TLD + test("real addresses still report, numeric domains and extension-shaped TLDs included", () => { + for (const s of [ + "mail alex@acme-corp.com", + "reach li.wei@163.com", + "ops dev@acme.sh", + "docs ops@acme.md", + "py team@acme.py", + "files sales@acme.zip", + "contact jo@mail.acme.io", + "reach alice@1.2.com", + "export jane.doe@acme-corp.com.json", + "log at ops-team@acme.io.log", + "uploaded jdoe@acme-corp.com.PDF", + "archive li.wei@163.com.tar.gz", + ]) { + expect([s, flags("pii.email", s)]).toEqual([s, true]); + } + }); +}); From 0cab97447cd020d4b86c45e67f4a3bbb0b0335c3 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Thu, 29 Oct 2026 02:58:34 -0700 Subject: [PATCH 13/19] v1.91.314.0 feat(autoplan): carry the owner's answer forward, gate ELI5 field, stacked approvals import (#3583) * refactor: share the decision-trail append rules (lib/decision-trail.ts) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * feat(autoplan): carry the owner's answer to a later gate that asks nothing new, gate.json eli5, stacked approvals import A later gate revision with nothing new is answered with the owner's recorded words (CARRIED); a 'do it' also covers new auto items that change no scope, cost or direction; an unreviewed post-cap delta gets one cross-family reviewer first (CARRY_CHECK, gstack-autoplan check). gate.json gains eli5 and the Captain brief requires ## ELI5 on runs that carry it. gstack-approvals import --stack writes every branch or none. The first next prints REVIEW_ESTIMATE. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry-forward, ELI5 section and stacked approvals import Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): carry only fully owner-answered gates; void stale delta checks - a pending delta check is voided (reservation released) when the owner answers, the plan is revised, or a new gate is written; a check bound to an older delta is void - carry refuses a gate with any approval item still pending or answered in item tokens - "do it" never covers an option that changes the plan; isDoIt ignores Captain-relayed prefixes - captain brief rebuilds ELI5 from the current plan and lists items taken under "do it" - gstack-approvals import --stack locates every branch before writing any - DRIVER.md back under its 162-line budget Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry-forward conditions, delta-check voiding and import --stack match the final code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog for autoplan carry-forward (unreleased; stamped at merge) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.314.0 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 +- autoplan/DRIVER.md | 8 +- autoplan/DRIVER.md.tmpl | 8 +- bin/gstack-approvals | 43 ++- bin/gstack-autoplan | 31 +- bin/gstack-decision-trail | 60 +--- captain/templates/brief.md | 5 +- captain/templates/plan-ask.md | 3 + capy/skill/gstack/captain.md | 14 +- docs/captain.md | 13 +- docs/troubleshooting.md | 9 +- docs/unattended.md | 47 ++++ lib/approval-record.ts | 23 +- lib/autoplan-carry.ts | 252 +++++++++++++++++ lib/autoplan-gate.ts | 47 +++- lib/autoplan-revise.ts | 9 +- lib/autoplan-run.ts | 28 +- lib/autoplan-state.ts | 2 + lib/captain-brief.ts | 26 +- lib/decision-trail.ts | 67 +++++ lib/gate-list.ts | 22 +- lib/headless-artifacts.ts | 3 + lib/result-codes-capy.ts | 2 +- package.json | 2 +- test/approval-record.test.ts | 40 +++ test/autoplan-answer-invalidation.test.ts | 4 +- test/autoplan-carry.test.ts | 327 ++++++++++++++++++++++ test/captain-brief.test.ts | 2 +- test/capy-captain-flow.test.ts | 4 + 31 files changed, 999 insertions(+), 121 deletions(-) create mode 100644 lib/autoplan-carry.ts create mode 100644 lib/decision-trail.ts create mode 100644 test/autoplan-carry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 472e3a1fb..8964d4a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.91.314.0] - 2026-10-29 + +**A later autoplan gate that asks nothing new is now answered with the owner's earlier words instead of another round trip.** Captains were carrying approvals across gate revisions by hand (GSTA-140 reused a rev-1 "Do it" on rev 2; ALPH-50 lost about 27 minutes to one), and a replay over 23 recorded runs found 14 later gates, 8 of them holding only post-cap delta items. + +### Added +- **Carry-forward** (`lib/autoplan-carry.ts`): when gate_rev N+1 asks nothing new and gate_rev N was fully answered by the owner with `rec` or "do it", gstack answers N+1 with those recorded words and prints `CARRIED: gate_rev= <- gate_rev= …`; anything else prints `CARRY: no ()` and goes to the owner. `--no-carry-forward` on the first `next` turns it off. +- **"do it" covers new low-risk items.** When the owner's own words were "do it", a new item that changes no scope, cost, direction or plan text takes its recommendation, is marked `carried_under: do it`, and is listed in the approval record and the Captain brief as not shown to the owner. A Captain's "do it" never counts. +- **Delta check.** A "plan changed after cap" item gets one reviewer from another model family on the delta first (`CARRY_CHECK:`, `gstack-autoplan check`), within a per-gate spend cap (`--carry-check-cap`, default $5). High or above, no reviewer, an owner answer, or a plan edit after dispatch leaves the gate for the owner and releases the reservation. The verdict is recorded on `gate.json`, the decision rows and `decisions.tsv`. +- **`eli5` in gate.json**: the plan's `## ELI5` and the four approvals-shape sections; `gstack-gate render` opens page 1 with them, and the Captain brief requires `## ELI5` (older runs render as before). +- **`gstack-approvals import --autoplan --stack b1,b2`** writes the same rows to every branch of a stack, or to none when one branch is unknown. +- **`REVIEW_ESTIMATE:`** on the first `next`: phases, voices, rounds, attempts and the reservation total. + +### Changed +- `lib/decision-trail.ts` holds the trail append rules shared by `gstack-decision-trail` and the carry. + ## [1.91.313.0] - 2026-10-29 **The PII scanner stops flagging two kinds of ordinary text: a bare power-of-two or hash constant in code, and a package@version-style filename.** Eval artifacts and PR bodies that print a PRNG range or name a versioned results file no longer raise a MEDIUM phone or email finding, and real phone numbers and addresses next to them still report. diff --git a/VERSION b/VERSION index f64064728..5ecd487b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.313.0 +1.91.314.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index a043d5f41..7e4680d3e 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.313.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.314.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/autoplan/DRIVER.md b/autoplan/DRIVER.md index f1e1a3f96..875ea1068 100644 --- a/autoplan/DRIVER.md +++ b/autoplan/DRIVER.md @@ -26,7 +26,7 @@ line yourself; gstack never writes the plan. 1. `next` prints one `ATTEMPT: phase=

voice=native|outside prompt= result=` per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to - a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. + a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take). 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=`); render it with `gstack-gate render

/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`). You never approve the gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. @@ -38,6 +38,7 @@ line yourself; gstack never writes the plan. 5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, relative). 6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below High prints `APPLIED: without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round. +7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). ### On Capy @@ -51,9 +52,8 @@ line yourself; gstack never writes the plan. with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a generation is published at rest, after each bound voice and after each phase transition; restore with `resume --out --from /`, which never redispatches a bound voice. -- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; - relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), - then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`). +- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; + relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan ` after `answer` (`--stack ` for a stack; `capy/skill/gstack/captain.md`). ### Commands diff --git a/autoplan/DRIVER.md.tmpl b/autoplan/DRIVER.md.tmpl index b2ff2b001..9b3ed3765 100644 --- a/autoplan/DRIVER.md.tmpl +++ b/autoplan/DRIVER.md.tmpl @@ -23,7 +23,7 @@ line yourself; gstack never writes the plan. 1. `next` prints one `ATTEMPT: phase=

voice=native|outside prompt= result=` per voice. Dispatch the native prompt to a subagent on your model and the outside prompt to - a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. + a subagent on another model family (or `gstack-outside-voice run`). Each writes `result=`. The first `next` also prints `REVIEW_ESTIMATE:` (most attempts and reservations the review can take). 2. Submit each voice exactly once (`BOUND:`). Both voices in closes the phase (`PHASE_CLOSED:`). 3. Repeat `next` until `status=gate_pending` (`GATE: gate_rev=`); render it with `gstack-gate render

/gate.json --all` and relay every page with the two cost lines printed under `GATE:`: `GATE_SPEND:` (spent, worst case, cap, headroom, attempts; host-subagent attempts are priced unknown and counted at their reservation, so quote their real figure from the host's cost view) and `GATE_REC_COST:` (the phases `rec` can reopen and the estimate for them, an upper bound; `gate_cost` in `--json`). You never approve the gate for the owner. Offer `rec` (every recommendation, plan changes included) as the default. @@ -35,6 +35,7 @@ line yourself; gstack never writes the plan. 5. `REOPENED: ` means loop `next` again (Eng last). `APPROVED:` means `export` (`DISPATCHES: est_input_tokens=`, relative). 6. Rounds: one by default; a second only for High or Critical findings; cap 2 (`--max-rounds`). A `revise` whose decisions all resolve below High prints `APPLIED: without re-review (below High)`. At the cap with High open, `ESCALATE: evidence sufficient, decision needed`: relay it, never ask for another round. +7. Carry-forward ([docs/unattended.md#carry-forward](../docs/unattended.md#carry-forward); `--no-carry-forward` on the first `next` opts out): a later gate that asks nothing new takes the owner's words from the gate before it (`CARRIED:`, then `APPROVED:`); `CARRY: no ()` leaves it for the owner; `CARRY_CHECK:` means one reviewer from another model family reads the printed delta prompt, then `check --out --attempt --result --model ` (or `--unavailable ""`). ### On Capy @@ -48,9 +49,8 @@ line yourself; gstack never writes the plan. with `gstack-config set autoplan_checkpoint drive`) so a run survives machine replacement: a generation is published at rest, after each bound voice and after each phase transition; restore with `resume --out --from /`, which never redispatches a bound voice. -- Plans carry `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; - relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), - then `gstack-approvals import --autoplan ` after `answer` (`capy/skill/gstack/captain.md`). +- Plans carry `## ELI5` (5-10 plain sentences), `## Before → after`, `## Why it matters`, `## What caused this work`, `## Big decisions`; + relay the printed `RELAY: gstack-owner-brief render --captain` output to your Captain (or the person), then `gstack-approvals import --autoplan ` after `answer` (`--stack ` for a stack; `capy/skill/gstack/captain.md`). ### Commands diff --git a/bin/gstack-approvals b/bin/gstack-approvals index da97642a9..3d3d5a9ef 100755 --- a/bin/gstack-approvals +++ b/bin/gstack-approvals @@ -5,7 +5,7 @@ * gstack-approvals add --ask --words --by --scope * [--pending ] [--outcome approved|rejected|pending|standing] * [--date YYYY-MM-DD] [--source ] [--branch ] [--json] - * gstack-approvals import --autoplan [--by ] [--branch ] [--json] + * gstack-approvals import --autoplan [--by ] [--branch | --stack ] [--json] * gstack-approvals render [--no-heading] [--branch ] * gstack-approvals list [--json] [--branch ] * gstack-approvals sync [--branch ] @@ -20,14 +20,15 @@ */ import * as path from 'node:path'; import { EXIT, exitTableHelp } from '../lib/headless-artifacts'; -import { appendRows, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record'; +import { appendRows, branchExists, buildRow, importAutoplan, locate, readRecord, renderRecord, syncRecord, type RecordError, type StoreLocation } from '../lib/approval-record'; import { resultLine } from '../lib/result-codes'; const HELP = `gstack-approvals — the per-thread approval record (capy/skill/gstack/captain.md) add --ask --words --by --scope [--pending ] [--outcome approved|rejected|pending|standing] [--date YYYY-MM-DD] [--source ] - import --autoplan [--by ] + import --autoplan [--by ] [--stack ] + --stack writes the same rows to every branch of a stack; each branch is checked first, and one unknown branch writes none render [--no-heading] the ## Approvals section for a PR body or report list [--json] sync re-mirror the record (GSTACK_APPROVALS_DIR, the Captain's drive approvals/, or the project-user Drive) @@ -40,7 +41,7 @@ Example: ${exitTableHelp()}`; -const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan']); +const VALUE_FLAGS = new Set(['--ask', '--words', '--by', '--scope', '--pending', '--outcome', '--date', '--source', '--branch', '--autoplan', '--stack']); function parse(args: string[]): { flags: Record; bools: Set; error?: string } { const flags: Record = {}; @@ -64,11 +65,45 @@ function fail(cmd: string, e: RecordError): number { return EXIT.refused; } +/** `import --stack`: every branch checked first, then the same rows (ids are branch-independent) written to each branch's record. */ +function importStack(cmd: string, flags: Record, json: boolean): number { + if (cmd !== 'import' || !flags['--autoplan'] || flags['--branch'] !== undefined) { process.stderr.write(`gstack-approvals: --stack goes with import --autoplan , without --branch\n`); return EXIT.usage; } + const branches = [...new Set(flags['--stack']!.split(',').map(b => b.trim()).filter(Boolean))]; + if (!branches.length) { process.stderr.write(`gstack-approvals: --stack needs \n`); return EXIT.usage; } + const cwd = process.cwd(); + const missing = branches.filter(b => !branchExists(cwd, b)); + if (missing.length) return fail(cmd, { code: 'APPROVAL_RECORD_NO_BRANCH', message: `no local or origin branch ${missing.join(', ')}; nothing was written` }); + const runDir = path.resolve(flags['--autoplan']); + const results: Array<{ branch: string; added: string[]; skipped: number; file: string; mirror: string | null; error?: string }> = []; + let firstError: RecordError | undefined; + const plans: Array<{ branch: string; loc: StoreLocation; rows: ReturnType & unknown[] }> = []; + for (const branch of branches) { + const loc = locate({ cwd, env: process.env, branch }); + if ('code' in loc) return fail(cmd, loc); + const rows = importAutoplan(runDir, branch, flags['--by']); + if ('code' in rows) return fail(cmd, rows); + plans.push({ branch, loc, rows }); + } + for (const { branch, loc, rows } of plans) { + const res = appendRows(loc, rows); + if ('code' in res) { firstError ??= res; results.push({ branch, added: [], skipped: 0, file: loc.file, mirror: loc.mirror ?? null, error: res.message }); continue; } + if (res.mirrorError) firstError ??= res.mirrorError; + results.push({ branch, added: res.added.map(r => r.id), skipped: res.skipped, file: loc.file, mirror: loc.mirror ?? null, ...(res.mirrorError ? { error: res.mirrorError.message } : {}) }); + } + if (json) process.stdout.write(JSON.stringify({ branches: results }) + '\n'); + else { + for (const r of results) process.stdout.write(`APPROVAL_IMPORTED: branch=${r.branch} n=${r.added.length} skipped=${r.skipped} ${r.file}${r.mirror ? ` mirror=${r.mirror}` : ''}${r.error ? ` error=${r.error}` : ''}\n`); + process.stdout.write(`APPROVAL_IMPORTED_STACK: branches=${results.length} added=${results.reduce((n, r) => n + r.added.length, 0)}\n`); + } + return firstError ? fail(cmd, firstError) : EXIT.ok; +} + function main(argv: string[]): number { const [cmd, ...rest] = argv; if (!cmd || cmd === '--help' || cmd === '-h') { process.stdout.write(HELP + '\n'); return cmd ? EXIT.ok : EXIT.usage; } const { flags, bools, error } = parse(rest); if (error) { process.stderr.write(`gstack-approvals: ${error}\n${HELP}\n`); return EXIT.usage; } + if (flags['--stack'] !== undefined) return importStack(cmd, flags, bools.has('--json')); const loc = locate({ cwd: process.cwd(), env: process.env, branch: flags['--branch'] }); if ('code' in loc) return fail(cmd, loc); const json = bools.has('--json'); diff --git a/bin/gstack-autoplan b/bin/gstack-autoplan index 0b0a1c45b..c42062867 100755 --- a/bin/gstack-autoplan +++ b/bin/gstack-autoplan @@ -19,13 +19,14 @@ * gstack-autoplan release --out --attempt drop a reservation for an attempt that never ran * gstack-autoplan close --out --phase

--deferred "" --by > close a phase accepted-with-findings * gstack-autoplan budget --out

[--spend-cap ] [--max-rounds ] --by > the owner's journaled cap change + * gstack-autoplan check --out --attempt --result --model | --unavailable the delta check before a carry * gstack-autoplan checkpoints --from list checkpoint generations, newest first, with hash ok|bad * gstack-autoplan contract print the coordinator contract every lane prompt inherits (plan E2) * * Logic: lib/autoplan-run.ts (next/submit/resume), lib/autoplan-gate.ts (close, gate, answer), * lib/autoplan-export.ts, lib/autoplan-prompts.ts, lib/spend-ledger.ts. Codes: lib/result-codes.ts. * Workflow commands end with GSTACK_RESULT: skill=autoplan status= run=; the run is never approved here. - * Mutating verbs (next, submit, answer, recommend, revise, close, budget, export, run) pass admission first (lib/autoplan-admission.ts) + * Mutating verbs (next, submit, answer, recommend, revise, close, budget, check, export, run) pass admission first (lib/autoplan-admission.ts) * and bring an older run directory forward (lib/autoplan-migrate.ts) under the run lock. */ import * as fs from 'node:fs'; @@ -33,6 +34,7 @@ import * as path from 'node:path'; import { amendmentSteps, answerGate, closeDeferred, finishGate, recommendGate } from '../lib/autoplan-gate'; import { gateCost, gateCostPayload, renderGateCost, type GateCost } from '../lib/autoplan-gate-cost'; import { attemptEstimate, changeBudget, escalateLine, liftStop } from '../lib/autoplan-budget'; +import { DEFAULT_CARRY_CHECK_CAP_USD, carryLines, submitCarryCheck } from '../lib/autoplan-carry'; import { ANSWERED_BY_RE } from '../lib/gate-list'; import { checkpointLines, checkpointRun, restoreCheckpoint, type CheckpointMoment } from '../lib/autoplan-checkpoint'; import { admit, touchInstallLease } from '../lib/autoplan-admission'; @@ -60,6 +62,9 @@ DRIVER: ${driverFile()} --estimate-usd: reserved per new attempt; unattended Capy runs reserve a default for host attempts so --spend-cap binds --scaffold (first next): a plan missing one of its two sections is reviewed from /plan-source.md with the heading added --light loads each phase's checklist plus the sections its scope needs (the unattended default); interactive kinds load everything + --no-carry-forward (first next): every later gate revision goes to the owner, even one that asks nothing new + --carry-check-cap (first next): the most one delta check may reserve before a carry (default ${DEFAULT_CARRY_CHECK_CAP_USD.toFixed(2)}) + the first next prints REVIEW_ESTIMATE: the most the review can reserve at this scope and round cap prepare the current phase; one ATTEMPT: line per voice; ends awaiting_result submit --out --phase

--voice native|outside --result | --record [--attest ] --model [--attempt ] [--runner ] [--usage-usd ] [--reconcile ] [--json] @@ -84,6 +89,9 @@ DRIVER: ${driverFile()} resume --out

[--from ] [--json] --from restores a hash-verified checkpoint into an empty --out first (a collection or root restores its newest valid generation; GSTACK_AUTOPLAN_CHECKPOINT_DIR set: every command leaving the run at rest publishes a generation under // and prints CHECKPOINT: on stderr) + check --out --attempt --result --model [--same-family ""] | --unavailable "" + bind the one-reviewer delta check a CARRY_CHECK: line asked for: nothing at High or above carries the owner's + last answer (CARRIED:); otherwise, or when unavailable, the gate goes to the owner as before checkpoints --from list checkpoint generations, newest first: run, gate_rev, time, hash ok|bad, current close --out --phase

--deferred "" --by > close the phase accepted-with-findings: open voices skipped (dispatched attempts settle unknown at their @@ -180,7 +188,10 @@ function doNext(out: string, rest: string[], json: boolean): number { const ui = rest.includes('--ui') ? true : rest.includes('--no-ui') ? false : undefined; const maxRounds = num(rest, '--max-rounds'); if (maxRounds !== undefined && (!Number.isInteger(maxRounds) || maxRounds < 1)) throw new RunError('ARTIFACT_SCHEMA', '--max-rounds needs an integer >= 1', 1); + const carryCheckCapUsd = num(rest, '--carry-check-cap'); + if (carryCheckCapUsd !== undefined && !(carryCheckCapUsd >= 0)) throw new RunError('ARTIFACT_SCHEMA', '--carry-check-cap needs a number >= 0', 1); initRun({ plan, out, cwd, planHeading: flag(rest, '--plan-heading'), outsideEnabled: !rest.includes('--no-outside'), spendCap: num(rest, '--spend-cap') ?? null, maxRounds, + carryForward: !rest.includes('--no-carry-forward'), carryCheckCapUsd, deadlineMin: num(rest, '--deadline'), light: rest.includes('--light'), scaffold: rest.includes('--scaffold'), scope: { ui, developerTool: rest.includes('--developer-tool'), agentPrimary: rest.includes('--agent-primary'), dx: rest.includes('--no-dx') ? false : undefined, ...(rest.includes('--docs-only') ? { docsOnly: true } : {}) } }, initLines); } const redispatch = rest.flatMap((a, i) => (a === '--redispatch' && rest[i + 1] ? [rest[i + 1]!] : [])); @@ -257,7 +268,7 @@ function doClose(out: string, rest: string[], json: boolean): number { const r = closeDeferred(state, phase as Phase, { reason: reason.trim(), by, cause: 'owner' }); moment = 'transition'; const lines = [...r.lines, ...(stopped ? liftStop(state, `${phase} closed deferred by ${by}`) : [])]; - if (!currentPhase(state)) { finishGate(state); lines.push(...gateLines(state, out)); } + if (!currentPhase(state)) { finishGate(state); lines.push(...carryLines(state), ...(state.status === 'gate_pending' ? gateLines(state, out) : [])); } else { state.status = 'running'; lines.push(`NEXT: gstack-autoplan next --out ${out}`); } writeState(state); lines.push(resultLineFor(state.status, out)); @@ -265,6 +276,19 @@ function doClose(out: string, rest: string[], json: boolean): number { return EXIT.ok; } +/** `check`: the delta reviewer's verdict before a carry (lib/autoplan-carry.ts). */ +function doCheck(out: string, rest: string[], json: boolean): number { + const unavailable = flag(rest, '--unavailable'); + const result = flag(rest, '--result'); + if (unavailable === undefined && (!result || !flag(rest, '--model'))) return usage('check needs --attempt and --result --model , or --unavailable ""'); + const r = submitCarryCheck({ out, attempt: flag(rest, '--attempt'), result: result ? path.resolve(result) : undefined, model: flag(rest, '--model'), unavailable, sameFamily: flag(rest, '--same-family') }); + moment = 'transition'; + const status = r.state.status; + const lines = [...r.lines, ...(status === 'gate_pending' ? gateLines(r.state, out) : []), ...(status === 'amend_pending' || status === 'running' ? [nextLine(r.state, out)] : []), resultLineFor(status, out)]; + emit(json, lines, { run: r.state.run, status, gate_rev: r.state.gate_rev ?? null, carry: r.state.carry ?? null, out }); + return EXIT.ok; +} + function doBudget(out: string, rest: string[], json: boolean): number { const state = readState(out); const lines = changeBudget(state, { spendCap: num(rest, '--spend-cap'), maxRounds: num(rest, '--max-rounds'), by: flag(rest, '--by') }); @@ -338,6 +362,7 @@ async function main(argv: string[]): Promise { if (cmd === 'revise') return mutating(out, json, () => doRevise(out, rest, json)); if (cmd === 'close') return mutating(out, json, () => doClose(out, rest, json)); if (cmd === 'budget') return mutating(out, json, () => doBudget(out, rest, json)); + if (cmd === 'check') return mutating(out, json, () => doCheck(out, rest, json)); if (cmd === 'run') { const admission = admit(process.env); if (admission.lines.length) process.stdout.write(admission.lines.join('\n') + '\n'); return await doRun(out, rest, json); } if (cmd === 'status') { const state = readState(out); @@ -416,7 +441,7 @@ async function main(argv: string[]): Promise { if (e instanceof RunError) { process.stderr.write(resultLine(`gstack-autoplan ${cmd}`, e.code, e.detail) + '\n'); if (e.after.lines?.length) process.stdout.write(e.after.lines.join('\n') + '\n'); - if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n'); + if (['next', 'submit', 'answer', 'resume', 'run', 'revise', 'recommend', 'close', 'budget', 'check'].includes(cmd)) process.stdout.write(resultLineFor(e.after.status ?? 'refused', out) + '\n'); return e.exit === 3 ? EXIT.refused : EXIT.fail; } process.stderr.write(`gstack-autoplan ${cmd}: ${e?.message ?? e}\n`); diff --git a/bin/gstack-decision-trail b/bin/gstack-decision-trail index 297c6aee4..750b00da8 100755 --- a/bin/gstack-decision-trail +++ b/bin/gstack-decision-trail @@ -17,7 +17,8 @@ * and a cell whose first byte a spreadsheet would run as a formula (=, +, -, @) * gets a leading apostrophe, so evidence taken from PR titles, filenames or * generated text cannot execute when a reviewer opens the file. License text: - * licenses/MIT-pstack.txt; NOTICE.md carries the entry. + * licenses/MIT-pstack.txt; NOTICE.md carries the entry. The append rules live in + * lib/decision-trail.ts, shared with the autoplan runner. * * The start-row rule is gstack's mechanisation of the same skill's prose: a run * (one agent conversation, with its later turns) that writes to a trail already @@ -26,11 +27,8 @@ * id, and an append with `--run ` refuses until that run's start row is the * newest start row in the file (DECISION_TRAIL_START_REQUIRED, exit 3). */ -import * as fs from "node:fs"; -import * as path from "node:path"; +import { appendTrailRow as appendRow, readTrailRows as readRows, type TrailRow as Row } from "../lib/decision-trail"; -const COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const; -const HEADER = COLUMNS.join("\t") + "\n"; const USAGE = `Usage: gstack-decision-trail [--run ] gstack-decision-trail start @@ -56,58 +54,6 @@ letters, digits and . _ : @ / - (a Capy jam_ id, a session id, a PR number). when the trail has rows and the newest start row is another run's. `; -type Row = { line: number; cells: string[] }; - -function clean(cell: string): string { - const flat = cell.replace(/[\t\n\r]/g, " "); - return /^[=+\-@]/.test(flat) ? `'${flat}` : flat; -} - -function readRows(file: string): Row[] { - if (!fs.existsSync(file)) return []; - const lines = fs.readFileSync(file, "utf8").split("\n"); - const rows: Row[] = []; - lines.forEach((raw, i) => { - let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw; - if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1); - // A repeated header (the append-on-flaky-mount case) is never a row. - if (text === "" || text === HEADER.trimEnd()) return; - rows.push({ line: i + 1, cells: text.split("\t") }); - }); - return rows; -} - -function appendRow(file: string, cells: readonly string[]): number { - const dir = path.dirname(file); - if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); - // Append the header too, never truncate: on a flaky mount the worst case is - // one stray header line, not lost rows. - // A file with no header line anywhere (empty, blank, or rows written by hand) - // gets one appended once; readRows skips a header line wherever it sits. - const size = fs.existsSync(file) ? fs.statSync(file).size : 0; - if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n"); - if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, HEADER); - const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); - fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n"); - return readRows(file).length; -} - -function hasHeader(file: string): boolean { - return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n") - .some(line => line.replace(/\r$/, "") === HEADER.trimEnd()); -} - -function endsWithNewline(file: string, size: number): boolean { - const fd = fs.openSync(file, "r"); - try { - const last = Buffer.alloc(1); - fs.readSync(fd, last, 0, 1, size - 1); - return last[0] === 0x0a; - } finally { - fs.closeSync(fd); - } -} - const RUN_ID = /^[A-Za-z0-9._:@/-]+$/; function checkRunId(runId: string): void { if (!RUN_ID.test(runId)) fail(`run id must match ${RUN_ID} (letters, digits, . _ : @ / -), got ${JSON.stringify(runId)}`, 2); diff --git a/captain/templates/brief.md b/captain/templates/brief.md index d185bc0bf..1f6b0decb 100644 --- a/captain/templates/brief.md +++ b/captain/templates/brief.md @@ -14,7 +14,7 @@ Spend cap: 1. Plan (when Plan: required). After the session-start install check, install for autoplan and follow autoplan/DRIVER.md "On Capy" (launch file sourced, GSTACK_AUTOPLAN_CHECKPOINT_DIR - set, run dir under ~/.capy/work). Write the plan with its ## Before → after, + set, run dir under ~/.capy/work). Write the plan with its ## ELI5, ## Before → after, ## Why it matters, ## What caused this work, ## Big decisions and ## Implementation plan sections, then run gstack-autoplan next --out --plan sized to this brief (a one-file fix: --no-ui --no-dx --max-rounds 1 --estimate-usd @@ -25,7 +25,8 @@ Spend cap: (gstack-autoplan answer --out --gate-rev --reply --rec-hash --by agent: --owner-reply ""), incorporate every accepted plan change (revise), finish every reopened phase, and start product edits only when the run - reports APPROVED. Then gstack-approvals import --autoplan --by "". + reports APPROVED (a later gate that asks nothing new prints CARRIED: and needs no second + ask). Then gstack-approvals import --autoplan --by "" (--stack b1,b2 for a stack). New scope you take on part-way goes back to step 1 with its own plan. 3. Ship. CHANGELOG entry under [Unreleased]; GUARD_CHECKS, pregate and /ship at the final head; one unstamped READY block (captain/templates/READY.md) to me; the PR body carries diff --git a/captain/templates/plan-ask.md b/captain/templates/plan-ask.md index fb298017f..854998ffc 100644 --- a/captain/templates/plan-ask.md +++ b/captain/templates/plan-ask.md @@ -7,6 +7,9 @@ shape from an autoplan run; write it by hand for any other plan. ```text Decision needed on : . Reply **rec** to take every recommendation. +## ELI5 +<5-10 plain sentences: what changes, for whom, and what it costs> + ## Before → after diff --git a/capy/skill/gstack/captain.md b/capy/skill/gstack/captain.md index d403cbe55..a33dba03d 100644 --- a/capy/skill/gstack/captain.md +++ b/capy/skill/gstack/captain.md @@ -48,6 +48,7 @@ gstack-approvals add --ask "Spend up to \$40 on a paid eval rerun for the ship c gstack-approvals add --outcome standing --ask "Merge #3330 when green" \ --words "yes merge when green" --by "Garry, 2026-10-16 (Captain rule)" --scope "every crew PR, through a train" gstack-approvals import --autoplan --by Garry # after gstack-autoplan answer; idempotent +gstack-approvals import --autoplan --by Garry --stack b1,b2 # a stacked PR set: every branch, or none gstack-approvals render # the ## Approvals section ``` @@ -93,15 +94,15 @@ project can follow it: the plan names (docs, trains, cleanups) included, and carry them through to done with no further ask. Say in one line what it covers. For an autoplan gate, "do it" answers as `rec`; record the words. -For an autoplan run, write parts 1 to 4 as `## Before → after`, `## Why it matters`, -`## What caused this work` and `## Big decisions` sections in the plan. Then, at the gate: +For an autoplan run, open the plan with `## ELI5` (5-10 plain sentences), then write parts 1 to 4 as +`## Before → after`, `## Why it matters`, `## What caused this work` and `## Big decisions` sections. Then, at the gate: ```bash gstack-owner-brief render --captain --out ``` -It prints a header line ("Decision needed … Reply **rec** …"), the four plan sections (each capped, with a -pointer to the plan for the rest), and "What we need from you", which lists every choice `rec` takes, in +It prints a header line ("Decision needed … Reply **rec** …"), the `### ELI5` summary (on a run whose gate +carries `eli5`), the four plan sections (each capped, with a pointer to the plan for the rest), and "What we need from you", which lists every choice `rec` takes, in words, and closes with the "do it" reply, then a `### Cost` section (spent so far, worst case, and the estimate for the phases `rec` can reopen). The last line, "For the relaying agent", carries the run, `gate_rev`, `rec_hash` and the exact `gstack-autoplan answer` command. Relay everything above that line. When the reply comes back: @@ -112,6 +113,11 @@ gstack-autoplan answer --out --gate-rev --reply rec --rec-hash gstack-approvals import --autoplan --by ``` +A later gate that asks nothing new is answered for you with the words you recorded (`CARRIED:`, then `APPROVED:`); +the brief says "Nothing pending. Carried forward", so import and build without a second ask. `CARRY_CHECK:` means +one reviewer from another model family reads the unreviewed delta first (`gstack-autoplan check`). Anything +else prints `CARRY: no ()` and goes to the person as today (`docs/unattended.md#carry-forward`). + If the person answers in words ("split it into two PRs"), map the words to tokens, answer, and confirm in one line what you recorded ("Recorded: split into two PRs; everything else as recommended"). A plan missing a section renders "Not ready for approval" with no reply invitation, and exits 1 (`CAPTAIN_BRIEF_INCOMPLETE`): diff --git a/docs/captain.md b/docs/captain.md index 7c0b7aadf..e955acfb7 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -138,8 +138,9 @@ this rule shipped cites this section by name. **What the lane sends.** The plan and its gate in plain language a person outside the project can follow: what changes, why now, what the change does, what it costs, the lane's pick, and the recommended one-word reply. `gstack-owner-brief render --captain --out ` -prints that from the plan's `## Before → after`, `## Why it matters`, -`## What caused this work` and `## Big decisions` sections, with the cost lines +prints that from the plan's `## ELI5`, `## Before → after`, `## Why it matters`, +`## What caused this work` and `## Big decisions` sections (also saved as `eli5` in +`gate.json`, so `gstack-gate render` opens with them), with the cost lines (`GATE_SPEND:`, `GATE_REC_COST:`) and a last line for the controller; a plan missing a section exits `CAPTAIN_BRIEF_INCOMPLETE`, so the lane adds the section and renders again ([capy/skill/gstack/captain.md](../capy/skill/gstack/captain.md) § 3 lists them). The lane @@ -156,10 +157,12 @@ controller and been answered into the run every accepted plan change has been incorporated (`revise`), every reopened phase has finished, and the run reports `APPROVED` (`status=complete` after `export`). An answered gate that still prints `PLAN_AMEND_PENDING` or `REOPENED:` is not a completed plan, and an -imported approval row alone is not completion. +imported approval row alone is not completion. A later gate revision that asks nothing new +is answered with the owner's recorded words and says `CARRIED:`; that is the owner's word, +not a new ask ([carry-forward](unattended.md#carry-forward)). -**The record.** `gstack-approvals import --autoplan --by ""` writes the -approval row (date, the ask, the owner's exact words, the lane that answered as relayer, +**The record.** `gstack-approvals import --autoplan --by ""` (with +`--stack ` for a stacked PR set) writes the approval row (date, the ask, the owner's exact words, the lane that answered as relayer, what it covers); `gstack-approvals render` puts it in the report and the PR body. Before training the PR the controller compares that row's words and `gate_rev` with the ask it relayed and the reply it carried back; a row that does not match is not READY (shape A). diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index aec8622f1..82e666d05 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -1737,8 +1737,11 @@ restate them. Rotate the secret if it was real. **Meaning.** The record is kept per branch (one Capy thread ships one branch), and HEAD is detached or the working directory is not a git repository. Exit 3. +From `import --autoplan

--stack ` it means a listed branch exists +neither locally nor on origin; the line names it, and no branch's record was written. -**Fix.** Check out the branch the work ships from, or pass `--branch `. +**Fix.** Check out the branch the work ships from, or pass `--branch `. For +`--stack`, correct or fetch the named branch and import again. ### `gstack-approvals: ... (APPROVAL_RECORD_UNAVAILABLE)` @@ -1777,7 +1780,9 @@ import again. Imports are idempotent, so a retry never records an answer twice. ### `gstack-owner-brief render --captain: ... (CAPTAIN_BRIEF_INCOMPLETE)` **Meaning.** The plan is missing at least one section the brief needs: `## Before → after`, -`## Why it matters`, `## What caused this work`, `## Big decisions`. The brief still prints, +`## Why it matters`, `## What caused this work`, `## Big decisions`, and `## ELI5` (alias +`## In plain words`) on a run whose `gate.json` carries an `eli5` field (gstack 1.91.305 and +later; older runs render as before). The brief still prints, but it opens with "Not ready for approval" and has no reply invitation. Exit 1. **Fix.** Add the named sections to the plan (`gstack-autoplan revise --plan ` if diff --git a/docs/unattended.md b/docs/unattended.md index 8ebbc5791..f4ed5bfdd 100644 --- a/docs/unattended.md +++ b/docs/unattended.md @@ -495,6 +495,53 @@ by its runner. Result codes: `EXECUTION_UNKNOWN`, `RUN_LOCKED`, decision ids are renamed and journaled); one written by a newer gstack is refused. +### Carry-forward + +A gate revision after the first (`gate_rev` 2 and later, written after a +`revise`) is answered with the owner's recorded words from the revision before +it when nothing new needs their decision. `gstack-autoplan` prints +`CARRIED: gate_rev= <- gate_rev= words="" by= reason=`, +then the usual `APPROVED:` lines, and journals `gate_carried`. The carry needs +all of: the earlier gate approved (`p1a` or `p1b`) with `rec` or "do it" +(not item tokens), no item there left pending, and the owner's words +recorded; that gate was not itself carried; every accepted change incorporated +or settled; the plan file's bytes equal those recorded at the gate or at the +`revise` that took the changes (any later edit refuses, an ELI5-only one +included); no urgent or +outside-plan finding open; and every new gate item one of the cases below. +Anything else prints `CARRY: no ()` (journal `carry_refused`) and the gate +goes to the owner as before. `--no-carry-forward` on the first `next` turns it +off for the run. + +- **"do it".** When the owner's words were "do it" (their words, not a + `captain:` relay of them), a new `auto` item that changes no scope, cost or + direction, whose recommended option changes no plan text, and is no round + or budget grant takes its recommendation. The + decision row gets `carried_under: do it`, the output prints + `CARRIED_UNDER_DO_IT:` per item, and the approval record lists them as + "taken under "do it", not seen by the owner". `rec` carries no new item. +- **Unreviewed post-cap delta.** A "plan changed after cap" item + recommending `a` (accept) is read by one reviewer before it carries. `CARRY_CHECK:` names the attempt and + the prompt (the delta against `plan-at-gate.r.md`, the accepted changes, + the full plan); the run reserves the estimate in `spend.json` and refuses + when it is over `--carry-check-cap ` (default 5) or the run's cap. + Dispatch it to a model family other than the plan reviewers', then + `check --out --attempt --result --model ` + (`--same-family ""` when no other family is available). A High or + Critical finding, or `--unavailable ""`, leaves the gate for the + owner. The verdict is saved as `carry_check` on `gate.json`, on the + decision rows (`carry_check`) and in `/decisions.tsv`. A pending + check is void, its reservation released and the gate left for the owner, + when the owner answers the gate, the plan is revised, a new gate is + written, or the delta changed after dispatch. + +Carried rows keep `answered_by: agent:gstack-autoplan` and name their source +(`carried_gate_from`, `carried_gate_rev`, `carried_answered_by`); +`gstack-approvals import --autoplan` writes the carried gate as its own row, +and `gstack-owner-brief render --captain` opens with "Nothing pending. +Carried forward from gate_rev ", followed by any items taken under "do it" +without being shown to the owner. + ## Owner brief `gstack-owner-brief render --out ` is what the owner sees at the final diff --git a/lib/approval-record.ts b/lib/approval-record.ts index ed8bcf738..38d798682 100644 --- a/lib/approval-record.ts +++ b/lib/approval-record.ts @@ -52,6 +52,11 @@ export type Env = NodeJS.ProcessEnv; const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 10_000 }); +/** A branch this checkout knows: a local head or an origin remote-tracking ref. */ +export function branchExists(cwd: string, branch: string): boolean { + return [`refs/heads/${branch}`, `refs/remotes/origin/${branch}`].some(ref => git(cwd, ['rev-parse', '--verify', '--quiet', ref]).status === 0); +} + /** One contained path segment for a branch: readable prefix plus a hash of the exact ref. */ export function branchKey(branch: string): string { const readable = branch.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[.-]+/, '').slice(0, 60) || 'branch'; @@ -226,13 +231,15 @@ export function renderRecord(read: ReadResult, loc: StoreLocation, o: { heading? return [...head, ...lines, '', `Source: gstack-approvals record for ${cell(loc.branch)} (display only; not an approval citation).`, ''].join('\n'); } -interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string } +interface DecisionLike { id?: string; title?: string; status?: string; chosen?: string | null; recommended?: string | null; gate_rev?: number; answered_at?: string; answered_by?: string; reply?: string; owner_reply?: string; carried_from?: string; label?: string; chosen_text?: string; + carried_gate_from?: string; carried_gate_rev?: number; carried_answered_by?: string; carried_under?: string; carry_check?: string } const stripRef = (t: string) => t.replace(/\s*\[[^\]]*\]\s*$/, '').trim(); /** * Rows for every answered gate in an autoplan run: one row per answer event (gate_rev, time, answerer), covering - * the items it settled. Carried-forward answers were recorded when first given and are skipped. Ids are stable, + * the items it settled. Item answers carried from an earlier revision were recorded when first given and are skipped; + * a whole gate carried forward (lib/autoplan-carry.ts) gets its own row naming the revision its words came from. Ids are stable, * so a rerun (or a retry after an interrupted import) adds nothing twice. */ export function importAutoplan(runDir: string, branch: string, by?: string): ApprovalRow[] | RecordError { @@ -265,10 +272,16 @@ export function importAutoplan(runDir: string, branch: string, by?: string): App const changed = items.filter(r => r.chosen && r.recommended && r.chosen !== r.recommended); const stillPending = rows.filter(r => r.gate_rev === rev && r.status === 'pending'); const answerer = first.answered_by ?? 'unknown'; - const who = answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`; - const scope = `the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`; + const carried = p1?.carried_gate_from ? p1 : undefined; + const relayed = carried?.carried_answered_by && carried.carried_answered_by !== 'human' ? `, relayed by ${carried.carried_answered_by}` : ''; + const who = carried ? `${by ?? 'the owner'} (words from gate_rev ${carried.carried_gate_rev}${relayed}), carried forward by gstack-autoplan` + : answerer === 'human' ? (by ? `${by}, typed at the gate` : 'the owner, typed at the gate') : `${by ?? 'the owner'}, relayed by ${answerer}`; + const unseen = items.filter(r => r.carried_under).map(r => stripRef(r.title ?? '')); + const checked = [...new Set(items.map(r => r.carry_check).filter((c): c is string => !!c))]; + const scope = [`the reviewed plan (sha256 ${planSha}), ${changed.length ? `${changed.length} recommendation(s) changed: ${changed.map(r => stripRef(r.title ?? '')).join('; ')}` : 'every recommendation taken'}`, + unseen.length ? `taken under "do it", not seen by the owner: ${unseen.join('; ')}` : '', checked.length ? `post-cap delta checked by ${checked.join(', ')}` : ''].filter(Boolean).join('; '); const built = buildRow({ - ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask), + ask: `Approve the plan "${planTitle}" (autoplan run ${runId}, ${carried ? `gate_rev ${rev} carried forward from gate_rev ${carried.carried_gate_rev}, ` : ''}${items.length + (p1 ? 1 : 0)} decisions)`.slice(0, CAPS.ask), words: first.owner_reply ?? first.reply ?? '(no words recorded)', by: who.slice(0, CAPS.by), scope: scope.slice(0, CAPS.scope), diff --git a/lib/autoplan-carry.ts b/lib/autoplan-carry.ts new file mode 100644 index 000000000..0e6ae18ef --- /dev/null +++ b/lib/autoplan-carry.ts @@ -0,0 +1,252 @@ +/** + * autoplan-carry — carry the owner's last answer to a later gate revision (GSTA-155, + * docs/unattended.md#carry-forward). When a gate_rev N+1 asks nothing the owner has not + * already decided, the runner answers it with the owner's recorded gate_rev N words instead + * of a second round trip, and records that it did (decisions.jsonl `carried_gate_from`, + * journal `gate_carried`, `/decisions.tsv`). It never invents an answer: every + * carried answer is the owner's own earlier words, and every refusal says which condition failed. + * + * A carry needs all of: carry not turned off (`--no-carry-forward`); gate_rev N approved (p1 a or b) + * with the owner's words recorded, and not itself a carry; every change accepted at N + * incorporated or settled; the plan file unchanged since the gate or the revise that took those + * changes, and the reviewed plan equal to it; no open urgent finding; and every new item already + * carried. Two kinds of new item do not block: items a "do it" covers (the owner's words are + * "do it": an auto item that changes no scope, cost or direction and is no round grant), and a + * capped-delta item recommending "accept", which first gets one reviewer on just the delta + * (`CARRY_CHECK:`, answered with `gstack-autoplan check`); a High or Critical finding there + * means no carry, and the gate goes to the owner as before. + */ +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { attemptEstimate } from './autoplan-budget'; +import { lineDiff } from './autoplan-confirm'; +import { answerGate } from './autoplan-gate'; +import { driftLine } from './autoplan-plan-text'; +import { BOUNDARY, RESULT_FORMAT, REVIEWER_TRAILER } from './autoplan-prompts'; +import { parseReviewResult } from './autoplan-reconcile'; +import { FILES, RunError, journal, now, readDecisions, readJournal, readState, writeDecisions, writeState, type RunState } from './autoplan-state'; +import { appendTrailRow } from './decision-trail'; +import { decisionId, parseReply, type GateItem, type GateList } from './gate-list'; +import { readJsonl } from './headless-artifacts'; +import { modelFamily } from './outside-voice-runner'; +import { release, reserve, settle } from './spend-ledger'; + +export const DEFAULT_CARRY_CHECK_CAP_USD = 5; +const HIGH = ['Critical', 'High']; +const DO_IT = /^\s*(?:(?![^:]*\bcaptain\b)[a-z]+(?: [a-z]+){0,2}:\s*)?do it[\s.!]*$/i; +/** The owner's verbatim words are a "do it" (optionally after their name), not a Captain's words or an item token (`p3b: do it`) naming one. */ +export const isDoIt = (words: string | undefined): boolean => !!words && DO_IT.test(words); + +export interface CarryCheck { + attempt: string; prompt: string; result: string; delta_sha256: string; items: string[]; estimate_usd: number; + status: 'dispatched' | 'passed' | 'failed' | 'unavailable'; model?: string; max_severity?: string; findings?: number; at: string; +} +export interface CarryRecord { + gate_rev: number; from_gate_rev?: number; verdict: 'carried' | 'refused' | 'check_pending'; + reason: string; words?: string; under_do_it?: string[]; check?: CarryCheck; lines: string[]; at: string; +} + +const sha256 = (text: string | Buffer) => createHash('sha256').update(text).digest('hex'); +const planFile = (state: RunState) => state.plan_input ?? state.plan_source; +const fileSha = (file: string) => (fs.existsSync(file) ? sha256(fs.readFileSync(file)) : undefined); +export const atGateCopy = (out: string, rev: number) => path.join(out, `plan-at-gate.r${rev}.md`); + +/** What finishGate journals beside gate_written: the plan file's bytes and a copy of the reviewed plan the owner is asked about. */ +export function gateEvidence(state: RunState, rev: number): { source_file_sha256?: string } { + if (fs.existsSync(state.active_plan)) fs.copyFileSync(state.active_plan, atGateCopy(state.out, rev)); + return { source_file_sha256: fileSha(planFile(state)) }; +} + +/** The lines the latest carry decision printed, for the command that wrote this gate revision. */ +export function carryLines(state: RunState): string[] { + const c = state.carry; + return c && c.gate_rev === state.gate_rev ? c.lines : []; +} + +function record(state: RunState, c: Omit): CarryRecord { + const full = { ...c, at: now() }; + state.carry = full; + journal(state.out, { event: c.verdict === 'carried' ? 'gate_carried' : c.verdict === 'check_pending' ? 'carry_check_dispatched' : 'carry_refused', gate_rev: c.gate_rev, from_gate_rev: c.from_gate_rev ?? null, reason: c.reason, ...(c.under_do_it?.length ? { under_do_it: c.under_do_it } : {}), ...(c.check ? { check: c.check.attempt, check_status: c.check.status } : {}) }); + appendTrailRow(path.join(state.out, 'decisions.tsv'), ['gate', c.verdict === 'carried' ? `carried gate_rev ${c.gate_rev} from gate_rev ${c.from_gate_rev}` : c.verdict === 'check_pending' ? `delta check before carrying gate_rev ${c.gate_rev}` : `no carry for gate_rev ${c.gate_rev}`, + c.reason, c.check ? path.basename(c.check.result) : 'decisions.jsonl', c.check?.status ?? c.verdict]); + return full; +} +const refuse = (state: RunState, rev: number, from: number | undefined, reason: string) => record(state, { gate_rev: rev, from_gate_rev: from, verdict: 'refused', reason, lines: [`CARRY: no (${reason})`] }); + +/** Drop a delta check still waiting for its reviewer (the owner answered, the plan was revised, a new gate was written) and release its reservation. */ +export function voidPendingCheck(state: RunState, why: string): string[] { + const c = state.carry; + if (c?.verdict !== 'check_pending' || !c.check) return []; + release(path.join(state.out, FILES.spend), c.check.attempt); + const out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check ${c.check.attempt} void: ${why}`); + out.check = { ...c.check, status: 'unavailable' }; + return out.lines; +} + +/** The reviewed plan's changes since the copy taken when the owner was asked at `rev`, and their hash. */ +function deltaSince(state: RunState, rev: number): { diff: string; sha: string } | undefined { + const before = atGateCopy(state.out, rev); + if (!fs.existsSync(before)) return undefined; + const diff = lineDiff(fs.readFileSync(before, 'utf8'), fs.readFileSync(state.active_plan, 'utf8')).filter(l => l.op !== ' ').map(l => `${l.op} ${l.text}`).join('\n'); + return { diff, sha: sha256(diff) }; +} + +interface Source { id: string; gate_rev: number; words: string; by?: string } +type Plan = { source: Source; doIt: GateItem[]; deltas: GateItem[] } | { reason: string; from?: number }; + +/** Every condition but the delta check; returns why not, or what the carry takes. */ +function assess(state: RunState, prev: GateList, list: GateList): Plan { + const from = prev.gate_rev; + if (state.carry_forward === false) return { reason: 'carry-forward is off for this run (--no-carry-forward)', from }; + const p1 = readDecisions(state.out).find(r => r.gate_rev === from && (r.label ?? '').toLowerCase() === 'p1'); + if (!p1 || p1.status === 'pending' || !p1.answered_at) return { reason: `gate_rev ${from} was not answered`, from }; + const unanswered = readDecisions(state.out).find(r => r.gate_rev === from && r.status === 'pending'); + if (unanswered) return { reason: `gate_rev ${from} left ${unanswered.label ?? unanswered.id} unanswered`, from }; + if (p1.chosen !== 'a' && p1.chosen !== 'b') return { reason: `gate_rev ${from} answer was p1${p1.chosen}, not an approval`, from }; + if (p1.carried_gate_from) return { reason: `gate_rev ${from} was itself carried; the owner answers this one`, from }; + if (parseReply(p1.reply ?? '', prev).mode !== 'rec') return { reason: `gate_rev ${from} was answered with item tokens, not rec or "do it"`, from }; + const words = p1.owner_reply ?? (p1.answered_by === 'human' ? p1.reply : undefined); + if (!words?.trim()) return { reason: `no owner words recorded at gate_rev ${from}`, from }; + const amendments = (state.amendments ?? []).filter(a => a.gate_rev === from); + const open = amendments.find(a => a.status !== 'incorporated' && a.status !== 'settled'); + if (open) return { reason: `accepted change ${open.decision} is ${open.status}`, from }; + const journalRows = readJournal(state.out); + const at = journalRows.map(r => r.event === 'gate_written' && r.gate_rev === from).lastIndexOf(true); + let expected: string | undefined = at >= 0 ? journalRows[at]!.source_file_sha256 : undefined; + for (const r of journalRows.slice(at + 1)) if (r.event === 'plan_revised') expected = r.source_file_sha256; + if (!expected) return { reason: 'no plan evidence recorded for the gate or the revise (a run written before carry-forward)', from }; + if (fileSha(planFile(state)) !== expected) return { reason: `the plan file ${path.basename(planFile(state))} changed after the owner's answer or the revise that took it`, from }; + if (driftLine(state, 'PLAN_SOURCE_CHANGED')) return { reason: 'the reviewed plan differs from the plan file', from }; + const urgent = readJsonl(path.join(state.out, 'findings.jsonl')).rows.find(r => (r.urgent === true || r.outside_plan === true) && !r.settled_by); + if (urgent) return { reason: `urgent finding open: ${String(urgent.title ?? urgent.id)}`, from }; + const doIt: GateItem[] = []; const deltas: GateItem[] = []; + for (const item of list.items) { + if (item.id === 'p1' || item.carried) continue; + const option = item.options.find(o => o.key === item.recommended); + if (item.cap?.kind === 'delta' && !option?.changes_plan && item.recommended === 'a') { deltas.push(item); continue; } + if (isDoIt(words) && item.kind === 'auto' && !item.cap && !option?.changes_plan && !['scope', 'cost', 'direction'].includes(item.changes ?? '')) { doIt.push(item); continue; } + return { reason: `new item ${item.id}: ${item.title}`, from }; + } + return { source: { id: p1.id, gate_rev: from, words, by: p1.answered_by }, doIt, deltas }; +} + +/** Called by finishGate once gate_rev N+1 is written (status gate_pending): carry, set up the delta check, or say why not. */ +export function evaluateCarry(state: RunState, prev: GateList | undefined, list: GateList): CarryRecord | undefined { + voidPendingCheck(state, `gate_rev ${list.gate_rev} was written`); + if (!prev || list.gate_rev < 2) return undefined; + try { + const plan = assess(state, prev, list); + if ('reason' in plan) return refuse(state, list.gate_rev, plan.from, plan.reason); + if (plan.deltas.length) return dispatchCheck(state, prev, list, plan); + return carry(state, list, plan.source, plan.doIt, plan.deltas); + } catch (e: any) { + if (readDecisions(state.out).some(r => r.gate_rev === list.gate_rev && r.answered_at && !r.carried_from)) throw e; + return refuse(state, list.gate_rev, prev.gate_rev, `error: ${e instanceof RunError ? `${e.detail} (${e.code})` : e?.message ?? e}`); + } +} + +function dispatchCheck(state: RunState, prev: GateList, list: GateList, plan: Extract): CarryRecord { + const rev = list.gate_rev; + const cap = state.carry_check_cap_usd ?? DEFAULT_CARRY_CHECK_CAP_USD; + const estimate = attemptEstimate(state, undefined, process.env).usd; + if (estimate > cap) return refuse(state, rev, prev.gate_rev, `delta check estimate ${estimate.toFixed(2)} is over the per-gate cap ${cap.toFixed(2)} (--carry-check-cap)`); + const delta = deltaSince(state, prev.gate_rev); + if (!delta) return refuse(state, rev, prev.gate_rev, `no copy of the plan the owner answered at gate_rev ${prev.gate_rev}`); + const current = fs.readFileSync(state.active_plan, 'utf8'); + const { diff, sha: deltaSha } = delta; + const attempt = `${state.run}-c${rev}`; + const prompt = path.join(state.out, `carry-check-g${rev}-prompt.md`); + const result = path.join(state.out, `carry-check-g${rev}.md`); + try { reserve(path.join(state.out, FILES.spend), attempt, estimate, { cap: state.spend_cap_usd, label: 'carry-check' }); } + catch (e: any) { return refuse(state, rev, prev.gate_rev, `delta check does not fit the run's spend cap: ${e?.message ?? e}`); } + const accepted = (state.amendments ?? []).filter(a => a.gate_rev === prev.gate_rev).map(a => `- ${a.title} -> ${a.text}`).join('\n') || '- (none recorded)'; + fs.writeFileSync(prompt, [BOUNDARY, '', + `You are checking one edit to a reviewed plan before gstack reuses the owner's approval of gate_rev ${prev.gate_rev} for gate_rev ${rev}. The owner accepted these changes:`, accepted, '', + 'The plan was edited after that answer, but the phases that would re-review the edit were at their round cap, so no reviewer has read it. Read the delta and say whether it implements the accepted changes, adds nothing the owner did not accept (scope, cost or direction), and leaves the plan correct. Rate a finding High or Critical only when the edit adds an unaccepted change, contradicts an accepted one, or would make the plan fail a test, break production, or lose data or secrets; anything else is Medium or lower.', '', + `Delta (reviewed plan at gate_rev ${prev.gate_rev} -> now; + added, - removed; SHA-256 ${deltaSha}):`, '```diff', diff, '```', '', + 'The complete current plan follows as context.', '', current, + REVIEWER_TRAILER, RESULT_FORMAT('carry-check', deltaSha, result)].join('\n')); + const check: CarryCheck = { attempt, prompt, result, delta_sha256: deltaSha, items: plan.deltas.map(i => i.id), estimate_usd: estimate, status: 'dispatched', at: now() }; + const family = state.native_model ? `not ${modelFamily(state.native_model)}` : 'differs from the plan reviewers where available'; + return record(state, { gate_rev: rev, from_gate_rev: prev.gate_rev, verdict: 'check_pending', words: plan.source.words, under_do_it: plan.doIt.map(i => i.id), check, + reason: `only an unreviewed post-cap delta is new (${check.items.join(',')}); one reviewer reads it first`, + lines: [`CARRY_CHECK: attempt=${attempt} items=${check.items.join(',')} prompt=${prompt} result=${result} model_family=${family} estimate=${estimate.toFixed(2)} cap=${cap.toFixed(2)}`, + `NEXT: dispatch one reviewer on the prompt, then gstack-autoplan check --out ${state.out} --attempt ${attempt} --result ${result} --model (or --unavailable "" to relay the gate as today)`] }); +} + +function carry(state: RunState, list: GateList, source: Source, doIt: GateItem[], deltas: GateItem[], check?: CarryCheck): CarryRecord { + const rev = list.gate_rev; + const p1 = list.items.find(i => i.id === 'p1')!; + const reply = list.items.map(i => `${i.id}${i.id === 'p1' ? p1.recommended : i.carried?.chosen ?? i.recommended}`).join(' '); + const reason = [doIt.length || deltas.length ? '' : 'no new choice', doIt.length ? `"do it" covers ${doIt.map(i => i.id).join(',')}` : '', deltas.length ? `delta check passed on ${deltas.map(i => i.id).join(',')}` : '', + 'plan unchanged since the accepted changes'].filter(Boolean).join('; '); + const answered = answerGate(state, rev, reply, 'agent:gstack-autoplan', { carry: { from: source.id, gate_rev: source.gate_rev, words: source.words, by: source.by, reason } }); + const marks = new Map>([...doIt.map(i => [decisionId(list.run, rev, i.id), { carried_under: 'do it' }] as const), ...deltas.map(i => [decisionId(list.run, rev, i.id), { carry_check: check?.attempt ?? '' }] as const)]); + if (marks.size) writeDecisions(state.out, readDecisions(state.out).map(r => (marks.has(r.id) ? { ...r, ...marks.get(r.id) } : r))); + const lines = [`CARRIED: gate_rev=${rev} <- gate_rev=${source.gate_rev} words=${JSON.stringify(source.words)} by=${source.by ?? 'unknown'} reason=${reason}`, + ...doIt.map(i => `CARRIED_UNDER_DO_IT: ${i.id} ${i.title} -> ${i.options.find(o => o.key === i.recommended)?.text ?? i.recommended} (the owner has not seen this item)`), + ...answered.lines]; + return record(state, { gate_rev: rev, from_gate_rev: source.gate_rev, verdict: 'carried', reason, words: source.words, under_do_it: doIt.map(i => i.id), ...(check ? { check } : {}), lines }); +} + +export interface CheckOptions { out: string; attempt?: string; result?: string; model?: string; unavailable?: string; sameFamily?: string } + +/** `gstack-autoplan check`: bind the delta reviewer's result (or its absence) and carry, or leave the gate for the owner. */ +export function submitCarryCheck(o: CheckOptions): { state: RunState; lines: string[] } { + const state = readState(path.resolve(o.out)); + const c = state.carry; + if (!c || c.verdict !== 'check_pending' || !c.check || state.status !== 'gate_pending' || c.gate_rev !== state.gate_rev) throw new RunError('PHASE_NOT_AWAITING', `no delta check is pending (run is ${state.status}, gate_rev=${state.gate_rev ?? 'none'})`, 3); + const check = c.check; + if (o.attempt !== check.attempt) throw new RunError('ATTEMPT_MISMATCH', `pass --attempt ${check.attempt}`, 3); + const stale = readDecisions(state.out).some(r => r.gate_rev === c.gate_rev && r.answered_at && !r.carried_from) ? `the owner answered gate_rev ${c.gate_rev}` + : deltaSince(state, c.from_gate_rev ?? 0)?.sha !== check.delta_sha256 ? 'the plan changed after the delta check was dispatched' : undefined; + if (stale) { + const lines = voidPendingCheck(state, stale); + writeState(state); + return { state, lines }; + } + const gateFile = path.join(state.out, 'gate.json'); + const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; + const spend = path.join(state.out, FILES.spend); + let out: CarryRecord; + if (o.unavailable !== undefined) { + release(spend, check.attempt); + list.carry_check = { attempt: check.attempt, model: 'none', verdict: 'unavailable', max_severity: 'none', findings: 0, at: now() }; + fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n'); + out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check unavailable: ${o.unavailable}`); + out.check = { ...check, status: 'unavailable' }; + } else { + if (!o.result || !o.model) throw new RunError('RESULT_RECEIPT_MISSING', 'check needs --result and --model , or --unavailable ""', 3); + if (!fs.existsSync(o.result)) throw new RunError('RESULT_RECEIPT_MISSING', `result file not found: ${o.result}`); + const family = modelFamily(o.model); + const author = state.native_model ? modelFamily(state.native_model) : undefined; + if (!o.sameFamily && (family === 'unknown' || family === author)) throw new RunError('MODEL_FAMILY_CONFLICT', `${o.model} is ${family === 'unknown' ? 'of no recognized family' : `the plan reviewers' family (${family})`}; use another family, or pass --same-family ""`, 3); + const text = fs.readFileSync(o.result, 'utf8'); + const parsed = parseReviewResult(text, { phase: 'carry-check', sha256: check.delta_sha256 }); + if (!parsed.receipt.ok) throw new RunError('RESULT_RECEIPT_MISSING', parsed.errors[0]!); + if (!parsed.fenceFound || parsed.errors.length) throw new RunError('RESULT_FINDINGS_MISSING', parsed.errors.join('; ')); + fs.writeFileSync(path.join(state.out, `carry-check-g${c.gate_rev}.bound.md`), text, process.platform === 'win32' ? {} : { mode: 0o444 }); + settle(spend, check.attempt, 'unknown'); + const order = ['Informational', 'Low', 'Medium', 'High', 'Critical']; + const max = parsed.findings.map(f => String(f.severity)).reduce((a, b) => (order.indexOf(b) > order.indexOf(a) ? b : a), 'none'); + const blocking = parsed.findings.find(f => HIGH.includes(String(f.severity))); + const done: CarryCheck = { ...check, status: blocking ? 'failed' : 'passed', model: o.model, max_severity: max, findings: parsed.findings.length, at: now() }; + list.carry_check = { attempt: check.attempt, model: o.model + (o.sameFamily ? ` (same family: ${o.sameFamily})` : ''), verdict: blocking ? 'failed' : 'passed', max_severity: max, findings: parsed.findings.length, at: done.at }; + if (blocking) for (const item of list.items) if (check.items.includes(item.id)) (item.flags ??= []).push(`carry check ${o.model}: ${blocking.severity} ${blocking.title}`); + fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n'); + if (blocking) { + out = refuse(state, c.gate_rev, c.from_gate_rev, `delta check found ${blocking.severity}: ${blocking.title}`); + out.check = done; + } else { + const plan = assess(state, { ...list, gate_rev: c.from_gate_rev! } as GateList, list); + if ('reason' in plan) { out = refuse(state, c.gate_rev, c.from_gate_rev, plan.reason); out.check = done; } + else { + out = carry(state, list, plan.source, plan.doIt, plan.deltas, done); + out.lines.unshift(`CARRY_CHECK: passed attempt=${check.attempt} model=${o.model} findings=${done.findings} highest=${max}`); + } + } + } + writeState(state); + return { state, lines: out.lines }; +} diff --git a/lib/autoplan-gate.ts b/lib/autoplan-gate.ts index 98b381f4f..1488dbc05 100644 --- a/lib/autoplan-gate.ts +++ b/lib/autoplan-gate.ts @@ -24,7 +24,8 @@ import { consensusRecord, reconcile, remedyKey, renderConsensus, SETTLEABLE_CHAN import type { Phase } from './autoplan-prompts'; import { FILES, RunError, journal as journalRow, now, readDecisions, voiceRows, voiceTerminal, writeDecisions, type Amendment, type RunState, type VoiceState } from './autoplan-state'; import { applyCapChoices, pendingDeltas, releaseUndispatched, roundCap, roundOf } from './autoplan-budget'; -import { capyRelayLine } from './captain-brief'; +import { evaluateCarry, gateEvidence, voidPendingCheck } from './autoplan-carry'; +import { buildEli5, capyRelayLine } from './captain-brief'; import { previousReview, reviewRows, settledIds } from './autoplan-confirm'; import { phaseClose } from './autoplan-timing'; import { ANSWERED_BY_RE, applyReply, decisionId, decisionRows, keepDirection, parseReply, pendingHint, recHash, validateGateList, type GateChoice, type GateItem, type GateList, type GateOption, type ParsedReply } from './gate-list'; @@ -237,7 +238,8 @@ export function buildGate(state: RunState, gateRev: number): GateList { } items.push(...capItems(state, { d: () => `d${++d}`, uc: () => `uc${++uc}` })); items.push(planApproval(state, items)); - const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0 }; + const list: GateList = { schema_version: 1, run: state.run, gate_rev: gateRev, items, title: '/autoplan final approval gate', rec_rev: 0, + ...(fs.existsSync(state.active_plan) ? { eli5: buildEli5(fs.readFileSync(state.active_plan, 'utf8'), state.active_plan) } : {}) }; const problems = validateGateList(list); if (problems.length) throw new Error(`gate list invalid: ${problems.join('; ')}`); return list; @@ -285,9 +287,29 @@ export function recommendGate(state: RunState, gateRev: number, itemId: string, ...[capyRelayLine(state.out)].filter((l): l is string => !!l)]; } -/** Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged answered proposals carry forward. */ +/** The gate list the owner last answered: gate.json, or its archive (`gate.r[.v].json`) once a reopen moved it. */ +function previousGate(out: string, rev: number): GateList | undefined { + if (rev < 1) return undefined; + const re = new RegExp(`^gate\\.r${rev}(?:\\.v(\\d+))?\\.json$`); + const archived = fs.readdirSync(out).map(f => [f, re.exec(f)] as const).filter(([, m]) => m).sort((a, b) => Number(b[1]![1] ?? 1) - Number(a[1]![1] ?? 1)).map(([f]) => f); + for (const name of ['gate.json', ...archived]) { + const file = path.join(out, name); + if (!fs.existsSync(file)) continue; + const list = JSON.parse(fs.readFileSync(file, 'utf8')) as GateList; + if (list.gate_rev === rev) return list; + } + return undefined; +} + +/** + * Every phase closed: write the gate list, decisions, tasks and the exported files; the run ends gate_pending. Unchanged + * answered proposals carry forward, and a later revision that asks nothing new carries the owner's last answer + * (lib/autoplan-carry.ts; its lines are on `carryLines(state)`). + */ export function finishGate(state: RunState): GateList { const gateRev = (state.gate_rev ?? 0) + 1; + const gateFile = path.join(state.out, 'gate.json'); + const prev = previousGate(state.out, state.gate_rev ?? 0); const list = buildGate(state, gateRev); const kept = readDecisions(state.out).filter(r => r.gate_rev !== gateRev); const answered = new Map(kept.filter(r => r.origin && r.chosen != null && r.status !== 'pending').map(r => [r.origin!, r])); @@ -295,7 +317,7 @@ export function finishGate(state: RunState): GateList { const prev = item.origin ? answered.get(item.origin) : undefined; if (prev && item.options.some(o => o.key === prev.chosen)) item.carried = { from: prev.carried_from ?? prev.id, chosen: prev.chosen!, gate_rev: prev.gate_rev }; } - fs.writeFileSync(path.join(state.out, 'gate.json'), JSON.stringify(list, null, 2) + '\n'); + fs.writeFileSync(gateFile, JSON.stringify(list, null, 2) + '\n'); const rows = decisionRows(list).map(row => { const item = list.items.find(i => decisionId(list.run, gateRev, i.id) === row.id)!; if (!item.carried) return row; @@ -308,7 +330,8 @@ export function finishGate(state: RunState): GateList { state.status = 'gate_pending'; state.gate = { rev: gateRev, items: list.items.length, written_at: now() }; exportRun(state); - journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id) }); + journalRow(state.out, { event: 'gate_written', gate_rev: gateRev, items: list.items.length, carried: list.items.filter(i => i.carried).map(i => i.id), ...gateEvidence(state, gateRev) }); + evaluateCarry(state, prev, list); return list; } @@ -321,6 +344,8 @@ export interface AnswerOptions { recHash?: string; /** The owner's verbatim words; required for every `--by agent:` answer. */ ownerReply?: string; + /** A whole-gate carry (lib/autoplan-carry.ts): the earlier p1 decision and the owner's words it reuses. Replaces ownerReply and its re-parse. */ + carry?: { from: string; gate_rev: number; words: string; by?: string; reason: string }; } /** How an accepted plan change is discharged: a packaging/ordering change settles without a plan edit. */ @@ -361,7 +386,7 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ if (state.status !== 'gate_pending' || !fs.existsSync(gateFile)) throw new RunError('PHASE_NOT_AWAITING', `run is ${state.status}, not gate_pending`, 3); if (answeredBy !== undefined && !ANSWERED_BY_RE.test(answeredBy)) throw new RunError('ANSWER_BY_REQUIRED', `--by ${JSON.stringify(answeredBy)} is neither human nor agent:`, 3); if (answeredBy === undefined && state.session_kind === 'unattended') throw new RunError('ANSWER_BY_REQUIRED', 'an unattended run records who answered; pass --by human or --by agent:', 3); - if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "" (or --owner-reply - / --owner-reply-file )`, 3); + if (answeredBy?.startsWith('agent:') && o.ownerReply === undefined && !o.carry) throw new RunError('OWNER_REPLY_REQUIRED', `${answeredBy} relayed or decided this reply; pass the owner's words verbatim with --owner-reply "" (or --owner-reply - / --owner-reply-file )`, 3); const list = JSON.parse(fs.readFileSync(gateFile, 'utf8')) as GateList; const rows = readDecisions(state.out); const prior = Object.fromEntries(rows.filter(r => r.gate_rev === list.gate_rev && r.chosen != null && r.status !== 'pending' && r.label).map(r => [r.label!.toLowerCase(), r.chosen!])); @@ -372,12 +397,13 @@ export function answerGate(state: RunState, gateRev: number, reply: string, answ if (o.recHash !== undefined && o.recHash !== currentHash) throw new RunError('GATE_REC_STALE', `--rec-hash ${o.recHash} was rendered before the recommendations changed; current rec_hash=${currentHash}`, 3); if (parsed.mode === 'rec' && list.rec_rev === undefined && o.recHash === undefined) throw new RunError('GATE_REC_STALE', `this gate was written before \`rec\`/\`all\` took plan changes (gate.json has no rec_rev), so the owner saw a different meaning; render it again (gstack-gate render ${gateFile} --all), relay it, and answer with --rec-hash ${currentHash}, or reply with explicit