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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/workflows.md +1 -1
- package/package.json +2 -2
- package/src/knowledge/version-paths.md +4 -3
- package/src/shared/health-check.md +1 -1
- package/src/shared/scripts/schemas/skill-brief.v1.json +5 -0
- package/src/shared/scripts/skf-atomic-write.py +4 -1
- package/src/shared/scripts/skf-description-guard.py +33 -80
- package/src/shared/scripts/skf-detect-language.py +34 -0
- package/src/shared/scripts/skf-detect-workspaces.py +54 -0
- package/src/shared/scripts/skf-enumerate-stack-skills.py +107 -16
- package/src/shared/scripts/skf-forge-tier-rw.py +4 -1
- package/src/shared/scripts/skf-manifest-ops.py +8 -4
- package/src/shared/scripts/skf-merge-ccc-exclusions.py +4 -1
- package/src/shared/scripts/skf-qmd-classify-collections.py +29 -3
- package/src/shared/scripts/skf-rebuild-managed-sections.py +72 -7
- package/src/shared/scripts/skf-scan-skill-md-structure.py +13 -3
- package/src/shared/scripts/skf-validate-brief-inputs.py +79 -18
- package/src/shared/scripts/skf-validate-brief-schema.py +47 -1
- package/src/shared/scripts/skf-validate-frontmatter.py +60 -2
- package/src/shared/scripts/skf-write-skill-brief.py +97 -17
- package/src/skf-analyze-source/assets/skill-brief-schema.md +10 -2
- package/src/skf-analyze-source/references/continue.md +3 -1
- package/src/skf-analyze-source/references/generate-briefs.md +30 -9
- package/src/skf-analyze-source/references/recommend.md +1 -1
- package/src/skf-brief-skill/SKILL.md +4 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +6 -1
- package/src/skf-brief-skill/references/analyze-target.md +22 -6
- package/src/skf-brief-skill/references/confirm-brief.md +4 -0
- package/src/skf-brief-skill/references/gather-intent.md +72 -3
- package/src/skf-brief-skill/references/headless-args.md +3 -2
- package/src/skf-brief-skill/references/portfolio-similarity-check.md +16 -8
- package/src/skf-brief-skill/references/scope-definition.md +20 -2
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +12 -2
- package/src/skf-create-skill/assets/compile-assembly-rules.md +20 -1
- package/src/skf-create-skill/assets/skill-sections.md +2 -1
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +18 -5
- package/src/skf-create-skill/references/compile.md +5 -2
- package/src/skf-create-skill/references/extract.md +8 -3
- package/src/skf-create-skill/references/extraction-patterns.md +14 -5
- package/src/skf-create-skill/references/generate-artifacts.md +5 -1
- package/src/skf-create-skill/references/source-resolution-protocols.md +5 -2
- package/src/skf-create-skill/references/validate.md +4 -3
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +3 -3
- package/src/skf-create-stack-skill/assets/stack-skill-template.md +56 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +13 -2
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -0
- package/src/skf-create-stack-skill/references/detect-integrations.md +5 -2
- package/src/skf-create-stack-skill/references/generate-output.md +49 -9
- package/src/skf-create-stack-skill/references/validate.md +8 -5
- package/src/skf-export-skill/references/load-skill.md +5 -4
- package/src/skf-export-skill/references/manifest-rebuild.md +1 -1
- package/src/skf-export-skill/references/token-report.md +2 -2
- package/src/skf-export-skill/references/update-context.md +25 -11
- package/src/skf-quick-skill/assets/skill-template.md +2 -2
- package/src/skf-quick-skill/references/compile.md +4 -2
- package/src/skf-quick-skill/references/write-and-validate.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +1 -1
- package/src/skf-refine-architecture/references/gap-analysis.md +30 -4
- package/src/skf-refine-architecture/references/init.md +2 -0
- package/src/skf-refine-architecture/references/issue-detection.md +8 -3
- package/src/skf-test-skill/SKILL.md +1 -1
- package/src/skf-test-skill/references/coherence-check.md +4 -4
- package/src/skf-test-skill/references/coverage-check.md +58 -16
- package/src/skf-test-skill/references/external-validators.md +7 -7
- package/src/skf-test-skill/references/init.md +5 -4
- package/src/skf-test-skill/references/score.md +6 -3
- package/src/skf-test-skill/references/scoring-rules.md +12 -0
- package/src/skf-test-skill/references/source-access-protocol.md +19 -3
- package/src/skf-test-skill/scripts/compute-score.py +4 -1
- package/src/skf-update-skill/references/detect-changes.md +15 -2
- package/src/skf-update-skill/references/init.md +8 -1
- package/src/skf-update-skill/references/merge.md +22 -0
- package/src/skf-update-skill/references/re-extract.md +19 -7
- package/src/skf-update-skill/references/write.md +3 -0
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/src/skf-verify-stack/assets/feasibility-report-template.md +1 -1
- package/src/skf-verify-stack/references/coverage.md +35 -6
- package/src/skf-verify-stack/references/init.md +6 -2
- package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
- package/src/skf-verify-stack/references/integrations.md +7 -3
- package/src/skf-verify-stack/references/report.md +5 -1
- package/src/skf-verify-stack/references/requirements.md +3 -1
- package/src/skf-verify-stack/references/synthesize.md +11 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
101
|
-
#
|
|
102
|
-
#
|
|
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
|
-
#
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|