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
@@ -0,0 +1,180 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/armelhbobdad/bmad-module-skill-forge/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json",
4
+ "title": "SKF_VERIFY_STACK_RESULT_JSON envelope (v1)",
5
+ "description": "Single-line JSON document skf-verify-stack emits when {headless_mode} is true, prefixed with the literal 'SKF_VERIFY_STACK_RESULT_JSON: ': once on stdout when step 6 (report) finishes, before it chains to the health check, and once on stderr at every HARD HALT (status error). The shared emitter, skf-emit-result-envelope.py, builds every line from the payload a stage stages in the run folder, folds in the auto-decisions and warnings the run recorded there, derives exit_code from halt_reason, stamps run_id and result_path, and checks the line against this schema, so the model never types it. The skill's references/exit-codes.md describes each field and the halt command.",
6
+ "$defs": {
7
+ "skf-envelope": {
8
+ "description": "The shared emitter's settings for this workflow, read by skf-emit-result-envelope.py: the workflow, the line's prefix, the wrapper, the status a halt carries, the exit code of each halt_reason, the result file stem and the stamp the per-run result file is named after (the run's run_id). Nothing in the schema references this entry, so it constrains no envelope.",
9
+ "const": {
10
+ "workflow": "skf-verify-stack",
11
+ "prefix": "SKF_VERIFY_STACK_RESULT_JSON",
12
+ "wrapper": null,
13
+ "halt_status": "error",
14
+ "exit_codes": {
15
+ "input-missing": 2,
16
+ "input-invalid": 2,
17
+ "skills-folder-missing": 3,
18
+ "forge-folder-unconfigured": 3,
19
+ "resolution-failure": 3,
20
+ "write-failed": 4,
21
+ "insufficient-skills": 5,
22
+ "previous-report-collision": 5,
23
+ "schema-violation": 5,
24
+ "user-cancelled": 6,
25
+ "inventory-unreliable": 7
26
+ },
27
+ "success_exit_code": 0,
28
+ "result_file": "verify-stack-result",
29
+ "result_stamp": "run_id"
30
+ }
31
+ }
32
+ },
33
+ "type": "object",
34
+ "additionalProperties": false,
35
+ "required": [
36
+ "status",
37
+ "report_path",
38
+ "report_latest_path",
39
+ "overall_verdict",
40
+ "coverage_percentage",
41
+ "recommendation_count",
42
+ "exit_code",
43
+ "halt_reason",
44
+ "run_id",
45
+ "result_path"
46
+ ],
47
+ "properties": {
48
+ "status": {
49
+ "type": "string",
50
+ "enum": ["success", "error"],
51
+ "description": "'success' when step 6 finished: the report passed the feasibility-report check and its -latest copy was written. 'error' on any HARD HALT."
52
+ },
53
+ "report_path": {
54
+ "type": ["string", "null"],
55
+ "minLength": 1,
56
+ "default": null,
57
+ "description": "The run's timestamped feasibility report, feasibility-report-<slug>-<timestamp>.md. On a halt it names the partial report the run wrote so far (step 1 writes it, and steps 2 to 5 fill it in); null when the run halted before step 1 wrote it."
58
+ },
59
+ "report_latest_path": {
60
+ "type": ["string", "null"],
61
+ "minLength": 1,
62
+ "default": null,
63
+ "description": "The feasibility-report-<slug>-latest.md copy of the finished report. Step 6 writes it only after the report passed the feasibility-report check, so it is null on a halt before that, which leaves the previous run's copy in place; the one later halt (the result contract could not be written) names the copy it published."
64
+ },
65
+ "overall_verdict": {
66
+ "type": ["string", "null"],
67
+ "enum": ["FEASIBLE", "CONDITIONALLY_FEASIBLE", "NOT_FEASIBLE", null],
68
+ "default": null,
69
+ "description": "The report's overallVerdict (case-sensitive schema tokens), as the feasibility-report check read it from the finished report. Null on a halt."
70
+ },
71
+ "coverage_percentage": {
72
+ "type": "integer",
73
+ "minimum": 0,
74
+ "default": 0,
75
+ "description": "The report's coveragePercentage, a whole number from 0 to 100 that the coverage tally helper computed, as the feasibility-report check read it from the finished report. 0 on a halt."
76
+ },
77
+ "recommendation_count": {
78
+ "type": "integer",
79
+ "minimum": 0,
80
+ "default": 0,
81
+ "description": "The report's recommendationCount, the count the verdict rollup helper derived from the report's counts (one recommendation per Missing or Replaced technology, per Blocked, Risky or Plausible row, for no integration pair, and per unmet requirement). 0 on a halt."
82
+ },
83
+ "exit_code": {
84
+ "type": "integer",
85
+ "enum": [0, 2, 3, 4, 5, 6, 7],
86
+ "description": "Process exit code matching references/exit-codes.md, derived from halt_reason: null gives 0, input-missing and input-invalid 2, skills-folder-missing, forge-folder-unconfigured and resolution-failure 3, write-failed 4, insufficient-skills, previous-report-collision and schema-violation 5, user-cancelled 6, inventory-unreliable 7."
87
+ },
88
+ "halt_reason": {
89
+ "type": ["string", "null"],
90
+ "enum": [
91
+ null,
92
+ "input-missing",
93
+ "input-invalid",
94
+ "skills-folder-missing",
95
+ "forge-folder-unconfigured",
96
+ "resolution-failure",
97
+ "write-failed",
98
+ "insufficient-skills",
99
+ "previous-report-collision",
100
+ "schema-violation",
101
+ "user-cancelled",
102
+ "inventory-unreliable"
103
+ ],
104
+ "description": "Null on success. One of the documented HALT classes on error, so a pipeline branches on the failure class without parsing the prose message."
105
+ },
106
+ "run_id": {
107
+ "type": ["string", "null"],
108
+ "minLength": 1,
109
+ "description": "The run's id, stamped by the emitter from the run folder name: the run's timestamp (YYYYMMDD-HHmmss, UTC), the same stamp the timestamped report's file name carries. Null for a halt before the run folder exists."
110
+ },
111
+ "result_path": {
112
+ "type": ["string", "null"],
113
+ "minLength": 1,
114
+ "description": "The per-run result file the emitter wrote in the forge data folder, verify-stack-result-<YYYYMMDD-HHmmss>.json, named after the run's own stamp (its run_id, the stamp the timestamped report's file name carries; -2, -3, ... appended when a run already took the name), beside its verify-stack-result-latest.json copy. Stamped by the emitter, never typed. Null on every halt, which writes no result file, and when the write failed (warnings say why)."
115
+ },
116
+ "headless_decisions": {
117
+ "type": "array",
118
+ "items": {
119
+ "type": "object",
120
+ "additionalProperties": false,
121
+ "required": ["gate", "default_action", "taken_action", "reason"],
122
+ "properties": {
123
+ "gate": {
124
+ "type": "string",
125
+ "enum": ["init.previous-report"],
126
+ "description": "The gate that auto-resolved: step 1's offer to compare against the newest earlier report. The gate records its decision in the run sink the moment it decides."
127
+ },
128
+ "default_action": {
129
+ "type": "string",
130
+ "minLength": 1,
131
+ "description": "The action documented as the gate's default ('Y' for [Y] use it)."
132
+ },
133
+ "taken_action": {
134
+ "type": "string",
135
+ "minLength": 1,
136
+ "description": "The action the run took. In a headless run it equals default_action."
137
+ },
138
+ "reason": {
139
+ "type": "string",
140
+ "minLength": 1,
141
+ "description": "Human-readable rationale, such as 'headless: delta comparison against <path>'."
142
+ },
143
+ "evidence": {
144
+ "type": "object",
145
+ "additionalProperties": true,
146
+ "description": "Optional gate-specific data, such as the previous report the run compares against."
147
+ }
148
+ }
149
+ },
150
+ "description": "Optional. One entry per gate that auto-resolved during the run, read from the run sink, so a halt reports the decisions taken before it. Present only when there is at least one."
151
+ },
152
+ "warnings": {
153
+ "type": "array",
154
+ "items": { "type": "string", "minLength": 1 },
155
+ "description": "Optional. Non-fatal anomalies the run recorded in its sink or the emitter raised, one string each, starting with its code: no_technology_referenced, no_live_technology or zero_coverage when the coverage step found no Covered technology (the architecture names none, marks each one for removal, or no skill matches one), skill_modified_mid_run when a paired skill changed after step 1 read it, all_pairs_blocked when every integration pair is Blocked, and result_file_write_failed when a result file could not be written (the emitter's own, or step 6's when it cannot create the forge data folder). Present only when there is at least one."
156
+ },
157
+ "error": {
158
+ "type": "object",
159
+ "additionalProperties": false,
160
+ "required": ["phase", "reason"],
161
+ "properties": {
162
+ "phase": {
163
+ "type": "string",
164
+ "minLength": 1,
165
+ "description": "Where the run halted: the step and gate, for example 'init:skills-folder' or 'report:check'."
166
+ },
167
+ "reason": {
168
+ "type": "string",
169
+ "minLength": 1,
170
+ "description": "The halt message the step displayed, on one line."
171
+ },
172
+ "path": {
173
+ "type": "string",
174
+ "description": "Optional path the failed operation targeted."
175
+ }
176
+ },
177
+ "description": "Present on a HARD HALT only: where the run stopped and why. Pipelines branch on halt_reason and read error for the diagnostic."
178
+ }
179
+ }
180
+ }
@@ -111,6 +111,40 @@
111
111
  "reason": { "type": "string", "minLength": 1 },
