bmad-method 6.2.1-next.14 → 6.2.1-next.16
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 +1 -0
- package/package.json +4 -3
- package/src/core-skills/bmad-advanced-elicitation/SKILL.md +1 -1
- package/tools/cli/installers/lib/core/dependency-resolver.js +11 -11
- package/tools/cli/installers/lib/core/installer.js +3 -3
- package/tools/cli/installers/lib/core/manifest.js +2 -2
- package/tools/cli/installers/lib/ide/shared/workflow-command-generator.js +4 -4
- package/tools/cli/installers/lib/modules/manager.js +10 -9
- package/tools/cli/lib/project-root.js +5 -5
- package/tools/cli/lib/yaml-xml-builder.js +6 -4
- package/tools/skill-validator.md +52 -22
- package/tools/validate-skills.js +736 -0
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.
|
|
4
|
+
"version": "6.2.1-next.16",
|
|
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}": [
|
|
@@ -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.
|
|
@@ -82,11 +82,11 @@ class DependencyResolver {
|
|
|
82
82
|
// Check if this is a source directory (has 'src' subdirectory)
|
|
83
83
|
const srcDir = path.join(bmadDir, 'src');
|
|
84
84
|
if (await fs.pathExists(srcDir)) {
|
|
85
|
-
// Source directory structure: src/core or src/bmm
|
|
85
|
+
// Source directory structure: src/core-skills or src/bmm-skills
|
|
86
86
|
if (module === 'core') {
|
|
87
|
-
moduleDir = path.join(srcDir, 'core');
|
|
87
|
+
moduleDir = path.join(srcDir, 'core-skills');
|
|
88
88
|
} else if (module === 'bmm') {
|
|
89
|
-
moduleDir = path.join(srcDir, 'bmm');
|
|
89
|
+
moduleDir = path.join(srcDir, 'bmm-skills');
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -401,8 +401,8 @@ class DependencyResolver {
|
|
|
401
401
|
const bmadPath = dep.dependency.replace(/^bmad\//, '');
|
|
402
402
|
|
|
403
403
|
// Try to resolve as if it's in src structure
|
|
404
|
-
// bmad/core/tasks/foo.md -> src/core/tasks/foo.md
|
|
405
|
-
// bmad/bmm/tasks/bar.md -> src/bmm/tasks/bar.md (bmm is directly under src/)
|
|
404
|
+
// bmad/core/tasks/foo.md -> src/core-skills/tasks/foo.md
|
|
405
|
+
// bmad/bmm/tasks/bar.md -> src/bmm-skills/tasks/bar.md (bmm is directly under src/)
|
|
406
406
|
// bmad/cis/agents/bar.md -> src/modules/cis/agents/bar.md
|
|
407
407
|
|
|
408
408
|
if (bmadPath.startsWith('core/')) {
|
|
@@ -584,11 +584,11 @@ class DependencyResolver {
|
|
|
584
584
|
const relative = path.relative(bmadDir, filePath);
|
|
585
585
|
const parts = relative.split(path.sep);
|
|
586
586
|
|
|
587
|
-
// Handle source directory structure (src/core, src/bmm, or src/modules/xxx)
|
|
587
|
+
// Handle source directory structure (src/core-skills, src/bmm-skills, or src/modules/xxx)
|
|
588
588
|
if (parts[0] === 'src') {
|
|
589
|
-
if (parts[1] === 'core') {
|
|
589
|
+
if (parts[1] === 'core-skills') {
|
|
590
590
|
return 'core';
|
|
591
|
-
} else if (parts[1] === 'bmm') {
|
|
591
|
+
} else if (parts[1] === 'bmm-skills') {
|
|
592
592
|
return 'bmm';
|
|
593
593
|
} else if (parts[1] === 'modules' && parts.length > 2) {
|
|
594
594
|
return parts[2];
|
|
@@ -631,11 +631,11 @@ class DependencyResolver {
|
|
|
631
631
|
let moduleBase;
|
|
632
632
|
|
|
633
633
|
// Check if file is in source directory structure
|
|
634
|
-
if (file.includes('/src/core/') || file.includes('/src/bmm/')) {
|
|
634
|
+
if (file.includes('/src/core-skills/') || file.includes('/src/bmm-skills/')) {
|
|
635
635
|
if (module === 'core') {
|
|
636
|
-
moduleBase = path.join(bmadDir, 'src', 'core');
|
|
636
|
+
moduleBase = path.join(bmadDir, 'src', 'core-skills');
|
|
637
637
|
} else if (module === 'bmm') {
|
|
638
|
-
moduleBase = path.join(bmadDir, 'src', 'bmm');
|
|
638
|
+
moduleBase = path.join(bmadDir, 'src', 'bmm-skills');
|
|
639
639
|
}
|
|
640
640
|
} else {
|
|
641
641
|
moduleBase = module === 'core' ? path.join(bmadDir, 'core') : path.join(bmadDir, 'modules', module);
|
|
@@ -1789,8 +1789,8 @@ class Installer {
|
|
|
1789
1789
|
.filter((entry) => entry.isDirectory() && entry.name !== '_config' && entry.name !== 'docs' && entry.name !== '_memory')
|
|
1790
1790
|
.map((entry) => entry.name);
|
|
1791
1791
|
|
|
1792
|
-
// Add core module to scan (it's installed at root level as _config, but we check src/core)
|
|
1793
|
-
const coreModulePath = getSourcePath('core');
|
|
1792
|
+
// Add core module to scan (it's installed at root level as _config, but we check src/core-skills)
|
|
1793
|
+
const coreModulePath = getSourcePath('core-skills');
|
|
1794
1794
|
const modulePaths = new Map();
|
|
1795
1795
|
|
|
1796
1796
|
// Map all module source paths
|
|
@@ -2709,7 +2709,7 @@ class Installer {
|
|
|
2709
2709
|
// Get source path
|
|
2710
2710
|
let sourcePath;
|
|
2711
2711
|
if (moduleId === 'core') {
|
|
2712
|
-
sourcePath = getSourcePath('core');
|
|
2712
|
+
sourcePath = getSourcePath('core-skills');
|
|
2713
2713
|
} else {
|
|
2714
2714
|
// First check if it's in the custom cache
|
|
2715
2715
|
if (customModuleSources.has(moduleId)) {
|
|
@@ -764,10 +764,10 @@ class Manifest {
|
|
|
764
764
|
const configs = {};
|
|
765
765
|
|
|
766
766
|
for (const moduleName of modules) {
|
|
767
|
-
// Handle core module differently - it's in src/core not src/modules/core
|
|
767
|
+
// Handle core module differently - it's in src/core-skills not src/modules/core
|
|
768
768
|
const configPath =
|
|
769
769
|
moduleName === 'core'
|
|
770
|
-
? path.join(process.cwd(), 'src', 'core', 'config.yaml')
|
|
770
|
+
? path.join(process.cwd(), 'src', 'core-skills', 'config.yaml')
|
|
771
771
|
: path.join(process.cwd(), 'src', 'modules', moduleName, 'config.yaml');
|
|
772
772
|
|
|
773
773
|
try {
|
|
@@ -146,13 +146,13 @@ When running any workflow:
|
|
|
146
146
|
transformWorkflowPath(workflowPath) {
|
|
147
147
|
let transformed = workflowPath;
|
|
148
148
|
|
|
149
|
-
if (workflowPath.includes('/src/bmm/')) {
|
|
150
|
-
const match = workflowPath.match(/\/src\/bmm\/(.+)/);
|
|
149
|
+
if (workflowPath.includes('/src/bmm-skills/')) {
|
|
150
|
+
const match = workflowPath.match(/\/src\/bmm-skills\/(.+)/);
|
|
151
151
|
if (match) {
|
|
152
152
|
transformed = `{project-root}/${this.bmadFolderName}/bmm/${match[1]}`;
|
|
153
153
|
}
|
|
154
|
-
} else if (workflowPath.includes('/src/core/')) {
|
|
155
|
-
const match = workflowPath.match(/\/src\/core\/(.+)/);
|
|
154
|
+
} else if (workflowPath.includes('/src/core-skills/')) {
|
|
155
|
+
const match = workflowPath.match(/\/src\/core-skills\/(.+)/);
|
|
156
156
|
if (match) {
|
|
157
157
|
transformed = `{project-root}/${this.bmadFolderName}/core/${match[1]}`;
|
|
158
158
|
}
|
|
@@ -187,7 +187,7 @@ class ModuleManager {
|
|
|
187
187
|
|
|
188
188
|
/**
|
|
189
189
|
* List all available modules (excluding core which is always installed)
|
|
190
|
-
* bmm is the only built-in module, directly under src/bmm
|
|
190
|
+
* bmm is the only built-in module, directly under src/bmm-skills
|
|
191
191
|
* All other modules come from external-official-modules.yaml
|
|
192
192
|
* @returns {Object} Object with modules array and customModules array
|
|
193
193
|
*/
|
|
@@ -195,10 +195,10 @@ class ModuleManager {
|
|
|
195
195
|
const modules = [];
|
|
196
196
|
const customModules = [];
|
|
197
197
|
|
|
198
|
-
// Add built-in bmm module (directly under src/bmm)
|
|
199
|
-
const bmmPath = getSourcePath('bmm');
|
|
198
|
+
// Add built-in bmm module (directly under src/bmm-skills)
|
|
199
|
+
const bmmPath = getSourcePath('bmm-skills');
|
|
200
200
|
if (await fs.pathExists(bmmPath)) {
|
|
201
|
-
const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm');
|
|
201
|
+
const bmmInfo = await this.getModuleInfo(bmmPath, 'bmm', 'src/bmm-skills');
|
|
202
202
|
if (bmmInfo) {
|
|
203
203
|
modules.push(bmmInfo);
|
|
204
204
|
}
|
|
@@ -251,7 +251,8 @@ class ModuleManager {
|
|
|
251
251
|
}
|
|
252
252
|
|
|
253
253
|
// Mark as custom if it's using custom.yaml OR if it's outside src/bmm or src/core
|
|
254
|
-
const isCustomSource =
|
|
254
|
+
const isCustomSource =
|
|
255
|
+
sourceDescription !== 'src/bmm-skills' && sourceDescription !== 'src/core-skills' && sourceDescription !== 'src/modules';
|
|
255
256
|
const moduleInfo = {
|
|
256
257
|
id: defaultName,
|
|
257
258
|
path: modulePath,
|
|
@@ -300,9 +301,9 @@ class ModuleManager {
|
|
|
300
301
|
return this.customModulePaths.get(moduleCode);
|
|
301
302
|
}
|
|
302
303
|
|
|
303
|
-
// Check for built-in bmm module (directly under src/bmm)
|
|
304
|
+
// Check for built-in bmm module (directly under src/bmm-skills)
|
|
304
305
|
if (moduleCode === 'bmm') {
|
|
305
|
-
const bmmPath = getSourcePath('bmm');
|
|
306
|
+
const bmmPath = getSourcePath('bmm-skills');
|
|
306
307
|
if (await fs.pathExists(bmmPath)) {
|
|
307
308
|
return bmmPath;
|
|
308
309
|
}
|
|
@@ -1141,10 +1142,10 @@ class ModuleManager {
|
|
|
1141
1142
|
const projectRoot = path.dirname(bmadDir);
|
|
1142
1143
|
const emptyResult = { createdDirs: [], movedDirs: [], createdWdsFolders: [] };
|
|
1143
1144
|
|
|
1144
|
-
// Special handling for core module - it's in src/core not src/modules
|
|
1145
|
+
// Special handling for core module - it's in src/core-skills not src/modules
|
|
1145
1146
|
let sourcePath;
|
|
1146
1147
|
if (moduleName === 'core') {
|
|
1147
|
-
sourcePath = getSourcePath('core');
|
|
1148
|
+
sourcePath = getSourcePath('core-skills');
|
|
1148
1149
|
} else {
|
|
1149
1150
|
sourcePath = await this.findModuleSource(moduleName, { silent: true });
|
|
1150
1151
|
if (!sourcePath) {
|
|
@@ -16,7 +16,7 @@ function findProjectRoot(startPath = __dirname) {
|
|
|
16
16
|
try {
|
|
17
17
|
const pkg = fs.readJsonSync(packagePath);
|
|
18
18
|
// Check if this is the BMAD project
|
|
19
|
-
if (pkg.name === 'bmad-method' || fs.existsSync(path.join(currentPath, 'src', 'core'))) {
|
|
19
|
+
if (pkg.name === 'bmad-method' || fs.existsSync(path.join(currentPath, 'src', 'core-skills'))) {
|
|
20
20
|
return currentPath;
|
|
21
21
|
}
|
|
22
22
|
} catch {
|
|
@@ -24,8 +24,8 @@ function findProjectRoot(startPath = __dirname) {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
// Also check for src/core as a marker
|
|
28
|
-
if (fs.existsSync(path.join(currentPath, 'src', 'core', 'agents'))) {
|
|
27
|
+
// Also check for src/core-skills as a marker
|
|
28
|
+
if (fs.existsSync(path.join(currentPath, 'src', 'core-skills', 'agents'))) {
|
|
29
29
|
return currentPath;
|
|
30
30
|
}
|
|
31
31
|
|
|
@@ -61,10 +61,10 @@ function getSourcePath(...segments) {
|
|
|
61
61
|
*/
|
|
62
62
|
function getModulePath(moduleName, ...segments) {
|
|
63
63
|
if (moduleName === 'core') {
|
|
64
|
-
return getSourcePath('core', ...segments);
|
|
64
|
+
return getSourcePath('core-skills', ...segments);
|
|
65
65
|
}
|
|
66
66
|
if (moduleName === 'bmm') {
|
|
67
|
-
return getSourcePath('bmm', ...segments);
|
|
67
|
+
return getSourcePath('bmm-skills', ...segments);
|
|
68
68
|
}
|
|
69
69
|
return getSourcePath('modules', moduleName, ...segments);
|
|
70
70
|
}
|
|
@@ -495,7 +495,7 @@ class YamlXmlBuilder {
|
|
|
495
495
|
|
|
496
496
|
// Extract module from path (e.g., /path/to/modules/bmm/agents/pm.yaml -> bmm)
|
|
497
497
|
// or /path/to/bmad/bmm/agents/pm.yaml -> bmm
|
|
498
|
-
// or /path/to/src/bmm/agents/pm.yaml -> bmm
|
|
498
|
+
// or /path/to/src/bmm-skills/agents/pm.yaml -> bmm
|
|
499
499
|
let module = 'core'; // default to core
|
|
500
500
|
const pathParts = agentYamlPath.split(path.sep);
|
|
501
501
|
|
|
@@ -515,10 +515,12 @@ class YamlXmlBuilder {
|
|
|
515
515
|
module = potentialModule;
|
|
516
516
|
}
|
|
517
517
|
} else if (srcIndex !== -1 && pathParts[srcIndex + 1]) {
|
|
518
|
-
// Path contains /src/{module}/ (bmm and core are directly under src/)
|
|
518
|
+
// Path contains /src/{module}/ (bmm-skills and core-skills are directly under src/)
|
|
519
519
|
const potentialModule = pathParts[srcIndex + 1];
|
|
520
|
-
if (potentialModule === 'bmm'
|
|
521
|
-
module =
|
|
520
|
+
if (potentialModule === 'bmm-skills') {
|
|
521
|
+
module = 'bmm';
|
|
522
|
+
} else if (potentialModule === 'core-skills') {
|
|
523
|
+
module = 'core';
|
|
522
524
|
}
|
|
523
525
|
}
|
|
524
526
|
|
package/tools/skill-validator.md
CHANGED
|
@@ -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.
|
|
9
|
-
3.
|
|
10
|
-
4.
|
|
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
|
|
59
|
-
- **Detection:** Regex test: `^[a-z0-9]
|
|
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 —
|
|
101
|
+
### WF-01 — Only SKILL.md May Have `name` in Frontmatter
|
|
81
102
|
|
|
82
103
|
- **Severity:** HIGH
|
|
83
|
-
- **Applies to:**
|
|
84
|
-
- **Rule:** The `name` field belongs only in `SKILL.md`.
|
|
85
|
-
- **Detection:** Parse frontmatter and check for `name:` key.
|
|
86
|
-
- **Fix:** Remove the `name:` line from
|
|
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 —
|
|
110
|
+
### WF-02 — Only SKILL.md May Have `description` in Frontmatter
|
|
89
111
|
|
|
90
112
|
- **Severity:** HIGH
|
|
91
|
-
- **Applies to:**
|
|
92
|
-
- **Rule:** The `description` field belongs only in `SKILL.md`.
|
|
93
|
-
- **Detection:** Parse frontmatter and check for `description:` key.
|
|
94
|
-
- **Fix:** Remove the `description:` line from
|
|
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 };
|