From a8e3c829fa064f99155af02bce84c81acd9960f8 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sat, 14 Mar 2026 04:18:28 +0100 Subject: [PATCH] fix(coding-agent): stop skill recursion at root closes #2075 --- packages/coding-agent/src/core/skills.ts | 42 +++++++++++++++---- .../skills/root-skill-preferred/SKILL.md | 3 ++ .../nested-child/SKILL.md | 3 ++ packages/coding-agent/test/skills.test.ts | 12 ++++++ 4 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 packages/coding-agent/test/fixtures/skills/root-skill-preferred/SKILL.md create mode 100644 packages/coding-agent/test/fixtures/skills/root-skill-preferred/nested-child/SKILL.md diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index 70e1aa64..3f046425 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -140,8 +140,9 @@ export interface LoadSkillsFromDirOptions { * Load skills from a directory. * * Discovery rules: - * - direct .md children in the root - * - recursive SKILL.md under subdirectories + * - if a directory contains SKILL.md, treat it as a skill root and do not recurse further + * - otherwise, load direct .md children in the root + * - recurse into subdirectories to find SKILL.md */ export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult { const { dir, source } = options; @@ -169,6 +170,35 @@ function loadSkillsFromDirInternal( try { const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name !== "SKILL.md") { + continue; + } + + const fullPath = join(dir, entry.name); + + let isFile = entry.isFile(); + if (entry.isSymbolicLink()) { + try { + isFile = statSync(fullPath).isFile(); + } catch { + continue; + } + } + + const relPath = toPosixPath(relative(root, fullPath)); + if (!isFile || ig.ignores(relPath)) { + continue; + } + + const result = loadSkillFromFile(fullPath, source); + if (result.skill) { + skills.push(result.skill); + } + diagnostics.push(...result.diagnostics); + return { skills, diagnostics }; + } + for (const entry of entries) { if (entry.name.startsWith(".")) { continue; @@ -208,13 +238,7 @@ function loadSkillsFromDirInternal( continue; } - if (!isFile) { - continue; - } - - const isRootMd = includeRootFiles && entry.name.endsWith(".md"); - const isSkillMd = !includeRootFiles && entry.name === "SKILL.md"; - if (!isRootMd && !isSkillMd) { + if (!isFile || !includeRootFiles || !entry.name.endsWith(".md")) { continue; } diff --git a/packages/coding-agent/test/fixtures/skills/root-skill-preferred/SKILL.md b/packages/coding-agent/test/fixtures/skills/root-skill-preferred/SKILL.md new file mode 100644 index 00000000..38938938 --- /dev/null +++ b/packages/coding-agent/test/fixtures/skills/root-skill-preferred/SKILL.md @@ -0,0 +1,3 @@ +--- +description: Root skill should win. +--- diff --git a/packages/coding-agent/test/fixtures/skills/root-skill-preferred/nested-child/SKILL.md b/packages/coding-agent/test/fixtures/skills/root-skill-preferred/nested-child/SKILL.md new file mode 100644 index 00000000..817eb820 --- /dev/null +++ b/packages/coding-agent/test/fixtures/skills/root-skill-preferred/nested-child/SKILL.md @@ -0,0 +1,3 @@ +--- +description: Nested skill should be ignored. +--- diff --git a/packages/coding-agent/test/skills.test.ts b/packages/coding-agent/test/skills.test.ts index b536ad40..5c6d19e1 100644 --- a/packages/coding-agent/test/skills.test.ts +++ b/packages/coding-agent/test/skills.test.ts @@ -86,6 +86,18 @@ describe("skills", () => { expect(diagnostics).toHaveLength(0); }); + it("should prefer a directory's root SKILL.md over nested SKILL.md files", () => { + const { skills, diagnostics } = loadSkillsFromDir({ + dir: join(fixturesDir, "root-skill-preferred"), + source: "test", + }); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe("root-skill-preferred"); + expect(skills[0].description).toBe("Root skill should win."); + expect(diagnostics).toHaveLength(0); + }); + it("should skip files without frontmatter", () => { const { skills, diagnostics } = loadSkillsFromDir({ dir: join(fixturesDir, "no-frontmatter"),