bmad-module-skill-forge 1.6.0 → 1.8.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 +10 -0
  8. package/src/shared/scripts/skf-atomic-write.py +4 -1
  9. package/src/shared/scripts/skf-detect-language.py +34 -0
  10. package/src/shared/scripts/skf-detect-workspaces.py +54 -0
  11. package/src/shared/scripts/skf-enumerate-stack-skills.py +107 -16
  12. package/src/shared/scripts/skf-forge-tier-rw.py +77 -1
  13. package/src/shared/scripts/skf-manifest-ops.py +8 -4
  14. package/src/shared/scripts/skf-merge-ccc-exclusions.py +4 -1
  15. package/src/shared/scripts/skf-qmd-classify-collections.py +29 -3
  16. package/src/shared/scripts/skf-rebuild-managed-sections.py +72 -7
  17. package/src/shared/scripts/skf-scan-skill-md-structure.py +13 -3
  18. package/src/shared/scripts/skf-validate-brief-inputs.py +79 -18
  19. package/src/shared/scripts/skf-validate-brief-schema.py +47 -1
  20. package/src/shared/scripts/skf-validate-frontmatter.py +111 -2
  21. package/src/shared/scripts/skf-write-skill-brief.py +97 -17
  22. package/src/skf-analyze-source/assets/skill-brief-schema.md +11 -2
  23. package/src/skf-analyze-source/references/generate-briefs.md +30 -9
  24. package/src/skf-analyze-source/references/identify-units.md +41 -4
  25. package/src/skf-analyze-source/references/init.md +6 -0
  26. package/src/skf-analyze-source/references/recommend.md +1 -1
  27. package/src/skf-analyze-source/references/scan-project.md +5 -2
  28. package/src/skf-analyze-source/references/unit-detection-heuristics.md +16 -0
  29. package/src/skf-brief-skill/SKILL.md +3 -3
  30. package/src/skf-brief-skill/assets/skill-brief-schema.md +6 -1
  31. package/src/skf-brief-skill/references/analyze-target.md +22 -6
  32. package/src/skf-brief-skill/references/confirm-brief.md +4 -0
  33. package/src/skf-brief-skill/references/gather-intent.md +14 -4
  34. package/src/skf-brief-skill/references/headless-args.md +3 -2
  35. package/src/skf-brief-skill/references/portfolio-similarity-check.md +16 -8
  36. package/src/skf-brief-skill/references/scope-definition.md +20 -2
  37. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  38. package/src/skf-brief-skill/references/write-brief.md +8 -2
  39. package/src/skf-create-skill/assets/compile-assembly-rules.md +19 -0
  40. package/src/skf-create-skill/assets/skill-sections.md +1 -0
  41. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +18 -5
  42. package/src/skf-create-skill/references/compile.md +5 -2
  43. package/src/skf-create-skill/references/extract.md +8 -3
  44. package/src/skf-create-skill/references/extraction-patterns.md +14 -5
  45. package/src/skf-create-skill/references/generate-artifacts.md +10 -11
  46. package/src/skf-create-skill/references/source-resolution-protocols.md +5 -2
  47. package/src/skf-create-skill/references/validate.md +4 -3
  48. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +3 -3
  49. package/src/skf-create-stack-skill/assets/stack-skill-template.md +56 -0
  50. package/src/skf-create-stack-skill/references/compile-stack.md +13 -2
  51. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -0
  52. package/src/skf-create-stack-skill/references/detect-integrations.md +5 -2
  53. package/src/skf-create-stack-skill/references/generate-output.md +49 -9
  54. package/src/skf-create-stack-skill/references/validate.md +8 -5
  55. package/src/skf-export-skill/references/load-skill.md +5 -4
  56. package/src/skf-export-skill/references/manifest-rebuild.md +1 -1
  57. package/src/skf-export-skill/references/token-report.md +2 -2
  58. package/src/skf-export-skill/references/update-context.md +25 -11
  59. package/src/skf-quick-skill/assets/skill-template.md +2 -2
  60. package/src/skf-quick-skill/references/compile.md +4 -2
  61. package/src/skf-quick-skill/references/write-and-validate.md +2 -2
  62. package/src/skf-refine-architecture/SKILL.md +1 -1
  63. package/src/skf-refine-architecture/references/gap-analysis.md +30 -4
  64. package/src/skf-refine-architecture/references/init.md +2 -0
  65. package/src/skf-refine-architecture/references/issue-detection.md +8 -3
  66. package/src/skf-test-skill/references/coherence-check.md +4 -4
  67. package/src/skf-test-skill/references/coverage-check.md +58 -16
  68. package/src/skf-test-skill/references/external-validators.md +7 -7
  69. package/src/skf-test-skill/references/init.md +5 -4
  70. package/src/skf-test-skill/references/score.md +6 -3
  71. package/src/skf-test-skill/references/scoring-rules.md +12 -0
  72. package/src/skf-test-skill/references/source-access-protocol.md +33 -3
  73. package/src/skf-test-skill/scripts/compute-score.py +4 -1
  74. package/src/skf-update-skill/references/detect-changes.md +15 -2
  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,30 @@ 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
