bmad-module-skill-forge 1.4.1 → 1.5.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 (230) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +0 -8
  3. package/docs/_data/pinned.yaml +1 -1
  4. package/docs/_internal/STABILITY.md +1 -1
  5. package/docs/architecture.md +2 -2
  6. package/docs/examples.md +1 -1
  7. package/docs/workflows.md +5 -5
  8. package/package.json +2 -2
  9. package/src/README.md +1 -1
  10. package/src/knowledge/ccc-bridge.md +12 -12
  11. package/src/knowledge/qmd-registry.md +5 -5
  12. package/src/shared/health-check.md +5 -5
  13. package/src/shared/references/description-guard-protocol.md +100 -0
  14. package/src/shared/references/output-contract-schema.md +1 -1
  15. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -6
  16. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +9 -3
  17. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +149 -0
  18. package/src/shared/scripts/schemas/skill-brief.v1.json +22 -2
  19. package/src/shared/scripts/skf-build-change-manifest.py +420 -0
  20. package/src/shared/scripts/skf-check-workspace-drift.py +321 -0
  21. package/src/shared/scripts/skf-compare-file-hashes.py +357 -0
  22. package/src/shared/scripts/skf-description-guard.py +359 -0
  23. package/src/shared/scripts/skf-detect-language.py +2 -2
  24. package/src/shared/scripts/skf-detect-scripts-assets.py +613 -0
  25. package/src/shared/scripts/skf-detect-tools.py +97 -8
  26. package/src/shared/scripts/skf-detect-workspaces.py +1 -1
  27. package/src/shared/scripts/skf-disqualify-candidates.py +576 -0
  28. package/src/shared/scripts/skf-emit-brief-result-envelope.py +3 -3
  29. package/src/shared/scripts/skf-emit-result-envelope.py +93 -9
  30. package/src/shared/scripts/skf-enumerate-stack-skills.py +514 -0
  31. package/src/shared/scripts/skf-forge-tier-rw.py +11 -11
  32. package/src/shared/scripts/skf-hash-content.py +284 -0
  33. package/src/shared/scripts/skf-load-provenance.py +295 -0
  34. package/src/shared/scripts/skf-merge-ccc-exclusions.py +34 -4
  35. package/src/shared/scripts/skf-pair-intersect.py +250 -0
  36. package/src/shared/scripts/skf-provenance-gap-dispatch.py +433 -0
  37. package/src/shared/scripts/skf-qmd-classify-collections.py +42 -8
  38. package/src/shared/scripts/skf-recommend-scope-type.py +2 -2
  39. package/src/shared/scripts/skf-render-quick-metadata.py +2 -2
  40. package/src/shared/scripts/skf-resolve-authoritative-files.py +504 -0
  41. package/src/shared/scripts/skf-scan-manifests.py +738 -0
  42. package/src/shared/scripts/skf-scan-skill-md-structure.py +375 -0
  43. package/src/shared/scripts/skf-update-active-symlink.py +315 -0
  44. package/src/shared/scripts/skf-validate-brief-inputs.py +5 -5
  45. package/src/shared/scripts/skf-validate-brief-schema.py +348 -0
  46. package/src/shared/scripts/skf-write-skill-brief.py +61 -5
  47. package/src/skf-analyze-source/SKILL.md +69 -16
  48. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  49. package/src/skf-analyze-source/customize.toml +55 -0
  50. package/src/skf-analyze-source/{steps-c/step-01b-continue.md → references/continue.md} +12 -12
  51. package/src/skf-analyze-source/{steps-c/step-06-generate-briefs.md → references/generate-briefs.md} +29 -10
  52. package/src/skf-analyze-source/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  53. package/src/skf-analyze-source/{steps-c/step-03-identify-units.md → references/identify-units.md} +44 -14
  54. package/src/skf-analyze-source/{steps-c/step-01-init.md → references/init.md} +27 -6
  55. package/src/skf-analyze-source/{steps-c/step-04-map-and-detect.md → references/map-and-detect.md} +52 -25
  56. package/src/skf-analyze-source/{steps-c/step-05-recommend.md → references/recommend.md} +15 -8
  57. package/src/skf-analyze-source/{steps-c/step-02-scan-project.md → references/scan-project.md} +12 -10
  58. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -0
  59. package/src/skf-analyze-source/templates/analysis-report-template.md +6 -6
  60. package/src/skf-audit-skill/SKILL.md +66 -15
  61. package/src/skf-audit-skill/assets/drift-report-template.md +6 -5
  62. package/src/skf-audit-skill/customize.toml +49 -0
  63. package/src/skf-audit-skill/{steps-c/step-07-health-check.md → references/health-check.md} +5 -3
  64. package/src/skf-audit-skill/{steps-c/step-01-init.md → references/init.md} +57 -19
  65. package/src/skf-audit-skill/{steps-c/step-02-re-index.md → references/re-index.md} +11 -11
  66. package/src/skf-audit-skill/{steps-c/step-06-report.md → references/report.md} +25 -7
  67. package/src/skf-audit-skill/{steps-c/step-04-semantic-diff.md → references/semantic-diff.md} +6 -6
  68. package/src/skf-audit-skill/{steps-c/step-05-severity-classify.md → references/severity-classify.md} +5 -5
  69. package/src/skf-audit-skill/references/severity-rules.md +2 -0
  70. package/src/skf-audit-skill/{steps-c/step-03-structural-diff.md → references/structural-diff.md} +29 -17
  71. package/src/skf-brief-skill/SKILL.md +48 -18
  72. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  73. package/src/skf-brief-skill/assets/skill-brief-schema.md +32 -2
  74. package/src/skf-brief-skill/customize.toml +44 -0
  75. package/src/skf-brief-skill/{steps-c/step-02-analyze-target.md → references/analyze-target.md} +5 -5
  76. package/src/skf-brief-skill/{steps-c/step-04-confirm-brief.md → references/confirm-brief.md} +13 -10
  77. package/src/skf-brief-skill/references/draft-checkpoint.md +4 -4
  78. package/src/skf-brief-skill/{steps-c/step-01-gather-intent.md → references/gather-intent.md} +24 -24
  79. package/src/skf-brief-skill/references/headless-args.md +9 -9
  80. package/src/skf-brief-skill/references/headless-source-authority-detection.md +1 -1
  81. package/src/skf-brief-skill/{steps-c/step-06-health-check.md → references/health-check.md} +4 -2
  82. package/src/skf-brief-skill/references/portfolio-similarity-check.md +2 -2
  83. package/src/skf-brief-skill/references/qmd-collection-registration.md +1 -1
  84. package/src/skf-brief-skill/{steps-c/step-03-scope-definition.md → references/scope-definition.md} +19 -16
  85. package/src/skf-brief-skill/references/version-resolution.md +2 -2
  86. package/src/skf-brief-skill/{steps-c/step-05-write-brief.md → references/write-brief.md} +11 -10
  87. package/src/skf-create-skill/SKILL.md +25 -19
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +3 -3
  89. package/src/skf-create-skill/assets/skill-sections.md +5 -5
  90. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +11 -11
  91. package/src/skf-create-skill/customize.toml +35 -0
  92. package/src/skf-create-skill/references/authoritative-files-protocol.md +142 -0
  93. package/src/skf-create-skill/{steps-c/step-05-compile.md → references/compile.md} +16 -16
  94. package/src/skf-create-skill/{steps-c/step-03d-component-extraction.md → references/component-extraction.md} +16 -16
  95. package/src/skf-create-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +4 -4
  96. package/src/skf-create-skill/{steps-c/step-04-enrich.md → references/enrich.md} +3 -3
  97. package/src/skf-create-skill/{steps-c/step-03-extract.md → references/extract.md} +52 -120
  98. package/src/skf-create-skill/references/extraction-patterns.md +8 -8
  99. package/src/skf-create-skill/{steps-c/step-07-generate-artifacts.md → references/generate-artifacts.md} +11 -11
  100. package/src/skf-create-skill/{steps-c/step-09-health-check.md → references/health-check.md} +4 -2
  101. package/src/skf-create-skill/{steps-c/step-01-load-brief.md → references/load-brief.md} +40 -32
  102. package/src/skf-create-skill/{steps-c/step-08-report.md → references/report.md} +8 -8
  103. package/src/skf-create-skill/references/source-resolution-protocols.md +6 -6
  104. package/src/skf-create-skill/{steps-c/sub/step-02b-ccc-discover.md → references/sub/ccc-discover.md} +5 -5
  105. package/src/skf-create-skill/{steps-c/sub/step-03c-fetch-docs.md → references/sub/fetch-docs.md} +28 -12
  106. package/src/skf-create-skill/{steps-c/sub/step-03b-fetch-temporal.md → references/sub/fetch-temporal.md} +81 -50
  107. package/src/skf-create-skill/{steps-c/step-06-validate.md → references/validate.md} +46 -26
  108. package/src/skf-create-stack-skill/SKILL.md +76 -17
  109. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +102 -0
  110. package/src/skf-create-stack-skill/assets/stack-skill-template.md +1 -1
  111. package/src/skf-create-stack-skill/customize.toml +46 -0
  112. package/src/skf-create-stack-skill/{steps-c/step-06-compile-stack.md → references/compile-stack.md} +6 -5
  113. package/src/skf-create-stack-skill/references/compose-mode-rules.md +3 -1
  114. package/src/skf-create-stack-skill/{steps-c/step-05-detect-integrations.md → references/detect-integrations.md} +32 -11
  115. package/src/skf-create-stack-skill/{steps-c/step-02-detect-manifests.md → references/detect-manifests.md} +28 -28
  116. package/src/skf-create-stack-skill/{steps-c/step-07-generate-output.md → references/generate-output.md} +11 -85
  117. package/src/skf-create-stack-skill/{steps-c/step-10-health-check.md → references/health-check.md} +4 -2
  118. package/src/skf-create-stack-skill/{steps-c/step-01-init.md → references/init.md} +70 -4
  119. package/src/skf-create-stack-skill/references/integration-patterns.md +2 -0
  120. package/src/skf-create-stack-skill/references/manifest-patterns.md +2 -0
  121. package/src/skf-create-stack-skill/{steps-c/step-04-parallel-extract.md → references/parallel-extract.md} +40 -17
  122. package/src/skf-create-stack-skill/{steps-c/step-03-rank-and-confirm.md → references/rank-and-confirm.md} +5 -4
  123. package/src/skf-create-stack-skill/{steps-c/step-09-report.md → references/report.md} +3 -3
  124. package/src/skf-create-stack-skill/{steps-c/step-08-validate.md → references/validate.md} +6 -6
  125. package/src/skf-drop-skill/SKILL.md +84 -12
  126. package/src/skf-drop-skill/customize.toml +57 -0
  127. package/src/skf-drop-skill/{steps-c/step-02-execute.md → references/execute.md} +49 -34
  128. package/src/skf-drop-skill/{steps-c/step-04-health-check.md → references/health-check.md} +6 -4
  129. package/src/skf-drop-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  130. package/src/skf-drop-skill/{steps-c/step-01-select.md → references/select.md} +63 -15
  131. package/src/skf-export-skill/SKILL.md +85 -16
  132. package/src/skf-export-skill/assets/managed-section-format.md +3 -3
  133. package/src/skf-export-skill/assets/snippet-format.md +2 -2
  134. package/src/skf-export-skill/customize.toml +49 -0
  135. package/src/skf-export-skill/{steps-c/step-03-generate-snippet.md → references/generate-snippet.md} +11 -11
  136. package/src/skf-export-skill/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  137. package/src/skf-export-skill/{steps-c/step-01-load-skill.md → references/load-skill.md} +11 -49
  138. package/src/skf-export-skill/references/manifest-rebuild.md +68 -0
  139. package/src/skf-export-skill/references/multi-skill-mode.md +38 -0
  140. package/src/skf-export-skill/references/orphan-context-detection.md +75 -0
  141. package/src/skf-export-skill/references/orphan-row-detection.md +102 -0
  142. package/src/skf-export-skill/{steps-c/step-02-package.md → references/package.md} +7 -7
  143. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +74 -0
  144. package/src/skf-export-skill/{steps-c/step-06-summary.md → references/summary.md} +20 -8
  145. package/src/skf-export-skill/{steps-c/step-05-token-report.md → references/token-report.md} +5 -5
  146. package/src/skf-export-skill/{steps-c/step-04-update-context.md → references/update-context.md} +109 -113
  147. package/src/skf-quick-skill/SKILL.md +63 -123
  148. package/src/skf-quick-skill/customize.toml +44 -0
  149. package/src/skf-quick-skill/references/batch-mode.md +102 -0
  150. package/src/skf-quick-skill/{steps-c/step-04-compile.md → references/compile.md} +10 -10
  151. package/src/skf-quick-skill/{steps-c/step-02-ecosystem-check.md → references/ecosystem-check.md} +5 -5
  152. package/src/skf-quick-skill/{steps-c/step-06-finalize.md → references/finalize.md} +7 -7
  153. package/src/skf-quick-skill/{steps-c/step-07-health-check.md → references/health-check.md} +3 -3
  154. package/src/skf-quick-skill/{steps-c/step-03-quick-extract.md → references/quick-extract.md} +5 -5
  155. package/src/skf-quick-skill/references/registry-resolution.md +2 -0
  156. package/src/skf-quick-skill/{steps-c/step-01-resolve-target.md → references/resolve-target.md} +14 -10
  157. package/src/skf-quick-skill/{steps-c/step-05-write-and-validate.md → references/write-and-validate.md} +4 -4
  158. package/src/skf-refine-architecture/SKILL.md +86 -16
  159. package/src/skf-refine-architecture/customize.toml +49 -0
  160. package/src/skf-refine-architecture/{steps-c/step-05-compile.md → references/compile.md} +4 -4
  161. package/src/skf-refine-architecture/{steps-c/step-02-gap-analysis.md → references/gap-analysis.md} +5 -5
  162. package/src/skf-refine-architecture/{steps-c/step-07-health-check.md → references/health-check.md} +6 -4
  163. package/src/skf-refine-architecture/{steps-c/step-04-improvements.md → references/improvements.md} +5 -5
  164. package/src/skf-refine-architecture/references/init.md +144 -0
  165. package/src/skf-refine-architecture/{steps-c/step-03-issue-detection.md → references/issue-detection.md} +5 -5
  166. package/src/skf-refine-architecture/references/refinement-rules.md +2 -0
  167. package/src/skf-refine-architecture/{steps-c/step-06-report.md → references/report.md} +14 -5
  168. package/src/skf-rename-skill/SKILL.md +82 -12
  169. package/src/skf-rename-skill/customize.toml +52 -0
  170. package/src/skf-rename-skill/{steps-c/step-02-execute.md → references/execute.md} +94 -99
  171. package/src/skf-rename-skill/references/health-check.md +30 -0
  172. package/src/skf-rename-skill/references/rebuild-context.md +110 -0
  173. package/src/skf-rename-skill/{steps-c/step-03-report.md → references/report.md} +13 -5
  174. package/src/skf-rename-skill/{steps-c/step-01-select.md → references/select.md} +59 -20
  175. package/src/skf-setup/SKILL.md +35 -35
  176. package/src/skf-setup/customize.toml +33 -0
  177. package/src/skf-setup/{steps-c/step-03-auto-index.md → references/auto-index.md} +10 -18
  178. package/src/skf-setup/{steps-c/step-01b-ccc-index.md → references/ccc-index.md} +16 -31
  179. package/src/skf-setup/{steps-c/step-01-detect-and-tier.md → references/detect-and-tier.md} +23 -10
  180. package/src/skf-setup/{steps-c/step-05-health-check.md → references/health-check.md} +1 -1
  181. package/src/skf-setup/{steps-c/step-04-report.md → references/report.md} +21 -19
  182. package/src/skf-setup/references/tier-rules.md +1 -1
  183. package/src/skf-setup/{steps-c/step-02-write-config.md → references/write-config.md} +14 -13
  184. package/src/skf-test-skill/SKILL.md +77 -15
  185. package/src/skf-test-skill/customize.toml +54 -0
  186. package/src/skf-test-skill/{steps-c/step-04-coherence-check.md → references/coherence-check.md} +28 -71
  187. package/src/skf-test-skill/{steps-c/step-03-coverage-check.md → references/coverage-check.md} +16 -39
  188. package/src/skf-test-skill/{steps-c/step-02-detect-mode.md → references/detect-mode.md} +4 -26
  189. package/src/skf-test-skill/{steps-c/step-04b-external-validators.md → references/external-validators.md} +6 -28
  190. package/src/skf-test-skill/references/health-check.md +14 -0
  191. package/src/skf-test-skill/{steps-c/step-01-init.md → references/init.md} +21 -37
  192. package/src/skf-test-skill/references/migration-section-rules.md +4 -2
  193. package/src/skf-test-skill/{steps-c/step-06-report.md → references/report.md} +41 -51
  194. package/src/skf-test-skill/{steps-c/step-05-score.md → references/score.md} +26 -43
  195. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  196. package/src/skf-test-skill/references/source-access-protocol.md +7 -5
  197. package/src/skf-test-skill/scripts/compute-score.py +88 -17
  198. package/src/skf-test-skill/templates/test-report-template.md +13 -13
  199. package/src/skf-update-skill/SKILL.md +23 -14
  200. package/src/skf-update-skill/customize.toml +44 -0
  201. package/src/skf-update-skill/{steps-c/step-02-detect-changes.md → references/detect-changes.md} +176 -68
  202. package/src/skf-update-skill/references/health-check.md +32 -0
  203. package/src/skf-update-skill/{steps-c/step-01-init.md → references/init.md} +56 -8
  204. package/src/skf-update-skill/references/manual-section-rules.md +4 -0
  205. package/src/skf-update-skill/references/merge-conflict-rules.md +4 -0
  206. package/src/skf-update-skill/{steps-c/step-04-merge.md → references/merge.md} +8 -8
  207. package/src/skf-update-skill/{steps-c/step-03-re-extract.md → references/re-extract.md} +53 -44
  208. package/src/skf-update-skill/references/remote-source-resolution.md +5 -1
  209. package/src/skf-update-skill/references/report.md +214 -0
  210. package/src/skf-update-skill/{steps-c/step-05-validate.md → references/validate.md} +5 -5
  211. package/src/skf-update-skill/{steps-c/step-06-write.md → references/write.md} +73 -51
  212. package/src/skf-verify-stack/SKILL.md +89 -17
  213. package/src/skf-verify-stack/assets/feasibility-report-template.md +4 -4
  214. package/src/skf-verify-stack/customize.toml +50 -0
  215. package/src/skf-verify-stack/references/coverage-patterns.md +2 -2
  216. package/src/skf-verify-stack/{steps-c/step-02-coverage.md → references/coverage.md} +7 -7
  217. package/src/skf-verify-stack/{steps-c/step-07-health-check.md → references/health-check.md} +7 -5
  218. package/src/skf-verify-stack/references/init.md +166 -0
  219. package/src/skf-verify-stack/references/integration-verification-rules.md +1 -1
  220. package/src/skf-verify-stack/{steps-c/step-03-integrations.md → references/integrations.md} +12 -12
  221. package/src/skf-verify-stack/{steps-c/step-06-report.md → references/report.md} +22 -41
  222. package/src/skf-verify-stack/{steps-c/step-04-requirements.md → references/requirements.md} +7 -7
  223. package/src/skf-verify-stack/{steps-c/step-05-synthesize.md → references/synthesize.md} +8 -8
  224. package/tools/validate-docs-drift.js +1 -1
  225. package/src/skf-refine-architecture/steps-c/step-01-init.md +0 -136
  226. package/src/skf-rename-skill/steps-c/step-04-health-check.md +0 -22
  227. package/src/skf-test-skill/steps-c/step-07-health-check.md +0 -25
  228. package/src/skf-update-skill/steps-c/step-07-report.md +0 -148
  229. package/src/skf-update-skill/steps-c/step-08-health-check.md +0 -22
  230. package/src/skf-verify-stack/steps-c/step-01-init.md +0 -178
@@ -0,0 +1,433 @@
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """SKF Provenance Gap Dispatch — Major-Version Scope Reconciliation feeder.
6
+
7
+ `skf-update-skill/references/detect-changes.md §1c` performs three
8
+ deterministic operations before the LLM can prompt the user:
9
+
10
+ 1. **Locate** the latest audit drift report under
11
+ `{forge_data_folder}/{skill_name}/{baseline_version}/drift-report-*.md`
12
+ 2. **Parse** the report for an "Out-of-Scope Observations" section
13
+ (either `## Out-of-Scope Observations` at the top level or
14
+ `### Out-of-Scope New Public API` under `## Remediation Suggestions`).
15
+ Extract each candidate's `path` and `evidence`.
16
+ 3. **Reconcile** candidates against `brief.scope.amendments[]`:
17
+ - `promoted` for this path → already in scope (skip)
18
+ - `skipped` for this path → user previously declined (skip)
19
+ - `demoted-include` or `demoted-exclude` → pre-decided demotion
20
+ - no matching amendment → unresolved (caller prompts user)
21
+
22
+ The prose previously asked the LLM to do all three per run. The
23
+ markdown parsing in particular is fiddly (heading lookahead, table
24
+ vs bullet vs definition list formats); keeping it in prose lets
25
+ report-format drift produce silent skips. This helper makes
26
+ discovery and classification one bash call.
27
+
28
+ Subcommand:
29
+ dispatch --skill-name <name> --baseline-version <ver> \\
30
+ --forge-data-folder <path> --brief <brief.yaml-path>
31
+
32
+ Output JSON (stdout):
33
+
34
+ {
35
+ "status": "no-report" | "no-candidates" | "candidates-found",
36
+ "report_path": "<abs path>" | null,
37
+ "candidates_total": N,
38
+ "classified": [
39
+ {
40
+ "path": "<glob or file path>",
41
+ "evidence": "<one-liner from report>",
42
+ "status": "already-in-scope"
43
+ | "pre-decided-skipped"
44
+ | "pre-decided-demoted"
45
+ | "unresolved",
46
+ "prior_action": "promoted" | "skipped"
47
+ | "demoted-include" | "demoted-exclude"
48
+ | null
49
+ }, ...
50
+ ],
51
+ "summary": {
52
+ "pre_decided_count": N,
53
+ "unresolved_count": N
54
+ }
55
+ }
56
+
57
+ `status` field shortcuts the caller:
58
+ - `no-report` → no drift report exists; §1c skipped entirely
59
+ - `no-candidates` → report exists but Out-of-Scope section is absent/empty
60
+ - `candidates-found` → classified[] is populated; iterate unresolved entries
61
+
62
+ Exit codes:
63
+ 0 — operation succeeded (any status)
64
+ 1 — user error (paths invalid, brief unparseable)
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import argparse
70
+ import json
71
+ import re
72
+ import sys
73
+ from pathlib import Path
74
+
75
+ import yaml
76
+
77
+
78
+ # --------------------------------------------------------------------------
79
+ # Discovery: locate the latest drift report
80
+ # --------------------------------------------------------------------------
81
+
82
+
83
+ def discover_drift_report(
84
+ forge_data_folder: Path, skill_name: str, baseline_version: str
85
+ ) -> Path | None:
86
+ """Return the most recent drift-report-*.md under
87
+ {forge_data_folder}/{skill_name}/{baseline_version}/, or None.
88
+
89
+ Reports are sorted by filename DESC — the convention is timestamped
90
+ filenames so lexicographic order == chronological order.
91
+ """
92
+ search_dir = forge_data_folder / skill_name / baseline_version
93
+ if not search_dir.is_dir():
94
+ return None
95
+ candidates = sorted(
96
+ search_dir.glob("drift-report-*.md"),
97
+ key=lambda p: p.name,
98
+ reverse=True,
99
+ )
100
+ return candidates[0] if candidates else None
101
+
102
+
103
+ # --------------------------------------------------------------------------
104
+ # Parse: extract Out-of-Scope candidates from a drift report
105
+ # --------------------------------------------------------------------------
106
+
107
+
108
+ _TOP_LEVEL_HEADING = re.compile(r"^##\s+Out-of-Scope Observations\s*$")
109
+ _NESTED_HEADING = re.compile(r"^###\s+Out-of-Scope New Public API\s*$")
110
+ _REMEDIATION_HEADING = re.compile(r"^##\s+Remediation Suggestions\s*$")
111
+ _ANY_TOP_LEVEL_HEADING = re.compile(r"^##\s+\S")
112
+ _ANY_SUB_LEVEL_HEADING = re.compile(r"^###\s+\S")
113
+
114
+
115
+ def extract_out_of_scope_section(text: str) -> str | None:
116
+ """Pull the Out-of-Scope block from a drift report. Returns the block
117
+ text (without the heading), or None if no matching section exists.
118
+
119
+ Supports two report shapes:
120
+ - top-level `## Out-of-Scope Observations` heading
121
+ - `### Out-of-Scope New Public API` under `## Remediation Suggestions`
122
+ """
123
+ lines = text.splitlines()
124
+ n = len(lines)
125
+
126
+ # Pass 1: top-level heading
127
+ for i, line in enumerate(lines):
128
+ if _TOP_LEVEL_HEADING.match(line):
129
+ return _gather_until_next_heading(lines, i + 1, _ANY_TOP_LEVEL_HEADING)
130
+
131
+ # Pass 2: nested heading under Remediation Suggestions
132
+ in_remediation = False
133
+ for i, line in enumerate(lines):
134
+ if _REMEDIATION_HEADING.match(line):
135
+ in_remediation = True
136
+ continue
137
+ if in_remediation and _ANY_TOP_LEVEL_HEADING.match(line):
138
+ in_remediation = False
139
+ continue
140
+ if in_remediation and _NESTED_HEADING.match(line):
141
+ return _gather_until_next_heading(lines, i + 1, _ANY_SUB_LEVEL_HEADING)
142
+
143
+ return None
144
+
145
+
146
+ def _gather_until_next_heading(
147
+ lines: list[str], start: int, stop_pattern: re.Pattern[str]
148
+ ) -> str:
149
+ """Collect lines from `start` until the next heading matching stop_pattern."""
150
+ out: list[str] = []
151
+ for i in range(start, len(lines)):
152
+ if stop_pattern.match(lines[i]):
153
+ break
154
+ out.append(lines[i])
155
+ return "\n".join(out).strip()
156
+
157
+
158
+ # --------------------------------------------------------------------------
159
+ # Parse candidates from the section text
160
+ # --------------------------------------------------------------------------
161
+
162
+
163
+ # Bullet-list shape: "- `path/to/thing` — evidence text"
164
+ # (em-dash, en-dash, or plain hyphen as separator)
165
+ _BULLET = re.compile(
166
+ r"^\s*[-*]\s+`(?P<path>[^`]+)`\s*[—–-]\s*(?P<evidence>.+?)\s*$"
167
+ )
168
+
169
+ # Table shape: "| path | evidence |"
170
+ _TABLE_ROW = re.compile(
171
+ r"^\|\s*`?(?P<path>[^|`]+?)`?\s*\|\s*(?P<evidence>[^|]+?)\s*\|"
172
+ )
173
+ _TABLE_SEPARATOR = re.compile(r"^\|\s*[-:]+\s*\|")
174
+
175
+
176
+ def parse_candidates(section_text: str) -> list[dict]:
177
+ r"""Extract (path, evidence) candidates from an Out-of-Scope section.
178
+
179
+ Recognized formats:
180
+ - Bullet lines: ``- `path/to` — evidence``
181
+ - Markdown table: ``| path | evidence |`` (skip header/separator rows)
182
+ """
183
+ if not section_text:
184
+ return []
185
+ candidates: list[dict] = []
186
+ in_table = False
187
+ table_header_seen = False
188
+ for line in section_text.splitlines():
189
+ stripped = line.strip()
190
+ if not stripped:
191
+ in_table = False
192
+ table_header_seen = False
193
+ continue
194
+
195
+ if _TABLE_SEPARATOR.match(stripped):
196
+ in_table = True
197
+ table_header_seen = True
198
+ continue
199
+
200
+ if stripped.startswith("|"):
201
+ if not table_header_seen:
202
+ # First table-shaped line is the header — skip
203
+ table_header_seen = True
204
+ continue
205
+ m = _TABLE_ROW.match(stripped)
206
+ if m:
207
+ path = m.group("path").strip()
208
+ evidence = m.group("evidence").strip()
209
+ if path and not _looks_like_table_header(path):
210
+ candidates.append({"path": path, "evidence": evidence})
211
+ continue
212
+
213
+ m = _BULLET.match(line)
214
+ if m:
215
+ candidates.append(
216
+ {
217
+ "path": m.group("path").strip(),
218
+ "evidence": m.group("evidence").strip(),
219
+ }
220
+ )
221
+ return candidates
222
+
223
+
224
+ def _looks_like_table_header(text: str) -> bool:
225
+ """True if `text` looks like a table-header cell label (e.g. 'path',
226
+ 'evidence'). Headers slip past `_TABLE_SEPARATOR` only when the report
227
+ uses a header row without the canonical `|---|---|` separator."""
228
+ return text.lower() in {"path", "evidence", "file", "files", "candidate", "rationale"}
229
+
230
+
231
+ # --------------------------------------------------------------------------
232
+ # Reconcile candidates against brief.scope.amendments[]
233
+ # --------------------------------------------------------------------------
234
+
235
+
236
+ _ALREADY_IN_SCOPE = "already-in-scope"
237
+ _PRE_DECIDED_SKIPPED = "pre-decided-skipped"
238
+ _PRE_DECIDED_DEMOTED = "pre-decided-demoted"
239
+ _UNRESOLVED = "unresolved"
240
+
241
+
242
+ def load_brief(brief_path: Path) -> dict:
243
+ """Parse a skill-brief.yaml file. Raises ValueError on load failure."""
244
+ try:
245
+ text = brief_path.read_text(encoding="utf-8")
246
+ except OSError as exc:
247
+ raise ValueError(f"cannot read brief at {brief_path}: {exc}") from exc
248
+ try:
249
+ brief = yaml.safe_load(text)
250
+ except yaml.YAMLError as exc:
251
+ raise ValueError(f"brief at {brief_path} is not valid YAML: {exc}") from exc
252
+ if not isinstance(brief, dict):
253
+ raise ValueError(
254
+ f"brief at {brief_path} must be a YAML mapping, got "
255
+ f"{type(brief).__name__}"
256
+ )
257
+ return brief
258
+
259
+
260
+ def reconcile(candidates: list[dict], brief: dict) -> list[dict]:
261
+ """Classify each candidate against brief.scope.amendments[]."""
262
+ scope = brief.get("scope")
263
+ amendments = []
264
+ if isinstance(scope, dict):
265
+ amendments = scope.get("amendments") or []
266
+ if not isinstance(amendments, list):
267
+ amendments = []
268
+
269
+ # index amendments by path → list of actions (most recent first preserved)
270
+ by_path: dict[str, list[str]] = {}
271
+ for amend in amendments:
272
+ if not isinstance(amend, dict):
273
+ continue
274
+ path = amend.get("path")
275
+ action = amend.get("action")
276
+ if isinstance(path, str) and isinstance(action, str):
277
+ by_path.setdefault(path, []).append(action)
278
+
279
+ classified: list[dict] = []
280
+ for candidate in candidates:
281
+ path = candidate["path"]
282
+ evidence = candidate.get("evidence", "")
283
+ prior_actions = by_path.get(path, [])
284
+ status, prior_action = _classify(prior_actions)
285
+ classified.append(
286
+ {
287
+ "path": path,
288
+ "evidence": evidence,
289
+ "status": status,
290
+ "prior_action": prior_action,
291
+ }
292
+ )
293
+ return classified
294
+
295
+
296
+ def _classify(prior_actions: list[str]) -> tuple[str, str | None]:
297
+ """Pick the dominant amendment action for a single candidate path.
298
+
299
+ A path may have multiple amendments (e.g. promoted then later
300
+ demoted-exclude). Most recent action wins — the list is in
301
+ amendment-record-insertion order, so iterate from the end.
302
+ """
303
+ for action in reversed(prior_actions):
304
+ if action == "promoted":
305
+ return _ALREADY_IN_SCOPE, "promoted"
306
+ if action == "skipped":
307
+ return _PRE_DECIDED_SKIPPED, "skipped"
308
+ if action in ("demoted-include", "demoted-exclude"):
309
+ return _PRE_DECIDED_DEMOTED, action
310
+ return _UNRESOLVED, None
311
+
312
+
313
+ # --------------------------------------------------------------------------
314
+ # Dispatch: orchestrate all three steps
315
+ # --------------------------------------------------------------------------
316
+
317
+
318
+ def dispatch(
319
+ *,
320
+ forge_data_folder: Path,
321
+ skill_name: str,
322
+ baseline_version: str,
323
+ brief_path: Path,
324
+ ) -> dict:
325
+ """Run the full §1c discovery + parse + reconcile pipeline."""
326
+ report = discover_drift_report(forge_data_folder, skill_name, baseline_version)
327
+ if report is None:
328
+ return {
329
+ "status": "no-report",
330
+ "report_path": None,
331
+ "candidates_total": 0,
332
+ "classified": [],
333
+ "summary": {"pre_decided_count": 0, "unresolved_count": 0},
334
+ }
335
+
336
+ text = report.read_text(encoding="utf-8")
337
+ section = extract_out_of_scope_section(text)
338
+ if section is None:
339
+ return {
340
+ "status": "no-candidates",
341
+ "report_path": str(report),
342
+ "candidates_total": 0,
343
+ "classified": [],
344
+ "summary": {"pre_decided_count": 0, "unresolved_count": 0},
345
+ }
346
+
347
+ candidates = parse_candidates(section)
348
+ if not candidates:
349
+ return {
350
+ "status": "no-candidates",
351
+ "report_path": str(report),
352
+ "candidates_total": 0,
353
+ "classified": [],
354
+ "summary": {"pre_decided_count": 0, "unresolved_count": 0},
355
+ }
356
+
357
+ brief = load_brief(brief_path)
358
+ classified = reconcile(candidates, brief)
359
+
360
+ pre_decided = sum(1 for c in classified if c["status"] != _UNRESOLVED)
361
+ unresolved = sum(1 for c in classified if c["status"] == _UNRESOLVED)
362
+
363
+ return {
364
+ "status": "candidates-found",
365
+ "report_path": str(report),
366
+ "candidates_total": len(classified),
367
+ "classified": classified,
368
+ "summary": {
369
+ "pre_decided_count": pre_decided,
370
+ "unresolved_count": unresolved,
371
+ },
372
+ }
373
+
374
+
375
+ # --------------------------------------------------------------------------
376
+ # CLI
377
+ # --------------------------------------------------------------------------
378
+
379
+
380
+ def _cmd_dispatch(args: argparse.Namespace) -> int:
381
+ forge = Path(args.forge_data_folder)
382
+ if not forge.is_dir():
383
+ print(
384
+ f"error: forge-data-folder not a directory: {forge}", file=sys.stderr
385
+ )
386
+ return 1
387
+ brief_path = Path(args.brief)
388
+ if not brief_path.is_file():
389
+ print(f"error: brief not found: {brief_path}", file=sys.stderr)
390
+ return 1
391
+ try:
392
+ result = dispatch(
393
+ forge_data_folder=forge,
394
+ skill_name=args.skill_name,
395
+ baseline_version=args.baseline_version,
396
+ brief_path=brief_path,
397
+ )
398
+ except ValueError as exc:
399
+ print(f"error: {exc}", file=sys.stderr)
400
+ return 1
401
+ json.dump(result, sys.stdout, indent=2)
402
+ sys.stdout.write("\n")
403
+ return 0
404
+
405
+
406
+ def _build_parser() -> argparse.ArgumentParser:
407
+ parser = argparse.ArgumentParser(
408
+ prog="skf-provenance-gap-dispatch",
409
+ description=(
410
+ "Locate the latest drift report, extract Out-of-Scope candidates, "
411
+ "and classify them against brief.scope.amendments[]."
412
+ ),
413
+ )
414
+ sub = parser.add_subparsers(dest="cmd", required=True)
415
+
416
+ p = sub.add_parser("dispatch", help="run the full §1c pipeline")
417
+ p.add_argument("--skill-name", required=True)
418
+ p.add_argument("--baseline-version", required=True)
419
+ p.add_argument("--forge-data-folder", required=True)
420
+ p.add_argument("--brief", required=True, help="path to skill-brief.yaml")
421
+ p.set_defaults(func=_cmd_dispatch)
422
+
423
+ return parser
424
+
425
+
426
+ def main(argv: list[str] | None = None) -> int:
427
+ parser = _build_parser()
428
+ args = parser.parse_args(argv)
429
+ return args.func(args)
430
+
431
+
432
+ if __name__ == "__main__":
433
+ raise SystemExit(main())
@@ -4,20 +4,20 @@
4
4
  # ///
5
5
  """SKF QMD Classify Collections — Set arithmetic over QMD collection names.
