bmad-module-skill-forge 1.5.1 → 1.7.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 (87) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/workflows.md +1 -1
  4. package/package.json +2 -2
  5. package/src/knowledge/version-paths.md +4 -3
  6. package/src/shared/health-check.md +1 -1
  7. package/src/shared/scripts/schemas/skill-brief.v1.json +5 -0
  8. package/src/shared/scripts/skf-atomic-write.py +4 -1
  9. package/src/shared/scripts/skf-description-guard.py +33 -80
  10. package/src/shared/scripts/skf-detect-language.py +34 -0
  11. package/src/shared/scripts/skf-detect-workspaces.py +54 -0
  12. package/src/shared/scripts/skf-enumerate-stack-skills.py +107 -16
  13. package/src/shared/scripts/skf-forge-tier-rw.py +4 -1
  14. package/src/shared/scripts/skf-manifest-ops.py +8 -4
  15. package/src/shared/scripts/skf-merge-ccc-exclusions.py +4 -1
  16. package/src/shared/scripts/skf-qmd-classify-collections.py +29 -3
  17. package/src/shared/scripts/skf-rebuild-managed-sections.py +72 -7
  18. package/src/shared/scripts/skf-scan-skill-md-structure.py +13 -3
  19. package/src/shared/scripts/skf-validate-brief-inputs.py +79 -18
  20. package/src/shared/scripts/skf-validate-brief-schema.py +47 -1
  21. package/src/shared/scripts/skf-validate-frontmatter.py +60 -2
  22. package/src/shared/scripts/skf-write-skill-brief.py +97 -17
  23. package/src/skf-analyze-source/assets/skill-brief-schema.md +10 -2
  24. package/src/skf-analyze-source/references/continue.md +3 -1
  25. package/src/skf-analyze-source/references/generate-briefs.md +30 -9
  26. package/src/skf-analyze-source/references/recommend.md +1 -1
  27. package/src/skf-brief-skill/SKILL.md +4 -2
  28. package/src/skf-brief-skill/assets/skill-brief-schema.md +6 -1
  29. package/src/skf-brief-skill/references/analyze-target.md +22 -6
  30. package/src/skf-brief-skill/references/confirm-brief.md +4 -0
  31. package/src/skf-brief-skill/references/gather-intent.md +72 -3
  32. package/src/skf-brief-skill/references/headless-args.md +3 -2
  33. package/src/skf-brief-skill/references/portfolio-similarity-check.md +16 -8
  34. package/src/skf-brief-skill/references/scope-definition.md +20 -2
  35. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  36. package/src/skf-brief-skill/references/write-brief.md +12 -2
  37. package/src/skf-create-skill/assets/compile-assembly-rules.md +20 -1
  38. package/src/skf-create-skill/assets/skill-sections.md +2 -1
  39. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +18 -5
  40. package/src/skf-create-skill/references/compile.md +5 -2
  41. package/src/skf-create-skill/references/extract.md +8 -3
  42. package/src/skf-create-skill/references/extraction-patterns.md +14 -5
  43. package/src/skf-create-skill/references/generate-artifacts.md +5 -1
  44. package/src/skf-create-skill/references/source-resolution-protocols.md +5 -2
  45. package/src/skf-create-skill/references/validate.md +4 -3
  46. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +3 -3
  47. package/src/skf-create-stack-skill/assets/stack-skill-template.md +56 -0
  48. package/src/skf-create-stack-skill/references/compile-stack.md +13 -2
  49. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -0
  50. package/src/skf-create-stack-skill/references/detect-integrations.md +5 -2
  51. package/src/skf-create-stack-skill/references/generate-output.md +49 -9
  52. package/src/skf-create-stack-skill/references/validate.md +8 -5
  53. package/src/skf-export-skill/references/load-skill.md +5 -4
  54. package/src/skf-export-skill/references/manifest-rebuild.md +1 -1
  55. package/src/skf-export-skill/references/token-report.md +2 -2
  56. package/src/skf-export-skill/references/update-context.md +25 -11
  57. package/src/skf-quick-skill/assets/skill-template.md +2 -2
  58. package/src/skf-quick-skill/references/compile.md +4 -2
  59. package/src/skf-quick-skill/references/write-and-validate.md +2 -2
  60. package/src/skf-refine-architecture/SKILL.md +1 -1
  61. package/src/skf-refine-architecture/references/gap-analysis.md +30 -4
  62. package/src/skf-refine-architecture/references/init.md +2 -0
  63. package/src/skf-refine-architecture/references/issue-detection.md +8 -3
  64. package/src/skf-test-skill/SKILL.md +1 -1
  65. package/src/skf-test-skill/references/coherence-check.md +4 -4
  66. package/src/skf-test-skill/references/coverage-check.md +58 -16
  67. package/src/skf-test-skill/references/external-validators.md +7 -7
  68. package/src/skf-test-skill/references/init.md +5 -4
  69. package/src/skf-test-skill/references/score.md +6 -3
  70. package/src/skf-test-skill/references/scoring-rules.md +12 -0
  71. package/src/skf-test-skill/references/source-access-protocol.md +19 -3
  72. package/src/skf-test-skill/scripts/compute-score.py +4 -1
  73. package/src/skf-update-skill/references/detect-changes.md +15 -2
  74. package/src/skf-update-skill/references/init.md +8 -1
  75. package/src/skf-update-skill/references/merge.md +22 -0
  76. package/src/skf-update-skill/references/re-extract.md +19 -7
  77. package/src/skf-update-skill/references/write.md +3 -0
  78. package/src/skf-verify-stack/SKILL.md +2 -2
  79. package/src/skf-verify-stack/assets/feasibility-report-template.md +1 -1
  80. package/src/skf-verify-stack/references/coverage.md +35 -6
  81. package/src/skf-verify-stack/references/init.md +6 -2
  82. package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
  83. package/src/skf-verify-stack/references/integrations.md +7 -3
  84. package/src/skf-verify-stack/references/report.md +5 -1
  85. package/src/skf-verify-stack/references/requirements.md +3 -1
  86. package/src/skf-verify-stack/references/synthesize.md +11 -3
  87. package/tools/cli/lib/installer.js +1 -0
