bmad-module-skill-forge 2.1.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (435) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +51 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +135 -18
  18. package/docs/verifying-a-skill.md +83 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +2 -2
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +32 -12
  40. package/src/shared/references/description-guard-protocol.md +14 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +30 -14
  64. package/src/shared/scripts/skf-build-change-manifest.py +2203 -24
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +694 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +1 -1
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +3 -2
  74. package/src/shared/scripts/skf-description-guard.py +117 -9
  75. package/src/shared/scripts/skf-detect-docs.py +892 -4
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +1 -1
  79. package/src/shared/scripts/skf-detect-tools.py +215 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +4084 -85
  86. package/src/shared/scripts/skf-extraction-inventory.py +746 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +539 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +444 -203
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +661 -0
  94. package/src/shared/scripts/skf-hash-content.py +166 -12
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +159 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +1 -1
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +161 -27
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +39 -7
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +128 -13
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +670 -119
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +5 -5
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +4 -1
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +2919 -54
  138. package/src/shared/scripts/skf-write-skill-brief.py +367 -19
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +27 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/init.md +148 -210
  168. package/src/skf-audit-skill/references/re-index.md +96 -108
  169. package/src/skf-audit-skill/references/report.md +96 -33
  170. package/src/skf-audit-skill/references/semantic-diff.md +27 -18
  171. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  172. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  173. package/src/skf-audit-skill/references/structural-diff.md +75 -90
  174. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  175. package/src/skf-audit-skill/scripts/render-drift-tables.py +685 -0
  176. package/src/skf-brief-skill/SKILL.md +36 -25
  177. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  178. package/src/skf-brief-skill/assets/scope-templates.md +17 -24
  179. package/src/skf-brief-skill/assets/skill-brief-schema.md +29 -15
  180. package/src/skf-brief-skill/customize.toml +48 -20
  181. package/src/skf-brief-skill/references/analyze-target.md +142 -125
  182. package/src/skf-brief-skill/references/confirm-brief.md +10 -8
  183. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  184. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  185. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  186. package/src/skf-brief-skill/references/headless-args.md +84 -16
  187. package/src/skf-brief-skill/references/health-check.md +1 -1
  188. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  189. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  190. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  191. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  192. package/src/skf-brief-skill/references/step-auto-brief.md +24 -80
  193. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  194. package/src/skf-brief-skill/references/version-resolution.md +3 -2
  195. package/src/skf-brief-skill/references/write-brief.md +66 -57
  196. package/src/skf-campaign/SKILL.md +31 -34
  197. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  198. package/src/skf-campaign/customize.toml +47 -16
  199. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  200. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  201. package/src/skf-campaign/references/health-check.md +7 -16
  202. package/src/skf-campaign/references/step-01-setup.md +46 -84
  203. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  204. package/src/skf-campaign/references/step-03-pins.md +26 -18
  205. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  206. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  207. package/src/skf-campaign/references/step-06-batch.md +41 -34
  208. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  209. package/src/skf-campaign/references/step-08-verify.md +28 -29
  210. package/src/skf-campaign/references/step-09-refine.md +29 -30
  211. package/src/skf-campaign/references/step-10-export.md +48 -36
  212. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  213. package/src/skf-campaign/references/step-resume.md +37 -68
  214. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  215. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  216. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  217. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  218. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  219. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  220. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  221. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  222. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  223. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  224. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  225. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  226. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  227. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  228. package/src/skf-create-skill/SKILL.md +27 -20
  229. package/src/skf-create-skill/assets/compile-assembly-rules.md +6 -5
  230. package/src/skf-create-skill/assets/skill-sections.md +17 -6
  231. package/src/skf-create-skill/customize.toml +20 -8
  232. package/src/skf-create-skill/references/authoritative-files-protocol.md +41 -42
  233. package/src/skf-create-skill/references/batch-mode.md +81 -0
  234. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  235. package/src/skf-create-skill/references/compile.md +107 -29
  236. package/src/skf-create-skill/references/component-extraction.md +113 -91
  237. package/src/skf-create-skill/references/enrich.md +41 -23
  238. package/src/skf-create-skill/references/entry-points-by-hand.md +20 -0
  239. package/src/skf-create-skill/references/extract.md +200 -161
  240. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  241. package/src/skf-create-skill/references/extraction-patterns-tracing.md +2 -2
  242. package/src/skf-create-skill/references/extraction-patterns.md +584 -196
  243. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  244. package/src/skf-create-skill/references/health-check.md +1 -1
  245. package/src/skf-create-skill/references/load-brief.md +53 -36
  246. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  247. package/src/skf-create-skill/references/report.md +28 -59
  248. package/src/skf-create-skill/references/source-resolution-protocols.md +49 -155
  249. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  250. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  251. package/src/skf-create-skill/references/step-doc-sources.md +55 -18
  252. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  253. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -57
  254. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  255. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  256. package/src/skf-create-skill/references/validate.md +173 -99
  257. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  258. package/src/skf-create-stack-skill/SKILL.md +29 -56
  259. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  260. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  261. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  262. package/src/skf-create-stack-skill/customize.toml +23 -12
  263. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  264. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  265. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  266. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  267. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  268. package/src/skf-create-stack-skill/references/init.md +90 -32
  269. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  270. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  271. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  272. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  273. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  274. package/src/skf-create-stack-skill/references/report.md +39 -33
  275. package/src/skf-create-stack-skill/references/validate.md +20 -39
  276. package/src/skf-drop-skill/SKILL.md +28 -51
  277. package/src/skf-drop-skill/customize.toml +44 -30
  278. package/src/skf-drop-skill/references/execute.md +79 -146
  279. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  280. package/src/skf-drop-skill/references/report.md +46 -30
  281. package/src/skf-drop-skill/references/select.md +118 -124
  282. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  283. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  284. package/src/skf-export-skill/SKILL.md +40 -62
  285. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  286. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  287. package/src/skf-export-skill/customize.toml +42 -13
  288. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  289. package/src/skf-export-skill/references/health-check.md +2 -0
  290. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  291. package/src/skf-export-skill/references/load-skill.md +88 -62
  292. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  293. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  294. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  295. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  296. package/src/skf-export-skill/references/package.md +15 -73
  297. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  298. package/src/skf-export-skill/references/result-envelope.md +31 -16
  299. package/src/skf-export-skill/references/summary.md +41 -17
  300. package/src/skf-export-skill/references/token-report.md +4 -12
  301. package/src/skf-export-skill/references/update-context.md +118 -237
  302. package/src/skf-forger/SKILL.md +61 -37
  303. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  304. package/src/skf-forger/customize.toml +43 -0
  305. package/src/skf-forger/references/pipeline-mode.md +73 -18
  306. package/src/skf-forger/scripts/forge-status.py +299 -0
  307. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  308. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  309. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  310. package/src/skf-quick-skill/SKILL.md +54 -53
  311. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  312. package/src/skf-quick-skill/customize.toml +51 -20
  313. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  314. package/src/skf-quick-skill/references/compile.md +30 -31
  315. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  316. package/src/skf-quick-skill/references/finalize.md +69 -18
  317. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  318. package/src/skf-quick-skill/references/health-check.md +5 -3
  319. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  320. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  321. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  322. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  323. package/src/skf-refine-architecture/SKILL.md +43 -53
  324. package/src/skf-refine-architecture/customize.toml +49 -20
  325. package/src/skf-refine-architecture/references/compile.md +97 -54
  326. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  327. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  328. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  329. package/src/skf-refine-architecture/references/improvements.md +14 -20
  330. package/src/skf-refine-architecture/references/init.md +98 -57
  331. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  332. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  333. package/src/skf-refine-architecture/references/report.md +65 -54
  334. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  335. package/src/skf-rename-skill/SKILL.md +19 -54
  336. package/src/skf-rename-skill/customize.toml +32 -26
  337. package/src/skf-rename-skill/references/execute.md +230 -184
  338. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  339. package/src/skf-rename-skill/references/health-check.md +1 -7
  340. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  341. package/src/skf-rename-skill/references/report.md +63 -52
  342. package/src/skf-rename-skill/references/select.md +197 -115
  343. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  344. package/src/skf-setup/SKILL.md +26 -26
  345. package/src/skf-setup/customize.toml +14 -10
  346. package/src/skf-setup/references/auto-index.md +50 -63
  347. package/src/skf-setup/references/ccc-index.md +29 -89
  348. package/src/skf-setup/references/detect-and-tier.md +58 -66
  349. package/src/skf-setup/references/health-check.md +2 -0
  350. package/src/skf-setup/references/invocation-contract.md +14 -0
  351. package/src/skf-setup/references/report.md +43 -166
  352. package/src/skf-setup/references/tier-rules.md +5 -5
  353. package/src/skf-setup/references/write-config.md +34 -57
  354. package/src/skf-test-skill/SKILL.md +17 -57
  355. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  356. package/src/skf-test-skill/customize.toml +27 -13
  357. package/src/skf-test-skill/references/coherence-check.md +134 -62
  358. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  359. package/src/skf-test-skill/references/coverage-check.md +217 -254
  360. package/src/skf-test-skill/references/detect-mode.md +5 -5
  361. package/src/skf-test-skill/references/external-validators.md +94 -82
  362. package/src/skf-test-skill/references/health-check.md +6 -7
  363. package/src/skf-test-skill/references/init.md +163 -88
  364. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  365. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  366. package/src/skf-test-skill/references/report.md +162 -142
  367. package/src/skf-test-skill/references/score.md +80 -107
  368. package/src/skf-test-skill/references/scoring-rules.md +41 -155
  369. package/src/skf-test-skill/references/source-access-protocol.md +19 -92
  370. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  371. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  372. package/src/skf-test-skill/scripts/build-result-context.py +287 -0
  373. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  374. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  375. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  376. package/src/skf-test-skill/scripts/gap-ledger.py +1381 -0
  377. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  378. package/src/skf-test-skill/scripts/load-coverage-inputs.py +760 -0
  379. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  380. package/src/skf-test-skill/scripts/reconcile-coverage.py +273 -79
  381. package/src/skf-test-skill/scripts/score-signatures.py +459 -0
  382. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  383. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  384. package/src/skf-test-skill/scripts/verify-declared-numerator.py +127 -52
  385. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  386. package/src/skf-update-skill/SKILL.md +38 -25
  387. package/src/skf-update-skill/customize.toml +27 -12
  388. package/src/skf-update-skill/references/detect-changes.md +233 -222
  389. package/src/skf-update-skill/references/gap-driven.md +336 -0
  390. package/src/skf-update-skill/references/health-check.md +31 -5
  391. package/src/skf-update-skill/references/init.md +250 -84
  392. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  393. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  394. package/src/skf-update-skill/references/merge.md +180 -47
  395. package/src/skf-update-skill/references/re-extract.md +53 -262
  396. package/src/skf-update-skill/references/report.md +100 -35
  397. package/src/skf-update-skill/references/write.md +331 -149
  398. package/src/skf-update-skill/scripts/skf-new-file-diff.py +29 -7
  399. package/src/skf-verify-stack/SKILL.md +23 -42
  400. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  401. package/src/skf-verify-stack/customize.toml +55 -22
  402. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  403. package/src/skf-verify-stack/references/coverage.md +67 -64
  404. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  405. package/src/skf-verify-stack/references/init.md +107 -62
  406. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  407. package/src/skf-verify-stack/references/integrations.md +106 -65
  408. package/src/skf-verify-stack/references/report.md +59 -42
  409. package/src/skf-verify-stack/references/requirements.md +32 -9
  410. package/src/skf-verify-stack/references/synthesize.md +53 -39
  411. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  412. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  413. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  414. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  415. package/tools/cli/commands/install.js +11 -4
  416. package/tools/cli/commands/status.js +15 -3
  417. package/tools/cli/commands/update.js +11 -4
  418. package/tools/cli/lib/tool-check.js +320 -0
  419. package/tools/cli/lib/version-check.js +22 -9
  420. package/tools/cli/skf-cli.js +4 -1
  421. package/.gitattributes +0 -16
  422. package/.nvmrc +0 -1
  423. package/docs/_internal/RELEASING.md +0 -645
  424. package/release-audits/v1.0.0-launch-audit.md +0 -728
  425. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  426. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  427. package/src/skf-campaign/manifest.yaml +0 -15
  428. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  429. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  430. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  431. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  432. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  433. package/src/skf-update-skill/references/validate.md +0 -124
  434. package/tools/validate-docs-drift.js +0 -233
  435. package/tools/validate-docs-links.js +0 -665
