fix(#924): revert Claude to flat skill layout so concrete skills are discoverable (#928)

PR #883 nested Claude skills 3 levels deep under gsd-ns-*/skills/<stem>/SKILL.md.
Claude Code's Skill tool scans only one level under ~/.claude/skills/ — nested
concrete skills were never listed and Skill(skill="gsd-plan-phase") calls failed.

Revert to flat layout: all ~61 concrete skills at ~/.claude/skills/gsd-<name>/SKILL.md.
The 6 other runtimes confirmed as non-recursive scanners (cline, qwen, hermes, augment,
trae, antigravity) retain their nested layout — only Claude changes.

Tradeoff: ~61 top-level skill dirs return to the flat install, but they are discoverable
and invokable. Nested concretes were invisible to the Skill tool entirely.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-09 08:59:20 -04:00
committed by GitHub
parent b866b95296
commit 03d9fcdd65
8 changed files with 300 additions and 48 deletions

View File

@@ -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-<router>/skills/<stem>/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-<name>/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)

View File

@@ -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;

View File

@@ -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-<router>/skills/<stem>/SKILL.md
// With the full profile (#924 fix), skills are FLAT: gsd-<stem>/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');

View File

@@ -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-<router>/skills/<stem>/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-<name>/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/<stem>/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.',
);
});
});

View File

@@ -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: <skillsRoot>/gsd-<stem>/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}`);

View File

@@ -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 ? ', …' : ''}]`,
);
});
});

View File

@@ -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)',
);
});
});

View File

@@ -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}`);
});
});