bmad-module-skill-forge 2.2.0 → 3.0.0

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