Merge remote-tracking branch 'origin/next' into kimi-runtime-support

# Conflicts:
#	docs/ARCHITECTURE.md
This commit is contained in:
Viktorplus
2026-06-08 21:27:05 +02:00
37 changed files with 1865 additions and 281 deletions

View File

@@ -56,8 +56,10 @@ const {
getPhaseFileStats,
readSubdirectories,
timeAgo,
extractCanonicalPlanId,
} = coreUtilsModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorModule = require('./phase-locator.cjs');
const { searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorModule;
import { findProjectRoot } from './project-root.cjs';
import { getGlobalConfigDir } from './runtime-homes.cjs';
@@ -520,150 +522,14 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] {
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
// extractCanonicalPlanId moved to core-utils.cjs (ADR-857 phase 2c / #877).
// The destructured binding above (from coreUtilsModule) makes it available to
// core-internal callers (searchPhaseInDir). It is NOT in core.cjs's public export =
// block (it was never public).
// It is consumed exclusively by phase-locator.cjs, which imports it from
// core-utils.cjs directly. It is NOT destructured in core.cts and is NOT
// in core.cjs's public export = block (it was never public).
interface PhaseSearchResult {
found: boolean;
directory: string;
phase_number: string;
phase_name: string | null;
phase_slug: string | null;
plans: string[];
summaries: string[];
incomplete_plans: string[];
has_research: boolean;
has_context: boolean;
has_verification: boolean;
has_reviews: boolean;
archived?: string;
}
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
try {
const dirs = readSubdirectories(baseDir, true);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) return null;
const phaseToken = extractPhaseToken(match);
const phaseNumber = phaseToken || normalized;
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken || null;
const phaseDir = path.join(baseDir, match);
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
const plans = unsortedPlans.sort();
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const incompletePlans = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
const canonical = extractCanonicalPlanId(p);
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
});
return {
found: true,
directory: toPosixPath(path.join(relBase, match)),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
plans,
summaries,
incomplete_plans: incompletePlans,
has_research: hasResearch,
has_context: hasContext,
has_verification: hasVerification,
has_reviews: hasReviews,
};
} catch {
return null;
}
}
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
if (!phase) return null;
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phase);
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
if (current) return current;
const milestonesDir = path.join(cwd, '.planning', 'milestones');
if (!fs.existsSync(milestonesDir)) return null;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const archiveDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of archiveDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = '.planning/milestones/' + archiveName;
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
}
} catch { /* intentionally empty */ }
return null;
}
interface ArchivedPhaseDir {
name: string;
milestone: string;
basePath: string;
fullPath: string;
}
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
const results: ArchivedPhaseDir[] = [];
if (!fs.existsSync(milestonesDir)) return results;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of phaseDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: path.join('.planning', 'milestones', archiveName),
fullPath: path.join(archivePath, dir),
});
}
}
} catch { /* intentionally empty */ }
return results;
}
// searchPhaseInDir, findPhaseInternal, getArchivedPhaseDirs moved to phase-locator.cjs
// (ADR-857 phase 2d / #881). The destructured bindings above (from phaseLocatorModule)
// make them available to core-internal callers; core.cjs re-exports findPhaseInternal,
// getArchivedPhaseDirs, and searchPhaseInDir for back-compat.
// ─── Roadmap milestone scoping (re-exported from roadmap-parser.cjs) ──────────
// stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone,

View File

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

183
src/phase-locator.cts Normal file
View File

@@ -0,0 +1,183 @@
/**
* Phase Locator — Phase-directory search and location
*
* ADR-857 rollout phase 2d: extracted from core.cts (issue #881).
* Owns active-phase discovery against the `.planning/phases/` tree
* (`searchPhaseInDir`, `findPhaseInternal`) and archived-phase-dir
* enumeration (`getArchivedPhaseDirs`), matching phase ids/tokens against
* the filesystem. Behaviour is preserved byte-for-behaviour from the prior
* location; only the module boundary moved. core.cjs re-exports
* `searchPhaseInDir`, `findPhaseInternal`, and `getArchivedPhaseDirs` for back-compat.
*
* New imports should pull phase-locator helpers from phase-locator.cjs
* directly.
*
* Dependencies (leaf modules only — no core.cjs, no loadConfig):
* - node:fs / node:path (stdlib)
* - ./phase-id.cjs (normalizePhaseName, phaseTokenMatches, extractPhaseToken)
* - ./core-utils.cjs (readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath)
* - ./planning-workspace.cjs (planningDir)
*/
import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdModule = require('./phase-id.cjs');
const { normalizePhaseName, phaseTokenMatches, extractPhaseToken } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import coreUtilsModule = require('./core-utils.cjs');
const { readSubdirectories, getPhaseFileStats, extractCanonicalPlanId, toPosixPath } = coreUtilsModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspace;
// ─── Phase search types ───────────────────────────────────────────────────────
interface PhaseSearchResult {
found: boolean;
directory: string;
phase_number: string;
phase_name: string | null;
phase_slug: string | null;
plans: string[];
summaries: string[];
incomplete_plans: string[];
has_research: boolean;
has_context: boolean;
has_verification: boolean;
has_reviews: boolean;
archived?: string;
}
interface ArchivedPhaseDir {
name: string;
milestone: string;
basePath: string;
fullPath: string;
}
// ─── Phase search helpers ─────────────────────────────────────────────────────
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
try {
const dirs = readSubdirectories(baseDir, true);
const match = dirs.find(d => phaseTokenMatches(d, normalized));
if (!match) return null;
const phaseToken = extractPhaseToken(match);
const phaseNumber = phaseToken || normalized;
const afterToken = match.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
const phaseName = afterToken || null;
const phaseDir = path.join(baseDir, match);
const { plans: unsortedPlans, summaries: unsortedSummaries, hasResearch, hasContext, hasVerification, hasReviews } = getPhaseFileStats(phaseDir);
const plans = unsortedPlans.sort();
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const incompletePlans = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
const canonical = extractCanonicalPlanId(p);
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
});
return {
found: true,
directory: toPosixPath(path.join(relBase, match)),
phase_number: phaseNumber,
phase_name: phaseName,
phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
plans,
summaries,
incomplete_plans: incompletePlans,
has_research: hasResearch,
has_context: hasContext,
has_verification: hasVerification,
has_reviews: hasReviews,
};
} catch {
return null;
}
}
function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | null {
if (!phase) return null;
const phasesDir = path.join(planningDir(cwd), 'phases');
const normalized = normalizePhaseName(phase);
const relPhasesDir = toPosixPath(path.relative(cwd, phasesDir));
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
if (current) return current;
const milestonesDir = path.join(cwd, '.planning', 'milestones');
if (!fs.existsSync(milestonesDir)) return null;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const archiveDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of archiveDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = '.planning/milestones/' + archiveName;
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
}
} catch { /* intentionally empty */ }
return null;
}
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
const results: ArchivedPhaseDir[] = [];
if (!fs.existsSync(milestonesDir)) return results;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of phaseDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: path.join('.planning', 'milestones', archiveName),
fullPath: path.join(archivePath, dir),
});
}
}
} catch { /* intentionally empty */ }
return results;
}
export = {
searchPhaseInDir,
findPhaseInternal,
getArchivedPhaseDirs,
};

