Compare commits

...

6 Commits

Author SHA1 Message Date
Alex Verkhovsky 84bade9a95
Merge branch 'main' into feat-deterministic-skill-validator 2026-03-18 10:13:59 -06:00
Alex Verkhovsky 4f1894908c refactor: tighten SKILL-04 regex, broaden WF-01/WF-02, remove forbidden names
- SKILL-04: require bmad- prefix, enforce single dashes via regex
  ^bmad-[a-z0-9]+(-[a-z0-9]+)*$, drop FORBIDDEN_NAME_SUBSTRINGS
- WF-01/WF-02: check all .md files (not just workflow.md) for stray
  name/description frontmatter, with tech-writer exception
- Update skill-validator.md prompt to match all rule changes

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:02:00 -06:00
Alex Verkhovsky be555aad8b
Merge pull request #2049 from bmad-code-org/fix-clickable-spec-link-convention
feat(quick-dev): clickable spec link at step-02 checkpoint and CWD-relative path convention
2026-03-18 00:13:04 -06:00
Alex Verkhovsky f0e43f02e2 feat(quick-dev): add clickable spec link at step-02 checkpoint and CWD-relative path convention
Step-02 now displays the finalized spec file path as a CWD-relative
clickable link after approval. Step-05 clarifies the dual convention:
project-root-relative paths (leading /) for spec-file content, CWD-relative
paths (no leading /) for terminal/conversation output. One-shot review
order explicitly uses CWD-relative path:line format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 00:09:25 -06:00
4 changed files with 109 additions and 70 deletions

View File

