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,8 +1,8 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
- """SKF Rebuild Managed Sections — Context file marker surgery.
5
+ """SKF Rebuild Managed Sections: context file marker surgery and section assembly.
6
6
 
7
7
  Reads a context file (CLAUDE.md/AGENTS.md/.cursorrules), finds the
8
8
  <!-- SKF:BEGIN --> / <!-- SKF:END --> markers, and replaces the managed
@@ -11,28 +11,126 @@ section with new content. Preserves all content outside the markers.
11
11
  CLI: python3 skf-rebuild-managed-sections.py <context-file> <action> [args]
12
12
 
13
13
  Actions:
14
- read — Extract current managed section content
15
- replace — Replace managed section with content from stdin or --content
16
- clear — Remove managed section entirely (markers + content)
17
- insert — Insert managed section if not present (at end of file)
18
- check — Check if managed section exists, report status
19
-
20
- Query actions (read-only, take positional file lists instead of <file> <action>):
14
+ read Extract current managed section content
15
+ replace Replace managed section with content from stdin or --content
16
+ clear Remove managed section entirely (markers + content)
17
+ insert Insert managed section if not present (at end of file,
18
+ creating the file when it is missing)
19
+ check Report the section and the write that fits the file as
20
+ `case`: create (no file), append (no section: insert),
21
+ regenerate (a section: replace), or, with status "error",
22
+ malformed or unreadable
23
+
24
+ replace and insert take only the body that goes between the markers: they
25
+ write `<!-- SKF:BEGIN updated:{date} -->` and `<!-- SKF:END -->` around it
26
+ themselves. They refuse (exit 1) content that holds a marker of its own, which
27
+ would nest a second pair inside the section. Without --content they read the
28
+ body from stdin as UTF-8 on every platform; callers stage it in a file and
29
+ redirect it (`replace < file`), because a body passed inline as
30
+ `--content "..."` goes through the shell, which expands the backticks and `$`
31
+ that snippets carry.
32
+
33
+ Malformed markers (the Case 4 halt): every `<!-- SKF:BEGIN` in the file,
34
+ matched by that prefix with or without an `updated:` date, must end with
35
+ `-->` on its own line and be closed by a `<!-- SKF:END -->` after it, with no
36
+ text between it and another BEGIN marker before that END. (BEGIN markers with
37
+ only blank space between them count as one: the second pair drop-skill and
38
+ rename-skill once wrote inside the section. Every rebuild after that left one
39
+ more END below the section, so replace and clear take in each END that
40
+ follows it with only blank space before it.) A file where one is not closed
41
+ is malformed: check reports it with status "error" and case "malformed", and
42
+ insert, replace and clear refuse it, all with exit 1 and the file unchanged.
43
+ An insert there would append a second section after the unclosed marker, and
44
+ the next replace would then take the user's lines between the two BEGIN
45
+ markers for SKF's own and delete them. A stray END is never malformed: in a
46
+ file with no section, check reports it as `markers_valid: false` with case
47
+ append.
48
+
49
+ Unreadable files: a context file that is a folder, that cannot be read, or
50
+ that is not UTF-8 text (a cp1252 CLAUDE.md saved on Windows) is an error
51
+ reported as JSON, with exit 1: check returns case "unreadable", read,
52
+ insert, replace and clear leave it unchanged, and orphan-detect and assemble
53
+ stop when a file they scan is one. root-probe skips such a snippet as it
54
+ skips a missing one.
55
+
56
+ Action-first actions (the action name comes first):
21
57
  orphan-detect <context-file>... --exported-skills a,b
22
- — Parse `[skill-name v...]` rows across one or more context
23
- files, drop rows whose skill_name is in the exported set,
24
- and report the survivors (deduped by (skill_name, version),
25
- with verbatim snippet_text and sorted source_files provenance)
26
- as `orphan_managed_rows`.
58
+ Parse `[skill-name v...]` rows across one or more context
59
+ files, drop rows whose skill_name is in the exported set,
60
+ and report the survivors (deduped by (skill_name, version),
61
+ with verbatim snippet_text and sorted source_files provenance)
62
+ as `orphan_managed_rows`. A file with malformed markers is
63
+ skipped and listed in `malformed_files`; an unreadable one is
64
+ an error.
27
65
  root-probe <snippet-file>... --reference-root .claude/skills/
28
- — Read each snippet's first line, parse its `root:` prefix
29
- (trailing `{skill-name}/` stripped), collect the unique
30
- `observed_prefixes`, and flag `mismatch` against the reference.
66
+ [--project-root DIR]
67
+ Read each snippet's first line, parse its `root:` prefix
68
+ (trailing `{skill-name}/` stripped), collect the unique
69
+ `observed_prefixes`, and flag `mismatch` against the reference,
70
+ with the skills that differ in `mismatched_skills`. A draft
71
+ root (`skills/{skill-name}/`, which every workflow that builds
72
+ a skill writes before an export chooses the real one) counts
73
+ as no evidence: its skill is listed in `draft_roots`. With
74
+ --project-root, `disk_root` names the root under DIR that
75
+ holds every mismatched skill's SKILL.md (the reference first,
76
+ then the one observed prefix), or is null.
77
+ assemble <context-file> --skills-folder DIR --skill-root PREFIX
78
+ [--skill-root-override PREFIX] [--include NAME@VERSION]...
79
+ [--snippet NAME=FILE]... [--snippet-dir DIR] [--renamed OLD:NEW]
80
+ [--dropped NAME]... [--orphan-sources FILE...] [--orphans keep|drop]
81
+ [--out FILE]
82
+ Build the body that replace and insert take for one context
83
+ file and write it to `<context-file>.skf-content`, or to
84
+ --out FILE (creating its folder), with no trailing newline, so
85
+ no caller types a snippet. A dry run, or a preview shown
86
+ before the user confirms, passes --out with a path outside
87
+ the project, so nothing lands beside the context file; --out
88
+ never names a context file the call reads. The skills are
89
+ every entry of DIR/.export-manifest.json (read through
90
+ skf-manifest-ops.py) whose active_version has an entry under
91
+ `versions` that is not deprecated, plus each --include at its
92
+ version (which wins over the manifest). Each one's
93
+ context-snippet.md comes from its versioned package, else its
94
+ `active` link, else the flat layout (--snippet names the file
95
+ to use instead, and --snippet-dir a folder of staged drafts,
96
+ read as DIR/<name>/context-snippet.md for each skill that has
97
+ one there). Its `root:` becomes the effective prefix plus
98
+ the skill name and `/`: --skill-root-override when given, else
99
+ --skill-root. A row found in the managed section of
100
+ <context-file> or of an --orphan-sources file is an orphan
101
+ when the manifest has no entry for its skill and neither
102
+ --include, --renamed OLD nor --dropped names it. Orphans are
103
+ kept verbatim (the default) or left out with --orphans drop;
104
+ pass every target context file, in the same order, to each
105
+ call, so every file keeps the same rows. Rows are sorted by
106
+ name, and the header counts single and stack skills from each
107
+ package's metadata.json `skill_type` (`individual` counts as
108
+ single; an orphan, or a package without one, counts as a stack
109
+ when its row has a `|stack:` line). Returns content_file,
110
+ n_single, n_stack, included[], orphan_rows[],
111
+ skipped_deprecated[], skipped_integrity[],
112
+ skipped_missing_snippet[], skipped_malformed_snippet[],
113
+ malformed_context_files[] and warnings[]. A manifest it cannot
114
+ read, or a context file it scans that is unreadable, is an
115
+ error, and nothing is written.
116
+ resolve-targets [--ides a,b] [--context-file NAME]
117
+ Map config.yaml `ides` to the context files to write, from
118
+ shared/data/ide-context-files.json: `targets` holds one
119
+ {context_file, skill_root, ides} per file, in the order of the
120
+ first IDE that maps to it, whose skill_root wins. An IDE the
121
+ mapping does not list resolves to AGENTS.md with
122
+ `.agents/skills/` and a warning; an empty list resolves to
123
+ AGENTS.md too, with a note.
124
+ --context-file keeps only that file (CLAUDE.md, .cursorrules
125
+ or AGENTS.md). `ides_written` is the union of targets[].ides
126
+ over the files written; `other_context_files` lists the known
127
+ context files no target names.
31
128
  """
32
129
 
33
130
  from __future__ import annotations
34
131
 
35
132
  import argparse
133
+ import importlib.util
36
134
  import json
37
135
  import os
38
136
  import re
@@ -46,6 +144,17 @@ MARKER_PATTERN = re.compile(
46
144
  r"(<!-- SKF:BEGIN[^>]*-->)(.*?)(<!-- SKF:END -->)",
47
145
  re.DOTALL,
48
146
  )
147
+ # Every BEGIN marker (by prefix) and every END marker, in file order, and a
148
+ # BEGIN marker that ends with `-->` on its own line, as this helper writes it.
149
+ ANY_MARKER_PATTERN = re.compile(re.escape(BEGIN_MARKER_PREFIX) + "|" + re.escape(END_MARKER))
150
+ WHOLE_BEGIN_PATTERN = re.compile(re.escape(BEGIN_MARKER_PREFIX) + r"[^>\n]*-->")
151
+ # An END marker with only blank space before it. Right after the section's
152
+ # END, it is one the nested pair drop-skill and rename-skill wrote left behind.
153
+ TRAILING_END_PATTERN = re.compile(r"\s*" + re.escape(END_MARKER))
154
+ # A marker of its own inside the body replace/insert are given, in any spacing
155
+ # (`<!-- SKF:BEGIN updated:… -->`, `<!--SKF:END-->`). A prose mention of the
156
+ # marker names without `<!--` is not one.
157
+ MARKER_IN_BODY_PATTERN = re.compile(r"<!--\s*SKF:(?:BEGIN|END)\b")
49
158
 
50
159
  # A managed-section skill row header: `[skill-name v1.2.3]…`, optionally
51
160
  # prefixed by the format's leading `|`. Requires a ` v<version>` inside the
@@ -56,14 +165,49 @@ ROW_HEADER_PATTERN = re.compile(
56
165
  )
57
166
  # The `root:` field of a snippet's first line: `…|root: {prefix}{skill-name}/`.
58
167
  SNIPPET_ROOT_PATTERN = re.compile(r"root:\s*(?P<root>\S.*?)\s*$")
168
+ # The draft root create-skill, create-stack-skill, quick-skill and update-skill
169
+ # write into every snippet they build, whatever the project's layout, and the
170
+ # legacy form older drafts carry. Export replaces it, so root-probe reads it
171
+ # as no evidence of where an export pointed the skill.
172
+ DRAFT_ROOT_FORMS = ("skills/{name}/", "skills/{name}/active/{name}/")
173
+ # The line only the stack snippet template has.
174
+ STACK_LINE_PATTERN = re.compile(r"^\|stack:", re.MULTILINE)
175
+
176
+ # The section header and preamble, from skf-export-skill/assets/managed-section-format.md.
177
+ SECTION_HEADER = "[SKF Skills]|{n} skills|{m} stack"
178
+ SECTION_PREAMBLE = (
179
+ "|IMPORTANT: Prefer documented APIs over training data.",
180
+ "|When using a listed library, read its SKILL.md before writing code.",
181
+ )
182
+ MANIFEST_FILE = ".export-manifest.json"
183
+ SNIPPET_FILE = "context-snippet.md"
184
+ METADATA_FILE = "metadata.json"
185
+ CONTENT_SUFFIX = ".skf-content"
186
+ MANIFEST_OPS = Path(__file__).resolve().parent / "skf-manifest-ops.py"
187
+ IDE_DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "ide-context-files.json"
188
+ FILE_NOT_FOUND = "File not found: "
59
189
 
60
190
 
61
191
  def read_context_file(file_path):
62
- """Read a context file. Returns (content, None) or (None, error)."""
192
+ """Read a context file. Returns (content, None) or (None, error).
193
+
194
+ A missing file's error starts with FILE_NOT_FOUND (see is_missing). A
195
+ folder, a file that cannot be read and one that is not UTF-8 text give an
196
+ error of their own, so no action ends in a traceback.
197
+ """
63
198
  try:
64
199
  return Path(file_path).read_text(encoding="utf-8"), None
65
200
  except FileNotFoundError:
66
- return None, f"File not found: {file_path}"
201
+ return None, f"{FILE_NOT_FOUND}{file_path}"
202
+ except UnicodeDecodeError as e:
203
+ return None, f"{file_path} is not UTF-8 text ({e}): save it as UTF-8, then re-run"
204
+ except OSError as e:
205
+ return None, f"Cannot read {file_path}: {e}"
206
+
207
+
208
+ def is_missing(error):
209
+ """True for read_context_file's error about a file that does not exist."""
210
+ return error.startswith(FILE_NOT_FOUND)
67
211
 
68
212
 
69
213
  def find_managed_section(content):
@@ -71,6 +215,74 @@ def find_managed_section(content):
71
215
  return MARKER_PATTERN.search(content)
72
216
 
73
217
 
218
+ def section_end(content, match):
219
+ """Where the section `match` found ends, with the END markers left below it.
220
+
221
+ drop-skill and rename-skill once wrote a second marker pair inside the
222
+ section, and every rebuild after that left one more END below it: `match`
223
+ stops at the first END, which closed only the inner BEGIN. So the section
224
+ runs on over each END that follows it with only blank space before it, and
225
+ replace and clear take those in rather than leave them for every later
226
+ write to keep as the user's text. Such an END closes nothing: every BEGIN
227
+ before it is closed already.
228
+ """
229
+ end = match.end()
230
+ while True:
231
+ closing = TRAILING_END_PATTERN.match(content, end)
232
+ if closing is None:
233
+ return end
234
+ end = closing.end()
235
+
236
+
237
+ def find_unclosed_begin(content):
238
+ """The first BEGIN marker no END marker closes, as (line, detail), else None.
239
+
240
+ Any `<!-- SKF:BEGIN` counts, by prefix. It must end with `-->` on its own
241
+ line and be closed by a `<!-- SKF:END -->` after it. Text between it and a
242
+ later BEGIN marker, with no END between them, leaves it unclosed: a
243
+ section swap would take that text for SKF's own. BEGIN markers with only
244
+ blank space between them count as one (the second pair drop-skill and
245
+ rename-skill once wrote inside the section). A stray END with no BEGIN
246
+ before it opens nothing, so an insert after it cannot pull the user's text
247
+ into a section.
248
+ """
249
+
250
+ def line_of(offset):
251
+ return content.count("\n", 0, offset) + 1
252
+
253
+ open_at = open_end = None
254
+ for m in ANY_MARKER_PATTERN.finditer(content):
255
+ if m.group(0) == END_MARKER:
256
+ open_at = None
257
+ continue
258
+ whole = WHOLE_BEGIN_PATTERN.match(content, m.start())
259
+ if not whole:
260
+ return line_of(m.start()), f"<!-- SKF:BEGIN on line {line_of(m.start())} does not end with --> on that line"
261
+ if open_at is not None and content[open_end : m.start()].strip():
262
+ return line_of(open_at), (
263
+ f"<!-- SKF:BEGIN on line {line_of(open_at)} has no matching <!-- SKF:END --> "
264
+ f"before the next <!-- SKF:BEGIN on line {line_of(m.start())}"
265
+ )
266
+ if open_at is None:
267
+ open_at = m.start()
268
+ open_end = whole.end()
269
+ if open_at is None:
270
+ return None
271
+ return line_of(open_at), f"<!-- SKF:BEGIN on line {line_of(open_at)} has no matching <!-- SKF:END --> after it"
272
+
273
+
274
+ def _malformed(file_path, problem, *, write):
275
+ """The error result for a file `find_unclosed_begin` reported."""
276
+ line, detail = problem
277
+ error = (
278
+ f"Malformed SKF markers in {file_path}: {detail}. Fix the markers by hand (restore the "
279
+ "<!-- SKF:END --> line, or remove the stray <!-- SKF:BEGIN), then re-run."
280
+ )
281
+ if write:
282
+ error += " The file was not changed."
283
+ return {"status": "error", "case": "malformed", "begin_line": line, "error": error}
284
+
285
+
74
286
  def _atomic_write(file_path, text):
75
287
  """Stage `text` into <file>.skf-tmp, fsync, then os.replace into place.
76
288
 
@@ -130,40 +342,48 @@ def _write_and_verify(file_path, updated, *, expect_section):
130
342
 
131
343
 
132
344
  def cmd_check(file_path):
133
- """Check if managed section exists."""
345
+ """Report the managed section and the write that fits the file (`case`)."""
134
346
  content, err = read_context_file(file_path)
347
+ if err and is_missing(err):
348
+ return {"status": "ok", "exists": False, "case": "create", "has_managed_section": False, "markers_valid": True}
135
349
  if err:
136
- return {"status": "error", "error": err}
350
+ return {
351
+ "status": "error",
352
+ "case": "unreadable",
353
+ "error": err,
354
+ "exists": True,
355
+ "has_managed_section": False,
356
+ "markers_valid": False,
357
+ }
137
358
 
138
- has_begin = BEGIN_MARKER_PREFIX in content
139
- has_end = END_MARKER in content
140
- match = find_managed_section(content)
359
+ problem = find_unclosed_begin(content)
360
+ if problem:
361
+ result = _malformed(file_path, problem, write=False)
362
+ result.update(exists=True, has_managed_section=False, markers_valid=False, error_detail=problem[1])
363
+ return result
141
364
 
365
+ match = find_managed_section(content)
142
366
  if match:
143
367
  section_content = match.group(2)
144
368
  return {
145
369
  "status": "ok",
370
+ "exists": True,
371
+ "case": "regenerate",
146
372
  "has_managed_section": True,
147
373
  "markers_valid": True,
148
374
  "section_length": len(section_content.strip()),
149
375
  "section_line_count": len(section_content.strip().split("\n")) if section_content.strip() else 0,
150
376
  }
151
- elif has_begin and not has_end:
152
- return {
153
- "status": "ok",
154
- "has_managed_section": False,
155
- "markers_valid": False,
156
- "error_detail": "Found <!-- SKF:BEGIN but no matching <!-- SKF:END -->",
157
- }
158
- elif not has_begin and has_end:
377
+ if END_MARKER in content:
159
378
  return {
160
379
  "status": "ok",
380
+ "exists": True,
381
+ "case": "append",
161
382
  "has_managed_section": False,
162
383
  "markers_valid": False,
163
384
  "error_detail": "Found <!-- SKF:END --> but no matching <!-- SKF:BEGIN",
164
385
  }
165
- else:
166
- return {"status": "ok", "has_managed_section": False, "markers_valid": True}
386
+ return {"status": "ok", "exists": True, "case": "append", "has_managed_section": False, "markers_valid": True}
167
387
 
168
388
 
169
389
  def cmd_read(file_path):
@@ -179,12 +399,35 @@ def cmd_read(file_path):
179
399
  return {"status": "ok", "has_managed_section": True, "content": match.group(2)}
180
400
 
181
401
 
402
+ def _marker_in_body_error(new_content):
403
+ """An error message when `new_content` holds an SKF marker, else None.
404
+
405
+ replace and insert write both markers around the body themselves, so a body
406
+ that carries `<!-- SKF:BEGIN …` or `<!-- SKF:END -->` (the whole managed
407
+ section instead of what goes between its markers) would leave a second
408
+ marker pair nested inside the section.
409
+ """
410
+ if MARKER_IN_BODY_PATTERN.search(new_content) is None:
411
+ return None
412
+ return (
413
+ "content holds an SKF marker (<!-- SKF:BEGIN or <!-- SKF:END): pass only "
414
+ "the body that goes between the markers; this helper writes both markers "
415
+ "and the updated: date itself. The file was not changed."
416
+ )
417
+
418
+
182
419
  def cmd_replace(file_path, new_content):
183
420
  """Replace managed section content between markers."""
421
+ marker_err = _marker_in_body_error(new_content)
422
+ if marker_err:
423
+ return {"status": "error", "error": marker_err}
184
424
  content, err = read_context_file(file_path)
185
425
  if err:
186
426
  return {"status": "error", "error": err}
187
427
 
428
+ problem = find_unclosed_begin(content)
429
+ if problem:
430
+ return _malformed(file_path, problem, write=True)
188
431
  match = find_managed_section(content)
189
432
  if not match:
190
433
  return {"status": "error", "error": "No managed section found. Use 'insert' to create one."}
@@ -192,7 +435,7 @@ def cmd_replace(file_path, new_content):
192
435
  # Replace content between markers with fresh timestamp
193
436
  today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
194
437
  new_section = f"<!-- SKF:BEGIN updated:{today} -->\n{new_content}\n{END_MARKER}"
195
- updated = content[: match.start()] + new_section + content[match.end() :]
438
+ updated = content[: match.start()] + new_section + content[section_end(content, match) :]
196
439
 
197
440
  verify_err = _write_and_verify(file_path, updated, expect_section=True)
198
441
  if verify_err:
@@ -206,13 +449,16 @@ def cmd_clear(file_path):
206
449
  if err:
207
450
  return {"status": "error", "error": err}
208
451
 
452
+ problem = find_unclosed_begin(content)
453
+ if problem:
454
+ return _malformed(file_path, problem, write=True)
209
455
  match = find_managed_section(content)
210
456
  if not match:
211
457
  return {"status": "ok", "action": "no_change", "reason": "No managed section found"}
212
458
 
213
459
  # Remove the entire marker block, plus any surrounding blank lines
214
460
  before = content[: match.start()].rstrip("\n")
215
- after = content[match.end() :].lstrip("\n")
461
+ after = content[section_end(content, match) :].lstrip("\n")
216
462
  updated = before + ("\n\n" if before and after else "") + after
217
463
 
218
464
  verify_err = _write_and_verify(file_path, updated, expect_section=False)
@@ -223,14 +469,20 @@ def cmd_clear(file_path):
223
469
 
224
470
  def cmd_insert(file_path, new_content):
225
471
  """Insert managed section at end of file if not present."""
472
+ marker_err = _marker_in_body_error(new_content)
473
+ if marker_err:
474
+ return {"status": "error", "error": marker_err}
226
475
  content, err = read_context_file(file_path)
227
476
  if err:
228
- # File doesn't exist — create it
229
- if "not found" in err.lower():
477
+ # A missing file is created; one that cannot be read is left alone.
478
+ if is_missing(err):
230
479
  content = ""
231
480
  else:
232
481
  return {"status": "error", "error": err}
233
482
 
483
+ problem = find_unclosed_begin(content)
484
+ if problem:
485
+ return _malformed(file_path, problem, write=True)
234
486
  match = find_managed_section(content)
235
487
  if match:
236
488
  return {"status": "error", "error": "Managed section already exists. Use 'replace' instead."}
@@ -282,27 +534,35 @@ def parse_managed_rows(body):
282
534
  return rows
283
535
 
284
536
 
285
- def cmd_orphan_detect(context_files, exported_skills):
286
- """Detect managed-section rows absent from the exported skill set.
537
+ def _scan_rows(context_files, known_names):
538
+ """Rows of skills outside `known_names` across `context_files`, the malformed files and the unreadable ones.
287
539
 
288
- Scans every context file that exists and has a managed section, parses its
289
- skill rows, drops those whose skill_name is in `exported_skills`, and
290
- accumulates the survivors keyed by (skill_name, version). The first-seen
291
- snippet_text wins; each file the row appears in is added to source_files.
540
+ Reads each file that exists and has a managed section. A file whose
541
+ markers are malformed is skipped and returned in the second list: its
542
+ section cannot be told apart from the user's own text. A file that cannot
543
+ be read, or is not UTF-8 text, is skipped and its error returned in the
544
+ third list: its rows are unknown, so a caller stops rather than drop them.
545
+ Rows are keyed by (skill_name, version): the first-seen snippet_text wins,
546
+ and each file the row appears in is added to its source_files.
292
547
  Deterministic: rows sorted by (skill_name, version), source_files sorted.
293
548
  """
294
- exported = {s.strip() for s in exported_skills.split(",") if s.strip()}
295
549
  orphans = {} # (name, version) -> {skill_name, version, snippet_text, source_files:set}
550
+ malformed, unreadable = [], []
296
551
  for cf in context_files:
297
552
  content, err = read_context_file(cf)
298
553
  if err:
299
- continue # missing file — no orphans by definition
554
+ if not is_missing(err):
555
+ unreadable.append(err)
556
+ continue # a missing file has no rows
557
+ if find_unclosed_begin(content):
558
+ malformed.append(cf)
559
+ continue
300
560
  match = find_managed_section(content)
301
561
  if not match:
302
- continue # no managed section — no orphans
562
+ continue # no managed section, no rows
303
563
  for row in parse_managed_rows(match.group(2)):
304
- if row["skill_name"] in exported:
305
- continue # not an orphan — it is being (re)exported
564
+ if row["skill_name"] in known_names:
565
+ continue
306
566
  key = (row["skill_name"], row["version"])
307
567
  if key in orphans:
308
568
  orphans[key]["source_files"].add(cf) # keep first-seen snippet_text
@@ -322,55 +582,553 @@ def cmd_orphan_detect(context_files, exported_skills):
322
582
  }
323
583
  for _, v in sorted(orphans.items())
324
584
  ]
325
- return {"status": "ok", "orphan_managed_rows": orphan_rows}
585
+ return orphan_rows, malformed, unreadable
326
586
 
327
587
 
328
- def cmd_root_probe(snippet_files, reference_root):
588
+ def cmd_orphan_detect(context_files, exported_skills):
589
+ """Detect managed-section rows absent from the exported skill set.
590
+
591
+ Scans every context file that exists and has a managed section, parses its
592
+ skill rows, drops those whose skill_name is in `exported_skills`, and
593
+ accumulates the survivors keyed by (skill_name, version). The first-seen
594
+ snippet_text wins; each file the row appears in is added to source_files.
595
+ Deterministic: rows sorted by (skill_name, version), source_files sorted.
596
+ A file with malformed markers is skipped and listed in malformed_files. A
597
+ file that cannot be read, or is not UTF-8 text, is an error.
598
+ """
599
+ exported = {s.strip() for s in exported_skills.split(",") if s.strip()}
600
+ orphan_rows, malformed, unreadable = _scan_rows(context_files, exported)
601
+ if unreadable:
602
+ return {"status": "error", "error": "; ".join(unreadable)}
603
+ return {"status": "ok", "orphan_managed_rows": orphan_rows, "malformed_files": malformed}
604
+
605
+
606
+ def _holds_skill(project_root, prefix, name):
607
+ """True when the folder `prefix` names under `project_root` holds `name`'s
608
+ SKILL.md: flat, as `npx skills add` installs it, or through the `active`
609
+ link of SKF's versioned layout."""
610
+ base = Path(project_root, prefix, name)
611
+ return (base / "SKILL.md").is_file() or (base / "active" / name / "SKILL.md").is_file()
612
+
613
+
614
+ def cmd_root_probe(snippet_files, reference_root, project_root=None):
329
615
  """Observe snippet root prefixes and flag mismatch against a reference.
330
616
 
331
617
  Reads the first line of each snippet that exists, parses its `root:` value,
332
618
  strips the trailing `{skill-name}/` to recover the prefix, and collects the
333
- unique prefixes. `mismatch` is True when any observed prefix differs from
334
- `reference_root`. Deterministic: observed_prefixes sorted.
619
+ unique prefixes. A root in the draft form (DRAFT_ROOT_FORMS) is no
620
+ evidence of where an export pointed the skill: the snippet is left out and
621
+ its skill listed in `draft_roots`. `mismatch` is True when any observed
622
+ prefix differs from `reference_root`, and `mismatched_skills` names the
623
+ skills whose prefix does. Deterministic: every list sorted.
624
+
625
+ With `project_root`, `disk_root` says which folder on disk holds the
626
+ mismatched skills: `reference_root` when it holds every one, else the one
627
+ observed prefix when only one was observed and it holds every one, else
628
+ None (also when nothing mismatched).
335
629
  """
336
630
  observed = set()
631
+ drafts = set()
632
+ mismatched = [] # (skill name or None, prefix)
337
633
  for sf in snippet_files:
338
634
  content, err = read_context_file(sf)
339
635
  if err:
340
- continue # missing snippet — skip
636
+ continue # a missing or unreadable snippet: skip
341
637
  first_line = content.split("\n", 1)[0]
342
638
  header = ROW_HEADER_PATTERN.match(first_line)
343
639
  root_match = SNIPPET_ROOT_PATTERN.search(first_line)
344
640
  if not root_match:
345
- continue # no root: field — skip
641
+ continue # no root: field, skip
346
642
  root_value = root_match.group("root")
643
+ name = header.group("name") if header else None
644
+ if name and root_value in {form.format(name=name) for form in DRAFT_ROOT_FORMS}:
645
+ drafts.add(name)
646
+ continue
347
647
  prefix = root_value
348
- if header:
349
- suffix = header.group("name") + "/"
350
- if root_value.endswith(suffix):
351
- prefix = root_value[: -len(suffix)]
648
+ if name and root_value.endswith(name + "/"):
649
+ prefix = root_value[: -len(name + "/")]
352
650
  observed.add(prefix)
651
+ if prefix != reference_root:
652
+ mismatched.append((name, prefix))
353
653
  observed_prefixes = sorted(observed)
354
- return {
654
+ result = {
355
655
  "status": "ok",
356
656
  "reference_root": reference_root,
357
657
  "observed_prefixes": observed_prefixes,
358
- "mismatch": any(p != reference_root for p in observed_prefixes),
658
+ "mismatch": bool(mismatched),
659
+ "mismatched_skills": sorted({name for name, _ in mismatched if name}),
660
+ "draft_roots": sorted(drafts),
359
661
  }
662
+ if project_root is not None:
663
+ def held_by(prefix):
664
+ return all(name and _holds_skill(project_root, prefix, name) for name, _ in mismatched)
665
+
666
+ disk_root = None
667
+ if mismatched and held_by(reference_root):
668
+ disk_root = reference_root
669
+ elif mismatched and len(observed_prefixes) == 1 and held_by(observed_prefixes[0]):
670
+ disk_root = observed_prefixes[0]
671
+ result["disk_root"] = disk_root
672
+ return result
673
+
674
+
675
+ # ---------------------------------------------------------------------------
676
+ # assemble: the managed-section body, built from the manifest and the snippets
677
+ # ---------------------------------------------------------------------------
678
+
679
+
680
+ def is_plain_name(value):
681
+ """True for a skill name or version that is one folder name and nothing else."""
682
+ return bool(value) and value not in (".", "..") and not any(c in value for c in "/\\:\0")
683
+
360
684
 
685
+ def _as_prefix(value):
686
+ """A skill root prefix, with the trailing `/` the `root:` field needs."""
687
+ value = (value or "").strip()
688
+ return value if not value or value.endswith("/") else value + "/"
361
689
 
362
- def _run_query_action(argv):
363
- """Dispatch the read-only query actions (orphan-detect / root-probe).
364
690
 
365
- These take a positional file list rather than the legacy `<file> <action>`
366
- shape, so they are parsed with argparse and routed here before the legacy
367
- path. Returns (result_dict, exit_code).
691
+ def _read_manifest(skills_folder):
692
+ """(exports, None) from the export manifest, or (None, error).
693
+
694
+ Reads through skf-manifest-ops.py, which installs beside this script, so
695
+ a v1 manifest or a legacy `platforms` field reads the same here as in
696
+ every other manifest step. A missing manifest has no exports.
368
697
  """
369
- action = argv[0]
698
+ try:
699
+ spec = importlib.util.spec_from_file_location("skf_manifest_ops", MANIFEST_OPS)
700
+ ops = importlib.util.module_from_spec(spec)
701
+ spec.loader.exec_module(ops)
702
+ except (ImportError, OSError, AttributeError) as e:
703
+ return None, f"cannot load {MANIFEST_OPS.name} beside this script ({e}): re-install SKF"
704
+ manifest_path = Path(skills_folder) / MANIFEST_FILE
705
+ try:
706
+ data, err = ops.read_manifest(manifest_path)
707
+ except (OSError, ValueError, AttributeError, TypeError) as e:
708
+ return None, f"cannot read {manifest_path}: {e}"
709
+ if err:
710
+ return None, f"cannot read {manifest_path}: {err}"
711
+ exports = data.get("exports", {})
712
+ if not isinstance(exports, dict):
713
+ return None, f"cannot read {manifest_path}: `exports` is not an object"
714
+ return exports, None
715
+
716
+
717
+ def _active_version(name, entry):
718
+ """(active_version, None) for a manifest entry the section can use, else (active_version, reason)."""
719
+ if not isinstance(entry, dict):
720
+ return None, "the manifest entry is not an object"
721
+ active = entry.get("active_version")
722
+ if not isinstance(active, str) or not active:
723
+ return None, "the manifest entry has no active_version"
724
+ versions = entry.get("versions")
725
+ if not isinstance(versions, dict) or not isinstance(versions.get(active), dict):
726
+ return active, f"active_version {active} has no entry under versions"
727
+ if not is_plain_name(name) or not is_plain_name(active):
728
+ return active, "the skill name or its active_version is not a plain folder name"
729
+ return active, None
730
+
731
+
732
+ def _find_snippet(skills_folder, name, version, given):
733
+ """(snippet path, source, package folder, paths tried) for one skill.
734
+
735
+ The package is the versioned one, else the `active` link's, else the flat
736
+ layout's: the first that holds a context-snippet.md. A `given` snippet
737
+ replaces that search, and its package is the first that holds a
738
+ metadata.json. The path is None when no snippet was found.
739
+ """
740
+ group = Path(skills_folder) / name
741
+ packages = [("versioned", group / version / name), ("active", group / "active" / name), ("flat", group)]
742
+ if given is not None:
743
+ if not Path(given).is_file():
744
+ return None, None, None, [str(given)]
745
+ package = next((p for _, p in packages if (p / METADATA_FILE).is_file()), None)
746
+ return Path(given), "given", package, [str(given)]
747
+ tried = []
748
+ for source, package in packages:
749
+ snippet = package / SNIPPET_FILE
750
+ tried.append(str(snippet))
751
+ if snippet.is_file():
752
+ return snippet, source, package, tried
753
+ return None, None, None, tried
754
+
755
+
756
+ def _row_shape(row_text):
757
+ """`stack` for a row with a `|stack:` line (the stack snippet template), else `single`."""
758
+ return "stack" if STACK_LINE_PATTERN.search(row_text) else "single"
759
+
760
+
761
+ def _skill_type(package, row_text):
762
+ """(skill_type, warning or None): metadata.json's skill_type, else the row's shape.
763
+
764
+ `individual` is a single skill, as skf-skill-inventory.py reads it too.
765
+ """
766
+ shape = _row_shape(row_text)
767
+ if package is None:
768
+ return shape, f"no {METADATA_FILE} found, counted as {shape} from its snippet"
769
+ try:
770
+ meta = json.loads((package / METADATA_FILE).read_text(encoding="utf-8"))
771
+ except (OSError, ValueError) as e:
772
+ return shape, f"cannot read {package / METADATA_FILE} ({e}), counted as {shape} from its snippet"
773
+ skill_type = meta.get("skill_type") if isinstance(meta, dict) else None
774
+ if skill_type in ("single", "individual"):
775
+ return "single", None
776
+ if skill_type == "stack":
777
+ return "stack", None
778
+ return shape, (
779
+ f"{package / METADATA_FILE} has no skill_type single, individual or stack, "
780
+ f"counted as {shape} from its snippet"
781
+ )
782
+
783
+
784
+ def _snippet_row(text, name, prefix):
785
+ """The row a snippet adds to the section, or (None, reason).
786
+
787
+ Returns ({text, version, root_rewritten, has_root}, None). The row is the
788
+ snippet without its blank edge lines, with `|` before its header line (the
789
+ format puts one there; a snippet that already has it keeps one) and its
790
+ `root:` value set to `prefix` + name + `/`. A snippet whose first line is
791
+ not the `[name vX]` row header of this skill, or that holds an SKF marker,
792
+ is refused: the section could not be parsed back into the same rows.
793
+ """
794
+ lines = text.split("\n")
795
+ while lines and not lines[0].strip():
796
+ lines.pop(0)
797
+ while lines and not lines[-1].strip():
798
+ lines.pop()
799
+ if not lines:
800
+ return None, "the snippet is empty"
801
+ if MARKER_IN_BODY_PATTERN.search(text):
802
+ return None, "the snippet holds an SKF marker (<!-- SKF:BEGIN or <!-- SKF:END)"
803
+ first = lines[0][1:] if lines[0].startswith("|") else lines[0]
804
+ header = ROW_HEADER_PATTERN.match(first)
805
+ if not first.startswith("[") or not header:
806
+ return None, "its first line is not a [skill-name vX.Y.Z] row header"
807
+ if header.group("name") != name:
808
+ return None, f"its header names {header.group('name')}, not {name}"
809
+ root = SNIPPET_ROOT_PATTERN.search(first)
810
+ rewritten = False
811
+ if root:
812
+ new_root = prefix + name + "/"
813
+ rewritten = root.group("root") != new_root
814
+ first = first[: root.start("root")] + new_root + first[root.end("root") :]
815
+ lines[0] = "|" + first
816
+ row = {"text": "\n".join(lines), "version": header.group("version"), "root_rewritten": rewritten, "has_root": root is not None}
817
+ return row, None
818
+
819
+
820
+ def cmd_assemble(
821
+ context_file,
822
+ skills_folder,
823
+ skill_root,
824
+ *,
825
+ override=None,
826
+ includes=(),
827
+ snippets=None,
828
+ snippet_dir=None,
829
+ renamed=None,
830
+ dropped=(),
831
+ orphan_sources=(),
832
+ orphans="keep",
833
+ out=None,
834
+ ):
835
+ """Build the managed-section body for `context_file` and stage it for replace/insert.
836
+
837
+ `includes` is a list of (name, version), `snippets` maps a skill name to
838
+ the snippet file to use, `snippet_dir` is a folder of staged drafts (a
839
+ `snippets` entry wins over it), `renamed` is (old, new) and `dropped` a
840
+ list of names. The body goes to `out`, else to
841
+ <context_file>.skf-content. See the module docstring for the rules.
842
+ """
843
+ snippets = snippets or {}
844
+ prefix = _as_prefix(override) or _as_prefix(skill_root)
845
+ if not prefix:
846
+ return {"status": "error", "error": "the skill root is empty: pass --skill-root (or --skill-root-override)"}
847
+ content_file = out or context_file + CONTENT_SUFFIX
848
+ if out:
849
+ read_here = {os.path.normcase(os.path.realpath(cf)) for cf in [*orphan_sources, context_file]}
850
+ if os.path.normcase(os.path.realpath(out)) in read_here:
851
+ return {
852
+ "status": "error",
853
+ "error": f"--out {out} is a context file this call reads: pass a path of its own. Nothing was written.",
854
+ }
855
+ if not Path(skills_folder).is_dir():
856
+ return {"status": "error", "error": f"the skills folder {skills_folder} does not exist. Nothing was written."}
857
+ exports, err = _read_manifest(skills_folder)
858
+ if err:
859
+ return {"status": "error", "error": f"{err}. Nothing was written."}
860
+ include_versions = dict(includes)
861
+
862
+ wanted = {} # name -> active_version
863
+ skipped_deprecated, skipped_integrity = [], []
864
+ for name in sorted(exports):
865
+ if name in include_versions:
866
+ continue # --include sets this skill's version
867
+ active, reason = _active_version(name, exports[name])
868
+ if reason:
869
+ skipped_integrity.append({"skill_name": name, "active_version": active, "reason": reason})
870
+ elif exports[name]["versions"][active].get("status") == "deprecated":
871
+ skipped_deprecated.append({"skill_name": name, "version": active})
872
+ else:
873
+ wanted[name] = active
874
+ wanted.update(include_versions)
875
+
876
+ entries = [] # (skill_name, version, row text, skill_type)
877
+ included, skipped_missing, skipped_malformed, warnings = [], [], [], []
878
+ for name in sorted(set(snippets) - set(wanted)):
879
+ warnings.append(f"--snippet {name}: not one of the section's skills, so its file was not read")
880
+ for name in sorted(wanted):
881
+ version = wanted[name]
882
+ given = snippets.get(name)
883
+ if given is None and snippet_dir and (Path(snippet_dir) / name / SNIPPET_FILE).is_file():
884
+ given = str(Path(snippet_dir) / name / SNIPPET_FILE)
885
+ path, source, package, tried = _find_snippet(skills_folder, name, version, given)
886
+ if path is None:
887
+ skipped_missing.append({"skill_name": name, "version": version, "tried": tried})
888
+ continue
889
+ try:
890
+ text = path.read_text(encoding="utf-8")
891
+ except (OSError, ValueError) as e:
892
+ skipped_malformed.append({"skill_name": name, "version": version, "snippet_path": str(path), "reason": f"cannot read it: {e}"})
893
+ continue
894
+ row, reason = _snippet_row(text, name, prefix)
895
+ if reason:
896
+ skipped_malformed.append({"skill_name": name, "version": version, "snippet_path": str(path), "reason": reason})
897
+ continue
898
+ if not row["has_root"]:
899
+ warnings.append(f"{name}: {path} has no root: field, so its row keeps no root path")
900
+ skill_type, warning = _skill_type(package, row["text"])
901
+ if warning:
902
+ warnings.append(f"{name}: {warning}")
903
+ entries.append((name, row["version"], row["text"], skill_type))
904
+ included.append(
905
+ {
906
+ "skill_name": name,
907
+ "version": row["version"],
908
+ "active_version": version,
909
+ "skill_type": skill_type,
910
+ "snippet_path": str(path),
911
+ "snippet_source": source,
912
+ "root_rewritten": row["root_rewritten"],
913
+ }
914
+ )
915
+
916
+ # Orphans: rows of skills the manifest does not know, from every source file.
917
+ known = set(exports) | set(wanted) | set(dropped)
918
+ if renamed:
919
+ known.add(renamed[0])
920
+ if renamed[1] not in wanted:
921
+ warnings.append(f"--renamed: {renamed[1]} is not in the manifest, so the section has no row for it")
922
+ sources, seen = [], set()
923
+ for cf in [*orphan_sources, context_file]:
924
+ key = os.path.normcase(os.path.abspath(cf))
925
+ if key not in seen:
926
+ seen.add(key)
927
+ sources.append(cf)
928
+ orphan_rows, malformed_files, unreadable = _scan_rows(sources, known)
929
+ if unreadable:
930
+ return {"status": "error", "error": f"{'; '.join(unreadable)}. Nothing was written."}
931
+ for row in orphan_rows:
932
+ row["skill_type"] = _row_shape(row["snippet_text"])
933
+ if orphans == "keep":
934
+ # A row that holds a marker variant would make replace and insert refuse the whole body.
935
+ marked = [r for r in orphan_rows if MARKER_IN_BODY_PATTERN.search(r["snippet_text"])]
936
+ if marked:
937
+ where = "; ".join(f"{r['skill_name']} v{r['version']} in {', '.join(r['source_files'])}" for r in marked)
938
+ return {
939
+ "status": "error",
940
+ "error": f"an orphan row to keep holds an SKF marker (<!-- SKF:BEGIN or <!-- SKF:END): {where}. "
941
+ "Fix it by hand, then re-run. Nothing was written.",
942
+ }
943
+ entries += [(r["skill_name"], r["version"], r["snippet_text"], r["skill_type"]) for r in orphan_rows]
944
+
945
+ entries.sort(key=lambda e: (e[0], e[1]))
946
+ n_single = sum(1 for e in entries if e[3] == "single")
947
+ n_stack = len(entries) - n_single
948
+ lines = [SECTION_HEADER.format(n=n_single, m=n_stack), *SECTION_PREAMBLE]
949
+ for entry in entries:
950
+ lines += ["|", entry[2]]
951
+ body = "\n".join(lines)
952
+
953
+ try:
954
+ Path(content_file).parent.mkdir(parents=True, exist_ok=True)
955
+ _atomic_write(content_file, body)
956
+ except OSError as e:
957
+ return {"status": "error", "error": f"cannot write {content_file}: {e}"}
958
+ return {
959
+ "status": "ok",
960
+ "context_file": context_file,
961
+ "content_file": content_file,
962
+ "skill_root": skill_root,
963
+ "effective_root": prefix,
964
+ "n_single": n_single,
965
+ "n_stack": n_stack,
966
+ "included": included,
967
+ "orphans": orphans,
968
+ "orphan_rows": orphan_rows,
969
+ "skipped_deprecated": skipped_deprecated,
970
+ "skipped_integrity": skipped_integrity,
971
+ "skipped_missing_snippet": skipped_missing,
972
+ "skipped_malformed_snippet": skipped_malformed,
973
+ "malformed_context_files": malformed_files,
974
+ "warnings": warnings,
975
+ }
976
+
977
+
978
+ # ---------------------------------------------------------------------------
979
+ # resolve-targets: config.yaml `ides` to the context files to write
980
+ # ---------------------------------------------------------------------------
981
+
982
+
983
+ def load_ide_mapping():
984
+ """(ides, unknown_ide) from shared/data/ide-context-files.json. Raises OSError or ValueError."""
985
+ data = json.loads(IDE_DATA_FILE.read_text(encoding="utf-8"))
986
+
987
+ def usable(entry):
988
+ return isinstance(entry, dict) and all(isinstance(entry.get(k), str) and entry[k] for k in ("context_file", "skill_root"))
989
+
990
+ ides = data.get("ides") if isinstance(data, dict) else None
991
+ unknown = data.get("unknown_ide") if isinstance(data, dict) else None
992
+ if not isinstance(ides, dict) or not usable(unknown) or not all(usable(e) for e in ides.values()):
993
+ raise ValueError("it needs an `ides` object and an `unknown_ide` entry, each with context_file and skill_root")
994
+ return ides, unknown
995
+
996
+
997
+ def cmd_resolve_targets(ides_csv, context_file=None):
998
+ """The context files to write for config.yaml's `ides`, one target per file.
999
+
1000
+ Unknown IDEs resolve to the mapping's `unknown_ide` with a warning, and an
1001
+ empty list to it with a note. `context_file` keeps that one file, with the
1002
+ skill root of the first configured IDE that maps to it (else the root the
1003
+ mapping gives that file: its first IDE's, or unknown_ide's for AGENTS.md).
1004
+ """
1005
+ try:
1006
+ known_ides, unknown = load_ide_mapping()
1007
+ except (OSError, ValueError) as e:
1008
+ return {"status": "error", "error": f"cannot read {IDE_DATA_FILE.name}: {e}"}
1009
+
1010
+ ides = []
1011
+ for raw in (ides_csv or "").split(","):
1012
+ ide = raw.strip()
1013
+ if ide and ide not in ides:
1014
+ ides.append(ide)
1015
+ warnings, notes, unknown_ides = [], [], []
1016
+ mapped = [] # (ide, context_file, skill_root)
1017
+ for ide in ides:
1018
+ entry = known_ides.get(ide)
1019
+ if entry is None:
1020
+ unknown_ides.append(ide)
1021
+ warnings.append(
1022
+ f"Unknown IDE '{ide}' in config.yaml: defaulting to {unknown['context_file']} with `{unknown['skill_root']}`"
1023
+ )
1024
+ entry = unknown
1025
+ mapped.append((ide, entry["context_file"], entry["skill_root"]))
1026
+
1027
+ known_files = []
1028
+ for entry in [*known_ides.values(), unknown]:
1029
+ if entry["context_file"] not in known_files:
1030
+ known_files.append(entry["context_file"])
1031
+
1032
+ if context_file:
1033
+ if context_file not in known_files:
1034
+ return {
1035
+ "status": "error",
1036
+ "error": f"unknown context file '{context_file}': expected one of {', '.join(known_files)}",
1037
+ }
1038
+ matching = [(ide, root) for ide, cf, root in mapped if cf == context_file]
1039
+ if matching:
1040
+ root = matching[0][1]
1041
+ elif context_file == unknown["context_file"]:
1042
+ root = unknown["skill_root"]
1043
+ else:
1044
+ root = next(e["skill_root"] for e in known_ides.values() if e["context_file"] == context_file)
1045
+ targets = [{"context_file": context_file, "skill_root": root, "ides": [ide for ide, _ in matching]}]
1046
+ others = [ide for ide, cf, _ in mapped if cf != context_file]
1047
+ if others:
1048
+ notes.append(
1049
+ f"Exporting to {context_file} only. config.yaml also lists: {', '.join(others)}. "
1050
+ "Run without `--context-file` to export to all configured IDEs."
1051
+ )
1052
+ else:
1053
+ targets, by_file = [], {}
1054
+ for ide, cf, root in mapped:
1055
+ if cf in by_file:
1056
+ by_file[cf]["ides"].append(ide)
1057
+ else:
1058
+ by_file[cf] = {"context_file": cf, "skill_root": root, "ides": [ide]}
1059
+ targets.append(by_file[cf])
1060
+ for target in targets:
1061
+ if len(target["ides"]) > 1:
1062
+ notes.append(
1063
+ f"Multiple IDEs target {target['context_file']}: using {target['ides'][0]}'s skill root "
1064
+ f"(`{target['skill_root']}`). Each IDE's skills are installed to its own directory."
1065
+ )
1066
+ if not targets:
1067
+ targets = [{"context_file": unknown["context_file"], "skill_root": unknown["skill_root"], "ides": []}]
1068
+ notes.append(
1069
+ f"No IDEs configured in config.yaml: defaulting to {unknown['context_file']} with `{unknown['skill_root']}`."
1070
+ )
1071
+
1072
+ targeted = {t["context_file"] for t in targets}
1073
+ return {
1074
+ "status": "ok",
1075
+ "targets": targets,
1076
+ "ide_map": {ide: cf for ide, cf, _ in mapped},
1077
+ "unknown_ides": unknown_ides,
1078
+ "other_context_files": [cf for cf in known_files if cf not in targeted],
1079
+ "warnings": warnings,
1080
+ "notes": notes,
1081
+ }
1082
+
1083
+
1084
+ # ---------------------------------------------------------------------------
1085
+ # CLI
1086
+ # ---------------------------------------------------------------------------
1087
+
1088
+ ACTION_FIRST = ("orphan-detect", "root-probe", "assemble", "resolve-targets")
1089
+
1090
+
1091
+ def _include_list(value):
1092
+ """--include: NAME@VERSION, or several separated by commas."""
1093
+ items = []
1094
+ for part in filter(None, (p.strip() for p in value.split(","))):
1095
+ name, sep, version = part.partition("@")
1096
+ if not sep or not is_plain_name(name) or not is_plain_name(version):
1097
+ raise argparse.ArgumentTypeError(f"expected NAME@VERSION with plain folder names, got {part!r}")
1098
+ items.append((name, version))
1099
+ return items
1100
+
1101
+
1102
+ def _name_list(value):
1103
+ """--dropped: a skill name, or several separated by commas."""
1104
+ names = [part for part in (p.strip() for p in value.split(",")) if part]
1105
+ if not all(is_plain_name(n) for n in names):
1106
+ raise argparse.ArgumentTypeError(f"expected skill names, got {value!r}")
1107
+ return names
1108
+
1109
+
1110
+ def _renamed_pair(value):
1111
+ """--renamed: OLD:NEW."""
1112
+ old, sep, new = value.partition(":")
1113
+ if not sep or not is_plain_name(old) or not is_plain_name(new) or old == new:
1114
+ raise argparse.ArgumentTypeError(f"expected OLD:NEW with two different skill names, got {value!r}")
1115
+ return old, new
1116
+
1117
+
1118
+ def _snippet_pair(value):
1119
+ """--snippet: NAME=FILE."""
1120
+ name, sep, path = value.partition("=")
1121
+ if not sep or not is_plain_name(name) or not path:
1122
+ raise argparse.ArgumentTypeError(f"expected NAME=FILE, got {value!r}")
1123
+ return name, path
1124
+
1125
+
1126
+ def _action_parser(action):
1127
+ """The argparse parser of one action-first action."""
1128
+ prog = f"skf-rebuild-managed-sections.py {action}"
370
1129
  if action == "orphan-detect":
371
1130
  parser = argparse.ArgumentParser(
372
- prog="skf-rebuild-managed-sections.py orphan-detect",
373
- description="Detect managed-section rows absent from the exported skill set.",
1131
+ prog=prog, description="Detect managed-section rows absent from the exported skill set."
374
1132
  )
375
1133
  parser.add_argument("context_files", nargs="+", help="Context file paths to scan")
376
1134
  parser.add_argument(
@@ -378,34 +1136,152 @@ def _run_query_action(argv):
378
1136
  default="",
379
1137
  help="Comma-separated skill names being exported (excluded from orphans)",
380
1138
  )
381
- args = parser.parse_args(argv[1:])
382
- return cmd_orphan_detect(args.context_files, args.exported_skills), 0
383
- # action == "root-probe"
384
- parser = argparse.ArgumentParser(
385
- prog="skf-rebuild-managed-sections.py root-probe",
386
- description="Observe snippet root prefixes and flag mismatch against a reference.",
387
- )
388
- parser.add_argument("snippet_files", nargs="+", help="Snippet file paths to probe")
389
- parser.add_argument(
390
- "--reference-root",
391
- required=True,
392
- help="Reference skill root prefix to compare observed prefixes against",
393
- )
1139
+ elif action == "root-probe":
1140
+ parser = argparse.ArgumentParser(
1141
+ prog=prog, description="Observe snippet root prefixes and flag mismatch against a reference."
1142
+ )
1143
+ parser.add_argument("snippet_files", nargs="+", help="Snippet file paths to probe")
1144
+ parser.add_argument(
1145
+ "--reference-root",
1146
+ required=True,
1147
+ help="Reference skill root prefix to compare observed prefixes against",
1148
+ )
1149
+ parser.add_argument(
1150
+ "--project-root",
1151
+ help="The project the roots are relative to: report in disk_root which root holds "
1152
+ "the mismatched skills",
1153
+ )
1154
+ elif action == "assemble":
1155
+ parser = argparse.ArgumentParser(
1156
+ prog=prog,
1157
+ description="Build a context file's managed-section body from the export manifest and the "
1158
+ "skill snippets, and write it to <context-file>.skf-content (or --out FILE) for replace or insert.",
1159
+ )
1160
+ parser.add_argument("context_file", help="The context file the body is for")
1161
+ parser.add_argument("--skills-folder", required=True, help="skills_output_folder, which holds .export-manifest.json")
1162
+ parser.add_argument("--skill-root", required=True, help="The context file's skill root, such as .claude/skills/")
1163
+ parser.add_argument(
1164
+ "--skill-root-override",
1165
+ default="",
1166
+ help="snippet_skill_root_override from config.yaml: every row's root uses it instead of --skill-root",
1167
+ )
1168
+ parser.add_argument(
1169
+ "--include",
1170
+ action="append",
1171
+ type=_include_list,
1172
+ default=[],
1173
+ metavar="NAME@VERSION",
1174
+ help="A skill to write at this version whatever the manifest says (the skills being exported)",
1175
+ )
1176
+ parser.add_argument(
1177
+ "--snippet",
1178
+ action="append",
1179
+ type=_snippet_pair,
1180
+ default=[],
1181
+ metavar="NAME=FILE",
1182
+ help="Read NAME's snippet from FILE instead of its package",
1183
+ )
1184
+ parser.add_argument(
1185
+ "--snippet-dir",
1186
+ metavar="DIR",
1187
+ help="A folder of staged snippet drafts: a skill with DIR/<name>/context-snippet.md "
1188
+ "reads it instead of its package (--snippet wins over it)",
1189
+ )
1190
+ parser.add_argument("--renamed", type=_renamed_pair, metavar="OLD:NEW", help="A rename: OLD's rows are not orphans")
1191
+ parser.add_argument(
1192
+ "--dropped",
1193
+ action="append",
1194
+ type=_name_list,
1195
+ default=[],
1196
+ metavar="NAME",
1197
+ help="A dropped skill: its rows are not orphans",
1198
+ )
1199
+ parser.add_argument(
1200
+ "--orphan-sources",
1201
+ nargs="+",
1202
+ default=[],
1203
+ metavar="FILE",
1204
+ help="Every target context file, scanned for orphan rows (the context file itself always is)",
1205
+ )
1206
+ parser.add_argument(
1207
+ "--orphans",
1208
+ choices=("keep", "drop"),
1209
+ default="keep",
1210
+ help="Write the orphan rows verbatim (keep, the default) or leave them out (drop)",
1211
+ )
1212
+ parser.add_argument(
1213
+ "--out",
1214
+ metavar="FILE",
1215
+ help="Write the body to FILE instead of <context-file>.skf-content (a dry run, or a preview "
1216
+ "before the user confirms, passes a path outside the project)",
1217
+ )
1218
+ else: # resolve-targets
1219
+ parser = argparse.ArgumentParser(
1220
+ prog=prog, description="Map config.yaml's IDE list to the context files to write and their skill roots."
1221
+ )
1222
+ parser.add_argument("--ides", default="", help="Comma-separated IDE identifiers from config.yaml `ides`")
1223
+ parser.add_argument(
1224
+ "--context-file",
1225
+ default="",
1226
+ help="Keep only this context file (CLAUDE.md, .cursorrules or AGENTS.md)",
1227
+ )
1228
+ return parser
1229
+
1230
+
1231
+ def _run_action_first(argv):
1232
+ """Parse and run an action-first action. Returns (result_dict, exit_code).
1233
+
1234
+ These take the action name first (`orphan-detect <file>...`) rather than
1235
+ the legacy `<file> <action>` shape, so they are parsed with argparse and
1236
+ routed here before the legacy path.
1237
+ """
1238
+ action = argv[0]
1239
+ parser = _action_parser(action)
394
1240
  args = parser.parse_args(argv[1:])
395
- return cmd_root_probe(args.snippet_files, args.reference_root), 0
1241
+ if action == "orphan-detect":
1242
+ result = cmd_orphan_detect(args.context_files, args.exported_skills)
1243
+ elif action == "root-probe":
1244
+ result = cmd_root_probe(args.snippet_files, args.reference_root, args.project_root)
1245
+ elif action == "assemble":
1246
+ includes = [item for group in args.include for item in group]
1247
+ names = [name for name, _ in includes]
1248
+ if len(set(names)) != len(names):
1249
+ parser.error("--include names a skill twice")
1250
+ snippet_names = [name for name, _ in args.snippet]
1251
+ if len(set(snippet_names)) != len(snippet_names):
1252
+ parser.error("--snippet names a skill twice")
1253
+ result = cmd_assemble(
1254
+ args.context_file,
1255
+ args.skills_folder,
1256
+ args.skill_root,
1257
+ override=args.skill_root_override,
1258
+ includes=includes,
1259
+ snippets=dict(args.snippet),
1260
+ snippet_dir=args.snippet_dir,
1261
+ renamed=args.renamed,
1262
+ dropped=[name for group in args.dropped for name in group],
1263
+ orphan_sources=args.orphan_sources,
1264
+ orphans=args.orphans,
1265
+ out=args.out,
1266
+ )
1267
+ else:
1268
+ result = cmd_resolve_targets(args.ides, args.context_file)
1269
+ return result, 0 if result["status"] == "ok" else 1
396
1270
 
397
1271
 
398
1272
  def main():
399
- # Query actions take a positional file list, so they are action-first
400
- # (`orphan-detect <file>...`) rather than the legacy `<file> <action>` shape.
401
- if len(sys.argv) >= 2 and sys.argv[1] in ("orphan-detect", "root-probe"):
402
- result, code = _run_query_action(sys.argv[1:])
1273
+ # Action-first actions take a positional file list or flags only, so the
1274
+ # action comes first (`orphan-detect <file>...`) rather than the legacy
1275
+ # `<file> <action>` shape.
1276
+ if len(sys.argv) >= 2 and sys.argv[1] in ACTION_FIRST:
1277
+ result, code = _run_action_first(sys.argv[1:])
403
1278
  print(json.dumps(result, indent=2))
404
1279
  sys.exit(code)
405
1280
 
406
1281
  if len(sys.argv) < 3:
407
1282
  print("Usage: python3 skf-rebuild-managed-sections.py <context-file> <action> [--content <text>]", file=sys.stderr)
408
1283
  print("Actions: read, replace, clear, insert, check", file=sys.stderr)
1284
+ print(" or: python3 skf-rebuild-managed-sections.py {" + ",".join(ACTION_FIRST) + "} ...", file=sys.stderr)
409
1285
  sys.exit(1)
410
1286
 
411
1287
  file_path = sys.argv[1]
@@ -417,7 +1293,15 @@ def main():
417
1293
  if idx + 1 < len(sys.argv):
418
1294
  content_arg = sys.argv[idx + 1]
419
1295
  elif action in ("replace", "insert") and not sys.stdin.isatty():
420
- content_arg = sys.stdin.read()
1296
+ # Read the bytes and decode them as UTF-8 ourselves: sys.stdin decodes
1297
+ # a pipe or redirect with the locale's code page (cp1252 on Windows),
1298
+ # which turns the em dash every snippet's IMPORTANT line carries into
1299
+ # mojibake that the byte-identity check then accepts.
1300
+ try:
1301
+ content_arg = sys.stdin.buffer.read().decode("utf-8")
1302
+ except UnicodeDecodeError as e:
1303
+ print(json.dumps({"status": "error", "error": f"content on stdin is not UTF-8: {e}"}, indent=2))
1304
+ sys.exit(1)
421
1305
 
422
1306
  if action == "check":
423
1307
  result = cmd_check(file_path)