diff --git a/CLAUDE.md b/CLAUDE.md
index 6aeab3e..f7cae86 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -109,7 +109,7 @@ Default: auto-detects scope from git context. Override with `/config-audit full|
node --test 'tests/**/*.test.mjs'
```
-929 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
+932 tests across 56 test files (17 lib + 29 scanner + 1 hook + 1 agent + 3 commands + 1 knowledge + 4 top-level). Test fixtures in `tests/fixtures/`. Top-level humanizer tests: `json-backcompat.test.mjs`, `raw-backcompat.test.mjs`, `scenario-read-test.test.mjs`, `snapshot-default-output.test.mjs`.
### CML scanner — context-window-scaled char budget
@@ -151,6 +151,27 @@ Beyond deny/allow overlap, the DIS scanner now also flags:
These predicates live in `scanners/lib/permission-rules.mjs` (shared with the CNF
conflict-detector). Behavior verified against `code.claude.com/docs/en/permissions`.
+### PLH scanner — plugin namespace collision
+
+The standalone PLH scanner (cross-plugin checks in `scan()`) flags **plugin namespace
+collisions**: two or more discovered plugins that declare the **same `name`** in
+`plugin.json`. The search-first finding that shaped this check: Claude Code namespaces
+every plugin component by the declared `name` — `/name:command`, `name:skill`, agent
+`name` (verified against `code.claude.com/docs/en/plugins`, and observable in any session's
+namespaced skill listing). A plugin component therefore can **never** shadow a user- or
+project-level one; the only shadow that loses components is a same-`name` collision, where
+the namespaces collapse into one and CC must pick a winner. Resolution between two installed
+same-name plugins is **undocumented**, so the loser's commands/skills/agents go silently
+unreachable — hence severity **MEDIUM** (dead config), `category: 'plugin-hygiene'`, with a
+COL-shaped `details.namespaces` payload (`{ source: 'plugin:
', name, path }`).
+
+Two design notes: (1) the check keys on the declared `name` field, **not** `basename(dir)` —
+the folder name is irrelevant to the namespace; `scanSinglePlugin` now returns `declaredName`
+for this. (2) Name-less plugins are excluded from the collision map (they are flagged by the
+missing-field check and must never group on an `undefined` key). The pre-existing cross-plugin
+**command**-name conflict check still uses the folder basename and over-reports given
+namespacing — left as a separate backlog candidate, intentionally out of scope here.
+
## Gotchas
- Session directories accumulate — use `/config-audit cleanup` to manage
diff --git a/README.md b/README.md
index f92c795..fb745c6 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@



-
+