6
6
 
7
- Replaces the prose-driven classification logic in `src/skf-setup/steps-c/
8
- step-03-auto-index.md` §3 with one Python invocation. Compares the live
7
+ Replaces the prose-driven classification logic in `src/skf-setup/references/
8
+ auto-index.md` §3 with one Python invocation. Compares the live
9
9
  QMD collections (from `qmd collection list`) against the forge registry
10
10
  (`qmd_collections` array in forge-tier.yaml) and classifies each name as
11
11
  Healthy / Orphaned / Stale, applying the forge-namespace suffix filter
12
12
  added in PR #244 to silently exclude collections owned by unrelated
13
13
  tools sharing the QMD daemon.
14
14
 
15
- Classification rules (per step-03 §3):
15
+ Classification rules (per step 3 §3):
16
16
 
17
17
  Healthy — name in {forge-suffix-matched live} AND in registry.
18
18
  No action needed.
19
19
  Orphaned — name in {forge-suffix-matched live} but NOT in registry.
20
- Flagged for user-prompted removal in step-03 §4.
20
+ Flagged for user-prompted removal in step 3 §4.
21
21
  Stale — name in registry but NOT in {all live}. Registry entry
22
22
  should be removed.
23
23
  Foreign — name in live but does NOT match a forge suffix. Silently
