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
@@ -0,0 +1,1796 @@
1
+ # /// script
2
+ # requires-python = ">=3.11"
3
+ # dependencies = []
4
+ # ///
5
+ """SKF Source Tree — read a remote skill's source at one commit.
6
+
7
+ A skill forged from a remote repository at Forge tier or above records, as
8
+ its `source_root`, the clone SKF keeps for that repository (the Compute
9
+ workspace path rule of create-skill's source-resolution-protocols.md:
10
+ `{SKF_WORKSPACE or ~/.skf/workspace}/repos/<host>/<owner...>/<repo>`). Every
11
+ SKF run on the repository shares that clone and leaves it at the commit it
12
+ needed, so update-skill never reads it as it stands. This helper gives each
13
+ update-skill run a private tree at one commit, moves the shared clone to
14
+ the commit the update recorded, and removes the tree at the end. resolve
15
+ does the same for a run that starts from a brief or a chosen ref
16
+ (create-skill, audit-skill): it turns a version into a tag, reads that
17
+ tag into a private tree, and moves the shared clone there when asked.
18
+
19
+ CLI:
20
+
21
+ uv run skf-source-tree.py open --source-repo <repo> --source-root <path> \\
22
+ --source-ref <ref> --pinned-commit <sha> [--target-ref <ref>] \\
23
+ [--timeout <seconds>]
24
+ uv run skf-source-tree.py resolve --source-repo <repo> \\
25
+ [--source-root <path>] [--target-ref <ref>] \\
26
+ [--version <version> [--name <name>] [--implicit]] \\
27
+ [--update-clone] [--hygiene-helper <path>] \\
28
+ [--lock-timeout <seconds>] [--timeout <seconds>]
29
+ uv run skf-source-tree.py advance --clone <path> --source-repo <repo> \\
30
+ --target <sha> [--expect-commit <sha>] [--target-ref <ref>] \\
31
+ [--tree <path>] [--hygiene-helper <path>] [--drift-helper <path>] \\
32
+ [--lock-timeout <seconds>] [--timeout <seconds>]
33
+ uv run skf-source-tree.py close --tree <path>
34
+
35
+ --tree (advance and close) takes the `tree` path open or resolve printed,
36
+ or the run folder that holds it.
37
+
38
+ Time limit. open, resolve and advance finish within --timeout seconds
39
+ (100 by default, so a shell tool that stops commands after two minutes
40
+ still gets the JSON): every git call and sibling helper is cut to the
41
+ time left, a network call leaves the last 15 seconds to the local steps
42
+ after it, a local call leaves the last 5 to the final read of the clone's
43
+ HEAD, and a step that runs out of time reports `timed-out`. A call the limit stops is
44
+ stopped with everything it started (git's transport, a filter it runs):
45
+ on POSIX SIGTERM to its process group first, so git removes its lock
46
+ files, then SIGKILL; on Windows `taskkill /T /F`. Its output goes to
47
+ temporary files, never pipes, so a child that outlives it cannot hold the
48
+ helper past the limit.
49
+
50
+ open
51
+
52
+ Takes a source only when --source-repo names a remote repository and
53
+ --source-root is SKF's clone of it: the workspace path computed from
54
+ --source-repo (compared as resolved paths, so a trailing slash or `~`
55
+ does not matter), or a clone of the same repository under
56
+ `repos/<host>/<owner...>/<repo>` of any SKF workspace folder, whatever
57
+ the letter case of those folder names. The clone's `remote.origin.url`
58
+ must name the same repository (read with `git config`, so `insteadOf`
59
+ does not apply). Anything else is `skipped`.
60
+
61
+ The target ref is --target-ref when given, else --source-ref; an empty,
62
+ `null`, `none` or `HEAD` ref means the remote's default branch. `git
63
+ ls-remote` names the commit the ref points to now: a tag wins over a
64
+ branch of the same name, and an annotated tag resolves to its commit. A
65
+ full 40-character commit is used as given. The helper then creates a
66
+ private git repository in a run folder of its own, fetches that one
67
+ commit into it (from the shared clone when the clone holds it, which
68
+ only reads the clone, otherwise from the remote), checks it out and lists
69
+ the files git changed between --pinned-commit and that commit.
70
+
71
+ open never writes to the shared clone: it never fetches into it, checks
72
+ it out, creates it, locks it or runs the hygiene helper there.
73
+
74
+ When the remote cannot be reached or the commit cannot be fetched,
75
+ --target-ref was not given and the clone holds --pinned-commit, the tree
76
+ is built at the pinned commit instead (`offline`).
77
+
78
+ Output (one ASCII line; every key is always present):
79
+ status "ready" | "offline" | "skipped" | "unavailable"
80
+ skip_reason null | "not-remote" | "not-workspace-clone"
81
+ reason null | "invalid-ref" | "git-unavailable" |
82
+ "upstream-unreachable" | "ref-not-found" |
83
+ "fetch-failed" | "checkout-failed" |
84
+ "tree-folder-failed" | "timed-out" (OPEN_REASONS)
85
+ message one line for the user (offline and unavailable), else null
86
+ tree absolute path of this run's tree (ready, offline), else null
87
+ clone the workspace clone advance may update (SKF's clone of
88
+ --source-repo, present or missing); null when skipped or
89
+ when that folder is not SKF's clone
90
+ target_ref the ref compared ("HEAD" for the default branch)
91
+ ref_kind "tag" | "branch" | "head" | "commit" | "pinned" | null
92
+ target_commit 40-hex commit the tree holds, else null
93
+ moved true | false | null (null without a pinned commit: an
94
+ empty, `local`, `null` or `none` --pinned-commit)
95
+ diff_status "ok" | "unavailable" | null (null unless ready or offline)
96
+ changed_files path of changed-files.json, else null
97
+ changed_counts {"added": n, "modified": n, "deleted": n}, else null
98
+ warnings list of strings
99
+
100
+ Run folder: `skf-tree-<UTC stamp>-<8 characters>/` under
101
+ `{SKF_WORKSPACE or ~/.skf/workspace}/trees/`, or under the system temp
102
+ folder when that folder cannot be created. It holds:
103
+ run.json {tool, created, tree, source_repo, clone, target_commit}
104
+ <repo>/ the tree: a private repository whose refs/skf/target
105
+ (and refs/skf/base) hold the commits it fetched
106
+ changed-files.json {"base": sha, "target": sha,
107
+ "files": [{"status": "A"|"M"|"D", "path": "a/b"}]}
108
+
109
+ Every open first removes run folders an earlier run left behind whose
110
+ name stamp is seven days old or more: only folders whose name matches,
111
+ never a link, and on POSIX only folders the current user owns. Folder
112
+ times are never used.
113
+
114
+ resolve
115
+
116
+ Takes a source when --source-repo names a remote repository (anything
117
+ else is `skipped` with `not-remote`: read the local path as it is), and
118
+ picks the ref to read:
119
+
120
+ 1. --target-ref: `HEAD` (any letter case) for the remote's default
121
+ branch, a 40-character commit, or a tag or branch. A commit is taken
122
+ as given, without `git ls-remote`, and one the remote does not have
123
+ ends `unavailable` with `fetch-failed`. A tag or branch is looked up
124
+ with `git ls-remote` (a tag wins over a branch of the same name); one
125
+ it does not find adds a warning and falls through.
126
+ 2. --version, matched against the remote's tags. --implicit (a version
127
+ the brief only carries as a hint) tries two forms, the version as
128
+ given and `v<version>`; otherwise seven, adding, with --name,
129
+ `<name>@<version>`, `@<scope>/<name>@<version>` (any scope),
130
+ `<name>/v<version>`, `<name>/<version>` and `<name>-v<version>` (a
131
+ leading `v` of --version is dropped in the forms after the first).
132
+ One match is read. Several are `ambiguous`: nothing is read, and
133
+ tag_resolution.candidates lists them in that order; run resolve again
134
+ with the chosen one as --target-ref. None falls back to `HEAD`, with
135
+ the five tags nearest the version in tag_resolution.nearest.
136
+ 3. Otherwise `HEAD`, the remote's default branch.
137
+
138
+ An empty, `null` or `none` value (any letter case: a JSON null written
139
+ out) of --target-ref, --version, --name or --source-root counts as not
140
+ given, so a brief's `target_ref: null` falls through to its version.
141
+ Only `HEAD` asks for the default branch.
142
+
143
+ It then builds a private tree at that commit exactly as open does (same
144
+ run folder, run.json and refs/skf/target; no changed-files.json), from
145
+ SKF's clone when the clone holds the commit, else from the remote.
146
+ Without --source-root, the clone is the workspace path computed from
147
+ --source-repo. With --source-root, the clone is that folder when it is
148
+ SKF's clone of --source-repo as open decides (the workspace path,
149
+ present or missing, or a clone of the same repository under another
150
+ workspace folder). A folder that is not SKF's clone, at either place,
151
+ is never used: the tree comes from the remote and --update-clone skips
152
+ with `not-a-clone`.
153
+
154
+ With --update-clone it then moves that clone to the commit, as advance
155
+ does but without --expect-commit (this run owns the move): inside one
156
+ process it clones a missing clone, takes `.skf-workspace.lock`, runs
157
+ --hygiene-helper, leaves a clone with local changes to tracked files
158
+ alone and never forces a checkout. It also leaves alone a clone whose
159
+ HEAD does not resolve (`head-unverified`): that may be another run's
160
+ clone still being written, since create-skill clones before it locks.
161
+ The tree is read either way, so a clone that could not be moved never
162
+ changes what the run reads.
163
+
164
+ Output (one ASCII line; every key is always present):
165
+ status "ready" | "ambiguous" | "skipped" | "unavailable"
166
+ skip_reason null | "not-remote"
167
+ reason null | one of OPEN_REASONS (unavailable)
168
+ message one line for the user (ambiguous, unavailable), else null
169
+ workspace_path SKF's clone path for --source-repo (the Compute
170
+ workspace path rule), null when skipped
171
+ clone SKF's clone it read from or moved, present or
172
+ missing; null when that folder is not SKF's clone
173
+ source_ref the ref picked ("HEAD" for the default branch), else null
174
+ ref_kind "tag" | "branch" | "head" | "commit" | null
175
+ source_commit 40-hex commit the tree holds, else null
176
+ tree absolute path of this run's tree (ready), else null
177
+ tag_resolution {"status": "target-ref" | "matched" | "ambiguous" |
178
+ "fallback-head" | "none",
179
+ "mode": "explicit" | "implicit" | null,
180
+ "requested": the --version (or --target-ref) or null,
181
+ "candidates": [matching tags],
182
+ "nearest": [up to five tags, newest first],
183
+ "reason": null | "no-matching-tag" |
184
+ "target-ref-not-found"}
185
+ clone_status null (no --update-clone) | "advanced" | "ok" | "skipped"
186
+ clone_skip_reason null | one of ADVANCE_SKIP_REASONS
187
+ clone_created true when this call cloned the missing clone
188
+ warnings list of strings
189
+
190
+ Remove the tree with close once the run no longer reads it.
191
+
192
+ advance
193
+
194
+ Moves the shared clone (--clone) to --target, the commit the update
195
+ recorded, so test-skill and audit-skill read that commit there. It takes
196
+ the clone's `.skf-workspace.lock`, the lock create-skill takes
197
+ (`fcntl.flock` on POSIX, the lock `flock -x` takes, and
198
+ `msvcrt.locking` on Windows), waiting up to --lock-timeout seconds, and
199
+ runs the hygiene helper inside it. It then does nothing when the clone
200
+ already holds --target; otherwise it checks the clone out at --target
201
+ only when the drift helper confirms the clone still holds --expect-commit
202
+ and no tracked file has local changes, taking the commit from the tree
203
+ --tree names before the remote. It never forces a checkout. A missing
204
+ clone is cloned first, as create-skill does (clone first, lock after),
205
+ into a folder beside it that is renamed to --clone once the clone is
206
+ complete: no other run sees it half made, and when another run made
207
+ --clone meanwhile, this call removes only its own folder
208
+ (`clone-failed`).
209
+
210
+ refs/skf/advance, the ref a fetch into the clone writes, is always
211
+ deleted again. When the time limit stops a fetch or the checkout in the
212
+ clone and git has to be killed, so it cannot remove the lock file it
213
+ held there (`shallow.lock`, `index.lock`), that file is removed; a
214
+ checkout stopped part way reports `checkout-interrupted`, since the
215
+ clone's files may then belong to both commits, and its warning gives
216
+ the fetch and the forced checkout that finish it (the clone had no
217
+ local changes when it began, and no ref keeps the commit there).
218
+
219
+ Output (one ASCII line):
220
+ status "advanced" | "ok" | "skipped"
221
+ skip_reason null | "invalid-target" | "not-a-clone" | "clone-failed" |
222
+ "lock-busy" | "head-moved" | "head-unverified" |
223
+ "local-changes" | "target-missing" | "checkout-failed" |
224
+ "checkout-interrupted" | "timed-out" (ADVANCE_SKIP_REASONS)
225
+ head_sha the clone's HEAD after the call, or null
226
+ created true when this call cloned the missing clone
227
+ lock "held" | "busy" | "unavailable" | "not-taken"
228
+ hygiene "ran" | "failed" | "skipped"
229
+ log_message one line
230
+ warnings list of strings
231
+
232
+ close
233
+
234
+ Removes the run folder that holds the tree --tree names, or the run
235
+ folder --tree names, only when its name matches the run-folder pattern,
236
+ it is not a link, on POSIX the current user owns it, and its run.json
237
+ names this tool and a tree inside it (the one --tree names, when --tree
238
+ names a tree).
239
+
240
+ Output (one ASCII line):
241
+ {"status": "removed" | "missing" | "left" | "refused",
242
+ "tree": <--tree>, "warnings": [...]}
243
+
244
+ Exit codes:
245
+ 0 open: ready, offline or skipped; resolve: ready, ambiguous or
246
+ skipped; advance and close: always once JSON is printed (they never
247
+ gate)
248
+ 3 open, resolve: unavailable (JSON on stdout; the caller halts)
249
+ 1 unexpected error: {"status": "error", "message": ...} on stderr,
250
+ nothing on stdout
251
+ 2 usage error (argparse)
252
+
253
+ git runs with the git location variables (GIT_DIR, GIT_INDEX_FILE, ...)
254
+ removed from its environment. A git hook or `git rebase --exec` exports
255
+ them (GIT_DIR too when run in a linked worktree); inherited by
256
+ `git -C <clone>`, they would make the helper read or write another
257
+ repository. git also runs with LC_ALL=C, no terminal prompt, no
258
+ credential-manager prompt and no hooks. It needs git 2.15 or newer.
259
+ """
260
+
261
+ from __future__ import annotations
262
+
263
+ import argparse
264
+ import bisect
265
+ import errno
266
+ import json
267
+ import os
268
+ import re
269
+ import shutil
270
+ import signal
271
+ import stat
272
+ import subprocess
273
+ import sys
274
+ import tempfile
275
+ import time
276
+ from datetime import datetime, timedelta, timezone
277
+ from pathlib import Path
278
+
279
+ if os.name == "nt":
280
+ import msvcrt
281
+ else:
282
+ import fcntl
283
+
284
+ TOOL = "skf-source-tree"
285
+ TREE_PREFIX = "skf-tree-"
286
+ TREE_NAME_RE = re.compile(r"^skf-tree-(\d{8}T\d{6}Z)-[a-z0-9_]{8}$")
287
+ RUN_FILE = "run.json"
288
+ CHANGED_FILE = "changed-files.json"
289
+ LOCK_NAME = ".skf-workspace.lock"
290
+ ADVANCE_REF = "refs/skf/advance"
291
+ STALE_TREE_DAYS = 7
292
+ LOCK_WAIT_SEC = 60.0
293
+ LOCK_POLL_SEC = 0.25
294
+ LOCAL_TIMEOUT_SEC = 60
295
+ LS_REMOTE_TIMEOUT_SEC = 120
296
+ NETWORK_TIMEOUT_SEC = 600 # also used for checkout and clone
297
+ SIBLING_TIMEOUT_SEC = 120
298
+ # open and advance finish within --timeout seconds; a network call leaves
299
+ # the last NETWORK_RESERVE_SEC of it to the local steps that follow, and a
300
+ # local call the last FINAL_READ_SEC to the unlimited final reads.
301
+ DEFAULT_TIMEOUT_SEC = 100.0
302
+ NETWORK_RESERVE_SEC = 15.0
303
+ FINAL_READ_SEC = 5.0
304
+ KILL_WAIT_SEC = 2.0 # each wait after the time limit stops a call: SIGTERM or taskkill, then the child
305
+ OPEN_REASONS = ("invalid-ref", "git-unavailable", "upstream-unreachable", "ref-not-found",
306
+ "fetch-failed", "checkout-failed", "tree-folder-failed", "timed-out")
307
+ ADVANCE_SKIP_REASONS = ("invalid-target", "not-a-clone", "clone-failed", "lock-busy", "head-moved",
308
+ "head-unverified", "local-changes", "target-missing", "checkout-failed",
309
+ "checkout-interrupted", "timed-out")
310
+ # The failures a call cut short by the time limit can cause: by a network
311
+ # call (cut NETWORK_RESERVE_SEC early) or by a local one.
312
+ _OPEN_NETWORK_LATE = ("upstream-unreachable", "fetch-failed")
313
+ _OPEN_LOCAL_LATE = ("invalid-ref", "checkout-failed")
314
+ _ADVANCE_NETWORK_LATE = ("clone-failed", "target-missing")
315
+ _ADVANCE_LOCAL_LATE = ("not-a-clone", "lock-busy", "head-unverified", "local-changes", "checkout-failed")
316
+ SHA40_RE = re.compile(r"^[0-9a-f]{40}$")
317
+ # No leading `-`; a scoped `@scope/pkg@1.0.0` tag is allowed.
318
+ REF_RE = re.compile(r"^[A-Za-z0-9_@][A-Za-z0-9._/+@-]*$")
319
+ HEAD_ALIASES = {"", "null", "none", "head"}
320
+ STAMP_FORMAT = "%Y%m%dT%H%M%SZ"
321
+ # Every git call that writes a checkout (checkout, clone) passes these.
322
+ CHECKOUT_OPTS = ("-c", "core.longpaths=true", "-c", "advice.detachedHead=false")
323
+
324
+ _URL_RE = re.compile(
325
+ r"^(?:https?|ssh|git)://(?:[^@/]+@)?(?P<host>[^/:]+)(?::\d+)?/(?P<path>.+?)/?$",
326
+ re.IGNORECASE,
327
+ )
328
+ _SCP_RE = re.compile(r"^(?:[^@/:]+@)?(?P<host>[^/:]{2,}):(?P<path>[^/].*?)/?$")
329
+ _SHORTHAND_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
330
+ _DRIVE_RE = re.compile(r"^[A-Za-z]:[\\/]")
331
+ # Characters that stop a host, owner or repository name from naming one
332
+ # folder: Windows reads `\` as a separator and `C:` as a drive.
333
+ _NOT_A_NAME_RE = re.compile(r"[\\:\x00-\x1f\x7f]")
334
+ _VERSION_TAIL_RE = re.compile(
335
+ r"(?P<nums>\d+(?:\.\d+)*)(?P<pre>(?:-|(?=[A-Za-z]))[0-9A-Za-z][0-9A-Za-z.-]*)?(?:\+[0-9A-Za-z.-]+)?")
336
+ NEAREST_TAGS = 5
337
+
338
+ # Keep identical to GIT_LOCATION_VARS in skf-check-workspace-drift.py.
339
+ GIT_LOCATION_VARS = (
340
+ "GIT_DIR",
341
+ "GIT_WORK_TREE",
342
+ "GIT_INDEX_FILE",
343
+ "GIT_PREFIX",
344
+ "GIT_COMMON_DIR",
345
+ "GIT_OBJECT_DIRECTORY",
346
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
347
+ "GIT_NAMESPACE",
348
+ )
349
+
350
+
351
+ # --------------------------------------------------------------------------
352
+ # Pinned copies
353
+ # --------------------------------------------------------------------------
354
+
355
+
356
+ # Keep identical to _is_link_or_junction in skf-skill-inventory.py
357
+ # (test/test-skf-skill-inventory.py pins the copies).
358
+ def _is_link_or_junction(p: Path) -> bool:
359
+ """True for POSIX symlinks AND Windows junctions/symlinks.
360
+
361
+ `Path.is_symlink()` is False for Windows junctions; os.readlink succeeds
362
+ for both symlinks and junctions (since CPython 3.8 on Windows). A regular
363
+ directory raises OSError on readlink, which is the signal we want to
364
+ refuse replacement. On Windows, any other reparse point (a cloud-sync
365
+ placeholder, a deduplicated file, an app execution alias) raises
366
+ ValueError: it does not redirect to another path, so it is not a link.
367
+ """
368
+ if p.is_symlink():
369
+ return True
370
+ if not p.exists() and not p.is_symlink():
371
+ return False
372
+ try:
373
+ os.readlink(p)
374
+ return True
375
+ except (OSError, ValueError):
376
+ return False
377
+
378
+
379
+ # Keep identical to _acquire_lock in skf-atomic-write.py
380
+ # (test/test-skf-source-tree.py pins the copy).
381
+ def _acquire_lock(fd: int) -> None:
382
+ """Acquire an exclusive non-blocking lock on fd (auto-released on close/exit)."""
383
+ if os.name == "nt":
384
+ try:
385
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
386
+ except OSError as e:
387
+ if e.errno in (errno.EAGAIN, errno.EACCES, errno.EDEADLK):
388
+ raise OSError(errno.EAGAIN, "lock held") from e
389
+ raise
390
+ else:
391
+ try:
392
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
393
+ except OSError as e:
394
+ if e.errno in (errno.EAGAIN, errno.EACCES):
395
+ raise OSError(errno.EAGAIN, "lock held") from e
396
+ raise
397
+
398
+
399
+ # Keep identical to _release_lock in skf-atomic-write.py
400
+ # (test/test-skf-source-tree.py pins the copy).
401
+ def _release_lock(fd: int) -> None:
402
+ if os.name == "nt":
403
+ try:
404
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
405
+ except OSError:
406
+ pass
407
+ else:
408
+ try:
409
+ fcntl.flock(fd, fcntl.LOCK_UN)
410
+ except OSError:
411
+ pass
412
+
413
+
414
+ # Keep identical to _same_path in skf-ccc-git-hygiene.py
415
+ # (test/test-skf-source-tree.py pins the copy).
416
+ def _same_path(a: str | Path, b: str | Path) -> bool:
417
+ """Path identity that covers Windows case and 8.3 names, forward-slash
418
+ git output and macOS /private."""
419
+ return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b))
420
+
421
+
422
+ def _resolve_outside_cwd(command: str) -> str | None:
423
+ """shutil.which with a CWD-shim guard. Returns the resolved path or None.
424
+
425
+ shutil.which on Windows searches the current directory ahead of PATH,
426
+ and CWD here may be a repository SKF does not control — a bare-name
427
+ lookup resolving into CWD would execute a planted shim (e.g. git.exe).
428
+ Such a resolution is treated as not-found. Explicit paths supplied by
429
+ callers (containing a separator) are honored as-is. Keep the code
430
+ identical to the sibling guards in skf-merge-ccc-exclusions.py,
431
+ skf-detect-tools.py, skf-qmd-classify-collections.py,
432
+ skf-ccc-git-hygiene.py, skf-tessl-review.py and
433
+ skf-verify-provenance-completeness.py
434
+ (test/test-skf-source-tree.py pins it against
435
+ skf-merge-ccc-exclusions.py).
436
+ """
437
+ resolved = shutil.which(command)
438
+ if resolved is None:
439
+ return None
440
+ if os.sep in command or (os.altsep and os.altsep in command):
441
+ return resolved
442
+ resolved_dir = os.path.dirname(resolved)
443
+ if resolved_dir:
444
+ cwd = os.path.normcase(os.path.abspath(os.getcwd()))
445
+ if os.path.normcase(os.path.abspath(resolved_dir)) == cwd:
446
+ return None
447
+ return resolved
448
+
449
+
450
+ # skf-check-workspace-drift.py (upstream), skf-github-probe.py and
451
+ # skf-classify-changed-files.py load this file for split_version, match_tags,
452
+ # nearest_tags, _run,
453
+ # _resolve_outside_cwd, _last_error, _stat_dir, SHA40_RE and REF_RE, and
454
+ # skf-fetch-temporal.py for parse_remote, _run, _resolve_outside_cwd, _rmtree
455
+ # and _is_link_or_junction, and skf-detect-registry.py for _git: keep those
456
+ # names and what they take and return.
457
+ def split_version(name: str) -> tuple[str, tuple] | None:
458
+ """(prefix, sort key) of a tag name that ends in a version, else None.
459
+
460
+ The version is a dotted number with an optional pre-release (`-rc.1`,
461
+ `rc1`) and build (`+build`) after it; the prefix is what comes before
462
+ it and never ends in a digit or a dot (`v`, `pkg@`, `tokio/v`). When a
463
+ name holds several, the one with the most numbers wins, then the first
464
+ (`lib2-v1.0` is `lib2-v` and 1.0, `v1.0.0-rc1` is `v` and 1.0.0-rc1).
465
+ Keys sort by version: trailing zeros do not count (1.2 == 1.2.0), a
466
+ release sorts after its pre-releases, pre-release numbers compare as
467
+ numbers. key[1] is 0 for a pre-release, 1 for a release.
468
+ """
469
+ best = None
470
+ for i, ch in enumerate(name):
471
+ if not ch.isdigit() or (i and name[i - 1] in "0123456789."):
472
+ continue
473
+ m = _VERSION_TAIL_RE.fullmatch(name, i)
474
+ if m is None:
475
+ continue
476
+ nums = [int(part) for part in m.group("nums").split(".")]
477
+ if best is None or len(nums) > len(best[1]):
478
+ best = (name[:i], nums, m.group("pre"))
479
+ if best is None:
480
+ return None
481
+ prefix, nums, pre = best
482
+ while len(nums) > 1 and nums[-1] == 0:
483
+ nums.pop()
484
+ if pre is None:
485
+ return prefix, (tuple(nums), 1, ())
486
+ ids = tuple((0, int(tok), "") if tok.isdigit() else (1, 0, tok.lower())
487
+ for tok in re.findall(r"\d+|[A-Za-z]+", pre))
488
+ return prefix, (tuple(nums), 0, ids)
489
+
490
+
491
+ # Keep identical to is_skippable_pinned in skf-check-workspace-drift.py
492
+ # (test/test-skf-source-tree.py pins the copy).
493
+ def is_skippable_pinned(pinned_commit: str) -> bool:
494
+ """Recognize the scalar values that mean 'no pinned commit'."""
495
+ if pinned_commit is None:
496
+ return True
497
+ stripped = pinned_commit.strip()
498
+ return stripped == "" or stripped.lower() == "local"
499
+
500
+
501
+ # Keep identical to classify_match in skf-check-workspace-drift.py
502
+ # (test/test-skf-source-tree.py pins the copy).
503
+ def classify_match(pinned: str, head: str) -> str | None:
504
+ """Return 'full' if pinned == head, 'short-prefix' if pinned is a prefix
505
+ of head (≥ 7 chars to avoid coincidental collisions on short SHAs).
506
+ Returns None on no match."""
507
+ if pinned == head:
508
+ return "full"
509
+ if len(pinned) >= 7 and head.startswith(pinned):
510
+ return "short-prefix"
511
+ return None
512
+
513
+
514
+ # --------------------------------------------------------------------------
515
+ # git
516
+ # --------------------------------------------------------------------------
517
+
518
+
519
+ def _child_env() -> dict:
520
+ """os.environ without the git location variables, and with no prompts."""
521
+ env = {k: v for k, v in os.environ.items() if k not in GIT_LOCATION_VARS}
522
+ env["LC_ALL"] = "C"
523
+ env["GIT_TERMINAL_PROMPT"] = "0"
524
+ env["GCM_INTERACTIVE"] = "never"
525
+ return env
526
+
527
+
528
+ _deadline: float | None = None
529
+
530
+
531
+ def _start_clock(seconds: float | None) -> None:
532
+ """Start the time limit of this open or advance (None: no limit)."""
533
+ global _deadline
534
+ _deadline = None if seconds is None else time.monotonic() + max(0.0, seconds)
535
+
536
+
537
+ def _cap(timeout: float, network: bool = False) -> float | None:
538
+ """timeout cut to the time the limit leaves; None when none is left.
539
+
540
+ A network call leaves the last NETWORK_RESERVE_SEC to the local steps,
541
+ a local call the last FINAL_READ_SEC to the final reads.
542
+ """
543
+ if _deadline is None:
544
+ return timeout
545
+ left = _deadline - time.monotonic() - (NETWORK_RESERVE_SEC if network else FINAL_READ_SEC)
546
+ return min(timeout, left) if left > 0 else None
547
+
548
+
549
+ def _expired() -> bool:
550
+ """True once no network call may start: the time limit is (nearly) spent."""
551
+ return _cap(1.0, network=True) is None
552
+
553
+
554
+ def _out_of_time() -> bool:
555
+ """True once no call at all may start."""
556
+ return _cap(1.0) is None
557
+
558
+
559
+ def _late(reason: str | None, network_reasons: tuple, local_reasons: tuple) -> bool:
560
+ """True when the time limit, not the reason itself, explains a failure."""
561
+ return (reason in network_reasons and _expired()) or (reason in local_reasons and _out_of_time())
562
+
563
+
564
+ def _stop(proc: subprocess.Popen, windows: bool = os.name == "nt") -> bool:
565
+ """Stop proc and everything it started once the time limit runs out.
566
+
567
+ POSIX sends SIGTERM to its process group first, so git removes its lock
568
+ files, then SIGKILL to whatever is left; Windows runs taskkill /T /F.
569
+ Returns True when proc itself had to be killed (taskkill /F, or SIGKILL
570
+ once SIGTERM had not ended it) and has ended: the lock files it held
571
+ stay behind. False when SIGTERM ended it, or when it has not ended.
572
+ """
573
+ killed = windows
574
+ try:
575
+ if windows:
576
+ taskkill = _resolve_outside_cwd("taskkill")
577
+ if taskkill:
578
+ subprocess.run([taskkill, "/F", "/T", "/PID", str(proc.pid)], stdin=subprocess.DEVNULL,
579
+ capture_output=True, timeout=KILL_WAIT_SEC, check=False)
580
+ proc.kill()
581
+ else:
582
+ os.killpg(proc.pid, signal.SIGTERM)
583
+ try:
584
+ proc.wait(timeout=KILL_WAIT_SEC)
585
+ ended = True
586
+ except subprocess.TimeoutExpired:
587
+ ended = False
588
+ os.killpg(proc.pid, signal.SIGKILL)
589
+ killed = not ended
590
+ except (OSError, ValueError, subprocess.TimeoutExpired):
591
+ pass
592
+ try:
593
+ proc.wait(timeout=KILL_WAIT_SEC)
594
+ except subprocess.TimeoutExpired:
595
+ return False
596
+ return killed
597
+
598
+
599
+ def _run(argv: list[str], timeout: float | None) -> tuple[int | None, bytes, bytes, bool]:
600
+ """Run argv with no stdin and _child_env(); (returncode, stdout, stderr, killed).
601
+
602
+ returncode is None when the time limit stopped the call, and killed is
603
+ True when it then had to be killed (see _stop). Output goes to
604
+ temporary files, not pipes: on Windows a child git starts (ssh, the
605
+ https transport) inherits the pipes and keeps them open after git is
606
+ stopped, and reading a pipe to its end would wait for it. The call gets
607
+ a process group (a POSIX session) of its own, so _stop reaches all it
608
+ started. Raises OSError or ValueError when argv cannot start.
609
+ """
610
+ kwargs = {}
611
+ if os.name == "nt":
612
+ kwargs["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
613
+ else:
614
+ kwargs["start_new_session"] = True
615
+ with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
616
+ proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out_f, stderr=err_f, env=_child_env(),
617
+ **kwargs)
618
+ killed = False
619
+ try:
620
+ returncode = proc.wait(timeout=timeout)
621
+ except subprocess.TimeoutExpired:
622
+ killed = _stop(proc)
623
+ returncode = None
624
+ out_f.seek(0)
625
+ err_f.seek(0)
626
+ return returncode, out_f.read(), err_f.read(), killed
627
+
628
+
629
+ # _git's returncode for a call the time limit stopped once it had started:
630
+ # STOPPED when SIGTERM ended it, so git removed its lock files itself, and
631
+ # KILLED when it had to be killed (see _stop), so they stay behind.
632
+ STOPPED = -2
633
+ KILLED = -3
634
+
635
+
636
+ def _git(cwd: str | Path, *args: str, timeout: float = LOCAL_TIMEOUT_SEC, network: bool = False,
637
+ limited: bool = True) -> tuple[int, bytes, str] | None:
638
+ """Run `git -C <cwd> <args>` with no hooks and the git location variables removed.
639
+
640
+ Returns None when git cannot be found (or only as a shim in CWD) or
641
+ cannot start, (-1, b"", "timeout") when the time limit leaves no time
642
+ to start the call (`limited`; see _cap), (STOPPED or KILLED, stdout
643
+ bytes, "timeout") when the limit stopped it, and otherwise (returncode,
644
+ stdout bytes, stderr text). stdout is not stripped: the `-z` outputs
645
+ need the raw bytes.
646
+ """
647
+ exe = _resolve_outside_cwd("git")
648
+ if exe is None:
649
+ return None
650
+ if limited:
651
+ timeout = _cap(timeout, network)
652
+ if timeout is None:
653
+ return -1, b"", "timeout"
654
+ else:
655
+ timeout = min(timeout, FINAL_READ_SEC)
656
+ try:
657
+ returncode, stdout, stderr, killed = _run([exe, "-c", f"core.hooksPath={os.devnull}", "-C", str(cwd),
658
+ *args], timeout)
659
+ except (OSError, ValueError):
660
+ return None
661
+ if returncode is None:
662
+ return (KILLED if killed else STOPPED), stdout, "timeout"
663
+ return returncode, stdout, stderr.decode("utf-8", errors="replace")
664
+
665
+
666
+ def _ok(res: tuple[int, bytes, str] | None) -> bool:
667
+ return res is not None and res[0] == 0
668
+
669
+
670
+ def _out(res: tuple[int, bytes, str] | None) -> str:
671
+ """The first stdout line of a successful call, else ""."""
672
+ if not _ok(res):
673
+ return ""
674
+ lines = res[1].decode("utf-8", errors="replace").strip().splitlines()
675
+ return lines[0].strip() if lines else ""
676
+
677
+
678
+ def _last_error(stderr: str | None) -> str:
679
+ """The last `fatal:`/`error:` line of git's stderr, else its last line."""
680
+ lines = [line.strip() for line in (stderr or "").splitlines() if line.strip()]
681
+ for line in reversed(lines):
682
+ if line.startswith(("fatal:", "error:")):
683
+ return line
684
+ return lines[-1] if lines else "git failed"
685
+
686
+
687
+ def _err(res: tuple[int, bytes, str] | None) -> str:
688
+ if res is None:
689
+ return "git could not run"
690
+ return _last_error(res[2])
691
+
692
+
693
+ def _rev(cwd: str | Path, rev: str, limited: bool = True) -> str | None:
694
+ """The full commit `rev` names in the repository at cwd, else None."""
695
+ sha = _out(_git(cwd, "rev-parse", "--verify", "--quiet", f"{rev}^{{commit}}", limited=limited))
696
+ return sha if SHA40_RE.match(sha) else None
697
+
698
+
699
+ # --------------------------------------------------------------------------
700
+ # Which sources this helper takes
701
+ # --------------------------------------------------------------------------
702
+
703
+
704
+ def _one_name(part: str) -> bool:
705
+ """True when part names one folder inside its parent on every platform.
706
+
707
+ Not `\\`, `:` or a control character, and not only dots and spaces:
708
+ Windows drops a name's trailing dots and spaces, so `.. ` is `..` there.
709
+ """
710
+ return bool(part.rstrip(". ")) and not _NOT_A_NAME_RE.search(part)
711
+
712
+
713
+ def parse_remote(value: str | None) -> tuple[str, tuple[str, ...], str] | None:
714
+ """(host, owner_parts, repo) for a remote repository, else None.
715
+
716
+ Accepts https/http/ssh/git URLs, scp-like `user@host:owner/repo` and the
717
+ `owner/repo` shorthand (github.com; only when no such local path
718
+ exists). A local path, `file:` URL or `<transport>::` address is None,
719
+ and so is a host, owner or repository part that is not one folder name
720
+ (see _one_name): SKF's clone and this run's tree are folders named
721
+ after them.
722
+ """
723
+ if not value:
724
+ return None
725
+ value = value.strip()
726
+ if (
727
+ not value
728
+ or value.startswith(("-", "/", "./", "../", "~"))
729
+ or value.lower().startswith("file:")
730
+ or "::" in value
731
+ or _DRIVE_RE.match(value)
732
+ ):
733
+ return None
734
+ m = _URL_RE.match(value)
735
+ if m:
736
+ host, path = m.group("host"), m.group("path")
737
+ else:
738
+ m = _SCP_RE.match(value)
739
+ if m:
740
+ host, path = m.group("host"), m.group("path")
741
+ elif _SHORTHAND_RE.match(value) and not os.path.exists(value):
742
+ host, path = "github.com", value
743
+ else:
744
+ return None
745
+ parts = path.split("/")
746
+ if parts and parts[-1].endswith(".git"):
747
+ parts[-1] = parts[-1][: -len(".git")]
748
+ if len(parts) < 2 or not all(_one_name(part) for part in (host, *parts)):
749
+ return None
750
+ return host.lower(), tuple(parts[:-1]), parts[-1]
751
+
752
+
753
+ def _key(parsed: tuple[str, tuple[str, ...], str]) -> tuple:
754
+ host, owner, repo = parsed
755
+ return host.casefold(), tuple(part.casefold() for part in owner), repo.casefold()
756
+
757
+
758
+ def _url(source_repo: str, parsed: tuple[str, tuple[str, ...], str]) -> str:
759
+ """The URL git reads: source_repo itself, or https for the shorthand."""
760
+ value = source_repo.strip()
761
+ if "://" in value or (_SCP_RE.match(value) and not _SHORTHAND_RE.match(value)):
762
+ return value
763
+ host, owner, repo = parsed
764
+ return f"https://{host}/{'/'.join(owner)}/{repo}"
765
+
766
+
767
+ def workspace_root() -> Path:
768
+ return Path(os.path.expanduser(os.environ.get("SKF_WORKSPACE") or "~/.skf/workspace"))
769
+
770
+
771
+ def clone_path(parsed: tuple[str, tuple[str, ...], str]) -> Path:
772
+ host, owner, repo = parsed
773
+ return workspace_root().joinpath("repos", host, *owner, repo)
774
+
775
+
776
+ def _stays_inside(parsed: tuple[str, tuple[str, ...], str]) -> bool:
777
+ """A second guard behind parse_remote: SKF's clone path for parsed lies
778
+ inside `repos/` of the workspace, and the repository name, which names
779
+ this run's tree inside its run folder, is one folder name."""
780
+ repos = os.path.normcase(os.path.abspath(workspace_root() / "repos"))
781
+ clone = os.path.normcase(os.path.abspath(clone_path(parsed)))
782
+ try:
783
+ inside = clone != repos and os.path.commonpath([repos, clone]) == repos
784
+ except ValueError: # another drive
785
+ return False
786
+ repo = parsed[2]
787
+ return inside and repo not in (".", "..") and os.path.dirname(os.path.join("run", repo)) == "run"
788
+
789
+
790
+ def _stat_dir(p: str | Path) -> bool | None:
791
+ """True for a folder, False when nothing is there, None when unreadable.
792
+
793
+ os.stat, not pathlib: from Python 3.14 pathlib answers False inside a
794
+ folder it cannot search instead of raising.
795
+ """
796
+ try:
797
+ st = os.stat(p)
798
+ except (FileNotFoundError, NotADirectoryError):
799
+ return False
800
+ except OSError:
801
+ return None
802
+ return stat.S_ISDIR(st.st_mode)
803
+
804
+
805
+ def _clone_matches(path: str | Path, parsed: tuple[str, tuple[str, ...], str]) -> bool:
806
+ """True when path is the top of a clone whose origin is the same repository."""
807
+ top = _out(_git(path, "rev-parse", "--show-toplevel"))
808
+ if not top or not _same_path(top, path):
809
+ return False
810
+ origin = parse_remote(_out(_git(path, "config", "--get", "remote.origin.url")))
811
+ return origin is not None and _key(origin) == _key(parsed)
812
+
813
+
814
+ def classify(source_repo: str, source_root: str) -> dict:
815
+ """Decide whether source_root is SKF's clone of source_repo."""
816
+ out = {"parsed": None, "clone": None, "usable": False, "skip_reason": None, "warnings": []}
817
+ parsed = parse_remote(source_repo)
818
+ if parsed is None or not _stays_inside(parsed):
819
+ out["skip_reason"] = "not-remote"
820
+ return out
821
+ out["parsed"] = parsed
822
+ root = os.path.expanduser(source_root.strip()) if source_root else ""
823
+ if not root:
824
+ out["skip_reason"] = "not-workspace-clone"
825
+ return out
826
+ workspace_clone = clone_path(parsed)
827
+ if _same_path(root, workspace_clone):
828
+ state = _stat_dir(workspace_clone)
829
+ if state is False:
830
+ out["clone"] = str(workspace_clone)
831
+ elif state is True and _clone_matches(workspace_clone, parsed):
832
+ out["clone"] = str(workspace_clone)
833
+ out["usable"] = True
834
+ else:
835
+ out["warnings"].append(
836
+ f"{workspace_clone} is not SKF's clone of {source_repo}; "
837
+ "this run reads a fresh copy and leaves that folder alone"
838
+ )
839
+ return out
840
+ host, owner, repo = parsed
841
+ # Letter case never matters here: create-skill copies the host as the
842
+ # URL spells it, and _clone_matches checks the origin.
843
+ tail = tuple(p.casefold() for p in ("repos", host, *owner, repo))
844
+ parts = Path(os.path.abspath(root)).parts
845
+ if (
846
+ _stat_dir(root) is True
847
+ and len(parts) >= len(tail)
848
+ and tuple(p.casefold() for p in parts[-len(tail):]) == tail
849
+ and _clone_matches(root, parsed)
850
+ ):
851
+ out["clone"] = root
852
+ out["usable"] = True
853
+ return out
854
+ out["skip_reason"] = "not-workspace-clone"
855
+ return out
856
+
857
+
858
+ # --------------------------------------------------------------------------
859
+ # Refs
860
+ # --------------------------------------------------------------------------
861
+
862
+
863
+ def _ref_ok(ref: str) -> bool:
864
+ if not REF_RE.fullmatch(ref) or ref == "@":
865
+ return False
866
+ return _ok(_git(tempfile.gettempdir(), "check-ref-format", "--allow-onelevel", f"refs/tags/{ref}"))
867
+
868
+
869
+ def resolve_ref(url: str, ref: str) -> tuple[str, str | None, str | None, str | None, str | None]:
870
+ """(state, kind, sha, remote_ref, error) for ref on the remote at url.
871
+
872
+ state is "ok", "ref-not-found" or "unreachable" (error then holds git's
873
+ last error line). A full commit needs no network.
874
+ """
875
+ if SHA40_RE.match(ref):
876
+ return "ok", "commit", ref, ref, None
877
+ if ref == "HEAD":
878
+ patterns = ["HEAD"]
879
+ else:
880
+ patterns = [f"refs/tags/{ref}", f"refs/tags/{ref}^{{}}", f"refs/heads/{ref}"]
881
+ res = _git(tempfile.gettempdir(), "ls-remote", "--", url, *patterns, timeout=LS_REMOTE_TIMEOUT_SEC,
882
+ network=True)
883
+ if not _ok(res):
884
+ return "unreachable", None, None, None, _err(res)
885
+ refs = {}
886
+ for line in res[1].decode("utf-8", errors="replace").splitlines():
887
+ sha, _, name = line.partition("\t")
888
+ if SHA40_RE.match(sha.strip()) and name.strip():
889
+ refs[name.strip()] = sha.strip()
890
+ for name, kind, remote_ref in (
891
+ (f"refs/tags/{ref}^{{}}", "tag", f"refs/tags/{ref}"),
892
+ (f"refs/tags/{ref}", "tag", f"refs/tags/{ref}"),
893
+ (f"refs/heads/{ref}", "branch", f"refs/heads/{ref}"),
894
+ ("HEAD", "head", "HEAD"),
895
+ ):
896
+ if name in refs and (ref == "HEAD") == (name == "HEAD"):
897
+ return "ok", kind, refs[name], remote_ref, None
898
+ return "ref-not-found", None, None, None, None
899
+
900
+
901
+ def list_tags(url: str) -> tuple[dict | None, str | None]:
902
+ """({tag: commit}, None) for the remote at url, or (None, git's last error line).
903
+
904
+ An annotated tag maps to the commit it names (its peeled `^{}` line).
905
+ """
906
+ res = _git(tempfile.gettempdir(), "ls-remote", "--tags", "--", url, timeout=LS_REMOTE_TIMEOUT_SEC,
907
+ network=True)
908
+ if not _ok(res):
909
+ return None, _err(res)
910
+ tags = {}
911
+ for line in res[1].decode("utf-8", errors="replace").splitlines():
912
+ sha, _, name = line.partition("\t")
913
+ sha, name = sha.strip(), name.strip()
914
+ if not SHA40_RE.match(sha) or not name.startswith("refs/tags/"):
915
+ continue
916
+ name = name[len("refs/tags/"):]
917
+ if name.endswith("^{}"):
918
+ tags[name[:-3]] = sha
919
+ else:
920
+ tags.setdefault(name, sha)
921
+ return tags, None
922
+
923
+
924
+ def match_tags(tags, version: str, name: str | None = None, implicit: bool = False) -> list[str]:
925
+ """The tags that name `version`, in the order the forms are tried.
926
+
927
+ Implicit: the version as given and `v<version>`. Otherwise also, with
928
+ name, `<name>@<v>`, `@<scope>/<name>@<v>` (any scope, by name),
929
+ `<name>/v<v>`, `<name>/<v>` and `<name>-v<v>`, where <v> drops a
930
+ leading `v` of the version.
931
+ """
932
+ bare = version[1:] if re.match(r"^[vV]\d", version) else version
933
+ forms = [version, f"v{bare}"]
934
+ if name and not implicit:
935
+ scoped = re.compile(rf"@[^/@]+/{re.escape(name)}@{re.escape(bare)}")
936
+ forms += [f"{name}@{bare}", *sorted(t for t in tags if scoped.fullmatch(t)),
937
+ f"{name}/v{bare}", f"{name}/{bare}", f"{name}-v{bare}"]
938
+ return [tag for tag in dict.fromkeys(forms) if tag in tags]
939
+
940
+
941
+ def nearest_tags(tags, version: str, count: int = NEAREST_TAGS) -> list[str]:
942
+ """Up to `count` tags that sort next to `version` by version, newest first."""
943
+ wanted = split_version(version)
944
+ if wanted is None:
945
+ return []
946
+ keyed = sorted((split[1], tag) for tag in tags if (split := split_version(tag)) is not None)
947
+ at = bisect.bisect_left([key for key, _tag in keyed], wanted[1])
948
+ lo, hi, picked = at - 1, at, []
949
+ while len(picked) < count and (lo >= 0 or hi < len(keyed)):
950
+ if hi < len(keyed) and (lo < 0 or hi - at <= at - 1 - lo):
951
+ picked.append(keyed[hi])
952
+ hi += 1
953
+ else:
954
+ picked.append(keyed[lo])
955
+ lo -= 1
956
+ return [tag for _key, tag in sorted(picked, reverse=True)]
957
+
958
+
959
+ # --------------------------------------------------------------------------
960
+ # Run folders
961
+ # --------------------------------------------------------------------------
962
+
963
+
964
+ def _on_rm_error(func, path, _exc) -> None:
965
+ """Make a read-only entry writable (Windows git pack files), then retry.
966
+
967
+ A link is never chmod-ed: that would change the file it points to.
968
+ """
969
+ try:
970
+ if not os.path.islink(path):
971
+ os.chmod(path, stat.S_IMODE(os.lstat(path).st_mode) | stat.S_IWRITE)
972
+ func(path)
973
+ except OSError:
974
+ pass
975
+
976
+
977
+ def _long_path(path: str) -> str:
978
+ """The extended-length form of an absolute Windows path (`\\\\?\\C:\\...`,
979
+ `\\\\?\\UNC\\server\\...`), which Windows accepts past MAX_PATH (260
980
+ characters) even where long paths are not enabled."""
981
+ if path.startswith("\\\\?\\"):
982
+ return path
983
+ if path.startswith("\\\\"):
984
+ return "\\\\?\\UNC\\" + path[2:]
985
+ return "\\\\?\\" + path
986
+
987
+
988
+ def _rmtree(path: str | Path, windows: bool = os.name == "nt") -> bool:
989
+ """Remove a folder tree; True when nothing is left at path.
990
+
991
+ On Windows through the extended-length path: the checkout passes
992
+ core.longpaths, so git writes files past MAX_PATH that a plain path
993
+ can neither list nor remove.
994
+ """
995
+ target = _long_path(os.path.abspath(path)) if windows else path
996
+ if sys.version_info >= (3, 12):
997
+ shutil.rmtree(target, onexc=_on_rm_error)
998
+ else:
999
+ shutil.rmtree(target, onerror=_on_rm_error)
1000
+ return not os.path.lexists(path)
1001
+
1002
+
1003
+ def _tree_roots() -> list[Path]:
1004
+ return [workspace_root() / "trees", Path(tempfile.gettempdir())]
1005
+
1006
+
1007
+ def sweep(warnings: list[str]) -> None:
1008
+ """Remove run folders whose name stamp is STALE_TREE_DAYS old or more."""
1009
+ now = datetime.now(timezone.utc)
1010
+ for root in _tree_roots():
1011
+ try:
1012
+ names = os.listdir(root)
1013
+ except OSError:
1014
+ continue
1015
+ for name in names:
1016
+ m = TREE_NAME_RE.match(name)
1017
+ if not m:
1018
+ continue
1019
+ try:
1020
+ stamp = datetime.strptime(m.group(1), STAMP_FORMAT).replace(tzinfo=timezone.utc)
1021
+ except ValueError:
1022
+ continue
1023
+ if now - stamp < timedelta(days=STALE_TREE_DAYS):
1024
+ continue # recent, or stamped in the future
1025
+ path = root / name
1026
+ if _is_link_or_junction(path):
1027
+ continue
1028
+ try:
1029
+ st = os.lstat(path)
1030
+ except OSError:
1031
+ continue
1032
+ if not stat.S_ISDIR(st.st_mode):
1033
+ continue
1034
+ if os.name != "nt" and st.st_uid != os.getuid():
1035
+ continue
1036
+ if not _rmtree(path):
1037
+ warnings.append(f"could not remove {path}")
1038
+
1039
+
1040
+ def _new_run_dir(warnings: list[str]) -> Path | None:
1041
+ prefix = f"{TREE_PREFIX}{datetime.now(timezone.utc).strftime(STAMP_FORMAT)}-"
1042
+ trees = workspace_root() / "trees"
1043
+ try:
1044
+ os.makedirs(trees, exist_ok=True)
1045
+ return Path(tempfile.mkdtemp(prefix=prefix, dir=trees))
1046
+ except OSError:
1047
+ warnings.append(f"could not create a tree folder under {trees}; using the system temp folder")
1048
+ try:
1049
+ return Path(tempfile.mkdtemp(prefix=prefix, dir=tempfile.gettempdir()))
1050
+ except OSError:
1051
+ return None
1052
+
1053
+
1054
+ def _write_json(path: str | Path, obj: dict) -> None:
1055
+ # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
1056
+ # translation that would otherwise corrupt verbatim writes on Windows.
1057
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o644)
1058
+ try:
1059
+ os.write(fd, json.dumps(obj).encode("utf-8"))
1060
+ finally:
1061
+ os.close(fd)
1062
+
1063
+
1064
+ def _read_record(run: Path) -> dict | None:
1065
+ """run.json of run folder `run` when it names this tool and a tree inside the folder, else None."""
1066
+ try:
1067
+ with open(run / RUN_FILE, encoding="utf-8") as fh:
1068
+ record = json.load(fh)
1069
+ except (OSError, ValueError):
1070
+ return None
1071
+ if not isinstance(record, dict) or record.get("tool") != TOOL or not isinstance(record.get("tree"), str):
1072
+ return None
1073
+ if not _same_path(Path(record["tree"]).parent, run):
1074
+ return None
1075
+ return record
1076
+
1077
+
1078
+ def _locate_run(given: Path) -> tuple[Path, Path | None]:
1079
+ """(run folder, tree) for a --tree value: the tree open printed, or its run folder (tree None).
1080
+
1081
+ An agent may bind the run folder, where changed-files.json sits, rather
1082
+ than the tree: a folder named like a run folder that holds run.json (or
1083
+ is gone) is taken as one.
1084
+ """
1085
+ if TREE_NAME_RE.match(given.name) and (not os.path.lexists(given) or os.path.isfile(given / RUN_FILE)):
1086
+ return given, None
1087
+ return given.parent, given
1088
+
1089
+
1090
+ def _seed(tree: Path, sha: str, name: str, clone: str | None, usable: bool, url: str,
1091
+ remote_ref: str | None) -> bool:
1092
+ """Fetch one commit into refs/skf/<name> of the tree.
1093
+
1094
+ From the shared clone when it holds the commit (a local fetch only reads
1095
+ the clone), otherwise from the remote. True when the ref names a commit.
1096
+ """
1097
+ dest = f"refs/skf/{name}"
1098
+ if usable and clone and sha and _ok(_git(clone, "cat-file", "-e", f"{sha}^{{commit}}")):
1099
+ _git(tree, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", str(clone), f"+{sha}:{dest}")
1100
+ if _rev(tree, dest):
1101
+ return True
1102
+ if remote_ref:
1103
+ _git(tree, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", url, f"+{remote_ref}:{dest}",
1104
+ timeout=NETWORK_TIMEOUT_SEC, network=True)
1105
+ return _rev(tree, dest) is not None
1106
+
1107
+
1108
+ def _make_tree(source_repo: str, repo: str, sha: str, clone: str | None, usable: bool, url: str,
1109
+ remote_ref: str | None, warnings: list[str]) -> tuple[Path | None, dict | None, str | None]:
1110
+ """A new run folder whose tree holds sha in refs/skf/target: (tree, run.json record, None).
1111
+
1112
+ (None, None, None) when no run folder could be made, and (None, None,
1113
+ error line) when the commit could not be fetched; the folder is then
1114
+ removed again.
1115
+ """
1116
+ run = _new_run_dir(warnings)
1117
+ if run is None:
1118
+ return None, None, None
1119
+ tree = run / repo
1120
+ record = {"tool": TOOL, "created": datetime.now(timezone.utc).strftime(STAMP_FORMAT), "tree": str(tree),
1121
+ "source_repo": source_repo, "clone": clone}
1122
+ _write_json(run / RUN_FILE, record)
1123
+ init = _git(run, "init", "--quiet", "--template=", repo)
1124
+ if _ok(init) and _seed(tree, sha, "target", clone, usable, url, remote_ref):
1125
+ return tree, record, None
1126
+ _rmtree(run)
1127
+ return None, None, _err(init) if not _ok(init) else "the commit could not be fetched"
1128
+
1129
+
1130
+ def _check_out(tree: Path) -> tuple[str | None, tuple[int, bytes, str] | None]:
1131
+ """Check the tree out at refs/skf/target: (the commit, or None when that failed; git's result)."""
1132
+ target_commit = _rev(tree, "refs/skf/target")
1133
+ checkout = _git(tree, *CHECKOUT_OPTS, "checkout", "--quiet", "--detach", target_commit or "",
1134
+ timeout=NETWORK_TIMEOUT_SEC)
1135
+ if not target_commit or not _ok(checkout) or _rev(tree, "HEAD") != target_commit:
1136
+ return None, checkout
1137
+ return target_commit, checkout
1138
+
1139
+
1140
+ def _unavailable(out: dict, reason: str, message: str) -> tuple[int, dict]:
1141
+ """Mark out unavailable and null the result keys the command reports (open or resolve)."""
1142
+ out.update(status="unavailable", reason=reason, message=message)
1143
+ for key in ("tree", "target_commit", "moved", "diff_status", "changed_files", "changed_counts",
1144
+ "source_commit"):
1145
+ if key in out:
1146
+ out[key] = None
1147
+ return 3, out
1148
+
1149
+
1150
+ # --------------------------------------------------------------------------
1151
+ # open
1152
+ # --------------------------------------------------------------------------
1153
+
1154
+
1155
+ def _diff(tree: Path, base: str, target: str) -> list[dict] | None:
1156
+ if base == target:
1157
+ return []
1158
+ res = _git(tree, "diff", "--name-status", "-z", "--no-renames", base, target)
1159
+ if not _ok(res):
1160
+ return None
1161
+ fields = res[1].split(b"\0")
1162
+ files = []
1163
+ for i in range(0, len(fields) - 1, 2):
1164
+ status_letter = fields[i].decode("ascii", errors="replace")[:1]
1165
+ path = fields[i + 1].decode("utf-8", errors="surrogateescape")
1166
+ status_letter = "M" if status_letter == "T" else status_letter
1167
+ if status_letter in ("A", "M", "D") and path:
1168
+ files.append({"status": status_letter, "path": path})
1169
+ return sorted(files, key=lambda f: f["path"])
1170
+
1171
+
1172
+ def open_tree(args: argparse.Namespace) -> tuple[int, dict]:
1173
+ out = {
1174
+ "status": None, "skip_reason": None, "reason": None, "message": None, "tree": None,
1175
+ "clone": None, "target_ref": None, "ref_kind": None, "target_commit": None,
1176
+ "moved": None, "diff_status": None, "changed_files": None, "changed_counts": None,
1177
+ "warnings": [],
1178
+ }
1179
+ _start_clock(args.timeout)
1180
+ source_repo = args.source_repo.strip()
1181
+ ref = (args.target_ref or args.source_ref or "").strip()
1182
+ if ref.lower() in HEAD_ALIASES:
1183
+ ref = "HEAD"
1184
+ elif re.fullmatch(r"[0-9A-Fa-f]{40}", ref):
1185
+ ref = ref.lower()
1186
+
1187
+ def _unavailable_or_late(reason: str, message: str) -> tuple[int, dict]:
1188
+ if _late(reason, _OPEN_NETWORK_LATE, _OPEN_LOCAL_LATE):
1189
+ reason = "timed-out"
1190
+ message = (f"Reading {ref} of {source_repo} did not finish within the "
1191
+ f"{args.timeout:g}-second time limit; a first fetch of a large repository is slow")
1192
+ return _unavailable(out, reason, message)
1193
+
1194
+ found = classify(source_repo, args.source_root)
1195
+ out["warnings"].extend(found["warnings"])
1196
+ if found["skip_reason"]:
1197
+ if found["parsed"] is not None and _out_of_time():
1198
+ # The clone checks were cut short: never read the folder as it stands.
1199
+ out["target_ref"] = ref
1200
+ return _unavailable_or_late("upstream-unreachable", "")
1201
+ out.update(status="skipped", skip_reason=found["skip_reason"])
1202
+ return 0, out
1203
+ parsed, clone, usable = found["parsed"], found["clone"], found["usable"]
1204
+ out["clone"] = clone
1205
+ out["target_ref"] = ref
1206
+ if _resolve_outside_cwd("git") is None:
1207
+ return _unavailable(out, "git-unavailable",
1208
+ f"git is not installed; update-skill needs it to read {source_repo}")
1209
+ if ref != "HEAD" and not SHA40_RE.match(ref) and not _ref_ok(ref):
1210
+ return _unavailable_or_late("invalid-ref", f"{ref} is not a valid tag or branch name")
1211
+
1212
+ sweep(out["warnings"])
1213
+
1214
+ # `null` and `none` are a JSON null written out, as for --source-ref.
1215
+ pinned = (args.pinned_commit or "").strip().lower()
1216
+ pin = "" if is_skippable_pinned(pinned) or pinned in ("null", "none") else pinned
1217
+ pin_full, pin_local = None, False
1218
+ if pin and usable:
1219
+ pin_full = _rev(clone, pin)
1220
+ pin_local = pin_full is not None
1221
+ if pin_full is None and SHA40_RE.match(pin):
1222
+ pin_full = pin
1223
+
1224
+ url = _url(source_repo, parsed)
1225
+ state, kind, sha, remote_ref, error = resolve_ref(url, ref)
1226
+ offline_message = None
1227
+ if state == "ref-not-found":
1228
+ return _unavailable(out, "ref-not-found",
1229
+ f"{ref} is neither a tag nor a branch of {source_repo}; "
1230
+ "pass the ref to use with --target-ref")
1231
+ if state == "unreachable":
1232
+ if args.target_ref:
1233
+ return _unavailable_or_late("upstream-unreachable",
1234
+ f"Could not reach {source_repo} ({error}), so {ref} could not be read")
1235
+ if not pin_local:
1236
+ pinned = pin[:12] or "(none recorded)"
1237
+ return _unavailable_or_late("upstream-unreachable",
1238
+ f"Could not reach {source_repo} ({error}), and no local copy of the "
1239
+ f"pinned commit {pinned} exists")
1240
+ sha, kind, remote_ref = pin_full, "pinned", None
1241
+ offline_message = (f"Could not reach {source_repo} ({error}); read the pinned commit "
1242
+ f"{pin_full[:12]} from the local copy, so upstream changes were not checked")
1243
+
1244
+ while True:
1245
+ tree, record, fetch_error = _make_tree(source_repo, parsed[2], sha, clone, usable, url, remote_ref,
1246
+ out["warnings"])
1247
+ if tree is not None:
1248
+ break
1249
+ if fetch_error is None:
1250
+ return _unavailable(out, "tree-folder-failed",
1251
+ "Could not create a folder for this run's source tree in the SKF "
1252
+ "workspace or the system temp folder")
1253
+ if offline_message is None and not args.target_ref and pin_local:
1254
+ sha, kind, remote_ref = pin_full, "pinned", None
1255
+ offline_message = (f"Could not fetch {ref} from {source_repo}; read the pinned commit "
1256
+ f"{pin_full[:12]} from the local copy, so upstream changes were not checked")
1257
+ continue
1258
+ return _unavailable_or_late("fetch-failed", f"Could not fetch {ref} from {source_repo}: {fetch_error}")
1259
+
1260
+ run = tree.parent
1261
+ target_commit, checkout = _check_out(tree)
1262
+ if target_commit is None:
1263
+ _rmtree(run)
1264
+ return _unavailable_or_late("checkout-failed",
1265
+ f"Could not check out {ref} of {source_repo}: {_err(checkout)}")
1266
+
1267
+ moved = None if not pin else classify_match(pin, target_commit) is None
1268
+ base = None
1269
+ if moved is False:
1270
+ base = target_commit
1271
+ elif pin_full and _seed(tree, pin_full, "base", clone, usable, url,
1272
+ pin_full if SHA40_RE.match(pin_full) else None):
1273
+ base = _rev(tree, "refs/skf/base")
1274
+
1275
+ files = _diff(tree, base, target_commit) if base else None
1276
+ if files is None:
1277
+ if pin_full and _expired():
1278
+ out["warnings"].append(f"the list of files changed since {pin_full[:12]} was not built within "
1279
+ f"the {args.timeout:g}-second time limit")
1280
+ out.update(diff_status="unavailable", changed_files=None, changed_counts=None)
1281
+ else:
1282
+ _write_json(run / CHANGED_FILE, {"base": base, "target": target_commit, "files": files})
1283
+ counts = {"added": 0, "modified": 0, "deleted": 0}
1284
+ for entry in files:
1285
+ counts[{"A": "added", "M": "modified", "D": "deleted"}[entry["status"]]] += 1
1286
+ out.update(diff_status="ok", changed_files=str(run / CHANGED_FILE), changed_counts=counts)
1287
+
1288
+ record["target_commit"] = target_commit
1289
+ _write_json(run / RUN_FILE, record)
1290
+ out.update(status="offline" if offline_message else "ready", message=offline_message,
1291
+ tree=str(tree), ref_kind=kind, target_commit=target_commit, moved=moved)
1292
+ return 0, out
1293
+
1294
+
1295
+ # --------------------------------------------------------------------------
1296
+ # resolve
1297
+ # --------------------------------------------------------------------------
1298
+
1299
+
1300
+ def _given(value: str | None) -> str:
1301
+ """value stripped; "" for an empty, `null` or `none` value (a JSON null written out)."""
1302
+ value = (value or "").strip()
1303
+ return "" if value.lower() in ("null", "none") else value
1304
+
1305
+
1306
+ def resolve(args: argparse.Namespace) -> tuple[int, dict]:
1307
+ out = {
1308
+ "status": None, "skip_reason": None, "reason": None, "message": None, "workspace_path": None,
1309
+ "clone": None, "source_ref": None, "ref_kind": None, "source_commit": None, "tree": None,
1310
+ "tag_resolution": {"status": "none", "mode": None, "requested": None, "candidates": [], "nearest": [],
1311
+ "reason": None},
1312
+ "clone_status": None, "clone_skip_reason": None, "clone_created": False, "warnings": [],
1313
+ }
1314
+ tags_out = out["tag_resolution"]
1315
+ _start_clock(args.timeout)
1316
+ source_repo = args.source_repo.strip()
1317
+ target_ref, version, name = _given(args.target_ref), _given(args.version), _given(args.name)
1318
+ source_root = _given(args.source_root)
1319
+ ref = None
1320
+
1321
+ def _unavailable_or_late(reason: str, message: str) -> tuple[int, dict]:
1322
+ if _late(reason, _OPEN_NETWORK_LATE, _OPEN_LOCAL_LATE):
1323
+ reason = "timed-out"
1324
+ message = (f"Reading {ref or target_ref or version or 'HEAD'} of {source_repo} did not finish "
1325
+ f"within the {args.timeout:g}-second time limit; a first fetch of a large repository "
1326
+ "is slow")
1327
+ return _unavailable(out, reason, message)
1328
+
1329
+ parsed = parse_remote(source_repo)
1330
+ if parsed is None or not _stays_inside(parsed):
1331
+ out.update(status="skipped", skip_reason="not-remote")
1332
+ return 0, out
1333
+ workspace = clone_path(parsed)
1334
+ out["workspace_path"] = str(workspace)
1335
+ found = classify(source_repo, source_root or str(workspace))
1336
+ out["warnings"].extend(found["warnings"])
1337
+ clone, usable = found["clone"], found["usable"]
1338
+ if clone is None and not found["warnings"]:
1339
+ out["warnings"].append(f"{source_root} is not SKF's clone of {source_repo}; "
1340
+ "the tree is read from the remote and that folder is left alone")
1341
+ out["clone"] = clone
1342
+ if _resolve_outside_cwd("git") is None:
1343
+ return _unavailable(out, "git-unavailable", f"git is not installed; it is needed to read {source_repo}")
1344
+ url = _url(source_repo, parsed)
1345
+
1346
+ kind = sha = remote_ref = None
1347
+ if target_ref:
1348
+ tags_out["requested"] = target_ref
1349
+ wanted = "HEAD" if target_ref.lower() == "head" else target_ref
1350
+ wanted = wanted.lower() if re.fullmatch(r"[0-9A-Fa-f]{40}", wanted) else wanted
1351
+ state = "ref-not-found"
1352
+ if wanted == "HEAD" or SHA40_RE.match(wanted) or _ref_ok(wanted):
1353
+ state, kind, sha, remote_ref, error = resolve_ref(url, wanted)
1354
+ if state == "unreachable":
1355
+ return _unavailable_or_late("upstream-unreachable", f"Could not reach {source_repo} ({error}), "
1356
+ f"so {target_ref} could not be read")
1357
+ if state == "ok":
1358
+ ref = wanted
1359
+ tags_out["status"] = "target-ref"
1360
+ else:
1361
+ tags_out.update(status="fallback-head", reason="target-ref-not-found")
1362
+ out["warnings"].append(f"target_ref ({target_ref}) does not resolve in {source_repo}; "
1363
+ + ("falling back to version matching" if version else "reading HEAD"))
1364
+
1365
+ if ref is None and version:
1366
+ mode = "implicit" if args.implicit else "explicit"
1367
+ tags_out.update(mode=mode, requested=version, reason=None)
1368
+ tags, error = list_tags(url)
1369
+ if tags is None:
1370
+ return _unavailable_or_late("upstream-unreachable",
1371
+ f"Could not list the tags of {source_repo} ({error})")
1372
+ candidates = match_tags(tags, version, name or None, implicit=args.implicit)
1373
+ tags_out["candidates"] = candidates
1374
+ if len(candidates) > 1:
1375
+ tags_out["status"] = "ambiguous"
1376
+ out.update(status="ambiguous",
1377
+ message=(f"Several tags of {source_repo} match version {version}: "
1378
+ f"{', '.join(candidates)}. Run resolve again with the one to use as "
1379
+ "--target-ref."))
1380
+ return 0, out
1381
+ if candidates:
1382
+ ref, kind, sha, remote_ref = candidates[0], "tag", tags[candidates[0]], f"refs/tags/{candidates[0]}"
1383
+ tags_out["status"] = "matched"
1384
+ else:
1385
+ nearest = nearest_tags(tags, version)
1386
+ tags_out.update(status="fallback-head", reason="no-matching-tag", nearest=nearest)
1387
+ out["warnings"].append(f"no tag of {source_repo} matches version {version}; reading HEAD, the "
1388
+ f"default branch (nearest tags: {', '.join(nearest) or 'none'})")
1389
+
1390
+ if ref is None:
1391
+ state, kind, sha, remote_ref, error = resolve_ref(url, "HEAD")
1392
+ if state == "unreachable":
1393
+ return _unavailable_or_late("upstream-unreachable",
1394
+ f"Could not reach {source_repo} ({error}), so HEAD could not be read")
1395
+ if state != "ok":
1396
+ return _unavailable(out, "ref-not-found", f"{source_repo} has no default branch to read")
1397
+ ref = "HEAD"
1398
+ out.update(source_ref=ref, ref_kind=kind)
1399
+
1400
+ sweep(out["warnings"])
1401
+ tree, record, fetch_error = _make_tree(source_repo, parsed[2], sha, clone, usable, url, remote_ref,
1402
+ out["warnings"])
1403
+ if tree is None and fetch_error is None:
1404
+ return _unavailable(out, "tree-folder-failed",
1405
+ "Could not create a folder for this run's source tree in the SKF "
1406
+ "workspace or the system temp folder")
1407
+ if tree is None:
1408
+ return _unavailable_or_late("fetch-failed", f"Could not fetch {ref} from {source_repo}: {fetch_error}")
1409
+ source_commit, checkout = _check_out(tree)
1410
+ if source_commit is None:
1411
+ _rmtree(tree.parent)
1412
+ return _unavailable_or_late("checkout-failed",
1413
+ f"Could not check out {ref} of {source_repo}: {_err(checkout)}")
1414
+ record["target_commit"] = source_commit
1415
+ _write_json(tree.parent / RUN_FILE, record)
1416
+ out.update(status="ready", tree=str(tree), source_commit=source_commit)
1417
+
1418
+ if args.update_clone:
1419
+ if clone is None:
1420
+ out.update(clone_status="skipped", clone_skip_reason="not-a-clone")
1421
+ else:
1422
+ moved = _advance(argparse.Namespace(
1423
+ clone=clone, source_repo=source_repo, target=source_commit, expect_commit="", target_ref=ref,
1424
+ tree=str(tree), hygiene_helper=args.hygiene_helper, drift_helper="",
1425
+ lock_timeout=args.lock_timeout, timeout=args.timeout), verify=False)
1426
+ out.update(clone_status=moved["status"], clone_skip_reason=moved["skip_reason"],
1427
+ clone_created=moved["created"])
1428
+ out["warnings"].extend(f"clone: {w}" for w in moved["warnings"])
1429
+ return 0, out
1430
+
1431
+
1432
+ # --------------------------------------------------------------------------
1433
+ # advance
1434
+ # --------------------------------------------------------------------------
1435
+
1436
+
1437
+ def _run_sibling(helper: str, *args: str) -> tuple[int, str] | None:
1438
+ """Run a sibling SKF helper with this interpreter; None when it cannot run."""
1439
+ timeout = _cap(SIBLING_TIMEOUT_SEC)
1440
+ if timeout is None:
1441
+ return None
1442
+ try:
1443
+ returncode, stdout, _stderr, _killed = _run([sys.executable, helper, *args], timeout)
1444
+ except (OSError, ValueError):
1445
+ return None
1446
+ if returncode is None:
1447
+ return None
1448
+ return returncode, stdout.decode("utf-8", errors="replace")
1449
+
1450
+
1451
+ def _clear_lock(clone: Path, res: tuple[int, bytes, str] | None, name: str, warnings: list[str]) -> None:
1452
+ """Remove the lock file `name` a git call the time limit killed left in clone's .git.
1453
+
1454
+ Only after KILLED: git held that lock while it ran (it fails at once
1455
+ when the lock exists), taskkill /F or SIGKILL kept it from removing
1456
+ it, and no other git can take it while the file is there. After
1457
+ STOPPED, SIGTERM let git remove it itself, so a lock of that name is
1458
+ another git's by then.
1459
+ """
1460
+ if res is None or res[0] != KILLED:
1461
+ return
1462
+ lock = clone / ".git" / name
1463
+ try:
1464
+ if stat.S_ISREG(os.lstat(lock).st_mode):
1465
+ os.remove(lock)
1466
+ except (FileNotFoundError, NotADirectoryError):
1467
+ pass
1468
+ except OSError as e:
1469
+ warnings.append(f"could not remove {lock} ({e}); remove it once no git command runs in {clone}")
1470
+
1471
+
1472
+ def _helper_file(path: str) -> str | None:
1473
+ if not path:
1474
+ return None
1475
+ expanded = os.path.expanduser(path)
1476
+ try:
1477
+ return expanded if stat.S_ISREG(os.stat(expanded).st_mode) else None
1478
+ except OSError:
1479
+ return None
1480
+
1481
+
1482
+ def _clone_new(clone: Path, url: str, branch: list[str], warnings: list[str]) -> tuple[int, bytes, str] | None:
1483
+ """Clone url to the missing folder clone, through a folder of this call's own.
1484
+
1485
+ git writes the clone to `.<repo>.skf-clone-*/<repo>` beside clone, and
1486
+ that folder is renamed to clone once the clone is complete: no other
1487
+ run ever sees a clone half made, and when another run made clone
1488
+ meanwhile, only this call's folder is removed. git's result, or (1, b"",
1489
+ error line) when a folder could not be made or moved.
1490
+ """
1491
+ try:
1492
+ os.makedirs(clone.parent, exist_ok=True)
1493
+ holder = Path(tempfile.mkdtemp(prefix=f".{clone.name}.skf-clone-", dir=clone.parent))
1494
+ except OSError as e:
1495
+ return 1, b"", f"error: {e}"
1496
+ try:
1497
+ made = holder / clone.name
1498
+ res = _git(holder, *CHECKOUT_OPTS, "clone", "--quiet", "--depth", "1", *branch, "--", url, str(made),
1499
+ timeout=NETWORK_TIMEOUT_SEC, network=True)
1500
+ if not _ok(res):
1501
+ return res
1502
+ # On POSIX os.rename replaces an empty folder: never take the place of a clone another run just began.
1503
+ if not os.path.lexists(clone):
1504
+ try:
1505
+ os.rename(made, clone)
1506
+ return res
1507
+ except OSError as e:
1508
+ if not os.path.lexists(clone):
1509
+ return 1, b"", f"error: could not move the new clone to {clone}: {e}"
1510
+ return 1, b"", f"error: another run created {clone} while this one cloned it"
1511
+ finally:
1512
+ if not _rmtree(holder):
1513
+ warnings.append(f"could not remove {holder}")
1514
+
1515
+
1516
+ def advance(args: argparse.Namespace) -> dict:
1517
+ _start_clock(args.timeout)
1518
+ return _advance(args)
1519
+
1520
+
1521
+ def _advance(args: argparse.Namespace, verify: bool = True) -> dict:
1522
+ """advance within the time limit already started; verify=False (resolve)
1523
+ moves a clone at any commit, since that run owns the move, once its HEAD
1524
+ resolves."""
1525
+ out = {"status": "skipped", "skip_reason": None, "head_sha": None, "created": False,
1526
+ "lock": "not-taken", "hygiene": "skipped", "log_message": "", "warnings": []}
1527
+ target = args.target.strip().lower()
1528
+ clone = Path(os.path.expanduser(args.clone.strip())) if args.clone.strip() else None
1529
+
1530
+ def finish(status: str, reason: str | None = None) -> dict:
1531
+ if _late(reason, _ADVANCE_NETWORK_LATE, _ADVANCE_LOCAL_LATE):
1532
+ reason = "timed-out"
1533
+ if reason == "timed-out":
1534
+ out["warnings"].append(f"the {args.timeout:g}-second time limit ran out before the clone was moved")
1535
+ head = _rev(clone, "HEAD", limited=False) if clone is not None and _stat_dir(clone) is True else None
1536
+ out.update(status=status, skip_reason=reason, head_sha=head)
1537
+ short = head[:7] if head else "no commit"
1538
+ if status == "advanced":
1539
+ out["log_message"] = f"source_tree_advance: advanced {out.pop('_old', 'none')} -> {short}"
1540
+ elif status == "ok":
1541
+ out["log_message"] = f"source_tree_advance: ok ({short})"
1542
+ else:
1543
+ out["log_message"] = f"source_tree_advance: skipped ({reason}) at {short}"
1544
+ out.pop("_old", None)
1545
+ return out
1546
+
1547
+ if not SHA40_RE.match(target):
1548
+ return finish("skipped", "invalid-target")
1549
+ parsed = parse_remote(args.source_repo)
1550
+ if parsed is None or clone is None or not _stays_inside(parsed):
1551
+ return finish("skipped", "not-a-clone")
1552
+ url = _url(args.source_repo, parsed)
1553
+
1554
+ state = _stat_dir(clone)
1555
+ clone_head = None
1556
+ if state is False:
1557
+ target_ref = (args.target_ref or "").strip()
1558
+ branch = []
1559
+ if target_ref.lower() not in HEAD_ALIASES and not SHA40_RE.match(target_ref.lower()):
1560
+ branch = [f"--branch={target_ref}"]
1561
+ res = _clone_new(clone, url, branch, out["warnings"])
1562
+ if not _ok(res):
1563
+ out["warnings"].append(_err(res))
1564
+ return finish("skipped", "clone-failed")
1565
+ out["created"] = True
1566
+ clone_head = _rev(clone, "HEAD")
1567
+ elif state is None or not _clone_matches(clone, parsed):
1568
+ return finish("skipped", "not-a-clone")
1569
+
1570
+ fd = None
1571
+ try:
1572
+ try:
1573
+ # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
1574
+ # translation; the lock file is never written, only locked.
1575
+ fd = os.open(clone / LOCK_NAME, os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0), 0o644)
1576
+ deadline = time.monotonic() + (_cap(max(0.0, args.lock_timeout)) or 0.0)
1577
+ while True:
1578
+ try:
1579
+ _acquire_lock(fd)
1580
+ out["lock"] = "held"
1581
+ break
1582
+ except OSError as e:
1583
+ if e.errno != errno.EAGAIN:
1584
+ raise
1585
+ if time.monotonic() >= deadline:
1586
+ out["lock"] = "busy"
1587
+ return finish("skipped", "lock-busy")
1588
+ time.sleep(LOCK_POLL_SEC)
1589
+ except OSError as e:
1590
+ out["warnings"].append(f"concurrency guard unavailable ({e}); continuing without it")
1591
+ out["lock"] = "unavailable"
1592
+ if fd is not None:
1593
+ os.close(fd)
1594
+ fd = None
1595
+
1596
+ head = _rev(clone, "HEAD")
1597
+ if head is None and not verify:
1598
+ # This run owns the move, but a HEAD that does not resolve may be another run's clone still
1599
+ # being written (create-skill clones before it locks): leave that clone to its run.
1600
+ return finish("skipped", "head-unverified")
1601
+
1602
+ hygiene = _helper_file(args.hygiene_helper)
1603
+ if hygiene:
1604
+ ran = _run_sibling(hygiene, "workspace", "--repo", str(clone))
1605
+ out["hygiene"] = "ran" if ran is not None and ran[0] == 0 else "failed"
1606
+
1607
+ out["_old"] = head[:7] if head else "none"
1608
+ if head == target:
1609
+ return finish("ok")
1610
+ if verify and out["created"]:
1611
+ if head != clone_head:
1612
+ return finish("skipped", "head-moved")
1613
+ elif verify:
1614
+ drift = _helper_file(args.drift_helper)
1615
+ if not drift:
1616
+ return finish("skipped", "head-unverified")
1617
+ ran = _run_sibling(drift, str(clone), "--pinned-commit", args.expect_commit)
1618
+ try:
1619
+ verdict = json.loads(ran[1]).get("status") if ran is not None else None
1620
+ except (ValueError, AttributeError):
1621
+ verdict = None
1622
+ if verdict == "mismatch":
1623
+ return finish("skipped", "head-moved")
1624
+ if verdict != "ok":
1625
+ return finish("skipped", "head-unverified")
1626
+
1627
+ status = _git(clone, "--no-optional-locks", "status", "--porcelain=v1", "-z", "--untracked-files=no")
1628
+ if not _ok(status) or status[1]:
1629
+ return finish("skipped", "local-changes")
1630
+
1631
+ # A fetch or checkout cut short in the shared clone could leave a lock
1632
+ # file there: start one only with the local reserve still ahead.
1633
+ if _expired():
1634
+ return finish("skipped", "timed-out")
1635
+ if not _ok(_git(clone, "cat-file", "-e", f"{target}^{{commit}}")):
1636
+ tree = os.path.expanduser(args.tree.strip()) if args.tree.strip() else ""
1637
+ if tree:
1638
+ run, named = _locate_run(Path(tree))
1639
+ record = _read_record(run) if named is None else None
1640
+ tree = record["tree"] if record else tree
1641
+ if tree and _stat_dir(tree) is True:
1642
+ fetched = _git(clone, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", tree,
1643
+ f"+{target}:{ADVANCE_REF}")
1644
+ _clear_lock(clone, fetched, "shallow.lock", out["warnings"])
1645
+ if not _ok(_git(clone, "cat-file", "-e", f"{target}^{{commit}}")):
1646
+ fetched = _git(clone, "fetch", "--quiet", "--no-tags", "--depth", "1", "--", url,
1647
+ f"+{target}:{ADVANCE_REF}", timeout=NETWORK_TIMEOUT_SEC, network=True)
1648
+ _clear_lock(clone, fetched, "shallow.lock", out["warnings"])
1649
+ if not _ok(_git(clone, "cat-file", "-e", f"{target}^{{commit}}")):
1650
+ _git(clone, "update-ref", "-d", ADVANCE_REF, limited=False)
1651
+ return finish("skipped", "target-missing")
1652
+
1653
+ if _expired():
1654
+ _git(clone, "update-ref", "-d", ADVANCE_REF, limited=False)
1655
+ return finish("skipped", "timed-out")
1656
+ checkout = _git(clone, *CHECKOUT_OPTS, "checkout", "--quiet", "--detach", target,
1657
+ timeout=NETWORK_TIMEOUT_SEC)
1658
+ _clear_lock(clone, checkout, "index.lock", out["warnings"])
1659
+ _git(clone, "update-ref", "-d", ADVANCE_REF, limited=False)
1660
+ if checkout is not None and checkout[0] in (STOPPED, KILLED):
1661
+ # git moves HEAD last: a checkout stopped after it did so had finished.
1662
+ if _rev(clone, "HEAD", limited=False) == target:
1663
+ return finish("advanced")
1664
+ out["warnings"].append(
1665
+ f"the {args.timeout:g}-second time limit stopped the checkout of {target[:12]} part way, so "
1666
+ f"{clone} may hold files of both commits; it had no local changes when the checkout began, and "
1667
+ f'these commands finish it: git -C "{clone}" fetch --depth 1 origin {target} and then '
1668
+ f'git -C "{clone}" checkout --force --detach {target}')
1669
+ return finish("skipped", "checkout-interrupted")
1670
+ if not _ok(checkout):
1671
+ out["warnings"].append(_err(checkout))
1672
+ return finish("skipped", "checkout-failed")
1673
+ if _rev(clone, "HEAD") != target:
1674
+ return finish("skipped", "checkout-failed")
1675
+ return finish("advanced")
1676
+ finally:
1677
+ if fd is not None:
1678
+ _release_lock(fd)
1679
+ os.close(fd)
1680
+
1681
+
1682
+ # --------------------------------------------------------------------------
1683
+ # close
1684
+ # --------------------------------------------------------------------------
1685
+
1686
+
1687
+ def close(args: argparse.Namespace) -> dict:
1688
+ out = {"status": "refused", "tree": args.tree, "warnings": []}
1689
+ given = Path(os.path.expanduser(args.tree.strip())) if args.tree.strip() else None
1690
+ if given is None:
1691
+ return out
1692
+ run, tree = _locate_run(given)
1693
+ if not TREE_NAME_RE.match(run.name):
1694
+ return out
1695
+ if not os.path.lexists(run):
1696
+ out["status"] = "missing"
1697
+ return out
1698
+ if _is_link_or_junction(run):
1699
+ return out
1700
+ try:
1701
+ st = os.lstat(run)
1702
+ except OSError:
1703
+ return out
1704
+ if not stat.S_ISDIR(st.st_mode) or (os.name != "nt" and st.st_uid != os.getuid()):
1705
+ return out
1706
+ record = _read_record(run)
1707
+ if record is None or (tree is not None and not _same_path(record["tree"], tree)):
1708
+ return out
1709
+ if _rmtree(run):
1710
+ out["status"] = "removed"
1711
+ else:
1712
+ out["status"] = "left"
1713
+ out["warnings"].append(f"could not remove {run}; a later run removes it once it is "
1714
+ f"{STALE_TREE_DAYS} days old")
1715
+ return out
1716
+
1717
+
1718
+ # --------------------------------------------------------------------------
1719
+ # CLI
1720
+ # --------------------------------------------------------------------------
1721
+
1722
+
1723
+ def _build_parser() -> argparse.ArgumentParser:
1724
+ parser = argparse.ArgumentParser(
1725
+ prog="skf-source-tree",
1726
+ description="Read a remote skill's source at one commit, in a private tree.",
1727
+ )
1728
+ sub = parser.add_subparsers(dest="cmd", required=True)
1729
+
1730
+ p_open = sub.add_parser("open", help="prepare this run's private source tree")
1731
+ p_open.add_argument("--source-repo", default="", help="metadata.json source_repo")
1732
+ p_open.add_argument("--source-root", default="", help="metadata.json source_root")
1733
+ p_open.add_argument("--source-ref", default="", help="metadata.json source_ref")
1734
+ p_open.add_argument("--pinned-commit", default="", help="metadata.json source_commit")
1735
+ p_open.add_argument("--target-ref", default="", help="tag, branch, HEAD or commit to read instead")
1736
+ p_open.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
1737
+ help="seconds open may take (default 100)")
1738
+
1739
+ p_resolve = sub.add_parser("resolve",
1740
+ help="pick the ref a new run reads and prepare its private source tree")
1741
+ p_resolve.add_argument("--source-repo", required=True, help="the remote repository (URL or owner/repo)")
1742
+ p_resolve.add_argument("--source-root", default="",
1743
+ help="SKF's clone of it, when not at the workspace path (metadata.json source_root)")
1744
+ p_resolve.add_argument("--target-ref", default="", help="tag, branch, HEAD or commit to read")
1745
+ p_resolve.add_argument("--version", default="", help="version to find among the tags")
1746
+ p_resolve.add_argument("--name", default="", help="skill name, for the monorepo tag forms")
1747
+ p_resolve.add_argument("--implicit", action="store_true",
1748
+ help="--version is only a hint: try the version and v<version> alone")
1749
+ p_resolve.add_argument("--update-clone", action="store_true",
1750
+ help="also move SKF's clone to the commit read (clone it when missing)")
1751
+ p_resolve.add_argument("--hygiene-helper", default="", help="path of skf-ccc-git-hygiene.py")
1752
+ p_resolve.add_argument("--lock-timeout", type=float, default=LOCK_WAIT_SEC,
1753
+ help="seconds to wait for the workspace lock")
1754
+ p_resolve.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
1755
+ help="seconds resolve may take (default 100)")
1756
+
1757
+ p_advance = sub.add_parser("advance", help="move the workspace clone to the recorded commit")
1758
+ p_advance.add_argument("--clone", required=True, help="the workspace clone open reported")
1759
+ p_advance.add_argument("--source-repo", required=True, help="metadata.json source_repo")
1760
+ p_advance.add_argument("--target", required=True, help="the 40-hex commit the update recorded")
1761
+ p_advance.add_argument("--expect-commit", default="", help="the skill's previous source_commit")
1762
+ p_advance.add_argument("--target-ref", default="", help="the ref the update read")
1763
+ p_advance.add_argument("--tree", default="",
1764
+ help="this run's tree (or its run folder), the first place to take the commit from")
1765
+ p_advance.add_argument("--hygiene-helper", default="", help="path of skf-ccc-git-hygiene.py")
1766
+ p_advance.add_argument("--drift-helper", default="", help="path of skf-check-workspace-drift.py")
1767
+ p_advance.add_argument("--lock-timeout", type=float, default=LOCK_WAIT_SEC,
1768
+ help="seconds to wait for the workspace lock")
1769
+ p_advance.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT_SEC,
1770
+ help="seconds advance may take (default 100)")
1771
+
1772
+ p_close = sub.add_parser("close", help="remove this run's private source tree")
1773
+ p_close.add_argument("--tree", required=True, help="the tree open reported, or its run folder")
1774
+ return parser
1775
+
1776
+
1777
+ def main(argv: list[str] | None = None) -> int:
1778
+ args = _build_parser().parse_args(argv)
1779
+ try:
1780
+ if args.cmd == "open":
1781
+ code, out = open_tree(args)
1782
+ elif args.cmd == "resolve":
1783
+ code, out = resolve(args)
1784
+ elif args.cmd == "advance":
1785
+ code, out = 0, advance(args)
1786
+ else:
1787
+ code, out = 0, close(args)
1788
+ except Exception as e: # noqa: BLE001 - one JSON error line, never a traceback
1789
+ print(json.dumps({"status": "error", "message": f"{type(e).__name__}: {e}"}), file=sys.stderr)
1790
+ return 1
1791
+ print(json.dumps(out))
1792
+ return code
1793
+
1794
+
1795
+ if __name__ == "__main__":
1796
+ raise SystemExit(main())