bmad-module-skill-forge 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/examples.md +1 -1
  4. package/docs/troubleshooting.md +8 -0
  5. package/docs/verifying-a-skill.md +7 -3
  6. package/docs/workflows.md +3 -3
  7. package/package.json +1 -1
  8. package/src/knowledge/qmd-registry.md +1 -1
  9. package/src/knowledge/version-paths.md +1 -1
  10. package/src/shared/health-check.md +2 -50
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  12. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
  13. package/src/shared/scripts/skf-atomic-write.py +212 -59
  14. package/src/shared/scripts/skf-build-change-manifest.py +593 -47
  15. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  16. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  17. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  18. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  19. package/src/shared/scripts/skf-extract-public-api.py +1309 -157
  20. package/src/shared/scripts/skf-extraction-inventory.py +580 -46
  21. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  22. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  23. package/src/shared/scripts/skf-github-probe.py +47 -8
  24. package/src/shared/scripts/skf-hash-content.py +14 -8
  25. package/src/shared/scripts/skf-load-provenance.py +3 -1
  26. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  27. package/src/shared/scripts/skf-preflight.py +15 -1
  28. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  29. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  30. package/src/shared/scripts/skf-structural-diff.py +133 -18
  31. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  32. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  33. package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
  34. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  35. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  36. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  37. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  38. package/src/skf-audit-skill/references/health-check.md +2 -0
  39. package/src/skf-audit-skill/references/init.md +2 -1
  40. package/src/skf-audit-skill/references/re-index.md +14 -9
  41. package/src/skf-audit-skill/references/report.md +2 -2
  42. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  43. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  44. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  45. package/src/skf-brief-skill/SKILL.md +1 -1
  46. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  47. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
  48. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  49. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  52. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  53. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  54. package/src/skf-create-skill/assets/skill-sections.md +6 -6
  55. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  56. package/src/skf-create-skill/references/compile.md +31 -60
  57. package/src/skf-create-skill/references/component-extraction.md +3 -3
  58. package/src/skf-create-skill/references/enrich.md +2 -2
  59. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  60. package/src/skf-create-skill/references/extract.md +22 -19
  61. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  62. package/src/skf-create-skill/references/extraction-patterns.md +8 -9
  63. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  64. package/src/skf-create-skill/references/report.md +16 -5
  65. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  66. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  67. package/src/skf-drop-skill/references/execute.md +5 -1
  68. package/src/skf-drop-skill/references/health-check.md +2 -0
  69. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  70. package/src/skf-drop-skill/references/report.md +2 -2
  71. package/src/skf-drop-skill/references/select.md +7 -3
  72. package/src/skf-test-skill/references/coherence-check.md +1 -1
  73. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  74. package/src/skf-test-skill/references/coverage-check.md +39 -57
  75. package/src/skf-test-skill/references/init.md +3 -5
  76. package/src/skf-test-skill/references/report.md +10 -8
  77. package/src/skf-test-skill/references/scoring-rules.md +6 -5
  78. package/src/skf-test-skill/references/source-access-protocol.md +14 -17
  79. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  80. package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
  82. package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
  83. package/src/skf-test-skill/scripts/score-signatures.py +36 -16
  84. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  85. package/src/skf-update-skill/references/detect-changes.md +32 -13
  86. package/src/skf-update-skill/references/gap-driven.md +3 -3
  87. package/src/skf-update-skill/references/init.md +6 -2
  88. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  89. package/src/skf-update-skill/references/merge.md +2 -2
  90. package/src/skf-update-skill/references/re-extract.md +2 -2
  91. package/src/skf-update-skill/references/report.md +3 -1
  92. package/src/skf-update-skill/references/write.md +43 -23
  93. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
@@ -34,6 +34,10 @@ sourceTreeProbeOrder:
34
34
  findTestReportProbeOrder:
35
35
  - '{project-root}/_bmad/skf/shared/scripts/skf-find-test-report.py'
36
36
  - '{project-root}/src/shared/scripts/skf-find-test-report.py'
37
+ # `{extractPublicApiHelper}`: §6c's source version.
38
+ extractPublicApiProbeOrder:
39
+ - '{project-root}/_bmad/skf/shared/scripts/skf-extract-public-api.py'
40
+ - '{project-root}/src/shared/scripts/skf-extract-public-api.py'
37
41
  ---
38
42
 
39
43
  <!-- Config: communicate in {communication_language}. -->
@@ -343,7 +347,7 @@ Dispatch on `{source_tree_status}`:
343
347
 
344
348
  ### 6c. Detect the Source Version
345
349
 
346
- Run only when `{source_tree_status}` is `ready` or `offline`. Read the source's version from `{source_root}` with the Version Reconciliation rules of `skf-create-skill/references/source-resolution-protocols.md`: the version file for the detected language (`pyproject.toml`, `setup.py`, `__version__`, `package.json`, `Cargo.toml`, `go.mod`) and its monorepo `package.json` priority, matching package names against this skill's `name`. When no version file gives one, leave `{source_version_detected}` unset and skip the rest of this section. Otherwise compare it with the metadata.json `version` through `{skillInventoryHelper}` (resolve it ← first existing path in `{skillInventoryProbeOrder}`), from `{project-root}`:
350
+ Run only when `{source_tree_status}` is `ready` or `offline`. Read the source's version from `{source_root}` by the Version Reconciliation section of `skf-create-skill/references/source-resolution-protocols.md` (its `{extractPublicApiHelper}` call, from `{extractPublicApiProbeOrder}`), with this skill's `name` and metadata.json `language` for the brief's. When no version file gives one, leave `{source_version_detected}` unset and skip the rest of this section. Otherwise compare it with the metadata.json `version` through `{skillInventoryHelper}` (resolve it ← first existing path in `{skillInventoryProbeOrder}`), from `{project-root}`:
347
351
 
348
352
  ```bash
349
353
  uv run {skillInventoryHelper} version order "{the source's version}" "{version}"
@@ -353,7 +357,7 @@ It prints `order` and `major_minor` (the source's version against `{version}`) a
353
357
 
354
358
  - **`order` is `higher`** (a higher semantic version than the metadata.json `version`): bind `{source_version_detected}` ← `a.normalized`. Step 4 §6b and step 5 §2 name the version this update writes after it.
355
359
  - **Otherwise** leave `{source_version_detected}` unset. When `major_minor` is `lower`, add `source-version-lower: {target_ref} reads {a.normalized}, older than {version}; this update keeps the patch-version rule` to `warnings[]`. A source version lower only in its patch number is expected, not a regression: every update that finds no higher version increments the skill's patch number (step 4 §6b), so a skill forged at the source's 1.2.0 is 1.2.1 after one update while the source still reads 1.2.0.
356
- - **Exit 1** (`code` `NOT_A_VERSION`: a value that names no version, such as a dynamic version in `pyproject.toml`), **no candidate, or no JSON:** leave `{source_version_detected}` unset: step 4 §6b takes the next patch version.
360
+ - **Exit 1** (`code` `NOT_A_VERSION`: a value that names no version), **no candidate, or no JSON:** leave `{source_version_detected}` unset: step 4 §6b takes the next patch version.
357
361
 
358
362
  ### 7. Present Baseline Summary
359
363
 
@@ -8,5 +8,5 @@ The argument set, gate map, outputs and headless result line of `skf-update-skil
8
8
  | **Flags** | `--headless` / `-H`; `--from-test-report` (gap-driven mode); `--target-ref <ref>`; `--allow-workspace-drift` (gap-driven only: update-skill takes nothing from HEAD, and a gap that needs the pinned tree, a rescope included, halts `halted-for-workspace-drift` before merge); `--allow-degraded`; `--detect-only` (status `detect-only`); `--dry-run` (status `dry-run`, writes nothing in any mode; a gap-driven.md halt such as `halted-for-remediation-path` or `halted-for-workspace-drift` still stops it). init.md §1 describes each; `--detect-only` wins over `--dry-run`. |
