bmad-module-skill-forge 2.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (437) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +49 -49
  3. package/docs/404.md +10 -8
  4. package/docs/_data/pinned.yaml +22 -13
  5. package/docs/_internal/STABILITY.md +58 -35
  6. package/docs/agents.md +39 -53
  7. package/docs/architecture.md +65 -51
  8. package/docs/bmad-synergy.md +53 -51
  9. package/docs/campaign.md +127 -75
  10. package/docs/concepts.md +34 -28
  11. package/docs/examples.md +70 -68
  12. package/docs/forge-auto.md +34 -31
  13. package/docs/getting-started.md +118 -57
  14. package/docs/how-it-works.md +25 -21
  15. package/docs/index.md +15 -15
  16. package/docs/skill-model.md +70 -59
  17. package/docs/troubleshooting.md +143 -18
  18. package/docs/verifying-a-skill.md +87 -57
  19. package/docs/why-skf.md +24 -22
  20. package/docs/workflows.md +274 -110
  21. package/package.json +18 -10
  22. package/src/forger/preferences.yaml +8 -1
  23. package/src/knowledge/agentskills-spec.md +2 -0
  24. package/src/knowledge/ccc-bridge.md +40 -24
  25. package/src/knowledge/confidence-tiers.md +9 -5
  26. package/src/knowledge/overview.md +3 -3
  27. package/src/knowledge/progressive-capability.md +8 -8
  28. package/src/knowledge/provenance-tracking.md +9 -5
  29. package/src/knowledge/qmd-registry.md +3 -3
  30. package/src/knowledge/skf-knowledge-index.csv +2 -2
  31. package/src/knowledge/skill-lifecycle.md +18 -11
  32. package/src/knowledge/split-body-strategy.md +0 -3
  33. package/src/knowledge/tool-resolution.md +4 -2
  34. package/src/knowledge/version-paths.md +101 -15
  35. package/src/module-help.csv +3 -3
  36. package/src/module.yaml +10 -0
  37. package/src/shared/data/ast-grep-recipes.yaml +608 -0
  38. package/src/shared/data/ide-context-files.json +30 -0
  39. package/src/shared/health-check.md +31 -59
  40. package/src/shared/references/description-guard-protocol.md +12 -9
  41. package/src/shared/references/feasibility-report-schema.md +19 -13
  42. package/src/shared/references/headless-gate-convention.md +55 -3
  43. package/src/shared/references/output-contract-schema.md +19 -8
  44. package/src/shared/references/pipeline-contracts.md +94 -29
  45. package/src/shared/references/tessl-review.md +77 -0
  46. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +194 -0
  47. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +192 -0
  48. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +32 -4
  49. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +174 -0
  50. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +284 -0
  51. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +164 -0
  52. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +160 -0
  53. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +261 -0
  54. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +192 -0
  55. package/src/shared/scripts/schemas/skf-rename-skill-result-envelope.v1.json +179 -0
  56. package/src/shared/scripts/schemas/skf-setup-result-envelope.v1.json +27 -11
  57. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +176 -0
  58. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +173 -0
  59. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +19 -5
  60. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +180 -0
  61. package/src/shared/scripts/schemas/skill-brief.v1.json +34 -0
  62. package/src/shared/scripts/schemas/workspace-detection.v1.json +1 -1
  63. package/src/shared/scripts/skf-atomic-write.py +233 -64
  64. package/src/shared/scripts/skf-build-change-manifest.py +2753 -28
  65. package/src/shared/scripts/skf-ccc-git-hygiene.py +532 -0
  66. package/src/shared/scripts/skf-check-unit-records.py +184 -0
  67. package/src/shared/scripts/skf-check-workspace-drift.py +538 -9
  68. package/src/shared/scripts/skf-classify-changed-files.py +772 -0
  69. package/src/shared/scripts/skf-comention-pairs.py +1333 -123
  70. package/src/shared/scripts/skf-compare-file-hashes.py +267 -105
  71. package/src/shared/scripts/skf-count-imports.py +1867 -0
  72. package/src/shared/scripts/skf-count-tokens.py +1 -1
  73. package/src/shared/scripts/skf-derive-assembly-shape.py +21 -3
  74. package/src/shared/scripts/skf-description-guard.py +99 -11
  75. package/src/shared/scripts/skf-detect-docs.py +694 -2
  76. package/src/shared/scripts/skf-detect-language.py +486 -107
  77. package/src/shared/scripts/skf-detect-registry.py +887 -0
  78. package/src/shared/scripts/skf-detect-scripts-assets.py +197 -55
  79. package/src/shared/scripts/skf-detect-tools.py +209 -27
  80. package/src/shared/scripts/skf-detect-workspaces.py +506 -30
  81. package/src/shared/scripts/skf-disqualify-candidates.py +821 -182
  82. package/src/shared/scripts/skf-emit-brief-result-envelope.py +69 -191
  83. package/src/shared/scripts/skf-emit-result-envelope.py +1604 -106
  84. package/src/shared/scripts/skf-enumerate-stack-skills.py +986 -117
  85. package/src/shared/scripts/skf-extract-public-api.py +5247 -96
  86. package/src/shared/scripts/skf-extraction-inventory.py +1280 -0
  87. package/src/shared/scripts/skf-extraction-snapshot.py +664 -0
  88. package/src/shared/scripts/skf-fetch-temporal.py +674 -0
  89. package/src/shared/scripts/skf-find-cycles.py +97 -29
  90. package/src/shared/scripts/skf-find-test-report.py +476 -0
  91. package/src/shared/scripts/skf-forge-tier-rw.py +469 -205
  92. package/src/shared/scripts/skf-github-fetch.py +365 -0
  93. package/src/shared/scripts/skf-github-probe.py +700 -0
  94. package/src/shared/scripts/skf-hash-content.py +180 -20
  95. package/src/shared/scripts/skf-language-corpora.py +1 -1
  96. package/src/shared/scripts/skf-load-provenance.py +161 -27
  97. package/src/shared/scripts/skf-manifest-ops.py +1 -1
  98. package/src/shared/scripts/skf-merge-ccc-exclusions.py +2307 -223
  99. package/src/shared/scripts/skf-merge-doc-urls.py +143 -20
  100. package/src/shared/scripts/skf-names-present.py +221 -0
  101. package/src/shared/scripts/skf-pair-intersect.py +119 -28
  102. package/src/shared/scripts/skf-parse-gaps.py +1384 -0
  103. package/src/shared/scripts/skf-preapply.py +1 -1
  104. package/src/shared/scripts/skf-preflight.py +176 -28
  105. package/src/shared/scripts/skf-promote-staged.py +430 -0
  106. package/src/shared/scripts/skf-provenance-gap-dispatch.py +64 -8
  107. package/src/shared/scripts/skf-qmd-classify-collections.py +229 -45
  108. package/src/shared/scripts/skf-quick-batch.py +774 -0
  109. package/src/shared/scripts/skf-rebuild-managed-sections.py +972 -88
  110. package/src/shared/scripts/skf-recommend-scope-type.py +323 -156
  111. package/src/shared/scripts/skf-render-metadata-stats.py +156 -12
  112. package/src/shared/scripts/skf-render-quick-metadata.py +132 -28
  113. package/src/shared/scripts/skf-render-stack-metadata.py +976 -0
  114. package/src/shared/scripts/skf-resolve-authoritative-files.py +155 -14
  115. package/src/shared/scripts/skf-resolve-package.py +523 -106
  116. package/src/shared/scripts/skf-rewrite-skill-name.py +433 -47
  117. package/src/shared/scripts/skf-run-lock.py +577 -0
  118. package/src/shared/scripts/skf-scan-manifests.py +998 -57
  119. package/src/shared/scripts/skf-scan-skill-md-structure.py +1341 -37
  120. package/src/shared/scripts/skf-severity-classify.py +712 -108
  121. package/src/shared/scripts/skf-shape-detect.py +298 -204
  122. package/src/shared/scripts/skf-shard-body.py +9 -8
  123. package/src/shared/scripts/skf-skill-inventory.py +2412 -89
  124. package/src/shared/scripts/skf-skills-module.py +472 -0
  125. package/src/shared/scripts/skf-source-tree.py +1796 -0
  126. package/src/shared/scripts/skf-structural-diff.py +787 -121
  127. package/src/shared/scripts/skf-tessl-review.py +706 -0
  128. package/src/shared/scripts/skf-update-active-symlink.py +120 -67
  129. package/src/shared/scripts/skf-update-run-state.py +624 -0
  130. package/src/shared/scripts/skf-validate-brief-inputs.py +92 -29
  131. package/src/shared/scripts/skf-validate-brief-schema.py +23 -2
  132. package/src/shared/scripts/skf-validate-feasibility-report.py +577 -66
  133. package/src/shared/scripts/skf-validate-frontmatter.py +103 -26
  134. package/src/shared/scripts/skf-validate-output.py +712 -64
  135. package/src/shared/scripts/skf-validate-pins.py +1 -1
  136. package/src/shared/scripts/skf-verify-no-trace.py +116 -10
  137. package/src/shared/scripts/skf-verify-provenance-completeness.py +3945 -152
  138. package/src/shared/scripts/skf-write-skill-brief.py +406 -25
  139. package/src/shared/tool-requirements.yaml +201 -0
  140. package/src/skf-analyze-source/SKILL.md +41 -36
  141. package/src/skf-analyze-source/assets/skill-brief-schema.md +7 -8
  142. package/src/skf-analyze-source/customize.toml +36 -24
  143. package/src/skf-analyze-source/references/auto-docs-only.md +58 -20
  144. package/src/skf-analyze-source/references/continue.md +2 -13
  145. package/src/skf-analyze-source/references/discover-additional-source.md +147 -0
  146. package/src/skf-analyze-source/references/generate-briefs.md +121 -55
  147. package/src/skf-analyze-source/references/headless-contract.md +42 -15
  148. package/src/skf-analyze-source/references/identify-units.md +96 -110
  149. package/src/skf-analyze-source/references/init.md +73 -71
  150. package/src/skf-analyze-source/references/map-and-detect.md +123 -105
  151. package/src/skf-analyze-source/references/map-unit-exports.md +68 -0
  152. package/src/skf-analyze-source/references/recommend.md +51 -70
  153. package/src/skf-analyze-source/references/scan-project.md +60 -84
  154. package/src/skf-analyze-source/references/scan-root.md +39 -0
  155. package/src/skf-analyze-source/references/step-auto-scope-coexistence.md +47 -0
  156. package/src/skf-analyze-source/references/step-auto-scope-corpora.md +53 -0
  157. package/src/skf-analyze-source/references/step-auto-scope-split.md +91 -0
  158. package/src/skf-analyze-source/references/step-auto-scope.md +208 -293
  159. package/src/skf-analyze-source/references/step-shape-detect.md +14 -12
  160. package/src/skf-analyze-source/references/unit-detection-heuristics.md +37 -42
  161. package/src/skf-audit-skill/SKILL.md +43 -51
  162. package/src/skf-audit-skill/assets/drift-report-template.md +28 -3
  163. package/src/skf-audit-skill/customize.toml +41 -20
  164. package/src/skf-audit-skill/references/constituent-freshness.md +91 -0
  165. package/src/skf-audit-skill/references/doc-drift.md +141 -0
  166. package/src/skf-audit-skill/references/headless-contract.md +29 -0
  167. package/src/skf-audit-skill/references/health-check.md +2 -0
  168. package/src/skf-audit-skill/references/init.md +149 -210
  169. package/src/skf-audit-skill/references/re-index.md +103 -110
  170. package/src/skf-audit-skill/references/report.md +96 -33
  171. package/src/skf-audit-skill/references/semantic-diff.md +34 -25
  172. package/src/skf-audit-skill/references/severity-classify.md +79 -65
  173. package/src/skf-audit-skill/references/severity-rules.md +15 -6
  174. package/src/skf-audit-skill/references/structural-diff.md +80 -90
  175. package/src/skf-audit-skill/references/upstream-checkout.md +77 -0
  176. package/src/skf-audit-skill/scripts/render-drift-tables.py +712 -0
  177. package/src/skf-brief-skill/SKILL.md +37 -26
  178. package/src/skf-brief-skill/assets/description-voice-examples.md +1 -1
  179. package/src/skf-brief-skill/assets/scope-templates.md +18 -25
  180. package/src/skf-brief-skill/assets/skill-brief-schema.md +30 -23
  181. package/src/skf-brief-skill/customize.toml +48 -20
  182. package/src/skf-brief-skill/references/analyze-target.md +144 -127
  183. package/src/skf-brief-skill/references/confirm-brief.md +11 -9
  184. package/src/skf-brief-skill/references/draft-checkpoint.md +36 -17
  185. package/src/skf-brief-skill/references/gather-intent-ratify.md +68 -0
  186. package/src/skf-brief-skill/references/gather-intent.md +87 -182
  187. package/src/skf-brief-skill/references/headless-args.md +84 -16
  188. package/src/skf-brief-skill/references/health-check.md +1 -1
  189. package/src/skf-brief-skill/references/invocation-contract.md +18 -13
  190. package/src/skf-brief-skill/references/portfolio-similarity-check.md +1 -1
  191. package/src/skf-brief-skill/references/qmd-collection-registration.md +7 -10
  192. package/src/skf-brief-skill/references/scope-definition.md +123 -47
  193. package/src/skf-brief-skill/references/step-auto-brief.md +26 -100
  194. package/src/skf-brief-skill/references/step-auto-validate.md +20 -121
  195. package/src/skf-brief-skill/references/version-resolution.md +7 -10
  196. package/src/skf-brief-skill/references/write-brief.md +66 -57
  197. package/src/skf-campaign/SKILL.md +31 -34
  198. package/src/skf-campaign/assets/campaign-state-schema.json +25 -4
  199. package/src/skf-campaign/customize.toml +47 -16
  200. package/src/skf-campaign/references/campaign-contracts.md +39 -29
  201. package/src/skf-campaign/references/campaign-directive-spec.md +4 -2
  202. package/src/skf-campaign/references/health-check.md +7 -16
  203. package/src/skf-campaign/references/step-01-setup.md +46 -84
  204. package/src/skf-campaign/references/step-02-strategy.md +31 -30
  205. package/src/skf-campaign/references/step-03-pins.md +26 -18
  206. package/src/skf-campaign/references/step-04-provenance.md +24 -23
  207. package/src/skf-campaign/references/step-05-skill-loop.md +67 -48
  208. package/src/skf-campaign/references/step-06-batch.md +41 -34
  209. package/src/skf-campaign/references/step-07-capstone.md +40 -25
  210. package/src/skf-campaign/references/step-08-verify.md +28 -29
  211. package/src/skf-campaign/references/step-09-refine.md +29 -30
  212. package/src/skf-campaign/references/step-10-export.md +48 -36
  213. package/src/skf-campaign/references/step-11-maintenance.md +31 -34
  214. package/src/skf-campaign/references/step-resume.md +37 -68
  215. package/src/skf-campaign/scripts/campaign-deps.py +57 -18
  216. package/src/skf-campaign/scripts/campaign-parse-manifest.py +361 -54
  217. package/src/skf-campaign/scripts/campaign-provenance.py +96 -53
  218. package/src/skf-campaign/scripts/campaign-quality-gate.py +592 -0
  219. package/src/skf-campaign/scripts/campaign-render-batch.py +257 -49
  220. package/src/skf-campaign/scripts/campaign-render-kickoff.py +343 -50
  221. package/src/skf-campaign/scripts/campaign-report.py +204 -37
  222. package/src/skf-campaign/scripts/campaign-state.py +1128 -0
  223. package/src/skf-campaign/scripts/campaign-status.py +10 -74
  224. package/src/skf-campaign/scripts/campaign-validate-pins.py +30 -3
  225. package/src/skf-campaign/scripts/campaign-validate-state.py +34 -4
  226. package/src/skf-campaign/templates/campaign-brief-template.yaml +4 -5
  227. package/src/skf-campaign/templates/campaign-report-template.md +5 -1
  228. package/src/skf-campaign/templates/kickoff-template.md +0 -14
  229. package/src/skf-create-skill/SKILL.md +27 -20
  230. package/src/skf-create-skill/assets/compile-assembly-rules.md +11 -8
  231. package/src/skf-create-skill/assets/skill-sections.md +21 -11
  232. package/src/skf-create-skill/customize.toml +20 -8
  233. package/src/skf-create-skill/references/authoritative-files-protocol.md +36 -58
  234. package/src/skf-create-skill/references/batch-mode.md +81 -0
  235. package/src/skf-create-skill/references/ccc-index-check.md +27 -0
  236. package/src/skf-create-skill/references/compile.md +119 -70
  237. package/src/skf-create-skill/references/component-extraction.md +113 -91
  238. package/src/skf-create-skill/references/enrich.md +41 -23
  239. package/src/skf-create-skill/references/entry-points-by-hand.md +37 -0
  240. package/src/skf-create-skill/references/extract.md +204 -162
  241. package/src/skf-create-skill/references/extraction-patterns-by-hand.md +141 -0
  242. package/src/skf-create-skill/references/extraction-patterns-tracing.md +11 -11
  243. package/src/skf-create-skill/references/extraction-patterns.md +588 -201
  244. package/src/skf-create-skill/references/generate-artifacts.md +145 -93
  245. package/src/skf-create-skill/references/health-check.md +1 -1
  246. package/src/skf-create-skill/references/load-brief.md +53 -36
  247. package/src/skf-create-skill/references/relabel-rule.md +12 -0
  248. package/src/skf-create-skill/references/report.md +37 -57
  249. package/src/skf-create-skill/references/source-resolution-protocols.md +58 -169
  250. package/src/skf-create-skill/references/step-auto-shard.md +20 -93
  251. package/src/skf-create-skill/references/step-doc-rot.md +44 -60
  252. package/src/skf-create-skill/references/step-doc-sources.md +28 -19
  253. package/src/skf-create-skill/references/sub/ccc-discover.md +18 -7
  254. package/src/skf-create-skill/references/sub/fetch-docs.md +106 -59
  255. package/src/skf-create-skill/references/sub/fetch-temporal.md +53 -166
  256. package/src/skf-create-skill/references/tier-degradation-rules.md +18 -9
  257. package/src/skf-create-skill/references/validate.md +173 -99
  258. package/src/skf-create-skill/scripts/scan-doc-rot.py +232 -99
  259. package/src/skf-create-stack-skill/SKILL.md +29 -56
  260. package/src/skf-create-stack-skill/assets/metadata-contract.md +51 -0
  261. package/src/skf-create-stack-skill/assets/provenance-map-schema.md +15 -15
  262. package/src/skf-create-stack-skill/assets/stack-skill-template.md +23 -57
  263. package/src/skf-create-stack-skill/customize.toml +23 -12
  264. package/src/skf-create-stack-skill/references/compile-stack.md +57 -66
  265. package/src/skf-create-stack-skill/references/compose-mode-rules.md +24 -44
  266. package/src/skf-create-stack-skill/references/detect-integrations.md +92 -95
  267. package/src/skf-create-stack-skill/references/detect-manifests.md +46 -71
  268. package/src/skf-create-stack-skill/references/generate-output.md +138 -128
  269. package/src/skf-create-stack-skill/references/init.md +90 -32
  270. package/src/skf-create-stack-skill/references/integration-patterns.md +1 -17
  271. package/src/skf-create-stack-skill/references/invocation-contract.md +34 -0
  272. package/src/skf-create-stack-skill/references/manifest-patterns.md +32 -20
  273. package/src/skf-create-stack-skill/references/parallel-extract.md +112 -38
  274. package/src/skf-create-stack-skill/references/rank-and-confirm.md +57 -75
  275. package/src/skf-create-stack-skill/references/report.md +39 -33
  276. package/src/skf-create-stack-skill/references/validate.md +20 -39
  277. package/src/skf-drop-skill/SKILL.md +28 -51
  278. package/src/skf-drop-skill/customize.toml +44 -30
  279. package/src/skf-drop-skill/references/execute.md +83 -146
  280. package/src/skf-drop-skill/references/health-check.md +2 -0
  281. package/src/skf-drop-skill/references/invocation-contract.md +64 -0
  282. package/src/skf-drop-skill/references/report.md +46 -30
  283. package/src/skf-drop-skill/references/select.md +122 -124
  284. package/src/skf-drop-skill/scripts/dir-sizes.py +54 -26
  285. package/src/skf-drop-skill/scripts/drop-roster.py +322 -0
  286. package/src/skf-export-skill/SKILL.md +40 -62
  287. package/src/skf-export-skill/assets/managed-section-format.md +14 -50
  288. package/src/skf-export-skill/assets/snippet-format.md +3 -1
  289. package/src/skf-export-skill/customize.toml +42 -13
  290. package/src/skf-export-skill/references/generate-snippet.md +54 -46
  291. package/src/skf-export-skill/references/health-check.md +2 -0
  292. package/src/skf-export-skill/references/invocation-contract.md +15 -0
  293. package/src/skf-export-skill/references/load-skill.md +88 -62
  294. package/src/skf-export-skill/references/manifest-rebuild.md +9 -10
  295. package/src/skf-export-skill/references/multi-skill-mode.md +4 -4
  296. package/src/skf-export-skill/references/orphan-context-detection.md +13 -5
  297. package/src/skf-export-skill/references/orphan-row-detection.md +31 -22
  298. package/src/skf-export-skill/references/package.md +15 -73
  299. package/src/skf-export-skill/references/preflight-snippet-root-probe.md +51 -70
  300. package/src/skf-export-skill/references/result-envelope.md +31 -16
  301. package/src/skf-export-skill/references/summary.md +41 -17
  302. package/src/skf-export-skill/references/token-report.md +4 -12
  303. package/src/skf-export-skill/references/update-context.md +118 -237
  304. package/src/skf-forger/SKILL.md +61 -37
  305. package/src/skf-forger/bmad-skill-manifest.yaml +4 -1
  306. package/src/skf-forger/customize.toml +43 -0
  307. package/src/skf-forger/references/pipeline-mode.md +73 -18
  308. package/src/skf-forger/scripts/forge-status.py +299 -0
  309. package/src/skf-forger/scripts/parse-pipeline.py +322 -54
  310. package/src/skf-forger/scripts/pipeline-gate.py +428 -0
  311. package/src/skf-forger/scripts/pipeline-journal.py +1052 -0
  312. package/src/skf-quick-skill/SKILL.md +54 -53
  313. package/src/skf-quick-skill/assets/skill-template.md +4 -2
  314. package/src/skf-quick-skill/customize.toml +51 -20
  315. package/src/skf-quick-skill/references/batch-mode.md +74 -21
  316. package/src/skf-quick-skill/references/compile.md +30 -31
  317. package/src/skf-quick-skill/references/ecosystem-check.md +7 -57
  318. package/src/skf-quick-skill/references/finalize.md +69 -18
  319. package/src/skf-quick-skill/references/halt-contract.md +41 -29
  320. package/src/skf-quick-skill/references/health-check.md +5 -3
  321. package/src/skf-quick-skill/references/quick-extract.md +114 -45
  322. package/src/skf-quick-skill/references/registry-resolution.md +9 -64
  323. package/src/skf-quick-skill/references/resolve-target.md +116 -66
  324. package/src/skf-quick-skill/references/write-and-validate.md +79 -40
  325. package/src/skf-refine-architecture/SKILL.md +43 -53
  326. package/src/skf-refine-architecture/customize.toml +49 -20
  327. package/src/skf-refine-architecture/references/compile.md +97 -54
  328. package/src/skf-refine-architecture/references/exit-codes.md +30 -0
  329. package/src/skf-refine-architecture/references/finding-storage.md +21 -0
  330. package/src/skf-refine-architecture/references/gap-analysis.md +101 -38
  331. package/src/skf-refine-architecture/references/improvements.md +14 -20
  332. package/src/skf-refine-architecture/references/init.md +98 -57
  333. package/src/skf-refine-architecture/references/issue-detection.md +32 -39
  334. package/src/skf-refine-architecture/references/refinement-rules.md +37 -48
  335. package/src/skf-refine-architecture/references/report.md +65 -54
  336. package/src/skf-refine-architecture/scripts/skf-check-preservation.py +1619 -0
  337. package/src/skf-rename-skill/SKILL.md +19 -54
  338. package/src/skf-rename-skill/customize.toml +32 -26
  339. package/src/skf-rename-skill/references/execute.md +230 -184
  340. package/src/skf-rename-skill/references/exit-codes.md +7 -7
  341. package/src/skf-rename-skill/references/health-check.md +1 -7
  342. package/src/skf-rename-skill/references/invocation-contract.md +25 -0
  343. package/src/skf-rename-skill/references/report.md +63 -52
  344. package/src/skf-rename-skill/references/select.md +197 -115
  345. package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -21
  346. package/src/skf-setup/SKILL.md +26 -26
  347. package/src/skf-setup/customize.toml +14 -10
  348. package/src/skf-setup/references/auto-index.md +50 -63
  349. package/src/skf-setup/references/ccc-index.md +29 -90
  350. package/src/skf-setup/references/detect-and-tier.md +58 -67
  351. package/src/skf-setup/references/health-check.md +2 -0
  352. package/src/skf-setup/references/invocation-contract.md +14 -0
  353. package/src/skf-setup/references/report.md +43 -166
  354. package/src/skf-setup/references/tier-rules.md +5 -5
  355. package/src/skf-setup/references/write-config.md +34 -57
  356. package/src/skf-test-skill/SKILL.md +17 -57
  357. package/src/skf-test-skill/assets/output-section-formats.md +33 -34
  358. package/src/skf-test-skill/customize.toml +27 -13
  359. package/src/skf-test-skill/references/coherence-check.md +134 -62
  360. package/src/skf-test-skill/references/coverage-check-tiers.md +35 -0
  361. package/src/skf-test-skill/references/coverage-check.md +209 -264
  362. package/src/skf-test-skill/references/detect-mode.md +5 -5
  363. package/src/skf-test-skill/references/external-validators.md +94 -82
  364. package/src/skf-test-skill/references/health-check.md +6 -7
  365. package/src/skf-test-skill/references/init.md +164 -91
  366. package/src/skf-test-skill/references/invocation-contract.md +66 -0
  367. package/src/skf-test-skill/references/migration-section-rules.md +5 -2
  368. package/src/skf-test-skill/references/report.md +164 -142
  369. package/src/skf-test-skill/references/score.md +80 -107
  370. package/src/skf-test-skill/references/scoring-rules.md +42 -155
  371. package/src/skf-test-skill/references/source-access-protocol.md +18 -94
  372. package/src/skf-test-skill/references/step-hard-gate.md +51 -33
  373. package/src/skf-test-skill/scripts/aggregate-coherence.py +218 -30
  374. package/src/skf-test-skill/scripts/build-result-context.py +380 -0
  375. package/src/skf-test-skill/scripts/check-metadata-coherence.py +37 -4
  376. package/src/skf-test-skill/scripts/combine-external-scores.py +23 -6
  377. package/src/skf-test-skill/scripts/compute-score.py +223 -45
  378. package/src/skf-test-skill/scripts/gap-ledger.py +1666 -0
  379. package/src/skf-test-skill/scripts/hard-gate.py +203 -0
  380. package/src/skf-test-skill/scripts/load-coverage-inputs.py +1198 -0
  381. package/src/skf-test-skill/scripts/locate-export-segments.py +835 -0
  382. package/src/skf-test-skill/scripts/reconcile-coverage.py +547 -79
  383. package/src/skf-test-skill/scripts/score-signatures.py +479 -0
  384. package/src/skf-test-skill/scripts/stage-helper-payload.py +148 -0
  385. package/src/skf-test-skill/scripts/validate-inventory.py +311 -16
  386. package/src/skf-test-skill/scripts/verify-declared-numerator.py +133 -56
  387. package/src/skf-test-skill/templates/test-report-template.md +21 -15
  388. package/src/skf-update-skill/SKILL.md +38 -25
  389. package/src/skf-update-skill/customize.toml +27 -12
  390. package/src/skf-update-skill/references/detect-changes.md +253 -223
  391. package/src/skf-update-skill/references/gap-driven.md +336 -0
  392. package/src/skf-update-skill/references/health-check.md +31 -5
  393. package/src/skf-update-skill/references/init.md +254 -84
  394. package/src/skf-update-skill/references/invocation-contract.md +12 -0
  395. package/src/skf-update-skill/references/manual-section-rules.md +6 -23
  396. package/src/skf-update-skill/references/merge.md +182 -49
  397. package/src/skf-update-skill/references/re-extract.md +53 -262
  398. package/src/skf-update-skill/references/report.md +102 -35
  399. package/src/skf-update-skill/references/write.md +357 -155
  400. package/src/skf-update-skill/scripts/skf-new-file-diff.py +183 -46
  401. package/src/skf-verify-stack/SKILL.md +23 -42
  402. package/src/skf-verify-stack/assets/feasibility-report-template.md +11 -5
  403. package/src/skf-verify-stack/customize.toml +55 -22
  404. package/src/skf-verify-stack/references/coverage-patterns.md +17 -8
  405. package/src/skf-verify-stack/references/coverage.md +67 -64
  406. package/src/skf-verify-stack/references/exit-codes.md +21 -8
  407. package/src/skf-verify-stack/references/init.md +107 -62
  408. package/src/skf-verify-stack/references/integration-verification-rules.md +12 -13
  409. package/src/skf-verify-stack/references/integrations.md +106 -65
  410. package/src/skf-verify-stack/references/report.md +59 -42
  411. package/src/skf-verify-stack/references/requirements.md +32 -9
  412. package/src/skf-verify-stack/references/synthesize.md +53 -39
  413. package/src/skf-verify-stack/scripts/skf-coverage-tally.py +398 -52
  414. package/src/skf-verify-stack/scripts/skf-previous-report.py +330 -0
  415. package/src/skf-verify-stack/scripts/skf-report-delta.py +453 -49
  416. package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +240 -27
  417. package/tools/cli/commands/install.js +11 -4
  418. package/tools/cli/commands/status.js +15 -3
  419. package/tools/cli/commands/update.js +11 -4
  420. package/tools/cli/lib/tool-check.js +320 -0
  421. package/tools/cli/lib/version-check.js +22 -9
  422. package/tools/cli/skf-cli.js +4 -1
  423. package/.gitattributes +0 -16
  424. package/.nvmrc +0 -1
  425. package/docs/_internal/RELEASING.md +0 -679
  426. package/release-audits/v1.0.0-launch-audit.md +0 -728
  427. package/src/skf-audit-skill/references/step-doc-drift.md +0 -177
  428. package/src/skf-brief-skill/references/headless-source-authority-detection.md +0 -26
  429. package/src/skf-campaign/manifest.yaml +0 -15
  430. package/src/skf-create-skill/assets/tessl-dismissal-rules.md +0 -106
  431. package/src/skf-create-skill/references/ecosystem-check.md +0 -74
  432. package/src/skf-drop-skill/references/headless-contract.md +0 -18
  433. package/src/skf-update-skill/references/merge-conflict-rules.md +0 -15
  434. package/src/skf-update-skill/references/remote-source-resolution.md +0 -98
  435. package/src/skf-update-skill/references/validate.md +0 -124
  436. package/tools/validate-docs-drift.js +0 -233
  437. package/tools/validate-docs-links.js +0 -665
