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,7 +1,7 @@
1
1
  #!/usr/bin/env python3
2
2
  # /// script
3
3
  # requires-python = ">=3.11"
4
- # dependencies = []
4
+ # dependencies = ["pyyaml"]
5
5
  # ///
6
6
  """Print the stdin payload of a shared SKF helper, read from the files on disk.
7
7
 
@@ -13,7 +13,7 @@ This script reads the files and prints the payload, and the step pipes it
13
13
  into the helper:
14
14
 
15
15
  uv run stage-helper-payload.py detect-workspaces --source-root <dir> \\
16
- | uv run skf-detect-workspaces.py
16
+ [--brief <skill-brief.yaml>] | uv run skf-detect-workspaces.py
17
17
 
18
18
  (The Quick-tier export scan needs no payload: skf-extract-public-api.py
19
19
  --mode quick reads its files itself, with --manifest-file and --entry-file.)
@@ -24,28 +24,48 @@ detect-workspaces prints {"tree": [...], "manifests": {...}}:
24
24
  it, sorted. Folders named in SKIPPED_DIRS (installed
25
25
  dependencies, virtual environments, build output, caches) and
26
26
  hidden folders (a name that starts with ".") are not read, and a
27
- linked folder is not followed.
27
+ linked folder is not followed. With --brief, the tree is the
28
+ detector's scoped_tree of it under the brief's scope.exclude
29
+ globs (#780): a workspace member counts only through the files
30
+ the brief keeps, so excluding a member's source files drops it,
31
+ excluding only its manifest does not, every root manifest
32
+ stays, and a workspace with no member left is no workspace.
28
33
  manifests the text of each file directly in --source-root that is not
29
34
  hidden, holds at most MAX_ROOT_FILE_BYTES and reads as UTF-8, by
30
- its name. The detector reads the root manifests it knows
31
- (package.json, pnpm-workspace.yaml, lerna.json, Cargo.toml ...)
32
- and ignores the rest, so this script keeps no list of their
33
- names to fall out of step with the detector's.
35
+ its name, whatever the brief excludes. The detector reads the
36
+ root manifests it knows (package.json, pnpm-workspace.yaml,
37
+ lerna.json, Cargo.toml ...) and ignores the rest, so this script
38
+ keeps no list of their names to fall out of step with the
39
+ detector's.
40
+
41
+ --brief reads the brief with load-coverage-inputs.py's read_brief (the
42
+ script beside this one, which loads skf-resolve-authoritative-files.py's
43
+ parse_brief_yaml from the shared scripts folder), and matches each
44
+ scope.exclude glob by its glob_match, skf-resolve-authoritative-files.py's
45
+ rule (`**` spans any number of path segments, none included; `*` and `?`
46
+ stay inside one), the rule skf-extract-public-api.py applies to the same
47
+ brief. skf-detect-workspaces.py's scoped_tree, loaded from the shared
48
+ scripts folder (SHARED_SCRIPTS), keeps the paths: the monorepo arm of
49
+ skf-extract-public-api.py reads the same function.
34
50
 
35
51
  The payload is one line of ASCII JSON on stdout, so a pipe carries the same
36
52
  bytes on every platform.
37
53
 
38
54
  Exit codes:
39
55
  0 the payload is printed
40
- 1 --source-root is not a folder; one JSON line {"status": "error",
41
- "error": ...} goes to stderr and nothing to stdout, so the helper the
42
- payload was piped into reads no input and fails too
56
+ 1 --source-root is not a folder, or --brief cannot be read (missing, not
57
+ UTF-8, not YAML, a value YAML cannot build such as an invalid date, not
58
+ a mapping, PyYAML not installed) or a script it loads is missing; one
59
+ JSON line {"status": "error", "error": ...} goes to stderr and nothing
60
+ to stdout, so the helper the payload was piped into reads no input and
61
+ fails too
43
62
  2 bad arguments (argparse: a missing or unknown argument, usage on stderr)
44
63
  """
45
64
 
46
65
  from __future__ import annotations
47
66
 
48
67
  import argparse
68
+ import importlib.util
49
69
  import json
50
70
  import os
51
71
  import sys
@@ -57,6 +77,9 @@ SKIPPED_DIRS = frozenset({
57
77
  "venv", "site-packages", "__pycache__", "target",
58
78
  })
59
79
  MAX_ROOT_FILE_BYTES = 1024 * 1024
80
+ SCRIPTS = Path(__file__).resolve().parent
81
+ # skf-detect-workspaces.py's folder, installed and in src/ alike.
82
+ SHARED_SCRIPTS = SCRIPTS.parent.parent / "shared" / "scripts"
60
83
 
61
84
 
