bmad-module-skill-forge 3.0.0 → 5.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 (141) 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/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +33 -26
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/troubleshooting.md +8 -0
  12. package/docs/verifying-a-skill.md +46 -21
  13. package/docs/why-skf.md +1 -1
  14. package/docs/workflows.md +5 -5
  15. package/package.json +1 -1
  16. package/src/knowledge/qmd-registry.md +1 -1
  17. package/src/knowledge/version-paths.md +1 -1
  18. package/src/shared/health-check.md +2 -50
  19. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  22. package/src/shared/scripts/skf-atomic-write.py +212 -59
  23. package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
  24. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  25. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  26. package/src/shared/scripts/skf-count-tokens.py +54 -10
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  28. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  29. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  30. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  31. package/src/shared/scripts/skf-extract-public-api.py +1673 -189
  32. package/src/shared/scripts/skf-extraction-inventory.py +584 -46
  33. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  34. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  35. package/src/shared/scripts/skf-github-probe.py +47 -8
  36. package/src/shared/scripts/skf-hash-content.py +14 -8
  37. package/src/shared/scripts/skf-load-provenance.py +3 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  39. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  40. package/src/shared/scripts/skf-preflight.py +15 -1
  41. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  42. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  43. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  44. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  45. package/src/shared/scripts/skf-shard-body.py +239 -41
  46. package/src/shared/scripts/skf-structural-diff.py +133 -18
  47. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  48. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  49. package/src/shared/scripts/skf-validate-output.py +12 -3
  50. package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
  51. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  52. package/src/shared/tool-requirements.yaml +8 -2
  53. package/src/skf-analyze-source/SKILL.md +2 -2
  54. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  55. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  56. package/src/skf-audit-skill/SKILL.md +2 -2
  57. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  58. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  59. package/src/skf-audit-skill/references/health-check.md +2 -0
  60. package/src/skf-audit-skill/references/init.md +2 -1
  61. package/src/skf-audit-skill/references/re-index.md +14 -9
  62. package/src/skf-audit-skill/references/report.md +2 -2
  63. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  64. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  65. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  66. package/src/skf-brief-skill/SKILL.md +3 -3
  67. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  68. package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
  69. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  70. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  71. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  72. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  73. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  74. package/src/skf-campaign/SKILL.md +2 -2
  75. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  76. package/src/skf-create-skill/SKILL.md +2 -2
  77. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  78. package/src/skf-create-skill/assets/skill-sections.md +7 -7
  79. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  80. package/src/skf-create-skill/references/compile.md +31 -60
  81. package/src/skf-create-skill/references/component-extraction.md +3 -3
  82. package/src/skf-create-skill/references/enrich.md +2 -2
  83. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  84. package/src/skf-create-skill/references/extract.md +23 -19
  85. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  86. package/src/skf-create-skill/references/extraction-patterns.md +10 -11
  87. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  88. package/src/skf-create-skill/references/report.md +21 -5
  89. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  90. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  91. package/src/skf-create-stack-skill/SKILL.md +2 -2
  92. package/src/skf-create-stack-skill/references/validate.md +1 -1
  93. package/src/skf-drop-skill/SKILL.md +2 -2
  94. package/src/skf-drop-skill/references/execute.md +5 -1
  95. package/src/skf-drop-skill/references/health-check.md +2 -0
  96. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  97. package/src/skf-drop-skill/references/report.md +2 -2
  98. package/src/skf-drop-skill/references/select.md +7 -3
  99. package/src/skf-export-skill/SKILL.md +2 -2
  100. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  101. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  102. package/src/skf-export-skill/references/load-skill.md +1 -1
  103. package/src/skf-export-skill/references/token-report.md +29 -9
  104. package/src/skf-forger/SKILL.md +2 -2
  105. package/src/skf-quick-skill/SKILL.md +2 -2
  106. package/src/skf-refine-architecture/SKILL.md +6 -8
  107. package/src/skf-rename-skill/SKILL.md +2 -2
  108. package/src/skf-setup/SKILL.md +2 -2
  109. package/src/skf-test-skill/SKILL.md +2 -2
  110. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  111. package/src/skf-test-skill/references/coherence-check.md +5 -4
  112. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  113. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  114. package/src/skf-test-skill/references/coverage-check.md +69 -111
  115. package/src/skf-test-skill/references/init.md +5 -6
  116. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  117. package/src/skf-test-skill/references/report.md +11 -9
  118. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  119. package/src/skf-test-skill/references/source-access-protocol.md +15 -18
  120. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  121. package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
  122. package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
  123. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  124. package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
  125. package/src/skf-test-skill/scripts/score-signatures.py +356 -61
  126. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  127. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  128. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/SKILL.md +2 -2
  131. package/src/skf-update-skill/references/detect-changes.md +32 -13
  132. package/src/skf-update-skill/references/gap-driven.md +37 -22
  133. package/src/skf-update-skill/references/init.md +6 -2
  134. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  135. package/src/skf-update-skill/references/merge.md +17 -12
  136. package/src/skf-update-skill/references/re-extract.md +4 -2
  137. package/src/skf-update-skill/references/report.md +7 -4
  138. package/src/skf-update-skill/references/write.md +77 -36
  139. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
  140. package/src/skf-verify-stack/SKILL.md +2 -2
  141. package/tools/cli/lib/tool-check.js +158 -2
@@ -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
 
@@ -84,10 +84,10 @@ When `{headless_mode}` is true, step 6 prints one `SKF_VERIFY_STACK_RESULT_JSON:
84
84
 
85
85
  **Bind the inputs** in every mode: `{architecture_doc_path}` ← the `--architecture-doc` value, `{prd_path}` ← `--prd` and `{previous_report_path}` ← `--previous-report`, each null when its flag is absent. init.md §1 asks only for those still null.
86
86
 
87
- 4. **Resolve workflow customization.** Run:
87
+ 4. **Resolve workflow customization.** Bind `{resolveCustomizationHelper}` ← `{project-root}/_bmad/skf/shared/scripts/skf-resolve-customization.py`, else `{project-root}/src/shared/scripts/skf-resolve-customization.py` (the first that exists), and run:
88
88
 
89
89
  ```bash
90
- uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow
90
+ uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
91
91
  ```
92
92
 
93
93
  It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-verify-stack.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case keep the reason as `{customization_resolver_unavailable}` (unset when the resolver ran) for init.md to record once it has created `{run_dir}`.
@@ -7,12 +7,16 @@
7
7
  *
8
8
  * Each probe runs a binary resolved on PATH outside the project folder, with
9
9
  * no shell and fixed arguments, and never npx, which can download. A .cmd or
10
- * .bat shim on Windows runs through cmd.exe. A missing, hanging or failing
11
- * probe reads as unknown: nothing here throws or changes an exit code.
10
+ * .bat shim on Windows runs through cmd.exe. tessl and skill-check are never
11
+ * run: tessl's version is read from the name of the binary it installs, and
12
+ * skill-check's, which has no --version, from its package.json. A missing,
13
+ * hanging or failing probe reads as unknown: nothing here throws or changes
14
+ * an exit code.
12
15
  */
13
16
 
14
17
  const { spawn } = require('node:child_process');
15
18
  const fs = require('node:fs');
19
+ const os = require('node:os');
16
20
  const path = require('node:path');
17
21
  const chalk = require('chalk');
18
22
  const yaml = require('js-yaml');
@@ -27,6 +31,25 @@ const MINIMUM = /^\d+(?:\.\d+)*$/;
27
31
  const NPX_TOOLS = new Set(['tessl', 'skill_check']);
28
32
  // ccc has no --version: `uv tool list` names each package uv installed, with its version.
29
33
  const CCC_PACKAGE = /^cocoindex-code v(\S+)/m;
34
+ // skill-check has no --version either (1.2.0 exits 2 on it). Its version is
35
+ // read from the package.json of the install its binary belongs to. A
36
+ // package.json is looked for at most this many folders above a binary's real
37
+ // path.
38
+ const SKILL_CHECK_PACKAGE = 'skill-check';
39
+ const PACKAGE_DEPTH = 4;
40
+ const MAX_PACKAGE_JSON = 1024 * 1024;
41
+ // tessl is not run either: on a tessl that has never run, any command,
42
+ // --version included, accepts tessl's Terms and turns on usage sharing for
43
+ // the user, and a run can start tessl's own update. tessl's installers name
44
+ // each binary tessl-<version>-<platform> in a tessl/versions folder and point
45
+ // the PATH entry at one: a symlink on Linux and macOS, a copy in tessl\bin on
46
+ // Windows.
47
+ const TESSL_BINARY = /^tessl-(.+)-(?:linux|darwin|win32)-(?:x64|arm64)(?:-musl)?(?:\.exe)?$/;
48
+ // The npm (@tessl/cli) and Homebrew (tessl) packages hold a launcher that
49
+ // runs the binary tessl installed, so their version is not the one that runs.
50
+ // @tessl/cli before 0.63.0 held the CLI itself.
51
+ const TESSL_LAUNCHERS = ['@tessl/cli', 'tessl'];
52
+ const FIRST_TESSL_LAUNCHER = '0.63.0';
30
53
  const MAX_OUTPUT = 64 * 1024;
31
54
  const STYLES = { ok: chalk.green, upgrade: chalk.yellow, unknown: chalk.yellow, missing: chalk.red, optional: chalk.dim };
32
55
 
@@ -172,6 +195,129 @@ async function versionOf(file, args, context) {
172
195
  return ok ? findVersion(stdout) : null;
173
196
  }
174
197
 
198
+ /**
199
+ * The package.json at `file` as an object, or null when there is none, it is
200
+ * not a regular file under MAX_PACKAGE_JSON (a FIFO would block the check
201
+ * past its timeout) or it cannot be read.
202
+ */
203
+ function readPackage(file) {
204
+ try {
205
+ const stat = fs.statSync(file);
206
+ if (!stat.isFile() || stat.size > MAX_PACKAGE_JSON) return null;
207
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
208
+ return data && typeof data === 'object' && !Array.isArray(data) ? data : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * The package.json of the package named one of `names` that installed
216
+ * `file`, the binary `bin` on PATH, or null: the shim folder's
217
+ * node_modules/<name>/package.json (npm's global layout on Windows), else the
218
+ * first package.json with a name above the binary's real path, when it is
219
+ * one of `names` and declares that file as its `bin` (npm, yarn, bun and
220
+ * Homebrew link the binary into its package on Linux and macOS). null for
221
+ * any other layout, such as pnpm's or Volta's shims.
222
+ */
223
+ function packageOf(file, bin, names) {
224
+ for (const name of names) {
225
+ const shim = readPackage(path.join(path.dirname(file), 'node_modules', ...name.split('/'), 'package.json'));
226
+ if (shim?.name === name) return shim;
227
+ }
228
+ const target = realpath(file);
229
+ const declares = (data, dir) => {
230
+ const entry = typeof data.bin === 'string' ? data.bin : data.bin?.[bin];
231
+ return typeof entry === 'string' && realpath(path.resolve(dir, entry)) === target;
232
+ };
233
+ let dir = path.dirname(target);
234
+ for (let depth = 0; depth < PACKAGE_DEPTH; depth++) {
235
+ const data = readPackage(path.join(dir, 'package.json'));
236
+ if (typeof data?.name === 'string') return names.includes(data.name) && declares(data, dir) ? data : null;
237
+ const parent = path.dirname(dir);
238
+ if (parent === dir) break;
239
+ dir = parent;
240
+ }
241
+ return null;
242
+ }
243
+
244
+ /** A file's stat, or null when it cannot be read (a dangling link, say). */
245
+ function statOf(file) {
246
+ try {
247
+ return fs.statSync(file);
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ /** The last part of `file`, in lower case on Windows, where names ignore case. */
254
+ function nameOf(file) {
255
+ return process.platform === 'win32' ? path.basename(file).toLowerCase() : path.basename(file);
256
+ }
257
+
258
+ /**
259
+ * The version of the tessl binary `file`, from its name: tessl-<version>-
260
+ * <platform> once its real path is resolved, or, for a copy at
261
+ * tessl/bin/tessl or tessl.exe (tessl's layout on Windows), the name of the
262
+ * one file of tessl/versions that has its size. null when there is no such
263
+ * file, or more than one.
264
+ */
265
+ function tesslBinaryVersion(file) {
266
+ const target = realpath(file);
267
+ const named = TESSL_BINARY.exec(nameOf(target));
268
+ if (named) return findVersion(named[1]);
269
+ const bin = path.dirname(target);
270
+ if (!/^tessl(?:\.exe)?$/.test(nameOf(target)) || nameOf(bin) !== 'bin' || nameOf(path.dirname(bin)) !== 'tessl') return null;
271
+ const size = statOf(target)?.size;
272
+ const versions = path.join(path.dirname(bin), 'versions');
273
+ let names;
274
+ try {
275
+ names = fs.readdirSync(versions);
276
+ } catch {
277
+ return null;
278
+ }
279
+ const copied = names.filter((name) => {
280
+ const stat = TESSL_BINARY.test(nameOf(name)) ? statOf(path.join(versions, name)) : null;
281
+ return stat?.isFile() && stat.size === size;
282
+ });
283
+ return size !== undefined && copied.length === 1 ? findVersion(TESSL_BINARY.exec(nameOf(copied[0]))[1]) : null;
284
+ }
285
+
286
+ /**
287
+ * The binary tessl's launchers run: ~/.local/bin/tessl, or
288
+ * %LOCALAPPDATA%\tessl\bin\tessl.exe on Windows. null when the home folder
289
+ * is not an absolute path, which would be read from the project folder.
290
+ */
291
+ function tesslLauncherTarget(env) {
292
+ const target =
293
+ process.platform === 'win32'
294
+ ? path.join(
295
+ envValue(env, 'LOCALAPPDATA') ?? path.join(envValue(env, 'USERPROFILE') || os.homedir(), 'AppData', 'Local'),
296
+ 'tessl',
297
+ 'bin',
298
+ 'tessl.exe',
299
+ )
300
+ : path.join(env.HOME || os.homedir(), '.local', 'bin', 'tessl');
301
+ return path.isAbsolute(target) ? target : null;
302
+ }
303
+
304
+ /**
305
+ * tessl's version, read without running it: from the binary on PATH (see
306
+ * tesslBinaryVersion), or, when that is the npm or Homebrew launcher, from
307
+ * the binary the launcher runs. An @tessl/cli older than the first launcher
308
+ * is the CLI itself, so its package.json has the version.
309
+ */
310
+ function tesslVersion(file, env) {
311
+ const own = tesslBinaryVersion(file);
312
+ if (own) return own;
313
+ const launcher = packageOf(file, 'tessl', TESSL_LAUNCHERS);
314
+ if (!launcher) return null;
315
+ const packaged = findVersion(launcher.version);
316
+ if (launcher.name === '@tessl/cli' && packaged && compareVersions(packaged, FIRST_TESSL_LAUNCHER)) return packaged;
317
+ const target = tesslLauncherTarget(env);
318
+ return target ? tesslBinaryVersion(target) : null;
319
+ }
320
+
175
321
  /** One tool: { found, version }, the version null when it cannot be read. */
176
322
  async function probe(key, tool, context) {
177
323
  if (key === 'node') return { found: true, version: process.versions.node };
@@ -190,6 +336,16 @@ async function probe(key, tool, context) {
190
336
  const match = ok ? CCC_PACKAGE.exec(stdout) : null;
191
337
  return { found: true, version: match ? findVersion(match[1]) : null };
192
338
  }
339
+ if (key === 'skill_check') {
340
+ const file = resolveOutsideProject(SKILL_CHECK_PACKAGE, context);
341
+ return file
342
+ ? { found: true, version: findVersion(packageOf(file, SKILL_CHECK_PACKAGE, [SKILL_CHECK_PACKAGE])?.version) }
343
+ : { found: false };
344
+ }
345
+ if (key === 'tessl') {
346
+ const file = resolveOutsideProject('tessl', context);
347
+ return file ? { found: true, version: tesslVersion(file, context.env) } : { found: false };
348
+ }
193
349
  const [name, ...args] = Array.isArray(tool.version_command) ? tool.version_command.map(String) : [];
194
350
  const file = name ? resolveOutsideProject(name, context) : null;
195
351
  if (!file) return { found: false };