bmad-module-skill-forge 4.0.0 → 6.0.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 (128) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/agents.md +1 -1
  5. package/docs/architecture.md +5 -5
  6. package/docs/campaign.md +1 -1
  7. package/docs/concepts.md +4 -4
  8. package/docs/examples.md +32 -25
  9. package/docs/getting-started.md +3 -3
  10. package/docs/how-it-works.md +2 -2
  11. package/docs/index.md +1 -1
  12. package/docs/skill-model.md +41 -39
  13. package/docs/troubleshooting.md +9 -3
  14. package/docs/verifying-a-skill.md +42 -21
  15. package/docs/why-skf.md +1 -1
  16. package/docs/workflows.md +6 -6
  17. package/package.json +1 -1
  18. package/src/knowledge/tool-resolution.md +6 -6
  19. package/src/shared/references/headless-gate-convention.md +4 -1
  20. package/src/shared/references/output-contract-schema.md +3 -3
  21. package/src/shared/references/pipeline-contracts.md +2 -1
  22. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  25. package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
  26. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  27. package/src/shared/scripts/skf-count-tokens.py +54 -10
  28. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  29. package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
  30. package/src/shared/scripts/skf-extract-public-api.py +472 -120
  31. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  32. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  33. package/src/shared/scripts/skf-parse-gaps.py +136 -26
  34. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  35. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  36. package/src/shared/scripts/skf-shard-body.py +239 -41
  37. package/src/shared/scripts/skf-validate-output.py +12 -3
  38. package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
  39. package/src/shared/tool-requirements.yaml +8 -2
  40. package/src/skf-analyze-source/SKILL.md +3 -2
  41. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  42. package/src/skf-analyze-source/references/generate-briefs.md +1 -1
  43. package/src/skf-analyze-source/references/headless-contract.md +1 -1
  44. package/src/skf-audit-skill/SKILL.md +4 -3
  45. package/src/skf-audit-skill/references/re-index.md +2 -2
  46. package/src/skf-audit-skill/references/report.md +1 -1
  47. package/src/skf-brief-skill/SKILL.md +3 -2
  48. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  49. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-campaign/SKILL.md +3 -2
  52. package/src/skf-campaign/references/campaign-contracts.md +2 -0
  53. package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
  54. package/src/skf-campaign/references/step-10-export.md +3 -2
  55. package/src/skf-create-skill/SKILL.md +4 -3
  56. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  57. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  58. package/src/skf-create-skill/references/component-extraction.md +1 -1
  59. package/src/skf-create-skill/references/extract.md +3 -2
  60. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  61. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  62. package/src/skf-create-skill/references/report.md +8 -3
  63. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  64. package/src/skf-create-skill/references/validate.md +1 -1
  65. package/src/skf-create-stack-skill/SKILL.md +3 -2
  66. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  67. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  68. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  69. package/src/skf-create-stack-skill/references/report.md +1 -1
  70. package/src/skf-create-stack-skill/references/validate.md +1 -1
  71. package/src/skf-drop-skill/SKILL.md +3 -2
  72. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  73. package/src/skf-drop-skill/references/report.md +1 -1
  74. package/src/skf-export-skill/SKILL.md +3 -2
  75. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  76. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  77. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  78. package/src/skf-export-skill/references/load-skill.md +1 -1
  79. package/src/skf-export-skill/references/summary.md +1 -1
  80. package/src/skf-export-skill/references/token-report.md +29 -9
  81. package/src/skf-forger/SKILL.md +3 -2
  82. package/src/skf-forger/references/pipeline-mode.md +2 -2
  83. package/src/skf-quick-skill/SKILL.md +4 -3
  84. package/src/skf-quick-skill/references/finalize.md +1 -1
  85. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  86. package/src/skf-quick-skill/references/write-and-validate.md +1 -1
  87. package/src/skf-refine-architecture/SKILL.md +8 -9
  88. package/src/skf-refine-architecture/customize.toml +1 -1
  89. package/src/skf-refine-architecture/references/report.md +1 -1
  90. package/src/skf-rename-skill/SKILL.md +3 -2
  91. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  92. package/src/skf-setup/SKILL.md +3 -2
  93. package/src/skf-setup/references/ccc-index.md +1 -1
  94. package/src/skf-setup/references/invocation-contract.md +1 -1
  95. package/src/skf-test-skill/SKILL.md +3 -2
  96. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  97. package/src/skf-test-skill/references/coherence-check.md +11 -8
  98. package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
  99. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  100. package/src/skf-test-skill/references/coverage-check.md +60 -97
  101. package/src/skf-test-skill/references/init.md +2 -1
  102. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  103. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  104. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  105. package/src/skf-test-skill/references/report.md +5 -5
  106. package/src/skf-test-skill/references/scoring-rules.md +8 -7
  107. package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
  108. package/src/skf-test-skill/references/source-access-protocol.md +14 -10
  109. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  110. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  111. package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
  112. package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
  113. package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
  114. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  115. package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
  116. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  117. package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
  118. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  119. package/src/skf-update-skill/SKILL.md +3 -2
  120. package/src/skf-update-skill/references/detect-changes.md +1 -1
  121. package/src/skf-update-skill/references/gap-driven.md +54 -33
  122. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  123. package/src/skf-update-skill/references/merge.md +15 -10
  124. package/src/skf-update-skill/references/re-extract.md +3 -1
  125. package/src/skf-update-skill/references/report.md +6 -5
  126. package/src/skf-update-skill/references/write.md +42 -20
  127. package/src/skf-verify-stack/SKILL.md +4 -3
  128. package/tools/cli/lib/tool-check.js +158 -2