@ -26,7 +26,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md'
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`
- **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.
- **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.
- **E**: Apply changes, then return to CHECKPOINT 1.

View File

@ -22,7 +22,7 @@ Build the trail as an ordered sequence of **stops** — clickable `path:line` re
2. **Lead with the entry point** — the single highest-leverage file:line a reviewer should look at first to grasp the design intent.
3. **Inside each concern**, order stops from most important / architecturally interesting to supporting. Lightly bias toward higher-risk or boundary-crossing stops.
4. **End with peripherals** — tests, config, types, and other supporting changes come last.
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.
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.
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.
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
3. Open the spec in the user's editor so they can click through the Suggested Review Order:
- 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.
- If `code` is not available (command fails), skip gracefully and tell the user the spec file path instead.
4. Display summary of your work to the user, including the commit hash if one was created. Include:
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:
- 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.
- **Navigation tip:** "Ctrl+click (Cmd+click on macOS) the links in the Suggested Review Order to jump to each stop."
- Offer to push and/or create a pull request.

View File

@ -10,7 +10,7 @@ Before running inference-based validation, run the deterministic validator:
node tools/validate-skills.js --json path/to/skill-dir
```
This checks 13 rules deterministically: SKILL-01, SKILL-02, SKILL-03, SKILL-04, SKILL-05, SKILL-06, WF-01, WF-02, PATH-02, STEP-01, STEP-06, STEP-07, SEQ-02.
This checks 14 rules deterministically: SKILL-01, SKILL-02, SKILL-03, SKILL-04, SKILL-05, SKILL-06, SKILL-07, WF-01, WF-02, PATH-02, STEP-01, STEP-06, STEP-07, SEQ-02.
Review its JSON output. For any rule that produced **zero findings** in the first pass, **skip it** during inference-based validation below — it has already been verified. If a rule produced any findings, the inference validator should still review that rule (some rules like SKILL-04 and SKILL-06 have sub-checks that benefit from judgment). Focus your inference effort on the remaining rules that require judgment (PATH-01, PATH-03, PATH-04, PATH-05, WF-03, STEP-02, STEP-03, STEP-04, STEP-05, SEQ-01, REF-01, REF-02, REF-03).
@ -68,9 +68,9 @@ If no findings are generated (from either pass), the skill passes validation.
- **Severity:** HIGH
- **Applies to:** `SKILL.md`
- **Rule:** The `name` value must use only lowercase letters, numbers, and hyphens. Max 64 characters. Must not contain "anthropic" or "claude".
- **Detection:** Regex test: `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`. String search for forbidden substrings.
- **Fix:** Rename to comply with the format.
- **Rule:** The `name` value must start with `bmad-`, use only lowercase letters, numbers, and single hyphens between segments.
- **Detection:** Regex test: `^bmad-[a-z0-9]+(-[a-z0-9]+)*$`.
- **Fix:** Rename to comply with the format (e.g., `bmad-my-skill`).
### SKILL-05 — `name` Must Match Directory Name
@ -88,23 +88,33 @@ If no findings are generated (from either pass), the skill passes validation.
- **Detection:** Check length. Look for trigger phrases like "Use when" or "Use if" — their absence suggests the description only says _what_ but not _when_.
- **Fix:** Append a "Use when..." clause to the description.
### SKILL-07 — SKILL.md Must Have Body Content
- **Severity:** HIGH
- **Applies to:** `SKILL.md`
- **Rule:** SKILL.md must have non-empty markdown body content after the frontmatter. The body provides L2 instructions — a SKILL.md with only frontmatter is incomplete.
- **Detection:** Extract content after the closing `---` frontmatter delimiter and check it is non-empty after trimming whitespace.
- **Fix:** Add markdown body with skill instructions after the closing `---`.
---
### WF-01 — workflow.md Must NOT Have `name` in Frontmatter
### WF-01 — Only SKILL.md May Have `name` in Frontmatter
- **Severity:** HIGH
- **Applies to:** `workflow.md` (if it exists)
- **Rule:** The `name` field belongs only in `SKILL.md`. If `workflow.md` has YAML frontmatter, it must not contain `name:`.
- **Detection:** Parse frontmatter and check for `name:` key.
- **Fix:** Remove the `name:` line from workflow.md frontmatter.
- **Applies to:** all `.md` files except `SKILL.md`
- **Rule:** The `name` field belongs only in `SKILL.md`. No other markdown file in the skill directory may have `name:` in its frontmatter.
- **Detection:** Parse frontmatter of every non-SKILL.md markdown file and check for `name:` key.
- **Fix:** Remove the `name:` line from the file's frontmatter.
- **Exception:** `bmad-agent-tech-writer` — has sub-skill files with intentional `name` fields (to be revisited).
### WF-02 — workflow.md Must NOT Have `description` in Frontmatter
### WF-02 — Only SKILL.md May Have `description` in Frontmatter
- **Severity:** HIGH
- **Applies to:** `workflow.md` (if it exists)
- **Rule:** The `description` field belongs only in `SKILL.md`. If `workflow.md` has YAML frontmatter, it must not contain `description:`.
- **Detection:** Parse frontmatter and check for `description:` key.
- **Fix:** Remove the `description:` line from workflow.md frontmatter.
- **Applies to:** all `.md` files except `SKILL.md`
- **Rule:** The `description` field belongs only in `SKILL.md`. No other markdown file in the skill directory may have `description:` in its frontmatter.
- **Detection:** Parse frontmatter of every non-SKILL.md markdown file and check for `description:` key.
- **Fix:** Remove the `description:` line from the file's frontmatter.
- **Exception:** `bmad-agent-tech-writer` — has sub-skill files with intentional `description` fields (to be revisited).
### WF-03 — workflow.md Frontmatter Variables Must Be Config or Runtime Only

View File

