bmad-module-skill-forge 2.2.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 +49 -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 +12 -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 +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  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 +209 -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 +15 -5
  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 +28 -19
  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 -59
  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 -90
  348. package/src/skf-setup/references/detect-and-tier.md +58 -67
  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 -679
  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
@@ -1,29 +1,42 @@
1
1
  #!/usr/bin/env python3
2
2
  # /// script
3
- # requires-python = ">=3.10"
3
+ # requires-python = ">=3.11"
4
4
  # dependencies = []
5
5
  # ///
6
6
  """SKF Find Cycles — enumerate the simple directed cycles in a pair graph.
7
7
 
8
- `skf-verify-stack`'s `integrations.md` §4 (Cross-Reference Each Integration
9
- Pair → Cycle detection) asks the LLM to build a directed pair graph — an edge
10
- `A → B` exists when skill A literally cites skill B via Check 4 — and then to
11
- "run cycle detection (DFS with visited + recursion stack)", appending one
12
- synthetic `Risky` verdict row per circular integration dependency found.
13
-
14
- Deciding *which* edges exist is a semantic judgment (Check-4 literal-citation
15
- analysis) and stays in the prompt. But once the edge set is fixed, the set of
16
- directed cycles is fully determined — a graph traversal with exactly one
17
- correct answer per edge set. In-prompt DFS silently misses real multi-hop
18
- cycles or invents spurious ones as the pair count grows; this helper makes the
19
- traversal deterministic and unit-testable. The prompt hands over the edges as
20
- JSON and appends the synthetic rows from `cycles[]` — no traversal by hand.
8
+ `skf-verify-stack`'s `integrations.md` (Cross-Reference Each Integration
9
+ Pair, Cycle detection) reads a directed pair graph: an edge `A -> B` exists
10
+ when skill A literally cites skill B (Check 4), one edge per citing direction
11
+ of the SKILL.md scanner's citations file. It appends one synthetic `Risky`
12
+ verdict row per cycle this helper lists. analyze-source runs it over import
13
+ edges, where two units that import each other are a mutual dependency.
14
+
15
+ Deciding *which* edges exist is a semantic judgment (Check 4 drops a direction
16
+ whose hits name only a common word) and stays in the prompt, which hands it
17
+ over as --exclude-edges. Once the edge set is fixed, the set of directed
18
+ cycles is fully determined: a graph traversal with exactly one correct answer
19
+ per edge set. In-prompt DFS silently misses real multi-hop cycles or invents
20
+ spurious ones as the pair count grows; this helper makes the traversal
21
+ deterministic and unit-testable, so nothing is traversed or filtered by hand.
21
22
 
22
23
  Subcommand:
23
- find --edges <json-file-or-'-'>
24
- --edges path to the edges JSON, or '-' to read from stdin.
25
-
26
- Input shape (the Check-4 citation edges, one [from, to] pair each):
24
+ find --edges <json-file-or-'-'> [--exclude-edges <json-file-or-'-'>]
25
+ [--skip-mutual]
26
+ --edges path to the edges JSON, or '-' to read from stdin.
27
+ --exclude-edges path to a JSON of the same shape, or '-' to read it
28
+ from stdin (not both inputs): each edge it lists is
29
+ left out of --edges before the traversal (an edge it
30
+ lists that --edges lacks changes nothing).
31
+ --skip-mutual leave out every cycle whose edges all have their
32
+ reverse in the edge set: two skills that cite each
33
+ other (["A", "B", "A"]), or a loop of such pairs,
34
+ give Check 4 evidence for their pairs, not a circular
35
+ dependency. A cycle with a one-way edge, and a
36
+ self-loop, stays.
37
+
38
+ Input shape (the Check-4 citation edges, one [from, to] pair each;
39
+ other keys, such as the citations file's, are ignored):
27
40
  {"edges": [["A", "B"], ["B", "C"], ["C", "A"], ...]}
28
41
 
29
42
  Emit JSON:
@@ -41,6 +54,8 @@ Subcommand:
41
54
 
42
55
  CLI examples:
43
56
  uv run skf-find-cycles.py find --edges edges.json
57
+ uv run skf-find-cycles.py find --edges citations.json \\
58
+ --exclude-edges rejected.json --skip-mutual
44
59
  echo '{"edges": [["A","B"],["B","A"]]}' | uv run skf-find-cycles.py find --edges -
45
60
 
46
61
  Exit codes:
@@ -114,14 +129,30 @@ def _canonicalize(cycle_nodes: list[str]) -> tuple[str, ...]:
114
129
  return min(rotations)
115
130
 
116
131
 
117
- def find_cycles(edges: list[tuple[str, str]]) -> dict:
132
+ def _mutual_only(cycle: tuple[str, ...], linked: set[tuple[str, str]]) -> bool:
133
+ """True when every edge of the cycle has its reverse in the edge set."""
134
+ n = len(cycle)
135
+ return all((cycle[(i + 1) % n], cycle[i]) in linked for i in range(n))
136
+
137
+
138
+ def exclude_edges(
139
+ edges: list[tuple[str, str]], excluded: list[tuple[str, str]]
140
+ ) -> list[tuple[str, str]]:
141
+ """Drop every occurrence of each excluded (from, to) edge, keeping order."""
142
+ drop = set(excluded)
143
+ return [edge for edge in edges if edge not in drop]
144
+
145
+
146
+ def find_cycles(edges: list[tuple[str, str]], *, skip_mutual: bool = False) -> dict:
118
147
  """Enumerate every simple directed cycle in the edge set.