View File

@@ -18,7 +18,7 @@ import os from 'node:os';
import readline from 'node:readline';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioModule = require('./io.cjs');
const { output, error, reapStaleTempFiles } = ioModule;
const { output, error, reapStaleTempFiles, ensureGsdTempDir, GSD_TEMP_DIR } = ioModule;
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -413,7 +413,8 @@ async function cmdExtractMessages(projectArg: string, options: { sessionId?: str
}
reapStaleTempFiles('gsd-pipeline-', { dirsOnly: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-pipeline-'));
ensureGsdTempDir();
const tmpDir = fs.mkdtempSync(path.join(GSD_TEMP_DIR, 'gsd-pipeline-'));
const outputPath = path.join(tmpDir, 'extracted-messages.jsonl');
let sessionsProcessed = 0;
@@ -601,7 +602,8 @@ async function cmdProfileSample(overridePath: string | null | undefined, options
}
reapStaleTempFiles('gsd-profile-', { dirsOnly: true });
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-profile-'));
ensureGsdTempDir();
const tmpDir = fs.mkdtempSync(path.join(GSD_TEMP_DIR, 'gsd-profile-'));
const outputPath = path.join(tmpDir, 'profile-sample.jsonl');
for (const msg of allMessages) {
fs.appendFileSync(outputPath, JSON.stringify(msg) + '\n');

View File

@@ -327,6 +327,46 @@ function getMilestoneInfo(cwd: string): MilestoneInfo {
}
}
// ─── Fence-aware text helper ──────────────────────────────────────────────────
/**
* Return a copy of `text` with every line that lies inside a fenced code block
* replaced by an empty string, using the same fence semantics as
* `computeSectionEnd` (backtick/tilde, ≥3 chars, indent ≤3 spaces, toggle;
* an unclosed fence treats remaining content as fenced).
*/
function stripFencedLines(text: string): string {
let fenceChar: string | null = null;
let fenceLen = 0;
const lines = text.split('\n');
const result: string[] = [];
for (const line of lines) {
const fm = line.match(/^\s{0,3}((?:`{3,}|~{3,}))(.*)/);
if (fm) {
const ch = fm[1][0];
const ln = fm[1].length;
const trailing = fm[2] || '';
if (!fenceChar) {
fenceChar = ch;
fenceLen = ln;
// The fence-open line itself is not a content line — blank it.
result.push('');
} else if (ch === fenceChar && ln >= fenceLen && /^\s*$/.test(trailing)) {
fenceChar = null;
fenceLen = 0;
// The fence-close line — blank it.
result.push('');
} else {
// A fence marker that doesn't close the current fence (different char or shorter) — keep treating as fenced content.
result.push(fenceChar ? '' : line);
}
} else {
result.push(fenceChar ? '' : line);
}
}
return result.join('\n');
}
// ─── Milestone phase filter ───────────────────────────────────────────────────
type MilestonePhaseFilter = ((dirName: string) => boolean) & {
@@ -416,8 +456,9 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null):
}
const phasePattern = /#{2,4}\s*(?:\[[^\]]+\]\s*)?Phase\s+([\w][\w.-]*)\s*:/gi;
const roadmapUnfenced = stripFencedLines(roadmap);
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(roadmap)) !== null) {
while ((m = phasePattern.exec(roadmapUnfenced)) !== null) {
milestonePhaseNums.add(m[1]);
}
} catch { /* intentionally empty */ }

View File

@@ -250,6 +250,7 @@ function kimiAgentsKind(destSubpath: string, prefix: string, configDir: string):
* @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,
@@ -257,6 +258,7 @@ function skillsKind(
converterName: string,
runtime: string,
configDir: string,
nested = false,
): ArtifactKind {
return {
kind: 'skills',
@@ -270,7 +272,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);
},
};
}
@@ -313,6 +315,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.
*/
@@ -336,7 +371,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;
@@ -364,7 +399,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':
@@ -374,20 +409,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':
@@ -405,7 +440,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 'kimi':