@ -1,7 +1,7 @@
/**
* Deterministic Skill Validator
*
* Validates 13 deterministic rules across all skill directories.
* Validates 14 deterministic rules across all skill directories.
* Acts as a fast first-pass complement to the inference-based skill validator.
*
* What it checks:
@ -11,6 +11,7 @@
* - SKILL-04: name format (lowercase, hyphens, no forbidden substrings)
* - SKILL-05: name matches directory basename
* - SKILL-06: description quality (length, "Use when"/"Use if")
* - SKILL-07: SKILL.md has body content after frontmatter
* - WF-01: workflow.md frontmatter has no name
* - WF-02: workflow.md frontmatter has no description
* - PATH-02: no installed_path variable
@ -41,14 +42,8 @@ const positionalArgs = args.filter((a) => !a.startsWith('--'));
// --- Constants ---
const SKILL_LOCATIONS = [path.join(SRC_DIR, 'core', 'skills'), path.join(SRC_DIR, 'core', 'tasks'), path.join(SRC_DIR, 'bmm', 'workflows')];
// Agent skills live separately
const AGENT_LOCATION = path.join(SRC_DIR, 'bmm', 'agents');
const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$/;
const NAME_REGEX = /^bmad-[a-z0-9]+(-[a-z0-9]+)*$/;
const STEP_FILENAME_REGEX = /^step-\d{2}[a-z]?-[a-z0-9-]+\.md$/;
const FORBIDDEN_NAME_SUBSTRINGS = ['anthropic', 'claude'];
const TIME_ESTIMATE_PATTERNS = [/takes?\s+\d+\s*min/i, /~\s*\d+\s*min/i, /estimated\s+time/i, /\bETA\b/];
const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
@ -73,8 +68,15 @@ function parseFrontmatter(content) {
const trimmed = content.trimStart();
if (!trimmed.startsWith('---')) return null;
const endIndex = trimmed.indexOf('\n---', 3);
if (endIndex === -1) return null;
let endIndex = trimmed.indexOf('\n---\n', 3);
if (endIndex === -1) {
// Handle file ending with \n---
if (trimmed.endsWith('\n---')) {
endIndex = trimmed.length - 4;
} else {
return null;
}
}
const fmBlock = trimmed.slice(3, endIndex).trim();
if (fmBlock === '') return {};
@ -105,8 +107,15 @@ function parseFrontmatterMultiline(content) {
const trimmed = content.trimStart();
if (!trimmed.startsWith('---')) return null;
const endIndex = trimmed.indexOf('\n---', 3);
if (endIndex === -1) return null;
let endIndex = trimmed.indexOf('\n---\n', 3);
if (endIndex === -1) {
// Handle file ending with \n---
if (trimmed.endsWith('\n---')) {
endIndex = trimmed.length - 4;
} else {
return null;
}
}
const fmBlock = trimmed.slice(3, endIndex).trim();
if (fmBlock === '') return {};
@ -250,7 +259,7 @@ function validateSkill(skillDir) {
detail: 'SKILL.md not found in skill directory.',
fix: 'Create SKILL.md as the skill entrypoint.',
});
// Cannot check SKILL-02 through SKILL-06 without SKILL.md
// Cannot check SKILL-02 through SKILL-07 without SKILL.md
return findings;
}
@ -304,30 +313,15 @@ function validateSkill(skillDir) {
const description = skillFm && skillFm.description;
// --- SKILL-04: name format ---
if (name) {
if (!NAME_REGEX.test(name)) {
findings.push({
rule: 'SKILL-04',
title: 'name Format',
severity: 'HIGH',
file: 'SKILL.md',
detail: `name "${name}" does not match pattern: ${NAME_REGEX}`,
fix: 'Rename to comply with lowercase letters, numbers, and hyphens only (max 64 chars).',
});
}
for (const forbidden of FORBIDDEN_NAME_SUBSTRINGS) {
if (name.toLowerCase().includes(forbidden)) {
findings.push({
rule: 'SKILL-04',
title: 'name Format',
severity: 'HIGH',
file: 'SKILL.md',
detail: `name "${name}" contains forbidden substring "${forbidden}".`,
fix: `Remove "${forbidden}" from the name.`,
});
}
}
if (name && !NAME_REGEX.test(name)) {
findings.push({
rule: 'SKILL-04',
title: 'name Format',
severity: 'HIGH',
file: 'SKILL.md',
detail: `name "${name}" does not match pattern: ${NAME_REGEX}`,
fix: 'Rename to comply with lowercase letters, numbers, and hyphens only (max 64 chars).',
});
}
// --- SKILL-05: name matches directory ---
@ -367,30 +361,66 @@ function validateSkill(skillDir) {
}
}
// --- WF-01 / WF-02: workflow.md must NOT have name/description ---
if (fs.existsSync(workflowMdPath)) {
const wfContent = safeReadFile(workflowMdPath, findings, 'workflow.md');
const wfFm = wfContent ? parseFrontmatter(wfContent) : null;
// --- SKILL-07: SKILL.md must have body content after frontmatter ---
{
const trimmed = skillContent.trimStart();
let bodyStart = -1;
if (trimmed.startsWith('---')) {
let endIdx = trimmed.indexOf('\n---\n', 3);
if (endIdx !== -1) {
bodyStart = endIdx + 4;
} else if (trimmed.endsWith('\n---')) {
bodyStart = trimmed.length; // no body at all
}
} else {
bodyStart = 0; // no frontmatter, entire file is body
}
const body = bodyStart >= 0 ? trimmed.slice(bodyStart).trim() : '';
if (body === '') {
findings.push({
rule: 'SKILL-07',
title: 'SKILL.md Must Have Body Content',
severity: 'HIGH',
file: 'SKILL.md',
detail: 'SKILL.md has no content after frontmatter. L2 instructions are required.',
fix: 'Add markdown body with skill instructions after the closing ---.',
});
}
}
if (wfFm && 'name' in wfFm) {
// --- WF-01 / WF-02: non-SKILL.md files must NOT have name/description ---
// TODO: bmad-agent-tech-writer has sub-skill files with intentional name/description
const WF_SKIP_SKILLS = new Set(['bmad-agent-tech-writer']);
for (const filePath of allFiles) {
if (path.extname(filePath) !== '.md') continue;
if (path.basename(filePath) === 'SKILL.md') continue;
if (WF_SKIP_SKILLS.has(dirName)) continue;
const relFile = path.relative(skillDir, filePath);
const content = safeReadFile(filePath, findings, relFile);
if (content === null) continue;
const fm = parseFrontmatter(content);
if (!fm) continue;
if ('name' in fm) {
findings.push({
rule: 'WF-01',
title: 'workflow.md Must NOT Have name in Frontmatter',
title: 'Only SKILL.md May Have name in Frontmatter',
severity: 'HIGH',
file: 'workflow.md',
detail: 'workflow.md frontmatter contains `name` — this belongs only in SKILL.md.',
fix: 'Remove the `name:` line from workflow.md frontmatter.',
file: relFile,
detail: `${relFile} frontmatter contains \`name\` — this belongs only in SKILL.md.`,
fix: "Remove the `name:` line from this file's frontmatter.",
});
}
if (wfFm && 'description' in wfFm) {
if ('description' in fm) {
findings.push({
rule: 'WF-02',
title: 'workflow.md Must NOT Have description in Frontmatter',
title: 'Only SKILL.md May Have description in Frontmatter',
severity: 'HIGH',
file: 'workflow.md',
detail: 'workflow.md frontmatter contains `description` — this belongs only in SKILL.md.',
fix: 'Remove the `description:` line from workflow.md frontmatter.',
file: relFile,
detail: `${relFile} frontmatter contains \`description\` — this belongs only in SKILL.md.`,
fix: "Remove the `description:` line from this file's frontmatter.",
});
}
}
@ -675,8 +705,7 @@ if (require.main === module) {
skillDirs = [target];
} else {
// Discover all skills
const allLocations = [...SKILL_LOCATIONS, AGENT_LOCATION];
skillDirs = discoverSkillDirs(allLocations);
skillDirs = discoverSkillDirs([SRC_DIR]);
}
if (skillDirs.length === 0) {