A Claude Code plugin that checks configuration health, suggests context-aware improvements, and auto-fixes issues — `CLAUDE.md`, `settings.json`, hooks, rules, MCP servers, `@imports`, and plugins. 13 deterministic scanners across 10 quality areas, context-aware feature recommendations, auto-fix with backup/rollback, a prompt-cache-aware Token Hotspots scanner with optional API-calibrated `--accurate-tokens` mode, plus cache-prefix stability, dead-tool, and cross-plugin collision detection. Zero external dependencies.
@@ -341,6 +341,18 @@ By default, `/config-audit` auto-detects scope from your git context. Override w
> predicate lives in `scanners/lib/permission-rules.mjs`; valid forms like `Bash(npm:*)`,
> `WebFetch(domain:host)`, and `Agent(model:opus)` are never flagged.
+> **Plugin namespace collisions — the one shadow that actually loses components.**
+> Claude Code namespaces every plugin component by the plugin's declared `name`
+> (`/name:command`, `name:skill`, agent `name`), so a plugin component can never shadow a
+> user- or project-level one — they live in separate namespaces. The real hazard is two
+> plugins that declare the **same** `name` in `plugin.json`: their namespaces collapse into
+> one, and because the resolution between two installed same-name plugins is undocumented,
+> one plugin's commands, skills, and agents are silently shadowed and become unreachable.
+> The standalone plugin-health scanner (PLH) flags this at **medium** severity, keying on the
+> declared `name` field rather than the folder name (the folder name is irrelevant to the
+> namespace). Skill-name overlaps across *different* namespaces are a separate, lower-severity
+> concern owned by the COL scanner.
+
### CLI Tools
All tools work standalone — no Claude Code session needed:
diff --git a/scanners/lib/humanizer-data.mjs b/scanners/lib/humanizer-data.mjs
index 10d12b3..37b4ba6 100644
--- a/scanners/lib/humanizer-data.mjs
+++ b/scanners/lib/humanizer-data.mjs
@@ -723,6 +723,14 @@ export const TRANSLATIONS = {
recommendation: 'Add the missing setting shown in the details.',
},
},
+ {
+ regex: /^Plugin namespace collision:/,
+ translation: {
+ title: 'Two plugins share the same namespace, so one hides the other',
+ description: 'Claude Code names a plugin\'s commands, skills, and agents after the plugin (like `/name:command`). When two plugins declare the same name, they share one namespace and only one is reachable — the other\'s commands, skills, and agents silently disappear.',
+ recommendation: 'Give each plugin a distinct `name` in its `plugin.json`. The folder name doesn\'t matter — the `name` field is what forms the namespace.',
+ },
+ },
],
_default: {
title: 'A plugin has a configuration issue',
diff --git a/scanners/plugin-health-scanner.mjs b/scanners/plugin-health-scanner.mjs
index 0272a4b..e6db1e2 100644
--- a/scanners/plugin-health-scanner.mjs
+++ b/scanners/plugin-health-scanner.mjs
@@ -99,6 +99,9 @@ async function scanSinglePlugin(pluginDir) {
const pluginName = basename(pluginDir);
let commandCount = 0;
let agentCount = 0;
+ // Declared namespace from plugin.json `name` (the prefix for /name:command,
+ // name:skill, agent "name"). Folder basename is NOT the namespace.
+ let declaredName = null;
// 1. Validate plugin.json
const pluginJsonPath = join(pluginDir, '.claude-plugin', 'plugin.json');
@@ -119,6 +122,9 @@ async function scanSinglePlugin(pluginDir) {
}
if (parsed) {
+ if (typeof parsed.name === 'string' && parsed.name.trim()) {
+ declaredName = parsed.name.trim();
+ }
for (const field of REQUIRED_PLUGIN_JSON_FIELDS) {
if (!parsed[field]) {
findings.push(finding({
@@ -309,7 +315,7 @@ async function scanSinglePlugin(pluginDir) {
}
} catch { /* skip */ }
- return { name: pluginName, findings, commandCount, agentCount };
+ return { name: pluginName, declaredName, findings, commandCount, agentCount };
}
/**
@@ -374,6 +380,48 @@ export async function scan(targetPath) {
} catch { /* no commands dir */ }
}
+ // Cross-plugin checks: plugin namespace (declared name) collisions.
+ // Claude Code namespaces every plugin component by the plugin's declared
+ // `name` (/name:command, name:skill, agent "name"). Two plugins that declare
+ // the SAME name collapse into one namespace; the resolution between two
+ // installed plugins is undocumented, so one plugin's components are silently
+ // shadowed. Name-less plugins are flagged elsewhere and never grouped here.
+ const byDeclaredName = new Map(); // declaredName → string[] of plugin dirs
+ for (let idx = 0; idx < pluginResults.length; idx++) {
+ const declaredName = pluginResults[idx].declaredName;
+ if (!declaredName) continue;
+ if (!byDeclaredName.has(declaredName)) byDeclaredName.set(declaredName, []);
+ byDeclaredName.get(declaredName).push(pluginDirs[idx]);
+ }
+ for (const [declaredName, dirs] of byDeclaredName) {
+ if (dirs.length < 2) continue;
+ allFindings.push(finding({
+ scanner: SCANNER,
+ severity: SEVERITY.medium,
+ title: `Plugin namespace collision: "${declaredName}"`,
+ description:
+ `${dirs.length} plugins declare the same name "${declaredName}" in plugin.json. ` +
+ `Claude Code namespaces every plugin component by that name ` +
+ `(/${declaredName}:command, ${declaredName}:skill, agent "${declaredName}"), so the ` +
+ 'namespaces collapse into one. Resolution between two installed plugins of the same ' +
+ "name is undocumented — one plugin's commands, skills, and agents are silently shadowed " +
+ 'and become unreachable.',
+ file: join(dirs[0], '.claude-plugin', 'plugin.json'),
+ evidence: `name="${declaredName}"; plugins=${dirs.map(d => basename(d)).join(',')}`,
+ recommendation:
+ 'Rename one plugin\'s "name" in plugin.json so each plugin owns a distinct namespace. ' +
+ 'The folder name does not matter — the declared "name" field is the namespace.',
+ category: 'plugin-hygiene',
+ details: {
+ namespaces: dirs.map(d => ({
+ source: `plugin:${basename(d)}`,
+ name: declaredName,
+ path: join(d, '.claude-plugin', 'plugin.json'),
+ })),
+ },
+ }));
+ }
+
return scannerResult(SCANNER, 'ok', allFindings, pluginDirs.length, Date.now() - start);
}
diff --git a/tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json
new file mode 100644
index 0000000..447f1fa
--- /dev/null
+++ b/tests/fixtures/duplicate-plugin-name/alpha/.claude-plugin/plugin.json
@@ -0,0 +1,5 @@
+{
+ "name": "dup",
+ "description": "Alpha plugin that declares the namespace 'dup'",
+ "version": "1.0.0"
+}
diff --git a/tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md b/tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md
new file mode 100644
index 0000000..1541bc0
--- /dev/null
+++ b/tests/fixtures/duplicate-plugin-name/alpha/CLAUDE.md
@@ -0,0 +1,12 @@
+# Dup Plugin
+
+## Commands
+| Command | Description |
+|---------|-------------|
+| `/dup:hello` | say hello |
+
+## Agents
+(none)
+
+## Hooks
+(none)
diff --git a/tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json
new file mode 100644
index 0000000..9d00129
--- /dev/null
+++ b/tests/fixtures/duplicate-plugin-name/beta/.claude-plugin/plugin.json
@@ -0,0 +1,5 @@
+{
+ "name": "dup",
+ "description": "Beta plugin that declares the SAME namespace 'dup' — collides with alpha",
+ "version": "2.0.0"
+}
diff --git a/tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md b/tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md
new file mode 100644
index 0000000..1541bc0
--- /dev/null
+++ b/tests/fixtures/duplicate-plugin-name/beta/CLAUDE.md
@@ -0,0 +1,12 @@
+# Dup Plugin
+
+## Commands
+| Command | Description |
+|---------|-------------|
+| `/dup:hello` | say hello |
+
+## Agents
+(none)
+
+## Hooks
+(none)
diff --git a/tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json
new file mode 100644
index 0000000..dad8885
--- /dev/null
+++ b/tests/fixtures/duplicate-plugin-name/delta/.claude-plugin/plugin.json
@@ -0,0 +1,4 @@
+{
+ "description": "Plugin with NO name field — must be excluded from the namespace-collision map",
+ "version": "1.0.0"
+}
diff --git a/tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json b/tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json
new file mode 100644
index 0000000..dad8885
--- /dev/null
+++ b/tests/fixtures/duplicate-plugin-name/gamma/.claude-plugin/plugin.json
@@ -0,0 +1,4 @@
+{
+ "description": "Plugin with NO name field — must be excluded from the namespace-collision map",
+ "version": "1.0.0"
+}
diff --git a/tests/scanners/plugin-health-scanner.test.mjs b/tests/scanners/plugin-health-scanner.test.mjs
index 5b39615..a19c106 100644
--- a/tests/scanners/plugin-health-scanner.test.mjs
+++ b/tests/scanners/plugin-health-scanner.test.mjs
@@ -9,6 +9,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
const FIXTURES = resolve(__dirname, '../fixtures');
const TEST_PLUGIN = resolve(FIXTURES, 'test-plugin');
const BROKEN_PLUGIN = resolve(FIXTURES, 'broken-plugin');
+const DUP_NAME = resolve(FIXTURES, 'duplicate-plugin-name');
describe('discoverPlugins', () => {
it('discovers a single plugin when pointed at plugin dir', async () => {
@@ -128,6 +129,53 @@ describe('cross-plugin command conflict detection', () => {
});
});
+describe('plugin namespace collision detection', () => {
+ const COLLISION_RE = /namespace collision/i;
+
+ it('flags two plugins that declare the same name', async () => {
+ resetCounter();
+ const result = await scan(DUP_NAME);
+ const collisions = result.findings.filter(f =>
+ f.scanner === 'PLH' && COLLISION_RE.test(f.title || '')
+ );
+ assert.equal(collisions.length, 1, `Expected exactly one namespace collision, got ${collisions.length}`);
+ const f = collisions[0];
+ assert.equal(f.severity, 'medium', 'Namespace collision is medium severity');
+ assert.equal(f.category, 'plugin-hygiene');
+ assert.ok(f.title.includes('dup'), `Title should name the colliding namespace: ${f.title}`);
+ assert.ok(f.details && Array.isArray(f.details.namespaces), 'Should carry details.namespaces');
+ assert.equal(f.details.namespaces.length, 2, 'Two plugins collide on "dup"');
+ for (const ns of f.details.namespaces) {
+ assert.equal(ns.name, 'dup');
+ assert.ok(ns.source.startsWith('plugin:'), `source should be plugin-scoped: ${ns.source}`);
+ assert.ok(ns.path, 'each namespace entry carries a path');
+ }
+ });
+
+ it('excludes name-less plugins from the collision map', async () => {
+ resetCounter();
+ const result = await scan(DUP_NAME);
+ // gamma + delta declare no name; they must NOT form an undefined/empty collision.
+ const collisions = result.findings.filter(f =>
+ f.scanner === 'PLH' && COLLISION_RE.test(f.title || '')
+ );
+ assert.equal(collisions.length, 1, 'Only the real "dup" collision; name-less plugins are ignored');
+ assert.ok(
+ !collisions.some(f => /undefined|""|''|null/.test(f.title)),
+ 'No collision finding for an empty/undefined name'
+ );
+ });
+
+ it('does not flag a single plugin as a collision', async () => {
+ resetCounter();
+ const result = await scan(TEST_PLUGIN);
+ const collisions = result.findings.filter(f =>
+ f.scanner === 'PLH' && COLLISION_RE.test(f.title || '')
+ );
+ assert.equal(collisions.length, 0, 'A single plugin must not collide with itself');
+ });
+});
+
describe('finding format', () => {
it('findings have standard fields', async () => {
resetCounter();