bmad-module-skill-forge 1.1.0 → 1.3.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -4
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/bmad-synergy.md +2 -2
  5. package/docs/getting-started.md +1 -1
  6. package/docs/skill-model.md +26 -32
  7. package/docs/troubleshooting.md +13 -1
  8. package/docs/why-skf.md +5 -4
  9. package/docs/workflows.md +53 -0
  10. package/package.json +2 -2
  11. package/src/knowledge/ccc-bridge.md +1 -1
  12. package/src/shared/references/output-contract-schema.md +10 -0
  13. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +134 -0
  14. package/src/shared/scripts/skf-detect-tools.py +359 -0
  15. package/src/shared/scripts/skf-emit-result-envelope.py +419 -0
  16. package/src/shared/scripts/skf-extract-public-api.py +505 -0
  17. package/src/shared/scripts/skf-forge-tier-rw.py +413 -0
  18. package/src/shared/scripts/skf-merge-ccc-exclusions.py +324 -0
  19. package/src/shared/scripts/skf-preflight.py +14 -4
  20. package/src/shared/scripts/skf-qmd-classify-collections.py +212 -0
  21. package/src/shared/scripts/skf-render-quick-metadata.py +192 -0
  22. package/src/shared/scripts/skf-resolve-package.py +264 -0
  23. package/src/shared/scripts/skf-validate-frontmatter.py +9 -3
  24. package/src/shared/scripts/skf-validate-output.py +24 -7
  25. package/src/skf-create-skill/steps-c/step-06-validate.md +1 -1
  26. package/src/skf-quick-skill/SKILL.md +178 -10
  27. package/src/skf-quick-skill/assets/skill-template.md +5 -1
  28. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  29. package/src/skf-quick-skill/steps-c/step-01-resolve-target.md +84 -16
  30. package/src/skf-quick-skill/steps-c/step-02-ecosystem-check.md +3 -3
  31. package/src/skf-quick-skill/steps-c/step-03-quick-extract.md +86 -43
  32. package/src/skf-quick-skill/steps-c/step-04-compile.md +49 -56
  33. package/src/skf-quick-skill/steps-c/step-05-write-and-validate.md +164 -0
  34. package/src/skf-quick-skill/steps-c/{step-06-write.md → step-06-finalize.md} +15 -7
  35. package/src/skf-quick-skill/steps-c/step-07-health-check.md +5 -3
  36. package/src/skf-setup/SKILL.md +25 -10
  37. package/src/skf-setup/references/tier-rules.md +2 -2
  38. package/src/skf-setup/steps-c/step-01-detect-and-tier.md +58 -71
  39. package/src/skf-setup/steps-c/step-01b-ccc-index.md +49 -66
  40. package/src/skf-setup/steps-c/step-02-write-config.md +59 -86
  41. package/src/skf-setup/steps-c/step-03-auto-index.md +73 -91
  42. package/src/skf-setup/steps-c/step-04-report.md +135 -11
  43. package/src/skf-setup/steps-c/step-05-health-check.md +4 -2
  44. package/src/skf-test-skill/steps-c/step-01-init.md +4 -4
  45. package/src/skf-quick-skill/steps-c/step-05-validate.md +0 -193
