@cleocode/core 2026.10.27 → 2026.11.1-canary.3

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 (520) hide show
  1. package/README.md +2 -0
  2. package/binaries/README.md +30 -10
  3. package/dist/.source-stamp.json +4 -0
  4. package/dist/audit/reconstruct.d.ts.map +1 -1
  5. package/dist/audit/reconstruct.js +3 -13
  6. package/dist/audit/reconstruct.js.map +1 -1
  7. package/dist/build-provenance.json +2 -2
  8. package/dist/cloud/nexus-cloud.d.ts.map +1 -1
  9. package/dist/cloud/nexus-cloud.js +3 -3
  10. package/dist/cloud/nexus-cloud.js.map +1 -1
  11. package/dist/cloud/nexus-device.d.ts +1 -1
  12. package/dist/cloud/nexus-link.d.ts +18 -2
  13. package/dist/cloud/nexus-link.d.ts.map +1 -1
  14. package/dist/cloud/nexus-link.js +26 -6
  15. package/dist/cloud/nexus-link.js.map +1 -1
  16. package/dist/cloud/nexus-vault.d.ts.map +1 -1
  17. package/dist/cloud/nexus-vault.js +3 -9
  18. package/dist/cloud/nexus-vault.js.map +1 -1
  19. package/dist/decide/sites/registry.d.ts +40 -0
  20. package/dist/decide/sites/registry.d.ts.map +1 -1
  21. package/dist/decide/sites/registry.js +38 -0
  22. package/dist/decide/sites/registry.js.map +1 -1
  23. package/dist/docs/adr-allocator.d.ts +3 -3
  24. package/dist/docs/adr-allocator.d.ts.map +1 -1
  25. package/dist/docs/adr-allocator.js +8 -5
  26. package/dist/docs/adr-allocator.js.map +1 -1
  27. package/dist/docs/blob-consolidation.d.ts.map +1 -1
  28. package/dist/docs/blob-consolidation.js +3 -2
  29. package/dist/docs/blob-consolidation.js.map +1 -1
  30. package/dist/docs/derive-links.d.ts +12 -0
  31. package/dist/docs/derive-links.d.ts.map +1 -1
  32. package/dist/docs/derive-links.js +19 -0
  33. package/dist/docs/derive-links.js.map +1 -1
  34. package/dist/docs/doc-name-rule.d.ts +105 -0
  35. package/dist/docs/doc-name-rule.d.ts.map +1 -0
  36. package/dist/docs/doc-name-rule.js +212 -0
  37. package/dist/docs/doc-name-rule.js.map +1 -0
  38. package/dist/docs/docs-inconsistency-detector.d.ts.map +1 -1
  39. package/dist/docs/docs-inconsistency-detector.js +4 -3
  40. package/dist/docs/docs-inconsistency-detector.js.map +1 -1
  41. package/dist/docs/docs-ops.d.ts +27 -5
  42. package/dist/docs/docs-ops.d.ts.map +1 -1
  43. package/dist/docs/docs-ops.js +149 -17
  44. package/dist/docs/docs-ops.js.map +1 -1
  45. package/dist/docs/docs-read-model.d.ts +45 -15
  46. package/dist/docs/docs-read-model.d.ts.map +1 -1
  47. package/dist/docs/docs-read-model.js +22 -102
  48. package/dist/docs/docs-read-model.js.map +1 -1
  49. package/dist/docs/docs-update.d.ts.map +1 -1
  50. package/dist/docs/docs-update.js +8 -2
  51. package/dist/docs/docs-update.js.map +1 -1
  52. package/dist/docs/doctor.d.ts +71 -4
  53. package/dist/docs/doctor.d.ts.map +1 -1
  54. package/dist/docs/doctor.js +324 -23
  55. package/dist/docs/doctor.js.map +1 -1
  56. package/dist/docs/duplicate-check.d.ts +138 -0
  57. package/dist/docs/duplicate-check.d.ts.map +1 -0
  58. package/dist/docs/duplicate-check.js +286 -0
  59. package/dist/docs/duplicate-check.js.map +1 -0
  60. package/dist/docs/fetch-result.d.ts.map +1 -1
  61. package/dist/docs/fetch-result.js +27 -0
  62. package/dist/docs/fetch-result.js.map +1 -1
  63. package/dist/docs/import/scanner.d.ts +2 -0
  64. package/dist/docs/import/scanner.d.ts.map +1 -1
  65. package/dist/docs/import/scanner.js +3 -1
  66. package/dist/docs/import/scanner.js.map +1 -1
  67. package/dist/docs/lineage.d.ts +66 -0
  68. package/dist/docs/lineage.d.ts.map +1 -0
  69. package/dist/docs/lineage.js +190 -0
  70. package/dist/docs/lineage.js.map +1 -0
  71. package/dist/docs/modernize/apply.d.ts +139 -0
  72. package/dist/docs/modernize/apply.d.ts.map +1 -0
  73. package/dist/docs/modernize/apply.js +760 -0
  74. package/dist/docs/modernize/apply.js.map +1 -0
  75. package/dist/docs/modernize/guard.d.ts +92 -0
  76. package/dist/docs/modernize/guard.d.ts.map +1 -0
  77. package/dist/docs/modernize/guard.js +214 -0
  78. package/dist/docs/modernize/guard.js.map +1 -0
  79. package/dist/docs/modernize/index.d.ts +68 -0
  80. package/dist/docs/modernize/index.d.ts.map +1 -0
  81. package/dist/docs/modernize/index.js +97 -0
  82. package/dist/docs/modernize/index.js.map +1 -0
  83. package/dist/docs/modernize/judge.d.ts +85 -0
  84. package/dist/docs/modernize/judge.d.ts.map +1 -0
  85. package/dist/docs/modernize/judge.js +186 -0
  86. package/dist/docs/modernize/judge.js.map +1 -0
  87. package/dist/docs/modernize/plan.d.ts +77 -0
  88. package/dist/docs/modernize/plan.d.ts.map +1 -0
  89. package/dist/docs/modernize/plan.js +510 -0
  90. package/dist/docs/modernize/plan.js.map +1 -0
  91. package/dist/docs/modernize/restore.d.ts +41 -0
  92. package/dist/docs/modernize/restore.d.ts.map +1 -0
  93. package/dist/docs/modernize/restore.js +103 -0
  94. package/dist/docs/modernize/restore.js.map +1 -0
  95. package/dist/docs/modernize/rules.d.ts +197 -0
  96. package/dist/docs/modernize/rules.d.ts.map +1 -0
  97. package/dist/docs/modernize/rules.js +510 -0
  98. package/dist/docs/modernize/rules.js.map +1 -0
  99. package/dist/docs/modernize/store.d.ts +64 -0
  100. package/dist/docs/modernize/store.d.ts.map +1 -0
  101. package/dist/docs/modernize/store.js +390 -0
  102. package/dist/docs/modernize/store.js.map +1 -0
  103. package/dist/docs/publications-store.d.ts +16 -2
  104. package/dist/docs/publications-store.d.ts.map +1 -1
  105. package/dist/docs/publications-store.js +23 -2
  106. package/dist/docs/publications-store.js.map +1 -1
  107. package/dist/docs/publish-pr.d.ts.map +1 -1
  108. package/dist/docs/publish-pr.js +12 -1
  109. package/dist/docs/publish-pr.js.map +1 -1
  110. package/dist/docs/rot.d.ts +94 -0
  111. package/dist/docs/rot.d.ts.map +1 -0
  112. package/dist/docs/rot.js +298 -0
  113. package/dist/docs/rot.js.map +1 -0
  114. package/dist/docs/similarity-check.d.ts +2 -0
  115. package/dist/docs/similarity-check.d.ts.map +1 -1
  116. package/dist/docs/similarity-check.js +6 -49
  117. package/dist/docs/similarity-check.js.map +1 -1
  118. package/dist/docs/slug-rename.d.ts +84 -0
  119. package/dist/docs/slug-rename.d.ts.map +1 -0
  120. package/dist/docs/slug-rename.js +166 -0
  121. package/dist/docs/slug-rename.js.map +1 -0
  122. package/dist/docs/slug-similarity.d.ts +24 -0
  123. package/dist/docs/slug-similarity.d.ts.map +1 -0
  124. package/dist/docs/slug-similarity.js +63 -0
  125. package/dist/docs/slug-similarity.js.map +1 -0
  126. package/dist/docs/wikilinks.d.ts +20 -4
  127. package/dist/docs/wikilinks.d.ts.map +1 -1
  128. package/dist/docs/wikilinks.js +52 -7
  129. package/dist/docs/wikilinks.js.map +1 -1
  130. package/dist/docs/writer-registry.d.ts.map +1 -1
  131. package/dist/docs/writer-registry.js +33 -0
  132. package/dist/docs/writer-registry.js.map +1 -1
  133. package/dist/doctor/type-matrix-repair.d.ts +194 -0
  134. package/dist/doctor/type-matrix-repair.d.ts.map +1 -0
  135. package/dist/doctor/type-matrix-repair.js +662 -0
  136. package/dist/doctor/type-matrix-repair.js.map +1 -0
  137. package/dist/gateway-client/generated/index.d.ts +2 -2
  138. package/dist/gateway-client/generated/index.d.ts.map +1 -1
  139. package/dist/gateway-client/generated/index.js +1 -1
  140. package/dist/gateway-client/generated/index.js.map +1 -1
  141. package/dist/gateway-client/generated/namespaces.gen.d.ts +5 -0
  142. package/dist/gateway-client/generated/namespaces.gen.d.ts.map +1 -1
  143. package/dist/gateway-client/generated/namespaces.gen.js +6 -1
  144. package/dist/gateway-client/generated/namespaces.gen.js.map +1 -1
  145. package/dist/gateway-client/generated/sdk.gen.d.ts +21 -1
  146. package/dist/gateway-client/generated/sdk.gen.d.ts.map +1 -1
  147. package/dist/gateway-client/generated/sdk.gen.js +55 -0
  148. package/dist/gateway-client/generated/sdk.gen.js.map +1 -1
  149. package/dist/gateway-client/generated/types.gen.d.ts +222 -0
  150. package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
  151. package/dist/gc/gc-subsystem.d.ts +4 -0
  152. package/dist/gc/gc-subsystem.d.ts.map +1 -1
  153. package/dist/gc/gc-subsystem.js +16 -0
  154. package/dist/gc/gc-subsystem.js.map +1 -1
  155. package/dist/git/work-tree.d.ts +48 -0
  156. package/dist/git/work-tree.d.ts.map +1 -1
  157. package/dist/git/work-tree.js +63 -1
  158. package/dist/git/work-tree.js.map +1 -1
  159. package/dist/hooks/handlers/session-hooks.d.ts +5 -4
  160. package/dist/hooks/handlers/session-hooks.d.ts.map +1 -1
  161. package/dist/hooks/handlers/session-hooks.js +5 -21
  162. package/dist/hooks/handlers/session-hooks.js.map +1 -1
  163. package/dist/internal.d.ts +5 -1
  164. package/dist/internal.d.ts.map +1 -1
  165. package/dist/internal.js +5 -1
  166. package/dist/internal.js.map +1 -1
  167. package/dist/memory/brain-doctor.d.ts.map +1 -1
  168. package/dist/memory/brain-doctor.js +6 -1
  169. package/dist/memory/brain-doctor.js.map +1 -1
  170. package/dist/memory/engine-compat.d.ts.map +1 -1
  171. package/dist/memory/engine-compat.js +6 -0
  172. package/dist/memory/engine-compat.js.map +1 -1
  173. package/dist/nexus/analyze-orchestrator.d.ts +24 -0
  174. package/dist/nexus/analyze-orchestrator.d.ts.map +1 -1
  175. package/dist/nexus/analyze-orchestrator.js +43 -5
  176. package/dist/nexus/analyze-orchestrator.js.map +1 -1
  177. package/dist/nexus/user-profile.d.ts +2 -1
  178. package/dist/nexus/user-profile.d.ts.map +1 -1
  179. package/dist/nexus/user-profile.js +13 -5
  180. package/dist/nexus/user-profile.js.map +1 -1
  181. package/dist/orchestrate/admission.d.ts +8 -0
  182. package/dist/orchestrate/admission.d.ts.map +1 -1
  183. package/dist/orchestrate/admission.js +3 -1
  184. package/dist/orchestrate/admission.js.map +1 -1
  185. package/dist/orchestrate/query-ops.d.ts.map +1 -1
  186. package/dist/orchestrate/query-ops.js +15 -4
  187. package/dist/orchestrate/query-ops.js.map +1 -1
  188. package/dist/orchestrate/spawn-ops.d.ts.map +1 -1
  189. package/dist/orchestrate/spawn-ops.js +43 -2
  190. package/dist/orchestrate/spawn-ops.js.map +1 -1
  191. package/dist/orchestration/index.d.ts +6 -0
  192. package/dist/orchestration/index.d.ts.map +1 -1
  193. package/dist/orchestration/index.js +3 -0
  194. package/dist/orchestration/index.js.map +1 -1
  195. package/dist/orchestration/spawn-prompt.d.ts.map +1 -1
  196. package/dist/orchestration/spawn-prompt.js +3 -1
  197. package/dist/orchestration/spawn-prompt.js.map +1 -1
  198. package/dist/paths.d.ts +43 -1
  199. package/dist/paths.d.ts.map +1 -1
  200. package/dist/paths.js +51 -1
  201. package/dist/paths.js.map +1 -1
  202. package/dist/project-lifecycle.d.ts.map +1 -1
  203. package/dist/project-lifecycle.js +31 -0
  204. package/dist/project-lifecycle.js.map +1 -1
  205. package/dist/release/authorization.d.ts +156 -0
  206. package/dist/release/authorization.d.ts.map +1 -0
  207. package/dist/release/authorization.js +460 -0
  208. package/dist/release/authorization.js.map +1 -0
  209. package/dist/release/ci-evidence.d.ts +55 -4
  210. package/dist/release/ci-evidence.d.ts.map +1 -1
  211. package/dist/release/ci-evidence.js +251 -22
  212. package/dist/release/ci-evidence.js.map +1 -1
  213. package/dist/release/ci-run-evidence.d.ts +107 -0
  214. package/dist/release/ci-run-evidence.d.ts.map +1 -0
  215. package/dist/release/ci-run-evidence.js +234 -0
  216. package/dist/release/ci-run-evidence.js.map +1 -0
  217. package/dist/release/cut.d.ts +231 -0
  218. package/dist/release/cut.d.ts.map +1 -0
  219. package/dist/release/cut.js +775 -0
  220. package/dist/release/cut.js.map +1 -0
  221. package/dist/release/deploy-evidence.d.ts +173 -0
  222. package/dist/release/deploy-evidence.d.ts.map +1 -0
  223. package/dist/release/deploy-evidence.js +381 -0
  224. package/dist/release/deploy-evidence.js.map +1 -0
  225. package/dist/release/engine-ops.d.ts.map +1 -1
  226. package/dist/release/engine-ops.js +10 -4
  227. package/dist/release/engine-ops.js.map +1 -1
  228. package/dist/release/index.d.ts +3 -0
  229. package/dist/release/index.d.ts.map +1 -1
  230. package/dist/release/index.js +2 -0
  231. package/dist/release/index.js.map +1 -1
  232. package/dist/release/invariants/archive-reason-invariant.d.ts.map +1 -1
  233. package/dist/release/invariants/archive-reason-invariant.js +4 -1
  234. package/dist/release/invariants/archive-reason-invariant.js.map +1 -1
  235. package/dist/release/open.d.ts.map +1 -1
  236. package/dist/release/open.js +2 -6
  237. package/dist/release/open.js.map +1 -1
  238. package/dist/release/plan.d.ts +34 -2
  239. package/dist/release/plan.d.ts.map +1 -1
  240. package/dist/release/plan.js +30 -6
  241. package/dist/release/plan.js.map +1 -1
  242. package/dist/release/preflight-skip.d.ts +4 -19
  243. package/dist/release/preflight-skip.d.ts.map +1 -1
  244. package/dist/release/preflight-skip.js +5 -110
  245. package/dist/release/preflight-skip.js.map +1 -1
  246. package/dist/release/reconcile-latest.d.ts +29 -0
  247. package/dist/release/reconcile-latest.d.ts.map +1 -0
  248. package/dist/release/reconcile-latest.js +71 -0
  249. package/dist/release/reconcile-latest.js.map +1 -0
  250. package/dist/release/reconcile.d.ts +3 -1
  251. package/dist/release/reconcile.d.ts.map +1 -1
  252. package/dist/release/reconcile.js +18 -6
  253. package/dist/release/reconcile.js.map +1 -1
  254. package/dist/release/release-published.d.ts +27 -0
  255. package/dist/release/release-published.d.ts.map +1 -0
  256. package/dist/release/release-published.js +35 -0
  257. package/dist/release/release-published.js.map +1 -0
  258. package/dist/release/tty-confirm.d.ts +23 -0
  259. package/dist/release/tty-confirm.d.ts.map +1 -0
  260. package/dist/release/tty-confirm.js +50 -0
  261. package/dist/release/tty-confirm.js.map +1 -0
  262. package/dist/remote/index.js +1 -1
  263. package/dist/remote/index.js.map +1 -1
  264. package/dist/resources/admission-ledger.d.ts +103 -1
  265. package/dist/resources/admission-ledger.d.ts.map +1 -1
  266. package/dist/resources/admission-ledger.js +200 -17
  267. package/dist/resources/admission-ledger.js.map +1 -1
  268. package/dist/resources/agent-lease.d.ts +82 -0
  269. package/dist/resources/agent-lease.d.ts.map +1 -0
  270. package/dist/resources/agent-lease.js +167 -0
  271. package/dist/resources/agent-lease.js.map +1 -0
  272. package/dist/resources/governor.d.ts +27 -1
  273. package/dist/resources/governor.d.ts.map +1 -1
  274. package/dist/resources/governor.js +88 -4
  275. package/dist/resources/governor.js.map +1 -1
  276. package/dist/resources/hook-footprint.d.ts +13 -1
  277. package/dist/resources/hook-footprint.d.ts.map +1 -1
  278. package/dist/resources/hook-footprint.js +33 -7
  279. package/dist/resources/hook-footprint.js.map +1 -1
  280. package/dist/resources/run-governed.d.ts +25 -0
  281. package/dist/resources/run-governed.d.ts.map +1 -1
  282. package/dist/resources/run-governed.js +44 -3
  283. package/dist/resources/run-governed.js.map +1 -1
  284. package/dist/sagas/reconcile.d.ts.map +1 -1
  285. package/dist/sagas/reconcile.js +11 -0
  286. package/dist/sagas/reconcile.js.map +1 -1
  287. package/dist/scaffold/ensure-dirs.d.ts.map +1 -1
  288. package/dist/scaffold/ensure-dirs.js +8 -6
  289. package/dist/scaffold/ensure-dirs.js.map +1 -1
  290. package/dist/security/override-cap-setting.d.ts +132 -0
  291. package/dist/security/override-cap-setting.d.ts.map +1 -1
  292. package/dist/security/override-cap-setting.js +88 -10
  293. package/dist/security/override-cap-setting.js.map +1 -1
  294. package/dist/security/owner-override-authority.d.ts.map +1 -1
  295. package/dist/security/owner-override-authority.js +5 -1
  296. package/dist/security/owner-override-authority.js.map +1 -1
  297. package/dist/selfimprove/draft-pr.d.ts.map +1 -1
  298. package/dist/selfimprove/draft-pr.js +4 -1
  299. package/dist/selfimprove/draft-pr.js.map +1 -1
  300. package/dist/session/engine-ops.d.ts.map +1 -1
  301. package/dist/session/engine-ops.js +4 -16
  302. package/dist/session/engine-ops.js.map +1 -1
  303. package/dist/sessions/briefing.d.ts +6 -0
  304. package/dist/sessions/briefing.d.ts.map +1 -1
  305. package/dist/sessions/briefing.js +34 -0
  306. package/dist/sessions/briefing.js.map +1 -1
  307. package/dist/sessions/session-end-snapshot-entry.js +2 -2
  308. package/dist/sessions/session-end-snapshot-entry.js.map +1 -1
  309. package/dist/sessions/session-end-snapshot.js +4 -4
  310. package/dist/sessions/session-end-snapshot.js.map +1 -1
  311. package/dist/skills/injection/token.d.ts.map +1 -1
  312. package/dist/skills/injection/token.js +3 -6
  313. package/dist/skills/injection/token.js.map +1 -1
  314. package/dist/skills/orchestrator/spawn.d.ts.map +1 -1
  315. package/dist/skills/orchestrator/spawn.js +3 -4
  316. package/dist/skills/orchestrator/spawn.js.map +1 -1
  317. package/dist/skills/propose-patch.d.ts.map +1 -1
  318. package/dist/skills/propose-patch.js +4 -1
  319. package/dist/skills/propose-patch.js.map +1 -1
  320. package/dist/spawn/branch-integration.d.ts.map +1 -1
  321. package/dist/spawn/branch-integration.js +5 -2
  322. package/dist/spawn/branch-integration.js.map +1 -1
  323. package/dist/store/attachment-repair.d.ts.map +1 -1
  324. package/dist/store/attachment-repair.js +5 -4
  325. package/dist/store/attachment-repair.js.map +1 -1
  326. package/dist/store/attachment-store.d.ts +6 -0
  327. package/dist/store/attachment-store.d.ts.map +1 -1
  328. package/dist/store/attachment-store.js +19 -2
  329. package/dist/store/attachment-store.js.map +1 -1
  330. package/dist/store/blob-ops.d.ts +1 -1
  331. package/dist/store/blob-ops.d.ts.map +1 -1
  332. package/dist/store/blob-ops.js +7 -3
  333. package/dist/store/blob-ops.js.map +1 -1
  334. package/dist/store/conduit-doc-history.d.ts +15 -1
  335. package/dist/store/conduit-doc-history.d.ts.map +1 -1
  336. package/dist/store/conduit-doc-history.js +16 -10
  337. package/dist/store/conduit-doc-history.js.map +1 -1
  338. package/dist/store/db-helpers.d.ts +29 -0
  339. package/dist/store/db-helpers.d.ts.map +1 -1
  340. package/dist/store/db-helpers.js +89 -1
  341. package/dist/store/db-helpers.js.map +1 -1
  342. package/dist/store/decision-adr-links.d.ts.map +1 -1
  343. package/dist/store/decision-adr-links.js +5 -2
  344. package/dist/store/decision-adr-links.js.map +1 -1
  345. package/dist/store/git-checkpoint.d.ts +11 -1
  346. package/dist/store/git-checkpoint.d.ts.map +1 -1
  347. package/dist/store/git-checkpoint.js +14 -3
  348. package/dist/store/git-checkpoint.js.map +1 -1
  349. package/dist/store/llmtxt-blob-adapter.d.ts.map +1 -1
  350. package/dist/store/llmtxt-blob-adapter.js +4 -1
  351. package/dist/store/llmtxt-blob-adapter.js.map +1 -1
  352. package/dist/store/schema/attachments.d.ts +7 -5
  353. package/dist/store/schema/attachments.d.ts.map +1 -1
  354. package/dist/store/schema/attachments.js +5 -2
  355. package/dist/store/schema/attachments.js.map +1 -1
  356. package/dist/store/session-store.d.ts.map +1 -1
  357. package/dist/store/session-store.js +6 -0
  358. package/dist/store/session-store.js.map +1 -1
  359. package/dist/store/sqlite-backup.d.ts +6 -1
  360. package/dist/store/sqlite-backup.d.ts.map +1 -1
  361. package/dist/store/sqlite-backup.js +9 -5
  362. package/dist/store/sqlite-backup.js.map +1 -1
  363. package/dist/store/sqlite-data-accessor.d.ts.map +1 -1
  364. package/dist/store/sqlite-data-accessor.js +63 -36
  365. package/dist/store/sqlite-data-accessor.js.map +1 -1
  366. package/dist/system/backup.d.ts +29 -0
  367. package/dist/system/backup.d.ts.map +1 -1
  368. package/dist/system/backup.js +240 -37
  369. package/dist/system/backup.js.map +1 -1
  370. package/dist/system/dependencies.d.ts +22 -0
  371. package/dist/system/dependencies.d.ts.map +1 -1
  372. package/dist/system/dependencies.js +49 -2
  373. package/dist/system/dependencies.js.map +1 -1
  374. package/dist/system/health.d.ts.map +1 -1
  375. package/dist/system/health.js +23 -1
  376. package/dist/system/health.js.map +1 -1
  377. package/dist/task-work/claims.d.ts.map +1 -1
  378. package/dist/task-work/claims.js +14 -0
  379. package/dist/task-work/claims.js.map +1 -1
  380. package/dist/task-work/index.d.ts.map +1 -1
  381. package/dist/task-work/index.js +22 -0
  382. package/dist/task-work/index.js.map +1 -1
  383. package/dist/tasks/ac-coverage-gate.d.ts +27 -0
  384. package/dist/tasks/ac-coverage-gate.d.ts.map +1 -1
  385. package/dist/tasks/ac-coverage-gate.js +63 -28
  386. package/dist/tasks/ac-coverage-gate.js.map +1 -1
  387. package/dist/tasks/affected-packages.d.ts +26 -30
  388. package/dist/tasks/affected-packages.d.ts.map +1 -1
  389. package/dist/tasks/affected-packages.js +27 -50
  390. package/dist/tasks/affected-packages.js.map +1 -1
  391. package/dist/tasks/affected-scope.d.ts +13 -3
  392. package/dist/tasks/affected-scope.d.ts.map +1 -1
  393. package/dist/tasks/affected-scope.js +29 -11
  394. package/dist/tasks/affected-scope.js.map +1 -1
  395. package/dist/tasks/change-set.d.ts.map +1 -1
  396. package/dist/tasks/change-set.js +39 -20
  397. package/dist/tasks/change-set.js.map +1 -1
  398. package/dist/tasks/command-gate-cmd.d.ts +33 -0
  399. package/dist/tasks/command-gate-cmd.d.ts.map +1 -0
  400. package/dist/tasks/command-gate-cmd.js +45 -0
  401. package/dist/tasks/command-gate-cmd.js.map +1 -0
  402. package/dist/tasks/complete-ci.d.ts +9 -0
  403. package/dist/tasks/complete-ci.d.ts.map +1 -1
  404. package/dist/tasks/complete-ci.js +21 -4
  405. package/dist/tasks/complete-ci.js.map +1 -1
  406. package/dist/tasks/complete.d.ts.map +1 -1
  407. package/dist/tasks/complete.js +14 -0
  408. package/dist/tasks/complete.js.map +1 -1
  409. package/dist/tasks/component-pr.d.ts +37 -0
  410. package/dist/tasks/component-pr.d.ts.map +1 -1
  411. package/dist/tasks/component-pr.js +106 -14
  412. package/dist/tasks/component-pr.js.map +1 -1
  413. package/dist/tasks/done-plan.d.ts +14 -1
  414. package/dist/tasks/done-plan.d.ts.map +1 -1
  415. package/dist/tasks/done-plan.js +125 -15
  416. package/dist/tasks/done-plan.js.map +1 -1
  417. package/dist/tasks/done-record.d.ts.map +1 -1
  418. package/dist/tasks/done-record.js +4 -0
  419. package/dist/tasks/done-record.js.map +1 -1
  420. package/dist/tasks/engine-converters.d.ts.map +1 -1
  421. package/dist/tasks/engine-converters.js +2 -0
  422. package/dist/tasks/engine-converters.js.map +1 -1
  423. package/dist/tasks/evidence-tree.d.ts +28 -1
  424. package/dist/tasks/evidence-tree.d.ts.map +1 -1
  425. package/dist/tasks/evidence-tree.js +66 -2
  426. package/dist/tasks/evidence-tree.js.map +1 -1
  427. package/dist/tasks/evidence.d.ts +39 -27
  428. package/dist/tasks/evidence.d.ts.map +1 -1
  429. package/dist/tasks/evidence.js +382 -188
  430. package/dist/tasks/evidence.js.map +1 -1
  431. package/dist/tasks/gate-notes.d.ts +17 -0
  432. package/dist/tasks/gate-notes.d.ts.map +1 -1
  433. package/dist/tasks/gate-notes.js.map +1 -1
  434. package/dist/tasks/gate-preview.d.ts +7 -1
  435. package/dist/tasks/gate-preview.d.ts.map +1 -1
  436. package/dist/tasks/gate-preview.js +30 -4
  437. package/dist/tasks/gate-preview.js.map +1 -1
  438. package/dist/tasks/gate-runner.d.ts +42 -0
  439. package/dist/tasks/gate-runner.d.ts.map +1 -1
  440. package/dist/tasks/gate-runner.js +234 -94
  441. package/dist/tasks/gate-runner.js.map +1 -1
  442. package/dist/tasks/manifest-scope.d.ts +115 -0
  443. package/dist/tasks/manifest-scope.d.ts.map +1 -0
  444. package/dist/tasks/manifest-scope.js +352 -0
  445. package/dist/tasks/manifest-scope.js.map +1 -0
  446. package/dist/tasks/merge-close.d.ts +376 -0
  447. package/dist/tasks/merge-close.d.ts.map +1 -0
  448. package/dist/tasks/merge-close.js +1497 -0
  449. package/dist/tasks/merge-close.js.map +1 -0
  450. package/dist/tasks/qa-run-binding.d.ts +5 -0
  451. package/dist/tasks/qa-run-binding.d.ts.map +1 -1
  452. package/dist/tasks/qa-run-binding.js +6 -1
  453. package/dist/tasks/qa-run-binding.js.map +1 -1
  454. package/dist/tasks/req.d.ts.map +1 -1
  455. package/dist/tasks/req.js +29 -1
  456. package/dist/tasks/req.js.map +1 -1
  457. package/dist/tasks/review-verdict.d.ts +159 -0
  458. package/dist/tasks/review-verdict.d.ts.map +1 -0
  459. package/dist/tasks/review-verdict.js +442 -0
  460. package/dist/tasks/review-verdict.js.map +1 -0
  461. package/dist/tasks/task-reparent.d.ts.map +1 -1
  462. package/dist/tasks/task-reparent.js +22 -3
  463. package/dist/tasks/task-reparent.js.map +1 -1
  464. package/dist/tasks/test-report-counts.d.ts +274 -0
  465. package/dist/tasks/test-report-counts.d.ts.map +1 -0
  466. package/dist/tasks/test-report-counts.js +746 -0
  467. package/dist/tasks/test-report-counts.js.map +1 -0
  468. package/dist/tasks/test-run-binding.d.ts +20 -45
  469. package/dist/tasks/test-run-binding.d.ts.map +1 -1
  470. package/dist/tasks/test-run-binding.js +34 -119
  471. package/dist/tasks/test-run-binding.js.map +1 -1
  472. package/dist/tasks/update.d.ts.map +1 -1
  473. package/dist/tasks/update.js +42 -0
  474. package/dist/tasks/update.js.map +1 -1
  475. package/dist/tasks/verification-policy.d.ts +12 -0
  476. package/dist/tasks/verification-policy.d.ts.map +1 -1
  477. package/dist/tasks/verification-policy.js +13 -0
  478. package/dist/tasks/verification-policy.js.map +1 -1
  479. package/dist/tasks/workspace-resolution.d.ts +225 -0
  480. package/dist/tasks/workspace-resolution.d.ts.map +1 -0
  481. package/dist/tasks/workspace-resolution.js +497 -0
  482. package/dist/tasks/workspace-resolution.js.map +1 -0
  483. package/dist/validation/doctor/checks.d.ts +20 -0
  484. package/dist/validation/doctor/checks.d.ts.map +1 -1
  485. package/dist/validation/doctor/checks.js +112 -0
  486. package/dist/validation/doctor/checks.js.map +1 -1
  487. package/dist/validation/engine-ops.d.ts.map +1 -1
  488. package/dist/validation/engine-ops.js +37 -3
  489. package/dist/validation/engine-ops.js.map +1 -1
  490. package/dist/validation/validate-ops.d.ts +4 -0
  491. package/dist/validation/validate-ops.d.ts.map +1 -1
  492. package/dist/validation/validate-ops.js +79 -2
  493. package/dist/validation/validate-ops.js.map +1 -1
  494. package/dist/worktree/list.d.ts +4 -21
  495. package/dist/worktree/list.d.ts.map +1 -1
  496. package/dist/worktree/list.js +14 -44
  497. package/dist/worktree/list.js.map +1 -1
  498. package/dist/worktree/merged-cleanup.d.ts +63 -0
  499. package/dist/worktree/merged-cleanup.d.ts.map +1 -0
  500. package/dist/worktree/merged-cleanup.js +571 -0
  501. package/dist/worktree/merged-cleanup.js.map +1 -0
  502. package/dist/worktree/prune.d.ts +64 -2
  503. package/dist/worktree/prune.d.ts.map +1 -1
  504. package/dist/worktree/prune.js +151 -6
  505. package/dist/worktree/prune.js.map +1 -1
  506. package/dist/worktree/sentinel-index.d.ts +14 -0
  507. package/dist/worktree/sentinel-index.d.ts.map +1 -1
  508. package/dist/worktree/sentinel-index.js +17 -0
  509. package/dist/worktree/sentinel-index.js.map +1 -1
  510. package/dist/worktree/worktree-adopt.d.ts +1 -13
  511. package/dist/worktree/worktree-adopt.d.ts.map +1 -1
  512. package/dist/worktree/worktree-adopt.js +2 -17
  513. package/dist/worktree/worktree-adopt.js.map +1 -1
  514. package/package.json +12 -12
  515. package/schemas/project-context.schema.json +33 -0
  516. package/templates/CLEO-INJECTION.md +6 -16
  517. package/templates/CLEO-REFERENCE.md +27 -4
  518. package/templates/git-hooks/legacy-hashes.json +2 -1
  519. package/templates/git-hooks/pre-commit +43 -11
  520. package/templates/workflows/release-prepare.yml.tmpl +9 -88