@@ -1,32 +1,223 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
5
  """SKF Skill Inventory — Scan skills directory and produce structured inventory.
6
6
 
7
7
  Scans the skills output folder, reads manifests and metadata, resolves active
8
- versions via symlinks, and outputs a JSON inventory. Reused by 9+ skills.
8
+ versions via symlinks, and outputs a JSON inventory. Read by drop-skill and
9
+ rename-skill (roster, the ownership of each skill folder and its forge
10
+ folder, the purge and rename verdicts, and the guarded delete),
11
+ create-skill, quick-skill and create-stack-skill (the write check before a
12
+ version is written), analyze-source (coexistence matches and the name of
13
+ every unit and brief), the flat-layout
14
+ fallback of update-skill, export-skill, audit-skill and test-skill (the
15
+ ownership gate before a flat skill is migrated), and test-skill's report
16
+ (the discovery catalog).
9
17
 
10
- CLI: python3 skf-skill-inventory.py <skills-output-folder>
11
- python3 skf-skill-inventory.py <skills-output-folder> --skill <name>
12
- python3 skf-skill-inventory.py <skills-output-folder> --manifest-only
13
- python3 skf-skill-inventory.py <skills-output-folder> --match-target <url-or-name>
18
+ CLI: uv run skf-skill-inventory.py <skills-output-folder>
19
+ uv run skf-skill-inventory.py <skills-output-folder> --skill <name>
20
+ uv run skf-skill-inventory.py <skills-output-folder> --manifest-only
21
+ uv run skf-skill-inventory.py <skills-output-folder> --match-target <url-or-name>
22
+ uv run skf-skill-inventory.py <skills-output-folder> --near <name>
23
+ uv run skf-skill-inventory.py derive-name --target <url-or-path> [--manifest-name <name>]
24
+ [--skills-folder <skills-output-folder>] [--probe-git]
25
+ uv run skf-skill-inventory.py derive-name --from <names.json|-> [--skills-folder <folder>]
26
+ uv run skf-skill-inventory.py <skills-output-folder> --forge-data-folder <path>
27
+ uv run skf-skill-inventory.py <skills-output-folder> --skill <name> --write-check
28
+ [--write-version <version>] [--forge-data-folder <path>]
29
+ uv run skf-skill-inventory.py <skills-output-folder> --skill <name> --purge-check
30
+ [--purge-version <version>] --forge-data-folder <path>
31
+ uv run skf-skill-inventory.py <skills-output-folder> --skill <name> --rename-check
32
+ --forge-data-folder <path>
33
+ uv run skf-skill-inventory.py guarded-delete --root <folder> [--root <folder>] [<path>...]
34
+ uv run skf-skill-inventory.py resolve <skills-output-folder> --skill <name>
35
+ --forge-data-folder <path> [--version <version>]
36
+ uv run skf-skill-inventory.py version normalize <version>
37
+ uv run skf-skill-inventory.py version order <a> <b>
38
+ uv run skf-skill-inventory.py version next-patch <version>
39
+ uv run skf-skill-inventory.py version bump --prior <version>
40
+ --prior-libraries <a,b> --libraries <a,c>
41
+ uv run skf-skill-inventory.py version primary <candidates.json|->
42
+
43
+ Exit 0 when `status` is "ok" (a write, purge or rename check exits 0
44
+ whatever its verdict, and a guarded delete whatever its `purge_status`);
45
+ exit 1 on an error (`DIR_NOT_FOUND`, `SKILL_NOT_FOUND` for `--skill` or
46
+ `resolve`, `USAGE` for a flag used wrongly, two check flags in one call,
47
+ `--forge-data-folder` without a value or missing from a purge or rename
48
+ check, for `version`: `NOT_A_VERSION`, `NOT_INCREASING` or `BAD_INPUT`,
49
+ and for `derive-name`: `BAD_INPUT`, or `NO_NAME` for one --target).
50
+
51
+ Ownership. The skills folder can hold skills SKF did not generate (a module's
52
+ own skills, skills installed from elsewhere). Only a `metadata.json` carrying
53
+ an SKF marker (`generated_by` naming an SKF generator, `tool_versions.skf`, or
54
+ `skill_type` with `forge_tier` or `confidence_tier`) proves that SKF generated
55
+ a skill, at the group root (flat layout) or at `{v}/{name}/metadata.json`
56
+ (versioned layout). The versioned layout, an `active` link, a manifest key or
57
+ a result file are not proof on their own. Each `skills[]` entry adds:
58
+
59
+ - `ownership`: "skf" (SKF evidence and nothing else), "mixed" (SKF evidence
60
+ plus entries SKF did not generate) or "foreign" (no SKF evidence). Evidence
61
+ is a marked version, a marked root `metadata.json`, the `_batch` folder, or
62
+ `.skf-` in the folder name (`_has_skf_evidence`, which the ccc helper
63
+ shares, so setup's ccc exclusions cover exactly these groups). A linked
64
+ folder is always "foreign", and a linked version folder, or a linked
65
+ package inside one, is never SKF output.
66
+ - `skf_skill`: a marked version, or a flat skill with a marked root.
67
+ - `flat_skf`: a root `SKILL.md` beside a marked root `metadata.json`; the
68
+ only flat layout a workflow may migrate.
69
+ - `foreign_entries`: the entries SKF did not generate, sorted; directories
70
+ end in `/`, a linked entry is listed by its bare name, and an entry inside
71
+ a marked version folder `v` is listed as `v/<entry>` (SKF puts only the
72
+ `{name}/` package and `.skf-` staging names there). A folder that holds no
73
+ file (only neutral clutter, `.skf-` names or folders like itself) is never
74
+ foreign: an interrupted SKF run leaves one before it writes metadata.json.
75
+
76
+ The existing `errors` list also names a linked folder (never checked for a
77
+ marker), a folder in the skill folder that SKF cannot list or search (listed
78
+ as foreign) and a `metadata.json` that cannot be read. The top-level `not_skf_output` lists the scanned names (never `_batch` or a `.skf-` name) whose `skf_skill` is
79
+ false that still look like a skill (a root `SKILL.md` or a `{v}/{name}/`
80
+ folder). `versions`, `active_version` and `active_path` keep their structural
81
+ meaning for every entry, except that the package folders of a flat SKF skill
82
+ (`references/{name}/`, for example) are never read as versions.
83
+
84
+ Forge folders. With `--forge-data-folder`, the top-level `forge_groups[]`
85
+ classifies `{forge_data_folder}/{name}` for every scanned name (`name`,
86
+ `path`, `ownership`, `foreign_entries`, `errors`; see `classify_forge_group`),
87
+ and `same_folder` says whether both settings name one folder. When they do,
88
+ `forge_groups` is empty and each skill folder is classified once, accepting
89
+ SKF's forge files too. The path given is echoed as `forge_data_folder`.
90
+
91
+ Write check. `--skill <name> --write-check [--write-version <v>]` returns
92
+ `write_check` (see `write_check`); the writers run it before creating any
93
+ directory. Its `version_generated_by` and `version_confidence_tier` name the
94
+ build already at that version, so quick-skill can ask before it replaces
95
+ another generator's package.
96
+
97
+ Purge and rename checks. `--skill <name> --purge-check [--purge-version
98
+ <v>]` returns `purge_check` (see `purge_check`): whether drop-skill may
99
+ purge the whole skill or one version, the rule that refused it, and the
100
+ folders the purge deletes and leaves in place. `--skill <name>
101
+ --rename-check` returns `rename_check` (see `rename_check`): whether
102
+ rename-skill may move the skill, and whether its forge folder moves with
103
+ it. Both need `--forge-data-folder` and apply the same version rule as the
104
+ write check (`0.1.0-rc/` is never version `0.1.0`).
105
+
106
+ Guarded delete. `guarded-delete --root <folder>... [<path>...]` deletes
107
+ each path that is a plain folder inside a root, reached through no link,
108
+ and reports each outcome and a `purge_status` (see `guarded_delete`); with
109
+ no path it deletes nothing and reports "success". drop-skill's purge and
110
+ rename-skill's delete of the old folders go through it, with the skills
111
+ and forge folders as roots.
112
+
113
+ Resolve. `resolve` returns `resolve` (see `resolve_skill`) for one skill:
114
+ the manifest's `active_version`, the `active` link's target, the version a
115
+ reading workflow uses and why (the Manifest-lag guard of
116
+ knowledge/version-paths.md, with each version's provenance `generated_at`),
117
+ `forge_version`, the metadata.json, provenance-map.json and
118
+ evidence-report.md paths (versioned first, flat as the fallback), and every
119
+ version newest first with its manifest status and the counts drop-skill and
120
+ rename-skill read. `--version` names the version instead (an operator's
121
+ choice). It reads, never writes, and does not decide ownership.
122
+
123
+ Version. `version` turns version strings into one answer: `normalize`
124
+ reduces a version or range to one folder name (`^18.2.0` gives `18.2.0`,
125
+ build metadata stripped), `order` compares two (`1.10.0` above `1.9.0`),
126
+ `next-patch` gives the next patch version, `bump` the compose-mode stack
127
+ version after a prior one (major when a library was removed, else minor,
128
+ refused when not above it), and `primary` the code-mode primary library of
129
+ a stack and the version it gives, ties broken by a fixed rule.
130
+
131
+ Skill names. `derive_name` is the one rule for the name analyze-source
132
+ writes a unit or a brief under, and the name `--match-target` compares, so
133
+ a coexistence check never looks for a name the brief does not get. With a
134
+ manifest name (the name the unit's own manifest gives its package, as
135
+ skf-scan-manifests.py reads it) of a package that is not private, the name
136
+ comes from the manifest: an npm scope joins the name (`@trpc/server` gives
137
+ `trpc-server`), a Go module path gives its last segment less a `/vN`
138
+ major-version suffix (`github.com/spf13/cobra` gives `cobra`), a Maven
139
+ `groupId:artifactId` its artifact, and any other name is kebab-cased
140
+ (`PyYAML` gives `pyyaml`, `serde_json` gives `serde-json`). A private
141
+ manifest (a monorepo's workspace root, an internal app) names nothing.
142
+ Without a manifest name, a merged unit (a composite, a merged monorepo)
143
+ takes the name its members' manifest names share (`@aws-sdk/client-s3`
144
+ and `@aws-sdk/core` give `aws-sdk`; Maven members of one group the
145
+ group's last segment). Else a documentation URL (an http(s) URL whose
146
+ host is not github.com, gitlab.com or bitbucket.org and that is not a git
147
+ clone URL, one whose path ends in `.git` or holds `/_git/`) gives its host
148
+ (`https://docs.example.com/guide` gives `docs-example-com`), and any other
149
+ target its last folder or repository name (`https://github.com/vercel/next.js`
150
+ gives `next-js`, `C:\\code\\mono` gives `mono`, and `.` the current
151
+ folder's name).
152
+
153
+ `derive-name --target <t> [--manifest-name <n>]` returns `name` and
154
+ `basis` ("manifest", "docs-host" or "target"), and exits 1 with `NO_NAME`
155
+ when no name can be derived. It also returns the target's `kind` and
156
+ `clone_url` (see `target_kind` and `clone_url`), so analyze-source routes
157
+ and clones a target by one rule on its `[auto]` path and its step-by-step
158
+ chain: `kind` is "local" for a path that starts with `/`, `./`, `../` or
159
+ `~` (or a Windows drive or `.\\`) or names an existing folder, "docs" for a
160
+ documentation URL and "remote" for anything else, and `clone_url` the URL
161
+ git clones a remote target from (null for the other kinds): a URL with a
162
+ scheme or an SSH address as given, `https://<path>` for a host path
163
+ without a scheme (`github.com/acme/mono`) and `https://github.com/<path>`
164
+ for the `owner/repo` shorthand. Both are null without a --target.
165
+ `--probe-git` adds git's verdict on a "docs" target as `git_probe` (see
166
+ `probe_git`): "repository" when git reads a repository at the URL (on most
167
+ hosts a repository's main page, such as a self-hosted GitLab, Gitea or
168
+ Codeberg page), "credentials" when the server asks git for credentials (a
169
+ private repository, or any page behind a login), "unanswered" when git
170
+ gives no answer in time, "no-git" when git is missing, else "docs". It also
171
+ adds `halt_message`, the message analyze-source stops with on
172
+ "repository", "credentials" and "unanswered" (see `git_probe_halt`), else
173
+ null. Both are null for the other kinds, which it does not probe. The name,
174
+ `kind` and `clone_url` never depend on them, so the name stays the one
175
+ coexistence matching compares offline.
176
+ `derive-name --from <file|->` takes a JSON
177
+ array of {"target", "manifest_name", "private", "members"} (all optional)
178
+ and returns `names[]`, each entry's `target` and `manifest_name` with its
179
+ `name` (null when none can be derived) and `basis` ("members" too). Entries
180
+ that derive one name are told apart by the folders above their targets
181
+ (`src/server/api` and `src/client/api` give `server-api` and `client-api`),
182
+ the shared name kept in `clash`; `unnamed` lists the entries with no name
183
+ and `duplicates` the index groups still sharing one. With
184
+ `--skills-folder` each name also carries `existing`: the skill of that
185
+ name in the folder, in the `matches[]` entry shape below (`match_reason`
186
+ "name"), or null.
187
+
188
+ Near names. `--near <name>` adds a top-level `near[]`: every skill SKF
189
+ generated that has a SKILL.md (`skf_skill` and `has_skill_md`), as
190
+ {name, active_version, close}, the closest to the name first. test-skill
191
+ shows it when the name it was given resolves to no version, instead of
192
+ sending the user to create a skill that may already exist. A name is
193
+ `close` when one name holds the other (case-insensitive) or difflib's
194
+ similarity ratio of the lower-cased names reaches 0.6 (the cutoff of
195
+ difflib.get_close_matches); the close names come first, then each group by
196
+ ratio, highest first, then by name.
14
197
 
15
198
  The --match-target mode deterministically computes coexistence matches: it
16
- normalizes scheme / trailing .git / trailing slash, derives the expected kebab
17
- skill name, compares case-insensitively, and emits a top-level `matches[]`
18
- array. This replaces the equivalent normalize/derive/compare that a consuming
19
- prompt would otherwise perform by hand (identical (target, inventory) always
20
- yields the same match set).
199
+ normalizes scheme / trailing .git / trailing slash, derives the expected
200
+ skill name (`derive_name`), compares case-insensitively, and emits a
201
+ top-level `matches[]` array (each match carries the skill's `skf_skill`)
202
+ and the name it compared, `match_name`. This replaces the equivalent
203
+ normalize/derive/compare that a consuming prompt would otherwise perform
204
+ by hand (identical (target, inventory) always yields the same match set).
21
205
  """
22
206
 
23
207
  from __future__ import annotations
24
208
 
209
+ import difflib
25
210
  import json
26
211
  import os
27
212
  import re
213
+ import shutil
214
+ import stat
215
+ import subprocess
28
216
  import sys
217
+ import tempfile
218
+ from datetime import datetime, timedelta, timezone
29
219
  from pathlib import Path
220
+ from urllib.parse import urlsplit, urlunsplit
30
221
 
31
222
 
32
223
  def read_json_file(path):
@@ -38,6 +229,486 @@ def read_json_file(path):
38
229
  return None, f"Not found: {path}"
39
230
  except json.JSONDecodeError as e:
40
231
  return None, f"JSON parse error in {path}: {e}"
