bmad-module-skill-forge 4.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 (85) 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 +32 -25
  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/verifying-a-skill.md +41 -20
  12. package/docs/why-skf.md +1 -1
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  16. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  17. package/src/shared/scripts/skf-build-change-manifest.py +881 -168
  18. package/src/shared/scripts/skf-count-tokens.py +54 -10
  19. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  20. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  21. package/src/shared/scripts/skf-extract-public-api.py +439 -107
  22. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  23. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  24. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  25. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  26. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  27. package/src/shared/scripts/skf-shard-body.py +239 -41
  28. package/src/shared/scripts/skf-validate-output.py +12 -3
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
  30. package/src/shared/tool-requirements.yaml +8 -2
  31. package/src/skf-analyze-source/SKILL.md +2 -2
  32. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  33. package/src/skf-audit-skill/SKILL.md +2 -2
  34. package/src/skf-audit-skill/references/re-index.md +2 -2
  35. package/src/skf-brief-skill/SKILL.md +2 -2
  36. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  37. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  38. package/src/skf-campaign/SKILL.md +2 -2
  39. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  40. package/src/skf-create-skill/SKILL.md +2 -2
  41. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  42. package/src/skf-create-skill/references/extract.md +2 -1
  43. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  44. package/src/skf-create-skill/references/report.md +6 -1
  45. package/src/skf-create-stack-skill/SKILL.md +2 -2
  46. package/src/skf-create-stack-skill/references/validate.md +1 -1
  47. package/src/skf-drop-skill/SKILL.md +2 -2
  48. package/src/skf-export-skill/SKILL.md +2 -2
  49. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  50. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  51. package/src/skf-export-skill/references/load-skill.md +1 -1
  52. package/src/skf-export-skill/references/token-report.md +29 -9
  53. package/src/skf-forger/SKILL.md +2 -2
  54. package/src/skf-quick-skill/SKILL.md +2 -2
  55. package/src/skf-refine-architecture/SKILL.md +6 -8
  56. package/src/skf-rename-skill/SKILL.md +2 -2
  57. package/src/skf-setup/SKILL.md +2 -2
  58. package/src/skf-test-skill/SKILL.md +2 -2
  59. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  60. package/src/skf-test-skill/references/coherence-check.md +4 -3
  61. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  62. package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
  63. package/src/skf-test-skill/references/coverage-check.md +45 -69
  64. package/src/skf-test-skill/references/init.md +2 -1
  65. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  66. package/src/skf-test-skill/references/report.md +2 -2
  67. package/src/skf-test-skill/references/scoring-rules.md +5 -4
  68. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  69. package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
  70. package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
  71. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  72. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  73. package/src/skf-test-skill/scripts/score-signatures.py +329 -54
  74. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  76. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  77. package/src/skf-update-skill/SKILL.md +2 -2
  78. package/src/skf-update-skill/references/detect-changes.md +1 -1
  79. package/src/skf-update-skill/references/gap-driven.md +36 -21
  80. package/src/skf-update-skill/references/merge.md +15 -10
  81. package/src/skf-update-skill/references/re-extract.md +3 -1
  82. package/src/skf-update-skill/references/report.md +5 -4
  83. package/src/skf-update-skill/references/write.md +39 -18
  84. package/src/skf-verify-stack/SKILL.md +2 -2
  85. 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):
@@ -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}`.
@@ -313,7 +313,7 @@ Pass `--provenance-map` whenever init.md §4 found one; `--brief` (as §1c left
313
313
 
314
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
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 underscore name, and step 3's diff would read each as a deleted export. From `{project-root}`, run:
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
317
 
318
318
  ```bash
319
319
  uv run {buildChangeManifestHelper} baseline-gaps \
@@ -82,20 +82,33 @@ Read the gaps of the test report at `{test_report_path}` and translate them into
82
82
  - **Exit 0:** bullet 3 reads `{run_dir}/gaps.json` and passes each of its `warnings[]` on as `test-report: <entry>`.
83
83
  - **Exit 1** (`status: "error"` in `{run_dir}/gaps.json`, with `code` `REPORT_MISSING`, `LEDGER_MISSING`, `LEDGER_INVALID` or `INVALID_INPUT`), **any other exit, no JSON, or no candidate resolves:** HALT with status `blocked` and display "**The test report's gaps could not be read:** {code}: {error}" ("skf-parse-gaps.py is missing; re-install SKF" when no candidate resolved). The halt procedure takes `phase: "detect-changes:parse-gaps"`, `path: "{test_report_path}"`, `reason: "{code}: {error}"`.
