fix(plh): align required-frontmatter with CC docs (drop optional model/tools/name)

plugin-health-scanner required `model`+`allowed-tools` on commands and `model`+`tools`
on agents, plus `name` on commands. Per primary docs these are OPTIONAL:
- Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
  `description` is recommended." `name` defaults to the directory name.
- Subagents (code.claude.com/docs sub-agents): "Only `name` and `description` are required";
  `model` defaults to `inherit`, `tools` inherits all.

REQUIRED_COMMAND_FRONTMATTER -> [description]; REQUIRED_AGENT_FRONTMATTER -> [name, description].
This was over-reporting: every command without an explicit `model` got a spurious medium
finding (10 on the okr plugin alone). Found via the okr pilot review. Suite 949/949, self-audit
A/A, scanner count 13 (no new scanner).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ter3E2JSi1Khgmuf2kady8
This commit is contained in:
Kjell Tore Guttormsen 2026-06-20 07:27:44 +02:00
commit a5cfc331fd
3 changed files with 19 additions and 16 deletions

View file

@ -19,18 +19,19 @@ const SCANNER = 'PLH';
const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version']; const REQUIRED_PLUGIN_JSON_FIELDS = ['name', 'description', 'version'];
const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks']; const RECOMMENDED_CLAUDE_MD_SECTIONS = ['commands', 'agents', 'hooks'];
// Keys as they appear after yaml-parser normalizeKey (hyphens → underscores) // Keys as they appear after yaml-parser normalizeKey (hyphens → underscores).
// Field requirements are pinned to the primary docs, NOT to "every field a plugin could set":
// - Commands/skills (code.claude.com/docs slash-commands): "All fields are optional. Only
// `description` is recommended." `name` defaults to the directory name; `model` and
// `allowed-tools` are optional. So only `description` is flagged.
// - Subagents (code.claude.com/docs sub-agents): "Only `name` and `description` are required."
// `model` (defaults to `inherit`) and `tools` (inherits all) are optional.
const REQUIRED_COMMAND_FRONTMATTER = [ const REQUIRED_COMMAND_FRONTMATTER = [
{ key: 'name', display: 'name' },
{ key: 'description', display: 'description' }, { key: 'description', display: 'description' },
{ key: 'model', display: 'model' },
{ key: 'allowed_tools', display: 'allowed-tools' },
]; ];
const REQUIRED_AGENT_FRONTMATTER = [ const REQUIRED_AGENT_FRONTMATTER = [
{ key: 'name', display: 'name' }, { key: 'name', display: 'name' },
{ key: 'description', display: 'description' }, { key: 'description', display: 'description' },
{ key: 'model', display: 'model' },
{ key: 'tools', display: 'tools' },
]; ];
// Component-path keys that REPLACE the default folder (per code.claude.com/docs // Component-path keys that REPLACE the default folder (per code.claude.com/docs
@ -346,7 +347,7 @@ async function scanSinglePlugin(pluginDir) {
title: 'Command missing frontmatter', title: 'Command missing frontmatter',
description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`, description: `Command "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath, file: filePath,
recommendation: 'Add YAML frontmatter with name, description, model', recommendation: 'Add YAML frontmatter with a description (other command fields are optional)',
})); }));
continue; continue;
} }
@ -385,7 +386,7 @@ async function scanSinglePlugin(pluginDir) {
title: 'Agent missing frontmatter', title: 'Agent missing frontmatter',
description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`, description: `Agent "${file}" in plugin "${pluginName}" has no frontmatter`,
file: filePath, file: filePath,
recommendation: 'Add YAML frontmatter with name, description, model, tools', recommendation: 'Add YAML frontmatter with name and description (model and tools are optional)',
})); }));
continue; continue;
} }

View file

@ -1,8 +1,8 @@
--- ---
name: bad-agent name: bad-agent
description: Missing model and tools
--- ---
# Bad Agent # Bad Agent
No model or tools in frontmatter. Has a `name` but no `description`. Per CC sub-agents docs, `name` and `description` are
required for subagents; `model` and `tools` are optional (inherit / all-tools by default).

View file

@ -99,15 +99,17 @@ describe('scan on broken-plugin', () => {
assert.equal(noFrontmatter.length, 1, 'Should detect command without frontmatter'); assert.equal(noFrontmatter.length, 1, 'Should detect command without frontmatter');
}); });
it('detects agent missing required frontmatter fields', async () => { it('flags missing required agent field (description) but not optional model/tools', async () => {
resetCounter(); resetCounter();
const result = await scan(BROKEN_PLUGIN); const result = await scan(BROKEN_PLUGIN);
// CA-PLH-005 (missing model) and CA-PLH-006 (missing tools) in broken-plugin. // Per CC sub-agents docs: only `name` and `description` are required; `model` and `tools`
const missingAgent = result.findings.filter(f => // are optional (inherit / all-tools by default). bad-agent.md has `name` only.
f.scanner === 'PLH' && (f.id === 'CA-PLH-005' || f.id === 'CA-PLH-006') const agentFields = result.findings.filter(f =>
f.scanner === 'PLH' && /Agent missing frontmatter field/.test(f.title || '')
); );
// bad-agent.md has name+description but missing model and tools assert.ok(agentFields.some(f => /description/.test(f.title)), 'missing required `description` must be flagged');
assert.ok(missingAgent.length >= 2, `Should detect missing model and tools, got ${missingAgent.length}: ${missingAgent.map(f => f.id).join(', ')}`); assert.ok(!agentFields.some(f => /\b(model|tools)\b/.test(f.title)),
`optional model/tools must not be flagged, got: ${agentFields.map(f => f.title).join('; ')}`);
}); });
}); });