bmad-method 6.2.1-next.13 → 6.2.1-next.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -9,3 +9,4 @@ Open source framework for structured, agent-assisted software delivery.
9
9
  `quality` mirrors the checks in `.github/workflows/quality.yaml`.
10
10
 
11
11
  - Skill validation rules are in `tools/skill-validator.md`.
12
+ - Deterministic skill checks run via `npm run validate:skills` (included in `quality`).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method",
4
- "version": "6.2.1-next.13",
4
+ "version": "6.2.1-next.15",
5
5
  "description": "Breakthrough Method of Agile AI-driven Development",
6
6
  "keywords": [
7
7
  "agile",
@@ -39,12 +39,13 @@
39
39
  "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
40
40
  "lint:md": "markdownlint-cli2 \"**/*.md\"",
41
41
  "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
42
- "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs",
42
+ "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",
43
43
  "rebundle": "node tools/cli/bundlers/bundle-web.js rebundle",
44
44
  "test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
45
45
  "test:install": "node test/test-installation-components.js",
46
46
  "test:refs": "node test/test-file-refs-csv.js",
47
- "validate:refs": "node tools/validate-file-refs.js --strict"
47
+ "validate:refs": "node tools/validate-file-refs.js --strict",
48
+ "validate:skills": "node tools/validate-skills.js --strict"
48
49
  },
