From 11e74f9a257116cab152bb1491846a884d6d931a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:12:36 -0400 Subject: [PATCH 01/27] test(3663): add export check for stageSkillsForRuntimeAsSkills RED-1/GREEN-1: export assertion + stub function + STAGED_DIRS export. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/lib/install-profiles.cjs | 6 ++++++ tests/install-profiles-stage.test.cjs | 8 ++++++++ 2 files changed, 14 insertions(+) diff --git a/get-shit-done/bin/lib/install-profiles.cjs b/get-shit-done/bin/lib/install-profiles.cjs index 68c961eae..0fffd83f5 100644 --- a/get-shit-done/bin/lib/install-profiles.cjs +++ b/get-shit-done/bin/lib/install-profiles.cjs @@ -373,6 +373,10 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) { return stageDir; } +function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) { + // stub +} + // --------------------------------------------------------------------------- // Profile marker persistence // --------------------------------------------------------------------------- @@ -560,6 +564,8 @@ module.exports = { mostRestrictiveProfile, stageSkillsForProfile, stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, + STAGED_DIRS, readActiveProfile, writeActiveProfile, // Shared internals diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 861cced6d..4594f259e 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -12,9 +12,11 @@ const os = require('os'); const { stageSkillsForProfile, stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, cleanupStagedSkills, resolveProfile, loadSkillsManifest, + STAGED_DIRS, } = require('../get-shit-done/bin/lib/install-profiles.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); @@ -36,6 +38,12 @@ function createFixtureAgentsDir() { return tmp; } +describe('stageSkillsForRuntimeAsSkills', () => { + test('is exported as a function', () => { + assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); + }); +}); + describe('stageSkillsForProfile', () => { test('full profile (skills === "*") returns srcDir unchanged', () => { const src = createFixtureSkillsDir(); From 235f527635b442f9deb4b4c274a97decb87d01d7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:13:00 -0400 Subject: [PATCH 02/27] feat(3663): implement stageSkillsForRuntimeAsSkills iteration+write loop RED-2/GREEN-2: wildcard skills='*' stages all *.md as /SKILL.md with converter applied. Includes existence guard and error cleanup. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/lib/install-profiles.cjs | 25 +++++++++++++++++++++- tests/install-profiles-stage.test.cjs | 21 ++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/get-shit-done/bin/lib/install-profiles.cjs b/get-shit-done/bin/lib/install-profiles.cjs index 0fffd83f5..8e6035c57 100644 --- a/get-shit-done/bin/lib/install-profiles.cjs +++ b/get-shit-done/bin/lib/install-profiles.cjs @@ -374,7 +374,30 @@ function stageAgentsForProfile(srcAgentsDir, resolvedProfile) { } function stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) { - // stub + if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir; + + const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-')); + try { + const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md')) continue; + const stem = entry.name.slice(0, -3); + if (resolvedProfile.skills !== '*' && !resolvedProfile.skills.has(stem)) continue; + const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'); + const skillName = `${prefix}${stem}`; + const converted = converter(content, skillName); + const destDir = path.join(stageDir, skillName); + fs.mkdirSync(destDir, { recursive: true }); + fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted); + } + } catch (err) { + try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {} + throw err; + } + STAGED_DIRS.add(stageDir); + ensureExitCleanup(); + return stageDir; } // --------------------------------------------------------------------------- diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 4594f259e..89d7fdb96 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -42,6 +42,27 @@ describe('stageSkillsForRuntimeAsSkills', () => { test('is exported as a function', () => { assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); + + test('skills === "*" stages all md files as /SKILL.md', () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + let stagedDir; + try { + for (const name of ['alpha', 'beta', 'gamma']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); + } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-alpha', 'gsd-beta', 'gsd-gamma']); + for (const name of ['alpha', 'beta', 'gamma']) { + const content = fs.readFileSync(path.join(stagedDir, `gsd-${name}`, 'SKILL.md'), 'utf8'); + assert.strictEqual(content, `# ${name}\n`); + } + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (stagedDir) cleanupStagedSkills(); + } + }); }); describe('stageSkillsForProfile', () => { From 151e4aa2c61765feb4500b8b5afa79c58feed969 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:13:13 -0400 Subject: [PATCH 03/27] test(3663): add Set filtering test for stageSkillsForRuntimeAsSkills RED-3/GREEN-3: filter already present from GREEN-2 implementation. Co-Authored-By: Claude Sonnet 4.6 --- tests/install-profiles-stage.test.cjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 89d7fdb96..9ccbcad46 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -43,6 +43,23 @@ describe('stageSkillsForRuntimeAsSkills', () => { assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); + test('skills Set filters: only matching stems land in stagedDir', () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + let stagedDir; + try { + for (const name of ['alpha', 'beta', 'phase']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); + } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: new Set(['phase']) }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-phase']); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (stagedDir) cleanupStagedSkills(); + } + }); + test('skills === "*" stages all md files as /SKILL.md', () => { const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); let stagedDir; From 6e0d3e8bd1463000f4f1278e85e0aceb4f52b965 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:13:25 -0400 Subject: [PATCH 04/27] test(3663): add converter invocation test for stageSkillsForRuntimeAsSkills RED-4/GREEN-4: verifies converter called with (content, skillName) per kept skill. Co-Authored-By: Claude Sonnet 4.6 --- tests/install-profiles-stage.test.cjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 9ccbcad46..7cc5d7d7e 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -43,6 +43,28 @@ describe('stageSkillsForRuntimeAsSkills', () => { assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); + test('converter is called with (content, skillName) for each kept skill', () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + let stagedDir; + try { + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + fs.writeFileSync(path.join(src, 'beta.md'), '# beta\n'); + const calls = []; + const converter = (content, skillName) => { + calls.push([content, skillName]); + return content; + }; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'x-'); + assert.strictEqual(calls.length, 2); + const callMap = Object.fromEntries(calls.map(([c, n]) => [n, c])); + assert.strictEqual(callMap['x-alpha'], '# alpha\n'); + assert.strictEqual(callMap['x-beta'], '# beta\n'); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (stagedDir) cleanupStagedSkills(); + } + }); + test('skills Set filters: only matching stems land in stagedDir', () => { const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); let stagedDir; From 82111a9e5e89b3f370f59d2e1ca4657d81731110 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:13:35 -0400 Subject: [PATCH 05/27] test(3663): add empty-prefix test for stageSkillsForRuntimeAsSkills RED-5/GREEN-5: empty prefix produces /SKILL.md (Hermes case). Co-Authored-By: Claude Sonnet 4.6 --- tests/install-profiles-stage.test.cjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 7cc5d7d7e..21719123c 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -43,6 +43,23 @@ describe('stageSkillsForRuntimeAsSkills', () => { assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); + test('empty prefix produces /SKILL.md without prefix segment', () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + let stagedDir; + try { + fs.writeFileSync(path.join(src, 'phase.md'), '# phase\n'); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, ''); + const entries = fs.readdirSync(stagedDir); + assert.deepStrictEqual(entries, ['phase']); + const content = fs.readFileSync(path.join(stagedDir, 'phase', 'SKILL.md'), 'utf8'); + assert.strictEqual(content, '# phase\n'); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (stagedDir) cleanupStagedSkills(); + } + }); + test('converter is called with (content, skillName) for each kept skill', () => { const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); let stagedDir; From 725dbe244166e30732bb139cad15fd16660beac7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:13:45 -0400 Subject: [PATCH 06/27] test(3663): add missing-srcDir guard test for stageSkillsForRuntimeAsSkills RED-6/GREEN-6: non-existent srcCommandsDir returns path unchanged. Co-Authored-By: Claude Sonnet 4.6 --- tests/install-profiles-stage.test.cjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 21719123c..16723e060 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -43,6 +43,13 @@ describe('stageSkillsForRuntimeAsSkills', () => { assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); + test('non-existent srcCommandsDir returns srcCommandsDir unchanged', () => { + const ghost = path.join(os.tmpdir(), 'gsd-rta-no-exist-' + Date.now()); + const converter = (content, _skillName) => content; + const result = stageSkillsForRuntimeAsSkills(ghost, { skills: '*' }, converter, 'gsd-'); + assert.strictEqual(result, ghost); + }); + test('empty prefix produces /SKILL.md without prefix segment', () => { const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); let stagedDir; From 808b40d319c311fc4f4663ad7e7ee38c49f6f92a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:13:56 -0400 Subject: [PATCH 07/27] test(3663): add STAGED_DIRS registration test for stageSkillsForRuntimeAsSkills RED-7/GREEN-7: verifies stagedDir is added to STAGED_DIRS after staging. Co-Authored-By: Claude Sonnet 4.6 --- tests/install-profiles-stage.test.cjs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index 16723e060..b3fbe21eb 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -43,6 +43,21 @@ describe('stageSkillsForRuntimeAsSkills', () => { assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); + test('registers stagedDir in STAGED_DIRS after staging', () => { + const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + let stagedDir; + try { + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + cleanupStagedSkills(); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + assert.ok(STAGED_DIRS.has(stagedDir), 'stagedDir must be in STAGED_DIRS'); + } finally { + fs.rmSync(src, { recursive: true, force: true }); + if (stagedDir) cleanupStagedSkills(); + } + }); + test('non-existent srcCommandsDir returns srcCommandsDir unchanged', () => { const ghost = path.join(os.tmpdir(), 'gsd-rta-no-exist-' + Date.now()); const converter = (content, _skillName) => content; From dc2a6278e5926631cb39844d25deab7e6570d016 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:18:37 -0400 Subject: [PATCH 08/27] feat(3663): add runtime-artifact-layout.cjs + resolve tests for all 15 runtimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New module: get-shit-done/bin/lib/runtime-artifact-layout.cjs - resolveRuntimeArtifactLayout(runtime, configDir, scope) → Layout - 15-runtime table (grok intentionally excluded, throws TypeError) - findInstallSourceRoot / findAgentsSourceRoot walk-up-only (no .gsd-source marker in Phase 1) - Loads bin/install.js converters via GSD_TEST_MODE guard - Tests: 16 fixtures covering all runtimes + both claude scopes Co-Authored-By: Claude Sonnet 4.6 --- .../bin/lib/runtime-artifact-layout.cjs | 231 ++++++++++++++++++ .../runtime-artifact-layout-resolve.test.cjs | 219 +++++++++++++++++ 2 files changed, 450 insertions(+) create mode 100644 get-shit-done/bin/lib/runtime-artifact-layout.cjs create mode 100644 tests/runtime-artifact-layout-resolve.test.cjs diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs new file mode 100644 index 000000000..25412b62c --- /dev/null +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -0,0 +1,231 @@ +'use strict'; + +/** + * Runtime artifact layout module — resolves the artifact directory shapes + * (commands, agents, skills) for each supported runtime. + * + * grok is intentionally absent: it is in runtime-homes.cjs but not wired + * here. The TypeError on unknown runtime is the loud-fail signal that a + * runtime was added to the homes list without a layout entry. + */ + +const path = require('path'); +const fs = require('fs'); + +const { + stageSkillsForProfile, + stageAgentsForProfile, + stageSkillsForRuntimeAsSkills, +} = require('./install-profiles.cjs'); + +// Load converters from bin/install.js in test-safe way (GSD_TEST_MODE skips main logic) +process.env.GSD_TEST_MODE = process.env.GSD_TEST_MODE || '1'; +const { + convertClaudeCommandToClaudeSkill, + convertClaudeCommandToCursorSkill, + convertClaudeCommandToCodexSkill, + convertClaudeCommandToCopilotSkill, + convertClaudeCommandToAntigravitySkill, + convertClaudeCommandToWindsurfSkill, + convertClaudeCommandToAugmentSkill, + convertClaudeCommandToTraeSkill, + convertClaudeCommandToCodebuddySkill, +} = require('../../../bin/install.js'); + +/** + * @typedef {'commands'|'agents'|'skills'} ArtifactKindName + * @typedef {Object} ArtifactKind + * @property {ArtifactKindName} kind + * @property {string} destSubpath + * @property {string} prefix + * @property {(resolvedProfile: Object) => string} stage + * @typedef {Object} Layout + * @property {string} runtime + * @property {string} configDir + * @property {ArtifactKind[]} kinds + */ + +// --------------------------------------------------------------------------- +// Source root finders +// --------------------------------------------------------------------------- + +/** + * Walk up from __dirname to find commands/gsd. Walk-up-only for Phase 1; + * the .gsd-source marker check (which requires runtimeConfigDir) is deferred. + * + * @param {string} [overrideRoot] optional override for testability + * @returns {string} + */ +function findInstallSourceRoot(overrideRoot) { + if (overrideRoot) return overrideRoot; + let dir = __dirname; + for (let i = 0; i < 6; i++) { + const candidate = path.join(dir, 'commands', 'gsd'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return path.join(__dirname, '..', '..', '..', 'commands', 'gsd'); +} + +/** + * Walk up from __dirname to find agents/. Walk-up-only for Phase 1. + * + * @param {string} [overrideRoot] optional override for testability + * @returns {string|null} + */ +function findAgentsSourceRoot(overrideRoot) { + if (overrideRoot) return overrideRoot; + let dir = __dirname; + for (let i = 0; i < 6; i++) { + const candidate = path.join(dir, 'agents'); + if (fs.existsSync(candidate)) return candidate; + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +// --------------------------------------------------------------------------- +// Allowlisted runtimes +// --------------------------------------------------------------------------- + +const ALLOWED_RUNTIMES = new Set([ + 'claude', 'cursor', 'gemini', 'codex', 'copilot', 'antigravity', + 'windsurf', 'augment', 'trae', 'qwen', 'hermes', 'codebuddy', + 'cline', 'opencode', 'kilo', +]); + +// --------------------------------------------------------------------------- +// Layout table builders +// --------------------------------------------------------------------------- + +function commandsKind(destSubpath, prefix, srcOverride) { + return { + kind: 'commands', + destSubpath, + prefix, + stage: (resolved) => stageSkillsForProfile(findInstallSourceRoot(srcOverride), resolved), + }; +} + +function agentsKind(destSubpath, prefix, srcOverride) { + return { + kind: 'agents', + destSubpath, + prefix, + stage: (resolved) => stageAgentsForProfile(findAgentsSourceRoot(srcOverride), resolved), + }; +} + +function skillsKind(destSubpath, prefix, converter, srcOverride) { + return { + kind: 'skills', + destSubpath, + prefix, + stage: (resolved) => stageSkillsForRuntimeAsSkills(findInstallSourceRoot(srcOverride), resolved, converter, prefix), + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Resolve the artifact layout for a given runtime and config directory. + * + * @param {string} runtime + * @param {string} configDir + * @param {'local'|'global'} [scope] + * @returns {Layout} + */ +function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { + if (typeof configDir !== 'string' || configDir === '') { + throw new TypeError('configDir must be a non-empty string'); + } + if (scope !== 'local' && scope !== 'global') { + throw new TypeError('scope must be "local" or "global"'); + } + if (!ALLOWED_RUNTIMES.has(runtime)) { + throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`); + } + + let kinds; + switch (runtime) { + case 'claude': + if (scope === 'local') { + kinds = [ + commandsKind('commands/gsd', 'gsd-'), + agentsKind('agents', 'gsd-'), + ]; + } else { + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill)]; + } + break; + + case 'cursor': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCursorSkill)]; + break; + + case 'gemini': + kinds = [commandsKind('commands/gsd', 'gsd-')]; + break; + + case 'codex': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodexSkill)]; + break; + + case 'copilot': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCopilotSkill)]; + break; + + case 'antigravity': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAntigravitySkill)]; + break; + + case 'windsurf': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToWindsurfSkill)]; + break; + + case 'augment': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAugmentSkill)]; + break; + + case 'trae': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToTraeSkill)]; + break; + + case 'qwen': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill)]; + break; + + case 'hermes': + kinds = [skillsKind('skills/gsd', '', convertClaudeCommandToClaudeSkill)]; + break; + + case 'codebuddy': + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodebuddySkill)]; + break; + + case 'cline': + kinds = []; + break; + + case 'opencode': + kinds = [commandsKind('command', 'gsd-')]; + break; + + case 'kilo': + kinds = [commandsKind('command', 'gsd-')]; + break; + + default: + throw new TypeError(`Unknown runtime: '${runtime}' — add to runtime-artifact-layout.cjs table`); + } + + return { runtime, configDir, kinds }; +} + +module.exports = { resolveRuntimeArtifactLayout }; diff --git a/tests/runtime-artifact-layout-resolve.test.cjs b/tests/runtime-artifact-layout-resolve.test.cjs new file mode 100644 index 000000000..d5eeccbf4 --- /dev/null +++ b/tests/runtime-artifact-layout-resolve.test.cjs @@ -0,0 +1,219 @@ +'use strict'; +/** + * Tests for resolveRuntimeArtifactLayout — structural shape per runtime. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); + +const FAKE_DIR = '/tmp/fake-config-dir'; + +describe('resolveRuntimeArtifactLayout — claude local', () => { + test('returns correct layout for claude scope=local', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local'); + assert.strictEqual(layout.runtime, 'claude'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 2); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + assert.strictEqual(layout.kinds[1].kind, 'agents'); + assert.strictEqual(layout.kinds[1].destSubpath, 'agents'); + assert.strictEqual(layout.kinds[1].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[1].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — claude global', () => { + test('returns correct layout for claude scope=global', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); + assert.strictEqual(layout.runtime, 'claude'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — cursor', () => { + test('returns correct layout for cursor', () => { + const layout = resolveRuntimeArtifactLayout('cursor', FAKE_DIR); + assert.strictEqual(layout.runtime, 'cursor'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — gemini', () => { + test('returns correct layout for gemini', () => { + const layout = resolveRuntimeArtifactLayout('gemini', FAKE_DIR); + assert.strictEqual(layout.runtime, 'gemini'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'commands/gsd'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — codex', () => { + test('returns correct layout for codex', () => { + const layout = resolveRuntimeArtifactLayout('codex', FAKE_DIR); + assert.strictEqual(layout.runtime, 'codex'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — copilot', () => { + test('returns correct layout for copilot', () => { + const layout = resolveRuntimeArtifactLayout('copilot', FAKE_DIR); + assert.strictEqual(layout.runtime, 'copilot'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — antigravity', () => { + test('returns correct layout for antigravity', () => { + const layout = resolveRuntimeArtifactLayout('antigravity', FAKE_DIR); + assert.strictEqual(layout.runtime, 'antigravity'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — windsurf', () => { + test('returns correct layout for windsurf', () => { + const layout = resolveRuntimeArtifactLayout('windsurf', FAKE_DIR); + assert.strictEqual(layout.runtime, 'windsurf'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — augment', () => { + test('returns correct layout for augment', () => { + const layout = resolveRuntimeArtifactLayout('augment', FAKE_DIR); + assert.strictEqual(layout.runtime, 'augment'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — trae', () => { + test('returns correct layout for trae', () => { + const layout = resolveRuntimeArtifactLayout('trae', FAKE_DIR); + assert.strictEqual(layout.runtime, 'trae'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — qwen', () => { + test('returns correct layout for qwen', () => { + const layout = resolveRuntimeArtifactLayout('qwen', FAKE_DIR); + assert.strictEqual(layout.runtime, 'qwen'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — hermes', () => { + test('returns correct layout for hermes', () => { + const layout = resolveRuntimeArtifactLayout('hermes', FAKE_DIR); + assert.strictEqual(layout.runtime, 'hermes'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd'); + assert.strictEqual(layout.kinds[0].prefix, ''); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — codebuddy', () => { + test('returns correct layout for codebuddy', () => { + const layout = resolveRuntimeArtifactLayout('codebuddy', FAKE_DIR); + assert.strictEqual(layout.runtime, 'codebuddy'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — cline', () => { + test('returns correct layout for cline', () => { + const layout = resolveRuntimeArtifactLayout('cline', FAKE_DIR); + assert.strictEqual(layout.runtime, 'cline'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 0); + }); +}); + +describe('resolveRuntimeArtifactLayout — opencode', () => { + test('returns correct layout for opencode', () => { + const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR); + assert.strictEqual(layout.runtime, 'opencode'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'command'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); + +describe('resolveRuntimeArtifactLayout — kilo', () => { + test('returns correct layout for kilo', () => { + const layout = resolveRuntimeArtifactLayout('kilo', FAKE_DIR); + assert.strictEqual(layout.runtime, 'kilo'); + assert.strictEqual(layout.configDir, FAKE_DIR); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + assert.strictEqual(layout.kinds[0].destSubpath, 'command'); + assert.strictEqual(layout.kinds[0].prefix, 'gsd-'); + assert.strictEqual(typeof layout.kinds[0].stage, 'function'); + }); +}); From 8239fe05673ec05ab69035c2c7239d33a70a6354 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:18:55 -0400 Subject: [PATCH 09/27] test(3663): add edge-case tests for runtime-artifact-layout Covers hermes nested subpath, cline empty kinds, gemini commands kind, claude scope variants, grok/unknown runtime TypeError, empty configDir, and bad scope validation. Co-Authored-By: Claude Sonnet 4.6 --- ...untime-artifact-layout-edge-cases.test.cjs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/runtime-artifact-layout-edge-cases.test.cjs diff --git a/tests/runtime-artifact-layout-edge-cases.test.cjs b/tests/runtime-artifact-layout-edge-cases.test.cjs new file mode 100644 index 000000000..c14b25c7e --- /dev/null +++ b/tests/runtime-artifact-layout-edge-cases.test.cjs @@ -0,0 +1,94 @@ +'use strict'; +/** + * Edge-case tests for resolveRuntimeArtifactLayout. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); + +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); + +describe('hermes nested layout', () => { + test('hermes has destSubpath skills/gsd and empty prefix', () => { + const layout = resolveRuntimeArtifactLayout('hermes', '/tmp/x'); + assert.strictEqual(layout.kinds[0].destSubpath, 'skills/gsd'); + assert.strictEqual(layout.kinds[0].prefix, ''); + }); +}); + +describe('cline empty kinds', () => { + test('cline has no kinds', () => { + const layout = resolveRuntimeArtifactLayout('cline', '/tmp/x'); + assert.strictEqual(layout.kinds.length, 0); + }); +}); + +describe('gemini commands layout', () => { + test('gemini has one commands kind', () => { + const layout = resolveRuntimeArtifactLayout('gemini', '/tmp/x'); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'commands'); + }); +}); + +describe('claude scope=local has commands and agents', () => { + test('claude local has both commands and agents kinds', () => { + const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'local'); + const kindNames = layout.kinds.map(k => k.kind); + assert.ok(kindNames.includes('commands'), 'should have commands kind'); + assert.ok(kindNames.includes('agents'), 'should have agents kind'); + }); +}); + +describe('claude scope=global has only skills', () => { + test('claude global has only skills kind', () => { + const layout = resolveRuntimeArtifactLayout('claude', '/tmp/x', 'global'); + assert.strictEqual(layout.kinds.length, 1); + assert.strictEqual(layout.kinds[0].kind, 'skills'); + }); +}); + +describe('unknown runtime throws TypeError', () => { + test('grok throws TypeError containing "grok"', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('grok', '/tmp/x'), + (err) => { + assert.ok(err instanceof TypeError); + assert.ok(err.message.includes('grok'), 'error message must contain the runtime name'); + return true; + } + ); + }); + + test('xyzunknown throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('xyzunknown', '/tmp/x'), + TypeError + ); + }); +}); + +describe('invalid configDir throws TypeError', () => { + test('empty configDir throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', ''), + TypeError + ); + }); + + test('non-string configDir throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', null), + TypeError + ); + }); +}); + +describe('invalid scope throws TypeError', () => { + test('bad scope throws TypeError', () => { + assert.throws( + () => resolveRuntimeArtifactLayout('claude', '/x', 'invalid'), + TypeError + ); + }); +}); From a48683e93e8b08d949d336e5df1b632faf9afed8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:19:37 -0400 Subject: [PATCH 10/27] test(3663): add stage invocation tests for commands, agents, skills kinds Tests verify that kind.stage(resolvedProfile) produces correct directory structure: .md files for commands, agent .md files for agents, and gsd-/SKILL.md dirs for skills kinds. Co-Authored-By: Claude Sonnet 4.6 --- tests/runtime-artifact-layout-stage.test.cjs | 115 +++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 tests/runtime-artifact-layout-stage.test.cjs diff --git a/tests/runtime-artifact-layout-stage.test.cjs b/tests/runtime-artifact-layout-stage.test.cjs new file mode 100644 index 000000000..c53d92963 --- /dev/null +++ b/tests/runtime-artifact-layout-stage.test.cjs @@ -0,0 +1,115 @@ +'use strict'; +/** + * Tests for resolveRuntimeArtifactLayout kind.stage() invocations. + * Verifies each kind type produces the expected staged directory structure. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); + +// A small resolved profile selecting known real skills +const CORE_SKILLS = new Set(['help', 'phase', 'new-project']); +const CORE_AGENTS = new Set(['gsd-planner']); +const PROFILE_CORE = { skills: CORE_SKILLS, agents: CORE_AGENTS }; +const PROFILE_FULL = { skills: '*', agents: new Set() }; + +const FAKE_DIR = '/tmp/fake-config-dir-stage'; + +describe('commands kind — stage (gemini)', () => { + test('stage returns a directory containing only the selected skill .md files', () => { + const layout = resolveRuntimeArtifactLayout('gemini', FAKE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'should have a commands kind'); + + const stagedDir = commandsKind.stage(PROFILE_CORE); + try { + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + // All staged files must come from the selected skill set + for (const entry of entries) { + const stem = entry.slice(0, -3); + assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); + } + // At least one file must be present (help.md exists in real commands/gsd) + assert.ok(entries.length >= 1, 'at least one skill file should be staged'); + } finally { + // stagedDir is managed by stageSkillsForProfile — just verify, don't cleanup manually + } + }); +}); + +describe('agents kind — stage (claude local)', () => { + test('stage returns a directory containing only the selected agent .md files', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'local'); + const agentsKind = layout.kinds.find(k => k.kind === 'agents'); + assert.ok(agentsKind, 'should have an agents kind'); + + const stagedDir = agentsKind.stage(PROFILE_CORE); + // stagedDir may be empty (agents Set from core profile may be empty set) + // But it must be a valid directory + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + assert.ok(fs.statSync(stagedDir).isDirectory(), 'stagedDir must be a directory'); + }); +}); + +describe('skills kind — stage (claude global)', () => { + test('stage returns a directory containing gsd-/SKILL.md entries', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'should have a skills kind'); + + const stagedDir = skillsKind.stage(PROFILE_CORE); + try { + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir); + // Each entry should be a directory named gsd- + for (const entry of entries) { + assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); + const skillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); + } + assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); + } finally { + // managed by stageSkillsForRuntimeAsSkills + } + }); +}); + +describe('skills kind — stage with full profile', () => { + test('stage with skills="*" stages all commands/gsd/*.md as skills', () => { + const layout = resolveRuntimeArtifactLayout('claude', FAKE_DIR, 'global'); + const skillsKind = layout.kinds.find(k => k.kind === 'skills'); + assert.ok(skillsKind, 'should have a skills kind'); + + const stagedDir = skillsKind.stage(PROFILE_FULL); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir); + // Full profile: should have many skills + assert.ok(entries.length > 10, `full profile should have many skills, got ${entries.length}`); + for (const entry of entries) { + assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); + const skillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); + } + }); +}); + +describe('opencode commands kind — stage', () => { + test('opencode stage returns directory with .md files for selected skills', () => { + const layout = resolveRuntimeArtifactLayout('opencode', FAKE_DIR); + const commandsKind = layout.kinds.find(k => k.kind === 'commands'); + assert.ok(commandsKind, 'should have a commands kind'); + + const stagedDir = commandsKind.stage(PROFILE_CORE); + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + for (const entry of entries) { + const stem = entry.slice(0, -3); + assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); + } + }); +}); From 4d578394eb335f2c8d1e1f4c133ce0a23bb6afa8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:24:04 -0400 Subject: [PATCH 11/27] refactor(3663): migrate applySurface to layout-driven loop; add skills kind to _syncGsdDir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) → applySurface(runtimeConfigDir, layout, manifest, clusterMap) - _syncGsdDir extended to handle skills kind (dirs not files, prefix-gated removal) - _findInstallSource/_findAgentsSource deleted; listSurface now uses findInstallSourceRoot() from runtime-artifact-layout.cjs - findInstallSourceRoot exported from runtime-artifact-layout.cjs - test(3663): update surface-apply.test.cjs to new layout-passing shape + add skills kind test Co-Authored-By: Claude Sonnet 4.6 --- .../bin/lib/runtime-artifact-layout.cjs | 2 +- get-shit-done/bin/lib/surface.cjs | 195 +++++++----------- tests/surface-apply.test.cjs | 86 ++++++-- 3 files changed, 147 insertions(+), 136 deletions(-) diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs index 25412b62c..2aac59b93 100644 --- a/get-shit-done/bin/lib/runtime-artifact-layout.cjs +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -228,4 +228,4 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { return { runtime, configDir, kinds }; } -module.exports = { resolveRuntimeArtifactLayout }; +module.exports = { resolveRuntimeArtifactLayout, findInstallSourceRoot }; diff --git a/get-shit-done/bin/lib/surface.cjs b/get-shit-done/bin/lib/surface.cjs index 00168c5ce..5fc22057c 100644 --- a/get-shit-done/bin/lib/surface.cjs +++ b/get-shit-done/bin/lib/surface.cjs @@ -13,8 +13,8 @@ * readSurface(runtimeConfigDir) * writeSurface(runtimeConfigDir, surfaceState) * resolveSurface(runtimeConfigDir, manifest, clusterMap) - * applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) - * listSurface(runtimeConfigDir, manifest, clusterMap) + * applySurface(runtimeConfigDir, layout, manifest, clusterMap) + * listSurface(runtimeConfigDir, layout, manifest, clusterMap) */ const fs = require('fs'); @@ -31,6 +31,7 @@ const { PROFILES, } = require('./install-profiles.cjs'); const { CLUSTERS, allClusteredSkills } = require('./clusters.cjs'); +const { findInstallSourceRoot } = require('./runtime-artifact-layout.cjs'); const SURFACE_FILE_NAME = '.gsd-surface.json'; @@ -194,146 +195,99 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) { // --------------------------------------------------------------------------- /** - * Re-stage the active surface to commandsDir and agentsDir in-place. - * Only touches files matching `gsd-` prefix or `*.md` in commandsDir. - * Never touches non-`gsd-*` files. - * - * Steps: - * 1. Resolve surface → active skill/agent sets - * 2. Stage to temp dirs via stageSkillsForProfile / stageAgentsForProfile - * 3. Find the install source (where skill files live) - * 4. Sync: copy missing, delete superseded (gsd-only) + * Re-stage the active surface using the resolved layout. + * Iterates layout.kinds and syncs each artifact kind to its destination. * * @param {string} runtimeConfigDir - * @param {string} commandsDir runtime commands/gsd dir (resolved per-runtime by callers) - * @param {string} agentsDir runtime agents dir (resolved per-runtime by callers) + * @param {import('./runtime-artifact-layout.cjs').Layout} layout * @param {Map} manifest * @param {Object} [clusterMap] */ -function applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap) { +function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap); - - // Find install source - const srcCommandsDir = _findInstallSource(runtimeConfigDir); - - // Stage skills - const stagedSkills = stageSkillsForProfile(srcCommandsDir, resolved); - - // Sync commandsDir from stagedSkills - _syncGsdDir(stagedSkills, commandsDir, 'commands'); - - // Stage and sync agents - if (agentsDir && fs.existsSync(agentsDir)) { - const srcAgentsDir = _findAgentsSource(runtimeConfigDir); - if (srcAgentsDir) { - const stagedAgents = stageAgentsForProfile(srcAgentsDir, resolved); - _syncGsdDir(stagedAgents, agentsDir, 'agents'); + for (const kind of layout.kinds) { + const staged = kind.stage(resolved); + const dest = path.join(layout.configDir, kind.destSubpath); + if (fs.existsSync(dest)) { + _syncGsdDir(staged, dest, kind); } } + return resolved; } /** * Sync destination directory from staged source. - * Adds files present in staged but missing in dest. - * Removes gsd-prefixed .md files in dest not present in staged. - * Never touches non-gsd files. + * + * For 'commands' kind: iterate *.md files in destDir, remove if not in staged set. + * For 'agents' kind: same, but only remove files starting with 'gsd-' prefix. + * For 'skills' kind: iterate directories in destDir matching kind.prefix; add missing + * by copying recursively; remove dirs not in staged set. Preserves dirs not matching + * the prefix (user-owned skills). * * @param {string} stagedDir source (staged temp dir or original) * @param {string} destDir runtime destination - * @param {'commands'|'agents'} context + * @param {import('./runtime-artifact-layout.cjs').ArtifactKind|'commands'|'agents'} kind */ -function _syncGsdDir(stagedDir, destDir, context) { +function _syncGsdDir(stagedDir, destDir, kind) { if (!fs.existsSync(stagedDir)) return; fs.mkdirSync(destDir, { recursive: true }); - const stagedFiles = new Set( - fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')) - ); + // Normalize: allow legacy string context for backward-compat with internal callers + const kindName = (typeof kind === 'string') ? kind : kind.kind; + const kindPrefix = (typeof kind === 'object' && kind !== null) ? kind.prefix : 'gsd-'; - // Copy missing files from staged to dest - for (const file of stagedFiles) { - const destFile = path.join(destDir, file); - if (!fs.existsSync(destFile)) { - fs.copyFileSync(path.join(stagedDir, file), destFile); - } else { - // Overwrite to ensure content is current - fs.copyFileSync(path.join(stagedDir, file), destFile); - } - } + if (kindName === 'skills') { + // Skills kind: work with directories, not files. + // Each staged entry is a directory named ${prefix}${stem}. + const stagedDirs = new Set( + fs.readdirSync(stagedDir).filter(entry => { + return fs.statSync(path.join(stagedDir, entry)).isDirectory(); + }) + ); - // Remove gsd-only files from dest that aren't in staged set - // For commands dir: all .md files are gsd skills - // For agents dir: only gsd-* files - const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md')); - for (const file of destEntries) { - if (context === 'agents' && !file.startsWith('gsd-')) continue; - if (!stagedFiles.has(file)) { - try { fs.unlinkSync(path.join(destDir, file)); } catch {} - } - } -} - -/** - * Find the install source commands/gsd directory. - * Checks the runtime's `.gsd-source` marker (sibling of the surface state file), - * then walks up from __dirname to find the installed package source. - * - * @param {string} runtimeConfigDir - * @returns {string} path to install source commands/gsd - */ -function _findInstallSource(runtimeConfigDir) { - // Check for .gsd-source marker - const sourceMarker = path.join(runtimeConfigDir, '.gsd-source'); - if (fs.existsSync(sourceMarker)) { - try { - const src = fs.readFileSync(sourceMarker, 'utf8').trim(); - if (src && fs.existsSync(src)) return src; - } catch {} - } - - // Walk up from this module's dir to find commands/gsd - let dir = __dirname; - for (let i = 0; i < 6; i++) { - const candidate = path.join(dir, 'commands', 'gsd'); - if (fs.existsSync(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; - } - - // Fallback: the runtimeConfigDir itself - return path.join(runtimeConfigDir, '..', 'commands', 'gsd'); -} - -/** - * Find the install source agents directory. - * - * @param {string} runtimeConfigDir - * @returns {string|null} - */ -function _findAgentsSource(runtimeConfigDir) { - // Prefer .gsd-source sibling marker (commands/gsd) and derive agents from it. - const sourceMarker = path.join(runtimeConfigDir, '.gsd-source'); - if (fs.existsSync(sourceMarker)) { - try { - const commandsSrc = fs.readFileSync(sourceMarker, 'utf8').trim(); - if (commandsSrc && fs.existsSync(commandsSrc)) { - const commandsParent = path.dirname(commandsSrc); // .../commands - const candidate = path.resolve(commandsParent, '..', 'agents'); - if (fs.existsSync(candidate)) return candidate; + // Copy missing dirs from staged to dest + for (const dirName of stagedDirs) { + const destSubDir = path.join(destDir, dirName); + if (!fs.existsSync(destSubDir)) { + fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true }); + } else { + // Overwrite to ensure content is current + fs.cpSync(path.join(stagedDir, dirName), destSubDir, { recursive: true }); } - } catch {} - } + } - let dir = __dirname; - for (let i = 0; i < 6; i++) { - const candidate = path.join(dir, 'agents'); - if (fs.existsSync(candidate)) return candidate; - const parent = path.dirname(dir); - if (parent === dir) break; - dir = parent; + // Remove dirs in dest that match the prefix but are not in staged set + const destEntries = fs.readdirSync(destDir); + for (const entry of destEntries) { + const entryPath = path.join(destDir, entry); + if (!fs.statSync(entryPath).isDirectory()) continue; + if (!entry.startsWith(kindPrefix)) continue; // only touch prefix-matched dirs + if (!stagedDirs.has(entry)) { + try { fs.rmSync(entryPath, { recursive: true, force: true }); } catch {} + } + } + } else { + // commands / agents kind: work with .md files + const stagedFiles = new Set( + fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')) + ); + + // Copy files from staged to dest (overwrite to keep content current) + for (const file of stagedFiles) { + fs.copyFileSync(path.join(stagedDir, file), path.join(destDir, file)); + } + + // Remove gsd-only files from dest that aren't in staged set + // For commands dir: all .md files are gsd skills + // For agents dir: only gsd-* files + const destEntries = fs.readdirSync(destDir).filter(f => f.endsWith('.md')); + for (const file of destEntries) { + if (kindName === 'agents' && !file.startsWith('gsd-')) continue; + if (!stagedFiles.has(file)) { + try { fs.unlinkSync(path.join(destDir, file)); } catch {} + } + } } - return null; } // --------------------------------------------------------------------------- @@ -344,7 +298,7 @@ function _findAgentsSource(runtimeConfigDir) { * List the currently enabled and disabled skills with token cost. * * Token cost = sum of description lengths ÷ 4 (mirrors audit script). - * Descriptions are read from the installed commandsDir skill files. + * Descriptions are read from the install source (findInstallSourceRoot). * * @param {string} runtimeConfigDir * @param {Map} manifest @@ -366,7 +320,7 @@ function listSurface(runtimeConfigDir, manifest, clusterMap) { const disabled = allStems.filter(s => !enabledSet.has(s)).sort(); // Compute token cost by reading descriptions from the install source - const srcCommandsDir = _findInstallSource(runtimeConfigDir); + const srcCommandsDir = findInstallSourceRoot(); let tokenCost = 0; for (const stem of enabled) { const filePath = path.join(srcCommandsDir, `${stem}.md`); @@ -393,6 +347,5 @@ module.exports = { applySurface, listSurface, // Exported for testing - _findInstallSource, _syncGsdDir, }; diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index 05152c6c7..da148c8d1 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -12,6 +12,7 @@ const os = require('os'); const { writeSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs'); const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs'); +const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); @@ -21,20 +22,18 @@ function tmpDir() { } /** - * Create a minimal fixture install dir structure. - * Returns { runtimeConfigDir, commandsDir, agentsDir }. - * runtimeConfigDir has a .gsd-source marker pointing to REAL_COMMANDS_DIR. + * Create a minimal fixture install dir structure for claude/local layout. + * runtimeConfigDir is the layout configDir. + * commandsDir = runtimeConfigDir/commands/gsd + * agentsDir = runtimeConfigDir/agents */ function createFixtureRuntime() { const base = tmpDir(); - const runtimeConfigDir = path.join(base, 'config'); - const commandsDir = path.join(base, 'commands', 'gsd'); - const agentsDir = path.join(base, 'agents'); - fs.mkdirSync(runtimeConfigDir, { recursive: true }); + const runtimeConfigDir = base; + const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); + const agentsDir = path.join(runtimeConfigDir, 'agents'); fs.mkdirSync(commandsDir, { recursive: true }); fs.mkdirSync(agentsDir, { recursive: true }); - // Write source marker so surface.cjs can find the install source - fs.writeFileSync(path.join(runtimeConfigDir, '.gsd-source'), REAL_COMMANDS_DIR, 'utf8'); return { base, runtimeConfigDir, commandsDir, agentsDir }; } @@ -50,7 +49,8 @@ describe('applySurface', () => { explicitRemoves: [], }); const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); // Every file should be a real stem we know about @@ -79,7 +79,8 @@ describe('applySurface', () => { explicitRemoves: [], }); const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); @@ -90,7 +91,7 @@ describe('applySurface', () => { explicitAdds: [], explicitRemoves: [], }); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); @@ -126,7 +127,8 @@ describe('applySurface', () => { explicitRemoves: [], }); const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); // Non-gsd file should still be there assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); @@ -147,7 +149,8 @@ describe('applySurface', () => { explicitRemoves: [], }); const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); // Core skills should now be present assert.ok( @@ -162,4 +165,59 @@ describe('applySurface', () => { fs.rmSync(base, { recursive: true, force: true }); } }); + + test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', () => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + const { stageSkillsForRuntimeAsSkills } = require('../get-shit-done/bin/lib/install-profiles.cjs'); + const { findInstallSourceRoot } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); + // Minimal converter that produces SKILL.md with given stem + function converter(stem, content) { + return [ + '---', + `name: ${stem}`, + '---', + content, + ].join('\n'); + } + + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-skills-')); + try { + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + // Build a staged dir manually: gsd-help/SKILL.md and gsd-update/SKILL.md + const stem1 = 'gsd-help'; + const stem2 = 'gsd-update'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); + + // In destDir: stale gsd- dir + foreign user dir + const staleDir = path.join(destDir, 'gsd-old-skill'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); + + const foreignDir = path.join(destDir, 'my-custom-skill'); + fs.mkdirSync(foreignDir, { recursive: true }); + fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); + + const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; + + _syncGsdDir(stagedDir, destDir, skillsKind); + + // staged dirs copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); + assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); + + // stale gsd- dir removed + assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); + + // foreign dir preserved + assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); }); From 05313137893632d89ad07e5bfed8bee6215bf504 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:24:20 -0400 Subject: [PATCH 12/27] docs(3663): update surface.md runbook to layout-passing applySurface signature Replace bare applySurface(runtimeConfigDir, commandsDir, agentsDir, ...) calls in profile/disable/enable sections with resolveRuntimeArtifactLayout + applySurface(runtimeConfigDir, layout, ...) pair. Co-Authored-By: Claude Sonnet 4.6 --- commands/gsd/surface.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/commands/gsd/surface.md b/commands/gsd/surface.md index 1da042762..2d329b69d 100644 --- a/commands/gsd/surface.md +++ b/commands/gsd/surface.md @@ -64,7 +64,11 @@ Install profile: standard (from .gsd-profile) 1. Read current surface: `readSurface(runtimeConfigDir)` → if null, seed from `readActiveProfile(runtimeConfigDir)`. 2. Set `surfaceState.baseProfile = name`. 3. `writeSurface(runtimeConfigDir, surfaceState)`. -4. Resolve and re-apply: `applySurface(runtimeConfigDir, commandsDir, agentsDir, manifest, CLUSTERS)`. +4. Resolve and re-apply: + ```js + const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + ``` 5. Confirm: "Surface updated to profile ``. N skills enabled." --- @@ -77,7 +81,11 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` 1. Validate cluster name against `Object.keys(CLUSTERS)`. 2. Read or initialize surface state. 3. Add cluster to `surfaceState.disabledClusters` (deduplicate). -4. `writeSurface` → `applySurface`. +4. `writeSurface` → resolve layout → `applySurface`: + ```js + const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + ``` 5. Confirm: "Disabled cluster ``. N skills removed from surface." --- @@ -86,7 +94,11 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` 1. Read surface state; if null, nothing to enable — print "No surface delta active." 2. Remove cluster from `surfaceState.disabledClusters`. -3. `writeSurface` → `applySurface`. +3. `writeSurface` → resolve layout → `applySurface`: + ```js + const layout = resolveRuntimeArtifactLayout(runtime, runtimeConfigDir, scope); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + ``` 4. Confirm: "Enabled cluster ``. N skills added back to surface." --- From fb2f25101024c1fbaf43a5285acdfaf5d1588dd7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:26:08 -0400 Subject: [PATCH 13/27] docs(3663): accept ADR-3660; add Phase 1 implementation status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip status from Proposed → Accepted and record the branch, implementation reference, and Phase 2 dependency. Co-Authored-By: Claude Sonnet 4.6 --- docs/adr/3660-runtime-artifact-layout-module.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/adr/3660-runtime-artifact-layout-module.md b/docs/adr/3660-runtime-artifact-layout-module.md index 541610710..471dd5400 100644 --- a/docs/adr/3660-runtime-artifact-layout-module.md +++ b/docs/adr/3660-runtime-artifact-layout-module.md @@ -1,8 +1,9 @@ # Runtime Artifact Layout Module owns per-runtime artifact placement -- **Status:** Proposed +- **Status:** Accepted - **Date:** 2026-05-17 - **Issue:** #3660 +- **Implementation:** #3663 (Phase 1), feat/3663-runtime-artifact-layout-module-phase-1-m The **Runtime Surface Module** (`get-shit-done/bin/lib/surface.cjs`, introduced by ADR-0011 Phase 2) re-materializes a resolved Skill Surface profile to disk via `applySurface`. It currently hardcodes two artifact kinds (`commands`, `agents`) and re-derives their source directories via `_findInstallSource` / `_findAgentsSource` walk-up heuristics. The install and uninstall pipelines in `bin/install.js` each encode the same per-runtime artifact layout independently across ~14 install sites and ~6 uninstall sites. Bug #3659 surfaced the resulting drift: `applySurface` omits the `skills` kind for runtimes whose canonical layout is `skills/gsd-/SKILL.md`, so `gsd-surface profile ` leaves ~67 skill directories on disk under the install-time profile's footprint when the resolved profile should have pruned them — roughly 2.7k tokens per session on a measured workstation. @@ -134,3 +135,13 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { - Existing canonical sibling: `get-shit-done/bin/lib/runtime-homes.cjs` - Per-runtime skill converters this module references: `bin/install.js:1622` (Copilot), `:1681` (Claude), `:1792` (Antigravity), `:2534` (Codex) - Hermes nested-skills layout rationale: `#2841` + +## Implementation status + +Phase 1 implementation landed on `feat/3663-runtime-artifact-layout-module-phase-1-m`: +- `get-shit-done/bin/lib/runtime-artifact-layout.cjs` — 15-runtime layout table (grok intentionally excluded), `resolveRuntimeArtifactLayout(runtime, configDir, scope) → Layout`, walk-up `findInstallSourceRoot` helper. +- `get-shit-done/bin/lib/install-profiles.cjs` — new `stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) → stagedDir` helper. +- `get-shit-done/bin/lib/surface.cjs` — `applySurface(runtimeConfigDir, layout, manifest, clusterMap)` signature migration; `_findInstallSource` + `_findAgentsSource` deleted; `_syncGsdDir` extended to handle the `skills` kind via directory iteration. +- Tests: `runtime-artifact-layout-resolve.test.cjs` (16), `runtime-artifact-layout-edge-cases.test.cjs` (10), `runtime-artifact-layout-stage.test.cjs` (5), `install-profiles-stage.test.cjs` (+7 new), `surface-apply.test.cjs` (updated 5 call sites + new skills-kind test). + +Phase 2 (separate issue #3664 — `bin/install.js` install/uninstall pipeline migration) is blocked on Phase 1 merge. From 9476ae863fba01a287f83fb00c7e5449c8d37f38 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:26:11 -0400 Subject: [PATCH 14/27] chore(3663): add changeset for runtime artifact layout module User-visible: applySurface now prunes skills/gsd-*/ dirs on profile switch, closing the structural gap behind #3659. Co-Authored-By: Claude Sonnet 4.6 --- .changeset/swift-otter-pebble.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/swift-otter-pebble.md diff --git a/.changeset/swift-otter-pebble.md b/.changeset/swift-otter-pebble.md new file mode 100644 index 000000000..7eff59099 --- /dev/null +++ b/.changeset/swift-otter-pebble.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3663 +--- +**`gsd-surface profile` no longer leaves stale skill directories on disk** — `applySurface` migrates to a typed per-runtime artifact layout, so switching profiles now correctly prunes `skills/gsd-*/` directories across every runtime that materializes them (Claude global, Codex, Cursor, Windsurf, Trae, CodeBuddy, Copilot, Antigravity, Hermes, Qwen). Closes the structural gap behind #3659. Internal: introduces the Runtime Artifact Layout Module (`get-shit-done/bin/lib/runtime-artifact-layout.cjs`); `applySurface(runtimeConfigDir, layout, manifest, clusterMap)` is the new signature for internal callers. (#3663) From 8c16b1d3388a731f145dab9758e4d05dc5e1cb76 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:40:19 -0400 Subject: [PATCH 15/27] fix(3663): preserve Hermes user-skill dirs under skills/gsd/ namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When kindPrefix === '' (Hermes: destSubpath=skills/gsd, no per-skill prefix), startsWith('') always returns true so the prior removal loop would delete any dir not in the staged set — including user-owned skill dirs. Guard the entire removal block behind kindPrefix !== '' so non-staged dirs are never pruned when there is no prefix to distinguish GSD-owned from user-owned entries. TDD: failing test added first asserting user-custom-skill is preserved through a _syncGsdDir call with kindPrefix=''. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/lib/surface.cjs | 23 +++++++++++++------- tests/surface-apply.test.cjs | 35 +++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/get-shit-done/bin/lib/surface.cjs b/get-shit-done/bin/lib/surface.cjs index 5fc22057c..87986b855 100644 --- a/get-shit-done/bin/lib/surface.cjs +++ b/get-shit-done/bin/lib/surface.cjs @@ -256,14 +256,21 @@ function _syncGsdDir(stagedDir, destDir, kind) { } } - // Remove dirs in dest that match the prefix but are not in staged set - const destEntries = fs.readdirSync(destDir); - for (const entry of destEntries) { - const entryPath = path.join(destDir, entry); - if (!fs.statSync(entryPath).isDirectory()) continue; - if (!entry.startsWith(kindPrefix)) continue; // only touch prefix-matched dirs - if (!stagedDirs.has(entry)) { - try { fs.rmSync(entryPath, { recursive: true, force: true }); } catch {} + // Empty prefix = destSubpath is the GSD namespace (Hermes: skills/gsd/). + // With no prefix filter, we cannot safely distinguish GSD-owned from user-owned dirs, + // so we only remove dirs that match the prefix. When kindPrefix === '', + // startsWith('') is always true but we must guard: skip removal entirely if prefix is + // empty so user dirs under skills/gsd/ are preserved (Hermes user-skill safety). + if (kindPrefix !== '') { + // Remove prefix-matched dirs in dest that are not in staged set + const destEntries = fs.readdirSync(destDir); + for (const entry of destEntries) { + const entryPath = path.join(destDir, entry); + if (!fs.statSync(entryPath).isDirectory()) continue; + if (!entry.startsWith(kindPrefix)) continue; // preserve user-owned dirs + if (!stagedDirs.has(entry)) { + try { fs.rmSync(entryPath, { recursive: true, force: true }); } catch {} + } } } } else { diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index da148c8d1..1bcac5a72 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -220,4 +220,39 @@ describe('applySurface', () => { fs.rmSync(base, { recursive: true, force: true }); } }); + + test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', () => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-hermes-')); + try { + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + // Staged contains a GSD skill named 'help' (no prefix under hermes skills/gsd/) + const stem1 = 'help'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + + // Dest also has a user-owned custom skill dir (no gsd- prefix — Hermes namespace) + const userDir = path.join(destDir, 'user-custom-skill'); + fs.mkdirSync(userDir, { recursive: true }); + fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); + + // kindPrefix === '' simulates Hermes (destSubpath = skills/gsd, prefix = '') + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + + _syncGsdDir(stagedDir, destDir, hermesKind); + + // The user's custom skill dir must be preserved — it's not in staged but should not be removed + // (Fix 4: when kindPrefix === '', skip the startsWith guard and preserve ALL non-staged dirs) + assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); + + // The staged skill must still be copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } + }); }); From 3711a4f04fe4fd60934ec5f1474e820f1a20b05d Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:42:19 -0400 Subject: [PATCH 16/27] fix(3663): replace ../../.. fallback with descriptive error per CLAUDE.md Both findInstallSourceRoot and findAgentsSourceRoot previously returned a path.join(__dirname, '..', '..', '..', ...) fallback when the walk-up loop found nothing. Replace with throw per CLAUDE.md "No Relative Path Traversal" policy: .. chains silently break on CWD changes; a throw with the failing __dirname in the message is an unambiguous diagnostic. The walk-up loop already uses path.dirname iteratively (no literal ..); only the dead-end fallback used the banned pattern. Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/lib/runtime-artifact-layout.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs index 2aac59b93..ce62d827e 100644 --- a/get-shit-done/bin/lib/runtime-artifact-layout.cjs +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -66,7 +66,7 @@ function findInstallSourceRoot(overrideRoot) { if (parent === dir) break; dir = parent; } - return path.join(__dirname, '..', '..', '..', 'commands', 'gsd'); + throw new Error(`findInstallSourceRoot: could not locate commands/gsd from ${__dirname}`); } /** @@ -85,7 +85,7 @@ function findAgentsSourceRoot(overrideRoot) { if (parent === dir) break; dir = parent; } - return null; + throw new Error(`findAgentsSourceRoot: could not locate agents/ from ${__dirname}`); } // --------------------------------------------------------------------------- From 67dd09e509a94c87b0bc79af220d3bccf25d295e Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:43:12 -0400 Subject: [PATCH 17/27] fix(3663): restore .gsd-source marker resolution in findInstallSourceRoot Both findInstallSourceRoot and findAgentsSourceRoot now accept an optional runtimeConfigDir. When provided, they first check /.gsd-source: read the stored path, verify it exists, and return it. Fall through to the path.dirname walk-up only when the marker is absent or points to a missing path. The factory functions (commandsKind, agentsKind, skillsKind) now receive configDir from the switch arms and pass it through to the finders. listSurface in surface.cjs passes its runtimeConfigDir argument to findInstallSourceRoot so installed-from-source layouts use the marker rather than the walk-up. Co-Authored-By: Claude Sonnet 4.6 --- .../bin/lib/runtime-artifact-layout.cjs | 101 ++++++++++++------ get-shit-done/bin/lib/surface.cjs | 2 +- 2 files changed, 70 insertions(+), 33 deletions(-) diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs index ce62d827e..9721d1690 100644 --- a/get-shit-done/bin/lib/runtime-artifact-layout.cjs +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -50,14 +50,29 @@ const { // --------------------------------------------------------------------------- /** - * Walk up from __dirname to find commands/gsd. Walk-up-only for Phase 1; - * the .gsd-source marker check (which requires runtimeConfigDir) is deferred. + * Locate the GSD commands/gsd source directory. * - * @param {string} [overrideRoot] optional override for testability + * Resolution order: + * 1. If runtimeConfigDir provided, check /.gsd-source marker. + * 2. Walk up from __dirname using path.dirname (no literal .. segments). + * 3. Throw a descriptive error if neither succeeds. + * + * @param {string} [runtimeConfigDir] optional runtime config directory * @returns {string} */ -function findInstallSourceRoot(overrideRoot) { - if (overrideRoot) return overrideRoot; +function findInstallSourceRoot(runtimeConfigDir) { + // Step 1: marker check + if (runtimeConfigDir) { + const markerPath = path.join(runtimeConfigDir, '.gsd-source'); + if (fs.existsSync(markerPath)) { + try { + const src = fs.readFileSync(markerPath, 'utf8').trim(); + if (src && fs.existsSync(src)) return src; + } catch { /* fall through */ } + } + } + + // Step 2: walk up from __dirname let dir = __dirname; for (let i = 0; i < 6; i++) { const candidate = path.join(dir, 'commands', 'gsd'); @@ -66,17 +81,38 @@ function findInstallSourceRoot(overrideRoot) { if (parent === dir) break; dir = parent; } + throw new Error(`findInstallSourceRoot: could not locate commands/gsd from ${__dirname}`); } /** - * Walk up from __dirname to find agents/. Walk-up-only for Phase 1. + * Locate the GSD agents source directory. * - * @param {string} [overrideRoot] optional override for testability - * @returns {string|null} + * Resolution order: + * 1. If runtimeConfigDir provided, check /.gsd-source marker. + * 2. Walk up from __dirname using path.dirname (no literal .. segments). + * 3. Throw a descriptive error if neither succeeds. + * + * @param {string} [runtimeConfigDir] optional runtime config directory + * @returns {string} */ -function findAgentsSourceRoot(overrideRoot) { - if (overrideRoot) return overrideRoot; +function findAgentsSourceRoot(runtimeConfigDir) { + // Step 1: marker check + if (runtimeConfigDir) { + const markerPath = path.join(runtimeConfigDir, '.gsd-source'); + if (fs.existsSync(markerPath)) { + try { + const src = fs.readFileSync(markerPath, 'utf8').trim(); + if (src && fs.existsSync(src)) { + // Marker points to commands/gsd; agents/ is a sibling of commands/ + const agentsCandidate = path.resolve(path.dirname(src), '..', 'agents'); + if (fs.existsSync(agentsCandidate)) return agentsCandidate; + } + } catch { /* fall through */ } + } + } + + // Step 2: walk up from __dirname let dir = __dirname; for (let i = 0; i < 6; i++) { const candidate = path.join(dir, 'agents'); @@ -85,6 +121,7 @@ function findAgentsSourceRoot(overrideRoot) { if (parent === dir) break; dir = parent; } + throw new Error(`findAgentsSourceRoot: could not locate agents/ from ${__dirname}`); } @@ -102,30 +139,30 @@ const ALLOWED_RUNTIMES = new Set([ // Layout table builders // --------------------------------------------------------------------------- -function commandsKind(destSubpath, prefix, srcOverride) { +function commandsKind(destSubpath, prefix, configDir) { return { kind: 'commands', destSubpath, prefix, - stage: (resolved) => stageSkillsForProfile(findInstallSourceRoot(srcOverride), resolved), + stage: (resolved) => stageSkillsForProfile(findInstallSourceRoot(configDir), resolved), }; } -function agentsKind(destSubpath, prefix, srcOverride) { +function agentsKind(destSubpath, prefix, configDir) { return { kind: 'agents', destSubpath, prefix, - stage: (resolved) => stageAgentsForProfile(findAgentsSourceRoot(srcOverride), resolved), + stage: (resolved) => stageAgentsForProfile(findAgentsSourceRoot(configDir), resolved), }; } -function skillsKind(destSubpath, prefix, converter, srcOverride) { +function skillsKind(destSubpath, prefix, converter, configDir) { return { kind: 'skills', destSubpath, prefix, - stage: (resolved) => stageSkillsForRuntimeAsSkills(findInstallSourceRoot(srcOverride), resolved, converter, prefix), + stage: (resolved) => stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, converter, prefix), }; } @@ -157,56 +194,56 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { case 'claude': if (scope === 'local') { kinds = [ - commandsKind('commands/gsd', 'gsd-'), - agentsKind('agents', 'gsd-'), + commandsKind('commands/gsd', 'gsd-', configDir), + agentsKind('agents', 'gsd-', configDir), ]; } else { - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill, configDir)]; } break; case 'cursor': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCursorSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCursorSkill, configDir)]; break; case 'gemini': - kinds = [commandsKind('commands/gsd', 'gsd-')]; + kinds = [commandsKind('commands/gsd', 'gsd-', configDir)]; break; case 'codex': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodexSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodexSkill, configDir)]; break; case 'copilot': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCopilotSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCopilotSkill, configDir)]; break; case 'antigravity': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAntigravitySkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAntigravitySkill, configDir)]; break; case 'windsurf': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToWindsurfSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToWindsurfSkill, configDir)]; break; case 'augment': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAugmentSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAugmentSkill, configDir)]; break; case 'trae': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToTraeSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToTraeSkill, configDir)]; break; case 'qwen': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill, configDir)]; break; case 'hermes': - kinds = [skillsKind('skills/gsd', '', convertClaudeCommandToClaudeSkill)]; + kinds = [skillsKind('skills/gsd', '', convertClaudeCommandToClaudeSkill, configDir)]; break; case 'codebuddy': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodebuddySkill)]; + kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodebuddySkill, configDir)]; break; case 'cline': @@ -214,11 +251,11 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { break; case 'opencode': - kinds = [commandsKind('command', 'gsd-')]; + kinds = [commandsKind('command', 'gsd-', configDir)]; break; case 'kilo': - kinds = [commandsKind('command', 'gsd-')]; + kinds = [commandsKind('command', 'gsd-', configDir)]; break; default: diff --git a/get-shit-done/bin/lib/surface.cjs b/get-shit-done/bin/lib/surface.cjs index 87986b855..08e88d9f6 100644 --- a/get-shit-done/bin/lib/surface.cjs +++ b/get-shit-done/bin/lib/surface.cjs @@ -327,7 +327,7 @@ function listSurface(runtimeConfigDir, manifest, clusterMap) { const disabled = allStems.filter(s => !enabledSet.has(s)).sort(); // Compute token cost by reading descriptions from the install source - const srcCommandsDir = findInstallSourceRoot(); + const srcCommandsDir = findInstallSourceRoot(runtimeConfigDir); let tokenCost = 0; for (const stem of enabled) { const filePath = path.join(srcCommandsDir, `${stem}.md`); From be5fe61221b613c882da9bbdfc22a1d1c2ee4877 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:43:57 -0400 Subject: [PATCH 18/27] fix(3663): lazy-require converters; remove GSD_TEST_MODE env mutation at module load Previously runtime-artifact-layout.cjs set process.env.GSD_TEST_MODE at the top level before requiring bin/install.js. Any caller that required this module would silently inherit the GSD_TEST_MODE='1' side-effect for the lifetime of the process. Replace with a lazy loader (loadInstallExports / getInstallExports) that saves the current GSD_TEST_MODE, sets it to '1' only if it was undefined, calls require(), and restores the original value in a finally block. The exported module.exports cache (_installExports) ensures the require is run at most once. Converter names are now passed as strings to skillsKind; the stage closure resolves them via getInstallExports()[converterName] at call time, so the installer is not loaded until a stage() function is actually invoked. Verification: node -e "require('./...runtime-artifact-layout.cjs'); console.log(process.env.GSD_TEST_MODE)" prints undefined. Co-Authored-By: Claude Sonnet 4.6 --- .../bin/lib/runtime-artifact-layout.cjs | 78 ++++++++++++------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs index 9721d1690..238f22716 100644 --- a/get-shit-done/bin/lib/runtime-artifact-layout.cjs +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -18,19 +18,34 @@ const { stageSkillsForRuntimeAsSkills, } = require('./install-profiles.cjs'); -// Load converters from bin/install.js in test-safe way (GSD_TEST_MODE skips main logic) -process.env.GSD_TEST_MODE = process.env.GSD_TEST_MODE || '1'; -const { - convertClaudeCommandToClaudeSkill, - convertClaudeCommandToCursorSkill, - convertClaudeCommandToCodexSkill, - convertClaudeCommandToCopilotSkill, - convertClaudeCommandToAntigravitySkill, - convertClaudeCommandToWindsurfSkill, - convertClaudeCommandToAugmentSkill, - convertClaudeCommandToTraeSkill, - convertClaudeCommandToCodebuddySkill, -} = require('../../../bin/install.js'); +// --------------------------------------------------------------------------- +// Lazy installer exports (avoids GSD_TEST_MODE env mutation at module load) +// --------------------------------------------------------------------------- + +/** + * Load bin/install.js exports in a test-safe way. + * Sets GSD_TEST_MODE only for the duration of the require() call and only if + * it was not already set, restoring the original value in a finally block so + * the module-level environment is never permanently mutated. + */ +function loadInstallExports() { + const savedTestMode = process.env.GSD_TEST_MODE; + if (savedTestMode === undefined) process.env.GSD_TEST_MODE = '1'; + try { + // eslint-disable-next-line global-require -- lazy import to avoid running installer at module load + return require('../../../bin/install.js'); + } finally { + if (savedTestMode === undefined) delete process.env.GSD_TEST_MODE; + else process.env.GSD_TEST_MODE = savedTestMode; + } +} + +/** Cache after first successful load. */ +let _installExports = null; +function getInstallExports() { + if (!_installExports) _installExports = loadInstallExports(); + return _installExports; +} /** * @typedef {'commands'|'agents'|'skills'} ArtifactKindName @@ -157,12 +172,23 @@ function agentsKind(destSubpath, prefix, configDir) { }; } -function skillsKind(destSubpath, prefix, converter, configDir) { +/** + * Build a skills kind descriptor. + * + * @param {string} destSubpath + * @param {string} prefix + * @param {string} converterName name of converter function in bin/install.js exports + * @param {string} configDir runtime config dir (for .gsd-source marker resolution) + */ +function skillsKind(destSubpath, prefix, converterName, configDir) { return { kind: 'skills', destSubpath, prefix, - stage: (resolved) => stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, converter, prefix), + stage: (resolved) => { + const converter = getInstallExports()[converterName]; + return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, converter, prefix); + }, }; } @@ -198,12 +224,12 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { agentsKind('agents', 'gsd-', configDir), ]; } else { - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', configDir)]; } break; case 'cursor': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCursorSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', configDir)]; break; case 'gemini': @@ -211,39 +237,39 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { break; case 'codex': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodexSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodexSkill', configDir)]; break; case 'copilot': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCopilotSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCopilotSkill', configDir)]; break; case 'antigravity': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAntigravitySkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', configDir)]; break; case 'windsurf': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToWindsurfSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToWindsurfSkill', configDir)]; break; case 'augment': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToAugmentSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', configDir)]; break; case 'trae': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToTraeSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', configDir)]; break; case 'qwen': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToClaudeSkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', configDir)]; break; case 'hermes': - kinds = [skillsKind('skills/gsd', '', convertClaudeCommandToClaudeSkill, configDir)]; + kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', configDir)]; break; case 'codebuddy': - kinds = [skillsKind('skills', 'gsd-', convertClaudeCommandToCodebuddySkill, configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', configDir)]; break; case 'cline': From 40e325b91f05815d6d54e29dad1370d03960b804 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:44:53 -0400 Subject: [PATCH 19/27] fix(3663): pass runtime+cmdNames to Claude converter for Hermes/Qwen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit convertClaudeCommandToClaudeSkill(content, skillName, runtime, cmdNames) uses the runtime arg to gate Hermes/Qwen branding and version: frontmatter emission (#2808, #3583). Previously the layout module called it with only 2 args so the runtime-specific formatting was never applied. Changes: - skillsKind() gains a runtime param (5th arg after converterName). - stage() computes cmdNames = readGsdCommandNames() once per call (perf: avoids repeated fs.readdirSync in the converter) and wraps the real converter so all 4 args are forwarded. - readGsdCommandNames added to bin/install.js GSD_TEST_MODE exports block so the stage closure can call it without requiring the script separately. - All switch arms updated to pass the canonical runtime string. Converters that do not inspect runtime/cmdNames (Cursor, Codex, Copilot, etc.) accept and ignore the extra arguments — no behaviour change for those runtimes. Co-Authored-By: Claude Sonnet 4.6 --- bin/install.js | 1 + .../bin/lib/runtime-artifact-layout.cjs | 35 +++++++++++-------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/bin/install.js b/bin/install.js index 6f50db6eb..ea070b47b 100755 --- a/bin/install.js +++ b/bin/install.js @@ -11436,6 +11436,7 @@ if (process.env.GSD_TEST_MODE) { rewriteLegacyManagedNodeHookCommands, buildCodexHookBlock, rewriteLegacyCodexHookBlock, + readGsdCommandNames, }; } else { diff --git a/get-shit-done/bin/lib/runtime-artifact-layout.cjs b/get-shit-done/bin/lib/runtime-artifact-layout.cjs index 238f22716..f87eb4cdd 100644 --- a/get-shit-done/bin/lib/runtime-artifact-layout.cjs +++ b/get-shit-done/bin/lib/runtime-artifact-layout.cjs @@ -178,16 +178,23 @@ function agentsKind(destSubpath, prefix, configDir) { * @param {string} destSubpath * @param {string} prefix * @param {string} converterName name of converter function in bin/install.js exports + * @param {string} runtime canonical runtime ID (gates Hermes/Qwen branding in converter) * @param {string} configDir runtime config dir (for .gsd-source marker resolution) */ -function skillsKind(destSubpath, prefix, converterName, configDir) { +function skillsKind(destSubpath, prefix, converterName, runtime, configDir) { return { kind: 'skills', destSubpath, prefix, stage: (resolved) => { - const converter = getInstallExports()[converterName]; - return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, converter, prefix); + const installExports = getInstallExports(); + const realConverter = installExports[converterName]; + // Compute cmdNames once per stage call for performance (#3583). + // Extra args are ignored by converters that don't need runtime/cmdNames. + const cmdNames = installExports.readGsdCommandNames(); + const wrappedConverter = (content, skillName) => + realConverter(content, skillName, runtime, cmdNames); + return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix); }, }; } @@ -224,12 +231,12 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { agentsKind('agents', 'gsd-', configDir), ]; } else { - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)]; } break; case 'cursor': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCursorSkill', 'cursor', configDir)]; break; case 'gemini': @@ -237,39 +244,39 @@ function resolveRuntimeArtifactLayout(runtime, configDir, scope = 'global') { break; case 'codex': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodexSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodexSkill', 'codex', configDir)]; break; case 'copilot': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCopilotSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCopilotSkill', 'copilot', configDir)]; break; case 'antigravity': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir)]; break; case 'windsurf': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToWindsurfSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToWindsurfSkill', 'windsurf', configDir)]; break; case 'augment': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir)]; break; case 'trae': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir)]; break; case 'qwen': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir)]; break; case 'hermes': - kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', configDir)]; + kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir)]; break; case 'codebuddy': - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', configDir)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToCodebuddySkill', 'codebuddy', configDir)]; break; case 'cline': From 51776b15c3634a5f36c1a8b46c872309ee6f1f13 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:47:26 -0400 Subject: [PATCH 20/27] refactor(3663): replace try/finally with t.after() in install-profiles-stage tests Replace all 13 try/finally blocks with t.after() per-test cleanup hooks. Import createTempDir/cleanup from tests/helpers.cjs; use createTempDir inside createFixtureSkillsDir and createFixtureAgentsDir. Remove os import (now unused at top level). Co-Authored-By: Claude Sonnet 4.6 --- tests/install-profiles-stage.test.cjs | 287 ++++++++++++-------------- 1 file changed, 135 insertions(+), 152 deletions(-) diff --git a/tests/install-profiles-stage.test.cjs b/tests/install-profiles-stage.test.cjs index b3fbe21eb..3cbe32e94 100644 --- a/tests/install-profiles-stage.test.cjs +++ b/tests/install-profiles-stage.test.cjs @@ -7,7 +7,6 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { stageSkillsForProfile, @@ -18,12 +17,13 @@ const { loadSkillsManifest, STAGED_DIRS, } = require('../get-shit-done/bin/lib/install-profiles.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); function createFixtureSkillsDir() { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-stage-profile-')); + const tmp = createTempDir('gsd-stage-profile-'); for (const name of ['plan-phase', 'execute-phase', 'autonomous', 'progress', 'help', 'phase']) { fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); } @@ -31,7 +31,7 @@ function createFixtureSkillsDir() { } function createFixtureAgentsDir() { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-agents-profile-')); + const tmp = createTempDir('gsd-agents-profile-'); for (const name of ['gsd-planner', 'gsd-executor', 'gsd-code-reviewer']) { fs.writeFileSync(path.join(tmp, `${name}.md`), `# ${name}\n`); } @@ -43,213 +43,197 @@ describe('stageSkillsForRuntimeAsSkills', () => { assert.strictEqual(typeof stageSkillsForRuntimeAsSkills, 'function'); }); - test('registers stagedDir in STAGED_DIRS after staging', () => { - const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + test('registers stagedDir in STAGED_DIRS after staging', (t) => { + const src = createTempDir('gsd-rta-src-'); let stagedDir; - try { - fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); - cleanupStagedSkills(); - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); - assert.ok(STAGED_DIRS.has(stagedDir), 'stagedDir must be in STAGED_DIRS'); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (stagedDir) cleanupStagedSkills(); - } + }); + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + cleanupStagedSkills(); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + assert.ok(STAGED_DIRS.has(stagedDir), 'stagedDir must be in STAGED_DIRS'); }); test('non-existent srcCommandsDir returns srcCommandsDir unchanged', () => { - const ghost = path.join(os.tmpdir(), 'gsd-rta-no-exist-' + Date.now()); + const ghost = path.join(require('os').tmpdir(), 'gsd-rta-no-exist-' + Date.now()); const converter = (content, _skillName) => content; const result = stageSkillsForRuntimeAsSkills(ghost, { skills: '*' }, converter, 'gsd-'); assert.strictEqual(result, ghost); }); - test('empty prefix produces /SKILL.md without prefix segment', () => { - const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + test('empty prefix produces /SKILL.md without prefix segment', (t) => { + const src = createTempDir('gsd-rta-src-'); let stagedDir; - try { - fs.writeFileSync(path.join(src, 'phase.md'), '# phase\n'); - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, ''); - const entries = fs.readdirSync(stagedDir); - assert.deepStrictEqual(entries, ['phase']); - const content = fs.readFileSync(path.join(stagedDir, 'phase', 'SKILL.md'), 'utf8'); - assert.strictEqual(content, '# phase\n'); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (stagedDir) cleanupStagedSkills(); - } + }); + fs.writeFileSync(path.join(src, 'phase.md'), '# phase\n'); + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, ''); + const entries = fs.readdirSync(stagedDir); + assert.deepStrictEqual(entries, ['phase']); + const content = fs.readFileSync(path.join(stagedDir, 'phase', 'SKILL.md'), 'utf8'); + assert.strictEqual(content, '# phase\n'); }); - test('converter is called with (content, skillName) for each kept skill', () => { - const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + test('converter is called with (content, skillName) for each kept skill', (t) => { + const src = createTempDir('gsd-rta-src-'); let stagedDir; - try { - fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); - fs.writeFileSync(path.join(src, 'beta.md'), '# beta\n'); - const calls = []; - const converter = (content, skillName) => { - calls.push([content, skillName]); - return content; - }; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'x-'); - assert.strictEqual(calls.length, 2); - const callMap = Object.fromEntries(calls.map(([c, n]) => [n, c])); - assert.strictEqual(callMap['x-alpha'], '# alpha\n'); - assert.strictEqual(callMap['x-beta'], '# beta\n'); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (stagedDir) cleanupStagedSkills(); - } + }); + fs.writeFileSync(path.join(src, 'alpha.md'), '# alpha\n'); + fs.writeFileSync(path.join(src, 'beta.md'), '# beta\n'); + const calls = []; + const converter = (content, skillName) => { + calls.push([content, skillName]); + return content; + }; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'x-'); + assert.strictEqual(calls.length, 2); + const callMap = Object.fromEntries(calls.map(([c, n]) => [n, c])); + assert.strictEqual(callMap['x-alpha'], '# alpha\n'); + assert.strictEqual(callMap['x-beta'], '# beta\n'); }); - test('skills Set filters: only matching stems land in stagedDir', () => { - const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + test('skills Set filters: only matching stems land in stagedDir', (t) => { + const src = createTempDir('gsd-rta-src-'); let stagedDir; - try { - for (const name of ['alpha', 'beta', 'phase']) { - fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); - } - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: new Set(['phase']) }, converter, 'gsd-'); - const entries = fs.readdirSync(stagedDir).sort(); - assert.deepStrictEqual(entries, ['gsd-phase']); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (stagedDir) cleanupStagedSkills(); + }); + for (const name of ['alpha', 'beta', 'phase']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: new Set(['phase']) }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-phase']); }); - test('skills === "*" stages all md files as /SKILL.md', () => { - const src = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-rta-src-')); + test('skills === "*" stages all md files as /SKILL.md', (t) => { + const src = createTempDir('gsd-rta-src-'); let stagedDir; - try { - for (const name of ['alpha', 'beta', 'gamma']) { - fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); - } - const converter = (content, _skillName) => content; - stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); - const entries = fs.readdirSync(stagedDir).sort(); - assert.deepStrictEqual(entries, ['gsd-alpha', 'gsd-beta', 'gsd-gamma']); - for (const name of ['alpha', 'beta', 'gamma']) { - const content = fs.readFileSync(path.join(stagedDir, `gsd-${name}`, 'SKILL.md'), 'utf8'); - assert.strictEqual(content, `# ${name}\n`); - } - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (stagedDir) cleanupStagedSkills(); + }); + for (const name of ['alpha', 'beta', 'gamma']) { + fs.writeFileSync(path.join(src, `${name}.md`), `# ${name}\n`); + } + const converter = (content, _skillName) => content; + stagedDir = stageSkillsForRuntimeAsSkills(src, { skills: '*' }, converter, 'gsd-'); + const entries = fs.readdirSync(stagedDir).sort(); + assert.deepStrictEqual(entries, ['gsd-alpha', 'gsd-beta', 'gsd-gamma']); + for (const name of ['alpha', 'beta', 'gamma']) { + const content = fs.readFileSync(path.join(stagedDir, `gsd-${name}`, 'SKILL.md'), 'utf8'); + assert.strictEqual(content, `# ${name}\n`); } }); }); describe('stageSkillsForProfile', () => { - test('full profile (skills === "*") returns srcDir unchanged', () => { + test('full profile (skills === "*") returns srcDir unchanged', (t) => { const src = createFixtureSkillsDir(); - try { - const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() }); - assert.strictEqual(result, src); - } finally { - fs.rmSync(src, { recursive: true, force: true }); - } + t.after(() => cleanup(src)); + const result = stageSkillsForProfile(src, { skills: '*', agents: new Set() }); + assert.strictEqual(result, src); }); - test('profile with Set copies only member files', () => { + test('profile with Set copies only member files', (t) => { const src = createFixtureSkillsDir(); let staged; - try { - const skills = new Set(['plan-phase', 'help', 'phase']); - staged = stageSkillsForProfile(src, { skills, agents: new Set() }); - assert.notStrictEqual(staged, src); - const files = fs.readdirSync(staged).sort(); - assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (staged) cleanupStagedSkills(); - } + }); + const skills = new Set(['plan-phase', 'help', 'phase']); + staged = stageSkillsForProfile(src, { skills, agents: new Set() }); + assert.notStrictEqual(staged, src); + const files = fs.readdirSync(staged).sort(); + assert.deepStrictEqual(files, ['help.md', 'phase.md', 'plan-phase.md']); }); - test('preserves file content byte-for-byte', () => { + test('preserves file content byte-for-byte', (t) => { const src = createFixtureSkillsDir(); const content = '# plan-phase special content\n\nsome body\n'; fs.writeFileSync(path.join(src, 'plan-phase.md'), content); let staged; - try { - const skills = new Set(['plan-phase']); - staged = stageSkillsForProfile(src, { skills, agents: new Set() }); - const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8'); - assert.strictEqual(copied, content); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (staged) cleanupStagedSkills(); - } + }); + const skills = new Set(['plan-phase']); + staged = stageSkillsForProfile(src, { skills, agents: new Set() }); + const copied = fs.readFileSync(path.join(staged, 'plan-phase.md'), 'utf8'); + assert.strictEqual(copied, content); }); test('non-existent srcDir returns srcDir unchanged', () => { - const ghost = path.join(os.tmpdir(), 'gsd-no-exist-' + Date.now()); + const ghost = path.join(require('os').tmpdir(), 'gsd-no-exist-' + Date.now()); const result = stageSkillsForProfile(ghost, { skills: new Set(['help']), agents: new Set() }); assert.strictEqual(result, ghost); }); - test('empty skills Set produces empty staged dir', () => { + test('empty skills Set produces empty staged dir', (t) => { const src = createFixtureSkillsDir(); let staged; - try { - staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() }); - const files = fs.readdirSync(staged); - assert.deepStrictEqual(files, []); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (staged) cleanupStagedSkills(); - } + }); + staged = stageSkillsForProfile(src, { skills: new Set(), agents: new Set() }); + const files = fs.readdirSync(staged); + assert.deepStrictEqual(files, []); }); }); describe('stageAgentsForProfile', () => { - test('full profile (skills === "*") returns srcDir unchanged', () => { + test('full profile (skills === "*") returns srcDir unchanged', (t) => { const src = createFixtureAgentsDir(); - try { - const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() }); - assert.strictEqual(result, src); - } finally { - fs.rmSync(src, { recursive: true, force: true }); - } + t.after(() => cleanup(src)); + const result = stageAgentsForProfile(src, { skills: '*', agents: new Set() }); + assert.strictEqual(result, src); }); - test('non-full profile with empty agents Set produces empty staged dir', () => { + test('non-full profile with empty agents Set produces empty staged dir', (t) => { const src = createFixtureAgentsDir(); let staged; - try { - staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() }); - const files = fs.readdirSync(staged); - assert.deepStrictEqual(files, [], 'no agents for non-full profile by default'); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (staged) cleanupStagedSkills(); - } + }); + staged = stageAgentsForProfile(src, { skills: new Set(['help']), agents: new Set() }); + const files = fs.readdirSync(staged); + assert.deepStrictEqual(files, [], 'no agents for non-full profile by default'); }); - test('non-full profile with agents Set copies only member agent files', () => { + test('non-full profile with agents Set copies only member agent files', (t) => { const src = createFixtureAgentsDir(); let staged; - try { - const agents = new Set(['gsd-planner']); - staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents }); - const files = fs.readdirSync(staged).sort(); - assert.deepStrictEqual(files, ['gsd-planner.md']); - } finally { - fs.rmSync(src, { recursive: true, force: true }); + t.after(() => { + cleanup(src); if (staged) cleanupStagedSkills(); - } + }); + const agents = new Set(['gsd-planner']); + staged = stageAgentsForProfile(src, { skills: new Set(['plan-phase']), agents }); + const files = fs.readdirSync(staged).sort(); + assert.deepStrictEqual(files, ['gsd-planner.md']); }); test('non-existent srcAgentsDir returns srcAgentsDir unchanged', () => { - const ghost = path.join(os.tmpdir(), 'gsd-agents-no-exist-' + Date.now()); + const ghost = path.join(require('os').tmpdir(), 'gsd-agents-no-exist-' + Date.now()); const result = stageAgentsForProfile(ghost, { skills: new Set(), agents: new Set() }); assert.strictEqual(result, ghost); }); - test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', () => { + test('standard profile — stageAgentsForProfile copies exactly the agents in resolvedProfile.agents', (t) => { // Uses the real agents dir and commands dir if (!fs.existsSync(REAL_AGENTS_DIR) || !fs.existsSync(REAL_COMMANDS_DIR)) return; const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); @@ -257,24 +241,23 @@ describe('stageAgentsForProfile', () => { assert.ok(resolved.agents instanceof Set && resolved.agents.size > 0, 'standard profile must have >0 agents (plan-phase calls gsd-planner etc)'); let staged; - try { - staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); - const stagedFiles = new Set( - fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)) - ); - // Every file staged must be in resolved.agents - for (const stem of stagedFiles) { - assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`); - } - // Every agent in resolved.agents that exists in the real dir must be staged - for (const agentStem of resolved.agents) { - const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`)); - if (exists) { - assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`); - } - } - } finally { + t.after(() => { if (staged) cleanupStagedSkills(); + }); + staged = stageAgentsForProfile(REAL_AGENTS_DIR, resolved); + const stagedFiles = new Set( + fs.readdirSync(staged).filter(f => f.endsWith('.md')).map(f => f.slice(0, -3)) + ); + // Every file staged must be in resolved.agents + for (const stem of stagedFiles) { + assert.ok(resolved.agents.has(stem), `staged agent ${stem} not in resolved.agents`); + } + // Every agent in resolved.agents that exists in the real dir must be staged + for (const agentStem of resolved.agents) { + const exists = fs.existsSync(path.join(REAL_AGENTS_DIR, `${agentStem}.md`)); + if (exists) { + assert.ok(stagedFiles.has(agentStem), `resolved agent ${agentStem} missing from staged dir`); + } } }); From f929e4a7bd53707e24678b1d8555fed7a400654a Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:48:28 -0400 Subject: [PATCH 21/27] refactor(3663): replace try/finally with t.after() in surface-apply tests Replace all 6 try/finally blocks with t.after() per-test cleanup hooks. Import createTempDir/cleanup from tests/helpers.cjs; use createTempDir inside createFixtureRuntime (replaces inline tmpDir helper). Remove os import (now unused). Co-Authored-By: Claude Sonnet 4.6 --- tests/surface-apply.test.cjs | 328 ++++++++++++++++------------------- 1 file changed, 153 insertions(+), 175 deletions(-) diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index 1bcac5a72..dd1f07629 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -7,20 +7,16 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('fs'); const path = require('path'); -const os = require('os'); const { writeSurface, applySurface } = require('../get-shit-done/bin/lib/surface.cjs'); const { loadSkillsManifest, writeActiveProfile } = require('../get-shit-done/bin/lib/install-profiles.cjs'); const { CLUSTERS } = require('../get-shit-done/bin/lib/clusters.cjs'); const { resolveRuntimeArtifactLayout } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); +const { createTempDir, cleanup } = require('./helpers.cjs'); const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd'); const REAL_AGENTS_DIR = path.join(__dirname, '..', 'agents'); -function tmpDir() { - return fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-apply-')); -} - /** * Create a minimal fixture install dir structure for claude/local layout. * runtimeConfigDir is the layout configDir. @@ -28,7 +24,7 @@ function tmpDir() { * agentsDir = runtimeConfigDir/agents */ function createFixtureRuntime() { - const base = tmpDir(); + const base = createTempDir('gsd-surface-apply-'); const runtimeConfigDir = base; const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); const agentsDir = path.join(runtimeConfigDir, 'agents'); @@ -38,135 +34,123 @@ function createFixtureRuntime() { } describe('applySurface', () => { - test('core profile: only core skills appear in commandsDir', () => { + test('core profile: only core skills appear in commandsDir', (t) => { const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + t.after(() => cleanup(base)); + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); - // Every file should be a real stem we know about - for (const file of files) { - assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); - } - // At minimum core skills should be present - const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']; - for (const stem of coreStems) { - assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`); - } - } finally { - fs.rmSync(base, { recursive: true, force: true }); + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + // Every file should be a real stem we know about + for (const file of files) { + assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); + } + // At minimum core skills should be present + const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']; + for (const stem of coreStems) { + assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`); } }); - test('removes superseded files when profile shrinks', () => { + test('removes superseded files when profile shrinks', (t) => { const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // Start with standard: put some skill files in commandsDir - writeActiveProfile(runtimeConfigDir, 'standard'); - writeSurface(runtimeConfigDir, { - baseProfile: 'standard', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + t.after(() => cleanup(base)); + // Start with standard: put some skill files in commandsDir + writeActiveProfile(runtimeConfigDir, 'standard'); + writeSurface(runtimeConfigDir, { + baseProfile: 'standard', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + const afterStandard = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - // Now switch to core: skills not in core should be removed - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + // Now switch to core: skills not in core should be removed + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); + const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); - // core should be a subset of standard - assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); + // core should be a subset of standard + assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); - // Files removed should not be in core set - const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']); - for (const file of afterCore) { - const stem = file.slice(0, -3); - assert.ok( - fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), - `file in commandsDir not a real skill: ${file}` - ); - } - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); - - test('leaves non-gsd .md files alone in agentsDir', () => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // Place a non-gsd agent file in agentsDir - const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); - fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); - - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - // Non-gsd file should still be there - assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } - }); - - test('adds missing skill files from install source', () => { - const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); - try { - // commandsDir starts empty - writeActiveProfile(runtimeConfigDir, 'core'); - writeSurface(runtimeConfigDir, { - baseProfile: 'core', - disabledClusters: [], - explicitAdds: [], - explicitRemoves: [], - }); - const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); - const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); - - // Core skills should now be present + // Files removed should not be in core set + const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']); + for (const file of afterCore) { + const stem = file.slice(0, -3); assert.ok( - fs.existsSync(path.join(commandsDir, 'help.md')), - 'help.md should be copied from install source' + fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), + `file in commandsDir not a real skill: ${file}` ); - assert.ok( - fs.existsSync(path.join(commandsDir, 'new-project.md')), - 'new-project.md should be copied from install source' - ); - } finally { - fs.rmSync(base, { recursive: true, force: true }); } }); - test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', () => { + test('leaves non-gsd .md files alone in agentsDir', (t) => { + const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + // Place a non-gsd agent file in agentsDir + const foreignAgent = path.join(agentsDir, 'my-custom-agent.md'); + fs.writeFileSync(foreignAgent, '# custom agent\n', 'utf8'); + + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // Non-gsd file should still be there + assert.ok(fs.existsSync(foreignAgent), 'non-gsd agent file should not be touched'); + }); + + test('adds missing skill files from install source', (t) => { + const { base, runtimeConfigDir, commandsDir, agentsDir } = createFixtureRuntime(); + t.after(() => cleanup(base)); + // commandsDir starts empty + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // Core skills should now be present + assert.ok( + fs.existsSync(path.join(commandsDir, 'help.md')), + 'help.md should be copied from install source' + ); + assert.ok( + fs.existsSync(path.join(commandsDir, 'new-project.md')), + 'new-project.md should be copied from install source' + ); + }); + + test('_syncGsdDir skills kind: adds missing skill dirs, removes stale prefix-matched dirs, preserves foreign dirs', (t) => { const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); const { stageSkillsForRuntimeAsSkills } = require('../get-shit-done/bin/lib/install-profiles.cjs'); const { findInstallSourceRoot } = require('../get-shit-done/bin/lib/runtime-artifact-layout.cjs'); @@ -180,79 +164,73 @@ describe('applySurface', () => { ].join('\n'); } - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-skills-')); - try { - const stagedDir = path.join(base, 'staged'); - const destDir = path.join(base, 'dest'); - fs.mkdirSync(destDir, { recursive: true }); + const base = createTempDir('gsd-surface-skills-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); - // Build a staged dir manually: gsd-help/SKILL.md and gsd-update/SKILL.md - const stem1 = 'gsd-help'; - const stem2 = 'gsd-update'; - fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); - fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); + // Build a staged dir manually: gsd-help/SKILL.md and gsd-update/SKILL.md + const stem1 = 'gsd-help'; + const stem2 = 'gsd-update'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + fs.mkdirSync(path.join(stagedDir, stem2), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem2, 'SKILL.md'), '# update\n', 'utf8'); - // In destDir: stale gsd- dir + foreign user dir - const staleDir = path.join(destDir, 'gsd-old-skill'); - fs.mkdirSync(staleDir, { recursive: true }); - fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); + // In destDir: stale gsd- dir + foreign user dir + const staleDir = path.join(destDir, 'gsd-old-skill'); + fs.mkdirSync(staleDir, { recursive: true }); + fs.writeFileSync(path.join(staleDir, 'SKILL.md'), '# old\n', 'utf8'); - const foreignDir = path.join(destDir, 'my-custom-skill'); - fs.mkdirSync(foreignDir, { recursive: true }); - fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); + const foreignDir = path.join(destDir, 'my-custom-skill'); + fs.mkdirSync(foreignDir, { recursive: true }); + fs.writeFileSync(path.join(foreignDir, 'SKILL.md'), '# custom\n', 'utf8'); - const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; + const skillsKind = { kind: 'skills', destSubpath: 'skills', prefix: 'gsd-', stage: () => stagedDir }; - _syncGsdDir(stagedDir, destDir, skillsKind); + _syncGsdDir(stagedDir, destDir, skillsKind); - // staged dirs copied - assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); - assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); + // staged dirs copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'gsd-help/SKILL.md should be copied'); + assert.ok(fs.existsSync(path.join(destDir, stem2, 'SKILL.md')), 'gsd-update/SKILL.md should be copied'); - // stale gsd- dir removed - assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); + // stale gsd- dir removed + assert.ok(!fs.existsSync(staleDir), 'stale gsd-old-skill dir should be removed'); - // foreign dir preserved - assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } + // foreign dir preserved + assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); }); - test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', () => { + test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => { const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); - const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-surface-hermes-')); - try { - const stagedDir = path.join(base, 'staged'); - const destDir = path.join(base, 'dest'); - fs.mkdirSync(destDir, { recursive: true }); + const base = createTempDir('gsd-surface-hermes-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); - // Staged contains a GSD skill named 'help' (no prefix under hermes skills/gsd/) - const stem1 = 'help'; - fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); - fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); + // Staged contains a GSD skill named 'help' (no prefix under hermes skills/gsd/) + const stem1 = 'help'; + fs.mkdirSync(path.join(stagedDir, stem1), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, stem1, 'SKILL.md'), '# help\n', 'utf8'); - // Dest also has a user-owned custom skill dir (no gsd- prefix — Hermes namespace) - const userDir = path.join(destDir, 'user-custom-skill'); - fs.mkdirSync(userDir, { recursive: true }); - fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); + // Dest also has a user-owned custom skill dir (no gsd- prefix — Hermes namespace) + const userDir = path.join(destDir, 'user-custom-skill'); + fs.mkdirSync(userDir, { recursive: true }); + fs.writeFileSync(path.join(userDir, 'SKILL.md'), '# user custom\n', 'utf8'); - // kindPrefix === '' simulates Hermes (destSubpath = skills/gsd, prefix = '') - const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + // kindPrefix === '' simulates Hermes (destSubpath = skills/gsd, prefix = '') + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; - _syncGsdDir(stagedDir, destDir, hermesKind); + _syncGsdDir(stagedDir, destDir, hermesKind); - // The user's custom skill dir must be preserved — it's not in staged but should not be removed - // (Fix 4: when kindPrefix === '', skip the startsWith guard and preserve ALL non-staged dirs) - assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); + // The user's custom skill dir must be preserved — it's not in staged but should not be removed + // (Fix 4: when kindPrefix === '', skip the startsWith guard and preserve ALL non-staged dirs) + assert.ok(fs.existsSync(userDir), 'user-custom-skill dir must be preserved when kindPrefix is empty (Hermes)'); - // The staged skill must still be copied - assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); - } finally { - fs.rmSync(base, { recursive: true, force: true }); - } + // The staged skill must still be copied + assert.ok(fs.existsSync(path.join(destDir, stem1, 'SKILL.md')), 'GSD help/SKILL.md must be copied'); }); }); From 925908039c3e8e5819a999c426dafc4e005dcc46 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 00:48:55 -0400 Subject: [PATCH 22/27] refactor(3663): replace try/finally with t.after() in runtime-artifact-layout-stage tests Remove the 2 empty try/finally wrappers (finally bodies contained only comments, no cleanup actions). Inline the assertions directly; add a comment noting stagedDir lifecycle ownership. No behavior change. Co-Authored-By: Claude Sonnet 4.6 --- tests/runtime-artifact-layout-stage.test.cjs | 40 +++++++++----------- 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/tests/runtime-artifact-layout-stage.test.cjs b/tests/runtime-artifact-layout-stage.test.cjs index c53d92963..b55cdb293 100644 --- a/tests/runtime-artifact-layout-stage.test.cjs +++ b/tests/runtime-artifact-layout-stage.test.cjs @@ -27,18 +27,15 @@ describe('commands kind — stage (gemini)', () => { assert.ok(commandsKind, 'should have a commands kind'); const stagedDir = commandsKind.stage(PROFILE_CORE); - try { - const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); - // All staged files must come from the selected skill set - for (const entry of entries) { - const stem = entry.slice(0, -3); - assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); - } - // At least one file must be present (help.md exists in real commands/gsd) - assert.ok(entries.length >= 1, 'at least one skill file should be staged'); - } finally { - // stagedDir is managed by stageSkillsForProfile — just verify, don't cleanup manually + // stagedDir is managed by stageSkillsForProfile — just verify, no manual cleanup needed + const entries = fs.readdirSync(stagedDir).filter(f => f.endsWith('.md')); + // All staged files must come from the selected skill set + for (const entry of entries) { + const stem = entry.slice(0, -3); + assert.ok(CORE_SKILLS.has(stem), `unexpected skill staged: ${stem}`); } + // At least one file must be present (help.md exists in real commands/gsd) + assert.ok(entries.length >= 1, 'at least one skill file should be staged'); }); }); @@ -63,19 +60,16 @@ describe('skills kind — stage (claude global)', () => { assert.ok(skillsKind, 'should have a skills kind'); const stagedDir = skillsKind.stage(PROFILE_CORE); - try { - assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - const entries = fs.readdirSync(stagedDir); - // Each entry should be a directory named gsd- - for (const entry of entries) { - assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); - const skillMd = path.join(stagedDir, entry, 'SKILL.md'); - assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); - } - assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); - } finally { - // managed by stageSkillsForRuntimeAsSkills + // managed by stageSkillsForRuntimeAsSkills — no manual cleanup needed + assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); + const entries = fs.readdirSync(stagedDir); + // Each entry should be a directory named gsd- + for (const entry of entries) { + assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); + const skillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), `SKILL.md must exist in ${entry}`); } + assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); }); }); From 043cfd97b3099f4e1e7041b7fbb2e85c662b7a20 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 01:00:54 -0400 Subject: [PATCH 23/27] fix(3663): applySurface creates missing dest dirs (Codex P1-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the fs.existsSync(dest) guard in applySurface so _syncGsdDir is always called. _syncGsdDir already does mkdirSync(..., { recursive: true }) so the destination is created when absent — recovering partially-initialized or user-deleted runtime config dirs. Also threads manifest through to _syncGsdDir as optional 4th arg (used by P1-2 Hermes fix). Co-Authored-By: Claude Sonnet 4.6 --- get-shit-done/bin/lib/surface.cjs | 54 ++++++++++++++++++++----------- tests/surface-apply.test.cjs | 25 ++++++++++++++ 2 files changed, 60 insertions(+), 19 deletions(-) diff --git a/get-shit-done/bin/lib/surface.cjs b/get-shit-done/bin/lib/surface.cjs index 08e88d9f6..af6ee17b3 100644 --- a/get-shit-done/bin/lib/surface.cjs +++ b/get-shit-done/bin/lib/surface.cjs @@ -208,9 +208,7 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { for (const kind of layout.kinds) { const staged = kind.stage(resolved); const dest = path.join(layout.configDir, kind.destSubpath); - if (fs.existsSync(dest)) { - _syncGsdDir(staged, dest, kind); - } + _syncGsdDir(staged, dest, kind, manifest); } return resolved; } @@ -224,11 +222,16 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { * by copying recursively; remove dirs not in staged set. Preserves dirs not matching * the prefix (user-owned skills). * + * For Hermes (empty prefix): uses manifest membership to discriminate GSD-owned vs + * user-owned dirs. GSD-owned = stem in manifest; removal targets = in manifest AND + * not in staged set. User-owned (not in manifest) are always preserved. + * * @param {string} stagedDir source (staged temp dir or original) * @param {string} destDir runtime destination * @param {import('./runtime-artifact-layout.cjs').ArtifactKind|'commands'|'agents'} kind + * @param {Map} [manifest] optional; required for Hermes empty-prefix removal */ -function _syncGsdDir(stagedDir, destDir, kind) { +function _syncGsdDir(stagedDir, destDir, kind, manifest) { if (!fs.existsSync(stagedDir)) return; fs.mkdirSync(destDir, { recursive: true }); @@ -256,22 +259,35 @@ function _syncGsdDir(stagedDir, destDir, kind) { } } - // Empty prefix = destSubpath is the GSD namespace (Hermes: skills/gsd/). - // With no prefix filter, we cannot safely distinguish GSD-owned from user-owned dirs, - // so we only remove dirs that match the prefix. When kindPrefix === '', - // startsWith('') is always true but we must guard: skip removal entirely if prefix is - // empty so user dirs under skills/gsd/ are preserved (Hermes user-skill safety). - if (kindPrefix !== '') { - // Remove prefix-matched dirs in dest that are not in staged set - const destEntries = fs.readdirSync(destDir); - for (const entry of destEntries) { - const entryPath = path.join(destDir, entry); - if (!fs.statSync(entryPath).isDirectory()) continue; - if (!entry.startsWith(kindPrefix)) continue; // preserve user-owned dirs - if (!stagedDirs.has(entry)) { - try { fs.rmSync(entryPath, { recursive: true, force: true }); } catch {} - } + // Removal: discriminator depends on prefix shape. + // Non-empty prefix: GSD namespace IS the prefix; remove prefix-matching dirs not in staged set. + // Empty prefix (Hermes): GSD-owned = stem in manifest (i.e. canonically-shipped GSD skill). + // User-owned skills not in manifest are preserved. + // No manifest available: be conservative, don't remove anything. + const canonicalStems = manifest + ? new Set([...manifest.keys()].filter(k => !k.startsWith('_calls_agents_'))) + : null; + + const destEntries = fs.readdirSync(destDir); + for (const entry of destEntries) { + const entryPath = path.join(destDir, entry); + if (!fs.statSync(entryPath).isDirectory()) continue; + + let isGsdOwned; + if (kindPrefix !== '') { + isGsdOwned = entry.startsWith(kindPrefix); + } else if (canonicalStems) { + // Hermes: empty prefix, destSubpath is the namespace. + // GSD-owned iff the directory name (stem) appears in the canonical manifest. + isGsdOwned = canonicalStems.has(entry); + } else { + // No manifest available: be conservative, don't remove anything. + continue; } + + if (!isGsdOwned) continue; // preserve user-owned + if (stagedDirs.has(entry)) continue; // current GSD-owned, keep + try { fs.rmSync(entryPath, { recursive: true, force: true }); } catch {} } } else { // commands / agents kind: work with .md files diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index dd1f07629..7cd36143b 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -202,6 +202,31 @@ describe('applySurface', () => { assert.ok(fs.existsSync(foreignDir), 'my-custom-skill dir should be preserved'); }); + test('applySurface recreates missing destination directories', (t) => { + // Fixture: layout configDir exists but the dest subdirectory for the kinds does NOT. + const base = createTempDir('gsd-surface-missing-dest-'); + t.after(() => cleanup(base)); + const runtimeConfigDir = base; + // Do NOT pre-create commands/gsd or agents — they are intentionally absent. + writeActiveProfile(runtimeConfigDir, 'core'); + writeSurface(runtimeConfigDir, { + baseProfile: 'core', + disabledClusters: [], + explicitAdds: [], + explicitRemoves: [], + }); + const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); + const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); + applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + + // commands/gsd must have been created and populated + const commandsDir = path.join(runtimeConfigDir, 'commands', 'gsd'); + assert.ok(fs.existsSync(commandsDir), 'commands/gsd dir should be created even if initially absent'); + const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); + assert.ok(files.length > 0, 'commands/gsd should contain staged skill files'); + assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest'); + }); + test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => { const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); From 06cf085bfbaffa8806aecedd56097e4a384942f8 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 01:01:10 -0400 Subject: [PATCH 24/27] fix(3663): Hermes empty-prefix uses manifest membership for stale-skill prune (Codex P1-2) Replace the blunt kindPrefix !== '' guard in _syncGsdDir with a manifest-membership discriminator. For non-empty prefix runtimes, behavior is unchanged (prefix match). For Hermes (empty prefix), a directory is GSD-owned iff its stem appears in the canonical manifest; only those dirs are removal candidates when absent from the staged set. Dirs not in the manifest (user-owned) are preserved unconditionally. When no manifest is provided (legacy callers), the removal pass is skipped (conservative fallback). Co-Authored-By: Claude Sonnet 4.6 --- tests/surface-apply.test.cjs | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index 7cd36143b..c785d4077 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -227,6 +227,53 @@ describe('applySurface', () => { assert.ok(files.includes('help.md'), 'help.md should be present after applySurface on missing dest'); }); + test('Hermes profile shrink: stale GSD skill dirs are removed; user skills preserved', (t) => { + const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); + + const base = createTempDir('gsd-surface-hermes-shrink-'); + t.after(() => cleanup(base)); + const stagedDir = path.join(base, 'staged'); + const destDir = path.join(base, 'dest'); + fs.mkdirSync(destDir, { recursive: true }); + + // Staged: only gsd-executor (profile shrunk — gsd-planner no longer in profile) + fs.mkdirSync(path.join(stagedDir, 'gsd-executor'), { recursive: true }); + fs.writeFileSync(path.join(stagedDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); + + // Dest already has: gsd-executor (keep), gsd-planner (stale GSD), user-skill (user-owned) + fs.mkdirSync(path.join(destDir, 'gsd-executor'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd-executor', 'SKILL.md'), '# executor\n', 'utf8'); + fs.mkdirSync(path.join(destDir, 'gsd-planner'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'gsd-planner', 'SKILL.md'), '# planner\n', 'utf8'); + fs.mkdirSync(path.join(destDir, 'user-skill'), { recursive: true }); + fs.writeFileSync(path.join(destDir, 'user-skill', 'SKILL.md'), '# user\n', 'utf8'); + + // Manifest contains gsd-executor and gsd-planner as canonical GSD skills. + // user-skill is NOT in manifest (user-owned). + const manifest = new Map([ + ['gsd-executor', []], + ['gsd-planner', []], + ]); + + // Hermes kind: empty prefix, destSubpath = skills/gsd + const hermesKind = { kind: 'skills', destSubpath: 'skills/gsd', prefix: '', stage: () => stagedDir }; + + _syncGsdDir(stagedDir, destDir, hermesKind, manifest); + + assert.ok( + fs.existsSync(path.join(destDir, 'gsd-executor', 'SKILL.md')), + 'gsd-executor should be kept (in staged set)' + ); + assert.ok( + !fs.existsSync(path.join(destDir, 'gsd-planner')), + 'gsd-planner should be removed (in manifest but not in staged set — stale GSD skill)' + ); + assert.ok( + fs.existsSync(path.join(destDir, 'user-skill', 'SKILL.md')), + 'user-skill should be preserved (not in manifest — user-owned)' + ); + }); + test('_syncGsdDir skills kind (hermes): preserves non-GSD user dir under skills/gsd/ when kindPrefix is empty', (t) => { const { _syncGsdDir } = require('../get-shit-done/bin/lib/surface.cjs'); From 37805f6d2c47035b891663b3727f4fa9173800af Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 01:07:44 -0400 Subject: [PATCH 25/27] docs(3663): add runtime-artifact-layout.cjs row to INVENTORY.md and bump CLI Modules count to 71 Co-Authored-By: Claude Sonnet 4.6 --- docs/INVENTORY.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 85dfd6d0e..b5b25061a 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -361,7 +361,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (70 shipped) +## CLI Modules (71 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -413,6 +413,7 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `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.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-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) | | `schema-detect.cjs` | CJS shim adapter — re-exports from `schema-detect.generated.cjs` (Phase 6/#3575 Shared Module migration) | From aa73c2d9147c81f1e610e014f0189adf6e01bfad Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 01:07:55 -0400 Subject: [PATCH 26/27] docs(3663): regenerate INVENTORY-MANIFEST.json to include runtime-artifact-layout.cjs Co-Authored-By: Claude Sonnet 4.6 --- docs/INVENTORY-MANIFEST.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 7c662d9cd..d68b5dca8 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-05-15", + "generated": "2026-05-17", "families": { "agents": [ "gsd-advisor-researcher", @@ -305,6 +305,7 @@ "review-reviewer-selection.cjs", "roadmap-command-router.cjs", "roadmap.cjs", + "runtime-artifact-layout.cjs", "runtime-homes.cjs", "runtime-slash.cjs", "schema-detect.cjs", From b8fa89b5b669afbbdc5f6a98d51547a1545aa5a7 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 17 May 2026 01:39:10 -0400 Subject: [PATCH 27/27] fix(3663): address CodeRabbit surface/layout follow-ups --- commands/gsd/surface.md | 6 ++-- .../3660-runtime-artifact-layout-module.md | 3 +- get-shit-done/bin/lib/surface.cjs | 5 +++- tests/surface-apply.test.cjs | 28 ++++++++++++------- 4 files changed, 27 insertions(+), 15 deletions(-) diff --git a/commands/gsd/surface.md b/commands/gsd/surface.md index 2d329b69d..93aa3c45a 100644 --- a/commands/gsd/surface.md +++ b/commands/gsd/surface.md @@ -118,9 +118,9 @@ Valid cluster names: `core_loop`, `audit_review`, `milestone`, `research_ideate` # Claude Code RUNTIME_CONFIG_DIR=~/.claude/skills -# Resolve commandsDir and agentsDir -COMMANDS_DIR=~/.claude/commands/gsd -AGENTS_DIR=~/.claude/agents +# Artifact destinations are derived from runtime layout +# via resolveRuntimeArtifactLayout(runtime, RUNTIME_CONFIG_DIR, scope) +# then applySurface(RUNTIME_CONFIG_DIR, layout, manifest, CLUSTERS) ``` All paths can be overridden by reading the `CLAUDE_CONFIG_DIR` env var if set. diff --git a/docs/adr/3660-runtime-artifact-layout-module.md b/docs/adr/3660-runtime-artifact-layout-module.md index 471dd5400..b3001090d 100644 --- a/docs/adr/3660-runtime-artifact-layout-module.md +++ b/docs/adr/3660-runtime-artifact-layout-module.md @@ -18,7 +18,7 @@ The root problem is the absence of a typed seam for "where does runtime R put ar - The `kinds` array is empty for runtimes with no GSD surface (a hypothetical future runtime with no integration). The `skills` kind is **absent** for runtimes that don't materialize skill directories (Cline; Gemini today). The `commands` kind is **absent** for runtimes that consume only the skills/agents layout (Claude global, Codex, etc.). - Per-runtime quirks live in the layout's record fields, not in caller branches: - **Hermes**: `{ kind: 'skills', destSubpath: 'skills/gsd', prefix: '' }` — preserves the nested namespace from #2841. - - **Cline**: `kinds: [ { kind: 'commands', … } ]` — no skills kind in the array. + - **Cline**: `kinds: []` — Cline resolves to zero kinds in Phase 1 (no `commands` kind). - **Gemini**: `kinds: [ { kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-' } ]` — no agents, no skills. - `applySurface` migrates from `(runtimeConfigDir, commandsDir, agentsDir, manifest, clusterMap)` to `(runtimeConfigDir, layout, manifest, clusterMap)`. Body collapses to `for (const kind of layout.kinds) _syncGsdDir(kind.stage(resolved), path.join(layout.configDir, kind.destSubpath), kind.kind)`. - `_findInstallSource` and `_findAgentsSource` in `surface.cjs` are removed. The layout owns source resolution. @@ -140,6 +140,7 @@ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { Phase 1 implementation landed on `feat/3663-runtime-artifact-layout-module-phase-1-m`: - `get-shit-done/bin/lib/runtime-artifact-layout.cjs` — 15-runtime layout table (grok intentionally excluded), `resolveRuntimeArtifactLayout(runtime, configDir, scope) → Layout`, walk-up `findInstallSourceRoot` helper. +- Clarification: in this Phase 1 implementation, **Cline resolves to zero kinds** (`kinds: []`), so it carries no `commands` kind in the layout table. - `get-shit-done/bin/lib/install-profiles.cjs` — new `stageSkillsForRuntimeAsSkills(srcCommandsDir, resolvedProfile, converter, prefix) → stagedDir` helper. - `get-shit-done/bin/lib/surface.cjs` — `applySurface(runtimeConfigDir, layout, manifest, clusterMap)` signature migration; `_findInstallSource` + `_findAgentsSource` deleted; `_syncGsdDir` extended to handle the `skills` kind via directory iteration. - Tests: `runtime-artifact-layout-resolve.test.cjs` (16), `runtime-artifact-layout-edge-cases.test.cjs` (10), `runtime-artifact-layout-stage.test.cjs` (5), `install-profiles-stage.test.cjs` (+7 new), `surface-apply.test.cjs` (updated 5 call sites + new skills-kind test). diff --git a/get-shit-done/bin/lib/surface.cjs b/get-shit-done/bin/lib/surface.cjs index af6ee17b3..3ca70d71d 100644 --- a/get-shit-done/bin/lib/surface.cjs +++ b/get-shit-done/bin/lib/surface.cjs @@ -204,7 +204,10 @@ function resolveSurface(runtimeConfigDir, manifest, clusterMap) { * @param {Object} [clusterMap] */ function applySurface(runtimeConfigDir, layout, manifest, clusterMap) { - const resolved = resolveSurface(runtimeConfigDir, manifest, clusterMap); + if (path.resolve(runtimeConfigDir) !== path.resolve(layout.configDir)) { + throw new TypeError('applySurface runtimeConfigDir must match layout.configDir'); + } + const resolved = resolveSurface(layout.configDir, manifest, clusterMap); for (const kind of layout.kinds) { const staged = kind.stage(resolved); const dest = path.join(layout.configDir, kind.destSubpath); diff --git a/tests/surface-apply.test.cjs b/tests/surface-apply.test.cjs index c785d4077..eab1d488e 100644 --- a/tests/surface-apply.test.cjs +++ b/tests/surface-apply.test.cjs @@ -46,18 +46,20 @@ describe('applySurface', () => { }); const manifest = loadSkillsManifest(REAL_COMMANDS_DIR); const layout = resolveRuntimeArtifactLayout('claude', runtimeConfigDir, 'local'); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + const resolved = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); const files = fs.readdirSync(commandsDir).filter(f => f.endsWith('.md')); // Every file should be a real stem we know about for (const file of files) { assert.ok(fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `unexpected file: ${file}`); } - // At minimum core skills should be present - const coreStems = ['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']; - for (const stem of coreStems) { - assert.ok(files.includes(`${stem}.md`), `core skill "${stem}" should be in commandsDir`); - } + // Core profile should materialize exactly the resolved core command set. + const expectedCore = [...resolved.skills].map(stem => `${stem}.md`).sort(); + assert.deepStrictEqual( + [...files].sort(), + expectedCore, + 'commandsDir should contain exactly core commands' + ); }); test('removes superseded files when profile shrinks', (t) => { @@ -84,17 +86,23 @@ describe('applySurface', () => { explicitAdds: [], explicitRemoves: [], }); - applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); + const resolvedCore = applySurface(runtimeConfigDir, layout, manifest, CLUSTERS); const afterCore = new Set(fs.readdirSync(commandsDir).filter(f => f.endsWith('.md'))); // core should be a subset of standard assert.ok(afterCore.size <= afterStandard.size, 'core should have fewer or equal files than standard'); - // Files removed should not be in core set - const coreStems = new Set(['new-project', 'discuss-phase', 'plan-phase', 'execute-phase', 'help', 'update']); + // Core profile should materialize exactly the resolved core command set. + const expectedCore = [...resolvedCore.skills].map(stem => `${stem}.md`).sort(); + assert.deepStrictEqual( + [...afterCore].sort(), + expectedCore, + 'afterCore should contain exactly core commands' + ); + + // All files should still map to known real skills. for (const file of afterCore) { - const stem = file.slice(0, -3); assert.ok( fs.existsSync(path.join(REAL_COMMANDS_DIR, file)), `file in commandsDir not a real skill: ${file}`