112
112
  "recorded": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}$" }
113
113
  }
114
+ },
115
+ "amendments": {
116
+ "type": "array",
117
+ "description": "Optional additive audit log of scope decisions workflows made after the brief was authored (skf-brief-skill assets/skill-brief-schema.md, Scope Amendments). An entry without category is an auth-doc decision.",
118
+ "items": {
119
+ "type": "object",
120
+ "properties": {
121
+ "category": {
122
+ "type": "string",
123
+ "description": "The workflow path that wrote the entry: auth-doc (also an entry without category), scope-expansion or demo-and-registry."
124
+ }
125
+ }
126
+ }
127
+ },
128
+ "registry_path": {
129
+ "type": "string",
130
+ "minLength": 1,
131
+ "description": "Optional, for scope.type component-library: the component registry file, relative to the source root. skf-create-skill step 3d reads it instead of detecting a registry, and writes it back when the user confirms or gives one."
132
+ },
133
+ "ui_variants": {
134
+ "type": "array",
135
+ "description": "Optional, for scope.type component-library: the design system variants, the first one primary.",
136
+ "items": {
137
+ "type": "object",
138
+ "properties": {
139
+ "name": { "type": "string", "minLength": 1 },
140
+ "package": { "type": "string", "minLength": 1 }
141
+ }
142
+ }
143
+ },
144
+ "demo_patterns": {
145
+ "type": "array",
146
+ "description": "Optional, for scope.type component-library: globs of the demo and example files step 3d leaves out. skf-create-skill step 3d reads them instead of detecting demo files, and writes them back when the user confirms or gives them.",
147
+ "items": { "type": "string", "minLength": 1 }
114
148
  }