@@ -21,6 +21,7 @@ Actions:
21
21
  from __future__ import annotations
22
22
 
23
23
  import json
24
+ import os
24
25
  import re
25
26
  import sys
26
27
  from datetime import datetime, timezone
@@ -47,6 +48,64 @@ def find_managed_section(content):
47
48
  return MARKER_PATTERN.search(content)
48
49
 
49
50
 
51
+ def _atomic_write(file_path, text):
52
+ """Stage `text` into <file>.skf-tmp, fsync, then os.replace into place.
53
+
54
+ Mirrors skf-atomic-write.py's `write` so the marker-surgery actions deliver
55
+ the crash-safety the step file documents: a mid-write process kill leaves
56
+ the original file intact rather than truncated. Cleans up the temp file on
57
+ failure. Raises OSError on any I/O failure.
58
+ """
59
+ path = Path(file_path)
60
+ tmp = path.with_name(path.name + ".skf-tmp")
61
+ # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
62
+ # translation that would otherwise diverge the on-disk bytes from the staged
63
+ # string and trip the byte-identity verify below.
64
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
65
+ try:
66
+ fd = os.open(tmp, flags, 0o644)
67
+ try:
68
+ os.write(fd, text.encode("utf-8"))
69
+ os.fsync(fd)
70
+ finally:
71
+ os.close(fd)
72
+ os.replace(tmp, path)
73
+ except OSError:
74
+ if tmp.exists():
75
+ try:
76
+ tmp.unlink()
77
+ except OSError:
78
+ pass
79
+ raise
80
+
81
+
82
+ def _write_and_verify(file_path, updated, *, expect_section):
83
+ """Atomically write `updated`, then re-read to confirm integrity.
84
+
85
+ Returns None on success or an error string. The re-read asserts the on-disk
86
+ bytes match what was staged (so content outside the markers is byte-identical)
87
+ and that managed-section marker presence matches `expect_section`.
88
+ """
89
+ try:
90
+ _atomic_write(file_path, updated)
91
+ except OSError as e:
92
+ return f"atomic write failed: {e}"
93
+
94
+ # Re-read raw bytes (no newline translation) for a true byte-identity check.
95
+ try:
96
+ on_disk = Path(file_path).read_bytes()
97
+ except OSError as e:
98
+ return f"post-write verification failed: {e}"
99
+ if on_disk != updated.encode("utf-8"):
100
+ return "post-write verification failed: on-disk bytes do not match staged content"
101
+ if (find_managed_section(on_disk.decode("utf-8")) is not None) != expect_section:
102
+ return (
103
+ "post-write verification failed: managed section "
104
+ + ("missing after write" if expect_section else "still present after clear")
105
+ )
106
+ return None
107
+
108
+
50
109
  def cmd_check(file_path):
