feat(#69): nest concrete skills under namespace routers at install (#883)

Emit the 6 gsd-ns-* routers as the only top-level skill bundles and nest
the ~61 concrete skills under <router>/skills/<name>/SKILL.md on runtimes
with confirmed non-recursive skill loaders (claude global, cline, qwen,
hermes, augment, trae, antigravity). Router bodies rewrite their routing
tables from Skill-tool dispatch to a Read skills/<name>/SKILL.md pattern.
Recursive/unconfirmed loaders (cursor, codex, copilot, windsurf, codebuddy,
opencode, kilo) keep the flat layout. Completes the v1.40 namespace
architecture (#2792) so the eager skill listing drops to ~6 entries.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 15:09:34 -04:00
committed by GitHub
parent dd81e3d120
commit 0a11d361ca
20 changed files with 975 additions and 115 deletions

View File

@@ -174,56 +174,65 @@ 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/gsd-*/SKILL.md using the same converter as the shim did.
// configDir/skills/ using the same converter as the shim did.
// With the full profile, skills are nested: gsd-ns-<router>/skills/<stem>/SKILL.md
const configDir = path.join(tmp, 'config');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, '.gsd-source'), COMMANDS_DIR + '\n');
installRuntimeArtifacts('claude', configDir, 'global', resolvedProfileFull);
const skillsDir = path.join(configDir, 'skills');
// Don't filter the directory listing by `startsWith('gsd-')` — that
// would silently hide exactly the kind of drift this test exists to
// catch (a `gsd:extract-learnings` colon variant or a bare
// `extract-learnings` without the namespace prefix would never be
// collected, and the loop below would never see them). Capture every
// generated directory and assert the namespace invariants explicitly.
const skillDirs = fs.readdirSync(skillsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
assert.ok(skillDirs.length > 0, 'expected generated skill directories under skillsDir');
for (const dir of skillDirs) {
assert.ok(
dir.startsWith('gsd-'),
`${dir}: generated skill directory must start with the canonical 'gsd-' namespace`,
);
assert.ok(
!dir.includes(':'),
`${dir}: generated skill directory must not contain the retired colon namespace separator`,
);
assert.ok(
!dir.includes('_'),
`${dir}: generated skill directory must use hyphens, not underscores`,
);
// Recursively collect all SKILL.md files under skills/ (handles both flat and
// nested layouts). Don't filter any paths — that would silently hide exactly
// the kind of drift this test exists to catch (a `gsd:extract-learnings`
// colon variant or a bare `extract-learnings` without the namespace prefix
// would never be collected, and the loop below would never see them).
function collectSkillMds(dir) {
const results = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...collectSkillMds(full));
} else if (entry.name === 'SKILL.md') {
results.push(full);
}
}
return results;
}
assert.ok(skillDirs.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings');
assert.ok(!skillDirs.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings');
const allSkillMdPaths = collectSkillMds(skillsDir);
assert.ok(allSkillMdPaths.length > 0, 'expected generated SKILL.md files under skillsDir');
for (const skillDir of skillDirs) {
const skillContent = fs.readFileSync(path.join(skillsDir, skillDir, 'SKILL.md'), 'utf-8');
// Validate every SKILL.md's name: field (the consumer-facing name used in
// autocomplete). We also check that the containing dir name doesn't use
// banned characters at any level of nesting.
const allNames = [];
for (const skillMdPath of allSkillMdPaths) {
const relPath = path.relative(skillsDir, skillMdPath);
const skillContent = fs.readFileSync(skillMdPath, 'utf-8');
// Scope the name: lookup to the YAML frontmatter block so a stray
// `name:` line in the body cannot satisfy the assertion.
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
assert.ok(fmMatch, `${skillDir}: generated SKILL.md must include frontmatter`);
assert.ok(fmMatch, `${relPath}: generated SKILL.md must include frontmatter`);
const nameLine = fmMatch[1].split('\n').find((l) => /^name:\s*/.test(l));
assert.ok(nameLine, `${skillDir}: generated SKILL.md is missing name: frontmatter`);
assert.ok(nameLine, `${relPath}: generated SKILL.md is missing name: frontmatter`);
const name = nameLine.replace(/^name:\s*/, '').trim();
assert.ok(name.startsWith('gsd-'), `${skillDir}: autocomplete name must start with gsd-, got ${name}`);
assert.ok(!name.includes(':'), `${skillDir}: autocomplete name must not contain colon, got ${name}`);
assert.ok(!name.includes('_'), `${skillDir}: autocomplete name must not contain underscore, got ${name}`);
assert.ok(name.startsWith('gsd-'), `${relPath}: autocomplete name must start with gsd-, got ${name}`);
assert.ok(!name.includes(':'), `${relPath}: autocomplete name must not contain colon, got ${name}`);
assert.ok(!name.includes('_'), `${relPath}: autocomplete name must not contain underscore, got ${name}`);
allNames.push(name);
// Also validate each path segment (dir name) in the relative path doesn't
// contain the banned characters — catches mislabeled directory names.
const segments = relPath.split(path.sep).slice(0, -1); // exclude 'SKILL.md' filename
for (const seg of segments) {
assert.ok(!seg.includes(':'), `${relPath}: dir segment "${seg}" must not contain colon`);
assert.ok(!seg.includes('_'), `${relPath}: dir segment "${seg}" must use hyphens, not underscores`);
}
}
assert.ok(allNames.includes('gsd-extract-learnings'), 'autocomplete surface must include gsd-extract-learnings');
assert.ok(!allNames.includes('gsd-extract_learnings'), 'autocomplete surface must not include gsd-extract_learnings');
});
test('transformContentToHyphen (from fix-slash-commands.cjs) rewrites colon to hyphen for known commands', () => {

View File

@@ -41,6 +41,53 @@ const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
const MANIFEST = loadSkillsManifest(REAL_COMMANDS_DIR);
const RESOLVED_CORE = resolveProfile({ modes: ['core'], manifest: MANIFEST });
/**
* Map from concrete skill stem → ns-* router stem.
* Used for nesting runtimes (claude, cline, qwen, etc.) when the full profile
* is installed: concrete skills live at <skillsRoot>/gsd-<router>/skills/<stem>/SKILL.md.
*/
const CHILD_ROUTER = {
// ns-workflow
'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow',
'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow',
'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow',
'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow',
'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow',
'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow',
// ns-project
'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project',
'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project',
'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project',
// ns-review
'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review',
'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review',
'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review',
'review': 'ns-review', 'ui-phase': 'ns-review',
// ns-context
'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context',
'extract-learnings': 'ns-context',
// ns-ideate
'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate',
'spike': 'ns-ideate',
// ns-manage
'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage',
'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage',
'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage',
'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage',
'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage',
'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage',
};
/**
* Returns the nested SKILL.md path for a concrete skill stem on cline
* (prefix='gsd-'): <skillsRoot>/gsd-<router>/skills/<stem>/SKILL.md
*/
function nestedClineSkillPath(skillsRoot, stem) {
const router = CHILD_ROUTER[stem];
if (!router) throw new Error(`No router mapping for stem: ${stem}`);
return path.join(skillsRoot, 'gsd-' + router, 'skills', stem, 'SKILL.md');
}
// ─── (a) Converter unit test ─────────────────────────────────────────────────
const SAMPLE_COMMAND = `---
@@ -347,11 +394,11 @@ describe('install() global cline — coexistence: skills AND .clinerules', () =>
`skills/ directory must exist under ${tmpGlobalDir} after global cline install`
);
// gsd-help is present in every profile (core, standard, full)
const helpSkillFile = path.join(skillsDir, 'gsd-help', 'SKILL.md');
// full profile: gsd-help is nested under gsd-ns-manage/skills/help/SKILL.md
const helpSkillFile = nestedClineSkillPath(skillsDir, 'help');
assert.ok(
fs.existsSync(helpSkillFile),
`skills/gsd-help/SKILL.md must exist under ${tmpGlobalDir} — skills emission broken for global cline`
`${path.relative(tmpGlobalDir, helpSkillFile)} must exist under ${tmpGlobalDir} — skills emission broken for global cline`
);
});
@@ -438,8 +485,9 @@ describe('convertClaudeToCliineMarkdown — bare ~/.claude and CLAUDE_CONFIG_DIR
installRuntimeArtifacts('cline', configDir, 'global', RESOLVED_FULL);
const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md');
assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist for full profile');
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
const surfaceSkill = nestedClineSkillPath(path.join(configDir, 'skills'), 'surface');
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist for full profile`);
const content = fs.readFileSync(surfaceSkill, 'utf8');
assert.ok(
@@ -497,8 +545,9 @@ describe('_applyRuntimeRewrites — cline custom-dir embedded path (Fix 1)', ()
// gsd-surface SKILL.md references config paths; with a custom configDir
// (not under $HOME), pathPrefix will be the absolute custom path.
const surfaceSkill = path.join(configDir, 'skills', 'gsd-surface', 'SKILL.md');
assert.ok(fs.existsSync(surfaceSkill), 'gsd-surface/SKILL.md must exist');
// full profile: surface is nested under gsd-ns-manage/skills/surface/SKILL.md
const surfaceSkill = nestedClineSkillPath(path.join(configDir, 'skills'), 'surface');
assert.ok(fs.existsSync(surfaceSkill), `${path.relative(configDir, surfaceSkill)} must exist`);
const content = fs.readFileSync(surfaceSkill, 'utf8');
// With a custom dir (path under /tmp, not ~/.cline), the output must NOT

View File

@@ -208,6 +208,95 @@ describe('gsd-health --context flag is wired into command + workflow', () => {
});
});
// ── Namespace nesting completeness (#69) ──────────────────────────────
// Guards that the install-layout nesting invariant (<=6 top-level entries)
// is always satisfiable: every router's requires list points at real files,
// every concrete skill is covered by at least one router, and each router's
// body table stays in sync with its requires list.
const NS_FILES = NAMESPACE_SKILLS.map((ns) => ns.file);
/**
* Parse the `requires:` flow-style array from a router file's raw content.
* Matches `requires: [a, b, c]` anywhere (frontmatter or body — always in fm).
*/
function parseRouterRequires(content) {
const m = content.match(/^requires:\s*\[([^\]]*)\]/m);
if (!m) return [];
return m[1].split(',').map((s) => s.trim()).filter(Boolean);
}
describe('namespace nesting completeness (#69)', () => {
// Build the concrete-skill set once (all *.md minus ns-*.md)
const allFiles = fs.readdirSync(COMMANDS_DIR).filter((f) => f.endsWith('.md'));
const concreteStemSet = new Set(
allFiles
.filter((f) => !f.startsWith('ns-'))
.map((f) => f.replace(/\.md$/, '')),
);
// Build per-router requires and the union over all routers
const routerRequires = new Map(); // stem -> string[]
for (const f of NS_FILES) {
const stem = f.replace(/\.md$/, '');
const content = fs.readFileSync(path.join(COMMANDS_DIR, f), 'utf-8');
routerRequires.set(stem, parseRouterRequires(content));
}
const allRoutedStems = new Set([...routerRequires.values()].flat());
test('every router requires entry resolves to a real concrete skill file', () => {
const bad = [];
for (const [routerStem, children] of routerRequires) {
for (const child of children) {
if (!fs.existsSync(path.join(COMMANDS_DIR, `${child}.md`))) {
bad.push(`${routerStem} → ${child}`);
}
}
}
assert.deepStrictEqual(
bad,
[],
`Router requires entries with no matching commands/gsd/<stem>.md: ${bad.join(', ')}`,
);
});
test('every concrete skill is routed by at least one namespace router', () => {
const unrouted = [...concreteStemSet].filter((stem) => !allRoutedStems.has(stem));
assert.deepStrictEqual(
unrouted,
[],
`Concrete skills not routed by any ns-*.md (add to a router's requires:): ${unrouted.join(', ')}`,
);
});
test("each router's routing-table rows reference only its own required sub-skills (plus flag variants)", () => {
const bad = [];
for (const [routerStem, children] of routerRequires) {
const childSet = new Set(children);
const content = fs.readFileSync(path.join(COMMANDS_DIR, `${routerStem}.md`), 'utf-8');
const fm = parseFrontmatter(content);
// Extract gsd-<stem> tokens from table data rows (last cell), strip flags
for (const line of fm._body.split('\n')) {
if (!line.startsWith('|') || /^\|[\s\-:|]+\|?\s*$/.test(line)) continue;
const cells = line.split('|').map((c) => c.trim()).filter(Boolean);
if (cells.length < 2) continue;
const lastCell = cells[cells.length - 1];
for (const match of lastCell.matchAll(/\bgsd-([a-z][a-z0-9-]*)/g)) {
const stem = match[1];
if (!childSet.has(stem)) {
bad.push(`${routerStem}: body table references gsd-${stem} but it's not in requires`);
}
}
}
}
assert.deepStrictEqual(
bad,
[],
`Routing table / requires mismatch:\n${bad.join('\n')}`,
);
});
});
// ── Cross-reference: every routed sub-skill must exist ─────────────────
// This is the regression guard the original PR lacked. Without it,
// post-#2790 consolidations can quietly invalidate router targets again.

View File

@@ -43,6 +43,52 @@ function makeTmpDir(prefix) {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
/**
* Map from concrete skill stem → ns-* router stem for nesting runtimes.
* Derived from the authoritative ns-*.md `requires:` lists.
*/
const CHILD_ROUTER = {
// ns-workflow
'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow',
'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow',
'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow',
'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow',
'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow',
'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow',
// ns-project
'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project',
'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project',
'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project',
// ns-review
'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review',
'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review',
'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review',
'review': 'ns-review', 'ui-phase': 'ns-review',
// ns-context
'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context',
'extract-learnings': 'ns-context',
// ns-ideate
'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate',
'spike': 'ns-ideate',
// ns-manage
'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage',
'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage',
'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage',
'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage',
'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage',
'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage',
};
/**
* Returns the nested SKILL.md path for a concrete skill stem on Claude
* (prefix='gsd-'): <skillsRoot>/gsd-<router>/skills/<stem>/SKILL.md
*/
function nestedClaudeSkillPath(skillsRoot, stem) {
const router = CHILD_ROUTER[stem];
if (!router) throw new Error(`No router mapping for stem: ${stem}`);
return path.join(skillsRoot, 'gsd-' + router, 'skills', stem, 'SKILL.md');
}
function readFrontmatter(mdPath) {
const content = fs.readFileSync(mdPath, 'utf8');
if (!content.startsWith('---')) return '';
@@ -253,7 +299,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-autonomous SKILL.md has context: fork after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'autonomous');
const fm = readFrontmatter(skillPath);
assert.match(fm, /^context:[ \t]*fork$/m,
`gsd-autonomous SKILL.md must have context: fork\nActual:\n${fm}`);
@@ -261,7 +307,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-autonomous SKILL.md has effort: xhigh after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-autonomous', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(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}`);
@@ -269,7 +315,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-execute-phase SKILL.md has context: fork after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'execute-phase');
const fm = readFrontmatter(skillPath);
assert.match(fm, /^context:[ \t]*fork$/m,
`gsd-execute-phase SKILL.md must have context: fork\nActual:\n${fm}`);
@@ -277,7 +323,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-execute-phase SKILL.md has effort: xhigh after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-execute-phase', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(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}`);
@@ -285,7 +331,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-plan-phase SKILL.md has context: fork after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(path.join(claudeHome, 'skills'), 'plan-phase');
const fm = readFrontmatter(skillPath);
assert.match(fm, /^context:[ \t]*fork$/m,
`gsd-plan-phase SKILL.md must have context: fork\nActual:\n${fm}`);
@@ -293,7 +339,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-plan-phase SKILL.md has effort: xhigh after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-plan-phase', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(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}`);
@@ -301,7 +347,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-progress SKILL.md has effort: low after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-progress', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(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}`);
@@ -309,7 +355,7 @@ describe('#769 Claude global install: SKILL.md files preserve context: fork and
test('gsd-stats SKILL.md has effort: low after global install', () => {
runClaudeGlobalInstall(claudeHome);
const skillPath = path.join(claudeHome, 'skills', 'gsd-stats', 'SKILL.md');
const skillPath = nestedClaudeSkillPath(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

@@ -421,9 +421,10 @@ describe('install: manifest records mode for both profiles', () => {
const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return { mode: '<no manifest>', skillCount: 0, agentCount: 0 };
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillCount = new Set(
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
).size;
// Count SKILL.md files under skills/ (works for both flat and ns-nested layouts).
const skillCount = Object.keys(m.files || {}).filter(
k => k.startsWith('skills/') && k.endsWith('/SKILL.md'),
).length;
const agentCount = Object.keys(m.files || {}).filter(k => k.startsWith('agents/')).length;
return { mode: m.mode, skillCount, agentCount };
} finally {
@@ -474,9 +475,10 @@ describe('install-minimal-backcompat: --minimal and --profile=core produce same
const manifestPath = path.join(targetDir, MANIFEST_NAME);
if (!fs.existsSync(manifestPath)) return { mode: null, skillCount: 0, profileMarker: null };
const m = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
const skillCount = new Set(
Object.keys(m.files || {}).filter(k => k.startsWith('skills/')).map(k => k.split('/')[1]),
).size;
// Count SKILL.md files under skills/ (works for both flat and ns-nested layouts).
const skillCount = Object.keys(m.files || {}).filter(
k => k.startsWith('skills/') && k.endsWith('/SKILL.md'),
).length;
const markerPath = path.join(targetDir, '.gsd-profile');
const profileMarker = fs.existsSync(markerPath) ? fs.readFileSync(markerPath, 'utf8').trim() : null;
return { mode: m.mode, skillCount, profileMarker };

View File

@@ -0,0 +1,296 @@
// #69: namespace nested-skill install layout — multi-runtime parity
// allow-test-rule: source-text-is-the-product
// Reads installed .md files (product artefacts) from a real install run —
// testing their on-disk layout tests the deployed contract.
'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');
// ---------------------------------------------------------------------------
// Runtime parity decision matrix (#69)
// ---------------------------------------------------------------------------
const NEST = [
{ runtime: 'claude', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
{ runtime: 'cline', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
{ runtime: 'qwen', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
{ runtime: 'hermes', scope: 'global', skillsSub: 'skills/gsd', prefix: '' },
{ runtime: 'augment', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
{ runtime: 'trae', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
{ runtime: 'antigravity', scope: 'global', skillsSub: 'skills', prefix: 'gsd-' },
];
const FLAT = [
{ runtime: 'cursor', scope: 'global', skillsSub: 'skills' },
{ runtime: 'codex', scope: 'global', skillsSub: 'skills' },
{ runtime: 'copilot', scope: 'global', skillsSub: 'skills' },
{ runtime: 'windsurf', scope: 'global', skillsSub: 'skills' },
{ runtime: 'codebuddy', scope: 'global', skillsSub: 'skills' },
{ runtime: 'opencode', scope: 'global', skillsSub: 'skills' },
{ runtime: 'kilo', scope: 'global', skillsSub: 'skills' },
];
const ROUTER_STEMS = ['ns-context', 'ns-ideate', 'ns-manage', 'ns-project', 'ns-review', 'ns-workflow'];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Parse the `requires:` flow-style array from a router file's raw content.
* Matches `requires: [a, b, c]` (inline array form).
*/
function parseRouterRequires(content) {
const m = content.match(/^requires:\s*\[([^\]]*)\]/m);
if (!m) return [];
return m[1].split(',').map((s) => s.trim()).filter(Boolean);
}
/**
* Read the requires list for a router stem from the source commands/gsd dir.
*/
function routerChildren(routerStem) {
const srcFile = path.join(COMMANDS_GSD, `${routerStem}.md`);
const content = fs.readFileSync(srcFile, 'utf-8');
return parseRouterRequires(content);
}
/**
* Create a fresh temp dir, run installRuntimeArtifacts into it, and return
* the tmpDir path. Caller must cleanup in finally.
*/
function runInstall(runtime, scope, resolved) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-nest-test-${runtime}-`));
installRuntimeArtifacts(runtime, tmpDir, scope, resolved);
return tmpDir;
}
// Resolve the full profile once (shared by all installs)
const MANIFEST = loadSkillsManifest(COMMANDS_GSD);
const RESOLVED_FULL = resolveProfile({ modes: ['full'], manifest: MANIFEST });
// ---------------------------------------------------------------------------
// NEST runtimes: should produce exactly 6 top-level router bundles
// ---------------------------------------------------------------------------
for (const { runtime, scope, skillsSub, prefix } of NEST) {
describe(`${runtime} (nested layout)`, () => {
let tmpDir;
before(() => {
tmpDir = runInstall(runtime, scope, RESOLVED_FULL);
});
after(() => {
if (tmpDir) {
try { cleanup(tmpDir); } catch { /* best-effort */ }
}
});
test(`${runtime}: exactly 6 top-level router bundles, no concrete skill at top level`, () => {
const skillsDir = path.join(tmpDir, skillsSub);
assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`);
// Find router bundle dirs (top-level, named <prefix>ns-*)
const topLevel = fs.readdirSync(skillsDir);
const routerDirs = topLevel.filter((n) => n.startsWith(`${prefix}ns-`));
assert.strictEqual(
routerDirs.length,
6,
`Expected exactly 6 router dirs under ${skillsDir}, got ${routerDirs.length}: [${routerDirs.join(', ')}]`,
);
// Each router dir must be a real directory with a SKILL.md
for (const rd of routerDirs) {
const routerPath = path.join(skillsDir, rd);
assert.ok(
fs.statSync(routerPath).isDirectory(),
`${rd} must be a directory`,
);
assert.ok(
fs.existsSync(path.join(routerPath, 'SKILL.md')),
`${rd}/SKILL.md must exist`,
);
}
// Sample concrete skills must NOT appear at top level
for (const concreteSample of ['plan-phase', 'code-review']) {
const concreteName = `${prefix}${concreteSample}`;
assert.ok(
!topLevel.includes(concreteName),
`Concrete skill ${concreteName} must NOT be at top level for ${runtime}`,
);
}
// Total GSD-owned top-level entries must be EXACTLY 6 (only the routers).
// For prefix='gsd-' runtimes: count dirs starting with 'gsd-'.
// For hermes (prefix=''): count ALL dirs under skills/gsd (everything is GSD-owned).
const gsdTopLevelCount = prefix !== ''
? topLevel.filter((n) => n.startsWith(prefix)).length
: topLevel.filter((n) => fs.statSync(path.join(skillsDir, n)).isDirectory()).length;
assert.strictEqual(
gsdTopLevelCount,
6,
`Expected exactly 6 total GSD-owned top-level skill dirs for ${runtime} (only routers), got ${gsdTopLevelCount}: [${topLevel.join(', ')}]`,
);
});
test(`${runtime}: every router has a skills/ subdir with its required children as nested SKILL.md`, () => {
const skillsDir = path.join(tmpDir, skillsSub);
for (const routerStem of ROUTER_STEMS) {
const routerDirName = `${prefix}${routerStem}`;
const routerDir = path.join(skillsDir, routerDirName);
assert.ok(
fs.existsSync(routerDir),
`Router dir must exist: ${routerDir}`,
);
const childrenSubdir = path.join(routerDir, 'skills');
assert.ok(
fs.statSync(childrenSubdir).isDirectory(),
`${routerDirName}/skills must be a directory`,
);
const children = routerChildren(routerStem);
assert.ok(children.length > 0, `Router ${routerStem} must have at least one child`);
for (const child of children) {
const childSkillMd = path.join(childrenSubdir, child, 'SKILL.md');
assert.ok(
fs.existsSync(childSkillMd),
`${routerDirName}/skills/${child}/SKILL.md must exist (child of ${routerStem})`,
);
assert.ok(
fs.statSync(childSkillMd).isFile(),
`${routerDirName}/skills/${child}/SKILL.md must be a file`,
);
}
}
});
test(`${runtime}: every router body Read-reference resolves to a nested file`, () => {
const skillsDir = path.join(tmpDir, skillsSub);
for (const routerStem of ROUTER_STEMS) {
const routerDirName = `${prefix}${routerStem}`;
const routerDir = path.join(skillsDir, routerDirName);
const routerSkillMd = path.join(routerDir, 'SKILL.md');
assert.ok(fs.existsSync(routerSkillMd), `${routerDirName}/SKILL.md must exist`);
const body = fs.readFileSync(routerSkillMd, 'utf-8');
// Extract skills/<stem>/SKILL.md paths from Read-reference lines in the table
const refs = [...body.matchAll(/skills\/([a-z0-9-]+)\/SKILL\.md/g)].map((m) => m[1]);
// There should be at least one reference in every nested router body
assert.ok(
refs.length > 0,
`${routerDirName}/SKILL.md must contain at least one skills/<stem>/SKILL.md reference`,
);
for (const stem of refs) {
assert.ok(
fs.existsSync(path.join(routerDir, 'skills', stem, 'SKILL.md')),
`${routerDirName}/SKILL.md references skills/${stem}/SKILL.md but it does not exist on disk`,
);
}
}
});
});
}
// ---------------------------------------------------------------------------
// claude extra: total top-level gsd- count must equal exactly 6
// ---------------------------------------------------------------------------
describe('claude: total top-level gsd- entries == 6', () => {
let tmpDir;
before(() => {
tmpDir = runInstall('claude', 'global', RESOLVED_FULL);
});
after(() => {
if (tmpDir) {
try { cleanup(tmpDir); } catch { /* best-effort */ }
}
});
test('claude: total top-level gsd- skill entries == 6', () => {
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(', ')}]`,
);
});
});
// ---------------------------------------------------------------------------
// FLAT runtimes: concrete skills stay top-level, no nesting
// ---------------------------------------------------------------------------
for (const { runtime, scope, skillsSub } of FLAT) {
describe(`${runtime} (flat layout)`, () => {
let tmpDir;
before(() => {
tmpDir = runInstall(runtime, scope, RESOLVED_FULL);
});
after(() => {
if (tmpDir) {
try { cleanup(tmpDir); } catch { /* best-effort */ }
}
});
test(`${runtime}: stays flat — concrete skills remain top-level, no nesting`, () => {
const skillsDir = path.join(tmpDir, skillsSub);
assert.ok(fs.existsSync(skillsDir), `skillsDir must exist: ${skillsDir}`);
const topLevel = fs.readdirSync(skillsDir);
const gsdEntries = topLevel.filter((n) => n.startsWith('gsd-'));
// For flat runtimes, there should be many more than 6 top-level gsd- entries
assert.ok(
gsdEntries.length >= 60,
`Flat runtime ${runtime} must have >= 60 gsd-* top-level entries (concrete skills), got ${gsdEntries.length}`,
);
// No router dir should contain a skills/ subdirectory (nesting must not have been applied)
const routerDirsPresent = topLevel.filter((n) => n.startsWith('gsd-ns-'));
for (const rd of routerDirsPresent) {
const nestedSkillsDir = path.join(skillsDir, rd, 'skills');
assert.ok(
!fs.existsSync(nestedSkillsDir),
`Flat runtime ${runtime}: router dir ${rd} must NOT have a skills/ subdirectory (nesting must not apply)`,
);
}
});
});
}

View File

@@ -53,6 +53,43 @@ const {
walk,
} = require('./helpers/install-shared.cjs');
/**
* Map from concrete skill stem → ns-* router stem for nesting runtimes.
* These runtimes nest concrete skills at <skillsRoot>/<prefix><router>/skills/<stem>/SKILL.md
* (claude/cline/qwen/trae/augment/antigravity: prefix='gsd-'; hermes: prefix='').
*/
const CHILD_ROUTER = {
// ns-workflow
'discuss-phase': 'ns-workflow', 'spec-phase': 'ns-workflow', 'plan-phase': 'ns-workflow',
'execute-phase': 'ns-workflow', 'verify-work': 'ns-workflow', 'phase': 'ns-workflow',
'progress': 'ns-workflow', 'ultraplan-phase': 'ns-workflow',
'plan-review-convergence': 'ns-workflow', 'add-tests': 'ns-workflow',
'ai-integration-phase': 'ns-workflow', 'autonomous': 'ns-workflow',
'fast': 'ns-workflow', 'mvp-phase': 'ns-workflow', 'quick': 'ns-workflow',
// ns-project
'new-project': 'ns-project', 'new-milestone': 'ns-project', 'complete-milestone': 'ns-project',
'audit-milestone': 'ns-project', 'milestone-summary': 'ns-project', 'import': 'ns-project',
'ingest-docs': 'ns-project', 'profile-user': 'ns-project', 'review-backlog': 'ns-project',
// ns-review
'code-review': 'ns-review', 'audit-uat': 'ns-review', 'secure-phase': 'ns-review',
'eval-review': 'ns-review', 'ui-review': 'ns-review', 'validate-phase': 'ns-review',
'debug': 'ns-review', 'forensics': 'ns-review', 'audit-fix': 'ns-review',
'review': 'ns-review', 'ui-phase': 'ns-review',
// ns-context
'map-codebase': 'ns-context', 'graphify': 'ns-context', 'docs-update': 'ns-context',
'extract-learnings': 'ns-context',
// ns-ideate
'capture': 'ns-ideate', 'explore': 'ns-ideate', 'sketch': 'ns-ideate',
'spike': 'ns-ideate',
// ns-manage
'config': 'ns-manage', 'workspace': 'ns-manage', 'workstreams': 'ns-manage',
'thread': 'ns-manage', 'pause-work': 'ns-manage', 'resume-work': 'ns-manage',
'update': 'ns-manage', 'ship': 'ns-manage', 'inbox': 'ns-manage',
'pr-branch': 'ns-manage', 'undo': 'ns-manage', 'cleanup': 'ns-manage',
'health': 'ns-manage', 'manager': 'ns-manage', 'settings': 'ns-manage',
'stats': 'ns-manage', 'surface': 'ns-manage', 'help': 'ns-manage',
};
// ─── Section 1: getDirName / getGlobalConfigDir / getConfigDirFromHome ──────────
describe('getDirName — all runtimes', () => {
@@ -286,7 +323,7 @@ describe('getConfigDirFromHome — spot-checks', () => {
// Full E2E for runtimes that have distinct install paths (hermes nested layout,
// qwen flat layout, trae flat layout). Others are covered by layout-loop tests.
describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => {
describe('install/uninstall — hermes (nested skills/gsd/<router>/skills/<stem>/ layout)', () => {
let tmpDir;
let previousCwd;
@@ -308,19 +345,28 @@ describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => {
assert.strictEqual(result.runtime, 'hermes');
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help', 'SKILL.md')));
// hermes nests: skills/gsd/<router>/skills/<stem>/SKILL.md
const hermesHelpPath = path.join(
targetDir, 'skills', 'gsd', CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md'
);
assert.ok(fs.existsSync(hermesHelpPath),
`help SKILL.md must exist at nested path: ${path.relative(targetDir, hermesHelpPath)}`);
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'DESCRIPTION.md')),
'DESCRIPTION.md at category root');
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'hermes');
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd/help/')),
JSON.stringify(manifest.files));
assert.ok(
Object.keys(manifest.files).some(f =>
f.startsWith('skills/gsd/' + CHILD_ROUTER['help'] + '/skills/help/')
),
JSON.stringify(manifest.files)
);
uninstall(false, 'hermes');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd', 'help')));
assert.ok(!fs.existsSync(hermesHelpPath));
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd')));
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
});
@@ -377,7 +423,7 @@ describe('install/uninstall — hermes (nested skills/gsd/ layout)', () => {
});
});
describe('install/uninstall — qwen (flat skills/gsd-* layout)', () => {
describe('install/uninstall — qwen (nested skills/gsd-<router>/skills/<stem>/ layout)', () => {
let tmpDir;
let previousCwd;
@@ -399,20 +445,29 @@ describe('install/uninstall — qwen (flat skills/gsd-* layout)', () => {
assert.strictEqual(result.runtime, 'qwen');
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
// qwen nests: skills/gsd-<router>/skills/<stem>/SKILL.md
const qwenHelpPath = path.join(
targetDir, 'skills', 'gsd-' + CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md'
);
assert.ok(fs.existsSync(qwenHelpPath),
`help SKILL.md must exist at nested path: ${path.relative(targetDir, qwenHelpPath)}`);
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'qwen');
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/')));
assert.ok(
Object.keys(manifest.files).some(f =>
f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/')
)
);
uninstall(false, 'qwen');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
assert.ok(!fs.existsSync(qwenHelpPath));
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
});
});
describe('install/uninstall — trae (flat skills/gsd-* layout)', () => {
describe('install/uninstall — trae (nested skills/gsd-<router>/skills/<stem>/ layout)', () => {
let tmpDir;
let previousCwd;
@@ -440,15 +495,24 @@ describe('install/uninstall — trae (flat skills/gsd-* layout)', () => {
configDir: fs.realpathSync(targetDir),
});
assert.ok(fs.existsSync(path.join(targetDir, 'skills', 'gsd-help', 'SKILL.md')));
// trae nests: skills/gsd-<router>/skills/<stem>/SKILL.md
const traeHelpPath = path.join(
targetDir, 'skills', 'gsd-' + CHILD_ROUTER['help'], 'skills', 'help', 'SKILL.md'
);
assert.ok(fs.existsSync(traeHelpPath),
`help SKILL.md must exist at nested path: ${path.relative(targetDir, traeHelpPath)}`);
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
const manifest = writeManifest(targetDir, 'trae');
assert.ok(Object.keys(manifest.files).some(f => f.startsWith('skills/gsd-help/')));
assert.ok(
Object.keys(manifest.files).some(f =>
f.startsWith('skills/gsd-' + CHILD_ROUTER['help'] + '/skills/help/')
)
);
uninstall(false, 'trae');
assert.ok(!fs.existsSync(path.join(targetDir, 'skills', 'gsd-help')));
assert.ok(!fs.existsSync(traeHelpPath));
assert.ok(!fs.existsSync(path.join(targetDir, 'gsd-core')));
});
});

View File

@@ -0,0 +1,80 @@
// #69 regression: applySurface must NOT re-flatten the nested skill layout
//
// Bug: stageSkillsForRuntimeAsSkills gated nesting on `resolvedProfile.skills === '*'`
// (the sentinel). applySurface → resolveSurface materializes the full profile into a
// concrete Set<string>, so the sentinel check was never true on the surface path.
// Result: applySurface called kind.stage(resolved) → stageSkillsForRuntimeAsSkills with
// a concrete Set → doNest = false → flat layout, overwriting the nested install.
//
// 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.
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const ROOT = path.join(__dirname, '..');
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
const { installRuntimeArtifacts } = require('../bin/install.js');
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
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) => {
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);
const skillsDir = path.join(tmpDir, 'skills');
// Sanity: install must produce nested layout
const topLevelAfterInstall = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
assert.strictEqual(
topLevelAfterInstall.length,
6,
`Install must produce exactly 6 gsd-* top-level dirs (routers). Got ${topLevelAfterInstall.length}: [${topLevelAfterInstall.join(', ')}]`,
);
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
'After install: gsd-ns-workflow/skills/plan-phase/SKILL.md must exist',
);
// Step 2: applySurface (full surface, no surface state file → resolves to full)
const layout = resolveRuntimeArtifactLayout('claude', tmpDir, 'global');
applySurface(tmpDir, layout, manifest);
// Step 3: assert nested layout is preserved after applySurface
const topLevelAfterSurface = fs.readdirSync(skillsDir).filter((n) => n.startsWith('gsd-'));
assert.strictEqual(
topLevelAfterSurface.length,
6,
`After applySurface: expected exactly 6 gsd-* top-level dirs (routers only). Got ${topLevelAfterSurface.length}: [${topLevelAfterSurface.join(', ')}]. ` +
'Re-flattening detected: applySurface must preserve nested layout (#69 regression).',
);
// The nested SKILL.md must still exist (not re-flattened to top-level concrete dir)
assert.ok(
fs.existsSync(path.join(skillsDir, 'gsd-ns-workflow', 'skills', 'plan-phase', 'SKILL.md')),
'After applySurface: gsd-ns-workflow/skills/plan-phase/SKILL.md must still exist (nested layout preserved)',
);
// The concrete skill must NOT have been promoted to a top-level flat dir
assert.ok(
!fs.existsSync(path.join(skillsDir, 'gsd-plan-phase', 'SKILL.md')),
'After applySurface: gsd-plan-phase/ must NOT exist at top level (#69 re-flatten regression guard)',
);
});
});

View File

@@ -417,20 +417,40 @@ describe('stage — skills kind (claude global)', () => {
assert.ok(entries.length >= 1, 'at least one skill dir should be staged');
});
test('stage with skills="*" stages all commands/gsd/*.md as skills', () => {
test('stage with skills="*" nests all commands/gsd/*.md under 6 routers (claude)', () => {
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');
const stagedDir = skillsKind.stage(PROFILE_FULL);
assert.ok(fs.existsSync(stagedDir), 'stagedDir must exist');
const entries = fs.readdirSync(stagedDir);
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}`);
// Claude is a NESTING runtime: full profile produces exactly 6 gsd-ns-* router dirs.
const topEntries = fs.readdirSync(stagedDir);
assert.strictEqual(topEntries.length, 6, `full profile should have exactly 6 router dirs, 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.
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`);
}
// 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}`);
});
});