115
149
  }
116
150
  }
@@ -16,7 +16,7 @@
16
16
  "oneOf": [
17
17
  {
18
18
  "type": "string",
19
- "enum": ["npm-workspaces", "pnpm-workspaces", "lerna", "cargo-workspace", "python-multi-package", "generic-folders"]
19
+ "enum": ["npm-workspaces", "pnpm-workspaces", "lerna", "rush", "nx", "cargo-workspace", "python-multi-package", "generic-folders"]
20
20
  },
21
21
  { "type": "null" }
22
22
  ]
@@ -1,10 +1,10 @@
1
1
  # /// script
2
- # requires-python = ">=3.10"
2
+ # requires-python = ">=3.11"
3
3
  # dependencies = []
4
4
  # ///
5
5
  """SKF Atomic Write — Crash-safe artifact writing for skill workflows.
6
6
 
7
- Provides three CLI subcommands skills can invoke via bash to avoid
7
+ Provides four CLI subcommands skills can invoke via bash to avoid
8
8
  partial-write corruption and active-symlink races.
9
9
 
10
10
  Subcommands:
@@ -21,19 +21,39 @@ Subcommands:
21
21
  restore. Supports rollback via --rollback to undo the most recent
22
22
  commit by restoring the rollback dir if still present.
23
23
 
24
- flip-link Atomically update symlink <link> to point at <target> using
25
- the `ln -sfn tmp && mv -Tf tmp link` pattern (or equivalent via
26
- os.replace on the link path). Holds an flock on <link>.lock.
24
+ flip-link Point the link <link> at <target>: create the new link as
25
+ <link>.skf-tmp-link, then os.replace it over <link>, holding an
26
+ exclusive lock on <link>.skf-lock, which only the flip holding
27
+ it removes. skf-update-active-symlink.py update takes the same
28
+ lock (take_flip_lock), so one flip of a link runs at a time.
29
+ Refuses to replace a <link> that is a real directory or file.
27
30
 
28
31
  Cross-platform: locking branches between fcntl (POSIX) and msvcrt
29
- (Windows). Symlink semantics on Windows require dev mode or admin —
30
- flip-link surfaces a clear error rather than silently falling back.
31
- Native Windows is untested in CI; the supported path is WSL2.
32
+ (Windows). On Windows os.symlink needs Developer Mode or admin; when it
33
+ fails for lack of privilege, flip-link creates a directory junction
34
+ (mklink /J) instead, which needs neither but requires <target> to be an
35
+ existing directory. Windows renames nothing over an existing directory
36
+ link, so on the junction path, and on Windows whenever the rename over the
37
+ old link is refused, replace_link removes the old link first (a junction
38
+ whose folder is gone included) and the flip is not atomic. The Windows
39
+ tests in test-skf-atomic-write.py and test-skf-update-active-symlink.py
40
+ make os.symlink fail as it does without the privilege, so CI's Windows
41
+ runner, which holds it, runs the junction path too.
32
42
 
33
43
  Exit codes:
34
- 0 on success
35
- 1 on user error (bad args, missing input)
36
- 2 on operation failure (disk full, permission, race-detected)
44
+ 0 on success (JSON on stdout)
45
+ 1 when commit-dir has no staging or rollback directory to use (JSON
46
+ error on stderr), and on any unexpected error (a Python traceback on
47
+ stderr); for flip-link that includes Windows refusing both a symlink
48
+ and a junction
49
+ 2 on a refused or failed operation, with {"status": "error", "message":
50
+ ...} on stderr: write failed, stage-dir could not clear a stale entry,
51
+ commit-dir found a non-directory target or could not swap, flip-link
52
+ found a non-link at <link>, another process holding its lock, a
53
+ lock file Windows kept refusing to open for its short wait while the
54
+ file was being removed, or (on POSIX) a locked lock file that kept
55
+ leaving the path for that same wait; and on bad arguments (argparse
56
+ usage on stderr, no JSON)
37
57
 
38
58
  CLI examples:
39
59
  cat metadata.json | python3 skf-atomic-write.py write --target /path/to/metadata.json
@@ -51,6 +71,7 @@ import os
51
71
  import shutil
52
72
  import subprocess
53
73
  import sys
74
+ import time
54
75
  from pathlib import Path
55
76
 
56
77
  if os.name == "nt":
@@ -58,6 +79,18 @@ if os.name == "nt":
58
79
  else:
59
80
  import fcntl
60
81
 
82
+ # While the flip that held <link>.skf-lock is removing it, Windows refuses a
83
+ # new open of the file with PermissionError. flip-link takes that for a held
84
+ # lock and tries again for up to this long (a flip fails fast on contention
85
+ # by design, so the wait is short), then exits 2 as it does for a held lock.
86
+ # The same wait bounds a POSIX flip that keeps finding the file it locked
87
+ # gone from the path: it opens the path again until then, then exits 2.
88
+ # A caller that gives take_flip_lock a longer `wait` (skf-update-active-symlink.py
89
+ # update) keeps both retries going until that wait ends.
90
+ _IS_WINDOWS = os.name == "nt"
91
+ _LOCK_OPEN_WAIT_SECONDS = 1.0
92
+ _LOCK_OPEN_POLL_SECONDS = 0.02
93
+
61
94
 
62
95
  def _create_symlink_or_junction(target: str, link_path: Path) -> str:
63
96
  """Create a directory link from link_path to target.