@@ -0,0 +1,1497 @@
1
+ /**
2
+ * Merge closes tasks (T14101, owner decision D11209).
3
+ *
4
+ * A merged PR whose required checks were green on its exact head closes the
5
+ * tasks its body names, with no agent step. CI never writes to a task store:
6
+ * the GitHub job only posts a receipt ({@link judgeMergedPr}); the store owner's
7
+ * machine applies it here ({@link sweepMergedClosures}), re-deriving everything
8
+ * from `gh` and recording through the same path as `cleo done --pr`
9
+ * (`recordTaskDone`, then `taskComplete`), so the same validators and audit
10
+ * apply. The sweep runs from the daemon loop, lazily (throttled) from the
11
+ * commands agents already run, and before `cleo release` decides what ships.
12
+ *
13
+ * PR body lines, one per task:
14
+ *
15
+ * - `Closes: T#### AC1,AC2` — binds those criteria and completes the task once
16
+ * the criteria every merged PR bound cover all of its criteria.
17
+ * - `Refs: T#### AC1` — binds those criteria; the task stays open.
18
+ *
19
+ * Every applied line writes one `merge_close` audit row keyed by
20
+ * (PR, merge commit, task), so a rerun records nothing twice.
21
+ *
22
+ * @task T14101
23
+ */
24
+ import { execFileSync } from 'node:child_process';
25
+ import { closeSync, existsSync, linkSync, openSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync, } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { join } from 'node:path';
28
+ import { discoveryEnv } from '../git/work-tree.js';
29
+ /** The audit action of an applied `Closes:` / `Refs:` line. */
30
+ export const MERGE_CLOSE_AUDIT_ACTION = 'merge_close';
31
+ /** The audit action of the one rerun of a merge commit's cancelled CI run (never repeated). */
32
+ export const MERGE_CLOSE_RERUN_ACTION = 'merge_close_rerun';
33
+ /** The audit action of a refusal already reported on the PR (posted once). */
34
+ export const MERGE_CLOSE_REFUSED_ACTION = 'merge_close_refused';
35
+ /** The lazy sweep runs at most this often per store. */
36
+ export const MERGE_SWEEP_INTERVAL_MS = 5 * 60_000;
37
+ /** A detached sweep child exits after this long, so a hung `gh` leaves no process behind. */
38
+ export const MERGE_SWEEP_CHILD_TIMEOUT_MS = 2 * 60_000;
39
+ /** The lock file, under `.cleo/`: held for the whole of one sweep. */
40
+ const SWEEP_LOCK_FILE = 'merge-sweep.lock';
41
+ /** The claim file, under `.cleo/`: its mtime is the last claimed sweep. */
42
+ const SWEEP_CLAIM_FILE = 'merge-sweep.claim';
43
+ /**
44
+ * Each sweep re-reads this far behind its high-water mark: GitHub's search
45
+ * index can list a PR some minutes after it merged, and re-reading an applied
46
+ * PR is a no-op (its `merge_close` audit row).
47
+ */
48
+ export const MERGE_SWEEP_OVERLAP_MS = 15 * 60_000;
49
+ /**
50
+ * A sweep stops taking new PRs after this long and carries the rest on its
51
+ * waiting list, so it finishes inside a lazy child's {@link MERGE_SWEEP_CHILD_TIMEOUT_MS}.
52
+ */
53
+ export const MERGE_SWEEP_MAX_RUN_MS = 90_000;
54
+ /**
55
+ * A sweep lock whose heartbeat is older than this is stale and may be taken:
56
+ * several times the longest a sweep runs ({@link MERGE_SWEEP_CHILD_TIMEOUT_MS}).
57
+ */
58
+ export const MERGE_SWEEP_LOCK_STALE_MS = 5 * MERGE_SWEEP_CHILD_TIMEOUT_MS;
59
+ /** A waiting PR is first re-judged after this long; each further wait doubles it. */
60
+ export const MERGE_WAITING_FIRST_BACKOFF_MS = 5 * 60_000;
61
+ /** The longest a waiting PR goes between re-judgements. */
62
+ export const MERGE_WAITING_MAX_BACKOFF_MS = 6 * 60 * 60_000;
63
+ /** A PR still waiting this long after it first waited is refused, with one final comment. */
64
+ export const MERGE_WAITING_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
65
+ /** A first sweep (no high-water mark yet) looks back this far. */
66
+ export const MERGE_SWEEP_LOOKBACK_MS = 7 * 24 * 60 * 60_000;
67
+ /** The merged PRs one `gh pr list` page returns. */
68
+ export const MERGE_SWEEP_PR_LIMIT = 50;
69
+ /**
70
+ * The most `gh pr list` pages one sweep reads. When more PRs merged than that,
71
+ * the unread (older) window is kept as a backlog the next sweep reads first,
72
+ * and the result reports `truncated`.
73
+ */
74
+ export const MERGE_SWEEP_MAX_PAGES = 10;
75
+ /** Open issues with this label become tasks (T14104). */
76
+ export const MACOS_REGRESSION_LABEL = 'macos-regression';
77
+ /** The external-link provider that records which task an issue became. */
78
+ const ISSUE_LINK_PROVIDER = 'github-issue';
79
+ /** The throttle and high-water file, under `.cleo/`. */
80
+ const SWEEP_STATE_FILE = 'merge-sweep.json';
81
+ /** The commit status the pre-merge verdict check sets on an open PR's head. */
82
+ export const MERGE_CLOSE_STATUS_CONTEXT = 'cleo/merge-close';
83
+ /**
84
+ * Why the criteria a line names are not independently verified on the PR's
85
+ * head (T14122), one reason per criterion, from the shared judge
86
+ * (`judgeReviewVerdicts`, the same text `cleo review check` prints): no
87
+ * verdict, an unmet one, one on an older head (a push after review), or one
88
+ * by the task's own author. Empty when every criterion is met.
89
+ *
90
+ * @param verdicts - The task's verdicts on the PR.
91
+ * @param acs - The criteria the line names.
92
+ * @param headSha - The PR's final head.
93
+ * @returns The reasons.
94
+ */
95
+ export async function unverifiedCriteria(verdicts, acs, headSha) {
96
+ if (headSha === null)
97
+ return acs.map((ac) => `${ac}: the PR's head is unknown`);
98
+ const { judgeReviewVerdicts } = await import('./review-verdict.js');
99
+ return judgeReviewVerdicts(verdicts, acs, headSha).gaps.map((gap) => gap.reason);
100
+ }
101
+ const DIRECTIVE_LINE = /^[ \t>*-]*(Closes|Refs)[ \t]*:[ \t]*(.*)$/gim;
102
+ const DIRECTIVE_PAYLOAD = /^(T\d+)\s+(AC\d+(?:\s*,\s*AC\d+)*)\s*$/i;
103
+ /**
104
+ * Parse a PR body's `Closes:` / `Refs:` lines (T14101). A line whose payload
105
+ * does not start with a task id (`Closes: #123`, GitHub's own syntax) is not
106
+ * CLEO's and is ignored; a CLEO line that is malformed, or names a task twice,
107
+ * is an error with the fix.
108
+ *
109
+ * @param body - The PR body.
110
+ * @returns The lines, and the errors.
111
+ * @example
112
+ * ```ts
113
+ * parseMergeDirectives('Closes: T14101 AC1,AC2');
114
+ * // { directives: [{ kind: 'closes', taskId: 'T14101', acs: ['AC1', 'AC2'] }], errors: [] }
115
+ * ```
116
+ */
117
+ export function parseMergeDirectives(body) {
118
+ const directives = [];
119
+ const errors = [];
120
+ for (const match of body.matchAll(DIRECTIVE_LINE)) {
121
+ const keyword = (match[1] ?? '').toLowerCase() === 'closes' ? 'Closes' : 'Refs';
122
+ const payload = (match[2] ?? '').replace(/`/g, '').trim();
123
+ if (!/^T\d/i.test(payload))
124
+ continue;
125
+ const parsed = DIRECTIVE_PAYLOAD.exec(payload);
126
+ if (!parsed) {
127
+ errors.push(`"${keyword}: ${payload}" is not "${keyword}: T#### AC1,AC2": name the task, then every criterion this PR delivers (no wildcard).`);
128
+ continue;
129
+ }
130
+ const taskId = (parsed[1] ?? '').toUpperCase();
131
+ if (directives.some((d) => d.taskId === taskId)) {
132
+ errors.push(`${taskId} is named on more than one Closes:/Refs: line; use one line per task.`);
133
+ continue;
134
+ }
135
+ const acs = [...new Set((parsed[2] ?? '').split(',').map((a) => a.trim().toUpperCase()))];
136
+ directives.push({ kind: keyword === 'Closes' ? 'closes' : 'refs', taskId, acs });
137
+ }
138
+ return { directives, errors };
139
+ }
140
+ /**
141
+ * Judge a merged PR without a task store (T14101): its lines, and whether its
142
+ * required checks were green on its exact head (`statusCheckRollup` is the
143
+ * head's checks). This is the CI job's receipt; the store side judges again.
144
+ *
145
+ * @param pr - The merged PR.
146
+ * @param required - Required check names.
147
+ * @returns The receipt.
148
+ */
149
+ export async function judgeMergedPr(pr, required) {
150
+ const { directives, errors } = parseMergeDirectives(pr.body);
151
+ const { evaluateRollup } = await import('../release/pr-evidence.js');
152
+ const ci = evaluateRollup(pr.statusCheckRollup ?? [], required, { prNumber: pr.number });
153
+ const closesNothing = errors.length > 0
154
+ ? `its Closes:/Refs: lines are malformed: ${errors.join(' ')}`
155
+ : directives.length === 0
156
+ ? 'its body names no task (add "Closes: T#### AC1,AC2" or "Refs: T#### AC1").'
157
+ : !ci.ok
158
+ ? `its required checks were not green on its head ${pr.headRefOid?.slice(0, 12) ?? '(unknown)'}: ${ci.reason}`
159
+ : undefined;
160
+ return {
161
+ prNumber: pr.number,
162
+ mergeSha: pr.mergeCommitSha,
163
+ headSha: pr.headRefOid,
164
+ directives,
165
+ errors,
166
+ ciGreen: ci.ok,
167
+ ...(ci.ok ? {} : { ciReason: ci.reason }),
168
+ ...(closesNothing !== undefined ? { closesNothing } : {}),
169
+ };
170
+ }
171
+ /**
172
+ * The PRs one sweep reads (T14101): first the recorded backlog windows,
173
+ * oldest first, then the window from `since` to now, all within one page
174
+ * budget. A window the budget cuts short stays in the backlog with its
175
+ * `until` lowered to what was read, so every window drains; a window the
176
+ * budget never reached stays whole. `newestRead` is the newest merge read in
177
+ * the new window (the next high-water mark), unset when it was not read.
178
+ */
179
+ async function listSweepWindows(deps, root, state, since, explicitSince) {
180
+ const budget = { pages: MERGE_SWEEP_MAX_PAGES };
181
+ const prior = state.backlog;
182
+ const recorded = prior === undefined ? [] : Array.isArray(prior) ? prior : [prior];
183
+ const backlog = [];
184
+ const byNumber = new Map();
185
+ for (const window of [...recorded].sort((a, b) => a.since.localeCompare(b.since))) {
186
+ // An explicit --since reads only its own window; the backlog waits, kept.
187
+ if (budget.pages === 0 || explicitSince) {
188
+ backlog.push(window);
189
+ continue;
190
+ }
191
+ const read = await readMergedWindow(deps, root, window.since, window.until, budget);
192
+ if (read === null)
193
+ return null;
194
+ for (const pr of read.prs)
195
+ byNumber.set(pr.number, pr);
196
+ if (read.unreadBefore !== undefined)
197
+ backlog.push({ since: window.since, until: read.unreadBefore });
198
+ }
199
+ let newestRead;
200
+ if (budget.pages > 0) {
201
+ const read = await readMergedWindow(deps, root, since, undefined, budget);
202
+ if (read === null)
203
+ return null;
204
+ for (const pr of read.prs) {
205
+ byNumber.set(pr.number, pr);
206
+ if (newestRead === undefined || pr.mergedAt > newestRead)
207
+ newestRead = pr.mergedAt;
208
+ }
209
+ // Pages run newest first, so a cut-short window's unread part is below what was read.
210
+ if (read.unreadBefore !== undefined)
211
+ backlog.push({ since, until: read.unreadBefore });
212
+ }
213
+ return {
214
+ prs: [...byNumber.values()],
215
+ backlog,
216
+ ...(newestRead !== undefined ? { newestRead } : {}),
217
+ };
218
+ }
219
+ /**
220
+ * Read every PR merged in `[since, until]`, newest first, a page at a time
221
+ * (each page's oldest merge is the next page's `until`), until a short page
222
+ * or the page budget runs out. Null when `gh` failed.
223
+ */
224
+ async function readMergedWindow(deps, root, since, until, budget) {
225
+ const seen = new Map();
226
+ let upper = until;
227
+ while (budget.pages > 0) {
228
+ budget.pages -= 1;
229
+ const page = await deps.listMergedPrs(root, since, upper);
230
+ if (page === null)
231
+ return null;
232
+ const fresh = page.filter((pr) => !seen.has(pr.number));
233
+ for (const pr of fresh)
234
+ seen.set(pr.number, pr);
235
+ if (page.length < MERGE_SWEEP_PR_LIMIT)
236
+ return { prs: [...seen.values()] };
237
+ // A full page of merges in one instant is not progress; leave the rest.
238
+ if (fresh.length === 0)
239
+ break;
240
+ upper = page.reduce((min, pr) => (pr.mergedAt < min ? pr.mergedAt : min), page[0]?.mergedAt ?? '');
241
+ }
242
+ return { prs: [...seen.values()], unreadBefore: upper ?? new Date().toISOString() };
243
+ }
244
+ function sweepStatePath(storeRoot) {
245
+ return join(storeRoot, '.cleo', SWEEP_STATE_FILE);
246
+ }
247
+ function readSweepState(storeRoot) {
248
+ try {
249
+ const parsed = JSON.parse(readFileSync(sweepStatePath(storeRoot), 'utf-8'));
250
+ return typeof parsed === 'object' && parsed !== null ? parsed : {};
251
+ }
252
+ catch {
253
+ return {};
254
+ }
255
+ }
256
+ /** Write the state tmp-then-rename, so a reader never sees half a file. */
257
+ function writeSweepState(storeRoot, state) {
258
+ const path = sweepStatePath(storeRoot);
259
+ const tmp = `${path}.${process.pid}.tmp`;
260
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`);
261
+ renameSync(tmp, path);
262
+ }
263
+ /**
264
+ * Take the sweep lock for a store (T14101): an `O_EXCL` lock file held for
265
+ * the whole sweep, its mtime refreshed while the sweep runs. A lock whose
266
+ * heartbeat is older than {@link MERGE_SWEEP_LOCK_STALE_MS} is moved aside by
267
+ * `rename` (only one caller's rename succeeds) and taken. Null when another
268
+ * sweep holds it.
269
+ *
270
+ * @param storeRoot - CLEO store root.
271
+ * @returns The release function, or null.
272
+ */
273
+ export function acquireMergeSweepLock(storeRoot, now = Date.now) {
274
+ const lock = join(storeRoot, '.cleo', SWEEP_LOCK_FILE);
275
+ let judged = null;
276
+ try {
277
+ if (now() - statSync(lock).mtimeMs < MERGE_SWEEP_LOCK_STALE_MS)
278
+ return null;
279
+ judged = readFileSync(lock, 'utf-8');
280
+ }
281
+ catch (err) {
282
+ if (err.code !== 'ENOENT')
283
+ return null;
284
+ }
285
+ if (judged !== null && !stealStaleLock(lock, judged))
286
+ return null;
287
+ const token = `${process.pid}:${Date.now()}:${Math.random()}`;
288
+ try {
289
+ const fd = openSync(lock, 'wx');
290
+ writeFileSync(fd, token);
291
+ closeSync(fd);
292
+ }
293
+ catch {
294
+ return null;
295
+ }
296
+ // T14139: a third party may have swapped the lock between our steal and our
297
+ // create; sweep only if the lock path holds our own token.
298
+ if (!lockHolds(lock, token))
299
+ return null;
300
+ const heartbeat = setInterval(() => {
301
+ try {
302
+ const now = new Date();
303
+ utimesSync(lock, now, now);
304
+ }
305
+ catch {
306
+ // A lost lock is noticed at release.
307
+ }
308
+ }, 30_000);
309
+ heartbeat.unref();
310
+ return () => {
311
+ clearInterval(heartbeat);
312
+ try {
313
+ if (lockHolds(lock, token))
314
+ unlinkSync(lock);
315
+ }
316
+ catch {
317
+ // Already gone or taken over: nothing of ours to remove.
318
+ }
319
+ };
320
+ }
321
+ /**
322
+ * Whether the lock path holds `token` (T14139).
323
+ *
324
+ * @param lock - The lock path.
325
+ * @param token - The token expected.
326
+ * @returns True when the lock file reads exactly `token`.
327
+ * @internal Exported for tests.
328
+ */
329
+ export function lockHolds(lock, token) {
330
+ try {
331
+ return readFileSync(lock, 'utf-8') === token;
332
+ }
333
+ catch {
334
+ return false;
335
+ }
336
+ }
337
+ /**
338
+ * Move aside the stale lock whose token was `judged`, and only that one: rename
339
+ * it to a unique name and read the token there. Another stealer may have
340
+ * replaced the stale lock with a fresh one between the read and the rename; if
341
+ * the renamed file is not the judged one, put it back (`link` refuses to
342
+ * clobber a lock created meanwhile) and give up.
343
+ *
344
+ * @param lock - The lock path.
345
+ * @param judged - The token read from the lock judged stale.
346
+ * @returns True when the judged lock was removed.
347
+ * @internal Exported for tests.
348
+ */
349
+ export function stealStaleLock(lock, judged) {
350
+ const aside = `${lock}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`;
351
+ try {
352
+ renameSync(lock, aside);
353
+ }
354
+ catch {
355
+ return false;
356
+ }
357
+ let got = null;
358
+ try {
359
+ got = readFileSync(aside, 'utf-8');
360
+ }
361
+ catch {
362
+ // Unreadable: treat as not the judged lock.
363
+ }
364
+ if (got !== judged) {
365
+ try {
366
+ linkSync(aside, lock);
367
+ }
368
+ catch {
369
+ // A newer lock already stands; it is the live one.
370
+ }
371
+ try {
372
+ unlinkSync(aside);
373
+ }
374
+ catch {
375
+ // Nothing left to clean.
376
+ }
377
+ return false;
378
+ }
379
+ try {
380
+ unlinkSync(aside);
381
+ }
382
+ catch {
383
+ // Already gone.
384
+ }
385
+ return true;
386
+ }
387
+ /**
388
+ * Claim the next sweep for a store, atomically (T14101): true for exactly one
389
+ * caller per {@link MERGE_SWEEP_INTERVAL_MS}. A claim older than the interval
390
+ * is moved aside by `rename` (only one caller's rename succeeds), and the new
391
+ * claim is created with `O_EXCL`, so two concurrent commands never start two
392
+ * sweeps.
393
+ *
394
+ * @param storeRoot - CLEO store root.
395
+ * @param now - Current time (ms).
396
+ * @returns True when this caller owns the sweep.
397
+ */
398
+ export function claimMergeSweep(storeRoot, now = Date.now()) {
399
+ const claim = join(storeRoot, '.cleo', SWEEP_CLAIM_FILE);
400
+ try {
401
+ if (now - statSync(claim).mtimeMs < MERGE_SWEEP_INTERVAL_MS)
402
+ return false;
403
+ const stale = `${claim}.${process.pid}.${now}`;
404
+ renameSync(claim, stale);
405
+ unlinkSync(stale);
406
+ }
407
+ catch (err) {
408
+ // No claim yet is the only error that leaves the sweep ours to take.
409
+ if (err.code !== 'ENOENT')
410
+ return false;
411
+ }
412
+ try {
413
+ closeSync(openSync(claim, 'wx'));
414
+ return true;
415
+ }
416
+ catch {
417
+ return false;
418
+ }
419
+ }
420
+ /**
421
+ * Whether a read command may start a background sweep (T14101): never in CI,
422
+ * under vitest, when turned off (`CLEO_MERGE_SWEEP=off`), inside a process git
423
+ * started (T14189), or without `gh` credentials (a token in the environment,
424
+ * or `gh`'s hosts file).
425
+ *
426
+ * Git exports `GIT_EXEC_PATH` to every program it runs (hooks, aliases,
427
+ * `rebase --exec`), `GIT_INDEX_FILE` to commit hooks and `GIT_DIR` to hooks in
428
+ * a linked worktree (observed on git 2.5x: pre-commit, commit-msg and
429
+ * post-commit). A hook's `cleo show` has no reason to start a sweep that
430
+ * deletes branches and worktrees after the commit returns.
431
+ *
432
+ * @param env - The environment.
433
+ * @param home - The home directory (for `gh`'s default config).
434
+ * @returns True when a detached sweep may be spawned.
435
+ */
436
+ export function mergeSweepSpawnAllowed(env, home = homedir()) {
437
+ if (env['CI'] === 'true' || env['VITEST'] !== undefined)
438
+ return false;
439
+ if (env['CLEO_MERGE_SWEEP'] === 'off')
440
+ return false;
441
+ if (env['GIT_EXEC_PATH'] || env['GIT_INDEX_FILE'] || env['GIT_DIR'])
442
+ return false;
443
+ if (env['GH_TOKEN'] || env['GITHUB_TOKEN'])
444
+ return true;
445
+ return existsSync(join(env['GH_CONFIG_DIR'] ?? join(home, '.config', 'gh'), 'hosts.yml'));
446
+ }
447
+ /**
448
+ * The environment of the detached merge sweep (T14189). A read command run
449
+ * from a git hook (commit-msg runs `cleo show`) carries the hook's `GIT_DIR`
450
+ * and `GIT_INDEX_FILE`; the sweep outlives the hook, so it must act on each
451
+ * project's own repository, never the hook's: every ambient repo variable is
452
+ * stripped, and the sweep marks itself a child that never spawns another.
453
+ *
454
+ * @param env - The parent's environment.
455
+ * @returns The child's environment.
456
+ * @example
457
+ * ```ts
458
+ * spawn(cli, ['done', '--from-merge', 'all'], { env: mergeSweepChildEnv(process.env) });
459
+ * ```
460
+ * @task T14189
461
+ */
462
+ export function mergeSweepChildEnv(env) {
463
+ return { ...discoveryEnv(env), CLEO_MERGE_SWEEP: 'off', CLEO_MERGE_SWEEP_CHILD: '1' };
464
+ }
465
+ /**
466
+ * Why the lazy merge sweep must not start for this invocation, or null
467
+ * (T14168). A CLI built inside a CLEO worktree carries unreleased code, so it
468
+ * never sweeps the real store; a `release` command run from a CLEO worktree
469
+ * never sweeps the project checkout it was not started in (T14160).
470
+ *
471
+ * @param command - The CLI command being run.
472
+ * @param cwd - Where the command was started (the CLI passes its own cwd).
473
+ * @returns The reason the sweep is skipped, or null when it may start.
474
+ *
475
+ * @example
476
+ * ```ts
477
+ * if (await lazySweepBlockedReason('release', invocationCwd)) return; // no background sweep
478
+ * ```
479
+ *
480
+ * @task T14168
481
+ */
482
+ export async function lazySweepBlockedReason(command, cwd) {
483
+ const { devBuildWorktree } = await import('../store/worktree-build-guard.js');
484
+ const buildWorktree = devBuildWorktree();
485
+ if (buildWorktree !== null)
486
+ return `this CLI was built in the worktree ${buildWorktree}; a dev build never sweeps the real store`;
487
+ if (command === 'release') {
488
+ const { resolveWorktreeRouting } = await import('../paths.js');
489
+ if (resolveWorktreeRouting(cwd).isWorktree)
490
+ return 'release ran from a CLEO worktree; the sweep runs only from the project checkout';
491
+ }
492
+ return null;
493
+ }
494
+ /** The criteria every earlier applied line bound for a task, and whether this PR was applied. */
495
+ async function priorBindings(storeRoot, taskId, prNumber, mergeSha) {
496
+ const { getTaskAccessor } = await import('../store/data-accessor.js');
497
+ const accessor = await getTaskAccessor(storeRoot);
498
+ const rows = await accessor.queryAuditLog({
499
+ taskIds: [taskId],
500
+ actions: [MERGE_CLOSE_AUDIT_ACTION, MERGE_CLOSE_REFUSED_ACTION],
501
+ });
502
+ const acs = new Set();
503
+ const reported = new Set();
504
+ let applied = false;
505
+ for (const row of rows) {
506
+ const details = JSON.parse(row.detailsJson ?? '{}');
507
+ const samePr = details.prNumber === prNumber && (details.mergeSha ?? null) === mergeSha;
508
+ if (row.action === MERGE_CLOSE_REFUSED_ACTION) {
509
+ if (samePr)
510
+ reported.add(details.outcome ?? 'refused');
511
+ continue;
512
+ }
513
+ for (const ac of details.acs ?? [])
514
+ acs.add(ac);
515
+ if (samePr)
516
+ applied = true;
517
+ }
518
+ return { acs, applied, reported };
519
+ }
520
+ async function appendMergeAudit(storeRoot, action, taskId, details) {
521
+ const { getTaskAccessor } = await import('../store/data-accessor.js');
522
+ const { randomBytes } = await import('node:crypto');
523
+ await (await getTaskAccessor(storeRoot)).appendLog({
524
+ id: `log-${Math.floor(Date.now() / 1000)}-${randomBytes(3).toString('hex')}`,
525
+ timestamp: new Date().toISOString(),
526
+ action,
527
+ taskId,
528
+ actor: 'merge-close',
529
+ details,
530
+ });
531
+ }
532
+ /**
533
+ * Why a refused record is only waiting on the merge commit's own CI, or null
534
+ * when the refusal is real (T14101): the newest run of the required workflow
535
+ * on the merge commit is missing, unfinished, cancelled or skipped. A
536
+ * cancelled or skipped run is rerun once per merge commit, ever (an audit row
537
+ * remembers it), and only where {@link MergeCloseDeps.canRerun} allows (off by
538
+ * default until T14136); a failed run is never rerun and refuses.
539
+ */
540
+ async function mergeCiWait(storeRoot, root, pr, taskId, required, deps, dryRun) {
541
+ const sha = pr.mergeCommitSha;
542
+ if (sha === null)
543
+ return null;
544
+ const workflow = required[0] ?? 'CI';
545
+ const run = await deps.mergeCiRun(root, sha, workflow);
546
+ const short = sha.slice(0, 12);
547
+ if (run === null)
548
+ return { reason: `no ${workflow} run on merge commit ${short} yet`, reran: false };
549
+ if (run.status !== 'completed')
550
+ return { reason: `${workflow} on merge commit ${short} is ${run.status}`, reran: false };
551
+ if (run.conclusion === 'failure' || run.conclusion === 'timed_out')
552
+ return { reason: `${workflow} failed on merge commit ${short}`, reran: false, final: true };
553
+ if (run.conclusion !== 'cancelled' && run.conclusion !== 'skipped')
554
+ return null;
555
+ const { getTaskAccessor } = await import('../store/data-accessor.js');
556
+ const accessor = await getTaskAccessor(storeRoot);
557
+ const rows = await accessor.queryAuditLog({ actions: [MERGE_CLOSE_RERUN_ACTION] });
558
+ const already = rows.some((row) => JSON.parse(row.detailsJson ?? '{}').mergeSha === sha);
559
+ const was = `${workflow} on merge commit ${short} was ${run.conclusion} and no later run stands in`;
560
+ if (already)
561
+ return { reason: `${was}; its one rerun is done, waiting for it`, reran: false };
562
+ if (dryRun || !deps.canRerun())
563
+ return {
564
+ reason: `${was}; waiting (automatic reruns are off until T14136; rerun it by hand)`,
565
+ reran: false,
566
+ };
567
+ if (!(await deps.rerunRun(root, run.runId)))
568
+ return {
569
+ reason: `${was}; the rerun of run ${run.runId} failed, retrying next sweep`,
570
+ reran: false,
571
+ };
572
+ await appendMergeAudit(storeRoot, MERGE_CLOSE_RERUN_ACTION, taskId, {
573
+ prNumber: pr.number,
574
+ mergeSha: sha,
575
+ runId: run.runId,
576
+ });
577
+ return { reason: `${was}; reran run ${run.runId}`, reran: true };
578
+ }
579
+ /** Whether a merged PR's own head still had required checks running (a pending, not a failed, CI). */
580
+ function headCiUnfinished(pr) {
581
+ return (pr.statusCheckRollup ?? []).some((check) => check.status !== undefined && check.status !== 'COMPLETED');
582
+ }
583
+ /** Apply one line of one merged PR to its task. */
584
+ async function applyDirective(storeRoot, root, required, pr, receipt, directive, deps, dryRun) {
585
+ const { taskId, acs } = directive;
586
+ const base = { taskId, acs };
587
+ const { getTaskAccessor } = await import('../store/data-accessor.js');
588
+ const accessor = await getTaskAccessor(storeRoot);
589
+ const task = await accessor.loadSingleTask(taskId);
590
+ if (!task)
591
+ return { ...base, action: 'refused', reason: `${taskId} is not a task in this store.` };
592
+ const prior = await priorBindings(storeRoot, taskId, pr.number, pr.mergeCommitSha);
593
+ if (prior.applied)
594
+ return { ...base, action: 'already', reason: `PR #${pr.number} was already applied.` };
595
+ if (task.status === 'done' || task.status === 'cancelled')
596
+ return { ...base, action: 'already', reason: `${taskId} is already ${task.status}.` };
597
+ const own = (await accessor.getAcRows(taskId))
598
+ .filter((row) => row.kind !== 'child_task')
599
+ .map((row) => `AC${row.ordinal}`);
600
+ const unknown = acs.filter((ac) => !own.includes(ac));
601
+ if (unknown.length > 0)
602
+ return {
603
+ ...base,
604
+ action: 'refused',
605
+ reason: `${taskId} has no ${unknown.join(', ')} (its criteria are ${own.join(', ') || 'none'}).`,
606
+ };
607
+ // A dry run before merge checks the lines against the store; CI and the
608
+ // recorded evidence are judged once the PR has merged.
609
+ const open = pr.mergedAt === '';
610
+ // Only failed CI, an unknown task and an unknown criterion are final; every
611
+ // other gap waits and is re-judged each sweep (a later verdict, a later
612
+ // Refs: merge, a transient gh or store failure).
613
+ if (!open && !receipt.ciGreen) {
614
+ const reason = receipt.closesNothing ?? 'CI was not green.';
615
+ return headCiUnfinished(pr)
616
+ ? { ...base, action: 'waiting', reason, ciWait: true }
617
+ : { ...base, action: 'refused', reason };
618
+ }
619
+ // T14122 (D11211): satisfies: comes only from an independent reviewer's
620
+ // `met` verdict on the PR's final head, never from the author's line.
621
+ const verdicts = deps.reviewVerdicts
622
+ ? await deps.reviewVerdicts(storeRoot, taskId, pr.number)
623
+ : [];
624
+ const unverified = await unverifiedCriteria(verdicts, acs, pr.headRefOid);
625
+ if (unverified.length > 0)
626
+ return {
627
+ ...base,
628
+ action: 'waiting',
629
+ reason: `${NOT_VERIFIED} ${unverified.join('; ')} (cleo review records the verdicts).`,
630
+ };
631
+ const audit = { prNumber: pr.number, mergeSha: pr.mergeCommitSha, kind: directive.kind, acs };
632
+ if (directive.kind === 'refs') {
633
+ if (!dryRun)
634
+ await appendMergeAudit(storeRoot, MERGE_CLOSE_AUDIT_ACTION, taskId, audit);
635
+ return { ...base, action: 'bound' };
636
+ }
637
+ const union = new Set([...prior.acs, ...acs]);
638
+ const missing = own.filter((ac) => !union.has(ac));
639
+ if (missing.length > 0)
640
+ return {
641
+ ...base,
642
+ action: 'waiting',
643
+ reason: `no merged PR binds ${missing.join(', ')} of ${taskId}: list them on this PR's Closes: line, ` +
644
+ 'or bind them first with a Refs: line on the PR that delivers them.',
645
+ };
646
+ if (open)
647
+ return { ...base, action: 'closed', reason: 'would close once it merges with green CI' };
648
+ const recorded = await deps.record(storeRoot, taskId, pr.number, [...union], dryRun);
649
+ if (!recorded.ok) {
650
+ // A merge whose own CI run was cancelled, skipped or unfinished, with no
651
+ // later run standing in yet (ci-evidence's T12742 rule, unchanged), waits.
652
+ const wait = await mergeCiWait(storeRoot, root, pr, taskId, required, deps, dryRun);
653
+ if (wait?.final)
654
+ return { ...base, action: 'refused', reason: `${wait.reason}: ${recorded.reason}` };
655
+ if (wait !== null)
656
+ return { ...base, action: 'waiting', reason: wait.reason, reran: wait.reran, ciWait: true };
657
+ return { ...base, action: 'waiting', reason: recorded.reason };
658
+ }
659
+ if (dryRun)
660
+ return { ...base, action: 'closed' };
661
+ const completed = await deps.complete(storeRoot, taskId, pr.number);
662
+ if (!completed.ok) {
663
+ // A concurrent sweep (or agent) may have completed it first.
664
+ const now = await accessor.loadSingleTask(taskId);
665
+ if (now?.status === 'done')
666
+ return { ...base, action: 'already', reason: `${taskId} was completed meanwhile.` };
667
+ return { ...base, action: 'waiting', reason: completed.reason };
668
+ }
669
+ await appendMergeAudit(storeRoot, MERGE_CLOSE_AUDIT_ACTION, taskId, audit);
670
+ return { ...base, action: 'closed' };
671
+ }
672
+ /**
673
+ * Post each refused or waiting task on its PR once per kind (an audit row
674
+ * remembers it): a gap wait is re-judged every sweep but commented once. The
675
+ * caller leaves out waits on the merge's own CI, which resolve themselves.
676
+ */
677
+ async function reportRefusals(storeRoot, root, pr, outcomes, deps) {
678
+ for (const outcome of outcomes) {
679
+ if ((outcome.action !== 'refused' && outcome.action !== 'waiting') || !outcome.reason)
680
+ continue;
681
+ const { reported } = await priorBindings(storeRoot, outcome.taskId, pr.number, pr.mergeCommitSha).catch(() => ({ reported: new Set() }));
682
+ if (reported.has(outcome.action))
683
+ continue;
684
+ const body = outcome.action === 'refused'
685
+ ? `CLEO merge-close: ${outcome.taskId} was not closed by this PR: ${outcome.reason}`
686
+ : `CLEO merge-close: ${outcome.taskId} is waiting to close from this PR (re-checked every sweep): ${outcome.reason}`;
687
+ if (!(await deps.comment(root, pr.number, body)))
688
+ continue;
689
+ await appendMergeAudit(storeRoot, MERGE_CLOSE_REFUSED_ACTION, outcome.taskId, {
690
+ prNumber: pr.number,
691
+ mergeSha: pr.mergeCommitSha,
692
+ reason: outcome.reason,
693
+ outcome: outcome.action,
694
+ }).catch(() => undefined);
695
+ }
696
+ }
697
+ /**
698
+ * Apply each merged PR's lines, oldest merge first, into `result`; returns
699
+ * the per-PR hook results. PRs not reached before `deadline` are left out of
700
+ * `result` and pushed to `carried`, for the waiting list.
701
+ */
702
+ async function applyMergedPrs(storeRoot, root, prs, required, deps, dryRun, result, deadline, carried, clock) {
703
+ const hooks = {};
704
+ const silent = new Set();
705
+ for (const pr of [...prs].sort((a, b) => a.mergedAt.localeCompare(b.mergedAt))) {
706
+ if (clock() > deadline) {
707
+ carried.push(pr.number);
708
+ continue;
709
+ }
710
+ const receipt = await judgeMergedPr(pr, required);
711
+ const entry = {
712
+ prNumber: pr.number,
713
+ mergeSha: pr.mergeCommitSha,
714
+ outcomes: [],
715
+ };
716
+ if (pr.mergedAt === '' && !dryRun) {
717
+ entry.closesNothing = `PR #${pr.number} has not merged; check it with --dry-run.`;
718
+ result.prs.push(entry);
719
+ continue;
720
+ }
721
+ if (receipt.directives.length === 0 || receipt.errors.length > 0) {
722
+ // The CI job comments these; a PR naming no task is not this store's.
723
+ if (receipt.closesNothing)
724
+ entry.closesNothing = receipt.closesNothing;
725
+ result.prs.push(entry);
726
+ continue;
727
+ }
728
+ for (const directive of receipt.directives) {
729
+ const applied = applyDirective(storeRoot, root, required, pr, receipt, directive, deps, dryRun);
730
+ const { reran, ciWait, ...outcome } = await applied.catch((err) => ({
731
+ taskId: directive.taskId,
732
+ acs: directive.acs,
733
+ // A throw (gh rate limit, SQLITE_BUSY, …) is transient: re-judge next sweep.
734
+ action: 'waiting',
735
+ reason: err.message,
736
+ }));
737
+ entry.outcomes.push(outcome);
738
+ if (ciWait === true)
739
+ silent.add(outcome);
740
+ if (reran === true)
741
+ result.reran++;
742
+ if (outcome.action === 'waiting')
743
+ result.waiting++;
744
+ if (outcome.action === 'closed')
745
+ result.closed.push(outcome.taskId);
746
+ if (outcome.action === 'bound')
747
+ result.bound.push(outcome.taskId);
748
+ if (outcome.action === 'refused')
749
+ result.refused.push({
750
+ taskId: outcome.taskId,
751
+ prNumber: pr.number,
752
+ reason: outcome.reason ?? 'refused',
753
+ });
754
+ }
755
+ // A wait on the merge's own CI resolves itself: no comment (lead rule).
756
+ const loud = entry.outcomes.filter((o) => !silent.has(o));
757
+ if (!dryRun)
758
+ await reportRefusals(storeRoot, root, pr, loud, deps);
759
+ // T14111: flag stale docs once, on the run that applied this PR.
760
+ const applied = entry.outcomes.filter((o) => o.action === 'closed' || o.action === 'bound');
761
+ if (!dryRun && deps.docsRot && pr.mergeCommitSha && applied.length > 0) {
762
+ const taskIds = applied.map((o) => o.taskId);
763
+ const flagged = await deps
764
+ .docsRot(storeRoot, { prNumber: pr.number, mergeSha: pr.mergeCommitSha, taskIds })
765
+ .catch(() => []);
766
+ hooks.docsRot = [...(hooks.docsRot ?? []), ...flagged];
767
+ }
768
+ // T14142: clean up after the tasks this PR completed, only when opted in. A
769
+ // `Refs:` (bound) task is still in progress: its worktree and branch stay.
770
+ const closed = entry.outcomes.filter((o) => o.action === 'closed');
771
+ if (!dryRun && deps.cleanupMerged && deps.canCleanup?.() === true && closed.length > 0) {
772
+ const cleaned = await deps
773
+ .cleanupMerged(storeRoot, closed.map((o) => o.taskId))
774
+ .catch(() => null);
775
+ if (cleaned !== null)
776
+ hooks.cleanup = [...(hooks.cleanup ?? []), cleaned];
777
+ }
778
+ result.prs.push(entry);
779
+ }
780
+ return hooks;
781
+ }
782
+ /**
783
+ * The criterion aliases a task owns (child-task criteria excluded), or null
784
+ * when the task is not in this store.
785
+ */
786
+ async function taskCriteria(storeRoot, taskId) {
787
+ const { getTaskAccessor } = await import('../store/data-accessor.js');
788
+ const accessor = await getTaskAccessor(storeRoot);
789
+ if ((await accessor.loadSingleTask(taskId)) === null)
790
+ return null;
791
+ return (await accessor.getAcRows(taskId))
792
+ .filter((row) => row.kind !== 'child_task')
793
+ .map((row) => `AC${row.ordinal}`);
794
+ }
795
+ /**
796
+ * The pre-merge check (T14122): for each open PR carrying Closes:/Refs:
797
+ * lines, judge every named criterion against the store (a known task and
798
+ * criterion, an independent `met` verdict on the PR's current head, and for
799
+ * Closes: every criterion bound) and set the `cleo/merge-close` status on that
800
+ * head, so a missing, unmet or stale verdict shows before merge. A status is
801
+ * posted only when it changed since the last sweep.
802
+ */
803
+ async function checkOpenPrs(storeRoot, root, deps, dryRun, posted) {
804
+ const open = await deps.listOpenPrs(root);
805
+ if (open === null)
806
+ return [];
807
+ const checks = [];
808
+ for (const pr of open) {
809
+ const { directives, errors } = parseMergeDirectives(pr.body);
810
+ if ((directives.length === 0 && errors.length === 0) || !pr.headRefOid)
811
+ continue;
812
+ const problems = [...errors];
813
+ for (const d of directives) {
814
+ const own = await taskCriteria(storeRoot, d.taskId);
815
+ if (own === null) {
816
+ problems.push(`${d.taskId} is not a task in this store`);
817
+ continue;
818
+ }
819
+ const unknown = d.acs.filter((ac) => !own.includes(ac));
820
+ if (unknown.length > 0)
821
+ problems.push(`${d.taskId} has no ${unknown.join(', ')}`);
822
+ const verdicts = deps.reviewVerdicts
823
+ ? await deps.reviewVerdicts(storeRoot, d.taskId, pr.number)
824
+ : [];
825
+ for (const p of await unverifiedCriteria(verdicts, d.acs, pr.headRefOid))
826
+ problems.push(`${d.taskId} ${p}`);
827
+ if (d.kind === 'closes') {
828
+ const prior = await priorBindings(storeRoot, d.taskId, pr.number, null);
829
+ const union = new Set([...prior.acs, ...d.acs]);
830
+ const missing = own.filter((ac) => !union.has(ac));
831
+ if (missing.length > 0)
832
+ problems.push(`${d.taskId} ${missing.join(', ')} unbound`);
833
+ }
834
+ }
835
+ const state = problems.length === 0 ? 'success' : 'failure';
836
+ const description = (state === 'success'
837
+ ? `${directives.length} task(s): every criterion independently verified`
838
+ : problems.join('; ')).slice(0, 140);
839
+ checks.push({ prNumber: pr.number, headSha: pr.headRefOid, state, description });
840
+ const key = `${pr.headRefOid}:${state}:${description}`;
841
+ if (dryRun || posted[String(pr.number)] === key)
842
+ continue;
843
+ if (await deps.setStatus(root, pr.headRefOid, state, description))
844
+ posted[String(pr.number)] = key;
845
+ }
846
+ return checks;
847
+ }
848
+ /**
849
+ * Run `fn` under a bound session (T14101): the caller's when there is one,
850
+ * else a short `merge-sweep` session this sweep starts and ends, so writes
851
+ * from the daemon pass session enforcement.
852
+ */
853
+ async function withSweepSession(storeRoot, fn) {
854
+ const { getActiveSessionInfo } = await import('../sessions/session-enforcement.js');
855
+ if ((await getActiveSessionInfo(storeRoot).catch(() => null)) !== null)
856
+ return fn();
857
+ const sessions = await import('../sessions/index.js');
858
+ const session = await sessions
859
+ .startSession(storeRoot, { scope: 'global', name: 'merge-sweep' })
860
+ .catch(() => null);
861
+ // No session could start: run anyway; a write enforcement refuses is a refusal like any other.
862
+ if (session === null)
863
+ return fn();
864
+ const prior = process.env['CLEO_SESSION_ID'];
865
+ process.env['CLEO_SESSION_ID'] = session.id;
866
+ try {
867
+ return await fn();
868
+ }
869
+ finally {
870
+ if (prior === undefined)
871
+ delete process.env['CLEO_SESSION_ID'];
872
+ else
873
+ process.env['CLEO_SESSION_ID'] = prior;
874
+ await sessions
875
+ .endSession(storeRoot, { sessionId: session.id, note: 'merge sweep' })
876
+ .catch(() => undefined);
877
+ }
878
+ }
879
+ /**
880
+ * Close the tasks merged PRs name (T14101): for each merged PR, judge its
881
+ * receipt, then apply each `Closes:` / `Refs:` line to its task. Never throws
882
+ * on `gh` or network trouble: it reports `swept: false` instead. Under
883
+ * `dryRun` it records, completes and comments nothing, and leaves the throttle
884
+ * alone.
885
+ *
886
+ * @param storeRoot - CLEO store root.
887
+ * @param opts - Dry run, force, since, PR numbers.
888
+ * @param deps - Outside access; defaults to `gh` and the done/complete path.
889
+ * @returns What was closed, bound and refused.
890
+ * @example
891
+ * ```ts
892
+ * const r = await sweepMergedClosures(root, { force: true, dryRun: true });
893
+ * console.log(r.closed);
894
+ * ```
895
+ */
896
+ export async function sweepMergedClosures(storeRoot, opts = {}, deps = defaultMergeCloseDeps) {
897
+ const dryRun = opts.dryRun === true;
898
+ const empty = {
899
+ swept: false,
900
+ dryRun,
901
+ prs: [],
902
+ closed: [],
903
+ bound: [],
904
+ refused: [],
905
+ waiting: 0,
906
+ reran: 0,
907
+ };
908
+ const targeted = (opts.prNumbers?.length ?? 0) > 0;
909
+ if (!opts.force && !targeted && !claimMergeSweep(storeRoot))
910
+ return { ...empty, skippedReason: 'throttled: the last sweep was under 5 minutes ago' };
911
+ if (!(await deps.ghAvailable()))
912
+ return { ...empty, skippedReason: 'gh is not available or not authenticated' };
913
+ // One sweep at a time per store, for the whole sweep (a dry run writes nothing).
914
+ const release = dryRun ? () => undefined : acquireMergeSweepLock(storeRoot, deps.now);
915
+ if (release === null)
916
+ return { ...empty, skippedReason: 'another merge sweep is running' };
917
+ try {
918
+ return await runSweep(storeRoot, opts, deps, empty, targeted);
919
+ }
920
+ finally {
921
+ release();
922
+ }
923
+ }
924
+ /** The prefix of a reason that waits only for independent reviewer verdicts. */
925
+ const NOT_VERIFIED = 'not independently verified:';
926
+ /** A waiting entry for a PR first seen waiting now (or carried with no history). */
927
+ function newWaitingEntry(at, pr) {
928
+ return {
929
+ firstAt: at,
930
+ nextAt: at,
931
+ intervalMs: MERGE_WAITING_FIRST_BACKOFF_MS / 2,
932
+ mergeSha: pr?.mergeCommitSha ?? null,
933
+ headSha: pr?.headRefOid ?? null,
934
+ lines: [],
935
+ verdictOnly: false,
936
+ reason: 'waiting',
937
+ verdictKey: '',
938
+ };
939
+ }
940
+ /** The waiting list as entries; the earlier plain-number form is due now. */
941
+ function normalizeWaiting(raw, at) {
942
+ if (raw === undefined)
943
+ return {};
944
+ if (Array.isArray(raw))
945
+ return Object.fromEntries(raw.map((n) => [String(n), newWaitingEntry(at)]));
946
+ return raw;
947
+ }
948
+ /** The entry with its backoff doubled (capped) and its next re-judgement scheduled. */
949
+ function backedOff(entry, now, verdictKey, reason) {
950
+ const intervalMs = Math.min(Math.max(entry.intervalMs * 2, MERGE_WAITING_FIRST_BACKOFF_MS), MERGE_WAITING_MAX_BACKOFF_MS);
951
+ return {
952
+ ...entry,
953
+ intervalMs,
954
+ nextAt: new Date(now + intervalMs).toISOString(),
955
+ verdictKey,
956
+ reason,
957
+ };
958
+ }
959
+ /** A fingerprint of the verdicts recorded on a PR's tasks (local reads only). */
960
+ async function verdictKeyFor(storeRoot, deps, prNumber, lines) {
961
+ if (!deps.reviewVerdicts)
962
+ return '';
963
+ const ids = [];
964
+ for (const line of lines) {
965
+ const verdicts = await deps.reviewVerdicts(storeRoot, line.taskId, prNumber).catch(() => []);
966
+ ids.push(...verdicts.map((v) => v.id));
967
+ }
968
+ return ids.sort().join(',');
969
+ }
970
+ /**
971
+ * Refuse each PR that waited past {@link MERGE_WAITING_MAX_AGE_MS} (T14101
972
+ * review), with one final comment per task, and drop it from the list.
973
+ */
974
+ async function ageOutWaiting(storeRoot, root, agedOut, deps, dryRun, result) {
975
+ const { getTaskAccessor } = await import('../store/data-accessor.js');
976
+ const accessor = await getTaskAccessor(storeRoot);
977
+ for (const [prNumber, entry] of agedOut) {
978
+ const reason = entry.verdictOnly
979
+ ? 'waited 14 days without a reviewer verdict'
980
+ : `waited 14 days without closing: ${entry.reason}`;
981
+ const outcomes = [];
982
+ for (const line of entry.lines) {
983
+ // T14139: a task closed by another route meanwhile is not refused, nor commented.
984
+ const status = (await accessor.loadSingleTask(line.taskId))?.status;
985
+ const closed = status === 'done' || status === 'cancelled';
986
+ outcomes.push(closed
987
+ ? { ...line, action: 'already', reason: `${line.taskId} is already ${status}.` }
988
+ : { ...line, action: 'refused', reason });
989
+ }
990
+ for (const o of outcomes)
991
+ if (o.action === 'refused')
992
+ result.refused.push({ taskId: o.taskId, prNumber, reason });
993
+ result.prs.push({ prNumber, mergeSha: entry.mergeSha, outcomes });
994
+ if (dryRun)
995
+ continue;
996
+ const pr = {
997
+ number: prNumber,
998
+ body: '',
999
+ mergedAt: entry.firstAt,
1000
+ headRefOid: entry.headSha,
1001
+ mergeCommitSha: entry.mergeSha,
1002
+ statusCheckRollup: [],
1003
+ };
1004
+ await reportRefusals(storeRoot, root, pr, outcomes, deps);
1005
+ }
1006
+ }
1007
+ /** The sweep proper, under the lock ({@link sweepMergedClosures}). */
1008
+ async function runSweep(storeRoot, opts, deps, empty, targeted) {
1009
+ const dryRun = empty.dryRun;
1010
+ const { resolveEvidenceExecutionRoot } = await import('./evidence.js');
1011
+ const root = resolveEvidenceExecutionRoot(storeRoot);
1012
+ const state = readSweepState(storeRoot);
1013
+ const clock = deps.now ?? Date.now;
1014
+ const startedAt = new Date(clock()).toISOString();
1015
+ const deadline = clock() + MERGE_SWEEP_MAX_RUN_MS;
1016
+ const highWater = state.highWater;
1017
+ // Re-read an overlap behind the cursor: search can list a PR late.
1018
+ const since = opts.since ??
1019
+ (highWater === undefined
1020
+ ? new Date(clock() - MERGE_SWEEP_LOOKBACK_MS).toISOString()
1021
+ : new Date(Date.parse(highWater) - MERGE_SWEEP_OVERLAP_MS).toISOString());
1022
+ let prs;
1023
+ let backlog = [];
1024
+ let newHighWater = highWater;
1025
+ const waitingIn = normalizeWaiting(state.waiting, startedAt);
1026
+ const waitingOut = {};
1027
+ const agedOut = [];
1028
+ if (targeted) {
1029
+ const viewed = await Promise.all((opts.prNumbers ?? []).map((n) => deps.viewPr(root, n)));
1030
+ prs = viewed.filter((pr) => pr !== null);
1031
+ }
1032
+ else {
1033
+ const listed = await listSweepWindows(deps, root, state, since, opts.since !== undefined);
1034
+ if (listed === null)
1035
+ return { ...empty, skippedReason: 'gh could not list merged PRs' };
1036
+ ({ prs, backlog } = listed);
1037
+ if (listed.newestRead !== undefined)
1038
+ newHighWater =
1039
+ highWater === undefined || listed.newestRead > highWater ? listed.newestRead : highWater;
1040
+ // PRs a previous sweep left waiting come back on their backoff (sooner
1041
+ // when their verdicts changed); one gh could not view stays listed.
1042
+ const known = new Set(prs.map((pr) => pr.number));
1043
+ for (const [key, entry] of Object.entries(waitingIn)) {
1044
+ const n = Number(key);
1045
+ if (known.has(n))
1046
+ continue;
1047
+ if (clock() - Date.parse(entry.firstAt) > MERGE_WAITING_MAX_AGE_MS) {
1048
+ agedOut.push([n, entry]);
1049
+ continue;
1050
+ }
1051
+ const verdictKey = await verdictKeyFor(storeRoot, deps, n, entry.lines);
1052
+ const changed = verdictKey !== entry.verdictKey;
1053
+ if (!changed && clock() < Date.parse(entry.nextAt)) {
1054
+ waitingOut[key] = entry;
1055
+ continue;
1056
+ }
1057
+ // Waiting only on verdicts that did not change: still unverified, no gh call.
1058
+ if (entry.verdictOnly && !changed) {
1059
+ waitingOut[key] = backedOff(entry, clock(), verdictKey, entry.reason);
1060
+ continue;
1061
+ }
1062
+ const pr = await deps.viewPr(root, n);
1063
+ if (pr === null)
1064
+ waitingOut[key] = backedOff(entry, clock(), verdictKey, entry.reason);
1065
+ else
1066
+ prs.push(pr);
1067
+ }
1068
+ }
1069
+ const required = await deps.requiredChecks(storeRoot);
1070
+ const result = { ...empty, swept: true };
1071
+ const oldest = backlog[0];
1072
+ if (oldest)
1073
+ result.truncated = {
1074
+ read: prs.length,
1075
+ unreadSince: oldest.since,
1076
+ unreadBefore: backlog[backlog.length - 1]?.until ?? oldest.until,
1077
+ };
1078
+ const statuses = { ...(state.statuses ?? {}) };
1079
+ const carried = [];
1080
+ const work = async () => {
1081
+ const hooks = await applyMergedPrs(storeRoot, root, prs, required, deps, dryRun, result, deadline, carried, clock);
1082
+ if (!targeted) {
1083
+ result.openChecks = await checkOpenPrs(storeRoot, root, deps, dryRun, statuses);
1084
+ hooks.macosRegression = await syncMacosRegressionIssues(storeRoot, root, deps, dryRun);
1085
+ if (!dryRun && deps.reconcile) {
1086
+ hooks.reconcile = await deps.reconcile(storeRoot).catch((err) => ({
1087
+ version: null,
1088
+ action: 'failed',
1089
+ reason: err.message,
1090
+ }));
1091
+ }
1092
+ }
1093
+ return hooks;
1094
+ };
1095
+ // Writes need a bound session; a daemon sweep has none of its own.
1096
+ const hooks = dryRun ? await work() : await withSweepSession(storeRoot, work);
1097
+ if (Object.keys(hooks).length > 0)
1098
+ result.hooks = hooks;
1099
+ if (!targeted)
1100
+ await ageOutWaiting(storeRoot, root, agedOut, deps, dryRun, result);
1101
+ if (!dryRun && !targeted) {
1102
+ const byNumber = new Map(prs.map((pr) => [pr.number, pr]));
1103
+ for (const entry of result.prs) {
1104
+ const waits = entry.outcomes.filter((o) => o.action === 'waiting');
1105
+ const pr = byNumber.get(entry.prNumber);
1106
+ if (waits.length === 0 || pr === undefined)
1107
+ continue;
1108
+ const lines = waits.map((o) => ({ taskId: o.taskId, acs: o.acs }));
1109
+ const prior = waitingIn[String(entry.prNumber)];
1110
+ const verdictKey = await verdictKeyFor(storeRoot, deps, entry.prNumber, lines);
1111
+ const reason = waits[0]?.reason ?? 'waiting';
1112
+ const next = {
1113
+ firstAt: prior?.firstAt ?? startedAt,
1114
+ nextAt: startedAt,
1115
+ intervalMs: prior ? prior.intervalMs : MERGE_WAITING_FIRST_BACKOFF_MS / 2,
1116
+ mergeSha: pr.mergeCommitSha,
1117
+ headSha: pr.headRefOid,
1118
+ lines,
1119
+ verdictOnly: waits.every((o) => (o.reason ?? '').startsWith(NOT_VERIFIED)),
1120
+ reason,
1121
+ verdictKey,
1122
+ };
1123
+ waitingOut[String(entry.prNumber)] = backedOff(next, clock(), verdictKey, reason);
1124
+ }
1125
+ // A PR a deadline left is re-judged at the next sweep, its age kept.
1126
+ for (const n of carried) {
1127
+ const prior = waitingIn[String(n)];
1128
+ waitingOut[String(n)] = prior
1129
+ ? { ...prior, nextAt: startedAt }
1130
+ : newWaitingEntry(startedAt, byNumber.get(n));
1131
+ }
1132
+ writeSweepState(storeRoot, {
1133
+ lastSweptAt: startedAt,
1134
+ ...(newHighWater !== undefined ? { highWater: newHighWater } : {}),
1135
+ ...(backlog.length > 0 ? { backlog } : {}),
1136
+ statuses,
1137
+ waiting: waitingOut,
1138
+ });
1139
+ }
1140
+ return result;
1141
+ }
1142
+ /**
1143
+ * Whether a directory is a CLEO project that takes merged CI as evidence
1144
+ * (T14101): a main git checkout (never a linked worktree, never a moved or
1145
+ * missing path) with `.cleo/project-context.json` setting
1146
+ * `evidence.ciSatisfies`. The daemon sweeps only these, never `$HOME`.
1147
+ *
1148
+ * @param root - Candidate project root.
1149
+ * @returns True when the merge sweep applies.
1150
+ */
1151
+ export function mergeSweepApplies(root) {
1152
+ try {
1153
+ // A main checkout only: a linked worktree's `.git` is a file (gitlink),
1154
+ // and a moved or missing path has none.
1155
+ if (!statSync(join(root, '.git')).isDirectory())
1156
+ return false;
1157
+ const context = JSON.parse(readFileSync(join(root, '.cleo', 'project-context.json'), 'utf-8'));
1158
+ return context.evidence?.ciSatisfies === true;
1159
+ }
1160
+ catch {
1161
+ return false;
1162
+ }
1163
+ }
1164
+ /**
1165
+ * The daemon's merge sweep (T14101): every registered project on this
1166
+ * machine that {@link mergeSweepApplies} to, behind the same gate the lazy
1167
+ * path uses ({@link mergeSweepSpawnAllowed}: never in CI, under vitest, when
1168
+ * off, or without gh credentials). Each project's sweep throttles and locks
1169
+ * itself. Never throws.
1170
+ *
1171
+ * @param opts - Environment, project lister and sweep, for tests.
1172
+ * @returns The roots swept.
1173
+ */
1174
+ export async function sweepRegisteredProjects(opts = {}) {
1175
+ if (!mergeSweepSpawnAllowed(opts.env ?? process.env))
1176
+ return [];
1177
+ const listRoots = opts.listRoots ??
1178
+ (async () => {
1179
+ const { nexusList } = await import('../nexus/registry.js');
1180
+ return (await nexusList()).map((project) => project.path);
1181
+ });
1182
+ const roots = await listRoots().catch(() => []);
1183
+ const swept = [];
1184
+ for (const root of new Set(roots)) {
1185
+ if (!mergeSweepApplies(root))
1186
+ continue;
1187
+ await (opts.sweep ?? ((r) => sweepMergedClosures(r)))(root).catch(() => undefined);
1188
+ swept.push(root);
1189
+ }
1190
+ return swept;
1191
+ }
1192
+ /**
1193
+ * The PR comment for a receipt (T14101): what the store owner's sweep will
1194
+ * close or bind, or why the PR closes nothing, plus the receipt as JSON.
1195
+ *
1196
+ * @param receipt - The receipt.
1197
+ * @returns Markdown for `gh pr comment`.
1198
+ */
1199
+ export function formatMergeReceipt(receipt) {
1200
+ const lines = receipt.closesNothing
1201
+ ? [`CLEO merge-close: this PR closes nothing, because ${receipt.closesNothing}`]
1202
+ : [
1203
+ "CLEO merge-close receipt: the store owner's sweep (`cleo done --from-merge`) will record this PR's evidence for:",
1204
+ ...receipt.directives.map((d) => `- ${d.kind === 'closes' ? 'close' : 'bind'} ${d.taskId} (${d.acs.join(', ')})`),
1205
+ ];
1206
+ return [...lines, '', '```json', JSON.stringify(receipt, null, 2), '```'].join('\n');
1207
+ }
1208
+ /**
1209
+ * Judge one merged PR without a task store and optionally comment the
1210
+ * receipt on it (T14101): the GitHub job's half. Reads only `gh` and the
1211
+ * tracked `.cleo/project-context.json` (for the required checks).
1212
+ *
1213
+ * @param storeRoot - Checkout root (the project context is read from it).
1214
+ * @param prNumber - The merged PR.
1215
+ * @param opts - `comment: true` posts the receipt on the PR.
1216
+ * @param deps - Outside access.
1217
+ * @returns The receipt, or null when `gh` could not read a merged PR.
1218
+ */
1219
+ export async function mergeReceiptFor(storeRoot, prNumber, opts = {}, deps = defaultMergeCloseDeps) {
1220
+ const pr = await deps.viewPr(storeRoot, prNumber);
1221
+ if (pr === null)
1222
+ return null;
1223
+ const receipt = await judgeMergedPr(pr, await deps.requiredChecks(storeRoot));
1224
+ const commented = opts.comment === true && (await deps.comment(storeRoot, prNumber, formatMergeReceipt(receipt)));
1225
+ return { receipt, commented };
1226
+ }
1227
+ /**
1228
+ * Turn open `macos-regression` issues into tasks (T14104), one per issue,
1229
+ * under the task `release.macosRegressionParent` names (an epic; unset, the
1230
+ * sync reads nothing), linked by issue URL in the external
1231
+ * task links so a rerun creates nothing twice; close an issue once its task is
1232
+ * done. Never throws. Under `dryRun` it creates and closes nothing.
1233
+ */
1234
+ async function syncMacosRegressionIssues(storeRoot, root, deps, dryRun) {
1235
+ const sync = { created: [], closedIssues: [] };
1236
+ // A root task must be a saga, so without a configured container there is
1237
+ // nowhere to file the tasks: read no issues at all.
1238
+ const { loadProjectContext } = await import('../agents/variable-substitution.js');
1239
+ const ctx = loadProjectContext(storeRoot);
1240
+ const release = (ctx.context?.['release'] ?? {});
1241
+ const { getTaskAccessor } = await import('../store/data-accessor.js');
1242
+ const accessor = await getTaskAccessor(storeRoot);
1243
+ const parent = typeof release.macosRegressionParent === 'string'
1244
+ ? await accessor.loadSingleTask(release.macosRegressionParent)
1245
+ : null;
1246
+ if (parent === null)
1247
+ return { ...sync, skippedReason: 'release.macosRegressionParent names no task in this store' };
1248
+ const issues = await deps.listLabelledIssues(root, MACOS_REGRESSION_LABEL);
1249
+ if (issues === null)
1250
+ return { ...sync, skippedReason: 'gh could not list issues' };
1251
+ const links = await import('../reconciliation/link-store.js');
1252
+ for (const issue of issues) {
1253
+ try {
1254
+ const link = await links.getLinkByExternalId(ISSUE_LINK_PROVIDER, issue.url, storeRoot);
1255
+ if (link === null) {
1256
+ if (dryRun) {
1257
+ sync.created.push(issue.url);
1258
+ continue;
1259
+ }
1260
+ const { addTask } = await import('./add.js');
1261
+ const added = await addTask({
1262
+ title: `macOS regression: ${issue.title}`,
1263
+ description: `Opened from ${issue.url} by the merge sweep (T14104).`,
1264
+ acceptance: [
1265
+ `the macOS failure in ${issue.url} is fixed and its shards pass on main nightly`,
1266
+ ],
1267
+ parentId: parent.id,
1268
+ labels: [MACOS_REGRESSION_LABEL],
1269
+ // The issue URL is the identity (deduped by the link below); two
1270
+ // regressions with similar titles are still two tasks. Audited.
1271
+ forceDuplicate: true,
1272
+ }, storeRoot, accessor);
1273
+ await links.createLink({
1274
+ taskId: added.task.id,
1275
+ providerId: ISSUE_LINK_PROVIDER,
1276
+ externalId: issue.url,
1277
+ externalUrl: issue.url,
1278
+ externalTitle: issue.title,
1279
+ linkType: 'created',
1280
+ syncDirection: 'inbound',
1281
+ }, storeRoot);
1282
+ sync.created.push(added.task.id);
1283
+ continue;
1284
+ }
1285
+ const task = await accessor.loadSingleTask(link.taskId);
1286
+ if (task?.status !== 'done' || dryRun)
1287
+ continue;
1288
+ const comment = `CLEO: ${task.id} is done; closing this macOS regression.`;
1289
+ if (await deps.closeIssue(root, issue.url, comment))
1290
+ sync.closedIssues.push(issue.url);
1291
+ }
1292
+ catch {
1293
+ // One issue's failure never stops the others; the next sweep retries it.
1294
+ }
1295
+ }
1296
+ return sync;
1297
+ }
1298
+ const PR_FIELDS = 'number,body,mergedAt,headRefOid,mergeCommit,statusCheckRollup';
1299
+ /** Normalise one `gh pr list/view --json PR_FIELDS` row (an open PR has `mergedAt: ''`). */
1300
+ function toMergedPr(row) {
1301
+ if (row === null || typeof row !== 'object')
1302
+ return null;
1303
+ const r = row;
1304
+ if (typeof r.number !== 'number')
1305
+ return null;
1306
+ const merge = r.mergeCommit;
1307
+ return {
1308
+ number: r.number,
1309
+ body: typeof r.body === 'string' ? r.body : '',
1310
+ mergedAt: typeof r.mergedAt === 'string' ? r.mergedAt : '',
1311
+ headRefOid: typeof r.headRefOid === 'string' ? r.headRefOid : null,
1312
+ mergeCommitSha: typeof merge?.oid === 'string' ? merge.oid : null,
1313
+ statusCheckRollup: Array.isArray(r.statusCheckRollup)
1314
+ ? r.statusCheckRollup
1315
+ : [],
1316
+ };
1317
+ }
1318
+ async function gh(args, cwd) {
1319
+ const { ghQueryTimeoutMs } = await import('../release/github-pr.js');
1320
+ try {
1321
+ return execFileSync('gh', args, {
1322
+ cwd,
1323
+ encoding: 'utf-8',
1324
+ stdio: ['ignore', 'pipe', 'pipe'],
1325
+ timeout: ghQueryTimeoutMs(),
1326
+ });
1327
+ }
1328
+ catch {
1329
+ return null;
1330
+ }
1331
+ }
1332
+ /** A step's engine result as a {@link StepResult}. */
1333
+ function stepOf(r) {
1334
+ return r.success ? { ok: true } : { ok: false, reason: r.error?.message ?? 'refused' };
1335
+ }
1336
+ /** The default {@link MergeCloseDeps}: `gh`, and the `cleo done --pr` record and complete path. */
1337
+ export const defaultMergeCloseDeps = {
1338
+ async listMergedPrs(root, since, until) {
1339
+ const out = await gh([
1340
+ 'pr',
1341
+ 'list',
1342
+ '--state',
1343
+ 'merged',
1344
+ '--search',
1345
+ until === undefined ? `merged:>=${since}` : `merged:${since}..${until}`,
1346
+ '--limit',
1347
+ String(MERGE_SWEEP_PR_LIMIT),
1348
+ '--json',
1349
+ PR_FIELDS,
1350
+ ], root);
1351
+ if (out === null)
1352
+ return null;
1353
+ const rows = JSON.parse(out);
1354
+ if (!Array.isArray(rows))
1355
+ return null;
1356
+ return rows.flatMap((row) => {
1357
+ const pr = toMergedPr(row);
1358
+ // No date filter here: the search is the window, and a page's length
1359
+ // tells the sweep whether older PRs remain.
1360
+ return pr && pr.mergedAt !== '' ? [pr] : [];
1361
+ });
1362
+ },
1363
+ async viewPr(root, prNumber) {
1364
+ const out = await gh(['pr', 'view', String(prNumber), '--json', PR_FIELDS], root);
1365
+ return out === null ? null : toMergedPr(JSON.parse(out));
1366
+ },
1367
+ async comment(root, prNumber, body) {
1368
+ return (await gh(['pr', 'comment', String(prNumber), '--body', body], root)) !== null;
1369
+ },
1370
+ async requiredChecks(storeRoot) {
1371
+ const { loadProjectContext } = await import('../agents/variable-substitution.js');
1372
+ const { resolveRequiredWorkflows } = await import('../release/pr-evidence.js');
1373
+ const ctx = loadProjectContext(storeRoot);
1374
+ return resolveRequiredWorkflows(process.env, ctx.loaded ? ctx.context : null);
1375
+ },
1376
+ async ghAvailable() {
1377
+ const { isGhCliAvailable } = await import('../release/github-pr.js');
1378
+ return isGhCliAvailable();
1379
+ },
1380
+ async record(storeRoot, taskId, prNumber, acs, dryRun) {
1381
+ const satisfies = acs;
1382
+ if (dryRun) {
1383
+ const { planTaskDone } = await import('./done-plan.js');
1384
+ const plan = await planTaskDone(taskId, { projectRoot: storeRoot, prNumber, satisfies });
1385
+ if (!plan.success)
1386
+ return stepOf(plan);
1387
+ const blocker = plan.data.blockers[0];
1388
+ return blocker ? { ok: false, reason: blocker.message } : { ok: true };
1389
+ }
1390
+ const { recordTaskDone } = await import('./done-record.js');
1391
+ const recorded = await recordTaskDone(taskId, {
1392
+ projectRoot: storeRoot,
1393
+ prNumber,
1394
+ satisfies,
1395
+ agent: 'merge-close',
1396
+ // The sweep runs no tool: a gate it cannot take from merged CI stays an
1397
+ // agent's (evidence.ciSatisfies makes testsPassed/qaPassed ci:<pr>).
1398
+ steps: {
1399
+ runTool: async (tool) => ({
1400
+ exitCode: null,
1401
+ cacheHit: false,
1402
+ durationMs: 0,
1403
+ timedOut: false,
1404
+ tail: '',
1405
+ killed: `merge-close runs no tool (${tool}); set evidence.ciSatisfies so merged CI records testsPassed and qaPassed`,
1406
+ }),
1407
+ },
1408
+ });
1409
+ return stepOf(recorded);
1410
+ },
1411
+ async complete(storeRoot, taskId, prNumber) {
1412
+ const { taskComplete } = await import('./complete.js');
1413
+ return stepOf(await taskComplete(storeRoot, taskId, { prNumber }));
1414
+ },
1415
+ async listLabelledIssues(root, label) {
1416
+ const args = ['issue', 'list', '--label', label, '--state', 'open', '--json', 'url,title'];
1417
+ const out = await gh([...args, '--limit', String(MERGE_SWEEP_PR_LIMIT)], root);
1418
+ if (out === null)
1419
+ return null;
1420
+ const rows = JSON.parse(out);
1421
+ if (!Array.isArray(rows))
1422
+ return null;
1423
+ return rows.flatMap((row) => {
1424
+ const r = row;
1425
+ return typeof r.url === 'string' && typeof r.title === 'string'
1426
+ ? [{ url: r.url, title: r.title }]
1427
+ : [];
1428
+ });
1429
+ },
1430
+ async reviewVerdicts(storeRoot, taskId, prNumber) {
1431
+ const { reviewVerdictsFor } = await import('./review-verdict.js');
1432
+ return reviewVerdictsFor(storeRoot, taskId, prNumber);
1433
+ },
1434
+ async cleanupMerged(storeRoot, taskIds) {
1435
+ const { cleanupMergedWork } = await import('../worktree/merged-cleanup.js');
1436
+ return cleanupMergedWork({ projectRoot: storeRoot, taskIds, dryRun: false });
1437
+ },
1438
+ canCleanup() {
1439
+ // Off until it has proven itself on a few releases; opt in with
1440
+ // CLEO_MERGE_SWEEP_CLEANUP=1, and never inside a CI job.
1441
+ return process.env['CLEO_MERGE_SWEEP_CLEANUP'] === '1' && process.env['CI'] !== 'true';
1442
+ },
1443
+ async docsRot(storeRoot, pr) {
1444
+ // T14111: flag the docs whose covers: globs match the merge's own changes.
1445
+ const { flagDocsRot } = await import('../docs/rot.js');
1446
+ return flagDocsRot(storeRoot, pr);
1447
+ },
1448
+ async closeIssue(root, url, comment) {
1449
+ return (await gh(['issue', 'close', url, '--comment', comment], root)) !== null;
1450
+ },
1451
+ async mergeCiRun(root, sha, workflow) {
1452
+ const args = ['run', 'list', '--commit', sha, '--workflow', workflow, '--limit', '1'];
1453
+ const out = await gh([...args, '--json', 'databaseId,status,conclusion'], root);
1454
+ if (out === null)
1455
+ return null;
1456
+ const [run] = JSON.parse(out);
1457
+ return run?.databaseId === undefined
1458
+ ? null
1459
+ : { runId: run.databaseId, status: run.status ?? '', conclusion: run.conclusion ?? null };
1460
+ },
1461
+ async rerunRun(root, runId) {
1462
+ return (await gh(['run', 'rerun', String(runId)], root)) !== null;
1463
+ },
1464
+ canRerun() {
1465
+ // Disabled until T14136 gives reruns their own concurrency group; opt in
1466
+ // with CLEO_MERGE_SWEEP_RERUN=1, and never inside a CI job.
1467
+ return process.env['CLEO_MERGE_SWEEP_RERUN'] === '1' && process.env['CI'] !== 'true';
1468
+ },
1469
+ async listOpenPrs(root) {
1470
+ const fields = 'number,body,headRefOid';
1471
+ const out = await gh(['pr', 'list', '--state', 'open', '--limit', String(MERGE_SWEEP_PR_LIMIT), '--json', fields], root);
1472
+ if (out === null)
1473
+ return null;
1474
+ const rows = JSON.parse(out);
1475
+ return Array.isArray(rows) ? rows.flatMap((row) => toMergedPr(row) ?? []) : null;
1476
+ },
1477
+ async reconcile(root) {
1478
+ const { reconcileLatestRelease } = await import('../release/reconcile-latest.js');
1479
+ return reconcileLatestRelease(root);
1480
+ },
1481
+ async setStatus(root, sha, state, description) {
1482
+ const args = [
1483
+ 'api',
1484
+ '--method',
1485
+ 'POST',
1486
+ `repos/{owner}/{repo}/statuses/${sha}`,
1487
+ '-f',
1488
+ `state=${state}`,
1489
+ '-f',
1490
+ `context=${MERGE_CLOSE_STATUS_CONTEXT}`,
1491
+ '-f',
1492
+ `description=${description}`,
1493
+ ];
1494
+ return (await gh(args, root)) !== null;
1495
+ },
1496
+ };
1497
+ //# sourceMappingURL=merge-close.js.map