refactor(skills): add SKILL.md entrypoint to skill directories

Align skill source format with Open Skills standard: each skill
directory now contains a SKILL.md with name/description frontmatter
where name must match the directory name exactly. The installer
copies skill directories verbatim instead of generating SKILL.md.

- Add SKILL.md to both tracer bullet skill directories
- Strip name/description from workflow.md frontmatter (SKILL.md owns it)
- Installer reads metadata from SKILL.md, validates name matches dirname
- Install path in manifest CSV now points to SKILL.md
- Copy filter excludes OS/editor artifacts (.DS_Store, backups, dotfiles)
- Debug-guard validation messages, keep name-mismatch as hard error
- Add typeof guard for malformed YAML frontmatter
- Add negative test cases for parseSkillMd validation (Suite 30)
This commit is contained in:
Alex Verkhovsky 2026-03-09 00:37:11 -06:00
parent ee25fcca6f
commit 34abaf2f4a
7 changed files with 194 additions and 106 deletions

View File

@ -0,0 +1,6 @@
---
name: bmad-quick-dev-new-preview
description: 'Unified quick flow - clarify intent, plan, implement, review, present.'
---
Follow the instructions in [workflow.md](workflow.md).

View File

@ -1,6 +1,4 @@
---
name: quick-dev-new-preview
description: 'Unified quick flow - clarify intent, plan, implement, review, present.'
main_config: '{project-root}/_bmad/bmm/config.yaml'
# Related workflows

View File

@ -0,0 +1,6 @@
---
name: bmad-review-adversarial-general
description: 'Perform a Cynical Review and produce a findings report. Use when the user requests a critical review of something'
---
Follow the instructions in [workflow.md](workflow.md).

View File

@ -1,8 +1,3 @@
---
name: bmad-review-adversarial-general
description: 'Perform a Cynical Review and produce a findings report. Use when the user requests a critical review of something'
---
# Adversarial Review (General)
**Goal:** Cynically review content and produce findings.

View File