51
110
  """Check if managed section exists."""
52
111
  content, err = read_context_file(file_path)
@@ -112,7 +171,9 @@ def cmd_replace(file_path, new_content):
112
171
  new_section = f"<!-- SKF:BEGIN updated:{today} -->\n{new_content}\n{END_MARKER}"
113
172
  updated = content[: match.start()] + new_section + content[match.end() :]
114
173
 
115
- Path(file_path).write_text(updated, encoding="utf-8")
174
+ verify_err = _write_and_verify(file_path, updated, expect_section=True)
175
+ if verify_err:
176
+ return {"status": "error", "error": verify_err}
116
177
  return {"status": "ok", "action": "replaced", "bytes_written": len(updated)}
117
178
 
118
179
 
@@ -131,7 +192,9 @@ def cmd_clear(file_path):
131
192
  after = content[match.end() :].lstrip("\n")
132
193
  updated = before + ("\n\n" if before and after else "") + after
133
194
 
134
- Path(file_path).write_text(updated, encoding="utf-8")
195
+ verify_err = _write_and_verify(file_path, updated, expect_section=False)
196
+ if verify_err:
197
+ return {"status": "error", "error": verify_err}
135
198
  return {"status": "ok", "action": "cleared", "bytes_written": len(updated)}
136
199
 
137
200
 
@@ -153,7 +216,9 @@ def cmd_insert(file_path, new_content):
153
216
  section = f"\n<!-- SKF:BEGIN updated:{today} -->\n{new_content}\n{END_MARKER}\n"
154
217
  updated = content.rstrip("\n") + "\n" + section if content.strip() else section.lstrip("\n")
155
218
 
156
- Path(file_path).write_text(updated, encoding="utf-8")
219
+ verify_err = _write_and_verify(file_path, updated, expect_section=True)
220
+ if verify_err:
221
+ return {"status": "error", "error": verify_err}
157
222
  return {"status": "ok", "action": "inserted", "bytes_written": len(updated)}
158
223
 
159
224
 
@@ -179,15 +244,15 @@ def main():
179
244
  elif action == "read":
180
245
  result = cmd_read(file_path)
181
246
  elif action == "replace":
182
- if content_arg is None:
183
- result = {"status": "error", "error": "replace requires --content or stdin"}
247
+ if content_arg is None or not content_arg.strip():
248
+ result = {"status": "error", "error": "replace requires non-empty --content or stdin"}
184
249
  else:
185
250
  result = cmd_replace(file_path, content_arg)
186
251
  elif action == "clear":
187
252
  result = cmd_clear(file_path)
188
253
  elif action == "insert":
189
- if content_arg is None:
190
- result = {"status": "error", "error": "insert requires --content or stdin"}
254
+ if content_arg is None or not content_arg.strip():
255
+ result = {"status": "error", "error": "insert requires non-empty --content or stdin"}
191
256
  else:
192
257
  result = cmd_insert(file_path, content_arg)
193
258
  else:
@@ -97,28 +97,38 @@ from pathlib import Path
97
97
 
98
98
  # These mirror the canonical synonyms documented in
99
99
  # `src/skf-test-skill/references/coherence-check.md` §2.1, with the
