bmad-module-skill-forge 2.2.0 → 4.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 (437) 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 +143 -18
  18. package/docs/verifying-a-skill.md +87 -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 +3 -3
  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 +31 -59
  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 +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  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 +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  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 +21 -3
  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 +197 -55
  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 +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -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 +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -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 +143 -20
  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 +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  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 +155 -14
  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 +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  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 +23 -2
  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 +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  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 +28 -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/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -1,5 +1,5 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
5
  """SKF Enumerate Stack Skills — inventory skill packages + resolve exports.
@@ -21,23 +21,79 @@ subprocesses read this inventory's cached result instead of re-reading
21
21
  SKILL.md, then extract usage patterns per skill (the part that does
22
22
  benefit from LLM judgment).
23
23
 
24
- Subcommand:
25
- enumerate <skills-root> [--pairs] [--reliability]
26
- Emit JSON {"skills": [...], "cycles": [...], "warnings": [...]}
27
- describing every subdirectory of <skills-root> that resolves to a
28
- skill package. Both layouts from knowledge/version-paths.md are
29
- supported: the flat layout (<child>/SKILL.md) and the version-nested
30
- layout (<child>/active/<name>/SKILL.md via the `active` symlink, or
31
- <child>/<version>/<name>/SKILL.md by highest version). Each entry
32
- carries:
33
-
34
- name — skill name (top-level subdirectory name)
35
- path — relative path to the resolved package under
36
- skills-root, forward-slash
37
- exports — exports list resolved via cascade
38
- exports_source — "metadata|references|skill-md|unknown"
39
- confidence — "T1|T2|T1-low" (mapped from exports_source)
40
- metadata_hash — sha256: prefix on metadata.json digest, or null
24
+ Subcommands:
25
+ enumerate <skills-root> [--pairs] [--reliability] [--expect-hashes FILE]
26
+ Emit JSON {"skills": [...], "cycles": [...], "warnings": [...],
27
+ "not_skf_output": [...]} for the skill folders under <skills-root>.
28
+ candidates <skills-root> [--explicit a,b]
29
+ Pick the compose-mode candidates and gate them against the same
30
+ roster, in one call (see Candidates below).
31
+ scope --skills a,b,c --in-scope a,b
32
+ Split the unique pairs of an inventory's skills by the skills in
33
+ scope (see Scope below).
34
+
35
+ Ownership. A stack roster reads only the skills SKF generated: a package
36
+ counts only when its metadata.json carries an SKF marker (generated_by
37
+ naming an SKF generator, tool_versions.skf, or skill_type with forge_tier
38
+ or confidence_tier), the rule skf-skill-inventory.py applies (the marker
39
+ functions are pinned copies of its own). For each top-level folder (dot
40
+ names, `_batch` and `.skf-` names aside) the package is the first marked
41
+ candidate of: the version the `active` link names (a real folder directly
42
+ in the group), the version folders from highest to lowest (`active`,
43
+ links and `.skf-` names aside), then a flat root SKILL.md beside a marked
44
+ root metadata.json. A versioned package is always `{version}/{name}/`,
45
+ named after the folder. `enumerate` never reads the export manifest;
46
+ `candidates` reads it only to pick candidates, and the package of each
47
+ one it keeps still comes from this roster.
48
+
49
+ A folder with no marked candidate is left out of `skills[]`: listed in
50
+ `not_skf_output` when it looks like a skill (a root SKILL.md or a
51
+ `{version}/{name}/` folder), or, when a candidate's metadata.json or
52
+ version folder cannot be read, named in one warning instead, because SKF
53
+ cannot tell whether it generated that folder. A linked package inside a
54
+ version is never SKF output either, and its metadata.json is never read.
55
+ A top-level link is never SKF output: it is listed in `not_skf_output`
56
+ when it looks like a skill, and a broken one, or one SKF cannot read
57
+ through, is skipped. `not_skf_output` is always present, sorted, and
58
+ never counts toward `warnings`, `--pairs` or `--reliability`.
59
+
60
+ Each `skills[]` entry carries:
61
+ name the top-level folder name
62
+ path the package relative to skills-root, forward-slash:
63
+ `x/active/x`, `x/{version}/x` or `x` (flat)
64
+ exports exports list resolved via cascade
65
+ exports_source "metadata|references|skill-md|unknown"
66
+ confidence "T1|T2|T1-low", the exports-source label (mapped from
67
+ exports_source, see Confidence mapping)
68
+ evidence_tier "T1|T1-low|T2|T3", the tier the package's own recorded
69
+ evidence supports (see Evidence tier)
70
+ metadata_hash "sha256:" digest of the package's metadata.json; never
71
+ null for an enumerated package, whose metadata.json is
72
+ always readable
73
+ skill_type metadata.json `skill_type`, when it is a string
74
+ language metadata.json `language`: a string, or a list of
75
+ strings (a stack may list its libraries' languages)
76
+ confidence_tier metadata.json `confidence_tier`, when it belongs to the
77
+ scale its skill_type records: a forge tier
78
+ (Quick|Forge|Forge+|Deep) for a single skill (skill_type
79
+ single, individual or null), a confidence tier
80
+ (T1|T1-low|T2|T3) for a stack, either scale for any
81
+ other skill_type
82
+ exports_documented
83
+ metadata.json `stats.exports_documented`, a whole number
84
+ metadata_schema_version
85
+ metadata.json `spec_version`, the metadata schema the
86
+ package was written to (such as "1.3")
87
+ source_repo metadata.json `source_repo`, trimmed
88
+ source_repo_basename
89
+ the repository name source_repo ends in (see Source
90
+ basenames)
91
+ source_root metadata.json `source_root`, trimmed
92
+ source_root_basename
93
+ the last folder name of source_root (see Source
94
+ basenames)
95
+ Each metadata.json field is null when the file lacks it or holds a value
96
+ of another shape, so a caller never reopens metadata.json to read it.
41
97
 
42
98
  Exports resolution cascade (must match parallel-extract.md §0):
43
99
 
@@ -50,12 +106,12 @@ Exports resolution cascade (must match parallel-extract.md §0):
50
106
 
51
107
  2. references/*.md — scan top-level `*.md` files under references/
52
108
  for an `## API` or `## Exports` section. Bulleted list items in
53
- that section become exports. Set exports_source="references",
54
- metadata_hash=null.
109
+ that section become exports. Set exports_source="references"
110
+ (metadata_hash stays the digest of metadata.json when one was read).
55
111
 
56
112
  3. SKILL.md prose — look for an `## Exports` or `## API Surface`
57
- section and parse its bulleted list. Set exports_source="skill-md",
58
- metadata_hash=null.
113
+ section and parse its bulleted list. Set exports_source="skill-md"
114
+ (metadata_hash stays the digest of metadata.json when one was read).
59
115
 
60
116
  4. None of the above — exports=[], exports_source="unknown",
61
117
  confidence="T1-low", append a warning
@@ -67,6 +123,31 @@ Confidence mapping:
67
123
  skill-md → T2 (prose parsing, comparable trust to references)
68
124
  unknown → T1-low (degraded extraction; nothing found)
69
125
 
126
+ `confidence` says only where the exports list came from: every package
127
+ whose metadata.json lists exports (even an empty list, which SKF's
128
+ generators always write) reads T1, whatever evidence it rests on.
129
+
130
+ Evidence tier:
131
+
132
+ `evidence_tier` is the largest bin of the package's metadata.json
133
+ `confidence_distribution` (t1 is T1, t1_low T1-low, t2 T2, t3 T3). A tie
134
+ goes to the weaker tier (T1-low over T1, T2 over T1-low, T3 over T2), so
135
+ the tier never overstates confidence. A bin counts only when it holds a
136
+ positive number; with none (the distribution is absent, not an object or
137
+ all zero) the tier is T1-low, the conservative default. create-stack-skill
138
+ picks a stack's own confidence_tier by the same rule. A docs-only skill
139
+ whose bins are all T3 reads T3 here, while its `confidence` reads T1.
140
+
141
+ Source basenames:
142
+
143
+ Lower case, from the last non-empty segment between `/` or `\\`
144
+ separators, for matching a skill to a technology name.
145
+ source_repo_basename also drops a trailing `.git`, and is null when
146
+ source_repo holds no separator (it is then no URL, owner/repo pair or
147
+ path). Either is null when no name remains (such as `.` or `..`).
148
+ `https://github.com/Org/Repo.git/` and `Org/Repo` both give `repo`; a
149
+ source_root of `packages/Core` gives `core`.
150
+
70
151
  Cycle detection:
71
152
 
72
153
  metadata.json may declare `composes: ["<skill-name>", ...]` referencing
@@ -75,11 +156,12 @@ Cycle detection:
75
156
  starting node to `cycles[]` and emit a warning. Cycles do not abort
76
157
  enumeration; the inventory still lists every skill.
77
158
 
78
- Symlink handling:
159
+ Warnings:
79
160
 
80
- A skill directory may itself be a symlink (e.g. an "active" pointer).
81
- If the target is unreadable (broken symlink, permission denied, etc.),
82
- emit a warning and skip the entry — do not crash the whole scan.
161
+ Each starts `<name>: `: a metadata.json or version folder that cannot
162
+ be read (on a candidate passed over, or on a folder SKF cannot tell it
163
+ generated), an SKF package with no SKILL.md, no exports found via any
164
+ resolution path, a folder that cannot be resolved, and composes cycles.
83
165
 
84
166
  Per-skill errors (malformed metadata.json, OSError, etc.) are captured
85
167
  as warnings on the top-level result; they do not exit the process.
@@ -99,7 +181,8 @@ consumers that read only skills/cycles/warnings are unaffected):
99
181
 
100
182
  --reliability
101
183
  Attach the inventory reliability verdict computed from the counts
102
- the script already owns:
184
+ the script already owns, over the skills SKF generated and their
185
+ warnings; `not_skf_output` never counts:
103
186
  skill_count — len(skills)
104
187
  warning_count — len(warnings)
105
188
  unreliable_ratio — warning_count / (skill_count + warning_count),
@@ -109,15 +192,100 @@ consumers that read only skills/cycles/warnings are unaffected):
109
192
  so consuming prompts read a boolean instead of
110
193
  re-deriving a ratio + threshold comparison).
111
194
  The raw counts are emitted alongside the boolean so a caller's halt
112
- message can still render "{warning_count}/{skill_count+warning_count}
113
- skills returned malformed metadata".
195
+ message can still render "{warning_count} warning(s) across
196
+ {skill_count} skill(s)".
197
+
198
+ --expect-hashes FILE
199
+ Compare each skill's metadata_hash with the one FILE recorded for it
200
+ and attach three sorted name lists: `changed_skills` (enumerated now
201
+ with another metadata_hash), `missing_skills` (recorded, but not
202
+ enumerated now: removed, or no longer readable) and `new_skills`
203
+ (enumerated now, not recorded). FILE, or stdin for `-`, holds an
204
+ earlier enumerate result (its skills[] entries give each name's
205
+ metadata_hash) or an object mapping each skill name to its hash.
206
+ Hashes compare as written. A caller that verifies skills over a long
207
+ run re-enumerates with the result it started from, instead of
208
+ re-reading each metadata.json modification time.
209
+
210
+ Candidates:
211
+
212
+ Compose mode builds a stack from the skills SKF generated. The
213
+ `candidates` subcommand picks them and gates them against the roster
214
+ `enumerate` builds:
215
+
216
+ candidate_source
217
+ Where the candidates came from. "explicit": the --explicit names
218
+ (trimmed, duplicates dropped). An --explicit entry that holds `/` or
219
+ `\\` is a package path instead, absolute or relative to the current
220
+ folder: it names the first folder of its path under <skills-root>
221
+ (`<skills-root>/react/1.2.0/react` and `<skills-root>/react/active/react`
222
+ both name `react`). "manifest": each key of
223
+ `<skills-root>/.export-manifest.json` `exports`. "active-links": when
224
+ there is no --explicit list and the manifest is absent, lists
225
+ nothing or cannot be read, each top-level folder holding
226
+ `active/<name>/SKILL.md` (`active` a link or a real folder; dot
227
+ names never match, and `_batch` and `.skf-` names are skipped as the
228
+ roster skips them).
229
+ kept
230
+ The roster entries (the shape of a skills[] entry) of the candidates
231
+ whose skill_type is single, individual or null (an early Quick Skill
232
+ package), sorted by name.
233
+ excluded
234
+ Every other candidate, once, as {skill_dir, reason, message}, sorted
235
+ by skill_dir. `message` is the log line to show, starting
236
+ `<skill_dir>: `. The reason is the first that applies:
237
+ not-a-skill in the roster with another skill_type (a stack)
238
+ not-skf-output listed in not_skf_output
239
+ roster-warning named by roster warnings (the message holds them)
240
+ no-such-folder an --explicit name with no folder at
241
+ <skills-root>/<name>
242
+ no-skf-package anything else (a folder holding no SKF package)
243
+ and an --explicit package path that is not inside <skills-root> is
244
+ excluded as given, with the reason `outside-skills-root`.
245
+ stale_manifest_keys
246
+ The manifest keys with no folder at <skills-root>/<key>, sorted: the
247
+ manifest names a skill that is gone, which the caller halts on. A
248
+ folder SKF cannot read is not called gone. Never in excluded.
249
+ manifest_parse_error
250
+ Why the manifest could not be read (unreadable, not JSON, its root or
251
+ its `exports` not an object); the candidates then come from the
252
+ active links. null otherwise, and whenever --explicit is given (the
253
+ manifest is not read then).
254
+
255
+ The result also carries the roster's `cycles`, `warnings` and
256
+ `not_skf_output`, with kept[] in place of skills[]. A composes cycle is
257
+ not an exclusion reason: cycles[] reports it as `enumerate` does.
258
+
259
+ Scope:
260
+
261
+ refine-architecture decides which skills its architecture document
262
+ covers after `enumerate` ran. The `scope` subcommand splits the pairs
263
+ `--pairs` emitted, so no prompt sorts them by hand, and a pair is never
264
+ dropped from both halves. It reads no skills root: --skills names the
265
+ inventory (the `name` of each skills[] entry of an enumerate result) and
266
+ --in-scope the skills in scope, both comma-separated, trimmed, with empty
267
+ items dropped. Names compare exactly, as skills[] spells them.
268
+
269
+ in_scope the --in-scope names that are inventory skills, sorted
270
+ out_of_scope every other inventory skill, sorted
271
+ unknown the --in-scope names that are no inventory skill, each
272
+ once, in the order given
273
+ in_scope_pairs the pairs, as `--pairs` emits them over --skills,
274
+ whose library_a and library_b are both in in_scope
275
+ out_of_scope_pairs every other pair: the two lists hold each pair once
276
+ pair_count, in_scope_pair_count, out_of_scope_pair_count
277
+ the sizes of the three pair lists (all, in, out)
114
278
 
115
279
  Exit codes:
116
280
  0 enumeration succeeded (including zero skills found)
117
- 1 user error (bad skills-root path)
281
+ 1 user error: a bad skills-root path, an --expect-hashes FILE that
282
+ cannot be read or holds neither accepted shape, an --explicit list
283
+ that names no skill, or a scope --skills list that names no skill
118
284
 
119
285
  CLI:
120
- uv run skf-enumerate-stack-skills.py enumerate <skills-root> [--pairs] [--reliability]
286
+ uv run skf-enumerate-stack-skills.py enumerate <skills-root> [--pairs] [--reliability] [--expect-hashes FILE]
287
+ uv run skf-enumerate-stack-skills.py candidates <skills-root> [--explicit a,b]
288
+ uv run skf-enumerate-stack-skills.py scope --skills a,b,c --in-scope a,b
121
289
  """
