bmad-module-skill-forge 1.6.0 → 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-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/generate-briefs.md +30 -9
- package/src/skf-analyze-source/references/recommend.md +1 -1
- package/src/skf-brief-skill/SKILL.md +3 -3
- 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 +14 -4
- 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 +8 -2
- package/src/skf-create-skill/assets/compile-assembly-rules.md +19 -0
- package/src/skf-create-skill/assets/skill-sections.md +1 -0
- 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/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/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
|
@@ -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
|
|
|
@@ -44,14 +44,19 @@ Context payload shape (consumed by `write`):
|
|
|
44
44
|
"type": "full-library" | ...,
|
|
45
45
|
"include": ["src/**/*.ts"],
|
|
46
46
|
"exclude": ["**/*.test.*"],
|
|
47
|
-
"notes": ""
|
|
47
|
+
"notes": "",
|
|
48
|
+
# Conditionally present (preserved verbatim on a ratify/re-write):
|
|
49
|
+
"tier_a_include": ["code/core/src/**"], # stratified-scope monorepos
|
|
50
|
+
"amendments": [{...}] # post-authoring audit log
|
|
48
51
|
},
|
|
49
52
|
|
|
50
53
|
# Conditionally present:
|
|
51
54
|
"doc_urls": [{"url": "...", "label": "..."}],
|
|
52
55
|
"scripts_intent": "detect" | "none" | free-text,
|
|
53
56
|
"assets_intent": "detect" | "none" | free-text,
|
|
54
|
-
"source_authority": "official" | "community" | "internal"
|
|
57
|
+
"source_authority": "official" | "community" | "internal",
|
|
58
|
+
"target_ref": "livekit/v0.7.42", # monorepo tag escape hatch
|
|
59
|
+
"source_ref": "v0.5.0" # auto-resolved git ref
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
Version precedence (resolved into the rendered YAML's `version` field):
|
|
@@ -84,14 +89,18 @@ Flat input form (`--from-flat`):
|
|
|
84
89
|
"forge_tier": "Quick",
|
|
85
90
|
"created": "2026-05-02",
|
|
86
91
|
"created_by": "armel",
|
|
87
|
-
"scope_type":
|
|
88
|
-
"scope_include":
|
|
89
|
-
"scope_exclude":
|
|
90
|
-
"scope_notes":
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
92
|
+
"scope_type": "full-library",
|
|
93
|
+
"scope_include": ["src/**/*.ts"],
|
|
94
|
+
"scope_exclude": ["**/*.test.*"],
|
|
95
|
+
"scope_notes": "",
|
|
96
|
+
"scope_tier_a_include": null | ["code/core/src/**"],
|
|
97
|
+
"scope_amendments": null | [{...}],
|
|
98
|
+
"doc_urls": null | [...],
|
|
99
|
+
"scripts_intent": null | "detect" | "none" | "...",
|
|
100
|
+
"assets_intent": null | "detect" | "none" | "...",
|
|
101
|
+
"source_authority": null | "official" | "community" | "internal",
|
|
102
|
+
"target_ref": null | "livekit/v0.7.42",
|
|
103
|
+
"source_ref": null | "v0.5.0"
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
Output (success):
|
|
@@ -293,6 +302,42 @@ def validate_context(ctx: dict[str, Any]) -> list[str]:
|
|
|
293
302
|
field=f"scope.rationale.{rk}",
|
|
294
303
|
)
|
|
295
304
|
|
|
305
|
+
# scope.tier_a_include — optional narrower tier-A include list for
|
|
306
|
+
# stratified-scope monorepos (read by skf-test-skill for the coverage
|
|
307
|
+
# denominator). Absent/None → key omitted. When present it must be a list
|
|
308
|
+
# of glob strings; the writer preserves it verbatim so a ratify/re-write
|
|
309
|
+
# does not silently drop it.
|
|
310
|
+
tier_a_include = scope.get("tier_a_include")
|
|
311
|
+
if tier_a_include is not None:
|
|
312
|
+
if not isinstance(tier_a_include, list):
|
|
313
|
+
_die("scope.tier_a_include must be an array of glob strings", field="scope.tier_a_include")
|
|
314
|
+
for i, pat in enumerate(tier_a_include):
|
|
315
|
+
if not isinstance(pat, str) or not pat:
|
|
316
|
+
_die(
|
|
317
|
+
f"scope.tier_a_include[{i}] must be a non-empty glob string",
|
|
318
|
+
field="scope.tier_a_include",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
# scope.amendments — optional additive audit log written post-authoring by
|
|
322
|
+
# skf-create-skill / skf-update-skill. The writer is not the amendments
|
|
323
|
+
# schema authority (those workflows own the entry shape); it only preserves
|
|
324
|
+
# the log verbatim on re-write. Light check: a list of objects.
|
|
325
|
+
amendments = scope.get("amendments")
|
|
326
|
+
if amendments is not None:
|
|
327
|
+
if not isinstance(amendments, list):
|
|
328
|
+
_die("scope.amendments must be an array of amendment objects", field="scope.amendments")
|
|
329
|
+
for i, entry in enumerate(amendments):
|
|
330
|
+
if not isinstance(entry, dict):
|
|
331
|
+
_die(f"scope.amendments[{i}] must be an object", field="scope.amendments")
|
|
332
|
+
|
|
333
|
+
# target_ref / source_ref — optional git refs (top-level). target_ref is a
|
|
334
|
+
# remote-monorepo tag escape hatch; source_ref is the auto-resolved ref.
|
|
335
|
+
# Both must round-trip on a ratify/re-write rather than being dropped.
|
|
336
|
+
for ref_field in ("target_ref", "source_ref"):
|
|
337
|
+
ref_val = ctx.get(ref_field)
|
|
338
|
+
if ref_val is not None and (not isinstance(ref_val, str) or not ref_val):
|
|
339
|
+
_die(f"{ref_field} must be a non-empty string when present", field=ref_field)
|
|
340
|
+
|
|
296
341
|
# target_version semver shape (when present)
|
|
297
342
|
tv = ctx.get("target_version")
|
|
298
343
|
if tv is not None:
|
|
@@ -320,6 +365,19 @@ def assemble_brief(ctx: dict[str, Any], resolved_version: str) -> dict[str, Any]
|
|
|
320
365
|
if source_type == "docs-only":
|
|
321
366
|
source_authority = "community" # forced
|
|
322
367
|
|
|
368
|
+
# Build the scope sub-object. tier_a_include (when present) sits between
|
|
369
|
+
# exclude and notes, matching the schema doc's illustrative order. Absent →
|
|
370
|
+
# the key is omitted, so briefs without it render byte-identically to before.
|
|
371
|
+
scope_obj: dict[str, Any] = {
|
|
372
|
+
"type": ctx["scope"]["type"],
|
|
373
|
+
"include": list(ctx["scope"]["include"]),
|
|
374
|
+
"exclude": list(ctx["scope"]["exclude"]),
|
|
375
|
+
}
|
|
376
|
+
scope_tier_a = ctx["scope"].get("tier_a_include")
|
|
377
|
+
if scope_tier_a is not None:
|
|
378
|
+
scope_obj["tier_a_include"] = list(scope_tier_a)
|
|
379
|
+
scope_obj["notes"] = ctx["scope"]["notes"]
|
|
380
|
+
|
|
323
381
|
brief: dict[str, Any] = {
|
|
324
382
|
"name": ctx["name"],
|
|
325
383
|
"version": resolved_version,
|
|
@@ -330,12 +388,7 @@ def assemble_brief(ctx: dict[str, Any], resolved_version: str) -> dict[str, Any]
|
|
|
330
388
|
"forge_tier": ctx["forge_tier"],
|
|
331
389
|
"created": ctx["created"],
|
|
332
390
|
"created_by": ctx["created_by"],
|
|
333
|
-
"scope":
|
|
334
|
-
"type": ctx["scope"]["type"],
|
|
335
|
-
"include": list(ctx["scope"]["include"]),
|
|
336
|
-
"exclude": list(ctx["scope"]["exclude"]),
|
|
337
|
-
"notes": ctx["scope"]["notes"],
|
|
338
|
-
},
|
|
391
|
+
"scope": scope_obj,
|
|
339
392
|
}
|
|
340
393
|
|
|
341
394
|
# Conditional: scope.rationale — canonical position is after `notes` and
|
|
@@ -353,6 +406,13 @@ def assemble_brief(ctx: dict[str, Any], resolved_version: str) -> dict[str, Any]
|
|
|
353
406
|
"recorded": scope_rationale["recorded"],
|
|
354
407
|
}
|
|
355
408
|
|
|
409
|
+
# Conditional: scope.amendments — additive audit log, emitted verbatim after
|
|
410
|
+
# rationale (matches the schema doc order). The writer preserves it as-is;
|
|
411
|
+
# the writing authority for entry shape is skf-create-skill / skf-update-skill.
|
|
412
|
+
scope_amendments = ctx["scope"].get("amendments")
|
|
413
|
+
if scope_amendments is not None:
|
|
414
|
+
brief["scope"]["amendments"] = scope_amendments
|
|
415
|
+
|
|
356
416
|
# Conditional: target_version (must equal version)
|
|
357
417
|
tv = ctx.get("target_version")
|
|
358
418
|
if tv is not None:
|
|
@@ -364,6 +424,14 @@ def assemble_brief(ctx: dict[str, Any], resolved_version: str) -> dict[str, Any]
|
|
|
364
424
|
)
|
|
365
425
|
brief["target_version"] = tv
|
|
366
426
|
|
|
427
|
+
# Conditional: target_ref / source_ref git refs — preserved verbatim when
|
|
428
|
+
# present (monorepo tag escape hatch + auto-resolved ref). Emitted after
|
|
429
|
+
# target_version so a ratify/re-write does not strip them.
|
|
430
|
+
for ref_field in ("target_ref", "source_ref"):
|
|
431
|
+
ref_val = ctx.get(ref_field)
|
|
432
|
+
if ref_val is not None:
|
|
433
|
+
brief[ref_field] = ref_val
|
|
434
|
+
|
|
367
435
|
# Conditional: doc_urls (always emitted when present)
|
|
368
436
|
doc_urls = ctx.get("doc_urls")
|
|
369
437
|
if doc_urls:
|
|
@@ -412,8 +480,11 @@ def atomic_write(target: Path, content: str) -> int:
|
|
|
412
480
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
413
481
|
tmp = target.with_name(target.name + ".skf-tmp")
|
|
414
482
|
encoded = content.encode("utf-8")
|
|
483
|
+
# O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
|
|
484
|
+
# translation that would otherwise corrupt verbatim writes on Windows.
|
|
485
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0)
|
|
415
486
|
try:
|
|
416
|
-
fd = os.open(tmp,
|
|
487
|
+
fd = os.open(tmp, flags, 0o644)
|
|
417
488
|
try:
|
|
418
489
|
os.write(fd, encoded)
|
|
419
490
|
os.fsync(fd)
|
|
@@ -439,6 +510,8 @@ _FLAT_SCOPE_KEYS = (
|
|
|
439
510
|
"scope_exclude",
|
|
440
511
|
"scope_notes",
|
|
441
512
|
"scope_rationale",
|
|
513
|
+
"scope_tier_a_include",
|
|
514
|
+
"scope_amendments",
|
|
442
515
|
)
|
|
443
516
|
|
|
444
517
|
|
|
@@ -493,6 +566,13 @@ def flat_to_nested(flat: dict[str, Any]) -> dict[str, Any]:
|
|
|
493
566
|
# six-subkey object validated by validate_context.
|
|
494
567
|
if "scope_rationale" in flat and flat["scope_rationale"] is not None:
|
|
495
568
|
scope["rationale"] = flat["scope_rationale"]
|
|
569
|
+
# Optional stratified-scope tier-A include list and post-authoring
|
|
570
|
+
# amendments log. Same null-drop semantics — preserved on a ratify
|
|
571
|
+
# re-write so the brief's authored surface and audit trail survive.
|
|
572
|
+
if "scope_tier_a_include" in flat and flat["scope_tier_a_include"] is not None:
|
|
573
|
+
scope["tier_a_include"] = flat["scope_tier_a_include"]
|
|
574
|
+
if "scope_amendments" in flat and flat["scope_amendments"] is not None:
|
|
575
|
+
scope["amendments"] = flat["scope_amendments"]
|
|
496
576
|
nested["scope"] = scope
|
|
497
577
|
return nested
|
|
498
578
|
|
|
@@ -27,6 +27,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
|
|
|
27
27
|
| `scripts_intent` | string | `detect` / `none` / free-text | Describes whether scripts should be extracted. Values: `detect` (auto-detect from source — default when absent), `none` (skip scripts), or a free-text description of expected scripts (e.g., "CLI validation tools in bin/"). |
|
|
28
28
|
| `assets_intent` | string | `detect` / `none` / free-text | Describes whether assets should be extracted. Values: `detect` (auto-detect from source — default when absent), `none` (skip assets), or a free-text description of expected assets (e.g., "JSON schemas in schemas/"). |
|
|
29
29
|
| `target_version` | string | Semantic version (`X.Y.Z` or `X.Y.Z-prerelease`) | User-specified target version. When present, overrides auto-detection and becomes the skill's version. Recommended for docs-only skills where auto-detection is unavailable. |
|
|
30
|
+
| `target_ref` | string | Git ref (tag or branch) | Optional. Explicit git ref used verbatim as the resolved `source_ref`, bypassing version-to-tag matching. Escape hatch for monorepo crate tags whose prefix differs from the skill name (e.g. tag `livekit/v0.7.42` for skill `livekit-rust`). Remote sources only. |
|
|
30
31
|
| `source_authority` | string | `official` / `community` / `internal` | Default `community`. Set to `official` only when the skill creator is the library maintainer. Forced to `community` when `source_type: "docs-only"`. |
|
|
31
32
|
| `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
|
|
32
33
|
|
|
@@ -59,11 +60,15 @@ The create-skill workflow (extract) also performs version reconciliation at extr
|
|
|
59
60
|
|
|
60
61
|
```yaml
|
|
61
62
|
scope:
|
|
62
|
-
type: full-library | specific-modules | public-api | component-library | docs-only
|
|
63
|
+
type: full-library | specific-modules | public-api | component-library | reference-app | docs-only
|
|
63
64
|
include:
|
|
64
65
|
- 'src/**/*.ts' # At least one required
|
|
65
66
|
exclude:
|
|
66
67
|
- 'src/**/*.test.ts' # Optional
|
|
68
|
+
# Optional: narrower tier-A include list for stratified-scope monorepos
|
|
69
|
+
# and reference-app pattern surfaces (refined later by skf-brief-skill)
|
|
70
|
+
# tier_a_include:
|
|
71
|
+
# - 'code/core/src/manager-api/**'
|
|
67
72
|
notes: 'Optional rationale for scope decision'
|
|
68
73
|
# Additional fields when scope.type is "component-library":
|
|
69
74
|
# registry_path: "path/to/registry.ts" # Optional — auto-detected if omitted
|
|
@@ -83,8 +88,11 @@ scope:
|
|
|
83
88
|
| specific-modules | Selected components or packages |
|
|
84
89
|
| public-api | Only exported interfaces |
|
|
85
90
|
| component-library | UI component libraries with registries, props-based APIs, and design system variants |
|
|
91
|
+
| reference-app | Whole app whose value is wiring patterns, not public exports (embedded sidecars, CLI demos, integration-pattern demonstrators). Also the home for **language / spec references** — engine- or spec-versioned query languages, grammars, or DSLs (e.g. SurrealQL) whose value is construct idioms, not exports. These have no separate scope type; they ride `reference-app` as a sub-shape (see `skf-create-skill` Language / spec-reference sub-shape) |
|
|
86
92
|
| docs-only | When source_type is docs-only — no source code available, all content from doc_urls |
|
|
87
93
|
|
|
94
|
+
> **Documented vs source language.** The `language` field records the language the skill *documents*. For a language / spec reference this may differ from the source language it is extracted from — e.g. a SurrealQL reference extracted from a Rust engine records `language: surrealql`, not `rust`. Use the documented language and a matching code-fence default (e.g. ` ```surql `) throughout the brief.
|
|
95
|
+
|
|
88
96
|
## YAML Template
|
|
89
97
|
|
|
90
98
|
```yaml
|
|
@@ -111,7 +119,7 @@ created_by: '{user_name}'
|
|
|
111
119
|
1. **Name uniqueness**: No duplicate names within forge_data_folder
|
|
112
120
|
2. **Source accessibility**: source_repo path must exist or be reachable
|
|
113
121
|
3. **Language recognized**: Must be a known programming language
|
|
114
|
-
4. **Scope type valid**: Must match one of the
|
|
122
|
+
4. **Scope type valid**: Must match one of the six defined types (full-library, specific-modules, public-api, component-library, reference-app, docs-only)
|
|
115
123
|
5. **Include patterns**: At least one include glob pattern required (exception: `docs-only` scope, where include patterns are optional since no source code is available)
|
|
116
124
|
6. **Forge tier match**: Must match value in forge-tier.yaml
|
|
117
125
|
7. **Docs-only mode**: When `source_type: "docs-only"`, `doc_urls` required (>= 1), `source_repo` optional
|