24
+ uv run skf-validate-frontmatter.py <skill-md-path> --max-body-lines 500 --max-body-tokens 5000
23
25
 
24
26
  Input:
25
27
  Path to a SKILL.md file.
26
28
  Optional --skill-dir-name: expected directory name for name-match check.
27
29
  If omitted, derived from the parent directory of the SKILL.md path.
30
+ Optional --max-body-lines N: when set, emit a high-severity `body` issue
31
+ if the SKILL.md body (lines after the closing frontmatter delimiter)
32
+ exceeds N lines. Opt-in — when omitted, body size is never checked and
33
+ the verdict is unchanged. Callers pass the skill-check `body.max_lines`
34
+ default (500) to pre-catch that hard reject before commit.
35
+ Optional --max-body-tokens N: when set, emit a high-severity `body`
36
+ issue if the estimated body token count exceeds N. Token count is
37
+ estimated as ceil(character_count / 4). Callers pass the skill-check
38
+ `body.max_tokens` default (5000) to pre-catch that soft/hard reject
39
+ before commit.
28
40
 
29
41
  Output:
30
42
  JSON object:
31
43
  status: "pass" | "fail" | "warn"
32
44
  issues: list of { severity, field, message }
33
45
  frontmatter: parsed frontmatter dict (if parseable)
46
+ body_lines: int body-line count, or null when delimiters are absent
34
47
  summary: { total, high, medium, low }
35
48
 
36
49
  Exit codes:
@@ -122,6 +135,53 @@ def parse_frontmatter(content: str) -> tuple[dict | None, list[dict]]:
122
135
  return fm, issues
123
136
 
124
137
 