100
- # SKF-template-specific headings (`Quick Start`, `Common Workflows`,
101
- # `Key API Summary`) folded in so they are first-class matches rather
102
- # than literal-name misses (per the §2.1 "Note" paragraph).
100
+ # SKF-template-specific headings folded in so they are first-class
101
+ # matches rather than literal-name misses (per the §2.1 "Note"
102
+ # paragraph). The set covers the Deep/create-skill template
103
+ # (`Quick Start`, `Common Workflows`, `Key API Summary`, `Key Types`),
104
+ # the quick-skill template (`Usage Patterns`, `Key Exports`), and the
105
+ # reference-app assembly overrides (`Adoption Steps` replaces Common
106
+ # Workflows for usage; `Pattern Surface` replaces Key API Summary for
107
+ # api_surface), since headings are matched on the full heading text,
108
+ # not a substring.
103
109
  REQUIRED_SYNONYMS: dict[str, list[str]] = {
104
110
  "description": ["Description", "Overview", "Purpose", "Summary"],
105
111
  "usage": [
106
112
  "Usage",
113
+ "Usage Patterns",
107
114
  "Examples",
108
115
  "How to use",
109
116
  "Quickstart",
110
117
  "Quick Start",
111
118
  "Getting Started",
112
119
  "Common Workflows",
120
+ "Adoption Steps",
113
121
  ],
114
122
  "api_surface": [
115
123
  "API",
116
124
  "API Surface",
117
125
  "Exports",
126
+ "Key Exports",
118
127
  "Public API",
119
128
  "Interface",
120
129
  "Reference",
121
130
  "Key API Summary",
131
+ "Pattern Surface",
122
132
  ],
123
133
  }
124
134
 
@@ -14,10 +14,20 @@ CLI:
14
14
  echo '{...}' | uv run skf-validate-brief-inputs.py
15
15
 
16
16
  Input (JSON object on stdin or via --json):
17
- Required:
17
+ Required (derive route — deriving a new brief from a repo/docs target):
18
18
  target_repo — string (URL or path); error if absent
19
19
  skill_name — string (kebab-case); error if absent or malformed
20
20
 
21
+ Ratify route (ratifying a pre-authored brief):
22
+ from_brief — string path to an existing skill-brief.yaml (file or
23
+ containing directory). When supplied, the run ratifies
24
+ that brief instead of deriving one: target_repo and
25
+ skill_name become optional (derived from the brief) and
26
+ are ignored with a warning if also passed. The path's
27
+ existence and the brief's schema are checked downstream
28
+ by skf-validate-brief-schema.py — this validator only
29
+ enforces that from_brief is a non-empty string.
30
+
21
31
  Optional with enum constraints:
22
32
  source_type — "source" | "docs-only" (default "source")
23
33
  source_authority — "official" | "community" | "internal" (default "community")
@@ -63,6 +73,7 @@ from typing import Any
63
73
  KNOWN_FIELDS = {
64
74
  "target_repo",
65
75
  "skill_name",
76
+ "from_brief",
66
77
  "source_type",
67
78
  "source_authority",
68
79
  "scope_type",
@@ -113,23 +124,68 @@ def validate(inp: dict[str, Any]) -> dict[str, Any]:
113
124
  errors: list[dict[str, str]] = []
114
125
  warnings: list[dict[str, str]] = []
115
126
 
116
- # Required: target_repo, skill_name
117
127
  target_repo = inp.get("target_repo")
118
128
  skill_name = inp.get("skill_name")
119
- if not target_repo:
120
- errors.append(_err("target_repo", "missing required argument target_repo"))
121
- if not skill_name:
122
- errors.append(_err("skill_name", "missing required argument skill_name"))
123
129
 
124
- # skill_name format
125
- if skill_name and isinstance(skill_name, str) and not KEBAB_RE.match(skill_name):
126
- errors.append(
127
- _err(
128
- "skill_name",
129
- f"skill_name must be kebab-case (lowercase letters/digits/hyphens, "
130
- f"no leading or trailing hyphen). Got: {skill_name!r}",
130
+ # Ratify route: `from_brief` points at a pre-authored brief to ratify. When
131
+ # supplied it is the source of truth — target_repo / skill_name are derived
132
+ # from the brief, so they are neither required nor format-checked here, and
133
+ # are ignored (with a warning) if also passed. A null `from_brief` is treated
134
+ # as "absent" so callers can pass it unconditionally.
135
+ from_brief = inp.get("from_brief")
136
+ ratify_route = from_brief is not None
137
+ if ratify_route:
138
+ if not isinstance(from_brief, str):
139
+ errors.append(
140
+ _err(
141
+ "from_brief",
142
+ f"from_brief must be a string path to a skill-brief.yaml. "
143
+ f"Got type {type(from_brief).__name__}",
144
+ )
145
+ )
146
+ elif not from_brief.strip():
147
+ errors.append(
148
+ _err(
149
+ "from_brief",
150
+ "from_brief is required but was empty — supply a path to a "
151
+ "skill-brief.yaml (file or containing directory)",
152
+ )
153
+ )
154
+ else:
155
+ # Valid from_brief value — flag any redundant derive-route args.
156
+ if target_repo:
157
+ warnings.append(
158
+ _err(
159
+ "target_repo",
160
+ "target_repo is ignored when from_brief is supplied — the "
161
+ "ratify route derives the target from the brief",
162
+ )
163
+ )
164
+ if skill_name:
165
+ warnings.append(
166
+ _err(
167
+ "skill_name",
168
+ "skill_name is ignored when from_brief is supplied — the "
169
+ "ratify route derives the name from the brief",
170
+ )
171
+ )
172
+
173
+ # Required (derive route only): target_repo, skill_name
174
+ if not ratify_route:
175
+ if not target_repo:
176
+ errors.append(_err("target_repo", "missing required argument target_repo"))
177
+ if not skill_name:
178
+ errors.append(_err("skill_name", "missing required argument skill_name"))
179
+
180
+ # skill_name format
181
+ if skill_name and isinstance(skill_name, str) and not KEBAB_RE.match(skill_name):
182
+ errors.append(
183
+ _err(
184
+ "skill_name",
185
+ f"skill_name must be kebab-case (lowercase letters/digits/hyphens, "
186
+ f"no leading or trailing hyphen). Got: {skill_name!r}",
187
+ )
131
188
  )
132
- )
133
189
 
134
190
  # source_type enum
135
191
  source_type_raw = inp.get("source_type", "source")
@@ -186,15 +242,20 @@ def validate(inp: dict[str, Any]) -> dict[str, Any]:
186
242
  )
187
243
  )
