bmad-module-skill-forge 1.9.0 → 2.0.1

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 (281) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.nvmrc +1 -1
  3. package/README.md +10 -5
  4. package/docs/_data/pinned.yaml +2 -2
  5. package/docs/agents.md +11 -2
  6. package/docs/architecture.md +5 -4
  7. package/docs/bmad-synergy.md +30 -7
  8. package/docs/campaign.md +172 -0
  9. package/docs/examples.md +7 -3
  10. package/docs/forge-auto.md +90 -0
  11. package/docs/getting-started.md +9 -0
  12. package/docs/how-it-works.md +6 -4
  13. package/docs/index.md +4 -3
  14. package/docs/skill-model.md +1 -1
  15. package/docs/troubleshooting.md +17 -1
  16. package/docs/verifying-a-skill.md +2 -2
  17. package/docs/why-skf.md +1 -1
  18. package/docs/workflows.md +64 -24
  19. package/package.json +2 -2
  20. package/src/knowledge/skill-lifecycle.md +1 -1
  21. package/src/module-help.csv +4 -3
  22. package/src/shared/data/language-corpora.json +32 -0
  23. package/src/shared/references/pipeline-contracts.md +6 -3
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-comention-pairs.py +350 -0
  26. package/src/shared/scripts/skf-count-tokens.py +222 -0
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  28. package/src/shared/scripts/skf-detect-docs.py +240 -4
  29. package/src/shared/scripts/skf-detect-language.py +82 -3
  30. package/src/shared/scripts/skf-detect-tools.py +105 -2
  31. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  32. package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
  33. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  34. package/src/shared/scripts/skf-find-cycles.py +246 -0
  35. package/src/shared/scripts/skf-hash-content.py +487 -2
  36. package/src/shared/scripts/skf-language-corpora.py +100 -0
  37. package/src/shared/scripts/skf-manifest-ops.py +67 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  39. package/src/shared/scripts/skf-preapply.py +1 -1
  40. package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
  41. package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
  42. package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
  43. package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
  44. package/src/shared/scripts/skf-shape-detect.py +576 -25
  45. package/src/shared/scripts/skf-shard-body.py +461 -0
  46. package/src/shared/scripts/skf-skill-inventory.py +139 -3
  47. package/src/shared/scripts/skf-structural-diff.py +307 -67
  48. package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
  49. package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
  50. package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
  51. package/src/shared/scripts/skf-validate-output.py +443 -10
  52. package/src/shared/scripts/skf-validate-pins.py +3 -3
  53. package/src/shared/scripts/skf-verify-no-trace.py +214 -0
  54. package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
  55. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  56. package/src/skf-analyze-source/SKILL.md +17 -32
  57. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  58. package/src/skf-analyze-source/customize.toml +2 -0
  59. package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
  60. package/src/skf-analyze-source/references/continue.md +8 -13
  61. package/src/skf-analyze-source/references/generate-briefs.md +28 -12
  62. package/src/skf-analyze-source/references/headless-contract.md +33 -0
  63. package/src/skf-analyze-source/references/health-check.md +1 -2
  64. package/src/skf-analyze-source/references/identify-units.md +25 -36
  65. package/src/skf-analyze-source/references/init.md +12 -25
  66. package/src/skf-analyze-source/references/map-and-detect.md +8 -20
  67. package/src/skf-analyze-source/references/recommend.md +4 -14
  68. package/src/skf-analyze-source/references/scan-project.md +3 -11
  69. package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
  70. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  71. package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
  72. package/src/skf-audit-skill/SKILL.md +9 -6
  73. package/src/skf-audit-skill/customize.toml +2 -0
  74. package/src/skf-audit-skill/references/init.md +58 -32
  75. package/src/skf-audit-skill/references/re-index.md +15 -34
  76. package/src/skf-audit-skill/references/report.md +11 -58
  77. package/src/skf-audit-skill/references/semantic-diff.md +4 -22
  78. package/src/skf-audit-skill/references/severity-classify.md +53 -87
  79. package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
  80. package/src/skf-audit-skill/references/structural-diff.md +43 -68
  81. package/src/skf-brief-skill/SKILL.md +7 -36
  82. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  83. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  84. package/src/skf-brief-skill/customize.toml +14 -0
  85. package/src/skf-brief-skill/references/analyze-target.md +13 -18
  86. package/src/skf-brief-skill/references/confirm-brief.md +2 -15
  87. package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
  88. package/src/skf-brief-skill/references/gather-intent.md +27 -24
  89. package/src/skf-brief-skill/references/headless-args.md +1 -1
  90. package/src/skf-brief-skill/references/health-check.md +1 -22
  91. package/src/skf-brief-skill/references/invocation-contract.md +37 -0
  92. package/src/skf-brief-skill/references/scope-definition.md +6 -15
  93. package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
  94. package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
  95. package/src/skf-brief-skill/references/version-resolution.md +1 -1
  96. package/src/skf-brief-skill/references/write-brief.md +19 -13
  97. package/src/skf-campaign/SKILL.md +121 -0
  98. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  99. package/src/skf-campaign/customize.toml +75 -0
  100. package/src/skf-campaign/manifest.yaml +15 -0
  101. package/src/skf-campaign/references/campaign-contracts.md +67 -0
  102. package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
  103. package/src/skf-campaign/references/health-check.md +35 -0
  104. package/src/skf-campaign/references/step-01-setup.md +121 -0
  105. package/src/skf-campaign/references/step-02-strategy.md +102 -0
  106. package/src/skf-campaign/references/step-03-pins.md +56 -0
  107. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  108. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  109. package/src/skf-campaign/references/step-06-batch.md +88 -0
  110. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  111. package/src/skf-campaign/references/step-08-verify.md +75 -0
  112. package/src/skf-campaign/references/step-09-refine.md +83 -0
  113. package/src/skf-campaign/references/step-10-export.md +106 -0
  114. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  115. package/src/skf-campaign/references/step-resume.md +115 -0
  116. package/src/skf-campaign/scripts/.gitkeep +0 -0
  117. package/src/skf-campaign/scripts/campaign-deps.py +244 -0
  118. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  119. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  120. package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
  121. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  122. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  123. package/src/skf-campaign/scripts/campaign-status.py +162 -0
  124. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  125. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  126. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  127. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  128. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  129. package/src/skf-create-skill/SKILL.md +15 -4
  130. package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
  131. package/src/skf-create-skill/assets/skill-sections.md +9 -100
  132. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
  133. package/src/skf-create-skill/customize.toml +13 -0
  134. package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
  135. package/src/skf-create-skill/references/compile.md +37 -47
  136. package/src/skf-create-skill/references/component-extraction.md +8 -35
  137. package/src/skf-create-skill/references/ecosystem-check.md +9 -25
  138. package/src/skf-create-skill/references/enrich.md +4 -17
  139. package/src/skf-create-skill/references/extract.md +20 -27
  140. package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
  141. package/src/skf-create-skill/references/extraction-patterns.md +10 -8
  142. package/src/skf-create-skill/references/generate-artifacts.md +20 -25
  143. package/src/skf-create-skill/references/health-check.md +2 -2
  144. package/src/skf-create-skill/references/load-brief.md +16 -18
  145. package/src/skf-create-skill/references/report.md +27 -14
  146. package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
  147. package/src/skf-create-skill/references/step-auto-shard.md +52 -25
  148. package/src/skf-create-skill/references/step-doc-rot.md +33 -15
  149. package/src/skf-create-skill/references/step-doc-sources.md +13 -4
  150. package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
  151. package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
  152. package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
  153. package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
  154. package/src/skf-create-skill/references/validate.md +69 -29
  155. package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
  156. package/src/skf-create-stack-skill/SKILL.md +15 -9
  157. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
  158. package/src/skf-create-stack-skill/customize.toml +10 -0
  159. package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
  160. package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
  161. package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
  162. package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
  163. package/src/skf-create-stack-skill/references/generate-output.md +24 -75
  164. package/src/skf-create-stack-skill/references/init.md +9 -81
  165. package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
  166. package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
  167. package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
  168. package/src/skf-create-stack-skill/references/report.md +27 -62
  169. package/src/skf-create-stack-skill/references/validate.md +35 -31
  170. package/src/skf-drop-skill/SKILL.md +20 -23
  171. package/src/skf-drop-skill/customize.toml +10 -0
  172. package/src/skf-drop-skill/references/execute.md +67 -36
  173. package/src/skf-drop-skill/references/headless-contract.md +18 -0
  174. package/src/skf-drop-skill/references/health-check.md +1 -13
  175. package/src/skf-drop-skill/references/report.md +44 -10
  176. package/src/skf-drop-skill/references/select.md +93 -48
  177. package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
  178. package/src/skf-export-skill/SKILL.md +17 -14
  179. package/src/skf-export-skill/assets/managed-section-format.md +2 -5
  180. package/src/skf-export-skill/customize.toml +10 -6
  181. package/src/skf-export-skill/references/generate-snippet.md +21 -35
  182. package/src/skf-export-skill/references/load-skill.md +44 -42
  183. package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
  184. package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
  185. package/src/skf-export-skill/references/package.md +27 -33
  186. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
  187. package/src/skf-export-skill/references/result-envelope.md +28 -0
  188. package/src/skf-export-skill/references/summary.md +11 -5
  189. package/src/skf-export-skill/references/token-report.md +42 -24
  190. package/src/skf-export-skill/references/update-context.md +32 -33
  191. package/src/skf-forger/SKILL.md +32 -39
  192. package/src/skf-forger/references/pipeline-mode.md +34 -0
  193. package/src/skf-forger/scripts/parse-pipeline.py +270 -0
  194. package/src/skf-quick-skill/SKILL.md +10 -60
  195. package/src/skf-quick-skill/customize.toml +9 -0
  196. package/src/skf-quick-skill/references/batch-mode.md +6 -4
  197. package/src/skf-quick-skill/references/compile.md +15 -52
  198. package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
  199. package/src/skf-quick-skill/references/finalize.md +12 -6
  200. package/src/skf-quick-skill/references/halt-contract.md +54 -0
  201. package/src/skf-quick-skill/references/health-check.md +3 -4
  202. package/src/skf-quick-skill/references/quick-extract.md +7 -18
  203. package/src/skf-quick-skill/references/registry-resolution.md +1 -23
  204. package/src/skf-quick-skill/references/resolve-target.md +27 -34
  205. package/src/skf-quick-skill/references/write-and-validate.md +10 -25
  206. package/src/skf-refine-architecture/SKILL.md +16 -8
  207. package/src/skf-refine-architecture/customize.toml +9 -0
  208. package/src/skf-refine-architecture/references/compile.md +10 -8
  209. package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
  210. package/src/skf-refine-architecture/references/health-check.md +1 -1
  211. package/src/skf-refine-architecture/references/improvements.md +9 -25
  212. package/src/skf-refine-architecture/references/init.md +9 -16
  213. package/src/skf-refine-architecture/references/issue-detection.md +10 -25
  214. package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
  215. package/src/skf-refine-architecture/references/report.md +14 -11
  216. package/src/skf-rename-skill/SKILL.md +11 -21
  217. package/src/skf-rename-skill/customize.toml +10 -0
  218. package/src/skf-rename-skill/references/execute.md +112 -82
  219. package/src/skf-rename-skill/references/exit-codes.md +12 -0
  220. package/src/skf-rename-skill/references/report.md +16 -8
  221. package/src/skf-rename-skill/references/select.md +69 -42
  222. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
  223. package/src/skf-setup/SKILL.md +21 -13
  224. package/src/skf-setup/customize.toml +10 -0
  225. package/src/skf-setup/references/auto-index.md +11 -10
  226. package/src/skf-setup/references/ccc-index.md +14 -15
  227. package/src/skf-setup/references/detect-and-tier.md +10 -8
  228. package/src/skf-setup/references/report.md +26 -32
  229. package/src/skf-setup/references/tier-rules.md +2 -32
  230. package/src/skf-setup/references/write-config.md +9 -10
  231. package/src/skf-test-skill/SKILL.md +10 -8
  232. package/src/skf-test-skill/customize.toml +2 -0
  233. package/src/skf-test-skill/references/coherence-check.md +19 -31
  234. package/src/skf-test-skill/references/coverage-check.md +111 -67
  235. package/src/skf-test-skill/references/detect-mode.md +10 -41
  236. package/src/skf-test-skill/references/external-validators.md +11 -19
  237. package/src/skf-test-skill/references/init.md +39 -21
  238. package/src/skf-test-skill/references/migration-section-rules.md +4 -4
  239. package/src/skf-test-skill/references/report.md +60 -40
  240. package/src/skf-test-skill/references/score.md +32 -64
  241. package/src/skf-test-skill/references/scoring-rules.md +6 -62
  242. package/src/skf-test-skill/references/source-access-protocol.md +15 -5
  243. package/src/skf-test-skill/references/step-hard-gate.md +1 -3
  244. package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
  245. package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
  246. package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
  247. package/src/skf-test-skill/scripts/compute-score.py +135 -4
  248. package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
  249. package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
  250. package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
  251. package/src/skf-update-skill/SKILL.md +17 -5
  252. package/src/skf-update-skill/customize.toml +11 -6
  253. package/src/skf-update-skill/references/detect-changes.md +48 -36
  254. package/src/skf-update-skill/references/health-check.md +5 -11
  255. package/src/skf-update-skill/references/init.md +40 -32
  256. package/src/skf-update-skill/references/manual-section-rules.md +1 -2
  257. package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
  258. package/src/skf-update-skill/references/merge.md +13 -57
  259. package/src/skf-update-skill/references/re-extract.md +27 -25
  260. package/src/skf-update-skill/references/report.md +14 -7
  261. package/src/skf-update-skill/references/validate.md +32 -30
  262. package/src/skf-update-skill/references/write.md +86 -51
  263. package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
  264. package/src/skf-verify-stack/SKILL.md +11 -21
  265. package/src/skf-verify-stack/customize.toml +12 -3
  266. package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
  267. package/src/skf-verify-stack/references/coverage.md +21 -10
  268. package/src/skf-verify-stack/references/exit-codes.md +14 -0
  269. package/src/skf-verify-stack/references/health-check.md +1 -1
  270. package/src/skf-verify-stack/references/init.md +20 -25
  271. package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
  272. package/src/skf-verify-stack/references/integrations.md +44 -49
  273. package/src/skf-verify-stack/references/report.md +28 -34
  274. package/src/skf-verify-stack/references/requirements.md +3 -3
  275. package/src/skf-verify-stack/references/synthesize.md +36 -39
  276. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
  277. package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
  278. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
  279. package/tools/cli/lib/ui.js +3 -2
  280. package/docs/deepwiki.md +0 -89
  281. package/src/skf-rename-skill/references/rebuild-context.md +0 -110
@@ -19,6 +19,17 @@ Two patterns recur across the update-skill workflow's stage prose:
19
19
  iterated, plus a stat-and-compare. NEW_FILE detection is intentionally
20
20
  out of scope here — that lives in `skf-detect-scripts-assets.py`.
21
21
 
22
+ 3. **[MANUAL]-section integrity** — the update-skill workflow's headline
23
+ Workflow Rule is "[MANUAL] sections survive regeneration with zero
24
+ content loss." init.md §5 captures a pre-write inventory of every
25
+ `<!-- [MANUAL:name] --> … <!-- [/MANUAL:name] -->` block; write.md §1
26
+ (HALT gate) and validate.md Check B verify the post-merge SKILL.md
27
+ against it. Doing that by an LLM marker-count + eyeball can silently
28
+ pass a block whose interior was truncated (marker count unchanged).
29
+ The `manual-inventory` / `manual-verify` subcommands extract each block
30
+ by marker regex, hash its byte-exact interior, and emit an exact
31
+ {preserved, modified, missing, moved, ok} verdict.
32
+
22
33
  Subcommands:
23
34
  hash <path> [--include-path]
24
35
  Emit JSON {"content_hash": "sha256:...", "size_bytes": N, "line_count": L}
@@ -40,6 +51,69 @@ Subcommands:
40
51
  The provenance file must contain `file_entries` as a top-level array
41
52
  OR be the array itself (handles both schemas).
42
53
 
54
+ manual-inventory <skill-md>
55
+ Extract every `<!-- [MANUAL:name] --> … <!-- [/MANUAL:name] -->` block
56
+ and emit
57
+ {
58
+ "blocks": [
59
+ {"name": "...", "content_hash": "sha256:...",
60
+ "byte_offset": N, "parent_heading": "..."|null}, ...
61
+ ],
62
+ "count": N
63
+ }
64
+ `content_hash` is the SHA-256 of the block's byte-exact interior (the
65
+ bytes between the open marker's `-->` and the close marker's `<!--`,
66
+ including surrounding newlines — zero normalization). `byte_offset` is
67
+ the byte position of the opening marker; `parent_heading` is the text
68
+ of the nearest preceding Markdown heading (or null). Blocks are sorted
69
+ by `byte_offset`. Persist this JSON as the captured pre-write inventory.
70
+
71
+ manual-verify --inventory <inventory.json> <skill-md>
72
+ Re-extract the blocks from the (post-merge) on-disk file and classify
73
+ each inventory block by name + hash + parent heading:
74
+ - name present, hash matches, same parent heading → preserved
75
+ - name present, hash matches, parent heading changed → moved
76
+ - name present, hash differs → modified
77
+ - name absent → missing
78
+ Emits {"preserved":[names], "modified":[names], "missing":[names],
79
+ "moved":[names], "ok": bool} where `ok = (modified empty AND missing
80
+ empty)`. A `moved` block does NOT fail the verdict — a byte-identical
81
+ block relocated with its logical parent section is a clean outcome.
82
+ The inventory may be the object emitted by `manual-inventory` OR a bare
83
+ `blocks[]` array (handles both shapes).
84
+
85
+ compare-constituent-hashes <provenance-map.json> [--skills-root <root>]
86
+ Constituent-drift detection for compose-mode stack skills. Replaces the
87
+ per-constituent read + SHA-256 + compare loop at
88
+ `src/skf-audit-skill/references/init.md` (Stack Skill Detection). Reads
89
+ the `constituents[]` array from a compose-mode provenance map (each
90
+ entry carrying `skill_name`, `skill_path`, and the compile-time
91
+ `metadata_hash`), reads each constituent's live
92
+ `{skill_path}/active/{skill_name}/metadata.json`, recomputes its
93
+ SHA-256 with the same `sha256:` prefix convention the writer used
94
+ (`skf-enumerate-stack-skills._sha256_of_bytes`), and buckets each
95
+ constituent:
96
+ {
97
+ "drifted": [{"skill_name","skill_path","stored_hash","current_hash"}],
98
+ "fresh": [{"skill_name"}],
99
+ "missing": [{"skill_name","skill_path","stored_hash","reason"}],
100
+ "skipped_null_hash": [{"skill_name"}],
101
+ "stats": {"total":N,"drifted":N,"fresh":N,"missing":N,
102
+ "skipped_null_hash":N}
103
+ }
104
+ `drifted` is the audit's HIGH-severity constituent-drift signal (the
105
+ live metadata.json differs from the compile-time snapshot). `missing`
106
+ carries a `reason` (`"metadata-not-found"` when the file is absent on
107
+ disk, `"incomplete-record"` when the provenance entry lacks
108
+ skill_name/skill_path). `skipped_null_hash` holds constituents whose
109
+ stored `metadata_hash` was null (recorded from a references/ cascade —
110
+ no baseline to compare against, so never reported as drift). Relative
111
+ `skill_path` values resolve against `--skills-root` (default: current
112
+ directory); absolute `skill_path` values are used as-is. A provenance
113
+ map with no `constituents` field (a single skill) yields all-empty
114
+ buckets. Stored hashes are prefix-normalized before comparison so a
115
+ bare-hex writer form still matches. Buckets are sorted by skill_name.
116
+
43
117
  Hash format: `sha256:` prefix on a hex digest. Matches the convention used