138
+ def body_line_count(content: str) -> int | None:
139
+ """Count the SKILL.md body lines — every line after the closing
140
+ frontmatter delimiter.
141
+
142
+ Mirrors skill-check's `body.max_lines` definition (body = content past
143
+ the frontmatter block) so a pre-commit gate keyed on this count agrees
144
+ with the post-commit `npx skill-check` verdict. Returns None when the
145
+ frontmatter delimiters are absent or unclosed — the body boundary is
146
+ then undefined and size cannot be assessed. Uses splitlines() to match
147
+ the line-counting convention used elsewhere in the SKF scripts (handles
148
+ LF/CRLF, no trailing-newline phantom line).
149
+ """
150
+ if not content.startswith("---\n") and not content.startswith("---\r\n"):
151
+ return None
152
+ lines = content.splitlines()
153
+ closing = -1
154
+ for i in range(1, len(lines)):
155
+ if lines[i] == "---":
156
+ closing = i
157
+ break
158
+ if closing == -1:
159
+ return None
160
+ return len(lines) - (closing + 1)
161
+
162
+
163
+ def body_token_estimate(content: str) -> int | None:
164
+ """Estimate the SKILL.md body token count as ceil(char_count / 4).
165
+
166
+ Returns None when the frontmatter delimiters are absent or unclosed
167
+ (same boundary condition as body_line_count). The heuristic matches
168
+ the agentskills.io spec's token estimation convention.
169
+ """
170
+ if not content.startswith("---\n") and not content.startswith("---\r\n"):
171
+ return None
172
+ lines = content.splitlines()
173
+ closing = -1
174
+ for i in range(1, len(lines)):
175
+ if lines[i] == "---":
176
+ closing = i
177
+ break
178
+ if closing == -1:
179
+ return None
180
+ body = "\n".join(lines[closing + 1:])
181
+ char_count = len(body)
182
+ return -(-char_count // 4) # ceil division
183
+
184
+
125
185
  def _validate_name(name: str, skill_dir_name: str | None) -> list[dict]:
126
186
  """Validate skill name format. Aligned with canonical validator.py."""
127
187
  issues: list[dict] = []
@@ -187,13 +247,36 @@ def _validate_name(name: str, skill_dir_name: str | None) -> list[dict]:
187
247
  def validate_frontmatter(
188
248
  content: str,
189
249
  skill_dir_name: str | None = None,
250
+ max_body_lines: int | None = None,
251
+ max_body_tokens: int | None = None,
190
252
  ) -> dict:
191
253
  """Validate SKILL.md frontmatter against agentskills.io spec.
192
254
 
193
- Returns a result dict with status, issues, frontmatter, and summary.
255
+ When `max_body_lines` is set, additionally emit a high-severity `body`
256
+ issue if the body exceeds that many lines. When `max_body_tokens` is
257
+ set, emit a high-severity `body` issue if the estimated token count
258
+ exceeds that limit. Both are opt-in — the verdict is unchanged when
259
+ they are None. Returns a result dict with status, issues, frontmatter,
260
+ body_lines, body_tokens, and summary.
194
261
  """
195
262
  fm, issues = parse_frontmatter(content)
196
263
 
264
+ body_lines = body_line_count(content)
265
+ if max_body_lines is not None and body_lines is not None and body_lines > max_body_lines:
266
+ issues.append({
267
+ "severity": "high",
268
+ "field": "body",
269
+ "message": f"body lines {body_lines} exceeds max {max_body_lines}",
270
+ })
271
+
272
+ body_tokens = body_token_estimate(content)
273
+ if max_body_tokens is not None and body_tokens is not None and body_tokens > max_body_tokens:
274
+ issues.append({
275
+ "severity": "high",
276
+ "field": "body",
277
+ "message": f"body token estimate {body_tokens} exceeds max {max_body_tokens}",
278
+ })
279
+
197
280
  if fm is not None:
198
281
  # Name validation
199
282
  name = fm.get("name", "")
@@ -255,6 +338,8 @@ def validate_frontmatter(
255
338
  "status": status,
256
339
  "issues": issues,
257
340
  "frontmatter": fm,
341
+ "body_lines": body_lines,
342
+ "body_tokens": body_tokens,
258
343
  "summary": {
259
344
  "total": len(issues),
260
345
  **severity_counts,
@@ -288,6 +373,29 @@ def main() -> int:
288
373
  default=None,
289
374
  help="expected skill directory name (default: derived from parent directory)",
290
375
  )
376
+ parser.add_argument(
377
+ "--max-body-lines",
378
+ metavar="N",
379
+ type=int,
380
+ default=None,
381
+ help=(
382
+ "when set, emit a high-severity 'body' issue if the SKILL.md body "
383
+ "exceeds N lines (opt-in; omitted = body size not checked). Pass "
384
+ "the skill-check body.max_lines default (500) to pre-catch that reject."
385
+ ),
386
+ )
387
+ parser.add_argument(
388
+ "--max-body-tokens",
389
+ metavar="N",
390
+ type=int,
391
+ default=None,
392
+ help=(
393
+ "when set, emit a high-severity 'body' issue if the estimated body "
394
+ "token count exceeds N (opt-in; omitted = body tokens not checked). "
395
+ "Token estimate uses ceil(char_count / 4). Pass the skill-check "
396
+ "body.max_tokens default (5000) to pre-catch that reject."
397
+ ),
398
+ )
291
399
  parser.add_argument(
292
400
  "-o", "--output",
293
401
  metavar="FILE",
@@ -302,12 +410,13 @@ def main() -> int:
302
410
  "status": "fail",
303
411
  "issues": [{"severity": "high", "field": "file", "message": f"File not found: {skill_md_path}"}],
304
412
  "frontmatter": None,
413
+ "body_lines": None,
305
414
  "summary": {"total": 1, "high": 1, "medium": 0, "low": 0},
306
415
  }
307
416
  else:
308
417
  content = skill_md_path.read_text(encoding="utf-8")
309
418
  skill_dir_name = args.skill_dir_name or skill_md_path.parent.name
310
- result = validate_frontmatter(content, skill_dir_name)
419
+ result = validate_frontmatter(content, skill_dir_name, args.max_body_lines, args.max_body_tokens)
311
420
 
312
421
  output_text = json.dumps(result, indent=2)
313
422