62
85
  class StageError(Exception):
@@ -70,6 +93,29 @@ def _source_root(value: str) -> Path:
70
93
  return root
71
94
 
72
95
 
96
+ def _load(folder: Path, filename: str):
97
+ """A script in `folder`, loaded as a module, as load-coverage-inputs.py loads its siblings."""
98
+ path = folder / filename
99
+ spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
100
+ if spec is None or spec.loader is None or not path.is_file():
101
+ raise StageError(f"{filename} not found in {folder}")
102
+ module = importlib.util.module_from_spec(spec)
103
+ spec.loader.exec_module(module)
104
+ return module
105
+
106
+
107
+ def scoped_tree(tree: list[str], brief: str) -> list[str]:
108
+ """The paths of `tree` workspace detection reads under the brief at `brief`."""
109
+ inputs = _load(SCRIPTS, "load-coverage-inputs.py")
110
+ try:
111
+ excludes = inputs.read_brief(brief)["scope"]["exclude"]
112
+ except inputs.InputError as exc:
113
+ raise StageError(str(exc)) from exc
114
+ except ValueError as exc: # a value YAML cannot build, such as an invalid date
115
+ raise StageError(f"--brief {brief} is not valid YAML: {' '.join(str(exc).split())}") from exc
116
+ return _load(SHARED_SCRIPTS, "skf-detect-workspaces.py").scoped_tree(tree, excludes, inputs.glob_match)
117
+
118
+
73
119
  # --------------------------------------------------------------------------
74
120
  # detect-workspaces
75
121
  # --------------------------------------------------------------------------
@@ -105,9 +151,13 @@ def root_texts(root: Path) -> dict[str, str]:
105
151
  return texts
106
152
 
107
153
 
108
- def workspaces_payload(source_root: str) -> dict:
154
+ def workspaces_payload(source_root: str, brief: str | None = None) -> dict:
155
+ """The tree (with `brief`, the part of it the brief keeps) and the root files."""
109
156
  root = _source_root(source_root)
110
- return {"tree": list_tree(root), "manifests": root_texts(root)}
157
+ tree = list_tree(root)
158
+ if brief is not None:
159
+ tree = scoped_tree(tree, brief)
160
+ return {"tree": tree, "manifests": root_texts(root)}
111
161
 
112
162
 
113
163
  # --------------------------------------------------------------------------
@@ -116,7 +166,7 @@ def workspaces_payload(source_root: str) -> dict:
116
166
 
117
167
 
118
168
  def _cmd_detect_workspaces(args: argparse.Namespace) -> dict:
119
- return workspaces_payload(args.source_root)
169
+ return workspaces_payload(args.source_root, args.brief)
120
170
 
121
171
 
122
172
  def _build_parser() -> argparse.ArgumentParser:
@@ -128,6 +178,7 @@ def _build_parser() -> argparse.ArgumentParser:
128
178
 
129
179
  p = sub.add_parser("detect-workspaces", help="the tree and root files for skf-detect-workspaces.py")
130
180
  p.add_argument("--source-root", required=True, help="the local source folder")
181
+ p.add_argument("--brief", help="the skill brief, whose scope.exclude globs leave paths out of the tree")
131
182
  p.set_defaults(func=_cmd_detect_workspaces)
132
183
 
133
184
  return parser
@@ -10,7 +10,8 @@ trust subagent output blindly: before any downstream step consumes the
10
10
  documented inventory, the shape is validated. That validation is pure
11
11
  structural checking — strip wrapping fences, parse JSON, assert required keys
12
12
  and types, assert each export entry is a dict with a non-empty `name` and a
13
- `kind` drawn from a fixed enum, and assert each cross-check mismatch carries its
13
+ `kind` drawn from a fixed enum (and, when present, a `signature_form` of
14
+ `partial` or `full`), and assert each cross-check mismatch carries its
14
15
  five fields — with exactly one correct pass/fail per input and no
15
16
  interpretation of meaning. It is therefore script work, not prompt work; the
16
17
  prompt reads this verdict and owns only the HALT decision.
@@ -26,6 +27,15 @@ violation, and the prompt re-dispatches the subagent with them, as it does
26
27
  for a schema violation. No grep runs in the prompt, so an export named
27
28
  `$state` or `a.b` is never a shell variable or a regex.
28
29
 
30
+ An entry's optional `signature_form` says how the skill presents its
31
+ signature: `partial` only when the skill labels the form partial (a
32
+ simplified form, a key-parameter summary, a `...` that stands in for
33
+ omitted members, not rest or variadic syntax), `full` otherwise.
34
+ A JSON null counts as absent, and an absent one as `full`. Any other value
35
+ is a violation naming the entry. score-signatures.py reads it: an optional
36
+ member a partial form leaves out is an Info observation, not a wrong
37
+ signature.
38
+
29
39
  Distinct from reconcile-coverage.py (coverage numerator arithmetic) and
