bmad-module-skill-forge 2.1.0 → 3.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 (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +51 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +14 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +117 -9
  75. package/src/shared/scripts/skf-detect-docs.py +892 -4
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +215 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +17 -6
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +55 -18
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -89
  348. package/src/skf-setup/references/detect-and-tier.md +58 -66
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -645
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -0,0 +1,1381 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # dependencies = []
5
+ # ///
6
+ """SKF test-skill gap ledger: one typed JSON record per finding.
7
+
8
+ test-skill's hard gate (step-hard-gate.md) must see every Critical and High
9
+ finding before scoring, and update-skill must read every gap with its source
10
+ and remediation after a FAIL. Markdown written by the model serves neither:
11
+ the coverage section holds tables and counts, the coherence formats carry no
12
+ severity, and the Gap Entry Format was applied only in the report step, after
13
+ the gate. This script keeps the findings in a JSON ledger instead. Each stage
14
+ appends the gaps it finds, classified against the Gap Severity table in
15
+ scoring-rules.md when they are written; hard-gate.py counts the blocking ones,
16
+ and the Gap Report is rendered from the same records on every path.
17
+
18
+ Ledger file: `{forge_version}/test-findings-{run_id}.json`
19
+
20
+ {
21
+ "schema_version": 1,
22
+ "run_id": "<run id from the file name>" | null,
23
+ "stages": ["coverage-check", "coherence-check", ...],
24
+ "records": [
25
+ {
26
+ "id": "GAP-001", # assigned here, in append order
27
+ "severity": "Critical" | "High" | "Medium" | "Low" | "Info",
28
+ "category": "<slug from CATEGORIES>",
29
+ "group": "Coverage" | "Coherence" | "Structural" | "Discovery" | "External",
30
+ "title": "<one line>",
31
+ "source": "<file:line or a section reference>",
32
+ "remediation": "<exact action that fixes the gap>",
33
+ "issue": "<what is wrong>", # optional
34
+ "export": "<export name>", # optional
35
+ "stage": "<the stage that appended it>"
36
+ }
37
+ ]
38
+ }
39
+
40
+ `stages` lists every stage that appended, even with no records, so the gate
41
+ can tell a stage that found nothing from a stage that never ran. `group` is
42
+ derived from the category; callers never supply `id`, `group` or `stage`.
43
+
44
+ Subcommands:
45
+
46
+ append --ledger <path> --stage <stage> [--input <file>]...
47
+ Read records from each <file> in turn, or from stdin without --input:
48
+ a JSON array, one record object, or {"records": [...]}. An empty array
49
+ records the stage with no findings. Every record is validated first
50
+ and nothing is written unless all are valid. A record equal to one
51
+ already in the ledger (same severity, category, title, source and
52
+ export) is not added again, so a stage that runs twice leaves one
53
+ copy. Creates the ledger when it does not exist.
54
+ Appends may overlap (parallel tool calls): each holds an exclusive
55
+ lock on <ledger>.skf-lock from its read to its write, and another
56
+ append waits for it (up to 30 s, then LEDGER_LOCKED), so no record an
57
+ append reported is lost. The write is atomic: a temp file of its own
58
+ beside the ledger, then a rename. The lock file is removed afterwards.
59
+ Output: {"status": "ok", "ledger", "stage", "appended": [ids],
60
+ "duplicates": [ids], "record_count"}
61
+
62
+ append --ledger <path> --stage <stage> --from <kind> --input <file>...
63
+ [--surface <file>] [--signatures <file>] [--numerator <file>]
64
+ [--stale <file>] [--provenance <file>] [--skill-dir <dir>]
65
+ [--metadata <path>] [--served <family>]...
66
+ Build the records from the result file a script wrote (see Adapters
67
+ below) instead of reading them, then append them as above, with the
68
+ same output. A gap a script finds is recorded with a fixed title,
69
+ Source, `export` and remediation, so a rerun dedupes it. Each kind
70
+ reads one --input (structure reads one or more) and only the options
71
+ its entry names; any other option, or a file that does not hold the
72
+ result its kind reads, is INVALID_INPUT and nothing is written.
73
+
74
+ Adapters (--from), each record classified by its Gap Severity row
75
+ (scoring-rules.md). `{meta}` is --metadata, the metadata.json path a record
76
+ cites (never read; default `metadata.json`):
77
+
78
+ coverage --input reconcile-coverage.py's result; --surface, --signatures,
79
+ --numerator, --stale, --provenance, --skill-dir, --metadata.
80
+ Barrel branch: each `missing` name is a Medium `missing-export`
81
+ gap `Missing export: {name}`, or a Medium `missing-type` gap
82
+ `Missing type: {name}` when the --signatures result lists it in
83
+ `missingTypes`; its Source is the file and line the --surface
84
+ result's `exports[]` records for it, else `{meta}`. Each `stale`
85
+ name the --stale result (classify-stale) marks `fabricated` is a
86
+ Critical `fabricated-signature` gap `Fabricated signature:
87
+ {name}` at its `source` (one with no `source` is INVALID_INPUT,
88
+ never a lesser gap); any other is a Medium
89
+ `stale-documentation` gap `Stale documentation: {name}` at the
90
+ first line of SKILL.md, then of references/*.md in path order,
91
+ that writes the name (validate-inventory.py's match, with
92
+ --skill-dir), else `SKILL.md`. Scalar and stack branches: a
93
+ `missingCount` above 0 is one Medium `missing-export` gap
94
+ `{missingCount} of {denominator} exports not documented` at
95
+ `{meta}`, except that a scalar count taken from the verified
96
+ numerator (`numeratorSource` `verified`) is replaced by one
97
+ `Missing export: {name}` gap per --numerator `absent` name, at
98
+ the first --provenance entry of that name, else `{meta}`.
99
+ Every per-name gap carries `export`.
100
+ guards --input load-coverage-inputs.py's surface result; --metadata.
101
+ `guards.deflation.fires` is a Medium `metadata-drift` gap at
102
+ `{meta}` (else the result's `inputs.metadata`), and
103
+ `guards.inflation.fires` a Medium `denominator-inflation` gap at
104
+ the result's `inputs.brief` (else `skill-brief.yaml`), whose
105
+ remediation recommends `stats.effective_denominator` when
106
+ `guards.umbrella.umbrella` is true, else `scope.tier_a_include`.
107
+ numerator --input verify-declared-numerator.py's result; --metadata.
108
+ `inflated` true is a High `numerator-inflation` gap at `{meta}`
109
+ whose issue lists the `absent` names.
110
+ metadata-coherence
111
+ --input check-metadata-coherence.py's result; --metadata. Each
112
+ `findings[]` entry, titled as it is and with its `detail` as the
113
+ issue, at `{meta}`: Medium is a Medium `metadata-drift` gap, Info
114
+ an Info `multi-denominator` gap.
115
+ provenance-line
116
+ --input skf-verify-provenance-completeness.py verify's result.
117
+ Each `stale[]` item whose reason is `line-not-definition`, at
118
+ its `{source_file}:{source_line}` with `export`: a Low
119
+ `provenance-line` gap `Provenance line is not the definition of
120
+ {export_name}` when `definition_lines` holds a line, else an Info
121
+ `provenance-unverified` gap `Provenance line not verified for
122
+ {export_name}`. Its other items are not gaps here.
123
+ coherence --input aggregate-coherence.py's result. Each
124
+ `invalidReferences[]` entry at `SKILL.md:{line}`, by `status`:
125
+ `missing` is a Critical `broken-reference` gap, `inaccurate` a
126
+ High `inaccurate-reference` gap (its `issues` as the issue) and
127
+ `escapes` a High `reference-escape` gap naming its `canonical`
128
+ path.
129
+ structure --input one or more skf-scan-skill-md-structure.py results,
130
+ each told apart by its keys; --served. A `scan --required-sections`
131
+ result: each family not `satisfied` is a High `structural` gap
132
+ `naive-coherence: missing required section: {family}`, unless
133
+ --served names it (another heading serves it: the caller's
134
+ judgment) or it is `description` and `frontmatter_description`
135
+ is true. A `scan` result: unbalanced fences are a High
136
+ `structural` gap, and each bare opening fence and each
137
+ `table_drift` row a Medium `structural` gap at `SKILL.md:{line}`.
138
+ A `usage-scope` result: each `zero_usage` export is a Medium
139
+ `structural` gap with `export`. A `scan` or `reference-check`
140
+ result whose `scripts_assets.missing` is true is a Medium
141
+ `scripts-assets` gap. The titles are coherence-check.md's
142
+ `naive-coherence:` texts; a `reference-check` result (contextual
143
+ mode) titles its gap `coherence:` instead.
144
+
145
+ render --ledger <path> [--heading]
146
+ Print the Gap Report body in Markdown on stdout: the totals, the
147
+ Remediation Summary table and one Gap Entry per record, Critical first,
148
+ then by id. Each entry names its category as `{group} ({category})`,
149
+ which skf-parse-gaps.py reads back for a report whose ledger is gone.
150
+ --heading adds the `## Gap Report` line. With no records the body says
151
+ no gaps were found.
152
+
153
+ summary --ledger <path>
154
+ Output: {"status": "ok", "ledger", "run_id", "stages", "total",
155
+ "counts": {severity: n}, "blocking", "non_blocking",
156
+ "by_category": {category: n}}
157
+
158
+ A Critical or High record blocks only when a stage before the hard gate
159
+ appended it. The report stage appends after the gate (the discovery test),
160
+ so render and summary count its Critical and High records as non-blocking:
161
+ render lists them on a line of their own, after the hard gate.
162
+
163
+ categories
164
+ Output the category vocabulary and the severities:
165
+ {"status": "ok", "severities": [...],
166
+ "categories": [{"category", "group", "description"}]}
167
+
168
+ Exit codes:
169
+ 0 the result was printed
170
+ 1 the ledger is missing, unreadable, not a valid ledger, held by another
171
+ append past the wait, or not writable
172
+ 2 the input records are invalid (nothing was written), or a usage error
173
+ (argparse: a missing or unknown argument, usage on stderr, no JSON)
174
+
175
+ Errors print {"status": "error", "code", "error", ...} on stdout, except for
176
+ render, which prints it on stderr so a redirected Gap Report stays clean. The
177
+ codes: LEDGER_MISSING, LEDGER_INVALID, LEDGER_LOCKED, WRITE_FAILED (exit 1),
178
+ INVALID_INPUT and INVALID_RECORD (exit 2, with `errors: [{index, error}]`).
179
+ """
180
+
181
+ from __future__ import annotations
182
+
183
+ import argparse
184
+ import contextlib
185
+ import errno
186
+ import importlib.util
187
+ import json
188
+ import os
189
+ import re
190
+ import secrets
191
+ import sys
192
+ import time
193
+ from pathlib import Path
194
+
195
+ if os.name == "nt":
196
+ import msvcrt
197
+ else:
198
+ import fcntl
199
+
200
+ SCHEMA_VERSION = 1
201
+
202
+ # How long an append waits for another append's lock before LEDGER_LOCKED.
203
+ # An append holds it for milliseconds; the wait covers a burst of parallel
204
+ # tool calls, not a writer that hangs.
205
+ LOCK_TIMEOUT_SECONDS = 30.0
206
+ _LOCK_POLL_SECONDS = 0.02
207
+ # Windows refuses to replace a file another process holds open, and a virus
208
+ # scanner or the search indexer may open a just-written ledger for a moment:
209
+ # save_ledger retries the rename there for up to this long before failing.
210
+ _IS_WINDOWS = os.name == "nt"
211
+ _REPLACE_WAIT_SECONDS = 5.0
212
+
213
+ SEVERITIES = ("Critical", "High", "Medium", "Low", "Info")
214
+ BLOCKING = frozenset({"Critical", "High"})
215
+ _SEVERITY_BY_LOWER = {s.lower(): s for s in SEVERITIES}
216
+ # Stages that append after the hard gate decided (report.md records the
217
+ # discovery test there): a Critical or High gap they record blocked nothing.
218
+ POST_GATE_STAGES = frozenset({"report"})
219
+
220
+ # Category slug -> (group, what the category covers). update-skill routes a
221
+ # gap on its category, not its severity, so the slugs are a closed set: a typo
222
+ # fails the append instead of reaching update-skill as an unknown category.
223
+ CATEGORIES: dict[str, tuple[str, str]] = {
224
+ "missing-export": ("Coverage", "an exported function or class the skill does not document"),
225
+ "signature-mismatch": ("Coverage", "a documented signature that differs from the source"),
226
+ "fabricated-signature": ("Coverage", "a documented export that is absent at the cited source line"),
227
+ "stale-documentation": ("Coverage", "documentation for an export the source no longer has"),
228
+ "missing-type": ("Coverage", "a type or interface the skill does not document"),
229
+ "provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
230
+ "provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
231
+ "provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
232
+ "metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
233
+ "denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
234
+ "numerator-inflation": (
235
+ "Coverage",
236
+ "a documented count equal to the denominator while declared exports are absent from the skill",
237
+ ),
238
+ "multi-denominator": ("Coverage", "barrel and documented-surface counts that diverge by design"),
239
+ "migration-section": ("Coverage", "a migration section that disagrees with the annotation data"),
240
+ "broken-reference": ("Coherence", "a reference whose target does not exist"),
241
+ "inaccurate-reference": ("Coherence", "a reference whose target exists but does not match it"),
242
+ "reference-escape": (
243
+ "Coherence",
244
+ "a reference whose real path leaves the skill, its source and, for a stack, the skills folder",
245
+ ),
246
+ "integration-pattern": ("Coherence", "an integration pattern that is incomplete"),
247
+ "split-body-mismatch": ("Coherence", "SKILL.md and a references/ file that disagree about an export"),
248
+ "scripts-assets": ("Coherence", "a Scripts & Assets section that is missing or names a missing file"),
249
+ "scripts-assets-provenance": ("Coherence", "a script or asset file without a provenance entry"),
250
+ "structural": (
251
+ "Structural",
252
+ "a section, code fence, table or usage example in SKILL.md that is missing or wrong",
253
+ ),
254
+ "metadata": ("Structural", "missing optional metadata or examples"),
255
+ "observation": ("Structural", "a style suggestion or another non-blocking observation"),
256
+ "discovery": ("Discovery", "a discovery test that misrouted prompts or did not run"),
257
+ "description": ("Discovery", "a description whose triggers need optimizing"),
258
+ "external-validator": ("External", "a skill-check diagnostic or a Tessl Review suggestion"),
259
+ }
260
+
261
+ REQUIRED_FIELDS = ("severity", "category", "title", "source", "remediation")
262
+ OPTIONAL_FIELDS = ("issue", "export")
263
+ ASSIGNED_FIELDS = ("id", "group", "stage")
264
+
265
+ # The Remediation Summary's Estimated Effort column, per severity.
266
+ EFFORT = {
267
+ "Critical": "Read the source code and write or correct the documentation",
268
+ "High": "Read the source code and write or correct the documentation",
269
+ "Medium": "Add type definitions, interface docs or the missing section",
270
+ "Low": "Add examples or metadata",
271
+ "Info": "Optional improvements, no action required",
272
+ }
273
+
274
+ _ID_RE = re.compile(r"^GAP-(\d{3,})$")
275
+ _STAGE_RE = re.compile(r"^[a-z][a-z0-9-]*$")
276
+ _RUN_ID_FROM_NAME = re.compile(r"^test-findings-(.+)\.json$")
277
+
278
+
279
+ class LedgerError(Exception):
280
+ """A ledger that is missing, unreadable or malformed."""
281
+
282
+ def __init__(self, code: str, message: str) -> None:
283
+ super().__init__(message)
284
+ self.code = code
285
+
286
+
287
+ # --------------------------------------------------------------------------
288
+ # Records
289
+ # --------------------------------------------------------------------------
290
+
291
+
292
+ def _one_line(value: str) -> str:
293
+ return " ".join(value.split())
294
+
295
+
296
+ def normalize_record(raw: object) -> tuple[dict | None, str | None]:
297
+ """Validate one input record. Returns (record, None) or (None, error).
298
+
299
+ >>> rec, err = normalize_record({"severity": "high", "category": "broken-reference",
300
+ ... "title": " Broken link ", "source": "SKILL.md:12", "remediation": "Fix it."})
301
+ >>> (rec["severity"], rec["title"], err)
302
+ ('High', 'Broken link', None)
303
+ >>> normalize_record({"severity": "Severe"})[1]
304
+ "missing required field 'category'"
305
+ """
306
+ if not isinstance(raw, dict):
307
+ return None, f"a record must be a JSON object, got {type(raw).__name__}"
308
+ for key in ASSIGNED_FIELDS:
309
+ if key in raw:
310
+ return None, f"'{key}' is assigned by the ledger; leave it out"
311
+ unknown = sorted(set(raw) - set(REQUIRED_FIELDS) - set(OPTIONAL_FIELDS))
312
+ if unknown:
313
+ return None, f"unknown field(s): {', '.join(unknown)}"
314
+ for key in REQUIRED_FIELDS:
315
+ if key not in raw:
316
+ return None, f"missing required field '{key}'"
317
+ if not isinstance(raw[key], str):
318
+ return None, f"'{key}' must be a string"
319
+ for key in OPTIONAL_FIELDS:
320
+ if raw.get(key) is not None and not isinstance(raw[key], str):
321
+ return None, f"'{key}' must be a string or null"
322
+ severity = _SEVERITY_BY_LOWER.get(raw["severity"].strip().lower())
323
+ if severity is None:
324
+ return None, (
325
+ f"unknown severity '{raw['severity']}' (expected one of {', '.join(SEVERITIES)})"
326
+ )
327
+ category = raw["category"].strip()
328
+ if category not in CATEGORIES:
329
+ return None, f"unknown category '{category}' (run the categories subcommand for the list)"
330
+ record = {
331
+ "severity": severity,
332
+ "category": category,
333
+ "group": CATEGORIES[category][0],
334
+ "title": _one_line(raw["title"]),
335
+ "source": _one_line(raw["source"]),
336
+ "remediation": raw["remediation"].strip(),
337
+ }
338
+ for key in ("title", "source", "remediation"):
339
+ if not record[key]:
340
+ return None, f"'{key}' must not be empty"
341
+ issue = (raw.get("issue") or "").strip()
342
+ if issue:
343
+ record["issue"] = issue
344
+ export = _one_line(raw.get("export") or "")
345
+ if export:
346
+ record["export"] = export
347
+ return record, None
348
+
349
+
350
+ def _dedupe_key(record: dict) -> tuple[str, str, str, str, str | None]:
351
+ """What makes two records the same gap. The export is part of it: two
352
+ missing exports can share a generic title and a barrel Source."""
353
+ return (
354
+ record["severity"],
355
+ record["category"],
356
+ record["title"],
357
+ record["source"],
358
+ record.get("export") or None,
359
+ )
360
+
361
+
362
+ def _id_number(record: dict) -> int:
363
+ m = _ID_RE.match(record.get("id", ""))
364
+ return int(m.group(1)) if m else 0
365
+
366
+
367
+ def ordered(records: list[dict]) -> list[dict]:
368
+ """Records by severity (Critical first), then by id."""
369
+ rank = {s: i for i, s in enumerate(SEVERITIES)}
370
+ return sorted(records, key=lambda r: (rank[r["severity"]], _id_number(r)))
371
+
372
+
373
+ def count_by_severity(records: list[dict]) -> dict[str, int]:
374
+ counts = {s: 0 for s in SEVERITIES}
375
+ for record in records:
376
+ counts[record["severity"]] += 1
377
+ return counts
378
+
379
+
380
+ def is_blocking(record: dict) -> bool:
381
+ """A Critical or High gap that a stage before the hard gate recorded.
382
+
383
+ >>> is_blocking({"severity": "High", "stage": "coherence-check"})
384
+ True
385
+ >>> is_blocking({"severity": "High", "stage": "report"})
386
+ False
387
+ """
388
+ return record["severity"] in BLOCKING and record.get("stage") not in POST_GATE_STAGES
389
+
390
+
391
+ # --------------------------------------------------------------------------
392
+ # Ledger file
393
+ # --------------------------------------------------------------------------
394
+
395
+
396
+ def new_ledger(path: Path) -> dict:
397
+ m = _RUN_ID_FROM_NAME.match(path.name)
398
+ return {
399
+ "schema_version": SCHEMA_VERSION,
400
+ "run_id": m.group(1) if m else None,
401
+ "stages": [],
402
+ "records": [],
403
+ }
404
+
405
+
406
+ def validate_ledger(data: object) -> None:
407
+ """Raise LedgerError unless `data` is a well-formed ledger."""
408
+ if not isinstance(data, dict):
409
+ raise LedgerError("LEDGER_INVALID", "the ledger must be a JSON object")
410
+ if data.get("schema_version") != SCHEMA_VERSION:
411
+ raise LedgerError(
412
+ "LEDGER_INVALID",
413
+ f"unsupported schema_version {data.get('schema_version')!r} (expected {SCHEMA_VERSION})",
414
+ )
415
+ stages = data.get("stages")
416
+ if not isinstance(stages, list) or not all(isinstance(s, str) for s in stages):
417
+ raise LedgerError("LEDGER_INVALID", "'stages' must be a list of strings")
418
+ records = data.get("records")
419
+ if not isinstance(records, list):
420
+ raise LedgerError("LEDGER_INVALID", "'records' must be a list")
421
+ seen: set[str] = set()
422
+ for index, record in enumerate(records):
423
+ if not isinstance(record, dict):
424
+ raise LedgerError("LEDGER_INVALID", f"record {index} is not an object")
425
+ rid = record.get("id")
426
+ if not isinstance(rid, str) or not _ID_RE.match(rid):
427
+ raise LedgerError("LEDGER_INVALID", f"record {index} has no valid id")
428
+ if rid in seen:
429
+ raise LedgerError("LEDGER_INVALID", f"duplicate id {rid}")
430
+ seen.add(rid)
431
+ if record.get("severity") not in SEVERITIES:
432
+ raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown severity")
433
+ if record.get("category") not in CATEGORIES:
434
+ raise LedgerError("LEDGER_INVALID", f"{rid} has an unknown category")
435
+ for key in ("title", "source", "remediation"):
436
+ if not isinstance(record.get(key), str) or not record[key]:
437
+ raise LedgerError("LEDGER_INVALID", f"{rid} has no {key}")
438
+ for key in OPTIONAL_FIELDS:
439
+ if record.get(key) is not None and not isinstance(record[key], str):
440
+ raise LedgerError("LEDGER_INVALID", f"{rid} has a {key} that is not a string")
441
+
442
+
443
+ def load_ledger(path: Path) -> dict:
444
+ """Read and validate a ledger. Raises LedgerError."""
445
+ if not path.is_file():
446
+ raise LedgerError("LEDGER_MISSING", f"no ledger at {path}")
447
+ try:
448
+ data = json.loads(path.read_text(encoding="utf-8"))
449
+ except (OSError, UnicodeDecodeError) as exc:
450
+ raise LedgerError("LEDGER_INVALID", f"cannot read {path}: {exc}") from exc
451
+ except json.JSONDecodeError as exc:
452
+ raise LedgerError("LEDGER_INVALID", f"{path} is not valid JSON: {exc}") from exc
453
+ validate_ledger(data)
454
+ return data
455
+
456
+
457
+ def save_ledger(path: Path, data: dict) -> None:
458
+ """Write the ledger atomically: a temp file of its own beside it, then a
459
+ rename. Call it under ledger_lock() so no other append runs between the
460
+ read and this write."""
461
+ path.parent.mkdir(parents=True, exist_ok=True)
462
+ tmp = path.with_name(f"{path.name}.{os.getpid()}-{secrets.token_hex(4)}.skf-tmp")
463
+ payload = (json.dumps(data, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
464
+ # O_EXCL: never write into another writer's temp file. O_BINARY (Windows
465
+ # only; 0 elsewhere) keeps the \n line ends.
466
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
467
+ fd = os.open(tmp, flags, 0o644)
468
+ try:
469
+ with open(fd, "wb") as fh:
470
+ fh.write(payload)
471
+ fh.flush()
472
+ os.fsync(fh.fileno())
473
+ deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
474
+ while True:
475
+ try:
476
+ os.replace(tmp, path)
477
+ break
478
+ except PermissionError:
479
+ if not _IS_WINDOWS or time.monotonic() >= deadline:
480
+ raise
481
+ time.sleep(_LOCK_POLL_SECONDS)
482
+ except BaseException:
483
+ with contextlib.suppress(OSError):
484
+ tmp.unlink()
485
+ raise
486
+
487
+
488
+ def _try_lock(fd: int) -> bool:
489
+ """Lock fd exclusively without waiting; False while another process holds it."""
490
+ try:
491
+ if os.name == "nt":
492
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
493
+ else:
494
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
495
+ except OSError as exc:
496
+ if exc.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK):
497
+ return False
498
+ raise
499
+ return True
500
+
501
+
502
+ def _unlock(fd: int) -> None:
503
+ with contextlib.suppress(OSError):
504
+ if os.name == "nt":
505
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
506
+ else:
507
+ fcntl.flock(fd, fcntl.LOCK_UN)
508
+
509
+
510
+ def _names_open_file(fd: int, path: Path) -> bool:
511
+ """True when `path` still names the file open as `fd`."""
512
+ try:
513
+ return os.path.samestat(os.fstat(fd), os.stat(path))
514
+ except OSError:
515
+ return False
516
+
517
+
518
+ @contextlib.contextmanager
519
+ def ledger_lock(path: Path, timeout: float | None = None):
520
+ """Hold the exclusive append lock on `<ledger>.skf-lock`.
521
+
522
+ The same fcntl / msvcrt lock as skf-atomic-write.py flip-link, but this
523
+ one waits: up to `timeout` seconds (LOCK_TIMEOUT_SECONDS), then it raises
524
+ LedgerError LEDGER_LOCKED. The lock dies with its process, so a crashed
525
+ append leaves nothing to clean up. The lock file is removed on release.
526
+ On POSIX that happens while the lock is still held, and a waiter checks
527
+ that the file it locked is still the one at the path: otherwise a waiter
528
+ on the removed file and a newcomer on a new one would both get a lock.
529
+ Windows removes no file that is open, so there the file goes after the
530
+ release, unless a waiter has it open.
531
+ """
532
+ timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
533
+ lock_path = path.with_name(path.name + ".skf-lock")
534
+ path.parent.mkdir(parents=True, exist_ok=True)
535
+ deadline = time.monotonic() + timeout
536
+
537
+ def refuse() -> LedgerError:
538
+ return LedgerError("LEDGER_LOCKED", f"another append held {lock_path} for more than {timeout:g} s")
539
+
540
+ while True:
541
+ fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
542
+ try:
543
+ while not _try_lock(fd):
544
+ if time.monotonic() >= deadline:
545
+ raise refuse()
546
+ time.sleep(_LOCK_POLL_SECONDS)
547
+ if os.name == "nt" or _names_open_file(fd, lock_path):
548
+ break
549
+ _unlock(fd)
550
+ except BaseException:
551
+ os.close(fd)
552
+ raise
553
+ os.close(fd)
554
+ if time.monotonic() >= deadline:
555
+ raise refuse()
556
+ try:
557
+ yield
558
+ finally:
559
+ if os.name != "nt":
560
+ with contextlib.suppress(OSError):
561
+ lock_path.unlink()
562
+ _unlock(fd)
563
+ os.close(fd)
564
+ if os.name == "nt":
565
+ with contextlib.suppress(OSError):
566
+ lock_path.unlink()
567
+
568
+
569
+ def append_records(ledger: dict, stage: str, records: list[dict]) -> tuple[list[str], list[str]]:
570
+ """Append validated records in place. Returns (appended ids, duplicate ids)."""
571
+ existing = {_dedupe_key(r): r["id"] for r in ledger["records"]}
572
+ next_number = max((_id_number(r) for r in ledger["records"]), default=0) + 1
573
+ appended: list[str] = []
574
+ duplicates: list[str] = []
575
+ for record in records:
576
+ key = _dedupe_key(record)
577
+ if key in existing:
578
+ duplicates.append(existing[key])
579
+ continue
580
+ rid = f"GAP-{next_number:03d}"
581
+ next_number += 1
582
+ ledger["records"].append({"id": rid, **record, "stage": stage})
583
+ existing[key] = rid
584
+ appended.append(rid)
585
+ if stage not in ledger["stages"]:
586
+ ledger["stages"].append(stage)
587
+ return appended, duplicates
588
+
589
+
590
+ def parse_input(text: str) -> list:
591
+ """The records an append reads: an array, one object or {"records": [...]}."""
592
+ data = json.loads(text)
593
+ if isinstance(data, list):
594
+ return data
595
+ if isinstance(data, dict):
596
+ if set(data) == {"records"}:
597
+ if not isinstance(data["records"], list):
598
+ raise ValueError("'records' must be a list")
599
+ return data["records"]
600
+ return [data]
601
+ raise ValueError("input must be a JSON array or object")
602
+
603
+
604
+ # --------------------------------------------------------------------------
605
+ # Adapters: a script's result file -> records (append --from)
606
+ # --------------------------------------------------------------------------
607
+
608
+
609
+ class AdapterError(Exception):
610
+ """A --from input that does not hold the result its kind reads."""
611
+
612
+
613
+ FAMILIES = ("description", "usage", "api_surface")
614
+ # The options (argparse dests) each kind reads, besides --input. A kind
615
+ # refuses any other, so a call that passes a file it would ignore fails.
616
+ ADAPTER_OPTIONS: dict[str, frozenset[str]] = {
617
+ "coverage": frozenset(
618
+ {"surface", "signatures", "numerator", "stale", "provenance", "skill_dir", "metadata"}
619
+ ),
620
+ "guards": frozenset({"metadata"}),
621
+ "numerator": frozenset({"metadata"}),
622
+ "metadata-coherence": frozenset({"metadata"}),
623
+ "provenance-line": frozenset(),
624
+ "coherence": frozenset(),
625
+ "structure": frozenset({"served"}),
626
+ }
627
+ ALL_ADAPTER_OPTIONS = frozenset().union(*ADAPTER_OPTIONS.values())
628
+ _DEFAULT_METADATA = "metadata.json"
629
+ _DEFAULT_BRIEF = "skill-brief.yaml"
630
+ # The remediation of a missing required section: the SKF template heading
631
+ # that serves the family.
632
+ _SECTION_FIX = {
633
+ "description": (
634
+ "Add a `## Overview` section to SKILL.md that says what the library does and when "
635
+ "to use it, or a non-empty `description` to its frontmatter."
636
+ ),
637
+ "usage": (
638
+ "Add a `## Quick Start` section to SKILL.md with a runnable example of the "
639
+ "library's most common calls."
640
+ ),
641
+ "api_surface": (
642
+ "Add a `## Key API Summary` section to SKILL.md that lists the library's exports "
643
+ "with their purpose."
644
+ ),
645
+ }
646
+
647
+ _SIBLINGS: dict[str, object] = {}
648
+
649
+
650
+ def _sibling(filename: str):
651
+ """A script beside this one, loaded once: validate-inventory.py (the
652
+ documented-name match the stale-documentation Source uses)."""
653
+ module = _SIBLINGS.get(filename)
654
+ if module is None:
655
+ path = Path(__file__).resolve().parent / filename
656
+ spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
657
+ if spec is None or spec.loader is None or not path.is_file():
658
+ raise AdapterError(f"{filename} not found beside {Path(__file__).name}")
659
+ module = importlib.util.module_from_spec(spec)
660
+ spec.loader.exec_module(module)
661
+ _SIBLINGS[filename] = module
662
+ return module
663
+
664
+
665
+ def read_result(path: str, flag: str):
666
+ """The JSON a script wrote. Raises AdapterError."""
667
+ try:
668
+ return json.loads(Path(path).read_bytes().decode("utf-8-sig"))
669
+ except (OSError, UnicodeDecodeError) as exc:
670
+ raise AdapterError(f"cannot read {flag} {path}: {exc}") from exc
671
+ except json.JSONDecodeError as exc:
672
+ raise AdapterError(f"{flag} {path} is not JSON: {exc.msg}") from exc
673
+
674
+
675
+ def _object(data: object, label: str, *keys: str) -> dict:
676
+ """`data` as the object a script writes, with each of `keys`."""
677
+ if not isinstance(data, dict) or any(key not in data for key in keys):
678
+ needs = ", ".join(f"'{key}'" for key in keys)
679
+ raise AdapterError(f"{label} is not that script's result: it needs an object with {needs}")
680
+ return data
681
+
682
+
683
+ def _list(data: dict, key: str, label: str) -> list:
684
+ value = data.get(key)
685
+ if value is None:
686
+ return []
687
+ if not isinstance(value, list):
688
+ raise AdapterError(f"{label}: '{key}' must be a list")
689
+ return value
690
+
691
+
692
+ def _names(data: dict, key: str, label: str) -> list[str]:
693
+ return [n for n in _list(data, key, label) if isinstance(n, str) and n]
694
+
695
+
696
+ def _count(value: object) -> int | None:
697
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
698
+
699
+
700
+ def _code(names: list[str]) -> str:
701
+ return ", ".join(f"`{n}`" for n in names)
702
+
703
+
704
+ def _gap(severity: str, category: str, title: str, source: str, remediation: str,
705
+ issue: str | None = None, export: str | None = None) -> dict:
706
+ record = {"severity": severity, "category": category, "title": title, "source": source,
707
+ "remediation": remediation}
708
+ if issue:
709
+ record["issue"] = issue
710
+ if export:
711
+ record["export"] = export
712
+ return record
713
+
714
+
715
+ def _surface_sources(surface: dict | None) -> dict[str, str]:
716
+ """name -> `file:line` (or `file`) from a surface result's `exports[]`:
717
+ the first record with a file, one with a line preferred."""
718
+ sources: dict[str, tuple[bool, str]] = {}
719
+ for rec in (surface or {}).get("exports") or []:
720
+ if not isinstance(rec, dict) or not isinstance(rec.get("name"), str):
721
+ continue
722
+ file = rec.get("file")
723
+ if not isinstance(file, str) or not file:
724
+ continue
725
+ line = _count(rec.get("line"))
726
+ where = (line is not None, f"{file}:{line}" if line is not None else file)
727
+ known = sources.get(rec["name"])
728
+ if known is None or (where[0] and not known[0]):
729
+ sources[rec["name"]] = where
730
+ return {name: where for name, (_, where) in sources.items()}
731
+
732
+
733
+ def _provenance_sources(provenance: dict | None) -> dict[str, str]:
734
+ """export_name -> `source_file:source_line` of its first provenance entry."""
735
+ sources: dict[str, str] = {}
736
+ for entry in (provenance or {}).get("entries") or []:
737
+ if not isinstance(entry, dict) or not isinstance(entry.get("export_name"), str):
738
+ continue
739
+ file = entry.get("source_file")
740
+ if entry["export_name"] in sources or not isinstance(file, str) or not file:
741
+ continue
742
+ line = _count(entry.get("source_line"))
743
+ sources[entry["export_name"]] = f"{file}:{line}" if line is not None else file
744
+ return sources
745
+
746
+
747
+ def _documenting_line(skill_dir: Path, name: str) -> str | None:
748
+ """The first line of SKILL.md, then of references/*.md in path order,
749
+ that writes `name` (validate-inventory.py's match), as `file:line`."""
750
+ pattern = _sibling("validate-inventory.py").name_pattern(name)
751
+ refs = skill_dir / "references"
752
+ files = [skill_dir / "SKILL.md"] + (sorted(refs.glob("*.md")) if refs.is_dir() else [])
753
+ for path in files:
754
+ try:
755
+ text = path.read_bytes().decode("utf-8-sig", errors="replace")
756
+ except OSError:
757
+ continue
758
+ for number, line in enumerate(text.split("\n"), start=1):
759
+ if pattern.search(line):
760
+ return f"{path.relative_to(skill_dir).as_posix()}:{number}"
761
+ return None
762
+
763
+
764
+ def _missing_export(name: str, where: str | None, meta: str, kind: str = "export") -> dict:
765
+ if where:
766
+ read = f"read its definition at `{where}`"
767
+ else:
768
+ read = f"find its definition in the source (`{meta}` lists it)"
769
+ if kind == "type":
770
+ return _gap("Medium", "missing-type", f"Missing type: {name}", where or meta,
771
+ f"Document the type `{name}` in SKILL.md: {read} and list its fields or members.",
772
+ issue=f"the type `{name}` is on the source API surface, and the skill does not document it",
773
+ export=name)
774
+ return _gap("Medium", "missing-export", f"Missing export: {name}", where or meta,
775
+ f"Document `{name}` in SKILL.md: {read} and add its signature, its purpose and a usage "
776
+ "example.",
777
+ issue=f"`{name}` is on the source API surface, and SKILL.md and references/ do not document it",
778
+ export=name)
779
+
780
+
781
+ def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
782
+ stale: object = None, provenance: object = None, skill_dir: str | None = None,
783
+ metadata: str | None = None) -> list[dict]:
784
+ """reconcile-coverage.py's result: missing and stale names, or the missing count."""
785
+ cov = _object(cov, "--input (reconcile-coverage.py)", "branch")
786
+ meta = metadata or _DEFAULT_METADATA
787
+ branch = cov["branch"]
788
+ records: list[dict] = []
789
+ if branch == "enumerated":
790
+ types = set()
791
+ if signatures is not None:
792
+ types = set(_names(_object(signatures, "--signatures (score-signatures.py)", "missingTypes"),
793
+ "missingTypes", "--signatures"))
794
+ where = _surface_sources(_object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
795
+ if surface is not None else None)
796
+ for name in _names(cov, "missing", "--input"):
797
+ records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
798
+ classified: dict[str, dict] = {}
799
+ if stale is not None:
800
+ if not isinstance(stale, list):
801
+ raise AdapterError("--stale is not classify-stale JSON (a list)")
802
+ classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
803
+ for name in _names(cov, "stale", "--input"):
804
+ item = classified.get(name) or {}
805
+ cited = item.get("source")
806
+ if item.get("fabricated") is True:
807
+ if not isinstance(cited, str) or not cited:
808
+ # a Critical gap needs the line it cites: never downgrade it
809
+ raise AdapterError(f"--stale: {name!r} is fabricated with no source")
810
+ records.append(_gap(
811
+ "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
812
+ f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
813
+ f"defines: no file its provenance cites defines it (`{cited}`).",
814
+ issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
815
+ "file does not define",
816
+ export=name))
817
+ continue
818
+ doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
819
+ records.append(_gap(
820
+ "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
821
+ f"Check `{name}` against the source and update or remove its documentation at "
822
+ f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
823
+ issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
824
+ export=name))
825
+ elif branch in ("scalar", "stack"):
826
+ if branch == "scalar" and cov.get("numeratorSource") == "verified":
827
+ if numerator is None:
828
+ raise AdapterError("the scalar count used the verified numerator: pass its result with "
829
+ "--numerator")
830
+ absent = _names(_object(numerator, "--numerator (verify-declared-numerator.py)", "absent"),
831
+ "absent", "--numerator")
832
+ where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
833
+ if provenance is not None else None)
834
+ records += [_missing_export(name, where.get(name), meta) for name in absent]
835
+ else:
836
+ missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
837
+ if missing:
838
+ records.append(_gap(
839
+ "Medium", "missing-export", f"{missing} of {denominator} exports not documented", meta,
840
+ f"Find the {missing} exports the denominator counts that SKILL.md and references/ do not "
841
+ f"name (`{meta}` lists the exports) and document each with its signature.",
842
+ issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
843
+ f"the {branch} denominator counts"))
844
+ elif branch != "docsOnly":
845
+ raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
846
+ return records
847
+
848
+
849
+ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
850
+ """load-coverage-inputs.py surface's deflation and inflation guards."""
851
+ surface = _object(surface, "--input (load-coverage-inputs.py surface)", "guards", "inputs")
852
+ guards = surface["guards"]
853
+ if guards is None:
854
+ return []
855
+ if not isinstance(guards, dict):
856
+ raise AdapterError("--input (load-coverage-inputs.py surface): 'guards' must be an object or null")
857
+ inputs = surface["inputs"] if isinstance(surface["inputs"], dict) else {}
858
+ meta = metadata or inputs.get("metadata") or _DEFAULT_METADATA
859
+ brief = inputs.get("brief") or _DEFAULT_BRIEF
860
+ records: list[dict] = []
861
+ deflation = guards.get("deflation") or {}
862
+ if deflation.get("fires") is True:
863
+ records.append(_gap(
864
+ "Medium", "metadata-drift",
865
+ "denominator deflation: effective_denominator below source public surface without tier_a_include",
866
+ meta,
867
+ f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, or "
868
+ "add `scope.tier_a_include` to the brief to name the authored tier the smaller count measures.",
869
+ issue=f"the re-derived surface counts {deflation.get('rederived')} exports, "
870
+ f"{deflation.get('pct')}% above `stats.effective_denominator` "
871
+ f"({deflation.get('effectiveDenominator')}), and the brief has no `scope.tier_a_include`"))
872
+ inflation = guards.get("inflation") or {}
873
+ if inflation.get("fires") is True:
874
+ if (guards.get("umbrella") or {}).get("umbrella") is True:
875
+ fix = (f"Set `stats.effective_denominator` in `{meta}` to the authored surface: the root barrel "
876
+ "is an umbrella of re-exports, so a `scope.tier_a_include` glob would still count them.")
877
+ else:
878
+ fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface, so "
879
+ "the denominator counts it instead of the coarse `scope.include` union.")
880
+ records.append(_gap(
881
+ "Medium", "denominator-inflation",
882
+ "denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
883
+ issue=f"the `scope.include` union counts {inflation.get('scopeIncludeUnion')} exports, "
884
+ f"{inflation.get('pct')}% above the {inflation.get('provenanceEntries')} provenance entries, "
885
+ "and the brief has no `scope.tier_a_include`"))
886
+ return records
887
+
888
+
889
+ def from_numerator(numerator: object, metadata: str | None = None) -> list[dict]:
890
+ """verify-declared-numerator.py's result: the numerator inflation gap."""
891
+ numerator = _object(numerator, "--input (verify-declared-numerator.py)", "inflated")
892
+ if numerator["inflated"] is not True:
893
+ return []
894
+ meta = metadata or _DEFAULT_METADATA
895
+ declared, verified = _count(numerator.get("declared")) or 0, _count(numerator.get("verified")) or 0
896
+ absent = _names(numerator, "absent", "--input")
897
+ return [_gap(
898
+ "High", "numerator-inflation",
899
+ f"numerator inflation: {declared - verified} of {declared} declared exports absent from "
900
+ "SKILL.md/references",
901
+ meta,
902
+ f"Document the absent exports in SKILL.md, or set `stats.exports_documented` in `{meta}` to the "
903
+ f"{verified} exports the skill documents.",
904
+ issue=f"`stats.exports_documented` equals the denominator, and these declared exports are absent "
905
+ f"from SKILL.md and references/: {_code(absent)}")]
906
+
907
+
908
+ def from_metadata_coherence(result: object, metadata: str | None = None) -> list[dict]:
909
+ """check-metadata-coherence.py's findings: count drift and multi-denominator notes."""
910
+ result = _object(result, "--input (check-metadata-coherence.py)", "findings")
911
+ meta = metadata or _DEFAULT_METADATA
912
+ records: list[dict] = []
913
+ for index, finding in enumerate(_list(result, "findings", "--input")):
914
+ if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
915
+ raise AdapterError(f"--input: findings[{index}] has no title")
916
+ detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
917
+ if finding.get("severity") == "Medium":
918
+ records.append(_gap(
919
+ "Medium", "metadata-drift", finding["title"], meta,
920
+ f"Recount the exports and correct the diverging counts in `{meta}`; update-skill applies it as "
921
+ "a metadata update.",
922
+ issue=detail))
923
+ elif finding.get("severity") == "Info":
924
+ records.append(_gap(
925
+ "Info", "multi-denominator", finding["title"], meta,
926
+ "No action required: the barrel and the documented surface measure different sets by design, "
927
+ "and the report records both counts.",
928
+ issue=detail))
929
+ else:
930
+ raise AdapterError(f"--input: findings[{index}] has severity {finding.get('severity')!r}, "
931
+ "neither Medium nor Info")
932
+ return records
933
+
934
+
935
+ def from_provenance_line(result: object) -> list[dict]:
936
+ """verify's `line-not-definition` items: provenance line gaps."""
937
+ result = _object(result, "--input (skf-verify-provenance-completeness.py verify)", "stale")
938
+ records: list[dict] = []
939
+ for item in _list(result, "stale", "--input"):
940
+ if not isinstance(item, dict) or item.get("reason") != "line-not-definition":
941
+ continue
942
+ name, file, line = item.get("export_name"), item.get("source_file"), item.get("source_line")
943
+ defs = [n for n in item.get("definition_lines") or [] if _count(n) is not None]
944
+ where = f"{file}:{line}"
945
+ if defs:
946
+ lines = ", ".join(str(n) for n in defs)
947
+ records.append(_gap(
948
+ "Low", "provenance-line", f"Provenance line is not the definition of {name}", where,
949
+ f"Set the provenance `source_line` of `{name}` in `{file}` to its definition line ({lines}) "
950
+ "and move its citations to that line; update-skill `--from-test-report` applies this when the "
951
+ "file defines it on one line.",
952
+ issue=f"the provenance map records `{name}` at `{where}`, which is not the line that defines "
953
+ f"it; the lines that define it are {lines}.",
954
+ export=name))
955
+ else:
956
+ records.append(_gap(
957
+ "Info", "provenance-unverified", f"Provenance line not verified for {name}", where,
958
+ f"Open `{file}` and confirm that line {line} defines `{name}`; if another line does, set the "
959
+ "provenance `source_line` to it.",
960
+ issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand",
961
+ export=name))
962
+ return records
963
+
964
+
965
+ def from_coherence(result: object) -> list[dict]:
966
+ """aggregate-coherence.py's invalid references."""
967
+ result = _object(result, "--input (aggregate-coherence.py)", "invalidReferences")
968
+ records: list[dict] = []
969
+ for index, ref in enumerate(_list(result, "invalidReferences", "--input")):
970
+ if not isinstance(ref, dict):
971
+ raise AdapterError(f"--input: invalidReferences[{index}] is not an object")
972
+ target, line = ref.get("target"), _count(ref.get("line"))
973
+ where = f"SKILL.md:{line}" if line is not None else "SKILL.md"
974
+ status = ref.get("status")
975
+ if status == "missing":
976
+ records.append(_gap(
977
+ "Critical", "broken-reference", f"coherence: broken reference: {target}", where,
978
+ f"Point the reference to `{target}` at {where} at a target that exists, or remove it: "
979
+ "nothing exists at its target.",
980
+ issue="; ".join(i for i in ref.get("issues") or [] if isinstance(i, str)) or None))
981
+ elif status == "inaccurate":
982
+ issues = "; ".join(i for i in ref.get("issues") or [] if isinstance(i, str))
983
+ records.append(_gap(
984
+ "High", "inaccurate-reference", f"coherence: inaccurate reference: {target}", where,
985
+ f"Correct the reference to `{target}` at {where} so it matches its target"
986
+ + (f": {issues}." if issues else "."),
987
+ issue=issues or None))
988
+ elif status == "escapes":
989
+ canonical = ref.get("canonical")
990
+ records.append(_gap(
991
+ "High", "reference-escape",
992
+ f"coherence: reference escapes skill/source sandbox: {target} → {canonical}", where,
993
+ f"Point the reference to `{target}` at {where} inside the skill package or its source tree, "
994
+ f"or remove it: it resolves to `{canonical}`."))
995
+ else:
996
+ raise AdapterError(f"--input: invalidReferences[{index}] has an unknown status {status!r}")
997
+ return records
998
+
999
+
1000
+ def _structure_shape(data: object) -> str | None:
1001
+ """Which skf-scan-skill-md-structure.py result `data` is."""
1002
+ if not isinstance(data, dict):
1003
+ return None
1004
+ if all(isinstance(data.get(f), dict) and "satisfied" in data[f] for f in FAMILIES):
1005
+ return "required-sections"
1006
+ if "zero_usage" in data:
1007
+ return "usage-scope"
1008
+ if "unbalanced_fences" in data:
1009
+ return "scan"
1010
+ if "references" in data and "counts" in data:
1011
+ return "reference-check"
1012
+ return None
1013
+
1014
+
1015
+ def _scripts_assets_gap(data: dict, prefix: str) -> list[dict]:
1016
+ section = data.get("scripts_assets")
1017
+ if not isinstance(section, dict) or section.get("missing") is not True:
1018
+ return []
1019
+ folders = [f for f in section.get("folders") or [] if isinstance(f, str)]
1020
+ named = " and ".join(f"`{f}/`" for f in folders) or "`scripts/` and `assets/`"
1021
+ return [_gap(
1022
+ "Medium", "scripts-assets",
1023
+ f"{prefix}: scripts/assets directory exists but Scripts & Assets section missing", "SKILL.md",
1024
+ f"Add a `## Scripts & Assets` section to SKILL.md that lists each file in {named} with its purpose.")]
1025
+
1026
+
1027
+ def from_structure(results: list[object], served: list[str] | None = None) -> list[dict]:
1028
+ """skf-scan-skill-md-structure.py results: the naive structural gaps, and
1029
+ the Scripts & Assets gap of either mode."""
1030
+ served_set = set(served or [])
1031
+ records: list[dict] = []
1032
+ for index, data in enumerate(results):
1033
+ shape = _structure_shape(data)
1034
+ if shape is None:
1035
+ raise AdapterError(f"--input #{index + 1} is not a scan, scan --required-sections, usage-scope or "
1036
+ "reference-check result")
1037
+ if shape == "required-sections":
1038
+ for family in FAMILIES:
1039
+ entry = data[family]
1040
+ if entry.get("satisfied") is True or family in served_set:
1041
+ continue
1042
+ if family == "description" and data.get("frontmatter_description") is True:
1043
+ continue
1044
+ tried = [t for t in entry.get("tried") or [] if isinstance(t, str)]
1045
+ records.append(_gap(
1046
+ "High", "structural", f"naive-coherence: missing required section: {family}", "SKILL.md",
1047
+ _SECTION_FIX[family],
1048
+ issue=f"no heading of SKILL.md serves the {family} family (synonyms tried: "
1049
+ f"{', '.join(tried)})"))
1050
+ elif shape == "scan":
1051
+ if data.get("unbalanced_fences") is True:
1052
+ records.append(_gap(
1053
+ "High", "structural", "naive-coherence: unbalanced code fence (unclosed block)", "SKILL.md",
1054
+ "Close the code block SKILL.md leaves open with a fence line of three backticks.",
1055
+ issue=f"SKILL.md has {data.get('fence_count')} fence lines, so one block is never closed"))
1056
+ for fence in _list(data, "bare_opening_fences", "--input"):
1057
+ line = _count(fence.get("line")) if isinstance(fence, dict) else None
1058
+ records.append(_gap(
1059
+ "Medium", "structural",
1060
+ f"naive-coherence: opening code fence at line {line} missing language tag",
1061
+ f"SKILL.md:{line}",
1062
+ f"Add a language tag (such as `bash` or `python`) to the opening fence at SKILL.md line "
1063
+ f"{line}."))
1064
+ for row in _list(data, "table_drift", "--input"):
1065
+ if not isinstance(row, dict):
1066
+ continue
1067
+ line, expected = _count(row.get("line")), _count(row.get("expected_cols"))
1068
+ where = f"section `{row['section']}`: " if row.get("section") else ""
1069
+ records.append(_gap(
1070
+ "Medium", "structural",
1071
+ f"naive-coherence: table row at line {line} has {row.get('actual_cols')} columns; "
1072
+ f"header has {expected}",
1073
+ f"SKILL.md:{line}",
1074
+ f"Give the row at SKILL.md line {line} the {expected} columns its header has (write a pipe "
1075
+ "inside a cell as `\\|`).",
1076
+ issue=f"{where}{row.get('row')}" if row.get("row") else None))
1077
+ records += _scripts_assets_gap(data, "naive-coherence")
1078
+ elif shape == "usage-scope":
1079
+ for export in _list(data, "zero_usage", "--input"):
1080
+ if not isinstance(export, dict) or not isinstance(export.get("name"), str):
1081
+ continue
1082
+ name, kind = export["name"], export.get("kind") or "export"
1083
+ records.append(_gap(
1084
+ "Medium", "structural",
1085
+ f"naive-coherence: exported {kind} `{name}` is not referenced in any usage-family "
1086
+ "section or reference file",
1087
+ "SKILL.md",
1088
+ f"Add an example that calls `{name}` to SKILL.md's usage section (or to a references/ file "
1089
+ "for a split body).",
1090
+ export=name))
1091
+ else:
1092
+ records += _scripts_assets_gap(data, "coherence")
1093
+ return records
1094
+
1095
+
1096
+ def build_records(args: argparse.Namespace) -> list[dict]:
1097
+ """The records `append --from` appends. Raises AdapterError."""
1098
+ kind = args.adapter
1099
+ stray = sorted(o for o in ALL_ADAPTER_OPTIONS - ADAPTER_OPTIONS[kind] if getattr(args, o) is not None)
1100
+ if stray:
1101
+ flags = ", ".join("--" + o.replace("_", "-") for o in stray)
1102
+ raise AdapterError(f"--from {kind} does not read {flags}")
1103
+ inputs = args.input or []
1104
+ if not inputs or (kind != "structure" and len(inputs) > 1):
1105
+ wanted = "one or more --input files" if kind == "structure" else "one --input file"
1106
+ raise AdapterError(f"--from {kind} reads {wanted}")
1107
+ if kind == "structure":
1108
+ return from_structure([read_result(p, "--input") for p in inputs], args.served)
1109
+ data = read_result(inputs[0], "--input")
1110
+ if kind == "coverage":
1111
+ files = {o: read_result(getattr(args, o), "--" + o) if getattr(args, o) is not None else None
1112
+ for o in ("surface", "signatures", "numerator", "stale", "provenance")}
1113
+ return from_coverage(data, skill_dir=args.skill_dir, metadata=args.metadata, **files)
1114
+ if kind == "guards":
1115
+ return from_guards(data, args.metadata)
1116
+ if kind == "numerator":
1117
+ return from_numerator(data, args.metadata)
1118
+ if kind == "metadata-coherence":
1119
+ return from_metadata_coherence(data, args.metadata)
1120
+ if kind == "provenance-line":
1121
+ return from_provenance_line(data)
1122
+ return from_coherence(data)
1123
+
1124
+
1125
+ # --------------------------------------------------------------------------
1126
+ # Gap Report
1127
+ # --------------------------------------------------------------------------
1128
+
1129
+
1130
+ def render_gap_report(ledger: dict, heading: bool = False) -> str:
1131
+ """The Gap Report section body, from the ledger (report.md §4c writes it)."""
1132
+ records = ordered(ledger["records"])
1133
+ counts = count_by_severity(records)
1134
+ blocking = sum(1 for r in records if is_blocking(r))
1135
+ after_gate = counts["Critical"] + counts["High"] - blocking
1136
+ lines: list[str] = []
1137
+ if heading:
1138
+ lines += ["## Gap Report", ""]
1139
+ lines += [
1140
+ f"**Total Gaps:** {len(records)}",
1141
+ f"**Blocking (Critical + High):** {blocking}",
1142
+ f"**Non-blocking (Medium + Low + Info):** {len(records) - blocking - after_gate}",
1143
+ ]
1144
+ if after_gate:
1145
+ lines.append(f"**Found after the hard gate (Critical + High, non-blocking):** {after_gate}")
1146
+ lines.append("")
1147
+ if not records:
1148
+ lines += ["No gaps found.", ""]
1149
+ return "\n".join(lines)
1150
+ lines += [
1151
+ "### Remediation Summary",
1152
+ "",
1153
+ "| Severity | Count | Estimated Effort |",
1154
+ "|----------|-------|------------------|",
1155
+ ]
1156
+ for severity in SEVERITIES:
1157
+ effort = EFFORT[severity] if counts[severity] else "None"
1158
+ lines.append(f"| {severity} | {counts[severity]} | {effort} |")
1159
+ lines += [f"| **Total** | **{len(records)}** | |", ""]
1160
+ for record in records:
1161
+ lines += [
1162
+ f"### {record['id']}: {record['title']}",
1163
+ "",
1164
+ f"**Severity:** {record['severity']}",
1165
+ # The group comes from the category, as at append time: a ledger
1166
+ # written by hand may lack it.
1167
+ f"**Category:** {CATEGORIES[record['category']][0]} ({record['category']})",
1168
+ f"**Source:** {record['source']}",
1169
+ ]
1170
+ if record.get("export"):
1171
+ lines.append(f"**Export:** {record['export']}")
1172
+ lines.append("")
1173
+ if record.get("issue"):
1174
+ lines += [f"**Issue:** {record['issue']}", ""]
1175
+ lines += [f"**Remediation:** {record['remediation']}", ""]
1176
+ return "\n".join(lines)
1177
+
1178
+
1179
+ def summarize(path: Path, ledger: dict) -> dict:
1180
+ records = ledger["records"]
1181
+ counts = count_by_severity(records)
1182
+ by_category: dict[str, int] = {}
1183
+ for record in records:
1184
+ by_category[record["category"]] = by_category.get(record["category"], 0) + 1
1185
+ blocking = sum(1 for r in records if is_blocking(r))
1186
+ return {
1187
+ "status": "ok",
1188
+ "ledger": str(path),
1189
+ "run_id": ledger.get("run_id"),
1190
+ "stages": list(ledger["stages"]),
1191
+ "total": len(records),
1192
+ "counts": counts,
1193
+ "blocking": blocking,
1194
+ "non_blocking": len(records) - blocking,
1195
+ "by_category": dict(sorted(by_category.items())),
1196
+ }
1197
+
1198
+
1199
+ # --------------------------------------------------------------------------
1200
+ # CLI
1201
+ # --------------------------------------------------------------------------
1202
+
1203
+
1204
+ def _emit(payload: dict, stream=None) -> None:
1205
+ print(json.dumps(payload, indent=2, ensure_ascii=False), file=stream or sys.stdout)
1206
+
1207
+
1208
+ def _error(code: str, message: str, **extra) -> dict:
1209
+ return {"status": "error", "code": code, "error": message, **extra}
1210
+
1211
+
1212
+ def _read_records(args: argparse.Namespace) -> list:
1213
+ """The records an append reads: built by --from, else from each --input
1214
+ file in turn, else from stdin. Raises AdapterError, OSError or ValueError."""
1215
+ if args.adapter:
1216
+ return build_records(args)
1217
+ given = sorted(o for o in ALL_ADAPTER_OPTIONS if getattr(args, o) is not None)
1218
+ if given:
1219
+ flags = ", ".join("--" + o.replace("_", "-") for o in given)
1220
+ raise AdapterError(f"{flags} work only with --from")
1221
+ if not args.input:
1222
+ # A byte order mark on stdin (a Windows editor or shell) is not JSON.
1223
+ return parse_input(sys.stdin.read().removeprefix("\ufeff"))
1224
+ records: list = []
1225
+ for path in args.input:
1226
+ records += parse_input(Path(path).read_text(encoding="utf-8-sig"))
1227
+ return records
1228
+
1229
+
1230
+ def _cmd_append(args: argparse.Namespace) -> int:
1231
+ path = Path(args.ledger)
1232
+ if not _STAGE_RE.match(args.stage):
1233
+ _emit(_error("INVALID_INPUT", f"--stage must be a lowercase slug, got '{args.stage}'"))
1234
+ return 2
1235
+ try:
1236
+ raw_records = _read_records(args)
1237
+ except AdapterError as exc:
1238
+ _emit(_error("INVALID_INPUT", str(exc)))
1239
+ return 2
1240
+ except (OSError, UnicodeDecodeError, ValueError) as exc:
1241
+ # json.JSONDecodeError is a ValueError.
1242
+ _emit(_error("INVALID_INPUT", f"cannot read the records: {exc}"))
1243
+ return 2
1244
+ records: list[dict] = []
1245
+ errors: list[dict] = []
1246
+ for index, raw in enumerate(raw_records):
1247
+ record, err = normalize_record(raw)
1248
+ if err:
1249
+ errors.append({"index": index, "error": err})
1250
+ else:
1251
+ records.append(record)
1252
+ if errors:
1253
+ _emit(_error("INVALID_RECORD", f"{len(errors)} invalid record(s); nothing was written", errors=errors))
1254
+ return 2
1255
+ try:
1256
+ # Read, append and write as one step: an append that overlaps this
1257
+ # one waits here instead of writing over the records it adds.
1258
+ with ledger_lock(path):
1259
+ ledger = load_ledger(path) if path.exists() else new_ledger(path)
1260
+ appended, duplicates = append_records(ledger, args.stage, records)
1261
+ save_ledger(path, ledger)
1262
+ except LedgerError as exc:
1263
+ _emit(_error(exc.code, str(exc)))
1264
+ return 1
1265
+ except OSError as exc:
1266
+ _emit(_error("WRITE_FAILED", f"cannot write {path}: {exc}"))
1267
+ return 1
1268
+ _emit(
1269
+ {
1270
+ "status": "ok",
1271
+ "ledger": str(path),
1272
+ "stage": args.stage,
1273
+ "appended": appended,
1274
+ "duplicates": duplicates,
1275
+ "record_count": len(ledger["records"]),
1276
+ }
1277
+ )
1278
+ return 0
1279
+
1280
+
1281
+ def _cmd_render(args: argparse.Namespace) -> int:
1282
+ path = Path(args.ledger)
1283
+ try:
1284
+ ledger = load_ledger(path)
1285
+ except LedgerError as exc:
1286
+ _emit(_error(exc.code, str(exc)), stream=sys.stderr)
1287
+ return 1
1288
+ sys.stdout.write(render_gap_report(ledger, heading=args.heading))
1289
+ return 0
1290
+
1291
+
1292
+ def _cmd_summary(args: argparse.Namespace) -> int:
1293
+ path = Path(args.ledger)
1294
+ try:
1295
+ ledger = load_ledger(path)
1296
+ except LedgerError as exc:
1297
+ _emit(_error(exc.code, str(exc)))
1298
+ return 1
1299
+ _emit(summarize(path, ledger))
1300
+ return 0
1301
+
1302
+
1303
+ def _cmd_categories(args: argparse.Namespace) -> int:
1304
+ _emit(
1305
+ {
1306
+ "status": "ok",
1307
+ "severities": list(SEVERITIES),
1308
+ "categories": [
1309
+ {"category": slug, "group": group, "description": description}
1310
+ for slug, (group, description) in CATEGORIES.items()
1311
+ ],
1312
+ }
1313
+ )
1314
+ return 0
1315
+
1316
+
1317
+ def _build_parser() -> argparse.ArgumentParser:
1318
+ parser = argparse.ArgumentParser(
1319
+ prog="gap-ledger",
1320
+ description="Append, render and summarize the test-skill gap ledger.",
1321
+ )
1322
+ sub = parser.add_subparsers(dest="cmd", required=True)
1323
+
1324
+ p = sub.add_parser("append", help="append records from stdin, --input or a script's result (--from)")
1325
+ p.add_argument("--ledger", required=True, help="{forge_version}/test-findings-{run_id}.json")
1326
+ p.add_argument("--stage", required=True, help="the stage appending, e.g. coverage-check")
1327
+ p.add_argument("--input", action="append",
1328
+ help="a JSON file with the records (default: stdin); with --from, the script result to read "
1329
+ "(structure takes it more than once)")
1330
+ p.add_argument("--from", dest="adapter", choices=tuple(ADAPTER_OPTIONS),
1331
+ help="build the records from the --input result of a script (see the docstring's Adapters)")
1332
+ p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds)")
1333
+ p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
1334
+ p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
1335
+ p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures)")
1336
+ p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
1337
+ p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
1338
+ p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")
1339
+ p.add_argument("--served", action="append", choices=FAMILIES,
1340
+ help="structure: a family another heading serves (the caller's judgment); repeatable")
1341
+ p.set_defaults(func=_cmd_append)
1342
+
1343
+ p = sub.add_parser("render", help="print the Gap Report in Markdown")
1344
+ p.add_argument("--ledger", required=True)
1345
+ p.add_argument("--heading", action="store_true", help="start with the ## Gap Report line")
1346
+ p.set_defaults(func=_cmd_render)
1347
+
1348
+ p = sub.add_parser("summary", help="counts by severity and category")
1349
+ p.add_argument("--ledger", required=True)
1350
+ p.set_defaults(func=_cmd_summary)
1351
+
1352
+ p = sub.add_parser("categories", help="list the category vocabulary")
1353
+ p.set_defaults(func=_cmd_categories)
1354
+
1355
+ return parser
1356
+
1357
+
1358
+ def _force_utf8(*streams) -> None:
1359
+ """Reconfigure the JSON and Markdown streams to UTF-8.
1360
+
1361
+ A default Windows console uses cp1252, which cannot carry a non-ASCII
1362
+ title or remediation. Keeps each stream's error handler; skips test
1363
+ doubles without reconfigure(). For stdin this must run before the read.
1364
+ """
1365
+ for stream in streams:
1366
+ if hasattr(stream, "reconfigure"):
1367
+ errors = getattr(stream, "errors", None)
1368
+ if errors is None:
1369
+ stream.reconfigure(encoding="utf-8")
1370
+ else:
1371
+ stream.reconfigure(encoding="utf-8", errors=errors)
1372
+
1373
+
1374
+ def main(argv: list[str] | None = None) -> int:
1375
+ _force_utf8(sys.stdin, sys.stdout, sys.stderr)
1376
+ args = _build_parser().parse_args(argv)
1377
+ return args.func(args)
1378
+
1379
+
1380
+ if __name__ == "__main__":
1381
+ raise SystemExit(main())