@@ -175,6 +175,7 @@ def classify(live: list[str], registry: list[str]) -> dict:
175
175
  stale = sorted(registry_set - all_live_set)
176
176
 
177
177
  return {
178
+ "live_names": sorted(all_live_set),
178
179
  "healthy": healthy,
179
180
  "orphaned": orphaned,
180
181
  "stale": stale,
@@ -183,6 +184,32 @@ def classify(live: list[str], registry: list[str]) -> dict:
183
184
  }
184
185
 
185
186
 
187
+ def fetch_live_names_from_qmd() -> tuple[list[str], str | None]:
188
+ """Invoke `qmd collection list` and return (names, error).
189
+
190
+ Owns the CLI parsing so callers (and prompts) don't reinvent it.
191
+ Empty stdout / non-zero exit → ([], error_message). The classifier's
192
+ same-process invocation here is the single source of truth for what
193
+ counts as a "live collection name".
194
+ """
195
+ import subprocess
196
+ try:
197
+ result = subprocess.run(
198
+ ["qmd", "collection", "list"],
199
+ capture_output=True,
200
+ text=True,
201
+ timeout=30,
202
+ check=False,
203
+ )
204
+ except (FileNotFoundError, subprocess.TimeoutExpired) as e:
205
+ return [], f"qmd collection list failed: {e}"
206
+ if result.returncode != 0:
207
+ return [], f"qmd collection list exited {result.returncode}: {result.stderr.strip() or '<no stderr>'}"
208
+ # Each non-empty line is a collection name (qmd's stable contract).
209
+ names = [line.strip() for line in result.stdout.splitlines() if line.strip()]
210
+ return names, None
211
+
212
+
186
213
  def main() -> None:
187
214
  parser = argparse.ArgumentParser(
188
215
  description="Classify QMD collections vs forge registry.",
@@ -190,9 +217,10 @@ def main() -> None:
190
217
  )
191
218
  parser.add_argument(
192
219
  "--live-names",
193
- default="",
194
- help="Comma-separated list of collection names currently in QMD "
195
- "(from `qmd collection list`). Empty string → no live collections.",
220
+ default=None,
221
+ help="Comma-separated list of collection names currently in QMD. "
222
+ "If omitted, the script invokes `qmd collection list` itself. "
223
+ "Empty string → no live collections.",
196
224
  )
197
225
  parser.add_argument(
198
226
  "--registry-from-yaml",
@@ -203,7 +231,13 @@ def main() -> None:
203
231
  )
204
232
  args = parser.parse_args()
205
233
 
206
- live = parse_live_names(args.live_names)
234
+ if args.live_names is None:
235
+ names, error = fetch_live_names_from_qmd()
236
+ if error is not None:
237
+ _die(2, error)
238
+ live = names
239
+ else:
240
+ live = parse_live_names(args.live_names)
207
241
  registry = load_registry_names(args.registry_from_yaml)
208
242
  _ok(classify(live, registry))
209
243
 
@@ -5,7 +5,7 @@
5
5
  """SKF Recommend Scope Type — deterministic 5-rule heuristic ladder.
6
6
 
7
7
  Single source of truth for the scope-type recommendation logic that
8
- skf-brief-skill step-03 §2c applies. Both the interactive recommendation
8
+ skf-brief-skill step 3 §2c applies. Both the interactive recommendation
9
9
  and the headless auto-selection paths invoke this script with the same
10
10
  inputs to eliminate the drift seam between them.
11
11
 
@@ -36,7 +36,7 @@ CLI:
36
36
 
37
37
  Input (JSON object on stdin or via --json):
38
38
  intent — string (combined intent + scope_hint), default ""
39
- module_count — integer (top-level modules from step-02), default 0
39
+ module_count — integer (top-level modules from step 2), default 0
40
40
  export_count — integer (named exports from manifest), default 0
41
41
  tree — list of repo-relative file paths, default []
42
42
  entry_files — optional [{path, content}], default null
@@ -8,9 +8,9 @@ skill-template.md schema with quick-skill-specific population rules.
8
8
  Pure renderer — no I/O beyond stdin/stdout. Reads a JSON payload of
9
9
  extracted state on stdin and emits the corresponding metadata.json
10
10
  on stdout. Replaces the hand-assembly the LLM previously did in
11
- skf-quick-skill step-04 §4.
11
+ skf-quick-skill step 4 §4.
12
12
 
13
- Constants vs. input-derived split mirrors step-04's documentation:
13
+ Constants vs. input-derived split mirrors step 4's documentation:
14
14
 
15
15
  Constants (always literal):
16
16
  skill_type "single"