44
118
  by skf-detect-scripts-assets.py and the existing prose ("SHA-256 content
45
119
  hash" — agnostic about prefix, but the prefix is the SKF convention to make
@@ -50,10 +124,14 @@ CLI examples:
50
124
  uv run skf-hash-content.py hash docs/AGENTS.md --include-path
51
125
  uv run skf-hash-content.py compare /path/to/source \\
52
126
  --provenance-map /path/to/provenance-map.json
127
+ uv run skf-hash-content.py manual-inventory SKILL.md
128
+ uv run skf-hash-content.py manual-verify --inventory inventory.json SKILL.md
53
129
 
54
130
  Exit codes:
55
- 0 — operation succeeded (including: file in compare missing on disk;
56
- its classification is DELETED_FILE, not an error)
131
+ 0 — operation succeeded (including: file in compare missing on disk, its
132
+ classification is DELETED_FILE; and manual-verify completing with
133
+ ok=false — an integrity failure is a *result*, read from the `ok`
134
+ field, not an operation error)
57
135
  1 — user error (bad path, malformed JSON, missing required field)
58
136
  """
59
137
 
@@ -62,6 +140,7 @@ from __future__ import annotations
62
140
  import argparse
63
141
  import hashlib
64
142
  import json
143
+ import re
65
144
  import sys
66
145
  from pathlib import Path
67
146
 
@@ -104,6 +183,21 @@ def hash_record(path: Path, *, include_path: bool = False) -> dict:
104
183
  return rec
105
184
 
106
185
 
186
+ _HASH_PREFIX_RE = re.compile(r"^[a-z0-9]+:")
187
+
188
+
189
+ def normalize_hash(value: str | None) -> str | None:
190
+ """Strip a leading algorithm-name prefix (`sha256:`, `sha1:`, …) from a
191
+ stored hash so bare-hex and prefixed forms compare equal.
192
+
193
+ Mirrors skf-compare-file-hashes.normalize_hash. Returns None for a
194
+ non-string input; idempotent on bare hex.
195
+ """
196
+ if not isinstance(value, str):
197
+ return None
198
+ return _HASH_PREFIX_RE.sub("", value, count=1)
199
+
200
+
107
201
  # --------------------------------------------------------------------------
108
202
  # Provenance comparison
109
203
  # --------------------------------------------------------------------------
@@ -208,6 +302,314 @@ def compare(source_root: Path, provenance_path: Path) -> dict:
208
302
  }
209
303
 
210
304
 
305
+ # --------------------------------------------------------------------------
306
+ # [MANUAL]-section integrity
307
+ # --------------------------------------------------------------------------
308
+
309
+
310
+ # Open marker: <!-- [MANUAL:name] --> (whitespace-tolerant)
311
+ # Close marker: <!-- [/MANUAL:name] --> (note the leading slash)
312
+ # The open pattern cannot match a close marker: after `<!--` it requires
313
+ # `[MANUAL` with no slash, whereas a close has `[/MANUAL`.
314
+ _OPEN_RE = re.compile(rb"<!--\s*\[MANUAL:([^\]]+)\]\s*-->")
315
+ _CLOSE_RE = re.compile(rb"<!--\s*\[/MANUAL:([^\]]+)\]\s*-->")
316
+ _HEADING_RE = re.compile(rb"^(#{1,6})[ \t]+(.*)$")
317
+
318
+
319
+ def sha256_of_bytes(data: bytes) -> str:
320
+ """SHA-256 of an in-memory byte string, with sha256: prefix."""
321
+ return "sha256:" + hashlib.sha256(data).hexdigest()
322
+
323
+
324
+ def _parent_heading(data: bytes, offset: int) -> str | None:
325
+ """Text of the nearest Markdown heading preceding `offset` (or None).
326
+
327
+ Returns the heading text with leading `#` markers and surrounding
328
+ whitespace stripped — e.g. b"## Usage Patterns\\n" -> "Usage Patterns".
329
+ """
330
+ heading: str | None = None
331
+ for line in data[:offset].split(b"\n"):
332
+ m = _HEADING_RE.match(line)
333
+ if m:
334
+ heading = m.group(2).strip().decode("utf-8", errors="replace")
335
+ return heading
336
+
337
+
338
+ def find_manual_blocks(data: bytes) -> list[dict]:
339
+ """Extract every well-formed [MANUAL] block from `data`.
340
+
341
+ A block is an opening `<!-- [MANUAL:name] -->` paired with the *earliest*
342
+ following `<!-- [/MANUAL:name] -->` of the same (whitespace-stripped)
343
+ name. `content_hash` covers the byte-exact interior — the bytes between
344
+ the open marker's `-->` and the close marker's `<!--`, with zero
345
+ normalization, so any interior edit (including an equal-marker-count
346
+ truncation) changes the hash.
347
+
348
+ An opening marker with no matching close is malformed and skipped.
349
+ Result is sorted by `byte_offset` for deterministic ordering.
350
+ """
351
+ closes = [
352
+ (m.start(), m.group(1).decode("utf-8", errors="replace").strip())
353
+ for m in _CLOSE_RE.finditer(data)
354
+ ]
355
+ blocks: list[dict] = []
356
+ for om in _OPEN_RE.finditer(data):
357
+ name = om.group(1).decode("utf-8", errors="replace").strip()
358
+ open_end = om.end()
359
+ close_start = None
360
+ for c_start, c_name in closes:
361
+ if c_start >= open_end and c_name == name:
362
+ if close_start is None or c_start < close_start:
363
+ close_start = c_start
364
+ if close_start is None:
365
+ continue # unclosed opening marker — malformed, skip
366
+ content = data[open_end:close_start]
367
+ blocks.append({
368
+ "name": name,
369
+ "content_hash": sha256_of_bytes(content),
370
+ "byte_offset": om.start(),
371
+ "parent_heading": _parent_heading(data, om.start()),
372
+ })
373
+ blocks.sort(key=lambda b: b["byte_offset"])
374
+ return blocks
375
+
376
+
377
+ def classify_manual_blocks(inv_blocks: list[dict], cur_blocks: list[dict]) -> dict:
378
+ """Classify each inventory block against the current on-disk blocks.
379
+
380
+ Buckets are mutually exclusive per inventory block:
381
+ - missing: name not present in the current file
382
+ - modified: name present but no current block's hash matches
383
+ - preserved: a current block matches name + hash + parent_heading
384
+ - moved: a current block matches name + hash but parent_heading
385
+ changed (relocated with its logical section — clean)
386
+
387
+ `ok = (modified empty AND missing empty)`; `moved` does not fail it.
388
+ """
389
+ from collections import defaultdict
390
+
391
+ cur_by_name: dict[str, list[dict]] = defaultdict(list)
392
+ for b in cur_blocks:
393
+ cur_by_name[b["name"]].append(b)
394
+
395
+ preserved: list[str] = []
396
+ modified: list[str] = []
397
+ missing: list[str] = []
398
+ moved: list[str] = []
399
+
400
+ for inv in inv_blocks:
401
+ name = inv.get("name")
402
+ candidates = cur_by_name.get(name, [])
403
+ if not candidates:
404
+ missing.append(name)
405
+ continue
406
+ hash_matches = [c for c in candidates if c["content_hash"] == inv.get("content_hash")]
407
+ if not hash_matches:
408
+ modified.append(name)
409
+ continue
410
+ if any(c.get("parent_heading") == inv.get("parent_heading") for c in hash_matches):
411
+ preserved.append(name)
412
+ else:
413
+ moved.append(name)
414
+
415
+ return {
416
+ "preserved": preserved,
417
+ "modified": modified,
418
+ "missing": missing,
419
+ "moved": moved,
420
+ "ok": not modified and not missing,
421
+ }
422
+
423
+
424
+ def manual_inventory(path: Path) -> dict:
425
+ """Build the [MANUAL] inventory for a file: {blocks[], count}."""
426
+ data = path.read_bytes()
427
+ blocks = find_manual_blocks(data)
428
+ return {"blocks": blocks, "count": len(blocks)}
429
+
430
+
431
+ def load_inventory_blocks(inventory_path: Path) -> list[dict]:
432
+ """Read the blocks[] list from a manual-inventory JSON file.
433
+
434
+ Accepts the object emitted by `manual_inventory` (has a `blocks` key)
435
+ OR a bare list of block records. Raises ValueError on malformed input.
436
+ """
437
+ try:
438
+ data = json.loads(inventory_path.read_text(encoding="utf-8"))
439
+ except (json.JSONDecodeError, OSError) as exc:
440
+ raise ValueError(
441
+ f"failed to read inventory file {inventory_path}: {exc}"
442
+ ) from exc
443
+ if isinstance(data, list):
444
+ return list(data)
445
+ if isinstance(data, dict):
446
+ blocks = data.get("blocks")
447
+ if blocks is None:
448
+ raise ValueError(
449
+ f"inventory file {inventory_path} has no `blocks` field"
450
+ )
451
+ if not isinstance(blocks, list):
452
+ raise ValueError(
453
+ f"`blocks` in {inventory_path} is not an array"
454
+ )
455
+ return list(blocks)
456
+ raise ValueError(
457
+ f"inventory file {inventory_path} must be an object or array; "
458
+ f"got {type(data).__name__}"
459
+ )
460
+
461
+
462
+ def manual_verify(inventory_path: Path, skill_md_path: Path) -> dict:
463
+ """Verify a post-merge file against a captured [MANUAL] inventory."""
464
+ inv_blocks = load_inventory_blocks(inventory_path)
465
+ cur_blocks = find_manual_blocks(skill_md_path.read_bytes())
466
+ return classify_manual_blocks(inv_blocks, cur_blocks)
467
+
468
+
469
+ # --------------------------------------------------------------------------
470
+ # Constituent-hash comparison (compose-mode stack drift)
471
+ # --------------------------------------------------------------------------
472
+
473
+
474
+ def load_constituents(provenance_path: Path) -> list[dict]:
475
+ """Extract the `constituents[]` list from a compose-mode provenance map.
476
+
477
+ Accepts three shapes:
478
+ - top-level object with a `constituents` key (canonical provenance map)
479
+ - top-level object with NO `constituents` field → empty list (a single
480
+ skill omits the array entirely per skill-sections.md — valid, no drift)
481
+ - top-level array of constituent records (already extracted)
482
+
483
+ Raises ValueError on read failure, malformed JSON, or a non-array
484
+ `constituents` field.
485
+ """
486
+ try:
487
+ text = provenance_path.read_text(encoding="utf-8")
488
+ except OSError as exc:
489
+ raise ValueError(
490
+ f"failed to read provenance file {provenance_path}: {exc}"
491
+ ) from exc
492
+ try:
493
+ data = json.loads(text)
494
+ except json.JSONDecodeError as exc:
495
+ raise ValueError(
496
+ f"malformed JSON in provenance file {provenance_path}: {exc}"
497
+ ) from exc
498
+
499
+ if isinstance(data, list):
500
+ return list(data)
501
+ if isinstance(data, dict):
502
+ constituents = data.get("constituents")
503
+ if constituents is None:
504
+ return [] # single skill — no constituents to verify
505
+ if not isinstance(constituents, list):
506
+ raise ValueError(
507
+ f"`constituents` in {provenance_path} is not an array"
508
+ )
509
+ return list(constituents)
510
+ raise ValueError(
511
+ f"provenance file {provenance_path} must be an object or array; "
512
+ f"got {type(data).__name__}"
513
+ )
514
+
515
+
516
+ def _constituent_metadata_path(skills_root: Path, skill_path: str, skill_name: str) -> Path:
517
+ """Resolve a constituent's live metadata.json path.
518
+
519
+ Mirrors init.md's Stack Skill Detection prose:
520
+ `{skill_path}/active/{skill_name}/metadata.json`. A relative `skill_path`
521
+ resolves against `skills_root`; an absolute one is used as-is.
522
+ """
523
+ base = Path(skill_path)
524
+ if not base.is_absolute():
525
+ base = skills_root / base
526
+ return base / "active" / skill_name / "metadata.json"
527
+
528
+
529
+ def compare_constituents(provenance_path: Path, skills_root: Path) -> dict:
530
+ """Classify each compose-mode constituent as drifted / fresh / missing.
531
+
532
+ For each constituent, the live `metadata.json` is re-hashed with the same
533
+ `sha256:`-prefixed convention the writer used and compared (prefix-
534
+ normalized) against the compile-time `metadata_hash`.
535
+ """
536
+ constituents = load_constituents(provenance_path)
537
+
538
+ drifted: list[dict] = []
539
+ fresh: list[dict] = []
540
+ missing: list[dict] = []
541
+ skipped_null_hash: list[dict] = []
542
+
543
+ for entry in constituents:
544
+ if not isinstance(entry, dict):
545
+ missing.append({
546
+ "skill_name": None,
547
+ "skill_path": None,
548
+ "stored_hash": None,
549
+ "reason": "incomplete-record",
550
+ })
551
+ continue
552
+ skill_name = entry.get("skill_name")
553
+ skill_path = entry.get("skill_path")
554
+ stored_hash = entry.get("metadata_hash")
555
+
556
+ if not isinstance(skill_name, str) or not skill_name or \
557
+ not isinstance(skill_path, str) or not skill_path:
558
+ missing.append({
559
+ "skill_name": skill_name if isinstance(skill_name, str) else None,
560
+ "skill_path": skill_path if isinstance(skill_path, str) else None,
561
+ "stored_hash": stored_hash if isinstance(stored_hash, str) else None,
562
+ "reason": "incomplete-record",
563
+ })
564
+ continue
565
+
566
+ if not isinstance(stored_hash, str) or not stored_hash:
567
+ # No compile-time baseline (recorded from a references/ cascade) —
568
+ # cannot be drift; do not flag.
569
+ skipped_null_hash.append({"skill_name": skill_name})
570
+ continue
571
+
572
+ meta_path = _constituent_metadata_path(skills_root, skill_path, skill_name)
573
+ if not meta_path.is_file():
574
+ missing.append({
575
+ "skill_name": skill_name,
576
+ "skill_path": skill_path,
577
+ "stored_hash": stored_hash,
578
+ "reason": "metadata-not-found",
579
+ })
580
+ continue
581
+
582
+ current_hash = sha256_of_file(meta_path)
583
+ if normalize_hash(stored_hash) == normalize_hash(current_hash):
584
+ fresh.append({"skill_name": skill_name})
585
+ else:
586
+ drifted.append({
587
+ "skill_name": skill_name,
588
+ "skill_path": skill_path,
589
+ "stored_hash": stored_hash,
590
+ "current_hash": current_hash,
591
+ })
592
+
593
+ drifted.sort(key=lambda r: (r.get("skill_name") or ""))
594
+ fresh.sort(key=lambda r: (r.get("skill_name") or ""))
595
+ missing.sort(key=lambda r: (r.get("skill_name") or ""))
596
+ skipped_null_hash.sort(key=lambda r: (r.get("skill_name") or ""))
597
+
598
+ return {
599
+ "drifted": drifted,
600
+ "fresh": fresh,
601
+ "missing": missing,
602
+ "skipped_null_hash": skipped_null_hash,
603
+ "stats": {
604
+ "total": len(constituents),
605
+ "drifted": len(drifted),
606
+ "fresh": len(fresh),
607
+ "missing": len(missing),
608
+ "skipped_null_hash": len(skipped_null_hash),
609
+ },
610
+ }
611
+
612
+
211
613
  # --------------------------------------------------------------------------
212
614
  # CLI
213
615
  # --------------------------------------------------------------------------
@@ -243,6 +645,55 @@ def _cmd_compare(args: argparse.Namespace) -> int:
243
645
  return 0
244
646
 
245
647
 
648
+ def _cmd_manual_inventory(args: argparse.Namespace) -> int:
649
+ path = Path(args.path)
650
+ if not path.is_file():
651
+ print(f"error: file not found: {path}", file=sys.stderr)
652
+ return 1
653
+ result = manual_inventory(path)
654
+ json.dump(result, sys.stdout, indent=2)
655
+ sys.stdout.write("\n")
656
+ return 0
657
+
658
+
659
+ def _cmd_manual_verify(args: argparse.Namespace) -> int:
660
+ path = Path(args.path)
661
+ inventory = Path(args.inventory)
662
+ if not path.is_file():
663
+ print(f"error: file not found: {path}", file=sys.stderr)
664
+ return 1
665
+ if not inventory.is_file():
666
+ print(f"error: inventory not found: {inventory}", file=sys.stderr)
667
+ return 1
668
+ try:
669
+ result = manual_verify(inventory, path)
670
+ except ValueError as exc:
671
+ print(f"error: {exc}", file=sys.stderr)
672
+ return 1
673
+ json.dump(result, sys.stdout, indent=2)
674
+ sys.stdout.write("\n")
675
+ return 0
676
+
677
+
678
+ def _cmd_compare_constituents(args: argparse.Namespace) -> int:
679
+ provenance = Path(args.provenance_map)
680
+ skills_root = Path(args.skills_root)
681
+ if not provenance.is_file():
682
+ print(f"error: provenance map not found: {provenance}", file=sys.stderr)
683
+ return 1
684
+ if not skills_root.is_dir():
685
+ print(f"error: skills root not a directory: {skills_root}", file=sys.stderr)
686
+ return 1
687
+ try:
688
+ result = compare_constituents(provenance, skills_root)
689
+ except ValueError as exc:
690
+ print(f"error: {exc}", file=sys.stderr)
691
+ return 1
692
+ json.dump(result, sys.stdout, indent=2)
693
+ sys.stdout.write("\n")
694
+ return 0
695
+
696
+
246
697
  def _build_parser() -> argparse.ArgumentParser:
247
698
  parser = argparse.ArgumentParser(
248
699
  prog="skf-hash-content",
@@ -271,6 +722,40 @@ def _build_parser() -> argparse.ArgumentParser:
271
722
  )
272
723
  p_cmp.set_defaults(func=_cmd_compare)
273
724
 
725
+ p_inv = sub.add_parser(
726
+ "manual-inventory",
727
+ help="extract [MANUAL] blocks and emit {blocks[], count} JSON",
728
+ )
729
+ p_inv.add_argument("path", help="path to the SKILL.md (or reference file)")
730
+ p_inv.set_defaults(func=_cmd_manual_inventory)
731
+
732
+ p_ver = sub.add_parser(
733
+ "manual-verify",
734
+ help="verify a file against a captured [MANUAL] inventory",
735
+ )
736
+ p_ver.add_argument("path", help="path to the post-merge SKILL.md (or reference file)")
737
+ p_ver.add_argument(
738
+ "--inventory",
739
+ required=True,
740
+ help="path to the manual-inventory JSON captured before the write",
741
+ )
742
+ p_ver.set_defaults(func=_cmd_manual_verify)
743
+
744
+ p_con = sub.add_parser(
745
+ "compare-constituent-hashes",
746
+ help="classify compose-mode stack constituents as drifted/fresh/missing",
747
+ )
748
+ p_con.add_argument(
749
+ "provenance_map",
750
+ help="path to the compose-mode provenance-map.json (object with constituents[] or bare array)",
751
+ )
752
+ p_con.add_argument(
753
+ "--skills-root",
754
+ default=".",
755
+ help="base directory for resolving relative constituent skill_path values (default: current directory)",
756
+ )
757
+ p_con.set_defaults(func=_cmd_compare_constituents)
758
+
274
759
  return parser
275
760
 
276
761
 
@@ -0,0 +1,100 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Language Corpora — canonical companion prose corpora for a language.
6
+
7
+ A whole-language skill (issue #427) is forged from a language-reference repo
8
+ (a compiler/interpreter such as rust-lang/rust). That repo carries the
9
+ language's CODE, but a skill's value comes from the language's PROSE — the
10
+ guide/Book, the standard/library API docs, idioms. README detection
11
+ (skf-detect-docs.py) is the primary source for those; this lookup guarantees
12
+ the canonical corpora for well-known languages even when the repo's README
13
+ does not link them.
14
+
15
+ Pure static lookup over src/shared/data/language-corpora.json — no network,
16
+ no git, no `gh`. Output is the brief `doc_urls` contract (`{url, label,
17
+ source}`), with `source` fixed to `"language-registry"` (issue #432), so the
18
+ result can be seeded directly into a skill brief and later distinguished from
19
+ README-detected docs.
20
+
21
+ CLI:
22
+ uv run src/shared/scripts/skf-language-corpora.py --language <id>
23
+
24
+ Output (JSON array on stdout): [{"url": "...", "label": "...", "source": "language-registry"}, ...]
25
+
26
+ Exit codes:
27
+ 0 registry hit — one or more corpora emitted
28
+ 1 no entry for this language (long-tail / unknown) — emits []
29
+ 2 error (bad args, missing/unreadable/invalid data file)
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ _DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "language-corpora.json"
40
+
41
+
42
+ def _die(message: str, code: str = "INTERNAL_ERROR") -> None:
43
+ json.dump({"error": message, "code": code}, sys.stderr, ensure_ascii=False)
44
+ sys.stderr.write("\n")
45
+ sys.exit(2)
46
+
47
+
48
+ def _load_registry() -> dict:
49
+ try:
50
+ data = json.loads(_DATA_FILE.read_text(encoding="utf-8"))
51
+ except OSError as exc:
52
+ _die(f"Cannot read {_DATA_FILE.as_posix()}: {exc}", "DATA_READ_ERROR")
53
+ except json.JSONDecodeError as exc:
54
+ _die(f"Cannot parse {_DATA_FILE.as_posix()}: {exc}", "DATA_PARSE_ERROR")
55
+ if not isinstance(data, dict):
56
+ _die("Corpora registry must be a JSON object", "DATA_PARSE_ERROR")
57
+ return data
58
+
59
+
60
+ def corpora_for(language: str) -> list[dict]:
61
+ """Return the `{url, label, source}` corpora for a language id, or [] if none.
62
+
63
+ Every emitted entry is stamped `source: "language-registry"` (issue #432) so
64
+ downstream — the brief writer, the noise-suppression filter (#431), and the
65
+ assembly tier-ordering (#430) — can distinguish a registry-guaranteed
66
+ corpus from an opportunistically README-detected doc.
67
+ """
68
+ registry = _load_registry()
69
+ entries = registry.get((language or "").strip().lower())
70
+ if not isinstance(entries, list):
71
+ return []
72
+ out: list[dict] = []
73
+ for e in entries:
74
+ if isinstance(e, dict) and e.get("url"):
75
+ out.append({
76
+ "url": e["url"],
77
+ "label": e.get("label", ""),
78
+ "source": "language-registry",
79
+ })
80
+ return out
81
+
82
+
83
+ def main(argv: list[str]) -> int:
84
+ parser = argparse.ArgumentParser(
85
+ description="Look up canonical companion prose corpora for a language.",
86
+ )
87
+ parser.add_argument(
88
+ "--language", required=True,
89
+ help="Lowercase language id (rust, python, go, typescript, ruby, ...)",
90
+ )
91
+ args = parser.parse_args(argv)
92
+
93
+ result = corpora_for(args.language)
94
+ json.dump(result, sys.stdout, ensure_ascii=False)
95
+ sys.stdout.write("\n")
96
+ return 0 if result else 1
97
+
98
+
99
+ if __name__ == "__main__":
100
+ sys.exit(main(sys.argv[1:]))