diff --git a/scanners/lib/active-config-reader.mjs b/scanners/lib/active-config-reader.mjs
index a5a46e1..fc31ee5 100644
--- a/scanners/lib/active-config-reader.mjs
+++ b/scanners/lib/active-config-reader.mjs
@@ -572,14 +572,20 @@ async function countPluginItems(pluginRoot) {
return counts;
}
-async function listMarkdownFiles(dir) {
+async function listMarkdownFiles(dir, recursive = false) {
const out = [];
let entries;
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
for (const e of entries) {
+ const full = join(dir, e.name);
+ if (e.isDirectory()) {
+ // Opt-in recursion (M-BUG-3): CC scans agents dirs recursively, so agents
+ // organized into subfolders must be enumerated too. Other callers stay flat.
+ if (recursive) out.push(...await listMarkdownFiles(full, true));
+ continue;
+ }
if (!e.isFile()) continue;
if (!e.name.endsWith('.md')) continue;
- const full = join(dir, e.name);
try {
const s = await stat(full);
out.push({ path: full, size: s.size });
@@ -662,6 +668,11 @@ export async function enumerateSkills(pluginList = []) {
// Rules, agents, output styles (v5.6 Foundation enumeration)
// ─────────────────────────────────────────────────────────────────────────
+/** True when `v` is a non-empty, non-whitespace string (a usable frontmatter field). */
+function hasText(v) {
+ return typeof v === 'string' && v.trim().length > 0;
+}
+
/**
* Build the project/user/plugin directory list for a per-kind enumerator.
* Project + user dirs live under `.claude/
`; plugins under each of the
@@ -669,8 +680,16 @@ export async function enumerateSkills(pluginList = []) {
*/
function configDirs(repoPath, pluginList, subdir, pluginSubdirs = [subdir]) {
const home = process.env.HOME || process.env.USERPROFILE || '';
- const dirs = [{ dir: join(repoPath, '.claude', subdir), source: 'project', pluginName: null }];
- if (home) dirs.push({ dir: join(home, '.claude', subdir), source: 'user', pluginName: null });
+ const projectDir = join(repoPath, '.claude', subdir);
+ const userDir = home ? join(home, '.claude', subdir) : null;
+ const dirs = [];
+ // M-BUG-4: when repoPath === $HOME (the `manifest --global` self-scan), the
+ // project dir resolves to the same path as the user dir. Count it once, as
+ // user scope, instead of enumerating the same directory twice.
+ if (!(userDir && userDir === projectDir)) {
+ dirs.push({ dir: projectDir, source: 'project', pluginName: null });
+ }
+ if (userDir) dirs.push({ dir: userDir, source: 'user', pluginName: null });
for (const p of pluginList) {
for (const sub of pluginSubdirs) {
dirs.push({ dir: join(p.path, sub), source: 'plugin', pluginName: p.name });
@@ -730,8 +749,17 @@ export async function enumerateAgents(repoPath, pluginList = []) {
const lp = deriveLoadPattern('agent');
const dirs = configDirs(repoPath, pluginList, 'agents');
for (const { dir, source, pluginName } of dirs) {
- const files = await listMarkdownFiles(dir);
+ const files = await listMarkdownFiles(dir, true); // M-BUG-3: CC scans agents dirs recursively
for (const f of files) {
+ // M-BUG-5: CC registers a subagent only when its frontmatter declares both
+ // `name` and `description` (docs: identity comes only from `name`; both are
+ // required). Frontmatter-less / incomplete files are registration no-ops
+ // that cost zero always-loaded tokens — don't count them as agents.
+ let frontmatter;
+ try {
+ ({ frontmatter } = parseFrontmatter(await readFile(f.path, 'utf-8')));
+ } catch { continue; }
+ if (!hasText(frontmatter && frontmatter.name) || !hasText(frontmatter && frontmatter.description)) continue;
out.push({
name: basename(f.path).replace(/\.md$/, ''),
source,
diff --git a/tests/lib/active-config-reader.test.mjs b/tests/lib/active-config-reader.test.mjs
index 0576727..10c1874 100644
--- a/tests/lib/active-config-reader.test.mjs
+++ b/tests/lib/active-config-reader.test.mjs
@@ -952,6 +952,70 @@ describe('enumerateAgents (v5.6)', () => {
await mkdir(root, { recursive: true });
assert.deepEqual(await enumerateAgents(root, []), []);
});
+
+ // M-BUG-5: CC registers a subagent only when its frontmatter declares both
+ // `name` and `description` (docs: identity comes only from `name`; both are
+ // required). Frontmatter-less / incomplete files are registration no-ops that
+ // cost zero always-loaded tokens — they must NOT be counted as agents.
+ it('M-BUG-5: skips files with no frontmatter at all', async () => {
+ const dir = join(root, '.claude', 'agents');
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, 'reviewer.md'), '---\nname: reviewer\ndescription: reviews code\n---\nbody\n');
+ await writeFile(join(dir, 'not-an-agent.md'), '# Not An Agent\n\nJust a markdown doc, no frontmatter.\n');
+ const agents = await enumerateAgents(root, []);
+ assert.deepEqual(agents.map(a => a.name).sort(), ['reviewer']);
+ });
+
+ it('M-BUG-5: skips files missing name or description', async () => {
+ const dir = join(root, '.claude', 'agents');
+ await mkdir(dir, { recursive: true });
+ await writeFile(join(dir, 'reviewer.md'), '---\nname: reviewer\ndescription: reviews code\n---\nbody\n');
+ await writeFile(join(dir, 'name-only.md'), '---\nname: name-only\n---\nbody\n');
+ await writeFile(join(dir, 'desc-only.md'), '---\ndescription: has no name\n---\nbody\n');
+ await writeFile(join(dir, 'empty-name.md'), '---\nname:\ndescription: blank name\n---\nbody\n');
+ const agents = await enumerateAgents(root, []);
+ assert.deepEqual(agents.map(a => a.name).sort(), ['reviewer']);
+ });
+
+ // M-BUG-3: CC scans agents dirs recursively, so a valid agent in a subfolder
+ // (e.g. agents/review/security.md) is registered and must be counted.
+ it('M-BUG-3: recurses into agent subdirectories', async () => {
+ const sub = join(root, '.claude', 'agents', 'review');
+ await mkdir(sub, { recursive: true });
+ await writeFile(join(sub, 'security.md'), '---\nname: security\ndescription: security review\n---\nbody\n');
+ const agents = await enumerateAgents(root, []);
+ const a = agents.find(x => x.name === 'security');
+ assert.ok(a, 'agent in subdir should be counted');
+ assert.equal(a.source, 'project');
+ });
+});
+
+// M-BUG-4: when repoPath === $HOME (the `manifest --global` self-scan), the
+// project agents dir resolves to the SAME path as the user agents dir. The
+// shared configDirs helper must count it once (as user scope), not twice.
+describe('enumerateAgents — HOME self-scan dedup (M-BUG-4)', () => {
+ let home, originalHome;
+ beforeEach(async () => {
+ home = uniqueDir('agents-home-selfscan');
+ await mkdir(join(home, '.claude', 'agents'), { recursive: true });
+ originalHome = process.env.HOME;
+ process.env.HOME = home;
+ });
+ afterEach(async () => {
+ process.env.HOME = originalHome;
+ await rm(home, { recursive: true, force: true });
+ });
+
+ it('does not double-count user agents when repoPath === HOME', async () => {
+ await writeFile(
+ join(home, '.claude', 'agents', 'solo.md'),
+ '---\nname: solo\ndescription: only one of me\n---\nbody\n',
+ );
+ const agents = await enumerateAgents(home, []);
+ const solos = agents.filter(a => a.name === 'solo');
+ assert.equal(solos.length, 1, 'agent at HOME must be counted once, not twice');
+ assert.equal(solos[0].source, 'user', 'HOME self-scan agents are user scope');
+ });
});
describe('enumerateOutputStyles (v5.6)', () => {