@@ -1,14 +1,14 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = ["pyyaml"]
4
4
  # ///
5
5
  """SKF Forge Tier RW — Read/write primitives for forger-sidecar YAML files.
6
6
 
7
7
  Replaces the prose-driven YAML emission in `src/skf-setup/references/
8
- write-config.md` §1-§3 (and the prose-driven cleanup logic in
9
- step 3 §5/§5b) with one Python invocation. The script is the source
10
- of truth for the on-disk forge-tier.yaml schema (matching the
11
- canonical template at write-config.md:24-58) and guarantees
8
+ write-config.md` §1 (and the prose-driven cleanup logic in
9
+ step 3 §4) with one Python invocation. The script is the source
10
+ of truth for the on-disk forge-tier.yaml schema (the canonical
11
+ template is render_forge_tier_yaml below) and guarantees
12
12
  that registry arrays are PRESERVED across rewrites — losing
13
13
  `qmd_collections` or `ccc_index_registry` would break every
14
14
  downstream skill (skf-create-skill, skf-audit-skill, skf-update-skill,
@@ -21,14 +21,31 @@ Subcommands:
21
21
  payload with status=ok so first-run callers can branch.
22
22
 
23
23
  write-tools Write a fresh forge-tier.yaml from a JSON context payload
24
- on stdin. Preserves `qmd_collections`,
25
- `ccc_index_registry`, and the user-customizable
26
- `ccc_index.staleness_threshold_hours` from the existing
27
- file (if any) by reading it first, then merging.
28
-
29
- init-prefs Create preferences.yaml with first-run defaults IF it
30
- does not exist. Idempotent — refuses to overwrite an
31
- existing file (preserves user customization).
24
+ on stdin, or, with --detect-from, from the outputs a
25
+ setup run staged in its run folder (below). Preserves
26
+ `qmd_collections`, `ccc_index_registry`, and the
27
+ user-customizable `ccc_index.staleness_threshold_hours`
28
+ from the existing file (if any) by reading it first, then
29
+ merging, and `ccc_index.exclude_patterns` when the payload
30
+ sends null (the SKF exclusion record kept across runs that
31
+ did not reconcile it).
32
+
33
+ --detect-from <detect-tools.json>
34
+ skf-detect-tools.py's output: `tools` (each tool's
35
+ `available`, ccc's `daemon`) and `tier.calculated`.
36
+ --ccc-from <ccc-exclusions.json>
37
+ skf-merge-ccc-exclusions.py's --result-to file, run
38
+ with --build-index: its `index` object (status,
39
+ indexed_path, last_indexed, file_count) and its
40
+ `effective_patterns` become `ccc_index`. A file that
41
+ is not there means the run did not prepare ccc
42
+ (status "none"), unless detect-tools.json reports
43
+ ccc available: the helper then wrote no result, so
44
+ the preparation failed (status "failed"), as with a
45
+ file that holds an error, or no JSON object.
46
+ Either way the exclusion record is kept.
47
+ With --detect-from the subcommand reads nothing on stdin,
48
+ so no value passes through the caller's hands.
32
49
 
33
50
  register-qmd-collection
34
51
  Append-or-replace a single entry in the `qmd_collections`
@@ -37,11 +54,19 @@ Subcommands:
37
54
  the same `name` is replaced, otherwise appended). All
38
55
  other forge-tier state (tools / tier / ccc_index /
39
56
  ccc_index_registry / other qmd_collections entries) is
40
- preserved verbatim. Used by skf-brief-skill step 5 §5
57
+ preserved verbatim. Used by skf-brief-skill step 5 §3b
41
58
  and skf-create-skill to register Deep-tier QMD
42
59
  collections without re-rendering the whole file in
43
60
  prose.
44
61
 
62
+ remove-qmd-collection
63
+ Remove every `qmd_collections` entry whose `name` is
64
+ --name. All other forge-tier state is preserved verbatim.
65
+ The rollback of register-qmd-collection, for a caller
66
+ whose `qmd collection add` failed after it removed the
67
+ old collection, so the registry matches QMD again. A
68
+ name with no entry changes nothing (action "absent").
69
+
45
70
  register-ccc-index
46
71
  Append-or-replace a single entry in the
47
72
  `ccc_index_registry` array. Reads the entry as JSON on
@@ -54,26 +79,48 @@ Subcommands:
54
79
  re-rendering the whole file in prose.
55
80
 
56
81
  clean-stale Two cleanup operations gated by flags:
57
- --qmd-live-names a,b,c — remove qmd_collections
58
- entries whose `name` is not in the comma-separated
59
- live-names list. Caller computes liveness via
60
- `qmd collection list` (or skf-qmd-classify-
61
- collections.py once that ships in PR B).
82
+ --qmd-live-from <qmd-classify.json>: remove
83
+ qmd_collections entries whose `name` is not in the
84
+ `live_names` list of skf-qmd-classify-collections.py's
85
+ staged output. A file that is not there, or holds no
86
+ `live_names` list (the classifier did not run, or
87
+ failed), skips QMD cleanup: an empty list would read
88
+ as "nothing is live" and empty the registry.
89
+ --qmd-live-names a,b,c: the same with the list given
90
+ inline (mutually exclusive with --qmd-live-from).
62
91
  --prune-missing-ccc-paths — remove ccc_index_registry
63
92
  entries whose `path` no longer exists on disk.
64
- Both flags can be passed in the same invocation.
93
+ Both kinds of cleanup can run in the same invocation.
65
94
 
66
95
  Output schema (the read subcommand and the response from every write):
67
96
 
68
97
  {"status": "ok", "version": "v1", ...subcommand-specific fields...}
69
98
 
99
+ The subcommands that take the registry lock (below) also return
100
+ `lock_stale_replaced`: null, or the {"held_by", "held_since"} of the
101
+ stale lock they replaced.
102
+
70
103
  Errors emit `{"status": "error", "message": "..."}` to stderr and
71
- exit non-zero (1 for user error, 2 for I/O failure).
104
+ exit non-zero (1 for user error, 2 for I/O failure or a registry lock
105
+ that cannot be taken, 3 when another call held the registry lock for the
106
+ whole --lock-timeout; nothing was written).
72
107
 
73
108
  Cross-platform: pure stdlib + PyYAML. Atomic writes via temp + rename
74
- mirror skf-atomic-write.py's pattern. Concurrent access requires
75
- external `flock` coordination — see step 7 of skf-create-skill for
76
- the precedent.
109
+ mirror skf-atomic-write.py's pattern. On Windows, which refuses to rename
110
+ over a file another process has open, a refused rename is retried for up
111
+ to 5 seconds before the write fails.
112
+
113
+ Registry lock: every subcommand that rewrites forge-tier.yaml
114
+ (write-tools, register-qmd-collection, remove-qmd-collection,
115
+ register-ccc-index, clean-stale) holds `forge-tier.yaml.lock` beside it
116
+ for its one read-modify-write and releases it before it exits, through
117
+ skf-run-lock.py loaded from this script's folder. Concurrent runs never
118
+ lose each other's entries, and no caller takes a lock of its own: the
119
+ lock lives inside one call, never across calls. A call waits up to
120
+ --lock-timeout seconds (30 by default) while another call holds the lock.
121
+ A lock older than 15 seconds was left by a call that was killed, and is
122
+ replaced. An empty lock file holds nothing (the helper never writes one;
123
+ an `flock` on the same path leaves one) and is replaced at once.
77
124
 
78
125
  CLI — invoke via `uv run` so the PEP 723 PyYAML dependency declared
79
126
  above is auto-resolved on first call and cached. `docs/getting-started.md`
@@ -84,18 +131,26 @@ system-wide:
84
131
 
85
132
  uv run skf-forge-tier-rw.py read --target /path/forge-tier.yaml
86
133
  echo '{...}' | uv run skf-forge-tier-rw.py write-tools --target /path/forge-tier.yaml
87
- uv run skf-forge-tier-rw.py init-prefs --target /path/preferences.yaml
134
+ uv run skf-forge-tier-rw.py write-tools --target /path/forge-tier.yaml \\
135
+ --detect-from /run/detect-tools.json --ccc-from /run/ccc-exclusions.json
136
+ uv run skf-forge-tier-rw.py register-qmd-collection --target /path/forge-tier.yaml < entry.json
137
+ uv run skf-forge-tier-rw.py remove-qmd-collection --target /path/forge-tier.yaml \\
138
+ --name foo-extraction
88
139
  echo '{...}' | uv run skf-forge-tier-rw.py register-ccc-index --target /path/forge-tier.yaml
89
140
  uv run skf-forge-tier-rw.py clean-stale --target /path/forge-tier.yaml \\
90
- --qmd-live-names foo-brief,bar-extraction --prune-missing-ccc-paths
141
+ --qmd-live-from /run/qmd-classify.json --prune-missing-ccc-paths
91
142
  """