@@ -37,9 +37,11 @@ Subcommands:
37
37
  any case) and its entry-point diff can be read, each export of a
38
38
  language family whose entry_points.by_language status is
39
39
  `barrel` is marked `public: true` when its name and file are a
40
- pair of the diff's `public` (its name, or the name `local` gives
41
- it in that file) or `extraction_gaps`, or when a `public` item of
42
- its name and family has no file; `public: false` otherwise. A
40
+ pair of the diff's `public` or `other_definitions` (each other
41
+ in-scope definition of a public name, #830; its name, or the
42
+ name `local` gives it in that file) or `extraction_gaps`, or
43
+ when a `public` item of its name and family has no file;
44
+ `public: false` otherwise. A
43
45
  `public` item with `via` namespace and a `members` list (what
44
46
  the submodule passes on, which users reach as `pkg.sub.name`)
45
47
  gives its members' pairs (each member's name, or its `local`
@@ -208,6 +210,7 @@ Inventory shape (the keys this helper reads or writes):
208
210
  "authoritative_files_scan": {...} | null,
209
211
  "extraction_rules": {"recipe_set", "recipes", "scope", "ast_grep_version"},
210
212
  "public_surface": {"by_language": {...}, "public": [...],
213
+ "other_definitions": [...],
211
214
  "extraction_gaps": [...]} | null,
212
215
  "t2_annotations": [...], "t3_items": [...], "warnings": ["..."]
213
216
  }
@@ -447,7 +450,7 @@ class _PublicSurface:
447
450
  self.barrels = {family for family, status in statuses.items() if status == "barrel"}
448
451
  self.pairs: set[tuple[str, str]] = set()
449
452
  self.unfiled: set[tuple[object, str]] = set() # (family, name) of a public name no file defines
450
- for key in ("public", "extraction_gaps"):
453
+ for key in ("public", "other_definitions", "extraction_gaps"):
451
454
  for item in diff.get(key) or []:
452
455
  if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"]:
453
456
  continue
@@ -459,8 +462,8 @@ class _PublicSurface:
459
462
  if isinstance(member, dict) and isinstance(member.get("name"), str) and member["name"]:
460
463
  self._add(family, member["name"], member.get("local"), member.get("file"), True)
461
464
  continue
462
- self._add(family, item["name"], item.get("local") if key == "public" else None, item.get("file"),
463
- key == "public")
465
+ self._add(family, item["name"], item.get("local") if key != "extraction_gaps" else None,
466
+ item.get("file"), key == "public")
464
467
 
465
468
  def _add(self, family: object, name: str, local: object, path: object, listed_public: bool) -> None:
466
469
  """The pairs one public name (or gap) gives: (name, file) and (local, file), or, with no file, its
@@ -507,6 +510,7 @@ def public_surface(extraction: object) -> tuple[_PublicSurface | None, str | Non
507
510
  # The fields of an entry_point_diff item the rule reads, which the inventory's public_surface keeps for `add`, and
508
511
  # those of each member of a namespace item.
509
512
  SURFACE_FIELDS = {"public": ("name", "language", "file", "local", "via", "members"),
513
+ "other_definitions": ("name", "language", "file", "local"),
510
514
  "extraction_gaps": ("name", "language", "file")}
511
515
  MEMBER_FIELDS = ("name", "local", "file")
512
516
  NOT_PUBLIC_NAMED = 10 # the names the not-public warning spells out, as update-skill's apply does
@@ -130,12 +130,12 @@ build
130
130
  recipe found (the runner's `entry_point_diff.extraction_gaps`), less the
131
131
  ones an export of the snapshot now holds, then, with --provenance-map,
132
132
  the baseline gaps: each entry of the map the snapshot lacks whose file
133
- still declares it (the runner leaves out an underscore name such as
134
- `__version__`, and a module), which would otherwise read as removed. A
135
- baseline gap gives the line that declares the name, `entry` null and,
136
- when the entry has one, its `export_type`. It is checked only for an
137
- entry whose file is `extracted` or `read-by-eye`, whose name is not
138
- dotted, and that the snapshot holds neither under its name nor under
133
+ still declares it (the runner leaves out a Python or Rust underscore
134
+ name such as `__version__`, and a module), which would otherwise read as
135
+ removed. A baseline gap gives the line that declares the name, `entry`
136
+ null and, when the entry has one, its `export_type`. It is checked only
137
+ for an entry whose file is `extracted` or `read-by-eye`, whose name is
138
+ not dotted, and that the snapshot holds neither under its name nor under
139
139
  its `reexported_as` target at that file. The selection and the
140
140
  declaration are skf-verify-provenance-completeness.py's (`baseline_gaps`,
141
141
  by `declared_line`), loaded from beside this script, the rule
@@ -91,10 +91,13 @@ provenance map): for a gap with an `export` (null for one with none),
91
91
  "candidates": [{"source_file", "source_line", "export_type"}, ...]}
92
92
 
93
93
  `candidates` holds every entries[] row whose export_name is the export, in
94
- the map's order. With a `source_citation`, only the rows whose source_file
95
- is the citation's file count (both compared stripped, with backslashes read
96
- as `/` and no leading `./`), and of several such rows the one whose
97
- source_line is the citation's line. Without one, every candidate counts.
94
+ the map's order; when no row has that export_name, every row whose
95
+ `reexported_as` is the export (one the map holds under its declared name,
96
+ which a gap names by the public name a re-export gives it), each view then
97
+ adding the row's own `export_name`. With a `source_citation`, only the
98
+ rows whose source_file is the citation's file count (both compared
99
+ stripped, with backslashes read as `/` and no leading `./`), and of
100
+ several such rows the one whose source_line is the citation's line. Without one, every candidate counts.
98
101
  One row left is `found` (its `entry`), several are `ambiguous` (a spot-check
99
102
  of the wrong one would move another export's line) and none is `not-found`.
100
103
  `match` prints {"status": "ok", "name", "citation", "map_match"} for a name
@@ -142,16 +145,34 @@ Translate (update-skill gap-driven.md §1): `translate` turns the gaps
142
145
  `parse` wrote (--gaps) into the change manifest, written to -o as
143
146
  {"mode": "gap-driven", "entries": [...]}. Each gap is routed by its
144
147
  `category` through CHANGE_CATEGORIES (gap-driven.md §1's table), never by
145
- its severity; a category the table does not hold is not routed. Each
146
- entry, in this key order:
148
+ its severity; a category the table does not hold is not routed, and takes
149
+ no entry, so nothing is written for it. A `stale-documentation` gap is not
150
+ routed: the source has no line to repair the name from, and removing its
151
+ documentation unchecked could delete a name the surface missed or one the
152
+ source still declares. Nor is a `metadata-drift` gap: write.md §2's
153
+ `metadata` call recounts the counts a patch would set (except under the
154
+ drift override and for a docs-only skill, where it passes the counts
155
+ metadata.json records), so no metadata patch holds. Each entry, in this
156
+ key order:
147
157
 
148
158
  name the gap's `export`, else the answered `name`
149
159
  gap_id, category the gap's `id`, its `category` (else the
150
160
  answered one), `severity`
151
161
  severity
152
162
  source_citation the gap's, left out when it is null or names a
153
- line inside the skill package (`SKILL.md` or a
154
- `references/*.md` file, under any folder)
163
+ line inside the skill package: `SKILL.md` or a
164
+ `.md` file under `references/`, written relative
165
+ to the package, after `{skill_package}/`, or
166
+ after the package folder of SKF's layout
167
+ (`skills/{name}/{version}/{name}/` or
168
+ `skills/{name}/`, after any leading folders)
169
+ package_citation a `split-body-mismatch` gap's in-package
170
+ citation, {"file", "line"}, its file relative to
171
+ the package (`{skill_package}/` dropped): the
172
+ file merge Priority 8 edits. Left out for any
173
+ other gap, and for one whose category is a
174
+ judgment's answer: an older report's Source may
175
+ name the body it kept
155
176
  remediation_paths, resolved_paths, rejected_paths
156
177
  the gap's ([] when parse had no --source-root);
157
178
  a `provenance-completeness` gap with no
@@ -193,14 +214,20 @@ the gap's own text to judge from, each need one of:
193
214
  `provenance-completeness` gap with no path to scan
194
215
 
195
216
  --judgments answers them: a JSON object keyed by gap id, each value an
196
- object of `category` (a slug, or null: not routed), `name` (a string, or
217
+ object of `category` (a slug, one the table does not route giving that
218
+ category's reason, or null: not routed), `name` (a string, or
197
219
  null: not routed), `rescope` (true or false; given for any `missing-export`
198
220
  or `missing-type` gap, it is honoured) and `source_file` (a path relative
199
221
  to the source root, or null). Other keys are left for gap-records. With
200
222
  every need answered it writes the manifest and prints {"status":
201
223
  "written", "output", "gap_count", "not_routed": [{"id", "title",
202
- "category"}], "warnings": [...]}. `warnings` holds parse's warnings as
203
- `test-report: <warning>`, `test-report: not routed: <id> (<category>)` for
224
+ "category", "reason"}], "warnings": [...]}. A not-routed gap's `reason`
225
+ says why update-skill does not repair it and who does: NOT_ROUTED_REASONS
226
+ gives one for each ledger category the table does not route (gap-driven.md
227
+ §1's Not routed list), NO_REPAIR is the reason of a gap with no
228
+ category or one no table holds, and NO_EXPORT that of a gap whose export
229
+ is answered null. `warnings` holds parse's warnings as `test-report:
230
+ <warning>`, `test-report: not routed: <id> (<category>): <reason>` for
204
231
  each gap not routed, and, with --read-only, `proposed-amendment: excluded
205
232
  <path> (scope-expansion); not written: --<mode>` for each rescope.
206
233
 
@@ -865,10 +892,16 @@ def _line_number(value: object) -> int | None:
865
892
  def map_match(entries: list, name: str, citation: dict | None) -> dict:
866
893
  """The provenance map entry an export names, keyed on its citation (see "Map match" in the module docstring)."""
867
894
  def view(entry: dict) -> dict:
868
- return {key: entry.get(key) for key in ("source_file", "source_line", "export_type")}
895
+ out = {key: entry.get(key) for key in ("source_file", "source_line", "export_type")}
896
+ if entry["export_name"].strip() != name.strip(): # matched by its reexported_as: the name to check
897
+ out["export_name"] = entry["export_name"]
898
+ return out
869
899
 
870
- named = [e for e in entries if isinstance(e, dict) and isinstance(e.get("export_name"), str)
871
- and e["export_name"].strip() == name.strip()]
900
+ def named_by(key: str) -> list[dict]:
901
+ return [e for e in entries if isinstance(e, dict) and isinstance(e.get("export_name"), str)
902
+ and isinstance(e.get(key), str) and e[key].strip() == name.strip()]
903
+
904
+ named = named_by("export_name") or named_by("reexported_as")
872
905
  left = named
873
906
  if citation:
874
907
  cited = _norm_file(citation.get("file"))
@@ -959,7 +992,6 @@ CHANGE_CATEGORIES = {
959
992
  "provenance-completeness": "NEW_EXPORT",
960
993
  "signature-mismatch": "MODIFIED_EXPORT",
961
994
  "fabricated-signature": "MODIFIED_EXPORT",
962
- "stale-documentation": "MODIFIED_EXPORT",
963
995
  "provenance-line": "MOVED_EXPORT",
964
996
  "split-body-mismatch": "STRUCTURAL_FIX",
965
997
  "structural": "STRUCTURAL_FIX",
@@ -969,9 +1001,51 @@ CHANGE_CATEGORIES = {
969
1001
  "integration-pattern": "STRUCTURAL_FIX",
970
1002
  "migration-section": "STRUCTURAL_FIX",
971
1003
  "scripts-assets": "STRUCTURAL_FIX",
972
- "metadata-drift": "metadata update",
973
1004
  "metadata": "metadata update",
974
1005
  }
1006
+ # gap-driven.md §1's Not routed list, word for word: why update-skill repairs no gap of each other ledger category,
1007
+ # and who does. A gap takes NO_REPAIR when it has no category or one no table holds, and NO_EXPORT when its export
1008
+ # is answered null.
1009
+ NOT_ROUTED_REASONS = {
1010
+ "stale-documentation": (
1011
+ "the source has no line to repair the name from, so a person removes its documentation when the name is "
1012
+ "gone from the source, fixes the brief's scope when the surface missed it, or, when the source still "
1013
+ "declares the name, keeps the documentation and re-runs Test Skill at a tier that classifies it"),
1014
+ "metadata-drift": (
1015
+ "update-skill recounts `stats.exports_public_api`, `stats.exports_documented` and "
1016
+ "`stats.effective_denominator` each time it writes metadata.json, so a metadata patch to them does not hold "
1017
+ "(under `--allow-workspace-drift` and for a docs-only skill it keeps the recorded public API counts and "
1018
+ "`stats.effective_denominator` instead); Create Skill rebuilds diverging barrel counts from the source and "
1019
+ "recounts a `stats.effective_denominator` the brief's `scope.tier_a_include` set disagrees with "
1020
+ "(denominator drift), a person fixes denominator deflation on a brief with no tier A list by adding "
1021
+ "`scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the "
1022
+ "named-export count leaves out need no fix"),
1023
+ "provenance-unverified": (
1024
+ "the line-check rules could not verify the line, or the recorded line is not a number, so a person checks "
1025
+ "by hand which line defines the export and, when it is not the recorded one, sets its provenance "
1026
+ "`source_line` to that line"),
1027
+ "denominator-inflation": (
1028
+ "it concerns the brief's scope and the count design, which a person decides in the skill brief, as the "
1029
+ "gap's remediation says"),
1030
+ "brief-scope-stale": (
1031
+ "a person edits the brief's scope globs by hand so each matches the files it meant, since update-skill "
1032
+ "does not rewrite them"),
1033
+ "multi-denominator": (
1034
+ "no fix is needed, since the barrel and the documented surface measure different sets by design and the "
1035
+ "report records both counts"),
1036
+ "numerator-inflation": "a person documents the absent exports in SKILL.md or references/, reading each from "
1037
+ "the source",
1038
+ "scripts-assets-provenance": "a person adds the file's row to the provenance map's `file_entries`, or removes "
1039
+ "the file from the skill",
1040
+ "observation": "a person applies it only when they agree, since it is advisory, and a documented extra needs no "
1041
+ "change",
1042
+ "discovery": "a person improves the skill's description so realistic prompts route to it, or runs the discovery "
1043
+ "test Test Skill skipped",
1044
+ "description": "a person edits the description in the SKILL.md frontmatter as the gap suggests",
1045
+ "external-validator": "a person applies the skill-check diagnostic or Tessl Review suggestion to SKILL.md",
1046
+ }
1047
+ NO_REPAIR = "update-skill has no repair for this category, so a person follows the gap's remediation"
1048
+ NO_EXPORT = "it names no export for update-skill to repair, so a person follows the gap's remediation"
975
1049
  EXPORT_CATEGORIES = ("NEW_EXPORT", "MODIFIED_EXPORT", "MOVED_EXPORT", "DELETED_EXPORT")
976
1050
  RESCOPE_CATEGORIES = ("missing-export", "missing-type") # rule R1
977
1051
  COMPLETENESS = "provenance-completeness" # rule R3
@@ -985,8 +1059,14 @@ _LEDGER_REMEDIATION = re.compile(
985
1059
  r"|Find the \d+ exports the denominator counts that SKILL\.md and references/ do not name "
986
1060
  r"\(`[^`\n]+` lists the exports\) and document each with its signature\."
987
1061
  )
988
- # A citation of generated markdown: the skill's SKILL.md or a references/*.md file.
989
- _PACKAGE_FILE = re.compile(r"(?:^|/)(?:SKILL\.md|references/[^/]+\.md)$")
1062
+ # A citation of generated markdown: the skill's SKILL.md or a .md file under its references/, relative to the
1063
+ # package, after `{skill_package}/`, or after the package folder of SKF's layout as a project-relative path
1064
+ # (`skills/{name}/{version}/{name}/`, or the flat `skills/{name}/`, after any leading folders). A path under any
1065
+ # other folder (`docs/references/guide.md`) is source.
1066
+ _PACKAGE_FILE = re.compile(
1067
+ r"(?:\{skill_package\}/|(?:[^/]+/)*skills/(?P<name>[^/]+)/(?:[^/]+/(?P=name)/)?)?"
1068
+ r"(?P<file>SKILL\.md|references/(?:[^/]+/)*[^/]+\.md)")
1069
+ SPLIT_BODY = "split-body-mismatch"
990
1070
  # The answers translate reads, with the JSON types each takes.
991
1071
  _ANSWER_TYPES = {"category": (str, type(None)), "name": (str, type(None)), "rescope": (bool,),
992
1072
  "source_file": (str, type(None))}
@@ -994,14 +1074,38 @@ _IS_WINDOWS = os.name == "nt"
994
1074
  _REPLACE_WAIT_SECONDS = 5.0
995
1075
 
996
1076
 
1077
+ def _package_path(citation: dict | None) -> str | None:
1078
+ """The citation's file relative to the skill package, or None when it names no file of the package."""
1079
+ path = _norm_file(citation.get("file")) if isinstance(citation, dict) else None
1080
+ path = path.strip("`") if path else None
1081
+ found = _PACKAGE_FILE.fullmatch(path) if path and ".." not in path.split("/") else None
1082
+ return found.group("file") if found else None
1083
+
1084
+
997
1085
  def in_skill_package(citation: dict | None) -> bool:
998
1086
  """True when a citation names a line of generated markdown, not source.
999
1087
 
1000
- >>> [in_skill_package({"file": f, "line": 1}) for f in ("SKILL.md", "{skill_package}/references/api.md", "src/a.ts")]
1001
- [True, True, False]
1088
+ >>> [in_skill_package({"file": f, "line": 1}) for f in ("SKILL.md", "{skill_package}/references/api.md",
1089
+ ... "references/integrations/a-b.md", "src/a.ts",
1090
+ ... "skills/oms-cognee/0.5.8/oms-cognee/SKILL.md",
1091
+ ... "docs/references/guide.md")]
1092
+ [True, True, True, False, True, False]
1002
1093
  """
1003
- path = _norm_file(citation.get("file")) if isinstance(citation, dict) else None
1004
- return bool(path and _PACKAGE_FILE.search(path))
1094
+ return _package_path(citation) is not None
1095
+
1096
+
1097
+ def package_citation(citation: dict | None) -> dict | None:
1098
+ """An in-package citation with its file relative to the package, else None.
1099
+
1100
+ >>> for f in ("{skill_package}/references/api.md", "SKILL.md", "skills/lib/references/api.md", "src/a.ts"):
1101
+ ... print(package_citation({"file": f, "line": 3}))
1102
+ {'file': 'references/api.md', 'line': 3}
1103
+ {'file': 'SKILL.md', 'line': 3}
1104
+ {'file': 'references/api.md', 'line': 3}
1105
+ None
1106
+ """
1107
+ path = _package_path(citation)
1108
+ return {"file": path, "line": citation.get("line")} if path else None
1005
1109
 
