bmad-module-skill-forge 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +33 -26
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/troubleshooting.md +8 -0
  12. package/docs/verifying-a-skill.md +46 -21
  13. package/docs/why-skf.md +1 -1
  14. package/docs/workflows.md +5 -5
  15. package/package.json +1 -1
  16. package/src/knowledge/qmd-registry.md +1 -1
  17. package/src/knowledge/version-paths.md +1 -1
  18. package/src/shared/health-check.md +2 -50
  19. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  22. package/src/shared/scripts/skf-atomic-write.py +212 -59
  23. package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
  24. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  25. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  26. package/src/shared/scripts/skf-count-tokens.py +54 -10
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  28. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  29. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  30. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  31. package/src/shared/scripts/skf-extract-public-api.py +1673 -189
  32. package/src/shared/scripts/skf-extraction-inventory.py +584 -46
  33. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  34. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  35. package/src/shared/scripts/skf-github-probe.py +47 -8
  36. package/src/shared/scripts/skf-hash-content.py +14 -8
  37. package/src/shared/scripts/skf-load-provenance.py +3 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  39. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  40. package/src/shared/scripts/skf-preflight.py +15 -1
  41. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  42. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  43. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  44. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  45. package/src/shared/scripts/skf-shard-body.py +239 -41
  46. package/src/shared/scripts/skf-structural-diff.py +133 -18
  47. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  48. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  49. package/src/shared/scripts/skf-validate-output.py +12 -3
  50. package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
  51. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  52. package/src/shared/tool-requirements.yaml +8 -2
  53. package/src/skf-analyze-source/SKILL.md +2 -2
  54. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  55. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  56. package/src/skf-audit-skill/SKILL.md +2 -2
  57. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  58. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  59. package/src/skf-audit-skill/references/health-check.md +2 -0
  60. package/src/skf-audit-skill/references/init.md +2 -1
  61. package/src/skf-audit-skill/references/re-index.md +14 -9
  62. package/src/skf-audit-skill/references/report.md +2 -2
  63. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  64. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  65. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  66. package/src/skf-brief-skill/SKILL.md +3 -3
  67. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  68. package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
  69. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  70. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  71. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  72. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  73. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  74. package/src/skf-campaign/SKILL.md +2 -2
  75. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  76. package/src/skf-create-skill/SKILL.md +2 -2
  77. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  78. package/src/skf-create-skill/assets/skill-sections.md +7 -7
  79. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  80. package/src/skf-create-skill/references/compile.md +31 -60
  81. package/src/skf-create-skill/references/component-extraction.md +3 -3
  82. package/src/skf-create-skill/references/enrich.md +2 -2
  83. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  84. package/src/skf-create-skill/references/extract.md +23 -19
  85. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  86. package/src/skf-create-skill/references/extraction-patterns.md +10 -11
  87. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  88. package/src/skf-create-skill/references/report.md +21 -5
  89. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  90. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  91. package/src/skf-create-stack-skill/SKILL.md +2 -2
  92. package/src/skf-create-stack-skill/references/validate.md +1 -1
  93. package/src/skf-drop-skill/SKILL.md +2 -2
  94. package/src/skf-drop-skill/references/execute.md +5 -1
  95. package/src/skf-drop-skill/references/health-check.md +2 -0
  96. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  97. package/src/skf-drop-skill/references/report.md +2 -2
  98. package/src/skf-drop-skill/references/select.md +7 -3
  99. package/src/skf-export-skill/SKILL.md +2 -2
  100. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  101. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  102. package/src/skf-export-skill/references/load-skill.md +1 -1
  103. package/src/skf-export-skill/references/token-report.md +29 -9
  104. package/src/skf-forger/SKILL.md +2 -2
  105. package/src/skf-quick-skill/SKILL.md +2 -2
  106. package/src/skf-refine-architecture/SKILL.md +6 -8
  107. package/src/skf-rename-skill/SKILL.md +2 -2
  108. package/src/skf-setup/SKILL.md +2 -2
  109. package/src/skf-test-skill/SKILL.md +2 -2
  110. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  111. package/src/skf-test-skill/references/coherence-check.md +5 -4
  112. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  113. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  114. package/src/skf-test-skill/references/coverage-check.md +69 -111
  115. package/src/skf-test-skill/references/init.md +5 -6
  116. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  117. package/src/skf-test-skill/references/report.md +11 -9
  118. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  119. package/src/skf-test-skill/references/source-access-protocol.md +15 -18
  120. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  121. package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
  122. package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
  123. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  124. package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
  125. package/src/skf-test-skill/scripts/score-signatures.py +356 -61
  126. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  127. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  128. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/SKILL.md +2 -2
  131. package/src/skf-update-skill/references/detect-changes.md +32 -13
  132. package/src/skf-update-skill/references/gap-driven.md +37 -22
  133. package/src/skf-update-skill/references/init.md +6 -2
  134. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  135. package/src/skf-update-skill/references/merge.md +17 -12
  136. package/src/skf-update-skill/references/re-extract.md +4 -2
  137. package/src/skf-update-skill/references/report.md +7 -4
  138. package/src/skf-update-skill/references/write.md +77 -36
  139. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
  140. package/src/skf-verify-stack/SKILL.md +2 -2
  141. package/tools/cli/lib/tool-check.js +158 -2
@@ -72,7 +72,8 @@ Context payload shape (consumed by `write`):
72
72
  "doc_urls": [{"url": "...", "label": "...", "source": "..."}],
73
73
  # `source` (optional per #432): language-registry | readme-detection |
74
74
  # homepage | pages-api | docs-folder. Threaded through when present;
75
- # absent → entry renders as {url, label} only (no false drift).
75
+ # absent → entry renders as {url, label} only (no false drift); any
76
+ # other value is refused before anything is written (exit 1).
76
77
  "scripts_intent": "detect" | "none" | free-text,
77
78
  "assets_intent": "detect" | "none" | free-text,
78
79
  "source_authority": "official" | "community" | "internal",
@@ -87,7 +88,9 @@ Version precedence (resolved into the rendered YAML's `version` field):
87
88
  3. Otherwise: detected_version when it is full X.Y.Z semver (SEMVER_RE);
88
89
  a detected version of another shape (`0.1`, `2.0.0rc1`) is passed as
89
90
  found and skipped here, with a warning.
90
- 4. Otherwise: "1.0.0".
91
+ 4. Otherwise: "1.0.0", with a warning when no detected_version was given
92
+ (a docs-only brief aside), so a headless caller tells the default
93
+ from a detected 1.0.0.
91
94
 
92
95
  When target_version is set, the rendered YAML includes a `target_version`
93
96
  field whose value MUST match `version` (the script enforces this
@@ -235,6 +238,8 @@ VALID_SCOPE_TYPES = {
235
238
  "reference-app",
236
239
  "docs-only",
237
240
  }
241
+ # skill-brief.v1.json's doc_urls[].source enum (a test pins it to the schema).
242
+ VALID_DOC_URL_SOURCES = {"language-registry", "readme-detection", "homepage", "pages-api", "docs-folder"}
238
243
 
239
244
 
240
245
  def _die(message: str, field: str | None = None, code: int = 1) -> None:
@@ -321,6 +326,10 @@ def validate_context(ctx: dict[str, Any]) -> list[str]:
321
326
  url = entry.get("url")
322
327
  if not url or not isinstance(url, str):
323
328
  _die(f"doc_urls[{i}].url is required and must be a non-empty string", field="doc_urls")
329
+ source = entry.get("source")
330
+ if source and (not isinstance(source, str) or source not in VALID_DOC_URL_SOURCES):
331
+ _die(f"doc_urls[{i}].source must be one of {sorted(VALID_DOC_URL_SOURCES)}. Got: {source!r}",
332
+ field="doc_urls")
324
333
 
325
334
  # source_authority (default 'community') with docs-only force rule
326
335
  source_authority = ctx.get("source_authority", "community")
@@ -437,6 +446,12 @@ def validate_context(ctx: dict[str, Any]) -> list[str]:
437
446
  warnings.append(
438
447
  f"detected_version {detected!r} is not full X.Y.Z semver — falling through to default 1.0.0"
439
448
  )
449
+ elif detected is None and tv is None and ctx.get("version_resolved") is None and source_type != "docs-only":
450
+ # The default would otherwise reach a headless caller as if detected.
451
+ warnings.append(
452
+ "no source version detected: version defaults to 1.0.0; for a remote source skf-create-skill "
453
+ "tries tag 1.0.0 or v1.0.0; set target_version in the brief to pin it"
454
+ )
440
455
 
441
456
  return warnings
442
457
 
@@ -594,10 +609,11 @@ def render_yaml(brief: dict[str, Any]) -> str:
594
609
 
595
610
  The step 5 §3 template shows leading and trailing `---` markers, but those were
596
611
  wrapping the example YAML for documentation purposes — actual on-disk YAML uses
597
- only the leading `---` (or none). A trailing `---` would start a second empty
598
- document and break callers that use `yaml.safe_load` (which expects a single
599
- document) — and skf-create-skill / audit-skill / update-skill all use
600
- `yaml.safe_load`, so consistency with their loaders matters.
612
+ only the leading `---` (or none). A trailing `---` starts a second, empty
613
+ document: SKF's own readers skip it (they parse a brief with
614
+ skf-resolve-authoritative-files.py's parse_brief_yaml, which reads the SKF 1.x
615
+ briefs that ended so), but other YAML tools that expect a single document,
616
+ such as `yaml.safe_load`, refuse it, so the writer leaves it out.
601
617
  """
602
618
  body = yaml.safe_dump(
603
619
  brief,
@@ -755,6 +771,23 @@ def _read_json_object(path: Path, flag: str) -> dict[str, Any]:
755
771
  return data
756
772
 
757
773
 
774
+ RESOLVER = Path(__file__).resolve().parent / "skf-resolve-authoritative-files.py"
775
+
776
+
777
+ def _parse_brief_yaml(text: str) -> Any:
778
+ """A brief's YAML as skf-resolve-authoritative-files.py (from this folder)
779
+ reads it: an SKF 1.x brief's trailing `---` is no second document."""
780
+ spec = importlib.util.spec_from_file_location("skf_resolve_authoritative_files", RESOLVER)
781
+ if spec is None or spec.loader is None:
782
+ _die(f"cannot load {RESOLVER.name}", code=2)
783
+ resolver = importlib.util.module_from_spec(spec)
784
+ try:
785
+ spec.loader.exec_module(resolver)
786
+ except (OSError, ImportError, SyntaxError) as exc:
787
+ _die(f"cannot load {RESOLVER.name} beside {Path(__file__).name}: {exc}", code=2)
788
+ return resolver.parse_brief_yaml(text)
789
+
790
+
758
791
  def base_context(base_brief: Path, doc_urls_file: Path | None, patch_file: Path | None) -> dict[str, Any]:
759
792
  """The write context of the base brief form (see the module docstring)."""
760
793
  try:
@@ -762,7 +795,7 @@ def base_context(base_brief: Path, doc_urls_file: Path | None, patch_file: Path
762
795
  except OSError as e:
763
796
  _die(f"write: cannot read the base brief {base_brief}: {e}", code=2)
764
797
  try:
765
- brief = yaml.safe_load(original.decode("utf-8-sig"))
798
+ brief = _parse_brief_yaml(original.decode("utf-8-sig"))
766
799
  except (UnicodeDecodeError, yaml.YAMLError) as e:
767
800
  _die(f"write: the base brief {base_brief} is not YAML: {e}")
768
801
  if not isinstance(brief, dict):
@@ -895,7 +928,7 @@ def cmd_amend(target: Path) -> int:
895
928
  except OSError as e:
896
929
  _die(f"amend: cannot read {target}: {e}", code=2)
897
930
  try:
898
- brief = yaml.safe_load(original.decode("utf-8-sig"))
931
+ brief = _parse_brief_yaml(original.decode("utf-8-sig"))
899
932
  except (UnicodeDecodeError, yaml.YAMLError) as e:
900
933
  _die(f"amend: {target} is not a YAML brief: {e}")
901
934
  if not isinstance(brief, dict) or not isinstance(brief.get("scope"), dict):
@@ -48,6 +48,9 @@
48
48
  # tested the versions SKF has run on, as strings
49
49
  # basis where minimum and tested come from
50
50
  # version_command the command that prints the installed version
51
+ # (skill_check and tessl have none: the check never runs
52
+ # them, and reads skill-check's package.json and the name
53
+ # of the binary tessl installs)
51
54
  # install_url where to install it from
52
55
  # upgrade how to upgrade it
53
56
  # acknowledged_as its row in the README Acknowledgements table (required
@@ -180,7 +183,9 @@ tools:
180
183
  minimum: null
181
184
  tested: ["0.111.0"]
182
185
  basis: No minimum yet, since no older tessl has run Tessl Review. The tests stub tessl. Pull request 517 (unreleased v3 development, merged 2026-09-28 as 2b3b5113) records live create-skill and test-skill runs on tessl 0.111.0 in which Tessl Review returned a score. On 2026-10-04 the v3.0.0 pre-release tool check (recorded in the pull request that added these versions) ran `skf status` from main b5150b08 (the release candidate), which read tessl 0.111.0, and ran skf-tessl-review.py collect against it signed out, which starts no review.
183
- version_command: [tessl, --version]
186
+ # tessl is not run: on a tessl that has never run, any command accepts its
187
+ # Terms for the user. The check reads the version from the name of the
188
+ # binary tessl installs (tessl-<version>-<platform> in tessl/versions/).
184
189
  install_url: https://tessl.io
185
190
  upgrade: tessl cli update
186
191
  acknowledged_as: tessl
@@ -195,7 +200,8 @@ tools:
195
200
  minimum: null
196
201
  tested: ["1.2.0"]
197
202
  basis: SKF runs it through npx at whatever version npx resolves, so there is no floor it can check. In the v3.0.0 pre-release tool check (recorded in the pull request that added these versions) on 2026-10-04, on main b5150b08 (the release candidate), npx resolved skill-check 1.2.0, npm's latest since 2026-02-21, and test-skill's check (`npx skill-check check <skill-dir> --format json --no-security-scan`) on a copy of an oh-my-skills skill returned every field SKF reads. No other version has a recorded run.
198
- version_command: [skill-check, --version]
203
+ # skill-check has no --version (1.2.0 exits 2 on it): the check reads the
204
+ # version from the package.json of the installed skill-check package.
199
205
  install_url: https://github.com/thedaviddias/skill-check
200
206
  upgrade: npm install -g skill-check@latest
201
207
  acknowledged_as: skill-check
@@ -80,10 +80,10 @@ These rules apply to every step in this workflow:
80
80
 
81
81
  3. **Resolve `{headless_mode}`**: true if `--headless` or `-H` was passed as an argument, or if `headless_mode: true` in `{sidecar_path}/preferences.yaml`. Default: false.
82
82
 
83
- 4. **Resolve workflow customization.** Run:
83
+ 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:
84
84
 
85
85
  ```bash
86
- uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow
86
+ uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
87
87
  ```
88
88
 
89
89
  It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-analyze-source.toml` (team overrides, committed) and `.user.toml` (personal overrides, gitignored). When it exits non-zero, prints no JSON or is missing, print one line, `[activation/warn] customization_resolver_unavailable: <reason>` (`<reason>`: its first stderr line, `not found` when the script is missing, `no JSON` when it printed none). If the resolver cannot run, read `{skill-root}/customize.toml` alone and use its bundled defaults: the `{project-root}/_bmad/custom/` overrides do not apply to this run. In that case keep the reason as `{customization_resolver_unavailable}` (unset when the resolver ran) for step 5 to record in the run sink.
@@ -31,7 +31,7 @@ Defines the output contract for skill-brief.yaml files generated by analyze-sour
31
31
  | `constituent_refs` | object | map of path→ref strings | Optional. Per-constituent git ref overrides for composite (multi-repo or multi-ref) sources. Keys must match entries in the analyze-source `project_paths` array. When absent, a single `target_ref` (or auto-detected ref) applies to all paths. Mutually exclusive with `target_ref`. |
32
32
  | `source_authority` | string | `official` / `community` / `internal` | Default `community`. Set to `official` only when the skill creator is the library maintainer. Forced to `community` when `source_type: "docs-only"`. |
33
33
  | `source_ref` | string | Git ref (tag/branch/HEAD) | Resolved git ref used for source access. Set automatically during tag resolution — do not set manually. |
34
- | `scope.tier_a_include` | array | Glob patterns (min 1, non-empty) | Optional narrower tier-A include list for a curated-subset skill — stratified monorepo packages, single-crate `specific-modules` scopes, and multi-entry exports-map packages. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`. **It also exempts the brief from the denominator deflation guard**, so supply it only when it genuinely narrows the surface — never as an empty list, and never as a way to widen. See `skf-test-skill/references/source-access-protocol.md`. |
34
+ | `scope.tier_a_include` | array | Glob patterns (min 1, non-empty) | Optional narrower tier-A include list for a curated-subset skill: stratified monorepo packages, single-crate `specific-modules` scopes, and multi-entry exports-map packages. When present, `skf-test-skill` re-derives the coverage denominator from this list instead of the coarse `scope.include`. **With it, the denominator deflation guard measures the stored `stats.effective_denominator` against this list's set, in either direction, instead of against the coarse `scope.include` union**, so supply it only when it genuinely narrows the surface, never as an empty list, and never as a way to widen. See `skf-test-skill/references/source-access-protocol.md`. |
35
35
  | `scope.registry_path` | string | Path from the source root (non-empty) | Optional, for `scope.type: "component-library"`. The component registry file. `skf-create-skill` step 3d reads it instead of detecting a registry, and writes it back, with a `demo-and-registry` amendment, when the user confirms a detected registry or gives its path. |
36
36
  | `scope.ui_variants` | array | `{name, package?}` objects (`name` non-empty) | Optional, for `scope.type: "component-library"`. The design system variants, the first one primary. |
37
37
  | `scope.demo_patterns` | array | Glob patterns (non-empty strings) | Optional, for `scope.type: "component-library"`. The demo and example files `skf-create-skill` step 3d leaves out. Step 3d reads them instead of detecting demo files, and writes them back, with a `demo-and-registry` amendment per glob, when the user confirms or adjusts the detected ones. |
@@ -27,7 +27,7 @@ uv run {extractPublicApiHelper} --mode full --source-root "{unit_root}" --scope-
27
27
 
28
28
  Otherwise read the JSON by the exit code:
29
29
 
30
- - **0 or 1:** `exports_count` ← `counts.exports_public_api`, `api_surface` ← the names in `entry_point_diff.public[]`, `export_pattern` ← `entry_points.status` with `aggregates.by_type` (for example "barrel: 12 functions, 5 classes", marked "capped" when `truncated` is true), `strategy_used: "ast-grep"`, `confidence: "T1"`. Then read by eye what the recipes could not, and name each one in `warnings`: every `entry_point_diff.extraction_gaps[]` name (public, but no recipe found its definition: read it at its `file` and `line`) and every `file_issues[]` file (one the parser could not fully read: add the exports it shows to `api_surface`). Exit 1 (`status: "incomplete"`) means an ast-grep run failed on some files: keep what it returned and put its `errors[]` in `warnings`.
30
+ - **0 or 1:** `exports_count` ← `counts.exports_public_api`, `api_surface` ← the names in `entry_point_diff.public[]` (a `namespace` item with a `members` list given as its members' names, the names its module passes on), `export_pattern` ← `entry_points.status` with `aggregates.by_type` (for example "barrel: 12 functions, 5 classes", marked "capped" when `truncated` is true), `strategy_used: "ast-grep"`, `confidence: "T1"`. Then read by eye what the recipes could not, and name each one in `warnings`: every `entry_point_diff.extraction_gaps[]` name (public, but no recipe found its definition: read it at its `file` and `line`) and every `file_issues[]` file (one the parser could not fully read: add the exports it shows to `api_surface`). Exit 1 (`status: "incomplete"`) means an ast-grep run failed on some files: keep what it returned and put its `errors[]` in `warnings`.
31
31
  - **3** (no ast-grep the runner can run): the JSON still lists `entry_points.files`. Read the exports from those entry-point files by eye (from the unit's source files when it lists none), with `strategy_used: "source-read"` and `confidence: "T1-low"`.
32
32
  - **2:** an input error, named on stderr (a wrong path or flag): fix the call and run it again.
33
33
 
@@ -72,10 +72,10 @@ Stage 1b returns to init.md at §5b's **Record for report**, then §6. Step 1 §
72
72
 
73
73
  2. **Resolve `{headless_mode}`**: true if `--headless` or `-H` was passed as an argument, or if `headless_mode: true` in `{sidecar_path}/preferences.yaml`. Default: false.
74
74
 
75
- 3. **Resolve workflow customization.** Run:
75
+ 3. **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:
76
76
 
77
77
  ```bash
78
- uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} --key workflow
78
+ uv run {resolveCustomizationHelper} --skill {skill-root} --project-root {project-root} --key workflow
79
79
  ```
80
80
 
81
81
  It merges the bundled `{skill-root}/customize.toml` with `{project-root}/_bmad/custom/skf-audit-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. Then keep the reason as `{customization_resolver_unavailable}`, which step 6 hands to the emitter as a warning.
@@ -60,6 +60,7 @@ previousWorkflow: 'create-skill'
60
60
  <!-- Appended by structural-diff, or for a compose-mode stack by constituent-freshness -->
61
61
  <!-- Constituent Freshness (compose-mode stacks): drifted, missing and not-compared constituents; each drifted (HIGH) or missing (MEDIUM) one is a finding -->
62
62
  <!-- Provenance label differences (not drift): informational table, rendered only when label_changes[] is non-empty and excluded from Total Drift Items -->
63
+ <!-- Off the public surface (not drift): a note under the Summary naming a public-api skill's new exports off its public surface (not_public[]), rendered only when summary.not_public is above 0 and excluded from Total Drift Items -->
63
64
 
64
65
  ---
65
66
 
@@ -20,7 +20,7 @@ Every hard halt in this workflow exits with a stable code so headless automators
20
20
 
21
21
  ## Result Contract (Headless)
22
22
 
23
- When `{headless_mode}` is true, step 6 prints one `SKF_AUDIT_RESULT_JSON: {...}` line on **stdout** before chaining to step 7, and every hard halt one on **stderr** with `status: "error"`. The shared emitter builds each line from the payload the stage stages in `{run_dir}`, folds in the decisions and warnings the run recorded there, stamps `run_id` and `result_path`, and checks it against `{project-root}/_bmad/skf/shared/scripts/schemas/skf-audit-result-envelope.v1.json` (`{project-root}/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json` in a dev checkout), whose descriptions say what each field holds. Never type the line yourself:
23
+ When `{headless_mode}` is true, step 6 prints one `SKF_AUDIT_RESULT_JSON: {...}` line on **stdout**, which the health check (step 7) displays as the run's last line, and every hard halt one on **stderr** with `status: "error"`. The shared emitter builds each line from the payload the stage stages in `{run_dir}`, folds in the decisions and warnings the run recorded there, stamps `run_id` and `result_path`, and checks it against `{project-root}/_bmad/skf/shared/scripts/schemas/skf-audit-result-envelope.v1.json` (`{project-root}/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json` in a dev checkout), whose descriptions say what each field holds. Never type the line yourself:
24
24
 
25
25
  ```
26
26
  SKF_AUDIT_RESULT_JSON: {"status":"success|error","skill_name":"…","drift_score":"CLEAN|MINOR|SIGNIFICANT|CRITICAL|null","report_path":"…|null","next_workflow":"update-skill|null","audit_ref":"…|null","upstream_moved":true|false|null,"upstream_ref":"…|null","exit_code":0,"halt_reason":null,"run_id":"…","result_path":"…|null"}
@@ -13,6 +13,8 @@ nextStepFile: 'shared/health-check.md'
13
13
 
14
14
  Chain to the shared workflow self-improvement health check at `{nextStepFile}`. This is the terminal step of audit-skill — after the shared health check completes, the workflow is fully done.
15
15
 
16
+ In a headless run report.md's Result Contract bound `{result_envelope_line}` to the `SKF_AUDIT_RESULT_JSON` line without displaying it: the shared health check displays that line verbatim as the run's last line wherever it stops, so a `claude -p` caller reads the envelope as the final message.
17
+
16
18
  ## Rules
17
19
 
18
20
  - No user-facing reports, file writes, or result contracts in this step — those belong in step 6
@@ -156,6 +156,7 @@ Load the following from the skill directory:
156
156
  - `name`, `version` and `generation_date`, and `{original_tier}`, the tier the skill was compiled at: `forge_tier` when it holds Quick, Forge, Forge+ or Deep (a stack records it there), else `confidence_tier` when it does (a single skill records it there), else null
157
157
  - `source_root` — Resolved source code path used during extraction
158
158
  - `source_repo`: the repository the skill was built from, which the [C] of `upstream-checkout.md` reads a newer ref of (`{source_repo}`, an empty string when the field is null or missing)
159
+ - `scope_type`: bind `{scope_type}` ← the `scope_type` metadata.json records, else the `scope.type` of `{forge_data_folder}/{skill_name}/skill-brief.yaml` when that file exists and names one (a skill whose metadata.json records no scope type is audited under its brief's), else null. When it is `public-api`, step 2 passes `--scope-type public-api` to the recipe runner and the snapshot build (`re-index.md` §3), so step 3 does not report as added an export off the public surface that create-skill and update-skill keep the skill's map to. The docs-only rule below reads the `scope_type` and `source_type` metadata.json itself records, never this brief fallback
159
160
 
160
161
  **A docs-only skill** (its `scope_type`, or the `source_type` an older `metadata.json` records, is `docs-only`) has no source tree: create-skill recorded each document it was built from in `doc_sources`, with its content hash, and step 5a compares them. Set `{docs_only_skill}` to true. When `doc_sources` is missing or empty, or no entry of it has a `content_hash` (create-skill records null for a document it could not fetch), nothing records the documents' state: HALT with **exit 3**, `halt_reason: "no-baseline"`, phase `init:baseline`: "**`{skill_name}` records no documentation hashes, so there is no baseline to audit it against.** Re-create it with `[CS] Create Skill`, which records them." Otherwise skip §4, Stack Skill Detection and §5, take §5b's docs-only skip, and continue at §6. Every other skill sets `{docs_only_skill}` to false and continues at §4.
161
162
 
@@ -252,7 +253,7 @@ Show `n/a` for a value the run does not have (a docs-only skill reads no provena
252
253
  - {Quick: text-diff comparison → T1-low for every export (read by eye)}
253
254
  - {Forge: AST structural comparison → T1 for each export an ast-grep rule matches, T1-low for each export read by eye}
254
255
  - {Forge+: AST structural comparison + CCC-assisted rename detection → the same labels as Forge}
255
- - {Deep: AST structural + QMD semantic comparison → the same labels as Forge, plus T2}
256
+ - {Deep: AST structural + a semantic comparison → the same labels as Forge, plus `T2` from QMD or `T1-low-fallback` from the skill's files}
256
257
  - {Compose-mode stack, at any tier: constituent freshness by metadata hash (step 1c), with no source re-index}
257
258
  - {Docs-only skill, at any tier: each tracked document's content hash against the one recorded at compile time (step 5a), each changed document graded by step 5, with no source re-index}"
258
259
 
@@ -56,7 +56,7 @@ Based on forge tier detected in Step 01:
56
56
  **Forge tier (ast-grep available):**
57
57
  - The recipe runner extracts, as the **AST Extraction Protocol** in `{extractionPatternsData}` says: one `skf-extract-public-api.py --mode full` call over the bounded scan list from §2 (§3 gives the command) runs every recipe for the language in one process, reads only the files on the list, and keeps every match (`--head-cap 0`: a match past a cap would read as a removed export in step 3). While it can run, never run the recipes one at a time, batch them, or merge and dedupe their matches by hand
58
58
  - Each export it returns is T1 as it stands: its name (`$NAME`), type, file, line (`$NAME`'s line, as create-skill records it, not the match's first line, which a decorator or an `export` line can hold), `ast_node_type` and `signature`, the declaration on one line even when a formatter split its parameters over several
59
- - Read by eye only what the runner leaves (§3): the forms the recipes leave out (Known Limitation #11 in `{extractionPatternsData}`) when a file uses them, the names an entry point exports that no recipe found, and the files it could not parse or read. Label each export by the tool that produced it: T1 with `extraction_method: ast-grep` for a runner export, T1-low with `extraction_method: source-read` and `ast_node_type: null` for an export read by eye
59
+ - Read by eye only what the runner leaves (§3): the forms the recipes leave out (Known Limitation #11 in `{extractionPatternsData}`) when a file uses them, the names an entry point exports that no recipe found, the provenance map's entries the runner leaves out (a Python or Rust underscore name such as `__version__`, a module) that the source still declares, and the files it could not parse or read. Label each export by the tool that produced it: T1 with `extraction_method: ast-grep` for a runner export, T1-low with `extraction_method: source-read` and `ast_node_type: null` for an export read by eye
60
60
 
61
61
  **Tier degradation handling (Forge/Forge+/Deep):** If the runner cannot run and ast-grep is unavailable, follow `{tierDegradationRulesData}` (AST Tool Unavailable, Per-File AST Failure) for the fallback and the user notification. A file ast-grep cannot parse falls back to reading that file only: its exports are T1-low with `extraction_method: source-read` and `ast_node_type: null`, and exports the runner matched in other files stay T1. Silent degradation is forbidden: the snapshot names each file's status, and the run names each file read after an ast-grep failure, and why (§3, §4); when ast-grep is unavailable for the whole run, every export is T1-low.
62
62
 
@@ -102,11 +102,12 @@ uv run {extractPublicApiHelper} --mode full \
102
102
  --source-root "{source_root}" \
103
103
  --files-from "{auditDataFolder}/scan-files.json" \
104
104
  --head-cap 0 \
105
+ [--scope-type public-api] \
105
106
  [--timeout "{runner_timeout}"] \
106
107
  -o "{auditDataFolder}/extraction.json"
107
108
  ```
108
109
 
109
- The runner stops its ast-grep runs after 540 seconds and still writes its JSON (status `incomplete`), so give the command your shell tool's longest timeout; when that is under 10 minutes, pass `--timeout` as `{runner_timeout}`, a little under it (`100` under a two-minute limit). Never open the JSON: item 2's helper reads it and prints what this step acts on, its `status` included (never the exit code). At Quick tier no runner runs.
110
+ The runner stops its ast-grep runs after 540 seconds and still writes its JSON (status `incomplete`), so give the command your shell tool's longest timeout; when that is under 10 minutes, pass `--timeout` as `{runner_timeout}`, a little under it (`100` under a two-minute limit). Pass `--scope-type public-api` only when `{scope_type}` (step 1 §3) is `public-api`, and never pass the brief (`--brief`): its globs would leave out files on the scan list, which step 3 would then report removed. Never open the JSON: item 2's helper reads it and prints what this step acts on, its `status` included (never the exit code). At Quick tier no runner runs.
110
111
 
111
112
  **2. Build the snapshot.** Resolve `{extractionSnapshotHelper}` ← first existing path in `{extractionSnapshotProbeOrder}` (if none exists, HALT with **exit 3**, `halt_reason: "helper-missing"`, phase `re-index:snapshot`) and, from `{project-root}`, run:
112
113
 
@@ -119,27 +120,31 @@ uv run {extractionSnapshotHelper} build \
119
120
  --provenance-map "{provenanceMap}" \
120
121
  [--extraction "{auditDataFolder}/extraction.json"] \
121
122
  [--details "{auditDataFolder}/export-details-{n}.json"]... \
123
+ [--scope-type public-api] \
122
124
  -o "{forge_version}/extraction-snapshot.json"
123
125
  ```
124
126
 
125
- Pass `--extraction` when the runner wrote its JSON, and one `--details` for each file item 3 wrote (none on the first build). The helper merges the runner's exports with the ones read by eye, gives every file on the list one status (`extracted`, `read-by-eye`, `hash-tracked` for a script, asset or doc only `file_entries[]` names, which step 3 compares by content hash, `missing`, `parse-failed` or `unread`) and prints one line. Act on its fields:
127
+ Pass `--extraction` when the runner wrote its JSON, `--scope-type public-api` when `{scope_type}` is `public-api` (at Quick tier too, where no runner runs), and one `--details` for each file item 3 wrote (none on the first build). The helper merges the runner's exports with the ones read by eye, gives every file on the list one status (`extracted`, `read-by-eye`, `hash-tracked` for a script, asset or doc only `file_entries[]` names, which step 3 compares by content hash, `missing`, `parse-failed` or `unread`) and prints one line. Act on its fields:
126
128
 
127
129
  - **`runner_status` is `incomplete`** on the first build (an ast-grep run failed, or the time limit ran out): run item 1 once more, then this command again.
128
- - **`runner_status` is `no-ast-grep`, or null at Forge tier and above** (no JSON at `-o` after the call, or no runner candidate): the runner cannot run. Follow **When the Runner Cannot Run** in `{extractionPatternsData}` over the files in `to_read`, with this audit's parameters in place of create-skill's: the files in scope are those `to_read` lists, and their count is the Decision Tree's input (not create-skill's step 3 count). Keep every match, in every directory batch and every retry: pass `find_code_by_rule` a `max_results` that no recipe can reach (100000), and a Known Limitation #4 `find_code` retry the same, and drop the `| head -{HEAD_CAP}` from the CLI streaming template, in each batch, since a match past a cap reads as a removed export in step 3. Record each export as item 3 does, T1 with `ast-grep` for one the recipes match.
129
- - **`to_read` or `extraction_gaps` is not empty**, or a file uses the forms the recipes leave out: item 3 reads them, then run this command again with its details. When `complete` is still false, the files `to_read` lists could not be read: HALT with **exit 3**, `halt_reason: "source-unreadable"`, phase `re-index:snapshot`, naming them. Step 3 never reads an incomplete snapshot.
130
+ - **`runner_status` is `no-ast-grep`, or null at Forge tier and above** (no JSON at `-o` after the call, or no runner candidate): the runner cannot run. Follow **When the Runner Cannot Run** in `{extractionPatternsData}` over the files in `to_read`, with this audit's parameters in place of create-skill's: the files in scope are those `to_read` lists, and their count is the Decision Tree's input (not create-skill's step 3 count). Keep every match, in every directory batch and every retry: pass `find_code_by_rule` a `max_results` that no recipe can reach (100000), and a Known Limitation #4 `find_code` retry the same, and drop the `| head -{HEAD_CAP}` from the CLI streaming template, in each batch, since a match past a cap reads as a removed export in step 3. Record each export as item 3 does, T1 with `ast-grep` for one the recipes match. When item 1's call left no JSON and a stderr line that holds `error:` (an input error, such as a source root it cannot find, or a failure it did not foresee), show that section's **Recipe runner failed** warning with item 3's **AST fallback** warnings, and record `recipe_runner_failed: {that line}` for the envelope: write the text to `{run_dir}/warning.txt` with a file write (the line can hold quotes), then, from `{project-root}`, run `uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warning "$(cat "{run_dir}/warning.txt")"`.
131
+ - **`to_read` or `extraction_gaps` is not empty**, or a file uses the forms the recipes leave out: item 3 reads them, then run this command again with its details. When `complete` is still false, the files `to_read` lists could not be read: HALT with **exit 3**, `halt_reason: "source-unreadable"`, phase `re-index:snapshot`, naming them. Step 3 never reads an incomplete snapshot. Item 3 reads the gaps once: when `complete` is true and `extraction_gaps` still lists a name after that read (one the reader could not place), warn "**Extraction gap:** {name} ({file}:{line}) was not found by eye; step 3 reports it as removed." for each and go on to §4 without reading again.
132
+ - **`public_surface.problem` is not null** on the build that goes on to §4 (with `--scope-type public-api`, the public-surface rule could read no surface: no runner JSON, as at Quick tier, a runner that could not run, an incomplete run, one with errors, or an entry point the runner could not trace): warn "**Public surface not applied:** {problem}; step 3 reports every export the provenance map does not hold as added." and record `public_surface_not_applied: {problem}` for the envelope, once: write the text to `{run_dir}/warning.txt` with a file write (the problem can hold quotes), then, from `{project-root}`, run `uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warning "$(cat "{run_dir}/warning.txt")"`.
130
133
  - **`complete` is true** and nothing is left to read: go on to §4.
131
- - **`status: "error"`:** an `error` that starts `cannot write` is a failed write: HALT with **exit 4**, `halt_reason: "write-failed"`, phase `re-index:snapshot`, `"path": "{forge_version}/extraction-snapshot.json"`. Any other names a file this step wrote: fix it and run the command again.
134
+ - **`status: "error"`:** an `error` that starts `cannot write` is a failed write: HALT with **exit 4**, `halt_reason: "write-failed"`, phase `re-index:snapshot`, `"path": "{forge_version}/extraction-snapshot.json"`. An `error` that starts `cannot load` (a helper this build loads beside it is missing) HALTs with **exit 3**, `halt_reason: "helper-missing"`, phase `re-index:snapshot`, showing the `error`. Any other names a file this step wrote: fix it and run the command again.
132
135
  - **The command fails otherwise, or prints no JSON:** HALT with **exit 3**, `halt_reason: "helper-missing"`, phase `re-index:snapshot`, showing the first stderr line.
133
136
 
134
137
  **3. What the runner leaves.** Read by eye (T1-low, `extraction_method: source-read`, `ast_node_type: null`) only:
135
138
 
136
139
  - each file in `to_read`: a `parse-failed` one (its `issue`: a syntax error, where a recipe can miss an export; not UTF-8; unreadable), with the warning "**AST fallback:** ast-grep could not parse {file} ({issue}); its exports were read from source and labeled T1-low.", and an `unread` one (an incomplete run, or no runner: then every file, by text pattern matching at Quick tier);
137
- - each name in `extraction_gaps`, in its `file` (an entry point exports it, no recipe found it);
140
+ - each name in `extraction_gaps`, in its `file` at its `line`: a gap with an `entry` is a name that entry point exports and no recipe found; a gap whose `entry` is null is a provenance map entry the runner leaves out (a Python or Rust underscore name such as `__version__`, a module) that its file still declares;
138
141
  - the forms the recipes leave out (Known Limitation #11 in `{extractionPatternsData}`) in a file that uses them.
139
142
 
140
- For each file to read, launch a subprocess (Pattern 2) that loads it and returns ONLY `{"files": [...], "exports": [...]}`, with no prose and no fences: `files` lists the file when the worker read the whole of it (also when it holds no export), and each export gives `name`, `type`, `signature` (the declaration on one line), `file` (relative to `{source_root}`, with forward slashes), `line` (`$NAME`'s line), `confidence: "T1-low"`, `extraction_method: "source-read"` and `ast_node_type: null`. Save each worker's JSON as it returned it, as `{auditDataFolder}/export-details-{n}.json` (`{n}` counting from 1), never merged by hand; without subprocesses, read the files in the main thread, one by one, and save one such file. A stack's export needs no library: the snapshot helper gives each one its file's `source_library` from the provenance map (the map `normalize` returns as `source_library_by_file`).
143
+ For each file to read, launch a subprocess (Pattern 2) that loads it and returns ONLY `{"files": [...], "exports": [...]}`, with no prose and no fences: `files` lists the file when the worker read the whole of it (also when it holds no export), and each export gives `name`, `type`, `signature` (the declaration on one line), `file` (relative to `{source_root}`, with forward slashes), `line` (`$NAME`'s line), `confidence: "T1-low"`, `extraction_method: "source-read"` and `ast_node_type: null`. For a gap whose `entry` is null, record its `name`, `file` and `line`; as `type`, its `export_type` when the declaration at that line still has that kind, else the kind it has now (a `variable` now declared by a `def` is a `function`, which step 3 reports as a changed kind), and when the gap gives no `export_type`, the kind the declaration has; as `signature`, the declaration on one line, the import line for an alias that renames (`from cognee.api.v1.visualize import visualize_graph as visualize`), and for a module or package (a gap at line 1 of a file the name is the module or package of) `module` and the file's path from `{source_root}` without its extension or a final `__init__` or `index`, dotted for Python (`module cognee.modules.pipelines`). Save each worker's JSON as it returned it, as `{auditDataFolder}/export-details-{n}.json` (`{n}` counting from 1), never merged by hand; without subprocesses, read the files in the main thread, one by one, and save one such file. A stack's export needs no library: the snapshot helper gives each one its file's `source_library` from the provenance map (the map `normalize` returns as `source_library_by_file`).
141
144
 
142
- **The snapshot.** The command writes `{forge_version}/extraction-snapshot.json`, which step 3 (`structural-diff.md`) reads directly: `exports[]` (for a stack, each with its file's `source_library`), `files[]` with each file's status, `files_by_status`, `counts` and `cap_hits` (§4) and `outside_scope` (step 6's Out-of-Scope New Public API table); `skf-extraction-snapshot.py --help` gives the full shape. Record the written path as `{extractionSnapshot}` in workflow context: step 3 passes it to the structural-diff helper.
145
+ **The public surface.** With `--scope-type public-api` the helper marks each export of a language family whose entry points are a barrel `public: true` or `public: false`, by the rule create-skill and update-skill keep a public-api skill's provenance map to (`public_surface` in `skf-extraction-inventory.py`), and the line's `public_surface` gives the rule's `problem` (null when it applied) and `marked_not_public`. Every export stays in the snapshot, whatever its mark: a map an older SKF wrote can hold an export off the surface, and dropping it would read as removed. Step 3's diff lists an export marked `public: false` that pairs with no map entry under `not_public[]`, never under `added[]`. Without the flag, or with any other scope type, nothing is marked and `public_surface` is null.
146
+
147
+ **The snapshot.** The command writes `{forge_version}/extraction-snapshot.json`, which step 3 (`structural-diff.md`) reads directly: `exports[]` (for a stack, each with its file's `source_library`; for a public-api skill, each with its `public` mark when the surface marks it), `files[]` with each file's status, `files_by_status`, `counts` and `cap_hits` (§4) and `outside_scope` (step 6's Out-of-Scope New Public API table); `skf-extraction-snapshot.py --help` gives the full shape. Record the written path as `{extractionSnapshot}` in workflow context: step 3 passes it to the structural-diff helper.
143
148
 
144
149
  ### 4. Validate Extraction Completeness
145
150
 
@@ -139,7 +139,7 @@ Build the section from the frontmatter of {outputFile}, where step 1 §6, step 2
139
139
  **Confidence Legend:**
140
140
  - **T1:** an ast-grep match (`extraction_method: ast-grep`) at any tier: high reliability, structural truth
141
141
  - **T1-low:** read by eye (`extraction_method: source-read`) at any tier: moderate reliability
142
- - **T1-low-fallback:** Deep-tier semantic diff read directly from the skill's docs and the current source because the QMD collection was empty: moderate reliability
142
+ - **T1-low-fallback:** Deep-tier semantic diff read directly from the skill's docs and the current source because no QMD extraction collection was registered for the skill, or the one registered was empty: moderate reliability
143
143
  - **T2:** Deep-tier semantic diff: a claim the skill's QMD collection holds, checked against the current source line it cites: evidence-backed semantic analysis
144
144
  - **T3:** external documentation reference: variable reliability, secondary source
145
145
  ```
@@ -196,7 +196,7 @@ When On Activation step 3 fell back to the bundled `customize.toml`, add `"custo
196
196
  uv run {emitEnvelopeHelper} emit --workflow skf-audit-skill --run-dir "{run_dir}" --result-dir "{forge_version}" < "{run_dir}/result-context.json"
197
197
  ```
198
198
 
199
- It writes `{forge_version}/audit-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and its `audit-skill-result-latest.json` copy (stable path for pipeline consumers: a copy, not a symlink), stamps the timestamp, `run_id`, `headless_decisions` and `warnings` into both, and prints the `SKF_AUDIT_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, display the line verbatim: chaining workflows read `drift_score`, `report_path`, `next_workflow`, `upstream_moved` and `upstream_ref` from it without polling the filesystem. A result file that could not be written leaves `result_path` null and a `result_file_write_failed` warning in the line, and the run still finishes. If the emitter exits non-zero, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HALT with **exit 4**, `halt_reason: "write-failed"`, phase `report:result-contract`: the run's result contract could not be written.
199
+ It writes `{forge_version}/audit-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and its `audit-skill-result-latest.json` copy (stable path for pipeline consumers: a copy, not a symlink), stamps the timestamp, `run_id`, `headless_decisions` and `warnings` into both, and prints the `SKF_AUDIT_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, bind `{result_envelope_line}` ← the line and do not display it here: the shared health check displays it verbatim as the run's last line, so a `claude -p` caller reads `drift_score`, `report_path`, `next_workflow`, `upstream_moved` and `upstream_ref` from the final message. A result file that could not be written leaves `result_path` null and a `result_file_write_failed` warning in the line, and the run still finishes. If the emitter exits non-zero, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HALT with **exit 4**, `halt_reason: "write-failed"`, phase `report:result-contract`: the run's result contract could not be written.
200
200
 
201
201
  **Hard-halt envelope (headless only).** Every hard halt prints its envelope through the emitter on **stderr** with `status: "error"` and the `exit_code` / `halt_reason` for its failure class, as each step's **Halt envelope** paragraph shows: it is the only failure signal a wrapping pipeline receives. `drift_score` carries its last known value (`null` if classification never ran); `report_path` is `null` when the report write failed.
202
202
 
@@ -11,7 +11,7 @@ auditDataFolder: '{forge_version}/.skf-audit/{timestamp}'
11
11
 
12
12
  ## STEP GOAL:
13
13
 
14
- Compare what the skill documents about each export (retrieved from its QMD extraction collection) against the current source under `{source_root}` to detect meaning-level changes that structural diff cannot catch. This step executes ONLY at Deep tier: at Quick, Forge, and Forge+ tiers, it appends a skip notice and auto-proceeds.
14
+ Compare what the skill documents about each export (retrieved from its QMD extraction collection, or read from its own files when none is registered or the one registered is empty) against the current source under `{source_root}` to detect meaning-level changes that structural diff cannot catch. This step executes ONLY at Deep tier: at Quick, Forge, and Forge+ tiers, it appends a skip notice and auto-proceeds.
15
15
 
16
16
  ## Rules
17
17
 
@@ -48,16 +48,16 @@ Continue to section 2.
48
48
 
49
49
  ### 2. Query Original Knowledge Context
50
50
 
51
- Run item 1 in the main thread, because only the main thread can end this step, then launch a subprocess (Pattern 3, data operations) for items 2 to 4:
51
+ Run item 1 in the main thread, because only the main thread can end this step or choose its route, then launch a subprocess (Pattern 3, data operations) for items 2 to 4:
52
52
  1. Read the `qmd_collections` registry from `{sidecar_path}/forge-tier.yaml`. Find the entry where `skill_name` matches `{skill_name}` AND `type` is `"extraction"`. Three cases must be handled distinctly — collapsing them into "found vs. not found" silently degrades semantic diff when a collection is registered but never indexed.
53
53
 
54
- - **Registry entry missing.** Log: "No QMD extraction collection found for `{skill_name}`. Semantic diff skipped." Append a `## Semantic Drift` section holding `**Status:** Skipped: no QMD extraction collection is registered for {skill_name}` to {outputFile}, append `'semantic-diff'` to `stepsCompleted`, and auto-proceed to {nextStepFile}.
54
+ - **Registry entry missing.** Whatever tier the skill was compiled at, a Deep-tier audit still checks its claims: log "No QMD extraction collection is registered for `{skill_name}`: semantic diff reads the skill's files instead.", bind `{collection_label}` ← `none (no QMD extraction collection is registered for {skill_name})` and take the **direct-content fallback** below. Never skip the step for it.
55
55
  - **Registry entry present but collection empty.** Run a pre-query probe — `qmd ls {collection_name}` (CLI) or the equivalent MCP call. If it reports zero files (`Files: 0 (updated never)` or an empty listing), the collection is registered but has never been indexed. Do **not** proceed to querying — queries will return nothing and the step would silently degrade.
56
56
  - Log: "QMD collection `{collection_name}` is registered but empty. Run `qmd update` to (re-)index `{collection.path}`, then re-audit for full Deep-tier semantic coverage."
57
- - Fall through to the **direct-content fallback** below instead of skipping outright.
58
- - **Registry entry present and populated.** Use the `name` field from the registry entry as the collection to query. Proceed to bullet 2.
57
+ - Bind `{collection_label}` ← `{collection_name} (registered but empty)` and fall through to the **direct-content fallback** below instead of skipping outright.
58
+ - **Registry entry present and populated.** Use the `name` field from the registry entry as the collection to query, bind `{collection_label}` ← `{collection_name}`, `{claims_source}` ← `from QMD` and `{confidence}` ← `T2`, and proceed to bullet 2.
59
59
 
60
- **Direct-content fallback** (used when the collection is registered but empty): read what the skill documents about each export from `SKILL.md` and `references/*.md` of the audited skill in place of item 2's query, then run items 3 and 4 on those claims, checking the current source under `{source_root}` with the Deep-tier AST tooling this step already requires (ast_bridge; see step 2 §1 "Deep tier"). This fallback is reachable only from Deep tier: §1 short-circuits Quick/Forge/Forge+ before §2 runs, so AST tooling is guaranteed available here. Record findings with confidence label `T1-low-fallback` rather than T2: the claims come from reading the skill's files, not from its QMD collection. The step's output schema is otherwise unchanged; set `qmd_collection = null` in the Semantic Drift header and annotate: "Semantic diff ran in direct-content fallback mode: QMD collection was registered but empty."
60
+ **Direct-content fallback** (used when no extraction collection is registered for the skill, or the one registered is empty): read what the skill documents about each export from `SKILL.md` and `references/*.md` of the audited skill in place of item 2's query, then run items 3 and 4 on those claims, checking the current source under `{source_root}` with the Deep-tier AST tooling this step already requires (ast_bridge; see step 2 §1 "Deep tier"). This fallback is reachable only from Deep tier: §1 short-circuits Quick/Forge/Forge+ before §2 runs, so AST tooling is guaranteed available here. Record findings with confidence label `T1-low-fallback` rather than T2: the claims come from reading the skill's files, not from its QMD collection. Bind `{claims_source}` ← `read from its SKILL.md and references/*.md in direct-content fallback mode` and `{confidence}` ← `T1-low-fallback`: §4's header then says the claims came from the skill's files and, in `{collection_label}`, why. The step's output schema is otherwise unchanged.
61
61
 
62
62
  2. Query the collection for what the skill documents about each export (usage, conventions, dependencies, architecture): the original side
63
63
  3. For each claim, read the export's current definition and call sites under `{source_root}` at the file and line `{forge_version}/extraction-snapshot.json` records for it: the current side (an export the snapshot no longer holds is a removed export step 3 already reports: skip it)
@@ -78,7 +78,7 @@ For each finding, record:
78
78
  - What changed (description)
79
79
  - Evidence (the current source file:line; a claim with no source line is not recorded)
80
80
  - Affected exports
81
- - Confidence: T2
81
+ - Confidence: `{confidence}` (`T2`, or `T1-low-fallback` in direct-content fallback mode)
82
82
 
83
83
  ### 4. Compile Semantic Drift Section
84
84
 
@@ -87,38 +87,38 @@ Append to {outputFile}:
87
87
  ```markdown
88
88
  ## Semantic Drift
89
89
 
90
- **Method:** the skill's claims (from QMD) checked against the current source (Deep tier)
91
- **QMD Collection:** {collection_name}
90
+ **Method:** the skill's claims ({claims_source}) checked against the current source (Deep tier)
91
+ **QMD Collection:** {collection_label}
92
92
 
93
93
  ### New Patterns Detected ({count})
94
94
 
95
95
  | Pattern | Description | Affected Exports | Evidence | Confidence |
96
96
  |---------|------------|-----------------|----------|------------|
97
- | {pattern} | {description} | {exports} | {evidence} | T2 |
97
+ | {pattern} | {description} | {exports} | {evidence} | {confidence} |
98
98
 
99
99
  ### Changed Conventions ({count})
100
100
 
101
101
  | Convention | Before | After | Affected Exports | Evidence | Confidence |
102
102
  |-----------|--------|-------|-----------------|----------|------------|
103
- | {convention} | {old} | {new} | {exports} | {evidence} | T2 |
103
+ | {convention} | {old} | {new} | {exports} | {evidence} | {confidence} |
104
104
 
105
105
  ### Dependency Shifts ({count})
106
106
 
107
107
  | Export | Original Dependencies | Current Dependencies | Change | Evidence | Confidence |
108
108
  |--------|---------------------|---------------------|--------|----------|------------|
109
- | {export} | {old_deps} | {new_deps} | {description} | {evidence} | T2 |
109
+ | {export} | {old_deps} | {new_deps} | {description} | {evidence} | {confidence} |
110
110
 
111
111
  ### Architectural Changes ({count})
112
112
 
113
113
  | Change | Description | Affected Exports | Evidence | Confidence |
114
114
  |--------|-------------|------------------|----------|------------|
115
- | {change} | {description} | {exports} | {evidence} | T2 |
115
+ | {change} | {description} | {exports} | {evidence} | {confidence} |
116
116
 
117
117
  ### Deprecated Patterns ({count})
118
118
 
119
119
  | Pattern | Documented In Skill | Current Status | Evidence | Confidence |
120
120
  |---------|-------------------|----------------|----------|------------|
121
- | {pattern} | {skill_reference} | {status} | {evidence} | T2 |
121
+ | {pattern} | {skill_reference} | {status} | {evidence} | {confidence} |
122
122
 
123
123
  ### Summary
124
124
 
@@ -132,7 +132,7 @@ Append to {outputFile}:
132
132
  | **Total Semantic Items** | {total} |
133
133
  ```
134
134
 
135
- Save the same rows to `{auditDataFolder}/semantic-findings.json`: step 5 classifies this file, not the tables. It is a JSON array with one object per row, `{"type": "semantic", "category", "name", "detail", "file", "line", "confidence"}`. `category` is its table's: New Patterns `pattern`, Changed Conventions `convention`, Dependency Shifts `dependency`, Architectural Changes `architecture` and Deprecated Patterns `deprecated_pattern`. `name` is the row's first cell, `detail` its description or change, `file` and `line` the current source line its evidence cites, and `confidence` the row's (T2, or T1-low-fallback).
135
+ Save the same rows to `{auditDataFolder}/semantic-findings.json`: step 5 classifies this file, not the tables. It is a JSON array with one object per row, `{"type": "semantic", "category", "name", "detail", "file", "line", "confidence"}`. `category` is its table's: New Patterns `pattern`, Changed Conventions `convention`, Dependency Shifts `dependency`, Architectural Changes `architecture` and Deprecated Patterns `deprecated_pattern`. `name` is the row's first cell, `detail` its description or change, `file` and `line` the current source line its evidence cites, and `confidence` the row's `{confidence}` (`T2`, or `T1-low-fallback` in direct-content fallback mode).
136
136
 
137
137
  ### 5. Update Report and Auto-Proceed
138
138
 
@@ -69,7 +69,7 @@ With `-o` the helper saves the diff to the file and prints one line, `{"status":
69
69
  - It starts `Cannot write output`: HALT with **exit 4**, `halt_reason: "write-failed"`, phase `structural-diff:diff`, `"path": "{auditDataFolder}/structural-diff.json"`.
70
70
  - Otherwise it names `{provenanceMap}`, which step 1 §4 read: HALT with **exit 3**, `halt_reason: "provenance-invalid"`, phase `structural-diff:diff`, `"path": "{provenanceMap}"`, showing the `error`.
71
71
 
72
- The helper canonicalizes both sides before it matches each export by its name and its file (the quote style of string defaults, stdlib module prefixes, and a renamed public re-export resolved through the re-export map it derives from the provenance map), so a cosmetic extractor difference never reads as drift. It records each transform it applied in `applied_transforms`, and takes `--reexport-map {file}` only to override the derived map.
72
+ The helper canonicalizes both sides before it matches each export by its name and its file (the quote style of string defaults, stdlib module prefixes, and a renamed public re-export resolved through the re-export map it derives from the provenance map), and, for a matched export, takes a semantic kind a by-eye read records as the runner's base kind when the runner's signature shows that form (`async_function` or `decorator` as `function`, `enum` as `class`), so a cosmetic extractor difference never reads as drift. It records each transform it applied in `applied_transforms`, and takes `--reexport-map {file}` only to override the derived map.
73
73
 
74
74
  The saved diff holds every set §5 renders. This step reads three of its fields itself: §1b reads `summary.removed` and the `name` of each `removed[]` entry (an export in the provenance map but not in the snapshot), and §6 records `applied_transforms` (each transform the helper applied, with its count) in the drift report's frontmatter for step 6's Provenance table.
75
75
 
@@ -95,33 +95,38 @@ When `added` is above 0, run §1's command again with the same arguments and the
95
95
 
96
96
  ### 2. What the Diff Decides
97
97
 
98
- Every set comes straight from the saved diff, never from set arithmetic of your own. Three of its facts steer what later steps judge:
98
+ Every set comes straight from the saved diff, never from set arithmetic of your own. Four of its facts steer what later steps judge:
99
99
 
100
100
  - **Moved** (`moved[]`): a matched export whose file changed. A move is **not** a removal.
101
101
  - **Ambiguous names** (`ambiguous_names[]`): a name left on both sides that occurs more than once on a side (a `GET` handler in several route files, for example), so the helper paired none of its entries. They stay in `removed[]` and `added[]`, and step 5 judges whether a removed and an added entry of one name are one export that moved.
102
+ - **Off the public surface** (`not_public[]`): for a public-api skill, step 2 marked each export by the public surface create-skill and update-skill keep its map to, and an export marked `public: false` that pairs with no map entry is listed here, not in `added[]`, since neither workflow documents it. It is not drift: it is not counted in Total Drift Items or the exit code, and step 5 never classifies it (`--from-diff` reads only `added[]`, `removed[]`, `changed[]` and `moved[]`). §5's renderer counts it in an **Off the public surface:** note. A name in `ambiguous_names[]` stays in `added[]` for step 5 to pair, and a map entry is matched whatever its mark, so none reads as removed.
102
103
  - **Not drift:** `summary.signature_unverified` counts matched exports whose signature sits in different fields on the two sides, so a change there cannot be seen, and `label_changes[]` lists matched exports whose `confidence` or `extraction_method` differs, which names the tool that read the export, not what the source says. Neither is counted in Total Drift Items, and step 5 classifies neither.
103
104
 
104
105
  ### 4b. Detect Script/Asset Drift
105
106
 
106
107
  **Only execute if provenance-map.json contains `file_entries`.**
107
108
 
108
- **Resolve `{compareFileHashesHelper}`** from `{compareFileHashesProbeOrder}`; first existing path wins. Run one comparison: it hashes the tracked `file_entries[]` and, the other way round, the files of the standard script, asset and doc folders of the source tree:
109
+ **Resolve `{compareFileHashesHelper}`** from `{compareFileHashesProbeOrder}`; first existing path wins. Run one comparison: it hashes the tracked `file_entries[]` and, the other way round, walks the source tree for the files the map does not track, with the script and asset detector create-skill and update-skill use, plus the documents under `docs/authoritative/`, keeping only those the skill brief's scope takes, by the scope test update-skill and create-skill use. A module of a Python package named like a script folder, a file the map's `entries[]` cite, a file under `scripts/[MANUAL]/` or `assets/[MANUAL]/`, and a script or an asset whose `scripts_intent` or `assets_intent` is `none` are never new files. Pass `--brief` only when that brief, `{forge_data_folder}/{skill_name}/skill-brief.yaml`, exists (a skill built without one, such as a quick skill, has none):
109
110
 
110
111
  ```bash
111
- uv run {compareFileHashesHelper} compare "{provenanceMap}" "{source_root}" > "{auditDataFolder}/file-drift.json"
112
+ uv run {compareFileHashesHelper} compare "{provenanceMap}" "{source_root}" \
113
+ [--brief "{forge_data_folder}/{skill_name}/skill-brief.yaml"] > "{auditDataFolder}/file-drift.json"
112
114
  ```
113
115
 
114
116
  The saved JSON, which step 5 builds its Script/Asset findings from:
115
117
 
116
118
  ```
117
119
  {
118
- "added": ["<rel-path>", ...], // present on disk in tracked dirs, NOT in file_entries
120
+ "added": ["<rel-path>", ...], // a script or an asset to the detector, or a docs/authoritative/ file, on disk, in the brief's scope, NOT in file_entries
121
+ "added_not_checked": null, // or why no new file was looked for: "no skill brief", the brief's error, or a helper it cannot load
119
122
  "removed": ["<rel-path>", ...], // in file_entries, missing on disk
120
123
  "changed": [{"path": "...", "stored_hash": "sha256:...", "current_hash": "sha256:..."}],
121
124
  "stats": {"added": N, "removed": N, "changed": N, "unchanged": N}
122
125
  }
123
126
  ```
124
127
 
128
+ **When `added_not_checked` is not null** (`no skill brief` without `--brief`, the error of a brief the helper could not read, or `cannot load ... re-install SKF` when a helper beside it could not be loaded), no new file was looked for, so `added` is empty, while the tracked files were still compared: §5's renderer prints the reason under the Script/Asset Drift table. Record it for the envelope too: write `new_files_not_checked: {added_not_checked}` to `{run_dir}/warning.txt` with a file write (the reason can hold quotes), then, from `{project-root}`, run `uv run {emitEnvelopeHelper} record --run-dir "{run_dir}" --warning "$(cat "{run_dir}/warning.txt")"`.
129
+
125
130
  **When no candidate resolves, or the command exits non-zero** (delete the file then), skip the check with a `### Script/Asset Drift: skipped ({the reason})` note: it is supplementary to the export diff, so it never halts the audit.
126
131
 
127
132
  ### Stack-Specific Structural Diff
@@ -31,6 +31,11 @@ structural DIFF [--file-drift FILE]
31
31
  **Total Drift Items**, their sum
32
32
  **Signatures not compared:** (only when summary.signature_unverified
33
33
  is above 0)
34
+ **Off the public surface:** (only when summary.not_public is above 0:
35
+ the exports of a public-api skill the
36
+ diff left out of Added Exports, which are
37
+ not drift; the first ten of not_public[]
38
+ as `name` (`file`), then `and N more`)
34
39
  ### By Library a stack diff (--group-by source_library):
35
40
  each groups[] entry's counts
36
41
  ### Script/Asset Drift (added N, removed N, changed N)
@@ -40,7 +45,11 @@ structural DIFF [--file-drift FILE]
40
45
  given and exists; when FILE cannot be read
41
46
  or is not such a result, the heading reads
42
47
  `### Script/Asset Drift: skipped (<reason>)`
43
- and the other tables still print)
48
+ and the other tables still print). When
49
+ FILE's `added_not_checked` names a reason
50
+ (no brief, or one the comparison could not
51
+ read), a **New files not checked:** line
52
+ follows the table
44
53
  ### Provenance label differences (not drift) (N)
45
54
  Export | Baseline label | Current label
46
55
  (only when label_changes[] is non-empty)
@@ -121,6 +130,8 @@ from pathlib import Path
121
130
  ROLLUP_MIN = 10
122
131
  # Representative names a rollup row shows.
123
132
  REP_COUNT = 3
133
+ # The exports the Off the public surface note names, as update-skill's not_public warning does.
134
+ NOT_PUBLIC_NAMED = 10
124
135
  SEVERITIES = ("CRITICAL", "HIGH", "MEDIUM", "LOW")
125
136
  NONE = "(none)"
126
137
  NA = "n/a"
@@ -400,7 +411,12 @@ def _file_drift_section(data) -> str:
400
411
  text(details[0]) if len(details) == 1 else NA])
401
412
  heading = (f"### Script/Asset Drift (added {_count(stats, 'added')}, removed {_count(stats, 'removed')}, "
402
413
  f"changed {_count(stats, 'changed')})")
403
- return heading + "\n\n" + _table(("File", "Change", "Detail"), rows)
414
+ section = heading + "\n\n" + _table(("File", "Change", "Detail"), rows)
415
+ reason = data.get("added_not_checked")
416
+ if isinstance(reason, str) and reason.strip():
417
+ section += (f"\n\n**New files not checked:** {text(reason.strip())}. The tracked files were compared, but "
418
+ "no new script, asset or document was looked for, so `added` lists none.")
419
+ return section
404
420
 
405
421
 
406
422
  def render_structural(diff, file_drift=None, file_drift_skipped=None) -> str:
@@ -455,6 +471,17 @@ def render_structural(diff, file_drift=None, file_drift_skipped=None) -> str:
455
471
  if unverified:
456
472
  block += (f"\n\n**Signatures not compared:** {unverified} matched exports hold their signature in "
457
473
  "different fields on the two sides, so a change there cannot be seen.")
474
+ not_public = _count(summary, "not_public")
475
+ if not_public:
476
+ one = not_public == 1
477
+ named = [f"{export_cell(r)} ({where(r.get('file'))})"
478
+ for r in _records(diff, "not_public")[:NOT_PUBLIC_NAMED]]
479
+ more = f" and {not_public - len(named)} more" if named and not_public > len(named) else ""
480
+ listed = f": {', '.join(named)}{more}" if named else ""
481
+ block += (f"\n\n**Off the public surface:** {not_public} {'export' if one else 'exports'} the provenance "
482
+ f"map does not hold {'is' if one else 'are'} off this public-api skill's public surface "
483
+ f"(`not_public[]` in the saved diff), so {'it is' if one else 'they are'} not reported as added "
484
+ f"and {'is' if one else 'are'} excluded from Total Drift Items{listed}.")
458
485
  parts.append(block)
459
486
 
460
487
  groups = _records(diff, "groups")