92
143
 
93
144
  from __future__ import annotations
94
145
 
95
146
  import argparse
147
+ import contextlib
148
+ import importlib.util
96
149
  import json
150
+ import math
97
151
  import os
98
152
  import sys
153
+ import time
99
154
  from datetime import datetime, timezone
100
155
  from pathlib import Path
101
156
 
@@ -103,28 +158,23 @@ import yaml
103
158
 
104
159
 
105
160
  DEFAULT_STALENESS_HOURS = 24
106
- PREFERENCES_TEMPLATE = """# Ferris Sidecar: User Preferences
107
- # Created by setup workflow on first run
108
- # Edit this file to customize Ferris behavior
109
-
110
- # Override detected tier (set to Quick, Forge, Forge+, or Deep to force a tier)
111
- tier_override: ~
112
-
113
- # Passive context injection (set to false to skip snippet generation and CLAUDE.md updates during export)
114
- passive_context: true
115
-
116
- # Headless mode (set to true to skip confirmation gates in all workflows)
117
- headless_mode: false
118
-
119
- # Compact greeting (set to true to skip the full capabilities table on session start)
120
- compact_greeting: false
121
-
122
- # Reserved for future use — these fields are not yet consumed by any workflow step
123
- # output_language: ~
124
- # skill_format_version: ~
125
- # citation_style: ~
126
- # confidence_display: ~
127
- """
161
+ RUN_LOCK_HELPER = Path(__file__).resolve().parent / "skf-run-lock.py"
162
+ REGISTRY_LOCK_WAIT_SEC = 30.0
163
+ # A call holds the registry lock for milliseconds; one this old was killed.
164
+ REGISTRY_LOCK_STALE_SEC = 15.0
165
+ EXIT_LOCK_BUSY = 3
166
+ # Windows refuses to replace a file another process has open, even one that
167
+ # only reads its attributes: a call about to take the lock checks the file
168
+ # exists, a `read` call opens it with no lock, and a virus scanner may open a
169
+ # just-written file for a moment. _atomic_write retries the rename there for
170
+ # up to this long before failing.
171
+ # The retry runs while the registry lock is held, so it stays well under
172
+ # REGISTRY_LOCK_STALE_SEC, after which a waiting call takes the lock as stale.
173
+ _IS_WINDOWS = os.name == "nt"
174
+ _REPLACE_WAIT_SECONDS = 5.0
175
+ _REPLACE_POLL_SECONDS = 0.02
176
+ # The tool keys write-tools records, in forge-tier.yaml order.
177
+ TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
128
178
 
