From 899920092d1d1903cf545c41aff63b4e83f2b9ab Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:18:08 +0800 Subject: [PATCH 1/2] fix(skill): stop registering auxiliary docs inside a skill bundle as skills Every plugin manifest skills entry becomes a scan root with isTopLevel=true, so the flat .md loop ran inside bundle dirs and registered payload files (GLOSSARY-FORMAT.md and friends) as standalone skills. Flat .md registration now only runs when the root is not itself a bundle (no top-level SKILL.md). --- packages/agent-core/src/skill/scanner.ts | 62 ++++++++++--------- .../agent-core/test/skill/scanner.test.ts | 47 ++++++++++++++ 2 files changed, 80 insertions(+), 29 deletions(-) diff --git a/packages/agent-core/src/skill/scanner.ts b/packages/agent-core/src/skill/scanner.ts index 6c1ddb462c..aad3f2b768 100644 --- a/packages/agent-core/src/skill/scanner.ts +++ b/packages/agent-core/src/skill/scanner.ts @@ -196,14 +196,42 @@ export async function discoverSkills( // A SKILL.md placed directly at a plugin skill root (e.g. plugin root fallback) // is treated as a single skill bundle. This only applies to plugin-derived roots, // not to user/project skill directories. - if (root.plugin !== undefined) { - const rootSkillMd = path.join(dirPath, 'SKILL.md'); - if (await isFile(rootSkillMd)) { + const rootSkillMd = path.join(dirPath, 'SKILL.md'); + const isBundleDir = await isFile(rootSkillMd); + if (isBundleDir && root.plugin !== undefined) { + await parseAndRegister({ + parse, + byName, + skillMdPath: rootSkillMd, + skillDirName: path.basename(dirPath), + root, + onDiscoveredSkill: options.onDiscoveredSkill, + warn, + skip, + }); + } + + // When the root itself is a skill bundle (it holds SKILL.md), its other + // .md files are payload (references, glossaries), not flat skills. + // Flat .md registration only makes sense for collection dirs. + if (!isBundleDir) { + for (const entry of entries) { + if (!entry.endsWith('.md')) continue; + if (entry === 'SKILL.md') continue; + const skillName = entry.slice(0, -'.md'.length); + if (directorySkills.has(skillName)) { + warn( + `Ignoring flat skill ${path.join(dirPath, entry)} because ${path.join(dirPath, skillName, 'SKILL.md')} exists with the same name`, + ); + continue; + } + const skillMdPath = path.join(dirPath, entry); + if (!(await isFile(skillMdPath))) continue; await parseAndRegister({ parse, byName, - skillMdPath: rootSkillMd, - skillDirName: path.basename(dirPath), + skillMdPath, + skillDirName: skillName, root, onDiscoveredSkill: options.onDiscoveredSkill, warn, @@ -211,30 +239,6 @@ export async function discoverSkills( }); } } - - for (const entry of entries) { - if (!entry.endsWith('.md')) continue; - if (entry === 'SKILL.md') continue; - const skillName = entry.slice(0, -'.md'.length); - if (directorySkills.has(skillName)) { - warn( - `Ignoring flat skill ${path.join(dirPath, entry)} because ${path.join(dirPath, skillName, 'SKILL.md')} exists with the same name`, - ); - continue; - } - const skillMdPath = path.join(dirPath, entry); - if (!(await isFile(skillMdPath))) continue; - await parseAndRegister({ - parse, - byName, - skillMdPath, - skillDirName: skillName, - root, - onDiscoveredSkill: options.onDiscoveredSkill, - warn, - skip, - }); - } } for (const entry of subdirs) { diff --git a/packages/agent-core/test/skill/scanner.test.ts b/packages/agent-core/test/skill/scanner.test.ts index 38377338ea..5cfa360a4e 100644 --- a/packages/agent-core/test/skill/scanner.test.ts +++ b/packages/agent-core/test/skill/scanner.test.ts @@ -130,6 +130,53 @@ describe('skill discovery', () => { expect(warnings.some((message) => message.includes('Ignoring flat skill'))).toBe(true); }); + it('treats .md files inside a skill bundle dir as payload, not flat skills', async () => { + const { repoDir } = await makeWorkspace(); + const bundleRoot = path.join(repoDir, 'plugin-skills', 'teach'); + await writeSkill(bundleRoot, 'SKILL.md', [ + '---', + 'name: teach', + 'description: Teaching skill', + '---', + '', + 'Teach body.', + ]); + await writeSkill(bundleRoot, 'GLOSSARY-FORMAT.md', [ + '---', + 'name: GLOSSARY-FORMAT', + 'description: Auxiliary reference doc', + '---', + '', + 'Glossary format reference.', + ]); + + const skills = await discoverSkills({ + roots: [{ path: bundleRoot, source: 'extra', plugin: { id: 'mattpocock-skills' } }], + }); + + expect(skills.map((skill) => skill.name)).toEqual(['teach']); + }); + + it('still registers flat .md skills in collection dirs without a top-level SKILL.md', async () => { + const { repoDir } = await makeWorkspace(); + const collectionRoot = path.join(repoDir, 'plugin-skills'); + await writeSkill(collectionRoot, path.join('teach', 'SKILL.md'), [ + '---', + 'name: teach', + 'description: Teaching skill', + '---', + '', + 'Teach body.', + ]); + await writeSkill(collectionRoot, 'review.md', ['Flat review body.']); + + const skills = await discoverSkills({ + roots: [{ path: collectionRoot, source: 'extra', plugin: { id: 'mattpocock-skills' } }], + }); + + expect(skills.map((skill) => skill.name).toSorted()).toEqual(['review', 'teach']); + }); + it('keeps flow skills user-visible while excluding them from model invocation', async () => { const { repoDir } = await makeWorkspace(); const projectRoot = path.join(repoDir, '.kimi-code', 'skills'); From 3a92b0d5ea2e59dc880f96ca6753f9c4ad40b070 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:42:44 +0900 Subject: [PATCH 2/2] chore: add changeset --- .changeset/skill-bundle-payload-not-skills.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/skill-bundle-payload-not-skills.md diff --git a/.changeset/skill-bundle-payload-not-skills.md b/.changeset/skill-bundle-payload-not-skills.md new file mode 100644 index 0000000000..b59b2ad4cc --- /dev/null +++ b/.changeset/skill-bundle-payload-not-skills.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core": patch +--- + +Stop registering auxiliary docs inside a skill bundle as skills: the bundle scanner now only treats the skill entrypoint as a skill, so payload files no longer appear as phantom skills in the catalog.