@@ -129,6 +162,16 @@ def _release_lock(fd: int) -> None:
129
162
  pass
130
163
 
131
164
 
165
+ # Keep identical to _names_open_file in gap-ledger.py
166
+ # (test/test-skf-atomic-write.py pins the copy).
167
+ def _names_open_file(fd: int, path: Path) -> bool:
168
+ """True when `path` still names the file open as `fd`."""
169
+ try:
170
+ return os.path.samestat(os.fstat(fd), os.stat(path))
171
+ except OSError:
172
+ return False
173
+
174
+
132
175
  def _die(code: int, message: str) -> None:
133
176
  print(json.dumps({"status": "error", "message": message}), file=sys.stderr)
134
177
  sys.exit(code)
@@ -247,13 +290,17 @@ def cmd_commit_dir(target: Path, rollback: bool = False) -> None:
247
290
  _ok({"committed": str(target)})
248
291
 
249
292
 
293
+ # Keep identical to _is_link_or_junction in skf-skill-inventory.py
294
+ # (test/test-skf-skill-inventory.py pins the copies).
250
295
  def _is_link_or_junction(p: Path) -> bool:
251
296
  """True for POSIX symlinks AND Windows junctions/symlinks.
252
297
 
253
298
  `Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
254
299
  for both symlinks and junctions (since CPython 3.8 on Windows). A regular
255
300
  directory raises OSError on readlink, which is the signal we want to
256
- refuse replacement.
301
+ refuse replacement. On Windows, any other reparse point (a cloud-sync
302
+ placeholder, a deduplicated file, an app execution alias) raises
303
+ ValueError: it does not redirect to another path, so it is not a link.
257
304
  """
258
305
  if p.is_symlink():
259
306
  return True
@@ -262,22 +309,186 @@ def _is_link_or_junction(p: Path) -> bool:
262
309
  try:
263
310
  os.readlink(p)
264
311
  return True
265
- except OSError:
312
+ except (OSError, ValueError):
266
313
  return False
267
314
 
268
315
 
316
+ def _remove_link(p: Path) -> None:
317
+ """Remove the symlink or junction at p, whether or not the folder it names exists.
318
+
319
+ os.readlink raises for a real folder or file (OSError) and for another
320
+ Windows reparse point (ValueError), so neither is ever removed. os.unlink
321
+ removes a symlink, and on Windows a directory symlink or a junction too;
322
+ os.rmdir is the fallback for a directory link unlink refuses.
323
+ """
324
+ if not p.is_symlink():
325
+ os.readlink(p)
326
+ try:
327
+ os.unlink(p)
328
+ except OSError:
329
+ os.rmdir(p)
330
+
331
+
332
+ def replace_link(link: Path, target: str, tmp_link: Path | None = None) -> str:
333
+ """Point the link <link> at <target> through <tmp_link>; return "symlink" or "junction".
334
+
335
+ The caller holds take_flip_lock(link) and has refused a real folder or
336
+ file at <link>. A stale <tmp_link> from a crashed flip is removed first.
337
+ The new link is made at <tmp_link> and renamed over <link>, which is
338
+ atomic for a symlink on POSIX. Windows renames nothing over an existing
339
+ directory link, so the old link is removed first on the junction path,
340
+ and on Windows when the rename is refused: a reader can then find no
341
+ <link> for a moment. A failure removes <tmp_link> and raises.
342
+ """
343
+ if tmp_link is None:
344
+ tmp_link = link.with_name(link.name + ".skf-tmp-link")
345
+ if os.path.lexists(tmp_link):
346
+ _remove_link(tmp_link)
347
+ link_kind = _create_symlink_or_junction(target, tmp_link)
348
+ try:
349
+ if link_kind == "junction" and os.path.lexists(link):
350
+ _remove_link(link)
351
+ try:
352
+ os.replace(tmp_link, link)
353
+ except OSError:
354
+ if not _IS_WINDOWS or not os.path.lexists(link):
355
+ raise
356
+ _remove_link(link)
357
+ os.replace(tmp_link, link)
358
+ except BaseException:
359
+ try:
360
+ if os.path.lexists(tmp_link):
361
+ _remove_link(tmp_link)
362
+ except (OSError, ValueError):
363
+ pass
364
+ raise
365
+ return link_kind
366
+
367
+
368
+ class FlipLockHeld(Exception):
369
+ """Another flip holds <link>.skf-lock; the message names <link>."""
370
+
371
+
372
+ def take_flip_lock(link: Path, wait: float = 0.0) -> int:
373
+ """Take <link>.skf-lock, the lock every flip of <link> holds, and return its fd.
374
+
375
+ flip-link and skf-update-active-symlink.py update both take it, so one
376
+ flip of a link runs at a time. `wait` is how long to keep trying a lock
377
+ another flip holds: the call closes the file, sleeps
378
+ _LOCK_OPEN_POLL_SECONDS and opens the path again. flip-link passes 0
379
+ and is refused at once. Raises FlipLockHeld when the lock stays held
380
+ past `wait`, and when the retries cmd_flip_link's docstring describes
381
+ (Windows refusing the open, a locked file that left the path) run out:
382
+ they run until max(_LOCK_OPEN_WAIT_SECONDS, wait). release_flip_lock
383
+ gives the lock back.
384
+ """
385
+ lock_path = link.with_name(link.name + ".skf-lock")
386
+ held_deadline = time.monotonic() + wait
387
+ deadline = time.monotonic() + max(_LOCK_OPEN_WAIT_SECONDS, wait)
388
+ vanished = False
389
+ while True:
390
+ try:
391
+ lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
392
+ except PermissionError as exc:
393
+ # Windows refuses to open a file whose removal is pending. A folder
394
+ # at the path, or nothing there twice running (once can be the
395
+ # removal ending just now), is a lasting denial: raise it.
396
+ if not _IS_WINDOWS or os.path.isdir(lock_path):
397
+ raise
398
+ if not os.path.lexists(lock_path):
399
+ if vanished:
400
+ raise
401
+ vanished = True
402
+ continue
403
+ vanished = False
404
+ if time.monotonic() >= deadline:
405
+ raise FlipLockHeld(f"another process holds flip lock on {link} (last open refused: {exc})")
406
+ time.sleep(_LOCK_OPEN_POLL_SECONDS)
407
+ continue
408
+ held = False
409
+ try:
410
+ try:
411
+ _acquire_lock(lock_fd)
412
+ except OSError as e:
413
+ if e.errno != errno.EAGAIN:
414
+ raise
415
+ if time.monotonic() >= held_deadline:
416
+ raise FlipLockHeld(f"another process holds flip lock on {link}")
417
+ held = True
418
+ else:
419
+ # os.name here and in release_flip_lock follows the real OS; _IS_WINDOWS is the test seam of the
420
+ # open retry and of replace_link's remove-first retry.
421
+ if os.name == "nt" or _names_open_file(lock_fd, lock_path):
422
+ return lock_fd
423
+ _release_lock(lock_fd)
424
+ except BaseException:
425
+ os.close(lock_fd)
426
+ raise
427
+ os.close(lock_fd)
428
+ if held:
429
+ time.sleep(_LOCK_OPEN_POLL_SECONDS)
430
+ continue
431
+ if time.monotonic() >= deadline:
432
+ raise FlipLockHeld(f"another process holds flip lock on {link}")
433
+
434
+
435
+ def release_flip_lock(link: Path, lock_fd: int) -> None:
436
+ """Give back the lock take_flip_lock returned, removing <link>.skf-lock."""
437
+ lock_path = link.with_name(link.name + ".skf-lock")
438
+ # Only the flip holding the lock gets here, so it alone removes the
439
+ # lock file: on POSIX while the lock is still held, so that a flip on
440
+ # the old file sees the path no longer names it; on Windows, which
441
+ # removes no open file, after the release and the close. Best effort:
442
+ # a leftover file is locked and removed by the next flip.
443
+ if os.name != "nt":
444
+ try:
445
+ lock_path.unlink()
446
+ except OSError:
447
+ pass
448
+ _release_lock(lock_fd)
449
+ os.close(lock_fd)
450
+ if os.name == "nt":
451
+ try:
452
+ lock_path.unlink()
453
+ except OSError:
454
+ pass
455
+
456
+
269
457
  def cmd_flip_link(link: Path, target: str) -> None:
270
458
  """Atomically point <link> at <target> using rename-over-symlink pattern.
271
459
 
272
460
  target is the value of the symlink (may be relative, as is convention
273
- for `active -> 1.0.0`). Held lock on <link>.lock prevents concurrent flips.
461
+ for `active -> 1.0.0`). A lock on <link>.skf-lock, which
462
+ skf-update-active-symlink.py update takes too, prevents concurrent flips.
274
463
 
275
464
  On Windows, os.symlink requires Developer Mode or admin. When that fails
276
465
  with PRIVILEGE_NOT_HELD/ACCESS_DENIED the helper falls back to a directory
277
466
  junction (no elevation needed); junctions resolve identically for
278
- skf-skill-inventory's resolve_active_version().
467
+ skf-skill-inventory's resolve_active_version(). replace_link makes the
468
+ new link and renames it over <link>; skf-update-active-symlink.py update
469
+ flips through it too.
470
+
471
+ The lock follows ledger_lock in gap-ledger.py (the same identity check
472
+ and removal order): a fix to one belongs in both. The lock file is
473
+ removed on release, by the flip that held the lock: a flip refused by a
474
+ held lock exits 2 and leaves the file to its holder. On POSIX the
475
+ removal happens while the lock is still held, and a flip checks that
476
+ the file it locked is still the one at the path: otherwise a flip on
477
+ the removed file and a newcomer on a new one would both get a lock. A
478
+ flip whose file is no longer at the path lets it go and opens the path
479
+ again: it goes on as soon as it locks the file that is at the path, and
480
+ exits 2 at once when another flip holds that file. Only when the files
481
+ it locks keep leaving the path until _LOCK_OPEN_WAIT_SECONDS runs out
482
+ does it exit 2 with the "another process holds flip lock" message a
483
+ held lock gives. Windows removes no file that is open, so there the file goes after the
484
+ release, unless another flip has it open. While the removal is under
485
+ way (the remover's own delete handle is open, or a virus scanner's or
486
+ the search indexer's), Windows refuses a new open of the file with
487
+ PermissionError: the flip retries for up to _LOCK_OPEN_WAIT_SECONDS,
488
+ then exits 2 with the held-lock message, naming the last refusal. A
489
+ refusal with a folder at the path, or with nothing there on an
490
+ immediate second try, is a lasting denial and is raised as before.
279
491
  """