129
179
 
130
180
  def _die(code: int, message: str) -> None:
@@ -155,7 +205,9 @@ def _read_yaml(path: Path) -> dict | None:
155
205
 
156
206
 
157
207
  def _atomic_write(target: Path, content: str) -> None:
158
- """Crash-safe write via temp + fsync + rename. Mirrors skf-atomic-write.py."""
208
+ """Crash-safe write via temp + fsync + rename, as skf-atomic-write.py does.
209
+ On Windows, a rename refused while another process has the file open is
210
+ retried for up to _REPLACE_WAIT_SECONDS."""
159
211
  target.parent.mkdir(parents=True, exist_ok=True)
160
212
  tmp = target.with_name(target.name + ".skf-tmp")
161
213
  # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
@@ -168,7 +220,15 @@ def _atomic_write(target: Path, content: str) -> None:
168
220
  os.fsync(fd)
169
221
  finally:
170
222
  os.close(fd)
171
- os.replace(tmp, target)
223
+ deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
224
+ while True:
225
+ try:
226
+ os.replace(tmp, target)
227
+ break
228
+ except PermissionError:
229
+ if not _IS_WINDOWS or time.monotonic() >= deadline:
230
+ raise
231
+ time.sleep(_REPLACE_POLL_SECONDS)
172
232
  except OSError as e:
173
233
  if tmp.exists():
174
234
  try:
@@ -178,6 +238,52 @@ def _atomic_write(target: Path, content: str) -> None:
178
238
  _die(2, f"atomic write failed for {target}: {e}")
179
239
 
180
240
 
241
+ def _load_run_lock():
242
+ """Load skf-run-lock.py, which installs into the same folder as this script."""
243
+ try:
244
+ spec = importlib.util.spec_from_file_location("skf_run_lock", RUN_LOCK_HELPER)
245
+ if spec is None or spec.loader is None:
246
+ raise ImportError("no import spec")
247
+ module = importlib.util.module_from_spec(spec)
248
+ spec.loader.exec_module(module)
249
+ except (OSError, ImportError) as e:
250
+ _die(2, f"cannot load the run-lock helper {RUN_LOCK_HELPER}: {e}")
251
+ return module
252
+
253
+
254
+ @contextlib.contextmanager
255
+ def _registry_lock(target: Path, command: str, timeout: float):
256
+ """Hold `<target>.lock` through one read-modify-write of forge-tier.yaml.
257
+
258
+ Yields the stale lock this call replaced ({"held_by", "held_since"}) or
259
+ None. Exits 3 when another call holds the lock for `timeout` seconds, 2
260
+ when the lock cannot be taken (a folder in its place, an I/O error).
261
+ """
262
+ run_lock = _load_run_lock()
263
+ lock = target.with_name(target.name + ".lock")
264
+ owner = f"forge-tier-rw:{command}:{run_lock.new_run_id()}"
265
+ try:
266
+ # The empty file an `flock` on this path leaves (create-skill prose
267
+ # before 3.0.0) holds nothing, so it must not make the call wait.
268
+ held = run_lock.acquire_within(lock, owner, REGISTRY_LOCK_STALE_SEC, timeout,
269
+ empty_is_stale=True)
270
+ except run_lock.LockBusy as busy:
271
+ info = busy.result
272
+ _die(EXIT_LOCK_BUSY,
273
+ f"{command}: {lock} is held by {info['held_by'] or 'an unnamed owner'} since "
274
+ f"{info['held_since']}: another run is writing {target.name}. Nothing was "
275
+ f"written; run the command again.")
276
+ except (OSError, ValueError) as e:
277
+ _die(2, f"{command}: cannot take {lock}: {e}")
278
+ try:
279
+ yield held["stale_replaced"]
280
+ finally:
281
+ try:
282
+ run_lock.release(lock, owner)
283
+ except (OSError, ValueError):
284
+ pass # a lock this call could not delete goes stale on its own
285
+
286
+
181
287
  def _yaml_block(value, indent: int = 0) -> str:
182
288
  """Dump a value as a YAML fragment, indented by `indent` spaces, no trailing newline."""
183
289
  text = yaml.safe_dump(value, default_flow_style=False, sort_keys=False, allow_unicode=True)
@@ -191,8 +297,8 @@ def _yaml_block(value, indent: int = 0) -> str:
191
297
  def render_forge_tier_yaml(payload: dict) -> str:
192
298
  """Render the canonical forge-tier.yaml from a context payload.
193
299
 
194
- Preserves the human-readable section comments from the template at
195
- write-config.md:24-58. Sections are emitted in a fixed order
300
+ This is the file's one template, human-readable section comments
301
+ included. Sections are emitted in a fixed order
196
302
  so re-runs against unchanged inputs produce byte-identical output.
197
303
  """