@@ -0,0 +1,192 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Render Quick Metadata — render metadata.json per the canonical
6
+ skill-template.md schema with quick-skill-specific population rules.
7
+
8
+ Pure renderer — no I/O beyond stdin/stdout. Reads a JSON payload of
9
+ extracted state on stdin and emits the corresponding metadata.json
10
+ on stdout. Replaces the hand-assembly the LLM previously did in
11
+ skf-quick-skill step-04 §4.
12
+
13
+ Constants vs. input-derived split mirrors step-04's documentation:
14
+
15
+ Constants (always literal):
16
+ skill_type "single"
17
+ spec_version "1.3"
18
+ source_authority "community"
19
+ confidence_tier "Quick"
20
+ generated_by "quick-skill"
21
+ confidence_distribution.t1, t2, t3 0
22
+ tool_versions.ast_grep, tool_versions.qmd null
23
+ stats.exports_internal, stats.scripts_count, stats.assets_count 0
24
+ stats.public_api_coverage, stats.total_coverage 1.0
25
+
26
+ Input-derived (from stdin payload):
27
+ name, version, description, language, source_repo,
28
+ source_root, source_commit, source_package,
29
+ exports[], dependencies[], compatibility,
30
+ provenance.language_hint, provenance.scope_hint,
31
+ tool_versions.skf
32
+
33
+ Computed:
34
+ generation_date ISO 8601 UTC ("YYYY-MM-DDTHH:MM:SSZ")
35
+ confidence_distribution.t1_low count(exports)
36
+ stats.exports_documented / public_api / total count(exports)
37
+
38
+ Input JSON shape (stdin):
39
+
40
+ {
41
+ "name": "foo",
42
+ "version": "1.2.3", (default "1.0.0")
43
+ "description": "...", (default "")
44
+ "language": "python",
45
+ "source_repo": "https://github.com/x/y",
46
+ "source_root": "src/foo", (optional)
47
+ "source_commit": "abc123", (optional)
48
+ "source_package": "foo", (optional)
49
+ "exports": [{"name":"fn","type":"def"}] or list of strings
50
+ "dependencies": ["a", "b"], (default [])
51
+ "compatibility": ">=3.10", (optional, default "")
52
+ "language_hint": null, (echoed verbatim)
53
+ "scope_hint": null, (echoed verbatim)
54
+ "skf_version": "1.2.0" (default "unknown")
55
+ }
56
+
57
+ CLI:
58
+
59
+ python3 skf-render-quick-metadata.py < input.json
60
+
61
+ Exit codes:
62
+
63
+ 0 success
64
+ 1 payload-level error (missing required field)
65
+ 2 stdin / argparse / JSON-decode error
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import argparse
71
+ import datetime as dt
72
+ import json
73
+ import sys
74
+
75
+
76
+ REQUIRED_FIELDS = ("name", "language", "source_repo")
77
+
78
+
79
+ def _normalize_exports(raw) -> list[str]:
80
+ """Accept either a list of strings or a list of {name, type, ...} dicts.
81
+ Returns a flat list of export names in declaration order, deduplicated.
82
+ """
83
+ out: list[str] = []
84
+ seen: set[str] = set()
85
+ if not isinstance(raw, list):
86
+ return out
87
+ for item in raw:
88
+ name: str | None = None
89
+ if isinstance(item, str):
90
+ name = item
91
+ elif isinstance(item, dict):
92
+ v = item.get("name")
93
+ if isinstance(v, str):
94
+ name = v
95
+ if name and name not in seen:
96
+ seen.add(name)
97
+ out.append(name)
98
+ return out
99
+
100
+
101
+ def _iso_utc_now() -> str:
102
+ """Returns 'YYYY-MM-DDTHH:MM:SSZ' for the current UTC instant."""
103
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
104
+
105
+
106
+ def render_metadata(payload: dict, *, now_fn=_iso_utc_now) -> dict:
107
+ """Render the metadata.json envelope. Pass `now_fn` to inject a
108
+ deterministic timestamp in tests.
109
+ """
110
+ missing = [f for f in REQUIRED_FIELDS if not payload.get(f)]
111
+ if missing:
112
+ return {"_error": f"missing required field(s): {', '.join(missing)}"}
113
+
114
+ exports = _normalize_exports(payload.get("exports"))
115
+ export_count = len(exports)
116
+
117
+ metadata = {
118
+ "name": payload["name"],
119
+ "version": payload.get("version") or "1.0.0",
120
+ "description": payload.get("description") or "",
121
+ "skill_type": "single",
122
+ "source_authority": "community",
123
+ "source_repo": payload["source_repo"],
124
+ "source_root": payload.get("source_root") or "",
125
+ "source_commit": payload.get("source_commit") or "",
126
+ "source_package": payload.get("source_package") or payload["name"],
127
+ "language": payload["language"],
128
+ "generated_by": "quick-skill",
129
+ "generation_date": now_fn(),
130
+ "confidence_tier": "Quick",
131
+ "spec_version": "1.3",
132
+ "exports": exports,
133
+ "confidence_distribution": {
134
+ "t1": 0,
135
+ "t1_low": export_count,
136
+ "t2": 0,
137
+ "t3": 0,
138
+ },
139
+ "tool_versions": {
140
+ "ast_grep": None,
141
+ "qmd": None,
142
+ "skf": payload.get("skf_version") or "unknown",
143
+ },
144
+ "stats": {
145
+ "exports_documented": export_count,
146
+ "exports_public_api": export_count,
147
+ "exports_internal": 0,
148
+ "exports_total": export_count,
149
+ "public_api_coverage": 1.0,
150
+ "total_coverage": 1.0,
151
+ "scripts_count": 0,
152
+ "assets_count": 0,
153
+ },
154
+ "dependencies": payload.get("dependencies") or [],
155
+ "compatibility": payload.get("compatibility") or "",
156
+ "provenance": {
157
+ "language_hint": payload.get("language_hint"),
158
+ "scope_hint": payload.get("scope_hint"),
159
+ },
160
+ }
161
+ return metadata
162
+
163
+
164
+ def main(argv: list[str]) -> int:
165
+ parser = argparse.ArgumentParser(
166
+ description="Render skf-quick-skill metadata.json from extracted state on stdin.",
167
+ )
168
+ parser.parse_args(argv) # currently no flags; kept for forward compat
169
+
170
+ raw = sys.stdin.read()
171
+ if not raw.strip():
172
+ sys.stderr.write("error: no input on stdin (expected JSON payload)\n")
173
+ return 2
174
+ try:
175
+ payload = json.loads(raw)
176
+ except json.JSONDecodeError as e:
177
+ sys.stderr.write(f"error: stdin JSON parse error: {e}\n")
178
+ return 2
179
+ if not isinstance(payload, dict):
180
+ sys.stderr.write("error: stdin payload must be a JSON object\n")
181
+ return 2
182
+
183
+ result = render_metadata(payload)
184
+ if "_error" in result:
185
+ sys.stderr.write(f"error: {result['_error']}\n")
186
+ return 1
187
+ print(json.dumps(result, indent=2))
188
+ return 0
189
+
190
+
191
+ if __name__ == "__main__":
192
+ sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,264 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Resolve Package — resolve a package name to a GitHub repository URL.
6
+
7
+ Walks the canonical fallback chain documented in
8
+ `src/skf-quick-skill/references/registry-resolution.md`:
9
+
10
+ 1. npm registry (JavaScript/TypeScript)
11
+ 2. PyPI registry (Python)
12
+ 3. crates.io registry (Rust)
13
+
14
+ Per-call timeout (default 10s); a timeout is treated as a soft failure
15
+ and the resolver falls through to the next entry. Web-search fallback
16
+ is intentionally NOT in this helper — registries are deterministic;
17
+ web search is judgment, and stays in the LLM step.
18
+
19
+ CLI:
20
+
21
+ python3 skf-resolve-package.py <package_name> [--timeout 10]
22
+
23
+ Output JSON (stdout):
24
+
25
+ status: "ok" | "fallthrough"
26
+ package_name: "<input>"
27
+ resolved_url: "https://github.com/<owner>/<repo>" (when status == "ok")
28
+ repo_owner: "<owner>" (when status == "ok")
29
+ repo_name: "<repo>" (when status == "ok")
30
+ registry_used: "npm" | "pypi" | "crates" (when status == "ok")
31
+ registries_tried: ["npm", ...]
32
+ registry_outcomes: {"npm": "ok|404|timeout|error|no-github-link", ...}
33
+
34
+ Exit codes:
35
+
36
+ 0 status == "ok"
37
+ 1 status == "fallthrough" — every registry replied without a GitHub
38
+ URL; the LLM step should fall back to web search.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import re
46
+ import socket
47
+ import sys
48
+ import urllib.error
49
+ import urllib.parse
50
+ import urllib.request
51
+ from typing import Optional
52
+
53
+ REGISTRY_TIMEOUT_SECONDS = 10.0
54
+ USER_AGENT = "skf-resolve-package/1.0 (+https://github.com/armelhbobdad/bmad-module-skill-forge)"
55
+
56
+ _GITHUB_URL_RE = re.compile(
57
+ r"https?://(?:www\.)?github\.com/([^/\s]+)/([^/\s.]+?)(?:\.git)?/?$",
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ def parse_github_url(url: str) -> Optional[tuple[str, str, str]]:
63
+ """Parse a GitHub URL/string into (canonical_url, owner, repo).
64
+
65
+ Handles the variants registries actually emit:
66
+ - https://github.com/owner/repo
67
+ - http://github.com/owner/repo
68
+ - github.com/owner/repo
69
+ - git+https://github.com/owner/repo.git
70
+ - git@github.com:owner/repo.git
71
+ - github:owner/repo (npm shortcut)
72
+ """
73
+ if not url or not isinstance(url, str):
74
+ return None
75
+
76
+ s = url.strip()
77
+
78
+ if s.startswith("github:"):
79
+ rest = s[len("github:") :]
80
+ if "/" in rest:
81
+ owner, _, repo = rest.partition("/")
82
+ repo = repo.removesuffix(".git").rstrip("/")
83
+ if owner and repo:
84
+ return f"https://github.com/{owner}/{repo}", owner, repo
85
+ return None
86
+
87
+ if s.startswith("git+"):
88
+ s = s[len("git+") :]
89
+
90
+ if s.startswith("git@github.com:"):
91
+ rest = s[len("git@github.com:") :]
92
+ rest = rest.removesuffix(".git").rstrip("/")
93
+ if "/" in rest:
94
+ owner, _, repo = rest.partition("/")
95
+ if owner and repo:
96
+ return f"https://github.com/{owner}/{repo}", owner, repo
97
+ return None
98
+
99
+ if s.startswith("github.com/"):
100
+ s = "https://" + s
101
+
102
+ m = _GITHUB_URL_RE.match(s)
103
+ if m:
104
+ owner, repo = m.group(1), m.group(2)
105
+ return f"https://github.com/{owner}/{repo}", owner, repo
106
+
107
+ return None
108
+
109
+
110
+ def _http_get_json(url: str, timeout: float) -> tuple[Optional[dict], str]:
111
+ """Fetch JSON. Returns (payload_or_None, outcome).
112
+
113
+ Outcome values:
114
+ "ok" — valid JSON object returned
115
+ "404" — HTTP 404 (package does not exist on this registry)
116
+ "timeout" — socket / urlopen timed out
117
+ "error" — any other transport / parse error
118
+ """
119
+ req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": USER_AGENT})
120
+ try:
121
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
122
+ payload = json.loads(resp.read().decode("utf-8"))
123
+ if isinstance(payload, dict):
124
+ return payload, "ok"
125
+ return None, "error"
126
+ except urllib.error.HTTPError as e:
127
+ return None, "404" if e.code == 404 else "error"
128
+ except urllib.error.URLError as e:
129
+ if isinstance(getattr(e, "reason", None), (socket.timeout, TimeoutError)):
130
+ return None, "timeout"
131
+ return None, "error"
132
+ except (TimeoutError, socket.timeout):
133
+ return None, "timeout"
134
+ except (ValueError, json.JSONDecodeError):
135
+ return None, "error"
136
+
137
+
138
+ def try_npm(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
139
+ """Try the npm registry. Returns (parsed_or_None, outcome)."""
140
+ encoded = urllib.parse.quote(package_name, safe="@")
141
+ url = f"https://registry.npmjs.org/{encoded}"
142
+ payload, outcome = _http_get_json(url, timeout)
143
+ if payload is None:
144
+ return None, outcome
145
+ candidates: list[str] = []
146
+ repo = payload.get("repository")
147
+ if isinstance(repo, dict) and isinstance(repo.get("url"), str):
148
+ candidates.append(repo["url"])
149
+ elif isinstance(repo, str):
150
+ candidates.append(repo)
151
+ homepage = payload.get("homepage")
152
+ if isinstance(homepage, str):
153
+ candidates.append(homepage)
154
+ for c in candidates:
155
+ parsed = parse_github_url(c)
156
+ if parsed:
157
+ return parsed, "ok"
158
+ return None, "no-github-link"
159
+
160
+
161
+ def try_pypi(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
162
+ """Try the PyPI registry. Returns (parsed_or_None, outcome)."""
163
+ encoded = urllib.parse.quote(package_name, safe="")
164
+ url = f"https://pypi.org/pypi/{encoded}/json"
165
+ payload, outcome = _http_get_json(url, timeout)
166
+ if payload is None:
167
+ return None, outcome
168
+ info = payload.get("info") or {}
169
+ candidates: list[str] = []
170
+ project_urls = info.get("project_urls") or {}
171
+ if isinstance(project_urls, dict):
172
+ for key in ("Source", "Source Code", "Repository", "Homepage"):
173
+ v = project_urls.get(key)
174
+ if isinstance(v, str):
175
+ candidates.append(v)
176
+ home_page = info.get("home_page")
177
+ if isinstance(home_page, str):
178
+ candidates.append(home_page)
179
+ for c in candidates:
180
+ parsed = parse_github_url(c)
181
+ if parsed:
182
+ return parsed, "ok"
183
+ return None, "no-github-link"
184
+
185
+
186
+ def try_crates(package_name: str, timeout: float) -> tuple[Optional[tuple[str, str, str]], str]:
187
+ """Try the crates.io registry. Returns (parsed_or_None, outcome)."""
188
+ encoded = urllib.parse.quote(package_name, safe="")
189
+ url = f"https://crates.io/api/v1/crates/{encoded}"
190
+ payload, outcome = _http_get_json(url, timeout)
191
+ if payload is None:
192
+ return None, outcome
193
+ crate = payload.get("crate") or {}
194
+ for key in ("repository", "homepage"):
195
+ v = crate.get(key)
196
+ if isinstance(v, str):
197
+ parsed = parse_github_url(v)
198
+ if parsed:
199
+ return parsed, "ok"
200
+ return None, "no-github-link"
201
+
202
+
203
+ _RESOLVER_NAMES: tuple[tuple[str, str], ...] = (
204
+ ("npm", "try_npm"),
205
+ ("pypi", "try_pypi"),
206
+ ("crates", "try_crates"),
207
+ )
208
+
209
+
210
+ def resolve_package(package_name: str, timeout: float = REGISTRY_TIMEOUT_SECONDS) -> dict:
211
+ registries_tried: list[str] = []
212
+ outcomes: dict[str, str] = {}
213
+
214
+ # Dynamic lookup so test code can monkeypatch try_npm / try_pypi / try_crates
215
+ # without re-binding entries in a module-level tuple of function refs.
216
+ for name, fn_name in _RESOLVER_NAMES:
217
+ registries_tried.append(name)
218
+ fn = globals()[fn_name]
219
+ result, outcome = fn(package_name, timeout)
220
+ outcomes[name] = outcome
221
+ if result is not None:
222
+ url, owner, repo = result
223
+ return {
224
+ "status": "ok",
225
+ "package_name": package_name,
226
+ "resolved_url": url,
227
+ "repo_owner": owner,
228
+ "repo_name": repo,
229
+ "registry_used": name,
230
+ "registries_tried": registries_tried,
231
+ "registry_outcomes": outcomes,
232
+ }
233
+
234
+ return {
235
+ "status": "fallthrough",
236
+ "package_name": package_name,
237
+ "registries_tried": registries_tried,
238
+ "registry_outcomes": outcomes,
239
+ }
240
+
241
+
242
+ def main(argv: list[str]) -> int:
243
+ parser = argparse.ArgumentParser(
244
+ description="Resolve a package name to a GitHub repository URL via npm/PyPI/crates.io.",
245
+ )
246
+ parser.add_argument(
247
+ "package_name",
248
+ help="Package name to resolve (e.g., lodash, @tanstack/query, requests, serde).",
249
+ )
250
+ parser.add_argument(
251
+ "--timeout",
252
+ type=float,
253
+ default=REGISTRY_TIMEOUT_SECONDS,
254
+ help=f"Per-registry timeout in seconds (default: {REGISTRY_TIMEOUT_SECONDS}).",
255
+ )
256
+ args = parser.parse_args(argv)
257
+
258
+ result = resolve_package(args.package_name, timeout=args.timeout)
259
+ print(json.dumps(result, indent=2))
260
+ return 0 if result["status"] == "ok" else 1
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main(sys.argv[1:]))
@@ -11,9 +11,15 @@ agentskills/agentskills/skills-ref/src/skills_ref/validator.py.
11
11
  Designed for pre-check use in test-skill, create-skill, update-skill, and
12
12
  export-skill workflows. Returns structured JSON for deterministic integration.
13
13
 
14
- CLI:
15
- python3 skf-validate-frontmatter.py <skill-md-path>
16
- python3 skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
14
+ CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
15
+ above is auto-resolved on first call and cached. `docs/getting-started.md`
16
+ documents uv as the runtime prerequisite for exactly this. Bare
17
+ `python3` will fail with `ModuleNotFoundError: No module named 'yaml'`
18
+ on a fresh interpreter where pyyaml has not been pip-installed
19
+ system-wide:
20
+
21
+ uv run skf-validate-frontmatter.py <skill-md-path>
22
+ uv run skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
17
23
 
18
24
  Input:
19
25
  Path to a SKILL.md file.
@@ -9,6 +9,7 @@ schema against agentskills.io specification. Outputs JSON validation results.
9
9
 
10
10
  CLI: python3 skf-validate-output.py <skill-package-dir>
11
11
  python3 skf-validate-output.py <skill-package-dir> --generated-by quick-skill
12
+ python3 skf-validate-output.py <skill-package-dir> --skip-frontmatter
12
13
  """
13
14
 
14
15
  from __future__ import annotations
@@ -73,7 +74,7 @@ def validate_body_structure(content):
73
74
  issues = []
74
75
  body = content.split("---", 2)[-1] if content.startswith("---") else content
75
76
 
76
- required_sections = ["Overview", "Key Exports", "Usage"]
77
+ required_sections = ["Overview", "Description", "Key Exports", "Usage"]
77
78
  for section in required_sections:
78
79
  pattern = rf"^##\s+.*{re.escape(section)}"
79
80
  if not re.search(pattern, body, re.MULTILINE | re.IGNORECASE):
@@ -155,8 +156,13 @@ def validate_metadata_json(data, generated_by=None):
155
156
  return issues
156
157
 
157
158
 
158
- def validate_skill_package(skill_dir, generated_by=None):
159
- """Validate a complete skill package directory."""
159
+ def validate_skill_package(skill_dir, generated_by=None, skip_frontmatter=False):
160
+ """Validate a complete skill package directory.
161
+
162
+ When `skip_frontmatter` is True, the SKILL.md frontmatter pass is omitted —
163
+ intended for callers that already validated frontmatter via skill-check or
164
+ skf-validate-frontmatter.py and only want body / snippet / metadata checks.
165
+ """
160
166
  skill_dir = Path(skill_dir)
161
167
  skill_name = skill_dir.name
162
168
 
@@ -183,9 +189,14 @@ def validate_skill_package(skill_dir, generated_by=None):
183
189
  skill_md_path = files["SKILL.md"]
184
190
  if skill_md_path.exists():
185
191
  content = skill_md_path.read_text(encoding="utf-8")
186
- fm_issues = validate_frontmatter(content, skill_name)
192
+ if skip_frontmatter:
193
+ fm_section = {"skipped": "frontmatter validation skipped (--skip-frontmatter)"}
194
+ fm_issues = []
195
+ else:
196
+ fm_issues = validate_frontmatter(content, skill_name)
197
+ fm_section = fm_issues
187
198
  body_issues = validate_body_structure(content)
188
- result["validation"]["skill_md"] = {"frontmatter": fm_issues, "body": body_issues}
199
+ result["validation"]["skill_md"] = {"frontmatter": fm_section, "body": body_issues}
189
200
  for issue in fm_issues + body_issues:
190
201
  result["summary"]["total_issues"] += 1
191
202
  result["summary"]["by_severity"][issue["severity"]] += 1
@@ -232,7 +243,11 @@ def validate_skill_package(skill_dir, generated_by=None):
232
243
 
233
244
  if __name__ == "__main__":
234
245
  if len(sys.argv) < 2:
235
- print("Usage: python3 skf-validate-output.py <skill-package-dir> [--generated-by <generator>]", file=sys.stderr)
246
+ print(
247
+ "Usage: python3 skf-validate-output.py <skill-package-dir> "
248
+ "[--generated-by <generator>] [--skip-frontmatter]",
249
+ file=sys.stderr,
250
+ )
236
251
  sys.exit(1)
237
252
 
238
253
  pkg_dir = sys.argv[1]
@@ -242,6 +257,8 @@ if __name__ == "__main__":
242
257
  if idx + 1 < len(sys.argv):
243
258
  gen_by = sys.argv[idx + 1]
244
259
 
245
- result = validate_skill_package(pkg_dir, generated_by=gen_by)
260
+ skip_fm = "--skip-frontmatter" in sys.argv
261
+
262
+ result = validate_skill_package(pkg_dir, generated_by=gen_by, skip_frontmatter=skip_fm)
246
263
  print(json.dumps(result, indent=2))
247
264
  sys.exit(0 if result["result"] == "PASS" else 1)
@@ -40,7 +40,7 @@ To prevent this, any tool invocation that may touch SKILL.md must run inside the
40
40
  2. **Execute.** Run the tool as specified in its section.
41
41
  3. **Verify.** After the tool completes, re-read the on-disk SKILL.md and compare its frontmatter `description` against `guarded_description` as **token streams**: split each string on whitespace (`str.split()` — any run of spaces/tabs/newlines collapses) and compare the resulting lists element-by-element. This catches content divergence (missing words, replaced phrases, truncation, angle-bracket re-introduction) while ignoring cosmetic whitespace changes that a tool may apply (trailing newline, re-wrapped quoted strings). Do NOT use a normalized-string equality — a tool that rewrites `"foo bar"` to `"foo bar"` (collapsed inner run) would trip a naive normalization even though no semantic content changed, and a tool that swapped one word would slip past a looser fuzzy match. Token-stream comparison is the sweet spot.
42
42
  4. **Restore on divergence.** If the post-tool description differs from `guarded_description` in any way other than whitespace normalization, write `guarded_description` back to the on-disk SKILL.md frontmatter and update the in-context copy to match. Record `description_guard_restored: true` with the tool name in context for the evidence report.
43
- 5. **Re-validate restored description.** After a restore, run `python3 {project-root}/src/shared/scripts/skf-validate-frontmatter.py <staging-skill-dir>/SKILL.md` against the on-disk file to confirm the restored description still satisfies the frontmatter contract (length limits, forbidden tokens, required fields). Capture `schema_revalidation_result` in context. If the validator exits non-zero OR reports failure for the `description` field: flip the Schema result back to `FAIL` in the evidence report (overriding any prior PASS/WARN from §2), record `description_guard_revalidation: FAIL` with the validator's diagnostic message, and continue — do not halt (step-09 health-check and result contract still need to run so the failure is surfaced through the normal artifact path).
43
+ 5. **Re-validate restored description.** After a restore, run `uv run {project-root}/src/shared/scripts/skf-validate-frontmatter.py <staging-skill-dir>/SKILL.md` against the on-disk file to confirm the restored description still satisfies the frontmatter contract (length limits, forbidden tokens, required fields). The script declares pyyaml in its PEP 723 inline metadata; `uv run` resolves it automatically (per `docs/getting-started.md`'s uv prereq), while bare `python3` would `ModuleNotFoundError` on a fresh interpreter. Capture `schema_revalidation_result` in context. If the validator exits non-zero OR reports failure for the `description` field: flip the Schema result back to `FAIL` in the evidence report (overriding any prior PASS/WARN from §2), record `description_guard_revalidation: FAIL` with the validator's diagnostic message, and continue — do not halt (step-09 health-check and result contract still need to run so the failure is surfaced through the normal artifact path).
44
44
 
45
45
  **What counts as divergence:**
46
46