bmad-module-skill-forge 3.0.0 → 4.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/examples.md +1 -1
  4. package/docs/troubleshooting.md +8 -0
  5. package/docs/verifying-a-skill.md +7 -3
  6. package/docs/workflows.md +3 -3
  7. package/package.json +1 -1
  8. package/src/knowledge/qmd-registry.md +1 -1
  9. package/src/knowledge/version-paths.md +1 -1
  10. package/src/shared/health-check.md +2 -50
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  12. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
  13. package/src/shared/scripts/skf-atomic-write.py +212 -59
  14. package/src/shared/scripts/skf-build-change-manifest.py +593 -47
  15. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  16. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  17. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  18. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  19. package/src/shared/scripts/skf-extract-public-api.py +1309 -157
  20. package/src/shared/scripts/skf-extraction-inventory.py +580 -46
  21. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  22. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  23. package/src/shared/scripts/skf-github-probe.py +47 -8
  24. package/src/shared/scripts/skf-hash-content.py +14 -8
  25. package/src/shared/scripts/skf-load-provenance.py +3 -1
  26. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  27. package/src/shared/scripts/skf-preflight.py +15 -1
  28. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  29. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  30. package/src/shared/scripts/skf-structural-diff.py +133 -18
  31. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  32. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  33. package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
  34. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  35. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  36. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  37. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  38. package/src/skf-audit-skill/references/health-check.md +2 -0
  39. package/src/skf-audit-skill/references/init.md +2 -1
  40. package/src/skf-audit-skill/references/re-index.md +14 -9
  41. package/src/skf-audit-skill/references/report.md +2 -2
  42. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  43. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  44. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  45. package/src/skf-brief-skill/SKILL.md +1 -1
  46. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  47. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
  48. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  49. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  52. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  53. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  54. package/src/skf-create-skill/assets/skill-sections.md +6 -6
  55. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  56. package/src/skf-create-skill/references/compile.md +31 -60
  57. package/src/skf-create-skill/references/component-extraction.md +3 -3
  58. package/src/skf-create-skill/references/enrich.md +2 -2
  59. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  60. package/src/skf-create-skill/references/extract.md +22 -19
  61. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  62. package/src/skf-create-skill/references/extraction-patterns.md +8 -9
  63. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  64. package/src/skf-create-skill/references/report.md +16 -5
  65. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  66. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  67. package/src/skf-drop-skill/references/execute.md +5 -1
  68. package/src/skf-drop-skill/references/health-check.md +2 -0
  69. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  70. package/src/skf-drop-skill/references/report.md +2 -2
  71. package/src/skf-drop-skill/references/select.md +7 -3
  72. package/src/skf-test-skill/references/coherence-check.md +1 -1
  73. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  74. package/src/skf-test-skill/references/coverage-check.md +39 -57
  75. package/src/skf-test-skill/references/init.md +3 -5
  76. package/src/skf-test-skill/references/report.md +10 -8
  77. package/src/skf-test-skill/references/scoring-rules.md +6 -5
  78. package/src/skf-test-skill/references/source-access-protocol.md +14 -17
  79. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  80. package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
  82. package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
  83. package/src/skf-test-skill/scripts/score-signatures.py +36 -16
  84. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  85. package/src/skf-update-skill/references/detect-changes.md +32 -13
  86. package/src/skf-update-skill/references/gap-driven.md +3 -3
  87. package/src/skf-update-skill/references/init.md +6 -2
  88. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  89. package/src/skf-update-skill/references/merge.md +2 -2
  90. package/src/skf-update-skill/references/re-extract.md +2 -2
  91. package/src/skf-update-skill/references/report.md +3 -1
  92. package/src/skf-update-skill/references/write.md +43 -23
  93. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  # /// script
3
3
  # requires-python = ">=3.11"
4
+ # dependencies = ["pyyaml"]
4
5
  # ///