49
50
  "lint-staged": {
50
51
  "*.{js,cjs,mjs}": [
@@ -26,7 +26,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
26
26
 
27
27
  Present summary. If token count exceeded 1600 and user chose [K], include the token count and explain why it may be a problem. HALT and ask human: `[A] Approve` | `[E] Edit`
28
28
 
29
- - **A**: Rename `{wipFile}` to `{spec_file}`, set status `ready-for-dev`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. → Step 3.
29
+ - **A**: Rename `{wipFile}` to `{spec_file}`, set status `ready-for-dev`. Everything inside `<frozen-after-approval>` is now locked — only the human can change it. Display the finalized spec path to the user as a CWD-relative path (no leading `/`) so it is clickable in the terminal. → Step 3.
30
30
  - **E**: Apply changes, then return to CHECKPOINT 1.
31
31
 
32
32
 
@@ -22,7 +22,7 @@ Build the trail as an ordered sequence of **stops** — clickable `path:line` re
22
22
  2. **Lead with the entry point** — the single highest-leverage file:line a reviewer should look at first to grasp the design intent.
23
23
  3. **Inside each concern**, order stops from most important / architecturally interesting to supporting. Lightly bias toward higher-risk or boundary-crossing stops.
24
24
  4. **End with peripherals** — tests, config, types, and other supporting changes come last.
25
- 5. **Every code reference is a clickable workspace-relative link.** Format each stop as a markdown link: `[short-name:line](/project-root-relative/path/to/file.ts#L42)`. The link target uses a leading `/` (workspace root) with a `#L` line anchor. Use the file's basename (or shortest unambiguous suffix) plus line number as the link text.
25
+ 5. **Every code reference is a clickable workspace-relative link** (project-root-relative for clickability in the editor). Format each stop as a markdown link: `[short-name:line](/project-root-relative/path/to/file.ts#L42)`. The link target uses a leading `/` (workspace root) with a `#L` line anchor. Use the file's basename (or shortest unambiguous suffix) plus line number as the link text.
26
26
  6. **Each stop gets one ultra-concise line of framing** (≤15 words) — why this approach was chosen here and what it achieves in the context of the change. No paragraphs.
27
27
 
28
28
  Format each stop as framing first, link on the next indented line:
@@ -53,7 +53,7 @@ When there is only one concern, omit the bold label — just list the stops dire
53
53
  3. Open the spec in the user's editor so they can click through the Suggested Review Order:
54
54
  - Run `code -r "{spec_file}"` to open the spec in the current VS Code window (reuses the window where the project or worktree is open). Always double-quote the path to handle spaces and special characters.
55
55
  - If `code` is not available (command fails), skip gracefully and tell the user the spec file path instead.
56
- 4. Display summary of your work to the user, including the commit hash if one was created. Include:
56
+ 4. Display summary of your work to the user, including the commit hash if one was created. Any file paths shown in conversation/terminal output must use CWD-relative format (no leading `/`) for terminal clickability — this differs from spec-file links which use project-root-relative paths. Include:
57
57
  - A note that the spec is open in their editor (or the file path if it couldn't be opened). Mention that `{spec_file}` now contains a Suggested Review Order.
58
58
  - **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
59
59
  - Offer to push and/or create a pull request.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-advanced-elicitation
3
- description: 'Push the LLM to reconsider, refine, and improve its recent output.'
3
+ 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.'
4
4
  ---
5
5
 
6
6
  Follow the instructions in ./workflow.md.
@@ -2,14 +2,27 @@
2
2
 
3
3
  An LLM-readable validation prompt for skills following the Agent Skills open standard.
4
4
 
5
+ ## First Pass — Deterministic Checks
6
+
7
+ Before running inference-based validation, run the deterministic validator:
8
+
9
+ ```bash
10
+ node tools/validate-skills.js --json path/to/skill-dir
11
+ ```
12
+
13
+ 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.
14
+
15
+ 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).
16
+
5
17
  ## How to Use
6
18
 
7
19
  1. You are given a **skill directory path** to validate.
8
- 2. Read every file in the skill directory recursively.
9
- 3. Apply every rule in the catalog below to every applicable file.
10
- 4. Produce a findings report using the report template at the end.
20
+ 2. Run the deterministic first pass (see above) and note which rules passed.
21
+ 3. Read every file in the skill directory recursively.
22
+ 4. Apply every rule in the catalog below to every applicable file, **skipping rules that passed the deterministic first pass**.
23
+ 5. Produce a findings report using the report template at the end, including any deterministic findings from the first pass.
11
24
 
12
- If no findings are generated, the skill passes validation.
25
+ If no findings are generated (from either pass), the skill passes validation.
13
26
 
14
27
  ---
15
28
 
@@ -55,9 +68,9 @@ If no findings are generated, the skill passes validation.
55
68
 
56
69
  - **Severity:** HIGH
57
70
  - **Applies to:** `SKILL.md`
58
- - **Rule:** The `name` value must use only lowercase letters, numbers, and hyphens. Max 64 characters. Must not contain "anthropic" or "claude".
59
- - **Detection:** Regex test: `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`. String search for forbidden substrings.
60
- - **Fix:** Rename to comply with the format.
71
+ - **Rule:** The `name` value must start with `bmad-`, use only lowercase letters, numbers, and single hyphens between segments.
72
+ - **Detection:** Regex test: `^bmad-[a-z0-9]+(-[a-z0-9]+)*$`.
73
+ - **Fix:** Rename to comply with the format (e.g., `bmad-my-skill`).
61
74
 
62
75
  ### SKILL-05 — `name` Must Match Directory Name
63
76
 
@@ -75,23 +88,33 @@ If no findings are generated, the skill passes validation.
75
88
  - **Detection:** Check length. Look for trigger phrases like "Use when" or "Use if" — their absence suggests the description only says _what_ but not _when_.
76
89
  - **Fix:** Append a "Use when..." clause to the description.
77
90
 
91
+ ### SKILL-07 — SKILL.md Must Have Body Content
92
+
93
+ - **Severity:** HIGH
94
+ - **Applies to:** `SKILL.md`
95
+ - **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.
96
+ - **Detection:** Extract content after the closing `---` frontmatter delimiter and check it is non-empty after trimming whitespace.
97
+ - **Fix:** Add markdown body with skill instructions after the closing `---`.
98
+
78
99
  ---
79
100
 
80
- ### WF-01 — workflow.md Must NOT Have `name` in Frontmatter
101
+ ### WF-01 — Only SKILL.md May Have `name` in Frontmatter
81
102
 
82
103
  - **Severity:** HIGH
83
- - **Applies to:** `workflow.md` (if it exists)
84
- - **Rule:** The `name` field belongs only in `SKILL.md`. If `workflow.md` has YAML frontmatter, it must not contain `name:`.
85
- - **Detection:** Parse frontmatter and check for `name:` key.
86
- - **Fix:** Remove the `name:` line from workflow.md frontmatter.
104
+ - **Applies to:** all `.md` files except `SKILL.md`
105
+ - **Rule:** The `name` field belongs only in `SKILL.md`. No other markdown file in the skill directory may have `name:` in its frontmatter.
106
+ - **Detection:** Parse frontmatter of every non-SKILL.md markdown file and check for `name:` key.
107
+ - **Fix:** Remove the `name:` line from the file's frontmatter.
108
+ - **Exception:** `bmad-agent-tech-writer` — has sub-skill files with intentional `name` fields (to be revisited).
87
109
 
88
- ### WF-02 — workflow.md Must NOT Have `description` in Frontmatter
110
+ ### WF-02 — Only SKILL.md May Have `description` in Frontmatter
89
111
 
90
112
  - **Severity:** HIGH
91
- - **Applies to:** `workflow.md` (if it exists)
92
- - **Rule:** The `description` field belongs only in `SKILL.md`. If `workflow.md` has YAML frontmatter, it must not contain `description:`.
93
- - **Detection:** Parse frontmatter and check for `description:` key.
94
- - **Fix:** Remove the `description:` line from workflow.md frontmatter.
113
+ - **Applies to:** all `.md` files except `SKILL.md`
114
+ - **Rule:** The `description` field belongs only in `SKILL.md`. No other markdown file in the skill directory may have `description:` in its frontmatter.
115
+ - **Detection:** Parse frontmatter of every non-SKILL.md markdown file and check for `description:` key.
116
+ - **Fix:** Remove the `description:` line from the file's frontmatter.
117
+ - **Exception:** `bmad-agent-tech-writer` — has sub-skill files with intentional `description` fields (to be revisited).
95
118
 
96
119
  ### WF-03 — workflow.md Frontmatter Variables Must Be Config or Runtime Only
97
120
 
@@ -103,6 +126,7 @@ If no findings are generated, the skill passes validation.
103
126
  - A legitimate external path expression (must not violate PATH-05 — no paths into another skill's directory)
104
127
 
105
128
  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).
129
+
106
130
  - **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.
107
131
  - **Fix:** Remove the variable. Use a hardcoded relative path inline where the file is referenced.
108
132
 
@@ -294,11 +318,11 @@ When reporting findings, use this format:
294
318
  ## Summary
295
319
 
296
320
  | Severity | Count |
297
- |----------|-------|
298
- | CRITICAL | N |
299
- | HIGH | N |
300
- | MEDIUM | N |
301
- | LOW | N |
321
+ | -------- | ----- |
322
+ | CRITICAL | N |
323
+ | HIGH | N |
324
+ | MEDIUM | N |
325
+ | LOW | N |
302
326
 
303
327
  ## Findings
304
328
 
@@ -329,28 +353,34 @@ Quick-reference for the Agent Skills open standard.
329
353
  For the full standard, see: [Agent Skills specification](https://agentskills.io/specification)
330
354
 
331
355
  ### Structure
356
+
332
357
  - Every skill is a directory with `SKILL.md` as the required entrypoint
333
358
  - YAML frontmatter between `---` markers provides metadata; markdown body provides instructions
334
359
  - Supporting files (scripts, templates, references) live alongside SKILL.md
335
360
 
336
361
  ### Path resolution
362
+
337
363
  - Relative file references resolve from the directory of the file that contains the reference, not from the skill root
338
364
  - Example: from `branch-a/deep/next.md`, `./deeper/final.md` resolves to `branch-a/deep/deeper/final.md`
339
365
  - Example: from `branch-a/deep/next.md`, `./branch-b/alt/leaf.md` incorrectly resolves to `branch-a/deep/branch-b/alt/leaf.md`
340
366
 
341
367
  ### Frontmatter fields (standard)
368
+
342
369
  - `name`: lowercase letters, numbers, hyphens only; max 64 chars; no "anthropic" or "claude"
343
370
  - `description`: required, max 1024 chars; should state what the skill does AND when to use it
344
371
 
345
372
  ### Progressive disclosure — three loading levels
373
+
346
374
  - **L1 Metadata** (~100 tokens): `name` + `description` loaded at startup into system prompt
347
375
  - **L2 Instructions** (<5k tokens): SKILL.md body loaded only when skill is triggered
348
376
  - **L3 Resources** (unlimited): additional files + scripts loaded/executed on demand; script output enters context, script code does not
349
377
 
350
378
  ### Key design principle
379
+
351
380
  - Skills are filesystem-based directories, not API payloads — Claude reads them via bash/file tools
352
381
  - Keep SKILL.md focused; offload detailed reference to separate files
353
382
 
354
383
  ### Practical tips
384
+
355
385
  - Keep SKILL.md under 500 lines
356
386
  - `description` drives auto-discovery — use keywords users would naturally say
@@ -0,0 +1,736 @@
1
+ /**
2
+ * Deterministic Skill Validator
3
+ *
4
+ * Validates 14 deterministic rules across all skill directories.
5
+ * Acts as a fast first-pass complement to the inference-based skill validator.
6
+ *
7
+ * What it checks:
8
+ * - SKILL-01: SKILL.md exists
9
+ * - SKILL-02: SKILL.md frontmatter has name
10
+ * - SKILL-03: SKILL.md frontmatter has description
11
+ * - SKILL-04: name format (lowercase, hyphens, no forbidden substrings)
12
+ * - SKILL-05: name matches directory basename
13
+ * - SKILL-06: description quality (length, "Use when"/"Use if")
14
+ * - SKILL-07: SKILL.md has body content after frontmatter
15
+ * - WF-01: workflow.md frontmatter has no name
16
+ * - WF-02: workflow.md frontmatter has no description
17
+ * - PATH-02: no installed_path variable
18
+ * - STEP-01: step filename format
19
+ * - STEP-06: step frontmatter has no name/description
20
+ * - STEP-07: step count 2-10
21
+ * - SEQ-02: no time estimates
22
+ *
23
+ * Usage:
24
+ * node tools/validate-skills.js # All skills, human-readable
25
+ * node tools/validate-skills.js path/to/skill-dir # Single skill
26
+ * node tools/validate-skills.js --strict # Exit 1 on HIGH+ findings
27
+ * node tools/validate-skills.js --json # JSON output
28
+ */
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ const PROJECT_ROOT = path.resolve(__dirname, '..');
34
+ const SRC_DIR = path.join(PROJECT_ROOT, 'src');
35
+
36
+ // --- CLI Parsing ---
37
+
38
+ const args = process.argv.slice(2);
39
+ const STRICT = args.includes('--strict');
40
+ const JSON_OUTPUT = args.includes('--json');
41
+ const positionalArgs = args.filter((a) => !a.startsWith('--'));
42
+
43
+ // --- Constants ---
44
+
45
+ const NAME_REGEX = /^bmad-[a-z0-9]+(-[a-z0-9]+)*$/;
46
+ const STEP_FILENAME_REGEX = /^step-\d{2}[a-z]?-[a-z0-9-]+\.md$/;
47
+ const TIME_ESTIMATE_PATTERNS = [/takes?\s+\d+\s*min/i, /~\s*\d+\s*min/i, /estimated\s+time/i, /\bETA\b/];
48
+
49
+ const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
50
+
51
+ // --- Output Escaping ---
52
+
53
+ function escapeAnnotation(str) {
54
+ return str.replaceAll('%', '%25').replaceAll('\r', '%0D').replaceAll('\n', '%0A');
55
+ }
56
+
57
+ function escapeTableCell(str) {
58
+ return String(str).replaceAll('|', String.raw`\|`);
59
+ }
60
+
61
+ // --- Frontmatter Parsing ---
62
+
63
+ /**
64
+ * Parse YAML frontmatter from a markdown file.
65
+ * Returns an object with key-value pairs, or null if no frontmatter.
66
+ */
67
+ function parseFrontmatter(content) {
68
+ const trimmed = content.trimStart();
69
+ if (!trimmed.startsWith('---')) return null;
70
+
71
+ let endIndex = trimmed.indexOf('\n---\n', 3);
72
+ if (endIndex === -1) {
73
+ // Handle file ending with \n---
74
+ if (trimmed.endsWith('\n---')) {
75
+ endIndex = trimmed.length - 4;
76
+ } else {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ const fmBlock = trimmed.slice(3, endIndex).trim();
82
+ if (fmBlock === '') return {};
83
+
84
+ const result = {};
85
+ for (const line of fmBlock.split('\n')) {
86
+ const colonIndex = line.indexOf(':');
87
+ if (colonIndex === -1) continue;
88
+ // Skip indented lines (nested YAML values)
89
+ if (line[0] === ' ' || line[0] === '\t') continue;
90
+ const key = line.slice(0, colonIndex).trim();
91
+ let value = line.slice(colonIndex + 1).trim();
92
+ // Strip surrounding quotes (single or double)
93
+ if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
94
+ value = value.slice(1, -1);
95
+ }
96
+ result[key] = value;
97
+ }
98
+
99
+ return result;
100
+ }
101
+
102
+ /**
103
+ * Parse YAML frontmatter, handling multiline values (description often spans lines).
104
+ * Returns an object with key-value pairs, or null if no frontmatter.
105
+ */
106
+ function parseFrontmatterMultiline(content) {
107
+ const trimmed = content.trimStart();
108
+ if (!trimmed.startsWith('---')) return null;
109
+
110
+ let endIndex = trimmed.indexOf('\n---\n', 3);
111
+ if (endIndex === -1) {
112
+ // Handle file ending with \n---
113
+ if (trimmed.endsWith('\n---')) {
114
+ endIndex = trimmed.length - 4;
115
+ } else {
116
+ return null;
117
+ }
118
+ }
119
+
120
+ const fmBlock = trimmed.slice(3, endIndex).trim();
121
+ if (fmBlock === '') return {};
122
+
123
+ const result = {};
124
+ let currentKey = null;
125
+ let currentValue = '';
126
+
127
+ for (const line of fmBlock.split('\n')) {
128
+ const colonIndex = line.indexOf(':');
129
+ // New key-value pair: must start at column 0 (no leading whitespace) and have a colon
130
+ if (colonIndex > 0 && line[0] !== ' ' && line[0] !== '\t') {
131
+ // Save previous key
132
+ if (currentKey !== null) {
133
+ result[currentKey] = stripQuotes(currentValue.trim());
134
+ }
135
+ currentKey = line.slice(0, colonIndex).trim();
136
+ currentValue = line.slice(colonIndex + 1);
137
+ } else if (currentKey !== null) {
138
+ // Skip YAML comment lines
139
+ if (line.trimStart().startsWith('#')) continue;
140
+ // Continuation of multiline value
141
+ currentValue += '\n' + line;
142
+ }
143
+ }
144
+
145
+ // Save last key
146
+ if (currentKey !== null) {
147
+ result[currentKey] = stripQuotes(currentValue.trim());
148
+ }
149
+
150
+ return result;
151
+ }
152
+
153
+ function stripQuotes(value) {
154
+ if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) {
155
+ return value.slice(1, -1);
156
+ }
157
+ return value;
158
+ }
159
+
160
+ // --- Safe File Reading ---
161
+
162
+ /**
163
+ * Read a file safely, returning null on error.
164
+ * Pushes a warning finding if the file cannot be read.
165
+ */
166
+ function safeReadFile(filePath, findings, relFile) {
167
+ try {
168
+ return fs.readFileSync(filePath, 'utf-8');
169
+ } catch (error) {
170
+ findings.push({
171
+ rule: 'READ-ERR',
172
+ title: 'File Read Error',
173
+ severity: 'MEDIUM',
174
+ file: relFile || path.basename(filePath),
175
+ detail: `Cannot read file: ${error.message}`,
176
+ fix: 'Check file permissions and ensure the file exists.',
177
+ });
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // --- Code Block Stripping ---
183
+
184
+ function stripCodeBlocks(content) {
185
+ return content.replaceAll(/```[\s\S]*?```/g, (m) => m.replaceAll(/[^\n]/g, ''));
186
+ }
187
+
188
+ // --- Skill Discovery ---
189
+
190
+ function discoverSkillDirs(rootDirs) {
191
+ const skillDirs = [];
192
+
193
+ function walk(dir) {
194
+ if (!fs.existsSync(dir)) return;
195
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
196
+
197
+ for (const entry of entries) {
198
+ if (!entry.isDirectory()) continue;
199
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
200
+
201
+ const fullPath = path.join(dir, entry.name);
202
+ const skillMd = path.join(fullPath, 'SKILL.md');
203
+
204
+ if (fs.existsSync(skillMd)) {
205
+ skillDirs.push(fullPath);
206
+ }
207
+
208
+ // Keep walking into subdirectories to find nested skills
209
+ walk(fullPath);
210
+ }
211
+ }
212
+
213
+ for (const rootDir of rootDirs) {
214
+ walk(rootDir);
215
+ }
216
+
217
+ return skillDirs.sort();
218
+ }
219
+
220
+ // --- File Collection ---
221
+
222
+ function collectSkillFiles(skillDir) {
223
+ const files = [];
224
+
225
+ function walk(dir) {
226
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
227
+ for (const entry of entries) {
228
+ if (entry.name === 'node_modules' || entry.name === '.git') continue;
229
+ const fullPath = path.join(dir, entry.name);
230
+ if (entry.isDirectory()) {
231
+ walk(fullPath);
232
+ } else if (entry.isFile()) {
233
+ files.push(fullPath);
234
+ }
235
+ }
236
+ }
237
+
238
+ walk(skillDir);
239
+ return files;
240
+ }
241
+
242
+ // --- Rule Checks ---
243
+
244
+ function validateSkill(skillDir) {
245
+ const findings = [];
246
+ const dirName = path.basename(skillDir);
247
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
248
+ const workflowMdPath = path.join(skillDir, 'workflow.md');
249
+ const stepsDir = path.join(skillDir, 'steps');
250
+
251
+ // Collect all files in the skill for PATH-02 and SEQ-02
252
+ const allFiles = collectSkillFiles(skillDir);
253
+
254
+ // --- SKILL-01: SKILL.md must exist ---
255
+ if (!fs.existsSync(skillMdPath)) {
256
+ findings.push({
257
+ rule: 'SKILL-01',
258
+ title: 'SKILL.md Must Exist',
259
+ severity: 'CRITICAL',
260
+ file: 'SKILL.md',
261
+ detail: 'SKILL.md not found in skill directory.',
262
+ fix: 'Create SKILL.md as the skill entrypoint.',
263
+ });
264
+ // Cannot check SKILL-02 through SKILL-07 without SKILL.md
265
+ return findings;
266
+ }
267
+
268
+ const skillContent = safeReadFile(skillMdPath, findings, 'SKILL.md');
269
+ if (skillContent === null) return findings;
270
+ const skillFm = parseFrontmatterMultiline(skillContent);
271
+
272
+ // --- SKILL-02: frontmatter has name ---
273
+ if (!skillFm || !('name' in skillFm)) {
274
+ findings.push({
275
+ rule: 'SKILL-02',
276
+ title: 'SKILL.md Must Have name in Frontmatter',
277
+ severity: 'CRITICAL',
278
+ file: 'SKILL.md',
279
+ detail: 'Frontmatter is missing the `name` field.',
280
+ fix: 'Add `name: <skill-name>` to the frontmatter.',
281
+ });
282
+ } else if (skillFm.name === '') {
283
+ findings.push({
284
+ rule: 'SKILL-02',
285
+ title: 'SKILL.md Must Have name in Frontmatter',
286
+ severity: 'CRITICAL',
287
+ file: 'SKILL.md',
288
+ detail: 'Frontmatter `name` field is empty.',
289
+ fix: 'Set `name` to the skill directory name (kebab-case).',
290
+ });
291
+ }
292
+
293
+ // --- SKILL-03: frontmatter has description ---
294
+ if (!skillFm || !('description' in skillFm)) {
295
+ findings.push({
296
+ rule: 'SKILL-03',
297
+ title: 'SKILL.md Must Have description in Frontmatter',
298
+ severity: 'CRITICAL',
299
+ file: 'SKILL.md',
300
+ detail: 'Frontmatter is missing the `description` field.',
301
+ fix: 'Add `description: <what it does and when to use it>` to the frontmatter.',
302
+ });
303
+ } else if (skillFm.description === '') {
304
+ findings.push({
305
+ rule: 'SKILL-03',
306
+ title: 'SKILL.md Must Have description in Frontmatter',
307
+ severity: 'CRITICAL',
308
+ file: 'SKILL.md',
309
+ detail: 'Frontmatter `description` field is empty.',
310
+ fix: 'Add a description stating what the skill does and when to use it.',
311
+ });
312
+ }
313
+
314
+ const name = skillFm && skillFm.name;
315
+ const description = skillFm && skillFm.description;
316
+
317
+ // --- SKILL-04: name format ---
318
+ if (name && !NAME_REGEX.test(name)) {
319
+ findings.push({
320
+ rule: 'SKILL-04',
321
+ title: 'name Format',
322
+ severity: 'HIGH',
323
+ file: 'SKILL.md',
324
+ detail: `name "${name}" does not match pattern: ${NAME_REGEX}`,
325
+ fix: 'Rename to comply with lowercase letters, numbers, and hyphens only (max 64 chars).',
326
+ });
327
+ }
328
+
329
+ // --- SKILL-05: name matches directory ---
330
+ if (name && name !== dirName) {
331
+ findings.push({
332
+ rule: 'SKILL-05',
333
+ title: 'name Must Match Directory Name',
334
+ severity: 'HIGH',
335
+ file: 'SKILL.md',
336
+ detail: `name "${name}" does not match directory name "${dirName}".`,
337
+ fix: `Change name to "${dirName}" or rename the directory.`,
338
+ });
339
+ }
340
+
341
+ // --- SKILL-06: description quality ---
342
+ if (description) {
343
+ if (description.length > 1024) {
344
+ findings.push({
345
+ rule: 'SKILL-06',
346
+ title: 'description Quality',
347
+ severity: 'MEDIUM',
348
+ file: 'SKILL.md',
349
+ detail: `description is ${description.length} characters (max 1024).`,
350
+ fix: 'Shorten the description to 1024 characters or less.',
351
+ });
352
+ }
353
+
354
+ if (!/use\s+when\b/i.test(description) && !/use\s+if\b/i.test(description)) {
355
+ findings.push({
356
+ rule: 'SKILL-06',
357
+ title: 'description Quality',
358
+ severity: 'MEDIUM',
359
+ file: 'SKILL.md',
360
+ detail: 'description does not contain "Use when" or "Use if" trigger phrase.',
361
+ fix: 'Append a "Use when..." clause to explain when to invoke this skill.',
362
+ });
363
+ }
364
+ }
365
+
366
+ // --- SKILL-07: SKILL.md must have body content after frontmatter ---
367
+ {
368
+ const trimmed = skillContent.trimStart();
369
+ let bodyStart = -1;
370
+ if (trimmed.startsWith('---')) {
371
+ let endIdx = trimmed.indexOf('\n---\n', 3);
372
+ if (endIdx !== -1) {
373
+ bodyStart = endIdx + 4;
374
+ } else if (trimmed.endsWith('\n---')) {
375
+ bodyStart = trimmed.length; // no body at all
376
+ }
377
+ } else {
378
+ bodyStart = 0; // no frontmatter, entire file is body
379
+ }
380
+ const body = bodyStart >= 0 ? trimmed.slice(bodyStart).trim() : '';
381
+ if (body === '') {
382
+ findings.push({
383
+ rule: 'SKILL-07',
384
+ title: 'SKILL.md Must Have Body Content',
385
+ severity: 'HIGH',
386
+ file: 'SKILL.md',
387
+ detail: 'SKILL.md has no content after frontmatter. L2 instructions are required.',
388
+ fix: 'Add markdown body with skill instructions after the closing ---.',
389
+ });
390
+ }
391
+ }
392
+
393
+ // --- WF-01 / WF-02: non-SKILL.md files must NOT have name/description ---
394
+ // TODO: bmad-agent-tech-writer has sub-skill files with intentional name/description
395
+ const WF_SKIP_SKILLS = new Set(['bmad-agent-tech-writer']);
396
+ for (const filePath of allFiles) {
397
+ if (path.extname(filePath) !== '.md') continue;
398
+ if (path.basename(filePath) === 'SKILL.md') continue;
399
+ if (WF_SKIP_SKILLS.has(dirName)) continue;
400
+
401
+ const relFile = path.relative(skillDir, filePath);
402
+ const content = safeReadFile(filePath, findings, relFile);
403
+ if (content === null) continue;
404
+ const fm = parseFrontmatter(content);
405
+ if (!fm) continue;
406
+
407
+ if ('name' in fm) {
408
+ findings.push({
409
+ rule: 'WF-01',
410
+ title: 'Only SKILL.md May Have name in Frontmatter',
411
+ severity: 'HIGH',
412
+ file: relFile,
413
+ detail: `${relFile} frontmatter contains \`name\` — this belongs only in SKILL.md.`,
414
+ fix: "Remove the `name:` line from this file's frontmatter.",
415
+ });
416
+ }
417
+
418
+ if ('description' in fm) {
419
+ findings.push({
420
+ rule: 'WF-02',
421
+ title: 'Only SKILL.md May Have description in Frontmatter',
422
+ severity: 'HIGH',
423
+ file: relFile,
424
+ detail: `${relFile} frontmatter contains \`description\` — this belongs only in SKILL.md.`,
425
+ fix: "Remove the `description:` line from this file's frontmatter.",
426
+ });
427
+ }
428
+ }
429
+
430
+ // --- PATH-02: no installed_path ---
431
+ for (const filePath of allFiles) {
432
+ // Only check markdown and yaml files
433
+ const ext = path.extname(filePath);
434
+ if (!['.md', '.yaml', '.yml'].includes(ext)) continue;
435
+
436
+ const relFile = path.relative(skillDir, filePath);
437
+ const content = safeReadFile(filePath, findings, relFile);
438
+ if (content === null) continue;
439
+
440
+ // Check frontmatter for installed_path key
441
+ const fm = parseFrontmatter(content);
442
+ if (fm && 'installed_path' in fm) {
443
+ findings.push({
444
+ rule: 'PATH-02',
445
+ title: 'No installed_path Variable',
446
+ severity: 'HIGH',
447
+ file: relFile,
448
+ detail: 'Frontmatter contains `installed_path:` key.',
449
+ fix: 'Remove `installed_path` from frontmatter. Use relative paths instead.',
450
+ });
451
+ }
452
+
453
+ // Check content for any mention of installed_path (variable ref, prose, bare text)
454
+ const stripped = stripCodeBlocks(content);
455
+ const lines = stripped.split('\n');
456
+ for (const [i, line] of lines.entries()) {
457
+ if (/installed_path/i.test(line)) {
458
+ findings.push({
459
+ rule: 'PATH-02',
460
+ title: 'No installed_path Variable',
461
+ severity: 'HIGH',
462
+ file: relFile,
463
+ line: i + 1,
464
+ detail: '`installed_path` reference found in content.',
465
+ fix: 'Remove all installed_path usage. Use relative paths (`./path` or `../path`) instead.',
466
+ });
467
+ }
468
+ }
469
+ }
470
+
471
+ // --- STEP-01: step filename format ---
472
+ // --- STEP-06: step frontmatter no name/description ---
473
+ // --- STEP-07: step count ---
474
+ // Only check the literal steps/ directory (variant directories like steps-c, steps-v
475
+ // use different naming conventions and are excluded per the rule specification)
476
+ if (fs.existsSync(stepsDir) && fs.statSync(stepsDir).isDirectory()) {
477
+ const stepDirName = 'steps';
478
+ const stepFiles = fs.readdirSync(stepsDir).filter((f) => f.endsWith('.md'));
479
+
480
+ // STEP-01: filename format
481
+ for (const stepFile of stepFiles) {
482
+ if (!STEP_FILENAME_REGEX.test(stepFile)) {
483
+ findings.push({
484
+ rule: 'STEP-01',
485
+ title: 'Step File Naming',
486
+ severity: 'MEDIUM',
487
+ file: path.join(stepDirName, stepFile),
488
+ detail: `Filename "${stepFile}" does not match pattern: ${STEP_FILENAME_REGEX}`,
489
+ fix: 'Rename to step-NN-description.md (NN = zero-padded number, optional letter suffix).',
490
+ });
491
+ }
492
+ }
493
+
494
+ // STEP-06: step frontmatter has no name/description
495
+ for (const stepFile of stepFiles) {
496
+ const stepPath = path.join(stepsDir, stepFile);
497
+ const stepContent = safeReadFile(stepPath, findings, path.join(stepDirName, stepFile));
498
+ if (stepContent === null) continue;
499
+ const stepFm = parseFrontmatter(stepContent);
500
+
501
+ if (stepFm) {
502
+ if ('name' in stepFm) {
503
+ findings.push({
504
+ rule: 'STEP-06',
505
+ title: 'Step File Frontmatter: No name or description',
506
+ severity: 'MEDIUM',
507
+ file: path.join(stepDirName, stepFile),
508
+ detail: 'Step file frontmatter contains `name:` — this is metadata noise.',
509
+ fix: 'Remove `name:` from step file frontmatter.',
510
+ });
511
+ }
512
+ if ('description' in stepFm) {
513
+ findings.push({
514
+ rule: 'STEP-06',
515
+ title: 'Step File Frontmatter: No name or description',
516
+ severity: 'MEDIUM',
517
+ file: path.join(stepDirName, stepFile),
518
+ detail: 'Step file frontmatter contains `description:` — this is metadata noise.',
519
+ fix: 'Remove `description:` from step file frontmatter.',
520
+ });
521
+ }
522
+ }
523
+ }
524
+
525
+ // STEP-07: step count 2-10
526
+ const stepCount = stepFiles.filter((f) => f.startsWith('step-')).length;
527
+ if (stepCount > 0 && (stepCount < 2 || stepCount > 10)) {
528
+ const detail =
529
+ stepCount < 2
530
+ ? `Only ${stepCount} step file found — consider inlining into workflow.md.`
531
+ : `${stepCount} step files found — more than 10 risks LLM context degradation.`;
532
+ findings.push({
533
+ rule: 'STEP-07',
534
+ title: 'Step Count',
535
+ severity: 'LOW',
536
+ file: stepDirName + '/',
537
+ detail,
538
+ fix: stepCount > 10 ? 'Consider consolidating steps.' : 'Consider expanding or inlining.',
539
+ });
540
+ }
541
+ }
542
+
543
+ // --- SEQ-02: no time estimates ---
544
+ for (const filePath of allFiles) {
545
+ const ext = path.extname(filePath);
546
+ if (!['.md', '.yaml', '.yml'].includes(ext)) continue;
547
+
548
+ const relFile = path.relative(skillDir, filePath);
549
+ const content = safeReadFile(filePath, findings, relFile);
550
+ if (content === null) continue;
551
+ const stripped = stripCodeBlocks(content);
552
+ const lines = stripped.split('\n');
553
+
554
+ for (const [i, line] of lines.entries()) {
555
+ for (const pattern of TIME_ESTIMATE_PATTERNS) {
556
+ if (pattern.test(line)) {
557
+ findings.push({
558
+ rule: 'SEQ-02',
559
+ title: 'No Time Estimates',
560
+ severity: 'LOW',
561
+ file: relFile,
562
+ line: i + 1,
563
+ detail: `Time estimate pattern found: "${line.trim()}"`,
564
+ fix: 'Remove time estimates — AI execution speed varies too much.',
565
+ });
566
+ break; // Only report once per line
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ return findings;
573
+ }
574
+
575
+ // --- Output Formatting ---
576
+
577
+ function formatHumanReadable(results) {
578
+ const output = [];
579
+ let totalFindings = 0;
580
+ const severityCounts = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
581
+
582
+ output.push(
583
+ `\nValidating skills in: ${SRC_DIR}`,
584
+ `Mode: ${STRICT ? 'STRICT (exit 1 on HIGH+)' : 'WARNING (exit 0)'}${JSON_OUTPUT ? ' + JSON' : ''}\n`,
585
+ );
586
+
587
+ let totalSkills = 0;
588
+ let skillsWithFindings = 0;
589
+
590
+ for (const { skillDir, findings } of results) {
591
+ totalSkills++;
592
+ const relDir = path.relative(PROJECT_ROOT, skillDir);
593
+
594
+ if (findings.length > 0) {
595
+ skillsWithFindings++;
596
+ output.push(`\n${relDir}`);
597
+
598
+ for (const f of findings) {
599
+ totalFindings++;
600
+ severityCounts[f.severity]++;
601
+ const location = f.line ? ` (line ${f.line})` : '';
602
+ output.push(` [${f.severity}] ${f.rule} — ${f.title}`, ` File: ${f.file}${location}`, ` ${f.detail}`);
603
+
604
+ if (process.env.GITHUB_ACTIONS) {
605
+ const absFile = path.join(skillDir, f.file);
606
+ const ghFile = path.relative(PROJECT_ROOT, absFile);
607
+ const line = f.line || 1;
608
+ const level = f.severity === 'LOW' ? 'notice' : 'warning';
609
+ console.log(`::${level} file=${ghFile},line=${line}::${escapeAnnotation(`${f.rule}: ${f.detail}`)}`);
610
+ }
611
+ }
612
+ }
613
+ }
614
+
615
+ // Summary
616
+ output.push(
617
+ `\n${'─'.repeat(60)}`,
618
+ `\nSummary:`,
619
+ ` Skills scanned: ${totalSkills}`,
620
+ ` Skills with findings: ${skillsWithFindings}`,
621
+ ` Total findings: ${totalFindings}`,
622
+ );
623
+
624
+ if (totalFindings > 0) {
625
+ output.push('', ` | Severity | Count |`, ` |----------|-------|`);
626
+ for (const sev of ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']) {
627
+ if (severityCounts[sev] > 0) {
628
+ output.push(` | ${sev.padEnd(8)} | ${String(severityCounts[sev]).padStart(5)} |`);
629
+ }
630
+ }
631
+ }
632
+
633
+ const hasHighPlus = severityCounts.CRITICAL > 0 || severityCounts.HIGH > 0;
634
+
635
+ if (totalFindings === 0) {
636
+ output.push(`\n All skills passed validation!`);
637
+ } else if (STRICT && hasHighPlus) {
638
+ output.push(`\n [STRICT MODE] HIGH+ findings found — exiting with failure.`);
639
+ } else if (STRICT) {
640
+ output.push(`\n [STRICT MODE] Only MEDIUM/LOW findings — pass.`);
641
+ } else {
642
+ output.push(`\n Run with --strict to treat HIGH+ findings as errors.`);
643
+ }
644
+
645
+ output.push('');
646
+
647
+ // Write GitHub Actions step summary
648
+ if (process.env.GITHUB_STEP_SUMMARY) {
649
+ let summary = '## Skill Validation\n\n';
650
+ if (totalFindings > 0) {
651
+ summary += '| Skill | Rule | Severity | File | Detail |\n';
652
+ summary += '|-------|------|----------|------|--------|\n';
653
+ for (const { skillDir, findings } of results) {
654
+ const relDir = path.relative(PROJECT_ROOT, skillDir);
655
+ for (const f of findings) {
656
+ summary += `| ${escapeTableCell(relDir)} | ${f.rule} | ${f.severity} | ${escapeTableCell(f.file)} | ${escapeTableCell(f.detail)} |\n`;
657
+ }
658
+ }
659
+ summary += '\n';
660
+ }
661
+ summary += `**${totalSkills} skills scanned, ${totalFindings} findings**\n`;
662
+ fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
663
+ }
664
+
665
+ return { output: output.join('\n'), hasHighPlus };
666
+ }
667
+
668
+ function formatJson(results) {
669
+ const allFindings = [];
670
+ for (const { skillDir, findings } of results) {
671
+ const relDir = path.relative(PROJECT_ROOT, skillDir);
672
+ for (const f of findings) {
673
+ allFindings.push({
674
+ skill: relDir,
675
+ rule: f.rule,
676
+ title: f.title,
677
+ severity: f.severity,
678
+ file: f.file,
679
+ line: f.line || null,
680
+ detail: f.detail,
681
+ fix: f.fix,
682
+ });
683
+ }
684
+ }
685
+
686
+ // Sort by severity
687
+ allFindings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
688
+
689
+ const hasHighPlus = allFindings.some((f) => f.severity === 'CRITICAL' || f.severity === 'HIGH');
690
+
691
+ return { output: JSON.stringify(allFindings, null, 2), hasHighPlus };
692
+ }
693
+
694
+ // --- Main ---
695
+
696
+ if (require.main === module) {
697
+ // Determine which skills to validate
698
+ let skillDirs;
699
+
700
+ if (positionalArgs.length > 0) {
701
+ // Single skill directory specified
702
+ const target = path.resolve(positionalArgs[0]);
703
+ if (!fs.existsSync(target) || !fs.statSync(target).isDirectory()) {
704
+ console.error(`Error: "${positionalArgs[0]}" is not a valid directory.`);
705
+ process.exit(2);
706
+ }
707
+ skillDirs = [target];
708
+ } else {
709
+ // Discover all skills
710
+ skillDirs = discoverSkillDirs([SRC_DIR]);
711
+ }
712
+
713
+ if (skillDirs.length === 0) {
714
+ console.error('No skill directories found.');
715
+ process.exit(2);
716
+ }
717
+
718
+ // Validate each skill
719
+ const results = [];
720
+ for (const skillDir of skillDirs) {
721
+ const findings = validateSkill(skillDir);
722
+ results.push({ skillDir, findings });
723
+ }
724
+
725
+ // Format output
726
+ const { output, hasHighPlus } = JSON_OUTPUT ? formatJson(results) : formatHumanReadable(results);
727
+ console.log(output);
728
+
729
+ // Exit code
730
+ if (STRICT && hasHighPlus) {
731
+ process.exit(1);
732
+ }
733
+ }
734
+
735
+ // --- Exports (for testing) ---
736
+ module.exports = { parseFrontmatter, parseFrontmatterMultiline, validateSkill, discoverSkillDirs };