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
@@ -23,11 +23,24 @@ Measured artifacts (all relative to the positional skill-package dir):
23
23
  extracted from the first --target-file that contains one
24
24
  (shared across the batch; measured once)
25
25
 
26
+ The managed_section object also gives `skills`, the number of skill rows (each
27
+ `[name v...]` row header) in the measured block, and `target_files`, every --target-file
28
+ that holds a block, in the order passed and each once. A target file that cannot be read
29
+ as UTF-8 text is skipped.
30
+
26
31
  package_total sums context-snippet.md + SKILL.md + metadata.json + references_total and
27
32
  DELIBERATELY EXCLUDES the managed-section row — the managed section is a shared all-skills
28
33
  cost reported separately, and including it would double-count this skill's own snippet
29
34
  (see references/token-report.md §1).
30
35
 
36
+ context_budget gives the token report's Context Budget Impact values, in tokens:
37
+ - always_on: the managed section (loaded in every session); null when no
38
+ target file holds a block
39
+ - on_trigger: SKILL.md (loaded when the skill triggers)
40
+ - full_disclosure: SKILL.md + references_total (loaded when every reference is read too);
41
+ neither the snippet nor metadata.json, so it is not package_total
42
+ - snippet: context-snippet.md (this skill's share of the managed section)
43
+
31
44
  CLI:
32
45
  python3 skf-count-tokens.py <skill-package-dir>
33
46
  python3 skf-count-tokens.py <skill-package-dir> --target-file CLAUDE.md
@@ -52,6 +65,13 @@ MANAGED_SECTION_RE = re.compile(
52
65
  re.DOTALL,
53
66
  )
54
67
 
68
+ # A skill row header of the managed section: `[name v<version>]` at the start of a line,
69
+ # optionally after the format's leading `|`, with or without a `root:` field. The
70
+ # ` v<version>` keeps the section's own `[SKF Skills]|{n} skills|{m} stack` header out.
71
+ # This is skf-rebuild-managed-sections.py's ROW_HEADER_PATTERN, so both scripts count the
72
+ # rows parse_managed_rows returns (test-skf-count-tokens.py keeps them in step).
73
+ MANAGED_ROW_RE = re.compile(r"^\|?\[(?P<name>[^\]]+?) v(?P<version>[^\]\s]+)\]", re.MULTILINE)
74
+
55
75
  # Core per-skill artifacts, in report order. (references/ is handled separately.)
56
76
  CORE_ARTIFACTS = [
57
77
  ("context-snippet.md", "context-snippet"),
@@ -87,6 +107,11 @@ def extract_managed_section(text: str) -> str | None:
87
107
  return m.group(0) if m else None
88
108
 
89
109
 
110
+ def count_managed_skills(section: str) -> int:
111
+ """The number of skill rows (`[name v...]` row headers) in a managed section."""
112
+ return len(MANAGED_ROW_RE.findall(section))
113
+
114
+
90
115
  def measure_reference_files(references_dir: Path) -> tuple[list[dict], dict]:
91
116
  """Measure every file under references/ (recursively, sorted). Returns (per-file rows, total)."""
92
117
  rows: list[dict] = []
@@ -144,20 +169,38 @@ def count_package(skill_dir: Path, target_files: list[Path] | None = None) -> di
144
169
  "tokens": sum(core_by_role[r]["tokens"] for r in core_by_role) + references_total["tokens"],
145
170
  }
146
171
 
147
- # Managed section — first --target-file that actually contains a block wins.
148
- managed_section = {"present": False, "source_file": None, "words": 0, "tokens": 0}
172
+ # Managed section: the first --target-file that actually contains a block is measured;
173
+ # target_files lists every one that holds a block.
174
+ managed_section = {"present": False, "source_file": None, "target_files": [], "skills": 0,
175
+ "words": 0, "tokens": 0}
176
+ seen: set[Path] = set()
149
177
  for tf in target_files:
150
178
  tf = Path(tf)
151
- if not tf.is_file():
179
+ if tf.resolve() in seen or not tf.is_file():
152
180
  continue
153
- section = extract_managed_section(_read_text(tf))
154
- if section is not None:
155
- managed_section = {
156
- "present": True,
157
- "source_file": tf.as_posix(),
181
+ seen.add(tf.resolve())
182
+ try:
183
+ section = extract_managed_section(_read_text(tf))
184
+ except (OSError, UnicodeDecodeError):
185
+ continue # unreadable or not UTF-8: it holds no block this helper can measure
186
+ if section is None:
187
+ continue
188
+ managed_section["target_files"].append(tf.as_posix())
189
+ if not managed_section["present"]:
190
+ managed_section.update(
191
+ present=True,
192
+ source_file=tf.as_posix(),
193
+ skills=count_managed_skills(section),
158
194
  **measure(section),
159
- }
160
- break
195
+ )
196
+
197
+ # The Context Budget Impact values token-report.md §2 renders.
198
+ context_budget = {
199
+ "always_on": managed_section["tokens"] if managed_section["present"] else None,
200
+ "on_trigger": core_by_role["skill-md"]["tokens"],
201
+ "full_disclosure": core_by_role["skill-md"]["tokens"] + references_total["tokens"],
202
+ "snippet": core_by_role["context-snippet"]["tokens"],
203
+ }
161
204
 
162
205
  return {
163
206
  "status": "ok",
@@ -166,6 +209,7 @@ def count_package(skill_dir: Path, target_files: list[Path] | None = None) -> di
166
209
  "references_total": references_total,
167
210
  "managed_section": managed_section,
168
211
  "package_total": package_total,
212
+ "context_budget": context_budget,
169
213
  }
170
214
 
171
215
 
@@ -46,6 +46,7 @@ Exit codes:
46
46
  from __future__ import annotations
47
47
 
48
48
  import argparse
49
+ import importlib.util
49
50
  import json
50
51
  import sys
51
52
  from pathlib import Path
@@ -53,6 +54,8 @@ from typing import Any
53
54
 
54
55
  import yaml
55
56
 
57
+ RESOLVER = Path(__file__).resolve().parent / "skf-resolve-authoritative-files.py"
58
+
56
59
 
57
60
  def derive_assembly_shape(brief: dict[str, Any]) -> dict[str, Any]:
58
61
  """Return the assembly-shape decision for a parsed brief."""
@@ -73,6 +76,20 @@ def _die(message: str) -> None:
73
76
  sys.exit(2)
74
77
 
75
78
 
79
+ def _parse_brief_yaml(text: str):
80
+ """The brief's YAML as skf-resolve-authoritative-files.py (from this
81
+ folder) reads it: an SKF 1.x brief's trailing `---` is no second document."""
82
+ spec = importlib.util.spec_from_file_location("skf_resolve_authoritative_files", RESOLVER)
83
+ if spec is None or spec.loader is None:
84
+ _die(f"cannot load {RESOLVER.name} beside {Path(__file__).name}")
85
+ resolver = importlib.util.module_from_spec(spec)
86
+ try:
87
+ spec.loader.exec_module(resolver)
88
+ except (OSError, ImportError, SyntaxError) as exc:
89
+ _die(f"cannot load {RESOLVER.name} beside {Path(__file__).name}: {exc}")
90
+ return resolver.parse_brief_yaml(text)
91
+
92
+
76
93
  def main(argv: list[str] | None = None) -> int:
77
94
  parser = argparse.ArgumentParser(
78
95
  prog="skf-derive-assembly-shape",
@@ -93,7 +110,7 @@ def main(argv: list[str] | None = None) -> int:
93
110
  text = p.read_text(encoding="utf-8")
94
111
 
95
112
  try:
96
- brief = yaml.safe_load(text)
113
+ brief = _parse_brief_yaml(text)
97
114
  except yaml.YAMLError as exc:
98
115
  _die(f"Brief is not valid YAML: {exc}")
99
116
  if not isinstance(brief, dict):
@@ -1,6 +1,6 @@
1
1
  # /// script
2
2
  # requires-python = ">=3.11"
3
- # dependencies = []
3
+ # dependencies = ["pyyaml"]
4
4
  # ///
5
5
  """SKF Detect Scripts & Assets — file-level artifact detection for skill compilation.
6
6
 
@@ -22,11 +22,32 @@ CLI:
22
22
  uv run skf-detect-scripts-assets.py detect <source-root> \\
23
23
  [--scripts-intent detect|none] \\
24
24
  [--assets-intent detect|none] \\
25
- [--scope-include "pattern1,pattern2,..."] \\
25
+ [--brief <skill-brief.yaml>] \\
26
26
  [--max-lines 500]
27
27
 
28
+ --brief keeps a file only when the skill brief's scope takes it, by the
29
+ one brief-scope test of skf-classify-changed-files.py (load_scope, loaded
30
+ from this folder only when --brief is given), which update-skill's
31
+ Category A and Category D and audit-skill's Script/Asset Drift use too:
32
+ the glob rules of skf-resolve-authoritative-files.py (`**` spans any
33
+ number of folders, `*` and `?` never cross a `/`), each `scope.include`
34
+ and `scope.exclude` pattern normalized (forward slashes, no leading `./`),
35
+ and a path in scope when an include matches it and no exclude does. With
36
+ an empty `scope.include`, a path is in scope when no exclude matches it
37
+ and its extension is one the brief's `language` uses. The test runs before
38
+ the script and asset rules. The brief's intents are not read here:
39
+ --scripts-intent and --assets-intent set them. A --brief that names no
40
+ file, one that cannot be read and an empty one (an unbound brief path)
41
+ exit 1, never an unscoped run. Without --brief every file is in scope.
42
+
28
43
  Detection rules — scripts:
29
- Directory convention: scripts/, bin/, tools/, cli/
44
+ Directory convention: scripts/, bin/, tools/, cli/ (at any depth). A
45
+ Python package's folder is code, not a script folder: a `.py` file
46
+ under such a folder that holds `__init__.py` is a script only with a
47
+ shebang, a top-level `if __name__ == "__main__":` block (either
48
+ comparison order, parentheses allowed, never a line inside a string)
49
+ or when it is the package's `__main__.py` (the `python -m` entry).
50
+ is_package_module() is that rule.
30
51
  Shebang signals: #!/bin/bash, #!/usr/bin/env python|node|bash|sh, etc.
31
52
  Entry point declarations: package.json `bin`, pyproject.toml [project.scripts]
32
53
  CLI argument-parser imports are detected for the LLM-judgment tier only —
@@ -44,6 +65,13 @@ Exclusions:
44
65
  Generated paths: dist/, build/, .webpack/, node_modules/, __pycache__/,
45
66
  target/ (Rust), .next/, .nuxt/, out/, coverage/
46
67
 
68
+ file_kind() is the per-file rule: a script (is_script) before an asset
69
+ (asset_type), each asked only when its kind is on. detect() calls it with
70
+ its intents; skf-compare-file-hashes.py (audit-skill's Script/Asset Drift)
71
+ loads this file and walks with iter_files, is_binary_path,
72
+ entry_point_script_paths and file_kind, both kinds on, so audit-skill finds
73
+ the same new files update-skill's Category D finds through detect.
74
+
47
75
  Size flag:
48
76
  Files >max-lines (default 500) get size_flag="oversized" but are still
49
77
  included — the caller decides whether to bundle them.
@@ -63,15 +91,18 @@ Confidence is always "T1-low" — file existence verified, content not
63
91
  AST-analyzed. Matches the spec at extraction-patterns-tracing.md §Provenance.
64
92
 
65
93
  Exit codes:
66
- 0 — detection succeeded (including --scripts-intent=none --assets-intent=none)
67
- 1 — user error (bad source root, malformed args)
94
+ 0 detection succeeded (including --scripts-intent=none --assets-intent=none)
95
+ 1 user error (bad source root, malformed args, a --brief that names no
96
+ file, cannot be read or is empty, or a scope test that cannot be
97
+ loaded): one `error:` line on stderr
68
98
  """
69
99
 
70
100
  from __future__ import annotations
71
101
 
72
102
  import argparse
73
- import fnmatch
103
+ import ast
74
104
  import hashlib
105
+ import importlib.util
75
106
  import json
76
107
  import re
77
108
  import sys
@@ -182,18 +213,6 @@ def in_directory(path: Path, root: Path, dir_names: set[str]) -> bool:
182
213
  return any(seg in dir_names for seg in relative_segments(path, root))
183
214
 
184
215
 
185
- def matches_scope(path: Path, root: Path, scope_patterns: list[str]) -> bool:
186
- """Match `path` against scope-include glob patterns relative to root.
187
-
188
- Forward-slash form so scope patterns like "scripts/*" match on Windows
189
- too — fnmatch is byte-exact and won't normalize separators for us.
190
- """
191
- if not scope_patterns:
192
- return True
193
- rel = path.relative_to(root).as_posix()
194
- return any(fnmatch.fnmatch(rel, pattern) for pattern in scope_patterns)
195
-
196
-
197
216
  # --------------------------------------------------------------------------
198
217
  # Read helpers
199
218
  # --------------------------------------------------------------------------
@@ -294,6 +313,8 @@ def _parse_package_json_bin(source_root: Path) -> Iterable[Path]:
294
313
  data = json.loads(text)
295
314
  except json.JSONDecodeError:
296
315
  return ()
316
+ if not isinstance(data, dict): # valid JSON but no object (`[]`, `null`, a number): no `bin`
317
+ return ()
297
318
  bin_field = data.get("bin")
298
319
  if isinstance(bin_field, str):
299
320
  candidates = [bin_field]
@@ -325,11 +346,72 @@ def _parse_pyproject_scripts(source_root: Path) -> Iterable[Path]:
325
346
  return ()
326
347
 
327
348
 
349
+ # The fallback for a file that does not parse: a column-0 `if` comparing
350
+ # __name__ with "__main__", either way round, parentheses allowed.
351
+ _MAIN_BLOCK_RE = re.compile(
352
+ r"^if\s*\(?\s*(?:__name__\s*==\s*(['\"])__main__\1|(['\"])__main__\2\s*==\s*__name__)\s*\)?\s*:", re.M
353
+ )
354
+
355
+
356
+ def in_package_script_dir(path: Path, source_root: Path) -> bool:
357
+ """True when a script folder (SCRIPT_DIRS) over `path` holds
358
+ `__init__.py`: a Python package that happens to be named tools/, cli/,
359
+ bin/ or scripts/, whose modules are code."""
360
+ segments = relative_segments(path, source_root)
361
+ return any(
362
+ seg in SCRIPT_DIRS and source_root.joinpath(*segments[: i + 1], "__init__.py").is_file()
363
+ for i, seg in enumerate(segments)
364
+ )
365
+
366
+
367
+ def has_shebang(path: Path) -> bool:
368
+ try:
369
+ with path.open("rb") as fh:
370
+ return fh.read(2) == b"#!"
371
+ except OSError:
372
+ return False
373
+
374
+
375
+ def _is_main_test(test: ast.expr) -> bool:
376
+ """`__name__ == "__main__"` or `"__main__" == __name__`."""
377
+ if not (isinstance(test, ast.Compare) and len(test.ops) == 1 and isinstance(test.ops[0], ast.Eq)):
378
+ return False
379
+ sides = (test.left, test.comparators[0])
380
+ return (any(isinstance(s, ast.Name) and s.id == "__name__" for s in sides)
381
+ and any(isinstance(s, ast.Constant) and s.value == "__main__" for s in sides))
382
+
383
+
384
+ def has_main_block(path: Path) -> bool:
385
+ """True when the file holds a top-level `if __name__ == "__main__":`
386
+ (either order, parenthesized or not). The file is parsed, so such a
387
+ line inside a string never counts; a file that does not parse is
388
+ matched line by line."""
389
+ text = read_text_safe(path)
390
+ if not text:
391
+ return False
392
+ try:
393
+ tree = ast.parse(text)
394
+ except (SyntaxError, ValueError):
395
+ return _MAIN_BLOCK_RE.search(text) is not None
396
+ return any(isinstance(node, ast.If) and _is_main_test(node.test) for node in tree.body)
397
+
398
+
399
+ def is_package_module(path: Path, source_root: Path) -> bool:
400
+ """True when `path` is a module of a Python package named like a script
401
+ folder (in_package_script_dir): code, not a script, unless it runs on
402
+ its own (a shebang, a top-level `__main__` block, or the package's
403
+ `__main__.py`, which `python -m` runs)."""
404
+ return (path.suffix.lower() == ".py" and in_package_script_dir(path, source_root)
405
+ and path.name != "__main__.py" and not (has_shebang(path) or has_main_block(path)))
406
+
407
+
328
408
  def is_script(path: Path, source_root: Path, *, entry_points: set[Path]) -> tuple[bool, str | None]:
329
409
  """Decide whether `path` is a script. Returns (is_script, shebang_lang)."""
330
410
  if path.resolve() in entry_points:
331
411
  return True, detect_shebang_language(path)
332
412
  if in_directory(path, source_root, SCRIPT_DIRS):
413
+ if is_package_module(path, source_root):
414
+ return False, None
333
415
  return True, detect_shebang_language(path)
334
416
  shebang_lang = detect_shebang_language(path)
335
417
  if shebang_lang is not None:
@@ -379,6 +461,25 @@ def asset_type(path: Path, source_root: Path) -> str | None:
379
461
  return None
380
462
 
381
463
 
464
+ def file_kind(path: Path, source_root: Path, entry_points: set[Path], *,
465
+ scripts: bool = True, assets: bool = True) -> tuple[str, str | None] | None:
466
+ """What `path` is: ("script", its shebang language or None), ("asset",
467
+ its asset type) or None. A script is never also an asset: is_script is
468
+ asked first, when `scripts` is on, then asset_type, when `assets` is on.
469
+ `entry_points` is entry_point_script_paths(source_root) (an empty set
470
+ when scripts are off). The kind strings are the ones
471
+ skf-classify-changed-files.py's Scope.wants takes."""
472
+ if scripts:
473
+ is_scr, shebang_lang = is_script(path, source_root, entry_points=entry_points)
474
+ if is_scr:
475
+ return "script", shebang_lang
476
+ if assets:
477
+ atype = asset_type(path, source_root)
478
+ if atype is not None:
479
+ return "asset", atype
480
+ return None
481
+
482
+
382
483
  # --------------------------------------------------------------------------
383
484
  # Purpose extraction
384
485
  # --------------------------------------------------------------------------
@@ -474,10 +575,12 @@ def detect(
474
575
  *,
475
576
  scripts_intent: str = "detect",
476
577
  assets_intent: str = "detect",
477
- scope_patterns: list[str] | None = None,
578
+ scope=None,
478
579
  max_lines: int = 500,
479
580
  ) -> dict:
480
- scope_patterns = scope_patterns or []
581
+ """The inventories of `source_root`. `scope` is the brief's Scope
582
+ (skf-classify-changed-files.py load_scope, --brief): a file it does not
583
+ take is neither a script nor an asset. None takes every file."""
481
584
  scripts_skipped = scripts_intent == "none"
482
585
  assets_skipped = assets_intent == "none"
483
586
 
@@ -494,30 +597,27 @@ def detect(
494
597
  files_scanned += 1
495
598
  if is_binary_path(path):
496
599
  continue
497
- if not matches_scope(path, source_root, scope_patterns):
600
+ if scope is not None and not scope.takes(path.relative_to(source_root).as_posix()):
498
601
  continue
499
602
 
500
- if not scripts_skipped:
501
- is_scr, shebang_lang = is_script(
502
- path, source_root, entry_points=entry_points
503
- )
504
- if is_scr:
505
- scripts_inventory.append(
506
- build_script_record(
507
- path, source_root,
508
- shebang_lang=shebang_lang, max_lines=max_lines,
509
- )
603
+ found = file_kind(path, source_root, entry_points,
604
+ scripts=not scripts_skipped, assets=not assets_skipped)
605
+ if found is None:
606
+ continue
607
+ kind, detail = found
608
+ if kind == "script":
609
+ scripts_inventory.append(
610
+ build_script_record(
611
+ path, source_root,
612
+ shebang_lang=detail, max_lines=max_lines,
510
613
  )
511
- continue # a script can't also be an asset
512
-
513
- if not assets_skipped:
514
- atype = asset_type(path, source_root)
515
- if atype is not None:
516
- assets_inventory.append(
517
- build_asset_record(
518
- path, source_root, atype=atype, max_lines=max_lines,
519
- )
614
+ )
615
+ else:
616
+ assets_inventory.append(
617
+ build_asset_record(
618
+ path, source_root, atype=detail, max_lines=max_lines,
520
619
  )
620
+ )
521
621
 
522
622
  # Deterministic ordering for stable diffs / cacheability
523
623
  scripts_inventory.sort(key=lambda r: r["source_file"])
@@ -541,28 +641,69 @@ def detect(
541
641
  # --------------------------------------------------------------------------
542
642
 
543
643
 
644
+ _SIBLINGS: dict[str, object] = {}
645
+
646
+
647
+ def _sibling(filename: str):
648
+ """A helper installed beside this script, loaded once as a module:
649
+ skf-classify-changed-files.py, whose load_scope is --brief's scope
650
+ test (it loads skf-resolve-authoritative-files.py beside it). Raises
651
+ whatever loading it raises."""
652
+ module = _SIBLINGS.get(filename)
653
+ if module is None:
654
+ path = Path(__file__).resolve().parent / filename
655
+ name = "skf_" + filename.removeprefix("skf-").removesuffix(".py").replace("-", "_")
656
+ spec = importlib.util.spec_from_file_location(name, path)
657
+ if spec is None or spec.loader is None or not path.is_file():
658
+ raise ImportError(f"{filename} not found beside {Path(__file__).name}")
659
+ module = importlib.util.module_from_spec(spec)
660
+ spec.loader.exec_module(module)
661
+ _SIBLINGS[filename] = module
662
+ return module
663
+
664
+
665
+ def _error(message: str) -> int:
666
+ """`message` as one `error:` line on stderr (a YAML error spans several
667
+ lines: each line break and the space around it becomes one space); exit 1."""
668
+ print("error: " + re.sub(r"\s*[\r\n]\s*", " ", message.strip("\r\n")), file=sys.stderr)
669
+ return 1
670
+
671
+
672
+ def load_brief_scope(brief: str):
673
+ """--brief's Scope. Raises ValueError for an empty value, a brief that
674
+ names no file or cannot be read, and a scope test that cannot be loaded
675
+ (a classifier or resolver missing, out of date or failing)."""
676
+ if not brief.strip():
677
+ raise ValueError("--brief is empty (an unbound brief path?): pass the brief, or leave --brief out")
678
+ try:
679
+ return _sibling("skf-classify-changed-files.py").load_scope(Path(brief))
680
+ except ValueError: # a brief it cannot find or read
681
+ raise
682
+ except Exception as exc: # whatever the scope test raises, the run reports it on one line
683
+ raise ValueError(f"cannot load the brief's scope test beside {Path(__file__).name}: {exc}; "
684
+ "re-install SKF") from exc
685
+
686
+
544
687
  def _cmd_detect(args: argparse.Namespace) -> int:
545
688
  source_root = Path(args.source_root).resolve()
546
689
  if not source_root.is_dir():
547
- print(f"error: source root not a directory: {source_root}", file=sys.stderr)
548
- return 1
549
- scope_patterns = (
550
- [s.strip() for s in args.scope_include.split(",") if s.strip()]
551
- if args.scope_include
552
- else []
553
- )
690
+ return _error(f"source root not a directory: {source_root}")
554
691
  if args.scripts_intent not in ("detect", "none"):
555
- print(f"error: --scripts-intent must be detect|none", file=sys.stderr)
556
- return 1
692
+ return _error("--scripts-intent must be detect|none")
557
693
  if args.assets_intent not in ("detect", "none"):
558
- print(f"error: --assets-intent must be detect|none", file=sys.stderr)
559
- return 1
694
+ return _error("--assets-intent must be detect|none")
695
+ scope = None
696
+ if args.brief is not None:
697
+ try:
698
+ scope = load_brief_scope(args.brief)
699
+ except ValueError as exc:
700
+ return _error(str(exc))
560
701
 
561
702
  result = detect(
562
703
  source_root,
563
704
  scripts_intent=args.scripts_intent,
564
705
  assets_intent=args.assets_intent,
565
- scope_patterns=scope_patterns,
706
+ scope=scope,
566
707
  max_lines=args.max_lines,
567
708
  )
568
709
  json.dump(result, sys.stdout, indent=2)
@@ -589,9 +730,10 @@ def _build_parser() -> argparse.ArgumentParser:
589
730
  help="detect|none (default: detect)",
590
731
  )
591
732
  p.add_argument(
592
- "--scope-include",
733
+ "--brief",
593
734
  default=None,
594
- help="comma-separated glob patterns to limit detection (relative to source-root)",
735
+ metavar="BRIEF",
736
+ help="the skill brief (skill-brief.yaml): detect only in the files its scope takes",
595
737
  )
596
738
  p.add_argument(
597
739
  "--max-lines",
@@ -160,6 +160,26 @@ skf-recommend-scope-type.py reads (registry.ts, components.ts and their .tsx
160
160
  forms, at any depth), found by that script's own rule (the sibling in this
161
161
  folder, which must sit beside this script).
162
162
 
163
+ Brief scope (scoped_tree)
164
+ -------------------------
165
+
166
+ scoped_tree(tree, excludes, glob_match) is the tree a skill brief's
167
+ scope.exclude globs leave, as workspace detection reads it (#780): a
168
+ workspace member counts only through the files the brief keeps. It holds
169
+ each file no exclude glob matches, but a manifest (a MEMBER_MANIFESTS name);
170
+ each root manifest (a file at the root named in MEMBER_MANIFESTS,
171
+ CONTENT_MANIFESTS or nx.json), always; and each member manifest (a
172
+ MEMBER_MANIFESTS name below the root) whose folder still holds, at any
173
+ depth, a non-manifest file no exclude glob matches, whether or not a glob
174
+ matches the manifest itself. A member manifest whose folder holds no
175
+ non-manifest file at all stays unless a glob matches it. So `rust/**`,
176
+ `rust/**/*.rs` or `rust/*/src/**` drops the members under rust/,
177
+ `**/Cargo.toml` alone drops none, and a workspace with no member left is
178
+ no workspace, while one with a member left still is. With no exclude glob
179
+ the tree is returned as given. Callers (test-skill's stage-helper-payload.py
180
+ and skf-extract-public-api.py's monorepo arm) pass the glob rule of
181
+ skf-resolve-authoritative-files.py, which this script does not load.
182
+
163
183
  Exit codes:
164
184
 
165
185
  0 success (regardless of is_monorepo value)
@@ -181,6 +201,7 @@ import posixpath
181
201
  import re
182
202
  import sys
183
203
  import tomllib
204
+ from collections.abc import Callable
184
205
  from pathlib import Path
185
206
  from typing import Optional
186
207
 
@@ -200,6 +221,11 @@ RECOGNISED_MANIFESTS = (
200
221
  # in the tree alone): --manifest-files lists them, --manifest-dir reads them,
201
222
  # and a warning names each one the tree lists that was not supplied.
202
223
  CONTENT_MANIFESTS = ("package.json", "pnpm-workspace.yaml", "lerna.json", "rush.json", "Cargo.toml")
224
+ # The manifest a workspace member is found by: a recognised one, or an Nx
225
+ # project's project.json. scoped_tree keeps one while its folder keeps a file.
226
+ MEMBER_MANIFESTS = RECOGNISED_MANIFESTS + ("project.json",)
227
+ # The root files scoped_tree keeps whatever the brief excludes.
228
+ ROOT_MANIFESTS = frozenset(MEMBER_MANIFESTS + CONTENT_MANIFESTS + ("nx.json",))
203
229
  MAX_MANIFEST_BYTES = 1024 * 1024
204
230
  # Folders a snapshot's module root is never followed down through (names
205
231
  # matched in lower case): tests, test helpers and fixtures, docs and sites,
@@ -698,6 +724,47 @@ def detect(payload: dict) -> dict:
698
724
  }
699
725
 
700
726
 
727
+ # --------------------------------------------------------------------------
728
+ # Brief scope
729
+ # --------------------------------------------------------------------------
730
+
731
+
732
+ def scoped_tree(tree: list[str], excludes: list[str], glob_match: Callable[[str, str], bool]) -> list[str]:
733
+ """The paths of `tree` workspace detection reads under a brief whose
734
+ scope.exclude globs are `excludes`, in their order: see "Brief scope" in
735
+ the module docstring. `glob_match(path, glob)` is the brief's glob rule."""
736
+ if not excludes:
737
+ return list(tree)
738
+
739
+ def manifest(path: str) -> bool:
740
+ return posixpath.basename(path) in MEMBER_MANIFESTS
741
+
742
+ excluded = {path for path in tree if any(glob_match(path, glob) for glob in excludes)}
743
+ holding: set[str] = set() # folders holding a non-manifest file
744
+ kept_holding: set[str] = set() # folders holding one no exclude glob matches
745
+ for path in tree:
746
+ if manifest(path):
747
+ continue
748
+ parts = path.split("/")
749
+ for depth in range(1, len(parts)):
750
+ folder = "/".join(parts[:depth])
751
+ holding.add(folder)
752
+ if path not in excluded:
753
+ kept_holding.add(folder)
754
+ kept = []
755
+ for path in tree:
756
+ if "/" not in path and path in ROOT_MANIFESTS:
757
+ keep = True
758
+ elif not manifest(path):
759
+ keep = path not in excluded
760
+ else:
761
+ folder = posixpath.dirname(path)
762
+ keep = folder in kept_holding or (folder not in holding and path not in excluded)
763
+ if keep:
764
+ kept.append(path)
765
+ return kept
766
+
767
+
701
768
  # --------------------------------------------------------------------------
702
769
  # Tree snapshot
703
770
  # --------------------------------------------------------------------------
@@ -423,11 +423,12 @@ def _compute_tool_deltas(current: dict, previous) -> tuple[list[str], list[str]]
423
423
  def _resolver_warning(payload: dict) -> list[str]:
424
424
  """The customization_resolver_unavailable warning, when the payload reports one.
425
425
 
426
- Every workflow's On Activation runs resolve_customization.py. When that
427
- script is missing or fails, the run falls back to the skill's own
428
- customize.toml, so the team and user overrides under `_bmad/custom/`
429
- silently do nothing; the payload key carries the resolver's reason so
430
- the envelope says so.
426
+ Every workflow's On Activation runs skf-resolve-customization.py, which
427
+ runs BMAD core's resolve_customization.py. When either script is missing
428
+ or fails, or a core before 6.11.0 would read another folder's overrides,
429
+ the run falls back to the skill's own customize.toml, so the team and
430
+ user overrides under `_bmad/custom/` silently do nothing; the payload
431
+ key carries the reason, whatever its words, so the envelope says so.
431
432
  """
432
433
  reason = payload.get("customization_resolver_unavailable")
433
434
  if not reason: