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
@@ -27,16 +27,21 @@ import {
27
27
  formatBlockReason,
28
28
  reviewRoundsExhaustedReason,
29
29
  } from './review-gate.js';
30
- import {type IntegrateResult, type ReviewProvider} from './integrator.js';
30
+ import {
31
+ type IntegrateResult,
32
+ type ReviewProvider,
33
+ proposeRequestNotOpened,
34
+ } from './integrator.js';
31
35
  import {extractDecisionsBlock} from './agent-stop.js';
32
36
  import {ledgerWrite} from './ledger-write.js';
33
37
  import {selectProvider} from './github.js';
34
38
  import type {IntegrationMode} from './config.js';
35
- import {parseFrontmatter} from './frontmatter.js';
39
+ import {parseFrontmatter, unquoteYamlScalar} from './frontmatter.js';
36
40
  import {git, run, runAsync, type RunResult} from './git.js';
37
41
  import {realSleep, type Sleep} from './retry-backoff.js';
38
42
  import {workBranchRef} from './slug-namespace.js';
39
43
  import {isAncestor} from './gc.js';
44
+ import {clearOwnQuestionResidueOnLand} from './land-question-residue.js';
40
45
  import {
41
46
  detectColocatedSidecars,
42
47
  formatSidecarGuardReason,
@@ -204,6 +209,16 @@ export interface IntegrationLifecycle {
204
209
  * WHICH lifecycle landed; it gates nothing.
205
210
  */
206
211
  commitTag?: string;
212
+ /**
213
+ * The reviewer POINTER line heading the propose-mode PR body (task
214
+ * `propose-pr-bodies-point-at-the-real-item`): it names the item's REAL resting
215
+ * path after this lifecycle lands, so a reviewer can open the file the PR
216
+ * carries. The task build default (`Task: work/tasks/done/<slug>.md`) is wrong
217
+ * for a lifecycle (a tasked spec rests in `work/specs/tasked/`, an intake
218
+ * document in its staged folder), so every lifecycle states its own. Compose it
219
+ * with {@link proposePointer}.
220
+ */
221
+ proposeHeader: string;
207
222
  }
208
223
 
209
224
  /**
@@ -429,6 +444,14 @@ export interface IntegrationCoreInput {
429
444
  reviewModel?: string;
430
445
  /** Bound the revise↔review loop (Gate 2). Defaults to 2. */
431
446
  reviewMaxRounds?: number;
447
+ /**
448
+ * The approved review prose of a review that already ran ELSEWHERE, set only
449
+ * by the CI tasking apply phase (task `ci-split-tasking`): the task-set review
450
+ * ran in the agent phase, so this land runs with `review` off and posts that
451
+ * prose as the PR comment instead. Ignored when this band's own review runs
452
+ * and approves (its verdict's prose wins). Unset everywhere else.
453
+ */
454
+ approvedReviewProse?: string;
432
455
  /** Integration mode the caller REQUESTED (`propose` default, or `merge`). */
433
456
  mode: IntegrationMode;
434
457
  /**
@@ -681,7 +704,6 @@ export async function performIntegration(
681
704
  const arbiter = input.arbiter;
682
705
  const slug = input.slug;
683
706
  const source = input.source;
684
- const recovering = input.recovering;
685
707
  const note = input.note ?? (() => {});
686
708
  // The work branch: the caller is on it (it carries the namespaced identity).
687
709
  // Prefer the explicit `branch`; else read the branch HEAD is on; only fall
@@ -1119,6 +1141,19 @@ export async function performIntegration(
1119
1141
  transcribeDecisionsIntoDoneRecord({cwd, slug, output: input.body, note});
1120
1142
  }
1121
1143
 
1144
+ // 2c. CLEAR THE TASK'S OWN STALE QUESTION STATE in the SAME done-move commit
1145
+ // (task `a-recovered-task-lands-without-stale-question-state`). A task that
1146
+ // was surfaced to needs-attention and then recovered carries
1147
+ // `needsAnswers: true` + its `work/questions/task-<slug>.md` sidecar on
1148
+ // this branch; the done-move makes both moot, so they leave in the commit
1149
+ // that makes them moot rather than on some later claim's reconcile. A
1150
+ // normal land is untouched. Residue that only ARRIVES with the rebase (the
1151
+ // surface landed on `main` after this branch was cut) is folded in after
1152
+ // the rebase instead ({@link foldQuestionResidueIntoTip}).
1153
+ if (!lifecycle) {
1154
+ clearOwnQuestionResidueOnLand({cwd, slug, note});
1155
+ }
1156
+
1122
1157
  // 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
1123
1158
  // atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
1124
1159
  await gitHard(['add', '-A'], cwd, env);
@@ -1150,6 +1185,29 @@ export async function performIntegration(
1150
1185
  await gitHard(['commit', '-q', '-m', commitMessage], cwd, env);
1151
1186
  note(`Committed: ${commitMessage}`);
1152
1187
 
1188
+ // The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
1189
+ // ONLY region serialised per repo under the `run` concurrency seam: it is the
1190
+ // land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
1191
+ // race (the loser pushing a non-fast-forward `${branch}:main`). Wrapping ONLY
1192
+ // this tail keeps the front-of-band gate (`prepare`+`verify`) and the Gate-2
1193
+ // review agent CONCURRENT across same-repo jobs (run's parallelism); inside the
1194
+ // lock the loser re-fetches + rebases onto the winner's now-advanced main, so
1195
+ // its push is a clean fast-forward (a genuine conflict routes ONE to
1196
+ // needs-attention). Single-job callers pass no lock ⇒ the tail runs directly,
1197
+ // byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
1198
+ // integration stays fully concurrent.
1199
+ const reconcileCtx: RebaseReconcileContext = {
1200
+ cwd,
1201
+ arbiter,
1202
+ slug,
1203
+ branch,
1204
+ lifecycle: lifecycle !== undefined,
1205
+ source,
1206
+ surfaceArbiter: input.surfaceArbiter,
1207
+ commitMessage,
1208
+ env,
1209
+ note,
1210
+ };
1153
1211
  // The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
1154
1212
  // ONLY region serialised per repo under the `run` concurrency seam: it is the
1155
1213
  // land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
@@ -1162,223 +1220,12 @@ export async function performIntegration(
1162
1220
  // byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
1163
1221
  // integration stays fully concurrent.
1164
1222
  const runRebaseToIntegrateTail = async (): Promise<IntegrationCoreResult> => {
1165
- // The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
1166
- // sibling-slug ledger arm and the divergent-done-move recovery), factored so it
1167
- // can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
1168
- // (a sibling advancing main mid-push needs the SAME reconcile, not a bare
1169
- // rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
1170
- // `{route}` when a genuine conflict / invariant violation must stop the tail.
1171
- const rebaseOntoMainWithReconcile = async (): Promise<{
1172
- route?: IntegrationCoreResult;
1173
- }> => {
1174
- // 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
1175
- // latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
1176
- //
1177
- // RECOVERY reconciliation: post the per-item-lock cut-over, a stuck item's
1178
- // body never moved into a `needs-attention/` folder (stuck is the lock
1179
- // `state: stuck`; the body rests in `backlog/`), so a recovery `complete`'s
1180
- // kept branch carries NO historical `in-progress → needs-attention`
1181
- // move-only commit to drop and `<arbiter>/main` holds no surface move to
1182
- // conflict with. The old recovery drop (`rebaseDroppingNeedsAttentionSurface`,
1183
- // drop-bookkeeping-rebase) is deleted; recovery is now the SAME plain replay
1184
- // onto `<arbiter>/main` as any other build (see the plain-rebase note below).
1185
- // Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
1186
- // EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
1187
- // has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
1188
- // be stale, causing a spurious rebase conflict); a regular clone (`do`/
1189
- // `complete`) already has it, where the explicit refspec is harmless (the same
1190
- // refspec `rebaseOntoArbiterMain` used before the convergence).
1191
- await gitHard(
1192
- [
1193
- 'fetch',
1194
- '--quiet',
1195
- arbiter,
1196
- `+refs/heads/main:refs/remotes/${arbiter}/main`,
1197
- ],
1198
- cwd,
1199
- env,
1200
- );
1201
- // ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
1202
- // freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
1203
- // just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
1204
- // the arbiter already holds the slug in >1 status folder with differing content
1205
- // (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
1206
- // — the arbiter holds the slug's source in a DIFFERENT folder than our local
1207
- // done-move removed — which is the case that turns the rebased "move" into a
1208
- // "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
1209
- // done-move).
1210
- // CONTINUE-BUILD EXEMPTION (task
1211
- // `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
1212
- // there was no first-time move on this commit (the slug was already in
1213
- // `done/` on the kept branch + on the arbiter), so the divergent-done-move
1214
- // reconcile reasoning does not apply. Skip the arbiter ledger placement
1215
- // pre-check too: it is structurally an instrument FOR that reconcile, and a
1216
- // continue-build is by construction already in `done/` on both sides.
1217
- if (!lifecycle && source !== 'done') {
1218
- const arbiterPlacement = readArbiterLedgerPlacement(
1219
- cwd,
1220
- arbiter,
1221
- slug,
1222
- env,
1223
- );
1224
- if (arbiterPlacement.error) {
1225
- note(arbiterPlacement.error);
1226
- return {
1227
- route: {
1228
- outcome: 'invariant-violation',
1229
- routedToNeedsAttention: false,
1230
- branch,
1231
- reason: arbiterPlacement.error,
1232
- },
1233
- };
1234
- }
1235
- }
1236
- // PLAIN rebase. After the per-item-lock cut-over (spec
1237
- // `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
1238
- // lands on a work branch: needs-attention is the lock `state: stuck` (not a
1239
- // `git mv` to `needs-attention/`), the body rests in `backlog/` while
1240
- // claimed, and the tasking/advancing markers are gone. So a recovery
1241
- // complete's kept branch carries NO historical route-to-needs-attention
1242
- // move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
1243
- // (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
1244
- // rebases are the same plain replay onto `<arbiter>/main`.
1245
- void recovering;
1246
- void lifecycle;
1247
- // RENAME-DETECTION-OFF (task
1248
- // `disable-rename-detection-on-continue-rebase`): scope
1249
- // `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
1250
- // durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
1251
- // NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
1252
- // rename `work/<from>/ → work/<to>/` (which would spuriously flag every
1253
- // sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
1254
- // location)` and force a FALSE needs-attention). Content-rename detection
1255
- // (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
1256
- // does NOT suppress this directory-rename conflict; only
1257
- // `merge.directoryRenames=false` does. NEVER a persistent `git config`
1258
- // write — the repo's config stays clean so a user's interactive
1259
- // `git rebase` is unaffected. A GENUINE content conflict still surfaces
1260
- // and still routes via `rebaseConflictRoute()` below.
1261
- const rebase = await gitSoft(
1262
- ['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`],
1263
- cwd,
1264
- env,
1265
- );
1266
- if (rebase.status !== 0) {
1267
- // NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
1268
- // LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
1269
- // `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
1270
- // status-folder move on `<arbiter>/main` between our base and this rebase) is
1271
- // a benign ledger-only divergence with NO semantic judgement: the reconcile
1272
- // ABORTS the rebase, then redoes OUR work as one clean commit on top of
1273
- // `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
1274
- // automatically). It is scoped STRICTLY to other slugs' ledger files — a
1275
- // conflict touching ANY code file, or THIS slug's own ledger, returns `false`
1276
- // (and leaves the rebase in progress), so the divergent-done-move recovery /
1277
- // needs-attention route below handles it; it NEVER widens to code. The tasking
1278
- // lifecycle is exempt (its move is not a task done-move).
1279
- const siblingReconciled = lifecycle
1280
- ? false
1281
- : await reconcileSiblingLedgerConflict({
1282
- cwd,
1283
- arbiter,
1284
- slug,
1285
- env,
1286
- note,
1287
- });
1288
- if (siblingReconciled) {
1289
- // The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
1290
- // arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
1291
- // band. THIS slug's own move is untouched.
1292
- note(
1293
- `Reconciled a sibling-slug ledger conflict during the rebase onto ` +
1294
- `${arbiter}/main (took the arbiter's version of the other slugs' ` +
1295
- `work/<status>/<slug>.md ledger files; no code file was touched).`,
1296
- );
1297
- } else if (!lifecycle && source !== 'done') {
1298
- // NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
1299
- // DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
1300
- // our local done-move did not remove — PR #86) is auto-RECONCILABLE without
1301
- // any semantic judgement: redo the done-move arbiter-resolved (remove the
1302
- // arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
1303
- // only do this when the post-abort tree's ONLY divergence is the slug's ledger
1304
- // file; a real code conflict still routes to needs-attention untouched.
1305
- await gitSoft(['rebase', '--abort'], cwd, env);
1306
- const recovered = await reconcileDivergentDoneMove({
1307
- cwd,
1308
- arbiter,
1309
- slug,
1310
- branch,
1311
- localSource: source,
1312
- env,
1313
- note,
1314
- });
1315
- if (recovered) {
1316
- // The branch is now cleanly on top of `<arbiter>/main` with the slug in
1317
- // `done/` ONLY — fall through to integrate (skip the needs-attention
1318
- // route below).
1319
- note(
1320
- `Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
1321
- 'work/done/ ONLY (the divergent source folder was removed; the move ' +
1322
- 'is a move, not a copy).',
1323
- );
1324
- } else {
1325
- return {route: await rebaseConflictRoute()};
1326
- }
1327
- } else {
1328
- await gitSoft(['rebase', '--abort'], cwd, env);
1329
- return {route: await rebaseConflictRoute()};
1330
- }
1331
- }
1332
- // Clean rebase (or a reconciled one): fall through to the gate + integrate.
1333
- return {};
1334
- };
1335
-
1336
1223
  // Run the step-4 rebase ONCE up front (before the slow fresh gate).
1337
- const firstRebase = await rebaseOntoMainWithReconcile();
1224
+ const firstRebase = await rebaseOntoMainWithReconcile(reconcileCtx);
1338
1225
  if (firstRebase.route) {
1339
1226
  return firstRebase.route;
1340
1227
  }
1341
1228
 
1342
- // The rebase-conflict needs-attention route, factored so the divergent-ledger
1343
- // recovery above can fall through to integrate while a genuine code conflict
1344
- // still routes here.
1345
- async function rebaseConflictRoute(): Promise<IntegrationCoreResult> {
1346
- // Then mark the item stuck on its per-item lock with the conflict reason
1347
- // (ADR §12) THROUGH the ledger write seam's needs-attention transition,
1348
- // rather than leaving it dangling. Post lock-cutover this is a lock amend
1349
- // (`state: stuck` + the reason on the lock entry, no `done/ →
1350
- // needs-attention/` folder move); the done-move already committed above
1351
- // stands. No partial state.
1352
- const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
1353
- const routed = await ledgerWrite.applyNeedsAttentionTransition({
1354
- cwd,
1355
- slug,
1356
- reason,
1357
- // Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
1358
- // conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
1359
- // (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
1360
- // no surface, no push, local-only. The push lives in the seam (HEAD's
1361
- // branch is `work/<slug>` here) — no bolted-on push.
1362
- arbiter: input.surfaceArbiter,
1363
- env,
1364
- note,
1365
- });
1366
- return {
1367
- outcome: 'rebase-conflict',
1368
- routedToNeedsAttention: routed.moved,
1369
- branch,
1370
- commitMessage,
1371
- reason: routed.moved
1372
- ? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1373
- `aborted (never auto-resolved) and '${slug}' was marked stuck on ` +
1374
- 'its per-item lock (surfaced by status). Resolve against the latest ' +
1375
- 'main, then `requeue` to release the stuck lock and re-run.'
1376
- : `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1377
- 'aborted (never auto-resolved). Resolve against the latest main, ' +
1378
- 'then re-run complete.',
1379
- };
1380
- }
1381
-
1382
1229
  // 4c. FRESH-WORKTREE GATE (task `gate-on-rebased-tip-fresh-worktree`): when ON,
1383
1230
  // the acceptance gate (`prepare` then `verify`) runs HERE — on the work
1384
1231
  // branch tip the rebase above just produced (the would-be-integrated tip) —
@@ -1510,220 +1357,627 @@ export async function performIntegration(
1510
1357
  }
1511
1358
  }
1512
1359
 
1513
- // 5. Integrate per mode through the ledger write seam's COMPLETE transition
1514
- // (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
1515
- // already brought the branch up to date, so the seam's sole strategy uses
1516
- // `integrate` (not `integrateWithRebase`) and never --forces. Provider
1517
- // selection: an injected `openPr` wins (legacy bridge); otherwise pick by
1518
- // the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
1519
- // `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
1520
- // `gh` degrades to push-only at runtime — never a hard failure (and the
1521
- // start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
1522
- // The seam is storage-agnostic: we hand it the work branch, the integration
1523
- // mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
1524
- // strategy.
1525
- // Provider precedence: an injected fully-formed provider wins (the `run`
1526
- // stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
1527
- // bridge; else select PURELY from the arbiter URL (no override). The orthogonal
1528
- // `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
1529
- // provider, the integrator simply skips `openRequest` when it is set.
1530
- const provider =
1531
- input.providerInstance ??
1532
- (input.openPr
1533
- ? bridgeProvider(input.openPr)
1534
- : selectProvider({
1535
- arbiterUrl: await arbiterUrl(cwd, arbiter, env),
1536
- }));
1537
- // Race-1 (claim-vs-integrate, task
1538
- // `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
1539
- // and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
1540
- // under the SEPARATE claim lock — or a sibling integrate advanced
1541
- // `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
1542
- // carries the sibling-ledger + divergent-done-move reconcile arms — a bare
1543
- // re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
1544
- // retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
1545
- // main: each retry re-rebases to a clean fast-forward. A genuine code conflict
1546
- // on a re-rebase routes to needs-attention via the SAME `route` the up-front
1547
- // rebase uses. A persistent non-fast-forward past the cap also routes (never a
1548
- // silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
1549
- //
1550
- // **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
1551
- // promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
1552
- // counts against a tiny give-up budget — only a GENUINE conflict surfaced by
1553
- // `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
1554
- // `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
1555
- // precondition recheck reused verbatim: if the slug is GONE from its expected
1556
- // source folder on the new `main` (a concurrent legitimate same-item winner
1557
- // already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
1558
- // definitively — never a silent re-push that would clobber the winner. The
1559
- // `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
1560
- // pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
1561
- // modest jitter on the refetch desynchronises a herd so the tail is not
1562
- // reached under sustained parallel load.
1563
- const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
1564
- const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
1565
- let integration!: IntegrateResult;
1566
- for (let mergeAttempt = 0; ; mergeAttempt++) {
1567
- integration = await ledgerWrite.applyCompleteTransition({
1360
+ // 5. The LAND half (`landIntegration`, reached through the `integrationLand`
1361
+ // seam): integrate per mode with the merge-mode CAS loop, then the review
1362
+ // comment. The CI agent phase records this call as its boundary; the apply
1363
+ // phase resumes here (task `ci-split-build-path`).
1364
+ return await integrationLand.land({
1365
+ ...reconcileCtx,
1366
+ mode,
1367
+ noPR: input.noPR,
1368
+ providerInstance: input.providerInstance,
1369
+ openPr: input.openPr,
1370
+ title: prTitle,
1371
+ body: composeProposeBody({
1372
+ slug,
1373
+ body: input.body,
1374
+ header: lifecycle?.proposeHeader,
1375
+ }),
1376
+ reviewProse: approvedVerdict?.review ?? input.approvedReviewProse,
1377
+ mergeRetries: input.mergeRetries,
1378
+ mergeJitterMs: input.mergeJitterMs,
1379
+ });
1380
+ };
1381
+
1382
+ // Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
1383
+ // it directly (an un-contended no-op, single-job behaviour unchanged).
1384
+ return input.integrateLock && input.integrateLockKey !== undefined
1385
+ ? await input.integrateLock(
1386
+ input.integrateLockKey,
1387
+ runRebaseToIntegrateTail,
1388
+ )
1389
+ : await runRebaseToIntegrateTail();
1390
+ }
1391
+
1392
+ /**
1393
+ * What {@link rebaseOntoMainWithReconcile} (and its conflict route) needs: the
1394
+ * work branch in `cwd`, the arbiter, and which ledger reconcile arms apply.
1395
+ */
1396
+ export interface RebaseReconcileContext {
1397
+ /** The checkout / worktree the work branch is checked out in. */
1398
+ cwd: string;
1399
+ /** Name of the arbiter git remote (valid in `cwd`). */
1400
+ arbiter: string;
1401
+ slug: string;
1402
+ /** The work branch (checked out in `cwd`). */
1403
+ branch: string;
1404
+ /**
1405
+ * True for a lifecycle (tasking / intake) transition: the task done-move
1406
+ * reconcile arms are exempt.
1407
+ */
1408
+ lifecycle: boolean;
1409
+ /** The done-move source folder (`done` exempts the ledger reconcile arms). */
1410
+ source: IntegrationCoreInput['source'];
1411
+ /** The arbiter a conflict surfaces on (the autonomous path); unset ⇒ local-only. */
1412
+ surfaceArbiter?: string;
1413
+ /** The completion commit's message (reported on the result). */
1414
+ commitMessage: string;
1415
+ env?: NodeJS.ProcessEnv;
1416
+ note: (message: string) => void;
1417
+ }
1418
+
1419
+ /** The inputs of {@link landIntegration}, the land half of the tail. */
1420
+ export interface IntegrationLandInput extends RebaseReconcileContext {
1421
+ /** The resolved integration mode. */
1422
+ mode: IntegrationMode;
1423
+ /** PR-INTENT: push the branch but open no PR (propose). */
1424
+ noPR?: boolean;
1425
+ /** An injected fully-formed provider (tests / embeddings); else selected from the arbiter URL. */
1426
+ providerInstance?: ReviewProvider;
1427
+ /** The legacy `openPr` bridge. */
1428
+ openPr?: IntegrationCoreInput['openPr'];
1429
+ /** The single-line propose PR title. */
1430
+ title: string;
1431
+ /** The composed propose PR body (header + summary); undefined ⇒ `--fill`. */
1432
+ body?: string;
1433
+ /** The approved Gate-2 review prose, posted as a PR comment after a propose. */
1434
+ reviewProse?: string;
1435
+ /** The merge-mode CAS-loop liveness ceiling ({@link DEFAULT_MERGE_RETRIES}). */
1436
+ mergeRetries?: number;
1437
+ /** The merge-mode refetch jitter ({@link DEFAULT_MERGE_JITTER_MS}). */
1438
+ mergeJitterMs?: number;
1439
+ /**
1440
+ * The tip the acceptance gate ran on, set ONLY by the CI apply phase (the
1441
+ * validated bundle tip, `ci-phase-build.ts`). When set, a merge-mode land
1442
+ * whose landed tree differs from this tip's tree reports "landed without
1443
+ * re-gate after N lost races" in the run output and in a
1444
+ * {@link LANDED_WITHOUT_REGATE_TRAILER} trailer on the landed commit (ADR
1445
+ * `ci-agent-job-holds-no-write-token` decision 2). Unset (every non-phase
1446
+ * land: `do`, `complete`, `run`) ⇒ no report and no rewrite, today's
1447
+ * behaviour unchanged.
1448
+ */
1449
+ gatedTip?: string;
1450
+ }
1451
+
1452
+ /**
1453
+ * The seam `performIntegration` reaches the land half through: a process-wide
1454
+ * object (like `ledgerWrite`) so the CI agent phase can RECORD the land as the
1455
+ * boundary (`phase-recorder.ts`) instead of performing it, and the apply phase
1456
+ * can resume at it (`ci-phase-build.ts`).
1457
+ */
1458
+ export interface IntegrationLandSeam {
1459
+ land(input: IntegrationLandInput): Promise<IntegrationCoreResult>;
1460
+ }
1461
+
1462
+ /** The process-wide {@link IntegrationLandSeam} (replaced only by the phase recorder). */
1463
+ export const integrationLand: IntegrationLandSeam = {
1464
+ land: (input) => landIntegration(input),
1465
+ };
1466
+
1467
+ /**
1468
+ * The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
1469
+ * sibling-slug ledger arm and the divergent-done-move recovery), factored so it
1470
+ * can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
1471
+ * (a sibling advancing main mid-push needs the SAME reconcile, not a bare
1472
+ * rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
1473
+ * `{route}` when a genuine conflict / invariant violation must stop the tail.
1474
+ * Module-level (not a closure) so BOTH halves of the rebase-to-integrate tail
1475
+ * reach it: the agent half runs it once before the fresh gate, and the land
1476
+ * half ({@link landIntegration}) re-runs it in the merge-mode CAS loop.
1477
+ */
1478
+ async function rebaseOntoMainWithReconcile(
1479
+ ctx: RebaseReconcileContext,
1480
+ ): Promise<{route?: IntegrationCoreResult}> {
1481
+ const {cwd, arbiter, slug, branch, lifecycle, source, env} = ctx;
1482
+ const note = ctx.note;
1483
+ // 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
1484
+ // latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
1485
+ //
1486
+ // RECOVERY reconciliation: post the per-item-lock cut-over, a stuck item's
1487
+ // body never moved into a `needs-attention/` folder (stuck is the lock
1488
+ // `state: stuck`; the body rests in `backlog/`), so a recovery `complete`'s
1489
+ // kept branch carries NO historical `in-progress → needs-attention`
1490
+ // move-only commit to drop and `<arbiter>/main` holds no surface move to
1491
+ // conflict with. The old recovery drop (`rebaseDroppingNeedsAttentionSurface`,
1492
+ // drop-bookkeeping-rebase) is deleted; recovery is now the SAME plain replay
1493
+ // onto `<arbiter>/main` as any other build (see the plain-rebase note below).
1494
+ // Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
1495
+ // EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
1496
+ // has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
1497
+ // be stale, causing a spurious rebase conflict); a regular clone (`do`/
1498
+ // `complete`) already has it, where the explicit refspec is harmless (the same
1499
+ // refspec `rebaseOntoArbiterMain` used before the convergence).
1500
+ await gitHard(
1501
+ [
1502
+ 'fetch',
1503
+ '--quiet',
1504
+ arbiter,
1505
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
1506
+ ],
1507
+ cwd,
1508
+ env,
1509
+ );
1510
+ // ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
1511
+ // freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
1512
+ // just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
1513
+ // the arbiter already holds the slug in >1 status folder with differing content
1514
+ // (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
1515
+ // — the arbiter holds the slug's source in a DIFFERENT folder than our local
1516
+ // done-move removed — which is the case that turns the rebased "move" into a
1517
+ // "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
1518
+ // done-move).
1519
+ // CONTINUE-BUILD EXEMPTION (task
1520
+ // `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
1521
+ // there was no first-time move on this commit (the slug was already in
1522
+ // `done/` on the kept branch + on the arbiter), so the divergent-done-move
1523
+ // reconcile reasoning does not apply. Skip the arbiter ledger placement
1524
+ // pre-check too: it is structurally an instrument FOR that reconcile, and a
1525
+ // continue-build is by construction already in `done/` on both sides.
1526
+ if (!lifecycle && source !== 'done') {
1527
+ const arbiterPlacement = readArbiterLedgerPlacement(
1528
+ cwd,
1529
+ arbiter,
1530
+ slug,
1531
+ env,
1532
+ );
1533
+ if (arbiterPlacement.error) {
1534
+ note(arbiterPlacement.error);
1535
+ return {
1536
+ route: {
1537
+ outcome: 'invariant-violation',
1538
+ routedToNeedsAttention: false,
1539
+ branch,
1540
+ reason: arbiterPlacement.error,
1541
+ },
1542
+ };
1543
+ }
1544
+ }
1545
+ // PLAIN rebase. After the per-item-lock cut-over (spec
1546
+ // `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
1547
+ // lands on a work branch: needs-attention is the lock `state: stuck` (not a
1548
+ // `git mv` to `needs-attention/`), the body rests in `backlog/` while
1549
+ // claimed, and the tasking/advancing markers are gone. So a recovery
1550
+ // complete's kept branch carries NO historical route-to-needs-attention
1551
+ // move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
1552
+ // (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
1553
+ // rebases are the same plain replay onto `<arbiter>/main`.
1554
+ // RENAME-DETECTION-OFF (task
1555
+ // `disable-rename-detection-on-continue-rebase`): scope
1556
+ // `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
1557
+ // durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
1558
+ // NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
1559
+ // rename `work/<from>/ → work/<to>/` (which would spuriously flag every
1560
+ // sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
1561
+ // location)` and force a FALSE needs-attention). Content-rename detection
1562
+ // (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
1563
+ // does NOT suppress this directory-rename conflict; only
1564
+ // `merge.directoryRenames=false` does. NEVER a persistent `git config`
1565
+ // write — the repo's config stays clean so a user's interactive
1566
+ // `git rebase` is unaffected. A GENUINE content conflict still surfaces
1567
+ // and still routes via `rebaseConflictRoute(ctx)` below.
1568
+ const rebase = await gitSoft(
1569
+ ['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`],
1570
+ cwd,
1571
+ env,
1572
+ );
1573
+ if (rebase.status !== 0) {
1574
+ // NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
1575
+ // LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
1576
+ // `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
1577
+ // status-folder move on `<arbiter>/main` between our base and this rebase) is
1578
+ // a benign ledger-only divergence with NO semantic judgement: the reconcile
1579
+ // ABORTS the rebase, then redoes OUR work as one clean commit on top of
1580
+ // `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
1581
+ // automatically). It is scoped STRICTLY to other slugs' ledger files — a
1582
+ // conflict touching ANY code file, or THIS slug's own ledger, returns `false`
1583
+ // (and leaves the rebase in progress), so the divergent-done-move recovery /
1584
+ // needs-attention route below handles it; it NEVER widens to code. The tasking
1585
+ // lifecycle is exempt (its move is not a task done-move).
1586
+ const siblingReconciled = lifecycle
1587
+ ? false
1588
+ : await reconcileSiblingLedgerConflict({
1589
+ cwd,
1590
+ arbiter,
1591
+ slug,
1592
+ env,
1593
+ note,
1594
+ });
1595
+ if (siblingReconciled) {
1596
+ // The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
1597
+ // arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
1598
+ // band. THIS slug's own move is untouched.
1599
+ note(
1600
+ `Reconciled a sibling-slug ledger conflict during the rebase onto ` +
1601
+ `${arbiter}/main (took the arbiter's version of the other slugs' ` +
1602
+ `work/<status>/<slug>.md ledger files; no code file was touched).`,
1603
+ );
1604
+ } else if (!lifecycle && source !== 'done') {
1605
+ // NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
1606
+ // DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
1607
+ // our local done-move did not remove — PR #86) is auto-RECONCILABLE without
1608
+ // any semantic judgement: redo the done-move arbiter-resolved (remove the
1609
+ // arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
1610
+ // only do this when the post-abort tree's ONLY divergence is the slug's ledger
1611
+ // file; a real code conflict still routes to needs-attention untouched.
1612
+ await gitSoft(['rebase', '--abort'], cwd, env);
1613
+ const recovered = await reconcileDivergentDoneMove({
1614
+ cwd,
1568
1615
  arbiter,
1616
+ slug,
1569
1617
  branch,
1570
- mode,
1571
- provider,
1572
- // PR-INTENT: when set (propose mode), push the branch but skip the PR.
1573
- noPR: input.noPR,
1574
- // Half A: an explicit single-line PR title (propose mode), so `gh` no longer
1575
- // derives a run-on title from the commit subject via `--fill`.
1576
- title: prTitle,
1577
- // Half B: the propose-mode PR body — the agent's summary under a deterministic
1578
- // runner header (task pointer). Undefined when no body was supplied (the
1579
- // header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
1580
- // regression). Ignored in merge mode by the provider/integrator.
1581
- body: composeProposeBody({slug, body: input.body}),
1582
- // Part (b) of the merged-branch hygiene task: when WE perform the merge
1583
- // (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
1584
- // INLINE right after the merge lands — the commits are now on `main`, so the
1585
- // head is provably merged and safe to delete (ancestor-guarded inside the
1586
- // integrator). Idempotent no-op when no remote head exists (the plain
1587
- // `${branch}:main` push opened none); ignored in `propose` mode (its branch is
1588
- // the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
1589
- deleteMergedHead: true,
1590
- cwd,
1618
+ localSource: source,
1591
1619
  env,
1620
+ note,
1592
1621
  });
1593
- // Only the merge push can be non-fast-forward (propose pushes its own ref).
1594
- if (integration.mergeNonFastForward !== true) {
1595
- break;
1596
- }
1597
- if (mergeAttempt >= maxMergeRetries) {
1598
- // LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
1599
- // now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
1600
- // false-contention budget). Route to needs-attention rather than looping
1601
- // forever or force-pushing main. A RARE outcome under realistic load thanks
1602
- // to the jitter below + the rebase-until-real-conflict semantics; tests
1603
- // reach it deterministically by injecting a small `mergeRetries`.
1604
- return await mergeNonFastForwardRoute(
1605
- `integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1606
- `non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
1607
- `times); gave up cleanly without --force`,
1622
+ if (recovered) {
1623
+ // The branch is now cleanly on top of `<arbiter>/main` with the slug in
1624
+ // `done/` ONLY — fall through to integrate (skip the needs-attention
1625
+ // route below).
1626
+ note(
1627
+ `Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
1628
+ 'work/done/ ONLY (the divergent source folder was removed; the move ' +
1629
+ 'is a move, not a copy).',
1608
1630
  );
1631
+ } else {
1632
+ return {route: await rebaseConflictRoute(ctx)};
1609
1633
  }
1610
- // Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
1611
- // maximises mutual rejection under sustained parallel load (thundering herd).
1612
- // A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
1613
- // Skipped when `mergeJitterMs === 0` (the test seam).
1614
- if (mergeJitterMs > 0) {
1615
- await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
1616
- }
1617
- // A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
1618
- // before retrying the push. A genuine conflict on the re-rebase routes via
1619
- // `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
1620
- // one-slug placement recheck IS the genuine-conflict terminator — see the
1621
- // `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
1622
- // (no route) loops without counting against a small budget.
1623
- const reRebase = await rebaseOntoMainWithReconcile();
1624
- if (reRebase.route) {
1625
- return reRebase.route;
1626
- }
1634
+ } else {
1635
+ await gitSoft(['rebase', '--abort'], cwd, env);
1636
+ return {route: await rebaseConflictRoute(ctx)};
1627
1637
  }
1638
+ }
1639
+ // Clean rebase (or a reconciled one): fold any question residue the rebase
1640
+ // brought in into the tip, then fall through to the gate + integrate.
1641
+ if (!lifecycle) {
1642
+ await foldQuestionResidueIntoTip(ctx);
1643
+ }
1644
+ return {};
1645
+ }
1628
1646
 
1629
- // The Race-1 needs-attention route for a merge that could not land (a genuine
1630
- // re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
1631
- // covers the cap-exhausted persistent-contention case). Mirrors
1632
- // `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
1633
- // slug sits in work/done/ and the seam bounces it from there.
1634
- async function mergeNonFastForwardRoute(
1635
- reason: string,
1636
- ): Promise<IntegrationCoreResult> {
1637
- const routed = await ledgerWrite.applyNeedsAttentionTransition({
1647
+ /**
1648
+ * After a rebase onto `<arbiter>/main`, CLEAR the landing task's own stale
1649
+ * question state that the rebase BROUGHT IN, and AMEND it into the tip commit
1650
+ * (the completion commit), so the land still carries it in the done-move commit
1651
+ * itself (task `a-recovered-task-lands-without-stale-question-state`).
1652
+ *
1653
+ * This is the half step 2c cannot see: a kept branch cut BEFORE the item was
1654
+ * surfaced (a re-dispatch continuing from the kept branch, a stranded-branch
1655
+ * finish) replays its `git mv ... → tasks/done/` over a `main` whose body now
1656
+ * carries `needsAnswers: true` and a sidecar, and git carries the flag into
1657
+ * `tasks/done/` and keeps the sidecar. Runs on every rebase of the land: the
1658
+ * build path's first rebase, the merge CAS loop's re-rebase (so the CI apply
1659
+ * phase is covered), and the stranded-recovery tail.
1660
+ *
1661
+ * A no-op (no amend) when there is nothing to clear, and when the tip is already
1662
+ * on `main` (never rewrite a commit that is not ours). Best-effort: a fault is
1663
+ * reported and the land proceeds; the claim-time reconcile remains the backstop.
1664
+ */
1665
+ async function foldQuestionResidueIntoTip(params: {
1666
+ cwd: string;
1667
+ arbiter: string;
1668
+ slug: string;
1669
+ env?: NodeJS.ProcessEnv;
1670
+ note: (message: string) => void;
1671
+ }): Promise<void> {
1672
+ const {cwd, arbiter, slug, env, note} = params;
1673
+ try {
1674
+ if (isAncestor(cwd, 'HEAD', `${arbiter}/main`, env)) {
1675
+ return; // no commit of ours on top of main to fold into.
1676
+ }
1677
+ const cleared = clearOwnQuestionResidueOnLand({cwd, slug, note});
1678
+ if (cleared.changed.length === 0) {
1679
+ return;
1680
+ }
1681
+ await gitHard(['add', '-A', '--', ...cleared.changed], cwd, env);
1682
+ await gitHard(['commit', '-q', '--amend', '--no-edit'], cwd, env);
1683
+ } catch (err) {
1684
+ note(
1685
+ `Could not fold the stale question state of '${slug}' into its land ` +
1686
+ `(${err instanceof Error ? err.message : String(err)}); the next ` +
1687
+ 'claim reconcile will drain it.',
1688
+ );
1689
+ }
1690
+ }
1691
+
1692
+ // The rebase-conflict needs-attention route, factored so the divergent-ledger
1693
+ // recovery above can fall through to integrate while a genuine code conflict
1694
+ // still routes here.
1695
+ async function rebaseConflictRoute(
1696
+ ctx: RebaseReconcileContext,
1697
+ ): Promise<IntegrationCoreResult> {
1698
+ const {cwd, arbiter, slug, branch, commitMessage, env, note} = ctx;
1699
+ // Then mark the item stuck on its per-item lock with the conflict reason
1700
+ // (ADR §12) THROUGH the ledger write seam's needs-attention transition,
1701
+ // rather than leaving it dangling. Post lock-cutover this is a lock amend
1702
+ // (`state: stuck` + the reason on the lock entry, no `done/ →
1703
+ // needs-attention/` folder move); the done-move already committed above
1704
+ // stands. No partial state.
1705
+ const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
1706
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1707
+ cwd,
1708
+ slug,
1709
+ reason,
1710
+ // Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
1711
+ // conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
1712
+ // (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
1713
+ // no surface, no push, local-only. The push lives in the seam (HEAD's
1714
+ // branch is `work/<slug>` here) — no bolted-on push.
1715
+ arbiter: ctx.surfaceArbiter,
1716
+ env,
1717
+ note,
1718
+ });
1719
+ return {
1720
+ outcome: 'rebase-conflict',
1721
+ routedToNeedsAttention: routed.moved,
1722
+ branch,
1723
+ commitMessage,
1724
+ reason: routed.moved
1725
+ ? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1726
+ `aborted (never auto-resolved) and '${slug}' was marked stuck on ` +
1727
+ 'its per-item lock (surfaced by status). Resolve against the latest ' +
1728
+ 'main, then `requeue` to release the stuck lock and re-run.'
1729
+ : `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1730
+ 'aborted (never auto-resolved). Resolve against the latest main, ' +
1731
+ 'then re-run complete.',
1732
+ };
1733
+ }
1734
+
1735
+ /**
1736
+ * **The LAND half of the rebase-to-integrate tail** (task `ci-split-build-path`,
1737
+ * spec `ci-agent-job-without-write-token` §8): integrate per mode through
1738
+ * `ledgerWrite.applyCompleteTransition`, with the merge-mode compare-and-swap
1739
+ * loop (a non-fast-forward push re-runs {@link rebaseOntoMainWithReconcile} and
1740
+ * retries, never `--force`), then post the approved Gate-2 review prose on the
1741
+ * opened PR. It launches no agent and runs no `prepare` / `verify`: the fresh
1742
+ * gate ran once, before it, in the other half. `performIntegration` reaches it
1743
+ * through the {@link integrationLand} seam, so the CI agent phase RECORDS this
1744
+ * call (the first write of the success path) and the apply phase resumes here
1745
+ * with trusted inputs and the validated work-branch tip.
1746
+ */
1747
+ export async function landIntegration(
1748
+ input: IntegrationLandInput,
1749
+ ): Promise<IntegrationCoreResult> {
1750
+ const {cwd, arbiter, slug, branch, mode, commitMessage, env} = input;
1751
+ const note = input.note;
1752
+ // 5. Integrate per mode through the ledger write seam's COMPLETE transition
1753
+ // (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
1754
+ // already brought the branch up to date, so the seam's sole strategy uses
1755
+ // `integrate` (not `integrateWithRebase`) and never --forces. Provider
1756
+ // selection: an injected `openPr` wins (legacy bridge); otherwise pick by
1757
+ // the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
1758
+ // `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
1759
+ // `gh` degrades to push-only at runtime — never a hard failure (and the
1760
+ // start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
1761
+ // The seam is storage-agnostic: we hand it the work branch, the integration
1762
+ // mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
1763
+ // strategy.
1764
+ // Provider precedence: an injected fully-formed provider wins (the `run`
1765
+ // stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
1766
+ // bridge; else select PURELY from the arbiter URL (no override). The orthogonal
1767
+ // `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
1768
+ // provider, the integrator simply skips `openRequest` when it is set.
1769
+ const provider =
1770
+ input.providerInstance ??
1771
+ (input.openPr
1772
+ ? bridgeProvider(input.openPr)
1773
+ : selectProvider({
1774
+ arbiterUrl: await arbiterUrl(cwd, arbiter, env),
1775
+ }));
1776
+ // Race-1 (claim-vs-integrate, task
1777
+ // `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
1778
+ // and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
1779
+ // under the SEPARATE claim lock — or a sibling integrate advanced
1780
+ // `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
1781
+ // carries the sibling-ledger + divergent-done-move reconcile arms — a bare
1782
+ // re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
1783
+ // retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
1784
+ // main: each retry re-rebases to a clean fast-forward. A genuine code conflict
1785
+ // on a re-rebase routes to needs-attention via the SAME `route` the up-front
1786
+ // rebase uses. A persistent non-fast-forward past the cap also routes (never a
1787
+ // silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
1788
+ //
1789
+ // **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
1790
+ // promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
1791
+ // counts against a tiny give-up budget — only a GENUINE conflict surfaced by
1792
+ // `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
1793
+ // `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
1794
+ // precondition recheck reused verbatim: if the slug is GONE from its expected
1795
+ // source folder on the new `main` (a concurrent legitimate same-item winner
1796
+ // already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
1797
+ // definitively — never a silent re-push that would clobber the winner. The
1798
+ // `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
1799
+ // pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
1800
+ // modest jitter on the refetch desynchronises a herd so the tail is not
1801
+ // reached under sustained parallel load.
1802
+ const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
1803
+ const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
1804
+ // The landed-vs-gated report (ADR `ci-agent-job-holds-no-write-token`
1805
+ // decision 2, task `ci-split-landed-vs-gated-report`), scoped to the CI apply
1806
+ // phase: only it sets `gatedTip` (the validated bundle tip the agent job
1807
+ // gated). The CAS loop below never re-gates, so a re-rebase after a lost race
1808
+ // can land a tree the gate never saw: {@link stampLandedWithoutRegate}
1809
+ // records that on the landed commit. Without `gatedTip` (every non-phase
1810
+ // land) the loop is exactly today's: no tree compare, no message rewrite.
1811
+ const gatedTree =
1812
+ input.gatedTip === undefined
1813
+ ? undefined
1814
+ : await treeOf(cwd, input.gatedTip, env);
1815
+ let regateReport: string | undefined;
1816
+ let integration!: IntegrateResult;
1817
+ for (let mergeAttempt = 0; ; mergeAttempt++) {
1818
+ integration = await ledgerWrite.applyCompleteTransition({
1819
+ arbiter,
1820
+ branch,
1821
+ mode,
1822
+ provider,
1823
+ // PR-INTENT: when set (propose mode), push the branch but skip the PR.
1824
+ noPR: input.noPR,
1825
+ // Half A: an explicit single-line PR title (propose mode), so `gh` no longer
1826
+ // derives a run-on title from the commit subject via `--fill`.
1827
+ title: input.title,
1828
+ // Half B: the propose-mode PR body — the agent's summary under a deterministic
1829
+ // runner header (task pointer). Undefined when no body was supplied (the
1830
+ // header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
1831
+ // regression). Ignored in merge mode by the provider/integrator.
1832
+ body: input.body,
1833
+ // Part (b) of the merged-branch hygiene task: when WE perform the merge
1834
+ // (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
1835
+ // INLINE right after the merge lands — the commits are now on `main`, so the
1836
+ // head is provably merged and safe to delete (ancestor-guarded inside the
1837
+ // integrator). Idempotent no-op when no remote head exists (the plain
1838
+ // `${branch}:main` push opened none); ignored in `propose` mode (its branch is
1839
+ // the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
1840
+ deleteMergedHead: true,
1841
+ cwd,
1842
+ env,
1843
+ });
1844
+ // Only the merge push can be non-fast-forward (propose pushes its own ref).
1845
+ if (integration.mergeNonFastForward !== true) {
1846
+ break;
1847
+ }
1848
+ if (mergeAttempt >= maxMergeRetries) {
1849
+ // LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
1850
+ // now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
1851
+ // false-contention budget). Route to needs-attention rather than looping
1852
+ // forever or force-pushing main. A RARE outcome under realistic load thanks
1853
+ // to the jitter below + the rebase-until-real-conflict semantics; tests
1854
+ // reach it deterministically by injecting a small `mergeRetries`.
1855
+ return await mergeNonFastForwardRoute(
1856
+ `integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1857
+ `non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
1858
+ `times); gave up cleanly without --force`,
1859
+ );
1860
+ }
1861
+ // Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
1862
+ // maximises mutual rejection under sustained parallel load (thundering herd).
1863
+ // A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
1864
+ // Skipped when `mergeJitterMs === 0` (the test seam).
1865
+ if (mergeJitterMs > 0) {
1866
+ await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
1867
+ }
1868
+ // A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
1869
+ // before retrying the push. A genuine conflict on the re-rebase routes via
1870
+ // `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
1871
+ // one-slug placement recheck IS the genuine-conflict terminator — see the
1872
+ // `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
1873
+ // (no route) loops without counting against a small budget.
1874
+ const reRebase = await rebaseOntoMainWithReconcile(input);
1875
+ if (reRebase.route) {
1876
+ return reRebase.route;
1877
+ }
1878
+ if (gatedTree !== undefined) {
1879
+ regateReport = await stampLandedWithoutRegate({
1638
1880
  cwd,
1639
- slug,
1640
- reason,
1641
- arbiter: input.surfaceArbiter,
1881
+ branch,
1882
+ gatedTree,
1883
+ lostRaces: mergeAttempt + 1,
1642
1884
  env,
1643
- note,
1644
1885
  });
1645
- return {
1646
- outcome: 'rebase-conflict',
1647
- routedToNeedsAttention: routed.moved,
1648
- branch,
1649
- commitMessage,
1650
- reason: routed.moved
1651
- ? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1652
- `non-fast-forward push (a sibling advanced main); '${slug}' was ` +
1653
- `marked stuck on its per-item lock (surfaced by status). Resolve ` +
1654
- `against the latest main, then \`requeue\` to release the stuck ` +
1655
- `lock and re-run.`
1656
- : `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1657
- `non-fast-forward push (a sibling advanced main). Resolve against the ` +
1658
- `latest main, then re-run complete.`,
1659
- };
1660
- }
1661
-
1662
- // 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
1663
- // refining `review-gate-pr-comment`): AFTER the propose integrate, where the
1664
- // approved verdict (with its deliberately-authored `review` prose), the
1665
- // resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
1666
- // scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
1667
- // (the audit trail; decided 2026-06-06). The `review` field is a first-class
1668
- // AUTHORED review (the prompt requires it), NOT the residue around the JSON
1669
- // — posting the residue was the bug
1670
- // (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
1671
- // reuses the SAME `provider` the integrate used (the core never imports `gh`).
1672
- // The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
1673
- // — by here the verdict has ALREADY routed (block never reaches this point; it
1674
- // routed to needs-attention above) and the integrate has ALREADY happened.
1675
- // The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
1676
- // (the normal path — post on it directly); else, when a PR WAS opened but its
1677
- // url was unparseable (`integration.requestOpened` true, `url` undefined — the
1678
- // `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
1679
- // BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
1680
- // the provider resolves the branch's open PR and comments on it, instead of
1681
- // silently dropping a review on a PR that genuinely exists. Only when NO PR was
1682
- // opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
1683
- // false) is it the honest clean no-op — and the branch-resolved fallback's own
1684
- // "no PR resolvable" path is a clean no-op too (it tries first). Either way
1685
- // `postPRComment*` never throws; the review stays in the run output. Because
1686
- // this lives in the shared core, BOTH `do`/`complete` AND `run` post the
1687
- // comment — no per-caller wiring.
1688
- if (approvedVerdict?.review !== undefined) {
1689
- if (integration.url !== undefined) {
1690
- const posted = provider.postPRComment({
1691
- cwd,
1692
- url: integration.url,
1693
- body: approvedVerdict.review,
1694
- env,
1695
- });
1696
- note(posted.instruction);
1697
- } else if (integration.requestOpened) {
1698
- // A PR opened but its url was unparseable — resolve it from the branch
1699
- // rather than dropping the review (the audit-trail fallback).
1700
- const posted = provider.postPRCommentOnBranch({
1701
- cwd,
1702
- branch,
1703
- body: approvedVerdict.review,
1704
- env,
1705
- });
1706
- note(posted.instruction);
1707
- }
1708
1886
  }
1709
-
1887
+ }
1888
+ if (regateReport !== undefined && integration.mergedToMain === true) {
1889
+ note(`${branch} ${regateReport}.`);
1890
+ }
1891
+ noteRequestNotOpened(integration, branch, note);
1892
+
1893
+ // The Race-1 needs-attention route for a merge that could not land (a genuine
1894
+ // re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
1895
+ // covers the cap-exhausted persistent-contention case). Mirrors
1896
+ // `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
1897
+ // slug sits in work/done/ and the seam bounces it from there.
1898
+ async function mergeNonFastForwardRoute(
1899
+ reason: string,
1900
+ ): Promise<IntegrationCoreResult> {
1901
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1902
+ cwd,
1903
+ slug,
1904
+ reason,
1905
+ arbiter: input.surfaceArbiter,
1906
+ env,
1907
+ note,
1908
+ });
1710
1909
  return {
1711
- outcome: 'completed',
1712
- routedToNeedsAttention: false,
1910
+ outcome: 'rebase-conflict',
1911
+ routedToNeedsAttention: routed.moved,
1713
1912
  branch,
1714
1913
  commitMessage,
1715
- integration,
1914
+ reason: routed.moved
1915
+ ? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1916
+ `non-fast-forward push (a sibling advanced main); '${slug}' was ` +
1917
+ `marked stuck on its per-item lock (surfaced by status). Resolve ` +
1918
+ `against the latest main, then \`requeue\` to release the stuck ` +
1919
+ `lock and re-run.`
1920
+ : `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1921
+ `non-fast-forward push (a sibling advanced main). Resolve against the ` +
1922
+ `latest main, then re-run complete.`,
1716
1923
  };
1717
- };
1924
+ }
1718
1925
 
1719
- // Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
1720
- // it directly (an un-contended no-op, single-job behaviour unchanged).
1721
- return input.integrateLock && input.integrateLockKey !== undefined
1722
- ? await input.integrateLock(
1723
- input.integrateLockKey,
1724
- runRebaseToIntegrateTail,
1725
- )
1726
- : await runRebaseToIntegrateTail();
1926
+ // 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
1927
+ // refining `review-gate-pr-comment`): AFTER the propose integrate, where the
1928
+ // approved verdict (with its deliberately-authored `review` prose), the
1929
+ // resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
1930
+ // scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
1931
+ // (the audit trail; decided 2026-06-06). The `review` field is a first-class
1932
+ // AUTHORED review (the prompt requires it), NOT the residue around the JSON
1933
+ // — posting the residue was the bug
1934
+ // (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
1935
+ // reuses the SAME `provider` the integrate used (the core never imports `gh`).
1936
+ // The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
1937
+ // — by here the verdict has ALREADY routed (block never reaches this point; it
1938
+ // routed to needs-attention above) and the integrate has ALREADY happened.
1939
+ // The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
1940
+ // (the normal path — post on it directly); else, when a PR WAS opened but its
1941
+ // url was unparseable (`integration.requestOpened` true, `url` undefined — the
1942
+ // `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
1943
+ // BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
1944
+ // the provider resolves the branch's open PR and comments on it, instead of
1945
+ // silently dropping a review on a PR that genuinely exists. Only when NO PR was
1946
+ // opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
1947
+ // false) is it the honest clean no-op — and the branch-resolved fallback's own
1948
+ // "no PR resolvable" path is a clean no-op too (it tries first). Either way
1949
+ // `postPRComment*` never throws; the review stays in the run output. Because
1950
+ // this lives in the shared core, BOTH `do`/`complete` AND `run` post the
1951
+ // comment — no per-caller wiring.
1952
+ if (input.reviewProse !== undefined) {
1953
+ if (integration.url !== undefined) {
1954
+ const posted = provider.postPRComment({
1955
+ cwd,
1956
+ url: integration.url,
1957
+ body: input.reviewProse,
1958
+ env,
1959
+ });
1960
+ note(posted.instruction);
1961
+ } else if (integration.requestOpened) {
1962
+ // A PR opened but its url was unparseable — resolve it from the branch
1963
+ // rather than dropping the review (the audit-trail fallback).
1964
+ const posted = provider.postPRCommentOnBranch({
1965
+ cwd,
1966
+ branch,
1967
+ body: input.reviewProse,
1968
+ env,
1969
+ });
1970
+ note(posted.instruction);
1971
+ }
1972
+ }
1973
+
1974
+ return {
1975
+ outcome: 'completed',
1976
+ routedToNeedsAttention: false,
1977
+ branch,
1978
+ commitMessage,
1979
+ integration,
1980
+ };
1727
1981
  }
1728
1982
 
1729
1983
  /**
@@ -1944,7 +2198,10 @@ async function recoverAlreadyCommitted(params: {
1944
2198
  for (;;) {
1945
2199
  const rebase = await gitSoft(rebaseArgs(), cwd, env);
1946
2200
  if (rebase.status === 0) {
1947
- break; // clean rebase ⇒ fall through to integrate
2201
+ // Clean rebase ⇒ fold in any question residue it brought (the kept
2202
+ // branch predates a surface on `main`), then fall through to integrate.
2203
+ await foldQuestionResidueIntoTip({cwd, arbiter, slug, env, note});
2204
+ break;
1948
2205
  }
1949
2206
  // ALWAYS abort on conflict — never leave mid-rebase between attempts.
1950
2207
  await gitSoft(['rebase', '--abort'], cwd, env);
@@ -2134,6 +2391,7 @@ async function recoverAlreadyCommitted(params: {
2134
2391
  `(absorbed a moving ${arbiter}/main across ${attempt} re-fetch+re-` +
2135
2392
  `rebase attempt${attempt === 1 ? '' : 's'}).`,
2136
2393
  );
2394
+ noteRequestNotOpened(integration, branch, note);
2137
2395
  // Post the Gate-2 review as a PR comment (task `committed-recovery-always-
2138
2396
  // reviews`): the SAME post-integrate audit trail the build path keeps — the
2139
2397
  // approved verdict's authored `review` prose, on the opened PR. Advisory only
@@ -2168,6 +2426,26 @@ async function recoverAlreadyCommitted(params: {
2168
2426
  };
2169
2427
  }
2170
2428
 
2429
+ /**
2430
+ * Say so when a propose land opened NO review request (task
2431
+ * `intake-reports-the-pr-it-actually-opened`): the branch is pushed, but the
2432
+ * provider could not (or was told not to) open a PR, so the log prints the
2433
+ * provider's instruction (the real `gh` error + the manual `gh pr create`
2434
+ * command) instead of leaving it unread. Every land (build, tasking, intake,
2435
+ * recovery) passes through here, so no caller has to remember to print it. A
2436
+ * merge land, an opened request and an already-landed no-op print nothing.
2437
+ */
2438
+ function noteRequestNotOpened(
2439
+ integration: IntegrateResult,
2440
+ branch: string,
2441
+ note: (message: string) => void,
2442
+ ): void {
2443
+ const reason = proposeRequestNotOpened(integration);
2444
+ if (reason !== undefined) {
2445
+ note(`No PR was opened for ${branch}: ${reason}`);
2446
+ }
2447
+ }
2448
+
2171
2449
  /**
2172
2450
  * Resolve the work branch the integration runs on: the branch HEAD is currently
2173
2451
  * on (the caller is ALWAYS on the work branch — the agent built there / the
@@ -2377,20 +2655,34 @@ export function synthesiseProposeTitle(input: {
2377
2655
  * header that points a reviewer back to the task file. Returns `undefined` when
2378
2656
  * no body was supplied — so the provider degrades to today's `gh ... --fill` (no
2379
2657
  * regression); the header is ONLY scaffolded when there IS prose to carry.
2658
+ * `header` overrides the default task-build pointer (`Task: work/tasks/done/<slug>.md`)
2659
+ * for a non-task lifecycle (tasking, intake), whose item rests elsewhere.
2380
2660
  * Exported for unit tests of the header + pointer.
2381
2661
  */
2382
2662
  export function composeProposeBody(input: {
2383
2663
  slug: string;
2384
2664
  body?: string;
2665
+ header?: string;
2385
2666
  }): string | undefined {
2386
2667
  const prose = input.body?.trim();
2387
2668
  if (!prose) {
2388
2669
  return undefined;
2389
2670
  }
2390
- const header = `Task: \`${workItemRel('done', `${input.slug}.md`)}\``;
2671
+ const header =
2672
+ input.header ??
2673
+ proposePointer('Task', workItemRel('done', `${input.slug}.md`));
2391
2674
  return `${header}\n\n${prose}`;
2392
2675
  }
2393
2676
 
2677
+ /**
2678
+ * The one-line reviewer pointer a propose PR body opens with: `<label>: `<path>``,
2679
+ * where `path` is the repo-relative file the PR carries (task
2680
+ * `propose-pr-bodies-point-at-the-real-item`).
2681
+ */
2682
+ export function proposePointer(label: 'Task' | 'Spec', path: string): string {
2683
+ return `${label}: \`${path}\``;
2684
+ }
2685
+
2394
2686
  /**
2395
2687
  * On a review APPROVE that carries ≥1 NON-BLOCKING finding, write ONE per-run
2396
2688
  * observation `work/notes/observations/review-nits-<slug>-<YYYY-MM-DD>.md` capturing all
@@ -2502,23 +2794,14 @@ function readTitle(content: string): string | undefined {
2502
2794
  const match = /^title\s*:\s*(.*)$/.exec(line);
2503
2795
  if (match) {
2504
2796
  const value = match[1].trim();
2505
- return value === '' ? undefined : unquote(value);
2797
+ // The shared frontmatter unquoter, so a title written by
2798
+ // `quoteYamlScalar` (`''` for a literal `'`) reads back verbatim.
2799
+ return value === '' ? undefined : unquoteYamlScalar(value);
2506
2800
  }
2507
2801
  }
2508
2802
  return undefined;
2509
2803
  }
2510
2804
 
2511
- function unquote(value: string): string {
2512
- if (value.length >= 2) {
2513
- const first = value[0];
2514
- const last = value[value.length - 1];
2515
- if ((first === '"' || first === "'") && last === first) {
2516
- return value.slice(1, -1);
2517
- }
2518
- }
2519
- return value;
2520
- }
2521
-
2522
2805
  function escapeRegExp(value: string): string {
2523
2806
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2524
2807
  }
@@ -3068,6 +3351,97 @@ type Gate2ReviewOutcome =
3068
3351
  verdict: ReviewVerdict | undefined;
3069
3352
  };
3070
3353
 
3354
+ /** The outcome of {@link runReviewRounds}: the verdict data, with no routing done. */
3355
+ export type ReviewRoundsOutcome =
3356
+ | {kind: 'approved'; verdict: ReviewVerdict | undefined}
3357
+ | {kind: 'blocked'; verdict: ReviewVerdict | undefined}
3358
+ | {
3359
+ kind: 'unparseable';
3360
+ /** The needs-attention reason (names the parse failure, a transient class). */
3361
+ reason: string;
3362
+ };
3363
+
3364
+ /**
3365
+ * Run a review gate for up to `maxRounds` rounds on ONE tree and return the
3366
+ * verdict as DATA, writing nothing (the WRITE-FREE core of {@link runGate2Review},
3367
+ * which adds the needs-attention routing). Factored out so the CI tasking agent
3368
+ * phase (task `ci-split-tasking`) runs the one-round task-set review the SAME
3369
+ * way `performIntegration` does, in the job that may launch agents, without the
3370
+ * routing writes (the apply phase routes). A non-parse error is re-thrown.
3371
+ */
3372
+ export async function runReviewRounds(params: {
3373
+ reviewGate: ReviewGate;
3374
+ slug: string;
3375
+ /** The tree the review AGENT inspects. */
3376
+ reviewCwd: string;
3377
+ maxRounds: number;
3378
+ reviewModel?: string;
3379
+ watch?: boolean;
3380
+ watchSink?: (line: string) => void;
3381
+ color?: boolean;
3382
+ sessionsDir?: string;
3383
+ /** The AGENT env (ambient, never the identity-scoped one). */
3384
+ env: NodeJS.ProcessEnv | undefined;
3385
+ }): Promise<ReviewRoundsOutcome> {
3386
+ const {reviewGate, slug, reviewCwd, maxRounds} = params;
3387
+ // CORROBORATED-APPROVAL semantics (NOT retry-until-pass): the gate runs the
3388
+ // reviewer up to `reviewMaxRounds` times on the SAME tip and approves ONLY if
3389
+ // EVERY round approves. A `block` is TERMINAL — it short-circuits the loop and
3390
+ // is never re-rolled, because the reviewer is stochastic and re-reviewing an
3391
+ // UNCHANGED tip after a block would just be a dice re-roll that could launder a
3392
+ // real reject into a pass. The extra rounds therefore exist to make a FALSE
3393
+ // APPROVE harder to slip through (a second reviewer gets a veto), never to give
3394
+ // blocked work a second chance. (A future builder-REVISE step that mutates the
3395
+ // tree between rounds is the ONLY thing that should make a block retryable; it
3396
+ // would change the artifact under review and is not implemented here.)
3397
+ let approved = false;
3398
+ let lastVerdict: ReviewVerdict | undefined;
3399
+ for (let round = 1; round <= maxRounds; round++) {
3400
+ let verdict: ReviewVerdict;
3401
+ try {
3402
+ verdict = await reviewGate({
3403
+ slug,
3404
+ cwd: reviewCwd,
3405
+ reviewModel: params.reviewModel,
3406
+ round,
3407
+ watch: params.watch,
3408
+ watchSink: params.watchSink,
3409
+ color: params.color,
3410
+ sessionsDir: params.sessionsDir,
3411
+ env: params.env,
3412
+ });
3413
+ } catch (err) {
3414
+ if (!(err instanceof ReviewParseError)) {
3415
+ // Anything else (a harness/connection throw, a programmer bug) is NOT this
3416
+ // gate's concern: re-throw so the existing catch sites classify it.
3417
+ throw err;
3418
+ }
3419
+ // A parse failure in ANY round is TERMINAL (never re-roll the remaining
3420
+ // rounds). The reason carries the parse-failure phrase the
3421
+ // `failure-cause.ts` signature matches (a transient class).
3422
+ return {
3423
+ kind: 'unparseable',
3424
+ reason:
3425
+ `PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
3426
+ `${err.message}`,
3427
+ };
3428
+ }
3429
+ lastVerdict = verdict;
3430
+ if (verdict.verdict !== 'approve') {
3431
+ // A `block` is TERMINAL: stop now (never re-roll an unchanged tip) and route
3432
+ // the blocking findings to needs-attention below. `approved` stays false.
3433
+ approved = false;
3434
+ break;
3435
+ }
3436
+ // An `approve`: provisionally approved, but keep going — every remaining round
3437
+ // must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
3438
+ approved = true;
3439
+ }
3440
+ return approved
3441
+ ? {kind: 'approved', verdict: lastVerdict}
3442
+ : {kind: 'blocked', verdict: lastVerdict};
3443
+ }
3444
+
3071
3445
  /**
3072
3446
  * Run the Gate-2 PR/code REVIEW gate against a given tree ({@param reviewCwd}) and
3073
3447
  * route a BLOCK to needs-attention, returning DATA the caller acts on. Factored out
@@ -3112,105 +3486,75 @@ async function runGate2Review(params: {
3112
3486
  `for '${slug}' (this is a wiring bug; the gate must not be skipped).`,
3113
3487
  );
3114
3488
  }
3115
- const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
3116
3489
  note('Running the PR/code review gate (Gate 2)…');
3117
- // CORROBORATED-APPROVAL semantics (NOT retry-until-pass): the gate runs the
3118
- // reviewer up to `reviewMaxRounds` times on the SAME tip and approves ONLY if
3119
- // EVERY round approves. A `block` is TERMINAL — it short-circuits the loop and
3120
- // is never re-rolled, because the reviewer is stochastic and re-reviewing an
3121
- // UNCHANGED tip after a block would just be a dice re-roll that could launder a
3122
- // real reject into a pass. The extra rounds therefore exist to make a FALSE
3123
- // APPROVE harder to slip through (a second reviewer gets a veto), never to give
3124
- // blocked work a second chance. (A future builder-REVISE step that mutates the
3125
- // tree between rounds is the ONLY thing that should make a block retryable; it
3126
- // would change the artifact under review and is not implemented here.)
3127
- let approved = false;
3128
- let lastVerdict: ReviewVerdict | undefined;
3129
- for (let round = 1; round <= maxRounds; round++) {
3130
- let verdict: ReviewVerdict;
3131
- try {
3132
- verdict = await reviewGate({
3133
- slug,
3134
- cwd: reviewCwd,
3135
- reviewModel: input.reviewModel,
3136
- round,
3137
- // `--watch` threading (task `watch-review-session`): when on, the production
3138
- // gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
3139
- watch: input.watch,
3140
- watchSink: input.watchSink,
3141
- color: input.color,
3142
- sessionsDir: input.sessionsDir,
3143
- // The review AGENT launches with the AMBIENT env, never the identity-scoped
3144
- // `env` (an agent must not act as the bot). Falls back to `env` when no
3145
- // identity is configured (unchanged for non-identity callers).
3146
- env: input.agentEnv ?? env,
3147
- });
3148
- } catch (err) {
3149
- if (!(err instanceof ReviewParseError)) {
3150
- // Anything else (a harness/connection throw, a programmer bug) is NOT this
3151
- // gate's concern — re-throw so the existing catch sites classify it.
3152
- throw err;
3153
- }
3154
- // THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
3155
- // the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
3156
- // malformed JSON verdict, common on large diffs + weaker models, AFTER the
3157
- // direction-2 repair pass could not salvage it). WITHOUT this catch the throw
3158
- // escapes the core and `performComplete` maps it to the generic `usage-error`
3159
- // (verbatim, no push, no surface) AFTER the green build but BEFORE the
3160
- // done-move/push — STRANDING the lock + work branch with no PR.
3161
- //
3162
- // A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
3163
- // the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
3164
- // the same tip would just be the dice re-roll the corroboration loop forbids).
3165
- // We route through the SAME work-preserving `applyNeedsAttentionTransition`
3166
- // seam the block path uses (it PUSHES the work branch + surfaces the item on
3167
- // `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
3168
- // ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
3169
- // path AND the fresh-worktree `review:` callback are covered by this ONE catch.
3170
- // The recorded reason carries the parse-failure phrase the `failure-cause.ts`
3171
- // signature matches → the `do`/`run` tail classifies it `transient-infra`
3172
- // (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
3173
- // and the direction-2 repair makes a re-run far more likely to parse). NEVER a
3174
- // silent approve.
3175
- const reason =
3176
- `PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
3177
- `${err.message}`;
3178
- const routed = await ledgerWrite.applyNeedsAttentionTransition({
3179
- cwd,
3180
- slug,
3490
+ const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
3491
+ const rounds = await runReviewRounds({
3492
+ reviewGate,
3493
+ slug,
3494
+ reviewCwd,
3495
+ maxRounds,
3496
+ reviewModel: input.reviewModel,
3497
+ // `--watch` threading (task `watch-review-session`): when on, the production
3498
+ // gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
3499
+ watch: input.watch,
3500
+ watchSink: input.watchSink,
3501
+ color: input.color,
3502
+ sessionsDir: input.sessionsDir,
3503
+ // The review AGENT launches with the AMBIENT env, never the identity-scoped
3504
+ // `env` (an agent must not act as the bot). Falls back to `env` when no
3505
+ // identity is configured (unchanged for non-identity callers).
3506
+ env: input.agentEnv ?? env,
3507
+ });
3508
+ if (rounds.kind === 'unparseable') {
3509
+ // THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
3510
+ // the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
3511
+ // malformed JSON verdict, common on large diffs + weaker models, AFTER the
3512
+ // direction-2 repair pass could not salvage it). WITHOUT this catch the throw
3513
+ // escapes the core and `performComplete` maps it to the generic `usage-error`
3514
+ // (verbatim, no push, no surface) AFTER the green build but BEFORE the
3515
+ // done-move/push — STRANDING the lock + work branch with no PR.
3516
+ //
3517
+ // A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
3518
+ // the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
3519
+ // the same tip would just be the dice re-roll the corroboration loop forbids).
3520
+ // We route through the SAME work-preserving `applyNeedsAttentionTransition`
3521
+ // seam the block path uses (it PUSHES the work branch + surfaces the item on
3522
+ // `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
3523
+ // ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
3524
+ // path AND the fresh-worktree `review:` callback are covered by this ONE catch.
3525
+ // The recorded reason carries the parse-failure phrase the `failure-cause.ts`
3526
+ // signature matches → the `do`/`run` tail classifies it `transient-infra`
3527
+ // (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
3528
+ // and the direction-2 repair makes a re-run far more likely to parse). NEVER a
3529
+ // silent approve.
3530
+ const reason = rounds.reason;
3531
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
3532
+ cwd,
3533
+ slug,
3534
+ reason,
3535
+ arbiter: input.surfaceArbiter,
3536
+ env,
3537
+ note,
3538
+ });
3539
+ const message = routed.moved
3540
+ ? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
3541
+ 'marked it stuck on its per-item lock (work branch pushed + lock ' +
3542
+ 'surfaced; transient-infra — re-run). NOT integrated.'
3543
+ : `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
3544
+ 'NOT integrating.';
3545
+ note(message);
3546
+ return {
3547
+ kind: 'blocked',
3548
+ result: {
3549
+ outcome: 'review-unparseable',
3550
+ routedToNeedsAttention: routed.moved,
3551
+ branch,
3181
3552
  reason,
3182
- arbiter: input.surfaceArbiter,
3183
- env,
3184
- note,
3185
- });
3186
- const message = routed.moved
3187
- ? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
3188
- 'marked it stuck on its per-item lock (work branch pushed + lock ' +
3189
- 'surfaced; transient-infra — re-run). NOT integrated.'
3190
- : `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
3191
- 'NOT integrating.';
3192
- note(message);
3193
- return {
3194
- kind: 'blocked',
3195
- result: {
3196
- outcome: 'review-unparseable',
3197
- routedToNeedsAttention: routed.moved,
3198
- branch,
3199
- reason,
3200
- },
3201
- };
3202
- }
3203
- lastVerdict = verdict;
3204
- if (verdict.verdict !== 'approve') {
3205
- // A `block` is TERMINAL: stop now (never re-roll an unchanged tip) and route
3206
- // the blocking findings to needs-attention below. `approved` stays false.
3207
- approved = false;
3208
- break;
3209
- }
3210
- // An `approve`: provisionally approved, but keep going — every remaining round
3211
- // must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
3212
- approved = true;
3553
+ },
3554
+ };
3213
3555
  }
3556
+ const lastVerdict = rounds.verdict;
3557
+ const approved = rounds.kind === 'approved';
3214
3558
  if (!approved) {
3215
3559
  // NON-approve verdict: route to needs-attention via the SAME seam the red gate
3216
3560
  // uses, NEVER integrate. We reach here EITHER because a round returned a
@@ -3403,6 +3747,113 @@ async function runFreshWorktreeGate(params: {
3403
3747
  }
3404
3748
  }
3405
3749
 
3750
+ /**
3751
+ * The git trailer key that marks a merge-mode land whose tree differs from the
3752
+ * gated tree (ADR `ci-agent-job-holds-no-write-token` decision 2). Its value is
3753
+ * the report, e.g. `landed without re-gate after 2 lost races`. A git trailer
3754
+ * like `CAS-Nonce` (`ledger-write.ts`), so `git log --format=%(trailers)`
3755
+ * reads it back.
3756
+ */
3757
+ export const LANDED_WITHOUT_REGATE_TRAILER = 'Landed-Without-Regate';
3758
+
3759
+ /** The landed-vs-gated report for `lostRaces` lost pushes. */
3760
+ export function landedWithoutRegateReport(lostRaces: number): string {
3761
+ return (
3762
+ `landed without re-gate after ${lostRaces} lost ` +
3763
+ (lostRaces === 1 ? 'race' : 'races')
3764
+ );
3765
+ }
3766
+
3767
+ /** The tree id of `rev` in `cwd`. */
3768
+ async function treeOf(
3769
+ cwd: string,
3770
+ rev: string,
3771
+ env: NodeJS.ProcessEnv | undefined,
3772
+ ): Promise<string> {
3773
+ return (
3774
+ await gitHard(['rev-parse', `${rev}^{tree}`], cwd, env)
3775
+ ).stdout.trim();
3776
+ }
3777
+
3778
+ /**
3779
+ * After the merge-mode CAS loop re-rebased `branch` for the `lostRaces`-th
3780
+ * time, rewrite its tip so its message carries exactly the right
3781
+ * {@link LANDED_WITHOUT_REGATE_TRAILER}: set (with this count) when the tip's
3782
+ * tree differs from `gatedTree`, removed when it is equal again. A previous
3783
+ * attempt's trailer rides the re-rebase as part of the message, which is why an
3784
+ * existing one is always replaced, never appended to. Only the message changes:
3785
+ * the tree, the parents and the author are the tip's own, and the branch ref
3786
+ * moves by a compare-and-swap `update-ref` (the working tree and the index
3787
+ * already match that tree). Returns the report when the trailer is set.
3788
+ */
3789
+ async function stampLandedWithoutRegate(params: {
3790
+ cwd: string;
3791
+ branch: string;
3792
+ gatedTree: string;
3793
+ lostRaces: number;
3794
+ env: NodeJS.ProcessEnv | undefined;
3795
+ }): Promise<string | undefined> {
3796
+ const {cwd, branch, gatedTree, lostRaces, env} = params;
3797
+ const ref = `refs/heads/${branch}`;
3798
+ const FIELD_SEP = '\u0000';
3799
+ const raw = (
3800
+ await gitHard(
3801
+ ['log', '-1', '--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%aI%x00%B', ref],
3802
+ cwd,
3803
+ env,
3804
+ )
3805
+ ).stdout;
3806
+ const [head, tree, parentList, authorName, authorEmail, authorDate, body] =
3807
+ raw.split(FIELD_SEP);
3808
+ const report =
3809
+ tree.trim() === gatedTree
3810
+ ? undefined
3811
+ : landedWithoutRegateReport(lostRaces);
3812
+ const original = (body ?? '').replace(/\s+$/, '');
3813
+ const prefix = `${LANDED_WITHOUT_REGATE_TRAILER}:`;
3814
+ const stripped = original
3815
+ .split('\n')
3816
+ .filter((line) => !line.startsWith(prefix))
3817
+ .join('\n')
3818
+ .replace(/\s+$/, '');
3819
+ const message =
3820
+ report === undefined
3821
+ ? stripped
3822
+ : (
3823
+ await gitHard(
3824
+ [
3825
+ 'interpret-trailers',
3826
+ '--trailer',
3827
+ `${LANDED_WITHOUT_REGATE_TRAILER}: ${report}`,
3828
+ ],
3829
+ cwd,
3830
+ env,
3831
+ `${stripped}\n`,
3832
+ )
3833
+ ).stdout.replace(/\s+$/, '');
3834
+ if (message === original) return report;
3835
+ const parents = parentList
3836
+ .trim()
3837
+ .split(/\s+/)
3838
+ .filter((p) => p !== '')
3839
+ .flatMap((p) => ['-p', p]);
3840
+ const rewritten = (
3841
+ await gitHard(
3842
+ ['commit-tree', tree.trim(), ...parents, '-F', '-'],
3843
+ cwd,
3844
+ {
3845
+ ...(env ?? process.env),
3846
+ GIT_AUTHOR_NAME: authorName,
3847
+ GIT_AUTHOR_EMAIL: authorEmail,
3848
+ GIT_AUTHOR_DATE: authorDate,
3849
+ },
3850
+ `${message}\n`,
3851
+ )
3852
+ ).stdout.trim();
3853
+ await gitHard(['update-ref', ref, rewritten, head.trim()], cwd, env);
3854
+ return report;
3855
+ }
3856
+
3406
3857
  /** Run git, returning the raw result (no throw) — for soft checks. */
3407
3858
  function gitSoft(
3408
3859
  args: string[],
@@ -3417,8 +3868,9 @@ async function gitHard(
3417
3868
  args: string[],
3418
3869
  cwd: string,
3419
3870
  env: NodeJS.ProcessEnv | undefined,
3871
+ input?: string,
3420
3872
  ): Promise<RunResult> {
3421
- const result = await runAsync('git', args, cwd, {env});
3873
+ const result = await runAsync('git', args, cwd, {env, input});
3422
3874
  if (result.status !== 0) {
3423
3875
  throw new Error(
3424
3876
  `git ${args.join(' ')} failed (exit ${result.status}): ${result.stderr.trim()}`,