feat(validator): lint personas for name, description and Composition

The smaller version asked for when #610 was closed. docs/agents.md states
three rules for agents/<name>.md — the same frontmatter format as a skill
with name equal to the file stem (the name is the subagent_type commands
spawn) and a description, and a closing Composition block — and no
validator checked them.

Add lintPersonaContent to scripts/lib/skill-lint.js, reusing the existing
frontmatter parsing and YAML-validity rules, and have validate-skills.js
walk agents/*.md after the skills, skipping an index README.md. Headings
are read from fence-stripped content so an example block after
Composition does not count as a later section. No spawn-reference
scanning, no listing-table checks, no new script, no workflow change.

All four personas pass: 25 skills and 4 personas checked, 0 errors.
This commit is contained in:
ayobamiseun committed 2026-10-03 10:48:32 +01:00
1 parent a06bc63b3f
commit 488c1ee06e
3 files changed
+148 -4

No files matched your search

+67 -1
View File
@@ -9,7 +9,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { lintSkillContent, lintSkillLayout, topLevelFrontmatterKeys } = require('./skill-lint.js');
const { lintSkillContent, lintSkillLayout, topLevelFrontmatterKeys, lintPersonaContent } = require('./skill-lint.js');
const KNOWN = new Set(['alpha', 'beta']);
@@ -648,3 +648,69 @@ test('the spec-key check tolerates CRLF frontmatter', () => {
const { errors } = lintSkillContent('alpha', content, KNOWN);
assert.equal(errors.filter(e => /key 'temperature'/.test(e)).length, 1);
});
// ─── Personas (agents/<name>.md) ─────────────────────────────────────────────
//
// docs/agents.md: same frontmatter format as a skill, name equal to the file
// stem, and the file ends with a Composition block. Nothing more is checked.
function personaFile({ name = 'code-reviewer', description = 'Reviews changes. Use before merging.', body } = {}) {
const fm = ['---', name === null ? null : `name: ${name}`, description === null ? null : `description: ${description}`, '---']
.filter(l => l !== null).join('\n');
return `${fm}\n\n${body ?? '# Persona\n\n## Rules\n\nBe precise.\n\n## Composition\n\n- **Invoke via:** `/ship`.\n'}`;
}
test('a conforming persona produces no errors', () => {
assert.deepEqual(lintPersonaContent('code-reviewer', personaFile()).errors, []);
});
test('persona: frontmatter name must match the file stem', () => {
const { errors } = lintPersonaContent('code-reviewer', personaFile({ name: 'reviewer' }));
assert.equal(errors.length, 1);
assert.match(errors[0], /name 'reviewer' does not match file name 'code-reviewer'/);
});
test('persona: description is required', () => {
const { errors } = lintPersonaContent('code-reviewer', personaFile({ description: null }));
assert.equal(errors.length, 1);
assert.match(errors[0], /missing required field: 'description'/);
});
test('persona: a missing frontmatter block is reported and nothing else is attempted', () => {
const { errors } = lintPersonaContent('code-reviewer', '# Persona\n\n## Composition\n');
assert.equal(errors.length, 1);
assert.match(errors[0], /Missing or malformed YAML frontmatter/);
});
test('persona: frontmatter a host would reject as YAML is reported', () => {
const { errors } = lintPersonaContent('code-reviewer', personaFile({ description: 'Reviews: everything. Use before merging.' }));
assert.equal(errors.length, 1);
assert.match(errors[0], /Frontmatter line/);
});
test('persona: the Composition section is required', () => {
const { errors } = lintPersonaContent('code-reviewer', personaFile({ body: '# Persona\n\n## Rules\n\nBe precise.\n' }));
assert.equal(errors.length, 1);
assert.match(errors[0], /Missing "## Composition" section/);
});
test('persona: Composition must be the last section', () => {
const { errors } = lintPersonaContent('code-reviewer', personaFile({
body: '# Persona\n\n## Composition\n\n- **Invoke via:** `/ship`.\n\n## Output Format\n\nA report.\n',
}));
assert.equal(errors.length, 1);
assert.match(errors[0], /must be the last section, but "## Output Format" follows it/);
});
test('persona: a heading inside a fenced example after Composition is not a later section', () => {
const body = [
'# Persona', '', '## Composition', '', '- **Invoke via:** `/ship`. Report in this shape:', '',
'```markdown', '## Review: [title]', 'Findings…', '```', '',
].join('\n');
assert.deepEqual(lintPersonaContent('code-reviewer', personaFile({ body })).errors, []);
});
test('persona: the Composition heading match is case-insensitive and tolerates trailing spaces', () => {
const body = '# Persona\n\n## composition \n\n- **Invoke via:** `/ship`.\n';
assert.deepEqual(lintPersonaContent('code-reviewer', personaFile({ body })).errors, []);
});
+55
View File
@@ -543,6 +543,59 @@ function lintSkillLayout(skillDir) {
return errors;
}
/**
* Lint a persona file (agents/<name>.md) against the three rules docs/agents.md
* states for them: frontmatter in the same format as a skill, with `name`
* equal to the file stem (the name is the `subagent_type` commands spawn) and
* a `description`; and a closing `## Composition` section. Headings inside
* fenced blocks are ignored, so an example after Composition does not count as
* a later section. Pure: no filesystem access. Returns { errors }.
*/
function lintPersonaContent(stem, content) {
const errors = [];
const fm = parseFrontmatter(content);
if (!fm) {
errors.push('Missing or malformed YAML frontmatter (expected --- block at top of file)');
return { errors };
}
errors.push(...frontmatterYamlErrors(content));
if (!fm.name) {
errors.push("Frontmatter missing required field: 'name'");
} else if (fm.name !== stem) {
errors.push(`Frontmatter name '${fm.name}' does not match file name '${stem}' (the name is the subagent_type commands spawn)`);
}
if (!fm.description) {
errors.push("Frontmatter missing required field: 'description'");
}
const headings = [...stripFencedCodeBlocks(content).matchAll(/^## +(.+?)\s*$/gm)].map(m => m[1]);
const compositionAt = headings.findIndex(h => /^Composition$/i.test(h));
if (compositionAt === -1) {
errors.push('Missing "## Composition" section (docs/agents.md: every persona file ends with a Composition block)');
} else if (compositionAt !== headings.length - 1) {
errors.push(`"## Composition" must be the last section, but "## ${headings[headings.length - 1]}" follows it`);
}
return { errors };
}
/**
* Lint a persona by file name: reads agents/<file>, then delegates to
* lintPersonaContent. Returns { errors }.
*/
function lintPersona(fileName, agentsDir) {
const stem = fileName.replace(/\.md$/, '');
let content;
try {
content = fs.readFileSync(path.join(agentsDir, fileName), 'utf8');
} catch (err) {
return { errors: [`Unreadable persona file: ${err.message}`] };
}
return lintPersonaContent(stem, content);
}
/**
* Lint a skill by directory name: reads its SKILL.md, then delegates to
* lintSkillContent. This is the thin filesystem wrapper the CLI uses.
@@ -581,4 +634,6 @@ module.exports = {
lintSkillContent,
lintSkillLayout,
lintSkill,
lintPersonaContent,
lintPersona,
};
+26 -3
View File
@@ -3,7 +3,8 @@
* validate-skills.js
*
* CLI that validates every skill in skills/ against the rules in
* docs/skill-anatomy.md. The rules themselves live in scripts/lib/skill-lint.js
* docs/skill-anatomy.md, and every persona in agents/ against the three rules
* in docs/agents.md. The rules themselves live in scripts/lib/skill-lint.js
* (a single source of truth, importable and unit-testable); this file is a thin
* wrapper that walks the skills directory, runs the linter, prints the report,
* and sets the exit code.
@@ -16,9 +17,10 @@
const fs = require('fs');
const path = require('path');
const { lintSkill } = require('./lib/skill-lint');
const { lintSkill, lintPersona } = require('./lib/skill-lint');
const SKILLS_DIR = path.resolve(__dirname, '..', 'skills');
const AGENTS_DIR = path.resolve(__dirname, '..', 'agents');
// ─── Main ────────────────────────────────────────────────────────────────────
@@ -53,8 +55,29 @@ function main() {
}
}
// ── Personas ─────────────────────────────────────────────────────────────
// agents/<name>.md files are consumed as subagents and spawned by name from
// commands, so they get the same frontmatter contract plus a closing
// Composition section (docs/agents.md). An index file such as README.md is
// not a persona and is skipped.
const personaFiles = fs.existsSync(AGENTS_DIR)
? fs.readdirSync(AGENTS_DIR).filter(f => f.endsWith('.md') && !/^readme\.md$/i.test(f)).sort()
: [];
if (personaFiles.length > 0) console.log('');
for (const fileName of personaFiles) {
const { errors } = lintPersona(fileName, AGENTS_DIR);
totalErrors += errors.length;
if (errors.length === 0) {
console.log(` ✓ agents/${fileName}`);
} else {
console.log(` ✗ agents/${fileName}`);
for (const msg of errors) console.log(` ERROR: ${msg}`);
}
}
const status = totalErrors > 0 ? 'FAILED' : totalWarnings > 0 ? 'PASSED WITH WARNINGS' : 'PASSED';
console.log(`\n${skillDirs.length} skills checked — ${totalErrors} error(s), ${totalWarnings} warning(s) — ${status}`);
console.log(`\n${skillDirs.length} skills and ${personaFiles.length} personas checked — ${totalErrors} error(s), ${totalWarnings} warning(s) — ${status}`);
if (totalErrors > 0) process.exit(1);
}