280
- lock_path = link.with_name(link.name + ".skf-lock")
281
492
  link.parent.mkdir(parents=True, exist_ok=True)
282
493
 
283
494
  # Refuse only if <link> is a real directory/file (not a symlink or junction).
@@ -285,56 +496,14 @@ def cmd_flip_link(link: Path, target: str) -> None:
285
496
  if link.exists() and not _is_link_or_junction(link):
286
497
  _die(2, f"refusing to replace non-link: {link}")
287
498
 
288
- lock_fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
289
- lock_held = False
290
499
  try:
291
- try:
292
- _acquire_lock(lock_fd)
293
- lock_held = True
294
- except OSError as e:
295
- if e.errno == errno.EAGAIN:
296
- _die(2, f"another process holds flip lock on {link}")
297
- raise
298
-
299
- tmp_link = link.with_name(link.name + ".skf-tmp-link")
300
- if tmp_link.is_symlink() or tmp_link.exists():
301
- # On Windows a junction returns is_dir()=True, exists()=True but
302
- # is_symlink()=False — must rmdir/unlink based on type.
303
- if tmp_link.is_dir() and not tmp_link.is_symlink():
304
- tmp_link.rmdir()
305
- else:
306
- tmp_link.unlink()
307
- try:
308
- link_kind = _create_symlink_or_junction(target, tmp_link)
309
- except OSError as e:
310
- if os.name == "nt" and getattr(e, "winerror", None) in (1314, 5):
311
- _die(
312
- 2,
313
- "symlink/junction creation failed on Windows. Junction fallback "
314
- "requires the target directory to exist. Either enable Developer "
315
- "Mode (Settings → Privacy & Security → For Developers) or use WSL2.",
316
- )
317
- raise
318
- # os.replace can swap a symlink-over-symlink atomically, but Windows
319
- # rejects renaming a directory (junction) over an existing directory
320
- # (junction or real). Drop the existing link first when junction-based.
321
- if link_kind == "junction" and (link.is_dir() or link.is_symlink() or link.exists()):
322
- if link.is_symlink() or not link.is_dir():
323
- link.unlink()
324
- else:
325
- link.rmdir()
326
- os.replace(tmp_link, link)
500
+ lock_fd = take_flip_lock(link)
501
+ except FlipLockHeld as exc:
502
+ _die(2, str(exc))
503
+ try:
504
+ link_kind = replace_link(link, target)
327
505
  finally:
328
- if lock_held:
329
- _release_lock(lock_fd)
330
- os.close(lock_fd)
331
- # Lock file is bookkeeping only — once the fd is closed the lock is
332
- # gone, so leaving the file around just litters the skill_group dir.
333
- # Best-effort unlink; a concurrent flipper may have already removed it.
334
- try:
335
- lock_path.unlink()
336
- except OSError:
337
- pass
506
+ release_flip_lock(link, lock_fd)
338
507
 
339
508
  _ok({"link": str(link), "points_to": target, "kind": link_kind})
340
509