84
84
 
85
- 2. **Translate each gap by its `category`** (the gap ledger's closed set of slugs), never by its severity: bullet 3's helper routes each one through the table below. A report older than the ledger gives no `category`: when bullet 3 asks, choose the row whose gap type its title and issue describe.
85
+ 2. **Translate each gap by its `category`** (the gap ledger's closed set of slugs), never by its severity: bullet 3's helper routes each one through the table below. A report older than the ledger gives no `category`: when bullet 3 asks, choose the row whose gap type its title and issue describe, or the Not routed slug that names it.
86
86
 
87
87
  | `category` | Gap type (a report older than the ledger) | Change Category |
88
88
  |------------|-------------------------------------------|-----------------|
89
89
  | `missing-export`, `missing-type` | Missing export, type or interface documentation | NEW_EXPORT (an undocumented public export or type), unless the remediation says the export is internal or out of scope: then DELETED_EXPORT (rescope), see rule R1 |
90
90
  | `split-body-mismatch` | Split-body inconsistency: the SKILL.md body and a `references/*.md` file document one export differently, with a `Source:` inside the skill package | STRUCTURAL_FIX, see rule R2 (for an older report, this row takes precedence over the Signature mismatch row below) |
91
- | `signature-mismatch`, `fabricated-signature`, `stale-documentation` | Signature mismatch; stale documentation (the skill documents an export the source's enumerated surface lacks and that is not a documented extra, an Info `observation` this table never routes) | MODIFIED_EXPORT (the documentation needs updating from source) |
91
+ | `signature-mismatch`, `fabricated-signature` | Signature mismatch | MODIFIED_EXPORT (the documentation needs updating from source) |
92
92
  | `structural`, `broken-reference`, `inaccurate-reference`, `reference-escape`, `integration-pattern`, `migration-section`, `scripts-assets` | Structural or coherence drift in the output files only, no source change | STRUCTURAL_FIX, see rule R2 |
93
93
  | `provenance-completeness` | Export documented in SKILL.md/references but **missing from the provenance-map** | NEW_EXPORT (provenance-completeness), see rule R3 |
94
94
  | `provenance-line` | Provenance line is not the export's definition | MOVED_EXPORT (provenance line), see rule R5 |
95
- | `metadata-drift`, `metadata` | Divergent export counts in the metadata; missing metadata or examples | metadata update, see rule R4 |
95
+ | `metadata` | Missing metadata or examples | metadata update, see rule R4 |
96
96
  | any other | any other gap | not routed |
97
97
 
98
- **Not routed:** update-skill has no repair for the other categories. `provenance-unverified` asks for a check by hand, `denominator-inflation`, `brief-scope-stale` and `multi-denominator` concern the brief's scope and the count design, and `numerator-inflation`, `scripts-assets-provenance`, `observation`, `discovery`, `description` and `external-validator` need a person or another workflow. Such a gap takes no manifest entry (bullet 3 lists it).
98
+ **Not routed:** update-skill has no repair for the other categories. Such a gap takes no manifest entry, so this run writes nothing for it: no provenance entry, no `exports[]` name and no documentation removed. Bullet 3's helper lists it with the reason below, which says who repairs it. A gap with no category, or one no table holds, takes "update-skill has no repair for this category, so a person follows the gap's remediation", and one whose export is answered null "it names no export for update-skill to repair, so a person follows the gap's remediation".
99
+
100
+ - `stale-documentation` (the skill documents a name the source's enumerated surface lacks and that is not a documented extra, an Info `observation`): the source has no line to repair the name from, so a person removes its documentation when the name is gone from the source, fixes the brief's scope when the surface missed it, or, when the source still declares the name, keeps the documentation and re-runs Test Skill at a tier that classifies it.
101
+ - `metadata-drift` (divergent export counts in the metadata): update-skill recounts `stats.exports_public_api`, `stats.exports_documented` and `stats.effective_denominator` each time it writes metadata.json, so a metadata patch to them does not hold (under `--allow-workspace-drift` and for a docs-only skill it keeps the recorded public API counts and `stats.effective_denominator` instead); Create Skill rebuilds diverging barrel counts from the source and recounts a `stats.effective_denominator` the brief's `scope.tier_a_include` set disagrees with (denominator drift), a person fixes denominator deflation on a brief with no tier A list by adding `scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the named-export count leaves out need no fix.
102
+ - `provenance-unverified`: the line-check rules could not verify the line, so a person checks by hand that it defines the export and, when another line does, sets its provenance `source_line` to that line.
103
+ - `denominator-inflation`: it concerns the brief's scope and the count design, which a person decides in the skill brief, as the gap's remediation says.
104
+ - `brief-scope-stale`: a person edits the brief's scope globs by hand so each matches the files it meant, since update-skill does not rewrite them.
105
+ - `multi-denominator`: no fix is needed, since the barrel and the documented surface measure different sets by design and the report records both counts.
106
+ - `numerator-inflation`: a person documents the absent exports in SKILL.md or references/, reading each from the source.
107
+ - `scripts-assets-provenance`: a person adds the file's row to the provenance map's `file_entries`, or removes the file from the skill.
108
+ - `observation`: a person applies it only when they agree, since it is advisory, and a documented extra needs no change.
109
+ - `discovery`: a person improves the skill's description so realistic prompts route to it, or runs the discovery test Test Skill skipped.
110
+ - `description`: a person edits the description in the SKILL.md frontmatter as the gap suggests.
111
+ - `external-validator`: a person applies the skill-check diagnostic or Tessl Review suggestion to SKILL.md.
99
112
 
100
113
  **Translation rules (referenced by the table above):**
101
114
 
@@ -110,9 +123,9 @@ Read the gaps of the test report at `{test_report_path}` and translate them into
110
123
  A rescope is honest only if the reduction is expressed in the brief's scope: on `true`, bullet 3's helper will give the manifest entry a `rescope` object holding the `scope.amendments[]` entry (`category: "scope-expansion"`, `action: "excluded"`, `path`, `reason` (the remediation), `date`, `workflow: "skf-update-skill"`) **and** the export's source path for `brief.scope.exclude`, then route the entry to merge Priority 1 (removal) so steps 4 and 5 remove it and recompute stats from the amended scope. This step writes neither to the brief: step 4 §6b writes both, the first write of the repair, and skips an amendment or an exclude path the brief already holds, so a re-run never adds them twice and a run that stops before step 4 (a halt, `--dry-run`, `--detect-only`) leaves the brief as it was. In those read-only modes its `--read-only` adds `proposed-amendment: excluded {path} (scope-expansion); not written: {--dry-run or --detect-only}` to its `warnings[]`. Never close a coverage gap by editing `metadata.stats` to equal the documented count: that is denominator deflation and `skf-test-skill` will reject it.
111
124
  - **R2: STRUCTURAL_FIX.** A finding whose fix edits the generated markdown only, with no source change and no provenance entry change. It carries `remediation` text describing the surgical markdown edit and routes to merge Priority 8 (generated-markdown edit only); it never adds, modifies, or removes a provenance `entries[]` row. Two kinds of finding qualify:
112
125
  - a coherence finding from `skf-scan-skill-md-structure.py` (e.g., `table_drift`, `unbalanced_fences`, a broken intra-skill anchor) that touches the generated output file only;
113
- - a split-body consistency finding (test-skill coverage-check §1b `cross_check_mismatches`): the SKILL.md body and a `references/*.md` file document one export differently. test-skill rates it High, as it rates a signature mismatch against the source, yet it needs nothing from the source: merge Priority 8 edits the `references/*.md` file to match the body. Its `category` is `split-body-mismatch`; in a report older than the ledger, recognize it by its `Source:`, which points inside the skill package (at the skill's own `SKILL.md` or at one of its `references/*.md` files, whether the path is written relative to the package or through `{skill_package}`), while its issue sets the SKILL.md body against a `references/*.md` file. Test for it before the High "Signature mismatch" row: a High signature gap whose `Source:` points into the source tree, or that has no `Source:`, stays `MODIFIED_EXPORT`.
114
- - **R3: provenance-completeness.** An export documented in SKILL.md/`references/` but absent from the provenance-map. These are documented-and-known, so `unknown` is never correct: route to §4a's targeted re-extraction **regardless of severity**, gated only on `source_root` being pinned and readable (§4).
115
- - **R4: metadata update.** A metadata-coherence patch that changes no export's source (e.g., reconcile a divergent `stats` count). Routes to merge Priority 8b and is applied by write.md §2 *before* the automatic stat recount (see merge.md §3 / write.md §2).
126
+ - a split-body consistency finding (test-skill coverage-check §1b `cross_check_mismatches`): the SKILL.md body and a `references/*.md` file document one export differently. test-skill rates it High, as it rates a signature mismatch against the source, yet it needs nothing from the source: its `Source:` names the side the pinned source contradicts (test-skill coverage-check §5b), which bullet 3's helper keeps as the entry's `package_citation` when the ledger recorded its category, and merge Priority 8 says which file it edits. Its `category` is `split-body-mismatch`; in a report older than the ledger, recognize it by its `Source:`, which points inside the skill package (at the skill's own `SKILL.md` or at one of its `references/*.md` files, whether the path is written relative to the package or through `{skill_package}`), while its issue sets the SKILL.md body against a `references/*.md` file. Test for it before the High "Signature mismatch" row: a High signature gap whose `Source:` points into the source tree, or that has no `Source:`, stays `MODIFIED_EXPORT`.
127
+ - **R3: provenance-completeness.** An export documented in SKILL.md/`references/` but absent from the provenance-map. These are documented-and-known, so `unknown` is never correct: route to §4a's targeted re-extraction **regardless of severity**, gated only on `source_root` being pinned and readable (§4). The one exception: when the map holds the name at another file than the gap cites (`map_match` `not-found` with a candidate that has a `source_file`), its provenance is not absent, so §4 records it `unknown` (`cited-elsewhere`) and never re-extracts it (§4 bullet 1).
128
+ - **R4: metadata update.** A `metadata` gap's patch: it changes no export's source and sets a field write.md §2's `metadata` call keeps (e.g., missing optional metadata), never a count that call recounts or `exports[]`, which is why a `metadata-drift` gap is not routed. Add no `effective_denominator_*` stats key: the recount drops each one when the denominator changes. Routes to merge Priority 8b and is applied by write.md §2 *before* the automatic stat recount (see merge.md §3 / write.md §2).
116
129
  - **R5: MOVED_EXPORT (provenance line).** A `provenance-line` gap (in a report older than the ledger, a Low gap titled `Provenance line is not the definition of {export_name}`, test-skill coverage-check §4c) says the provenance map's `source_line` for that export is not the line that defines it. Its `Source:` is the map's `{source_file}:{source_line}`, so `source_citation` is set, and its remediation lists the file's definition lines. This row takes precedence over the Low `metadata update` row (R4). Route the entry to §4's spot-check only, with no public-reachability gate and no §4a.
117
130
 
118
131
  3. **Build the change manifest through `{parseGapsHelper}`**, never by hand. From `{project-root}`, run:
@@ -129,16 +142,16 @@ Read the gaps of the test report at `{test_report_path}` and translate them into
129
142
  Pass `--judgments` once the file exists, and `--read-only` with the run's flag in a `--detect-only` or `--dry-run` run. It routes each gap by the table and rules R1 to R5 and writes `{run_dir}/change-manifest.json`, where §4, merge and step 5 read it, as `{"mode": "gap-driven", "entries": [...]}`, each entry with the fields the "Translate" section of its docstring lists: §4 partitions on its `change_category` and merge.md §3 dispatches on it. Act on its `status`:
130
143
 
131
144
  - **`needs-judgment`** (it wrote nothing): for each `needs_judgment[]` item, judge from its gap's `title`, `issue` and `remediation`, which the item carries, add your answer to each of its `needs` to `{run_dir}/gap-judgments.json` under the item's `gap_id` (`{"<gap_id>": {"<need>": <answer>}}`), then run it again:
132
- - `category` (a report older than the ledger): the slug of the row bullet 2 says the gap's title and issue describe (`missing-export` or `missing-type` for a missing export or type), or null for a gap no row routes;
145
+ - `category` (a report older than the ledger): the slug of the row bullet 2 says the gap's title and issue describe (`missing-export` or `missing-type` for a missing export or type), else the Not routed slug that names it (`stale-documentation` for documentation of a name the source surface lacks, `metadata-drift` for divergent export counts in the metadata), so the gap takes that category's reason, or null for a gap neither names;
133
146
  - `name`: the export the gap's title names, or null when it names none, which leaves the gap unrouted;
134
147
  - `rescope`: rule R1's answer, true or false;
135
148
  - `source_file`: the source file, relative to `{source_root}`, that defines the export (a rescope's exclude path) or that a rule R3 gap's documentation cites, or null.
136
- - **`written`:** set `gap_count` from its `gap_count`, keep its `not_routed[]` (the `id`, `title` and `category` of each gap the table does not route) for §2, and add each of its `warnings[]` to `warnings[]`: the parse's, as `test-report: <entry>`, `test-report: not routed: {id} ({category})` for each gap not routed, and a read-only run's proposed amendments. When you answer `rescope` for a gap it did not ask about (rule R1: an export you found upstream internal or `#[doc(hidden)]`), add the answer to `{run_dir}/gap-judgments.json` and run it again before §2, taking that run's output in place of this one.
149
+ - **`written`:** set `gap_count` from its `gap_count`, keep its `not_routed[]` (the `id`, `title`, `category` and `reason` of each gap the table does not route, the reason saying why update-skill does not repair it and who does) for §2, and add each of its `warnings[]` to `warnings[]`: the parse's, as `test-report: <entry>`, `test-report: not routed: {id} ({category}): {reason}` for each gap not routed, and a read-only run's proposed amendments. When you answer `rescope` for a gap it did not ask about (rule R1: an export you found upstream internal or `#[doc(hidden)]`), add the answer to `{run_dir}/gap-judgments.json` and run it again before §2, taking that run's output in place of this one.
137
150
  - **Exit 1** (`status: "error"`) **or no JSON:** HALT with status `blocked` and display "**The test report's gaps could not be translated:** {code}: {error}". The halt procedure takes `phase: "detect-changes:parse-gaps"`, `path: "{test_report_path}"`, `reason: "{code}: {error}"`.
138
151
 
139
152
  ### 2. Display the Gap Summary and Route
140
153
 
141
- Display "**Gap-driven update mode.** Translated {gap_count} test report findings into the change manifest; source drift detection skipped.", then list each gap §1 did not route, as `{id}: {title} ({category})`, and say this run does not repair them. Then take the branch that applies:
154
+ Display "**Gap-driven update mode.** Translated {gap_count} test report findings into the change manifest; source drift detection skipped.", then list each gap §1 did not route, as `{id}: {title} ({category}): {reason}`, and say this run does not repair them. Then take the branch that applies:
142
155
 
143
156
  - **`detect_only_mode == true`** → display "**Detect-only mode: skipping re-extract, merge and write.** Loading report..." and load, read the full file, then execute `{reportFile}` (report.md), which emits status `detect-only`.
144
157
  - **No gap translated** (`gap_count` is 0, an empty manifest) → load, read the full file, then execute `{reportFile}` (report.md), which emits status `no-changes`.
@@ -182,8 +195,8 @@ Bind `{workspace_drift_status}` ← `status` and `{head_short_sha}` ← `head_sh
182
195
 
183
196
  - `a public API recount from the tree (rule R1)`: a rescope (`DELETED_EXPORT`);
184
197
  - `a line from the tree (rule R3)`: a provenance-completeness gap (`provenance_completeness: true`), which §4 bullet 1 routes to §4a;
185
- - `a line and a signature from the tree`: an export whose `map_match` is `not-found`;
186
- - `a signature from the tree`: an export the map holds (`found` or `ambiguous`).
198
+ - `a line and a signature from the tree`: an export whose `map_match` is `not-found` with no candidate that has a `source_file`;
199
+ - `a signature from the tree`: an export the map holds (`found` or `ambiguous`, or `not-found` with a candidate that has a `source_file`: a citation of another file than the map's entries of its name).
187
200
 
188
201
  When its `status` is not `drift-blocked`, go on with its output in §4. Otherwise HALT with status `halted-for-workspace-drift` before merge runs, so merge writes nothing and §4a never runs. A rescope's amendment is not in the skill brief yet (rule R1): step 4 §6b writes it, so this halt leaves the brief as it was, and a re-run asks again. Display, with `{source_commit}` and `{source_ref}` read from metadata.json:
189
202
 
@@ -231,10 +244,10 @@ Spot-check each gap's citation instead of re-extracting the files: confirm each
231
244
  - **`drift-blocked`:** §3's drift gate halts.
232
245
  - **`blocked`** (`rescope_without_amendment[]`): a `DELETED_EXPORT` whose manifest entry carries no `rescope` object (its `scope.amendments[]` entry, `action: "excluded"`, and its `scope.exclude` path, rule R1). HALT with status `blocked` (halt procedure: `phase: "re-extract:rescope"`, `reason: "rescope-without-amendment: {name}"`): a rescope without a brief scope amendment is denominator deflation and must not be written.
233
246
  - **`needs-judgment`:** each item is a spot-check whose `file` the definition-line rules cover no language of. Read the file and add, under the item's `gap_id` in `{run_dir}/gap-judgments.json`, its `declaring_line`: the line that declares the export itself, never a decorator or comment above it, or null when no line does. Then run it again.
234
- - **`planned`:** when `reextract.entries` is not empty, run §4a for them: the plan wrote their files to `{run_dir}/remediation-files.json`. Add none of its `warnings[]`: bullet 2's call prints them again.
247
+ - **`planned`:** when `reextract.entries` or `reextract.signatures` is not empty, run §4a for them: the plan wrote their files to `{run_dir}/remediation-files.json`, each entry's `resolved_paths[]` and then the `file` of each export `reextract.signatures` lists (a `NEW_EXPORT` or `MODIFIED_EXPORT` its spot-check located, `verified` or `moved`, at the `line` it gives), whose signature bullet 2 takes from §4a's record of it there. A `NEW_EXPORT` whose `reachability` `{run_dir}/gap-judgments.json` does not answer yet is listed too, so its scan goes unused when bullet 2's gate finds it internal. Add none of its `warnings[]`: bullet 2's call prints them again.
235
248
  - **Exit 1 or no JSON:** HALT with status `blocked` (halt procedure: `phase: "re-extract:gap-records"`, its stderr as `reason`).
236
249
 
237
- A severity is blocking unless it is `Medium`, `Low` or `Info`, compared case-insensitively, so a missing or unrecognized one is blocking, like `Critical` and `High`. A blocking gap must produce provenance read from the source or halt in this file before merge: only a `Medium`, `Low` or `Info` gap may degrade to `unknown`.
250
+ A severity is blocking unless it is `Medium`, `Low` or `Info`, compared case-insensitively, so a missing or unrecognized one is blocking, like `Critical` and `High`. A blocking gap whose export the map does not hold must produce provenance read from the source or halt in this file before merge: for such an export, only a `Medium`, `Low` or `Info` gap may degrade to `unknown`. A `NEW_EXPORT` or `MODIFIED_EXPORT` whose export the map holds (its `map_match` is `found` or `ambiguous`, or `not-found` with a candidate that has a `source_file`: the gap cites another file than the map's entries of its name) never goes to §4a's targeted re-extraction (`reextract.entries`), whatever its severity: when the map holds several entries of its name (`ambiguous`, so nothing is checked), its spot-check at the entry ends `unknown` or `missing` (the entry's file is gone), or the check at a citation of another file pins no line (`unknown`, `cited-elsewhere`: that file's declaration may be another package's, so it is never re-extracted), its entry stays unchanged. Step 5's `apply` warns for it, naming the cause of an `unknown` (several map entries of its name, no definition line or several at the entry, a file no rule covers, a failed or skipped check, a citation of another file than the map's entries of its name). For a blocking gap the `provenance:` warning also names the gap and the one fix a re-run acts on: for several entries of its name, fix them by hand, or give the gap a `file:line` Source at the definition when that file holds only one of them, so the re-run's lookup takes that one; otherwise fix the entry by hand (set its `source_file` and `source_line` to the line that defines it), since the re-run checks the same entry again. Then re-run update-skill. For a `cited-elsewhere` gap, whatever its severity, the warning names the gap, the cited file and its check's cause (such as `missing` or `no-definition`), and where the map holds the name, and the fix for each side that may be wrong: when the map is right, re-run Test Skill, since the test compared another file's declaration, and if the gap comes back, exclude the cited file in the brief's scope when the skill does not cover it (not when the cited file is gone); when an export moved, fix that map entry by hand, then re-run update-skill. When its check read nothing at the cited file (no source tree, the verifier missing, a failed check), the warning says instead to give update-skill the source tree, re-install SKF, or fix what the check failed on, then re-run update-skill.
238
251
  2. **Write the gap-driven records** to `{run_dir}/reextract-records.json`, before §5 sends the run on: merge reads them, and step 5's `apply` writes the provenance map from them. Run the same call without `--plan` and `--files-out`:
239
252
 
240
253
  ```bash
@@ -249,9 +262,9 @@ Spot-check each gap's citation instead of re-extracting the files: confirm each
249
262
  -o "{run_dir}/reextract-records.json"
250
263
  ```
251
264
 
252
- Pass `--remediation-records`, `--evidence` and `--tier` when §4a ran. It writes one verification record per export-bearing entry, the confidence breakdown and counts, and in `files` each record of `{run_dir}/remediation-records.json` it matched by name to an entry §4a scanned for, within that entry's own `resolved_paths[]`, copied as `records` wrote it: never type a record or count one by hand. With `--evidence` it appends its `targeted_reextraction` record to `{run_dir}/evidence-records.jsonl` as one JSON line keyed `targeted_reextraction`, which the evidence report (step 5 §4) shows beside the verified, moved and missing tally. Act on its `status`:
265
+ Pass `--remediation-records`, `--evidence` and `--tier` when §4a ran. It writes one verification record per export-bearing entry, the confidence breakdown and counts, and in `files` each record of `{run_dir}/remediation-records.json` it matched by name, or by the public name the runner gives its local name in that file, to an entry §4a scanned for, within that entry's own `resolved_paths[]`, copied as `records` wrote it (one matched by that public name is copied under it, with its own name as `local`), and the record of each export `reextract.signatures` lists, whose `export_type`, `params`, `return_type`, `extraction_method` and `ast_node_type` it puts on that export's verification record, with a `provenance: ... signature not repaired` warning for one it finds no record of at its line: never type a record or count one by hand. The public-name match reads the runner's entry-point diff, so it holds at Forge tier and above: at Quick tier, or when §4a reads the files by eye after the runner exits 3, a gap that names a renamed re-export by its public name matches no record and is `unresolved` below. With `--evidence` it appends its `targeted_reextraction` record to `{run_dir}/evidence-records.jsonl` as one JSON line keyed `targeted_reextraction`, which the evidence report (step 5 §4) shows beside the verified, moved and missing tally. Act on its `status`:
253
266
 
254
- - **`unresolved`:** an entry routed to §4a has no record of its name in its path set, or had no path to scan (`files_scanned: 0`). A found export the public-reachability gate below finds internal is a resolution, not a failure. HALT with status `halted-for-remediation-path`, under `--dry-run` too (the run stops here instead of exiting `dry-run`), and display a consolidated report of every `unresolved[]` entry:
267
+ - **`unresolved`:** an entry routed to §4a has no record of its name in its path set (nor one the runner re-exports under that name there), or had no path to scan (`files_scanned: 0`). A found export the public-reachability gate below finds internal is a resolution, not a failure. HALT with status `halted-for-remediation-path`, under `--dry-run` too (the run stops here instead of exiting `dry-run`), and display a consolidated report of every `unresolved[]` entry:
255
268
 
256
269
  ```
257
270
  Targeted re-extraction failed for {N} gap(s).
@@ -289,16 +302,16 @@ Spot-check each gap's citation instead of re-extracting the files: confirm each
289
302
 
290
303
  ### 4a. Targeted Re-Extraction Branch (Helper, Called from §4 Bullet 1)
291
304
 
292
- **Do not execute this section sequentially.** It is a helper procedure §4 bullet 1 invokes for the entries its plan lists in `reextract.entries`. A run in which §4 bullet 1 routes no entry here skips this section entirely.
305
+ **Do not execute this section sequentially.** It is a helper procedure §4 bullet 1 invokes for the entries its plan lists in `reextract.entries` and `reextract.signatures`. A run in which §4 bullet 1 routes no entry here skips this section entirely.
293
306
 
294
- **Used by:** §4 bullet 1's plan, for the entries it lists in `reextract.entries`: each scans its `resolved_paths[]` (an entry with none has nothing to scan), and §4 bullet 2 matches what this section extracts to each entry by name.
307
+ **Used by:** §4 bullet 1's plan, for the entries it lists in `reextract.entries`: each scans its `resolved_paths[]` (an entry with none has nothing to scan), and §4 bullet 2 matches what this section extracts to each entry by name. For each export it lists in `reextract.signatures`, §4 bullet 2 takes the signature this section extracts at its `file` and `line`.
295
308
 
296
- **Purpose:** produce AST-backed provenance for a gap with a blocking `severity` (§4 bullet 1) and no citation that pins a line, so step 5 §3 never writes `source_file: null` for a blocking finding. Honors the workflow-level rule **Never hallucinate** (every statement must have AST provenance) against the most common gap-driven trigger: a failing test report whose Gap Report `Source:` field is a region reference (e.g., `@storybook/addon-docs control primitives`) rather than a `file:line` pair.
309
+ **Purpose:** produce AST-backed provenance for a gap with a blocking `severity` (§4 bullet 1) and no citation that pins a line, so step 5 §3 never writes `source_file: null` for a blocking finding. Honors the workflow-level rule **Never hallucinate** (every statement must have AST provenance) against the most common gap-driven trigger: a failing test report whose Gap Report `Source:` field is a region reference (e.g., `@storybook/addon-docs control primitives`) rather than a `file:line` pair. For an export a spot-check located, it reads the signature SKILL.md and the provenance map take, so neither keeps a stale one nor takes one from merge's prose.
297
310
 
298
311
  **Procedure:**
299
312
 
300
313
  1. **Source access:** read the files under `{source_root}`, the tree §3 confirmed holds the pinned commit, never through the gh contents API, zread or deepwiki, which may serve another commit.
301
- 2. **The file set** is `{run_dir}/remediation-files.json`, which §4 bullet 1's plan wrote: the `resolved_paths[]` of every entry routed here, as they are. §1's `{parseGapsHelper}` resolved them under `{source_root}` and refused, into `rejected_paths[]`, every path that leaves it (`outside-root`, or `symlink-outside-root` through a link) or names no file (`not-found`, `no-match`). Remediation text is user-editable: never scan a refused path, and never expand or check a path by hand.
314
+ 2. **The file set** is `{run_dir}/remediation-files.json`, which §4 bullet 1's plan wrote: the `resolved_paths[]` of every entry routed here, as they are. §1's `{parseGapsHelper}` resolved them under `{source_root}` and refused, into `rejected_paths[]`, every path that leaves it (`outside-root`, or `symlink-outside-root` through a link) or names no file (`not-found`, `no-match`). The plan adds the `file` of each export `reextract.signatures` lists, where its spot-check located it under `{source_root}`; of a file only those exports bring, read by eye (item 3) only the exports they name. Remediation text is user-editable: never scan a refused path, and never expand or check a path by hand.
302
315
  3. **Extract:** read the file set with the tier-appropriate extractor: `records` below labels each export by the tool that produced it, T1 (`extraction_method: ast-grep`) for an ast-grep match and T1-low (`extraction_method: source-read`) for an export read by eye. At Forge tier and above, run the recipe runner over the file set (resolve `{extractPublicApiHelper}` ← first existing path in `{extractPublicApiProbeOrder}`), from `{project-root}`:
303
316
 
304
317
  ```bash
@@ -326,7 +339,9 @@ Spot-check each gap's citation instead of re-extracting the files: confirm each
326
339
 
327
340
  Display the summary below, with the `counts` §4 bullet 2 printed:
328
341
 
329
- "**Gap-driven re-extraction.** Verified {verified_count}/{gap_count} citations against live source. Moved: {moved_count}. Missing: {missing_count}. Re-extracted (via remediation paths or provenance-completeness, §4a): {re_extracted_count}. Rescoped (removed from surface): {rescoped_count}. Unknown (not in provenance map, a `Medium`, `Low` or `Info` gap §4a found nothing for, no single definition line, or a line the drift override kept from being moved): {unknown_count}."
342
+ "**Gap-driven re-extraction.** Verified {verified_count}/{gap_count} citations against live source. Moved: {moved_count}. Missing (entry left unchanged): {missing_count}. Re-extracted (via remediation paths or provenance-completeness, §4a): {re_extracted_count}. Rescoped (removed from surface): {rescoped_count}. Unknown (not in provenance map, a `Medium`, `Low` or `Info` gap §4a found nothing for, several map entries of its name, a citation of another file than the map's entries of its name, no single definition line, or a line the drift override kept from being moved): {unknown_count}."
343
+
344
+ A `NEW_EXPORT` or `MODIFIED_EXPORT` the provenance map holds that ends `missing` or `unknown` keeps its entry unchanged, whatever its severity: step 5's `apply` warns for each, with the cause of an `unknown`, and for a blocking gap says what to fix (§4 bullet 1). One whose gap cites another file than the map's entries of its name (`cited-elsewhere`) is never re-extracted, and its warning says what to fix whatever its severity: re-run Test Skill when the map is right, or fix the map entry by hand when the export moved.
330
345
 
331
346
  When `{workspace_drift_status}` is `overridden`, add: "Every check read HEAD {head_short_sha}, not pinned {pinned_short_sha}: no line was moved or pinned."
332
347