9
9
  | **Gates** | step 1: init.md §4's degraded-rebuild gate [D]/[X] when no provenance map is found (headless: halts, or takes [D] under `--allow-degraded`; a gap-driven run halts `blocked` there instead); init.md §4b's [G]/[S] test-report offer, interactive only (headless warns `unconsumed-test-report`); Confirm Gate [C] (init.md §8) | step 2, each only when triggered: detect-changes.md §1b's promote prompt and §1c's scope-expansion prompt [P]/[S]/[U] (headless: defers each candidate to a person), §2.2's deletion-ratio gate [C]/[B]/[A] (headless: [C]) | step 2g (gap-driven): gap-driven.md §1 rule R1's [D]/[R] for each gap whose remediation names removal (headless: [D], or [R] only when the export is internal or `#[doc(hidden)]`) | step 4: Confirm Gate [C] only after the user resolved [MANUAL] conflicts (merge.md §4); a clean merge auto-proceeds, and a headless run with conflicts halts `halted-for-manual-mismatch` before anything is written |
10
10
  | **Outputs** | A new version folder `{skill_group}/{new_version}/` and its forge folder, beside the unchanged previous version (a gap-driven repair writes the current version in place), and a finished run's `update-skill-result-{YYYYMMDD-HHmmss}.json` and `update-skill-result-latest.json` in that version's forge folder (a run that found no change: the current one's). `--detect-only` and `--dry-run` write none of these, nor the skill brief. Run state goes to a run folder under `{project-root}/_bmad-output/.skf-run/` that step 7 removes; a halt keeps it |
11
- | **Concurrency** | A second real update of the same skill halts `halted-for-concurrent-run` while the first holds its run lock (init.md §1b); `--detect-only` and `--dry-run` take no lock. A remote skill is read from a private tree, and no mode writes to the shared workspace clone before write.md §9. |
11
+ | **Concurrency** | A second real update of the same skill halts `halted-for-concurrent-run` while the first holds its run lock (init.md §1b); `--detect-only` and `--dry-run` take no lock. write.md §8 halts with the same status when another flip of the `active` link holds its lock for 5 seconds. A remote skill is read from a private tree, and no mode writes to the shared workspace clone before write.md §9. |
12
12
  | **Headless** | No gate prompts: each resolves as the Gates row says, and the run records the decision. One `SKF_UPDATE_RESULT_JSON` line (report.md §5b), at step 6 or at a HALT, printed by the shared emitter and checked against `shared/scripts/schemas/skf-update-result-envelope.v1.json`. It is the run's last line: the shared health check displays it after everything else; a HALT displays its line and stops. Pipelines branch on `skf_update.status`: `success`, `no-changes`, `detect-only` and `dry-run` are successful exits. |
@@ -79,7 +79,7 @@ Apply merge in the following priority order:
79
79
  - **Gap-driven rescopes** (`DELETED_EXPORT` from gap-driven.md §1 rule R1, verification `rescoped`) are processed here with the same removal. §6b writes the entry's `rescope` (its `scope.amendments[]` entry, `action: "excluded"`, and its `scope.exclude` path) to the skill brief before SKILL.md, and step 5 removes the provenance `entries[]` row and recomputes `stats` from the amended `brief.scope` (write.md §2/§3). gap-driven.md §4 HALTs on a rescope that carries no `rescope`, so no unscoped removal reaches here.
80
80
 
81
81
  **Priority 2 — Process MOVED exports:**
82
- - Update file:line citations in generated content
82
+ - Update file:line citations in generated content: a `MOVED_EXPORT`'s citation takes its `new_line`, and when the change manifest gives it an `old_file` (a move across files), a citation `[AST:{old_file}:L{old_line}]` (or `[SRC:...]`) becomes the file its `per_file` item names and its `new_line`, `[AST:{file_path}:L{new_line}]`, keeping its prefix. A `MODIFIED_EXPORT` with an `old_file` (it also changed) is rewritten whole by Priority 4.
83
83
  - **Gap-driven:** move citations only for an export whose gap-driven.md §4 spot-check recorded `moved`. A `MOVED_EXPORT` that recorded `unknown` (the drift override among the causes), `verified` or `missing` moves none: write.md §3 leaves its line as it is.
84
84
  - Update provenance map file references
85
85
  - [MANUAL] blocks unaffected (content unchanged)
@@ -99,6 +99,7 @@ Apply merge in the following priority order:
99
99
  - Append new export content to appropriate section
100
100
  - Place before any [MANUAL] blocks at section boundary
101
101
  - No conflicts expected (new content, no existing [MANUAL])
102
+ - **A `public-api` skill's public surface (normal mode only):** document no new export, and no export of an ADDED or MOVED file, whose record in `{run_dir}/reextract-records.json` is marked `public: false`, unless the provenance map holds the export (by name at its `old_path` for a MOVED file) or the old name it renames, or this update removes an entry of its name (a `DELETED_EXPORT`, a DELETED file's entry): `records` (re-extract.md §4) found it off the public surface, and step 5's `apply` adds no entry for it, so SKILL.md, the provenance map and metadata.json's `exports[]` name the same exports. A record with no `public` mark is documented as before, and an export the map already holds keeps its content (Priority 4) whatever its mark. In gap-driven and degraded mode document every record whatever its mark: `apply` maps them all.
102
103
  - **Gap-driven cited `NEW_EXPORT` whose spot-check pinned a line** (gap-driven.md §4 recorded `verified` or `moved` for an export the provenance map does not hold): cite it as `[SRC:{source_file}:L{line}]`, where `{line}` is the citation's line for `verified` and the `new_location` line for `moved`: the line write.md §3 records in its new `source-read` entry. The spot-check found that line by the verifier's text rules, not by an ast-grep recipe, so the prefix is `SRC`, never `AST`.
103
104
 
104
105
  **Priority 6 — Process script/asset file changes (from Category D in change manifest):**
@@ -111,7 +112,6 @@ Category D operates on every `file_entries[]` row regardless of `file_type`. Han
111
112
  - NEW_FILE: queue file for copy from `{source_root}`, add to `file_entries`