188
244
 
189
- # docs-only requires doc_urls
245
+ # docs-only requires doc_urls — derive route only. On the ratify route the
246
+ # brief on disk is the source of truth for source_type/doc_urls, so a
247
+ # redundant `source_type: docs-only` arg must not HALT a run whose doc_urls
248
+ # live in the brief rather than the args.
190
249
  doc_urls = inp.get("doc_urls")
191
- if source_type == "docs-only" and not doc_urls:
250
+ if not ratify_route and source_type == "docs-only" and not doc_urls:
192
251
  errors.append(
193
252
  _err("doc_urls", "doc_urls is required when source_type is docs-only")
194
253
  )
195
254
 
196
- # target_repo shape (warning only — script doesn't HEAD-check)
197
- if isinstance(target_repo, str) and target_repo:
255
+ # target_repo shape (warning only — script doesn't HEAD-check). Skipped on
256
+ # the ratify route, where target_repo is already flagged as ignored above —
257
+ # a second "doesn't look like a URL" warning would just be noise.
258
+ if not ratify_route and isinstance(target_repo, str) and target_repo:
198
259
  looks_like_url = URL_RE.match(target_repo) is not None
199
260
  looks_like_path = (
200
261
  target_repo.startswith("/")
@@ -46,6 +46,7 @@ Exit codes:
46
46
  from __future__ import annotations
47
47
 
48
48
  import argparse
49
+ import datetime
49
50
  import json
50
51
  import sys
51
52
  from pathlib import Path
@@ -133,6 +134,18 @@ def _translate_jsonschema_error(err) -> dict:
133
134
  ),
134
135
  }
135
136
  if validator == "type":
137
+ # A bare YAML date/datetime scalar (e.g. `created: 2026-05-01` without
138
+ # quotes) parses as a Python date, not a string. Give an actionable
139
+ # "quote it" hint instead of the opaque "has type `date`" message.
140
+ if isinstance(inst, datetime.date) and err.validator_value == "string":
141
+ return {
142
+ "field": field,
143
+ "message": (
144
+ f"Brief validation failed: `{field}` was parsed as a YAML date, "
145
+ f"not a string. Quote it (e.g. `'2026-05-01'`) so it is stored as "
146
+ f"text. Update your skill-brief.yaml and re-run."
147
+ ),
148
+ }
136
149
  expected = err.validator_value
137
150
  actual = type(inst).__name__