1006
1110
 
1007
1111
  def written_remediation(text: object) -> bool:
@@ -1051,9 +1155,10 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
1051
1155
  entries: list[dict] = []
1052
1156
  not_routed: list[dict] = []
1053
1157
 
1054
- def skip(gap: dict, category: str | None) -> None:
1055
- not_routed.append({"id": gap["id"], "title": gap.get("title"), "category": category})
1056
- warnings.append(f"test-report: not routed: {gap['id']} ({category or 'no category'})")
1158
+ def skip(gap: dict, category: str | None, reason: str | None = None) -> None:
1159
+ reason = reason or NOT_ROUTED_REASONS.get(category or "", NO_REPAIR)
1160
+ not_routed.append({"id": gap["id"], "title": gap.get("title"), "category": category, "reason": reason})
1161
+ warnings.append(f"test-report: not routed: {gap['id']} ({category or 'no category'}): {reason}")
1057
1162
 
1058
1163
  def ask(gap: dict, category: str | None, want: list[str]) -> None:
1059
1164
  needs.append({"gap_id": gap["id"], "title": gap.get("title"), "category": category,
@@ -1079,11 +1184,14 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
1079
1184
  name = export or _answer_text(answers.get("name"))
1080
1185
  if change in EXPORT_CATEGORIES and name is None:
1081
1186
  if "name" in answers: # answered null: no export to repair
1082
- skip(gap, category)
1187
+ skip(gap, category, NO_EXPORT)
1083
1188
  else:
1084
1189
  ask(gap, category, ["name"])
1085
1190
  continue
1086
1191
  citation = gap.get("source_citation")
1192
+ # the gap's own category, which the ledger recorded: a judged one is an older report's, whose Source may
1193
+ # name the body that report still kept
1194
+ package = package_citation(citation) if gap.get("category") == SPLIT_BODY else None
1087
1195
  if in_skill_package(citation):
1088
1196
  citation = None
1089
1197
  match = map_match(map_entries, name, citation) if name else None
@@ -1123,6 +1231,8 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
1123
1231
  entry = {"name": name, "gap_id": gap["id"], "category": category, "severity": gap.get("severity")}
1124
1232
  if citation:
1125
1233
  entry["source_citation"] = citation
1234
+ if package:
1235
+ entry["package_citation"] = package
1126
1236
  entry["remediation_paths"] = list(gap.get("remediation_paths") or [])
1127
1237
  entry["resolved_paths"] = resolved
1128
1238
  entry["rejected_paths"] = rejected
@@ -73,7 +73,7 @@ Judgment payload (stdin JSON, compute mode)::
73
73
 
74
74
  {
75
75
  "exports_public_api": 40, # int — public-entry-point export count (judgment)
76
- "exports_internal": 19, # int — non-underscore internal export count (judgment)
76
+ "exports_internal": 19, # int: internal export count, Python/Rust/Dart `_` names left out (judgment)
77
77
  "effective_denominator": 12, # int|null — optional, curated-subset scopes only
78
78
  "pattern_surfaces_documented": 8, # int — reference-app only (exports_documented proxy)
79
79
  "exports_documented": 0, # int — stack only (own-barrel count, usually 0)
@@ -0,0 +1,197 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Resolve Customization: BMAD core's customization resolver, on any core from 6.4.0.
6
+
7
+ Every SKF workflow, and Ferris, reads its `_bmad/custom/` overrides through
8
+ BMAD core's customization resolver, `_bmad/scripts/resolve_customization.py`
9
+ under the project root, which the BMAD Method installer adds. SKF passes it
10
+ `--project-root`, a flag core takes only from 6.11.0: core 6.4.0 to 6.10.0
11
+ stops on it with argparse's exit 2, so SKF runs this helper in its place.
12
+ It takes core's long flags only (core's -s, -p and -k are refused):
13
+
14
+ uv run skf-resolve-customization.py --skill <skill-root> \\
15
+ --project-root <project-root> --key <key> [--key <key>]...
16
+
17
+ 1. It runs core's resolver with those arguments, `--project-root` included,
18
+ under its own interpreter, from the project root. `uv run` gives this
19
+ file a Python that meets its requires-python, which core needs for
20
+ `tomllib`; core 6.10.0 and earlier declare none, so uv would not choose
21
+ one for them.
22
+ 2. When core rejects `--project-root` (exit 2, nothing on stdout, and
23
+ `error: unrecognized arguments: ... --project-root` on stderr), it works
24
+ out the root a core before 6.11.0 picks without the flag: the nearest
25
+ folder, from the skill folder up and the skill folder itself first, that
26
+ holds `_bmad` or `.git` (a file or a folder), else the same search from
27
+ the project root. When that is the project root, it runs core again
28
+ without the flag, and that run's result is the helper's. Otherwise core
29
+ would read another folder's overrides, so it refuses.
30
+
31
+ Output:
32
+ stdout on exit 0, core's stdout byte for byte (the JSON core prints);
33
+ on any other exit, nothing.
34
+ stderr the stderr of the core run whose result is the helper's: on a
35
+ rerun without the flag, the rerun's only (the rejected call's is
36
+ dropped). A failure of the helper's own prints one line first: the
37
+ reason a workflow's warning quotes.
38
+
39
+ Exit codes:
40
+ 0 core resolved the customization
41
+ 1 core's resolver is missing (first stderr line: `not found`), could
42
+ not start, did not finish or was stopped by a signal, or core exited 1
43
+ (a customize.toml or an override it cannot read)
44
+ 2 a core before 6.11.0 would read the overrides under another folder
45
+ (first stderr line: `BMAD core before 6.11.0: ...`), a usage error of
46
+ the helper's, or core's own exit 2 (a core from 6.11.0), passed through
47
+ 3 core found no Python 3.11 or later (`tomllib`)
48
+ Any other exit code is core's own, passed through.
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import argparse
54
+ import os
55
+ import re
56
+ import subprocess
57
+ import sys
58
+ from pathlib import Path
59
+
60
+ CORE_RESOLVER = ("_bmad", "scripts", "resolve_customization.py")
61
+ FIRST_CORE_WITH_FLAG = "6.11.0"
62
+ CORE_TIMEOUT_SEC = 120
63
+ PROJECT_ROOT_FLAG = "--project-root"
64
+ MISSING = "not found"
65
+ ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
66
+ # argparse's line for a flag it does not know, wherever the usage line wraps.
67
+ REJECTED_FLAG_RE = re.compile(r"error: unrecognized arguments: (?:.*\s)?--project-root(?:[=\s]|$)", re.M)
68
+
69
+
70
+ def _build_parser() -> argparse.ArgumentParser:
71
+ parser = argparse.ArgumentParser(
72
+ prog="skf-resolve-customization",
73
+ description=(
74
+ "Run BMAD core's customization resolver with --project-root. On a core before "
75
+ f"{FIRST_CORE_WITH_FLAG}, which rejects that flag, run it again without the flag when "
76
+ "core would read this project's overrides anyway, and refuse otherwise."
77
+ ),
78
+ )
79
+ parser.add_argument("--skill", required=True, help="the skill folder that holds customize.toml")
80
+ parser.add_argument(
81
+ "--project-root",
82
+ required=True,
83
+ help="the project folder: core's resolver is under its _bmad/scripts/, the overrides under _bmad/custom/",
84
+ )
85
+ parser.add_argument(
86
+ "--key",
87
+ action="append",
88
+ default=[],
89
+ help="a dotted key to resolve, repeatable; with none, core prints the whole merge",
90
+ )
91
+ return parser
92
+
93
+
94
+ def _nearest_marked(start: Path) -> Path | None:
95
+ """The nearest folder, `start` itself first, holding `_bmad` or `.git` (a file or a folder)."""
96
+ current = start.resolve()
97
+ while True:
98
+ if (current / "_bmad").exists() or (current / ".git").exists():
99
+ return current
100
+ if current.parent == current:
101
+ return None
102
+ current = current.parent
103
+
104
+
105
+ def legacy_project_root(skill: Path, cwd: Path) -> Path | None:
106
+ """The project root BMAD core 6.4.0 to 6.10.0 picks without --project-root, run from `cwd`."""
107
+ return _nearest_marked(skill) or _nearest_marked(cwd)
108
+
109
+
110
+ def _same_folder(a: Path, b: Path) -> bool:
111
+ try:
112
+ return os.path.samefile(a, b)
113
+ except OSError:
114
+ return a.resolve() == b.resolve()
115
+
116
+
117
+ def rejects_project_root(returncode: int, stdout: bytes, stderr: bytes) -> bool:
118
+ """True when core stopped on --project-root as a flag it does not know; never on exit 2 alone."""
119
+ if returncode != 2 or stdout:
120
+ return False
121
+ text = ANSI_RE.sub("", stderr.decode("utf-8", "replace"))
122
+ return REJECTED_FLAG_RE.search(text) is not None
123
+
124
+
125
+ def _run_core(argv: list[str], cwd: Path) -> subprocess.CompletedProcess:
126
+ env = dict(os.environ)
127
+ # Core 6.11.0 and later import config_utils from their own folder, which a safe path drops.
128
+ env.pop("PYTHONSAFEPATH", None)
129
+ # Core before 6.8.0 writes its JSON in the locale's encoding.
130
+ env["PYTHONIOENCODING"] = "utf-8"
131
+ return subprocess.run(
132
+ argv, cwd=cwd, env=env, stdin=subprocess.DEVNULL, capture_output=True,
133
+ timeout=CORE_TIMEOUT_SEC, check=False,
134
+ )
135
+
136
+
137
+ def _write(stream, data: bytes) -> None:
138
+ if not data:
139
+ return
140
+ buffer = getattr(stream, "buffer", None)
141
+ if buffer is None:
142
+ stream.write(data.decode("utf-8", "replace"))
143
+ stream.flush()
144
+ return
145
+ buffer.write(data)
146
+ buffer.flush()
147
+
148
+
149
+ def _say(*lines: str) -> None:
150
+ _write(sys.stderr, "".join(line + "\n" for line in lines).encode("utf-8"))
151
+
152
+
153
+ def _finish(proc: subprocess.CompletedProcess) -> int:
154
+ """Core's result as the helper's: its stdout only on exit 0, its stderr always."""
155
+ if proc.returncode < 0: # stopped by a signal: core may have printed no reason
156
+ _say(f"BMAD core's customization resolver was stopped by signal {-proc.returncode}")
157
+ _write(sys.stderr, proc.stderr)
158
+ return 1
159
+ if proc.returncode == 0:
160
+ _write(sys.stdout, proc.stdout)
161
+ _write(sys.stderr, proc.stderr)
162
+ return proc.returncode
163
+
164
+
165
+ def main(argv: list[str] | None = None) -> int:
166
+ args = _build_parser().parse_args(argv)
167
+ root = Path(os.path.abspath(args.project_root))
168
+ skill = Path(os.path.abspath(args.skill))
169
+ core = root.joinpath(*CORE_RESOLVER)
170
+ if not core.is_file():
171
+ _say(MISSING, f"BMAD core's customization resolver is not at {core}: the BMAD Method installer adds it.")
172
+ return 1
173
+ command = [sys.executable, str(core), "--skill", str(skill)]
174
+ keys = [word for key in args.key for word in ("--key", key)]
175
+ try:
176
+ first = _run_core([*command, PROJECT_ROOT_FLAG, str(root), *keys], root)
177
+ if not rejects_project_root(first.returncode, first.stdout, first.stderr):
178
+ return _finish(first)
179
+ legacy = legacy_project_root(skill, root)
180
+ if legacy is not None and _same_folder(legacy, root):
181
+ return _finish(_run_core([*command, *keys], root))
182
+ except subprocess.TimeoutExpired:
183
+ _say(f"BMAD core's customization resolver did not finish within {CORE_TIMEOUT_SEC} seconds")
184
+ return 1
185
+ except OSError as exc:
186
+ _say(f"BMAD core's customization resolver could not start: {exc}")
187
+ return 1
188
+ _say(
189
+ f"BMAD core before {FIRST_CORE_WITH_FLAG}: its customization resolver takes no {PROJECT_ROOT_FLAG} "
190
+ f"and would read the overrides under {legacy}, not under {root}; upgrade BMAD core to "
191
+ f"{FIRST_CORE_WITH_FLAG} or later"
192
+ )
193
+ return 2
194
+
195
+
196
+ if __name__ == "__main__":
197
+ sys.exit(main())