From 7f097d524f5e48239015d1975b7be4398cb8d8fb Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 00:19:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(acr):=20enumerateAgents=20counts=20only=20C?= =?UTF-8?q?C-registered=20agents=20=E2=80=94=20recurse=20+=20frontmatter?= =?UTF-8?q?=20filter=20+=20HOME=20dedup=20(M-BUG-3/4/5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enumerateAgents previously counted every .md in an agents dir as an always-loaded agent. Per the official CC subagents docs, CC registers a subagent only when its frontmatter declares both name and description, scans agents dirs recursively, and (at a HOME self-scan) must not count ~/.claude/agents twice. - M-BUG-5: require valid name+description frontmatter; frontmatter-less files are registration no-ops costing 0 always-loaded tokens. Fixes the user-agent over-count (this machine: 13 -> 0). - M-BUG-3: listMarkdownFiles gains opt-in recursion; enumerateAgents recurses so agents in subfolders (agents/review/x.md) are counted, matching CC. - M-BUG-4: configDirs dedupes project==user paths, so a `manifest --global` self-scan (repoPath===$HOME) counts ~/.claude once (user scope), killing the spurious "project 13" double-count. Benefits rules/agents/output-styles. TDD: 4 failing tests -> green. Full suite 1305/0; --json/--raw byte-stable, frozen v5.0.0 + SC-5 + default-output snapshots untouched (no snapshot records agent enumeration rows). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EnUvKEqyEa1m9gy6Aqhdqq --- scanners/lib/active-config-reader.mjs | 38 +++++++++++++-- tests/lib/active-config-reader.test.mjs | 64 +++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 5 deletions(-) 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)', () => {