bmad-method 6.11.1-next.9 → 6.12.0

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.
Files changed (153) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/AGENTS.md +12 -0
  3. package/CLAUDE.md +1 -0
  4. package/README.md +13 -19
  5. package/README_CN.md +0 -10
  6. package/README_KR.md +90 -0
  7. package/README_VN.md +0 -10
  8. package/greptile.json +52 -0
  9. package/package.json +6 -5
  10. package/src/bmm-skills/agents/bmad-agent-analyst/SKILL.md +1 -1
  11. package/src/bmm-skills/agents/bmad-agent-analyst/customize.toml +1 -3
  12. package/src/bmm-skills/agents/bmad-agent-architect/SKILL.md +1 -1
  13. package/src/bmm-skills/agents/bmad-agent-architect/customize.toml +1 -3
  14. package/src/bmm-skills/agents/bmad-agent-dev/SKILL.md +1 -1
  15. package/src/bmm-skills/agents/bmad-agent-dev/customize.toml +1 -3
  16. package/src/bmm-skills/agents/bmad-agent-pm/SKILL.md +1 -1
  17. package/src/bmm-skills/agents/bmad-agent-pm/customize.toml +1 -3
  18. package/src/bmm-skills/agents/bmad-agent-ux-designer/SKILL.md +1 -1
  19. package/src/bmm-skills/agents/bmad-agent-ux-designer/customize.toml +1 -3
  20. package/src/bmm-skills/module-help.csv +2 -2
  21. package/src/bmm-skills/plan/bmad-architecture/SKILL.md +1 -1
  22. package/src/bmm-skills/plan/bmad-architecture/customize.toml +5 -6
  23. package/src/bmm-skills/plan/bmad-create-epics-and-stories/SKILL.md +1 -1
  24. package/src/bmm-skills/plan/bmad-create-epics-and-stories/customize.toml +1 -3
  25. package/src/bmm-skills/plan/bmad-create-epics-and-stories/steps/step-04-final-validation.md +1 -1
  26. package/src/bmm-skills/plan/bmad-prd/SKILL.md +1 -1
  27. package/src/bmm-skills/plan/bmad-prd/customize.toml +5 -6
  28. package/src/bmm-skills/plan/bmad-prfaq/SKILL.md +1 -1
  29. package/src/bmm-skills/plan/bmad-prfaq/customize.toml +1 -3
  30. package/src/bmm-skills/plan/bmad-prfaq/references/verdict.md +1 -1
  31. package/src/bmm-skills/plan/bmad-product-brief/SKILL.md +1 -1
  32. package/src/bmm-skills/plan/bmad-product-brief/customize.toml +5 -6
  33. package/src/bmm-skills/plan/bmad-project-context/SKILL.md +7 -6
  34. package/src/bmm-skills/plan/bmad-project-context/references/best-practices.md +2 -2
  35. package/src/bmm-skills/plan/bmad-spec/SKILL.md +1 -1
  36. package/src/bmm-skills/plan/bmad-spec/customize.toml +5 -5
  37. package/src/bmm-skills/plan/bmad-sprint-planning/SKILL.md +1 -1
  38. package/src/bmm-skills/plan/bmad-sprint-planning/customize.toml +1 -3
  39. package/src/bmm-skills/plan/bmad-sprint-planning/scripts/__pycache__/sprint_plan.cpython-311.pyc +0 -0
  40. package/src/bmm-skills/plan/bmad-sprint-planning/scripts/tests/__pycache__/test_sprint_plan.cpython-311-pytest-9.1.1.pyc +0 -0
  41. package/src/bmm-skills/plan/bmad-ux/SKILL.md +1 -1
  42. package/src/bmm-skills/plan/bmad-ux/customize.toml +1 -3
  43. package/src/bmm-skills/ship/bmad-build/SKILL.md +1 -1
  44. package/src/bmm-skills/ship/bmad-build/customize.toml +21 -33
  45. package/src/bmm-skills/ship/bmad-build/references/claims-check.md +14 -0
  46. package/src/bmm-skills/ship/bmad-build/review-prompts/edge-case-hunter.md +31 -7
  47. package/src/bmm-skills/ship/bmad-build/review-prompts/verification-gap.md +4 -4
  48. package/src/bmm-skills/ship/bmad-build/spec-template.md +26 -4
  49. package/src/bmm-skills/ship/bmad-build/step-01-clarify-and-route.md +21 -26
  50. package/src/bmm-skills/ship/bmad-build/step-02-plan.md +31 -16
  51. package/src/bmm-skills/ship/bmad-build/step-03-implement.md +5 -3
  52. package/src/bmm-skills/ship/bmad-build/step-04-review.md +49 -19
  53. package/src/bmm-skills/ship/bmad-build/step-05-present.md +9 -41
  54. package/src/bmm-skills/ship/bmad-build/step-oneshot.md +60 -29
  55. package/src/bmm-skills/ship/bmad-build/sync-sprint-status.md +5 -19
  56. package/src/bmm-skills/ship/bmad-build-auto/customize.toml +17 -19
  57. package/src/bmm-skills/ship/bmad-build-auto/references/claims-check.md +14 -0
  58. package/src/bmm-skills/ship/bmad-build-auto/review-prompts/edge-case-hunter.md +31 -7
  59. package/src/bmm-skills/ship/bmad-build-auto/review-prompts/verification-gap.md +4 -4
  60. package/src/bmm-skills/ship/bmad-build-auto/spec-template.md +4 -7
  61. package/src/bmm-skills/ship/bmad-build-auto/step-01-clarify-and-route.md +3 -2
  62. package/src/bmm-skills/ship/bmad-build-auto/step-03-implement.md +5 -1
  63. package/src/bmm-skills/ship/bmad-build-auto/step-04-review.md +58 -38
  64. package/src/bmm-skills/ship/bmad-build-auto/workflow.md +1 -1
  65. package/src/bmm-skills/ship/bmad-code-review/SKILL.md +1 -1
  66. package/src/bmm-skills/ship/bmad-code-review/customize.toml +15 -18
  67. package/src/bmm-skills/ship/bmad-code-review/references/claims-check.md +14 -0
  68. package/src/bmm-skills/ship/bmad-code-review/review-prompts/edge-case-hunter.md +31 -7
  69. package/src/bmm-skills/ship/bmad-code-review/review-prompts/verification-gap.md +4 -4
  70. package/src/bmm-skills/ship/bmad-code-review/steps/step-01-gather-context.md +30 -23
  71. package/src/bmm-skills/ship/bmad-code-review/steps/step-02-review.md +2 -2
  72. package/src/bmm-skills/ship/bmad-code-review/steps/step-03-triage.md +25 -21
  73. package/src/bmm-skills/ship/bmad-code-review/steps/step-04-present.md +20 -18
  74. package/src/bmm-skills/ship/bmad-correct-course/SKILL.md +13 -5
  75. package/src/bmm-skills/ship/bmad-correct-course/customize.toml +1 -3
  76. package/src/bmm-skills/ship/bmad-qa-generate-e2e-tests/SKILL.md +2 -2
  77. package/src/bmm-skills/ship/bmad-qa-generate-e2e-tests/customize.toml +1 -3
  78. package/src/bmm-skills/ship/bmad-retrospective/SKILL.md +1 -1
  79. package/src/bmm-skills/ship/bmad-retrospective/customize.toml +1 -3
  80. package/src/bmm-skills/ship/bmad-retrospective/scripts/__pycache__/sprint_status.cpython-311.pyc +0 -0
  81. package/src/bmm-skills/ship/bmad-retrospective/scripts/tests/__pycache__/test_git_evidence.cpython-311-pytest-9.1.1.pyc +0 -0
  82. package/src/bmm-skills/ship/bmad-retrospective/scripts/tests/__pycache__/test_sprint_status.cpython-311-pytest-9.1.1.pyc +0 -0
  83. package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/SKILL.md +4 -4
  84. package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/customize.toml +2 -4
  85. package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-05-wrapup.md +1 -1
  86. package/src/bmm-skills/v6-shims/README.md +1 -0
  87. package/src/bmm-skills/v6-shims/bmad-checkpoint-preview/SKILL.md +21 -0
  88. package/src/bmm-skills/v6-shims/bmad-create-architecture/SKILL.md +1 -1
  89. package/src/bmm-skills/v6-shims/bmad-create-architecture/customize.toml +1 -3
  90. package/src/bmm-skills/v6-shims/bmad-create-prd/SKILL.md +1 -1
  91. package/src/bmm-skills/v6-shims/bmad-create-prd/customize.toml +1 -3
  92. package/src/bmm-skills/v6-shims/bmad-create-story/SKILL.md +3 -3
  93. package/src/bmm-skills/v6-shims/bmad-create-story/customize.toml +1 -3
  94. package/src/bmm-skills/v6-shims/bmad-dev-story/SKILL.md +2 -2
  95. package/src/bmm-skills/v6-shims/bmad-dev-story/customize.toml +1 -6
  96. package/src/bmm-skills/v6-shims/bmad-domain-research/SKILL.md +1 -1
  97. package/src/bmm-skills/v6-shims/bmad-edit-prd/SKILL.md +1 -1
  98. package/src/bmm-skills/v6-shims/bmad-edit-prd/customize.toml +1 -3
  99. package/src/bmm-skills/v6-shims/bmad-market-research/SKILL.md +1 -1
  100. package/src/bmm-skills/v6-shims/bmad-sprint-status/SKILL.md +1 -1
  101. package/src/bmm-skills/v6-shims/bmad-sprint-status/customize.toml +1 -3
  102. package/src/bmm-skills/v6-shims/bmad-technical-research/SKILL.md +1 -1
  103. package/src/bmm-skills/v6-shims/bmad-validate-prd/SKILL.md +1 -1
  104. package/src/bmm-skills/v6-shims/bmad-validate-prd/customize.toml +1 -3
  105. package/src/core-skills/bmad-advanced-elicitation/SKILL.md +22 -23
  106. package/src/core-skills/bmad-brainstorming/SKILL.md +1 -1
  107. package/src/core-skills/bmad-brainstorming/customize.toml +5 -6
  108. package/src/core-skills/bmad-brainstorming/scripts/brain.py +19 -0
  109. package/src/core-skills/bmad-brainstorming/scripts/tests/test_brain.py +50 -0
  110. package/src/core-skills/bmad-customize/SKILL.md +1 -1
  111. package/src/core-skills/bmad-deep-recon/SKILL.md +1 -1
  112. package/src/core-skills/bmad-forge-idea/SKILL.md +1 -1
  113. package/src/core-skills/bmad-forge-idea/customize.toml +5 -6
  114. package/src/core-skills/bmad-forge-idea/scripts/resolve_personas.py +3 -1
  115. package/src/core-skills/bmad-forge-idea/scripts/tests/test_resolve_personas.py +22 -0
  116. package/src/core-skills/bmad-help/SKILL.md +1 -1
  117. package/src/core-skills/bmad-party-mode/SKILL.md +1 -1
  118. package/src/core-skills/bmad-party-mode/customize.toml +4 -4
  119. package/src/core-skills/bmad-party-mode/references/create-party.md +1 -1
  120. package/src/core-skills/bmad-party-mode/scripts/resolve_party.py +3 -1
  121. package/src/core-skills/bmad-party-mode/scripts/tests/test_resolve_party.py +22 -0
  122. package/src/core-skills/bmad-review/SKILL.md +5 -4
  123. package/src/core-skills/bmad-review/customize.toml +1 -1
  124. package/src/core-skills/bmad-review/references/lens-edge-case-hunter.md +18 -0
  125. package/src/core-skills/module-help.csv +1 -1
  126. package/src/scripts/__pycache__/config_utils.cpython-311.pyc +0 -0
  127. package/src/scripts/resolve_config.py +9 -1
  128. package/src/scripts/resolve_customization.py +72 -7
  129. package/src/scripts/tests/__pycache__/test_config_utils.cpython-311.pyc +0 -0
  130. package/src/scripts/tests/__pycache__/test_resolve_config.cpython-311.pyc +0 -0
  131. package/src/scripts/tests/__pycache__/test_resolve_customization.cpython-311.pyc +0 -0
  132. package/src/scripts/tests/test_resolve_config.py +28 -0
  133. package/src/scripts/tests/test_resolve_customization.py +102 -0
  134. package/tools/installer/core/installer.js +33 -1
  135. package/tools/installer/core/shim-policy.js +75 -7
  136. package/tools/installer/ide/platform-codes.yaml +13 -0
  137. package/tools/installer/prompts.js +15 -2
  138. package/tools/installer/ui.js +28 -8
  139. package/tools/skill-validator.md +85 -151
  140. package/tools/tests/__pycache__/test_validate_skills.cpython-311.pyc +0 -0
  141. package/tools/tests/fixtures/validate-skills/bmad/SKILL.md +8 -0
  142. package/tools/tests/fixtures/validate-skills/deprecated-shim/SKILL.md +9 -0
  143. package/tools/tests/fixtures/validate-skills/missing-trigger/SKILL.md +9 -0
  144. package/tools/tests/fixtures/validate-skills/with-trigger/SKILL.md +8 -0
  145. package/tools/tests/test_validate_skills.py +476 -0
  146. package/tools/validate-published-implementation-model.mjs +0 -9
  147. package/tools/validate_skills.py +698 -0
  148. package/tools/validate-skills.js +0 -735
  149. /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/generate-trail.md +0 -0
  150. /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-01-orientation.md +0 -0
  151. /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-02-walkthrough.md +0 -0
  152. /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-03-detail-pass.md +0 -0
  153. /package/src/bmm-skills/ship/{bmad-checkpoint-preview → bmad-walkthrough}/step-04-testing.md +0 -0
