diff --git a/.changeset/924-claude-flat-skill-layout.md b/.changeset/924-claude-flat-skill-layout.md new file mode 100644 index 000000000..2fcfb9d7b --- /dev/null +++ b/.changeset/924-claude-flat-skill-layout.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 924 +--- +**Claude global install reverted to flat skill layout so concrete skills are discoverable.** PR #883 introduced nested skill layout for Claude (`~/.claude/skills/gsd-ns-/skills//SKILL.md`), but Claude Code's skill discovery scans only one level under `~/.claude/skills/` — nested concrete skills were never listed in the Skill-tool available-skills list and direct `Skill(skill="gsd-plan-phase")` calls stopped working. This fix reverts Claude to the flat layout (`~/.claude/skills/gsd-/SKILL.md`) so all ~61 concrete skills are top-level and immediately discoverable. The 6 other runtimes that confirmed non-recursive scanning (cline, qwen, hermes, augment, trae, antigravity) retain their nested layout. (#924) diff --git a/src/runtime-artifact-layout.cts b/src/runtime-artifact-layout.cts index 18d37eab0..edc1868cf 100644 --- a/src/runtime-artifact-layout.cts +++ b/src/runtime-artifact-layout.cts @@ -281,8 +281,6 @@ function convertedCommandsKind( // flat conservatively. Verified June 2026: // // NEST (confirmed non-recursive / one-level scan): -// claude — https://code.claude.com/docs/en/skills + anthropics/claude-code#28266 -// (scans one level under ~/.claude/skills; nested skills not auto-listed) // cline — cline/cline skills.ts scanSkillsDirectory uses flat fs.readdir // qwen — QwenLM/qwen-code skill-load.ts flat readdir ("depth 2 enough") // hermes — hermes-agent.nousresearch.com/docs/user-guide/features/skills @@ -296,6 +294,12 @@ function convertedCommandsKind( // opencode — sst/opencode skill/index.ts glob "skills/**/SKILL.md" // kilo — Kilo-Org/kilocode (opencode fork, same ** glob) // +// FLAT (reverted from nested — nested skills not discoverable by Skill tool, #924): +// claude — https://code.claude.com/docs/en/skills + anthropics/claude-code#28266 +// (one-level scan under ~/.claude/skills — but Skill-tool errors on unknown +// names rather than re-routing via the router; concrete skills must be +// at the top level so Skill(skill="gsd-plan-phase") succeeds) +// // FLAT (nested-scan behaviour unconfirmed → conservative): // codex — developers.openai.com/codex/skills/ // copilot — docs.github.com/en/copilot/concepts/agents/about-agent-skills @@ -325,7 +329,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope: agentsKind('agents', 'gsd-', configDir), ]; } else { - kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir, true /* #69 nested: non-recursive scan, see matrix above */)]; + kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)]; } break; diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs index 88b1a01be..db95ef051 100644 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -175,7 +175,8 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => { // Use the real COMMANDS_DIR as the source via .gsd-source marker. // installRuntimeArtifacts('claude', configDir, 'global') writes to // configDir/skills/ using the same converter as the shim did. - // With the full profile, skills are nested: gsd-ns-/skills//SKILL.md + // With the full profile (#924 fix), skills are FLAT: gsd-/SKILL.md + // (nested layout reverted for Claude — Claude Code scans only one level). const configDir = path.join(tmp, 'config'); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n'); diff --git a/tests/bug-924-claude-flat-skill-layout.test.cjs b/tests/bug-924-claude-flat-skill-layout.test.cjs new file mode 100644 index 000000000..9f4436184 --- /dev/null +++ b/tests/bug-924-claude-flat-skill-layout.test.cjs @@ -0,0 +1,189 @@ +// allow-test-rule: source-text-is-the-product +// Reads installed SKILL.md files from a real install run — +// testing their on-disk layout tests the deployed contract. + +/** + * Regression test for bug #924. + * + * PR #883 accidentally nested concrete gsd-* skills 3 levels deep for the + * Claude global install: + * + * ~/.claude/skills/gsd-ns-/skills//SKILL.md + * + * Claude Code's skills discovery scans only ONE level under ~/.claude/skills/, + * so nested concretes were never listed in the Skill-tool available-skills list. + * Direct `Skill(skill="gsd-plan-phase")` calls stopped working. + * + * Fix: revert Claude to the FLAT layout — concrete skills at the top level: + * + * ~/.claude/skills/gsd-/SKILL.md + * + * The 6 ns-* routers are also top-level entries in the flat layout (they are + * concrete skills themselves). No nested skills/ subdirs for Claude. + * + * Other 6 runtimes (cline, qwen, hermes, augment, trae, antigravity) stay nested. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { describe, test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const os = require('node:os'); + +const ROOT = path.join(__dirname, '..'); +const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd'); + +const { installRuntimeArtifacts } = require('../bin/install.js'); +const { cleanup } = require('./helpers.cjs'); +const { + loadSkillsManifest, + resolveProfile, +} = require('../gsd-core/bin/lib/install-profiles.cjs'); +const { applySurface } = require('../gsd-core/bin/lib/surface.cjs'); +const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs'); + +const MANIFEST = loadSkillsManifest(COMMANDS_GSD); +const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST }); + +// --------------------------------------------------------------------------- +// #924 regression: Claude global install must use FLAT layout +// --------------------------------------------------------------------------- + +describe('bug-924: claude global install uses flat skill layout (concrete skills discoverable)', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-claude-flat-')); + installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); + }); + + after(() => { + if (tmpDir) { + try { cleanup(tmpDir); } catch { /* best-effort */ } + } + }); + + test('claude global: concrete skills are at the TOP LEVEL of skills/ (flat, directly discoverable)', () => { + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok(fs.existsSync(skillsDir), `skills/ dir must exist under ${tmpDir}`); + + const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + + // Flat layout must have MANY more than 6 top-level gsd-* entries (concrete skills). + // Pre-#924-fix nested layout had exactly 6 (only routers). Flat must have >= 60. + assert.ok( + topLevel.length >= 60, + `Claude global must have >= 60 gsd-* top-level skill dirs (concrete flat layout). ` + + `Got ${topLevel.length}: [${topLevel.slice(0, 10).join(', ')}${topLevel.length > 10 ? ', …' : ''}]. ` + + 'Nested layout detected — #924 regression: Claude must be flat.', + ); + }); + + test('claude global: gsd-plan-phase is directly at the top level of skills/', () => { + const skillsDir = path.join(tmpDir, 'skills'); + const planPhaseDir = path.join(skillsDir, 'gsd-plan-phase'); + assert.ok( + fs.existsSync(path.join(planPhaseDir, 'SKILL.md')), + `skills/gsd-plan-phase/SKILL.md must exist at top level for Claude global install. ` + + 'Concrete skill buried in nested layout — #924 regression.', + ); + }); + + test('claude global: gsd-execute-phase is directly at the top level of skills/', () => { + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-execute-phase', 'SKILL.md')), + `skills/gsd-execute-phase/SKILL.md must exist at top level for Claude global install.`, + ); + }); + + test('claude global: gsd-code-review is directly at the top level of skills/', () => { + const skillsDir = path.join(tmpDir, 'skills'); + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-code-review', 'SKILL.md')), + `skills/gsd-code-review/SKILL.md must exist at top level for Claude global install.`, + ); + }); + + test('claude global: gsd-ns-workflow is at the top level as a concrete skill (no nested skills/ subdir)', () => { + const skillsDir = path.join(tmpDir, 'skills'); + const nsWorkflowDir = path.join(skillsDir, 'gsd-ns-workflow'); + assert.ok( + fs.existsSync(path.join(nsWorkflowDir, 'SKILL.md')), + `skills/gsd-ns-workflow/SKILL.md must exist at top level (router as concrete skill).`, + ); + + // In the FLAT layout, gsd-ns-workflow/ must NOT have a skills/ subdir. + // A skills/ subdir means nested layout was applied (the #924 regression). + assert.ok( + !fs.existsSync(path.join(nsWorkflowDir, 'skills')), + `skills/gsd-ns-workflow/skills/ must NOT exist in flat layout (nested layout detected — #924 regression).`, + ); + }); + + test('claude global: no concrete skill is nested under gsd-ns-*/skills//SKILL.md', () => { + const skillsDir = path.join(tmpDir, 'skills'); + const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-ns-')); + + for (const nsDir of topLevel) { + const nestedSkillsDir = path.join(skillsDir, nsDir, 'skills'); + assert.ok( + !fs.existsSync(nestedSkillsDir), + `${nsDir}/skills/ must NOT exist in Claude flat layout (#924 regression: nested layout detected).`, + ); + } + }); +}); + +// --------------------------------------------------------------------------- +// #924 regression: applySurface on Claude must also preserve flat layout +// (no re-nesting after surface update) +// --------------------------------------------------------------------------- + +describe('bug-924: applySurface on claude preserves flat layout (no re-nesting)', () => { + let tmpDir; + + before(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-surface-')); + installRuntimeArtifacts('claude', tmpDir, 'global', RESOLVED_FULL); + }); + + after(() => { + if (tmpDir) { + try { cleanup(tmpDir); } catch { /* best-effort */ } + } + }); + + test('claude: applySurface keeps concrete skills at the top level (flat, no re-nesting)', () => { + const skillsDir = path.join(tmpDir, 'skills'); + + // Sanity: install must produce flat layout (>= 60 top-level gsd-* dirs) + const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterInstall.length >= 60, + `Install must produce flat layout with >= 60 gsd-* dirs. Got ${topLevelAfterInstall.length}.`, + ); + + // Run applySurface (full surface → full profile) + const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global'); + applySurface(tmpDir, layout, MANIFEST); + + // After applySurface: still flat + const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterSurface.length >= 60, + `After applySurface: must still have >= 60 gsd-* top-level dirs (flat). ` + + `Got ${topLevelAfterSurface.length}. Re-nesting detected.`, + ); + + // gsd-plan-phase must remain directly accessible + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After applySurface: gsd-plan-phase/SKILL.md must remain at top level.', + ); + }); +}); diff --git a/tests/enh-769-context-fork-effort.install.test.cjs b/tests/enh-769-context-fork-effort.install.test.cjs index a7a658592..c2c69b5ff 100644 --- a/tests/enh-769-context-fork-effort.install.test.cjs +++ b/tests/enh-769-context-fork-effort.install.test.cjs @@ -41,7 +41,12 @@ const os = require('node:os'); const { install, convertClaudeCommandToClaudeSkill } = require('../bin/install.js'); const { cleanup } = require('./helpers.cjs'); -const { nestedSkillPath } = require('./helpers/nested-layout.cjs'); + +// #924: Claude global install is now FLAT — concrete skills are at the top level. +// flatSkillPath returns: /gsd-/SKILL.md +function flatSkillPath(skillsRoot, stem) { + return path.join(skillsRoot, `gsd-${stem}`, 'SKILL.md'); +} const REPO_ROOT = path.resolve(__dirname, '..'); const SOURCE_COMMANDS_DIR = path.join(REPO_ROOT, 'commands', 'gsd'); @@ -268,7 +273,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-autonomous SKILL.md does NOT have context: fork after global install (#921)', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'autonomous'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); const fm = readFrontmatter(skillPath); assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, `gsd-autonomous is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); @@ -276,7 +281,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'autonomous'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'autonomous'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*xhigh$/m, `gsd-autonomous SKILL.md must have effort: xhigh\nActual:\n${fm}`); @@ -284,7 +289,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-execute-phase SKILL.md does NOT have context: fork after global install (#921)', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'execute-phase'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); const fm = readFrontmatter(skillPath); assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, `gsd-execute-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); @@ -292,7 +297,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'execute-phase'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'execute-phase'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*xhigh$/m, `gsd-execute-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); @@ -300,7 +305,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-plan-phase SKILL.md does NOT have context: fork after global install (#921)', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'plan-phase'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); const fm = readFrontmatter(skillPath); assert.doesNotMatch(fm, /^context:[ \t]*fork$/m, `gsd-plan-phase is a spawning orchestrator; its SKILL.md must NOT have context: fork (#921)\nActual:\n${fm}`); @@ -308,7 +313,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'plan-phase'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'plan-phase'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*xhigh$/m, `gsd-plan-phase SKILL.md must have effort: xhigh\nActual:\n${fm}`); @@ -316,7 +321,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-progress SKILL.md has effort: low after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'progress'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'progress'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*low$/m, `gsd-progress SKILL.md must have effort: low\nActual:\n${fm}`); @@ -324,7 +329,7 @@ describe('#769/#921 Claude global install: spawning-orchestrator SKILL.md files test('gsd-stats SKILL.md has effort: low after global install', () => { runClaudeGlobalInstall(claudeHome); - const skillPath = nestedSkillPath(path.join(claudeHome, 'skills'), 'gsd-', 'stats'); + const skillPath = flatSkillPath(path.join(claudeHome, 'skills'),'stats'); const fm = readFrontmatter(skillPath); assert.match(fm, /^effort:[ \t]*low$/m, `gsd-stats SKILL.md must have effort: low\nActual:\n${fm}`); diff --git a/tests/install-nested-layout.test.cjs b/tests/install-nested-layout.test.cjs index f3a9fd50f..89bd00cc5 100644 --- a/tests/install-nested-layout.test.cjs +++ b/tests/install-nested-layout.test.cjs @@ -32,7 +32,8 @@ const { COMMANDS_GSD, ROUTER_STEMS, routerChildren } = require('./helpers/nested // --------------------------------------------------------------------------- const NEST = [ - { runtime: 'claude', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, + // Claude reverted to flat (#924: nested layout breaks Skill-tool discovery on Claude Code). + // Only the 6 runtimes below keep the nested layout. { runtime: 'cline', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, { runtime: 'qwen', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' }, { runtime: 'hermes', scope: 'global', skillsSub: 'skills/gsd', prefix: '' }, @@ -42,6 +43,9 @@ const NEST = [ ]; const FLAT = [ + // Claude reverted to flat (#924): Claude Code scans only one level under ~/.claude/skills/ + // so nested concretes were never discoverable by the Skill tool. + { runtime: 'claude', scope: 'global', skillsSub: 'skills' }, { runtime: 'cursor', scope: 'global', skillsSub: 'skills' }, { runtime: 'codex', scope: 'global', skillsSub: 'skills' }, { runtime: 'copilot', scope: 'global', skillsSub: 'skills' }, @@ -200,10 +204,13 @@ for (const { runtime, scope, skillsSub, prefix } of NEST) { } // --------------------------------------------------------------------------- -// claude extra: total top-level gsd- count must equal exactly 6 +// claude extra: total top-level gsd- count must be >= 60 (FLAT, #924) +// +// Pre-#924 (nested) this block asserted exactly 6 (only routers). +// Post-#924 (flat) Claude has all concrete skills at the top level. // --------------------------------------------------------------------------- -describe('claude: total top-level gsd- entries == 6', () => { +describe('claude: total top-level gsd- entries >= 60 (flat layout, #924)', () => { let tmpDir; before(() => { @@ -216,15 +223,15 @@ describe('claude: total top-level gsd- entries == 6', () => { } }); - test('claude: total top-level gsd- skill entries == 6', () => { + test('claude: >= 60 gsd-* top-level skill entries (concrete flat layout, not nested)', () => { const skillsDir = path.join(tmpDir, 'skills'); assert.ok(fs.existsSync(skillsDir), 'skills/ dir must exist'); const topLevel = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); - assert.strictEqual( - topLevel.length, - 6, - `Expected exactly 6 gsd-* top-level entries under claude/skills, got ${topLevel.length}: [${topLevel.join(', ')}]`, + assert.ok( + topLevel.length >= 60, + `Expected >= 60 gsd-* top-level entries under claude/skills (flat layout after #924 fix). ` + + `Got ${topLevel.length}: [${topLevel.slice(0, 10).join(', ')}${topLevel.length > 10 ? ', …' : ''}]`, ); }); }); diff --git a/tests/issue-69-surface-keeps-nested.test.cjs b/tests/issue-69-surface-keeps-nested.test.cjs index 3fa38d32b..ec10dcb2d 100644 --- a/tests/issue-69-surface-keeps-nested.test.cjs +++ b/tests/issue-69-surface-keeps-nested.test.cjs @@ -8,6 +8,10 @@ // // Fix (install-profiles.cts): gate nesting on full OR full-equivalent (all routerStems // present in the concrete Set) so that the surface path preserves nesting. +// +// NOTE: As of #924 Claude has been REVERTED to FLAT. This test now uses Cline as the +// representative nested runtime. The original claude-global test below is updated to +// assert the flat layout (>= 60 top-level gsd-* entries, concrete skills discoverable). 'use strict'; @@ -29,18 +33,19 @@ const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-ar const { cleanup } = require('./helpers.cjs'); describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', () => { - test('claude global full: applySurface keeps 6 router dirs and nested gsd-ns-workflow/skills/plan-phase/SKILL.md', (t) => { + // #924: Claude is now flat; use Cline as the representative nested runtime. + test('cline global full: applySurface keeps 6 router dirs and nested gsd-ns-manage/skills/help/SKILL.md', (t) => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-69-surface-')); t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } }); // Step 1: full install const manifest = loadSkillsManifest(COMMANDS_GSD); const resolved = resolveProfile({ modes: ['full'], manifest }); - installRuntimeArtifacts('claude', tmpDir, 'global', resolved); + installRuntimeArtifacts('cline', tmpDir, 'global', resolved); const skillsDir = path.join(tmpDir, 'skills'); - // Sanity: install must produce nested layout + // Sanity: install must produce nested layout (6 top-level router dirs) const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); assert.strictEqual( topLevelAfterInstall.length, @@ -53,7 +58,7 @@ describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', ); // Step 2: applySurface (full surface, no surface state file → resolves to full) - const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global'); + const layout = resolveRuntimeArtifactLayout('cline', tmpDir, 'global'); applySurface(tmpDir, layout, manifest); // Step 3: assert nested layout is preserved after applySurface @@ -77,4 +82,50 @@ describe('issue-69: applySurface preserves nested skill layout (no re-flatten)', 'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)', ); }); + + // #924 companion: Claude must use FLAT layout and applySurface must NOT re-nest it. + test('claude global full: install produces flat layout and applySurface preserves it (#924)', (t) => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-924-69-')); + t.after(() => { try { cleanup(tmpDir); } catch { /* best-effort */ } }); + + const manifest = loadSkillsManifest(COMMANDS_GSD); + const resolved = resolveProfile({ modes: ['full'], manifest }); + installRuntimeArtifacts('claude', tmpDir, 'global', resolved); + + const skillsDir = path.join(tmpDir, 'skills'); + + // Install must produce FLAT layout (>= 60 gsd-* dirs) + const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterInstall.length >= 60, + `Claude install must produce >= 60 gsd-* top-level dirs (flat, #924). Got ${topLevelAfterInstall.length}.`, + ); + + // gsd-plan-phase must be directly at top level + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After claude install: gsd-plan-phase/SKILL.md must be at top level (flat layout, #924)', + ); + + // No nested skills/ subdirs under gsd-ns-* in Claude + assert.ok( + !fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills')), + 'After claude install: gsd-ns-workflow/skills/ must NOT exist (flat layout, no nesting, #924)', + ); + + // applySurface must preserve flat layout + const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global'); + applySurface(tmpDir, layout, manifest); + + const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-')); + assert.ok( + topLevelAfterSurface.length >= 60, + `After applySurface: claude must still have >= 60 gsd-* dirs (flat preserved). Got ${topLevelAfterSurface.length}.`, + ); + + assert.ok( + fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')), + 'After applySurface: gsd-plan-phase/SKILL.md must remain at top level (#924)', + ); + }); }); diff --git a/tests/runtime-artifact-layout.test.cjs b/tests/runtime-artifact-layout.test.cjs index 7e45474e8..e02f02c37 100644 --- a/tests/runtime-artifact-layout.test.cjs +++ b/tests/runtime-artifact-layout.test.cjs @@ -417,7 +417,7 @@ describe('stage — skills kind (claude global)', () => { assert.ok(entries.length >= 1, 'at least one skill dir should be staged'); }); - test('stage with skills="*" nests all commands/gsd/*.md under 6 routers (claude)', () => { + test('stage with skills="*" produces flat layout for claude (#924: reverted from nested)', () => { const layout = resolveRuntimeArtifactLayout('claude', FAKE_STAGE_DIR, 'global'); const skillsKind = layout.kinds.find(k => k.kind === 'skills'); assert.ok(skillsKind, 'should have a skills kind'); @@ -425,32 +425,22 @@ describe('stage — skills kind (claude global)', () => { const stagedDir = skillsKind.stage(PROFILE_FULL); assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist'); - // Claude is a NESTING runtime: full profile produces exactly 6 gsd-ns-* router dirs. + // #924: Claude is reverted to FLAT. Full profile produces >= 60 top-level gsd-* dirs. + // (Previously nested: exactly 6 gsd-ns-* router dirs. That broke Skill-tool discovery.) const topEntries = fs.readdirSync(stagedDir); - assert.strictEqual(topEntries.length, 6, `full profile should have exactly 6 router dirs, got ${topEntries.length}`); + assert.ok( + topEntries.length >= 60, + `full profile should have >= 60 top-level skill dirs (flat layout, #924), got ${topEntries.length}`, + ); for (const entry of topEntries) { - assert.ok(entry.startsWith('gsd-ns-'), `top-level entry should be a gsd-ns-* router: ${entry}`); - // Each router has its own SKILL.md. - const routerSkillMd = path.join(stagedDir, entry, 'SKILL.md'); - assert.ok(fs.existsSync(routerSkillMd), `router SKILL.md must exist in ${entry}`); - // Each router has a skills/ subdirectory with nested children. + assert.ok(entry.startsWith('gsd-'), `entry should start with gsd-: ${entry}`); + // Each skill dir has its own SKILL.md at the top level. + const skillMd = path.join(stagedDir, entry, 'SKILL.md'); + assert.ok(fs.existsSync(skillMd), `SKILL.md must exist at top level in ${entry}`); + // No nested skills/ subdirectory: flat layout means no nesting. const skillsSubdir = path.join(stagedDir, entry, 'skills'); - assert.ok(fs.existsSync(skillsSubdir), `skills/ subdir must exist in ${entry}`); - assert.ok(fs.statSync(skillsSubdir).isDirectory(), `${entry}/skills must be a directory`); + assert.ok(!fs.existsSync(skillsSubdir), `skills/ subdir must NOT exist in ${entry} (flat layout, #924)`); } - - // Total SKILL.md files across all routers + nested children must be large (proves no skill was dropped). - function countSkillMdFiles(dir) { - let count = 0; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) count += countSkillMdFiles(fullPath); - else if (entry.name === 'SKILL.md') count++; - } - return count; - } - const totalSkillMd = countSkillMdFiles(stagedDir); - assert.ok(totalSkillMd >= 60, `full profile should have >= 60 total SKILL.md files (routers + children), got ${totalSkillMd}`); }); });