Compare commits

...

15 Commits

Author SHA1 Message Date
Frank 641b11d38b
Merge b94189c35d into 1b8424cf6d 2026-03-18 18:59:17 -05:00
Brian b94189c35d
Merge branch 'main' into fix/src-core-rename-mismatch 2026-03-18 18:59:14 -05:00
Alex Verkhovsky 1b8424cf6d
Merge pull request #2058 from bmad-code-org/fix-elicitation-trigger
fix: add Use when trigger to advanced-elicitation description
2026-03-18 17:21:40 -06:00
Alex Verkhovsky 9973b3c35a
Merge branch 'main' into fix-elicitation-trigger 2026-03-18 17:20:52 -06:00
Alex Verkhovsky 1a0da0278f
Merge pull request #2051 from bmad-code-org/feat-deterministic-skill-validator
feat(tools): add deterministic skill validator for CI
2026-03-18 17:20:30 -06:00
Alex Verkhovsky 52ebc3330d
Merge branch 'main' into fix-elicitation-trigger 2026-03-18 17:18:11 -06:00
Alex Verkhovsky 8b13628496 fix: add Use if trigger to advanced-elicitation description
Clears the SKILL-06 validator finding for missing trigger phrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:46:43 -06:00
Alex Verkhovsky 642b6a0cf4 ci: add validate:skills to GitHub quality workflow
The deterministic skill validator was in the npm quality chain but
missing from the GitHub Actions workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:38:09 -06:00
Alex Verkhovsky fd1e24c5c2 fix: address PR review findings in skill validator
- Guard against YAML comment lines in parseFrontmatterMultiline
- Broaden PATH-02 to detect any installed_path mention, not just variable refs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 14:35:52 -06:00
Alex Verkhovsky 84bade9a95
Merge branch 'main' into feat-deterministic-skill-validator 2026-03-18 10:13:59 -06:00
Alex Verkhovsky 4f1894908c refactor: tighten SKILL-04 regex, broaden WF-01/WF-02, remove forbidden names
- SKILL-04: require bmad- prefix, enforce single dashes via regex
  ^bmad-[a-z0-9]+(-[a-z0-9]+)*$, drop FORBIDDEN_NAME_SUBSTRINGS
- WF-01/WF-02: check all .md files (not just workflow.md) for stray
  name/description frontmatter, with tech-writer exception
- Update skill-validator.md prompt to match all rule changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:02:01 -06:00
Alex Verkhovsky 7a214cc7d8 fix: tighten frontmatter parsing and add SKILL-07 body content check
- Require \n---\n (not just \n---) for closing frontmatter delimiter
  in both parseFrontmatter and parseFrontmatterMultiline, with fallback
  for files ending in \n---
- Add SKILL-07: SKILL.md must have non-empty body content after
  frontmatter (L2 instructions are required)
- Update rule count to 14

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:02:01 -06:00
Alex Verkhovsky ac5cb552c0 refactor: discover skills by walking src instead of hardcoded paths
Replace SKILL_LOCATIONS array and AGENT_LOCATION constant with a single
walk from SRC_DIR. Any directory under src/ containing SKILL.md is a
skill — no need to enumerate locations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:02:00 -06:00
Alex Verkhovsky 22035ef015
Merge branch 'main' into feat-deterministic-skill-validator 2026-03-18 00:11:46 -06:00
Alex Verkhovsky 5a1f356e2c feat(tools): add deterministic skill validator for CI
Add tools/validate-skills.js — a Node CLI that checks 13 deterministic
rules (SKILL-01–06, WF-01–02, PATH-02, STEP-01/06/07, SEQ-02) across
all skill directories. Runs in under a second, exits non-zero on HIGH+
findings in strict mode, and outputs JSON for the inference validator.

- Add validate:skills npm script to quality chain
- Update skill-validator.md with first-pass integration instructions
- Update AGENTS.md push gate documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:49:01 -06:00
6 changed files with 796 additions and 25 deletions

View File

@ -108,3 +108,6 @@ jobs:
- name: Validate file references
run: npm run validate:refs
- name: Validate skills
run: npm run validate:skills

View File

@ -9,3 +9,4 @@ Open source framework for structured, agent-assisted software delivery.
`quality` mirrors the checks in `.github/workflows/quality.yaml`.
- Skill validation rules are in `tools/skill-validator.md`.
- Deterministic skill checks run via `npm run validate:skills` (included in `quality`).

View File

