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
@@ -5,14 +5,16 @@ import { LEDGER_STATUS_FOLDERS, workItemPath, workItemRel, workFolderPath, workF
5
5
  import { runVerify } from './verify.js';
6
6
  import { ensurePrepared } from './prepare.js';
7
7
  import { ReviewParseError, formatBlockReason, reviewRoundsExhaustedReason, } from './review-gate.js';
8
+ import { proposeRequestNotOpened, } from './integrator.js';
8
9
  import { extractDecisionsBlock } from './agent-stop.js';
9
10
  import { ledgerWrite } from './ledger-write.js';
10
11
  import { selectProvider } from './github.js';
11
- import { parseFrontmatter } from './frontmatter.js';
12
+ import { parseFrontmatter, unquoteYamlScalar } from './frontmatter.js';
12
13
  import { git, run, runAsync } from './git.js';
13
14
  import { realSleep } from './retry-backoff.js';
14
15
  import { workBranchRef } from './slug-namespace.js';
15
16
  import { isAncestor } from './gc.js';
17
+ import { clearOwnQuestionResidueOnLand } from './land-question-residue.js';
16
18
  import { detectColocatedSidecars, formatSidecarGuardReason, } from './sidecar-guard.js';
17
19
  /**
18
20
  * Build the human-facing CONTEXT tail appended to an `acceptance gate failed`
@@ -156,7 +158,6 @@ export async function performIntegration(input) {
156
158
  const arbiter = input.arbiter;
157
159
  const slug = input.slug;
158
160
  const source = input.source;
159
- const recovering = input.recovering;
160
161
  const note = input.note ?? (() => { });
161
162
  // The work branch: the caller is on it (it carries the namespaced identity).
162
163
  // Prefer the explicit `branch`; else read the branch HEAD is on; only fall
@@ -574,6 +575,18 @@ export async function performIntegration(input) {
574
575
  if (!lifecycle) {
575
576
  transcribeDecisionsIntoDoneRecord({ cwd, slug, output: input.body, note });
576
577
  }
578
+ // 2c. CLEAR THE TASK'S OWN STALE QUESTION STATE in the SAME done-move commit
579
+ // (task `a-recovered-task-lands-without-stale-question-state`). A task that
580
+ // was surfaced to needs-attention and then recovered carries
581
+ // `needsAnswers: true` + its `work/questions/task-<slug>.md` sidecar on
582
+ // this branch; the done-move makes both moot, so they leave in the commit
583
+ // that makes them moot rather than on some later claim's reconcile. A
584
+ // normal land is untouched. Residue that only ARRIVES with the rebase (the
585
+ // surface landed on `main` after this branch was cut) is folded in after
586
+ // the rebase instead ({@link foldQuestionResidueIntoTip}).
587
+ if (!lifecycle) {
588
+ clearOwnQuestionResidueOnLand({ cwd, slug, note });
589
+ }
577
590
  // 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
578
591
  // atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
579
592
  await gitHard(['add', '-A'], cwd, env);
@@ -612,205 +625,35 @@ export async function performIntegration(input) {
612
625
  // needs-attention). Single-job callers pass no lock ⇒ the tail runs directly,
613
626
  // byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
614
627
  // integration stays fully concurrent.
628
+ const reconcileCtx = {
629
+ cwd,
630
+ arbiter,
631
+ slug,
632
+ branch,
633
+ lifecycle: lifecycle !== undefined,
634
+ source,
635
+ surfaceArbiter: input.surfaceArbiter,
636
+ commitMessage,
637
+ env,
638
+ note,
639
+ };
640
+ // The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
641
+ // ONLY region serialised per repo under the `run` concurrency seam: it is the
642
+ // land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
643
+ // race (the loser pushing a non-fast-forward `${branch}:main`). Wrapping ONLY
644
+ // this tail keeps the front-of-band gate (`prepare`+`verify`) and the Gate-2
645
+ // review agent CONCURRENT across same-repo jobs (run's parallelism); inside the
646
+ // lock the loser re-fetches + rebases onto the winner's now-advanced main, so
647
+ // its push is a clean fast-forward (a genuine conflict routes ONE to
648
+ // needs-attention). Single-job callers pass no lock ⇒ the tail runs directly,
649
+ // byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
650
+ // integration stays fully concurrent.
615
651
  const runRebaseToIntegrateTail = async () => {
616
- // The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
617
- // sibling-slug ledger arm and the divergent-done-move recovery), factored so it
618
- // can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
619
- // (a sibling advancing main mid-push needs the SAME reconcile, not a bare
620
- // rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
621
- // `{route}` when a genuine conflict / invariant violation must stop the tail.
622
- const rebaseOntoMainWithReconcile = async () => {
623
- // 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
624
- // latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
625
- //
626
- // RECOVERY reconciliation: post the per-item-lock cut-over, a stuck item's
627
- // body never moved into a `needs-attention/` folder (stuck is the lock
628
- // `state: stuck`; the body rests in `backlog/`), so a recovery `complete`'s
629
- // kept branch carries NO historical `in-progress → needs-attention`
630
- // move-only commit to drop and `<arbiter>/main` holds no surface move to
631
- // conflict with. The old recovery drop (`rebaseDroppingNeedsAttentionSurface`,
632
- // drop-bookkeeping-rebase) is deleted; recovery is now the SAME plain replay
633
- // onto `<arbiter>/main` as any other build (see the plain-rebase note below).
634
- // Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
635
- // EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
636
- // has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
637
- // be stale, causing a spurious rebase conflict); a regular clone (`do`/
638
- // `complete`) already has it, where the explicit refspec is harmless (the same
639
- // refspec `rebaseOntoArbiterMain` used before the convergence).
640
- await gitHard([
641
- 'fetch',
642
- '--quiet',
643
- arbiter,
644
- `+refs/heads/main:refs/remotes/${arbiter}/main`,
645
- ], cwd, env);
646
- // ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
647
- // freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
648
- // just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
649
- // the arbiter already holds the slug in >1 status folder with differing content
650
- // (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
651
- // — the arbiter holds the slug's source in a DIFFERENT folder than our local
652
- // done-move removed — which is the case that turns the rebased "move" into a
653
- // "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
654
- // done-move).
655
- // CONTINUE-BUILD EXEMPTION (task
656
- // `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
657
- // there was no first-time move on this commit (the slug was already in
658
- // `done/` on the kept branch + on the arbiter), so the divergent-done-move
659
- // reconcile reasoning does not apply. Skip the arbiter ledger placement
660
- // pre-check too: it is structurally an instrument FOR that reconcile, and a
661
- // continue-build is by construction already in `done/` on both sides.
662
- if (!lifecycle && source !== 'done') {
663
- const arbiterPlacement = readArbiterLedgerPlacement(cwd, arbiter, slug, env);
664
- if (arbiterPlacement.error) {
665
- note(arbiterPlacement.error);
666
- return {
667
- route: {
668
- outcome: 'invariant-violation',
669
- routedToNeedsAttention: false,
670
- branch,
671
- reason: arbiterPlacement.error,
672
- },
673
- };
674
- }
675
- }
676
- // PLAIN rebase. After the per-item-lock cut-over (spec
677
- // `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
678
- // lands on a work branch: needs-attention is the lock `state: stuck` (not a
679
- // `git mv` to `needs-attention/`), the body rests in `backlog/` while
680
- // claimed, and the tasking/advancing markers are gone. So a recovery
681
- // complete's kept branch carries NO historical route-to-needs-attention
682
- // move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
683
- // (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
684
- // rebases are the same plain replay onto `<arbiter>/main`.
685
- void recovering;
686
- void lifecycle;
687
- // RENAME-DETECTION-OFF (task
688
- // `disable-rename-detection-on-continue-rebase`): scope
689
- // `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
690
- // durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
691
- // NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
692
- // rename `work/<from>/ → work/<to>/` (which would spuriously flag every
693
- // sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
694
- // location)` and force a FALSE needs-attention). Content-rename detection
695
- // (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
696
- // does NOT suppress this directory-rename conflict; only
697
- // `merge.directoryRenames=false` does. NEVER a persistent `git config`
698
- // write — the repo's config stays clean so a user's interactive
699
- // `git rebase` is unaffected. A GENUINE content conflict still surfaces
700
- // and still routes via `rebaseConflictRoute()` below.
701
- const rebase = await gitSoft(['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`], cwd, env);
702
- if (rebase.status !== 0) {
703
- // NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
704
- // LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
705
- // `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
706
- // status-folder move on `<arbiter>/main` between our base and this rebase) is
707
- // a benign ledger-only divergence with NO semantic judgement: the reconcile
708
- // ABORTS the rebase, then redoes OUR work as one clean commit on top of
709
- // `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
710
- // automatically). It is scoped STRICTLY to other slugs' ledger files — a
711
- // conflict touching ANY code file, or THIS slug's own ledger, returns `false`
712
- // (and leaves the rebase in progress), so the divergent-done-move recovery /
713
- // needs-attention route below handles it; it NEVER widens to code. The tasking
714
- // lifecycle is exempt (its move is not a task done-move).
715
- const siblingReconciled = lifecycle
716
- ? false
717
- : await reconcileSiblingLedgerConflict({
718
- cwd,
719
- arbiter,
720
- slug,
721
- env,
722
- note,
723
- });
724
- if (siblingReconciled) {
725
- // The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
726
- // arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
727
- // band. THIS slug's own move is untouched.
728
- note(`Reconciled a sibling-slug ledger conflict during the rebase onto ` +
729
- `${arbiter}/main (took the arbiter's version of the other slugs' ` +
730
- `work/<status>/<slug>.md ledger files; no code file was touched).`);
731
- }
732
- else if (!lifecycle && source !== 'done') {
733
- // NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
734
- // DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
735
- // our local done-move did not remove — PR #86) is auto-RECONCILABLE without
736
- // any semantic judgement: redo the done-move arbiter-resolved (remove the
737
- // arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
738
- // only do this when the post-abort tree's ONLY divergence is the slug's ledger
739
- // file; a real code conflict still routes to needs-attention untouched.
740
- await gitSoft(['rebase', '--abort'], cwd, env);
741
- const recovered = await reconcileDivergentDoneMove({
742
- cwd,
743
- arbiter,
744
- slug,
745
- branch,
746
- localSource: source,
747
- env,
748
- note,
749
- });
750
- if (recovered) {
751
- // The branch is now cleanly on top of `<arbiter>/main` with the slug in
752
- // `done/` ONLY — fall through to integrate (skip the needs-attention
753
- // route below).
754
- note(`Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
755
- 'work/done/ ONLY (the divergent source folder was removed; the move ' +
756
- 'is a move, not a copy).');
757
- }
758
- else {
759
- return { route: await rebaseConflictRoute() };
760
- }
761
- }
762
- else {
763
- await gitSoft(['rebase', '--abort'], cwd, env);
764
- return { route: await rebaseConflictRoute() };
765
- }
766
- }
767
- // Clean rebase (or a reconciled one): fall through to the gate + integrate.
768
- return {};
769
- };
770
652
  // Run the step-4 rebase ONCE up front (before the slow fresh gate).
771
- const firstRebase = await rebaseOntoMainWithReconcile();
653
+ const firstRebase = await rebaseOntoMainWithReconcile(reconcileCtx);
772
654
  if (firstRebase.route) {
773
655
  return firstRebase.route;
774
656
  }
775
- // The rebase-conflict needs-attention route, factored so the divergent-ledger
776
- // recovery above can fall through to integrate while a genuine code conflict
777
- // still routes here.
778
- async function rebaseConflictRoute() {
779
- // Then mark the item stuck on its per-item lock with the conflict reason
780
- // (ADR §12) THROUGH the ledger write seam's needs-attention transition,
781
- // rather than leaving it dangling. Post lock-cutover this is a lock amend
782
- // (`state: stuck` + the reason on the lock entry, no `done/ →
783
- // needs-attention/` folder move); the done-move already committed above
784
- // stands. No partial state.
785
- const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
786
- const routed = await ledgerWrite.applyNeedsAttentionTransition({
787
- cwd,
788
- slug,
789
- reason,
790
- // Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
791
- // conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
792
- // (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
793
- // no surface, no push, local-only. The push lives in the seam (HEAD's
794
- // branch is `work/<slug>` here) — no bolted-on push.
795
- arbiter: input.surfaceArbiter,
796
- env,
797
- note,
798
- });
799
- return {
800
- outcome: 'rebase-conflict',
801
- routedToNeedsAttention: routed.moved,
802
- branch,
803
- commitMessage,
804
- reason: routed.moved
805
- ? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
806
- `aborted (never auto-resolved) and '${slug}' was marked stuck on ` +
807
- 'its per-item lock (surfaced by status). Resolve against the latest ' +
808
- 'main, then `requeue` to release the stuck lock and re-run.'
809
- : `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
810
- 'aborted (never auto-resolved). Resolve against the latest main, ' +
811
- 'then re-run complete.',
812
- };
813
- }
814
657
  // 4c. FRESH-WORKTREE GATE (task `gate-on-rebased-tip-fresh-worktree`): when ON,
815
658
  // the acceptance gate (`prepare` then `verify`) runs HERE — on the work
816
659
  // branch tip the rebase above just produced (the would-be-integrated tip) —
@@ -934,209 +777,513 @@ export async function performIntegration(input) {
934
777
  }
935
778
  }
936
779
  }
937
- // 5. Integrate per mode through the ledger write seam's COMPLETE transition
938
- // (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
939
- // already brought the branch up to date, so the seam's sole strategy uses
940
- // `integrate` (not `integrateWithRebase`) and never --forces. Provider
941
- // selection: an injected `openPr` wins (legacy bridge); otherwise pick by
942
- // the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
943
- // `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
944
- // `gh` degrades to push-only at runtime — never a hard failure (and the
945
- // start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
946
- // The seam is storage-agnostic: we hand it the work branch, the integration
947
- // mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
948
- // strategy.
949
- // Provider precedence: an injected fully-formed provider wins (the `run`
950
- // stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
951
- // bridge; else select PURELY from the arbiter URL (no override). The orthogonal
952
- // `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
953
- // provider, the integrator simply skips `openRequest` when it is set.
954
- const provider = input.providerInstance ??
955
- (input.openPr
956
- ? bridgeProvider(input.openPr)
957
- : selectProvider({
958
- arbiterUrl: await arbiterUrl(cwd, arbiter, env),
959
- }));
960
- // Race-1 (claim-vs-integrate, task
961
- // `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
962
- // and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
963
- // under the SEPARATE claim lock — or a sibling integrate advanced
964
- // `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
965
- // carries the sibling-ledger + divergent-done-move reconcile arms — a bare
966
- // re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
967
- // retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
968
- // main: each retry re-rebases to a clean fast-forward. A genuine code conflict
969
- // on a re-rebase routes to needs-attention via the SAME `route` the up-front
970
- // rebase uses. A persistent non-fast-forward past the cap also routes (never a
971
- // silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
972
- //
973
- // **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
974
- // promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
975
- // counts against a tiny give-up budget — only a GENUINE conflict surfaced by
976
- // `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
977
- // `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
978
- // precondition recheck reused verbatim: if the slug is GONE from its expected
979
- // source folder on the new `main` (a concurrent legitimate same-item winner
980
- // already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
981
- // definitively — never a silent re-push that would clobber the winner. The
982
- // `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
983
- // pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
984
- // modest jitter on the refetch desynchronises a herd so the tail is not
985
- // reached under sustained parallel load.
986
- const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
987
- const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
988
- let integration;
989
- for (let mergeAttempt = 0;; mergeAttempt++) {
990
- integration = await ledgerWrite.applyCompleteTransition({
991
- arbiter,
992
- branch,
993
- mode,
994
- provider,
995
- // PR-INTENT: when set (propose mode), push the branch but skip the PR.
996
- noPR: input.noPR,
997
- // Half A: an explicit single-line PR title (propose mode), so `gh` no longer
998
- // derives a run-on title from the commit subject via `--fill`.
999
- title: prTitle,
1000
- // Half B: the propose-mode PR body — the agent's summary under a deterministic
1001
- // runner header (task pointer). Undefined when no body was supplied (the
1002
- // header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
1003
- // regression). Ignored in merge mode by the provider/integrator.
1004
- body: composeProposeBody({ slug, body: input.body }),
1005
- // Part (b) of the merged-branch hygiene task: when WE perform the merge
1006
- // (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
1007
- // INLINE right after the merge lands — the commits are now on `main`, so the
1008
- // head is provably merged and safe to delete (ancestor-guarded inside the
1009
- // integrator). Idempotent no-op when no remote head exists (the plain
1010
- // `${branch}:main` push opened none); ignored in `propose` mode (its branch is
1011
- // the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
1012
- deleteMergedHead: true,
780
+ // 5. The LAND half (`landIntegration`, reached through the `integrationLand`
781
+ // seam): integrate per mode with the merge-mode CAS loop, then the review
782
+ // comment. The CI agent phase records this call as its boundary; the apply
783
+ // phase resumes here (task `ci-split-build-path`).
784
+ return await integrationLand.land({
785
+ ...reconcileCtx,
786
+ mode,
787
+ noPR: input.noPR,
788
+ providerInstance: input.providerInstance,
789
+ openPr: input.openPr,
790
+ title: prTitle,
791
+ body: composeProposeBody({
792
+ slug,
793
+ body: input.body,
794
+ header: lifecycle?.proposeHeader,
795
+ }),
796
+ reviewProse: approvedVerdict?.review ?? input.approvedReviewProse,
797
+ mergeRetries: input.mergeRetries,
798
+ mergeJitterMs: input.mergeJitterMs,
799
+ });
800
+ };
801
+ // Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
802
+ // it directly (an un-contended no-op, single-job behaviour unchanged).
803
+ return input.integrateLock && input.integrateLockKey !== undefined
804
+ ? await input.integrateLock(input.integrateLockKey, runRebaseToIntegrateTail)
805
+ : await runRebaseToIntegrateTail();
806
+ }
807
+ /** The process-wide {@link IntegrationLandSeam} (replaced only by the phase recorder). */
808
+ export const integrationLand = {
809
+ land: (input) => landIntegration(input),
810
+ };
811
+ /**
812
+ * The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
813
+ * sibling-slug ledger arm and the divergent-done-move recovery), factored so it
814
+ * can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
815
+ * (a sibling advancing main mid-push needs the SAME reconcile, not a bare
816
+ * rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
817
+ * `{route}` when a genuine conflict / invariant violation must stop the tail.
818
+ * Module-level (not a closure) so BOTH halves of the rebase-to-integrate tail
819
+ * reach it: the agent half runs it once before the fresh gate, and the land
820
+ * half ({@link landIntegration}) re-runs it in the merge-mode CAS loop.
821
+ */
822
+ async function rebaseOntoMainWithReconcile(ctx) {
823
+ const { cwd, arbiter, slug, branch, lifecycle, source, env } = ctx;
824
+ const note = ctx.note;
825
+ // 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
826
+ // latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
827
+ //
828
+ // RECOVERY reconciliation: post the per-item-lock cut-over, a stuck item's
829
+ // body never moved into a `needs-attention/` folder (stuck is the lock
830
+ // `state: stuck`; the body rests in `backlog/`), so a recovery `complete`'s
831
+ // kept branch carries NO historical `in-progress → needs-attention`
832
+ // move-only commit to drop and `<arbiter>/main` holds no surface move to
833
+ // conflict with. The old recovery drop (`rebaseDroppingNeedsAttentionSurface`,
834
+ // drop-bookkeeping-rebase) is deleted; recovery is now the SAME plain replay
835
+ // onto `<arbiter>/main` as any other build (see the plain-rebase note below).
836
+ // Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
837
+ // EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
838
+ // has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
839
+ // be stale, causing a spurious rebase conflict); a regular clone (`do`/
840
+ // `complete`) already has it, where the explicit refspec is harmless (the same
841
+ // refspec `rebaseOntoArbiterMain` used before the convergence).
842
+ await gitHard([
843
+ 'fetch',
844
+ '--quiet',
845
+ arbiter,
846
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
847
+ ], cwd, env);
848
+ // ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
849
+ // freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
850
+ // just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
851
+ // the arbiter already holds the slug in >1 status folder with differing content
852
+ // (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
853
+ // — the arbiter holds the slug's source in a DIFFERENT folder than our local
854
+ // done-move removed — which is the case that turns the rebased "move" into a
855
+ // "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
856
+ // done-move).
857
+ // CONTINUE-BUILD EXEMPTION (task
858
+ // `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
859
+ // there was no first-time move on this commit (the slug was already in
860
+ // `done/` on the kept branch + on the arbiter), so the divergent-done-move
861
+ // reconcile reasoning does not apply. Skip the arbiter ledger placement
862
+ // pre-check too: it is structurally an instrument FOR that reconcile, and a
863
+ // continue-build is by construction already in `done/` on both sides.
864
+ if (!lifecycle && source !== 'done') {
865
+ const arbiterPlacement = readArbiterLedgerPlacement(cwd, arbiter, slug, env);
866
+ if (arbiterPlacement.error) {
867
+ note(arbiterPlacement.error);
868
+ return {
869
+ route: {
870
+ outcome: 'invariant-violation',
871
+ routedToNeedsAttention: false,
872
+ branch,
873
+ reason: arbiterPlacement.error,
874
+ },
875
+ };
876
+ }
877
+ }
878
+ // PLAIN rebase. After the per-item-lock cut-over (spec
879
+ // `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
880
+ // lands on a work branch: needs-attention is the lock `state: stuck` (not a
881
+ // `git mv` to `needs-attention/`), the body rests in `backlog/` while
882
+ // claimed, and the tasking/advancing markers are gone. So a recovery
883
+ // complete's kept branch carries NO historical route-to-needs-attention
884
+ // move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
885
+ // (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
886
+ // rebases are the same plain replay onto `<arbiter>/main`.
887
+ // RENAME-DETECTION-OFF (task
888
+ // `disable-rename-detection-on-continue-rebase`): scope
889
+ // `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
890
+ // durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
891
+ // NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
892
+ // rename `work/<from>/ → work/<to>/` (which would spuriously flag every
893
+ // sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
894
+ // location)` and force a FALSE needs-attention). Content-rename detection
895
+ // (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
896
+ // does NOT suppress this directory-rename conflict; only
897
+ // `merge.directoryRenames=false` does. NEVER a persistent `git config`
898
+ // write — the repo's config stays clean so a user's interactive
899
+ // `git rebase` is unaffected. A GENUINE content conflict still surfaces
900
+ // and still routes via `rebaseConflictRoute(ctx)` below.
901
+ const rebase = await gitSoft(['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`], cwd, env);
902
+ if (rebase.status !== 0) {
903
+ // NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
904
+ // LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
905
+ // `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
906
+ // status-folder move on `<arbiter>/main` between our base and this rebase) is
907
+ // a benign ledger-only divergence with NO semantic judgement: the reconcile
908
+ // ABORTS the rebase, then redoes OUR work as one clean commit on top of
909
+ // `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
910
+ // automatically). It is scoped STRICTLY to other slugs' ledger files — a
911
+ // conflict touching ANY code file, or THIS slug's own ledger, returns `false`
912
+ // (and leaves the rebase in progress), so the divergent-done-move recovery /
913
+ // needs-attention route below handles it; it NEVER widens to code. The tasking
914
+ // lifecycle is exempt (its move is not a task done-move).
915
+ const siblingReconciled = lifecycle
916
+ ? false
917
+ : await reconcileSiblingLedgerConflict({
1013
918
  cwd,
919
+ arbiter,
920
+ slug,
1014
921
  env,
922
+ note,
1015
923
  });
1016
- // Only the merge push can be non-fast-forward (propose pushes its own ref).
1017
- if (integration.mergeNonFastForward !== true) {
1018
- break;
1019
- }
1020
- if (mergeAttempt >= maxMergeRetries) {
1021
- // LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
1022
- // now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
1023
- // false-contention budget). Route to needs-attention rather than looping
1024
- // forever or force-pushing main. A RARE outcome under realistic load thanks
1025
- // to the jitter below + the rebase-until-real-conflict semantics; tests
1026
- // reach it deterministically by injecting a small `mergeRetries`.
1027
- return await mergeNonFastForwardRoute(`integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1028
- `non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
1029
- `times); gave up cleanly without --force`);
1030
- }
1031
- // Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
1032
- // maximises mutual rejection under sustained parallel load (thundering herd).
1033
- // A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
1034
- // Skipped when `mergeJitterMs === 0` (the test seam).
1035
- if (mergeJitterMs > 0) {
1036
- await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
1037
- }
1038
- // A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
1039
- // before retrying the push. A genuine conflict on the re-rebase routes via
1040
- // `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
1041
- // one-slug placement recheck IS the genuine-conflict terminator — see the
1042
- // `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
1043
- // (no route) loops without counting against a small budget.
1044
- const reRebase = await rebaseOntoMainWithReconcile();
1045
- if (reRebase.route) {
1046
- return reRebase.route;
1047
- }
924
+ if (siblingReconciled) {
925
+ // The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
926
+ // arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
927
+ // band. THIS slug's own move is untouched.
928
+ note(`Reconciled a sibling-slug ledger conflict during the rebase onto ` +
929
+ `${arbiter}/main (took the arbiter's version of the other slugs' ` +
930
+ `work/<status>/<slug>.md ledger files; no code file was touched).`);
1048
931
  }
1049
- // The Race-1 needs-attention route for a merge that could not land (a genuine
1050
- // re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
1051
- // covers the cap-exhausted persistent-contention case). Mirrors
1052
- // `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
1053
- // slug sits in work/done/ and the seam bounces it from there.
1054
- async function mergeNonFastForwardRoute(reason) {
1055
- const routed = await ledgerWrite.applyNeedsAttentionTransition({
932
+ else if (!lifecycle && source !== 'done') {
933
+ // NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
934
+ // DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
935
+ // our local done-move did not remove — PR #86) is auto-RECONCILABLE without
936
+ // any semantic judgement: redo the done-move arbiter-resolved (remove the
937
+ // arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
938
+ // only do this when the post-abort tree's ONLY divergence is the slug's ledger
939
+ // file; a real code conflict still routes to needs-attention untouched.
940
+ await gitSoft(['rebase', '--abort'], cwd, env);
941
+ const recovered = await reconcileDivergentDoneMove({
1056
942
  cwd,
943
+ arbiter,
1057
944
  slug,
1058
- reason,
1059
- arbiter: input.surfaceArbiter,
945
+ branch,
946
+ localSource: source,
1060
947
  env,
1061
948
  note,
1062
949
  });
1063
- return {
1064
- outcome: 'rebase-conflict',
1065
- routedToNeedsAttention: routed.moved,
1066
- branch,
1067
- commitMessage,
1068
- reason: routed.moved
1069
- ? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1070
- `non-fast-forward push (a sibling advanced main); '${slug}' was ` +
1071
- `marked stuck on its per-item lock (surfaced by status). Resolve ` +
1072
- `against the latest main, then \`requeue\` to release the stuck ` +
1073
- `lock and re-run.`
1074
- : `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1075
- `non-fast-forward push (a sibling advanced main). Resolve against the ` +
1076
- `latest main, then re-run complete.`,
1077
- };
1078
- }
1079
- // 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
1080
- // refining `review-gate-pr-comment`): AFTER the propose integrate, where the
1081
- // approved verdict (with its deliberately-authored `review` prose), the
1082
- // resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
1083
- // scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
1084
- // (the audit trail; decided 2026-06-06). The `review` field is a first-class
1085
- // AUTHORED review (the prompt requires it), NOT the residue around the JSON
1086
- // — posting the residue was the bug
1087
- // (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
1088
- // reuses the SAME `provider` the integrate used (the core never imports `gh`).
1089
- // The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
1090
- // — by here the verdict has ALREADY routed (block never reaches this point; it
1091
- // routed to needs-attention above) and the integrate has ALREADY happened.
1092
- // The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
1093
- // (the normal path — post on it directly); else, when a PR WAS opened but its
1094
- // url was unparseable (`integration.requestOpened` true, `url` undefined — the
1095
- // `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
1096
- // BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
1097
- // the provider resolves the branch's open PR and comments on it, instead of
1098
- // silently dropping a review on a PR that genuinely exists. Only when NO PR was
1099
- // opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
1100
- // false) is it the honest clean no-op — and the branch-resolved fallback's own
1101
- // "no PR resolvable" path is a clean no-op too (it tries first). Either way
1102
- // `postPRComment*` never throws; the review stays in the run output. Because
1103
- // this lives in the shared core, BOTH `do`/`complete` AND `run` post the
1104
- // comment — no per-caller wiring.
1105
- if (approvedVerdict?.review !== undefined) {
1106
- if (integration.url !== undefined) {
1107
- const posted = provider.postPRComment({
1108
- cwd,
1109
- url: integration.url,
1110
- body: approvedVerdict.review,
1111
- env,
1112
- });
1113
- note(posted.instruction);
950
+ if (recovered) {
951
+ // The branch is now cleanly on top of `<arbiter>/main` with the slug in
952
+ // `done/` ONLY — fall through to integrate (skip the needs-attention
953
+ // route below).
954
+ note(`Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
955
+ 'work/done/ ONLY (the divergent source folder was removed; the move ' +
956
+ 'is a move, not a copy).');
1114
957
  }
1115
- else if (integration.requestOpened) {
1116
- // A PR opened but its url was unparseable — resolve it from the branch
1117
- // rather than dropping the review (the audit-trail fallback).
1118
- const posted = provider.postPRCommentOnBranch({
1119
- cwd,
1120
- branch,
1121
- body: approvedVerdict.review,
1122
- env,
1123
- });
1124
- note(posted.instruction);
958
+ else {
959
+ return { route: await rebaseConflictRoute(ctx) };
1125
960
  }
1126
961
  }
962
+ else {
963
+ await gitSoft(['rebase', '--abort'], cwd, env);
964
+ return { route: await rebaseConflictRoute(ctx) };
965
+ }
966
+ }
967
+ // Clean rebase (or a reconciled one): fold any question residue the rebase
968
+ // brought in into the tip, then fall through to the gate + integrate.
969
+ if (!lifecycle) {
970
+ await foldQuestionResidueIntoTip(ctx);
971
+ }
972
+ return {};
973
+ }
974
+ /**
975
+ * After a rebase onto `<arbiter>/main`, CLEAR the landing task's own stale
976
+ * question state that the rebase BROUGHT IN, and AMEND it into the tip commit
977
+ * (the completion commit), so the land still carries it in the done-move commit
978
+ * itself (task `a-recovered-task-lands-without-stale-question-state`).
979
+ *
980
+ * This is the half step 2c cannot see: a kept branch cut BEFORE the item was
981
+ * surfaced (a re-dispatch continuing from the kept branch, a stranded-branch
982
+ * finish) replays its `git mv ... → tasks/done/` over a `main` whose body now
983
+ * carries `needsAnswers: true` and a sidecar, and git carries the flag into
984
+ * `tasks/done/` and keeps the sidecar. Runs on every rebase of the land: the
985
+ * build path's first rebase, the merge CAS loop's re-rebase (so the CI apply
986
+ * phase is covered), and the stranded-recovery tail.
987
+ *
988
+ * A no-op (no amend) when there is nothing to clear, and when the tip is already
989
+ * on `main` (never rewrite a commit that is not ours). Best-effort: a fault is
990
+ * reported and the land proceeds; the claim-time reconcile remains the backstop.
991
+ */
992
+ async function foldQuestionResidueIntoTip(params) {
993
+ const { cwd, arbiter, slug, env, note } = params;
994
+ try {
995
+ if (isAncestor(cwd, 'HEAD', `${arbiter}/main`, env)) {
996
+ return; // no commit of ours on top of main to fold into.
997
+ }
998
+ const cleared = clearOwnQuestionResidueOnLand({ cwd, slug, note });
999
+ if (cleared.changed.length === 0) {
1000
+ return;
1001
+ }
1002
+ await gitHard(['add', '-A', '--', ...cleared.changed], cwd, env);
1003
+ await gitHard(['commit', '-q', '--amend', '--no-edit'], cwd, env);
1004
+ }
1005
+ catch (err) {
1006
+ note(`Could not fold the stale question state of '${slug}' into its land ` +
1007
+ `(${err instanceof Error ? err.message : String(err)}); the next ` +
1008
+ 'claim reconcile will drain it.');
1009
+ }
1010
+ }
1011
+ // The rebase-conflict needs-attention route, factored so the divergent-ledger
1012
+ // recovery above can fall through to integrate while a genuine code conflict
1013
+ // still routes here.
1014
+ async function rebaseConflictRoute(ctx) {
1015
+ const { cwd, arbiter, slug, branch, commitMessage, env, note } = ctx;
1016
+ // Then mark the item stuck on its per-item lock with the conflict reason
1017
+ // (ADR §12) THROUGH the ledger write seam's needs-attention transition,
1018
+ // rather than leaving it dangling. Post lock-cutover this is a lock amend
1019
+ // (`state: stuck` + the reason on the lock entry, no `done/ →
1020
+ // needs-attention/` folder move); the done-move already committed above
1021
+ // stands. No partial state.
1022
+ const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
1023
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1024
+ cwd,
1025
+ slug,
1026
+ reason,
1027
+ // Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
1028
+ // conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
1029
+ // (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
1030
+ // no surface, no push, local-only. The push lives in the seam (HEAD's
1031
+ // branch is `work/<slug>` here) — no bolted-on push.
1032
+ arbiter: ctx.surfaceArbiter,
1033
+ env,
1034
+ note,
1035
+ });
1036
+ return {
1037
+ outcome: 'rebase-conflict',
1038
+ routedToNeedsAttention: routed.moved,
1039
+ branch,
1040
+ commitMessage,
1041
+ reason: routed.moved
1042
+ ? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1043
+ `aborted (never auto-resolved) and '${slug}' was marked stuck on ` +
1044
+ 'its per-item lock (surfaced by status). Resolve against the latest ' +
1045
+ 'main, then `requeue` to release the stuck lock and re-run.'
1046
+ : `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1047
+ 'aborted (never auto-resolved). Resolve against the latest main, ' +
1048
+ 'then re-run complete.',
1049
+ };
1050
+ }
1051
+ /**
1052
+ * **The LAND half of the rebase-to-integrate tail** (task `ci-split-build-path`,
1053
+ * spec `ci-agent-job-without-write-token` §8): integrate per mode through
1054
+ * `ledgerWrite.applyCompleteTransition`, with the merge-mode compare-and-swap
1055
+ * loop (a non-fast-forward push re-runs {@link rebaseOntoMainWithReconcile} and
1056
+ * retries, never `--force`), then post the approved Gate-2 review prose on the
1057
+ * opened PR. It launches no agent and runs no `prepare` / `verify`: the fresh
1058
+ * gate ran once, before it, in the other half. `performIntegration` reaches it
1059
+ * through the {@link integrationLand} seam, so the CI agent phase RECORDS this
1060
+ * call (the first write of the success path) and the apply phase resumes here
1061
+ * with trusted inputs and the validated work-branch tip.
1062
+ */
1063
+ export async function landIntegration(input) {
1064
+ const { cwd, arbiter, slug, branch, mode, commitMessage, env } = input;
1065
+ const note = input.note;
1066
+ // 5. Integrate per mode through the ledger write seam's COMPLETE transition
1067
+ // (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
1068
+ // already brought the branch up to date, so the seam's sole strategy uses
1069
+ // `integrate` (not `integrateWithRebase`) and never --forces. Provider
1070
+ // selection: an injected `openPr` wins (legacy bridge); otherwise pick by
1071
+ // the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
1072
+ // `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
1073
+ // `gh` degrades to push-only at runtime — never a hard failure (and the
1074
+ // start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
1075
+ // The seam is storage-agnostic: we hand it the work branch, the integration
1076
+ // mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
1077
+ // strategy.
1078
+ // Provider precedence: an injected fully-formed provider wins (the `run`
1079
+ // stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
1080
+ // bridge; else select PURELY from the arbiter URL (no override). The orthogonal
1081
+ // `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
1082
+ // provider, the integrator simply skips `openRequest` when it is set.
1083
+ const provider = input.providerInstance ??
1084
+ (input.openPr
1085
+ ? bridgeProvider(input.openPr)
1086
+ : selectProvider({
1087
+ arbiterUrl: await arbiterUrl(cwd, arbiter, env),
1088
+ }));
1089
+ // Race-1 (claim-vs-integrate, task
1090
+ // `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
1091
+ // and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
1092
+ // under the SEPARATE claim lock — or a sibling integrate advanced
1093
+ // `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
1094
+ // carries the sibling-ledger + divergent-done-move reconcile arms — a bare
1095
+ // re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
1096
+ // retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
1097
+ // main: each retry re-rebases to a clean fast-forward. A genuine code conflict
1098
+ // on a re-rebase routes to needs-attention via the SAME `route` the up-front
1099
+ // rebase uses. A persistent non-fast-forward past the cap also routes (never a
1100
+ // silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
1101
+ //
1102
+ // **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
1103
+ // promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
1104
+ // counts against a tiny give-up budget — only a GENUINE conflict surfaced by
1105
+ // `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
1106
+ // `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
1107
+ // precondition recheck reused verbatim: if the slug is GONE from its expected
1108
+ // source folder on the new `main` (a concurrent legitimate same-item winner
1109
+ // already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
1110
+ // definitively — never a silent re-push that would clobber the winner. The
1111
+ // `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
1112
+ // pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
1113
+ // modest jitter on the refetch desynchronises a herd so the tail is not
1114
+ // reached under sustained parallel load.
1115
+ const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
1116
+ const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
1117
+ // The landed-vs-gated report (ADR `ci-agent-job-holds-no-write-token`
1118
+ // decision 2, task `ci-split-landed-vs-gated-report`), scoped to the CI apply
1119
+ // phase: only it sets `gatedTip` (the validated bundle tip the agent job
1120
+ // gated). The CAS loop below never re-gates, so a re-rebase after a lost race
1121
+ // can land a tree the gate never saw: {@link stampLandedWithoutRegate}
1122
+ // records that on the landed commit. Without `gatedTip` (every non-phase
1123
+ // land) the loop is exactly today's: no tree compare, no message rewrite.
1124
+ const gatedTree = input.gatedTip === undefined
1125
+ ? undefined
1126
+ : await treeOf(cwd, input.gatedTip, env);
1127
+ let regateReport;
1128
+ let integration;
1129
+ for (let mergeAttempt = 0;; mergeAttempt++) {
1130
+ integration = await ledgerWrite.applyCompleteTransition({
1131
+ arbiter,
1132
+ branch,
1133
+ mode,
1134
+ provider,
1135
+ // PR-INTENT: when set (propose mode), push the branch but skip the PR.
1136
+ noPR: input.noPR,
1137
+ // Half A: an explicit single-line PR title (propose mode), so `gh` no longer
1138
+ // derives a run-on title from the commit subject via `--fill`.
1139
+ title: input.title,
1140
+ // Half B: the propose-mode PR body — the agent's summary under a deterministic
1141
+ // runner header (task pointer). Undefined when no body was supplied (the
1142
+ // header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
1143
+ // regression). Ignored in merge mode by the provider/integrator.
1144
+ body: input.body,
1145
+ // Part (b) of the merged-branch hygiene task: when WE perform the merge
1146
+ // (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
1147
+ // INLINE right after the merge lands — the commits are now on `main`, so the
1148
+ // head is provably merged and safe to delete (ancestor-guarded inside the
1149
+ // integrator). Idempotent no-op when no remote head exists (the plain
1150
+ // `${branch}:main` push opened none); ignored in `propose` mode (its branch is
1151
+ // the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
1152
+ deleteMergedHead: true,
1153
+ cwd,
1154
+ env,
1155
+ });
1156
+ // Only the merge push can be non-fast-forward (propose pushes its own ref).
1157
+ if (integration.mergeNonFastForward !== true) {
1158
+ break;
1159
+ }
1160
+ if (mergeAttempt >= maxMergeRetries) {
1161
+ // LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
1162
+ // now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
1163
+ // false-contention budget). Route to needs-attention rather than looping
1164
+ // forever or force-pushing main. A RARE outcome under realistic load thanks
1165
+ // to the jitter below + the rebase-until-real-conflict semantics; tests
1166
+ // reach it deterministically by injecting a small `mergeRetries`.
1167
+ return await mergeNonFastForwardRoute(`integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1168
+ `non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
1169
+ `times); gave up cleanly without --force`);
1170
+ }
1171
+ // Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
1172
+ // maximises mutual rejection under sustained parallel load (thundering herd).
1173
+ // A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
1174
+ // Skipped when `mergeJitterMs === 0` (the test seam).
1175
+ if (mergeJitterMs > 0) {
1176
+ await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
1177
+ }
1178
+ // A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
1179
+ // before retrying the push. A genuine conflict on the re-rebase routes via
1180
+ // `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
1181
+ // one-slug placement recheck IS the genuine-conflict terminator — see the
1182
+ // `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
1183
+ // (no route) loops without counting against a small budget.
1184
+ const reRebase = await rebaseOntoMainWithReconcile(input);
1185
+ if (reRebase.route) {
1186
+ return reRebase.route;
1187
+ }
1188
+ if (gatedTree !== undefined) {
1189
+ regateReport = await stampLandedWithoutRegate({
1190
+ cwd,
1191
+ branch,
1192
+ gatedTree,
1193
+ lostRaces: mergeAttempt + 1,
1194
+ env,
1195
+ });
1196
+ }
1197
+ }
1198
+ if (regateReport !== undefined && integration.mergedToMain === true) {
1199
+ note(`${branch} ${regateReport}.`);
1200
+ }
1201
+ noteRequestNotOpened(integration, branch, note);
1202
+ // The Race-1 needs-attention route for a merge that could not land (a genuine
1203
+ // re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
1204
+ // covers the cap-exhausted persistent-contention case). Mirrors
1205
+ // `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
1206
+ // slug sits in work/done/ and the seam bounces it from there.
1207
+ async function mergeNonFastForwardRoute(reason) {
1208
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1209
+ cwd,
1210
+ slug,
1211
+ reason,
1212
+ arbiter: input.surfaceArbiter,
1213
+ env,
1214
+ note,
1215
+ });
1127
1216
  return {
1128
- outcome: 'completed',
1129
- routedToNeedsAttention: false,
1217
+ outcome: 'rebase-conflict',
1218
+ routedToNeedsAttention: routed.moved,
1130
1219
  branch,
1131
1220
  commitMessage,
1132
- integration,
1221
+ reason: routed.moved
1222
+ ? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1223
+ `non-fast-forward push (a sibling advanced main); '${slug}' was ` +
1224
+ `marked stuck on its per-item lock (surfaced by status). Resolve ` +
1225
+ `against the latest main, then \`requeue\` to release the stuck ` +
1226
+ `lock and re-run.`
1227
+ : `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1228
+ `non-fast-forward push (a sibling advanced main). Resolve against the ` +
1229
+ `latest main, then re-run complete.`,
1133
1230
  };
1231
+ }
1232
+ // 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
1233
+ // refining `review-gate-pr-comment`): AFTER the propose integrate, where the
1234
+ // approved verdict (with its deliberately-authored `review` prose), the
1235
+ // resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
1236
+ // scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
1237
+ // (the audit trail; decided 2026-06-06). The `review` field is a first-class
1238
+ // AUTHORED review (the prompt requires it), NOT the residue around the JSON
1239
+ // — posting the residue was the bug
1240
+ // (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
1241
+ // reuses the SAME `provider` the integrate used (the core never imports `gh`).
1242
+ // The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
1243
+ // — by here the verdict has ALREADY routed (block never reaches this point; it
1244
+ // routed to needs-attention above) and the integrate has ALREADY happened.
1245
+ // The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
1246
+ // (the normal path — post on it directly); else, when a PR WAS opened but its
1247
+ // url was unparseable (`integration.requestOpened` true, `url` undefined — the
1248
+ // `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
1249
+ // BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
1250
+ // the provider resolves the branch's open PR and comments on it, instead of
1251
+ // silently dropping a review on a PR that genuinely exists. Only when NO PR was
1252
+ // opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
1253
+ // false) is it the honest clean no-op — and the branch-resolved fallback's own
1254
+ // "no PR resolvable" path is a clean no-op too (it tries first). Either way
1255
+ // `postPRComment*` never throws; the review stays in the run output. Because
1256
+ // this lives in the shared core, BOTH `do`/`complete` AND `run` post the
1257
+ // comment — no per-caller wiring.
1258
+ if (input.reviewProse !== undefined) {
1259
+ if (integration.url !== undefined) {
1260
+ const posted = provider.postPRComment({
1261
+ cwd,
1262
+ url: integration.url,
1263
+ body: input.reviewProse,
1264
+ env,
1265
+ });
1266
+ note(posted.instruction);
1267
+ }
1268
+ else if (integration.requestOpened) {
1269
+ // A PR opened but its url was unparseable — resolve it from the branch
1270
+ // rather than dropping the review (the audit-trail fallback).
1271
+ const posted = provider.postPRCommentOnBranch({
1272
+ cwd,
1273
+ branch,
1274
+ body: input.reviewProse,
1275
+ env,
1276
+ });
1277
+ note(posted.instruction);
1278
+ }
1279
+ }
1280
+ return {
1281
+ outcome: 'completed',
1282
+ routedToNeedsAttention: false,
1283
+ branch,
1284
+ commitMessage,
1285
+ integration,
1134
1286
  };
1135
- // Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
1136
- // it directly (an un-contended no-op, single-job behaviour unchanged).
1137
- return input.integrateLock && input.integrateLockKey !== undefined
1138
- ? await input.integrateLock(input.integrateLockKey, runRebaseToIntegrateTail)
1139
- : await runRebaseToIntegrateTail();
1140
1287
  }
1141
1288
  /**
1142
1289
  * RECOVER an already-committed, already-done-moved STRANDED branch (spec
@@ -1275,7 +1422,10 @@ async function recoverAlreadyCommitted(params) {
1275
1422
  for (;;) {
1276
1423
  const rebase = await gitSoft(rebaseArgs(), cwd, env);
1277
1424
  if (rebase.status === 0) {
1278
- break; // clean rebase ⇒ fall through to integrate
1425
+ // Clean rebase ⇒ fold in any question residue it brought (the kept
1426
+ // branch predates a surface on `main`), then fall through to integrate.
1427
+ await foldQuestionResidueIntoTip({ cwd, arbiter, slug, env, note });
1428
+ break;
1279
1429
  }
1280
1430
  // ALWAYS abort on conflict — never leave mid-rebase between attempts.
1281
1431
  await gitSoft(['rebase', '--abort'], cwd, env);
@@ -1451,6 +1601,7 @@ async function recoverAlreadyCommitted(params) {
1451
1601
  : `Recovered '${slug}': integrated the kept commit from ${branch} ` +
1452
1602
  `(absorbed a moving ${arbiter}/main across ${attempt} re-fetch+re-` +
1453
1603
  `rebase attempt${attempt === 1 ? '' : 's'}).`);
1604
+ noteRequestNotOpened(integration, branch, note);
1454
1605
  // Post the Gate-2 review as a PR comment (task `committed-recovery-always-
1455
1606
  // reviews`): the SAME post-integrate audit trail the build path keeps — the
1456
1607
  // approved verdict's authored `review` prose, on the opened PR. Advisory only
@@ -1485,6 +1636,21 @@ async function recoverAlreadyCommitted(params) {
1485
1636
  integration,
1486
1637
  };
1487
1638
  }
1639
+ /**
1640
+ * Say so when a propose land opened NO review request (task
1641
+ * `intake-reports-the-pr-it-actually-opened`): the branch is pushed, but the
1642
+ * provider could not (or was told not to) open a PR, so the log prints the
1643
+ * provider's instruction (the real `gh` error + the manual `gh pr create`
1644
+ * command) instead of leaving it unread. Every land (build, tasking, intake,
1645
+ * recovery) passes through here, so no caller has to remember to print it. A
1646
+ * merge land, an opened request and an already-landed no-op print nothing.
1647
+ */
1648
+ function noteRequestNotOpened(integration, branch, note) {
1649
+ const reason = proposeRequestNotOpened(integration);
1650
+ if (reason !== undefined) {
1651
+ note(`No PR was opened for ${branch}: ${reason}`);
1652
+ }
1653
+ }
1488
1654
  /**
1489
1655
  * Resolve the work branch the integration runs on: the branch HEAD is currently
1490
1656
  * on (the caller is ALWAYS on the work branch — the agent built there / the
@@ -1665,6 +1831,8 @@ export function synthesiseProposeTitle(input) {
1665
1831
  * header that points a reviewer back to the task file. Returns `undefined` when
1666
1832
  * no body was supplied — so the provider degrades to today's `gh ... --fill` (no
1667
1833
  * regression); the header is ONLY scaffolded when there IS prose to carry.
1834
+ * `header` overrides the default task-build pointer (`Task: work/tasks/done/<slug>.md`)
1835
+ * for a non-task lifecycle (tasking, intake), whose item rests elsewhere.
1668
1836
  * Exported for unit tests of the header + pointer.
1669
1837
  */
1670
1838
  export function composeProposeBody(input) {
@@ -1672,9 +1840,18 @@ export function composeProposeBody(input) {
1672
1840
  if (!prose) {
1673
1841
  return undefined;
1674
1842
  }
1675
- const header = `Task: \`${workItemRel('done', `${input.slug}.md`)}\``;
1843
+ const header = input.header ??
1844
+ proposePointer('Task', workItemRel('done', `${input.slug}.md`));
1676
1845
  return `${header}\n\n${prose}`;
1677
1846
  }
1847
+ /**
1848
+ * The one-line reviewer pointer a propose PR body opens with: `<label>: `<path>``,
1849
+ * where `path` is the repo-relative file the PR carries (task
1850
+ * `propose-pr-bodies-point-at-the-real-item`).
1851
+ */
1852
+ export function proposePointer(label, path) {
1853
+ return `${label}: \`${path}\``;
1854
+ }
1678
1855
  /**
1679
1856
  * On a review APPROVE that carries ≥1 NON-BLOCKING finding, write ONE per-run
1680
1857
  * observation `work/notes/observations/review-nits-<slug>-<YYYY-MM-DD>.md` capturing all
@@ -1769,21 +1946,13 @@ function readTitle(content) {
1769
1946
  const match = /^title\s*:\s*(.*)$/.exec(line);
1770
1947
  if (match) {
1771
1948
  const value = match[1].trim();
1772
- return value === '' ? undefined : unquote(value);
1949
+ // The shared frontmatter unquoter, so a title written by
1950
+ // `quoteYamlScalar` (`''` for a literal `'`) reads back verbatim.
1951
+ return value === '' ? undefined : unquoteYamlScalar(value);
1773
1952
  }
1774
1953
  }
1775
1954
  return undefined;
1776
1955
  }
1777
- function unquote(value) {
1778
- if (value.length >= 2) {
1779
- const first = value[0];
1780
- const last = value[value.length - 1];
1781
- if ((first === '"' || first === "'") && last === first) {
1782
- return value.slice(1, -1);
1783
- }
1784
- }
1785
- return value;
1786
- }
1787
1956
  function escapeRegExp(value) {
1788
1957
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1789
1958
  }
@@ -2216,39 +2385,15 @@ async function reconcileDivergentDoneMove(params) {
2216
2385
  return true;
2217
2386
  }
2218
2387
  /**
2219
- * Run the Gate-2 PR/code REVIEW gate against a given tree ({@param reviewCwd}) and
2220
- * route a BLOCK to needs-attention, returning DATA the caller acts on. Factored out
2221
- * of {@link performIntegration} so the SAME gate can run in TWO places without
2222
- * forking its logic (MAINTAINER DECISION 2, task `gate-on-rebased-tip-fresh-worktree`):
2223
- *
2224
- * - fresh-worktree gate OFF: the caller invokes it at the FRONT on the pre-rebase
2225
- * `cwd`, right after the front `verify` (today's order, byte-for-byte);
2226
- * - fresh-worktree gate ON: the caller invokes it on the REBASED TIP (the fresh
2227
- * gate worktree), right AFTER the rebased-tip `verify` passes — so
2228
- * verify-THEN-review holds on the SAME merged tree.
2229
- *
2230
- * The review AGENT inspects {@param reviewCwd} (the tree under review); the
2231
- * needs-attention ROUTING always targets `params.cwd` (where the work branch +
2232
- * ledger live), so the routing is identical whichever tree was reviewed — ONLY the
2233
- * source of the reviewed tree moved, not the verdict handling.
2234
- *
2235
- * It NEVER mutates `mode` or writes the nits observation itself (those are the
2236
- * caller's concern, because WHEN they happen differs by path — pre-commit on OFF,
2237
- * post-commit-amend on ON); it returns the verdict and lets the caller place those
2238
- * effects correctly for its band position.
2388
+ * Run a review gate for up to `maxRounds` rounds on ONE tree and return the
2389
+ * verdict as DATA, writing nothing (the WRITE-FREE core of {@link runGate2Review},
2390
+ * which adds the needs-attention routing). Factored out so the CI tasking agent
2391
+ * phase (task `ci-split-tasking`) runs the one-round task-set review the SAME
2392
+ * way `performIntegration` does, in the job that may launch agents, without the
2393
+ * routing writes (the apply phase routes). A non-parse error is re-thrown.
2239
2394
  */
2240
- async function runGate2Review(params) {
2241
- const { reviewCwd, input, slug, branch, cwd, env, note } = params;
2242
- const reviewGate = input.reviewGate;
2243
- if (!reviewGate) {
2244
- // `review` on with no gate wired is a usage error — the floor must never be
2245
- // silently skipped. (Production always wires `harnessReviewGate`.) The caller's
2246
- // try/catch maps a thrown error to its usage-error outcome.
2247
- throw new Error(`review is on but no review gate is configured — cannot run Gate 2 ` +
2248
- `for '${slug}' (this is a wiring bug; the gate must not be skipped).`);
2249
- }
2250
- const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
2251
- note('Running the PR/code review gate (Gate 2)…');
2395
+ export async function runReviewRounds(params) {
2396
+ const { reviewGate, slug, reviewCwd, maxRounds } = params;
2252
2397
  // CORROBORATED-APPROVAL semantics (NOT retry-until-pass): the gate runs the
2253
2398
  // reviewer up to `reviewMaxRounds` times on the SAME tip and approves ONLY if
2254
2399
  // EVERY round approves. A `block` is TERMINAL — it short-circuits the loop and
@@ -2267,72 +2412,28 @@ async function runGate2Review(params) {
2267
2412
  verdict = await reviewGate({
2268
2413
  slug,
2269
2414
  cwd: reviewCwd,
2270
- reviewModel: input.reviewModel,
2415
+ reviewModel: params.reviewModel,
2271
2416
  round,
2272
- // `--watch` threading (task `watch-review-session`): when on, the production
2273
- // gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
2274
- watch: input.watch,
2275
- watchSink: input.watchSink,
2276
- color: input.color,
2277
- sessionsDir: input.sessionsDir,
2278
- // The review AGENT launches with the AMBIENT env, never the identity-scoped
2279
- // `env` (an agent must not act as the bot). Falls back to `env` when no
2280
- // identity is configured (unchanged for non-identity callers).
2281
- env: input.agentEnv ?? env,
2417
+ watch: params.watch,
2418
+ watchSink: params.watchSink,
2419
+ color: params.color,
2420
+ sessionsDir: params.sessionsDir,
2421
+ env: params.env,
2282
2422
  });
2283
2423
  }
2284
2424
  catch (err) {
2285
2425
  if (!(err instanceof ReviewParseError)) {
2286
2426
  // Anything else (a harness/connection throw, a programmer bug) is NOT this
2287
- // gate's concern — re-throw so the existing catch sites classify it.
2427
+ // gate's concern: re-throw so the existing catch sites classify it.
2288
2428
  throw err;
2289
2429
  }
2290
- // THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
2291
- // the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
2292
- // malformed JSON verdict, common on large diffs + weaker models, AFTER the
2293
- // direction-2 repair pass could not salvage it). WITHOUT this catch the throw
2294
- // escapes the core and `performComplete` maps it to the generic `usage-error`
2295
- // (verbatim, no push, no surface) AFTER the green build but BEFORE the
2296
- // done-move/push — STRANDING the lock + work branch with no PR.
2297
- //
2298
- // A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
2299
- // the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
2300
- // the same tip would just be the dice re-roll the corroboration loop forbids).
2301
- // We route through the SAME work-preserving `applyNeedsAttentionTransition`
2302
- // seam the block path uses (it PUSHES the work branch + surfaces the item on
2303
- // `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
2304
- // ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
2305
- // path AND the fresh-worktree `review:` callback are covered by this ONE catch.
2306
- // The recorded reason carries the parse-failure phrase the `failure-cause.ts`
2307
- // signature matches → the `do`/`run` tail classifies it `transient-infra`
2308
- // (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
2309
- // and the direction-2 repair makes a re-run far more likely to parse). NEVER a
2310
- // silent approve.
2311
- const reason = `PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
2312
- `${err.message}`;
2313
- const routed = await ledgerWrite.applyNeedsAttentionTransition({
2314
- cwd,
2315
- slug,
2316
- reason,
2317
- arbiter: input.surfaceArbiter,
2318
- env,
2319
- note,
2320
- });
2321
- const message = routed.moved
2322
- ? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2323
- 'marked it stuck on its per-item lock (work branch pushed + lock ' +
2324
- 'surfaced; transient-infra — re-run). NOT integrated.'
2325
- : `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2326
- 'NOT integrating.';
2327
- note(message);
2430
+ // A parse failure in ANY round is TERMINAL (never re-roll the remaining
2431
+ // rounds). The reason carries the parse-failure phrase the
2432
+ // `failure-cause.ts` signature matches (a transient class).
2328
2433
  return {
2329
- kind: 'blocked',
2330
- result: {
2331
- outcome: 'review-unparseable',
2332
- routedToNeedsAttention: routed.moved,
2333
- branch,
2334
- reason,
2335
- },
2434
+ kind: 'unparseable',
2435
+ reason: `PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
2436
+ `${err.message}`,
2336
2437
  };
2337
2438
  }
2338
2439
  lastVerdict = verdict;
@@ -2346,6 +2447,111 @@ async function runGate2Review(params) {
2346
2447
  // must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
2347
2448
  approved = true;
2348
2449
  }
2450
+ return approved
2451
+ ? { kind: 'approved', verdict: lastVerdict }
2452
+ : { kind: 'blocked', verdict: lastVerdict };
2453
+ }
2454
+ /**
2455
+ * Run the Gate-2 PR/code REVIEW gate against a given tree ({@param reviewCwd}) and
2456
+ * route a BLOCK to needs-attention, returning DATA the caller acts on. Factored out
2457
+ * of {@link performIntegration} so the SAME gate can run in TWO places without
2458
+ * forking its logic (MAINTAINER DECISION 2, task `gate-on-rebased-tip-fresh-worktree`):
2459
+ *
2460
+ * - fresh-worktree gate OFF: the caller invokes it at the FRONT on the pre-rebase
2461
+ * `cwd`, right after the front `verify` (today's order, byte-for-byte);
2462
+ * - fresh-worktree gate ON: the caller invokes it on the REBASED TIP (the fresh
2463
+ * gate worktree), right AFTER the rebased-tip `verify` passes — so
2464
+ * verify-THEN-review holds on the SAME merged tree.
2465
+ *
2466
+ * The review AGENT inspects {@param reviewCwd} (the tree under review); the
2467
+ * needs-attention ROUTING always targets `params.cwd` (where the work branch +
2468
+ * ledger live), so the routing is identical whichever tree was reviewed — ONLY the
2469
+ * source of the reviewed tree moved, not the verdict handling.
2470
+ *
2471
+ * It NEVER mutates `mode` or writes the nits observation itself (those are the
2472
+ * caller's concern, because WHEN they happen differs by path — pre-commit on OFF,
2473
+ * post-commit-amend on ON); it returns the verdict and lets the caller place those
2474
+ * effects correctly for its band position.
2475
+ */
2476
+ async function runGate2Review(params) {
2477
+ const { reviewCwd, input, slug, branch, cwd, env, note } = params;
2478
+ const reviewGate = input.reviewGate;
2479
+ if (!reviewGate) {
2480
+ // `review` on with no gate wired is a usage error — the floor must never be
2481
+ // silently skipped. (Production always wires `harnessReviewGate`.) The caller's
2482
+ // try/catch maps a thrown error to its usage-error outcome.
2483
+ throw new Error(`review is on but no review gate is configured — cannot run Gate 2 ` +
2484
+ `for '${slug}' (this is a wiring bug; the gate must not be skipped).`);
2485
+ }
2486
+ note('Running the PR/code review gate (Gate 2)…');
2487
+ const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
2488
+ const rounds = await runReviewRounds({
2489
+ reviewGate,
2490
+ slug,
2491
+ reviewCwd,
2492
+ maxRounds,
2493
+ reviewModel: input.reviewModel,
2494
+ // `--watch` threading (task `watch-review-session`): when on, the production
2495
+ // gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
2496
+ watch: input.watch,
2497
+ watchSink: input.watchSink,
2498
+ color: input.color,
2499
+ sessionsDir: input.sessionsDir,
2500
+ // The review AGENT launches with the AMBIENT env, never the identity-scoped
2501
+ // `env` (an agent must not act as the bot). Falls back to `env` when no
2502
+ // identity is configured (unchanged for non-identity callers).
2503
+ env: input.agentEnv ?? env,
2504
+ });
2505
+ if (rounds.kind === 'unparseable') {
2506
+ // THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
2507
+ // the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
2508
+ // malformed JSON verdict, common on large diffs + weaker models, AFTER the
2509
+ // direction-2 repair pass could not salvage it). WITHOUT this catch the throw
2510
+ // escapes the core and `performComplete` maps it to the generic `usage-error`
2511
+ // (verbatim, no push, no surface) AFTER the green build but BEFORE the
2512
+ // done-move/push — STRANDING the lock + work branch with no PR.
2513
+ //
2514
+ // A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
2515
+ // the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
2516
+ // the same tip would just be the dice re-roll the corroboration loop forbids).
2517
+ // We route through the SAME work-preserving `applyNeedsAttentionTransition`
2518
+ // seam the block path uses (it PUSHES the work branch + surfaces the item on
2519
+ // `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
2520
+ // ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
2521
+ // path AND the fresh-worktree `review:` callback are covered by this ONE catch.
2522
+ // The recorded reason carries the parse-failure phrase the `failure-cause.ts`
2523
+ // signature matches → the `do`/`run` tail classifies it `transient-infra`
2524
+ // (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
2525
+ // and the direction-2 repair makes a re-run far more likely to parse). NEVER a
2526
+ // silent approve.
2527
+ const reason = rounds.reason;
2528
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
2529
+ cwd,
2530
+ slug,
2531
+ reason,
2532
+ arbiter: input.surfaceArbiter,
2533
+ env,
2534
+ note,
2535
+ });
2536
+ const message = routed.moved
2537
+ ? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2538
+ 'marked it stuck on its per-item lock (work branch pushed + lock ' +
2539
+ 'surfaced; transient-infra — re-run). NOT integrated.'
2540
+ : `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2541
+ 'NOT integrating.';
2542
+ note(message);
2543
+ return {
2544
+ kind: 'blocked',
2545
+ result: {
2546
+ outcome: 'review-unparseable',
2547
+ routedToNeedsAttention: routed.moved,
2548
+ branch,
2549
+ reason,
2550
+ },
2551
+ };
2552
+ }
2553
+ const lastVerdict = rounds.verdict;
2554
+ const approved = rounds.kind === 'approved';
2349
2555
  if (!approved) {
2350
2556
  // NON-approve verdict: route to needs-attention via the SAME seam the red gate
2351
2557
  // uses, NEVER integrate. We reach here EITHER because a round returned a
@@ -2488,13 +2694,80 @@ async function runFreshWorktreeGate(params) {
2488
2694
  }
2489
2695
  }
2490
2696
  }
2697
+ /**
2698
+ * The git trailer key that marks a merge-mode land whose tree differs from the
2699
+ * gated tree (ADR `ci-agent-job-holds-no-write-token` decision 2). Its value is
2700
+ * the report, e.g. `landed without re-gate after 2 lost races`. A git trailer
2701
+ * like `CAS-Nonce` (`ledger-write.ts`), so `git log --format=%(trailers)`
2702
+ * reads it back.
2703
+ */
2704
+ export const LANDED_WITHOUT_REGATE_TRAILER = 'Landed-Without-Regate';
2705
+ /** The landed-vs-gated report for `lostRaces` lost pushes. */
2706
+ export function landedWithoutRegateReport(lostRaces) {
2707
+ return (`landed without re-gate after ${lostRaces} lost ` +
2708
+ (lostRaces === 1 ? 'race' : 'races'));
2709
+ }
2710
+ /** The tree id of `rev` in `cwd`. */
2711
+ async function treeOf(cwd, rev, env) {
2712
+ return (await gitHard(['rev-parse', `${rev}^{tree}`], cwd, env)).stdout.trim();
2713
+ }
2714
+ /**
2715
+ * After the merge-mode CAS loop re-rebased `branch` for the `lostRaces`-th
2716
+ * time, rewrite its tip so its message carries exactly the right
2717
+ * {@link LANDED_WITHOUT_REGATE_TRAILER}: set (with this count) when the tip's
2718
+ * tree differs from `gatedTree`, removed when it is equal again. A previous
2719
+ * attempt's trailer rides the re-rebase as part of the message, which is why an
2720
+ * existing one is always replaced, never appended to. Only the message changes:
2721
+ * the tree, the parents and the author are the tip's own, and the branch ref
2722
+ * moves by a compare-and-swap `update-ref` (the working tree and the index
2723
+ * already match that tree). Returns the report when the trailer is set.
2724
+ */
2725
+ async function stampLandedWithoutRegate(params) {
2726
+ const { cwd, branch, gatedTree, lostRaces, env } = params;
2727
+ const ref = `refs/heads/${branch}`;
2728
+ const FIELD_SEP = '\u0000';
2729
+ const raw = (await gitHard(['log', '-1', '--format=%H%x00%T%x00%P%x00%an%x00%ae%x00%aI%x00%B', ref], cwd, env)).stdout;
2730
+ const [head, tree, parentList, authorName, authorEmail, authorDate, body] = raw.split(FIELD_SEP);
2731
+ const report = tree.trim() === gatedTree
2732
+ ? undefined
2733
+ : landedWithoutRegateReport(lostRaces);
2734
+ const original = (body ?? '').replace(/\s+$/, '');
2735
+ const prefix = `${LANDED_WITHOUT_REGATE_TRAILER}:`;
2736
+ const stripped = original
2737
+ .split('\n')
2738
+ .filter((line) => !line.startsWith(prefix))
2739
+ .join('\n')
2740
+ .replace(/\s+$/, '');
2741
+ const message = report === undefined
2742
+ ? stripped
2743
+ : (await gitHard([
2744
+ 'interpret-trailers',
2745
+ '--trailer',
2746
+ `${LANDED_WITHOUT_REGATE_TRAILER}: ${report}`,
2747
+ ], cwd, env, `${stripped}\n`)).stdout.replace(/\s+$/, '');
2748
+ if (message === original)
2749
+ return report;
2750
+ const parents = parentList
2751
+ .trim()
2752
+ .split(/\s+/)
2753
+ .filter((p) => p !== '')
2754
+ .flatMap((p) => ['-p', p]);
2755
+ const rewritten = (await gitHard(['commit-tree', tree.trim(), ...parents, '-F', '-'], cwd, {
2756
+ ...(env ?? process.env),
2757
+ GIT_AUTHOR_NAME: authorName,
2758
+ GIT_AUTHOR_EMAIL: authorEmail,
2759
+ GIT_AUTHOR_DATE: authorDate,
2760
+ }, `${message}\n`)).stdout.trim();
2761
+ await gitHard(['update-ref', ref, rewritten, head.trim()], cwd, env);
2762
+ return report;
2763
+ }
2491
2764
  /** Run git, returning the raw result (no throw) — for soft checks. */
2492
2765
  function gitSoft(args, cwd, env) {
2493
2766
  return runAsync('git', args, cwd, { env });
2494
2767
  }
2495
2768
  /** Run git; throw on non-zero (genuinely unexpected plumbing failures). */
2496
- async function gitHard(args, cwd, env) {
2497
- const result = await runAsync('git', args, cwd, { env });
2769
+ async function gitHard(args, cwd, env, input) {
2770
+ const result = await runAsync('git', args, cwd, { env, input });
2498
2771
  if (result.status !== 0) {
2499
2772
  throw new Error(`git ${args.join(' ')} failed (exit ${result.status}): ${result.stderr.trim()}`);
2500
2773
  }