122
290
 
123
291
  from __future__ import annotations
@@ -126,7 +294,10 @@ import argparse
126
294
  import hashlib
127
295
  import itertools
128
296
  import json
297
+ import math
298
+ import os
129
299
  import re
300
+ import stat
130
301
  import sys
131
302
  from pathlib import Path
132
303
 
@@ -148,6 +319,8 @@ SOURCE_UNKNOWN = "unknown"
148
319
  # inventory does not trip the halt.
149
320
  RELIABILITY_THRESHOLD = 0.20
150
321
 
322
+ # The exports-source label (`confidence`). It says where the exports list
323
+ # came from, never what evidence the package rests on: that is evidence_tier.
151
324
  _CONFIDENCE_BY_SOURCE = {
152
325
  SOURCE_METADATA: "T1",
153
326
  SOURCE_REFERENCES: "T2",
@@ -155,6 +328,40 @@ _CONFIDENCE_BY_SOURCE = {
155
328
  SOURCE_UNKNOWN: "T1-low",
156
329
  }
157
330
 
331
+ # The confidence_distribution bins of metadata.json, from the strongest tier
332
+ # to the weakest. dominant_tier reads them in this order and lets a later bin
333
+ # win a tie, so a tie resolves toward the weaker tier.
334
+ _DISTRIBUTION_BINS = (("t1", "T1"), ("t1_low", "T1-low"), ("t2", "T2"), ("t3", "T3"))
335
+
336
+ # The tier of a package that records no evidence (the conservative default).
337
+ _NO_EVIDENCE_TIER = "T1-low"
338
+
339
+ # confidence_tier holds a different scale per skill_type: a single skill
340
+ # records the forge tier it was compiled at, a stack the dominant confidence
341
+ # tier of its libraries (its forge tier is in forge_tier). skill_type
342
+ # individual (the earlier name for single) and a missing skill_type (an early
343
+ # Quick Skill package) read as single; any other skill_type takes either scale.
344
+ _FORGE_TIERS = ("Quick", "Forge", "Forge+", "Deep")
345
+ _STACK_TIERS = ("T1", "T1-low", "T2", "T3")
346
+ _SINGLE_SKILL_TYPES = (None, "single", "individual")
347
+
348
+ # A source_repo or source_root path or URL splits into segments on either
349
+ # separator.
350
+ _PATH_SEPARATOR_RE = re.compile(r"[/\\]")
351
+
352
+ # candidates: the export manifest in the skills root, where each candidate
353
+ # came from, and why a candidate was excluded.
354
+ EXPORT_MANIFEST = ".export-manifest.json"
355
+ CANDIDATES_EXPLICIT = "explicit"
356
+ CANDIDATES_MANIFEST = "manifest"
357
+ CANDIDATES_ACTIVE_LINKS = "active-links"
358
+ REASON_NOT_A_SKILL = "not-a-skill"
359
+ REASON_NOT_SKF_OUTPUT = "not-skf-output"
360
+ REASON_ROSTER_WARNING = "roster-warning"
361
+ REASON_NO_SUCH_FOLDER = "no-such-folder"
362
+ REASON_NO_SKF_PACKAGE = "no-skf-package"
363
+ REASON_OUTSIDE_SKILLS_ROOT = "outside-skills-root"
364
+
158
365
  # Section headings recognised in the references/ cascade and the SKILL.md
159
366
  # cascade. Case-sensitive — these are the canonical SKF heading forms
160
367
  # documented in parallel-extract.md §0.
@@ -213,29 +420,31 @@ def _normalize_exports(raw) -> list[str]:
213
420
  return out
214
421
 
215
422
 
216
- def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | None, list[str], list[str]]:
423
+ def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | None, list[str], list[str], dict]:
217
424
  """Try to resolve exports from metadata.json.
218
425
 
219
- Returns (exports, metadata_hash, composes, warnings).
220
- exports — list of names if metadata.json was readable AND
426
+ Returns (exports, metadata_hash, composes, warnings, metadata).
427
+ exports : list of names if metadata.json was readable AND
221
428
  contained an `exports[]` array (may be empty);
222
429
  None if metadata.json absent or malformed.
223
- metadata_hash — sha256: digest of metadata.json content, or None
430
+ metadata_hash : sha256: digest of metadata.json content, or None
224
431
  if file absent / unreadable.
225
- composes — list of constituent skill names (for cycle
432
+ composes : list of constituent skill names (for cycle
226
433
  detection); empty list if absent.
227
- warnings — per-skill warnings (malformed JSON, etc.)
434
+ warnings : per-skill warnings (malformed JSON, etc.)
435
+ metadata : the parsed metadata.json object, or {} when the
436
+ file is absent, unreadable or not a JSON object.
228
437
  """
229
438
  meta_path = skill_dir / "metadata.json"
230
439
  if not meta_path.is_file():
231
- return None, None, [], []
440
+ return None, None, [], [], {}
232
441
 
233
442
  warnings: list[str] = []
234
443
  try:
235
444
  raw_bytes = meta_path.read_bytes()
236
445
  except OSError as exc:
237
446
  warnings.append(f"failed to read metadata.json: {exc}")
238
- return None, None, [], warnings
447
+ return None, None, [], warnings, {}
239
448
 
240
449
  metadata_hash = _sha256_of_bytes(raw_bytes)
241
450
 
@@ -245,11 +454,11 @@ def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | Non
245
454
  warnings.append(f"metadata.json is not valid JSON: {exc}")
246
455
  # Hash is still useful — record it so callers can correlate
247
456
  # malformed files across runs.
248
- return None, metadata_hash, [], warnings
457
+ return None, metadata_hash, [], warnings, {}
249
458
 
250
459
  if not isinstance(data, dict):
251
460
  warnings.append("metadata.json root is not an object")
252
- return None, metadata_hash, [], warnings
461
+ return None, metadata_hash, [], warnings, {}
253
462
 
254
463
  composes_raw = data.get("composes")
255
464
  composes = [c for c in composes_raw if isinstance(c, str)] if isinstance(composes_raw, list) else []
@@ -258,10 +467,10 @@ def _resolve_from_metadata(skill_dir: Path) -> tuple[list[str] | None, str | Non
258
467
  # metadata.json exists and is valid JSON but lacks an exports
259
468
  # field — fall through to references/ cascade. Carry the hash
260
469
  # forward so callers see metadata existed.
261
- return None, metadata_hash, composes, warnings
470
+ return None, metadata_hash, composes, warnings, data
262
471
 
263
472
  exports = _normalize_exports(data.get("exports"))
264
- return exports, metadata_hash, composes, warnings
473
+ return exports, metadata_hash, composes, warnings, data
265
474
 
266
475
 
267
476
  # --------------------------------------------------------------------------
@@ -337,6 +546,127 @@ def _resolve_from_skill_md(skill_dir: Path) -> list[str]:
337
546
  return _parse_list_section(content, _SKILL_MD_HEADINGS)
338
547
 
339
548
 
549
+ # --------------------------------------------------------------------------
550
+ # Evidence tier and metadata.json fields
551
+ # --------------------------------------------------------------------------
552
+
553
+
554
+ def _bin_count(value):
555
+ """A confidence_distribution bin's count: a positive finite number, else 0."""
556
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
557
+ return 0
558
+ if isinstance(value, float) and not math.isfinite(value):
559
+ return 0
560
+ return value if value > 0 else 0
561
+
562
+
563
+ def dominant_tier(distribution) -> str:
564
+ """The tier of the largest bin of a metadata.json confidence_distribution.
565
+
566
+ The bins are read from the strongest tier to the weakest and a later bin
567
+ wins a tie, so a tie resolves toward the weaker tier (T1-low over T1, T2
568
+ over T1-low, T3 over T2) and the tier never overstates confidence. A bin
569
+ counts only when it holds a positive number. T1-low when none does: the
570
+ distribution is absent, not an object or all zero.
571
+ """
572
+ tier, best = _NO_EVIDENCE_TIER, 0
573
+ if isinstance(distribution, dict):
574
+ for key, bin_tier in _DISTRIBUTION_BINS:
575
+ count = _bin_count(distribution.get(key))
576
+ if count and count >= best:
577
+ tier, best = bin_tier, count
578
+ return tier
579
+
580
+
581
+ def _text(value) -> str | None:
582
+ """A string with its surrounding whitespace removed, or None when empty or not a string."""
583
+ if isinstance(value, str) and value.strip():
584
+ return value.strip()
585
+ return None
586
+
587
+
588
+ def _language(value) -> str | list[str] | None:
589
+ """metadata.json `language`: a string, or the strings of a list (a stack may list several)."""
590
+ if isinstance(value, list):
591
+ names = [name for name in (_text(item) for item in value) if name]
592
+ return names or None
593
+ return _text(value)
594
+
595
+
596
+ def _confidence_tier(value, skill_type: str | None) -> str | None:
597
+ """metadata.json `confidence_tier` when it belongs to its skill_type's scale."""
598
+ if skill_type == "stack":
599
+ allowed = _STACK_TIERS
600
+ elif skill_type in _SINGLE_SKILL_TYPES:
601
+ allowed = _FORGE_TIERS
602
+ else:
603
+ allowed = _FORGE_TIERS + _STACK_TIERS
604
+ return value if isinstance(value, str) and value in allowed else None
605
+
606
+
607
+ def _whole_number(value) -> int | None:
608
+ """A non-negative whole number (an integral float too), else None."""
609
+ if isinstance(value, bool):
610
+ return None
611
+ if isinstance(value, float) and value.is_integer():
612
+ value = int(value)
613
+ return value if isinstance(value, int) and value >= 0 else None
614
+
615
+
616
+ def _last_segment(value: str) -> str | None:
617
+ """The lower-case last non-empty `/` or `\\` segment, or None when it names no folder."""
618
+ segments = [segment for segment in _PATH_SEPARATOR_RE.split(value) if segment]
619
+ if not segments or segments[-1] in (".", ".."):
620
+ return None
621
+ return segments[-1].lower()
622
+
623
+
624
+ def source_repo_basename(source_repo: str | None) -> str | None:
625
+ """The repository name a source_repo URL, owner/repo pair or path ends in.
626
+
627
+ The last segment, lower case, without a trailing `.git`. None when
628
+ source_repo is absent, holds no separator (so it is no URL, pair or
629
+ path), or leaves no name.
630
+ """
631
+ if source_repo is None or not _PATH_SEPARATOR_RE.search(source_repo):
632
+ return None
633
+ segment = _last_segment(source_repo)
634
+ if segment is not None and segment.endswith(".git"):
635
+ segment = segment[:-len(".git")]
636
+ return segment if segment not in (None, "", ".", "..") else None
637
+
638
+
639
+ def source_root_basename(source_root: str | None) -> str | None:
640
+ """The last folder name of a source_root path, lower case, or None."""
641
+ return None if source_root is None else _last_segment(source_root)
642
+
643
+
644
+ def metadata_fields(metadata: dict) -> dict:
645
+ """The metadata.json facts a skills[] entry carries besides its exports.
646
+
647
+ Each is None when metadata.json lacks it or holds a value of another
648
+ shape, so a caller never reopens metadata.json to read it.
649
+ """
650
+ skill_type = metadata.get("skill_type")
651
+ if not isinstance(skill_type, str):
652
+ skill_type = None
653
+ stats = metadata.get("stats")
654
+ source_repo = _text(metadata.get("source_repo"))
655
+ source_root = _text(metadata.get("source_root"))
656
+ return {
657
+ "skill_type": skill_type,
658
+ "language": _language(metadata.get("language")),
659
+ "confidence_tier": _confidence_tier(metadata.get("confidence_tier"), skill_type),
660
+ "exports_documented": _whole_number(
661
+ stats.get("exports_documented") if isinstance(stats, dict) else None),
662
+ "metadata_schema_version": _text(metadata.get("spec_version")),
663
+ "source_repo": source_repo,
664
+ "source_repo_basename": source_repo_basename(source_repo),
665
+ "source_root": source_root,
666
+ "source_root_basename": source_root_basename(source_root),
667
+ }
668
+
669
+
340
670
  # --------------------------------------------------------------------------
341
671
  # Per-skill resolution (cascade orchestrator)
342
672
  # --------------------------------------------------------------------------
@@ -353,7 +683,7 @@ def resolve_skill(skill_dir: Path, skill_name: str) -> tuple[dict, list[str], li
353
683
  warnings: list[str] = []
354
684
 
355
685
  # 1. metadata.json
356
- exports, metadata_hash, composes, meta_warnings = _resolve_from_metadata(skill_dir)
686
+ exports, metadata_hash, composes, meta_warnings, metadata = _resolve_from_metadata(skill_dir)
357
687
  warnings.extend(f"{skill_name}: {w}" for w in meta_warnings)
358
688
 
359
689
  if exports is not None:
@@ -382,8 +712,10 @@ def resolve_skill(skill_dir: Path, skill_name: str) -> tuple[dict, list[str], li
382
712
  "exports": exports,
383
713
  "exports_source": source,
384
714
  "confidence": _CONFIDENCE_BY_SOURCE[source],
715
+ "evidence_tier": dominant_tier(metadata.get("confidence_distribution")),
385
716
  "metadata_hash": metadata_hash,
386
717
  }
718
+ entry.update(metadata_fields(metadata))
387
719
  return entry, composes, warnings
388
720
 
389
721
 
@@ -456,58 +788,197 @@ def _version_sort_key(name: str) -> tuple:
456
788
  return (tuple(nums), 0 if pre == "" else -1, name)
457
789
 
458
790
 
459
- def _inner_package(version_dir: Path) -> Path | None:
460
- """Within a version directory, return the inner package dir holding SKILL.md.
791
+ # Keep identical to SKF_GENERATORS in skf-skill-inventory.py
792
+ # (test/test-skf-skill-inventory.py pins the copies).
793
+ SKF_GENERATORS = frozenset({"quick-skill", "create-skill", "create-stack-skill"})
461
794
 
462
- Matches the `{version}/{skill-name}/SKILL.md` shape from
463
- knowledge/version-paths.md. Returns None if no inner dir has a SKILL.md.
795
+
796
+ # Keep identical to _has_skf_metadata in skf-skill-inventory.py
797
+ # (test/test-skf-skill-inventory.py pins the copies).
798
+ def _has_skf_metadata(path: Path) -> bool:
799
+ """True when a flat skill's metadata.json carries an SKF marker."""
800
+ try:
801
+ data = json.loads(path.read_text(encoding="utf-8"))
802
+ except (OSError, ValueError):
803
+ return False
804
+ if not isinstance(data, dict):
805
+ return False
806
+ generated_by = data.get("generated_by")
807
+ if isinstance(generated_by, str) and generated_by in SKF_GENERATORS:
808
+ return True
809
+ tool_versions = data.get("tool_versions")
810
+ if isinstance(tool_versions, dict) and "skf" in tool_versions:
811
+ return True
812
+ return (data.get("skill_type") in ("single", "individual", "stack")
813
+ and ("forge_tier" in data or "confidence_tier" in data))
814
+
815
+
816
+ # Keep identical to _is_link_or_junction in skf-skill-inventory.py
817
+ # (test/test-skf-skill-inventory.py pins the copies).
818
+ def _is_link_or_junction(p: Path) -> bool:
819
+ """True for POSIX symlinks AND Windows junctions/symlinks.
820
+
821
+ `Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
822
+ for both symlinks and junctions (since CPython 3.8 on Windows). A regular
823
+ directory raises OSError on readlink, which is the signal we want to
824
+ refuse replacement. On Windows, any other reparse point (a cloud-sync
825
+ placeholder, a deduplicated file, an app execution alias) raises
826
+ ValueError: it does not redirect to another path, so it is not a link.
464
827
  """
828
+ if p.is_symlink():
829
+ return True
830
+ if not p.exists() and not p.is_symlink():
831
+ return False
465
832
  try:
466
- for inner in sorted(version_dir.iterdir()):
467
- if inner.is_dir() and (inner / "SKILL.md").is_file():
468
- return inner
469
- except OSError:
470
- return None
471
- return None
472
-
833
+ os.readlink(p)
834
+ return True
835
+ except (OSError, ValueError):
836
+ return False
473
837
 
474
- def _resolve_package_dir(child: Path) -> Path | None:
475
- """Resolve the agentskills package dir (the one containing SKILL.md).
476
838
 
477
- Supports both layouts defined in knowledge/version-paths.md:
478
- - flat: {child}/SKILL.md
479
- - version-nested: {child}/active/{skill-name}/SKILL.md (via the `active`
480
- symlink) or {child}/{version}/{skill-name}/SKILL.md.
839
+ # Keep identical to _is_marked_version in skf-skill-inventory.py
840
+ # (test/test-skf-skill-inventory.py pins the copies).
841
+ def _is_marked_version(version_dir: Path, name: str) -> bool:
842
+ """True when `version_dir/name/metadata.json` carries an SKF marker.
481
843
 
482
- For the nested layout the `active` symlink wins; otherwise the highest
483
- version directory by semver precedence is used. Returns None when no
484
- SKILL.md is reachable (caller skips the entry as a non-package).
844
+ SKF never links a version folder or the package inside one: a linked
845
+ one is not its output, whatever the metadata behind the link says.
485
846
  """
486
- # Flat layout — direct SKILL.md.
487
- if (child / "SKILL.md").is_file():
488
- return child
847
+ package = version_dir / name
848
+ return (not _is_link_or_junction(version_dir) and not _is_link_or_junction(package)
849
+ and _has_skf_metadata(package / "metadata.json"))
489
850
 
490
- # Nested layout — prefer the stable `active` pointer.
491
- active = child / "active"
492
- if active.is_dir(): # is_dir() follows the symlink; False if broken
493
- pkg = _inner_package(active)
494
- if pkg is not None:
495
- return pkg
496
851
 
497
- # Fallback — highest version directory.
852
+ # Keep identical to _looks_like_skill in skf-skill-inventory.py
853
+ # (test/test-skf-skill-inventory.py pins the copies).
854
+ def _looks_like_skill(skill_group_dir, skill_name):
855
+ """True when a group holds a root SKILL.md or a {v}/{name}/ folder."""
856
+ if (skill_group_dir / "SKILL.md").is_file():
857
+ return True
498
858
  try:
499
- version_dirs = [
500
- d for d in child.iterdir() if d.is_dir() and d.name != "active"
501
- ]
859
+ return any(child.is_dir() and (child / skill_name).is_dir()
860
+ for child in skill_group_dir.iterdir())
502
861
  except OSError:
862
+ return False
863
+
864
+
865
+ def _metadata_problem(package: Path) -> str | None:
866
+ """What keeps `package/metadata.json` from being read, or None.
867
+
868
+ None when the file is absent or holds a JSON object; otherwise the
869
+ reason, so a corrupt metadata.json on an SKF skill is counted as a
870
+ warning instead of silently reading as "not SKF output".
871
+ """
872
+ meta_path = package / "metadata.json"
873
+ try:
874
+ # os.stat, not Path.is_file: from Python 3.14 pathlib reads a file
875
+ # SKF cannot reach as absent instead of raising.
876
+ if not stat.S_ISREG(os.stat(meta_path).st_mode):
877
+ return None
878
+ data = json.loads(meta_path.read_bytes().decode("utf-8"))
879
+ except (FileNotFoundError, NotADirectoryError):
503
880
  return None
504
- for vdir in sorted(version_dirs, key=lambda d: _version_sort_key(d.name), reverse=True):
505
- pkg = _inner_package(vdir)
506
- if pkg is not None:
507
- return pkg
881
+ except OSError as exc:
882
+ return f"failed to read ({exc})"
883
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
884
+ return f"is not valid JSON ({exc})"
885
+ if not isinstance(data, dict):
886
+ return "root is not an object"
887
+ return None
888
+
889
+
890
+ def _active_version_dir(child: Path) -> Path | None:
891
+ """The version folder `child/active` names, when it is a folder directly in `child`.
892
+
893
+ `active` as a link counts only when it resolves to a real folder
894
+ directly in the group whose name holds no `.skf-`; a real `active/`
895
+ folder counts as itself.
896
+ """
897
+ active = child / "active"
898
+ if _is_link_or_junction(active):
899
+ try:
900
+ target = active.resolve(strict=True)
901
+ group = child.resolve(strict=True)
902
+ except (OSError, RuntimeError):
903
+ return None
904
+ version = child / target.name
905
+ if (target.parent != group or ".skf-" in target.name or not target.is_dir()
906
+ or _is_link_or_junction(version)):
907
+ return None
908
+ return version
909
+ if active.is_dir():
910
+ return active
508
911
  return None
509
912
 
510
913
 
914
+ def _skf_package(child: Path, name: str) -> tuple[Path | None, list[str]]:
915
+ """The package SKF generated that a stack roster reads, and what it could not read.
916
+
917
+ Candidates, in order: the version `active` names, the version folders
918
+ from highest to lowest (`active`, links and `.skf-` names aside), then a
919
+ flat root `SKILL.md`. The first whose `metadata.json` carries an SKF
920
+ marker wins (`{v}/{name}/metadata.json`, or the root one for the flat
921
+ layout). Returns (package, problems): package is None when no candidate
922
+ is marked; problems names each candidate passed over because SKF could
923
+ not read it, its metadata.json or its version folder. A linked package
924
+ is never SKF output (`_is_marked_version`), so it is passed over without
925
+ a problem and its metadata.json is never read.
926
+ """
927
+ problems: list[str] = []
928
+
929
+ def marked(version: Path) -> bool:
930
+ package = version / name
931
+ try:
932
+ os.listdir(version) # raises on every Python when SKF cannot search it
933
+ if _is_marked_version(version, name):
934
+ return True
935
+ if _is_link_or_junction(package):
936
+ return False
937
+ except OSError as exc:
938
+ problems.append(f"{name}/{version.name} cannot be read ({exc})")
939
+ return False
940
+ problem = _metadata_problem(package)
941
+ if problem:
942
+ problems.append(f"{name}/{version.name}/{name}/metadata.json {problem}")
943
+ return False
944
+
945
+ active = _active_version_dir(child)
946
+ if active is not None and marked(active):
947
+ return child / "active" / name, problems
948
+ try:
949
+ entries = list(child.iterdir())
950
+ except OSError as exc:
951
+ entries = []
952
+ problems.append(f"{name} cannot be listed ({exc})")
953
+ versions: list[Path] = []
954
+ for entry in entries:
955
+ if (entry.name == "active" or ".skf-" in entry.name
956
+ or (active is not None and entry.name == active.name)):
957
+ continue
958
+ try:
959
+ if _is_link_or_junction(entry) or not entry.is_dir():
960
+ continue
961
+ # os.stat raises on every Python when SKF cannot search `entry`;
962
+ # from 3.14, Path.is_dir would read such a folder as empty.
963
+ if not stat.S_ISDIR(os.stat(entry / name).st_mode):
964
+ continue
965
+ except (FileNotFoundError, NotADirectoryError):
966
+ continue
967
+ except OSError:
968
+ pass # a folder SKF cannot search may hold a version: marked() names it
969
+ versions.append(entry)
970
+ for vdir in sorted(versions, key=lambda d: _version_sort_key(d.name), reverse=True):
971
+ if marked(vdir):
972
+ return vdir / name, problems
973
+ if (child / "SKILL.md").is_file():
974
+ if _has_skf_metadata(child / "metadata.json"):
975
+ return child, problems
976
+ problem = _metadata_problem(child)
977
+ if problem:
978
+ problems.append(f"{name}/metadata.json {problem}")
979
+ return None, problems
980
+
981
+
511
982
  # --------------------------------------------------------------------------
512
983
  # Enumeration entry point
513
984
  # --------------------------------------------------------------------------
@@ -515,7 +986,7 @@ def _resolve_package_dir(child: Path) -> Path | None:
515
986
 
516
987
  def enumerate_stack_skills(skills_root: Path) -> dict:
517
988
  """Walk `skills_root`, build inventory, detect cycles, return result."""
518
- result: dict = {"skills": [], "cycles": [], "warnings": []}
989
+ result: dict = {"skills": [], "cycles": [], "warnings": [], "not_skf_output": []}
519
990
  compose_graph: dict[str, list[str]] = {}
520
991
 
521
992
  try:
@@ -528,38 +999,44 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
528
999
 
529
1000
  for child in children:
530
1001
  name = child.name
531
- if name.startswith("."):
532
- continue
1002
+ if name.startswith(".") or name == "_batch" or ".skf-" in name:
1003
+ continue # hidden folders and SKF's own batch and staging names
533
1004
 
534
- # Symlink fallback — a child-level symlink (e.g. a top-level `active`
535
- # pointer) must resolve to a readable directory. If it's broken, warn
536
- # and skip; otherwise keep the original path so the reported `path`
537
- # stays relative to skills_root.
538
- if child.is_symlink():
1005
+ # A linked top-level folder is never SKF output: SKF never links a
1006
+ # skill folder. List it when it looks like a skill; a broken link,
1007
+ # or one SKF cannot read through, holds nothing to list.
1008
+ if _is_link_or_junction(child):
539
1009
  try:
540
- target = child.resolve(strict=True)
541
- except (FileNotFoundError, RuntimeError, OSError) as exc:
542
- result["warnings"].append(
543
- f"{name}: symlink target unreadable ({exc})"
544
- )
545
- continue
546
- if not target.is_dir():
547
- result["warnings"].append(
548
- f"{name}: symlink target is not a directory"
549
- )
550
- continue
551
- elif not child.is_dir():
1010
+ if _looks_like_skill(child, name):
1011
+ result["not_skf_output"].append(name)
1012
+ except OSError:
1013
+ pass
1014
+ continue
1015
+ if not child.is_dir():
552
1016
  continue
553
1017
 
554
- # Resolve the package dir across flat and version-nested layouts
555
- # (knowledge/version-paths.md). Subdirs with no reachable SKILL.md
556
- # (e.g. `shared/`, `knowledge/`) are skipped silently — not packages.
557
1018
  try:
558
- skill_dir = _resolve_package_dir(child)
559
- except OSError as exc:
1019
+ skill_dir, problems = _skf_package(child, name)
1020
+ foreign = skill_dir is None and not problems and _looks_like_skill(child, name)
1021
+ except Exception as exc: # noqa: BLE001 — per-skill failures are warnings, not fatal
1022
+ # An unreadable folder raises OSError; a metadata.json nested
1023
+ # too deeply for the JSON parser raises RecursionError.
560
1024
  result["warnings"].append(f"{name}: failed to resolve package dir ({exc})")
561
1025
  continue
562
1026
  if skill_dir is None:
1027
+ if problems:
1028
+ result["warnings"].append(
1029
+ f"{name}: SKF cannot tell whether it generated this skill: "
1030
+ + "; ".join(problems))
1031
+ elif foreign:
1032
+ result["not_skf_output"].append(name)
1033
+ continue
1034
+ rel = skill_dir.relative_to(skills_root).as_posix()
1035
+ if problems:
1036
+ result["warnings"].append(
1037
+ f"{name}: " + "; ".join(problems) + f"; read the SKF package at {rel} instead")
1038
+ if not (skill_dir / "SKILL.md").is_file():
1039
+ result["warnings"].append(f"{name}: the SKF package at {rel} has no SKILL.md")
563
1040
  continue
564
1041
 
565
1042
  try:
@@ -568,14 +1045,9 @@ def enumerate_stack_skills(skills_root: Path) -> dict:
568
1045
  result["warnings"].append(f"{name}: enumeration failed: {exc}")
569
1046
  continue
570
1047
 
571
- # Reflect the resolved package location (forward-slash, relative to
572
- # skills_root) — for the nested layout this is the `{active_skill}`
573
- # path, for the flat layout just the skill name.
574
- try:
575
- entry["path"] = skill_dir.relative_to(skills_root).as_posix()
576
- except ValueError:
577
- entry["path"] = name
578
-
1048
+ # The resolved package location, forward-slash and relative to
1049
+ # skills_root: `x/active/x`, `x/{version}/x` or `x` (flat).
1050
+ entry["path"] = rel
579
1051
  result["skills"].append(entry)
580
1052
  result["warnings"].extend(skill_warnings)
581
1053
  compose_graph[name] = composes
@@ -611,6 +1083,36 @@ def compute_pairs(skills: list[dict]) -> list[dict]:
611
1083
  ]
612
1084
 
613
1085
 
1086
+ def compute_scope(names: list[str], in_scope: list[str]) -> dict:
1087
+ """Split an inventory's pairs by the skills in scope (`scope`).
1088
+
1089
+ `names` are the inventory's skill names and `in_scope` the names of the
1090
+ skills in scope. The pairs are compute_pairs() over `names`, so they are
1091
+ the ones `--pairs` emitted, in the same order; a pair is in scope when
1092
+ both of its libraries are. See "Scope" in the module docstring for the
1093
+ keys.
1094
+ """
1095
+ known = set(names)
1096
+ unknown: list[str] = []
1097
+ for name in in_scope:
1098
+ if name not in known and name not in unknown:
1099
+ unknown.append(name)
1100
+ kept = {name for name in in_scope if name in known}
1101
+ pairs = compute_pairs([{"name": name} for name in known])
1102
+ inside = [p for p in pairs if p["library_a"] in kept and p["library_b"] in kept]
1103
+ outside = [p for p in pairs if not (p["library_a"] in kept and p["library_b"] in kept)]
1104
+ return {
1105
+ "in_scope": sorted(kept),
1106
+ "out_of_scope": sorted(known - kept),
1107
+ "unknown": unknown,
1108
+ "in_scope_pairs": inside,
1109
+ "out_of_scope_pairs": outside,
1110
+ "pair_count": len(pairs),
1111
+ "in_scope_pair_count": len(inside),
1112
+ "out_of_scope_pair_count": len(outside),
1113
+ }
1114
+
1115
+
614
1116
  # --------------------------------------------------------------------------
615
1117
  # Derived output: inventory reliability verdict (--reliability)
616
1118
  # --------------------------------------------------------------------------
@@ -623,7 +1125,7 @@ def compute_reliability(skill_count: int, warning_count: int) -> dict:
623
1125
  or 0.0 for an empty inventory (no ZeroDivisionError). The inventory is
624
1126
  reliable when that ratio is <= RELIABILITY_THRESHOLD (boundary
625
1127
  inclusive). Emits the raw counts too so a caller's halt message can
626
- render "{warning_count}/{skill_count+warning_count}".
1128
+ render "{warning_count} warning(s) across {skill_count} skill(s)".
627
1129
  """
628
1130
  total = skill_count + warning_count
629
1131
  unreliable_ratio = (warning_count / total) if total else 0.0
@@ -635,11 +1137,266 @@ def compute_reliability(skill_count: int, warning_count: int) -> dict:
635
1137
  }
636
1138
 
637
1139
 
1140
+ # --------------------------------------------------------------------------
1141
+ # Derived output: metadata_hash changes (--expect-hashes)
1142
+ # --------------------------------------------------------------------------
1143
+
1144
+
1145
+ def load_expected_hashes(text: str) -> dict[str, str | None]:
1146
+ """The metadata_hash recorded for each skill name, from --expect-hashes JSON.
1147
+
1148
+ Takes an enumerate result, whose skills[] entries give each `name` its
1149
+ `metadata_hash`, or an object mapping each skill name to its hash (a
1150
+ string, or null for none). Raises ValueError on anything else.
1151
+ """
1152
+ data = json.loads(text)
1153
+ if not isinstance(data, dict):
1154
+ raise ValueError("the JSON root is not an object")
1155
+ if not isinstance(data.get("skills"), list):
1156
+ if not all(h is None or isinstance(h, str) for h in data.values()):
1157
+ raise ValueError(
1158
+ "expected an enumerate result (with skills[]) or an object "
1159
+ "mapping each skill name to its metadata_hash")
1160
+ return dict(data)
1161
+ expected: dict[str, str | None] = {}
1162
+ for entry in data["skills"]:
1163
+ name = entry.get("name") if isinstance(entry, dict) else None
1164
+ if not isinstance(name, str):
1165
+ raise ValueError("a skills[] entry has no string name")
1166
+ metadata_hash = entry.get("metadata_hash")
1167
+ if metadata_hash is not None and not isinstance(metadata_hash, str):
1168
+ raise ValueError(f"skills[] entry {name!r} has a metadata_hash that is not a string")
1169
+ if name in expected:
1170
+ raise ValueError(f"skills[] lists {name!r} twice")
1171
+ expected[name] = metadata_hash
1172
+ return expected
1173
+
1174
+
1175
+ def compare_hashes(skills: list[dict], expected: dict[str, str | None]) -> dict:
1176
+ """Which skills changed against the metadata_hash recorded for them.
1177
+
1178
+ changed_skills: enumerated now with another metadata_hash;
1179
+ missing_skills: recorded but not enumerated now; new_skills: enumerated
1180
+ now but not recorded. Each sorted by name; hashes compare as written.
1181
+ """
1182
+ current = {entry["name"]: entry["metadata_hash"] for entry in skills}
1183
+ return {
1184
+ "changed_skills": sorted(
1185
+ name for name, metadata_hash in expected.items()
1186
+ if name in current and current[name] != metadata_hash),
1187
+ "missing_skills": sorted(name for name in expected if name not in current),
1188
+ "new_skills": sorted(name for name in current if name not in expected),
1189
+ }
1190
+
1191
+
1192
+ # --------------------------------------------------------------------------
1193
+ # Candidates: pick the compose-mode candidates and gate them (candidates)
1194
+ # --------------------------------------------------------------------------
1195
+
1196
+
1197
+ def _is_folder_name(skills_root: Path, name: str) -> bool:
1198
+ """True when `name` can name one folder directly in `skills_root`."""
1199
+ folder = skills_root / name
1200
+ return (name not in ("", ".", "..") and "\x00" not in name
1201
+ and folder.parent == skills_root and folder.name == name)
1202
+
1203
+
1204
+ def _has_folder(skills_root: Path, name: str) -> bool:
1205
+ """True when `skills_root/name` is a folder, or SKF cannot tell.
1206
+
1207
+ A name that cannot be one folder in the skills root (empty, `.`, `..`, a
1208
+ path) never is. A folder SKF cannot stat counts as present, so a
1209
+ manifest key is never called stale because of a folder SKF only fails to
1210
+ read.
1211
+ """
1212
+ if not _is_folder_name(skills_root, name):
1213
+ return False
1214
+ try:
1215
+ return stat.S_ISDIR(os.stat(skills_root / name).st_mode)
1216
+ except (FileNotFoundError, NotADirectoryError):
1217
+ return False
1218
+ except OSError:
1219
+ return True
1220
+
1221
+
1222
+ def _manifest_keys(skills_root: Path) -> tuple[list[str], str | None]:
1223
+ """The skill names the export manifest lists, and why it could not be read.
1224
+
1225
+ ([], None) when the manifest is absent or lists nothing; ([], reason)
1226
+ when it cannot be read or is not the manifest's shape (an object whose
1227
+ `exports`, when present, is an object).
1228
+ """
1229
+ manifest = skills_root / EXPORT_MANIFEST
1230
+ try:
1231
+ raw = manifest.read_bytes()
1232
+ except FileNotFoundError:
1233
+ return [], None
1234
+ except OSError as exc:
1235
+ return [], f"{EXPORT_MANIFEST} cannot be read ({exc})"
1236
+ try:
1237
+ data = json.loads(raw.decode("utf-8"))
1238
+ except (ValueError, RecursionError) as exc: # JSON, UTF-8 or nesting errors
1239
+ return [], f"{EXPORT_MANIFEST} is not valid JSON ({exc})"
1240
+ if not isinstance(data, dict):
1241
+ return [], f"{EXPORT_MANIFEST} root is not an object"
1242
+ exports = data.get("exports")
1243
+ if exports is None:
1244
+ return [], None
1245
+ if not isinstance(exports, dict):
1246
+ return [], f"{EXPORT_MANIFEST} exports is not an object"
1247
+ return list(exports), None
1248
+
1249
+
1250
+ def _active_link_candidates(skills_root: Path) -> list[str]:
1251
+ """The top-level folders matching `<skills-root>/*/active/*/SKILL.md`.
1252
+
1253
+ `active` may be a link or a real folder. Dot names never match, as in a
1254
+ glob, and SKF's own `_batch` and `.skf-` names are skipped as the roster
1255
+ skips them.
1256
+ """
1257
+ try:
1258
+ children = list(skills_root.iterdir())
1259
+ except OSError:
1260
+ return []
1261
+ found: list[str] = []
1262
+ for child in children:
1263
+ name = child.name
1264
+ if name.startswith(".") or name == "_batch" or ".skf-" in name:
1265
+ continue
1266
+ active = child / "active"
1267
+ try:
1268
+ packages = os.listdir(active)
1269
+ except OSError: # no `active`, not a folder, or unreadable
1270
+ continue
1271
+ for package in packages:
1272
+ if package.startswith("."):
1273
+ continue
1274
+ try:
1275
+ if stat.S_ISREG(os.stat(active / package / "SKILL.md").st_mode):
1276
+ found.append(name)
1277
+ break
1278
+ except OSError:
1279
+ continue
1280
+ return found
1281
+
1282
+
1283
+ def explicit_skill_dir(skills_root: Path, entry: str) -> str | None:
1284
+ """The skill folder an --explicit entry names; None for a path outside `skills_root`.
1285
+
1286
+ An entry with no `/` or `\\` is the folder name itself. Any other entry is
1287
+ a package path, absolute or relative to the current folder, that names
1288
+ the first folder of its path under `skills_root`: compared as written
1289
+ first (so `active/<name>` counts as written), then with links resolved.
1290
+ """
1291
+ if "/" not in entry and "\\" not in entry:
1292
+ return entry
1293
+ path = Path(entry.replace("\\", "/"))
1294
+ for resolve in (False, True):
1295
+ try:
1296
+ if resolve:
1297
+ parts = path.resolve().relative_to(skills_root.resolve()).parts
1298
+ else:
1299
+ parts = Path(os.path.abspath(path)).relative_to(os.path.abspath(skills_root)).parts
1300
+ except (ValueError, OSError, RuntimeError): # outside, or a path SKF cannot resolve
1301
+ continue
1302
+ if parts:
1303
+ return parts[0]
1304
+ return None
1305
+
1306
+
1307
+ def _excluded(skill_dir: str, reason: str, message: str) -> dict:
1308
+ return {"skill_dir": skill_dir, "reason": reason, "message": message}
1309
+
1310
+
1311
+ def compute_candidates(skills_root: Path, explicit: list[str] | None = None) -> dict:
1312
+ """Pick the compose-mode candidates and gate them against the roster.
1313
+
1314
+ `explicit` names the candidates when given; else the export manifest
1315
+ keys; else, when the manifest is absent, lists nothing or cannot be
1316
+ read, the folders holding an `active` package. A candidate is kept when
1317
+ the roster has it with skill_type single, individual or null; every
1318
+ other one is excluded with the first reason that applies, except a
1319
+ manifest key with no folder, which is a stale manifest key.
1320
+ """
1321
+ roster = enumerate_stack_skills(skills_root)
1322
+ manifest_parse_error = None
1323
+ outside: list[str] = []
1324
+ if explicit is not None:
1325
+ source, names = CANDIDATES_EXPLICIT, []
1326
+ for entry in explicit:
1327
+ skill_dir = explicit_skill_dir(skills_root, entry)
1328
+ if skill_dir is None:
1329
+ outside.append(entry)
1330
+ else:
1331
+ names.append(skill_dir)
1332
+ else:
1333
+ names, manifest_parse_error = _manifest_keys(skills_root)
1334
+ source = CANDIDATES_MANIFEST
1335
+ if not names:
1336
+ source, names = CANDIDATES_ACTIVE_LINKS, _active_link_candidates(skills_root)
1337
+ # One order for every source and platform: a Path sorts without case on
1338
+ # Windows, a name always with it.
1339
+ names = sorted(set(names))
1340
+
1341
+ by_name = {entry["name"]: entry for entry in roster["skills"]}
1342
+ not_skf_output = set(roster["not_skf_output"])
1343
+ kept: list[dict] = []
1344
+ excluded: list[dict] = []
1345
+ stale: list[str] = []
1346
+ for name in names:
1347
+ entry = by_name.get(name)
1348
+ if entry is not None:
1349
+ if entry["skill_type"] in _SINGLE_SKILL_TYPES:
1350
+ kept.append(entry)
1351
+ else:
1352
+ excluded.append(_excluded(
1353
+ name, REASON_NOT_A_SKILL,
1354
+ f"{name}: not a skill (skill_type {entry['skill_type']}), excluding"))
1355
+ continue
1356
+ if name in not_skf_output:
1357
+ excluded.append(_excluded(name, REASON_NOT_SKF_OUTPUT, f"{name}: not SKF output, excluding"))
1358
+ continue
1359
+ named = [w for w in roster["warnings"] if w.startswith(f"{name}: ")]
1360
+ if named:
1361
+ excluded.append(_excluded(name, REASON_ROSTER_WARNING, "; ".join(named)))
1362
+ continue
1363
+ if not _has_folder(skills_root, name):
1364
+ if source == CANDIDATES_MANIFEST:
1365
+ stale.append(name)
1366
+ else:
1367
+ excluded.append(_excluded(
1368
+ name, REASON_NO_SUCH_FOLDER, f"{name}: no such skill folder, excluding"))
1369
+ continue
1370
+ excluded.append(_excluded(name, REASON_NO_SKF_PACKAGE, f"{name}: no SKF skill package, excluding"))
1371
+ for entry in sorted(set(outside)):
1372
+ excluded.append(_excluded(
1373
+ entry, REASON_OUTSIDE_SKILLS_ROOT, f"{entry}: not inside the skills folder, excluding"))
1374
+ excluded.sort(key=lambda x: x["skill_dir"])
1375
+
1376
+ return {
1377
+ "candidate_source": source,
1378
+ "kept": kept,
1379
+ "excluded": excluded,
1380
+ "stale_manifest_keys": stale,
1381
+ "manifest_parse_error": manifest_parse_error,
1382
+ "cycles": roster["cycles"],
1383
+ "warnings": roster["warnings"],
1384
+ "not_skf_output": roster["not_skf_output"],
1385
+ }
1386
+
1387
+
638
1388
  # --------------------------------------------------------------------------
639
1389
  # CLI
640
1390
  # --------------------------------------------------------------------------
641
1391
 
642
1392
 
1393
+ def _read_expected_hashes(source: str) -> dict[str, str | None]:
1394
+ """Load the --expect-hashes file (`-` reads stdin). Raises ValueError or OSError."""
1395
+ if source == "-":
1396
+ return load_expected_hashes(sys.stdin.read())
1397
+ return load_expected_hashes(Path(source).read_text(encoding="utf-8"))
1398
+
1399
+
643
1400
  def _cmd_enumerate(args: argparse.Namespace) -> int:
644
1401
  skills_root = Path(args.skills_root)
645
1402
  if not skills_root.is_dir():
@@ -648,9 +1405,16 @@ def _cmd_enumerate(args: argparse.Namespace) -> int:
648
1405
  file=sys.stderr,
649
1406
  )
650
1407
  return 1
1408
+ expected = None
1409
+ if args.expect_hashes is not None:
1410
+ try:
1411
+ expected = _read_expected_hashes(args.expect_hashes)
1412
+ except (OSError, ValueError, RecursionError) as exc:
1413
+ print(f"error: --expect-hashes {args.expect_hashes}: {exc}", file=sys.stderr)
1414
+ return 1
651
1415
  result = enumerate_stack_skills(skills_root)
652
1416
  # Additive derived output — attached only when the flag is set, so the
653
- # default shape stays {skills, cycles, warnings} for existing consumers.
1417
+ # default shape stays {skills, cycles, warnings, not_skf_output}.
654
1418
  if args.pairs:
655
1419
  result["pairs"] = compute_pairs(result["skills"])
656
1420
  result["pair_count"] = len(result["pairs"])
@@ -658,11 +1422,47 @@ def _cmd_enumerate(args: argparse.Namespace) -> int:
658
1422
  result.update(
659
1423
  compute_reliability(len(result["skills"]), len(result["warnings"]))
660
1424
  )
1425
+ if expected is not None:
1426
+ result.update(compare_hashes(result["skills"], expected))
661
1427
  json.dump(result, sys.stdout, indent=2)
662
1428
  sys.stdout.write("\n")
663
1429
  return 0
664
1430
 
665
1431
 
1432
+ def _parse_explicit(value: str) -> list[str]:
1433
+ """The names of a comma-separated list (--explicit, --skills, --in-scope), trimmed, empty items dropped."""
1434
+ return [name for name in (part.strip() for part in value.split(",")) if name]
1435
+
1436
+
1437
+ def _cmd_candidates(args: argparse.Namespace) -> int:
1438
+ skills_root = Path(args.skills_root)
1439
+ if not skills_root.is_dir():
1440
+ print(
1441
+ f"error: skills root not a directory: {skills_root}",
1442
+ file=sys.stderr,
1443
+ )
1444
+ return 1
1445
+ explicit = None
1446
+ if args.explicit is not None:
1447
+ explicit = _parse_explicit(args.explicit)
1448
+ if not explicit:
1449
+ print("error: --explicit names no skill", file=sys.stderr)
1450
+ return 1
1451
+ json.dump(compute_candidates(skills_root, explicit), sys.stdout, indent=2)
1452
+ sys.stdout.write("\n")
1453
+ return 0
1454
+
1455
+
1456
+ def _cmd_scope(args: argparse.Namespace) -> int:
1457
+ names = _parse_explicit(args.skills)
1458
+ if not names:
1459
+ print("error: --skills names no skill", file=sys.stderr)
1460
+ return 1
1461
+ json.dump(compute_scope(names, _parse_explicit(args.in_scope)), sys.stdout, indent=2)
1462
+ sys.stdout.write("\n")
1463
+ return 0
1464
+
1465
+
666
1466
  def _build_parser() -> argparse.ArgumentParser:
667
1467
  parser = argparse.ArgumentParser(
668
1468
  prog="skf-enumerate-stack-skills",
@@ -680,7 +1480,7 @@ def _build_parser() -> argparse.ArgumentParser:
680
1480
  )
681
1481
  p_enum.add_argument(
682
1482
  "skills_root",
683
- help="directory containing skill packages (each subdir has SKILL.md)",
1483
+ help="directory holding skill folders; only packages SKF generated are enumerated",
684
1484
  )
685
1485
  p_enum.add_argument(
686
1486
  "--pairs",
@@ -699,8 +1499,61 @@ def _build_parser() -> argparse.ArgumentParser:
699
1499
  f"`warning_count`; threshold {RELIABILITY_THRESHOLD})"
700
1500
  ),
701
1501
  )
1502
+ p_enum.add_argument(
1503
+ "--expect-hashes",
1504
+ metavar="FILE",
1505
+ help=(
1506
+ "additionally compare each skill's metadata_hash with FILE "
1507
+ "(an earlier enumerate result, or an object mapping each skill "
1508
+ "name to its metadata_hash; `-` reads stdin) and emit "
1509
+ "`changed_skills`, `missing_skills` and `new_skills`"
1510
+ ),
1511
+ )
702
1512
  p_enum.set_defaults(func=_cmd_enumerate)