119
148
 
120
149
  Deterministic: same edge set → byte-identical output. Runs a DFS from each
121
150
  node (in sorted order) tracking the current recursion path; whenever the
122
151
  walk returns to its start node a cycle is recorded, canonicalised to its
123
152
  minimal rotation, and de-duplicated. Graphs here are small pair graphs, so
124
- the exhaustive enumeration is inexpensive.
153
+ the exhaustive enumeration is inexpensive. With `skip_mutual`, a cycle
154
+ built only from mutual edges (A -> B -> A, or a longer loop whose every
155
+ edge has its reverse) is not recorded; a self-loop is.
125
156
  """
126
157
  # Build sorted, de-duplicated adjacency for deterministic traversal.
127
158
  adj: dict[str, list[str]] = {}
@@ -152,6 +183,10 @@ def find_cycles(edges: list[tuple[str, str]]) -> dict:
152
183
  for start in sorted(nodes):
153
184
  dfs(start, start, [start], {start})
154
185
 
186
+ if skip_mutual:
187
+ linked = set(edges)
188
+ seen = {c for c in seen if len(c) == 1 or not _mutual_only(c, linked)}
189
+
155
190
  # Stable output order: by cycle length, then lexicographically.
156
191
  ordered = sorted(seen, key=lambda c: (len(c), c))
157
192
  cycles = [list(c) + [c[0]] for c in ordered]
@@ -163,30 +198,45 @@ def find_cycles(edges: list[tuple[str, str]]) -> dict:
163
198
  # --------------------------------------------------------------------------
164
199
 
165
200
 
166
- def _read_source(source: str) -> str:
201
+ def _read_source(source: str, flag: str = "--edges") -> str:
167
202
  """Read text from a file path or stdin (if source == '-')."""
168
203
  if source == "-":
169
204
  try:
170
205
  return sys.stdin.read()
171
206
  except OSError as exc:
172
- raise InputError(f"failed to read --edges from stdin: {exc}") from exc
207
+ raise InputError(f"failed to read {flag} from stdin: {exc}") from exc
173
208
  path = Path(source)
174
209
  if not path.is_file():
175
- raise InputError(f"--edges file not found: {path}")
210
+ raise InputError(f"{flag} file not found: {path}")
176
211
  try:
177
212
  return path.read_text(encoding="utf-8")
178
213
  except OSError as exc:
179
- raise InputError(f"failed to read --edges file {path}: {exc}") from exc
214
+ raise InputError(f"failed to read {flag} file {path}: {exc}") from exc
180
215
 
181
216
 
182
- def _cmd_find(args: argparse.Namespace) -> int:
183
- text = _read_source(args.edges)
217
+ def _load_edges(source: str, flag: str) -> list[tuple[str, str]]:
218
+ text = _read_source(source, flag)
184
219
  try:
185
220
  payload = json.loads(text)
186
221
  except json.JSONDecodeError as exc:
187
- raise InputError(f"malformed JSON in --edges input: {exc}") from exc
188
- edges = parse_edges(payload)
189
- result = find_cycles(edges)
222
+ raise InputError(f"malformed JSON in {flag} input: {exc}") from exc
223
+ try:
224
+ return parse_edges(payload)
225
+ except InputError as exc:
226
+ if flag == "--edges":
227
+ raise
228
+ raise InputError(f"{flag}: {exc}") from exc
229
+
230
+
231
+ def _cmd_find(args: argparse.Namespace) -> int:
232
+ if args.edges == "-" and args.exclude_edges == "-":
233
+ raise InputError(
234
+ "--edges and --exclude-edges cannot both read from stdin ('-')"
235
+ )
236
+ edges = _load_edges(args.edges, "--edges")
237
+ if args.exclude_edges is not None:
238
+ edges = exclude_edges(edges, _load_edges(args.exclude_edges, "--exclude-edges"))
239
+ result = find_cycles(edges, skip_mutual=args.skip_mutual)
190
240
  if args.verbose:
191
241
  print(
192
242
  f"analyzed {len(edges)} edge(s); found {result['cycle_count']} cycle(s)",
@@ -219,6 +269,24 @@ def _build_parser() -> argparse.ArgumentParser:
219
269
  'Shape: {"edges": [["<from>", "<to>"], ...]}'
220
270
  ),
221
271
  )
272
+ p_find.add_argument(
273
+ "--exclude-edges",
274
+ default=None,
275
+ help=(
276
+ "path to a JSON of the same shape, or '-' for stdin (not with "
277
+ "--edges -): each edge it lists is left out of --edges before "
278
+ "the traversal"
279
+ ),
280
+ )
281
+ p_find.add_argument(
282
+ "--skip-mutual",
283
+ action="store_true",
284
+ help=(
285
+ "leave out every cycle whose edges all have their reverse in the "
286
+ "edge set (A cites B and B cites A, or a loop of such pairs); a "
287
+ "cycle with a one-way edge stays"
288
+ ),
289
+ )
222
290
  p_find.add_argument(
223
291
  "--verbose",
224
292
  action="store_true",
@@ -0,0 +1,476 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Find Test Report: locate a skill's newest test report and read its verdict.
6
+
7
+ skf-export-skill (load-skill.md section 4b) and skf-update-skill (init.md,
8
+ the --from-test-report lookup, and in a normal run the check for a failing
9
+ report the skill has not been repaired from) both find the test report by
10
+ hand: glob, sort, frontmatter read, with a fallback to the stable result
11
+ file. The same three lookups run in the prompt for every skill of a batch.
12
+ This helper runs them once, the same way for both workflows.
13
+
14
+ Lookup order (the first one that finds a finished report wins):
15
+
16
+ 1. versioned-glob {forge_data_folder}/{skill_name}/{version}/test-report-{skill_name}-*.md
17
+ (only when --version is given)
18
+ 2. flat-glob {forge_data_folder}/{skill_name}/test-report-{skill_name}-*.md
19
+ 3. latest-json skf-test-skill-result-latest.json in the versioned folder,
20
+ then the flat folder: the report named in its outputs[]
21
+
22
+ Within a glob, reports sort newest first by the run id in the file name
23
+ (`test-report-{skill_name}-{YYYYMMDDTHHMMSSZ}-...md`; the timestamp is the
24
+ first part, so the name order is the time order). A name whose part after
25
+ `test-report-{skill_name}-` does not start with a digit belongs to another
26
+ skill (`test-report-foo-bar-...` is not a report of `foo`) and is ignored.
27
+ A report whose frontmatter `testResult` is empty is unfinished (a run that
28
+ stopped before its verdict): it is listed in `skipped[]` and the next older
29
+ report is read instead.
30
+
31
+ Subcommand:
32
+
33
+ find --forge-data-folder <dir> --skill-name <name> [--version <version>]
34
+ [--newer-than <time>] [--provenance-map <provenance-map.json>]
35
+
36
+ Output (stdout):
37
+
38
+ {
39
+ "status": "found" | "not-found",
40
+ "skill_name": "<name>",
41
+ "version": "<version>" | null,
42
+ "path": "<report path>" | null,
43
+ "report_exists": true | false | null,
44
+ "source": "versioned-glob" | "flat-glob" | "latest-json" | null,
45
+ "testResult": "pass" | "fail" | "inconclusive" | "pass-with-drift" | null,
46
+ "score": <number> | null,
47
+ "run_id": "<run id>" | null,
48
+ "test_date": "<testDate>" | null,
49
+ "result_path": "<skf-test-skill-result-latest.json>" | null,
50
+ "skipped": [{"path", "reason": "unfinished"}],
51
+ "warnings": ["..."]
52
+ }
53
+
54
+ `testResult` and `score` come from the report frontmatter. For latest-json,
55
+ when the report the result file names is gone, they come from the result
56
+ file's `summary` (`result`, `score`), `report_exists` is false (true for
57
+ every report the helper read, null when none was found) and a warning says
58
+ so. `testResult` is lower case with hyphens (`PASS_WITH_DRIFT` reads as
59
+ `pass-with-drift`).
60
+
61
+ With --newer-than (update-skill passes the skill's metadata.json
62
+ `generation_date`), the output also holds `"newer": true | false | null`:
63
+ true when the report's time is later than <time>, false when it is not,
64
+ null when no report was found or either time cannot be read. The report's
65
+ time is the later of the `YYYYMMDDTHHMMSSZ` its run id starts with and its
66
+ `test_date`, a time without a zone there read as UTC. <time> is an ISO-8601
67
+ date or date-time: with a zone it is that instant, and a date alone or a
68
+ time without a zone is the earliest instant it can name, 14 hours before
69
+ its UTC reading (the day starts first at UTC+14), so a report from the day
70
+ the skill was generated counts as newer rather than hiding.
71
+
72
+ With --provenance-map (update-skill passes the map it loaded), the output
73
+ also holds `"applied": true | false | null`: true when the map's update
74
+ block says a gap-driven update applied this report (top-level `update_type`
75
+ `gap-driven` and `test_report_run_id` equal to the report's `run_id`),
76
+ false when it does not, null when no report was found or the map cannot be
77
+ read as a JSON object.
78
+
79
+ Exit codes:
80
+ 0 a result was printed (found or not-found)
81
+ 1 --skill-name or --version is not a single folder name (message on
82
+ stderr)
83
+ 2 usage error (argparse: a missing or unknown argument, usage on stderr)
84
+ """
85
+
86
+ from __future__ import annotations
87
+
88
+ import argparse
89
+ import json
90
+ import math
91
+ import re
92
+ import sys
93
+ from datetime import datetime, timedelta, timezone
94
+ from pathlib import Path
95
+
96
+ LATEST_RESULT_NAME = "skf-test-skill-result-latest.json"
97
+ _RUN_ID_RE = re.compile(r"^\d{8}T\d{6}Z(?:-[A-Za-z0-9]+)*$")
98
+ _RUN_ID_STAMP_RE = re.compile(r"^(\d{8}T\d{6}Z)")
99
+ # The easternmost zone (UTC+14): a day there starts 14 hours before UTC's.
100
+ _EARLIEST_ZONE_OFFSET = timedelta(hours=14)
101
+
102
+
103
+ # --------------------------------------------------------------------------
104
+ # Frontmatter
105
+ # --------------------------------------------------------------------------
106
+
107
+
108
+ def read_frontmatter(text: str) -> dict[str, str]:
109
+ """Top-level scalar keys of a `---` frontmatter block, quotes removed.
110
+
111
+ Nested blocks and comments are skipped and a flow list comes back as its
112
+ text: the verdict keys are scalars.
113
+
114
+ >>> read_frontmatter("---\\ntestResult: 'fail'\\nscore: 72\\nsteps: ['a']\\n---\\n# R")
115
+ {'testResult': 'fail', 'score': '72', 'steps': "['a']"}
116
+ >>> read_frontmatter("# no frontmatter")
117
+ {}
118
+ """
119
+ lines = text.lstrip("\ufeff").splitlines()
120
+ if not lines or lines[0].strip() != "---":
121
+ return {}
122
+ out: dict[str, str] = {}
123
+ for line in lines[1:]:
124
+ if line.strip() == "---":
125
+ return out
126
+ m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*?)\s*$", line)
127
+ if not m:
128
+ continue
129
+ value = m.group(2)
130
+ if value.startswith("#"):
131
+ value = ""
132
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
133
+ value = value[1:-1]
134
+ out[m.group(1)] = value
135
+ return {}
136
+
137
+
138
+ def normalize_result(value: object) -> str | None:
139
+ """`PASS_WITH_DRIFT` -> `pass-with-drift`; empty -> None.
140
+
141
+ >>> normalize_result("PASS_WITH_DRIFT"), normalize_result(" Fail "), normalize_result("")
142
+ ('pass-with-drift', 'fail', None)
143
+ """
144
+ if not isinstance(value, str):
145
+ return None
146
+ text = value.strip().lower().replace("_", "-")
147
+ return text or None
148
+
149
+
150
+ def normalize_score(value: object) -> int | float | None:
151
+ """A frontmatter or summary score as a number, or None.
152
+
153
+ >>> normalize_score("85"), normalize_score("72.5"), normalize_score(""), normalize_score("N/A")
154
+ (85, 72.5, None, None)
155
+ >>> normalize_score("nan"), normalize_score(True)
156
+ (None, None)
157
+ """
158
+ if isinstance(value, bool):
159
+ return None
160
+ if isinstance(value, (int, float)):
161
+ number = float(value)
162
+ elif isinstance(value, str):
163
+ try:
164
+ number = float(value.strip().rstrip("%"))
165
+ except ValueError:
166
+ return None
167
+ else:
168
+ return None
169
+ if not math.isfinite(number):
170
+ return None
171
+ return int(number) if number.is_integer() else number
172
+
173
+
174
+ def parse_time(value: object, *, earliest: bool = False) -> datetime | None:
175
+ """An ISO-8601 date or date-time, or a run id's leading stamp, as a UTC instant.
176
+
177
+ A time with a zone is that instant. A date alone or a time without a
178
+ zone reads as UTC, or with `earliest` as the earliest instant it can
179
+ name, 14 hours before its UTC reading.
180
+
181
+ >>> parse_time("2026-09-30T10:10:10Z").isoformat()
182
+ '2026-09-30T10:10:10+00:00'
183
+ >>> parse_time("20260930T101010Z-22-bbbb").isoformat()
184
+ '2026-09-30T10:10:10+00:00'
185
+ >>> parse_time("2026-09-30", earliest=True).isoformat()
186
+ '2026-09-29T10:00:00+00:00'
187
+ >>> parse_time("yesterday") is None, parse_time(None) is None
188
+ (True, True)
189
+ """
190
+ if not isinstance(value, str) or not value.strip():
191
+ return None
192
+ text = value.strip()
193
+ stamp = _RUN_ID_STAMP_RE.match(text)
194
+ try:
195
+ if stamp:
196
+ return datetime.strptime(stamp.group(1), "%Y%m%dT%H%M%SZ").replace(tzinfo=timezone.utc)
197
+ moment = datetime.fromisoformat(text)
198
+ except (ValueError, OverflowError):
199
+ return None
200
+ if moment.tzinfo is not None:
201
+ return moment.astimezone(timezone.utc)
202
+ moment = moment.replace(tzinfo=timezone.utc)
203
+ return moment - _EARLIEST_ZONE_OFFSET if earliest else moment
204
+
205
+
206
+ def report_is_newer(result: dict, newer_than: str) -> bool | None:
207
+ """Whether the found report's time is later than `newer_than` (None:
208
+ no report, or a time that cannot be read). The report's time is the
209
+ later of its run id's stamp and its test_date, so a date-only test_date
210
+ (midnight) never hides a report the run id dates later that day."""
211
+ if result["status"] != "found":
212
+ return None
213
+ times = [t for t in (parse_time(result["run_id"]), parse_time(result["test_date"])) if t is not None]
214
+ bound = parse_time(newer_than, earliest=True)
215
+ if not times or bound is None:
216
+ return None
217
+ return max(times) > bound
218
+
219
+
220
+ def report_was_applied(result: dict, provenance_map: Path) -> bool | None:
221
+ """Whether the provenance map's update block names the found report as
222
+ the one a gap-driven update applied (None: no report, or a map that
223
+ cannot be read as a JSON object)."""
224
+ if result["status"] != "found":
225
+ return None
226
+ try:
227
+ data = json.loads(provenance_map.read_text(encoding="utf-8"))
228
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
229
+ return None
230
+ if not isinstance(data, dict):
231
+ return None
232
+ applied = data.get("test_report_run_id")
233
+ return bool(
234
+ data.get("update_type") == "gap-driven" and isinstance(applied, str) and applied
235
+ and applied == result["run_id"]
236
+ )
237
+
238
+
239
+ # --------------------------------------------------------------------------
240
+ # Lookups
241
+ # --------------------------------------------------------------------------
242
+
243
+
244
+ def report_candidates(folder: Path, skill_name: str) -> list[tuple[str, Path]]:
245
+ """(run id, path) for each report of `skill_name` in `folder`, newest first."""
246
+ if not folder.is_dir():
247
+ return []
248
+ prefix = f"test-report-{skill_name}-"
249
+ found: list[tuple[str, Path]] = []
250
+ for path in folder.glob(f"{glob_escape(prefix)}*.md"):
251
+ rest = path.name[len(prefix):-len(".md")]
252
+ if path.is_file() and rest[:1].isdigit():
253
+ found.append((rest, path))
254
+ found.sort(key=lambda item: item[0], reverse=True)
255
+ return found
256
+
257
+
258
+ def glob_escape(text: str) -> str:
259
+ """Escape glob metacharacters in a literal name part."""
260
+ return re.sub(r"([*?\[])", r"[\1]", text)
261
+
262
+
263
+ def read_report(path: Path) -> dict:
264
+ try:
265
+ text = path.read_text(encoding="utf-8")
266
+ except (OSError, UnicodeDecodeError):
267
+ return {}
268
+ return read_frontmatter(text)
269
+
270
+
271
+ def _run_id(frontmatter: dict, path: Path, skill_name: str) -> str | None:
272
+ if frontmatter.get("runId"):
273
+ return frontmatter["runId"]
274
+ prefix = f"test-report-{skill_name}-"
275
+ if path.name.startswith(prefix) and path.name.endswith(".md"):
276
+ rest = path.name[len(prefix):-len(".md")]
277
+ if _RUN_ID_RE.match(rest):
278
+ return rest
279
+ return None
280
+
281
+
282
+ def _found(result: dict, *, path: Path, source: str, frontmatter: dict, skill_name: str) -> dict:
283
+ result.update(
284
+ {
285
+ "status": "found",
286
+ "path": str(path),
287
+ "report_exists": True,
288
+ "source": source,
289
+ "testResult": normalize_result(frontmatter.get("testResult")),
290
+ "score": normalize_score(frontmatter.get("score")),
291
+ "run_id": _run_id(frontmatter, path, skill_name),
292
+ "test_date": frontmatter.get("testDate") or None,
293
+ }
294
+ )
295
+ return result
296
+
297
+
298
+ def _glob_lookup(result: dict, folder: Path, skill_name: str, source: str) -> bool:
299
+ for _, path in report_candidates(folder, skill_name):
300
+ frontmatter = read_report(path)
301
+ if not normalize_result(frontmatter.get("testResult")):
302
+ result["skipped"].append({"path": str(path), "reason": "unfinished"})
303
+ continue
304
+ _found(result, path=path, source=source, frontmatter=frontmatter, skill_name=skill_name)
305
+ return True
306
+ return False
307
+
308
+
309
+ def _report_from_outputs(outputs: object) -> str | None:
310
+ if not isinstance(outputs, list):
311
+ return None
312
+ paths = [o.get("path") for o in outputs if isinstance(o, dict) and isinstance(o.get("path"), str)]
313
+ for entry in outputs:
314
+ if isinstance(entry, dict) and entry.get("type") == "report" and isinstance(entry.get("path"), str):
315
+ return entry["path"]
316
+ for path in paths:
317
+ if Path(path).name.startswith("test-report-") and path.endswith(".md"):
318
+ return path
319
+ return None
320
+
321
+
322
+ def resolve_output_path(recorded: str, result_file: Path) -> Path | None:
323
+ """Where a path from outputs[] points: absolute as written; a relative one
324
+ beside the result file (the report is written in the same folder), then as
325
+ written from the working directory."""
326
+ path = Path(recorded)
327
+ if path.is_absolute():
328
+ return path if path.is_file() else None
329
+ for candidate in (result_file.parent / path, result_file.parent / path.name, Path.cwd() / path):
330
+ if candidate.is_file():
331
+ return candidate
332
+ return None
333
+
334
+
335
+ def _latest_json_lookup(result: dict, folder: Path, skill_name: str) -> bool:
336
+ result_file = folder / LATEST_RESULT_NAME
337
+ if not result_file.is_file():
338
+ return False
339
+ try:
340
+ data = json.loads(result_file.read_text(encoding="utf-8"))
341
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
342
+ result["warnings"].append(f"cannot read {result_file}: {exc}")
343
+ return False
344
+ if not isinstance(data, dict):
345
+ result["warnings"].append(f"{result_file} is not a JSON object")
346
+ return False
347
+ recorded = _report_from_outputs(data.get("outputs"))
348
+ if recorded is None:
349
+ result["warnings"].append(f"{result_file} names no report in outputs[]")
350
+ return False
351
+ result["result_path"] = str(result_file)
352
+ report = resolve_output_path(recorded, result_file)
353
+ summary = data.get("summary") if isinstance(data.get("summary"), dict) else {}
354
+ if report is not None:
355
+ frontmatter = read_report(report)
356
+ _found(result, path=report, source="latest-json", frontmatter=frontmatter, skill_name=skill_name)
357
+ if result["testResult"] is None:
358
+ result["testResult"] = normalize_result(summary.get("result"))
359
+ if result["score"] is None:
360
+ result["score"] = normalize_score(summary.get("score"))
361
+ return True
362
+ result["warnings"].append(
363
+ f"the report {recorded} named in {result_file} is missing; the verdict comes from its summary"
364
+ )
365
+ result.update(
366
+ {
367
+ "status": "found",
368
+ "path": recorded,
369
+ "report_exists": False,
370
+ "source": "latest-json",
371
+ "testResult": normalize_result(summary.get("result")),
372
+ "score": normalize_score(summary.get("score")),
373
+ "run_id": data.get("runId") if isinstance(data.get("runId"), str) else None,
374
+ "test_date": data.get("timestamp") if isinstance(data.get("timestamp"), str) else None,
375
+ }
376
+ )
377
+ return True
378
+
379
+
380
+ def find_test_report(forge_data_folder: Path, skill_name: str, version: str | None) -> dict:
381
+ """Run the three lookups in order and return the result object."""
382
+ result: dict = {
383
+ "status": "not-found",
384
+ "skill_name": skill_name,
385
+ "version": version,
386
+ "path": None,
387
+ "report_exists": None,
388
+ "source": None,
389
+ "testResult": None,
390
+ "score": None,
391
+ "run_id": None,
392
+ "test_date": None,
393
+ "result_path": None,
394
+ "skipped": [],
395
+ "warnings": [],
396
+ }
397
+ flat = forge_data_folder / skill_name
398
+ versioned = flat / version if version else None
399
+ if versioned is not None and _glob_lookup(result, versioned, skill_name, "versioned-glob"):
400
+ return result
401
+ if _glob_lookup(result, flat, skill_name, "flat-glob"):
402
+ return result
403
+ for folder in (versioned, flat):
404
+ if folder is not None and _latest_json_lookup(result, folder, skill_name):
405
+ return result
406
+ return result
407
+
408
+
409
+ # --------------------------------------------------------------------------
410
+ # CLI
411
+ # --------------------------------------------------------------------------
412
+
413
+
414
+ def _valid_segment(value: str) -> bool:
415
+ return bool(value) and value not in (".", "..") and "/" not in value and "\\" not in value
416
+
417
+
418
+ def _cmd_find(args: argparse.Namespace) -> int:
419
+ for flag, value in (("--skill-name", args.skill_name), ("--version", args.version)):
420
+ if value is not None and not _valid_segment(value):
421
+ print(f"error: {flag} must be a single folder name, got {value!r}", file=sys.stderr)
422
+ return 1
423
+ result = find_test_report(Path(args.forge_data_folder), args.skill_name, args.version)
424
+ if args.newer_than is not None:
425
+ result["newer"] = report_is_newer(result, args.newer_than)
426
+ if args.provenance_map is not None:
427
+ result["applied"] = report_was_applied(result, Path(args.provenance_map))
428
+ print(json.dumps(result, indent=2, ensure_ascii=False))
429
+ return 0
430
+
431
+
432
+ def _build_parser() -> argparse.ArgumentParser:
433
+ parser = argparse.ArgumentParser(
434
+ prog="skf-find-test-report",
435
+ description="Locate a skill's newest finished test report and read its verdict.",
436
+ )
437
+ sub = parser.add_subparsers(dest="cmd", required=True)
438
+
439
+ p = sub.add_parser("find", help="run the versioned, flat and latest-json lookups")
440
+ p.add_argument("--forge-data-folder", required=True)
441
+ p.add_argument("--skill-name", required=True)
442
+ p.add_argument("--version", help="the version folder to search first (the active version)")
443
+ p.add_argument(
444
+ "--newer-than",
445
+ metavar="TIME",
446
+ help="also say whether the report is newer than this ISO-8601 time (the skill's generation_date)",
447
+ )
448
+ p.add_argument(
449
+ "--provenance-map",
450
+ metavar="FILE",
451
+ help="also say whether this map's update block names the report as applied",
452
+ )
453
+ p.set_defaults(func=_cmd_find)
454
+
455
+ return parser
456
+
457
+
458
+ def _force_utf8(*streams) -> None:
459
+ """Reconfigure the JSON streams to UTF-8 (a Windows console uses cp1252)."""
460
+ for stream in streams:
461
+ if hasattr(stream, "reconfigure"):
462
+ errors = getattr(stream, "errors", None)
463
+ if errors is None:
464
+ stream.reconfigure(encoding="utf-8")
465
+ else:
466
+ stream.reconfigure(encoding="utf-8", errors=errors)
467
+
468
+
469
+ def main(argv: list[str] | None = None) -> int:
470
+ _force_utf8(sys.stdout)
471
+ args = _build_parser().parse_args(argv)
472
+ return args.func(args)
473
+
474
+
475
+ if __name__ == "__main__":
476
+ raise SystemExit(main())