5
6
  """Deterministic NEW_FILE detection for skf-update-skill (detect-changes Category D).
6
7
 
@@ -10,39 +11,70 @@ file that is present in source yet absent from the provenance map: a NEW_FILE.
10
11
  Deriving that set is a set-difference plus a filter with exactly one correct
11
12
  answer per input: take every `source_file` in the scripts/assets inventory
12
13
  (emitted by `skf-detect-scripts-assets detect`), subtract the paths already in
13
- `file_entries[].source_file`, and set aside any user-authored `[MANUAL]` path.
14
- Doing that subtraction in the prompt drifts across runs; doing it here is
15
- byte-stable.
14
+ `file_entries[].source_file`, and set aside any user-authored `[MANUAL]` path,
15
+ any path the map's `entries[]` cite (code Category A and B track) and, with
16
+ --brief, any path the skill brief leaves out. Doing that subtraction in the
17
+ prompt drifts across runs; doing it here is byte-stable.
16
18
 
17
19
  Usage:
18
- skf-detect-scripts-assets.py detect <source-root> \
19
- | skf-new-file-diff.py <provenance-map-path>
20
+ skf-detect-scripts-assets.py detect <source-root> \\
21
+ | skf-new-file-diff.py <provenance-map-path> [--brief <brief-path>]
20
22
 
21
23
  Reads the detect JSON on stdin (needs `scripts_inventory[]` and
22
24
  `assets_inventory[]`, each row carrying `source_file`). Reads the provenance
23
- map at the path argument (canonical `{file_entries: [...]}` object, or a bare
24
- array of entries).
25
+ map at the path argument (canonical `{file_entries: [...], entries: [...]}`
26
+ object, or a bare array of file entries). A map with no `file_entries`, or a
27
+ null one, tracks no file (a skill with no script, asset or promoted document,
28
+ or a map written before the field existed); a `file_entries` or `entries`
29
+ that is neither null nor an array is bad input.
30
+
31
+ --brief <brief-path> (the skill's skill-brief.yaml) keeps a detected path
32
+ only when the brief takes it, by update-skill Category A's in-scope test
33
+ (skf-classify-changed-files.py load_scope, loaded from the shared
34
+ scripts folder, the one brief-scope test the script and asset detector's
35
+ and audit-skill's --brief use too, with the glob rules of
36
+ skf-resolve-authoritative-files.py): each `scope.include` and
37
+ `scope.exclude` pattern normalized (forward slashes, no leading `./`),
38
+ and a path in scope when an include
39
+ matches it and no exclude does. With an empty `scope.include`, a path is in
40
+ scope when no exclude matches it and its extension is one a file the map's
41
+ `entries[]` cite has, or one the brief's `language` uses. The brief's
42
+ `scripts_intent` and `assets_intent` are honoured too: `none` drops every
43
+ detected path of that kind, and an absent or free-text intent detects.
44
+ An empty --brief (an unbound brief path) is a usage error, never an
45
+ unscoped run. Every path, the map's and the detector's, is compared
46
+ stripped, with forward slashes and no leading `./`.
25
47
 
26
48
  Output JSON (stdout):
27
49
  {
28
50
  "new_files": [ {"source_file": "...", "kind": "script|asset"}, ... ],
29
51
  "skipped_manual": [ "...", ... ], # under scripts/[MANUAL]/ or assets/[MANUAL]/
30
52
  "already_tracked": [ "...", ... ], # present in file_entries
53
+ "tracked_code": [ "...", ... ], # cited by entries[]: code, not a new file
54
+ "out_of_scope": [ "...", ... ], # --brief: outside the brief's scope
55
+ "intent_none": [ "...", ... ], # --brief: a kind whose intent is none
31
56
  "stats": {"inventory_total": N, "new": N,
32
57
  "skipped_manual": N, "already_tracked": N}
33
58
  }
34
59
 
35
- All three arrays are sorted by source_file; new_files carries the kind of the
36
- inventory it came from (scripts -> "script", assets -> "asset"). A path found
37
- in both inventories is counted once, resolved as "script".
60
+ Each detected path lands in exactly one list, tested in this order:
61
+ already_tracked, skipped_manual, tracked_code, out_of_scope, intent_none,
62
+ and new_files for the rest. All arrays are sorted by
63
+ source_file; new_files carries the kind of the inventory it came from
64
+ (scripts -> "script", assets -> "asset"). A path found in both inventories is
65
+ counted once, resolved as "script". The three set-aside lists (tracked_code,
66
+ out_of_scope, intent_none) are never counted in `stats`, whose
67
+ inventory_total still counts every detected path.
38
68
 
39
69
  Exit codes:
40
70
  0 success (and --help)
41
71
  2 bad input (a usage error, unreadable/invalid stdin JSON, missing/invalid
42
- provenance map)
72
+ provenance map, a missing or unreadable brief, or a shared helper that
73
+ cannot be loaded)
43
74
  """