198
304
  tools = payload["tools"]
@@ -219,7 +325,7 @@ def render_forge_tier_yaml(payload: dict) -> str:
219
325
  "status": ccc_index.get("status"),
220
326
  "staleness_threshold_hours": ccc_index.get("staleness_threshold_hours", DEFAULT_STALENESS_HOURS),
221
327
  "file_count": ccc_index.get("file_count"),
222
- "exclude_patterns": ccc_index.get("exclude_patterns", []),
328
+ "exclude_patterns": ccc_index.get("exclude_patterns") or [],
223
329
  }
224
330
 
225
331
  parts = [
@@ -257,13 +363,14 @@ def _yaml_scalar(value) -> str:
257
363
  def _merge_preserved_fields(payload: dict, existing: dict | None) -> dict:
258
364
  """Inject preserved fields from the existing file into the new payload.
259
365
 
260
- Three preservation rules (per step 2 §1 "Note on re-runs"):
366
+ Four preservation rules (write-tools in the module docstring):
261
367
  - `qmd_collections` array — preserved entirely from existing.
262
368
  - `ccc_index_registry` array — preserved entirely from existing.
263
369
  - `ccc_index.staleness_threshold_hours` scalar — preserved if user set
264
370
  a non-default value; else uses payload value or DEFAULT.
265
- Note: `ccc_index.exclude_patterns` is NOT preserved (rewritten fresh
266
- by step 1b on every run, per the explicit step 2 contract).
371
+ - `ccc_index.exclude_patterns` array — replaced when the payload
372
+ carries a list; kept from the existing file when the payload sends
373
+ null or omits it (an existing value that is not a list becomes []).
267
374
  """
268
375
  if existing is None:
269
376
  return payload
@@ -277,6 +384,31 @@ def _merge_preserved_fields(payload: dict, existing: dict | None) -> dict:
277
384
  payload["ccc_index"]["staleness_threshold_hours"] = existing_ccc.get(
278
385
  "staleness_threshold_hours", DEFAULT_STALENESS_HOURS
279
386
  )
387
+ if payload["ccc_index"].get("exclude_patterns") is None:
388
+ prior = existing_ccc.get("exclude_patterns")
389
+ payload["ccc_index"]["exclude_patterns"] = prior if isinstance(prior, list) else []
390
+ return payload
391
+
392
+
393
+ def _payload_from(data: dict, **replace) -> dict:
394
+ """The render payload of an existing forge-tier.yaml, with `replace` applied.
395
+
396
+ render_forge_tier_yaml() emits exactly the six known top-level sections
397
+ (tools, tier, tier_detected_at, ccc_index, ccc_index_registry,
398
+ qmd_collections), so a rewrite from this payload drops any other
399
+ top-level key, as cmd_write_tools does. Update render_forge_tier_yaml()
400
+ and this function together when the schema grows.
401
+ """
402
+ payload = {
403
+ "tools": data.get("tools", {}),
404
+ "tier": data.get("tier", "Quick"),
405
+ "tier_detected_at": data.get("tier_detected_at",
406
+ datetime.now(timezone.utc).isoformat()),
407
+ "ccc_index": data.get("ccc_index", {}),
408
+ "ccc_index_registry": data.get("ccc_index_registry", []),
409
+ "qmd_collections": data.get("qmd_collections", []),
410
+ }
411
+ payload.update(replace)
280
412
  return payload
281
413
 
282
414
 
@@ -291,14 +423,86 @@ def cmd_read(target: Path) -> None:
291
423
  _ok({"exists": True, "data": data})
292
424
 
293
425
 
294
- def cmd_write_tools(target: Path) -> None:
295
- raw = sys.stdin.read()
296
- if not raw.strip():
297
- _die(1, "write-tools: empty stdin (expected JSON payload)")
426
+ def _staged_object(path: Path):
427
+ """The JSON object a staged helper output holds; None when it holds none (or cannot be read)."""
298
428
  try:
299
- payload = json.loads(raw)
300
- except json.JSONDecodeError as e:
301
- _die(1, f"write-tools: invalid JSON on stdin: {e}")
429
+ # utf-8-sig: a shell that writes a byte-order mark still stages JSON.
430
+ value = json.loads(path.read_text(encoding="utf-8-sig"))
431
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
432
+ return None
433
+ return value if isinstance(value, dict) else None
434
+
435
+
436
+ def _staged_ccc_index(ccc_from: Path | None, ccc_available: bool = False) -> dict:
437
+ """forge-tier.yaml's `ccc_index` from the merge helper's --result-to file.
438
+
439
+ No file: the run did not prepare ccc (status "none"), or, when
440
+ `ccc_available`, the helper ran and wrote nothing (a usage error, or a
441
+ call its host stopped), so the preparation failed (status "failed"). A
442
+ file holding an error, or no JSON object: the preparation failed too.
443
+ Each keeps the exclusion record (exclude_patterns null). A result
444
+ without an `index` object comes from a run without --build-index,
445
+ which a setup run never makes: that is a usage error.
446
+ """
447
+ unset = {"indexed_path": None, "last_indexed": None, "file_count": None, "exclude_patterns": None}
448
+ if ccc_from is None or not ccc_from.exists():
449
+ if ccc_available:
450
+ return {**unset, "status": "failed"}
451
+ return {**unset, "status": "none"}
452
+ result = _staged_object(ccc_from)
453
+ if result is None or result.get("status") != "ok":
454
+ return {**unset, "status": "failed"}
455
+ index = result.get("index")
456
+ if not isinstance(index, dict):
457
+ _die(1, f"write-tools: {ccc_from} holds no `index` result (run the merge helper with --build-index)")
458
+ patterns = result.get("effective_patterns")
459
+ return {
460
+ "indexed_path": index.get("indexed_path"),
461
+ "last_indexed": index.get("last_indexed"),
462
+ "status": index.get("status"),
463
+ "file_count": index.get("file_count"),
464
+ "exclude_patterns": patterns if isinstance(patterns, list) else None,
465
+ }
466
+
467
+
468
+ def payload_from_staged(detect_from: Path, ccc_from: Path | None) -> dict:
469
+ """The write-tools payload from a setup run's staged detector and ccc outputs."""
470
+ detect = _staged_object(detect_from)
471
+ if detect is None:
472
+ _die(1, f"write-tools: {detect_from} holds no skf-detect-tools.py output")
473
+ tools, tier = detect.get("tools"), detect.get("tier")
474
+ if not isinstance(tools, dict) or not isinstance(tier, dict) or not tier.get("calculated"):
475
+ _die(1, f"write-tools: {detect_from} has no `tools` and `tier.calculated`")
476
+
477
+ def probe(key: str) -> dict:
478
+ value = tools.get(key)
479
+ return value if isinstance(value, dict) else {}
480
+
481
+ return {
482
+ "tools": {
483
+ **{key: probe(key).get("available") is True for key in TOOL_KEYS},
484
+ "ccc_daemon": probe("ccc").get("daemon"),
485
+ "security_scan": probe("security_scan").get("available") is True,
486
+ },
487
+ "tier": tier["calculated"],
488
+ "ccc_index": _staged_ccc_index(ccc_from, probe("ccc").get("available") is True),
489
+ }
490
+
491
+
492
+ def cmd_write_tools(target: Path, lock_timeout: float = REGISTRY_LOCK_WAIT_SEC,
493
+ detect_from: Path | None = None, ccc_from: Path | None = None) -> None:
494
+ if detect_from is not None:
495
+ payload = payload_from_staged(detect_from, ccc_from)
496
+ else:
497
+ if ccc_from is not None:
498
+ _die(1, "write-tools: --ccc-from needs --detect-from")
499
+ raw = sys.stdin.read()
500
+ if not raw.strip():
501
+ _die(1, "write-tools: empty stdin (expected JSON payload)")
502
+ try:
503
+ payload = json.loads(raw)
504
+ except json.JSONDecodeError as e:
505
+ _die(1, f"write-tools: invalid JSON on stdin: {e}")
302
506
 
303
507
  required = {"tools", "tier", "ccc_index"}
304
508
  missing = required - set(payload.keys())
@@ -307,11 +511,12 @@ def cmd_write_tools(target: Path) -> None:
307
511
 
308
512
  payload.setdefault("tier_detected_at", datetime.now(timezone.utc).isoformat())
309
513
 
310
- existing = _read_yaml(target)
311
- payload = _merge_preserved_fields(payload, existing)
514
+ with _registry_lock(target, "write-tools", lock_timeout) as stale_replaced:
515
+ existing = _read_yaml(target)
516
+ payload = _merge_preserved_fields(payload, existing)
312
517
 
313
- rendered = render_forge_tier_yaml(payload)
314
- _atomic_write(target, rendered)
518
+ rendered = render_forge_tier_yaml(payload)
519
+ _atomic_write(target, rendered)
315
520
  _ok({
316
521
  "wrote": str(target),
317
522
  "preserved_arrays": {
@@ -319,24 +524,12 @@ def cmd_write_tools(target: Path) -> None:
319
524
  "ccc_index_registry": len(payload.get("ccc_index_registry", [])),
320
525
  },
321
526
  "tier": payload["tier"],
527
+ "lock_stale_replaced": stale_replaced,
322
528
  })
323
529
 
324
530
 
325
- def cmd_init_prefs(target: Path) -> None:
326
- if target.exists():
327
- _ok({"exists": True, "wrote": False, "path": str(target)})
328
- return
329
- _atomic_write(target, PREFERENCES_TEMPLATE)
330
- _ok({"exists": True, "wrote": True, "path": str(target), "first_run": True})
331
-
332
-
333
- def cmd_register_qmd_collection(target: Path) -> None:
334
- # Note: render_forge_tier_yaml() emits exactly the six known top-level
335
- # sections (tools, tier, tier_detected_at, ccc_index, ccc_index_registry,
336
- # qmd_collections). If a future schema revision adds a new top-level key,
337
- # this subcommand will silently drop it — same limitation that already
338
- # affects cmd_write_tools and cmd_clean_stale. Update render_forge_tier_yaml()
339
- # AND those three subcommands together when the schema grows.
531
+ def cmd_register_qmd_collection(target: Path,
532
+ lock_timeout: float = REGISTRY_LOCK_WAIT_SEC) -> None:
340
533
  raw = sys.stdin.read()
341
534
  if not raw.strip():
342
535
  _die(1, "register-qmd-collection: empty stdin (expected JSON entry)")
@@ -351,41 +544,67 @@ def cmd_register_qmd_collection(target: Path) -> None:
351
544
  if not name or not isinstance(name, str):
352
545
  _die(1, "register-qmd-collection: entry must include a non-empty 'name' string")
353
546
 
354
- data = _read_yaml(target)
355
- if data is None:
356
- _die(1, f"register-qmd-collection: target does not exist: {target}. "
357
- f"Run setup workflow first to create forge-tier.yaml.")
358
-
359
- collections = list(data.get("qmd_collections") or [])
360
- replaced = False
361
- for i, existing in enumerate(collections):
362
- if isinstance(existing, dict) and existing.get("name") == name:
363
- collections[i] = entry
364
- replaced = True
365
- break
366
- if not replaced:
367
- collections.append(entry)
368
-
369
- payload = {
370
- "tools": data.get("tools", {}),
371
- "tier": data.get("tier", "Quick"),
372
- "tier_detected_at": data.get("tier_detected_at",
373
- datetime.now(timezone.utc).isoformat()),
374
- "ccc_index": data.get("ccc_index", {}),
375
- "ccc_index_registry": data.get("ccc_index_registry", []),
376
- "qmd_collections": collections,
377
- }
378
- rendered = render_forge_tier_yaml(payload)
379
- _atomic_write(target, rendered)
547
+ missing = (f"register-qmd-collection: target does not exist: {target}. "
548
+ f"Run setup workflow first to create forge-tier.yaml.")
549
+ if not target.exists():
550
+ _die(1, missing)
551
+ with _registry_lock(target, "register-qmd-collection", lock_timeout) as stale_replaced:
552
+ data = _read_yaml(target)
553
+ if data is None:
554
+ _die(1, missing)
555
+
556
+ collections = list(data.get("qmd_collections") or [])
557
+ replaced = False
558
+ for i, existing in enumerate(collections):
559
+ if isinstance(existing, dict) and existing.get("name") == name:
560
+ collections[i] = entry
561
+ replaced = True
562
+ break
563
+ if not replaced:
564
+ collections.append(entry)
565
+
566
+ rendered = render_forge_tier_yaml(_payload_from(data, qmd_collections=collections))
567
+ _atomic_write(target, rendered)
380
568
  _ok({
381
569
  "name": name,
382
570
  "action": "replaced" if replaced else "appended",
383
571
  "qmd_collections_count": len(collections),
384
572
  "wrote": str(target),
573
+ "lock_stale_replaced": stale_replaced,
574
+ })
575
+
576
+
577
+ def cmd_remove_qmd_collection(target: Path, name: str,
578
+ lock_timeout: float = REGISTRY_LOCK_WAIT_SEC) -> None:
579
+ if not name.strip():
580
+ _die(1, "remove-qmd-collection: --name must be a non-empty collection name")
581
+
582
+ missing = (f"remove-qmd-collection: target does not exist: {target}. "
583
+ f"Run setup workflow first to create forge-tier.yaml.")
584
+ if not target.exists():
585
+ _die(1, missing)
586
+ with _registry_lock(target, "remove-qmd-collection", lock_timeout) as stale_replaced:
587
+ data = _read_yaml(target)
588
+ if data is None:
589
+ _die(1, missing)
590
+
591
+ collections = list(data.get("qmd_collections") or [])
592
+ kept = [e for e in collections if not (isinstance(e, dict) and e.get("name") == name)]
593
+ removed = len(collections) - len(kept)
594
+ if removed:
595
+ rendered = render_forge_tier_yaml(_payload_from(data, qmd_collections=kept))
596
+ _atomic_write(target, rendered)
597
+ _ok({
598
+ "name": name,
599
+ "action": "removed" if removed else "absent",
600
+ "removed_count": removed,
601
+ "qmd_collections_count": len(kept),
602
+ "wrote": str(target) if removed else None,
603
+ "lock_stale_replaced": stale_replaced,
385
604
  })
386
605
 
387
606
 
388
- def cmd_register_ccc_index(target: Path) -> None:
607
+ def cmd_register_ccc_index(target: Path, lock_timeout: float = REGISTRY_LOCK_WAIT_SEC) -> None:
389
608
  raw = sys.stdin.read()
390
609
  if not raw.strip():
391
610
  _die(1, "register-ccc-index: empty stdin (expected JSON entry)")
@@ -403,100 +622,104 @@ def cmd_register_ccc_index(target: Path) -> None:
403
622
  if not skill_name or not isinstance(skill_name, str):
404
623
  _die(1, "register-ccc-index: entry must include a non-empty 'skill_name' string")
405
624
 
406
- data = _read_yaml(target)
407
- if data is None:
408
- _die(1, f"register-ccc-index: target does not exist: {target}. "
409
- f"Run setup workflow first to create forge-tier.yaml.")
410
-
411
- registry = list(data.get("ccc_index_registry") or [])
412
- replaced = False
413
- for i, existing in enumerate(registry):
414
- if (isinstance(existing, dict)
415
- and existing.get("source_repo") == source_repo
416
- and existing.get("skill_name") == skill_name):
417
- registry[i] = entry
418
- replaced = True
419
- break
420
- if not replaced:
421
- registry.append(entry)
422
-
423
- payload = {
424
- "tools": data.get("tools", {}),
425
- "tier": data.get("tier", "Quick"),
426
- "tier_detected_at": data.get("tier_detected_at",
427
- datetime.now(timezone.utc).isoformat()),
428
- "ccc_index": data.get("ccc_index", {}),
429
- "ccc_index_registry": registry,
430
- "qmd_collections": data.get("qmd_collections", []),
431
- }
432
- rendered = render_forge_tier_yaml(payload)
433
- _atomic_write(target, rendered)
625
+ missing = (f"register-ccc-index: target does not exist: {target}. "
626
+ f"Run setup workflow first to create forge-tier.yaml.")
627
+ if not target.exists():
628
+ _die(1, missing)
629
+ with _registry_lock(target, "register-ccc-index", lock_timeout) as stale_replaced:
630
+ data = _read_yaml(target)
631
+ if data is None:
632
+ _die(1, missing)
633
+
634
+ registry = list(data.get("ccc_index_registry") or [])
635
+ replaced = False
636
+ for i, existing in enumerate(registry):
637
+ if (isinstance(existing, dict)
638
+ and existing.get("source_repo") == source_repo
639
+ and existing.get("skill_name") == skill_name):
640
+ registry[i] = entry
641
+ replaced = True
642
+ break
643
+ if not replaced:
644
+ registry.append(entry)
645
+
646
+ rendered = render_forge_tier_yaml(_payload_from(data, ccc_index_registry=registry))
647
+ _atomic_write(target, rendered)
434
648
  _ok({
435
649
  "source_repo": source_repo,
436
650
  "skill_name": skill_name,
437
651
  "action": "replaced" if replaced else "appended",
438
652
  "ccc_index_registry_count": len(registry),
439
653
  "wrote": str(target),
654
+ "lock_stale_replaced": stale_replaced,
440
655
  })
441
656
 
442
657
 
443
- def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
444
- prune_missing_ccc_paths: bool) -> None:
445
- data = _read_yaml(target)
446
- if data is None:
447
- _die(1, f"clean-stale: target does not exist: {target}")
448
-
449
- qmd_removed: list[str] = []
450
- ccc_removed: list[str] = []
451
-
452
- if qmd_live_names is not None:
453
- live_set = set(qmd_live_names)
454
- kept = []
455
- for entry in data.get("qmd_collections", []) or []:
456
- if not isinstance(entry, dict):
457
- kept.append(entry)
458
- continue
459
- name = entry.get("name")
460
- if name in live_set:
461
- kept.append(entry)
462
- else:
463
- qmd_removed.append(str(name))
464
- data["qmd_collections"] = kept
465
-
466
- if prune_missing_ccc_paths:
467
- kept = []
468
- for entry in data.get("ccc_index_registry", []) or []:
469
- if not isinstance(entry, dict):
470
- kept.append(entry)
471
- continue
472
- entry_path = entry.get("path")
473
- if entry_path and Path(entry_path).exists():
474
- kept.append(entry)
475
- else:
476
- ccc_removed.append(str(entry_path))
477
- data["ccc_index_registry"] = kept
658
+ def live_names_from(path: Path) -> list[str] | None:
659
+ """The `live_names` of a staged classifier output, or None to skip QMD cleanup.
478
660
 
479
- if not qmd_removed and not ccc_removed:
480
- _ok({"qmd_removed": [], "ccc_removed": [], "wrote": False})
481
- return
661
+ A file that is not there (the classifier did not run) or that holds no
662
+ list of names (it failed) gives None, never [], which would remove
663
+ every qmd_collections entry.
664
+ """
665
+ if not path.exists():
666
+ return None
667
+ names = (_staged_object(path) or {}).get("live_names")
668
+ if not isinstance(names, list) or not all(isinstance(n, str) for n in names):
669
+ return None
670
+ return names
482
671
 