@@ -0,0 +1,698 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """Deterministic Skill Validator
6
+
7
+ Validates 10 deterministic rules across skill directories.
8
+ Acts as a fast first-pass complement to the inference-based skill validator.
9
+
10
+ What it checks:
11
+ - SKILL-01: SKILL.md exists
12
+ - SKILL-02: SKILL.md frontmatter has name
13
+ - SKILL-03: SKILL.md frontmatter has description
14
+ - SKILL-04: name format (lowercase, hyphens, no forbidden substrings)
15
+ - SKILL-05: name matches directory basename
16
+ - SKILL-06: description quality (length, "Use when"/"Use if")
17
+ - SKILL-07: SKILL.md has body content after frontmatter
18
+ - PATH-02: no installed_path variable
19
+ - SEQ-02: no time estimates
20
+ - TPL-01: template files must not contain compile-time {{.var}} substitutions
21
+
22
+ Usage:
23
+ uv run --python 3.11 tools/validate_skills.py # All skills, human-readable
24
+ uv run --python 3.11 tools/validate_skills.py path/to/skill-dir # Single skill
25
+ uv run --python 3.11 tools/validate_skills.py --strict # Exit 1 on HIGH+ findings
26
+ uv run --python 3.11 tools/validate_skills.py --json # JSON output
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+
37
+ sys.dont_write_bytecode = True
38
+
39
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
40
+ SRC_DIR = os.path.join(PROJECT_ROOT, "src")
41
+
42
+ # JS RegExp#toString() forms — Python's re.Pattern repr is not load-bearing for messages.
43
+ NAME_REGEX_DISPLAY = r"/^(?:bmad|bmad-[a-z0-9]+(?:-[a-z0-9]+)*)$/"
44
+
45
+ NAME_REGEX = re.compile(r"^(?:bmad|bmad-[a-z0-9]+(?:-[a-z0-9]+)*)$")
46
+ TIME_ESTIMATE_PATTERNS = [
47
+ re.compile(r"takes?\s+\d+\s*min", re.I),
48
+ re.compile(r"~\s*\d+\s*min", re.I),
49
+ re.compile(r"estimated\s+time", re.I),
50
+ re.compile(r"\bETA\b"),
51
+ ]
52
+ TEMPLATE_FILENAME_REGEX = re.compile(r"template", re.I)
53
+ COMPILE_TIME_SUB_REGEX = re.compile(r"\{\{\.\w+\}\}")
54
+ INSTALLED_PATH_RE = re.compile(r"installed_path", re.I)
55
+ USE_WHEN_RE = re.compile(r"use\s+when\b", re.I)
56
+ USE_IF_RE = re.compile(r"use\s+if\b", re.I)
57
+ DEPRECATED_RE = re.compile(r"^\s*deprecated\b", re.I)
58
+
59
+ SKIP_DIRS = {"node_modules", ".git"}
60
+ SCAN_EXTENSIONS = {".md", ".yaml", ".yml"}
61
+
62
+ SEVERITY_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}
63
+
64
+
65
+ # --- Output Escaping ---
66
+
67
+
68
+ def escape_annotation(s: str) -> str:
69
+ return s.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
70
+
71
+
72
+ def escape_table_cell(s: str) -> str:
73
+ return str(s).replace("|", "\\|")
74
+
75
+
76
+ def _relpath(to_path: str, start: str) -> str:
77
+ rel = os.path.relpath(to_path, start)
78
+ return "" if rel == "." else rel
79
+
80
+
81
+ def _finding(
82
+ rule: str,
83
+ title: str,
84
+ severity: str,
85
+ file: str,
86
+ detail: str,
87
+ fix: str,
88
+ line: int | None = None,
89
+ ) -> dict:
90
+ item = {
91
+ "rule": rule,
92
+ "title": title,
93
+ "severity": severity,
94
+ "file": file,
95
+ "detail": detail,
96
+ "fix": fix,
97
+ }
98
+ if line is not None:
99
+ item["line"] = line
100
+ return item
101
+
102
+
103
+ # --- Frontmatter Parsing ---
104
+
105
+
106
+ def _frontmatter_block(content: str) -> str | None:
107
+ trimmed = content.lstrip()
108
+ if not trimmed.startswith("---"):
109
+ return None
110
+
111
+ end_index = trimmed.find("\n---\n", 3)
112
+ if end_index == -1:
113
+ if trimmed.endswith("\n---"):
114
+ end_index = len(trimmed) - 4
115
+ else:
116
+ return None
117
+
118
+ return trimmed[3:end_index].strip()
119
+
120
+
121
+ def _strip_quotes(value: str) -> str:
122
+ if (value.startswith("'") and value.endswith("'")) or (
123
+ value.startswith('"') and value.endswith('"')
124
+ ):
125
+ return value[1:-1]
126
+ return value
127
+
128
+
129
+ def parse_frontmatter(content: str) -> dict[str, str] | None:
130
+ fm_block = _frontmatter_block(content)
131
+ if fm_block is None:
132
+ return None
133
+ if fm_block == "":
134
+ return {}
135
+
136
+ result: dict[str, str] = {}
137
+ for line in fm_block.split("\n"):
138
+ colon_index = line.find(":")
139
+ if colon_index == -1:
140
+ continue
141
+ # Skip indented lines (nested YAML values)
142
+ if line[:1] in (" ", "\t"):
143
+ continue
144
+ key = line[:colon_index].strip()
145
+ value = line[colon_index + 1 :].strip()
146
+ result[key] = _strip_quotes(value)
147
+ return result
148
+
149
+
150
+ def parse_frontmatter_multiline(content: str) -> dict[str, str] | None:
151
+ fm_block = _frontmatter_block(content)
152
+ if fm_block is None:
153
+ return None
154
+ if fm_block == "":
155
+ return {}
156
+
157
+ result: dict[str, str] = {}
158
+ current_key: str | None = None
159
+ current_value = ""
160
+
161
+ for line in fm_block.split("\n"):
162
+ colon_index = line.find(":")
163
+ # New key: column 0 (no leading whitespace) and colon not at index 0
164
+ if colon_index > 0 and line[:1] not in (" ", "\t"):
165
+ if current_key is not None:
166
+ result[current_key] = _strip_quotes(current_value.strip())
167
+ current_key = line[:colon_index].strip()
168
+ current_value = line[colon_index + 1 :]
169
+ elif current_key is not None:
170
+ if line.lstrip().startswith("#"):
171
+ continue
172
+ current_value += "\n" + line
173
+
174
+ if current_key is not None:
175
+ result[current_key] = _strip_quotes(current_value.strip())
176
+ return result
177
+
178
+
179
+ # --- Safe File Reading ---
180
+
181
+
182
+ def safe_read_file(file_path: str, findings: list[dict], rel_file: str | None) -> str | None:
183
+ try:
184
+ with open(file_path, encoding="utf-8", errors="replace", newline="") as f:
185
+ return f.read()
186
+ except OSError as error:
187
+ findings.append(
188
+ _finding(
189
+ "READ-ERR",
190
+ "File Read Error",
191
+ "MEDIUM",
192
+ rel_file or os.path.basename(file_path),
193
+ f"Cannot read file: {error}",
194
+ "Check file permissions and ensure the file exists.",
195
+ )
196
+ )
197
+ return None
198
+
199
+
200
+ # --- Code Block Stripping ---
201
+
202
+
203
+ def _blank(match: re.Match[str]) -> str:
204
+ return re.sub(r"[^\n]", "", match.group(0))
205
+
206
+
207
+ def strip_code_blocks(content: str) -> str:
208
+ return re.sub(r"```.*?```", _blank, content, flags=re.DOTALL)
209
+
210
+
211
+ # --- Skill Discovery ---
212
+
213
+
214
+ def discover_skill_dirs(root_dirs: list[str]) -> list[str]:
215
+ skill_dirs: list[str] = []
216
+
217
+ def walk(dir_path: str) -> None:
218
+ if not os.path.exists(dir_path):
219
+ return
220
+ with os.scandir(dir_path) as it:
221
+ entries = list(it)
222
+ for entry in entries:
223
+ if not entry.is_dir(follow_symlinks=False):
224
+ continue
225
+ if entry.name in SKIP_DIRS:
226
+ continue
227
+ full_path = entry.path
228
+ if os.path.exists(os.path.join(full_path, "SKILL.md")):
229
+ skill_dirs.append(full_path)
230
+ walk(full_path)
231
+
232
+ for root_dir in root_dirs:
233
+ walk(root_dir)
234
+ skill_dirs.sort()
235
+ return skill_dirs
236
+
237
+
238
+ # --- File Collection ---
239
+
240
+
241
+ def collect_skill_files(skill_dir: str, findings: list[dict]) -> list[str]:
242
+ files: list[str] = []
243
+
244
+ def walk(current_dir: str) -> None:
245
+ try:
246
+ with os.scandir(current_dir) as it:
247
+ entries = sorted(it, key=lambda e: e.name)
248
+ except OSError as error:
249
+ rel_file = _relpath(current_dir, skill_dir)
250
+ findings.append(
251
+ _finding(
252
+ "READ-ERR",
253
+ "File Read Error",
254
+ "MEDIUM",
255
+ rel_file or os.path.basename(current_dir),
256
+ f"Cannot read file: {error}",
257
+ "Check file permissions and ensure the file exists.",
258
+ )
259
+ )
260
+ return
261
+ for entry in entries:
262
+ if entry.name in SKIP_DIRS:
263
+ continue
264
+ if entry.is_dir(follow_symlinks=False):
265
+ walk(entry.path)
266
+ elif entry.is_file(follow_symlinks=False):
267
+ files.append(entry.path)
268
+
269
+ walk(skill_dir)
270
+ return files
271
+
272
+
273
+ # --- Rule Checks ---
274
+
275
+
276
+ def validate_skill(skill_dir: str) -> list[dict]:
277
+ findings: list[dict] = []
278
+ dir_name = os.path.basename(skill_dir)
279
+ skill_md_path = os.path.join(skill_dir, "SKILL.md")
280
+
281
+ all_files = collect_skill_files(skill_dir, findings)
282
+
283
+ if not os.path.exists(skill_md_path):
284
+ findings.append(
285
+ _finding(
286
+ "SKILL-01",
287
+ "SKILL.md Must Exist",
288
+ "CRITICAL",
289
+ "SKILL.md",
290
+ "SKILL.md not found in skill directory.",
291
+ "Create SKILL.md as the skill entrypoint.",
292
+ )
293
+ )
294
+ return findings
295
+
296
+ skill_content = safe_read_file(skill_md_path, findings, "SKILL.md")
297
+ if skill_content is None:
298
+ return findings
299
+ skill_fm = parse_frontmatter_multiline(skill_content)
300
+
301
+ if not skill_fm or "name" not in skill_fm:
302
+ findings.append(
303
+ _finding(
304
+ "SKILL-02",
305
+ "SKILL.md Must Have name in Frontmatter",
306
+ "CRITICAL",
307
+ "SKILL.md",
308
+ "Frontmatter is missing the `name` field.",
309
+ "Add `name: <skill-name>` to the frontmatter.",
310
+ )
311
+ )
312
+ elif skill_fm["name"] == "":
313
+ findings.append(
314
+ _finding(
315
+ "SKILL-02",
316
+ "SKILL.md Must Have name in Frontmatter",
317
+ "CRITICAL",
318
+ "SKILL.md",
319
+ "Frontmatter `name` field is empty.",
320
+ "Set `name` to the skill directory name (kebab-case).",
321
+ )
322
+ )
323
+
324
+ if not skill_fm or "description" not in skill_fm:
325
+ findings.append(
326
+ _finding(
327
+ "SKILL-03",
328
+ "SKILL.md Must Have description in Frontmatter",
329
+ "CRITICAL",
330
+ "SKILL.md",
331
+ "Frontmatter is missing the `description` field.",
332
+ "Add `description: <what it does and when to use it>` to the frontmatter.",
333
+ )
334
+ )
335
+ elif skill_fm["description"] == "":
336
+ findings.append(
337
+ _finding(
338
+ "SKILL-03",
339
+ "SKILL.md Must Have description in Frontmatter",
340
+ "CRITICAL",
341
+ "SKILL.md",
342
+ "Frontmatter `description` field is empty.",
343
+ "Add a description stating what the skill does and when to use it.",
344
+ )
345
+ )
346
+
347
+ name = skill_fm.get("name") if skill_fm else None
348
+ description = skill_fm.get("description") if skill_fm else None
349
+
350
+ is_deprecated = isinstance(description, str) and bool(DEPRECATED_RE.search(description))
351
+
352
+ if name and not NAME_REGEX.search(name):
353
+ findings.append(
354
+ _finding(
355
+ "SKILL-04",
356
+ "name Format",
357
+ "HIGH",
358
+ "SKILL.md",
359
+ f'name "{name}" does not match pattern: {NAME_REGEX_DISPLAY}',
360
+ "Rename to comply with lowercase letters, numbers, and hyphens only (max 64 chars).",
361
+ )
362
+ )
363
+
364
+ if name and name != dir_name:
365
+ findings.append(
366
+ _finding(
367
+ "SKILL-05",
368
+ "name Must Match Directory Name",
369
+ "HIGH",
370
+ "SKILL.md",
371
+ f'name "{name}" does not match directory name "{dir_name}".',
372
+ f'Change name to "{dir_name}" or rename the directory.',
373
+ )
374
+ )
375
+
376
+ if description:
377
+ if len(description) > 1024:
378
+ findings.append(
379
+ _finding(
380
+ "SKILL-06",
381
+ "description Quality",
382
+ "MEDIUM",
383
+ "SKILL.md",
384
+ f"description is {len(description)} characters (max 1024).",
385
+ "Shorten the description to 1024 characters or less.",
386
+ )
387
+ )
388
+
389
+ if not is_deprecated and not USE_WHEN_RE.search(description) and not USE_IF_RE.search(description):
390
+ findings.append(
391
+ _finding(
392
+ "SKILL-06",
393
+ "description Quality",
394
+ "MEDIUM",
395
+ "SKILL.md",
396
+ 'description does not contain "Use when" or "Use if" trigger phrase.',
397
+ 'Append a "Use when..." clause to explain when to invoke this skill.',
398
+ )
399
+ )
400
+
401
+ trimmed = skill_content.lstrip()
402
+ body_start = -1
403
+ if trimmed.startswith("---"):
404
+ end_idx = trimmed.find("\n---\n", 3)
405
+ if end_idx != -1:
406
+ body_start = end_idx + 4
407
+ elif trimmed.endswith("\n---"):
408
+ body_start = len(trimmed)
409
+ else:
410
+ body_start = 0
411
+ body = trimmed[body_start:].strip() if body_start >= 0 else ""
412
+ if body == "":
413
+ findings.append(
414
+ _finding(
415
+ "SKILL-07",
416
+ "SKILL.md Must Have Body Content",
417
+ "HIGH",
418
+ "SKILL.md",
419
+ "SKILL.md has no content after frontmatter. L2 instructions are required.",
420
+ "Add markdown body with skill instructions after the closing ---.",
421
+ )
422
+ )
423
+
424
+ for file_path in all_files:
425
+ ext = os.path.splitext(file_path)[1]
426
+ if ext not in SCAN_EXTENSIONS:
427
+ continue
428
+
429
+ rel_file = _relpath(file_path, skill_dir)
430
+ content = safe_read_file(file_path, findings, rel_file)
431
+ if content is None:
432
+ continue
433
+
434
+ fm = parse_frontmatter(content)
435
+ if fm and "installed_path" in fm:
436
+ findings.append(
437
+ _finding(
438
+ "PATH-02",
439
+ "No installed_path Variable",
440
+ "HIGH",
441
+ rel_file,
442
+ "Frontmatter contains `installed_path:` key.",
443
+ "Remove `installed_path` from frontmatter. Use relative paths instead.",
444
+ )
445
+ )
446
+
447
+ stripped = strip_code_blocks(content)
448
+ for i, line in enumerate(stripped.split("\n")):
449
+ if INSTALLED_PATH_RE.search(line):
450
+ findings.append(
451
+ _finding(
452
+ "PATH-02",
453
+ "No installed_path Variable",
454
+ "HIGH",
455
+ rel_file,
456
+ "`installed_path` reference found in content.",
457
+ "Remove all installed_path usage. Use relative paths (`./path` or `../path`) instead.",
458
+ line=i + 1,
459
+ )
460
+ )
461
+
462
+ for file_path in all_files:
463
+ ext = os.path.splitext(file_path)[1]
464
+ if ext not in SCAN_EXTENSIONS:
465
+ continue
466
+
467
+ rel_file = _relpath(file_path, skill_dir)
468
+ content = safe_read_file(file_path, findings, rel_file)
469
+ if content is None:
470
+ continue
471
+ stripped = strip_code_blocks(content)
472
+
473
+ for i, line in enumerate(stripped.split("\n")):
474
+ for pattern in TIME_ESTIMATE_PATTERNS:
475
+ if pattern.search(line):
476
+ findings.append(
477
+ _finding(
478
+ "SEQ-02",
479
+ "No Time Estimates",
480
+ "LOW",
481
+ rel_file,
482
+ f'Time estimate pattern found: "{line.strip()}"',
483
+ "Remove time estimates — AI execution speed varies too much.",
484
+ line=i + 1,
485
+ )
486
+ )
487
+ break
488
+
489
+ for file_path in all_files:
490
+ if os.path.splitext(file_path)[1] != ".md":
491
+ continue
492
+ base = os.path.basename(file_path)
493
+ if not TEMPLATE_FILENAME_REGEX.search(base):
494
+ continue
495
+
496
+ rel_file = _relpath(file_path, skill_dir)
497
+ content = safe_read_file(file_path, findings, rel_file)
498
+ if content is None:
499
+ continue
500
+
501
+ for i, line in enumerate(content.split("\n")):
502
+ match = COMPILE_TIME_SUB_REGEX.search(line)
503
+ if match:
504
+ findings.append(
505
+ _finding(
506
+ "TPL-01",
507
+ "Template files must not contain compile-time substitutions",
508
+ "HIGH",
509
+ rel_file,
510
+ f"Template file contains compile-time substitution `{match.group(0)}` — this would be baked at render time and leak a machine-local value into every spec produced from the template.",
511
+ "Remove the `{{.var}}` reference. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated spec.",
512
+ line=i + 1,
513
+ )
514
+ )
515
+
516
+ return findings
517
+
518
+
519
+ # --- Output Formatting ---
520
+
521
+
522
+ def format_human_readable(
523
+ results: list[dict],
524
+ project_root: str,
525
+ src_dir: str,
526
+ strict: bool,
527
+ json_output: bool,
528
+ github_actions: bool,
529
+ ) -> tuple[str, bool]:
530
+ output: list[str] = []
531
+ total_findings = 0
532
+ severity_counts = {"CRITICAL": 0, "HIGH": 0, "MEDIUM": 0, "LOW": 0}
533
+
534
+ mode = "STRICT (exit 1 on HIGH+)" if strict else "WARNING (exit 0)"
535
+ if json_output:
536
+ mode += " + JSON"
537
+ output.extend(
538
+ [
539
+ f"\nValidating skills in: {src_dir}",
540
+ f"Mode: {mode}\n",
541
+ ]
542
+ )
543
+
544
+ total_skills = 0
545
+ skills_with_findings = 0
546
+
547
+ for result in results:
548
+ skill_dir = result["skillDir"]
549
+ findings = result["findings"]
550
+ total_skills += 1
551
+ rel_dir = _relpath(skill_dir, project_root)
552
+
553
+ if findings:
554
+ skills_with_findings += 1
555
+ output.append(f"\n{rel_dir}")
556
+
557
+ for f in findings:
558
+ total_findings += 1
559
+ severity_counts[f["severity"]] += 1
560
+ location = f" (line {f['line']})" if f.get("line") else ""
561
+ output.extend(
562
+ [
563
+ f" [{f['severity']}] {f['rule']} — {f['title']}",
564
+ f" File: {f['file']}{location}",
565
+ f" {f['detail']}",
566
+ ]
567
+ )
568
+
569
+ if github_actions:
570
+ abs_file = os.path.join(skill_dir, f["file"])
571
+ gh_file = _relpath(abs_file, project_root)
572
+ line = f.get("line") or 1
573
+ level = "notice" if f["severity"] == "LOW" else "warning"
574
+ message = escape_annotation(f"{f['rule']}: {f['detail']}")
575
+ print(f"::{level} file={gh_file},line={line}::{message}")
576
+
577
+ output.extend(
578
+ [
579
+ f"\n{'─' * 60}",
580
+ "\nSummary:",
581
+ f" Skills scanned: {total_skills}",
582
+ f" Skills with findings: {skills_with_findings}",
583
+ f" Total findings: {total_findings}",
584
+ ]
585
+ )
586
+
587
+ if total_findings > 0:
588
+ output.extend(["", " | Severity | Count |", " |----------|-------|"])
589
+ for sev in ("CRITICAL", "HIGH", "MEDIUM", "LOW"):
590
+ if severity_counts[sev] > 0:
591
+ output.append(f" | {sev:<8} | {severity_counts[sev]:>5} |")
592
+
593
+ has_high_plus = severity_counts["CRITICAL"] > 0 or severity_counts["HIGH"] > 0
594
+
595
+ if total_findings == 0:
596
+ output.append("\n All skills passed validation!")
597
+ elif strict and has_high_plus:
598
+ output.append("\n [STRICT MODE] HIGH+ findings found — exiting with failure.")
599
+ elif strict:
600
+ output.append("\n [STRICT MODE] Only MEDIUM/LOW findings — pass.")
601
+ else:
602
+ output.append("\n Run with --strict to treat HIGH+ findings as errors.")
603
+
604
+ output.append("")
605
+
606
+ step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
607
+ if step_summary:
608
+ summary = "## Skill Validation\n\n"
609
+ if total_findings > 0:
610
+ summary += "| Skill | Rule | Severity | File | Detail |\n"
611
+ summary += "|-------|------|----------|------|--------|\n"
612
+ for result in results:
613
+ rel_dir = _relpath(result["skillDir"], project_root)
614
+ for f in result["findings"]:
615
+ summary += (
616
+ f"| {escape_table_cell(rel_dir)} | {f['rule']} | {f['severity']} | "
617
+ f"{escape_table_cell(f['file'])} | {escape_table_cell(f['detail'])} |\n"
618
+ )
619
+ summary += "\n"
620
+ summary += f"**{total_skills} skills scanned, {total_findings} findings**\n"
621
+ with open(step_summary, "a", encoding="utf-8") as fh:
622
+ fh.write(summary)
623
+
624
+ return "\n".join(output), has_high_plus
625
+
626
+
627
+ def format_json(results: list[dict], project_root: str) -> tuple[str, bool]:
628
+ all_findings: list[dict] = []
629
+ for result in results:
630
+ rel_dir = _relpath(result["skillDir"], project_root)
631
+ for f in result["findings"]:
632
+ all_findings.append(
633
+ {
634
+ "skill": rel_dir,
635
+ "rule": f["rule"],
636
+ "title": f["title"],
637
+ "severity": f["severity"],
638
+ "file": f["file"],
639
+ "line": f.get("line") or None,
640
+ "detail": f["detail"],
641
+ "fix": f["fix"],
642
+ }
643
+ )
644
+
645
+ all_findings.sort(key=lambda item: SEVERITY_ORDER[item["severity"]])
646
+ has_high_plus = any(f["severity"] in ("CRITICAL", "HIGH") for f in all_findings)
647
+ return json.dumps(all_findings, indent=2, ensure_ascii=False), has_high_plus
648
+
649
+
650
+ # --- Main ---
651
+
652
+
653
+ def run(
654
+ project_root: str,
655
+ skill_dir: str | None = None,
656
+ strict: bool = False,
657
+ json_output: bool = False,
658
+ ) -> int:
659
+ src_dir = os.path.join(project_root, "src")
660
+ github_actions = bool(os.environ.get("GITHUB_ACTIONS"))
661
+
662
+ if skill_dir is not None:
663
+ target = os.path.abspath(skill_dir)
664
+ if not os.path.isdir(target):
665
+ print(f'Error: "{skill_dir}" is not a valid directory.', file=sys.stderr)
666
+ return 2
667
+ skill_dirs = [target]
668
+ else:
669
+ skill_dirs = discover_skill_dirs([src_dir])
670
+
671
+ if not skill_dirs:
672
+ print("No skill directories found.", file=sys.stderr)
673
+ return 2
674
+
675
+ results = [{"skillDir": d, "findings": validate_skill(d)} for d in skill_dirs]
676
+
677
+ if json_output:
678
+ output, has_high_plus = format_json(results, project_root)
679
+ else:
680
+ output, has_high_plus = format_human_readable(
681
+ results, project_root, src_dir, strict, json_output, github_actions
682
+ )
683
+ print(output)
684
+
685
+ return 1 if strict and has_high_plus else 0
686
+
687
+
688
+ def main(argv: list[str] | None = None) -> int:
689
+ parser = argparse.ArgumentParser(description="Validate skill directories against deterministic rules.")
690
+ parser.add_argument("--strict", action="store_true", help="exit 1 on HIGH+ findings")
691
+ parser.add_argument("--json", action="store_true", dest="json_output", help="JSON output")
692
+ parser.add_argument("skill_dir", nargs="?", default=None, help="single skill directory")
693
+ args = parser.parse_args(argv)
694
+ return run(PROJECT_ROOT, skill_dir=args.skill_dir, strict=args.strict, json_output=args.json_output)
695
+
696
+
697
+ if __name__ == "__main__":
698
+ sys.exit(main())