232
+ except (OSError, ValueError) as e:
233
+ # A directory named like the file, a permission error, or bytes that
234
+ # are not UTF-8: report it instead of crashing the whole scan.
235
+ return None, f"Cannot read {path}: {e}"
236
+
237
+
238
+ # Keep identical to RESULT_JSON_RE in skf-merge-ccc-exclusions.py
239
+ # (test/test-skf-skill-inventory.py pins the copies).
240
+ RESULT_JSON_RE = re.compile(r"^([a-z0-9][a-z0-9-]*)-result(-latest|-\d[^/]*)?\.json$")
241
+ # Keep identical to SKF_GENERATORS in skf-merge-ccc-exclusions.py
242
+ # and skf-enumerate-stack-skills.py
243
+ # (test/test-skf-skill-inventory.py pins the copies).
244
+ SKF_GENERATORS = frozenset({"quick-skill", "create-skill", "create-stack-skill"})
245
+ # The flat package a migration moves into {v}/{name}/ (knowledge/version-paths.md).
246
+ FLAT_PACKAGE_ENTRIES = frozenset({"SKILL.md", "metadata.json", "context-snippet.md",
247
+ "references", "scripts", "assets"})
248
+ # OS and VCS clutter that never decides who owns a skill folder.
249
+ NEUTRAL_GROUP_ENTRIES = frozenset({".DS_Store", ".gitkeep", ".gitignore", ".gitattributes",
250
+ "Thumbs.db", "desktop.ini"})
251
+
252
+
253
+ # Keep identical to _has_skf_metadata in skf-merge-ccc-exclusions.py
254
+ # and skf-enumerate-stack-skills.py
255
+ # (test/test-skf-skill-inventory.py pins the copies).
256
+ def _has_skf_metadata(path: Path) -> bool:
257
+ """True when a flat skill's metadata.json carries an SKF marker."""
258
+ try:
259
+ data = json.loads(path.read_text(encoding="utf-8"))
260
+ except (OSError, ValueError):
261
+ return False
262
+ if not isinstance(data, dict):
263
+ return False
264
+ generated_by = data.get("generated_by")
265
+ if isinstance(generated_by, str) and generated_by in SKF_GENERATORS:
266
+ return True
267
+ tool_versions = data.get("tool_versions")
268
+ if isinstance(tool_versions, dict) and "skf" in tool_versions:
269
+ return True
270
+ return (data.get("skill_type") in ("single", "individual", "stack")
271
+ and ("forge_tier" in data or "confidence_tier" in data))
272
+
273
+
274
+ # Keep identical to _is_link_or_junction in skf-atomic-write.py, skf-enumerate-stack-skills.py,
275
+ # skf-validate-rename-name.py, skf-source-tree.py and skf-tessl-review.py
276
+ # (test/test-skf-skill-inventory.py pins the copies).
277
+ def _is_link_or_junction(p: Path) -> bool:
278
+ """True for POSIX symlinks AND Windows junctions/symlinks.
279
+
280
+ `Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
281
+ for both symlinks and junctions (since CPython 3.8 on Windows). A regular
282
+ directory raises OSError on readlink, which is the signal we want to
283
+ refuse replacement. On Windows, any other reparse point (a cloud-sync
284
+ placeholder, a deduplicated file, an app execution alias) raises
285
+ ValueError: it does not redirect to another path, so it is not a link.
286
+ """
287
+ if p.is_symlink():
288
+ return True
289
+ if not p.exists() and not p.is_symlink():
290
+ return False
291
+ try:
292
+ os.readlink(p)
293
+ return True
294
+ except (OSError, ValueError):
295
+ return False
296
+
297
+
298
+ def _is_active_pointer(name):
299
+ """True for the `active` link name and the flip helper's `active.skf-*` names."""
300
+ return name == "active" or name.startswith("active.skf-")
301
+
302
+
303
+ # Keep identical to _is_marked_version in skf-merge-ccc-exclusions.py
304
+ # and skf-enumerate-stack-skills.py
305
+ # (test/test-skf-skill-inventory.py pins the copies).
306
+ def _is_marked_version(version_dir: Path, name: str) -> bool:
307
+ """True when `version_dir/name/metadata.json` carries an SKF marker.
308
+
309
+ SKF never links a version folder or the package inside one: a linked
310
+ one is not its output, whatever the metadata behind the link says.
311
+ """
312
+ package = version_dir / name
313
+ return (not _is_link_or_junction(version_dir) and not _is_link_or_junction(package)
314
+ and _has_skf_metadata(package / "metadata.json"))
315
+
316
+
317
+ # Keep identical to _has_skf_evidence in skf-merge-ccc-exclusions.py
318
+ # (test/test-skf-skill-inventory.py pins the copies).
319
+ def _has_skf_evidence(group_dir: Path, name: str) -> bool:
320
+ """True when skill group `group_dir` holds evidence that SKF generated it.
321
+
322
+ Evidence is the `_batch` name, `.skf-` in the name, a marked root
323
+ `metadata.json`, or a marked version folder (`.skf-` staging folders
324
+ aside). The versioned layout, an `active` link, a manifest key or a
325
+ result file are not evidence on their own, and a linked group holds none.
326
+ """
327
+ if _is_link_or_junction(group_dir):
328
+ return False
329
+ if name == "_batch" or ".skf-" in name:
330
+ return True
331
+ if _has_skf_metadata(group_dir / "metadata.json"):
332
+ return True
333
+ try:
334
+ children = list(group_dir.iterdir())
335
+ except OSError:
336
+ return False
337
+ return any(".skf-" not in child.name and child.is_dir() and _is_marked_version(child, name)
338
+ for child in children)
339
+
340
+
341
+ # Keep identical to FORGE_GROUP_DIRS in skf-merge-ccc-exclusions.py
342
+ # (test/test-skf-skill-inventory.py pins the copies).
343
+ FORGE_GROUP_DIRS = frozenset({"_campaign", "improvement-queue"})
344
+ # Keep identical to FORGE_VERSION_ANCHORS in skf-merge-ccc-exclusions.py
345
+ # (test/test-skf-skill-inventory.py pins the copies).
346
+ FORGE_VERSION_ANCHORS = frozenset({"provenance-map.json", "evidence-report.md", "extraction-rules.yaml"})
347
+ # The other names SKF writes into a forge version folder
348
+ # (knowledge/version-paths.md, forge_data_folder tree), and
349
+ # .manual-inventory.json, which update-skill kept there before it kept
350
+ # that inventory beside its lock. A test-skill run writes its report as
351
+ # .skf-test-report-{name}-{run_id}.md beside .test-skill.lock until the
352
+ # report's checks pass: like every `.skf-` name it is SKF's, never foreign.
353
+ # manual-backup/ holds the earlier SKILL.md that skf-promote-staged.py
354
+ # --carry-manual keeps when a re-run's SKILL.md lacks one of its [MANUAL]
355
+ # blocks; it is no `.skf-` name, which would read as an interrupted run's.
356
+ FORGE_VERSION_FILES = FORGE_VERSION_ANCHORS | frozenset({
357
+ "evidence-report-fallback.md", "extraction-snapshot.json", ".manual-inventory.json",
358
+ ".test-skill.lock", "manual-backup"})
359
+ FORGE_REPORT_RE = re.compile(r"^(test-report|drift-report)(-.+)?\.md$")
360
+ # test-skill's gap ledger, one per run (skf-test-skill scripts/gap-ledger.py).
361
+ TEST_FINDINGS_RE = re.compile(r"^test-findings-.+\.json$")
362
+
363
+
364
+ # Keep identical to _has_forge_evidence in skf-merge-ccc-exclusions.py
365
+ # (test/test-skf-skill-inventory.py pins the copies).
366
+ def _has_forge_evidence(group_dir: Path, name: str) -> bool:
367
+ """True when forge group `group_dir` holds evidence that SKF generated it.
368
+
369
+ Evidence is `.skf-` in the name, SKF's own `_campaign` or
370
+ `improvement-queue` folder, a skill brief (`skill-brief.yaml*` or
371
+ `.brief-draft.json`) or a `*-result*.json` file directly in the group, or
372
+ a provenance map, evidence report, extraction rules or `*-result*.json`
373
+ file directly in a folder of the group (`.skf-` staging folders aside).
374
+ A linked group, folder or file is never evidence.
375
+ """
376
+ if _is_link_or_junction(group_dir):
377
+ return False
378
+ if ".skf-" in name or name in FORGE_GROUP_DIRS:
379
+ return True
380
+ try:
381
+ children = list(group_dir.iterdir())
382
+ except OSError:
383
+ return False
384
+ for child in children:
385
+ entry = child.name
386
+ if _is_link_or_junction(child):
387
+ continue
388
+ if child.is_file():
389
+ if (entry.startswith("skill-brief.yaml") or entry == ".brief-draft.json"
390
+ or RESULT_JSON_RE.match(entry)):
391
+ return True
392
+ elif child.is_dir() and ".skf-" not in entry:
393
+ try:
394
+ inner = list(child.iterdir())
395
+ except OSError:
396
+ continue
397
+ if any((f.name in FORGE_VERSION_ANCHORS or RESULT_JSON_RE.match(f.name))
398
+ and not _is_link_or_junction(f) and f.is_file() for f in inner):
399
+ return True
400
+ return False
401
+
402
+
403
+ def _is_forge_version_name(entry):
404
+ """True for a name SKF writes into a forge version folder."""
405
+ return (entry in FORGE_VERSION_FILES or bool(FORGE_REPORT_RE.match(entry))
406
+ or bool(TEST_FINDINGS_RE.match(entry)) or bool(RESULT_JSON_RE.match(entry))
407
+ or entry.endswith("-tmp"))
408
+
409
+
410
+ def _is_forge_root_name(entry, evidence):
411
+ """True for a file SKF writes directly into a forge group.
412
+
413
+ A brief or a result file always is. The legacy flat artifacts (a
414
+ provenance map, evidence report, extraction rules or test or drift
415
+ report, or a test-skill gap ledger, at the group root) count only beside
416
+ other evidence.
417
+ """
418
+ if (entry.startswith("skill-brief.yaml") or entry == ".brief-draft.json"
419
+ or RESULT_JSON_RE.match(entry)):
420
+ return True
421
+ return evidence and (entry in FORGE_VERSION_ANCHORS or bool(FORGE_REPORT_RE.match(entry))
422
+ or bool(TEST_FINDINGS_RE.match(entry)))
423
+
424
+
425
+ def _holds_no_file(folder):
426
+ """True when `folder` holds no file: only neutral clutter, `.skf-` names
427
+ and folders that hold no file themselves (a link is content).
428
+
429
+ That is all an interrupted SKF run leaves before it writes a package's
430
+ metadata.json; deleting or writing into such a folder loses no file. A
431
+ folder SKF cannot list or search holds content too.
432
+ """
433
+ try:
434
+ children = list(folder.iterdir())
435
+ except OSError:
436
+ return False
437
+ for child in children:
438
+ name = child.name
439
+ if name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
440
+ continue
441
+ try:
442
+ if _is_link_or_junction(child) or not child.is_dir() or not _holds_no_file(child):
443
+ return False
444
+ except OSError: # a folder it cannot search: pathlib raises there before Python 3.14
445
+ return False
446
+ return True
447
+
448
+
449
+ def _forge_folder_entries(folder, group_evidence, package=None):
450
+ """(verdict, entries SKF did not write as `v/<entry>`) for folder `v` of a forge group.
451
+
452
+ A folder is marked when it directly holds a provenance map, evidence
453
+ report, extraction rules or `*-result*.json` file. SKF's per-version
454
+ names count inside a marked folder or in a group with evidence, except
455
+ `package`: in a skills-side version folder (both settings name one
456
+ folder) the skill's own package is SKF output only when marked, even
457
+ when its name looks like a forge name such as `*-tmp`.
458
+ Verdicts: "skf", "mixed" (marked, plus other entries), "empty" (not
459
+ marked, nothing else) or "foreign".
460
+ """
461
+ v = folder.name
462
+ try:
463
+ children = sorted(folder.iterdir(), key=lambda p: p.name)
464
+ except OSError:
465
+ return "foreign", []
466
+ marked = any((c.name in FORGE_VERSION_ANCHORS or RESULT_JSON_RE.match(c.name))
467
+ and not _is_link_or_junction(c) and c.is_file() for c in children)
468
+ skf_named = marked or group_evidence
469
+ inner = []
470
+ for child in children:
471
+ name = child.name
472
+ if name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
473
+ continue
474
+ if _is_link_or_junction(child):
475
+ inner.append(f"{v}/{name}")
476
+ continue
477
+ if skf_named and name != package and _is_forge_version_name(name):
478
+ continue
479
+ if child.is_dir() and _holds_no_file(child):
480
+ continue
481
+ inner.append(f"{v}/{name}/" if child.is_dir() else f"{v}/{name}")
482
+ if marked:
483
+ return ("mixed" if inner else "skf"), inner
484
+ return ("foreign" if inner else "empty"), inner
485
+
486
+
487
+ def classify_forge_group(group_dir, skill_name):
488
+ """Decide whether SKF generated forge group `group_dir` ({forge_data_folder}/{name}).
489
+
490
+ Returns {name, path, ownership, foreign_entries, errors}. `ownership`:
491
+ "absent" (nothing at the path), "reserved" (SKF's own `_campaign` or
492
+ `improvement-queue` folder, never a skill's), "skf" (SKF evidence and
493
+ nothing else), "mixed" (evidence plus entries SKF did not write), "empty"
494
+ (no evidence and nothing SKF did not write) or "foreign" (no evidence
495
+ and entries SKF did not write; a link or a path that is not a folder,
496
+ even with a reserved name, with the reason in `errors`). Whether the
497
+ group holds SKF output at all is `_has_forge_evidence`, the rule the ccc
498
+ helper shares. A folder in the group is listed as `v/` when SKF wrote
499
+ nothing in it, and its entries as `v/<entry>` when it is marked; a
500
+ linked entry is listed by its bare name.
501
+ """
502
+ result = {"name": skill_name, "path": str(group_dir), "ownership": "absent",
503
+ "foreign_entries": [], "errors": []}
504
+ if not os.path.lexists(group_dir):
505
+ return result
506
+ result["ownership"] = "foreign"
507
+ if _is_link_or_junction(group_dir):
508
+ result["errors"].append(
509
+ f"forge folder is a link; SKF never moves or deletes through it: {group_dir}")
510
+ return result
511
+ if not group_dir.is_dir():
512
+ result["errors"].append(f"forge folder is not a folder: {group_dir}")
513
+ return result
514
+ if skill_name in FORGE_GROUP_DIRS:
515
+ result["ownership"] = "reserved"
516
+ return result
517
+ try:
518
+ children = sorted(group_dir.iterdir(), key=lambda p: p.name)
519
+ except OSError as e:
520
+ result["errors"].append(f"Cannot list {group_dir}: {e}")
521
+ return result
522
+ evidence = _has_forge_evidence(group_dir, skill_name)
523
+ foreign = []
524
+ for child in children:
525
+ name = child.name
526
+ if name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
527
+ continue
528
+ if _is_link_or_junction(child):
529
+ foreign.append(name)
530
+ elif child.is_dir():
531
+ verdict, inner = _forge_folder_entries(child, evidence)
532
+ if verdict == "mixed":
533
+ foreign.extend(inner)
534
+ elif verdict == "foreign":
535
+ foreign.append(name + "/")
536
+ elif not _is_forge_root_name(name, evidence):
537
+ foreign.append(name)
538
+ result["foreign_entries"] = sorted(foreign)
539
+ if evidence:
540
+ result["ownership"] = "mixed" if foreign else "skf"
541
+ else:
542
+ result["ownership"] = "foreign" if foreign else "empty"
543
+ return result
544
+
545
+
546
+ def _same_folder(skills_dir, forge_dir):
547
+ """True when the skills and forge settings name one folder."""
548
+ if forge_dir is None:
549
+ return False
550
+ try:
551
+ if skills_dir.exists() and forge_dir.exists():
552
+ return os.path.samefile(skills_dir, forge_dir)
553
+ except OSError:
554
+ pass
555
+ return (os.path.normcase(os.path.abspath(skills_dir))
556
+ == os.path.normcase(os.path.abspath(forge_dir)))
557
+
558
+
559
+ def _version_foreign_entries(version_dir, skill_name, errors, also_forge=False):
560
+ """The entries of a marked version folder SKF did not put there, as `v/<entry>`.
561
+
562
+ SKF writes only the `{name}/` package and `.skf-` staging names into a
563
+ version folder (and, with `also_forge`, its forge per-version files);
564
+ neutral clutter is ignored. Directories end in `/`, a linked entry is
565
+ listed by its bare name.
566
+ """
567
+ try:
568
+ children = sorted(version_dir.iterdir(), key=lambda p: p.name)
569
+ except OSError as e:
570
+ errors.append(f"Cannot list {version_dir}: {e}")
571
+ return [version_dir.name + "/"]
572
+ found = []
573
+ for child in children:
574
+ name = child.name
575
+ if name == skill_name or name in NEUTRAL_GROUP_ENTRIES or ".skf-" in name:
576
+ continue
577
+ if also_forge and not _is_link_or_junction(child) and _is_forge_version_name(name):
578
+ continue
579
+ if _is_link_or_junction(child) or not child.is_dir():
580
+ found.append(f"{version_dir.name}/{name}")
581
+ else:
582
+ found.append(f"{version_dir.name}/{name}/")
583
+ return found
584
+
585
+
586
+ def _read_error(folder, name):
587
+ """The OSError that keeps SKF from listing `folder` or searching it for `name`, else None.
588
+
589
+ os.listdir needs read permission on `folder` and os.lstat of `folder/name`
590
+ search permission. Both raise on every Python version, where pathlib's
591
+ is_dir and is_symlink return False inside a folder they cannot search
592
+ from Python 3.14 on and raise before it.
593
+ """
594
+ try:
595
+ os.listdir(folder)
596
+ os.lstat(os.path.join(folder, name))
597
+ except (FileNotFoundError, NotADirectoryError):
598
+ return None
599
+ except OSError as e:
600
+ return e
601
+ return None
602
+
603
+
604
+ def classify_ownership(skill_group_dir, skill_name, also_forge=False):
605
+ """Decide whether SKF generated the skill group `skill_group_dir`.
606
+
607
+ Returns {ownership, skf_skill, flat_skf, foreign_entries, errors}.
608
+ Whether the group holds SKF output at all is `_has_skf_evidence`, the
609
+ rule the ccc helper shares. Only a marked metadata.json is proof there:
610
+ the versioned layout or an `active` link alone does not count, because a
611
+ module skill can sit in that layout (an earlier SKF migrated such skills
612
+ without asking), and a manifest key does not count because the folder on
613
+ disk may have changed since the export. A top-level entry of the group
614
+ is SKF output when it is a version folder `v` whose `v/{name}/metadata.json`
615
+ is marked (neither `v` nor `v/{name}` a link), an `active` or
616
+ `active.skf-*` link (or a real `active/` folder holding a marked
617
+ package), a name containing `.skf-`, a result file, or, beside a marked
618
+ root metadata.json, one of the flat package entries. Neutral clutter is
619
+ ignored; anything else is foreign. Inside a marked version folder,
620
+ anything but the `{name}/` package, a `.skf-` name or neutral clutter is
621
+ foreign too, listed as `v/<entry>`. Everything inside
622
+ `_batch` or a `.skf-` folder is SKF output. A folder that holds no file
623
+ (only neutral clutter, `.skf-` names or folders like itself) is never
624
+ foreign: an interrupted SKF run leaves one before it writes metadata.json.
625
+ With `also_forge` (both settings name one folder), SKF's forge files
626
+ count too, as ccc's kinds union does: a brief or result file at the
627
+ root, forge per-version files in a version folder, and forge evidence;
628
+ the skill's own `v/{name}/` package still counts only when marked.
629
+ A folder SKF cannot list or search is foreign, and `errors` names it.
630
+ """
631
+ result = {
632
+ "ownership": "foreign",
633
+ "skf_skill": False,
634
+ "flat_skf": False,
635
+ "foreign_entries": [],
636
+ "errors": [],
637
+ }
638
+ if _is_link_or_junction(skill_group_dir):
639
+ result["errors"].append(
640
+ f"skill folder is a link; SKF never moves or deletes through it: {skill_group_dir}"
641
+ )
642
+ return result
643
+ try:
644
+ children = sorted(skill_group_dir.iterdir(), key=lambda p: p.name)
645
+ except OSError as e:
646
+ result["errors"].append(f"Cannot list {skill_group_dir}: {e}")
647
+ return result
648
+ whole_group = skill_name == "_batch" or ".skf-" in skill_name
649
+ root_marker = _has_skf_metadata(skill_group_dir / "metadata.json")
650
+ try:
651
+ forge_evidence = also_forge and _has_forge_evidence(skill_group_dir, skill_name)
652
+ except OSError: # a folder it cannot search; the loop below names it
653
+ forge_evidence = False
654
+ marked_version = False
655
+ foreign = []
656
+ for child in children:
657
+ name = child.name
658
+ if name in NEUTRAL_GROUP_ENTRIES or whole_group:
659
+ continue
660
+ if _is_link_or_junction(child):
661
+ if not _is_active_pointer(name):
662
+ foreign.append(name)
663
+ continue
664
+ if child.is_dir():
665
+ error = _read_error(child, skill_name)
666
+ if error is not None: # checked first: the checks below read inside it
667
+ result["errors"].append(f"Cannot read {child}: {error}")
668
+ foreign.append(name + "/")
669
+ continue
670
+ if ".skf-" in name or (child.is_file() and RESULT_JSON_RE.match(name)):
671
+ continue
672
+ if root_marker and name in FLAT_PACKAGE_ENTRIES:
673
+ continue # checked first: references/{name}/ is not a version
674
+ is_dir = child.is_dir()
675
+ if also_forge and not is_dir and _is_forge_root_name(name, forge_evidence or root_marker):
676
+ continue
677
+ if is_dir and _is_marked_version(child, skill_name):
678
+ marked_version = True
679
+ foreign.extend(_version_foreign_entries(child, skill_name, result["errors"], also_forge))
680
+ continue
681
+ if is_dir and also_forge:
682
+ verdict, inner = _forge_folder_entries(child, forge_evidence, skill_name)
683
+ if verdict != "foreign":
684
+ foreign.extend(inner)
685
+ continue
686
+ if is_dir and _holds_no_file(child):
687
+ continue # an interrupted run's staging or empty package folders
688
+ foreign.append(name + "/" if is_dir else name)
689
+ result["flat_skf"] = root_marker and (skill_group_dir / "SKILL.md").is_file()
690
+ result["skf_skill"] = marked_version or result["flat_skf"]
691
+ result["foreign_entries"] = sorted(foreign)
692
+ try:
693
+ evidence = _has_skf_evidence(skill_group_dir, skill_name)
694
+ except OSError: # a folder it cannot search, named in `errors` above
695
+ evidence = marked_version or root_marker
696
+ if evidence or forge_evidence:
697
+ result["ownership"] = "mixed" if foreign else "skf"
698
+ return result
699
+
700
+
701
+ # Keep identical to _looks_like_skill in skf-enumerate-stack-skills.py
702
+ # (test/test-skf-skill-inventory.py pins the copies).
703
+ def _looks_like_skill(skill_group_dir, skill_name):
704
+ """True when a group holds a root SKILL.md or a {v}/{name}/ folder."""
705
+ if (skill_group_dir / "SKILL.md").is_file():
706
+ return True
707
+ try:
708
+ return any(child.is_dir() and (child / skill_name).is_dir()
709
+ for child in skill_group_dir.iterdir())
710
+ except OSError:
711
+ return False
41
712
 
42
713
 
43
714
  def resolve_active_version(skill_group_dir):
@@ -47,13 +718,16 @@ def resolve_active_version(skill_group_dir):
47
718
  """
48
719
  active_link = skill_group_dir / "active"
49
720
  if active_link.is_symlink() or active_link.is_dir():
50
- target = active_link.resolve()
721
+ try:
722
+ target = active_link.resolve()
723
+ except (OSError, RuntimeError): # a link loop
724
+ return None, None
51
725
  if target.is_dir():
52
726
  return target.name, target
53
727
  return None, None
54
728
 
55
729
 
56
- def scan_skill_group(skill_group_dir, skill_name):
730
+ def scan_skill_group(skill_group_dir, skill_name, also_forge=False):
57
731
  """Scan a single skill group directory and return its inventory entry."""
58
732
  entry = {
59
733
  "name": skill_name,
@@ -68,14 +742,30 @@ def scan_skill_group(skill_group_dir, skill_name):
68
742
  "errors": [],
69
743
  }
70
744
 
71
- # Check for version directories (contain a skill-name subdirectory)
72
- for child in sorted(skill_group_dir.iterdir()):
745
+ ownership = classify_ownership(skill_group_dir, skill_name, also_forge)
746
+ entry["errors"].extend(ownership.pop("errors"))
747
+ entry.update(ownership)
748
+
749
+ try:
750
+ children = sorted(skill_group_dir.iterdir())
751
+ except OSError:
752
+ children = [] # classify_ownership already reported it
753
+
754
+ # Check for version directories (contain a skill-name subdirectory). In a
755
+ # flat SKF skill the package's own folders are never versions, even when
756
+ # one holds a same-named subfolder such as references/{name}/. Inside a
757
+ # folder SKF cannot search, the os.path checks return False on every
758
+ # Python version (classify_ownership names that folder in `errors`).
759
+ package_dirs = FLAT_PACKAGE_ENTRIES if entry["flat_skf"] else frozenset()
760
+ for child in children:
761
+ if child.name in package_dirs:
762
+ continue
73
763
  if child.is_dir() and child.name != "active" and not child.name.startswith("."):
74
764
  # Check if this is a version dir (contains skill-name subdir or SKILL.md)
75
765
  skill_subdir = child / skill_name
76
- if skill_subdir.is_dir():
766
+ if os.path.isdir(skill_subdir):
77
767
  entry["versions"].append(child.name)
78
- elif (child / "SKILL.md").exists():
768
+ elif os.path.exists(child / "SKILL.md"):
79
769
  # Flat version dir without skill-name nesting
80
770
  entry["versions"].append(child.name)
81
771
 
@@ -85,9 +775,9 @@ def scan_skill_group(skill_group_dir, skill_name):
85
775
  entry["active_version"] = active_ver
86
776
  # The active path points to the version dir; skill files are in version/skill-name/
87
777
  skill_pkg = active_path / skill_name
88
- if skill_pkg.is_dir():
778
+ if os.path.isdir(skill_pkg):
89
779
  entry["active_path"] = str(skill_pkg)
90
- elif (active_path / "SKILL.md").exists():
780
+ elif os.path.exists(active_path / "SKILL.md"):
91
781
  entry["active_path"] = str(active_path)
92
782
  else:
93
783
  entry["active_path"] = str(active_path)
@@ -101,14 +791,20 @@ def scan_skill_group(skill_group_dir, skill_name):
101
791
 
102
792
  # Load metadata from active path
103
793
  active_dir = Path(entry["active_path"]) if entry["active_path"] else None
104
- if active_dir and active_dir.is_dir():
105
- entry["has_skill_md"] = (active_dir / "SKILL.md").exists()
106
- entry["has_provenance_map"] = (active_dir / "provenance-map.json").exists()
107
- entry["has_context_snippet"] = (active_dir / "context-snippet.md").exists()
794
+ if active_dir and os.path.isdir(active_dir):
795
+ entry["has_skill_md"] = os.path.exists(active_dir / "SKILL.md")
796
+ entry["has_provenance_map"] = os.path.exists(active_dir / "provenance-map.json")
797
+ entry["has_context_snippet"] = os.path.exists(active_dir / "context-snippet.md")
108
798
 
109
799
  metadata_path = active_dir / "metadata.json"
110
800
  meta, meta_err = read_json_file(metadata_path)
111
- if meta:
801
+ if meta_err:
802
+ if "Not found" not in meta_err:
803
+ entry["errors"].append(meta_err)
804
+ elif not isinstance(meta, dict):
805
+ entry["errors"].append(f"metadata.json is not a JSON object: {metadata_path}")
806
+ elif meta:
807
+ stats = meta.get("stats")
112
808
  entry["metadata"] = {
113
809
  "version": meta.get("version"),
114
810
  "language": meta.get("language"),
@@ -116,14 +812,990 @@ def scan_skill_group(skill_group_dir, skill_name):
116
812
  "source_repo": meta.get("source_repo"),
117
813
  "generated_by": meta.get("generated_by"),
118
814
  "confidence_tier": meta.get("confidence_tier"),
119
- "exports_total": meta.get("stats", {}).get("exports_total"),
815
+ "exports_total": stats.get("exports_total") if isinstance(stats, dict) else None,
120
816
  }
121
- elif meta_err and "Not found" not in meta_err:
122
- entry["errors"].append(meta_err)
123
817
 
124
818
  return entry
125
819
 
126
820
 
821
+ def _marked_active_version(skill_group_dir, skill_name):
822
+ """The version folder `active` names when SKF generated it, else None.
823
+
824
+ `active` counts only as a link to a folder directly in the group (not a
825
+ `.skf-` name, not a link) whose package is marked, or as a real `active/`
826
+ folder holding a marked package, reported as "active".
827
+ """
828
+ active = skill_group_dir / "active"
829
+ if _is_link_or_junction(active):
830
+ try:
831
+ target = active.resolve(strict=True)
832
+ group = skill_group_dir.resolve(strict=True)
833
+ except (OSError, RuntimeError):
834
+ return None
835
+ version = skill_group_dir / target.name
836
+ if (target.parent != group or ".skf-" in target.name or not target.is_dir()
837
+ or not _is_marked_version(version, skill_name)):
838
+ return None
839
+ return target.name
840
+ if active.is_dir() and _is_marked_version(active, skill_name):
841
+ return "active"
842
+ return None
843
+
844
+
845
+ def write_check(skills_folder, skill_name, version=None, forge_folder=None):
846
+ """Decide whether a writer may add `{name}/{version}/{name}/` and flip `active`.
847
+
848
+ Returns {name, version, verdict, reason, folder, detail, foreign_entries,
849
+ marked_active_version, version_generated_by, version_confidence_tier}.
850
+ `verdict` is "ok", "not-skf-output" or "flat-layout" (the halt reason a
851
+ refusal uses); `reason` names the rule that decided (None for "ok").
852
+ Nothing at the skill folder is a new skill. A folder that holds only
853
+ what an interrupted SKF run leaves is written into; a mixed folder is
854
+ written into when the target version is new or SKF's own. `forge_folder`
855
+ only decides whether both settings name one folder, so SKF's forge files
856
+ there count as SKF output. On "ok", `version_generated_by` and
857
+ `version_confidence_tier` are the `generated_by` and `confidence_tier`
858
+ of the `{name}/{version}/{name}/metadata.json` the write would replace
859
+ (each None when that file, or the string, is absent), so a writer names
860
+ the build it would overwrite.
861
+ """
862
+ skills_dir = Path(skills_folder)
863
+ forge_dir = Path(forge_folder) if forge_folder else None
864
+ group = skills_dir / skill_name
865
+ out = {"name": skill_name, "version": version, "verdict": "ok", "reason": None,
866
+ "folder": str(group), "detail": None, "foreign_entries": [],
867
+ "marked_active_version": None, "version_generated_by": None,
868
+ "version_confidence_tier": None}
869
+
870
+ def refuse(verdict, reason, detail, folder=None):
871
+ out.update(verdict=verdict, reason=reason, detail=detail)
872
+ if folder is not None:
873
+ out["folder"] = str(folder)
874
+ return out
875
+
876
+ if skill_name == "_batch" or ".skf-" in skill_name or skill_name in FORGE_GROUP_DIRS:
877
+ reserved_at = (forge_dir / skill_name) if (forge_dir and skill_name in FORGE_GROUP_DIRS) else group
878
+ return refuse("not-skf-output", "reserved-name",
879
+ "is a name SKF keeps for its own files, never a skill's", reserved_at)
880
+ if not os.path.lexists(group):
881
+ return out
882
+ if _is_link_or_junction(group):
883
+ return refuse("not-skf-output", "link", "is a link; SKF never writes through a link")
884
+ if not group.is_dir():
885
+ return refuse("not-skf-output", "not-a-folder", "is not a folder")
886
+ own = classify_ownership(group, skill_name, _same_folder(skills_dir, forge_dir))
887
+ out["foreign_entries"] = own["foreign_entries"]
888
+ if own["errors"]:
889
+ return refuse("not-skf-output", "unreadable", "; ".join(own["errors"]))
890
+ if own["flat_skf"] and not _marked_version_names(group, skill_name):
891
+ return refuse("flat-layout", "flat-layout", "still uses the flat layout")
892
+ if not own["skf_skill"] and own["foreign_entries"]:
893
+ return refuse("not-skf-output", "not-skf-output",
894
+ "has no SKF marker in any `metadata.json` and holds entries SKF did not "
895
+ "generate: " + ", ".join(own["foreign_entries"]))
896
+ if version is not None:
897
+ listed = _version_entries(own["foreign_entries"], version)
898
+ if listed:
899
+ return refuse("not-skf-output", "version",
900
+ "is not a version SKF generated, or holds entries SKF did not "
901
+ "generate: " + ", ".join(listed), group / version)
902
+ out["marked_active_version"] = _marked_active_version(group, skill_name)
903
+ if version is not None:
904
+ metadata, _ = read_json_file(group / version / skill_name / "metadata.json")
905
+ if isinstance(metadata, dict):
906
+ for key, field in (("version_generated_by", "generated_by"),
907
+ ("version_confidence_tier", "confidence_tier")):
908
+ value = metadata.get(field)
909
+ out[key] = value if isinstance(value, str) and value else None
910
+ return out
911
+
912
+
913
+ def _version_entries(entries, version):
914
+ """The `foreign_entries` that are version folder `version` or inside it.
915
+
916
+ A linked version folder is listed by its bare name (`v`), one SKF did not
917
+ generate as `v/` and an entry SKF did not put in a marked one as
918
+ `v/<entry>`. A prefix of another version is not that version: `0.1.0-rc/`
919
+ is never version `0.1.0`.
920
+ """
921
+ return [e for e in entries if e == version or e == version + "/" or e.startswith(version + "/")]
922
+
923
+
924
+ def _is_reserved_group(skill_name):
925
+ """True for SKF's own `_batch` folder and its `.skf-` staging names, never a skill."""
926
+ return skill_name == "_batch" or ".skf-" in skill_name
927
+
928
+
929
+ def purge_check(skills_folder, skill_name, forge_folder, version=None):
930
+ """Decide whether drop-skill may purge `skill_name`, whole or one version.
931
+
932
+ Returns {name, version, scope, verdict, reason, folder, detail,
933
+ offending_entries, ownership, foreign_entries, errors, same_folder,
934
+ forge_ownership, forge_foreign_entries, forge_errors,
935
+ affected_directories, forge_left_in_place}. `verdict` is "ok" or
936
+ "not-skf-output". A purge deletes only what SKF generated, so `reason`
937
+ names the rule that refused it:
938
+
939
+ - "reserved-name": `_batch` or a `.skf-` name, SKF's own folders.
940
+ - "skill-foreign": the skill folder is not SKF output (no marker, a link,
941
+ not a folder, or a folder SKF cannot list; `detail` says which).
942
+ - "skill-mixed-whole": a whole-skill purge of a skill folder that also
943
+ holds entries SKF did not generate.
944
+ - "skill-version-not-skf": version folder `version` is a link or not SKF
945
+ output (`v` or `v/` in `foreign_entries`).
946
+ - "skill-version-mixed": version folder `version` holds entries SKF did
947
+ not put there (`v/<entry>`).
948
+ - "forge-mixed-whole" and "forge-version-mixed": the same two rules for
949
+ the skill's forge folder, when the two settings name different folders.
950
+
951
+ A forge folder SKF did not generate (`foreign`, a link or a path that is
952
+ not a folder), SKF's own `reserved` folder, and a forge version folder
953
+ listed as `v` or `v/` do not refuse the purge: the purge leaves them in
954
+ place and `forge_left_in_place` names the path. `affected_directories`
955
+ lists the folders a purge at this scope deletes, whatever the verdict
956
+ (a deprecate keeps them): the skill folder, or its version folder, then
957
+ the forge one, each only when something is there, with no trailing
958
+ separator, and the forge one never when both settings name one folder.
959
+ """
960
+ skills_dir, forge_dir = Path(skills_folder), Path(forge_folder)
961
+ group, forge_group = skills_dir / skill_name, forge_dir / skill_name
962
+ same = _same_folder(skills_dir, forge_dir)
963
+ scope = "skill" if version is None else "version"
964
+ out = {"name": skill_name, "version": version, "scope": scope, "verdict": "ok", "reason": None,
965
+ "folder": str(group), "detail": None, "offending_entries": [], "ownership": "absent",
966
+ "foreign_entries": [], "errors": [], "same_folder": same, "forge_ownership": None,
967
+ "forge_foreign_entries": [], "forge_errors": [], "affected_directories": [],
968
+ "forge_left_in_place": None}
969
+
970
+ def refuse(reason, detail, folder, entries=()):
971
+ if out["verdict"] == "ok":
972
+ out.update(verdict="not-skf-output", reason=reason, detail=detail, folder=str(folder),
973
+ offending_entries=list(entries))
974
+
975
+ if _is_reserved_group(skill_name):
976
+ refuse("reserved-name", "is a name SKF keeps for its own files, never a skill's", group)
977
+ return out
978
+ target = group if version is None else group / version
979
+ if os.path.lexists(group):
980
+ own = classify_ownership(group, skill_name, same)
981
+ out.update(ownership=own["ownership"], foreign_entries=own["foreign_entries"], errors=own["errors"])
982
+ if own["ownership"] == "foreign":
983
+ refuse("skill-foreign", "; ".join(own["errors"]) or "has no SKF marker in its `metadata.json`",
984
+ group)
985
+ elif own["ownership"] == "mixed" and version is None:
986
+ refuse("skill-mixed-whole", "also holds entries SKF did not generate: "
987
+ + ", ".join(own["foreign_entries"]), group, own["foreign_entries"])
988
+ elif own["ownership"] == "mixed":
989
+ listed = _version_entries(own["foreign_entries"], version)
990
+ inside = [e for e in listed if e.startswith(version + "/") and e != version + "/"]
991
+ if inside:
992
+ refuse("skill-version-mixed", "holds entries SKF did not generate: " + ", ".join(inside),
993
+ target, inside)
994
+ elif listed:
995
+ refuse("skill-version-not-skf", "is a link or has no SKF marker in its `metadata.json`",
996
+ target, listed)
997
+ if os.path.lexists(target):
998
+ out["affected_directories"].append(str(target))
999
+ if same:
1000
+ return out
1001
+
1002
+ forge = classify_forge_group(forge_group, skill_name)
1003
+ out.update(forge_ownership=forge["ownership"], forge_foreign_entries=forge["foreign_entries"],
1004
+ forge_errors=forge["errors"])
1005
+ forge_target = forge_group if version is None else forge_group / version
1006
+ leave = forge["ownership"] in ("foreign", "reserved")
1007
+ if forge["ownership"] == "mixed" and version is None:
1008
+ refuse("forge-mixed-whole", "also holds entries SKF did not generate: "
1009
+ + ", ".join(forge["foreign_entries"]), forge_group, forge["foreign_entries"])
1010
+ elif forge["ownership"] == "mixed":
1011
+ listed = _version_entries(forge["foreign_entries"], version)
1012
+ inside = [e for e in listed if e.startswith(version + "/") and e != version + "/"]
1013
+ if inside:
1014
+ refuse("forge-version-mixed", "holds entries SKF did not generate: " + ", ".join(inside),
1015
+ forge_target, inside)
1016
+ leave = bool(listed) and not inside
1017
+ if not os.path.lexists(forge_target):
1018
+ return out
1019
+ if leave:
1020
+ out["forge_left_in_place"] = str(forge_target)
1021
+ else:
1022
+ out["affected_directories"].append(str(forge_target))
1023
+ return out
1024
+
1025
+
1026
+ def rename_check(skills_folder, skill_name, forge_folder):
1027
+ """Decide whether rename-skill may move `skill_name` to a new name.
1028
+
1029
+ Rename copies `{skills_output_folder}/{name}/` and its forge folder, then
1030
+ deletes the old ones, so it moves only a skill SKF generated in the
1031
+ versioned layout. Returns {name, verdict, reason, folder, detail,
1032
+ offending_entries, ownership, skf_skill, flat_skf, foreign_entries,
1033
+ errors, same_folder, forge_ownership, forge_foreign_entries,
1034
+ forge_errors, forge_move, forge_left_in_place}. `verdict` is "ok",
1035
+ "not-skf-output" or "flat-layout" (the halt reason a refusal uses);
1036
+ `reason` names the first rule that refused it:
1037
+
1038
+ - "reserved-name": `_batch` or a `.skf-` name.
1039
+ - "absent": nothing at the skill folder.
1040
+ - "foreign": the skill folder is not SKF output (no marker, a link, not
1041
+ a folder, or one SKF cannot list; `detail` says which), or holds SKF's
1042
+ files but no skill SKF generated.
1043
+ - "mixed": it also holds entries SKF did not generate.
1044
+ - "flat-layout": it still uses the flat layout.
1045
+ - "forge-link", "forge-not-a-folder", "forge-unreadable": the forge
1046
+ folder is a link, is not a folder, or cannot be listed.
1047
+ - "forge-mixed": the forge folder also holds entries SKF did not
1048
+ generate.
1049
+
1050
+ When both settings name one folder the skill folder is the forge folder,
1051
+ so no forge rule applies and nothing else moves. Otherwise `forge_move`
1052
+ is true when the forge folder is SKF output or holds no file of its own
1053
+ ("skf" or "empty"), and `forge_left_in_place` names a forge folder SKF
1054
+ did not generate, or SKF's own reserved one, which keeps its name.
1055
+ """
1056
+ skills_dir, forge_dir = Path(skills_folder), Path(forge_folder)
1057
+ group, forge_group = skills_dir / skill_name, forge_dir / skill_name
1058
+ same = _same_folder(skills_dir, forge_dir)
1059
+ out = {"name": skill_name, "verdict": "ok", "reason": None, "folder": str(group), "detail": None,
1060
+ "offending_entries": [], "ownership": "absent", "skf_skill": False, "flat_skf": False,
1061
+ "foreign_entries": [], "errors": [], "same_folder": same, "forge_ownership": None,
1062
+ "forge_foreign_entries": [], "forge_errors": [], "forge_move": False,
1063
+ "forge_left_in_place": None}
1064
+
1065
+ def refuse(verdict, reason, detail, folder=group, entries=()):
1066
+ out.update(verdict=verdict, reason=reason, detail=detail, folder=str(folder),
1067
+ offending_entries=list(entries))
1068
+ return out
1069
+
1070
+ if _is_reserved_group(skill_name):
1071
+ return refuse("not-skf-output", "reserved-name", "is a name SKF keeps for its own files, never a skill's")
1072
+ if not os.path.lexists(group):
1073
+ return refuse("not-skf-output", "absent", "has no folder in the skills folder")
1074
+ own = classify_ownership(group, skill_name, same)
1075
+ out.update(ownership=own["ownership"], skf_skill=own["skf_skill"], flat_skf=own["flat_skf"],
1076
+ foreign_entries=own["foreign_entries"], errors=own["errors"])
1077
+ if own["ownership"] == "foreign":
1078
+ return refuse("not-skf-output", "foreign",
1079
+ "; ".join(own["errors"]) or "has no SKF marker in its `metadata.json`")
1080
+ if own["ownership"] == "mixed":
1081
+ return refuse("not-skf-output", "mixed", "also holds entries SKF did not generate: "
1082
+ + ", ".join(own["foreign_entries"]), group, own["foreign_entries"])
1083
+ if not own["skf_skill"]: # SKF's files, such as a brief, but no skill it generated
1084
+ return refuse("not-skf-output", "foreign", "has no SKF marker in its `metadata.json`")
1085
+ if own["flat_skf"]:
1086
+ return refuse("flat-layout", "flat-layout", "still uses the flat layout")
1087
+ if same:
1088
+ return out
1089
+ forge = classify_forge_group(forge_group, skill_name)
1090
+ out.update(forge_ownership=forge["ownership"], forge_foreign_entries=forge["foreign_entries"],
1091
+ forge_errors=forge["errors"])
1092
+ if forge["errors"] and forge["ownership"] != "reserved":
1093
+ if _is_link_or_junction(forge_group):
1094
+ reason = "forge-link"
1095
+ elif not forge_group.is_dir():
1096
+ reason = "forge-not-a-folder"
1097
+ else:
1098
+ reason = "forge-unreadable"
1099
+ return refuse("not-skf-output", reason, "; ".join(forge["errors"]), forge_group)
1100
+ if forge["ownership"] == "mixed":
1101
+ return refuse("not-skf-output", "forge-mixed", "also holds entries SKF did not generate: "
1102
+ + ", ".join(forge["foreign_entries"]), forge_group, forge["foreign_entries"])
1103
+ out["forge_move"] = forge["ownership"] in ("skf", "empty")
1104
+ if forge["ownership"] in ("foreign", "reserved"):
1105
+ out["forge_left_in_place"] = str(forge_group)
1106
+ return out
1107
+
1108
+
1109
+ # --------------------------------------------------------------------------
1110
+ # Guarded delete: a purge deletes only plain folders inside SKF's folders
1111
+ # --------------------------------------------------------------------------
1112
+
1113
+ LINK_REFUSAL = "a link; SKF never deletes through a link"
1114
+
1115
+
1116
+ def _strip_trailing_separators(path):
1117
+ """`path` without the trailing separators that make a delete follow a link."""
1118
+ separators = "/" + os.sep + (os.altsep or "")
1119
+ return path.rstrip(separators) or path
1120
+
1121
+
1122
+ def _parts_below(root, path):
1123
+ """The folder names from `root` down to `path`, or None when `path` is not below `root`.
1124
+
1125
+ Both are made absolute without resolving links (a link at or above a
1126
+ root is the user's configuration) and compared case-insensitively where
1127
+ the platform is.
1128
+ """
1129
+ root_abs, path_abs = os.path.abspath(root), os.path.abspath(path)
1130
+ try:
1131
+ inside = os.path.commonpath([os.path.normcase(root_abs), os.path.normcase(path_abs)])
1132
+ except ValueError: # another drive on Windows
1133
+ return None
1134
+ if inside != os.path.normcase(root_abs) or os.path.normcase(path_abs) == inside:
1135
+ return None
1136
+ return Path(os.path.relpath(path_abs, root_abs)).parts
1137
+
1138
+
1139
+ def _tree_bytes(path):
1140
+ """The bytes of the files under `path`, a link counted as itself and never followed."""
1141
+ total = 0
1142
+ for folder, _dirs, files in os.walk(path):
1143
+ for name in files:
1144
+ try:
1145
+ total += os.lstat(os.path.join(folder, name)).st_size
1146
+ except OSError:
1147
+ pass
1148
+ return total
1149
+
1150
+
1151
+ def _remove_tree(path):
1152
+ """shutil.rmtree, clearing a read-only bit (Windows) and retrying once."""
1153
+
1154
+ def retry(func, target, _error):
1155
+ os.chmod(target, stat.S_IREAD | stat.S_IWRITE | stat.S_IEXEC)
1156
+ func(target)
1157
+
1158
+ if sys.version_info >= (3, 12):
1159
+ shutil.rmtree(path, onexc=retry)
1160
+ else:
1161
+ shutil.rmtree(path, onerror=retry)
1162
+
1163
+
1164
+ def _delete_one(roots, path):
1165
+ """("deleted", bytes), ("absent", None) or ("failed", reason) for one path of guarded_delete."""
1166
+ if not Path(path).parts: # "" or "." would name the working folder
1167
+ return "failed", "names no folder"
1168
+ if ".." in Path(path).parts:
1169
+ return "failed", "has a `..` part; SKF deletes only a plain path"
1170
+ below = [(root, parts) for root in roots if (parts := _parts_below(root, path)) is not None]
1171
+ if not below:
1172
+ return "failed", "is not inside " + " or ".join(roots) + "; SKF deletes only inside its folders"
1173
+ root, parts = max(below, key=lambda b: len(os.path.abspath(b[0]))) # the closest root
1174
+ for i in range(1, len(parts) + 1):
1175
+ step = Path(root, *parts[:i])
1176
+ if _is_link_or_junction(step):
1177
+ return "failed", LINK_REFUSAL if i == len(parts) else f"{step} is {LINK_REFUSAL}"
1178
+ if not os.path.lexists(path):
1179
+ return "absent", None
1180
+ if not os.path.isdir(path):
1181
+ return "failed", "is not a folder; SKF deletes only folders here"
1182
+ size = _tree_bytes(path)
1183
+ try:
1184
+ _remove_tree(path)
1185
+ except OSError as e:
1186
+ return "failed", f"cannot delete it: {e}"
1187
+ if os.path.lexists(path):
1188
+ return "failed", "is still there after the delete"
1189
+ return "deleted", size
1190
+
1191
+
1192
+ def guarded_delete(roots, paths):
1193
+ """Delete each folder of `paths` that lies inside one of `roots`, as drop-skill's purge.
1194
+
1195
+ rename-skill deletes its old folders the same way. Each path loses its
1196
+ trailing separators first. It is refused, and listed in
1197
+ `delete_failures` with the reason, when it names no folder or has a
1198
+ `..` part, is not below a root (a root itself included), is a link or
1199
+ junction or lies below one between the closest root and it (SKF never
1200
+ deletes through a link: the delete would reach the files the link points
1201
+ to), or is not a folder. A path with nothing at it goes to
1202
+ `already_absent`. Otherwise its size is measured, the folder is deleted
1203
+ and checked gone: then it goes to `files_deleted`, else to
1204
+ `delete_failures`. Returns {roots, files_deleted, already_absent,
1205
+ delete_failures, attempted, bytes_freed, purge_status}: `attempted`
1206
+ counts the paths deleted or refused, and `purge_status` is "failed" when
1207
+ it is above 0 and nothing was deleted, "partial" when some were, else
1208
+ "success" (no path at all included: a purge whose folders are already
1209
+ gone has nothing to delete).
1210
+ """
1211
+ deleted, absent, failures, freed = [], [], [], 0
1212
+ for raw in paths:
1213
+ path = _strip_trailing_separators(raw)
1214
+ outcome, detail = _delete_one(roots, path)
1215
+ if outcome == "deleted":
1216
+ deleted.append(path)
1217
+ freed += detail
1218
+ elif outcome == "absent":
1219
+ absent.append(path)
1220
+ else:
1221
+ failures.append({"path": path, "error": detail})
1222
+ attempted = len(deleted) + len(failures)
1223
+ if attempted and not deleted:
1224
+ status = "failed"
1225
+ elif failures:
1226
+ status = "partial"
1227
+ else:
1228
+ status = "success"
1229
+ return {"roots": list(roots), "files_deleted": deleted, "already_absent": absent,
1230
+ "delete_failures": failures, "attempted": attempted, "bytes_freed": freed,
1231
+ "purge_status": status}
1232
+
1233
+
1234
+ def _marked_version_names(skill_group_dir, skill_name):
1235
+ """The names of the marked version folders of a group (`.skf-` names aside)."""
1236
+ try:
1237
+ children = list(skill_group_dir.iterdir())
1238
+ except OSError:
1239
+ return []
1240
+ return sorted(c.name for c in children
1241
+ if ".skf-" not in c.name and c.is_dir() and _is_marked_version(c, skill_name))
1242
+
1243
+
1244
+ # --------------------------------------------------------------------------
1245
+ # Versions: normalize, order, next patch, compose-mode bump, primary library
1246
+ # --------------------------------------------------------------------------
1247
+
1248
+ # What may follow the dotted numbers of a version: a pre-release tag such as
1249
+ # `-rc.1`, `rc1` or `.dev0`, identifiers split by `.` or `-`.
1250
+ PRE_RELEASE_RE = re.compile(r"[-._]?[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*")
1251
+ BUILD_RE = re.compile(r"[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*")
1252
+ # A comparator of a range: npm, Python, Ruby and Cargo operators, then a version.
1253
+ COMPARATOR_RE = re.compile(r"(===|==|>=|<=|~=|~>|!=|\^|~|>|<|=)?\s*([^\s,]+)")
1254
+ COMPARATOR_SEPARATOR_RE = re.compile(r"\s*,?\s*")
1255
+ UPPER_BOUND_OPERATORS = frozenset({"<", "<=", "!="})
1256
+ # Trailing wildcard components (`1.2.x`, `1.*`), dropped before a bound is read.
1257
+ WILDCARD_TAIL_RE = re.compile(r"(?:\.[xX*])+$")
1258
+ WILDCARDS = frozenset({"x", "X", "*"})
1259
+
1260
+
1261
+ def _parse_plain_version(text):
1262
+ """(release, pre, build) for a plain version string, else None.
1263
+
1264
+ A leading `v` is dropped and the build metadata after `+` split off.
1265
+ `release` holds the dotted numbers, padded to three (`2.0` is `2.0.0`);
1266
+ `pre` is whatever follows them (`-rc.1`, `rc1`, `.dev0`) as written, or "".
1267
+ A trailing wildcard component (`1.2.x`) is not a plain version.
1268
+ """
1269
+ s = str(text).strip()
1270
+ s, plus, build = s.partition("+")
1271
+ if plus and not BUILD_RE.fullmatch(build):
1272
+ return None
1273
+ m = re.match(r"[vV]?(\d+(?:\.\d+)*)", s)
1274
+ if not m:
1275
+ return None
1276
+ pre = s[m.end():]
1277
+ if pre and (not PRE_RELEASE_RE.fullmatch(pre) or re.match(r"\.[xX](?:\.|$)", pre)):
1278
+ return None
1279
+ release = [str(int(part)) for part in m.group(1).split(".")]
1280
+ release += ["0"] * (3 - len(release))
1281
+ return release, pre, (build or None)
1282
+
1283
+
1284
+ def _version_text(parsed):
1285
+ release, pre, _ = parsed
1286
+ return ".".join(release) + pre
1287
+
1288
+
1289
+ def _order_key(parsed):
1290
+ """Sort key: numbers compared as numbers (1.10.0 above 1.9.0), a release
1291
+ above its pre-releases, pre-release identifiers compared with their digit
1292
+ runs as numbers (rc10 above rc2) and ranked below letters, as in semver."""
1293
+ release, pre, _ = parsed
1294
+ nums = [int(part) for part in release]
1295
+ while nums and nums[-1] == 0:
1296
+ nums.pop()
1297
+ if not pre:
1298
+ return (tuple(nums), (1,))
1299
+ ids = tuple(
1300
+ tuple((0, int(run)) if run.isdigit() else (1, run) for run in re.findall(r"\d+|\D+", ident))
1301
+ for ident in pre.lstrip("-._").split("."))
1302
+ return (tuple(nums), (0,) + ids)
1303
+
1304
+
1305
+ def _lower_bound(spec):
1306
+ """The lower bound of one comparator set (`>=1.2, <2`, `^18.2.0`, `1.0 - 2.0`),
1307
+ as a parsed version, or the reason there is none (a string)."""
1308
+ spec = spec.strip()
1309
+ hyphen = re.fullmatch(r"(\S+)\s+-\s+(\S+)", spec)
1310
+ if hyphen:
1311
+ comparators = [(">=", hyphen.group(1)), ("<=", hyphen.group(2))]
1312
+ else:
1313
+ comparators, pos = [], 0
1314
+ while pos < len(spec):
1315
+ m = COMPARATOR_RE.match(spec, pos)
1316
+ if not m:
1317
+ return f"not a version or range: {spec!r}"
1318
+ comparators.append((m.group(1) or "", m.group(2)))
1319
+ pos = COMPARATOR_SEPARATOR_RE.match(spec, m.end()).end()
1320
+ if not comparators:
1321
+ return "empty version"
1322
+ lows = []
1323
+ for op, token in comparators:
1324
+ if token in WILDCARDS:
1325
+ continue # admits any version: no bound
1326
+ parsed = _parse_plain_version(WILDCARD_TAIL_RE.sub("", token))
1327
+ if parsed is None:
1328
+ return f"not a version: {token!r}"
1329
+ if op not in UPPER_BOUND_OPERATORS:
1330
+ lows.append(parsed)
1331
+ if not lows:
1332
+ return f"names no lower bound: {spec!r}"
1333
+ return max(lows, key=_order_key)
1334
+
1335
+
1336
+ def _reduce_version(text):
1337
+ """((release, pre, build), rule) for a version or range, or a reason string.
1338
+
1339
+ `rule` is "version" for a plain version and "range" for a specifier
1340
+ reduced to its lower bound: the version it names after `^`, `~`, `~=`,
1341
+ `~>`, `>=`, `>`, `=` or `==` (a `>` bound kept as written), the highest
1342
+ such bound when a set has several, the lowest over `||` alternatives, and
1343
+ trailing `x` or `*` components read as 0. `workspace:` and `npm:<name>@`
1344
+ prefixes are dropped. A specifier with no lower bound (`<2.0.0`, `*`,
1345
+ `latest`, a URL or a path) has none.
1346
+ """
1347
+ spec = str(text).strip()
1348
+ if spec.startswith("workspace:"):
1349
+ spec = spec[len("workspace:"):].strip()
1350
+ if spec.startswith("npm:") and "@" in spec[5:]:
1351
+ spec = spec.rsplit("@", 1)[1].strip()
1352
+ if not spec:
1353
+ return "empty version"
1354
+ plain = _parse_plain_version(spec)
1355
+ if plain is not None:
1356
+ return plain, "version"
1357
+ bounds = []
1358
+ for alternative in spec.split("||"):
1359
+ bound = _lower_bound(alternative)
1360
+ if isinstance(bound, str):
1361
+ return bound
1362
+ bounds.append(bound)
1363
+ return min(bounds, key=_order_key), "range"
1364
+
1365
+
1366
+ def _reduce_or_raise(text):
1367
+ """The parsed version `text` reduces to; ValueError when it names none."""
1368
+ reduced = _reduce_version(text)
1369
+ if isinstance(reduced, str):
1370
+ raise ValueError(f"{text!r}: {reduced}")
1371
+ return reduced[0]
1372
+
1373
+
1374
+ def normalize_version(text):
1375
+ """Reduce a version or a range specifier to one version folder name.
1376
+
1377
+ Returns {input, normalized, rule, build_metadata, reason}: `normalized`
1378
+ is null and `reason` says why when the input names no version. See
1379
+ `_reduce_version` for the rules; build metadata is stripped as the
1380
+ Version Sanitization rules of knowledge/version-paths.md say.
1381
+ """
1382
+ out = {"input": text, "normalized": None, "rule": None, "build_metadata": None, "reason": None}
1383
+ reduced = _reduce_version(text)
1384
+ if isinstance(reduced, str):
1385
+ out["reason"] = reduced
1386
+ return out
1387
+ parsed, rule = reduced
1388
+ out.update(normalized=_version_text(parsed), rule=rule, build_metadata=parsed[2])
1389
+ return out
1390
+
1391
+
1392
+ ORDER_WORDS = {-1: "lower", 0: "equal", 1: "higher"}
1393
+
1394
+
1395
+ class NotIncreasingError(ValueError):
1396
+ """A computed version that is not above the version it follows."""
1397
+
1398
+
1399
+ def _sign(a, b):
1400
+ return (a > b) - (a < b)
1401
+
1402
+
1403
+ def order_versions(a, b):
1404
+ """How version `a` orders against version `b`.
1405
+
1406
+ Returns {a, b, order, major_minor, higher}: `order` and `major_minor` (the
1407
+ same test on the first two numbers only) are "lower", "equal" or
1408
+ "higher", read as "a is ... than b"; `higher` is the higher normalized
1409
+ version, null when they are equal. Raises ValueError when either names no
1410
+ version.
1411
+ """
1412
+ pa, pb = _reduce_or_raise(a), _reduce_or_raise(b)
1413
+ cmp = _sign(_order_key(pa), _order_key(pb))
1414
+ mm = _sign(tuple(int(p) for p in pa[0][:2]), tuple(int(p) for p in pb[0][:2]))
1415
+ return {"a": normalize_version(a), "b": normalize_version(b), "order": ORDER_WORDS[cmp],
1416
+ "major_minor": ORDER_WORDS[mm],
1417
+ "higher": None if cmp == 0 else _version_text(pa if cmp > 0 else pb)}
1418
+
1419
+
1420
+ def next_patch(text):
1421
+ """The next patch version after `text`: the release of a pre-release
1422
+ (`1.2.3-rc.1` gives `1.2.3`, as semver's patch increment does), else the
1423
+ third number plus one (`1.2.3` gives `1.2.4`; a fourth number is dropped).
1424
+ Raises ValueError when `text` names no version."""
1425
+ parsed = _reduce_or_raise(text)
1426
+ release, pre, _ = parsed
1427
+ if pre:
1428
+ nxt = ".".join(release)
1429
+ else:
1430
+ nxt = f"{release[0]}.{release[1]}.{int(release[2]) + 1}"
1431
+ if _order_key(_parse_plain_version(nxt)) <= _order_key(parsed):
1432
+ raise NotIncreasingError(f"next patch {nxt} is not above {_version_text(parsed)}")
1433
+ return {"input": text, "normalized": _version_text(parsed), "next_patch": nxt}
1434
+
1435
+
1436
+ def compose_bump(prior, prior_libraries, libraries):
1437
+ """The compose-mode stack version after `prior` (create-stack-skill
1438
+ generate-output.md §1, Stack version).
1439
+
1440
+ Major when a library of `prior_libraries` is not in `libraries` (removed
1441
+ or replaced), else minor. Returns {prior, prior_normalized, bump, version,
1442
+ removed, added}. Raises ValueError when `prior` names no version or the
1443
+ result is not above it (NotIncreasingError).
1444
+ """
1445
+ parsed = _reduce_or_raise(prior)
1446
+ release = parsed[0]
1447
+ before, after = set(prior_libraries), set(libraries)
1448
+ removed, added = sorted(before - after), sorted(after - before)
1449
+ if removed:
1450
+ bump, version = "major", f"{int(release[0]) + 1}.0.0"
1451
+ else:
1452
+ bump, version = "minor", f"{release[0]}.{int(release[1]) + 1}.0"
1453
+ if _order_key(_parse_plain_version(version)) <= _order_key(parsed):
1454
+ raise NotIncreasingError(f"{version} is not above the prior version {_version_text(parsed)}")
1455
+ return {"prior": prior, "prior_normalized": _version_text(parsed), "bump": bump,
1456
+ "version": version, "removed": removed, "added": added}
1457
+
1458
+
1459
+ def primary_library(candidates):
1460
+ """The code-mode primary library of a stack and the version it gives
1461
+ (create-stack-skill generate-output.md §1, Stack version).
1462
+
1463
+ `candidates` is a list of {name, import_count, version}. The primary is
1464
+ the highest `import_count`. On a tie, a candidate whose version reduces
1465
+ to one (see `normalize_version`) goes first, then the lowest name,
1466
+ compared case-insensitively. `version` falls back to 1.0.0 when the
1467
+ primary has none. Returns {primary, import_count, version_input, version,
1468
+ fallback, tied, reason}; `reason` is "highest-import-count",
1469
+ "tie-usable-version", "tie-name-order" or "no-candidates". Raises
1470
+ ValueError on a malformed candidate.
1471
+ """
1472
+ rows = []
1473
+ for c in candidates:
1474
+ if not isinstance(c, dict) or not isinstance(c.get("name"), str) or not c["name"]:
1475
+ raise ValueError(f"each candidate needs a name: {c!r}")
1476
+ count = c.get("import_count", 0)
1477
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
1478
+ raise ValueError(f"import_count must be a whole number: {c!r}")
1479
+ version = c.get("version")
1480
+ if version is not None and not isinstance(version, str):
1481
+ raise ValueError(f"version must be a string or null: {c!r}")
1482
+ usable = version is not None and not isinstance(_reduce_version(version), str)
1483
+ rows.append((c["name"], count, version, usable))
1484
+ if not rows:
1485
+ return {"primary": None, "import_count": None, "version_input": None,
1486
+ "version": "1.0.0", "fallback": True, "tied": [], "reason": "no-candidates"}
1487
+ top = max(r[1] for r in rows)
1488
+ tied = [r for r in rows if r[1] == top]
1489
+ pool = [r for r in tied if r[3]] or tied
1490
+ name, count, version, usable = min(pool, key=lambda r: (r[0].casefold(), r[0]))
1491
+ if len(tied) == 1:
1492
+ reason = "highest-import-count"
1493
+ elif len(pool) == 1:
1494
+ reason = "tie-usable-version"
1495
+ else:
1496
+ reason = "tie-name-order"
1497
+ return {"primary": name, "import_count": count, "version_input": version,
1498
+ "version": normalize_version(version)["normalized"] if usable else "1.0.0",
1499
+ "fallback": not usable, "tied": sorted((r[0] for r in tied), key=lambda n: (n.casefold(), n)),
1500
+ "reason": reason}
1501
+
1502
+
1503
+ def _version_folder_key(name):
1504
+ """Newest-first sort key for version folder names; a name that is not a
1505
+ version sorts after every version, by name."""
1506
+ parsed = _parse_plain_version(name)
1507
+ if parsed is None:
1508
+ return (0, (), name)
1509
+ return (1, _order_key(parsed), name)
1510
+
1511
+
1512
+ # --------------------------------------------------------------------------
1513
+ # Resolve: the version a reading workflow uses, and its paths
1514
+ # --------------------------------------------------------------------------
1515
+
1516
+ ISO_TIME_RE = re.compile(
1517
+ r"(\d{4})-(\d{2})-(\d{2})"
1518
+ r"(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?)?"
1519
+ r"\s*([Zz]|[+-]\d{2}(?::?\d{2})?)?")
1520
+
1521
+
1522
+ # Keep identical to _parse_iso_utc in skf-load-provenance.py
1523
+ # (test/test-skf-skill-inventory.py pins the copies).
1524
+ def _parse_iso_utc(value):
1525
+ """An ISO-8601 date or date-time as an aware UTC datetime, else None.
1526
+
1527
+ A time without a zone is read as UTC, a date alone as its midnight UTC.
1528
+ """
1529
+ if not isinstance(value, str):
1530
+ return None
1531
+ m = ISO_TIME_RE.fullmatch(value.strip())
1532
+ if not m:
1533
+ return None
1534
+ year, month, day, hour, minute, second, fraction, zone = m.groups()
1535
+ offset = timedelta(0)
1536
+ if zone and zone not in ("Z", "z"):
1537
+ digits = zone[1:].replace(":", "")
1538
+ offset = timedelta(hours=int(digits[:2]), minutes=int(digits[2:] or 0))
1539
+ if zone[0] == "-":
1540
+ offset = -offset
1541
+ try:
1542
+ moment = datetime(int(year), int(month), int(day), int(hour or 0), int(minute or 0),
1543
+ int(second or 0), int((fraction or "0")[:6].ljust(6, "0")))
1544
+ return (moment - offset).replace(tzinfo=timezone.utc)
1545
+ except (ValueError, OverflowError): # no such date, or out of datetime's range
1546
+ return None
1547
+
1548
+
1549
+ def _utc_text(moment):
1550
+ return moment.strftime("%Y-%m-%dT%H:%M:%SZ")
1551
+
1552
+
1553
+ def _provenance_time(path):
1554
+ """(generated_at, source) for a provenance map: its `generated_at` field in
1555
+ UTC ("provenance-map"), else the file's modification time ("mtime"), else
1556
+ (None, None) when there is no map."""
1557
+ data, err = read_json_file(path)
1558
+ if err is None and isinstance(data, dict):
1559
+ moment = _parse_iso_utc(data.get("generated_at"))
1560
+ if moment is not None:
1561
+ return _utc_text(moment), "provenance-map"
1562
+ try:
1563
+ if path.is_file():
1564
+ return _utc_text(datetime.fromtimestamp(path.stat().st_mtime, timezone.utc)), "mtime"
1565
+ except OSError:
1566
+ pass
1567
+ return None, None
1568
+
1569
+
1570
+ def _active_link_version(skill_group_dir):
1571
+ """(version, error) for the `active` link of a skill folder.
1572
+
1573
+ The version is the name of the folder the link leads to when that folder
1574
+ sits directly in the skill folder (not a `.skf-` name). A broken or
1575
+ looping link, or one that leads anywhere else, gives (None, reason); no
1576
+ link gives (None, None). A real `active/` folder is not a link.
1577
+ """
1578
+ active = skill_group_dir / "active"
1579
+ if not _is_link_or_junction(active):
1580
+ if active.is_dir():
1581
+ return None, f"`active` is a folder, not a link: {active}"
1582
+ return None, None
1583
+ try:
1584
+ target = active.resolve(strict=True)
1585
+ group = skill_group_dir.resolve(strict=True)
1586
+ except (OSError, RuntimeError):
1587
+ return None, f"`active` link is broken: {active}"
1588
+ if target.parent != group or ".skf-" in target.name or not target.is_dir():
1589
+ return None, f"`active` link does not lead to a version folder of the skill: {active}"
1590
+ return target.name, None
1591
+
1592
+
1593
+ def _manifest_versions(entry, updated_at):
1594
+ """(active_version, last_exported, {version: status}) from one manifest entry.
1595
+
1596
+ A v2 entry maps each version to its record; a v1 entry lists versions
1597
+ and marks the active one deprecated with `deprecated: true`, as
1598
+ skf-manifest-ops.py migrates it.
1599
+ """
1600
+ if not isinstance(entry, dict):
1601
+ return None, None, {}
1602
+ active = entry.get("active_version")
1603
+ active = active if isinstance(active, str) and active else None
1604
+ versions = entry.get("versions")
1605
+ statuses, last_exported = {}, None
1606
+ if isinstance(versions, dict):
1607
+ for v, record in versions.items():
1608
+ status = record.get("status") if isinstance(record, dict) else None
1609
+ statuses[v] = status if isinstance(status, str) else None
1610
+ record = versions.get(active) if active else None
1611
+ if isinstance(record, dict) and isinstance(record.get("last_exported"), str):
1612
+ last_exported = record["last_exported"]
1613
+ elif isinstance(versions, list):
1614
+ deprecated = entry.get("deprecated") is True
1615
+ for v in versions:
1616
+ if isinstance(v, str):
1617
+ statuses[v] = ("deprecated" if deprecated else "active") if v == active else "archived"
1618
+ last_exported = updated_at if isinstance(updated_at, str) else None
1619
+ return active, last_exported, statuses
1620
+
1621
+
1622
+ def _disk_versions(skill_group_dir, skill_name):
1623
+ """The version folders of a skill folder: each folder directly in it that
1624
+ holds a `{name}/` folder (not `active`, a `.skf-` or dot name, or a flat
1625
+ package folder beside a root SKILL.md)."""
1626
+ try:
1627
+ children = list(skill_group_dir.iterdir())
1628
+ except OSError:
1629
+ return []
1630
+ flat = (skill_group_dir / "SKILL.md").is_file()
1631
+ found = []
1632
+ for child in children:
1633
+ name = child.name
1634
+ if (name.startswith(".") or _is_active_pointer(name) or ".skf-" in name
1635
+ or (flat and name in FLAT_PACKAGE_ENTRIES)):
1636
+ continue
1637
+ if child.is_dir() and (child / skill_name).is_dir():
1638
+ found.append(name)
1639
+ return found
1640
+
1641
+
1642
+ def _artifact(versioned, flat):
1643
+ """{path, source, versioned, flat}: the first of the two paths that is a
1644
+ file, versioned first; `path` and `source` are null when neither is."""
1645
+ for source, path in (("versioned", versioned), ("flat", flat)):
1646
+ if path is not None and path.is_file():
1647
+ return {"path": str(path), "source": source,
1648
+ "versioned": str(versioned) if versioned else None, "flat": str(flat)}
1649
+ return {"path": None, "source": None,
1650
+ "versioned": str(versioned) if versioned else None, "flat": str(flat)}
1651
+
1652
+
1653
+ RESOLVE_DETAILS = {
1654
+ "requested": "the caller asked for version {chosen}",
1655
+ "manifest-and-link": "the manifest and the `active` link both name {chosen}",
1656
+ "manifest-lags-link": ("the manifest names {manifest} but the `active` link names {chosen}, "
1657
+ "which a writing workflow flipped after the last export; using the "
1658
+ "link (run export-skill to reconcile the manifest)"),
1659
+ "manifest": "the manifest names {chosen}",
1660
+ "link": "the `active` link names {chosen}; the manifest does not list the skill",
1661
+ "flat-layout": "the skill still uses the flat layout (SKILL.md at the skill folder root)",
1662
+ "newest-on-disk": ("neither the manifest nor an `active` link names a version on disk; "
1663
+ "{chosen} is the newest version folder"),
1664
+ "missing": "no version of the skill is on disk",
1665
+ }
1666
+
1667
+
1668
+ def resolve_skill(skills_folder, skill_name, forge_data_folder, version=None):
1669
+ """Resolve the version a reading workflow uses for `skill_name`, and its paths.
1670
+
1671
+ Implements Reading Workflows in knowledge/version-paths.md. The chosen
1672
+ version and `reason`, first that applies:
1673
+
1674
+ - "requested": `version` was given.
1675
+ - "manifest-and-link": the manifest's `active_version` and the `active`
1676
+ link name the same version, and its package is on disk.
1677
+ - "manifest-lags-link": they differ and the link's version has its
1678
+ package on disk (the Manifest-lag guard: the link wins).
1679
+ - "manifest": the manifest's version has its package on disk (no link,
1680
+ or the link names a version with no package).
1681
+ - "link": the manifest does not list the skill; the link's version has
1682
+ its package on disk.
1683
+ - "flat-layout": no version resolved and SKILL.md sits at the skill
1684
+ folder root; `chosen_version` and `forge_version` are null.
1685
+ - "newest-on-disk": no version resolved, but a version folder holds the
1686
+ package; the newest one.
1687
+ - "missing": nothing on disk; the manifest's version, else the link's,
1688
+ else null.
1689
+
1690
+ `candidates` describes the manifest's and the link's version (package
1691
+ on disk, provenance map, its `generated_at` in UTC or the file's mtime).
1692
+ `paths` gives metadata.json, provenance-map.json and evidence-report.md,
1693
+ each the versioned path when that file exists, else the flat one
1694
+ (`{skill_group}/metadata.json`, `{forge_group}/<file>`), else null.
1695
+ `versions` lists the manifest's and the on-disk versions newest first,
1696
+ with the manifest status of each; `counts` and `newest_non_deprecated`
1697
+ (the newest manifest version whose status is not "deprecated") read
1698
+ them. Returns None when neither the manifest nor the skills folder
1699
+ knows the skill.
1700
+ """
1701
+ skills_dir, forge_dir = Path(skills_folder), Path(forge_data_folder)
1702
+ group, forge_group = skills_dir / skill_name, forge_dir / skill_name
1703
+ errors = []
1704
+ manifest, manifest_error = read_json_file(skills_dir / ".export-manifest.json")
1705
+ if manifest_error and manifest_error.startswith("Not found"):
1706
+ manifest_error = None
1707
+ if manifest is not None and not isinstance(manifest, dict):
1708
+ manifest, manifest_error = None, "the export manifest is not a JSON object"
1709
+ exports = manifest.get("exports") if isinstance(manifest, dict) else None
1710
+ entry = exports.get(skill_name) if isinstance(exports, dict) else None
1711
+ manifest_version, last_exported, statuses = _manifest_versions(
1712
+ entry, manifest.get("updated_at") if isinstance(manifest, dict) else None)
1713
+ if manifest_version is not None and not _safe_segment(manifest_version):
1714
+ errors.append(f"the manifest's active_version is not a folder name: {manifest_version!r}")
1715
+ manifest_version = None
1716
+ if not isinstance(entry, dict) and not os.path.lexists(group):
1717
+ return None
1718
+ is_group = group.is_dir()
1719
+ link_version, link_error = _active_link_version(group) if is_group else (None, None)
1720
+ if link_error:
1721
+ errors.append(link_error)
1722
+ on_disk = _disk_versions(group, skill_name) if is_group else []
1723
+
1724
+ def has_package(v):
1725
+ return v is not None and (group / v / skill_name).is_dir()
1726
+
1727
+ listed = sorted(set(statuses) | set(on_disk), key=_version_folder_key, reverse=True)
1728
+ newest_on_disk = next((v for v in listed if v in on_disk), None)
1729
+ if version is not None:
1730
+ chosen, reason = version, "requested"
1731
+ elif has_package(link_version) and link_version == manifest_version:
1732
+ chosen, reason = link_version, "manifest-and-link"
1733
+ elif has_package(link_version) and manifest_version is not None:
1734
+ chosen, reason = link_version, "manifest-lags-link"
1735
+ elif has_package(manifest_version):
1736
+ chosen, reason = manifest_version, "manifest"
1737
+ elif has_package(link_version):
1738
+ chosen, reason = link_version, "link"
1739
+ elif is_group and (group / "SKILL.md").is_file():
1740
+ chosen, reason = None, "flat-layout"
1741
+ elif newest_on_disk is not None:
1742
+ chosen, reason = newest_on_disk, "newest-on-disk"
1743
+ else:
1744
+ chosen, reason = manifest_version or link_version, "missing"
1745
+
1746
+ def candidate(v):
1747
+ if v is None:
1748
+ return None
1749
+ provenance = forge_group / v / "provenance-map.json"
1750
+ generated_at, source = _provenance_time(provenance)
1751
+ return {"version": v, "skill_package_exists": has_package(v),
1752
+ "provenance_map": str(provenance) if provenance.is_file() else None,
1753
+ "generated_at": generated_at, "generated_at_source": source}
1754
+
1755
+ package = group / chosen / skill_name if chosen else None
1756
+ forge_version = forge_group / chosen if chosen else None
1757
+ if reason == "flat-layout":
1758
+ package = group
1759
+ counts = {"total": len(listed), "manifest": len(statuses), "on_disk": len(on_disk),
1760
+ "non_deprecated": sum(1 for s in statuses.values() if s != "deprecated"),
1761
+ "by_status": {}}
1762
+ for status in statuses.values():
1763
+ key = status if status is not None else "unknown"
1764
+ counts["by_status"][key] = counts["by_status"].get(key, 0) + 1
1765
+ return {
1766
+ "name": skill_name,
1767
+ "layout": "flat" if reason == "flat-layout" else ("versioned" if chosen else "none"),
1768
+ "chosen_version": chosen,
1769
+ "reason": reason,
1770
+ "detail": RESOLVE_DETAILS[reason].format(chosen=chosen, manifest=manifest_version),
1771
+ "active_version": manifest_version,
1772
+ "manifest_last_exported": last_exported,
1773
+ "symlink_target": link_version,
1774
+ "candidates": {"manifest": candidate(manifest_version), "symlink": candidate(link_version)},
1775
+ "skill_group": str(group),
1776
+ "skill_package": str(package) if package else None,
1777
+ "skill_package_exists": package is not None and package.is_dir(),
1778
+ "forge_group": str(forge_group),
1779
+ "forge_version": str(forge_version) if forge_version else None,
1780
+ "paths": {
1781
+ "metadata": _artifact(package / "metadata.json" if chosen else None,
1782
+ group / "metadata.json"),
1783
+ "provenance_map": _artifact(forge_version / "provenance-map.json" if chosen else None,
1784
+ forge_group / "provenance-map.json"),
1785
+ "evidence_report": _artifact(forge_version / "evidence-report.md" if chosen else None,
1786
+ forge_group / "evidence-report.md"),
1787
+ },
1788
+ "versions": [{"version": v, "status": statuses.get(v), "in_manifest": v in statuses,
1789
+ "on_disk": v in on_disk} for v in listed],
1790
+ "newest_non_deprecated": next(
1791
+ (v for v in listed if v in statuses and statuses[v] != "deprecated"), None),
1792
+ "newest_on_disk": newest_on_disk,
1793
+ "counts": counts,
1794
+ "manifest_error": manifest_error,
1795
+ "errors": errors,
1796
+ }
1797
+
1798
+
127
1799
  def normalize_url(value):
128
1800
  """Normalize a URL / path / source_repo for case-insensitive comparison.
129
1801
 
@@ -161,30 +1833,311 @@ def _kebab(segment):
161
1833
  return s.strip("-")
162
1834
 
163
1835
 
164
- def derive_name(target):
165
- """Derive the expected skill name from a target URL / path / bare name.
1836
+ # Hosts step-auto-scope's URL table reads as git hosting; an http(s) URL on
1837
+ # any other host is a documentation URL, named after its host, unless its
1838
+ # path marks a git clone URL (see :func:`_docs_host`). Naming stays offline:
1839
+ # :func:`probe_git` tells a repository's page address among them apart.
1840
+ GIT_HOSTS = ("github.com", "gitlab.com", "bitbucket.org")
1841
+
1842
+ _MAJOR_VERSION_SEGMENT = re.compile(r"v\d+")
166
1843
 
167
- Mirrors step-auto-scope §6 (repo/package name = the last non-empty path
168
- segment, kebab-lowercased) and §0a (a bare doc hostname has its dots turned
169
- into hyphens). Both cases funnel through :func:`_kebab`, so
170
- ``github.com/x/bar-baz`` -> ``bar-baz`` and ``docs.example.com`` ->
171
- ``docs-example-com``. Returns "" when no segment can be derived.
1844
+
1845
+ def _manifest_skill_name(manifest_name):
1846
+ """The skill name a manifest's package name gives ("" when none).
1847
+
1848
+ ``@scope/pkg`` (npm) joins the scope: ``scope-pkg``. ``groupId:artifactId``
1849
+ (Maven) gives the artifact. A path whose first segment is a domain (a Go
1850
+ module) gives its last segment, a ``/vN`` major-version suffix skipped.
1851
+ Anything else (``vendor/package`` from Composer included) is kebab-cased
1852
+ whole.
172
1853
  """
173
- if not target:
1854
+ if not manifest_name:
1855
+ return ""
1856
+ s = str(manifest_name).strip()
1857
+ if s.startswith("@"):
1858
+ return _kebab(s)
1859
+ if ":" in s:
1860
+ return _kebab(s.rsplit(":", 1)[1])
1861
+ segments = [seg for seg in s.split("/") if seg]
1862
+ if len(segments) > 1 and "." in segments[0]:
1863
+ if len(segments) > 2 and _MAJOR_VERSION_SEGMENT.fullmatch(segments[-1]):
1864
+ segments = segments[:-1]
1865
+ return _kebab(segments[-1])
1866
+ return _kebab(s)
1867
+
1868
+
1869
+ def _members_skill_name(members):
1870
+ """The name the members of a merged unit share ("" when none).
1871
+
1872
+ Maven ``group:artifact`` members of one group give the group's last
1873
+ segment (``com.acme:core`` and ``com.acme:api`` give ``acme``); Go module
1874
+ paths their longest common path, domain and one folder at least, less a
1875
+ ``/vN`` suffix; any other names the words their skill names start with
1876
+ alike (``@trpc/server`` and ``@trpc/client`` give ``trpc``, ``serde`` and
1877
+ ``serde_json`` give ``serde``). Two members at least.
1878
+ """
1879
+ names = [str(m).strip() for m in members or () if m and str(m).strip()]
1880
+ if len(names) < 2:
174
1881
  return ""
175
- s = str(target).strip().lower()
1882
+ if all(":" in n for n in names):
1883
+ groups = {n.rsplit(":", 1)[0] for n in names}
1884
+ return _kebab(groups.pop().split(".")[-1]) if len(groups) == 1 else ""
1885
+ paths = [[seg for seg in n.split("/") if seg] for n in names]
1886
+ if all(len(p) > 1 and "." in p[0] for p in paths):
1887
+ common = os.path.commonprefix(paths)
1888
+ if common and _MAJOR_VERSION_SEGMENT.fullmatch(common[-1]):
1889
+ common = common[:-1]
1890
+ return _kebab(common[-1]) if len(common) > 1 else ""
1891
+ common = os.path.commonprefix([_manifest_skill_name(n).split("-") for n in names])
1892
+ return "-".join(word for word in common if word)
1893
+
1894
+
1895
+ def _docs_host(target):
1896
+ """The host of a documentation URL, or None for any other target.
1897
+
1898
+ A clone URL on any host (its path ends in ``.git``, as
1899
+ ``https://git.example.com/team/repo.git`` does, or holds ``/_git/``, as
1900
+ an Azure DevOps ``https://dev.azure.com/org/project/_git/repo`` does)
1901
+ is a repository, not documentation.
1902
+ """
1903
+ s = str(target).strip()
1904
+ if not s.lower().startswith(("https://", "http://")):
1905
+ return None
1906
+ parts = urlsplit(s)
1907
+ host = (parts.hostname or "").lower()
1908
+ if not host:
1909
+ return None
1910
+ path = parts.path.rstrip("/").lower()
1911
+ if path.endswith(".git") or "/_git/" in path:
1912
+ return None
1913
+ bare = host[4:] if host.startswith("www.") else host
1914
+ return None if bare in GIT_HOSTS else host
1915
+
1916
+
1917
+ def _target_segments(target):
1918
+ """The path segments of a repository URL or a local path: scheme, a
1919
+ Windows drive, ``.git`` and trailing slashes stripped, ``\\`` read as
1920
+ ``/``.
1921
+ """
1922
+ s = str(target).strip()
176
1923
  for scheme in ("https://", "http://"):
177
- if s.startswith(scheme):
1924
+ if s.lower().startswith(scheme):
178
1925
  s = s[len(scheme):]
179
1926
  break
1927
+ s = re.sub(r"^[A-Za-z]:(?=[\\/]|$)", "", s.replace("\\", "/"))
180
1928
  s = s.rstrip("/")
181
- if s.endswith(".git"):
1929
+ if s.lower().endswith(".git"):
182
1930
  s = s[:-4]
183
- s = s.rstrip("/")
184
- segments = [seg for seg in s.split("/") if seg]
185
- if not segments:
186
- return ""
187
- return _kebab(segments[-1])
1931
+ return [seg for seg in s.rstrip("/").split("/") if seg]
1932
+
1933
+
1934
+ def _target_name(target):
1935
+ """The last folder or repository name of a target ("" when none). A
1936
+ relative or home path that ends in ``.``, ``..`` or ``~`` names the
1937
+ folder it resolves to (``.`` the current one).
1938
+ """
1939
+ segments = _target_segments(target)
1940
+ last = segments[-1] if segments else ""
1941
+ if last in (".", "..", "~"):
1942
+ local = str(target).strip().replace("\\", "/")
1943
+ return os.path.basename(os.path.abspath(os.path.expanduser(local)))
1944
+ return last
1945
+
1946
+
1947
+ # A URL git reads as given: a scheme (https://, ssh://, git://, file://) or
1948
+ # an SSH address (git@github.com:acme/mono.git).
1949
+ _URL_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://")
1950
+ _SSH_ADDRESS_RE = re.compile(r"^[\w.-]+@[\w.-]+:")
1951
+ # A Windows path: a drive (C:\code, C:/code, C:), a UNC or rooted path, or .\ and ..\.
1952
+ _WINDOWS_PATH_RE = re.compile(r"^(?:[A-Za-z]:(?:[\\/]|$)|\\|\.{1,2}\\)")
1953
+
1954
+
1955
+ def target_kind(target):
1956
+ """The kind of an analysis target: "local", "docs" or "remote" (None for none).
1957
+
1958
+ A path that starts with `/`, `./`, `../` or `~` (`.` and `..` too, and a
1959
+ Windows drive, UNC or `.\\` path), or that names an existing folder, is
1960
+ local; a documentation URL (an http(s) URL not on :data:`GIT_HOSTS` and
1961
+ not a clone URL, see :func:`_docs_host`) is docs; anything else is a repository git clones (:func:`clone_url`).
1962
+ """
1963
+ s = str(target or "").strip()
1964
+ if not s:
1965
+ return None
1966
+ if (s.startswith(("/", "./", "../", "~")) or s in (".", "..") or _WINDOWS_PATH_RE.match(s)
1967
+ or os.path.isdir(os.path.expanduser(s))):
1968
+ return "local"
1969
+ return "docs" if _docs_host(s) else "remote"
1970
+
1971
+
1972
+ def clone_url(target):
1973
+ """The URL git clones a remote target from, None for a local or docs one.
1974
+
1975
+ A URL with a scheme or an SSH address is used as given. git reads a
1976
+ shorthand or a host path without a scheme as a local folder, so a host
1977
+ path (its first segment holds a `.` or a port, as `github.com/acme/mono`
1978
+ and `gitlab.com/group/repo` do) gets `https://`, and the `owner/repo`
1979
+ shorthand `https://github.com/`.
1980
+ """
1981
+ if target_kind(target) != "remote":
1982
+ return None
1983
+ s = str(target).strip()
1984
+ if _URL_SCHEME_RE.match(s) or _SSH_ADDRESS_RE.match(s):
1985
+ return s
1986
+ path = s.strip("/")
1987
+ first = path.split("/", 1)[0]
1988
+ return f"https://{path}" if ("." in first or ":" in first) else f"https://github.com/{path}"
1989
+
1990
+
1991
+ # How long `probe_git` waits for git, in seconds.
1992
+ GIT_PROBE_TIMEOUT = 20
1993
+ # What git prints (in the C locale) when the server asks for credentials and
1994
+ # none can be given without a prompt, or refuses the stored ones: a private
1995
+ # repository, or any other page behind a login.
1996
+ _GIT_CREDENTIAL_ERRORS = ("terminal prompts disabled", "Authentication failed")
1997
+ # What a run asks for in place of an address git reads as a repository.
1998
+ _CLONE_REMEDY = "use its `.git` clone URL, its address without https:// or its SSH address"
1999
+
2000
+
2001
+ def probe_git(url):
2002
+ """git's verdict on ``url``: "repository", "credentials", "unanswered", "no-git" or "docs".
2003
+
2004
+ Runs ``git ls-remote --exit-code --heads <url>``, the URL less its query
2005
+ and fragment (git appends ``/info/refs`` to the address it is given),
2006
+ with every prompt off (no terminal prompt, no askpass program, no
2007
+ credential-manager window), so it never waits on the user; a credential
2008
+ helper's stored credentials still apply. ``--heads`` asks for the branches
2009
+ alone (protocol v2 sends the server the prefix ``refs/heads/``), so a
2010
+ repository with a huge ref list (one ref per Gerrit patch set) answers at
2011
+ once. "repository" when git exits 0, or 2 (a repository that answered
2012
+ with no branch); "credentials" when the server asks for credentials or
2013
+ refuses the stored ones: a private repository answers so, and so does
2014
+ documentation behind a login, so it never says which; "docs" on any
2015
+ other answer (``repository not found`` for a documentation site);
2016
+ "unanswered" when git gives no answer in :data:`GIT_PROBE_TIMEOUT`
2017
+ seconds; "no-git" when git is missing or cannot run. stderr goes to a
2018
+ file, not a pipe, so a timeout returns at once even while git's transport
2019
+ helper still runs.
2020
+ """
2021
+ address = urlunsplit(urlsplit(str(url).strip())._replace(query="", fragment=""))
2022
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "",
2023
+ "GCM_INTERACTIVE": "never", "LC_ALL": "C"}
2024
+ with tempfile.TemporaryFile() as err:
2025
+ try:
2026
+ proc = subprocess.run(["git", "ls-remote", "--exit-code", "--heads", address], env=env,
2027
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=err,
2028
+ timeout=GIT_PROBE_TIMEOUT)
2029
+ except subprocess.TimeoutExpired:
2030
+ return "unanswered"
2031
+ except OSError:
2032
+ return "no-git"
2033
+ if proc.returncode in (0, 2):
2034
+ return "repository"
2035
+ err.seek(0)
2036
+ message = err.read().decode("utf-8", errors="replace")
2037
+ return "credentials" if any(text in message for text in _GIT_CREDENTIAL_ERRORS) else "docs"
2038
+
2039
+
2040
+ def git_probe_halt(target, verdict):
2041
+ """The message a run stops with for ``target`` on :func:`probe_git`'s ``verdict``, or None.
2042
+
2043
+ A run stops when git reads the address as a repository, when the server
2044
+ asks for credentials and when git gives no answer; it goes on as
2045
+ documentation on "docs" and on "no-git" (nothing could ask git, and a
2046
+ documentation URL needs no git).
2047
+ """
2048
+ return {
2049
+ "repository": f"{target} is a git repository: {_CLONE_REMEDY}.",
2050
+ "credentials": f"{target} asks for credentials: SKF reads no documentation behind a login; "
2051
+ f"if it is a private git repository, {_CLONE_REMEDY}.",
2052
+ "unanswered": f"{target} gave git no answer in {GIT_PROBE_TIMEOUT} s: if it is a git repository, "
2053
+ f"{_CLONE_REMEDY}; if it is documentation, give a documentation URL that answers.",
2054
+ }.get(verdict)
2055
+
2056
+
2057
+ def derive_name_with_basis(target, manifest_name=None, private=False, members=None):
2058
+ """``(name, basis)`` for a unit or brief; see :func:`derive_name`.
2059
+
2060
+ ``basis`` is "manifest", "members", "docs-host" or "target", or None
2061
+ when no name can be derived (``name`` is then "").
2062
+ """
2063
+ name = "" if private else _manifest_skill_name(manifest_name)
2064
+ if name:
2065
+ return name, "manifest"
2066
+ name = _members_skill_name(members)
2067
+ if name:
2068
+ return name, "members"
2069
+ if not target:
2070
+ return "", None
2071
+ host = _docs_host(target)
2072
+ if host:
2073
+ name = _kebab(host)
2074
+ return (name, "docs-host") if name else ("", None)
2075
+ name = _kebab(_target_name(target))
2076
+ return (name, "target") if name else ("", None)
2077
+
2078
+
2079
+ def derive_name(target, manifest_name=None, private=False, members=None):
2080
+ """The one skill-name rule: the name a unit or brief is written under,
2081
+ and the name coexistence matching compares.
2082
+
2083
+ From ``manifest_name`` when it gives one (see
2084
+ :func:`_manifest_skill_name`) and the package is not ``private`` (a
2085
+ private root, such as a monorepo's workspace manifest, names no
2086
+ published package); else from the ``members`` of a merged unit, the
2087
+ name they share (:func:`_members_skill_name`); else from ``target``: a
2088
+ documentation URL (an http(s) URL not on :data:`GIT_HOSTS` and not a
2089
+ clone URL, see :func:`_docs_host`) gives its host, and any other target its last folder or repository name, scheme,
2090
+ a Windows drive, ``.git`` and trailing slashes stripped (``.``, ``..``
2091
+ and ``~`` name the folder they resolve to). Every case funnels through
2092
+ :func:`_kebab`, so ``github.com/x/bar-baz`` -> ``bar-baz``,
2093
+ ``https://docs.example.com/guide/intro`` -> ``docs-example-com``,
2094
+ ``C:\\code\\mono`` -> ``mono`` and manifest name ``@trpc/server`` ->
2095
+ ``trpc-server``. Returns "" when no name can be derived.
2096
+ """
2097
+ return derive_name_with_basis(target, manifest_name, private, members)[0]
2098
+
2099
+
2100
+ def _tell_apart(names):
2101
+ """Rename the batch entries that derive one name: each takes, before
2102
+ it, the fewest folders above its target's last segment that make every
2103
+ name of the group unique (``src/server/api`` and ``src/client/api`` give
2104
+ ``server-api`` and ``client-api``), and a renamed entry keeps the name
2105
+ it shared in ``clash``. Returns the index groups that still share a
2106
+ name.
2107
+ """
2108
+ groups = {}
2109
+ for i, entry in enumerate(names):
2110
+ if entry["name"]:
2111
+ groups.setdefault(entry["name"], []).append(i)
2112
+ for name, members in groups.items():
2113
+ if len(members) < 2:
2114
+ continue
2115
+ parents = {i: _target_segments(names[i]["target"] or "")[:-1] for i in members}
2116
+ for depth in range(1, max(len(p) for p in parents.values()) + 1):
2117
+ renamed = {i: _kebab("-".join(parents[i][-depth:] + [name])) for i in members}
2118
+ if len(set(renamed.values())) == len(members):
2119
+ for i in members:
2120
+ if renamed[i] != name:
2121
+ names[i].update(name=renamed[i], clash=name)
2122
+ break
2123
+ final = {}
2124
+ for i, entry in enumerate(names):
2125
+ if entry["name"]:
2126
+ final.setdefault(entry["name"], []).append(i)
2127
+ return [ids for ids in final.values() if len(ids) > 1]
2128
+
2129
+
2130
+ def _match_entry(entry, reason):
2131
+ """One ``matches[]`` entry for inventory ``entry``, matched by ``reason``."""
2132
+ meta = entry.get("metadata")
2133
+ return {
2134
+ "name": entry.get("name"),
2135
+ "active_version": entry.get("active_version"),
2136
+ "source_repo": meta.get("source_repo") if meta else None,
2137
+ "active_path": entry.get("active_path"),
2138
+ "match_reason": reason,
2139
+ "skf_skill": bool(entry.get("skf_skill")),
2140
+ }
188
2141
 
189
2142
 
190
2143
  def compute_matches(skills, target):
@@ -192,10 +2145,12 @@ def compute_matches(skills, target):
192
2145
 
193
2146
  For each inventory skill, a hit fires when either the normalized
194
2147
  ``metadata.source_repo`` equals the normalized target (URL match) or the
195
- derived expected name equals the skill's name, case-insensitively (name
196
- match). Each match entry is
197
- ``{name, active_version, source_repo, active_path, match_reason}`` where
198
- ``match_reason`` is ``"url"``, ``"name"``, or ``"both"``.
2148
+ derived expected name (:func:`derive_name`) equals the skill's name,
2149
+ case-insensitively (name match). Each match entry is
2150
+ ``{name, active_version, source_repo, active_path, match_reason, skf_skill}``
2151
+ where ``match_reason`` is ``"url"``, ``"name"``, or ``"both"`` and
2152
+ ``skf_skill`` says whether SKF generated the matched skill (only those can
2153
+ be merged into through update-skill).
199
2154
  """
200
2155
  norm_target = normalize_url(target)
201
2156
  derived = derive_name(target)
@@ -222,19 +2177,55 @@ def compute_matches(skills, target):
222
2177
  reason = "url"
223
2178
  else:
224
2179
  reason = "name"
225
- matches.append({
226
- "name": entry.get("name"),
227
- "active_version": entry.get("active_version"),
228
- "source_repo": source_repo,
229
- "active_path": entry.get("active_path"),
230
- "match_reason": reason,
231
- })
2180
+ matches.append(_match_entry(entry, reason))
232
2181
  return matches
233
2182
 
234
2183
 
235
- def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_target=None):
2184
+ def existing_by_name(skills_folder):
2185
+ """Lowercased skill name -> its ``matches[]`` entry (``match_reason``
2186
+ "name") for every skill in ``skills_folder``; empty when the folder does
2187
+ not exist.
2188
+ """
2189
+ inventory = scan_inventory(skills_folder)
2190
+ if inventory.get("status") != "ok":
2191
+ return {}
2192
+ return {str(entry.get("name") or "").strip().lower(): _match_entry(entry, "name")
2193
+ for entry in inventory["skills"]}
2194
+
2195
+
2196
+ # A name at least this similar to the name asked for is a near match (the
2197
+ # default cutoff of difflib.get_close_matches).
2198
+ NEAR_CUTOFF = 0.6
2199
+
2200
+
2201
+ def near_skills(skills, query):
2202
+ """The skills SKF generated that have a SKILL.md, closest to `query` first.
2203
+
2204
+ >>> skills = [{"name": n, "skf_skill": True, "has_skill_md": True, "active_version": "1.0.0"}
2205
+ ... for n in ("zod", "cocoindex-code", "cocoindx")]
2206
+ >>> [(s["name"], s["close"]) for s in near_skills(skills, "cocoindex")]
2207
+ [('cocoindx', True), ('cocoindex-code', True), ('zod', False)]
2208
+ """
2209
+ wanted = (query or "").strip().lower()
2210
+ ranked = []
2211
+ for entry in skills:
2212
+ if not (entry.get("skf_skill") and entry.get("has_skill_md")):
2213
+ continue
2214
+ name = str(entry.get("name") or "")
2215
+ lowered = name.lower()
2216
+ ratio = difflib.SequenceMatcher(None, wanted, lowered).ratio()
2217
+ close = bool(wanted) and (wanted in lowered or lowered in wanted or ratio >= NEAR_CUTOFF)
2218
+ ranked.append(((not close, -ratio, name),
2219
+ {"name": name, "active_version": entry.get("active_version"), "close": close}))
2220
+ return [item for _, item in sorted(ranked, key=lambda pair: pair[0])]
2221
+
2222
+
2223
+ def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_target=None,
2224
+ forge_data_folder=None, near=None):
236
2225
  """Scan the skills output folder and produce an inventory."""
237
2226
  skills_dir = Path(skills_folder)
2227
+ forge_dir = Path(forge_data_folder) if forge_data_folder else None
2228
+ same_folder = _same_folder(skills_dir, forge_dir)
238
2229
 
239
2230
  if not skills_dir.is_dir():
240
2231
  return {
@@ -272,8 +2263,9 @@ def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_
272
2263
  skill_names = set()
273
2264
 
274
2265
  # From manifest exports
275
- if manifest and "exports" in manifest:
276
- skill_names.update(manifest["exports"].keys())
2266
+ exports = manifest.get("exports") if isinstance(manifest, dict) else None
2267
+ if isinstance(exports, dict):
2268
+ skill_names.update(exports.keys())
277
2269
 
278
2270
  # From directory listing
279
2271
  for child in skills_dir.iterdir():
@@ -293,11 +2285,22 @@ def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_
293
2285
  }
294
2286
 
295
2287
  # Scan each skill group
2288
+ not_skf_output = []
296
2289
  for name in sorted(skill_names):
297
2290
  skill_group_dir = skills_dir / name
298
2291
  if skill_group_dir.is_dir():
299
- entry = scan_skill_group(skill_group_dir, name)
2292
+ entry = scan_skill_group(skill_group_dir, name, same_folder)
300
2293
  result["skills"].append(entry)
2294
+ if (not entry["skf_skill"] and name != "_batch" and ".skf-" not in name
2295
+ and _looks_like_skill(skill_group_dir, name)):
2296
+ not_skf_output.append(name)
2297
+ result["not_skf_output"] = not_skf_output
2298
+ # Forge folders (opt-in via --forge-data-folder; additive top-level keys).
2299
+ if forge_dir is not None:
2300
+ result["forge_data_folder"] = str(forge_dir)
2301
+ result["same_folder"] = same_folder
2302
+ result["forge_groups"] = [] if same_folder else [
2303
+ classify_forge_group(forge_dir / name, name) for name in sorted(skill_names)]
301
2304
 
302
2305
  # Compute summary
303
2306
  result["summary"]["total_skills"] = len(result["skills"])
@@ -305,42 +2308,362 @@ def scan_inventory(skills_folder, skill_filter=None, manifest_only=False, match_
305
2308
  result["summary"]["with_metadata"] = sum(1 for s in result["skills"] if s["metadata"])
306
2309
  result["summary"]["with_provenance"] = sum(1 for s in result["skills"] if s["has_provenance_map"])
307
2310
 
308
- # Coexistence matching (opt-in via --match-target; additive top-level key).
2311
+ # Coexistence matching (opt-in via --match-target; additive top-level keys).
309
2312
  if match_target is not None:
2313
+ result["match_name"] = derive_name(match_target)
310
2314
  result["matches"] = compute_matches(result["skills"], match_target)
2315
+ # Near names (opt-in via --near; additive top-level key).
2316
+ if near is not None:
2317
+ result["near"] = near_skills(result["skills"], near)
311
2318
 
312
2319
  return result
313
2320
 
314
2321
 
315
- if __name__ == "__main__":
316
- if len(sys.argv) < 2:
317
- print(
318
- "Usage: python3 skf-skill-inventory.py <skills-output-folder> "
319
- "[--skill <name>] [--manifest-only] [--match-target <url-or-name>]",
320
- file=sys.stderr,
2322
+ USAGE = ("Usage: uv run skf-skill-inventory.py <skills-output-folder> "
2323
+ "[--skill <name>] [--manifest-only] [--match-target <url-or-name>] "
2324
+ "[--near <name>] [--forge-data-folder <path>]\n"
2325
+ " uv run skf-skill-inventory.py derive-name --target <url-or-path> "
2326
+ "[--manifest-name <name>] [--skills-folder <skills-output-folder>] [--probe-git]\n"
2327
+ " uv run skf-skill-inventory.py derive-name --from <names.json|-> "
2328
+ "[--skills-folder <skills-output-folder>]\n"
2329
+ " uv run skf-skill-inventory.py <skills-output-folder> --skill <name> "
2330
+ "--write-check [--write-version <version>] [--forge-data-folder <path>]\n"
2331
+ " uv run skf-skill-inventory.py <skills-output-folder> --skill <name> "
2332
+ "--purge-check [--purge-version <version>] --forge-data-folder <path>\n"
2333
+ " uv run skf-skill-inventory.py <skills-output-folder> --skill <name> "
2334
+ "--rename-check --forge-data-folder <path>\n"
2335
+ " uv run skf-skill-inventory.py guarded-delete --root <folder> [--root <folder>] "
2336
+ "[<path>...]\n"
2337
+ " uv run skf-skill-inventory.py resolve <skills-output-folder> --skill <name> "
2338
+ "--forge-data-folder <path> [--version <version>]\n"
2339
+ " uv run skf-skill-inventory.py version normalize <version>\n"
2340
+ " uv run skf-skill-inventory.py version order <a> <b>\n"
2341
+ " uv run skf-skill-inventory.py version next-patch <version>\n"
2342
+ " uv run skf-skill-inventory.py version bump --prior <version> "
2343
+ "--prior-libraries <a,b> --libraries <a,c>\n"
2344
+ " uv run skf-skill-inventory.py version primary <candidates.json|->")
2345
+
2346
+
2347
+ def _flag_value(argv, flag, required=False):
2348
+ """The value after `flag`, or None when the flag is absent.
2349
+
2350
+ A flag without a value reads as absent, as it always has, unless
2351
+ `required`: `--forge-data-folder`, `--write-version` and, in write-check
2352
+ mode, `--skill` raise ValueError instead (a missing value, or the next
2353
+ flag in its place), so a caller never gets a verdict it did not ask for.
2354
+ """
2355
+ if flag not in argv:
2356
+ return None
2357
+ idx = argv.index(flag)
2358
+ if idx + 1 < len(argv) and not (required and argv[idx + 1].startswith("--")):
2359
+ return argv[idx + 1]
2360
+ if required:
2361
+ raise ValueError(f"{flag} needs a value")
2362
+ return None
2363
+
2364
+
2365
+ def _safe_segment(value):
2366
+ """True for a single path segment: not empty, `.` or `..`, and no separator."""
2367
+ return bool(value) and value not in (".", "..") and "/" not in value and "\\" not in value
2368
+
2369
+
2370
+ def _usage_error(message):
2371
+ print(json.dumps({"status": "error", "error": message, "code": "USAGE"}, indent=2))
2372
+ print(USAGE, file=sys.stderr)
2373
+ return 1
2374
+
2375
+
2376
+ def _check_flag_pairs(args, flags):
2377
+ """ValueError for an argument that is not one of `flags` followed by its value."""
2378
+ for i in range(0, len(args), 2):
2379
+ if args[i] not in flags:
2380
+ raise ValueError(f"unexpected argument: {args[i]!r}")
2381
+
2382
+
2383
+ def _main_resolve(argv):
2384
+ """`resolve <skills-output-folder> --skill <name> --forge-data-folder <path> [--version <v>]`."""
2385
+ try:
2386
+ if not argv or argv[0].startswith("--"):
2387
+ raise ValueError("resolve needs <skills-output-folder> first")
2388
+ skill = _flag_value(argv, "--skill", required=True)
2389
+ forge_data_folder = _flag_value(argv, "--forge-data-folder", required=True)
2390
+ version = _flag_value(argv, "--version", required=True)
2391
+ _check_flag_pairs(argv[1:], ("--skill", "--forge-data-folder", "--version"))
2392
+ if not skill:
2393
+ raise ValueError("resolve needs --skill <name>")
2394
+ if not forge_data_folder:
2395
+ raise ValueError("resolve needs --forge-data-folder <path>")
2396
+ if not _safe_segment(skill):
2397
+ raise ValueError(f"--skill must be one folder name: {skill!r}")
2398
+ if version is not None and not _safe_segment(version):
2399
+ raise ValueError(f"--version must be one folder name: {version!r}")
2400
+ except ValueError as e:
2401
+ return _usage_error(str(e))
2402
+ skills_dir = Path(argv[0])
2403
+ if not skills_dir.is_dir():
2404
+ result = {"status": "error", "error": f"Skills directory not found: {skills_dir}",
2405
+ "code": "DIR_NOT_FOUND"}
2406
+ else:
2407
+ resolved = resolve_skill(skills_dir, skill, forge_data_folder, version)
2408
+ if resolved is None:
2409
+ result = {"status": "error", "code": "SKILL_NOT_FOUND",
2410
+ "error": f"Skill '{skill}' is neither in {skills_dir} nor in its export manifest"}
2411
+ else:
2412
+ result = {"status": "ok", "skills_folder": str(skills_dir),
2413
+ "forge_data_folder": forge_data_folder, "resolve": resolved}
2414
+ print(json.dumps(result, indent=2))
2415
+ return 0 if result["status"] == "ok" else 1
2416
+
2417
+
2418
+ def _library_list(value):
2419
+ return [name.strip() for name in value.split(",") if name.strip()]
2420
+
2421
+
2422
+ def _read_candidates(source):
2423
+ """The candidate list of `version primary`, from a JSON file or `-` (stdin)."""
2424
+ try:
2425
+ text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
2426
+ data = json.loads(text)
2427
+ except (OSError, ValueError) as e:
2428
+ raise ValueError(f"cannot read candidates from {source}: {e}") from e
2429
+ if not isinstance(data, list):
2430
+ raise ValueError("candidates must be a JSON array of {name, import_count, version}")
2431
+ return data
2432
+
2433
+
2434
+ def _main_version(argv):
2435
+ """`version normalize|order|next-patch|bump|primary ...`; exit 1 with
2436
+ `NOT_A_VERSION`, `NOT_INCREASING`, `BAD_INPUT` or `USAGE` on an error."""
2437
+ command, args = (argv[0], argv[1:]) if argv else (None, [])
2438
+ code = "NOT_A_VERSION"
2439
+ try:
2440
+ if command == "normalize" and len(args) == 1:
2441
+ result = normalize_version(args[0])
2442
+ if result["normalized"] is None:
2443
+ print(json.dumps({"status": "error", "error": result["reason"],
2444
+ "code": "NOT_A_VERSION", "command": command, **result}, indent=2))
2445
+ return 1
2446
+ elif command == "order" and len(args) == 2:
2447
+ result = order_versions(args[0], args[1])
2448
+ elif command == "next-patch" and len(args) == 1:
2449
+ result = next_patch(args[0])
2450
+ elif command == "bump":
2451
+ try:
2452
+ prior = _flag_value(args, "--prior", required=True)
2453
+ prior_libraries = _flag_value(args, "--prior-libraries", required=True)
2454
+ libraries = _flag_value(args, "--libraries", required=True)
2455
+ _check_flag_pairs(args, ("--prior", "--prior-libraries", "--libraries"))
2456
+ if prior is None or prior_libraries is None or libraries is None:
2457
+ raise ValueError("bump needs --prior, --prior-libraries and --libraries")
2458
+ except ValueError as e:
2459
+ return _usage_error(str(e))
2460
+ result = compose_bump(prior, _library_list(prior_libraries), _library_list(libraries))
2461
+ elif command == "primary" and len(args) == 1:
2462
+ code = "BAD_INPUT"
2463
+ result = primary_library(_read_candidates(args[0]))
2464
+ else:
2465
+ return _usage_error(f"unknown version command or wrong arguments: {' '.join(argv)!r}")
2466
+ except NotIncreasingError as e:
2467
+ print(json.dumps({"status": "error", "error": str(e), "code": "NOT_INCREASING"}, indent=2))
2468
+ return 1
2469
+ except ValueError as e:
2470
+ print(json.dumps({"status": "error", "error": str(e), "code": code}, indent=2))
2471
+ return 1
2472
+ print(json.dumps({"status": "ok", "command": command, **result}, indent=2))
2473
+ return 0
2474
+
2475
+
2476
+ def _read_name_entries(source):
2477
+ """The `derive-name --from` entries, from a JSON file or `-` (stdin)."""
2478
+ try:
2479
+ text = sys.stdin.read() if source == "-" else Path(source).read_text(encoding="utf-8")
2480
+ data = json.loads(text)
2481
+ except (OSError, ValueError) as e:
2482
+ raise ValueError(f"cannot read names from {source}: {e}") from e
2483
+ if not isinstance(data, list):
2484
+ raise ValueError('--from must be a JSON array of {"target", "manifest_name", '
2485
+ '"private", "members"}')
2486
+ entries = []
2487
+ for i, raw in enumerate(data):
2488
+ if not isinstance(raw, dict):
2489
+ raise ValueError(f"entry {i} is not an object")
2490
+ target, manifest_name = raw.get("target"), raw.get("manifest_name")
2491
+ for key, value in (("target", target), ("manifest_name", manifest_name)):
2492
+ if value is not None and not isinstance(value, str):
2493
+ raise ValueError(f"entry {i} `{key}` must be a string or null")
2494
+ private, members = raw.get("private"), raw.get("members")
2495
+ if private is not None and not isinstance(private, bool):
2496
+ raise ValueError(f"entry {i} `private` must be true, false or null")
2497
+ if members is not None and not (
2498
+ isinstance(members, list) and all(isinstance(m, str) for m in members)):
2499
+ raise ValueError(f"entry {i} `members` must be a list of names or null")
2500
+ entries.append({"target": target, "manifest_name": manifest_name,
2501
+ "private": bool(private), "members": members or []})
2502
+ return entries
2503
+
2504
+
2505
+ def _main_derive_name(argv):
2506
+ """`derive-name --target <t> [--manifest-name <n>] [--skills-folder <f>] [--probe-git]` or
2507
+ `derive-name --from <file|-> [--skills-folder <f>]`; exit 1 with `USAGE`,
2508
+ `BAD_INPUT` or (for one --target) `NO_NAME` on an error."""
2509
+ flags = ("--target", "--manifest-name", "--from", "--skills-folder")
2510
+ probe = "--probe-git" in argv
2511
+ argv = [arg for arg in argv if arg != "--probe-git"]
2512
+ try:
2513
+ target, manifest_name, source, skills_folder = (
2514
+ _flag_value(argv, flag, required=True) for flag in flags)
2515
+ _check_flag_pairs(argv, flags)
2516
+ if source is not None and (target is not None or manifest_name is not None):
2517
+ raise ValueError("--from takes no --target or --manifest-name")
2518
+ if source is None and target is None and manifest_name is None:
2519
+ raise ValueError("derive-name needs --target <url-or-path> or --from <names.json|->")
2520
+ if probe and source is not None:
2521
+ raise ValueError("--probe-git takes one --target, not --from")
2522
+ except ValueError as e:
2523
+ return _usage_error(str(e))
2524
+ existing = existing_by_name(skills_folder) if skills_folder is not None else None
2525
+
2526
+ def named(entry):
2527
+ name, basis = derive_name_with_basis(entry["target"], entry["manifest_name"],
2528
+ entry.get("private", False), entry.get("members"))
2529
+ return {"target": entry["target"], "manifest_name": entry["manifest_name"],
2530
+ "name": name or None, "basis": basis}
2531
+
2532
+ def with_existing(out):
2533
+ if existing is not None:
2534
+ out["existing"] = existing.get(out["name"]) if out["name"] else None
2535
+ return out
2536
+
2537
+ if source is None:
2538
+ result = named({"target": target, "manifest_name": manifest_name})
2539
+ result.update(kind=target_kind(target), clone_url=clone_url(target))
2540
+ if not result["name"]:
2541
+ print(json.dumps({"status": "error", "code": "NO_NAME", "command": "derive-name",
2542
+ "error": "no skill name can be derived from the target or manifest name"},
2543
+ indent=2))
2544
+ return 1
2545
+ del result["target"], result["manifest_name"]
2546
+ if probe:
2547
+ verdict = probe_git(target) if result["kind"] == "docs" else None
2548
+ result.update(git_probe=verdict, halt_message=git_probe_halt(target, verdict))
2549
+ print(json.dumps({"status": "ok", "command": "derive-name", **with_existing(result)}, indent=2))
2550
+ return 0
2551
+ try:
2552
+ entries = _read_name_entries(source)
2553
+ except ValueError as e:
2554
+ print(json.dumps({"status": "error", "code": "BAD_INPUT", "command": "derive-name",
2555
+ "error": str(e)}, indent=2))
2556
+ return 1
2557
+ names = [named(entry) for entry in entries]
2558
+ duplicates = _tell_apart(names)
2559
+ names = [with_existing(entry) for entry in names]
2560
+ print(json.dumps({"status": "ok", "command": "derive-name", "names": names,
2561
+ "unnamed": [i for i, entry in enumerate(names) if not entry["name"]],
2562
+ "duplicates": duplicates}, indent=2))
2563
+ return 0
2564
+
2565
+
2566
+ def _main_guarded_delete(argv):
2567
+ """`guarded-delete --root <folder> [--root <folder>]... [<path>...]`; exit 0 whatever
2568
+ `purge_status` says (no path deletes nothing), exit 1 with `USAGE` on an error."""
2569
+ roots, paths = [], []
2570
+ try:
2571
+ i = 0
2572
+ while i < len(argv):
2573
+ if argv[i] == "--root":
2574
+ if i + 1 >= len(argv) or not argv[i + 1] or argv[i + 1].startswith("--"):
2575
+ raise ValueError("--root needs a folder")
2576
+ roots.append(argv[i + 1])
2577
+ i += 2
2578
+ continue
2579
+ if argv[i].startswith("--"):
2580
+ raise ValueError(f"unexpected argument: {argv[i]!r}")
2581
+ paths.append(argv[i])
2582
+ i += 1
2583
+ if not roots:
2584
+ raise ValueError("guarded-delete needs --root <folder>")
2585
+ except ValueError as e:
2586
+ return _usage_error(str(e))
2587
+ print(json.dumps({"status": "ok", "command": "guarded-delete", **guarded_delete(roots, paths)}, indent=2))
2588
+ return 0
2589
+
2590
+
2591
+ # The verdict flags: at most one per call, each with --skill <name>.
2592
+ CHECK_FLAGS = ("--write-check", "--purge-check", "--rename-check")
2593
+ CHECK_KEYS = {"--write-check": "write_check", "--purge-check": "purge_check", "--rename-check": "rename_check"}
2594
+
2595
+
2596
+ def main(argv):
2597
+ if len(argv) < 1:
2598
+ print(USAGE, file=sys.stderr)
2599
+ return 1
2600
+ if argv[0] == "resolve":
2601
+ return _main_resolve(argv[1:])
2602
+ if argv[0] == "version":
2603
+ return _main_version(argv[1:])
2604
+ if argv[0] == "guarded-delete":
2605
+ return _main_guarded_delete(argv[1:])
2606
+ if argv[0] == "derive-name":
2607
+ return _main_derive_name(argv[1:])
2608
+ folder = argv[0]
2609
+ checks = [flag for flag in CHECK_FLAGS if flag in argv]
2610
+ check = checks[0] if checks else None
2611
+ try:
2612
+ if len(checks) > 1:
2613
+ raise ValueError("pass one of " + ", ".join(CHECK_FLAGS) + ", not several")
2614
+ skill = _flag_value(argv, "--skill", required=check is not None)
2615
+ match_target = _flag_value(argv, "--match-target")
2616
+ near = _flag_value(argv, "--near", required=True)
2617
+ forge_data_folder = _flag_value(argv, "--forge-data-folder", required=True)
2618
+ versions = {flag: _flag_value(argv, flag, required=True)
2619
+ for flag in ("--write-version", "--purge-version")}
2620
+ if check and folder.startswith("--"):
2621
+ raise ValueError(f"{check} needs <skills-output-folder> first")
2622
+ if check and not skill:
2623
+ raise ValueError(f"{check} needs --skill <name>")
2624
+ for flag, needs in (("--write-version", "--write-check"), ("--purge-version", "--purge-check")):
2625
+ if versions[flag] is not None and check != needs:
2626
+ raise ValueError(f"{flag} needs {needs}")
2627
+ if check in ("--purge-check", "--rename-check") and not forge_data_folder:
2628
+ raise ValueError(f"{check} needs --forge-data-folder <path>")
2629
+ if check and not _safe_segment(skill):
2630
+ raise ValueError(f"--skill must be one folder name: {skill!r}")
2631
+ for flag, value in versions.items():
2632
+ if value is not None and not _safe_segment(value):
2633
+ raise ValueError(f"{flag} must be one folder name: {value!r}")
2634
+ except ValueError as e:
2635
+ print(json.dumps({"status": "error", "error": str(e), "code": "USAGE"}, indent=2))
2636
+ print(USAGE, file=sys.stderr)
2637
+ return 1
2638
+ if check:
2639
+ skills_dir = Path(folder)
2640
+ if os.path.lexists(skills_dir) and not skills_dir.is_dir():
2641
+ result = {"status": "error", "error": f"Skills directory not found: {skills_dir}",
2642
+ "code": "DIR_NOT_FOUND"}
2643
+ else:
2644
+ forge_dir = Path(forge_data_folder) if forge_data_folder else None
2645
+ if check == "--write-check":
2646
+ verdict = write_check(skills_dir, skill, versions["--write-version"], forge_data_folder)
2647
+ elif check == "--purge-check":
2648
+ verdict = purge_check(skills_dir, skill, forge_data_folder, versions["--purge-version"])
2649
+ else:
2650
+ verdict = rename_check(skills_dir, skill, forge_data_folder)
2651
+ result = {"status": "ok", "skills_folder": str(skills_dir),
2652
+ "forge_data_folder": forge_data_folder,
2653
+ "same_folder": _same_folder(skills_dir, forge_dir),
2654
+ CHECK_KEYS[check]: verdict}
2655
+ else:
2656
+ result = scan_inventory(
2657
+ folder,
2658
+ skill_filter=skill,
2659
+ manifest_only="--manifest-only" in argv,
2660
+ match_target=match_target,
2661
+ forge_data_folder=forge_data_folder,
2662
+ near=near,
321
2663
  )
322
- sys.exit(1)
323
-
324
- folder = sys.argv[1]
325
- skill = None
326
- manifest_only = "--manifest-only" in sys.argv
327
- match_target = None
328
-
329
- if "--skill" in sys.argv:
330
- idx = sys.argv.index("--skill")
331
- if idx + 1 < len(sys.argv):
332
- skill = sys.argv[idx + 1]
333
-
334
- if "--match-target" in sys.argv:
335
- idx = sys.argv.index("--match-target")
336
- if idx + 1 < len(sys.argv):
337
- match_target = sys.argv[idx + 1]
338
-
339
- result = scan_inventory(
340
- folder,
341
- skill_filter=skill,
342
- manifest_only=manifest_only,
343
- match_target=match_target,
344
- )
345
2664
  print(json.dumps(result, indent=2))
346
- sys.exit(0 if result["status"] == "ok" else 1)
2665
+ return 0 if result["status"] == "ok" else 1
2666
+
2667
+
2668
+ if __name__ == "__main__":
2669
+ sys.exit(main(sys.argv[1:]))