483
- # Round-trip through render to preserve the canonical format.
484
- # Existing data already contains the full state; render needs the same shape.
485
- payload = {
486
- "tools": data.get("tools", {}),
487
- "tier": data.get("tier", "Quick"),
488
- "tier_detected_at": data.get("tier_detected_at",
489
- datetime.now(timezone.utc).isoformat()),
490
- "ccc_index": data.get("ccc_index", {}),
491
- "ccc_index_registry": data.get("ccc_index_registry", []),
492
- "qmd_collections": data.get("qmd_collections", []),
493
- }
494
- rendered = render_forge_tier_yaml(payload)
495
- _atomic_write(target, rendered)
672
+
673
+ def cmd_clean_stale(target: Path, qmd_live_names: list[str] | None,
674
+ prune_missing_ccc_paths: bool,
675
+ lock_timeout: float = REGISTRY_LOCK_WAIT_SEC) -> None:
676
+ missing = f"clean-stale: target does not exist: {target}"
677
+ if not target.exists():
678
+ _die(1, missing)
679
+ with _registry_lock(target, "clean-stale", lock_timeout) as stale_replaced:
680
+ data = _read_yaml(target)
681
+ if data is None:
682
+ _die(1, missing)
683
+
684
+ qmd_removed: list[str] = []
685
+ ccc_removed: list[str] = []
686
+
687
+ if qmd_live_names is not None:
688
+ live_set = set(qmd_live_names)
689
+ kept = []
690
+ for entry in data.get("qmd_collections", []) or []:
691
+ if not isinstance(entry, dict):
692
+ kept.append(entry)
693
+ continue
694
+ name = entry.get("name")
695
+ if name in live_set:
696
+ kept.append(entry)
697
+ else:
698
+ qmd_removed.append(str(name))
699
+ data["qmd_collections"] = kept
700
+
701
+ if prune_missing_ccc_paths:
702
+ kept = []
703
+ for entry in data.get("ccc_index_registry", []) or []:
704
+ if not isinstance(entry, dict):
705
+ kept.append(entry)
706
+ continue
707
+ entry_path = entry.get("path")
708
+ if entry_path and Path(entry_path).exists():
709
+ kept.append(entry)
710
+ else:
711
+ ccc_removed.append(str(entry_path))
712
+ data["ccc_index_registry"] = kept
713
+
714
+ if qmd_removed or ccc_removed:
715
+ # Round-trip through render to preserve the canonical format.
716
+ rendered = render_forge_tier_yaml(_payload_from(data))
717
+ _atomic_write(target, rendered)
496
718
  _ok({
497
719
  "qmd_removed": qmd_removed,
498
720
  "ccc_removed": ccc_removed,
499
- "wrote": True,
721
+ "wrote": bool(qmd_removed or ccc_removed),
722
+ "lock_stale_replaced": stale_replaced,
500
723
  })