30
40
  compute-score.py (weight tables): this script only validates the *shape* of the
31
41
  subagent inventory and, on success, echoes the fence-stripped parsed inventory
@@ -50,6 +60,14 @@ skf-scan-skill-md-structure.py usage-scope): on a side where the name ends
50
60
  in a letter, digit, `_` or `$`, the next character may not be one of those.
51
61
  So `get` is found in `get(url)` but not in `target` or `getAll`.
52
62
 
63
+ The skill-citation scan is shared too: `_skill_cited_files` (with
64
+ `_cited_path`, `skill_markdown_files` and `_SKILL_CITATION_RE`) gives the
65
+ files the skill's `[AST:]` and `[SRC:]` citations name on a line of SKILL.md
66
+ or references/**/*.md that writes a name with the match above, or on the
67
+ next non-blank line. gap-ledger.py reads it to pick the declaration of a
68
+ stale homonym, score-signatures.py to plan a signature check at the
69
+ declaration the skill cites.
70
+
53
71
  CLI usage (mirrors reconcile-coverage.py):
54
72
  uv run validate-inventory.py --input <response file> [--output <inventory file>]
55
73
  [--skill-package <package folder>]
@@ -57,13 +75,16 @@ CLI usage (mirrors reconcile-coverage.py):
57
75
  uv run validate-inventory.py --json-input '<raw response>' # explicit flag
58
76
  echo '<raw response>' | uv run validate-inventory.py --stdin # piped input
59
77
 
60
- --input reads the raw response from a file (UTF-8): coverage-check.md saves
61
- the subagent's response with the Write tool and passes its path, so a quote
62
- or an apostrophe in a description never meets a shell. --output writes the
63
- validated inventory object to a file (UTF-8 JSON) when it is valid, for
64
- the steps that read it by path; an invalid inventory removes a file left
65
- there by an earlier attempt. --skill-package names the skill package under
66
- test (the folder that holds SKILL.md) and adds the spot-check.
78
+ --input reads the raw response from a file (UTF-8): the coverage-check.md §1
79
+ subagent writes its response to the path it is given with the Write tool,
80
+ and the parent saves it there with the Write tool when the subagent returned
81
+ it instead or left the file absent or empty. coverage-check.md passes that
82
+ path, so a quote or an apostrophe in a description never meets a shell.
83
+ --output writes the validated inventory object to a file (UTF-8
84
+ JSON) when it is valid, for the steps that read it by path; an invalid
85
+ inventory removes a file left there by an earlier attempt. --skill-package
86
+ names the skill package under test (the folder that holds SKILL.md) and
87
+ adds the spot-check.
67
88
 
68
89
  Input: the subagent's RAW response text (JSON, optionally wrapped in a markdown
69
90
  code fence — a leading line of three backticks with an optional language tag and
