dorfl 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (418) hide show
  1. package/dist/advance-ci-template.d.ts +17 -16
  2. package/dist/advance-ci-template.d.ts.map +1 -1
  3. package/dist/advance-ci-template.js +42 -53
  4. package/dist/advance-ci-template.js.map +1 -1
  5. package/dist/advance-drivers.d.ts +8 -0
  6. package/dist/advance-drivers.d.ts.map +1 -1
  7. package/dist/advance-drivers.js +28 -4
  8. package/dist/advance-drivers.js.map +1 -1
  9. package/dist/advance-isolated.d.ts.map +1 -1
  10. package/dist/advance-isolated.js +3 -2
  11. package/dist/advance-isolated.js.map +1 -1
  12. package/dist/advance-lifecycle-template.d.ts +41 -40
  13. package/dist/advance-lifecycle-template.d.ts.map +1 -1
  14. package/dist/advance-lifecycle-template.js +309 -489
  15. package/dist/advance-lifecycle-template.js.map +1 -1
  16. package/dist/advance-loop-driver.d.ts.map +1 -1
  17. package/dist/advance-loop-driver.js +3 -2
  18. package/dist/advance-loop-driver.js.map +1 -1
  19. package/dist/advance-treeless-publish.d.ts +31 -0
  20. package/dist/advance-treeless-publish.d.ts.map +1 -1
  21. package/dist/advance-treeless-publish.js +40 -0
  22. package/dist/advance-treeless-publish.js.map +1 -1
  23. package/dist/advance.d.ts +63 -3
  24. package/dist/advance.d.ts.map +1 -1
  25. package/dist/advance.js +80 -12
  26. package/dist/advance.js.map +1 -1
  27. package/dist/advancing-lock.d.ts +9 -0
  28. package/dist/advancing-lock.d.ts.map +1 -1
  29. package/dist/advancing-lock.js +21 -1
  30. package/dist/advancing-lock.js.map +1 -1
  31. package/dist/agent-env.d.ts +61 -0
  32. package/dist/agent-env.d.ts.map +1 -0
  33. package/dist/agent-env.js +144 -0
  34. package/dist/agent-env.js.map +1 -0
  35. package/dist/agent-stop.d.ts +35 -0
  36. package/dist/agent-stop.d.ts.map +1 -1
  37. package/dist/agent-stop.js +62 -0
  38. package/dist/agent-stop.js.map +1 -1
  39. package/dist/apply-merge-action.d.ts +76 -34
  40. package/dist/apply-merge-action.d.ts.map +1 -1
  41. package/dist/apply-merge-action.js +339 -129
  42. package/dist/apply-merge-action.js.map +1 -1
  43. package/dist/apply-stuck-action.js +2 -2
  44. package/dist/apply-stuck-action.js.map +1 -1
  45. package/dist/ci-agent-result.d.ts +151 -0
  46. package/dist/ci-agent-result.d.ts.map +1 -0
  47. package/dist/ci-agent-result.js +314 -0
  48. package/dist/ci-agent-result.js.map +1 -0
  49. package/dist/ci-handoff-apply.d.ts +162 -0
  50. package/dist/ci-handoff-apply.d.ts.map +1 -0
  51. package/dist/ci-handoff-apply.js +497 -0
  52. package/dist/ci-handoff-apply.js.map +1 -0
  53. package/dist/ci-handoff-format.d.ts +369 -0
  54. package/dist/ci-handoff-format.d.ts.map +1 -0
  55. package/dist/ci-handoff-format.js +646 -0
  56. package/dist/ci-handoff-format.js.map +1 -0
  57. package/dist/ci-handoff-lfs.d.ts +146 -0
  58. package/dist/ci-handoff-lfs.d.ts.map +1 -0
  59. package/dist/ci-handoff-lfs.js +397 -0
  60. package/dist/ci-handoff-lfs.js.map +1 -0
  61. package/dist/ci-handoff.d.ts +99 -0
  62. package/dist/ci-handoff.d.ts.map +1 -0
  63. package/dist/ci-handoff.js +277 -0
  64. package/dist/ci-handoff.js.map +1 -0
  65. package/dist/ci-lock-outputs.d.ts +93 -0
  66. package/dist/ci-lock-outputs.d.ts.map +1 -0
  67. package/dist/ci-lock-outputs.js +223 -0
  68. package/dist/ci-lock-outputs.js.map +1 -0
  69. package/dist/ci-phase-build.d.ts +178 -0
  70. package/dist/ci-phase-build.d.ts.map +1 -0
  71. package/dist/ci-phase-build.js +989 -0
  72. package/dist/ci-phase-build.js.map +1 -0
  73. package/dist/ci-phase-driver.d.ts +247 -0
  74. package/dist/ci-phase-driver.d.ts.map +1 -0
  75. package/dist/ci-phase-driver.js +370 -0
  76. package/dist/ci-phase-driver.js.map +1 -0
  77. package/dist/ci-phase-intake.d.ts +172 -0
  78. package/dist/ci-phase-intake.d.ts.map +1 -0
  79. package/dist/ci-phase-intake.js +555 -0
  80. package/dist/ci-phase-intake.js.map +1 -0
  81. package/dist/ci-phase-tasking.d.ts +205 -0
  82. package/dist/ci-phase-tasking.d.ts.map +1 -0
  83. package/dist/ci-phase-tasking.js +906 -0
  84. package/dist/ci-phase-tasking.js.map +1 -0
  85. package/dist/ci-phase-treeless.d.ts +224 -0
  86. package/dist/ci-phase-treeless.d.ts.map +1 -0
  87. package/dist/ci-phase-treeless.js +1482 -0
  88. package/dist/ci-phase-treeless.js.map +1 -0
  89. package/dist/ci-read-token.d.ts +47 -0
  90. package/dist/ci-read-token.d.ts.map +1 -0
  91. package/dist/ci-read-token.js +80 -0
  92. package/dist/ci-read-token.js.map +1 -0
  93. package/dist/cli.d.ts.map +1 -1
  94. package/dist/cli.js +447 -22
  95. package/dist/cli.js.map +1 -1
  96. package/dist/close-job-template.d.ts +10 -1
  97. package/dist/close-job-template.d.ts.map +1 -1
  98. package/dist/close-job-template.js +46 -14
  99. package/dist/close-job-template.js.map +1 -1
  100. package/dist/close-job.d.ts +20 -5
  101. package/dist/close-job.d.ts.map +1 -1
  102. package/dist/close-job.js +65 -12
  103. package/dist/close-job.js.map +1 -1
  104. package/dist/complete.d.ts.map +1 -1
  105. package/dist/complete.js +20 -1
  106. package/dist/complete.js.map +1 -1
  107. package/dist/config.d.ts +41 -44
  108. package/dist/config.d.ts.map +1 -1
  109. package/dist/config.js +2 -4
  110. package/dist/config.js.map +1 -1
  111. package/dist/continue-branch.d.ts +63 -15
  112. package/dist/continue-branch.d.ts.map +1 -1
  113. package/dist/continue-branch.js +75 -8
  114. package/dist/continue-branch.js.map +1 -1
  115. package/dist/crashed-run-save.d.ts +73 -0
  116. package/dist/crashed-run-save.d.ts.map +1 -0
  117. package/dist/crashed-run-save.js +86 -0
  118. package/dist/crashed-run-save.js.map +1 -0
  119. package/dist/cwd-section.d.ts.map +1 -1
  120. package/dist/cwd-section.js +10 -2
  121. package/dist/cwd-section.js.map +1 -1
  122. package/dist/decision-engine.d.ts +10 -0
  123. package/dist/decision-engine.d.ts.map +1 -1
  124. package/dist/decision-engine.js.map +1 -1
  125. package/dist/do-config.d.ts +2 -24
  126. package/dist/do-config.d.ts.map +1 -1
  127. package/dist/do-config.js +1 -33
  128. package/dist/do-config.js.map +1 -1
  129. package/dist/do.d.ts +149 -1
  130. package/dist/do.d.ts.map +1 -1
  131. package/dist/do.js +115 -25
  132. package/dist/do.js.map +1 -1
  133. package/dist/dorfl-item-template.d.ts +98 -0
  134. package/dist/dorfl-item-template.d.ts.map +1 -0
  135. package/dist/dorfl-item-template.js +504 -0
  136. package/dist/dorfl-item-template.js.map +1 -0
  137. package/dist/env-config.d.ts.map +1 -1
  138. package/dist/env-config.js +7 -5
  139. package/dist/env-config.js.map +1 -1
  140. package/dist/frontmatter.d.ts +52 -0
  141. package/dist/frontmatter.d.ts.map +1 -1
  142. package/dist/frontmatter.js +136 -2
  143. package/dist/frontmatter.js.map +1 -1
  144. package/dist/gc.d.ts +13 -0
  145. package/dist/gc.d.ts.map +1 -1
  146. package/dist/gc.js +16 -2
  147. package/dist/gc.js.map +1 -1
  148. package/dist/git.d.ts +18 -1
  149. package/dist/git.d.ts.map +1 -1
  150. package/dist/git.js +70 -5
  151. package/dist/git.js.map +1 -1
  152. package/dist/harness.d.ts +44 -5
  153. package/dist/harness.d.ts.map +1 -1
  154. package/dist/harness.js +24 -1
  155. package/dist/harness.js.map +1 -1
  156. package/dist/index.d.ts +11 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +7 -3
  159. package/dist/index.js.map +1 -1
  160. package/dist/install-ci-action-pins.d.ts +123 -0
  161. package/dist/install-ci-action-pins.d.ts.map +1 -0
  162. package/dist/install-ci-action-pins.js +190 -0
  163. package/dist/install-ci-action-pins.js.map +1 -0
  164. package/dist/install-ci-actions-pr-creation.d.ts +70 -0
  165. package/dist/install-ci-actions-pr-creation.d.ts.map +1 -0
  166. package/dist/install-ci-actions-pr-creation.js +106 -0
  167. package/dist/install-ci-actions-pr-creation.js.map +1 -0
  168. package/dist/install-ci-capabilities/dorfl-item.d.ts +12 -0
  169. package/dist/install-ci-capabilities/dorfl-item.d.ts.map +1 -0
  170. package/dist/install-ci-capabilities/dorfl-item.js +26 -0
  171. package/dist/install-ci-capabilities/dorfl-item.js.map +1 -0
  172. package/dist/install-ci-core.d.ts +155 -37
  173. package/dist/install-ci-core.d.ts.map +1 -1
  174. package/dist/install-ci-core.js +304 -200
  175. package/dist/install-ci-core.js.map +1 -1
  176. package/dist/install-ci-github.d.ts +47 -2
  177. package/dist/install-ci-github.d.ts.map +1 -1
  178. package/dist/install-ci-github.js +113 -1
  179. package/dist/install-ci-github.js.map +1 -1
  180. package/dist/install-ci.d.ts +25 -6
  181. package/dist/install-ci.d.ts.map +1 -1
  182. package/dist/install-ci.js +72 -36
  183. package/dist/install-ci.js.map +1 -1
  184. package/dist/intake-marker.d.ts +6 -1
  185. package/dist/intake-marker.d.ts.map +1 -1
  186. package/dist/intake-marker.js.map +1 -1
  187. package/dist/intake-triage.d.ts.map +1 -1
  188. package/dist/intake-triage.js +6 -2
  189. package/dist/intake-triage.js.map +1 -1
  190. package/dist/intake-trigger-template.d.ts +31 -25
  191. package/dist/intake-trigger-template.d.ts.map +1 -1
  192. package/dist/intake-trigger-template.js +107 -292
  193. package/dist/intake-trigger-template.js.map +1 -1
  194. package/dist/intake.d.ts +186 -4
  195. package/dist/intake.d.ts.map +1 -1
  196. package/dist/intake.js +533 -416
  197. package/dist/intake.js.map +1 -1
  198. package/dist/integration-core.d.ts +154 -1
  199. package/dist/integration-core.d.ts.map +1 -1
  200. package/dist/integration-core.js +759 -486
  201. package/dist/integration-core.js.map +1 -1
  202. package/dist/integrator.d.ts +12 -0
  203. package/dist/integrator.d.ts.map +1 -1
  204. package/dist/integrator.js +21 -0
  205. package/dist/integrator.js.map +1 -1
  206. package/dist/isolation.d.ts +12 -2
  207. package/dist/isolation.d.ts.map +1 -1
  208. package/dist/isolation.js +19 -8
  209. package/dist/isolation.js.map +1 -1
  210. package/dist/item-lock.d.ts +93 -11
  211. package/dist/item-lock.d.ts.map +1 -1
  212. package/dist/item-lock.js +233 -65
  213. package/dist/item-lock.js.map +1 -1
  214. package/dist/land-question-residue.d.ts +58 -0
  215. package/dist/land-question-residue.d.ts.map +1 -0
  216. package/dist/land-question-residue.js +95 -0
  217. package/dist/land-question-residue.js.map +1 -0
  218. package/dist/lifecycle-gather.d.ts +16 -0
  219. package/dist/lifecycle-gather.d.ts.map +1 -1
  220. package/dist/lifecycle-gather.js +54 -6
  221. package/dist/lifecycle-gather.js.map +1 -1
  222. package/dist/merge-answer.d.ts +41 -0
  223. package/dist/merge-answer.d.ts.map +1 -0
  224. package/dist/merge-answer.js +60 -0
  225. package/dist/merge-answer.js.map +1 -0
  226. package/dist/merge-question-surfacer.d.ts +105 -30
  227. package/dist/merge-question-surfacer.d.ts.map +1 -1
  228. package/dist/merge-question-surfacer.js +202 -91
  229. package/dist/merge-question-surfacer.js.map +1 -1
  230. package/dist/merge-question-tick.d.ts +64 -0
  231. package/dist/merge-question-tick.d.ts.map +1 -0
  232. package/dist/merge-question-tick.js +96 -0
  233. package/dist/merge-question-tick.js.map +1 -0
  234. package/dist/mint-adr.d.ts.map +1 -1
  235. package/dist/mint-adr.js +20 -2
  236. package/dist/mint-adr.js.map +1 -1
  237. package/dist/needs-attention.d.ts +72 -2
  238. package/dist/needs-attention.d.ts.map +1 -1
  239. package/dist/needs-attention.js +275 -38
  240. package/dist/needs-attention.js.map +1 -1
  241. package/dist/phase-recorder.d.ts +168 -0
  242. package/dist/phase-recorder.d.ts.map +1 -0
  243. package/dist/phase-recorder.js +218 -0
  244. package/dist/phase-recorder.js.map +1 -0
  245. package/dist/phase.d.ts +94 -0
  246. package/dist/phase.d.ts.map +1 -0
  247. package/dist/phase.js +148 -0
  248. package/dist/phase.js.map +1 -0
  249. package/dist/pi-harness.d.ts.map +1 -1
  250. package/dist/pi-harness.js +19 -6
  251. package/dist/pi-harness.js.map +1 -1
  252. package/dist/prepare.d.ts.map +1 -1
  253. package/dist/prepare.js +4 -0
  254. package/dist/prepare.js.map +1 -1
  255. package/dist/prompt.d.ts +4 -0
  256. package/dist/prompt.d.ts.map +1 -1
  257. package/dist/prompt.js +48 -1
  258. package/dist/prompt.js.map +1 -1
  259. package/dist/protocol/CLAIM-PROTOCOL.md +3 -1
  260. package/dist/protocol/WORK-CONTRACT.md +1 -1
  261. package/dist/reap-branches.d.ts +48 -0
  262. package/dist/reap-branches.d.ts.map +1 -1
  263. package/dist/reap-branches.js +64 -0
  264. package/dist/reap-branches.js.map +1 -1
  265. package/dist/ref-write.d.ts +198 -0
  266. package/dist/ref-write.d.ts.map +1 -0
  267. package/dist/ref-write.js +79 -0
  268. package/dist/ref-write.js.map +1 -0
  269. package/dist/repo-config.js +8 -8
  270. package/dist/repo-config.js.map +1 -1
  271. package/dist/repo-mirror.d.ts +36 -0
  272. package/dist/repo-mirror.d.ts.map +1 -1
  273. package/dist/repo-mirror.js +91 -1
  274. package/dist/repo-mirror.js.map +1 -1
  275. package/dist/review-verdict.d.ts +1 -1
  276. package/dist/review-verdict.js +1 -1
  277. package/dist/run.d.ts +7 -1
  278. package/dist/run.d.ts.map +1 -1
  279. package/dist/run.js +28 -4
  280. package/dist/run.js.map +1 -1
  281. package/dist/scan.d.ts +19 -1
  282. package/dist/scan.d.ts.map +1 -1
  283. package/dist/scan.js +21 -8
  284. package/dist/scan.js.map +1 -1
  285. package/dist/sidecar.d.ts +14 -0
  286. package/dist/sidecar.d.ts.map +1 -1
  287. package/dist/sidecar.js +21 -0
  288. package/dist/sidecar.js.map +1 -1
  289. package/dist/single-job-warning.d.ts +63 -0
  290. package/dist/single-job-warning.d.ts.map +1 -0
  291. package/dist/single-job-warning.js +110 -0
  292. package/dist/single-job-warning.js.map +1 -0
  293. package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +3 -1
  294. package/dist/skills/setup/protocol/WORK-CONTRACT.md +1 -1
  295. package/dist/spec-complete.d.ts +13 -6
  296. package/dist/spec-complete.d.ts.map +1 -1
  297. package/dist/spec-complete.js +23 -11
  298. package/dist/spec-complete.js.map +1 -1
  299. package/dist/start.d.ts.map +1 -1
  300. package/dist/start.js +47 -14
  301. package/dist/start.js.map +1 -1
  302. package/dist/status.d.ts +12 -0
  303. package/dist/status.d.ts.map +1 -1
  304. package/dist/status.js +11 -3
  305. package/dist/status.js.map +1 -1
  306. package/dist/tasking.d.ts +246 -1
  307. package/dist/tasking.d.ts.map +1 -1
  308. package/dist/tasking.js +452 -358
  309. package/dist/tasking.js.map +1 -1
  310. package/dist/triage-persist.d.ts.map +1 -1
  311. package/dist/triage-persist.js +21 -2
  312. package/dist/triage-persist.js.map +1 -1
  313. package/dist/verify-workflow-template.d.ts.map +1 -1
  314. package/dist/verify-workflow-template.js +8 -4
  315. package/dist/verify-workflow-template.js.map +1 -1
  316. package/dist/verify.d.ts.map +1 -1
  317. package/dist/verify.js +4 -0
  318. package/dist/verify.js.map +1 -1
  319. package/dist/watch-session.d.ts +42 -13
  320. package/dist/watch-session.d.ts.map +1 -1
  321. package/dist/watch-session.js +52 -23
  322. package/dist/watch-session.js.map +1 -1
  323. package/dist/work-layout.d.ts +10 -4
  324. package/dist/work-layout.d.ts.map +1 -1
  325. package/dist/work-layout.js +11 -3
  326. package/dist/work-layout.js.map +1 -1
  327. package/dist/workspace.d.ts +65 -4
  328. package/dist/workspace.d.ts.map +1 -1
  329. package/dist/workspace.js +159 -11
  330. package/dist/workspace.js.map +1 -1
  331. package/package.json +1 -1
  332. package/src/advance-ci-template.ts +80 -81
  333. package/src/advance-drivers.ts +45 -6
  334. package/src/advance-isolated.ts +3 -5
  335. package/src/advance-lifecycle-template.ts +375 -538
  336. package/src/advance-loop-driver.ts +3 -5
  337. package/src/advance-treeless-publish.ts +61 -0
  338. package/src/advance.ts +145 -15
  339. package/src/advancing-lock.ts +36 -1
  340. package/src/agent-env.ts +174 -0
  341. package/src/agent-stop.ts +81 -0
  342. package/src/apply-merge-action.ts +401 -154
  343. package/src/apply-stuck-action.ts +2 -2
  344. package/src/ci-agent-result.ts +421 -0
  345. package/src/ci-handoff-apply.ts +737 -0
  346. package/src/ci-handoff-format.ts +926 -0
  347. package/src/ci-handoff-lfs.ts +519 -0
  348. package/src/ci-handoff.ts +392 -0
  349. package/src/ci-lock-outputs.ts +273 -0
  350. package/src/ci-phase-build.ts +1328 -0
  351. package/src/ci-phase-driver.ts +548 -0
  352. package/src/ci-phase-intake.ts +861 -0
  353. package/src/ci-phase-tasking.ts +1255 -0
  354. package/src/ci-phase-treeless.ts +2028 -0
  355. package/src/ci-read-token.ts +90 -0
  356. package/src/cli.ts +591 -29
  357. package/src/close-job-template.ts +52 -15
  358. package/src/close-job.ts +77 -12
  359. package/src/complete.ts +19 -1
  360. package/src/config.ts +43 -48
  361. package/src/continue-branch.ts +121 -16
  362. package/src/crashed-run-save.ts +162 -0
  363. package/src/cwd-section.ts +10 -0
  364. package/src/decision-engine.ts +10 -0
  365. package/src/do-config.ts +2 -57
  366. package/src/do.ts +170 -28
  367. package/src/dorfl-item-template.ts +556 -0
  368. package/src/env-config.ts +7 -5
  369. package/src/frontmatter.ts +155 -2
  370. package/src/gc.ts +27 -2
  371. package/src/git.ts +83 -5
  372. package/src/harness.ts +58 -6
  373. package/src/index.ts +50 -1
  374. package/src/install-ci-action-pins.ts +209 -0
  375. package/src/install-ci-actions-pr-creation.ts +186 -0
  376. package/src/install-ci-capabilities/dorfl-item.ts +33 -0
  377. package/src/install-ci-core.ts +388 -233
  378. package/src/install-ci-github.ts +149 -1
  379. package/src/install-ci.ts +83 -37
  380. package/src/intake-marker.ts +6 -1
  381. package/src/intake-triage.ts +7 -3
  382. package/src/intake-trigger-template.ts +126 -327
  383. package/src/intake.ts +725 -544
  384. package/src/integration-core.ts +975 -523
  385. package/src/integrator.ts +28 -0
  386. package/src/isolation.ts +29 -10
  387. package/src/item-lock.ts +308 -92
  388. package/src/land-question-residue.ts +122 -0
  389. package/src/lifecycle-gather.ts +82 -10
  390. package/src/merge-answer.ts +82 -0
  391. package/src/merge-question-surfacer.ts +320 -112
  392. package/src/merge-question-tick.ts +182 -0
  393. package/src/mint-adr.ts +28 -2
  394. package/src/needs-attention.ts +350 -42
  395. package/src/phase-recorder.ts +350 -0
  396. package/src/phase.ts +177 -0
  397. package/src/pi-harness.ts +25 -5
  398. package/src/prepare.ts +4 -0
  399. package/src/prompt.ts +68 -1
  400. package/src/reap-branches.ts +107 -0
  401. package/src/ref-write.ts +324 -0
  402. package/src/repo-config.ts +8 -8
  403. package/src/repo-mirror.ts +107 -1
  404. package/src/review-verdict.ts +1 -1
  405. package/src/run.ts +47 -6
  406. package/src/scan.ts +37 -7
  407. package/src/sidecar.ts +36 -0
  408. package/src/single-job-warning.ts +135 -0
  409. package/src/spec-complete.ts +36 -14
  410. package/src/start.ts +55 -16
  411. package/src/status.ts +29 -3
  412. package/src/tasking.ts +593 -378
  413. package/src/triage-persist.ts +24 -2
  414. package/src/verify-workflow-template.ts +8 -4
  415. package/src/verify.ts +4 -0
  416. package/src/watch-session.ts +86 -28
  417. package/src/work-layout.ts +11 -3
  418. package/src/workspace.ts +210 -14
package/src/integrator.ts CHANGED
@@ -380,6 +380,34 @@ export interface IntegrateResult {
380
380
  commit?: string;
381
381
  }
382
382
 
383
+ /**
384
+ * The HONEST propose-land check (task `intake-reports-the-pr-it-actually-opened`):
385
+ * the reason NO review request exists for a propose land that pushed its branch,
386
+ * else `undefined`. A propose land whose provider did not open a request (a
387
+ * degraded `gh pr create`, e.g. a repository that refuses PR creation by Actions;
388
+ * the push-only `none` provider; the `noPR` intent) returns the provider's
389
+ * `instruction` (the real `gh` error + the manual `gh pr create` command), so every
390
+ * caller can say "pushed, but no PR" instead of implying one. `undefined` for a
391
+ * merge land, an opened request, a benign already-landed no-op (nothing was
392
+ * pushed, nothing to propose), or no integration at all.
393
+ */
394
+ export function proposeRequestNotOpened(
395
+ integration: IntegrateResult | undefined,
396
+ ): string | undefined {
397
+ if (
398
+ integration === undefined ||
399
+ integration.mode !== 'propose' ||
400
+ integration.requestOpened ||
401
+ integration.alreadyLanded === true
402
+ ) {
403
+ return undefined;
404
+ }
405
+ return (
406
+ integration.instruction ??
407
+ `Pushed ${integration.pushedRef}; no review request was opened.`
408
+ );
409
+ }
410
+
383
411
  /** Outcome of the full rebase-then-integrate flow. */
384
412
  export interface IntegrateWithRebaseResult {
385
413
  outcome: 'integrated' | 'needs-attention';
package/src/isolation.ts CHANGED
@@ -4,8 +4,10 @@ import {reapJob} from './gc.js';
4
4
  import {
5
5
  branchAheadOfArbiter,
6
6
  rebaseContinuedBranchOntoMain,
7
- pushContinuedBranchWithStaleLeaseRetry,
7
+ type ContinueRebaseFailure,
8
8
  } from './continue-branch.js';
9
+ import {refWrite} from './ref-write.js';
10
+ import {activePhase} from './phase.js';
9
11
  import {workBranchRef, type SlugNamespace} from './slug-namespace.js';
10
12
 
11
13
  /**
@@ -69,13 +71,22 @@ export interface IsolatedTree {
69
71
  */
70
72
  continued?: boolean;
71
73
  /**
72
- * True iff a CONTINUE rebase onto fresh main CONFLICTED at onboard-time
74
+ * True iff a CONTINUE rebase onto fresh main did not apply at onboard-time
73
75
  * (a requeue kept a `work/<slug>` whose commits did not replay onto the
74
- * current main; aborted, never auto-resolved). The pipeline routes the item
76
+ * current main, by a genuine conflict or any other rebase failure; aborted,
77
+ * never auto-resolved). The pipeline routes the item
75
78
  * to needs-attention (the §10 path) instead of running the agent. Absent /
76
79
  * false on a fresh cut and on a clean continue.
77
80
  */
78
81
  continueRebaseConflict?: boolean;
82
+ /**
83
+ * Why the continue rebase did not apply (set exactly when
84
+ * {@link continueRebaseConflict} is): a genuine conflict with its paths, or
85
+ * any other rebase failure with git's report, plus the `main` sha rebased
86
+ * onto. The needs-attention reason renders it with
87
+ * `describeContinueRebaseFailure`.
88
+ */
89
+ continueRebaseFailure?: ContinueRebaseFailure;
79
90
  /**
80
91
  * Set iff the CONTINUE reconcile push to the arbiter FAILED TERMINALLY at
81
92
  * onboard-time (the stale-lease retry cap exhausted, or a non-stale-lease
@@ -199,6 +210,7 @@ export function jobWorktreeHandle(
199
210
  arbiterUrl: job.mirror.url,
200
211
  continued: job.continued,
201
212
  continueRebaseConflict: job.continueRebaseConflict,
213
+ continueRebaseFailure: job.continueRebaseFailure,
202
214
  continuePushFailure: job.continuePushFailure,
203
215
  teardown(opts?: {reachableOnly?: boolean}): void {
204
216
  // Auto-reap at end-of-job (ADR §4): re-apply the provably-safe deletion
@@ -261,7 +273,7 @@ export function inPlaceStrategy(options: {
261
273
  // it)? In a normal clone the refs are the remote-tracking
262
274
  // `<arbiter>/work/<slug>` and `<arbiter>/main`.
263
275
  let continued = false;
264
- let continueRebaseConflict = false;
276
+ let continueRebaseFailure: ContinueRebaseFailure | undefined;
265
277
  let continuePushFailure: string | undefined;
266
278
  // ARBITER-AUTHORITATIVE continue-detection: `ls-remote` the arbiter so a
267
279
  // STALE local remote-tracking ref (a plain `git fetch` does NOT prune
@@ -310,8 +322,14 @@ export function inPlaceStrategy(options: {
310
322
  `${arbiter}/main`,
311
323
  env,
312
324
  );
313
- if (rebase.kind === 'conflict') {
314
- continueRebaseConflict = true;
325
+ if (rebase.kind !== 'clean') {
326
+ continueRebaseFailure = rebase;
327
+ } else if (activePhase() === 'agent') {
328
+ // CI AGENT PHASE (ADR `ci-agent-job-holds-no-write-token` decision 7):
329
+ // the agent job holds no write token, so the rebase stays LOCAL. The
330
+ // apply job pushes the rebased branch (in the handoff bundle) with
331
+ // `--force-with-lease` against the tip the lock job observed
332
+ // (`continueTip`), before any other write.
315
333
  } else {
316
334
  // The helper THROWS on a terminal failure (the stale-lease retry cap, or
317
335
  // a non-stale-lease rejection / unreachable arbiter). CATCH it and flag
@@ -319,7 +337,7 @@ export function inPlaceStrategy(options: {
319
337
  // — the kept work stays committed + recoverable — rather than crashing
320
338
  // the run and stranding it silently in-progress (the stale-lease bug).
321
339
  try {
322
- const pushed = pushContinuedBranchWithStaleLeaseRetry({
340
+ const pushed = refWrite.pushContinuedBranch({
323
341
  cwd: checkout,
324
342
  branch,
325
343
  arbiter,
@@ -327,8 +345,8 @@ export function inPlaceStrategy(options: {
327
345
  expectedRemoteTip,
328
346
  env,
329
347
  });
330
- if (pushed.kind === 'conflict') {
331
- continueRebaseConflict = true;
348
+ if (pushed.kind !== 'pushed') {
349
+ continueRebaseFailure = pushed;
332
350
  }
333
351
  } catch (err) {
334
352
  continuePushFailure =
@@ -376,7 +394,8 @@ export function inPlaceStrategy(options: {
376
394
  arbiterRemote: arbiter,
377
395
  arbiterUrl,
378
396
  continued,
379
- continueRebaseConflict,
397
+ continueRebaseConflict: continueRebaseFailure !== undefined,
398
+ continueRebaseFailure,
380
399
  continuePushFailure,
381
400
  // NO-OP teardown: the checkout is the human's / CI's tree — left on
382
401
  // `work/<slug>` in a defined state, NEVER reaped (ADR §2/§4). The
package/src/item-lock.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {randomUUID} from 'node:crypto';
2
2
  import {runAsync, type RunResult} from './git.js';
3
+ import {refWrite} from './ref-write.js';
3
4
  import {
4
5
  resolveSidecarIdentity,
5
6
  sidecarPathFor,
@@ -128,8 +129,27 @@ export interface LockEntry {
128
129
  state: LockState;
129
130
  holder: string;
130
131
  since: string;
132
+ /**
133
+ * Why a finished build still holds the lock, when it does (task
134
+ * `wire-merge-questions-into-the-advance-tick`, decision 4). Absent on every
135
+ * live hold. See {@link LockKeptFor}.
136
+ */
137
+ keptFor?: LockKeptFor;
131
138
  }
132
139
 
140
+ /**
141
+ * The PROPOSE-KEPT marker a lock entry may carry. `propose-pr`: a propose land
142
+ * pushed the work branch (and opened a PR where the host allows it) but the
143
+ * work is not on `main` yet, so the build's `implement` lock is KEPT HELD
144
+ * "until the PR merges" (task `propose-keep-lock-until-pr-merge`). No build is
145
+ * running under it. It is what lets the merge-question surfacer tell a finished
146
+ * propose build (ask) from a live build (skip), and what the answered-merge
147
+ * lock phase may take over (for the same item) instead of backing off. A lock
148
+ * WITHOUT the marker is a live build, including the rebuild of a bounced task's
149
+ * kept branch, whose tip can already carry the done-move.
150
+ */
151
+ export type LockKeptFor = 'propose-pr';
152
+
133
153
  /** Outcome of an acquire attempt. `acquired` = we hold it; `lost` = someone else
134
154
  * does (a genuine same-item conflict). `error` = environment/usage. */
135
155
  export type AcquireOutcome = 'acquired' | 'lost' | 'error';
@@ -151,12 +171,12 @@ export interface ReleaseResult {
151
171
  }
152
172
 
153
173
  /**
154
- * Outcome of an AMEND-style transition (resume-crash-orphan / requeue) — the
174
+ * Outcome of an AMEND-style transition (resume-crash-orphan) — the
155
175
  * lock-entry STATE MACHINE's interior moves. Post-CONTRACT step (task
156
176
  * `retire-stuck-lock-state`) the `mark-stuck` transition + the `wrong-state`
157
177
  * verdict are retired with the `stuck` state itself.
158
178
  * - `transitioned` — we won the CAS; the entry is now at the target state
159
- * (or removed, for requeue).
179
+ * (or removed, for the crash-orphan clear).
160
180
  * - `not-held` — there is no entry to transition (the move's precondition
161
181
  * is a held entry; absent ⇒ illegal here).
162
182
  * - `lost` — the leased CAS was rejected because a CONCURRENT writer
@@ -172,7 +192,7 @@ export interface TransitionResult {
172
192
  entry: string;
173
193
  ref: string;
174
194
  message: string;
175
- /** The lock entry AFTER a successful `transitioned` (absent for requeue's removal). */
195
+ /** The lock entry AFTER a successful `transitioned` (absent for a removal). */
176
196
  lock?: LockEntry;
177
197
  }
178
198
 
@@ -213,9 +233,12 @@ export function serialiseLockEntry(e: LockEntry): string {
213
233
  `state: ${e.state}`,
214
234
  `holder: ${e.holder}`,
215
235
  `since: ${e.since}`,
236
+ ...(e.keptFor === undefined ? [] : [`keptFor: ${e.keptFor}`]),
216
237
  '---',
217
238
  '',
218
- `Lock held for \`${e.entry}\` (${e.action}/${e.state}).`,
239
+ e.keptFor === undefined
240
+ ? `Lock held for \`${e.entry}\` (${e.action}/${e.state}).`
241
+ : `Lock kept for \`${e.entry}\` (${e.action}/${e.state}, ${e.keptFor}): the build finished; its work is not on main yet.`,
219
242
  '',
220
243
  ].join('\n');
221
244
  }
@@ -326,11 +349,7 @@ export async function acquireItemLock(
326
349
  env,
327
350
  );
328
351
  // CREATE-ONLY push: --force-with-lease=<ref>: (empty) succeeds iff ref absent.
329
- const push = await gitSoft(
330
- ['push', arbiter, `${commit}:${ref}`, `--force-with-lease=${ref}:`],
331
- cwd,
332
- env,
333
- );
352
+ const push = await refWrite.createLockRef({arbiter, ref, commit, cwd, env});
334
353
  if (push.status === 0) {
335
354
  return {outcome: 'acquired', entry, ref, message: `locked ${entry}`};
336
355
  }
@@ -379,6 +398,42 @@ export async function acquireItemLock(
379
398
  }
380
399
  }
381
400
 
401
+ /**
402
+ * Lock refs whose release is LEASED on a known sha: `<ref>` → the sha this
403
+ * process last observed for it (spec `ci-agent-job-without-write-token` §8, the
404
+ * lock-ownership rule; task `ci-split-build-path-non-integrate-intents`).
405
+ *
406
+ * The CI apply phase holds the lock the lock JOB took, and every CI run's lock
407
+ * `holder` is the same bot, so only the sha tells runs apart. Its releases are
408
+ * reached from deep inside the reused write halves (the surface, the
409
+ * return-to-backlog, the land's needs-attention routes), which do not carry the
410
+ * sha, so the apply phase registers it here for its whole run
411
+ * ({@link leaseLockReleases}) and {@link releaseLockEntry} refuses to delete a
412
+ * lock that no longer points at it. Process-global for the same reason the
413
+ * phase is (`phase.ts`): a phase run is a one-shot process. Empty outside the
414
+ * apply phase, where every release behaves exactly as before.
415
+ */
416
+ const releaseLeases = new Map<string, string>();
417
+
418
+ /**
419
+ * Lease every release of `item`'s lock on `expectedSha` until the returned
420
+ * function is called: a release then deletes the ref only while it still points
421
+ * at `expectedSha`, and otherwise reports an `error` and leaves it untouched (the
422
+ * lock was re-taken by another run).
423
+ */
424
+ export function leaseLockReleases(
425
+ item: string,
426
+ expectedSha: string,
427
+ ): () => void {
428
+ const ref = itemLockRef(lockEntryFor(item));
429
+ const previous = releaseLeases.get(ref);
430
+ releaseLeases.set(ref, expectedSha);
431
+ return () => {
432
+ if (previous === undefined) releaseLeases.delete(ref);
433
+ else releaseLeases.set(ref, previous);
434
+ };
435
+ }
436
+
382
437
  export interface ReleaseOptions {
383
438
  /** The NAMESPACED item identity (same forms as {@link AcquireOptions.item}). */
384
439
  item: string;
@@ -453,11 +508,24 @@ async function releaseLockEntry(
453
508
  // Delete the ref on the arbiter. Lease on the current value guards against a
454
509
  // concurrent change between our fetch and the delete.
455
510
  const cur = (await gitHard(['rev-parse', ref], cwd, env)).stdout.trim();
456
- const del = await gitSoft(
457
- ['push', arbiter, '--delete', ref, `--force-with-lease=${ref}:${cur}`],
511
+ const lease = releaseLeases.get(ref);
512
+ if (lease !== undefined && lease !== cur) {
513
+ return {
514
+ outcome: 'error',
515
+ entry,
516
+ ref,
517
+ message:
518
+ `not released: ${ref} now points at ${cur}, not the leased ` +
519
+ `${lease} (another run holds the lock now)`,
520
+ };
521
+ }
522
+ const del = await refWrite.deleteLockRef({
523
+ arbiter,
524
+ ref,
525
+ expectedSha: cur,
458
526
  cwd,
459
527
  env,
460
- );
528
+ });
461
529
  if (del.status === 0) {
462
530
  // Drop our local copy of the ref too (best-effort).
463
531
  await gitSoft(['update-ref', '-d', ref], cwd, env);
@@ -583,7 +651,7 @@ export async function releaseHeldItemLock(
583
651
  /**
584
652
  * Fetch the lock refs and return the held entry + its current ref sha, or
585
653
  * `undefined` when the item is at REST (no ref). Shared read-before-CAS step for
586
- * the amend-style transitions (mark-stuck / resume / requeue): they all need BOTH
654
+ * the amend-style transitions (resume): they need BOTH
587
655
  * the current entry (to check the state precondition + carry forward `action` /
588
656
  * `holder` / `since`) and the current sha (to lease the CAS on it).
589
657
  */
@@ -626,52 +694,6 @@ async function fetchHeldEntry(
626
694
  return {lock, sha};
627
695
  }
628
696
 
629
- /**
630
- * AMEND the held entry in place via a leased CAS: build a NEW parentless commit
631
- * carrying `next` and push it to the SAME ref with `--force-with-lease=<ref>:<sha>`
632
- * (the sha we just read). The arbiter accepts ONLY if the ref is unchanged since
633
- * our read — a concurrent writer who moved it makes our lease fail (`lost`). No
634
- * retry loop: a rejection is a genuine same-item race the caller should lose.
635
- */
636
- async function amendHeldEntry(
637
- next: LockEntry,
638
- ref: string,
639
- expectedSha: string,
640
- cwd: string,
641
- arbiter: string,
642
- env: NodeJS.ProcessEnv | undefined,
643
- ): Promise<TransitionResult> {
644
- const commit = await buildLockCommit(next, cwd, env);
645
- const push = await gitSoft(
646
- [
647
- 'push',
648
- arbiter,
649
- `${commit}:${ref}`,
650
- `--force-with-lease=${ref}:${expectedSha}`,
651
- ],
652
- cwd,
653
- env,
654
- );
655
- if (push.status === 0) {
656
- // Move our local copy to the new commit too (best-effort) so a subsequent
657
- // read in the same clone sees the amended entry without a refetch.
658
- await gitSoft(['update-ref', ref, commit], cwd, env);
659
- return {
660
- outcome: 'transitioned',
661
- entry: next.entry,
662
- ref,
663
- message: `${next.entry} → ${next.action}/${next.state}`,
664
- lock: next,
665
- };
666
- }
667
- return {
668
- outcome: 'lost',
669
- entry: next.entry,
670
- ref,
671
- message: `'${next.entry}' lock changed concurrently (CAS lost). Back off.`,
672
- };
673
- }
674
-
675
697
  /** Verdict of the SHARED leased delete {@link leasedDeleteLockRef}: `deleted` (the
676
698
  * ref was removed), `lost` (a concurrent change moved the ref between the read and
677
699
  * the delete — the lease was REJECTED, never `--force`d), or `error`. */
@@ -695,17 +717,13 @@ async function leasedDeleteLockRef(
695
717
  arbiter: string,
696
718
  env: NodeJS.ProcessEnv | undefined,
697
719
  ): Promise<LeasedDeleteOutcome> {
698
- const del = await gitSoft(
699
- [
700
- 'push',
701
- arbiter,
702
- '--delete',
703
- ref,
704
- `--force-with-lease=${ref}:${expectedSha}`,
705
- ],
720
+ const del = await refWrite.deleteLockRef({
721
+ arbiter,
722
+ ref,
723
+ expectedSha,
706
724
  cwd,
707
725
  env,
708
- );
726
+ });
709
727
  if (del.status !== 0) {
710
728
  return 'lost';
711
729
  }
@@ -871,21 +889,35 @@ export async function resumeItemLock(
871
889
  }
872
890
  }
873
891
 
892
+ export interface MarkProposeKeptOptions {
893
+ /** The NAMESPACED item identity (same forms as {@link AcquireOptions.item}). */
894
+ item: string;
895
+ cwd: string;
896
+ arbiter?: string;
897
+ /**
898
+ * The lock sha the caller holds (the CI apply phase's `lockSha`). When set,
899
+ * the mark is refused (`lost`) unless the ref still points at it. Unset: the
900
+ * current sha is read and leased on (the laptop `complete`, which holds the
901
+ * lock it claimed).
902
+ */
903
+ expectedSha?: string;
904
+ env?: NodeJS.ProcessEnv;
905
+ }
906
+
874
907
  /**
875
- * requeue: give up on a held lock and return the item to the pool by REMOVING
876
- * the entry. Post-CONTRACT step (task `retire-stuck-lock-state`) there is NO
877
- * `stuck` state to guard against — the held lock is always `active`, so
878
- * requeue works on any held entry (a leased delete; a concurrent change ⇒
879
- * `lost`). The body never moved (it rests in the pool on `main`), so requeue
880
- * is purely "release the lock"; the kept `work/<slug>` branch remains for
881
- * recovery.
908
+ * STAMP the {@link LockKeptFor} `propose-pr` marker on a held lock (task
909
+ * `wire-merge-questions-into-the-advance-tick`, decision 4): a propose land
910
+ * that keeps the build's lock held "until the PR merges" calls this, so the lock
911
+ * says it is kept for a propose PR and no longer covers a live build. The entry
912
+ * keeps its `action`, `holder` and `since`; the ref is replaced by a leased CAS
913
+ * (never forced), so a concurrent change is `lost`. Idempotent: a lock that
914
+ * already carries the marker is `transitioned` without a write.
882
915
  */
883
- export async function requeueItemLock(
884
- opts: ReleaseOptions,
916
+ export async function markLockKeptForProposePr(
917
+ opts: MarkProposeKeptOptions,
885
918
  ): Promise<TransitionResult> {
886
919
  const arbiter = opts.arbiter ?? 'origin';
887
- const env = opts.env;
888
- const cwd = opts.cwd;
920
+ const {cwd, env} = opts;
889
921
  if (!opts.item) {
890
922
  return {outcome: 'error', entry: '', ref: '', message: 'missing item'};
891
923
  }
@@ -898,34 +930,155 @@ export async function requeueItemLock(
898
930
  outcome: 'not-held',
899
931
  entry,
900
932
  ref,
901
- message: `'${entry}' not locked`,
933
+ message: `'${entry}' not locked; nothing to mark`,
902
934
  };
903
935
  }
904
- const del = await gitSoft(
905
- [
906
- 'push',
936
+ if (opts.expectedSha !== undefined && held.sha !== opts.expectedSha) {
937
+ return {
938
+ outcome: 'lost',
939
+ entry,
940
+ ref,
941
+ message: `'${entry}' now points at ${held.sha}, not ${opts.expectedSha}; not marked`,
942
+ };
943
+ }
944
+ if (held.lock.keptFor === 'propose-pr') {
945
+ return {
946
+ outcome: 'transitioned',
947
+ entry,
948
+ ref,
949
+ message: `'${entry}' is already kept for a propose PR`,
950
+ lock: held.lock,
951
+ };
952
+ }
953
+ const lock: LockEntry = {...held.lock, keptFor: 'propose-pr'};
954
+ const commit = await buildLockCommit(lock, cwd, env);
955
+ const push = await refWrite.replaceLockRef({
956
+ arbiter,
957
+ ref,
958
+ commit,
959
+ expectedSha: held.sha,
960
+ cwd,
961
+ env,
962
+ });
963
+ if (push.status !== 0) {
964
+ return {
965
+ outcome: 'lost',
966
+ entry,
967
+ ref,
968
+ message: `marking '${entry}' kept for a propose PR was rejected: ${push.stderr.trim()}`,
969
+ };
970
+ }
971
+ return {
972
+ outcome: 'transitioned',
973
+ entry,
974
+ ref,
975
+ message: `marked '${entry}' kept for a propose PR`,
976
+ lock,
977
+ };
978
+ } catch (err) {
979
+ return {
980
+ outcome: 'error',
981
+ entry,
982
+ ref,
983
+ message: err instanceof Error ? err.message : String(err),
984
+ };
985
+ }
986
+ }
987
+
988
+ export interface TakeOverProposeKeptOptions {
989
+ /** The NAMESPACED item identity (same forms as {@link AcquireOptions.item}). */
990
+ item: string;
991
+ /** The action the taken-over lock is held for (the answered merge: `advance`). */
992
+ action: LockAction;
993
+ cwd: string;
994
+ arbiter?: string;
995
+ holder?: string;
996
+ env?: NodeJS.ProcessEnv;
997
+ }
998
+
999
+ /**
1000
+ * TAKE OVER a lock that a finished propose build of the SAME item keeps held
1001
+ * (task `wire-merge-questions-into-the-advance-tick`, decisions 2 and 4): the
1002
+ * answered-merge lock phase calls this when the create-only acquire found the
1003
+ * item locked. It succeeds ONLY when the held entry carries the
1004
+ * {@link LockKeptFor} `propose-pr` marker and was taken for `implement` (the
1005
+ * propose build's own claim); the ref is then replaced, leased on the sha read,
1006
+ * by a fresh `action` hold without the marker, so the rest of the run owns it
1007
+ * exactly like a lock it created. Any other held lock (a live build, a tasking,
1008
+ * another advance) is `lost`, as the plain acquire reports it. A lock released
1009
+ * meanwhile falls back to the plain create-only acquire.
1010
+ */
1011
+ export async function takeOverProposeKeptLock(
1012
+ opts: TakeOverProposeKeptOptions,
1013
+ ): Promise<AcquireResult> {
1014
+ const arbiter = opts.arbiter ?? 'origin';
1015
+ const {cwd, env} = opts;
1016
+ if (!opts.item) {
1017
+ return {outcome: 'error', entry: '', ref: '', message: 'missing item'};
1018
+ }
1019
+ const entry = lockEntryFor(opts.item);
1020
+ const ref = itemLockRef(entry);
1021
+ try {
1022
+ const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
1023
+ if (!held) {
1024
+ return acquireItemLock({
1025
+ item: opts.item,
1026
+ action: opts.action,
1027
+ cwd,
907
1028
  arbiter,
908
- '--delete',
1029
+ holder: opts.holder,
1030
+ env,
1031
+ });
1032
+ }
1033
+ if (
1034
+ held.lock.keptFor !== 'propose-pr' ||
1035
+ held.lock.action !== 'implement' ||
1036
+ held.lock.entry !== entry
1037
+ ) {
1038
+ return {
1039
+ outcome: 'lost',
1040
+ entry,
909
1041
  ref,
910
- `--force-with-lease=${ref}:${held.sha}`,
911
- ],
1042
+ message:
1043
+ `'${entry}' is already locked (${held.lock.action}, not kept for a ` +
1044
+ 'propose PR): a live hold is never taken over. Back off.',
1045
+ };
1046
+ }
1047
+ const holder = opts.holder ?? (await resolveHolder(cwd, env));
1048
+ const commit = await buildLockCommit(
1049
+ {
1050
+ entry,
1051
+ action: opts.action,
1052
+ state: 'active',
1053
+ holder,
1054
+ since: new Date().toISOString(),
1055
+ },
912
1056
  cwd,
913
1057
  env,
914
1058
  );
915
- if (del.status === 0) {
916
- await gitSoft(['update-ref', '-d', ref], cwd, env);
1059
+ const push = await refWrite.replaceLockRef({
1060
+ arbiter,
1061
+ ref,
1062
+ commit,
1063
+ expectedSha: held.sha,
1064
+ cwd,
1065
+ env,
1066
+ });
1067
+ if (push.status !== 0) {
917
1068
  return {
918
- outcome: 'transitioned',
1069
+ outcome: 'lost',
919
1070
  entry,
920
1071
  ref,
921
- message: `requeued ${entry} (lock released, body still in pool)`,
1072
+ message: `taking over '${entry}' lost the race: ${push.stderr.trim()}`,
922
1073
  };
923
1074
  }
1075
+ // Keep the local copy in step, as the create-only acquire's fetch would.
1076
+ await gitSoft(['update-ref', ref, commit], cwd, env);
924
1077
  return {
925
- outcome: 'lost',
1078
+ outcome: 'acquired',
926
1079
  entry,
927
1080
  ref,
928
- message: `'${entry}' lock changed concurrently (CAS lost). Back off.`,
1081
+ message: `took over '${entry}' from its finished propose build (kept for a propose PR)`,
929
1082
  };
930
1083
  } catch (err) {
931
1084
  return {
@@ -2410,6 +2563,65 @@ export async function heldTaskSlugsStrict(
2410
2563
  );
2411
2564
  }
2412
2565
 
2566
+ /**
2567
+ * STRICT (fail-CLOSED) twin of {@link listItemLockEntries}: THROWS when the
2568
+ * lock refs cannot be read (the same fetch {@link listItemLocks} does), so a
2569
+ * caller that ACTS on the absence of a lock (the merge-question surfacer asks
2570
+ * about a branch only when no live build holds its item) never mistakes an
2571
+ * unreadable arbiter for "no locks". A held ref whose body does not parse is
2572
+ * returned as a plain `implement` hold without the marker (a live build: the
2573
+ * safe reading).
2574
+ */
2575
+ export async function listItemLockEntriesStrict(
2576
+ cwd: string,
2577
+ arbiter = 'origin',
2578
+ env?: NodeJS.ProcessEnv,
2579
+ ): Promise<LockEntry[]> {
2580
+ const names = await listItemLocks(cwd, arbiter, env);
2581
+ const entries: LockEntry[] = [];
2582
+ for (const entry of names) {
2583
+ const show = await gitSoft(
2584
+ ['show', `${itemLockRef(entry)}:lock.md`],
2585
+ cwd,
2586
+ env,
2587
+ );
2588
+ const lock = show.status === 0 ? parseLockEntry(show.stdout) : undefined;
2589
+ entries.push(
2590
+ lock ?? {
2591
+ entry,
2592
+ action: 'implement',
2593
+ state: 'active',
2594
+ holder: '',
2595
+ since: '',
2596
+ },
2597
+ );
2598
+ }
2599
+ return entries;
2600
+ }
2601
+
2602
+ /**
2603
+ * The TASK slugs whose lock a finished propose build keeps held, i.e. whose
2604
+ * entry carries the {@link LockKeptFor} `propose-pr` marker (task
2605
+ * `wire-merge-questions-into-the-advance-tick`, decision 6). Selection uses it
2606
+ * to let an answered `merge` through the held-slug subtraction; the
2607
+ * merge-question surfacer to tell a finished propose build from a live one.
2608
+ * GRACEFUL: a read fault yields an empty set, so a held lock keeps excluding
2609
+ * its item (the safe direction).
2610
+ */
2611
+ export async function proposeKeptTaskSlugs(
2612
+ cwd: string,
2613
+ arbiter = 'origin',
2614
+ env?: NodeJS.ProcessEnv,
2615
+ ): Promise<Set<string>> {
2616
+ const entries = await listItemLockEntries(cwd, arbiter, env);
2617
+ const prefix = 'task-';
2618
+ return new Set(
2619
+ entries
2620
+ .filter((e) => e.keptFor === 'propose-pr' && e.entry.startsWith(prefix))
2621
+ .map((e) => e.entry.slice(prefix.length)),
2622
+ );
2623
+ }
2624
+
2413
2625
  /**
2414
2626
  * List the SPEC slugs currently lock-held on the arbiter — the held-SPEC set the
2415
2627
  * TASKABLE-spec pool readers SUBTRACT (fix
@@ -2492,6 +2704,10 @@ export function parseLockEntry(body: string): LockEntry | undefined {
2492
2704
  state: 'active',
2493
2705
  holder: fields.holder ?? '',
2494
2706
  since: fields.since ?? '',
2707
+ // Only the one known marker value is read; anything else is a live hold.
2708
+ ...(fields.keptFor === 'propose-pr'
2709
+ ? {keptFor: 'propose-pr' as const}
2710
+ : {}),
2495
2711
  };
2496
2712
  }
2497
2713