112
113
  - Files in `scripts/[MANUAL]/` or `assets/[MANUAL]/` are never modified (user-authored)
113
114
  - Update Section 7b manifest table to reflect changes
114
- - Update `metadata.json` `scripts[]`/`assets[]` arrays and `stats.scripts_count`/`stats.assets_count`
115
115
 
116
116
  - **`file_type: "doc"`** (authoritative docs promoted by §2a/§1b):
117
117
  - MODIFIED_FILE: update `file_entries` content_hash only. **Do NOT copy the file** — doc-type entries are source-tracked but not bundled. Record the drift in the update report.
@@ -66,7 +66,7 @@ The halt leaves `{run_dir}` in place.
66
66
  **Forge tier (AST structural extraction):**
67
67
 
68
68
  - Step 2's Category B ran the recipe runner (`{extractPublicApiHelper}` `--mode full`, the recipes of the AST Extraction Protocol in `{extractionPatternsData}`) over every file to extract, `{run_dir}/extract-files.json`, and wrote `{run_dir}/extraction.json`: never run it again here. §4's helper takes each file's exports from its `exports[]`, each at the line of its name, never a decorator or `export` line above it: T1 (AST-verified structural truth) with `extraction_method: ast-grep` and the recipe's labels.
69
- - Read by eye only what the recipes leave out: an export a file defines in a form Known Limitation #11 in `{extractionPatternsData}` lists, a file `file_issues[]` names (not UTF-8, or code the parser could not read, where a recipe can miss an export), a name `entry_point_diff.extraction_gaps[]` lists, and each file step 2 told the user it read by eye. Step 2 already read those of the modified and added files into `{run_dir}/export-details.json` (its entries with an `export_type`), and §4's helper takes them from there. An export read by eye is T1-low with `extraction_method: source-read` and `ast_node_type: null`.
69
+ - Read by eye only what the recipes leave out: an export a file defines in a form Known Limitation #11 in `{extractionPatternsData}` lists, a file `file_issues[]` names (not UTF-8, or code the parser could not read, where a recipe can miss an export), a name `entry_point_diff.extraction_gaps[]` lists, a map entry `{run_dir}/baseline-gaps.json` lists (a module, an alias that renames, `__version__` or another underscore name the runner never reports, which its file still declares), and each file step 2 told the user it read by eye. Step 2 already read those of the modified and added files into `{run_dir}/export-details.json` (its entries with an `export_type`), and §4's helper takes them from there. An export read by eye is T1-low with `extraction_method: source-read` and `ast_node_type: null`.
70
70
 
71
71
  **Tier degradation handling (Forge/Forge+/Deep):** If step 2's runner could run no ast-grep, or left files unread (step 2 told the user which), follow `{tierDegradationRulesData}` for fallback strategy and user notification requirements. Silent degradation is forbidden: the user must always know which files were read by eye instead of matched, and why.
72
72
 
@@ -135,7 +135,7 @@ uv run {buildChangeManifestHelper} records \
135
135
  -o "{run_dir}/reextract-records.json"
136
136
  ```
137
137
 
138
- Pass `--extraction` and `--export-details` when step 2 wrote them, and `--patches` when §2 or §3 wrote a patch. It seeds each export's record from step 2's files (name, type, signature, location, labels, return type and `params` in the provenance map's typed form), merges the patches, writes `{run_dir}/reextract-records.json`, where merge and step 5's `apply` read it, and prints `files_extracted`, `exports_extracted` and `confidence_breakdown` (T1, T1-low, T2): never type a record or count one by hand. Add each of its `warnings[]` to `warnings[]`. On exit 1 whose error names a patch file, rewrite that file once in the shape of §2's return contract and run `records` again. On a second exit 1, any other exit 1, no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "re-extract:records"`, its stderr as `reason`).
138
+ Pass `--extraction` and `--export-details` when step 2 wrote them, and `--patches` when §2 or §3 wrote a patch. It seeds each export's record from step 2's files (name, type, signature, location, labels, return type and `params` in the provenance map's typed form), merges the patches, writes `{run_dir}/reextract-records.json`, where merge and step 5's `apply` read it, and prints `files_extracted`, `exports_extracted` and `confidence_breakdown` (T1, T1-low, T2): never type a record or count one by hand. Under a `public-api` scope (the `scope.type` detect-changes.md §1 bound, which `{run_dir}/extraction.json` records) it marks each export `public: true` or `public: false` by the runner's public surface (its `entry_point_diff`, by the rule the "Records" section of the helper's docstring gives), and prints `marked_not_public`, the count of `public: false`: in a normal-mode update merge documents, and step 5's `apply` adds, no export marked `public: false` that the provenance map does not already hold (merge.md Priority 5). In degraded mode the mark is not used. Add each of its `warnings[]` to `warnings[]`. On exit 1 whose error names a patch file, rewrite that file once in the shape of §2's return contract and run `records` again. On a second exit 1, any other exit 1, no JSON, or no candidate resolves: HALT with status `blocked` (halt procedure: `phase: "re-extract:records"`, its stderr as `reason`).
139
139
 
140
140
  ### 5. Display Extraction Summary and Auto-Proceed
141
141
 
@@ -153,6 +153,8 @@ In `{headless_mode}`, print the line as §5b says, with `status: "dry-run"` and
153
153
 