@@ -73,7 +94,7 @@ Output (stdout, one object):
73
94
  {
74
95
  "valid": <bool>, # true only when parse + every schema check pass
75
96
  "violations": [<str>, ...], # human-readable failures; empty when valid
76
- "rejectedCount": <int>, # count of malformed exports[] entries
97
+ "rejectedCount": <int>, # exports[] entries with no valid name or kind
77
98
  "exportsCount": <int>, # len(exports) when parsed; 0 otherwise
78
99
  "inventory": {...} | null, # fence-stripped parsed inventory when valid; null otherwise
79
100
  "completeness": { # present when valid
@@ -102,6 +123,7 @@ from __future__ import annotations
102
123
  import argparse
103
124
  import json
104
125
  import os
126
+ import posixpath
105
127
  import re
106
128
  import sys
107
129
  from pathlib import Path
@@ -126,6 +148,10 @@ VALID_KINDS = (
126
148
  "adapter",
127
149
  )
128
150
 
151
+ # The optional exports[].signature_form: `partial` only when the skill labels
152
+ # the documented form partial, else `full` (or the key left out).
153
+ SIGNATURE_FORMS = ("partial", "full")
154
+
129
155
  REQUIRED_MISMATCH_FIELDS = (
130
156
  "export",
131
157
  "skill_md_line",
@@ -208,6 +234,69 @@ def names_present(names, doc_text):
208
234
  return sorted(present)
209
235
 
210
236
 
237
+ # --- Skill citations ----------------------------------------------------------
238
+
239
+ # A source citation the skill writes, `[AST:path:L12]`, `[SRC:path:L12-34]` or `[SRC:path:L12-L34]` (one with no
240
+ # `:L<n>` part is ignored). Groups: prefix, path, first line, the `L` of a range's end, the range's end.
241
+ # Keep identical to _SKILL_CITATION_RE in skf-verify-provenance-completeness.py.
242
+ _SKILL_CITATION_RE = re.compile(
243
+ r"\[(AST|SRC):([^\[\]\n]+?):L(\d+)(?:-(L?)(\d+))?\]"
244
+ )
245
+ # Any bracketed citation (`[AST:...]`, `[SRC:...]`, `[EXT:...]`): a line names a documented name outside them.
246
+ _ANY_CITATION_RE = re.compile(r"\[[A-Z]+:[^\[\]\n]*\]")
247
+
248
+
249
+ def _cited_path(path: str) -> str:
250
+ """A cited, `declared_in` or surface path as each compares: forward slashes, no `./`."""
251
+ path = path.strip().replace("\\", "/")
252
+ while path.startswith("./"):
253
+ path = path[2:]
254
+ return posixpath.normpath(path) if path else path
255
+
256
+
257
+ def skill_markdown_files(skill_dir: Path) -> list[Path]:
258
+ """`SKILL.md` then every `references/**/*.md`, in a stable order.
259
+ Keep identical to skill_markdown_files in skf-verify-provenance-completeness.py."""
260
+ files: list[Path] = []
261
+ skill_md = skill_dir / "SKILL.md"
262
+ if skill_md.is_file():
263
+ files.append(skill_md)
264
+ refs = skill_dir / "references"
265
+ if refs.is_dir():
266
+ files.extend(sorted(p for p in refs.rglob("*.md") if p.is_file()))
267
+ return files
268
+
269
+
270
+ def skill_markdown_lines(skill_dir: Path) -> list[list[str]]:
271
+ """The lines of each file skill_markdown_files lists (an unreadable one is
272
+ left out), read once for every name _skill_cited_files looks up."""
273
+ texts: list[list[str]] = []
274
+ for path in skill_markdown_files(skill_dir):
275
+ try:
276
+ texts.append(path.read_bytes().decode("utf-8-sig", errors="replace").split("\n"))
277
+ except OSError:
278
+ continue
279
+ return texts
280
+
281
+
282
+ def _skill_cited_files(skill_dir: Path, name: str, texts: list[list[str]] | None = None) -> set[str]:
283
+ """The files the skill's `[AST:]` and `[SRC:]` citations name on a line of
284
+ SKILL.md or references/**/*.md that writes `name` outside its citations
285
+ (the documented-name match), or on the next non-blank line (a heading,
286
+ then its citation). `texts`, skill_markdown_lines(skill_dir) read once,
287
+ spares a caller that looks up many names reading the files again."""
288
+ pattern = name_pattern(name)
289
+ cited: set[str] = set()
290
+ for lines in skill_markdown_lines(skill_dir) if texts is None else texts:
291
+ for number, line in enumerate(lines):
292
+ if not pattern.search(_ANY_CITATION_RE.sub(" ", line)):
293
+ continue
294
+ following = next((ln for ln in lines[number + 1:] if ln.strip()), "")
295
+ for cited_line in (line, following):
296
+ cited.update(_cited_path(m.group(2)) for m in _SKILL_CITATION_RE.finditer(cited_line))
297
+ return cited
298
+
299
+
211
300
  # --- Spot-check -------------------------------------------------------------
212
301
 
213
302
 
@@ -392,6 +481,16 @@ def validate_inventory(raw, skill_package_path=None):
392
481
  f"{{{', '.join(VALID_KINDS)}}})"
393
482
  )
394
483
 
484
+ # The optional signature_form names one of two forms, or is left out.
485
+ for idx, entry in enumerate(exports):
486
+ if isinstance(entry, dict) and entry.get("signature_form") is not None \
487
+ and entry["signature_form"] not in SIGNATURE_FORMS:
488
+ label = f" (`{entry['name']}`)" if isinstance(entry.get("name"), str) and entry["name"] else ""
489
+ violations.append(
490
+ f"exports[{idx}]{label}: `signature_form` must be `partial` or `full` when present, "
491
+ f"not {json.dumps(entry['signature_form'])}"
492
+ )
493
+
395
494
  # Non-empty cross-check mismatch entries must carry all five fields.
396
495
  for idx, entry in enumerate(cross):
397
496
  if not isinstance(entry, dict):
@@ -8,11 +8,13 @@
8
8
  Lookup-and-count helper for the SKF test-skill workflow (coverage-check.md
9
9
  §2b, its "Numerator ground truth" paragraph; §4b records the gap). It fires
10
10
  only on the inflation signature
11
- (`stats.exports_documented == effective_denominator` exactly): the numerator
12
- equals the denominator, the tell of a documented count padded to force 100%
13
- coverage. On that signature the declared count is not trusted: every declared
14
- export name is looked up in the skill's documentation surface and only the
15
- names that actually appear count toward coverage.
11
+ (`stats.exports_documented == effective_denominator` exactly, or, for a
12
+ provenance map with no `::` entry, its distinct entry names number
13
+ `effective_denominator`): the numerator equals the denominator, the tell of a
14
+ documented count padded to force 100% coverage. On that signature the declared
15
+ count is not trusted: every declared export name is looked up in the skill's
16
+ documentation surface and only the names that actually appear count toward
17
+ coverage.
16
18
 
17
19
  Distinct from reconcile-coverage.py (whose scalar/stack branches look up the §1
18
20
  inventory names and return only a residual missing *count*): this script looks
@@ -9,6 +9,7 @@ hardGate: ''
9
9
  testResult: ''
10
10
  score: ''
11
11
  threshold: ''
12
+ thresholdSource: ''
12
13
  analysisConfidence: ''
13
14
  toolingStatus: ''
14
15
  workspaceDrift: ''
@@ -79,10 +79,10 @@ Headless callers: the inputs, flags, gate map, outputs and the `SKF_UPDATE_RESUL
79
79
  SKF_JSON
80
80
  ```
81
81
 
82
- 4. **Resolve workflow customization.** Run:
82
+ 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:
83
83
 
84
84
  ```bash
85
- uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow
85
+ uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
86
86
  ```
87
87
 
88
88
  It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-update-skill.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. Record the warning in the run log at once: write `customization_resolver_unavailable: <reason>` to `{run_dir}/resolver-warning.txt` with a file write (a resolver error can hold quotes or `$( )`, so never echo it or type it into an argument), then run `uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warning "$(cat "{run_dir}/resolver-warning.txt")"` from `{project-root}`.
@@ -7,8 +7,8 @@ hashContentProbeOrder:
7
7
  - '{project-root}/_bmad/skf/shared/scripts/skf-hash-content.py'
8
8
  - '{project-root}/src/shared/scripts/skf-hash-content.py'
9
9
  # `{buildChangeManifestHelper}`: §3's `build` (and §1's for a docs-only
10
- # skill), §2.2's `deletion-ratio` and Category C's `rename-candidates`. HALT
11
- # if neither exists.
10
+ # skill), §2.2's `deletion-ratio`, Category B's `baseline-gaps` and Category
11
+ # C's `rename-candidates`. HALT if neither exists.
12
12
  buildChangeManifestProbeOrder:
13
13
  - '{project-root}/_bmad/skf/shared/scripts/skf-build-change-manifest.py'
14
14
  - '{project-root}/src/shared/scripts/skf-build-change-manifest.py'
@@ -17,13 +17,13 @@ buildChangeManifestProbeOrder:
17
17
  provenanceGapDispatchProbeOrder:
18
18
  - '{project-root}/_bmad/skf/shared/scripts/skf-provenance-gap-dispatch.py'
19
19
  - '{project-root}/src/shared/scripts/skf-provenance-gap-dispatch.py'
20
- # `{detectScriptsAssetsHelper}`: Category D's script and asset walk, as
21
- # create-skill's. HALT if neither exists.
20
+ # `{detectScriptsAssetsHelper}`: Category D's script and asset walk, the
21
+ # detector create-skill runs, over the whole source. HALT if neither exists.
22
22
  detectScriptsAssetsProbeOrder:
23
23
  - '{project-root}/_bmad/skf/shared/scripts/skf-detect-scripts-assets.py'
24
24
  - '{project-root}/src/shared/scripts/skf-detect-scripts-assets.py'
25
- # `{newFileDiffHelper}`: Category D's NEW_FILE set difference, bundled with
26
- # this skill.
25
+ # `{newFileDiffHelper}`: Category D's NEW_FILE set difference, kept to the
26
+ # skill brief's scope, bundled with this skill.
27
27
  newFileDiffHelper: 'scripts/skf-new-file-diff.py'
28
28
  # `{resolveAuthoritativeFilesHelper}`: §1b's mirror. HALT if neither exists.
29
29
  resolveAuthoritativeFilesProbeOrder:
@@ -103,6 +103,8 @@ It writes `{"mode": "docs-only", "no_changes", "changed_urls", "fetch_failed", "
103
103
 
104
104
  **The brief this run reads.** Bind `{brief_path}` ← `{forge_data_folder}/{skill_name}/skill-brief.yaml`. When `detect_only_mode` or `dry_run_mode` is true and that file exists, copy it to `{run_dir}/skill-brief.yaml` and bind `{brief_path}` to the copy: §1b and §1c write each decision there, Category A reads the scope from there, and the brief itself stays as it was. Each amendment a read-only run writes to the copy is a proposed one: add `proposed-amendment: {action} {path} ({category}); not written: {--dry-run or --detect-only}` to `warnings[]` as you write it, and step 6 lists them with the change manifest.
105
105
 
106
+ **The scope type this run reads.** Bind `{scope_type}` ← the `scope_type` metadata.json records (init.md §2), else the `scope.type` of `{brief_path}` when that file exists and names one: a skill whose metadata.json records no scope type updates under its brief's. Category B's runner takes it as `--scope-type` and writes it to `{run_dir}/extraction.json` as `scope.type`, where step 3's `records` and step 5's `apply` read it: under `public-api`, a normal-mode update neither documents nor adds to the provenance map a new export off the runner's public surface (re-extract.md §4, write.md §3).
107
+
106
108
  ### 1b. Discovered Authoritative Files Protocol (Mirror)
107
109
 
108
110
  **Purpose:** mirror `skf-create-skill` §2a into update-skill. `skf-create-skill` §2a catches authoritative AI documentation files (`llms.txt`, `AGENTS.md`, `.cursorrules`, etc.) during **creation**, but a project may add these files *after* the skill was created. Without this mirror, update-skill would either miss the new file entirely (if it doesn't match the provenance map's file patterns) or classify it as a generic ADDED file in §2 Category A with no authoritative-file treatment. The mirror surfaces the discovery with the same P/S/U prompt create-skill uses, honoring any prior amendments.
@@ -309,9 +311,22 @@ Pass `--provenance-map` whenever init.md §4 found one; `--brief` (as §1c left
309
311
  -o "{run_dir}/extraction.json"
310
312
  ```
311
313
 
312
- Pass `--language` and `--scope-type` when metadata.json records them. It takes no `--brief`: Category A applied the scope, and a tracked file the brief's scope leaves out (gap-driven.md §1's rule R1 excludes a rescoped export's file) keeps its exports. `--head-cap 0` keeps every match, since a dropped one would read as a deleted export. Step 3 reads this file and never runs the runner again. **Exit 1** (`incomplete`): run it once more; when still incomplete, step 2 below reads by eye each listed file with no export in `exports[]`. **Exit 2 or 3, no JSON, no candidate resolves, or Quick tier:** write `{"exports": []}` to `{run_dir}/extraction.json`; step 2 reads every listed file by eye (at Quick tier, by text pattern). Tell the user which files were read by eye.
314
+ Pass `--language` when metadata.json records it, and `--scope-type` when §1 bound `{scope_type}`. It takes no `--brief`: Category A applied the scope, and a tracked file the brief's scope leaves out (gap-driven.md §1's rule R1 excludes a rescoped export's file) keeps its exports. `--head-cap 0` keeps every match, since a dropped one would read as a deleted export. Step 3 reads this file and never runs the runner again. **Exit 1** (`incomplete`, its JSON written): run it once more; when still incomplete, step 2 below reads by eye each listed file with no export in `exports[]` or that an `errors[]` item lists in `unread`, and tells the user which files were read by eye. **Exit 2** (an input error, such as a file list it cannot read, or a failure it did not foresee, named in one stderr line with no JSON; `uv` failing to start it exits 2 too): HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-b"`, the stderr line that holds `error:` as `reason`). No by-eye read stands in for it: the skill is not written yet, and reading every file by eye would hide the error. **Exit 3 (`no-ast-grep`), no JSON on another exit, no candidate resolves, or Quick tier:** write `{"exports": []}` to `{run_dir}/extraction.json`, with `"scope": {"type": "{scope_type}"}` when §1 bound `{scope_type}`, so step 5's `apply` can warn that a `public-api` surface was not applied; step 2 reads every listed file by eye (at Quick tier, by text pattern). Tell the user which files were read by eye.
315
+
316
+ Then, at every tier (in degraded mode there is no map: skip it), list the map's entries the runner left out that their file still declares: the runner never reports a module, an alias that renames, a dunder such as `__version__` or another Python or Rust underscore name, and step 3's diff would read each as a deleted export. From `{project-root}`, run:
317
+
318
+ ```bash
319
+ uv run {buildChangeManifestHelper} baseline-gaps \
320
+ --provenance-map "{provenance_map_path}" \
321
+ --extraction "{run_dir}/extraction.json" \
322
+ --files "{run_dir}/modified-files.json" \
323
+ --source-root "{source_root}" \
324
+ -o "{run_dir}/baseline-gaps.json"
325
+ ```
326
+
327
+ It writes `{"gaps": [{name, file, line, entry, export_type}], "unchecked": [{name, file, export_type}]}`: in `gaps[]` each entry of a modified file that no export of `{run_dir}/extraction.json` holds and whose file still declares it, at the line that declares it, by the verifier's rule (audit-skill's extraction snapshot applies the same one), and in `unchecked[]` each such entry no rule can check (its file neither Python nor TS/JS, a file the rule will not read, such as a test file or one with a symlink on its path, or a dotted module name), which step 2 reads by eye. Never add a gap or drop one by eye. On exit 1 (an input it cannot read, or a `skf-verify-provenance-completeness.py` beside it that cannot be loaded), no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-b"`, its stderr as `reason`): without it, every name the runner leaves out would read as deleted.
313
328
 
314
- 2. **What the recipes do not record.** Workers, in parallel batches (Pattern 4) when the list is long, read the modified and added files and each return ONLY `{"exports": [...]}`, with no prose and no fences. For each function export the runner found whose `params` it left null (it could not read the signature): `export_name` and `source_file` as it wrote them, with `params` (each parameter as the source writes it, `name: type`, the form the provenance map's `params` hold) and `return_type` (null when there is none), read at its `source_line`. For each export it could not find (a name `entry_point_diff.extraction_gaps[]` lists, a form Known Limitation #11 in `{extractionPatternsData}` lists, an export of a file `file_issues[]` names or that was read by eye): the same, plus `export_type`, `source_line`, `confidence: T1-low` and `extraction_method: source-read`. Write the union of their `exports` arrays to `{run_dir}/export-details.json`.
329
+ 2. **What the recipes do not record.** Workers, in parallel batches (Pattern 4) when the list is long, read the modified and added files and each return ONLY `{"exports": [...]}`, with no prose and no fences. For each function export the runner found whose `params` it left null (it could not read the signature): `export_name` and `source_file` as it wrote them, with `params` (each parameter as the source writes it, `name: type`, the form the provenance map's `params` hold) and `return_type` (null when there is none), read at its `source_line`. For each export it could not find (a name `entry_point_diff.extraction_gaps[]` lists, a name `{run_dir}/baseline-gaps.json` lists in `gaps[]`, a form Known Limitation #11 in `{extractionPatternsData}` lists, an export of a file `file_issues[]` names or that was read by eye): the same, plus `export_type`, `source_line`, `confidence: T1-low` and `extraction_method: source-read`. Record a baseline gap at its `file` and `line` (`source_line`), with, as `export_type`, its `export_type` while the declaration there still has that kind, else the kind it has now (a `variable` now declared by a `def` is a `function`, which step 3 reports as a changed kind), and the declaration's kind when the gap gives none; `params` and `return_type` as the declaration writes them, null when it has none (a module, an alias or a variable). For each `unchecked[]` entry, read its file by eye and record it the same way, at the line that declares it, only when the file still declares it. Record each name and file once: when a file read by eye whole (at Quick tier, or when the runner could not run or left the file unread) holds a name a gap or an `unchecked[]` entry lists, that read's record stands and the gap adds none. Write the union of their `exports` arrays to `{run_dir}/export-details.json`.
315
330
 
316
331
  3. **The diff.** Resolve `{structuralDiffHelper}` ← first existing path in `{structuralDiffProbeOrder}` and, from `{project-root}`, run:
317
332
 
@@ -368,15 +383,19 @@ The helper emits:
368
383
  }
369
384
  ```
370
385
 
371
- The compare helper reports only tracked files; NEW_FILE detection (a file present in source but absent from the provenance map) is a set-difference, so it runs through a script rather than the prompt. Pipe the same deterministic detector create-skill step 3 §4c uses (resolved via `detectScriptsAssetsProbeOrder`) into `{newFileDiffHelper}`, which subtracts the provenance map's `file_entries[].source_file` and sets aside user-authored `[MANUAL]` paths:
386
+ A map with no `file_entries` field, or a null one (a skill with no script, asset or promoted document, or a map written before the field existed), tracks no file: the comparison lists no row and the set difference below subtracts nothing.
387
+
388
+ The compare helper reports only tracked files; NEW_FILE detection (a file present in source but absent from the provenance map) is a set-difference, so it runs through a script rather than the prompt. **When `{brief_path}` does not exist** (a skill built without a brief, such as a quick skill), skip it: with no scope to keep to, the walk would read every script and asset of the repository as new. Add `new-files-not-checked: no skill brief, so no new script or asset was looked for` to `warnings[]`, write no `{run_dir}/new-files.json`, and leave `--new-files` out of §3's `build` and step 5's `apply`. Otherwise pipe the detector create-skill step 3 §4c runs (resolved via `detectScriptsAssetsProbeOrder`), which walks the whole source, into `{newFileDiffHelper}`, which keeps what the brief takes and subtracts what the map tracks:
372
389
 
373
390
  ```bash
374
391
  uv run {detectScriptsAssetsHelper} detect "{source_root}" \
375
- | uv run {newFileDiffHelper} "{provenance_map_path}" \
392
+ | uv run {newFileDiffHelper} "{provenance_map_path}" [--brief "{brief_path}"] \
376
393
  > "{run_dir}/new-files.json"
377
394
  ```
378
395
 
379
- It writes `{"new_files":[{source_file, kind}], "skipped_manual":[...], "already_tracked":[...], "stats":{...}}`. §3's `build` reads both files, typing each compare row by its `file_entries[]` row's `file_type` and each new file by its `kind`: sort no row by hand. `skipped_manual[]` are user-authored files under `scripts/[MANUAL]/` or `assets/[MANUAL]/`, preserved and not touched; `already_tracked[]` were handled by the compare above.
396
+ Pass `--brief` as Category A does, `{brief_path}` as §1c left it (in a read-only run, its copy in `{run_dir}`). On exit 2 (the detector's JSON, the map or the brief it cannot read, named in its JSON `error` on stderr, which also catches a detector that printed nothing), no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "detect-changes:category-d"`, its `error` as `reason`).
397
+
398
+ It writes `{"new_files":[{source_file, kind}], "skipped_manual":[...], "already_tracked":[...], "tracked_code":[...], "out_of_scope":[...], "intent_none":[...], "stats":{...}}`. §3's `build` reads both files, typing each compare row by its `file_entries[]` row's `file_type` and each new file by its `kind`: sort no row by hand. `skipped_manual[]` are user-authored files under `scripts/[MANUAL]/` or `assets/[MANUAL]/`, preserved and not touched; `already_tracked[]` were handled by the compare above; `tracked_code[]` are code files the map's `entries[]` cite, which Categories A and B compare; `out_of_scope[]` are paths the brief's scope leaves out, by Category A's in-scope test; `intent_none[]` are paths of a kind whose `scripts_intent` or `assets_intent` is `none` (an absent or free-text intent detects). A path in these lists is no new file and no `stats` count, so step 6's report counts each of the three lists from this file, where it shows Category D: a brief scoped wrongly shows as many paths out of scope. A new file Category A lists too stays a new file: Category A lists every changed in-scope file, a genuine script included.
380
399
 
381
400
  **Write the category JSON** to `{run_dir}/categories.json`, where §2.2 and §3 read it beside the helper files: the two flags, `degraded_mode` and `update_mode: "normal"`. Category C stays in the files its steps wrote, which §2.2, §3 and step 5's `apply` take as `--category-c` and `--ccc-pairs`:
382
401
 
@@ -488,13 +507,13 @@ The helper emits the unified manifest envelope:
488
507
  "total_export_changes": N,
489
508
  "per_file": [
490
509
  {"file_path": "...", "status": "MODIFIED|ADDED|DELETED|MOVED",
491
- "exports_affected": [{name, change_type, old_line, new_line}, ...]}
510
+ "exports_affected": [{name, change_type, old_line, new_line, old_file?}, ...]}
492
511
  ],