138
151
  return {
@@ -207,6 +220,33 @@ def _docs_only_rules(brief: dict) -> tuple[list[dict], list[dict]]:
207
220
  return errors, warnings
208
221
 
209
222
 
223
+ def _target_version_matches_version_rule(brief: dict) -> list[dict]:
224
+ """`target_version`, when present, must equal `version` (writer invariant).
225
+
226
+ The JSON schema constrains both fields to the semver pattern independently
227
+ but cannot express their cross-field equality. Without this check, a brief
228
+ with mismatched `target_version`/`version` passes schema validation (the
229
+ ratify gate) and only fails later at the writer's `assemble_brief`
230
+ invariant. Surfacing it here turns that into a clean `brief-invalid` at the
231
+ validation step. Non-string values are left to the schema's pattern/type
232
+ rules — we only compare when both are strings.
233
+ """
234
+ tv = brief.get("target_version")
235
+ v = brief.get("version")
236
+ if isinstance(tv, str) and isinstance(v, str) and tv != v:
237
+ return [
238
+ {
239
+ "field": "target_version",
240
+ "message": (
241
+ f"Brief validation failed: `target_version` (`{tv}`) must equal "
242
+ f"`version` (`{v}`). When `target_version` is set it becomes the "
243
+ f"skill's version. Update your skill-brief.yaml and re-run."
244
+ ),
245
+ }
246
+ ]
247
+ return []
248
+
249
+
210
250
  def _version_non_empty_rule(brief: dict) -> list[dict]:
211
251
  """The §3 prose calls out version-whitespace-only as a hard error."""
212
252
  version = brief.get("version")
@@ -246,6 +286,7 @@ def validate_brief(brief: dict) -> dict:
246
286
  warnings.extend(cond_warnings)
247
287
 
248
288
  errors.extend(_version_non_empty_rule(brief))
289
+ errors.extend(_target_version_matches_version_rule(brief))
249
290
 
250
291
  return {
251
292
  "valid": not errors,
@@ -260,7 +301,12 @@ def validate_brief(brief: dict) -> dict:
260
301
 
261
302
 
262
303
  def _emit(envelope: dict) -> None:
263
- json.dump(envelope, sys.stdout, indent=2)
304
+ # default=str keeps the echoed `brief` serializable even when it contains
305
+ # non-JSON scalars (e.g. a YAML date object from an unquoted `created:`
306
+ # field). Without it, emitting the invalid-brief envelope would crash with
307
+ # `TypeError: Object of type date is not JSON serializable` instead of
308
+ # returning the graded brief-invalid result.
309
+ json.dump(envelope, sys.stdout, indent=2, default=str)
264
310
  sys.stdout.write("\n")
265
311
 
266
312
 
@@ -20,17 +20,24 @@ system-wide:
20
20
 
21
21
  uv run skf-validate-frontmatter.py <skill-md-path>
22
22
  uv run skf-validate-frontmatter.py <skill-md-path> --skill-dir-name <name>
23
+ uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500
23
24
 
24
25
  Input:
25
26
  Path to a SKILL.md file.
26
27
  Optional --skill-dir-name: expected directory name for name-match check.
27
28
  If omitted, derived from the parent directory of the SKILL.md path.
29
+ Optional --max-body-lines N: when set, emit a high-severity `body` issue
30
+ if the SKILL.md body (lines after the closing frontmatter delimiter)
31
+ exceeds N lines. Opt-in — when omitted, body size is never checked and
32
+ the verdict is unchanged. Callers pass the skill-check `body.max_lines`
33
+ default (500) to pre-catch that hard reject before commit.
28
34
 
29
35
  Output:
30
36
  JSON object:
31
37
  status: "pass" | "fail" | "warn"
32
38
  issues: list of { severity, field, message }
33
39
  frontmatter: parsed frontmatter dict (if parseable)
40
+ body_lines: int body-line count, or null when delimiters are absent
34
41
  summary: { total, high, medium, low }
35
42
 
36
43
  Exit codes:
@@ -122,6 +129,31 @@ def parse_frontmatter(content: str) -> tuple[dict | None, list[dict]]:
122
129
  return fm, issues
123
130
 
124
131
 
132
+ def body_line_count(content: str) -> int | None:
133
+ """Count the SKILL.md body lines — every line after the closing
134
+ frontmatter delimiter.
135
+
136
+ Mirrors skill-check's `body.max_lines` definition (body = content past
137
+ the frontmatter block) so a pre-commit gate keyed on this count agrees
138
+ with the post-commit `npx skill-check` verdict. Returns None when the
139
+ frontmatter delimiters are absent or unclosed — the body boundary is
140
+ then undefined and size cannot be assessed. Uses splitlines() to match
141
+ the line-counting convention used elsewhere in the SKF scripts (handles
142
+ LF/CRLF, no trailing-newline phantom line).
143
+ """
144
+ if not content.startswith("---\n") and not content.startswith("---\r\n"):
145
+ return None
146
+ lines = content.splitlines()
147
+ closing = -1
148
+ for i in range(1, len(lines)):
149
+ if lines[i] == "---":
150
+ closing = i
151
+ break
152
+ if closing == -1:
153
+ return None
154
+ return len(lines) - (closing + 1)
155
+
156
+
125
157
  def _validate_name(name: str, skill_dir_name: str | None) -> list[dict]:
126
158
  """Validate skill name format. Aligned with canonical validator.py."""
127
159
  issues: list[dict] = []
@@ -187,13 +219,26 @@ def _validate_name(name: str, skill_dir_name: str | None) -> list[dict]:
187
219
  def validate_frontmatter(
188
220
  content: str,
189
221
  skill_dir_name: str | None = None,
222
+ max_body_lines: int | None = None,
190
223
  ) -> dict:
191
224
  """Validate SKILL.md frontmatter against agentskills.io spec.
192
225
 
193
- Returns a result dict with status, issues, frontmatter, and summary.
226
+ When `max_body_lines` is set, additionally emit a high-severity `body`
227
+ issue if the body exceeds that many lines (opt-in — the verdict is
228
+ unchanged when it is None, so the other consumers of this validator are
229
+ unaffected). Returns a result dict with status, issues, frontmatter,
230
+ body_lines, and summary.
194
231
  """
195
232
  fm, issues = parse_frontmatter(content)
196
233
 
234
+ body_lines = body_line_count(content)
235
+ if max_body_lines is not None and body_lines is not None and body_lines > max_body_lines:
236
+ issues.append({
237
+ "severity": "high",
238
+ "field": "body",
239
+ "message": f"body lines {body_lines} exceeds max {max_body_lines}",
240
+ })
241
+
197
242
  if fm is not None:
198
243
  # Name validation
199
244
  name = fm.get("name", "")
@@ -255,6 +300,7 @@ def validate_frontmatter(
255
300
  "status": status,
256
301
  "issues": issues,
257
302
  "frontmatter": fm,
303
+ "body_lines": body_lines,
258
304
  "summary": {
259
305
  "total": len(issues),
260
306
  **severity_counts,
@@ -288,6 +334,17 @@ def main() -> int:
288
334
  default=None,
289
335
  help="expected skill directory name (default: derived from parent directory)",
290
336
  )
337
+ parser.add_argument(
338
+ "--max-body-lines",
339
+ metavar="N",
340
+ type=int,
341
+ default=None,
342
+ help=(
343
+ "when set, emit a high-severity 'body' issue if the SKILL.md body "
344
+ "exceeds N lines (opt-in; omitted = body size not checked). Pass "
345
+ "the skill-check body.max_lines default (500) to pre-catch that reject."
346
+ ),
347
+ )
291
348
  parser.add_argument(
292
349
  "-o", "--output",
293
350
  metavar="FILE",
@@ -302,12 +359,13 @@ def main() -> int:
302
359
  "status": "fail",
303
360
  "issues": [{"severity": "high", "field": "file", "message": f"File not found: {skill_md_path}"}],
304
361
  "frontmatter": None,
362
+ "body_lines": None,
305
363
  "summary": {"total": 1, "high": 1, "medium": 0, "low": 0},
306
364
  }
307
365
  else:
308
366
  content = skill_md_path.read_text(encoding="utf-8")
309
367
  skill_dir_name = args.skill_dir_name or skill_md_path.parent.name
310
- result = validate_frontmatter(content, skill_dir_name)
368
+ result = validate_frontmatter(content, skill_dir_name, args.max_body_lines)
311
369
 
312
370
  output_text = json.dumps(result, indent=2)
313
371