703
1513
 
1514
+ p_cand = sub.add_parser(
1515
+ "candidates",
1516
+ help="pick the compose-mode candidates and gate them against the roster",
1517
+ )
1518
+ p_cand.add_argument(
1519
+ "skills_root",
1520
+ help="directory holding skill folders; only packages SKF generated can be kept",
1521
+ )
1522
+ p_cand.add_argument(
1523
+ "--explicit",
1524
+ metavar="NAMES",
1525
+ help=(
1526
+ "comma-separated skill folder names or package paths to gate, "
1527
+ "instead of the export manifest keys or the folders holding an "
1528
+ "`active` package; a path names its first folder under skills-root"
1529
+ ),
1530
+ )
1531
+ p_cand.set_defaults(func=_cmd_candidates)
1532
+
1533
+ p_scope = sub.add_parser(
1534
+ "scope",
1535
+ help="split the unique pairs of an inventory's skills by the skills in scope",
1536
+ )
1537
+ p_scope.add_argument(
1538
+ "--skills",
1539
+ required=True,
1540
+ metavar="NAMES",
1541
+ help=(
1542
+ "comma-separated names of the inventory's skills (the `name` of "
1543
+ "each skills[] entry of an enumerate result)"
1544
+ ),
1545
+ )
1546
+ p_scope.add_argument(
1547
+ "--in-scope",
1548
+ required=True,
1549
+ metavar="NAMES",
1550
+ help=(
1551
+ "comma-separated names of the skills in scope; a name that is no "
1552
+ "inventory skill is listed in `unknown`"
1553
+ ),
1554
+ )
1555
+ p_scope.set_defaults(func=_cmd_scope)
1556
+
704
1557
  return parser
705
1558
 
706
1559
 
@@ -710,5 +1563,21 @@ def main(argv: list[str] | None = None) -> int:
710
1563
  return args.func(args)
711
1564
 
712
1565
 
1566
+ def _force_utf8(*streams) -> None:
1567
+ """Reconfigure stdout and stderr to UTF-8, keeping each stream's error handler.
1568
+
1569
+ A Windows console pipes them as cp1252, which cannot print every character
1570
+ of the --help text, so --help would stop with UnicodeEncodeError.
1571
+ """
1572
+ for stream in streams:
1573
+ if hasattr(stream, "reconfigure"):
1574
+ errors = getattr(stream, "errors", None)
1575
+ if errors is None:
1576
+ stream.reconfigure(encoding="utf-8")
1577
+ else:
1578
+ stream.reconfigure(encoding="utf-8", errors=errors)
1579
+
1580
+
713
1581
  if __name__ == "__main__":
1582
+ _force_utf8(sys.stdout, sys.stderr)
714
1583
  raise SystemExit(main())