@ -39,12 +39,13 @@
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
"lint:md": "markdownlint-cli2 \"**/*.md\"",
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs",
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs && npm run validate:skills",
"rebundle": "node tools/cli/bundlers/bundle-web.js rebundle",
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
"test:install": "node test/test-installation-components.js",
"test:refs": "node test/test-file-refs-csv.js",
"validate:refs": "node tools/validate-file-refs.js --strict"
"validate:refs": "node tools/validate-file-refs.js --strict",
"validate:skills": "node tools/validate-skills.js --strict"
},
"lint-staged": {
"*.{js,cjs,mjs}": [

View File

@ -1,6 +1,6 @@
---
name: bmad-advanced-elicitation
description: 'Push the LLM to reconsider, refine, and improve its recent output.'
description: 'Push the LLM to reconsider, refine, and improve its recent output. Use when user asks for deeper critique or mentions a known deeper critique method, e.g. socratic, first principles, pre-mortem, red team.'
---
Follow the instructions in ./workflow.md.

View File

@ -2,14 +2,27 @@
An LLM-readable validation prompt for skills following the Agent Skills open standard.
## First Pass — Deterministic Checks
Before running inference-based validation, run the deterministic validator:
```bash
node tools/validate-skills.js --json path/to/skill-dir
```
This checks 14 rules deterministically: SKILL-01, SKILL-02, SKILL-03, SKILL-04, SKILL-05, SKILL-06, SKILL-07, WF-01, WF-02, PATH-02, STEP-01, STEP-06, STEP-07, SEQ-02.
Review its JSON output. For any rule that produced **zero findings** in the first pass, **skip it** during inference-based validation below — it has already been verified. If a rule produced any findings, the inference validator should still review that rule (some rules like SKILL-04 and SKILL-06 have sub-checks that benefit from judgment). Focus your inference effort on the remaining rules that require judgment (PATH-01, PATH-03, PATH-04, PATH-05, WF-03, STEP-02, STEP-03, STEP-04, STEP-05, SEQ-01, REF-01, REF-02, REF-03).
## How to Use
1. You are given a **skill directory path** to validate.
2. Read every file in the skill directory recursively.
3. Apply every rule in the catalog below to every applicable file.
4. Produce a findings report using the report template at the end.
2. Run the deterministic first pass (see above) and note which rules passed.
3. Read every file in the skill directory recursively.
4. Apply every rule in the catalog below to every applicable file, **skipping rules that passed the deterministic first pass**.
5. Produce a findings report using the report template at the end, including any deterministic findings from the first pass.
If no findings are generated, the skill passes validation.
If no findings are generated (from either pass), the skill passes validation.
---
@ -55,9 +68,9 @@ If no findings are generated, the skill passes validation.
- **Severity:** HIGH
- **Applies to:** `SKILL.md`
- **Rule:** The `name` value must use only lowercase letters, numbers, and hyphens. Max 64 characters. Must not contain "anthropic" or "claude".
- **Detection:** Regex test: `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`. String search for forbidden substrings.
- **Fix:** Rename to comply with the format.
- **Rule:** The `name` value must start with `bmad-`, use only lowercase letters, numbers, and single hyphens between segments.
- **Detection:** Regex test: `^bmad-[a-z0-9]+(-[a-z0-9]+)*$`.
- **Fix:** Rename to comply with the format (e.g., `bmad-my-skill`).
### SKILL-05 — `name` Must Match Directory Name
@ -75,23 +88,33 @@ If no findings are generated, the skill passes validation.
- **Detection:** Check length. Look for trigger phrases like "Use when" or "Use if" — their absence suggests the description only says _what_ but not _when_.
- **Fix:** Append a "Use when..." clause to the description.
### SKILL-07 — SKILL.md Must Have Body Content
- **Severity:** HIGH
- **Applies to:** `SKILL.md`
- **Rule:** SKILL.md must have non-empty markdown body content after the frontmatter. The body provides L2 instructions — a SKILL.md with only frontmatter is incomplete.
- **Detection:** Extract content after the closing `---` frontmatter delimiter and check it is non-empty after trimming whitespace.
- **Fix:** Add markdown body with skill instructions after the closing `---`.
---
### WF-01 — workflow.md Must NOT Have `name` in Frontmatter
### WF-01 — Only SKILL.md May Have `name` in Frontmatter
- **Severity:** HIGH
- **Applies to:** `workflow.md` (if it exists)
- **Rule:** The `name` field belongs only in `SKILL.md`. If `workflow.md` has YAML frontmatter, it must not contain `name:`.
- **Detection:** Parse frontmatter and check for `name:` key.
- **Fix:** Remove the `name:` line from workflow.md frontmatter.
- **Applies to:** all `.md` files except `SKILL.md`
- **Rule:** The `name` field belongs only in `SKILL.md`. No other markdown file in the skill directory may have `name:` in its frontmatter.
- **Detection:** Parse frontmatter of every non-SKILL.md markdown file and check for `name:` key.
- **Fix:** Remove the `name:` line from the file's frontmatter.
- **Exception:** `bmad-agent-tech-writer` — has sub-skill files with intentional `name` fields (to be revisited).
### WF-02 — workflow.md Must NOT Have `description` in Frontmatter
### WF-02 — Only SKILL.md May Have `description` in Frontmatter
- **Severity:** HIGH
- **Applies to:** `workflow.md` (if it exists)
- **Rule:** The `description` field belongs only in `SKILL.md`. If `workflow.md` has YAML frontmatter, it must not contain `description:`.
- **Detection:** Parse frontmatter and check for `description:` key.
- **Fix:** Remove the `description:` line from workflow.md frontmatter.
- **Applies to:** all `.md` files except `SKILL.md`
- **Rule:** The `description` field belongs only in `SKILL.md`. No other markdown file in the skill directory may have `description:` in its frontmatter.
- **Detection:** Parse frontmatter of every non-SKILL.md markdown file and check for `description:` key.
- **Fix:** Remove the `description:` line from the file's frontmatter.
- **Exception:** `bmad-agent-tech-writer` — has sub-skill files with intentional `description` fields (to be revisited).
### WF-03 — workflow.md Frontmatter Variables Must Be Config or Runtime Only
@ -103,6 +126,7 @@ If no findings are generated, the skill passes validation.
- A legitimate external path expression (must not violate PATH-05 — no paths into another skill's directory)
It must NOT be a path to a file within the skill directory (see PATH-04), nor a path into another skill's directory (see PATH-05).
- **Detection:** For each frontmatter variable, check if its value resolves to a file inside the skill (e.g., starts with `./`, `{installed_path}`, or is a bare relative path to a sibling file). If so, it is an intra-skill path variable. Also check if the value is a path into another skill's directory — if so, it violates PATH-05 and is not a legitimate external path.
- **Fix:** Remove the variable. Use a hardcoded relative path inline where the file is referenced.
@ -294,7 +318,7 @@ When reporting findings, use this format:
## Summary
| Severity | Count |
|----------|-------|
| -------- | ----- |
| CRITICAL | N |
| HIGH | N |
| MEDIUM | N |
@ -329,28 +353,34 @@ Quick-reference for the Agent Skills open standard.
For the full standard, see: [Agent Skills specification](https://agentskills.io/specification)
### Structure
- Every skill is a directory with `SKILL.md` as the required entrypoint
- YAML frontmatter between `---` markers provides metadata; markdown body provides instructions
- Supporting files (scripts, templates, references) live alongside SKILL.md
### Path resolution
- Relative file references resolve from the directory of the file that contains the reference, not from the skill root
- Example: from `branch-a/deep/next.md`, `./deeper/final.md` resolves to `branch-a/deep/deeper/final.md`
- Example: from `branch-a/deep/next.md`, `./branch-b/alt/leaf.md` incorrectly resolves to `branch-a/deep/branch-b/alt/leaf.md`
### Frontmatter fields (standard)
- `name`: lowercase letters, numbers, hyphens only; max 64 chars; no "anthropic" or "claude"
- `description`: required, max 1024 chars; should state what the skill does AND when to use it
### Progressive disclosure — three loading levels
- **L1 Metadata** (~100 tokens): `name` + `description` loaded at startup into system prompt
- **L2 Instructions** (<5k tokens): SKILL.md body loaded only when skill is triggered
- **L3 Resources** (unlimited): additional files + scripts loaded/executed on demand; script output enters context, script code does not
### Key design principle
- Skills are filesystem-based directories, not API payloads — Claude reads them via bash/file tools
- Keep SKILL.md focused; offload detailed reference to separate files
### Practical tips
- Keep SKILL.md under 500 lines
- `description` drives auto-discovery — use keywords users would naturally say

736
tools/validate-skills.js Normal file
View File

@ -0,0 +1,736 @@
/**
* Deterministic Skill Validator
*
* Validates 14 deterministic rules across all skill directories.
* Acts as a fast first-pass complement to the inference-based skill validator.
*
* What it checks:
* - SKILL-01: SKILL.md exists
* - SKILL-02: SKILL.md frontmatter has name
* - SKILL-03: SKILL.md frontmatter has description
* - SKILL-04: name format (lowercase, hyphens, no forbidden substrings)
* - SKILL-05: name matches directory basename
* - SKILL-06: description quality (length, "Use when"/"Use if")
* - SKILL-07: SKILL.md has body content after frontmatter
* - WF-01: workflow.md frontmatter has no name
* - WF-02: workflow.md frontmatter has no description
* - PATH-02: no installed_path variable
* - STEP-01: step filename format
* - STEP-06: step frontmatter has no name/description
* - STEP-07: step count 2-10
* - SEQ-02: no time estimates
*
* Usage:
* node tools/validate-skills.js # All skills, human-readable
* node tools/validate-skills.js path/to/skill-dir # Single skill
* node tools/validate-skills.js --strict # Exit 1 on HIGH+ findings
* node tools/validate-skills.js --json # JSON output
*/
const fs = require('node:fs');
const path = require('node:path');
const PROJECT_ROOT = path.resolve(__dirname, '..');
const SRC_DIR = path.join(PROJECT_ROOT, 'src');
// --- CLI Parsing ---
const args = process.argv.slice(2);
const STRICT = args.includes('--strict');
const JSON_OUTPUT = args.includes('--json');
const positionalArgs = args.filter((a) => !a.startsWith('--'));
// --- Constants ---
const NAME_REGEX = /^bmad-[a-z0-9]+(-[a-z0-9]+)*$/;
const STEP_FILENAME_REGEX = /^step-\d{2}[a-z]?-[a-z0-9-]+\.md$/;
const TIME_ESTIMATE_PATTERNS = [/takes?\s+\d+\s*min/i, /~\s*\d+\s*min/i, /estimated\s+time/i, /\bETA\b/];
const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
// --- Output Escaping ---
function escapeAnnotation(str) {
return str.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
}
function escapeTableCell(str) {
return String(str).replaceAll('|', String.raw`\|`);
}
// --- Frontmatter Parsing ---
/**
* Parse YAML frontmatter from a markdown file.
* Returns an object with key-value pairs, or null if no frontmatter.
*/
function parseFrontmatter(content) {
const trimmed = content.trimStart();
if (!trimmed.startsWith('---')) return null;
let endIndex = trimmed.indexOf('\n---\n', 3);
if (endIndex === -1) {
// Handle file ending with \n---
if (trimmed.endsWith('\n---')) {
endIndex = trimmed.length - 4;
} else {
return null;
}
}
const fmBlock = trimmed.slice(3, endIndex).trim();
if (fmBlock === '') return {};
const result = {};
for (const line of fmBlock.split('\n')) {
const colonIndex = line.indexOf(':');
if (colonIndex === -1) continue;
// Skip indented lines (nested YAML values)
if (line[0] === ' ' || line[0] === '\t') continue;
const key = line.slice(0, colonIndex).trim();
let value = line.slice(colonIndex + 1).trim();
// Strip surrounding quotes (single or double)
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
value = value.slice(1, -1);
}
result[key] = value;
}
return result;
}
/**
* Parse YAML frontmatter, handling multiline values (description often spans lines).
* Returns an object with key-value pairs, or null if no frontmatter.
*/
function parseFrontmatterMultiline(content) {
const trimmed = content.trimStart();
if (!trimmed.startsWith('---')) return null;
let endIndex = trimmed.indexOf('\n---\n', 3);
if (endIndex === -1) {
// Handle file ending with \n---
if (trimmed.endsWith('\n---')) {
endIndex = trimmed.length - 4;
} else {
return null;
}
}
const fmBlock = trimmed.slice(3, endIndex).trim();
if (fmBlock === '') return {};
const result = {};
let currentKey = null;
let currentValue = '';
for (const line of fmBlock.split('\n')) {
const colonIndex = line.indexOf(':');
// New key-value pair: must start at column 0 (no leading whitespace) and have a colon
if (colonIndex > 0 && line[0] !== ' ' && line[0] !== '\t') {
// Save previous key
if (currentKey !== null) {
result[currentKey] = stripQuotes(currentValue.trim());
}
currentKey = line.slice(0, colonIndex).trim();
currentValue = line.slice(colonIndex + 1);
} else if (currentKey !== null) {
// Skip YAML comment lines
if (line.trimStart().startsWith('#')) continue;
// Continuation of multiline value
currentValue += '\n' + line;
}
}
// Save last key
if (currentKey !== null) {
result[currentKey] = stripQuotes(currentValue.trim());
}
return result;
}
function stripQuotes(value) {
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
return value.slice(1, -1);
}
return value;
}
// --- Safe File Reading ---
/**
* Read a file safely, returning null on error.
* Pushes a warning finding if the file cannot be read.
*/
function safeReadFile(filePath, findings, relFile) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (error) {
findings.push({
rule: 'READ-ERR',
title: 'File Read Error',
severity: 'MEDIUM',
file: relFile || path.basename(filePath),
detail: `Cannot read file: ${error.message}`,
fix: 'Check file permissions and ensure the file exists.',
});
return null;
}
}
// --- Code Block Stripping ---
function stripCodeBlocks(content) {
return content.replaceAll(/```[\s\S]*?```/g, (m) => m.replaceAll(/[^\n]/g, ''));
}
// --- Skill Discovery ---
function discoverSkillDirs(rootDirs) {
const skillDirs = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git') continue;
const fullPath = path.join(dir, entry.name);
const skillMd = path.join(fullPath, 'SKILL.md');
if (fs.existsSync(skillMd)) {
skillDirs.push(fullPath);
}
// Keep walking into subdirectories to find nested skills
walk(fullPath);
}
}
for (const rootDir of rootDirs) {
walk(rootDir);
}
return skillDirs.sort();
}
// --- File Collection ---
function collectSkillFiles(skillDir) {
const files = [];
function walk(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name === '.git') continue;
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile()) {
files.push(fullPath);
}
}
}
walk(skillDir);
return files;
}
// --- Rule Checks ---
function validateSkill(skillDir) {
const findings = [];
const dirName = path.basename(skillDir);
const skillMdPath = path.join(skillDir, 'SKILL.md');
const workflowMdPath = path.join(skillDir, 'workflow.md');
const stepsDir = path.join(skillDir, 'steps');
// Collect all files in the skill for PATH-02 and SEQ-02
const allFiles = collectSkillFiles(skillDir);
// --- SKILL-01: SKILL.md must exist ---
if (!fs.existsSync(skillMdPath)) {
findings.push({
rule: 'SKILL-01',
title: 'SKILL.md Must Exist',
severity: 'CRITICAL',
file: 'SKILL.md',
detail: 'SKILL.md not found in skill directory.',
fix: 'Create SKILL.md as the skill entrypoint.',
});
// Cannot check SKILL-02 through SKILL-07 without SKILL.md
return findings;
}
const skillContent = safeReadFile(skillMdPath, findings, 'SKILL.md');
if (skillContent === null) return findings;
const skillFm = parseFrontmatterMultiline(skillContent);
// --- SKILL-02: frontmatter has name ---
if (!skillFm || !('name' in skillFm)) {
findings.push({
rule: 'SKILL-02',
title: 'SKILL.md Must Have name in Frontmatter',
severity: 'CRITICAL',
file: 'SKILL.md',
detail: 'Frontmatter is missing the `name` field.',
fix: 'Add `name: <skill-name>` to the frontmatter.',
});
} else if (skillFm.name === '') {
findings.push({
rule: 'SKILL-02',
title: 'SKILL.md Must Have name in Frontmatter',
severity: 'CRITICAL',
file: 'SKILL.md',
detail: 'Frontmatter `name` field is empty.',
fix: 'Set `name` to the skill directory name (kebab-case).',
});
}
// --- SKILL-03: frontmatter has description ---
if (!skillFm || !('description' in skillFm)) {
findings.push({
rule: 'SKILL-03',
title: 'SKILL.md Must Have description in Frontmatter',
severity: 'CRITICAL',
file: 'SKILL.md',
detail: 'Frontmatter is missing the `description` field.',
fix: 'Add `description: <what it does and when to use it>` to the frontmatter.',
});
} else if (skillFm.description === '') {
findings.push({
rule: 'SKILL-03',
title: 'SKILL.md Must Have description in Frontmatter',
severity: 'CRITICAL',
file: 'SKILL.md',
detail: 'Frontmatter `description` field is empty.',
fix: 'Add a description stating what the skill does and when to use it.',
});
}
const name = skillFm && skillFm.name;
const description = skillFm && skillFm.description;
// --- SKILL-04: name format ---
if (name && !NAME_REGEX.test(name)) {
findings.push({
rule: 'SKILL-04',
title: 'name Format',
severity: 'HIGH',
file: 'SKILL.md',
detail: `name "${name}" does not match pattern: ${NAME_REGEX}`,
fix: 'Rename to comply with lowercase letters, numbers, and hyphens only (max 64 chars).',
});
}
// --- SKILL-05: name matches directory ---
if (name && name !== dirName) {
findings.push({
rule: 'SKILL-05',
title: 'name Must Match Directory Name',
severity: 'HIGH',
file: 'SKILL.md',
detail: `name "${name}" does not match directory name "${dirName}".`,
fix: `Change name to "${dirName}" or rename the directory.`,
});
}
// --- SKILL-06: description quality ---
if (description) {
if (description.length > 1024) {
findings.push({
rule: 'SKILL-06',
title: 'description Quality',
severity: 'MEDIUM',
file: 'SKILL.md',
detail: `description is ${description.length} characters (max 1024).`,
fix: 'Shorten the description to 1024 characters or less.',
});
}
if (!/use\s+when\b/i.test(description) && !/use\s+if\b/i.test(description)) {
findings.push({
rule: 'SKILL-06',
title: 'description Quality',
severity: 'MEDIUM',
file: 'SKILL.md',
detail: 'description does not contain "Use when" or "Use if" trigger phrase.',
fix: 'Append a "Use when..." clause to explain when to invoke this skill.',
});
}
}
// --- SKILL-07: SKILL.md must have body content after frontmatter ---
{
const trimmed = skillContent.trimStart();
let bodyStart = -1;
if (trimmed.startsWith('---')) {
let endIdx = trimmed.indexOf('\n---\n', 3);
if (endIdx !== -1) {
bodyStart = endIdx + 4;
} else if (trimmed.endsWith('\n---')) {
bodyStart = trimmed.length; // no body at all
}
} else {
bodyStart = 0; // no frontmatter, entire file is body
}
const body = bodyStart >= 0 ? trimmed.slice(bodyStart).trim() : '';
if (body === '') {
findings.push({
rule: 'SKILL-07',
title: 'SKILL.md Must Have Body Content',
severity: 'HIGH',
file: 'SKILL.md',
detail: 'SKILL.md has no content after frontmatter. L2 instructions are required.',
fix: 'Add markdown body with skill instructions after the closing ---.',
});
}
}
// --- WF-01 / WF-02: non-SKILL.md files must NOT have name/description ---
// TODO: bmad-agent-tech-writer has sub-skill files with intentional name/description
const WF_SKIP_SKILLS = new Set(['bmad-agent-tech-writer']);
for (const filePath of allFiles) {
if (path.extname(filePath) !== '.md') continue;
if (path.basename(filePath) === 'SKILL.md') continue;
if (WF_SKIP_SKILLS.has(dirName)) continue;
const relFile = path.relative(skillDir, filePath);
const content = safeReadFile(filePath, findings, relFile);
if (content === null) continue;
const fm = parseFrontmatter(content);
if (!fm) continue;
if ('name' in fm) {
findings.push({
rule: 'WF-01',
title: 'Only SKILL.md May Have name in Frontmatter',
severity: 'HIGH',
file: relFile,
detail: `${relFile} frontmatter contains \`name\` — this belongs only in SKILL.md.`,
fix: "Remove the `name:` line from this file's frontmatter.",
});
}
if ('description' in fm) {
findings.push({
rule: 'WF-02',
title: 'Only SKILL.md May Have description in Frontmatter',
severity: 'HIGH',
file: relFile,
detail: `${relFile} frontmatter contains \`description\` — this belongs only in SKILL.md.`,
fix: "Remove the `description:` line from this file's frontmatter.",
});
}
}
// --- PATH-02: no installed_path ---
for (const filePath of allFiles) {
// Only check markdown and yaml files
const ext = path.extname(filePath);
if (!['.md', '.yaml', '.yml'].includes(ext)) continue;
const relFile = path.relative(skillDir, filePath);
const content = safeReadFile(filePath, findings, relFile);
if (content === null) continue;
// Check frontmatter for installed_path key
const fm = parseFrontmatter(content);
if (fm && 'installed_path' in fm) {
findings.push({
rule: 'PATH-02',
title: 'No installed_path Variable',
severity: 'HIGH',
file: relFile,
detail: 'Frontmatter contains `installed_path:` key.',
fix: 'Remove `installed_path` from frontmatter. Use relative paths instead.',
});
}
// Check content for any mention of installed_path (variable ref, prose, bare text)
const stripped = stripCodeBlocks(content);
const lines = stripped.split('\n');
for (const [i, line] of lines.entries()) {
if (/installed_path/i.test(line)) {
findings.push({
rule: 'PATH-02',
title: 'No installed_path Variable',
severity: 'HIGH',
file: relFile,
line: i + 1,
detail: '`installed_path` reference found in content.',
fix: 'Remove all installed_path usage. Use relative paths (`./path` or `../path`) instead.',
});
}
}
}
// --- STEP-01: step filename format ---
// --- STEP-06: step frontmatter no name/description ---
// --- STEP-07: step count ---
// Only check the literal steps/ directory (variant directories like steps-c, steps-v
// use different naming conventions and are excluded per the rule specification)
if (fs.existsSync(stepsDir) && fs.statSync(stepsDir).isDirectory()) {
const stepDirName = 'steps';
const stepFiles = fs.readdirSync(stepsDir).filter((f) => f.endsWith('.md'));
// STEP-01: filename format
for (const stepFile of stepFiles) {
if (!STEP_FILENAME_REGEX.test(stepFile)) {
findings.push({
rule: 'STEP-01',
title: 'Step File Naming',
severity: 'MEDIUM',
file: path.join(stepDirName, stepFile),
detail: `Filename "${stepFile}" does not match pattern: ${STEP_FILENAME_REGEX}`,
fix: 'Rename to step-NN-description.md (NN = zero-padded number, optional letter suffix).',
});
}
}
// STEP-06: step frontmatter has no name/description
for (const stepFile of stepFiles) {
const stepPath = path.join(stepsDir, stepFile);
const stepContent = safeReadFile(stepPath, findings, path.join(stepDirName, stepFile));
if (stepContent === null) continue;
const stepFm = parseFrontmatter(stepContent);
if (stepFm) {
if ('name' in stepFm) {
findings.push({
rule: 'STEP-06',
title: 'Step File Frontmatter: No name or description',
severity: 'MEDIUM',
file: path.join(stepDirName, stepFile),
detail: 'Step file frontmatter contains `name:` — this is metadata noise.',
fix: 'Remove `name:` from step file frontmatter.',
});
}
if ('description' in stepFm) {
findings.push({
rule: 'STEP-06',
title: 'Step File Frontmatter: No name or description',
severity: 'MEDIUM',
file: path.join(stepDirName, stepFile),
detail: 'Step file frontmatter contains `description:` — this is metadata noise.',
fix: 'Remove `description:` from step file frontmatter.',
});
}
}
}
// STEP-07: step count 2-10
const stepCount = stepFiles.filter((f) => f.startsWith('step-')).length;
if (stepCount > 0 && (stepCount < 2 || stepCount > 10)) {
const detail =
stepCount < 2
? `Only ${stepCount} step file found — consider inlining into workflow.md.`
: `${stepCount} step files found — more than 10 risks LLM context degradation.`;
findings.push({
rule: 'STEP-07',
title: 'Step Count',
severity: 'LOW',
file: stepDirName + '/',
detail,
fix: stepCount > 10 ? 'Consider consolidating steps.' : 'Consider expanding or inlining.',
});
}
}
// --- SEQ-02: no time estimates ---
for (const filePath of allFiles) {
const ext = path.extname(filePath);
if (!['.md', '.yaml', '.yml'].includes(ext)) continue;
const relFile = path.relative(skillDir, filePath);
const content = safeReadFile(filePath, findings, relFile);
if (content === null) continue;
const stripped = stripCodeBlocks(content);
const lines = stripped.split('\n');
for (const [i, line] of lines.entries()) {
for (const pattern of TIME_ESTIMATE_PATTERNS) {
if (pattern.test(line)) {
findings.push({
rule: 'SEQ-02',
title: 'No Time Estimates',
severity: 'LOW',
file: relFile,
line: i + 1,
detail: `Time estimate pattern found: "${line.trim()}"`,
fix: 'Remove time estimates — AI execution speed varies too much.',
});
break; // Only report once per line
}
}
}
}
return findings;
}
// --- Output Formatting ---
function formatHumanReadable(results) {
const output = [];
let totalFindings = 0;
const severityCounts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
output.push(
`\nValidating skills in: ${SRC_DIR}`,
`Mode: ${STRICT ? 'STRICT (exit 1 on HIGH+)' : 'WARNING (exit 0)'}${JSON_OUTPUT ? ' + JSON' : ''}\n`,
);
let totalSkills = 0;
let skillsWithFindings = 0;
for (const { skillDir, findings } of results) {
totalSkills++;
const relDir = path.relative(PROJECT_ROOT, skillDir);
if (findings.length > 0) {
skillsWithFindings++;
output.push(`\n${relDir}`);
for (const f of findings) {
totalFindings++;
severityCounts[f.severity]++;
const location = f.line ? ` (line ${f.line})` : '';
output.push(` [${f.severity}] ${f.rule}${f.title}`, ` File: ${f.file}${location}`, ` ${f.detail}`);
if (process.env.GITHUB_ACTIONS) {
const absFile = path.join(skillDir, f.file);
const ghFile = path.relative(PROJECT_ROOT, absFile);
const line = f.line || 1;
const level = f.severity === 'LOW' ? 'notice' : 'warning';
console.log(`::${level} file=${ghFile},line=${line}::${escapeAnnotation(`${f.rule}: ${f.detail}`)}`);
}
}
}
}
// Summary
output.push(
`\n${'─'.repeat(60)}`,
`\nSummary:`,
` Skills scanned: ${totalSkills}`,
` Skills with findings: ${skillsWithFindings}`,
` Total findings: ${totalFindings}`,
);
if (totalFindings > 0) {
output.push('', ` | Severity | Count |`, ` |----------|-------|`);
for (const sev of ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']) {
if (severityCounts[sev] > 0) {
output.push(` | ${sev.padEnd(8)} | ${String(severityCounts[sev]).padStart(5)} |`);
}
}
}
const hasHighPlus = severityCounts.CRITICAL > 0 || severityCounts.HIGH > 0;
if (totalFindings === 0) {
output.push(`\n All skills passed validation!`);
} else if (STRICT && hasHighPlus) {
output.push(`\n [STRICT MODE] HIGH+ findings found — exiting with failure.`);
} else if (STRICT) {
output.push(`\n [STRICT MODE] Only MEDIUM/LOW findings — pass.`);
} else {
output.push(`\n Run with --strict to treat HIGH+ findings as errors.`);
}
output.push('');
// Write GitHub Actions step summary
if (process.env.GITHUB_STEP_SUMMARY) {
let summary = '## Skill Validation\n\n';
if (totalFindings > 0) {
summary += '| Skill | Rule | Severity | File | Detail |\n';
summary += '|-------|------|----------|------|--------|\n';
for (const { skillDir, findings } of results) {
const relDir = path.relative(PROJECT_ROOT, skillDir);
for (const f of findings) {
summary += `| ${escapeTableCell(relDir)} | ${f.rule} | ${f.severity} | ${escapeTableCell(f.file)} | ${escapeTableCell(f.detail)} |\n`;
}
}
summary += '\n';
}
summary += `**${totalSkills} skills scanned, ${totalFindings} findings**\n`;
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
}
return { output: output.join('\n'), hasHighPlus };
}
function formatJson(results) {
const allFindings = [];
for (const { skillDir, findings } of results) {
const relDir = path.relative(PROJECT_ROOT, skillDir);
for (const f of findings) {
allFindings.push({
skill: relDir,
rule: f.rule,
title: f.title,
severity: f.severity,
file: f.file,
line: f.line || null,
detail: f.detail,
fix: f.fix,
});
}
}
// Sort by severity
allFindings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
const hasHighPlus = allFindings.some((f) => f.severity === 'CRITICAL' || f.severity === 'HIGH');
return { output: JSON.stringify(allFindings, null, 2), hasHighPlus };
}
// --- Main ---
if (require.main === module) {
// Determine which skills to validate
let skillDirs;
if (positionalArgs.length > 0) {
// Single skill directory specified
const target = path.resolve(positionalArgs[0]);
if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
console.error(`Error: "${positionalArgs[0]}" is not a valid directory.`);
process.exit(2);
}
skillDirs = [target];
} else {
// Discover all skills
skillDirs = discoverSkillDirs([SRC_DIR]);
}
if (skillDirs.length === 0) {
console.error('No skill directories found.');
process.exit(2);
}
// Validate each skill
const results = [];
for (const skillDir of skillDirs) {
const findings = validateSkill(skillDir);
results.push({ skillDir, findings });
}
// Format output
const { output, hasHighPlus } = JSON_OUTPUT ? formatJson(results) : formatHumanReadable(results);
console.log(output);
// Exit code
if (STRICT && hasHighPlus) {
process.exit(1);
}
}
// --- Exports (for testing) ---
module.exports = { parseFrontmatter, parseFrontmatterMultiline, validateSkill, discoverSkillDirs };