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