493
512
  "category_d": {"scripts_modified": [...], ..., "docs_deleted": [...]}
494
513
  }
495
514
  ```
496
515
 
497
- `per_file` entries are sorted MODIFIED → ADDED → DELETED → MOVED, then alphabetically within each status group, so downstream stages can rely on stable ordering. MOVED entries include an extra `old_path` field. `category_d` lists the paths behind the `scripts_*`, `assets_*` and `docs_*` counts: a `docs_*` path is a tracked document, which only Category D detects, so a change to one alone is a change. `{run_dir}/change-manifest.json` is the change manifest: step 3, step 6 and step 5's `apply` read it there, so it survives a compacted context.
516
+ `per_file` entries are sorted MODIFIED → ADDED → DELETED → MOVED, then alphabetically within each status group, so downstream stages can rely on stable ordering. MOVED entries include an extra `old_path` field, and a `MOVED_EXPORT` across files an `old_file`, the file it left, where `apply` finds its entry. `category_d` lists the paths behind the `scripts_*`, `assets_*` and `docs_*` counts: a `docs_*` path is a tracked document, which only Category D detects, so a change to one alone is a change. `{run_dir}/change-manifest.json` is the change manifest: step 3, step 6 and step 5's `apply` read it there, so it survives a compacted context.
498
517
 
499
518
  ### 4. Check for No-Change Shortcut
500
519