44
75
 
45
76
  import argparse
77
+ import importlib.util
46
78
  import json
47
79
  import re
48
80
  import sys
@@ -51,6 +83,13 @@ from pathlib import Path
51
83
  # scripts/[MANUAL]/... or assets/[MANUAL]/... anywhere in the (posix) path
52
84
  _MANUAL_RE = re.compile(r"(?:^|/)(?:scripts|assets)/\[MANUAL\]/")
53
85
 
86
+ # The shared helper that holds the scope test, installed beside this skill's
87
+ # folder: it loads skf-resolve-authoritative-files.py beside it.
88
+ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts"
89
+ CLASSIFIER = "skf-classify-changed-files.py"
90
+
91
+ _SHARED: dict[str, object] = {}
92
+
54
93
 
55
94
  def _fail(msg: str) -> "NoReturn": # type: ignore[valid-type]
56
95
  print(json.dumps({"error": msg}), file=sys.stderr)
@@ -58,37 +97,88 @@ def _fail(msg: str) -> "NoReturn": # type: ignore[valid-type]
58
97
 
59
98
 
60
99
  def _posix(p: str) -> str:
61
- return p.replace("\\", "/")
100
+ """A path as skf-resolve-authoritative-files.py's normalize_rel_path
101
+ writes it: stripped, forward slashes, no leading `./`."""
102
+ p = p.strip().replace("\\", "/")
103
+ while p.startswith("./"):
104
+ p = p[2:]
105
+ return p
106
+
107
+
108
+ def _shared(filename: str):
109
+ """A script of SHARED_SCRIPTS, loaded once; a failure to load it is bad input (exit 2)."""
110
+ module = _SHARED.get(filename)
111
+ if module is None:
112
+ path = SHARED_SCRIPTS / filename
113
+ try:
114
+ spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
115
+ if spec is None or spec.loader is None or not path.is_file():
116
+ raise ImportError(f"{filename} not found in {SHARED_SCRIPTS}")
117
+ module = importlib.util.module_from_spec(spec)
118
+ spec.loader.exec_module(module)
119
+ except Exception as exc: # whatever the script raises, the run reports it
120
+ _fail(f"cannot load {filename} from {SHARED_SCRIPTS}: {exc}; re-install SKF")
121
+ _SHARED[filename] = module
122
+ return module
123
+
124
+
125
+ def _source_files(rows: list) -> set[str]:
126
+ found: set[str] = set()
127
+ for entry in rows:
128
+ if isinstance(entry, dict):
129
+ sf = entry.get("source_file")
130
+ if isinstance(sf, str) and _posix(sf):
131
+ found.add(_posix(sf))
132
+ return found
62
133
 
63
134
 
64
- def load_tracked_source_files(provenance_path: Path) -> set[str]:
65
- """Return the set of file_entries[].source_file already in the provenance map."""
135
+ def load_map_paths(provenance_path: Path) -> tuple[set[str], set[str]]:
136
+ """(file_entries[].source_file, entries[].source_file) of the provenance map.
137
+
138
+ A missing or null `file_entries` (or `entries`) is an empty list; one
139
+ that is neither null nor an array is bad input. A bare array is the
140
+ file entries themselves.
141
+ """
66
142
  try:
67
143
  data = json.loads(provenance_path.read_text(encoding="utf-8"))
68
144
  except (json.JSONDecodeError, OSError) as exc:
69
145
  _fail(f"failed to read provenance map {provenance_path}: {exc}")
70
146
 
71
147
  if isinstance(data, list):
72
- entries = data
73
- elif isinstance(data, dict):
74
- entries = data.get("file_entries")
75
- if entries is None:
76
- _fail(f"provenance map {provenance_path} has no `file_entries` field")
77
- if not isinstance(entries, list):
78
- _fail(f"`file_entries` in {provenance_path} is not an array")
79
- else:
148
+ return _source_files(data), set()
149
+ if not isinstance(data, dict):
80
150
  _fail(
81
151
  f"provenance map {provenance_path} must be an object or array; "
82
152
  f"got {type(data).__name__}"
83
153
  )
154
+ rows = {}
155
+ for key in ("file_entries", "entries"):
156
+ value = data.get(key)
157
+ if value is None:
158
+ value = [] # a map that tracks no such row
159
+ if not isinstance(value, list):
160
+ _fail(f"`{key}` in {provenance_path} is not an array")
161
+ rows[key] = value
162
+ return _source_files(rows["file_entries"]), _source_files(rows["entries"])
84
163
 
85
- tracked: set[str] = set()
86
- for entry in entries:
87
- if isinstance(entry, dict):
88
- sf = entry.get("source_file")
89
- if isinstance(sf, str) and sf:
90
- tracked.add(_posix(sf))
91
- return tracked
164
+
165
+ def load_tracked_source_files(provenance_path: Path) -> set[str]:
166
+ """Return the set of file_entries[].source_file already in the provenance map."""
167
+ return load_map_paths(provenance_path)[0]
168
+
169
+
170
+ def load_scope(brief_path: Path, cited: set[str]):
171
+ """The brief's Scope (skf-classify-changed-files.py load_scope): its
172
+ takes(path) is Category A's in-scope test and its wants(kind) the
173
+ brief's intent. A brief it cannot find or read, or a shared helper it
174
+ cannot load, is bad input (exit 2)."""
175
+ classifier = _shared(CLASSIFIER)
176
+ try:
177
+ return classifier.load_scope(brief_path, cited)
178
+ except ValueError as exc: # a brief it cannot find or read
179
+ _fail(str(exc))
180
+ except Exception as exc: # an out-of-date classifier, or a resolver beside it that cannot load
181
+ _fail(f"cannot load the brief's scope test from {SHARED_SCRIPTS}: {exc}; re-install SKF")
92
182
 
93
183
 
94
184
  def collect_inventory(detect: dict) -> list[tuple[str, str]]:
@@ -114,38 +204,53 @@ def collect_inventory(detect: dict) -> list[tuple[str, str]]:
114
204
  return sorted(seen.items())
115
205
 
116
206
 
117
- def diff(detect: dict, tracked: set[str]) -> dict:
207
+ def diff(detect: dict, tracked: set[str], cited: set[str] | frozenset = frozenset(),
208
+ scope=None) -> dict:
209
+ """Sort the detected paths into the output lists; `scope` is load_scope's
210
+ Scope (--brief) or None."""
118
211
  new_files: list[dict] = []
119
- skipped_manual: list[str] = []
120
- already_tracked: list[str] = []
212
+ lists: dict[str, list[str]] = {name: [] for name in (
213
+ "skipped_manual", "already_tracked", "tracked_code", "out_of_scope", "intent_none")}
121
214
 
122
215
  inventory = collect_inventory(detect)
123
216
  for sf, kind in inventory:
124
217
  if sf in tracked:
125
- already_tracked.append(sf)
218
+ lists["already_tracked"].append(sf)
126
219
  elif _MANUAL_RE.search(sf):
127
- skipped_manual.append(sf)
220
+ lists["skipped_manual"].append(sf)
221
+ elif sf in cited:
222
+ lists["tracked_code"].append(sf)
223
+ elif scope is not None and not scope.takes(sf):
224
+ lists["out_of_scope"].append(sf)
225
+ elif scope is not None and not scope.wants(kind):
226
+ lists["intent_none"].append(sf)
128
227
  else:
129
228
  new_files.append({"source_file": sf, "kind": kind})
130
229
 
131
230
  return {
132
231
  "new_files": new_files,
133
- "skipped_manual": sorted(skipped_manual),
134
- "already_tracked": sorted(already_tracked),
232
+ "skipped_manual": sorted(lists["skipped_manual"]),
233
+ "already_tracked": sorted(lists["already_tracked"]),
234
+ "tracked_code": sorted(lists["tracked_code"]),
235
+ "out_of_scope": sorted(lists["out_of_scope"]),
236
+ "intent_none": sorted(lists["intent_none"]),
135
237
  "stats": {
136
238
  "inventory_total": len(inventory),
137
239
  "new": len(new_files),
138
- "skipped_manual": len(skipped_manual),
139
- "already_tracked": len(already_tracked),
240
+ "skipped_manual": len(lists["skipped_manual"]),
241
+ "already_tracked": len(lists["already_tracked"]),
140
242
  },
141
243
  }
142
244
 
143
245
 
246
+ USAGE = "usage: skf-new-file-diff.py <provenance-map-path> [--brief <brief-path>] (detect JSON on stdin)"
247
+
248
+
144
249
  class _JsonErrorParser(argparse.ArgumentParser):
145
250
  """Report a usage error as the JSON error and exit 2 every other bad input gets."""
146
251
 
147
252
  def error(self, message: str) -> "NoReturn": # type: ignore[valid-type]
148
- _fail(f"usage: skf-new-file-diff.py <provenance-map-path> (detect JSON on stdin): {message}")
253
+ _fail(f"{USAGE}: {message}")
149
254
 
150
255
 
151
256
  def _build_parser() -> argparse.ArgumentParser:
@@ -153,17 +258,26 @@ def _build_parser() -> argparse.ArgumentParser:
153
258
  prog="skf-new-file-diff.py",
154
259
  description=__doc__,
155
260
  formatter_class=argparse.RawDescriptionHelpFormatter,
261
+ allow_abbrev=False,
156
262
  )
157
263
  parser.add_argument(
158
264
  "provenance_map",
159
265
  metavar="provenance-map-path",
160
266
  help="the skill's provenance-map.json (the detect JSON is read on stdin)",
161
267
  )
268
+ parser.add_argument(
269
+ "--brief",
270
+ metavar="brief-path",
271
+ help="the skill brief: keep only the paths its scope takes and its intents want",
272
+ )
162
273
  return parser
163
274
 
164
275
 
165
276
  def main(argv: list[str]) -> int:
166
- args = _build_parser().parse_args(argv[1:])
277
+ parser = _build_parser()
278
+ args = parser.parse_args(argv[1:])
279
+ if args.brief is not None and not args.brief.strip():
280
+ parser.error("--brief is empty (an unbound brief path?): pass the brief, or leave --brief out")
167
281
 
168
282
  provenance_path = Path(args.provenance_map)
169
283
  if not provenance_path.exists():
@@ -179,8 +293,9 @@ def main(argv: list[str]) -> int:
179
293
  if not isinstance(detect, dict):
180
294
  _fail(f"detect JSON must be an object; got {type(detect).__name__}")
181
295
 
182
- tracked = load_tracked_source_files(provenance_path)
183
- result = diff(detect, tracked)
296
+ tracked, cited = load_map_paths(provenance_path)
297
+ scope = load_scope(Path(args.brief), cited) if args.brief is not None else None
298
+ result = diff(detect, tracked, cited, scope)
184
299
  print(json.dumps(result, indent=2))
185
300
  return 0
186
301