@ -1607,9 +1607,10 @@ async function runTests() {
await fs.ensureDir(skillDir29);
await fs.writeFile(path.join(skillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile(
path.join(skillDir29, 'workflow.md'),
'---\nname: My Custom Skill\ndescription: A skill at an unusual path\n---\n\nSkill body content\n',
path.join(skillDir29, 'SKILL.md'),
'---\nname: my-skill\ndescription: A skill at an unusual path\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
);
await fs.writeFile(path.join(skillDir29, 'workflow.md'), '# My Custom Skill\n\nSkill body content\n');
// --- Regular workflow dir: core/workflows/regular-wf/ (type: workflow) ---
const wfDir29 = path.join(tempFixture29, 'core', 'workflows', 'regular-wf');
@ -1625,18 +1626,20 @@ async function runTests() {
await fs.ensureDir(wfSkillDir29);
await fs.writeFile(path.join(wfSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile(
path.join(wfSkillDir29, 'workflow.md'),
'---\nname: Workflow Skill\ndescription: A skill inside workflows dir\n---\n\nSkill in workflows\n',
path.join(wfSkillDir29, 'SKILL.md'),
'---\nname: wf-skill\ndescription: A skill inside workflows dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
);
await fs.writeFile(path.join(wfSkillDir29, 'workflow.md'), '# Workflow Skill\n\nSkill in workflows\n');
// --- Skill inside tasks/ dir: core/tasks/task-skill/ ---
const taskSkillDir29 = path.join(tempFixture29, 'core', 'tasks', 'task-skill');
await fs.ensureDir(taskSkillDir29);
await fs.writeFile(path.join(taskSkillDir29, 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile(
path.join(taskSkillDir29, 'workflow.md'),
'---\nname: Task Skill\ndescription: A skill inside tasks dir\n---\n\nSkill in tasks\n',
path.join(taskSkillDir29, 'SKILL.md'),
'---\nname: task-skill\ndescription: A skill inside tasks dir\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
);
await fs.writeFile(path.join(taskSkillDir29, 'workflow.md'), '# Task Skill\n\nSkill in tasks\n');
// Minimal agent so core module is detected
await fs.ensureDir(path.join(tempFixture29, 'core', 'agents'));
@ -1649,14 +1652,14 @@ async function runTests() {
// Skill at unusual path should be in skills
const skillEntry29 = generator29.skills.find((s) => s.canonicalId === 'my-skill');
assert(skillEntry29 !== undefined, 'Skill at unusual path appears in skills[]');
assert(skillEntry29 && skillEntry29.name === 'My Custom Skill', 'Skill has correct name from frontmatter');
assert(skillEntry29 && skillEntry29.name === 'my-skill', 'Skill has correct name from frontmatter');
assert(
skillEntry29 && skillEntry29.path.includes('custom-area/my-skill/workflow.md'),
skillEntry29 && skillEntry29.path.includes('custom-area/my-skill/SKILL.md'),
'Skill path includes relative path from module root',
);
// Skill should NOT be in workflows
const inWorkflows29 = generator29.workflows.find((w) => w.name === 'My Custom Skill');
const inWorkflows29 = generator29.workflows.find((w) => w.name === 'my-skill');
assert(inWorkflows29 === undefined, 'Skill at unusual path does NOT appear in workflows[]');
// Skill in tasks/ dir should be in skills
@ -1664,7 +1667,7 @@ async function runTests() {
assert(taskSkillEntry29 !== undefined, 'Skill in tasks/ dir appears in skills[]');
// Skill in tasks/ should NOT appear in tasks[]
const inTasks29 = generator29.tasks.find((t) => t.name === 'Task Skill');
const inTasks29 = generator29.tasks.find((t) => t.name === 'task-skill');
assert(inTasks29 === undefined, 'Skill in tasks/ dir does NOT appear in tasks[]');
// Regular workflow should be in workflows, NOT in skills
@ -1677,7 +1680,7 @@ async function runTests() {
// Skill inside workflows/ should be in skills[], NOT in workflows[] (exercises findWorkflows skip at lines 311/322)
const wfSkill29 = generator29.skills.find((s) => s.canonicalId === 'wf-skill');
assert(wfSkill29 !== undefined, 'Skill in workflows/ dir appears in skills[]');
const wfSkillInWorkflows29 = generator29.workflows.find((w) => w.name === 'Workflow Skill');
const wfSkillInWorkflows29 = generator29.workflows.find((w) => w.name === 'wf-skill');
assert(wfSkillInWorkflows29 === undefined, 'Skill in workflows/ dir does NOT appear in workflows[]');
// Test scanInstalledModules recognizes skill-only modules
@ -1685,9 +1688,10 @@ async function runTests() {
await fs.ensureDir(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill'));
await fs.writeFile(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'bmad-skill-manifest.yaml'), 'type: skill\n');
await fs.writeFile(
path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'workflow.md'),
'---\nname: Nested Skill\ndescription: desc\n---\nbody\n',
path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'SKILL.md'),
'---\nname: my-skill\ndescription: desc\n---\n\nFollow the instructions in [workflow.md](workflow.md).\n',
);
await fs.writeFile(path.join(skillOnlyModDir29, 'deep', 'nested', 'my-skill', 'workflow.md'), '# Nested Skill\n\nbody\n');
const scannedModules29 = await generator29.scanInstalledModules(tempFixture29);
assert(scannedModules29.includes('skill-only-mod'), 'scanInstalledModules recognizes skill-only module');
@ -1699,6 +1703,74 @@ async function runTests() {
console.log('');
// ============================================================
// Suite 30: parseSkillMd validation (negative cases)
// ============================================================
console.log(`${colors.yellow}Test Suite 30: parseSkillMd Validation${colors.reset}\n`);
let tempFixture30;
try {
tempFixture30 = path.join(os.tmpdir(), `bmad-test-30-${Date.now()}`);
await fs.ensureDir(tempFixture30);
const generator30 = new ManifestGenerator();
generator30.bmadFolderName = '_bmad';
// Case 1: Missing SKILL.md entirely
const noSkillDir = path.join(tempFixture30, 'no-skill-md');
await fs.ensureDir(noSkillDir);
const result1 = await generator30.parseSkillMd(path.join(noSkillDir, 'SKILL.md'), noSkillDir, 'no-skill-md');
assert(result1 === null, 'parseSkillMd returns null when SKILL.md is missing');
// Case 2: SKILL.md with no frontmatter
const noFmDir = path.join(tempFixture30, 'no-frontmatter');
await fs.ensureDir(noFmDir);
await fs.writeFile(path.join(noFmDir, 'SKILL.md'), '# Just a heading\n\nNo frontmatter here.\n');
const result2 = await generator30.parseSkillMd(path.join(noFmDir, 'SKILL.md'), noFmDir, 'no-frontmatter');
assert(result2 === null, 'parseSkillMd returns null when SKILL.md has no frontmatter');
// Case 3: SKILL.md missing description
const noDescDir = path.join(tempFixture30, 'no-desc');
await fs.ensureDir(noDescDir);
await fs.writeFile(path.join(noDescDir, 'SKILL.md'), '---\nname: no-desc\n---\n\nBody.\n');
const result3 = await generator30.parseSkillMd(path.join(noDescDir, 'SKILL.md'), noDescDir, 'no-desc');
assert(result3 === null, 'parseSkillMd returns null when description is missing');
// Case 4: SKILL.md missing name
const noNameDir = path.join(tempFixture30, 'no-name');
await fs.ensureDir(noNameDir);
await fs.writeFile(path.join(noNameDir, 'SKILL.md'), '---\ndescription: has desc but no name\n---\n\nBody.\n');
const result4 = await generator30.parseSkillMd(path.join(noNameDir, 'SKILL.md'), noNameDir, 'no-name');
assert(result4 === null, 'parseSkillMd returns null when name is missing');
// Case 5: Name mismatch
const mismatchDir = path.join(tempFixture30, 'actual-dir-name');
await fs.ensureDir(mismatchDir);
await fs.writeFile(path.join(mismatchDir, 'SKILL.md'), '---\nname: wrong-name\ndescription: A skill\n---\n\nBody.\n');
const result5 = await generator30.parseSkillMd(path.join(mismatchDir, 'SKILL.md'), mismatchDir, 'actual-dir-name');
assert(result5 === null, 'parseSkillMd returns null when name does not match directory name');
// Case 6: Valid SKILL.md (positive control)
const validDir = path.join(tempFixture30, 'valid-skill');
await fs.ensureDir(validDir);
await fs.writeFile(path.join(validDir, 'SKILL.md'), '---\nname: valid-skill\ndescription: A valid skill\n---\n\nBody.\n');
const result6 = await generator30.parseSkillMd(path.join(validDir, 'SKILL.md'), validDir, 'valid-skill');
assert(result6 !== null && result6.name === 'valid-skill', 'parseSkillMd returns metadata for valid SKILL.md');
// Case 7: Malformed YAML (non-object)
const malformedDir = path.join(tempFixture30, 'malformed');
await fs.ensureDir(malformedDir);
await fs.writeFile(path.join(malformedDir, 'SKILL.md'), '---\njust a string\n---\n\nBody.\n');
const result7 = await generator30.parseSkillMd(path.join(malformedDir, 'SKILL.md'), malformedDir, 'malformed');
assert(result7 === null, 'parseSkillMd returns null for non-object YAML frontmatter');
} catch (error) {
assert(false, 'parseSkillMd validation test succeeds', error.message);
} finally {
if (tempFixture30) await fs.remove(tempFixture30).catch(() => {});
}
console.log('');
// ============================================================
// Summary
// ============================================================

View File

@ -148,7 +148,7 @@ class ManifestGenerator {
/**
* Recursively walk a module directory tree, collecting skill directories.
* A skill directory is one that contains both a bmad-skill-manifest.yaml with
* type: skill AND a workflow.md file.
* type: skill AND a SKILL.md file with name/description frontmatter.
* Populates this.skills[] and this.skillClaimedDirs (Set of absolute paths).
*/
async collectSkills() {
@ -169,75 +169,62 @@ class ManifestGenerator {
return;
}
// Check this directory for skill manifest + workflow file
// Check this directory for skill manifest
const manifest = await this.loadSkillManifest(dir);
const workflowFile = 'workflow.md';
const workflowPath = path.join(dir, workflowFile);
if (await fs.pathExists(workflowPath)) {
const artifactType = this.getArtifactType(manifest, workflowFile);
if (artifactType === 'skill') {
// Read and parse the workflow file
try {
const rawContent = await fs.readFile(workflowPath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
// Determine if this directory is a skill (type: skill in manifest)
const skillFile = 'SKILL.md';
const artifactType = this.getArtifactType(manifest, skillFile);
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const workflow = yaml.parse(frontmatterMatch[1]);
if (artifactType === 'skill') {
const skillMdPath = path.join(dir, 'SKILL.md');
const dirName = path.basename(dir);
if (!workflow || !workflow.name || !workflow.description) {
if (debug) console.log(`[DEBUG] collectSkills: skipped (missing name/description): ${workflowPath}`);
} else {
// Build path relative from module root
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
const installPath = relativePath
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${workflowFile}`
: `${this.bmadFolderName}/${moduleName}/${workflowFile}`;
// Validate and parse SKILL.md
const skillMeta = await this.parseSkillMd(skillMdPath, dir, dirName, debug);
// Skills derive canonicalId from directory name — never from manifest
if (manifest && manifest.__single && manifest.__single.canonicalId) {
console.warn(
`Warning: Skill manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for skills (directory name is the canonical ID)`,
);
}
const canonicalId = path.basename(dir);
if (skillMeta) {
// Build path relative from module root (points to SKILL.md — the permanent entrypoint)
const relativePath = path.relative(modulePath, dir).split(path.sep).join('/');
const installPath = relativePath
? `${this.bmadFolderName}/${moduleName}/${relativePath}/${skillFile}`
: `${this.bmadFolderName}/${moduleName}/${skillFile}`;
this.skills.push({
name: workflow.name,
description: this.cleanForCSV(workflow.description),
module: moduleName,
path: installPath,
canonicalId,
install_to_bmad: this.getInstallToBmad(manifest, workflowFile),
});
// Skills derive canonicalId from directory name — never from manifest
if (manifest && manifest.__single && manifest.__single.canonicalId) {
console.warn(
`Warning: Skill manifest at ${dir}/bmad-skill-manifest.yaml contains canonicalId — this field is ignored for skills (directory name is the canonical ID)`,
);
}
const canonicalId = dirName;
// Add to files list
this.files.push({
type: 'skill',
name: workflow.name,
module: moduleName,
path: installPath,
});
this.skills.push({
name: skillMeta.name,
description: this.cleanForCSV(skillMeta.description),
module: moduleName,
path: installPath,
canonicalId,
install_to_bmad: this.getInstallToBmad(manifest, skillFile),
});
this.skillClaimedDirs.add(dir);
// Add to files list
this.files.push({
type: 'skill',
name: skillMeta.name,
module: moduleName,
path: installPath,
});
if (debug) {
console.log(`[DEBUG] collectSkills: claimed skill "${workflow.name}" as ${canonicalId} at ${dir}`);
}
}
} else {
if (debug) console.log(`[DEBUG] collectSkills: skipped (no frontmatter): ${workflowPath}`);
}
} catch (error) {
if (debug) console.log(`[DEBUG] collectSkills: failed to parse ${workflowPath}: ${error.message}`);
this.skillClaimedDirs.add(dir);
if (debug) {
console.log(`[DEBUG] collectSkills: claimed skill "${skillMeta.name}" as ${canonicalId} at ${dir}`);
}
}
}
// Warn if manifest says type:skill but no workflow file found
// Warn if manifest says type:skill but directory was not claimed
if (manifest && !this.skillClaimedDirs.has(dir)) {
// Check if any entry in the manifest is type:skill
let hasSkillType = false;
if (manifest.__single) {
hasSkillType = manifest.__single.type === 'skill';
@ -250,12 +237,7 @@ class ManifestGenerator {
}
}
if (hasSkillType && debug) {
const hasWorkflow = entries.some((e) => e.name === workflowFile);
if (hasWorkflow) {
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but workflow file failed to parse: ${dir}`);
} else {
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but no workflow.md: ${dir}`);
}
console.log(`[DEBUG] collectSkills: dir has type:skill manifest but failed validation: ${dir}`);
}
}
@ -275,6 +257,50 @@ class ManifestGenerator {
}
}
/**
* Parse and validate SKILL.md for a skill directory.
* Returns parsed frontmatter object with name/description, or null if invalid.
* @param {string} skillMdPath - Absolute path to SKILL.md
* @param {string} dir - Skill directory path (for error messages)
* @param {string} dirName - Expected name (must match frontmatter name)
* @param {boolean} debug - Whether to emit debug-level messages
* @returns {Promise<Object|null>} Parsed frontmatter or null
*/
async parseSkillMd(skillMdPath, dir, dirName, debug = false) {
if (!(await fs.pathExists(skillMdPath))) {
if (debug) console.log(`[DEBUG] parseSkillMd: "${dir}" is missing SKILL.md — skipping`);
return null;
}
try {
const rawContent = await fs.readFile(skillMdPath, 'utf8');
const content = rawContent.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const skillMeta = yaml.parse(frontmatterMatch[1]);
if (!skillMeta || typeof skillMeta !== 'object' || !skillMeta.name || !skillMeta.description) {
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" is missing name or description — skipping`);
return null;
}
if (skillMeta.name !== dirName) {
console.error(`Error: SKILL.md name "${skillMeta.name}" does not match directory name "${dirName}" — skipping`);
return null;
}
return skillMeta;
}
if (debug) console.log(`[DEBUG] parseSkillMd: SKILL.md in "${dir}" has no frontmatter — skipping`);
return null;
} catch (error) {
if (debug) console.log(`[DEBUG] parseSkillMd: failed to parse SKILL.md in "${dir}": ${error.message} — skipping`);
return null;
}
}
/**
* Collect all workflows from core and selected modules
* Scans the INSTALLED bmad directory, not the source

View File

@ -627,7 +627,8 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
/**
* Install verbatim skill directories (type: skill entries from skill-manifest.csv).
* Copies the entire source directory into the IDE skill directory, auto-generating SKILL.md.
* Copies the entire source directory as-is into the IDE skill directory.
* The source SKILL.md is used directly no frontmatter transformation or file generation.
* @param {string} projectDir - Project directory
* @param {string} bmadDir - BMAD installation directory
* @param {string} targetPath - Target skills directory
@ -653,7 +654,7 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
if (!canonicalId) continue;
// Derive source directory from path column
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/workflow.md"
// path is like "_bmad/bmm/workflows/bmad-quick-flow/bmad-quick-dev-new-preview/SKILL.md"
// Strip bmadFolderName prefix and join with bmadDir, then get dirname
const relativePath = record.path.replace(new RegExp(`^${bmadFolderName}/`), '');
const sourceFile = path.join(bmadDir, relativePath);
@ -666,30 +667,14 @@ LOAD and execute from: {project-root}/{{bmadFolderName}}/{{path}}
await fs.remove(skillDir);
await fs.ensureDir(skillDir);
// Parse workflow.md frontmatter for description
let description = `${canonicalId} skill`;
try {
const workflowContent = await fs.readFile(sourceFile, 'utf8');
const fmMatch = workflowContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (fmMatch) {
const frontmatter = yaml.parse(fmMatch[1]);
if (frontmatter?.description) {
description = frontmatter.description;
}
}
} catch (error) {
await prompts.log.warn(`Failed to parse frontmatter from ${sourceFile}: ${error.message}`);
}
// Generate SKILL.md with YAML-safe frontmatter
const frontmatterYaml = yaml.stringify({ name: canonicalId, description: String(description) }, { lineWidth: 0 }).trimEnd();
const skillMd = `---\n${frontmatterYaml}\n---\n\nIT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL workflow.md, READ its entire contents and follow its directions exactly!\n`;
await fs.writeFile(path.join(skillDir, 'SKILL.md'), skillMd);
// Copy all files except bmad-skill-manifest.yaml
// Copy all skill files, filtering OS/editor artifacts
const skipPatterns = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
const skipSuffixes = ['~', '.swp', '.swo', '.bak'];
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === 'bmad-skill-manifest.yaml') continue;
if (skipPatterns.has(entry.name)) continue;
if (entry.name.startsWith('.') && entry.name !== '.gitkeep') continue;
if (skipSuffixes.some((s) => entry.name.endsWith(s))) continue;
const srcPath = path.join(sourceDir, entry.name);
const destPath = path.join(skillDir, entry.name);
await fs.copy(srcPath, destPath);