501
724
 
502
725
 
@@ -513,50 +736,91 @@ def main() -> None:
513
736
  p_read = sub.add_parser("read", help="Read a forge-tier.yaml and emit JSON")
514
737
  p_read.add_argument("--target", type=Path, required=True)
515
738
 
739
+ def add_lock_timeout(p: argparse.ArgumentParser) -> None:
740
+ p.add_argument("--lock-timeout", type=float, default=REGISTRY_LOCK_WAIT_SEC,
741
+ help="seconds to wait while another call holds forge-tier.yaml.lock "
742
+ f"(default {REGISTRY_LOCK_WAIT_SEC:g})")
743
+
516
744
  p_write = sub.add_parser("write-tools",
517
- help="Write a fresh forge-tier.yaml from a JSON payload on stdin")
745
+ help="Write a fresh forge-tier.yaml from a JSON payload on stdin, "
746
+ "or from a setup run's staged outputs")
518
747
  p_write.add_argument("--target", type=Path, required=True)
519
-
520
- p_init = sub.add_parser("init-prefs",
521
- help="Create preferences.yaml with first-run defaults if missing")
522
- p_init.add_argument("--target", type=Path, required=True)
748
+ p_write.add_argument("--detect-from", type=Path, default=None,
749
+ help="skf-detect-tools.py's staged output; read instead of stdin.")
750
+ p_write.add_argument("--ccc-from", type=Path, default=None,
751
+ help="skf-merge-ccc-exclusions.py's --result-to file (with --detect-from). "
752
+ "Not there: ccc was not prepared this run, or, when the detector "
753
+ "reports ccc available, the preparation failed.")
754
+ add_lock_timeout(p_write)
523
755
 
524
756
  p_clean = sub.add_parser("clean-stale",
525
757
  help="Remove stale qmd_collections / ccc_index_registry entries")
526
758
  p_clean.add_argument("--target", type=Path, required=True)
527
- p_clean.add_argument("--qmd-live-names", default=None,
528
- help="Comma-separated list of currently-live QMD collection names. "
529
- "Entries in qmd_collections whose name is NOT in this list are removed. "
530
- "Omit the flag entirely to skip QMD cleanup.")
759
+ live = p_clean.add_mutually_exclusive_group()
760
+ live.add_argument("--qmd-live-from", type=Path, default=None,
761
+ help="skf-qmd-classify-collections.py's staged output. Entries in "
762
+ "qmd_collections whose name is NOT in its live_names are removed. A file "
763
+ "that is not there, or holds no live_names list, skips QMD cleanup.")
764
+ live.add_argument("--qmd-live-names", default=None,
765
+ help="Comma-separated list of currently-live QMD collection names. "
766
+ "Entries in qmd_collections whose name is NOT in this list are removed. "
767
+ "Omit the flag entirely to skip QMD cleanup.")
531
768
  p_clean.add_argument("--prune-missing-ccc-paths", action="store_true",
532
769
  help="Remove ccc_index_registry entries whose path no longer exists.")
770
+ add_lock_timeout(p_clean)
533
771
 
534
772
  p_register = sub.add_parser("register-qmd-collection",
535
773
  help="Append-or-replace a single qmd_collections entry by name")
536
774
  p_register.add_argument("--target", type=Path, required=True)
775
+ add_lock_timeout(p_register)
776
+
777
+ p_remove = sub.add_parser("remove-qmd-collection",
778
+ help="Remove the qmd_collections entries with this name (rollback)")
779
+ p_remove.add_argument("--target", type=Path, required=True)
780
+ p_remove.add_argument("--name", required=True, help="the collection name to remove")
781
+ add_lock_timeout(p_remove)
537
782
 
538
783
  p_ccc = sub.add_parser("register-ccc-index",
539
784
  help="Append-or-replace a single ccc_index_registry entry by source_repo+skill_name")
540
785
  p_ccc.add_argument("--target", type=Path, required=True)
786
+ add_lock_timeout(p_ccc)
541
787
 
542
788
  args = parser.parse_args()
543
789
 
790
+ lock_timeout = getattr(args, "lock_timeout", REGISTRY_LOCK_WAIT_SEC)
791
+ if not math.isfinite(lock_timeout) or lock_timeout < 0:
792
+ _die(1, f"{args.cmd}: --lock-timeout must be a number of seconds, 0 or more")
793
+
544
794
  if args.cmd == "read":
545
795
  cmd_read(args.target)
546
796
  elif args.cmd == "write-tools":
547
- cmd_write_tools(args.target)
548
- elif args.cmd == "init-prefs":
549
- cmd_init_prefs(args.target)
797
+ cmd_write_tools(args.target, lock_timeout, args.detect_from, args.ccc_from)
550
798
  elif args.cmd == "clean-stale":
551
799
  live = None
552
- if args.qmd_live_names is not None:
800
+ if args.qmd_live_from is not None:
801
+ live = live_names_from(args.qmd_live_from)
802
+ elif args.qmd_live_names is not None:
553
803
  live = [n.strip() for n in args.qmd_live_names.split(",") if n.strip()]
554
- cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths)
804
+ cmd_clean_stale(args.target, live, args.prune_missing_ccc_paths, lock_timeout)
555
805
  elif args.cmd == "register-qmd-collection":
556
- cmd_register_qmd_collection(args.target)
806
+ cmd_register_qmd_collection(args.target, lock_timeout)
807
+ elif args.cmd == "remove-qmd-collection":
808
+ cmd_remove_qmd_collection(args.target, args.name, lock_timeout)
557
809
  elif args.cmd == "register-ccc-index":
558
- cmd_register_ccc_index(args.target)
810
+ cmd_register_ccc_index(args.target, lock_timeout)
811
+
812
+
813
+ def _force_utf8(*streams) -> None:
814
+ """Reconfigure stdin and the JSON streams to UTF-8 (a Windows console uses cp1252)."""
815
+ for stream in streams:
816
+ if hasattr(stream, "reconfigure"):
817
+ errors = getattr(stream, "errors", None)
818
+ if errors is None:
819
+ stream.reconfigure(encoding="utf-8")
820
+ else:
821
+ stream.reconfigure(encoding="utf-8", errors=errors)
559
822
 
560
823
 
561
824
  if __name__ == "__main__":
825
+ _force_utf8(sys.stdin, sys.stdout, sys.stderr)
562
826
  main()