154
154
  {when the change manifest's `category_d` lists a document: **Tracked documents changed upstream:** each `docs_modified` path (modified) and `docs_deleted` path (deleted)}
155
155
 
156
+ {when step 2 wrote `{run_dir}/new-files.json`: **New scripts and assets:** {the length of its `new_files[]`} new; set aside: {the length of `tracked_code[]`} code files the map cites, {the length of `out_of_scope[]`} outside the brief's scope, {the length of `intent_none[]`} of a kind whose intent is `none` (counted from the lists, never from `stats`)}
157
+
156
158
  {in gap-driven mode, when gap-driven.md §1 left gaps unrouted: **Not repaired by this run:** each `{id}: {title} ({category})`}
157
159
 
158
160
  ### Export Changes
@@ -270,7 +272,7 @@ In `{headless_mode}`, bind `{result_envelope_line}` to that line and do not disp
270
272
  - `status`: `"success"` here; §1 stages `"no-changes"`, §1a `"detect-only"` and §1b `"dry-run"`; a halt prints its own `halted-for-*` or `blocked` line through its step's halt procedure and never reaches this step. The full enum lives in the schema.
271
273
  - `headless_decisions[]`: every gate's record in the run's decision log (init.md §4 degraded-rebuild and §8 confirmation, detect-changes.md §1b / §1c / §2.2, gap-driven.md §1 rule R1, merge.md §8). Each entry `{gate, default_action, taken_action, reason, evidence?}`. Empty when no gate auto-resolved.
272
274
  - `error`: null on every exit this step prints. A halt's line carries `{phase, path?, reason}`, and pipelines branch on `error !== null` for non-zero exit semantics.
273
- - `warnings[]`: every entry the run recorded, among them `source-tree:`, `source-not-fetched`, `file-diff-unavailable`, `no-baseline-time`, `moved-check-skipped` and `unknown-language` (detect-changes.md §2.1 Category A), `source-version-lower`, `unconsumed-test-report` (init.md §4b), `interrupted-run-cleaned` and `interrupted-run-not-cleaned` (init.md §1b), `test-report:` entries (what the test report's helpers could not read, and `test-report: not routed: {id} ({category})` for each gap gap-driven.md §1 did not route), `proposed-amendment:` (a read-only run's brief decisions, not written), `doc-fetch-failed`, `doc-not-hashed` and `doc-drift-not-checked` (a docs-only skill's documents), `workspace-clone-not-updated`, `target-ref-not-recorded`, `workspace_drift_overridden` (gap-driven.md §3), `run-state-not-finished` and `provenance:` entries (write.md §3 and §6: provenance findings left for a person, and spot-check entries §3 left for a person to decide).
275
+ - `warnings[]`: every entry the run recorded, among them `source-tree:`, `source-not-fetched`, `file-diff-unavailable`, `no-baseline-time`, `moved-check-skipped` and `unknown-language` (detect-changes.md §2.1 Category A), `new-files-not-checked` (Category D: a skill with no brief, so no new script or asset was looked for), `source-version-lower`, `unconsumed-test-report` (init.md §4b), `interrupted-run-cleaned` and `interrupted-run-not-cleaned` (init.md §1b), `test-report:` entries (what the test report's helpers could not read, and `test-report: not routed: {id} ({category})` for each gap gap-driven.md §1 did not route), `proposed-amendment:` (a read-only run's brief decisions, not written), `doc-fetch-failed`, `doc-not-hashed` and `doc-drift-not-checked` (a docs-only skill's documents), `workspace-clone-not-updated`, `target-ref-not-recorded`, `workspace_drift_overridden` (gap-driven.md §3), `run-state-not-finished`, `public-api-counted-by-hand:` (write.md §2: the recipe runner exited 2, so the public names were found without it; it quotes the runner's error line) and `provenance:` entries (write.md §3 and §6: provenance findings left for a person, and spot-check entries §3 left for a person to decide).
274
276
 
275
277
  **Post-finalization hook.** A finished run, this one or §1's no-change exit, fires the hook; `--detect-only`, `--dry-run` and a halt never do (customize.toml says so). If `{onCompleteCommand}` (resolved in SKILL.md On Activation §4 from `workflow.on_complete`) is non-empty, invoke it after the emitter wrote both result files:
276
278
 
@@ -72,8 +72,8 @@ checkWorkspaceDriftProbeOrder:
72
72
  - '{project-root}/_bmad/skf/shared/scripts/skf-check-workspace-drift.py'
73
73
  - '{project-root}/src/shared/scripts/skf-check-workspace-drift.py'
74
74
  # Resolve `{buildChangeManifestHelper}` to the first existing path; HALT if
75
- # neither exists: §3's `apply` writes the provenance map from this run's
76
- # records, never by hand.
75
+ # neither exists: §3's `apply` writes the provenance map and §2's `metadata`
76
+ # call metadata.json, from this run's records, never by hand.
77
77
  buildChangeManifestProbeOrder:
78
78
  - '{project-root}/_bmad/skf/shared/scripts/skf-build-change-manifest.py'
79
79
  - '{project-root}/src/shared/scripts/skf-build-change-manifest.py'
@@ -89,8 +89,8 @@ compareDocHashesProbeOrder:
89
89
  validateOutputProbeOrder:
90
90
  - '{project-root}/_bmad/skf/shared/scripts/skf-validate-output.py'
91
91
  - '{project-root}/src/shared/scripts/skf-validate-output.py'
92
- # `{extractPublicApiHelper}`: §2's public API counts; if neither exists, §2
93
- # counts by hand.
92
+ # `{extractPublicApiHelper}`: §2's public API counts, the runner's or those
93
+ # `{entryPointsByHandData}` has it make; if neither exists, §2 halts.
94
94
  extractPublicApiProbeOrder:
95
95
  - '{project-root}/_bmad/skf/shared/scripts/skf-extract-public-api.py'
96
96
  - '{project-root}/src/shared/scripts/skf-extract-public-api.py'
@@ -183,17 +183,17 @@ uv run {runLockHelper} acquire \
183
183
 
184
184
  `{lock_recovery}` is, outside gap-driven mode, "The previous version is unchanged, and the halt removed `{skill_group}/{new_version}/` and `{forge_data_folder}/{skill_name}/{new_version}/`, which step 4 created for this update: re-run update-skill once no other update of {skill_name} runs." and, in gap-driven mode, "This repair already wrote SKILL.md in place; the halt restored the package and the skill brief from the snapshot step 4 took, so the skill is as it was: re-run update-skill once no other update of {skill_name} runs."
185
185
 
186
- Update `{skill_package}/metadata.json`:
187
- - **First, apply any queued `metadata_patches[]`** (staged by merge Priority 8b from gap-driven `metadata update` entries): apply each surgical patch described in the gap's remediation (reconcile a divergent count, add an explanatory stat, etc.) *before* the automatic recount below, so the recount overrides only the fields it owns and the patch survives for any field it does not. If a patch and the recount disagree on a field the recount owns (e.g., `exports_documented`), the recount wins — log the divergence so a still-stale stat surfaces in the report rather than being silently overwritten.
188
- - **For gap-driven rescopes** (`DELETED_EXPORT` / verification `rescoped`): the removed exports are already dropped from the `exports` array below, and `stats` recompute from that reduced surface: never set a `stats` count by hand to match the documented total. The reduction is justified by the `brief.scope.exclude` + `scope.amendments[]` (`action: "excluded"`) step 4 §6b wrote from gap-driven.md §1's rule R1; the recount simply reflects the smaller surface.
189
- - Update `version` to `{new_version}`, the version step 4 §6b chose: **if `update_mode == "gap-driven"`, that is the unchanged version**: the skill is being repaired against the same source commit, so of the fields that mark an update only `generation_date` below changes, and step 4 wrote into the existing version directory (step 1 §6c records no source version in gap-driven mode). Otherwise it is the version whose folder step 4 §6b created: `{source_version_detected}` when step 1 §6c recorded `source_version_detected`, else the next patch version. Never pick another value here: `version` must name the folder `metadata.json` sits in, the one §8 points the `active` link at. From here on `{version}` is `{new_version}`.
190
- - Update `generation_date` timestamp to the current UTC time to the second, `YYYY-MM-DDTHH:MM:SSZ`, read from the clock (`date -u +%Y-%m-%dT%H:%M:%SZ`), never typed: step 1 §4b offers a test report only when it is newer. It is the only update bookkeeping metadata.json carries, in every mode: §3's update operation block records `last_update` and `update_type` in provenance-map.json. Write neither key into metadata.json, and remove a `last_update` or `update_type` an older SKF version left there, so metadata.json never keeps the date or type of an earlier update beside the map's current one.
191
- - **Record the source commit** (when `{source_tree_status}` is `ready` or `offline`): set `source_commit` to `{target_commit}`, the commit every step of this run read, and, when `{target_ref_override}` is set, `source_ref` to `{target_ref}`. Leave `source_root` as metadata.json records it: it names the clone SKF keeps for the repository; never write `{source_tree}` or any other path of this run into an artifact. For any other source (a local folder, gap-driven mode, docs-only) leave `source_commit` and `source_ref` unchanged. The in-context `{source_commit}` stays the value init.md §6 read; §9 needs it.
192
- - Update `exports` array to reflect current export list
193
- - **Compute the `stats` block and `confidence_distribution` deterministically** with `{renderMetadataStatsHelper}` (resolve from `{renderMetadataStatsProbeOrder}`; first existing path wins): the same helper sibling create-skill compiles them with, so create and update emit byte-identical stats for identical inputs. The helper owns all the arithmetic: it bins each provenance `entries[]` row once by its `signature_source` tier into `confidence_distribution.{t1,t1_low,t2,t3}`, sets `exports_documented` = the entry count for the library shape (for a reference app, the Pattern Surface row count; see **Shape** below), and derives `exports_total` = `exports_public_api` + `exports_internal`, `public_api_coverage` = documented / public_api (`null` if public_api is 0), `total_coverage` = documented / total (`null` if total is 0), plus `scripts_count` / `assets_count` from the inventory arrays. Run `uv run {renderMetadataStatsHelper} --help` for the full contract. Do not hand-bin the distribution: binning T2 annotations + T3 doc items on top of the per-export tiers double-counts, which per-entry binning by `signature_source` makes structurally impossible. You supply only the payload:
186
+ The `metadata` call below writes `{skill_package}/metadata.json`, never by hand:
187
+ - **First, apply any queued `metadata_patches[]`** (staged by merge Priority 8b from gap-driven `metadata update` entries) to `metadata.json`, before §3's `apply` reads it: each surgical patch described in the gap's remediation (reconcile a divergent count, add an explanatory stat, etc.). The `metadata` call keeps a patch in any field but those its `--help` lists (e.g., `exports_documented`), whose value it writes; apply none to `exports[]`, which §3's `apply` builds from the list it reads. Log each patch the call overwrites or you leave out, so a still-stale value surfaces in the report rather than being silently overwritten.
188
+ - **For gap-driven rescopes** (`DELETED_EXPORT` / verification `rescoped`): §3's `apply` drops the removed exports from `exports`, and `stats` recompute from that reduced surface, which the `brief.scope.exclude` + `scope.amendments[]` (`action: "excluded"`) step 4 §6b wrote from gap-driven.md §1's rule R1 justify.
189
+ - `version` is `{new_version}`, the version step 4 §6b chose: **if `update_mode == "gap-driven"`, that is the unchanged version**: the skill is being repaired against the same source commit, so of the fields that mark an update only `generation_date` below changes, and step 4 wrote into the existing version directory (step 1 §6c records no source version in gap-driven mode). Otherwise it is the version whose folder step 4 §6b created: `{source_version_detected}` when step 1 §6c recorded `source_version_detected`, else the next patch version. Never pick another value here: `version` must name the folder `metadata.json` sits in, the one §8 points the `active` link at. From here on `{version}` is `{new_version}`.
190
+ - Bind `{generation_date}` ← the current UTC time to the second, read from the clock with `date -u +%Y-%m-%dT%H:%M:%SZ`, never typed: step 1 §4b offers a test report only when it is newer. It is the only update bookkeeping metadata.json carries, in every mode: §3's update operation block records `last_update` and `update_type` in provenance-map.json, and the `metadata` call removes either key an older SKF version left in metadata.json.
191
+ - **Record the source commit** (when `{source_tree_status}` is `ready` or `offline`): pass `--source-commit "{target_commit}"`, the commit every step of this run read, and, when `{target_ref_override}` is set, `--source-ref "{target_ref}"`. The call leaves `source_root` as metadata.json records it: it names the clone SKF keeps for the repository; never write `{source_tree}` or any other path of this run into an artifact. For any other source (a local folder, gap-driven mode, docs-only) pass neither, which keeps both. The in-context `{source_commit}` stays the value init.md §6 read; §9 needs it.
192
+ - **Compute the `stats` block and `confidence_distribution` deterministically** with `{renderMetadataStatsHelper}` (resolve from `{renderMetadataStatsProbeOrder}`; first existing path wins): the same helper sibling create-skill compiles them with, so create and update emit byte-identical stats for identical inputs. The helper owns all the arithmetic: it bins each provenance `entries[]` row once by its `signature_source` tier into `confidence_distribution.{t1,t1_low,t2,t3}`, sets `exports_documented` = the entry count for the library shape (for a reference app, the Pattern Surface row count; see **Shape** below), and derives `exports_total` = `exports_public_api` + `exports_internal`, `public_api_coverage` = documented / public_api (`null` if public_api is 0), `total_coverage` = documented / total (`null` if total is 0), plus `scripts_count` / `assets_count` from the inventory arrays. Run `uv run {renderMetadataStatsHelper} --help` for the full contract. You supply only the payload:
194
193
 
195
194
  **Payload (passed as JSON on stdin):**
196
- - `exports_public_api` and `exports_internal`, unless the drift override below applies. At Quick tier, count the entry points by hand with `{entryPointsByHandData}`, as create-skill does. Otherwise, from `{project-root}`, run `uv run {extractPublicApiHelper} --mode full --source-root "{source_root}" [--brief "{forge_data_folder}/{skill_name}/skill-brief.yaml"] [--language "{language}"] [--scope-type "{scope_type}"] --head-cap 0 -o "{run_dir}/public-api.json"` once over the whole scope (no `--files-from`), with the longest timeout your shell tool takes (a large tree takes minutes), as create-skill's extract.md §4 does, and pass its `counts.exports_public_api` and `counts.exports_internal`, as its compile.md §4 does. Pass `--brief` when that file exists (step 4 §6b wrote a rescope's amendment into it), and `--language` and `--scope-type` when metadata.json records them. On exit 1 (`incomplete`) run it once more. When its `files_without_recipes` is not empty (in-scope files no recipe reads), read those files' entry points as `{entryPointsByHandData}` says and add their names to the two counts, as create-skill's extract.md §4b does. Still incomplete, or on exit 2 or 3, no JSON, no candidate, or `entry_points.status` `no-entry-point`, count the entry points by hand with `{entryPointsByHandData}`. A docs-only skill has no tree to count: pass the values metadata.json records, as the drift override does.
195
+ - `exports_public_api` and `exports_internal`, unless the drift override below applies. At Quick tier, count them by `{entryPointsByHandData}`, as create-skill does, and pass the `counts` of `{run_dir}/denominator.json`. Otherwise, from `{project-root}`, run `uv run {extractPublicApiHelper} --mode full --source-root "{source_root}" [--brief "{forge_data_folder}/{skill_name}/skill-brief.yaml"] [--language "{language}"] [--scope-type "{scope_type}"] --head-cap 0 -o "{run_dir}/public-api.json"` once over the whole scope (no `--files-from`), with the longest timeout your shell tool takes (a large tree takes minutes), as create-skill's extract.md §4 does, and pass its `counts.exports_public_api` and `counts.exports_internal`, as its compile.md §4 does. Pass `--brief` when that file exists (step 4 §6b wrote a rescope's amendment into it), and `--language` and `--scope-type` when metadata.json records them. On exit 1 (`incomplete`) run it once more. When its `files_without_recipes` is not empty (in-scope files no recipe reads), count those files' entry points' names by steps 2 to 4 of `{entryPointsByHandData}`, with `--extraction "{run_dir}/public-api.json"`, as create-skill's extract.md §4b does, and pass the `counts` of `{run_dir}/denominator.json` in place of the runner's: they hold its names. Still incomplete, or on exit 2 or 3, no JSON, no candidate, or `entry_points.status` `no-entry-point`, count them by `{entryPointsByHandData}` and pass the `counts` of `{run_dir}/denominator.json`. On exit 2 (an input error, such as a skill brief it cannot read, or a failure it did not foresee), also add `public-api-counted-by-hand: {line}` to `warnings[]`, `{line}` being the stderr line that holds `error:`, recorded as **Warnings go to the run log** says, and show it to the user: the runner should have made these counts. When a call of `{entryPointsByHandData}` fails as its step 4 says, HALT with status `blocked` (halt procedure: `phase: "write:public-api"`, its `error:` line, or the helper that does not resolve, as `reason`). A docs-only skill has no tree to count: pass the values metadata.json records, as the drift override does.
196
+ - `effective_denominator`: the `counts.effective_denominator` of the JSON whose two counts you passed above, when its `counts.effective_denominator_decision.store` is true and the skill brief's `scope.notes` documents the stratification: `skf-extract-public-api.py` decides `store` as it does for create-skill, so the count follows the source and the brief this update read. Under the drift override, and for a docs-only skill, pass the value `stats.effective_denominator` records once the queued `metadata_patches[]` are applied, when it records one. Otherwise leave it out, and `metadata.json` keeps no `stats.effective_denominator`: remove one an earlier run recorded.
197
197
  - `scripts` / `assets`: the scripts / assets inventory arrays (or `[]` when empty) — the helper sets `scripts_count` / `assets_count` from their lengths
198
198
  - `pattern_surfaces_documented` (reference app only): the number of rows in the `## Pattern Surface` table of the merged SKILL.md, the count create-skill's compile.md §4 passes. The helper uses it as `exports_documented` and emits it as `stats.pattern_surfaces_documented`, the count test-skill measures a reference app's coverage by.
199
199
 
@@ -201,23 +201,36 @@ Update `{skill_package}/metadata.json`:
201
201
 
202
202
  **Shape:** read `scope_type` from `{skill_package}/metadata.json` before this section rewrites it (create-skill records the brief's `scope.type` there). When it is `reference-app`, pass `--shape reference-app` and put `pattern_surfaces_documented` in the payload, as create-skill's compile.md §4 does; for any other `scope_type`, or none, pass no `--shape` (the helper's default, the library shape).
203
203
 
204
- **Invoke** — since the helper reads `entries[]`, stage §3's `provenance-map.json` write first (§3 does not depend on these stats):
204
+ **Invoke**: the helper reads `entries[]`, so run §3's `apply` first; the scripts / assets inventory arrays are the `scripts` and `assets` lists it wrote to `{run_dir}/metadata-lists.json`:
205
205
 
206
206
  ```bash
207
207
  echo '{"exports_public_api": {N}, "exports_internal": {M}, "scripts": {scripts-inventory-or-[]}, "assets": {assets-inventory-or-[]}}' \
208
- | uv run {renderMetadataStatsHelper} {forge_version}/provenance-map.json
208
+ | uv run {renderMetadataStatsHelper} {forge_version}/provenance-map.json -o "{run_dir}/stats.json"
209
209
  ```
210
210
 
211
211
  For a reference app, the same call with the Pattern Surface row count and the shape:
212
212
 
213
213
  ```bash
214
214
  echo '{"exports_public_api": {N}, "exports_internal": {M}, "pattern_surfaces_documented": {P}, "scripts": {scripts-inventory-or-[]}, "assets": {assets-inventory-or-[]}}' \
215
- | uv run {renderMetadataStatsHelper} {forge_version}/provenance-map.json --shape reference-app
215
+ | uv run {renderMetadataStatsHelper} {forge_version}/provenance-map.json --shape reference-app -o "{run_dir}/stats.json"
216
216
  ```
217
217
 
218
- Write the returned `stats` and `confidence_distribution` objects into `metadata.json` **verbatim**. If the helper reports `coherence.ok: false`, fix the provenance map and re-run the helper; never hand-edit the stats. A `confidence_distribution` violation means some provenance entries carry a missing or unrecognized `signature_source` (§3 must write it on every entry).
218
+ If its `coherence.ok` (in `{run_dir}/stats.json`, which the `metadata` call reads) is false, fix the provenance map and re-run the helper; never hand-edit the stats. A `confidence_distribution` violation means some provenance entries carry a missing or unrecognized `signature_source` (§3 must write it on every entry).
219
219
 
220
- **Label violations.** A violation whose `field` is `provenance.entries[<i>].<field>` means entry `<i>` (its `export_name` is in the violation) carries a label its `extraction_method` does not allow: relabel it by the `## Relabel Rule` section of `{extractionPatternsData}`, with `{source_root}` as the tree its node-kind lookup reads. **Under the drift override** (`{workspace_drift_status}` is `overridden`, gap-driven.md §3) run no recipe at `{source_root}`, so no `kind-at`: HEAD is not the pinned commit, so a kind read there says nothing about the entry. Leave each violation that needs a kind from the tree in place and list it as a WARN ending ` (drift override: HEAD {head_short_sha} is not pinned {pinned_short_sha})`; a relabel that reads nothing from the tree still applies. With the status `ok` or `skipped`, run the lookup as the rule says. Rewrite `{forge_version}/provenance-map.json` with the relabeled entries, re-run the helper with the same payload and `--shape`, and write the `stats` and `confidence_distribution` it returns: a relabeled `signature_source` moves the distribution, and a violation left as a WARN does not block the write. This relabel applies to every entry the helper flags, including a gap-driven `verified` entry (§3). Record the relabeled export names, and each WARN left, as `{provenance_relabels}` in workflow context: the update report lists them under its Confidence Tier Breakdown (report.md), so no entry moves from T1 to T1-low unannounced.
220
+ **Label violations.** A violation whose `field` is `provenance.entries[<i>].<field>` means entry `<i>` (its `export_name` is in the violation) carries a label its `extraction_method` does not allow: relabel it by the `## Relabel Rule` section of `{extractionPatternsData}`, with `{source_root}` as the tree its node-kind lookup reads. **Under the drift override** (`{workspace_drift_status}` is `overridden`, gap-driven.md §3) run no recipe at `{source_root}`, so no `kind-at`: HEAD is not the pinned commit, so a kind read there says nothing about the entry. Leave each violation that needs a kind from the tree in place and list it as a WARN ending ` (drift override: HEAD {head_short_sha} is not pinned {pinned_short_sha})`; a relabel that reads nothing from the tree still applies. With the status `ok` or `skipped`, run the lookup as the rule says. Rewrite `{forge_version}/provenance-map.json` with the relabeled entries and re-run the helper with the same payload, `--shape` and `-o`, whose file the `metadata` call reads: a relabeled `signature_source` moves the distribution, and a violation left as a WARN does not block the write. This relabel applies to every entry the helper flags, including a gap-driven `verified` entry (§3). Record the relabeled export names, and each WARN left, as `{provenance_relabels}` in workflow context: the update report lists them under its Confidence Tier Breakdown (report.md), so no entry moves from T1 to T1-low unannounced.
221
+ - **Write metadata.json** once the stats helper's last run wrote `{run_dir}/stats.json`. From `{project-root}`, run:
222
+
223
+ ```bash
224
+ uv run {buildChangeManifestHelper} metadata \
225
+ --metadata "{skill_package}/metadata.json" \
226
+ --lists "{run_dir}/metadata-lists.json" \
227
+ --stats "{run_dir}/stats.json" \
228
+ --version "{new_version}" \
229
+ --generation-date "{generation_date}" \
230
+ [--source-commit "{target_commit}"] [--source-ref "{target_ref}"]
231
+ ```
232
+
233
+ On exit 1, fix the input its stderr names and run it again; when it fails again, prints no JSON or no candidate resolves, HALT with status `halted-for-write-failure` (halt procedure: `phase: "write:metadata"`, `path: "{skill_package}/metadata.json"`, `reason: "<its stderr>"`).
221
234
 
222
235
  **A docs-only skill's document hashes.** When step 2 wrote `{run_dir}/doc-hashes.json` (its docs-only branch), record the new hash of each document it found changed, after the writes above, so the next update compares against the documents this one read. Resolve `{compareDocHashesHelper}` ← first existing path in `{compareDocHashesProbeOrder}` and, from `{project-root}`, run:
223
236
 
@@ -254,21 +267,24 @@ uv run {buildChangeManifestHelper} apply \
254
267
  --source-commit "{map_source_commit}" \
255
268
  --source-ref "{map_source_ref}" \
256
269
  [--drift-head "{head_short_sha}" --drift-pinned "{pinned_short_sha}"] \
270
+ --not-public-out "{run_dir}/not-public.json" \
271
+ --metadata-in "{skill_package}/metadata.json" \
272
+ --lists-out "{run_dir}/metadata-lists.json" \
257
273
  -o "{forge_version}/provenance-map.json"
258
274
  ```
259
275
 
260
- `{update_type}` follows the run's mode as report.md §2 names it (`{update_mode}`): `incremental` for `normal`, `gap-driven` for `gap-driven`, `full` for `degraded` (a degraded run has no map: leave out `--provenance-map`, and `apply` starts a new one). Pass each `{run_dir}` file the run wrote: the change manifest and the records in every mode, `category-c.json` and `ccc-pairs.json` (step 2's Category C and its CCC check), `extraction.json` and `export-details.json` when step 2 wrote them, `merge-records.json` when step 4 did, the Category D files (`category-d-compare.json`, `new-files.json`) with `--source-root` when step 2 ran Category D, and `promoted-docs.json` when step 2 §1b wrote it. `{generation_date}` is the value §2 wrote, `--test-report-run-id` goes only with a gap-driven run (the `{test_report_run_id}` of the report it applied), `{manual_sections_preserved}` is `len(preserved) + len(moved)` from the §1 `manual-verify` verdict (never a marker count), and `{map_source_commit}` and `{map_source_ref}` are the `source_commit` and `source_ref` §2 writes to `metadata.json` (an empty string for a null one): audit-skill reads its baseline commit from the map's top-level `source_commit` and `source_ref`. Pass `--drift-head` and `--drift-pinned` only when `{workspace_drift_status}` is `overridden` (gap-driven.md §3). Every entry `apply` writes carries `signature_source`, which §2's stats helper bins, and `apply` sets the map's update operation block (`"last_update"`, `"update_type"` and its other keys), replacing an earlier update's.
276
+ `{update_type}` follows the run's mode as report.md §2 names it (`{update_mode}`): `incremental` for `normal`, `gap-driven` for `gap-driven`, `full` for `degraded` (a degraded run has no map: leave out `--provenance-map`, and `apply` starts a new one). Pass each `{run_dir}` file the run wrote: the change manifest and the records in every mode, `category-c.json` and `ccc-pairs.json` (step 2's Category C and its CCC check), `extraction.json` and `export-details.json` when step 2 wrote them, `merge-records.json` when step 4 did, the Category D files (`category-d-compare.json`, and `new-files.json` when step 2 wrote it: a skill with no brief has none) with `--source-root` when step 2 ran Category D, and `promoted-docs.json` when step 2 §1b wrote it. `{generation_date}` is the value §2 bound, `--test-report-run-id` goes only with a gap-driven run (the `{test_report_run_id}` of the report it applied), `{manual_sections_preserved}` is `len(preserved) + len(moved)` from the §1 `manual-verify` verdict (never a marker count), and `{map_source_commit}` and `{map_source_ref}` are the `source_commit` and `source_ref` §2 writes to `metadata.json` (an empty string for a null one): audit-skill reads its baseline commit from the map's top-level `source_commit` and `source_ref`. Pass `--drift-head` and `--drift-pinned` only when `{workspace_drift_status}` is `overridden` (gap-driven.md §3). Pass `--metadata-in` and `--lists-out` in every mode: `apply` writes there the lists §2's `metadata` call reads. Every entry `apply` writes carries `signature_source`, which §2's stats helper bins, and `apply` sets the map's update operation block (`"last_update"`, `"update_type"` and its other keys), replacing an earlier update's.
261
277
 
262
278
  Dispatch on its exit code:
263
279
 
264
- - **0:** it printed `{status: "written", entries, file_entries, warnings, map}`. Bind `{provenance_spot_check_warnings}` ← its `warnings`, and add each to `warnings[]`; the update report lists each as a WARN (report.md §2).
280
+ - **0:** it printed `{status: "written", entries, not_public, file_entries, warnings, map}`. Bind `{provenance_spot_check_warnings}` ← its `warnings`, and add each to `warnings[]`; the update report lists each as a WARN (report.md §2). `not_public` lists, `{name, file}`, each new export of a `public-api` skill it did not add in normal mode as off the public surface (the `public: false` mark of `reextract-records.json`, else the same rule over the `scope.type` and `entry_point_diff` of `extraction.json`), and one of its `warnings` counts them; it also wrote that list to `{run_dir}/not-public.json` (`[]` when it left none out). When `extraction.json` names `public-api` but gives no surface the rule can read (Quick tier, a runner failure, an incomplete run), it adds every name, with one warning that says so.
265
281
  - **3** (`status` `refused`): a blocking gap reached the provenance write with no source line (`blocking_unresolved[]`: a `NEW_EXPORT` the map does not hold, `unknown`, with a severity other than `Medium`, `Low` or `Info`, a missing one included). gap-driven.md §4 sends every such gap to its §4a and halts with `halted-for-remediation-path` before merge when §4a does not find it, so this happens only when that check was skipped or bypassed: `apply` wrote nothing, and no null citation is written. HALT with status `blocked` and write no `metadata.json`, `provenance-map.json` or other artifact. Tell the user: "**A blocking gap reached the provenance write with no source line:** {each export name, with its severity or "no severity"}. gap-driven.md §4 should have stopped on it with `halted-for-remediation-path`. The halt restored SKILL.md and references/ from the snapshot step 4 took; give each gap a `file:line` Source or name the file that defines its export in its Remediation (or downgrade it to Medium, Low or Info), then re-run update-skill." The halt procedure takes `phase: "write:provenance-map"`, `path: "{forge_version}/provenance-map.json"`, `reason: "blocking-gap-unresolved: {export names}: gap-driven.md §4 should have halted with halted-for-remediation-path"`.
266
282
  - **Exit 1, no JSON, or no candidate resolves:** HALT with status `halted-for-write-failure` (halt procedure: `phase: "write:provenance-map"`, `path: "{forge_version}/provenance-map.json"`, `reason: "<its stderr, or skf-build-change-manifest.py is missing; re-install SKF>"`).
267
283
 
268
284
  **For script/asset file changes:** `apply` updates the rows; copy or remove the files themselves in `{skill_package}`, each path the change manifest's `category_d` lists: a `scripts_*` path under `scripts/`, an `assets_*` path under `assets/`:
269
285
  - MODIFIED_FILE (`*_modified`): copy the file from `{source_root}` (the row's `content_hash` is updated by `apply`)
270
286
  - DELETED_FILE (`*_deleted`): remove the file
271
- - NEW_FILE (`*_added`): copy the file from `{source_root}` (`apply` adds its row with `file_name`, `file_type`, `source_file`, `confidence: "T1-low"`, `extraction_method: "file-copy"`, `content_hash`)
287
+ - NEW_FILE (`*_added`): copy the file from `{source_root}` (`apply` adds its row)
272
288
  - A `docs_*` path is source-tracked, never bundled: copy and remove nothing for it.
273
289
 
274
290
  ### 4. Write Updated evidence-report.md
@@ -458,7 +474,7 @@ uv run {updateActiveSymlinkHelper} update \
458
474
  --version {version}
459
475
  ```
460
476
 
461
- The helper emits a result envelope with `status` ∈ `{ok, flipped, mismatch, missing-target}` and a pre-formatted `log_message`. Log the message to the evidence report.
477
+ The helper emits a result envelope with `status` ∈ `{ok, flipped, mismatch, missing-target, busy}` and a pre-formatted `log_message`. Log the message to the evidence report.
462
478
 
463
479
  **Dispatch on `status`:**
464
480
 
@@ -466,8 +482,10 @@ The helper emits a result envelope with `status` ∈ `{ok, flipped, mismatch, mi
466
482
  - **`flipped`** (exit 0): symlink was atomically updated (temp-and-replace). Continue to §8a.
467
483
  - **`missing-target`** (exit 2): `{skill_group}/{version}/` does not exist on disk, so step 4 §6b did not create it. HALT with status `halted-for-write-failure` and display `halt_message` verbatim.
468
484
  - **`mismatch`** (exit 2): the link still points elsewhere after the flip. HALT with status `halted-for-write-failure` and display `halt_message` verbatim.
485
+ - **`busy`** (exit 2): another flip of the link held its lock for the helper's whole wait, so the link is unchanged. HALT with status `halted-for-concurrent-run` and display `halt_message` verbatim.
486
+ - **Any other exit, or no JSON on stdout** (exit 1, or an exit 2 that prints no JSON: the helper could not run, could not move the link, or failed after moving it, and the last line it prints on stderr says why): HALT with status `halted-for-write-failure` and display that line, which is `exit <code>` when the helper prints nothing on stderr. The halt procedure takes `phase: "write:active-symlink"`, `path: "{skill_group}/active"`, `reason: "<that line>"`.
469
487
 
470
- For both exit-2 halts, the halt procedure takes `phase: "write:active-symlink"`, `path: "{skill_group}/active"`, `reason: "<status>: <halt_message>"`. Its rollback removes the new version (the `active` link does not name it) or, in gap-driven mode, restores the package from the snapshot.
488
+ For each exit-2 halt, the halt procedure takes `phase: "write:active-symlink"`, `path: "{skill_group}/active"`, `reason: "<status>: <halt_message>"`. Its rollback removes the new version (the `active` link does not name it) or, in gap-driven mode, restores the package from the snapshot.
471
489
 
472
490
  ### 8a. Verify the Active Symlink
473
491
 
@@ -481,6 +499,8 @@ uv run {updateActiveSymlinkHelper} verify \
481
499
 
482
500
  On `mismatch` (exit 2), HALT with status `halted-for-write-failure` and display the helper's `halt_message` verbatim: it names the diverged target, the expected version and the recovery command. The halt procedure takes `phase: "write:verify-active-symlink"`, `path: "{skill_group}/active"`, `reason: "mismatch: <halt_message>"`.
483
501
 
502
+ On `ok` (exit 0), continue to §9. On any other exit, or no JSON on stdout (exit 1, or an exit 2 that prints no JSON: the helper could not run or could not read the link, as when a real folder has replaced `active` or the skill folder is gone since §8, and the last line it prints on stderr says why), HALT with status `halted-for-write-failure` and display that line, which is `exit <code>` when the helper prints nothing on stderr. The halt procedure takes `phase: "write:verify-active-symlink"`, `path: "{skill_group}/active"`, `reason: "<that line>"`.
503
+
484
504
  ### 9. Move the Workspace Clone to the Recorded Commit
485
505
 
486
506
  Run only when `{source_tree_status}` is `ready` and `{workspace_clone}` is not null. test-skill and audit-skill read the skill's source from metadata.json `source_root`, the clone SKF keeps for this repository, and test-skill compares its HEAD with the `source_commit` §2 just wrote. From `{project-root}`, resolve `{sourceTreeHelper}` ← first existing path in `{sourceTreeProbeOrder}`, `{cccGitHygieneHelper}` ← first existing path in `{cccGitHygieneProbeOrder}` and `{checkWorkspaceDriftHelper}` ← first existing path in `{checkWorkspaceDriftProbeOrder}`, then run: