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:
@@ -331,14 +331,114 @@ function stageAgentsForProfile(srcAgentsDir: string, resolvedProfile: ResolvedPr
|
||||
return stageDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Namespace-router → concrete sub-skill mapping for nested install layouts (#69).
|
||||
*/
|
||||
interface NamespaceBundleMap {
|
||||
routerStems: Set<string>;
|
||||
routerChildren: Map<string, string[]>;
|
||||
childToRouters: Map<string, string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the namespace router → concrete sub-skill mapping (#69). The
|
||||
* authoritative source is each `ns-*.md` router file's `requires:` frontmatter
|
||||
* list. A concrete skill may be routed by more than one router (e.g. spec-phase
|
||||
* is shared by ns-workflow and ns-ideate); it is nested — and physically
|
||||
* duplicated — under every owning router.
|
||||
*/
|
||||
function buildNamespaceBundleMap(srcCommandsDir: string): NamespaceBundleMap {
|
||||
const routerStems = new Set<string>();
|
||||
const routerChildren = new Map<string, string[]>();
|
||||
const childToRouters = new Map<string, string[]>();
|
||||
if (!fs.existsSync(srcCommandsDir)) {
|
||||
return { routerStems, routerChildren, childToRouters };
|
||||
}
|
||||
for (const entry of fs.readdirSync(srcCommandsDir, { withFileTypes: true })) {
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
if (!entry.name.startsWith('ns-')) continue;
|
||||
const stem = entry.name.slice(0, -3);
|
||||
let children: string[] = [];
|
||||
try {
|
||||
children = parseRequires(fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8'));
|
||||
} catch { children = []; }
|
||||
routerStems.add(stem);
|
||||
routerChildren.set(stem, children);
|
||||
for (const child of children) {
|
||||
const owners = childToRouters.get(child) || [];
|
||||
owners.push(stem);
|
||||
childToRouters.set(child, owners);
|
||||
}
|
||||
}
|
||||
return { routerStems, routerChildren, childToRouters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a converted namespace-router SKILL.md so its routing table points at
|
||||
* nested sub-skill files instead of bare Skill-tool names (#69). Each table row
|
||||
* whose final cell carries a `gsd-<stem>` token (optionally with `--flag`
|
||||
* suffixes) is rewritten to `Read \`skills/<stem>/SKILL.md\`` (flags preserved
|
||||
* as a note), the `Invoke` column header becomes `Read`, and the
|
||||
* "Invoke … using the Skill tool" trailer becomes a file-read instruction.
|
||||
* Only lines beginning with a table pipe are touched, so the `|` inside the
|
||||
* `description:` frontmatter field is never matched.
|
||||
*/
|
||||
function transformRouterBodyToNested(converted: string): string {
|
||||
const lines = converted.split('\n');
|
||||
const out = lines.map((line) => {
|
||||
if (/Invoke the matched skill directly using the Skill tool\./.test(line)) {
|
||||
return line.replace(
|
||||
/Invoke the matched skill directly using the Skill tool\./,
|
||||
"Read the matched sub-skill's SKILL.md and follow its instructions. The `skills/<name>/SKILL.md` paths in the right column are relative to this skill's own directory.",
|
||||
);
|
||||
}
|
||||
if (!/^\s*\|/.test(line)) return line;
|
||||
if (/^\s*\|[\s:|-]+\|\s*$/.test(line)) return line;
|
||||
if (/\|\s*Invoke\s*\|/.test(line)) {
|
||||
return line.replace(/\|\s*Invoke\s*\|/, '| Read |');
|
||||
}
|
||||
const cells = line.split('|');
|
||||
const lastIdx = cells.length - 2;
|
||||
if (lastIdx < 1) return line;
|
||||
const cell = cells[lastIdx];
|
||||
const m = cell.match(/gsd-([a-z0-9-]+)((?:\s+--[a-z0-9-]+)*)/i);
|
||||
if (!m) return line;
|
||||
const stem = m[1];
|
||||
const flags = m[2].trim();
|
||||
cells[lastIdx] = flags
|
||||
? ` Read \`skills/${stem}/SKILL.md\` (${flags}) `
|
||||
: ` Read \`skills/${stem}/SKILL.md\` `;
|
||||
return cells.join('|');
|
||||
});
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function stageSkillsForRuntimeAsSkills(
|
||||
srcCommandsDir: string,
|
||||
resolvedProfile: ResolvedProfile,
|
||||
converter: (content: string, skillName: string) => string,
|
||||
prefix: string,
|
||||
nested = false,
|
||||
): string {
|
||||
if (!fs.existsSync(srcCommandsDir)) return srcCommandsDir;
|
||||
|
||||
// Nesting applies to the `full` install AND to any surface whose skill set
|
||||
// still contains every namespace router (a full/reset surface). It must NOT
|
||||
// depend on the `'*'` sentinel alone: applySurface() materializes `full` into
|
||||
// a concrete Set, so a sentinel-only gate would re-flatten the layout on every
|
||||
// surface apply/reset (#69 adversarial-review finding). A partial surface that
|
||||
// drops a whole router cluster falls back to flat automatically.
|
||||
const bundles = nested ? buildNamespaceBundleMap(srcCommandsDir) : null;
|
||||
let doNest = false;
|
||||
if (nested && bundles && bundles.routerStems.size > 0) {
|
||||
if (resolvedProfile.skills === '*') {
|
||||
doNest = true;
|
||||
} else {
|
||||
const present = resolvedProfile.skills;
|
||||
doNest = [...bundles.routerStems].every((r) => present.has(r));
|
||||
}
|
||||
}
|
||||
|
||||
const stageDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-runtime-skills-'));
|
||||
try {
|
||||
const entries = fs.readdirSync(srcCommandsDir, { withFileTypes: true });
|
||||
@@ -350,6 +450,30 @@ function stageSkillsForRuntimeAsSkills(
|
||||
const content = fs.readFileSync(path.join(srcCommandsDir, entry.name), 'utf8');
|
||||
const skillName = `${prefix}${stem}`;
|
||||
const converted = converter(content, skillName);
|
||||
|
||||
if (doNest && bundles!.routerStems.has(stem)) {
|
||||
// Router skill: rewrite its routing table to the nested Read pattern and
|
||||
// emit it as the single top-level bundle entry.
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), transformRouterBodyToNested(converted));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (doNest && bundles!.childToRouters.has(stem)) {
|
||||
// Concrete skill routed by one or more namespace routers: nest a copy
|
||||
// under each owning router's skills/ subdir so it drops out of the
|
||||
// top-level eager listing while staying readable by file path (#69).
|
||||
for (const routerStem of bundles!.childToRouters.get(stem)!) {
|
||||
const destDir = path.join(stageDir, `${prefix}${routerStem}`, 'skills', stem);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Flat top-level skill (default behaviour; also the unrouted fallback when
|
||||
// nesting is active).
|
||||
const destDir = path.join(stageDir, skillName);
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, 'SKILL.md'), converted);
|
||||
|
||||
@@ -204,6 +204,7 @@ function agentsKind(destSubpath: string, prefix: string, configDir: string): Art
|
||||
* @param converterName name of converter function in bin/install.js exports
|
||||
* @param runtime canonical runtime ID (gates Hermes/Qwen branding in converter)
|
||||
* @param configDir runtime config dir (for .gsd-source marker resolution)
|
||||
* @param nested if true, nest concrete skills under their ns-* routers (#69)
|
||||
*/
|
||||
function skillsKind(
|
||||
destSubpath: string,
|
||||
@@ -211,6 +212,7 @@ function skillsKind(
|
||||
converterName: string,
|
||||
runtime: string,
|
||||
configDir: string,
|
||||
nested = false,
|
||||
): ArtifactKind {
|
||||
return {
|
||||
kind: 'skills',
|
||||
@@ -224,7 +226,7 @@ function skillsKind(
|
||||
const cmdNames = installExports.readGsdCommandNames();
|
||||
const wrappedConverter = (content: string, skillName: string): string =>
|
||||
realConverter(content, skillName, runtime, cmdNames);
|
||||
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix);
|
||||
return stageSkillsForRuntimeAsSkills(findInstallSourceRoot(configDir), resolved, wrappedConverter, prefix, nested);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -267,6 +269,39 @@ function convertedCommandsKind(
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nested skill-bundle support matrix (#69)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// When a runtime's skill loader scans only one level deep (non-recursive), a
|
||||
// concrete skill nested at `<router>/skills/<name>/SKILL.md` drops out of the
|
||||
// eager top-level listing yet stays readable by file path — which is exactly
|
||||
// what namespace routing needs. Recursive loaders surface every nested SKILL.md
|
||||
// as a peer (zero token saving), so they stay flat. Unconfirmed loaders stay
|
||||
// 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
|
||||
// (single-level subdir probe of the tap path)
|
||||
// augment — https://docs.augmentcode.com/cli/skills (flat single-level)
|
||||
// trae — docs.trae.ai/ide/skills + Trae-AI/TRAE#2253 (flat; nesting errors)
|
||||
// antigravity— discuss.ai.google.dev/t/more-antigravity-issues/145875 ("will not recursive scan")
|
||||
//
|
||||
// FLAT (recursive loader → nesting gives no saving):
|
||||
// cursor — https://cursor.com/docs/skills (walks skills root recursively)
|
||||
// opencode — sst/opencode skill/index.ts glob "skills/**/SKILL.md"
|
||||
// kilo — Kilo-Org/kilocode (opencode fork, same ** glob)
|
||||
//
|
||||
// FLAT (nested-scan behaviour unconfirmed → conservative):
|
||||
// codex — developers.openai.com/codex/skills/
|
||||
// copilot — docs.github.com/en/copilot/concepts/agents/about-agent-skills
|
||||
// windsurf — docs.devin.ai/desktop/cascade/skills
|
||||
// codebuddy — codebuddy.ai/docs/cli/skills
|
||||
|
||||
/**
|
||||
* Resolve the artifact layout for a given runtime and config directory.
|
||||
*/
|
||||
@@ -290,7 +325,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
agentsKind('agents', 'gsd-', configDir),
|
||||
];
|
||||
} else {
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'claude', configDir, true /* #69 nested: non-recursive scan, see matrix above */)];
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -318,7 +353,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'antigravity':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToAntigravitySkill', 'antigravity', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'windsurf':
|
||||
@@ -328,20 +363,20 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
case 'augment':
|
||||
kinds = [
|
||||
commandsKind('commands', 'gsd-', configDir),
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir),
|
||||
skillsKind('skills', 'gsd-', 'convertClaudeCommandToAugmentSkill', 'augment', configDir, true /* #69 nested */),
|
||||
];
|
||||
break;
|
||||
|
||||
case 'trae':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToTraeSkill', 'trae', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'qwen':
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir)];
|
||||
kinds = [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClaudeSkill', 'qwen', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'hermes':
|
||||
kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir)];
|
||||
kinds = [skillsKind('skills/gsd', '', 'convertClaudeCommandToClaudeSkill', 'hermes', configDir, true /* #69 nested */)];
|
||||
break;
|
||||
|
||||
case 'codebuddy':
|
||||
@@ -359,7 +394,7 @@ function resolveRuntimeArtifactLayout(runtime: string, configDir: string, scope:
|
||||
break;
|
||||
|
||||
case 'cline':
|
||||
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir)] : [];
|
||||
kinds = scope === 'global' ? [skillsKind('skills', 'gsd-', 'convertClaudeCommandToClineSkill', 'cline', configDir, true /* #69 nested */)] : [];
|
||||
break;
|
||||
|
||||
case 'opencode':
|
||||
|
||||
Reference in New Issue
Block a user