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:
5
.changeset/924-claude-flat-skill-layout.md
Normal file
5
.changeset/924-claude-flat-skill-layout.md
Normal 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)
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
189
tests/bug-924-claude-flat-skill-layout.test.cjs
Normal file
189
tests/bug-924-claude-flat-skill-layout.test.cjs
Normal 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.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 ? ', …' : ''}]`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user