dorfl 0.0.0 → 0.1.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 (619) hide show
  1. package/dist/advance-ci-template.d.ts +73 -0
  2. package/dist/advance-ci-template.d.ts.map +1 -0
  3. package/dist/advance-ci-template.js +104 -0
  4. package/dist/advance-ci-template.js.map +1 -0
  5. package/dist/advance-classify.d.ts +132 -0
  6. package/dist/advance-classify.d.ts.map +1 -0
  7. package/dist/advance-classify.js +120 -0
  8. package/dist/advance-classify.js.map +1 -0
  9. package/dist/advance-drivers.d.ts +182 -0
  10. package/dist/advance-drivers.d.ts.map +1 -0
  11. package/dist/advance-drivers.js +231 -0
  12. package/dist/advance-drivers.js.map +1 -0
  13. package/dist/advance-isolated.d.ts +156 -0
  14. package/dist/advance-isolated.d.ts.map +1 -0
  15. package/dist/advance-isolated.js +256 -0
  16. package/dist/advance-isolated.js.map +1 -0
  17. package/dist/advance-lifecycle-template.d.ts +107 -0
  18. package/dist/advance-lifecycle-template.d.ts.map +1 -0
  19. package/dist/advance-lifecycle-template.js +668 -0
  20. package/dist/advance-lifecycle-template.js.map +1 -0
  21. package/dist/advance-loop-driver.d.ts +325 -0
  22. package/dist/advance-loop-driver.d.ts.map +1 -0
  23. package/dist/advance-loop-driver.js +437 -0
  24. package/dist/advance-loop-driver.js.map +1 -0
  25. package/dist/advance-treeless-publish.d.ts +108 -0
  26. package/dist/advance-treeless-publish.d.ts.map +1 -0
  27. package/dist/advance-treeless-publish.js +71 -0
  28. package/dist/advance-treeless-publish.js.map +1 -0
  29. package/dist/advance.d.ts +340 -0
  30. package/dist/advance.d.ts.map +1 -0
  31. package/dist/advance.js +1122 -0
  32. package/dist/advance.js.map +1 -0
  33. package/dist/advancing-lock.d.ts +294 -0
  34. package/dist/advancing-lock.d.ts.map +1 -0
  35. package/dist/advancing-lock.js +594 -0
  36. package/dist/advancing-lock.js.map +1 -0
  37. package/dist/agent-launch.d.ts +79 -0
  38. package/dist/agent-launch.d.ts.map +1 -0
  39. package/dist/agent-launch.js +61 -0
  40. package/dist/agent-launch.js.map +1 -0
  41. package/dist/agent-stop.d.ts +149 -0
  42. package/dist/agent-stop.d.ts.map +1 -0
  43. package/dist/agent-stop.js +307 -0
  44. package/dist/agent-stop.js.map +1 -0
  45. package/dist/apply-decide.d.ts +127 -0
  46. package/dist/apply-decide.d.ts.map +1 -0
  47. package/dist/apply-decide.js +176 -0
  48. package/dist/apply-decide.js.map +1 -0
  49. package/dist/apply-merge-action.d.ts +206 -0
  50. package/dist/apply-merge-action.d.ts.map +1 -0
  51. package/dist/apply-merge-action.js +307 -0
  52. package/dist/apply-merge-action.js.map +1 -0
  53. package/dist/apply-persist.d.ts +174 -0
  54. package/dist/apply-persist.d.ts.map +1 -0
  55. package/dist/apply-persist.js +359 -0
  56. package/dist/apply-persist.js.map +1 -0
  57. package/dist/arbiter.d.ts +120 -0
  58. package/dist/arbiter.d.ts.map +1 -0
  59. package/dist/arbiter.js +255 -0
  60. package/dist/arbiter.js.map +1 -0
  61. package/dist/brand.d.ts +70 -0
  62. package/dist/brand.d.ts.map +1 -0
  63. package/dist/brand.js +84 -0
  64. package/dist/brand.js.map +1 -0
  65. package/dist/buildable-body.d.ts +132 -0
  66. package/dist/buildable-body.d.ts.map +1 -0
  67. package/dist/buildable-body.js +131 -0
  68. package/dist/buildable-body.js.map +1 -0
  69. package/dist/categorise.d.ts +66 -0
  70. package/dist/categorise.d.ts.map +1 -0
  71. package/dist/categorise.js +106 -0
  72. package/dist/categorise.js.map +1 -0
  73. package/dist/claim-cas.d.ts +117 -0
  74. package/dist/claim-cas.d.ts.map +1 -0
  75. package/dist/claim-cas.js +312 -0
  76. package/dist/claim-cas.js.map +1 -0
  77. package/dist/cli-spinner.d.ts +112 -0
  78. package/dist/cli-spinner.d.ts.map +1 -0
  79. package/dist/cli-spinner.js +157 -0
  80. package/dist/cli-spinner.js.map +1 -0
  81. package/dist/cli.d.ts +11 -0
  82. package/dist/cli.d.ts.map +1 -0
  83. package/dist/cli.js +3094 -0
  84. package/dist/cli.js.map +1 -0
  85. package/dist/close-job-template.d.ts +70 -0
  86. package/dist/close-job-template.d.ts.map +1 -0
  87. package/dist/close-job-template.js +180 -0
  88. package/dist/close-job-template.js.map +1 -0
  89. package/dist/close-job.d.ts +95 -0
  90. package/dist/close-job.d.ts.map +1 -0
  91. package/dist/close-job.js +226 -0
  92. package/dist/close-job.js.map +1 -0
  93. package/dist/complete.d.ts +361 -0
  94. package/dist/complete.d.ts.map +1 -0
  95. package/dist/complete.js +885 -0
  96. package/dist/complete.js.map +1 -0
  97. package/dist/concurrency.d.ts +68 -0
  98. package/dist/concurrency.d.ts.map +1 -0
  99. package/dist/concurrency.js +112 -0
  100. package/dist/concurrency.js.map +1 -0
  101. package/dist/config-override.d.ts +76 -0
  102. package/dist/config-override.d.ts.map +1 -0
  103. package/dist/config-override.js +50 -0
  104. package/dist/config-override.js.map +1 -0
  105. package/dist/config.d.ts +668 -0
  106. package/dist/config.d.ts.map +1 -0
  107. package/dist/config.js +241 -0
  108. package/dist/config.js.map +1 -0
  109. package/dist/continue-branch.d.ts +249 -0
  110. package/dist/continue-branch.d.ts.map +1 -0
  111. package/dist/continue-branch.js +389 -0
  112. package/dist/continue-branch.js.map +1 -0
  113. package/dist/cwd-section.d.ts +186 -0
  114. package/dist/cwd-section.d.ts.map +1 -0
  115. package/dist/cwd-section.js +209 -0
  116. package/dist/cwd-section.js.map +1 -0
  117. package/dist/decision-engine.d.ts +170 -0
  118. package/dist/decision-engine.d.ts.map +1 -0
  119. package/dist/decision-engine.js +136 -0
  120. package/dist/decision-engine.js.map +1 -0
  121. package/dist/detect.d.ts +17 -0
  122. package/dist/detect.d.ts.map +1 -0
  123. package/dist/detect.js +118 -0
  124. package/dist/detect.js.map +1 -0
  125. package/dist/do-autopick.d.ts +85 -0
  126. package/dist/do-autopick.d.ts.map +1 -0
  127. package/dist/do-autopick.js +112 -0
  128. package/dist/do-autopick.js.map +1 -0
  129. package/dist/do-config.d.ts +312 -0
  130. package/dist/do-config.d.ts.map +1 -0
  131. package/dist/do-config.js +358 -0
  132. package/dist/do-config.js.map +1 -0
  133. package/dist/do-remote-auto.d.ts +75 -0
  134. package/dist/do-remote-auto.d.ts.map +1 -0
  135. package/dist/do-remote-auto.js +111 -0
  136. package/dist/do-remote-auto.js.map +1 -0
  137. package/dist/do.d.ts +621 -0
  138. package/dist/do.d.ts.map +1 -0
  139. package/dist/do.js +1882 -0
  140. package/dist/do.js.map +1 -0
  141. package/dist/drop-source.d.ts +96 -0
  142. package/dist/drop-source.d.ts.map +1 -0
  143. package/dist/drop-source.js +91 -0
  144. package/dist/drop-source.js.map +1 -0
  145. package/dist/eligibility.d.ts +46 -0
  146. package/dist/eligibility.d.ts.map +1 -0
  147. package/dist/eligibility.js +34 -0
  148. package/dist/eligibility.js.map +1 -0
  149. package/dist/env-config.d.ts +51 -0
  150. package/dist/env-config.d.ts.map +1 -0
  151. package/dist/env-config.js +272 -0
  152. package/dist/env-config.js.map +1 -0
  153. package/dist/failure-cause.d.ts +70 -0
  154. package/dist/failure-cause.d.ts.map +1 -0
  155. package/dist/failure-cause.js +126 -0
  156. package/dist/failure-cause.js.map +1 -0
  157. package/dist/format.d.ts +43 -0
  158. package/dist/format.d.ts.map +1 -0
  159. package/dist/format.js +256 -0
  160. package/dist/format.js.map +1 -0
  161. package/dist/frontmatter.d.ts +215 -0
  162. package/dist/frontmatter.d.ts.map +1 -0
  163. package/dist/frontmatter.js +345 -0
  164. package/dist/frontmatter.js.map +1 -0
  165. package/dist/gate-readiness.d.ts +84 -0
  166. package/dist/gate-readiness.d.ts.map +1 -0
  167. package/dist/gate-readiness.js +103 -0
  168. package/dist/gate-readiness.js.map +1 -0
  169. package/dist/gc.d.ts +165 -0
  170. package/dist/gc.d.ts.map +1 -0
  171. package/dist/gc.js +313 -0
  172. package/dist/gc.js.map +1 -0
  173. package/dist/gh-failure.d.ts +42 -0
  174. package/dist/gh-failure.d.ts.map +1 -0
  175. package/dist/gh-failure.js +49 -0
  176. package/dist/gh-failure.js.map +1 -0
  177. package/dist/git.d.ts +75 -0
  178. package/dist/git.d.ts.map +1 -0
  179. package/dist/git.js +130 -0
  180. package/dist/git.js.map +1 -0
  181. package/dist/github.d.ts +187 -0
  182. package/dist/github.d.ts.map +1 -0
  183. package/dist/github.js +343 -0
  184. package/dist/github.js.map +1 -0
  185. package/dist/harness.d.ts +242 -0
  186. package/dist/harness.d.ts.map +1 -0
  187. package/dist/harness.js +157 -0
  188. package/dist/harness.js.map +1 -0
  189. package/dist/identity.d.ts +167 -0
  190. package/dist/identity.d.ts.map +1 -0
  191. package/dist/identity.js +231 -0
  192. package/dist/identity.js.map +1 -0
  193. package/dist/index.d.ts +147 -0
  194. package/dist/index.d.ts.map +1 -0
  195. package/dist/index.js +76 -0
  196. package/dist/index.js.map +1 -0
  197. package/dist/install-ci-branch-protection.d.ts +147 -0
  198. package/dist/install-ci-branch-protection.d.ts.map +1 -0
  199. package/dist/install-ci-branch-protection.js +166 -0
  200. package/dist/install-ci-branch-protection.js.map +1 -0
  201. package/dist/install-ci-capabilities/advance-lifecycle.d.ts +15 -0
  202. package/dist/install-ci-capabilities/advance-lifecycle.d.ts.map +1 -0
  203. package/dist/install-ci-capabilities/advance-lifecycle.js +28 -0
  204. package/dist/install-ci-capabilities/advance-lifecycle.js.map +1 -0
  205. package/dist/install-ci-capabilities/close-job.d.ts +13 -0
  206. package/dist/install-ci-capabilities/close-job.d.ts.map +1 -0
  207. package/dist/install-ci-capabilities/close-job.js +26 -0
  208. package/dist/install-ci-capabilities/close-job.js.map +1 -0
  209. package/dist/install-ci-capabilities/example-noop.d.ts +16 -0
  210. package/dist/install-ci-capabilities/example-noop.d.ts.map +1 -0
  211. package/dist/install-ci-capabilities/example-noop.js +23 -0
  212. package/dist/install-ci-capabilities/example-noop.js.map +1 -0
  213. package/dist/install-ci-capabilities/intake.d.ts +15 -0
  214. package/dist/install-ci-capabilities/intake.d.ts.map +1 -0
  215. package/dist/install-ci-capabilities/intake.js +28 -0
  216. package/dist/install-ci-capabilities/intake.js.map +1 -0
  217. package/dist/install-ci-capabilities/verify.d.ts +14 -0
  218. package/dist/install-ci-capabilities/verify.d.ts.map +1 -0
  219. package/dist/install-ci-capabilities/verify.js +27 -0
  220. package/dist/install-ci-capabilities/verify.js.map +1 -0
  221. package/dist/install-ci-core.d.ts +446 -0
  222. package/dist/install-ci-core.d.ts.map +1 -0
  223. package/dist/install-ci-core.js +760 -0
  224. package/dist/install-ci-core.js.map +1 -0
  225. package/dist/install-ci-github.d.ts +167 -0
  226. package/dist/install-ci-github.d.ts.map +1 -0
  227. package/dist/install-ci-github.js +315 -0
  228. package/dist/install-ci-github.js.map +1 -0
  229. package/dist/install-ci.d.ts +105 -0
  230. package/dist/install-ci.d.ts.map +1 -0
  231. package/dist/install-ci.js +363 -0
  232. package/dist/install-ci.js.map +1 -0
  233. package/dist/intake-event.d.ts +88 -0
  234. package/dist/intake-event.d.ts.map +1 -0
  235. package/dist/intake-event.js +66 -0
  236. package/dist/intake-event.js.map +1 -0
  237. package/dist/intake-marker.d.ts +95 -0
  238. package/dist/intake-marker.d.ts.map +1 -0
  239. package/dist/intake-marker.js +127 -0
  240. package/dist/intake-marker.js.map +1 -0
  241. package/dist/intake-triage.d.ts +48 -0
  242. package/dist/intake-triage.d.ts.map +1 -0
  243. package/dist/intake-triage.js +95 -0
  244. package/dist/intake-triage.js.map +1 -0
  245. package/dist/intake-trigger-template.d.ts +185 -0
  246. package/dist/intake-trigger-template.d.ts.map +1 -0
  247. package/dist/intake-trigger-template.js +449 -0
  248. package/dist/intake-trigger-template.js.map +1 -0
  249. package/dist/intake.d.ts +569 -0
  250. package/dist/intake.d.ts.map +1 -0
  251. package/dist/intake.js +1628 -0
  252. package/dist/intake.js.map +1 -0
  253. package/dist/integration-core.d.ts +539 -0
  254. package/dist/integration-core.d.ts.map +1 -0
  255. package/dist/integration-core.js +2195 -0
  256. package/dist/integration-core.js.map +1 -0
  257. package/dist/integrator.d.ts +343 -0
  258. package/dist/integrator.d.ts.map +1 -0
  259. package/dist/integrator.js +400 -0
  260. package/dist/integrator.js.map +1 -0
  261. package/dist/isolation.d.ts +219 -0
  262. package/dist/isolation.d.ts.map +1 -0
  263. package/dist/isolation.js +261 -0
  264. package/dist/isolation.js.map +1 -0
  265. package/dist/issue-provider.d.ts +349 -0
  266. package/dist/issue-provider.d.ts.map +1 -0
  267. package/dist/issue-provider.js +360 -0
  268. package/dist/issue-provider.js.map +1 -0
  269. package/dist/item-lock.d.ts +626 -0
  270. package/dist/item-lock.d.ts.map +1 -0
  271. package/dist/item-lock.js +1381 -0
  272. package/dist/item-lock.js.map +1 -0
  273. package/dist/item-path.d.ts +49 -0
  274. package/dist/item-path.d.ts.map +1 -0
  275. package/dist/item-path.js +66 -0
  276. package/dist/item-path.js.map +1 -0
  277. package/dist/ledger-lint.d.ts +129 -0
  278. package/dist/ledger-lint.d.ts.map +1 -0
  279. package/dist/ledger-lint.js +249 -0
  280. package/dist/ledger-lint.js.map +1 -0
  281. package/dist/ledger-read.d.ts +357 -0
  282. package/dist/ledger-read.d.ts.map +1 -0
  283. package/dist/ledger-read.js +442 -0
  284. package/dist/ledger-read.js.map +1 -0
  285. package/dist/ledger-write.d.ts +330 -0
  286. package/dist/ledger-write.d.ts.map +1 -0
  287. package/dist/ledger-write.js +411 -0
  288. package/dist/ledger-write.js.map +1 -0
  289. package/dist/lifecycle-gather.d.ts +30 -0
  290. package/dist/lifecycle-gather.d.ts.map +1 -0
  291. package/dist/lifecycle-gather.js +205 -0
  292. package/dist/lifecycle-gather.js.map +1 -0
  293. package/dist/lifecycle-pools.d.ts +180 -0
  294. package/dist/lifecycle-pools.d.ts.map +1 -0
  295. package/dist/lifecycle-pools.js +78 -0
  296. package/dist/lifecycle-pools.js.map +1 -0
  297. package/dist/merge-question-surfacer.d.ts +166 -0
  298. package/dist/merge-question-surfacer.d.ts.map +1 -0
  299. package/dist/merge-question-surfacer.js +297 -0
  300. package/dist/merge-question-surfacer.js.map +1 -0
  301. package/dist/mint-adr.d.ts +126 -0
  302. package/dist/mint-adr.d.ts.map +1 -0
  303. package/dist/mint-adr.js +257 -0
  304. package/dist/mint-adr.js.map +1 -0
  305. package/dist/mirror-pool-scan.d.ts +125 -0
  306. package/dist/mirror-pool-scan.d.ts.map +1 -0
  307. package/dist/mirror-pool-scan.js +104 -0
  308. package/dist/mirror-pool-scan.js.map +1 -0
  309. package/dist/needs-attention.d.ts +341 -0
  310. package/dist/needs-attention.d.ts.map +1 -0
  311. package/dist/needs-attention.js +900 -0
  312. package/dist/needs-attention.js.map +1 -0
  313. package/dist/orphan-sidecar.d.ts +79 -0
  314. package/dist/orphan-sidecar.d.ts.map +1 -0
  315. package/dist/orphan-sidecar.js +71 -0
  316. package/dist/orphan-sidecar.js.map +1 -0
  317. package/dist/output.d.ts +48 -0
  318. package/dist/output.d.ts.map +1 -0
  319. package/dist/output.js +66 -0
  320. package/dist/output.js.map +1 -0
  321. package/dist/pi-harness.d.ts +179 -0
  322. package/dist/pi-harness.d.ts.map +1 -0
  323. package/dist/pi-harness.js +342 -0
  324. package/dist/pi-harness.js.map +1 -0
  325. package/dist/placement.d.ts +99 -0
  326. package/dist/placement.d.ts.map +1 -0
  327. package/dist/placement.js +67 -0
  328. package/dist/placement.js.map +1 -0
  329. package/dist/prd-to-spec.d.ts +315 -0
  330. package/dist/prd-to-spec.d.ts.map +1 -0
  331. package/dist/prd-to-spec.js +684 -0
  332. package/dist/prd-to-spec.js.map +1 -0
  333. package/dist/prepare.d.ts +121 -0
  334. package/dist/prepare.d.ts.map +1 -0
  335. package/dist/prepare.js +140 -0
  336. package/dist/prepare.js.map +1 -0
  337. package/dist/prompt.d.ts +360 -0
  338. package/dist/prompt.d.ts.map +1 -0
  339. package/dist/prompt.js +499 -0
  340. package/dist/prompt.js.map +1 -0
  341. package/dist/protocol/ADR-FORMAT.md +47 -0
  342. package/dist/protocol/CLAIM-PROTOCOL.md +217 -0
  343. package/dist/protocol/REVIEW-PROTOCOL.md +119 -0
  344. package/dist/protocol/SURFACE-PROTOCOL.md +121 -0
  345. package/dist/protocol/TASKING-PROTOCOL.md +122 -0
  346. package/dist/protocol/WORK-CONTRACT.md +276 -0
  347. package/dist/protocol/spec-template.md +71 -0
  348. package/dist/protocol/task-template.md +65 -0
  349. package/dist/readiness.d.ts +66 -0
  350. package/dist/readiness.d.ts.map +1 -0
  351. package/dist/readiness.js +36 -0
  352. package/dist/readiness.js.map +1 -0
  353. package/dist/reap-branches.d.ts +102 -0
  354. package/dist/reap-branches.d.ts.map +1 -0
  355. package/dist/reap-branches.js +149 -0
  356. package/dist/reap-branches.js.map +1 -0
  357. package/dist/recover-isolated.d.ts +72 -0
  358. package/dist/recover-isolated.d.ts.map +1 -0
  359. package/dist/recover-isolated.js +188 -0
  360. package/dist/recover-isolated.js.map +1 -0
  361. package/dist/registry.d.ts +172 -0
  362. package/dist/registry.d.ts.map +1 -0
  363. package/dist/registry.js +296 -0
  364. package/dist/registry.js.map +1 -0
  365. package/dist/repo-config.d.ts +201 -0
  366. package/dist/repo-config.d.ts.map +1 -0
  367. package/dist/repo-config.js +414 -0
  368. package/dist/repo-config.js.map +1 -0
  369. package/dist/repo-key.d.ts +20 -0
  370. package/dist/repo-key.d.ts.map +1 -0
  371. package/dist/repo-key.js +68 -0
  372. package/dist/repo-key.js.map +1 -0
  373. package/dist/repo-mirror.d.ts +177 -0
  374. package/dist/repo-mirror.d.ts.map +1 -0
  375. package/dist/repo-mirror.js +271 -0
  376. package/dist/repo-mirror.js.map +1 -0
  377. package/dist/retry-backoff.d.ts +90 -0
  378. package/dist/retry-backoff.d.ts.map +1 -0
  379. package/dist/retry-backoff.js +98 -0
  380. package/dist/retry-backoff.js.map +1 -0
  381. package/dist/review-gate.d.ts +173 -0
  382. package/dist/review-gate.d.ts.map +1 -0
  383. package/dist/review-gate.js +261 -0
  384. package/dist/review-gate.js.map +1 -0
  385. package/dist/review-verdict.d.ts +149 -0
  386. package/dist/review-verdict.d.ts.map +1 -0
  387. package/dist/review-verdict.js +332 -0
  388. package/dist/review-verdict.js.map +1 -0
  389. package/dist/run.d.ts +221 -0
  390. package/dist/run.d.ts.map +1 -0
  391. package/dist/run.js +963 -0
  392. package/dist/run.js.map +1 -0
  393. package/dist/scan.d.ts +308 -0
  394. package/dist/scan.d.ts.map +1 -0
  395. package/dist/scan.js +374 -0
  396. package/dist/scan.js.map +1 -0
  397. package/dist/select-order.d.ts +75 -0
  398. package/dist/select-order.d.ts.map +1 -0
  399. package/dist/select-order.js +108 -0
  400. package/dist/select-order.js.map +1 -0
  401. package/dist/select-priority.d.ts +188 -0
  402. package/dist/select-priority.d.ts.map +1 -0
  403. package/dist/select-priority.js +80 -0
  404. package/dist/select-priority.js.map +1 -0
  405. package/dist/select.d.ts +25 -0
  406. package/dist/select.d.ts.map +1 -0
  407. package/dist/select.js +43 -0
  408. package/dist/select.js.map +1 -0
  409. package/dist/session-path.d.ts +36 -0
  410. package/dist/session-path.d.ts.map +1 -0
  411. package/dist/session-path.js +129 -0
  412. package/dist/session-path.js.map +1 -0
  413. package/dist/sidecar-apply.d.ts +83 -0
  414. package/dist/sidecar-apply.d.ts.map +1 -0
  415. package/dist/sidecar-apply.js +111 -0
  416. package/dist/sidecar-apply.js.map +1 -0
  417. package/dist/sidecar.d.ts +245 -0
  418. package/dist/sidecar.d.ts.map +1 -0
  419. package/dist/sidecar.js +481 -0
  420. package/dist/sidecar.js.map +1 -0
  421. package/dist/slug-namespace.d.ts +204 -0
  422. package/dist/slug-namespace.d.ts.map +1 -0
  423. package/dist/slug-namespace.js +229 -0
  424. package/dist/slug-namespace.js.map +1 -0
  425. package/dist/spec-complete.d.ts +44 -0
  426. package/dist/spec-complete.d.ts.map +1 -0
  427. package/dist/spec-complete.js +69 -0
  428. package/dist/spec-complete.js.map +1 -0
  429. package/dist/start.d.ts +97 -0
  430. package/dist/start.d.ts.map +1 -0
  431. package/dist/start.js +633 -0
  432. package/dist/start.js.map +1 -0
  433. package/dist/status.d.ts +199 -0
  434. package/dist/status.d.ts.map +1 -0
  435. package/dist/status.js +228 -0
  436. package/dist/status.js.map +1 -0
  437. package/dist/surface-gate.d.ts +162 -0
  438. package/dist/surface-gate.d.ts.map +1 -0
  439. package/dist/surface-gate.js +206 -0
  440. package/dist/surface-gate.js.map +1 -0
  441. package/dist/surface-persist.d.ts +86 -0
  442. package/dist/surface-persist.d.ts.map +1 -0
  443. package/dist/surface-persist.js +129 -0
  444. package/dist/surface-persist.js.map +1 -0
  445. package/dist/tasker-review-loop.d.ts +249 -0
  446. package/dist/tasker-review-loop.d.ts.map +1 -0
  447. package/dist/tasker-review-loop.js +369 -0
  448. package/dist/tasker-review-loop.js.map +1 -0
  449. package/dist/tasking-eligibility.d.ts +74 -0
  450. package/dist/tasking-eligibility.d.ts.map +1 -0
  451. package/dist/tasking-eligibility.js +52 -0
  452. package/dist/tasking-eligibility.js.map +1 -0
  453. package/dist/tasking-lock.d.ts +111 -0
  454. package/dist/tasking-lock.d.ts.map +1 -0
  455. package/dist/tasking-lock.js +256 -0
  456. package/dist/tasking-lock.js.map +1 -0
  457. package/dist/tasking.d.ts +275 -0
  458. package/dist/tasking.d.ts.map +1 -0
  459. package/dist/tasking.js +952 -0
  460. package/dist/tasking.js.map +1 -0
  461. package/dist/triage-gate.d.ts +127 -0
  462. package/dist/triage-gate.d.ts.map +1 -0
  463. package/dist/triage-gate.js +139 -0
  464. package/dist/triage-gate.js.map +1 -0
  465. package/dist/triage-persist.d.ts +163 -0
  466. package/dist/triage-persist.d.ts.map +1 -0
  467. package/dist/triage-persist.js +387 -0
  468. package/dist/triage-persist.js.map +1 -0
  469. package/dist/verdict-json.d.ts +32 -0
  470. package/dist/verdict-json.d.ts.map +1 -0
  471. package/dist/verdict-json.js +74 -0
  472. package/dist/verdict-json.js.map +1 -0
  473. package/dist/verify-workflow-template.d.ts +60 -0
  474. package/dist/verify-workflow-template.d.ts.map +1 -0
  475. package/dist/verify-workflow-template.js +126 -0
  476. package/dist/verify-workflow-template.js.map +1 -0
  477. package/dist/verify.d.ts +60 -0
  478. package/dist/verify.d.ts.map +1 -0
  479. package/dist/verify.js +62 -0
  480. package/dist/verify.js.map +1 -0
  481. package/dist/watch-session.d.ts +112 -0
  482. package/dist/watch-session.d.ts.map +1 -0
  483. package/dist/watch-session.js +347 -0
  484. package/dist/watch-session.js.map +1 -0
  485. package/dist/work-layout.d.ts +198 -0
  486. package/dist/work-layout.d.ts.map +1 -0
  487. package/dist/work-layout.js +217 -0
  488. package/dist/work-layout.js.map +1 -0
  489. package/dist/work-on.d.ts +154 -0
  490. package/dist/work-on.d.ts.map +1 -0
  491. package/dist/work-on.js +387 -0
  492. package/dist/work-on.js.map +1 -0
  493. package/dist/workspace.d.ts +224 -0
  494. package/dist/workspace.d.ts.map +1 -0
  495. package/dist/workspace.js +325 -0
  496. package/dist/workspace.js.map +1 -0
  497. package/package.json +46 -2
  498. package/src/advance-ci-template.ts +203 -0
  499. package/src/advance-classify.ts +197 -0
  500. package/src/advance-drivers.ts +414 -0
  501. package/src/advance-isolated.ts +432 -0
  502. package/src/advance-lifecycle-template.ts +791 -0
  503. package/src/advance-loop-driver.ts +745 -0
  504. package/src/advance-treeless-publish.ts +177 -0
  505. package/src/advance.ts +1564 -0
  506. package/src/advancing-lock.ts +988 -0
  507. package/src/agent-launch.ts +137 -0
  508. package/src/agent-stop.ts +361 -0
  509. package/src/apply-decide.ts +242 -0
  510. package/src/apply-merge-action.ts +502 -0
  511. package/src/apply-persist.ts +518 -0
  512. package/src/arbiter.ts +372 -0
  513. package/src/brand.ts +111 -0
  514. package/src/buildable-body.ts +196 -0
  515. package/src/categorise.ts +158 -0
  516. package/src/claim-cas.ts +513 -0
  517. package/src/cli-spinner.ts +225 -0
  518. package/src/cli.ts +4369 -0
  519. package/src/close-job-template.ts +236 -0
  520. package/src/close-job.ts +319 -0
  521. package/src/complete.ts +1379 -0
  522. package/src/concurrency.ts +151 -0
  523. package/src/config-override.ts +116 -0
  524. package/src/config.ts +883 -0
  525. package/src/continue-branch.ts +542 -0
  526. package/src/cwd-section.ts +392 -0
  527. package/src/decision-engine.ts +272 -0
  528. package/src/detect.ts +124 -0
  529. package/src/do-autopick.ts +223 -0
  530. package/src/do-config.ts +589 -0
  531. package/src/do-remote-auto.ts +197 -0
  532. package/src/do.ts +2623 -0
  533. package/src/drop-source.ts +194 -0
  534. package/src/eligibility.ts +79 -0
  535. package/src/env-config.ts +305 -0
  536. package/src/failure-cause.ts +142 -0
  537. package/src/format.ts +313 -0
  538. package/src/frontmatter.ts +485 -0
  539. package/src/gate-readiness.ts +147 -0
  540. package/src/gc.ts +510 -0
  541. package/src/gh-failure.ts +53 -0
  542. package/src/git.ts +186 -0
  543. package/src/github.ts +468 -0
  544. package/src/harness.ts +355 -0
  545. package/src/identity.ts +322 -0
  546. package/src/index.ts +785 -0
  547. package/src/install-ci-branch-protection.ts +255 -0
  548. package/src/install-ci-capabilities/advance-lifecycle.ts +34 -0
  549. package/src/install-ci-capabilities/close-job.ts +32 -0
  550. package/src/install-ci-capabilities/example-noop.ts +24 -0
  551. package/src/install-ci-capabilities/intake.ts +34 -0
  552. package/src/install-ci-capabilities/verify.ts +33 -0
  553. package/src/install-ci-core.ts +1088 -0
  554. package/src/install-ci-github.ts +376 -0
  555. package/src/install-ci.ts +552 -0
  556. package/src/intake-event.ts +102 -0
  557. package/src/intake-marker.ts +195 -0
  558. package/src/intake-triage.ts +138 -0
  559. package/src/intake-trigger-template.ts +591 -0
  560. package/src/intake.ts +2445 -0
  561. package/src/integration-core.ts +3065 -0
  562. package/src/integrator.ts +771 -0
  563. package/src/isolation.ts +484 -0
  564. package/src/issue-provider.ts +733 -0
  565. package/src/item-lock.ts +1858 -0
  566. package/src/item-path.ts +75 -0
  567. package/src/ledger-lint.ts +332 -0
  568. package/src/ledger-read.ts +924 -0
  569. package/src/ledger-write.ts +865 -0
  570. package/src/lifecycle-gather.ts +298 -0
  571. package/src/lifecycle-pools.ts +250 -0
  572. package/src/merge-question-surfacer.ts +496 -0
  573. package/src/mint-adr.ts +362 -0
  574. package/src/mirror-pool-scan.ts +240 -0
  575. package/src/needs-attention.ts +1506 -0
  576. package/src/orphan-sidecar.ts +150 -0
  577. package/src/output.ts +89 -0
  578. package/src/pi-harness.ts +403 -0
  579. package/src/placement.ts +131 -0
  580. package/src/prd-to-spec.ts +1023 -0
  581. package/src/prepare.ts +230 -0
  582. package/src/prompt.ts +760 -0
  583. package/src/readiness.ts +98 -0
  584. package/src/reap-branches.ts +278 -0
  585. package/src/recover-isolated.ts +276 -0
  586. package/src/registry.ts +475 -0
  587. package/src/repo-config.ts +550 -0
  588. package/src/repo-key.ts +74 -0
  589. package/src/repo-mirror.ts +367 -0
  590. package/src/retry-backoff.ts +130 -0
  591. package/src/review-gate.ts +389 -0
  592. package/src/review-verdict.ts +422 -0
  593. package/src/run.ts +1430 -0
  594. package/src/scan.ts +611 -0
  595. package/src/select-order.ts +143 -0
  596. package/src/select-priority.ts +266 -0
  597. package/src/select.ts +62 -0
  598. package/src/session-path.ts +153 -0
  599. package/src/sidecar-apply.ts +216 -0
  600. package/src/sidecar.ts +700 -0
  601. package/src/slug-namespace.ts +367 -0
  602. package/src/spec-complete.ts +118 -0
  603. package/src/start.ts +974 -0
  604. package/src/status.ts +441 -0
  605. package/src/surface-gate.ts +337 -0
  606. package/src/surface-persist.ts +241 -0
  607. package/src/tasker-review-loop.ts +671 -0
  608. package/src/tasking-eligibility.ts +114 -0
  609. package/src/tasking-lock.ts +416 -0
  610. package/src/tasking.ts +1438 -0
  611. package/src/triage-gate.ts +248 -0
  612. package/src/triage-persist.ts +570 -0
  613. package/src/verdict-json.ts +73 -0
  614. package/src/verify-workflow-template.ts +159 -0
  615. package/src/verify.ts +123 -0
  616. package/src/watch-session.ts +397 -0
  617. package/src/work-layout.ts +262 -0
  618. package/src/work-on.ts +660 -0
  619. package/src/workspace.ts +502 -0
@@ -0,0 +1,3065 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from 'node:fs';
9
+ import {tmpdir} from 'node:os';
10
+ import {join} from 'node:path';
11
+ import {
12
+ LEDGER_STATUS_FOLDERS,
13
+ type LedgerStatusFolder,
14
+ type WorkFolderKey,
15
+ workItemPath,
16
+ workItemRel,
17
+ workFolderPath,
18
+ workFolderPrefix,
19
+ } from './work-layout.js';
20
+ import {runVerify, type VerifyConfig} from './verify.js';
21
+ import {ensurePrepared} from './prepare.js';
22
+ import {
23
+ type ReviewGate,
24
+ type ReviewFinding,
25
+ type ReviewVerdict,
26
+ ReviewParseError,
27
+ formatBlockReason,
28
+ reviewRoundsExhaustedReason,
29
+ } from './review-gate.js';
30
+ import {type IntegrateResult, type ReviewProvider} from './integrator.js';
31
+ import {ledgerWrite} from './ledger-write.js';
32
+ import {selectProvider} from './github.js';
33
+ import type {IntegrationMode} from './config.js';
34
+ import {parseFrontmatter} from './frontmatter.js';
35
+ import {git, run, runAsync, type RunResult} from './git.js';
36
+ import {realSleep, type Sleep} from './retry-backoff.js';
37
+ import {workBranchRef} from './slug-namespace.js';
38
+ import {isAncestor} from './gc.js';
39
+
40
+ /**
41
+ * **The shared gate→integrate BACK-HALF** of the per-item pipeline, extracted out
42
+ * of `performComplete` (`complete.ts`) so BOTH the human `do`/`complete` path and
43
+ * (a later task) the autonomous `run` path share ONE implementation of the
44
+ * gate→integrate band. The exact ORDER depends on the fresh-worktree gate
45
+ * (`freshWorktreeGate`, task `gate-on-rebased-tip-fresh-worktree`):
46
+ *
47
+ * - fresh gate OFF (the pre-rebase gate, today byte-for-byte):
48
+ * verify (cwd) → review (cwd) → effective-mode decision → done-move →
49
+ * atomic commit → rebase-onto-arbiter → integrate.
50
+ * - fresh gate ON (the default; gate the tree that MERGES):
51
+ * done-move → atomic commit → rebase-onto-arbiter → verify (rebased tip) →
52
+ * review (rebased tip) → effective-mode decision → integrate.
53
+ *
54
+ * Either way `verify` is the deterministic FLOOR and runs FIRST, with the Gate-2
55
+ * REVIEW (judgement) layered ON TOP and only on `verify`'s green — and (ON path,
56
+ * MAINTAINER DECISION 2) BOTH run on the SAME rebased tip (the tree that actually
57
+ * integrates), so verify-then-review holds on the merged tree, never split across
58
+ * two trees. Any failure routes to needs-attention.
59
+ *
60
+ * It is the CORE in the head / core / tail decomposition (prd
61
+ * `work/prds/tasked/run-do-integrate-convergence.md`): the caller-specific HEAD
62
+ * (repo/arbiter/branch checks, source resolution, the `recovering` flag) and TAIL
63
+ * (switch-to-main / `syncLocalMain` / delete-local-branch / `--no-switch` / the
64
+ * propose next-step block, or — for `run` — the job-record + worktree reap) stay
65
+ * in their respective callers; this is the band they share.
66
+ *
67
+ * It returns DATA ({@link IntegrationCoreResult}) and performs the needs-attention
68
+ * ROUTING and the effective-mode DECISION — but NO caller-specific side effects
69
+ * (no `git switch main`, no branch delete, no job record, no propose next-step
70
+ * block). The human-vs-autonomous difference rides entirely on the {@link
71
+ * IntegrationCoreInput.surfaceArbiter} field (human = unset ⇒ local-only routing;
72
+ * autonomous = set ⇒ surface-on-main + push-branch routing) — DATA, never a
73
+ * caller-identity flag.
74
+ *
75
+ * The band below was extracted (a pure refactor) from `performComplete`. A
76
+ * resolved `merge` ALWAYS lands on a green gate (and, with review on, an
77
+ * `approve`); `propose` always leaves the merge to a human. There is no
78
+ * `merge`→`propose` downgrade — `merge` IS the auto-land mode. The effective
79
+ * mode the core resolved is carried in {@link IntegrationCoreResult.integration}'s
80
+ * `mode`; the tail reads it from there, NEVER from the requested mode.
81
+ */
82
+
83
+ /**
84
+ * The outcome the core resolved. These already match `complete`'s
85
+ * `CompleteOutcome` subset the core can produce — the tail maps them 1:1 (it adds
86
+ * only `refused`/`usage-error`, which are HEAD concerns the core never reaches).
87
+ */
88
+ export type IntegrationCoreOutcome =
89
+ | 'completed' // gated, reviewed, moved, committed, rebased, integrated
90
+ | 'prepare-failed' // the env-prep step (prepare) was red — env not ready, verify untrusted
91
+ | 'gate-failed' // the acceptance gate (verify) was red (and not skipped)
92
+ | 'review-blocked' // Gate 2 (PR/code review) returned `block` (or exhausted rounds)
93
+ | 'review-unparseable' // Gate 2 ran but its verdict JSON could not be parsed (malformed output) — work-preserving route, transient-infra cause (NOT a reviewer block)
94
+ | 'rebase-conflict' // rebase onto arbiter/main conflicted (aborted; human resolves)
95
+ | 'invariant-violation' // one-slug-one-folder would break (slug in two folders on the arbiter)
96
+ | 'already-integrated'; // committed-recovery: the kept tip is already on <arbiter>/main (clean no-op)
97
+
98
+ /**
99
+ * The CORE's input — everything the band needs, nothing caller-shaped. Every
100
+ * divergence between the human `complete` path and the autonomous `do`/`run` path
101
+ * maps to a FIELD VALUE here, not an `if (caller === …)` branch (prd: "zero
102
+ * caller-identity leakage"). In-place vs worktree = `cwd`; arbiter name =
103
+ * `arbiter`; human vs autonomous surfacing = `surfaceArbiter`; do's recovery =
104
+ * `source` + `recovering`; per-repo/lang gate = `verify`.
105
+ */
106
+ /**
107
+ * A NON-TASK lifecycle the integrate band threads in place of its default,
108
+ * task-shaped done-move + title source. The shared band
109
+ * (verify→review→commit→rebase→integrate→propose-PR-with-title/body) is
110
+ * IDENTICAL; only the "which item move + which file to read the title from" step
111
+ * is caller-supplied. This is the seam the `do prd:<slug>` TASKING transition
112
+ * rides (task `slice-output-through-integration`): its "item move" is the prd
113
+ * LIFECYCLE move (`work/prds/ready/<slug>.md → work/prds/tasked/<slug>.md`, residence =
114
+ * tasked-ness) plus its EMITTED backlog files — NOT a task done-move. Supplying it
115
+ * makes every integrate-time arg (`--propose`/`--merge`, provider, title/body)
116
+ * apply to tasking BY CONSTRUCTION, because they resolve ONCE here.
117
+ *
118
+ * When set, the band: (1) reads the PR title / default commit summary from
119
+ * {@link titlePath} instead of `work/<source>/<slug>.md`; (2) calls {@link stage}
120
+ * (runner-owned, on the work branch) instead of the task `git mv → work/done/`;
121
+ * and (3) uses the PLAIN rebase (a tasking transition never `recover`s a
122
+ * surfaced needs-attention move). Everything else — the `git add -A` that sweeps
123
+ * the agent's uncommitted work + the staged lifecycle move, the atomic commit,
124
+ * the rebase, the integrate, and the propose-mode PR title/body — is shared.
125
+ */
126
+ export interface IntegrationLifecycle {
127
+ /**
128
+ * Absolute path to the item file whose `title:` frontmatter seeds the default
129
+ * commit summary AND the synthesised propose-mode PR title. For the tasking
130
+ * transition this is the held prd (`work/prds/ready/<slug>.md`) — read BEFORE
131
+ * {@link stage} moves it. IGNORED when {@link title} is supplied (the explicit
132
+ * title wins — no file read).
133
+ */
134
+ titlePath: string;
135
+ /**
136
+ * The drafted item TITLE, supplied EXPLICITLY (no file read). When set, the band
137
+ * uses THIS as the title source for the default commit summary AND the synthesised
138
+ * propose-mode PR title, INSTEAD of reading {@link titlePath}. This is the seam for
139
+ * a lifecycle whose output file does NOT yet exist at title-read time — the intake
140
+ * lone-task / prd path WRITES `work/backlog/<slug>.md` / `work/prds/ready/<slug>.md` in
141
+ * {@link stage}, which runs AFTER the title read, so a `titlePath` read would race
142
+ * the write and fall back to a generic subject. The `do prd:` tasking transition
143
+ * leaves this unset (its `titlePath` is an already-existing held prd, read fine).
144
+ */
145
+ title?: string;
146
+ /**
147
+ * STAGE the lifecycle move + emitted files into the index on the current work
148
+ * branch (runner-owned git; the agent never does git). For the tasking
149
+ * transition: `git mv work/prds/ready/<slug>.md → work/prds/tasked/<slug>.md`
150
+ * (residence = tasked-ness; no marker), and write+`git add` the produced
151
+ * `work/backlog/*.md` files. The band's subsequent `git add -A` + atomic commit
152
+ * folds this staging
153
+ * AND the agent's uncommitted backlog writes into ONE runner-owned commit. May
154
+ * be async (it shells git).
155
+ */
156
+ stage(): Promise<void> | void;
157
+ /**
158
+ * The trailing transition tag on the runner-owned commit subject
159
+ * (`<type>(<slug>): <summary>; <commitTag>`). Defaults to `done` (the build
160
+ * lifecycle); the tasking transition supplies `tasked`. Cosmetic — it names
161
+ * WHICH lifecycle landed; it gates nothing.
162
+ */
163
+ commitTag?: string;
164
+ }
165
+
166
+ /**
167
+ * The default LIVENESS CEILING for the merge-mode `${branch}:main` push (the
168
+ * durable promotions `tasks/ready → tasks/done` and, via `lifecycle`, `prds/ready
169
+ * → prds/tasked`). Task `c2-rebase-until-real-on-durable-main-promotions` turned
170
+ * the previous SMALL FIXED CAP (was 5, the `run-fleet-claim-integrate-and-sibling-
171
+ * rebase-concurrency-safe` Race-1 budget) into rebase-until-real-conflict: a CLEAN
172
+ * re-rebase no longer counts against a tiny give-up budget — only a GENUINE
173
+ * conflict surfaced by {@link rebaseOntoMainWithReconcile} stops the loop (the
174
+ * step-4 rebase IS the source-folder precondition recheck for the slug-relocation
175
+ * family: if the slug is GONE from its expected source folder on the new `main`,
176
+ * the `git mv` replay fails and `rebaseConflictRoute` routes definitively — never a
177
+ * silent re-push that would clobber a concurrent legitimate same-item winner).
178
+ *
179
+ * The cap survives only as a LARGE liveness ceiling that bounds the pathological
180
+ * livelock tail (a sustained-parallel-load hot ref where the loser's round-trip is
181
+ * always beaten by another winner — classic CAS livelock). Combined with the
182
+ * modest jitter on the refetch below it desynchronises the herd, so a route-to-
183
+ * needs-attention from this loop becomes a RARE livelock signal rather than the
184
+ * ROUTINE false-contention signal it was at the old cap of 5.
185
+ *
186
+ * SCOPE (`work/notes/ideas/ledger-lock-evolution-per-item-ref-vs-rebase-until-real-
187
+ * conflict.md` `### C2` SCOPE box, repeated here because over-applying C2 is the
188
+ * one near-fatal mistake): the durable promotions are slug RELOCATIONS, not the
189
+ * same-path / append family. They MUST keep their source-folder precondition
190
+ * recheck — reused here verbatim as `rebaseOntoMainWithReconcile`'s rebase replay
191
+ * + arbiter ledger-placement read. Do NOT add a new conflict-detection path; the
192
+ * existing one IS the genuine-conflict terminator.
193
+ *
194
+ * Tests inject a small `mergeRetries` (or `0`) to exercise the old un-retried /
195
+ * cap-exhausted route deterministically — that test seam is preserved.
196
+ */
197
+ const DEFAULT_MERGE_RETRIES = 1000;
198
+
199
+ /**
200
+ * Default modest jitter (ms) on the refetch between merge-push retries — load-
201
+ * bearing under sustained parallel load: an instant lockstep refetch→re-push loop
202
+ * maximises mutual rejection (a thundering herd), fattening the livelock tail.
203
+ * Modest randomisation desynchronises the herd. Each attempt sleeps a UNIFORMLY-
204
+ * random integer in `[0, DEFAULT_MERGE_JITTER_MS]` ms. Small enough (≤25ms) that
205
+ * the additional latency under realistic contention is negligible vs the
206
+ * round-trip cost of a rebase+push, and small enough that the in-tree concurrency
207
+ * tests are not slowed measurably. Tests override via `mergeJitterMs: 0`.
208
+ */
209
+ const DEFAULT_MERGE_JITTER_MS = 25;
210
+
211
+ /**
212
+ * Promise-based sleep, used for the {@link DEFAULT_MERGE_JITTER_MS} merge-push
213
+ * retry jitter (C2). Internal — the legacy non-seamed sleep this module uses
214
+ * for the C2 jitter (kept for byte-for-byte compatibility with existing tests).
215
+ * The recovery-rebase loop uses the INJECTABLE {@link Sleep} seam from
216
+ * `retry-backoff.ts` instead, so its timeline is test-driveable.
217
+ */
218
+ function sleepMs(ms: number): Promise<void> {
219
+ return new Promise((resolve) => setTimeout(resolve, ms));
220
+ }
221
+
222
+ /**
223
+ * **Default cap on RE-FETCH+RE-REBASE attempts** in the committed-recovery tail
224
+ * (`recoverAlreadyCommitted`, task `recovery-rebase-retry-against-moving-arbiter
225
+ * -main`). The recovery's single fetch-then-rebase is a CONTENTION race against a
226
+ * concurrently-MOVING `<arbiter>/main` (a sibling `advance` run lands a burst of
227
+ * `advance: surface observation:…` commits); a one-shot rebase against a stale
228
+ * fetched base can conflict against a main that already moved AGAIN, surfacing a
229
+ * purely transient race as `rebase-conflict`. So the rebase is wrapped in a small
230
+ * bounded CONTENTION loop: re-fetch `<arbiter>/main` (it may have advanced) +
231
+ * re-rebase; on clean → integrate; on conflict → `--abort`, a small jitter sleep,
232
+ * try again. The cap stops the loop ONLY when every fresh-fetched attempt still
233
+ * conflicts (a genuinely persistent conflict ⇒ route to needs-attention exactly
234
+ * as today). Small on purpose — a few attempts ride out a `advance` burst (each
235
+ * burst is tens of commits over a few seconds); a real conflict surfaces fast.
236
+ *
237
+ * This is the CONTENTION model (instant re-fetch+rebuild, like `claim-cas.ts`
238
+ * and the Race-1 merge loop above), NOT the OUTAGE model in
239
+ * {@link file://./retry-backoff.ts} (exponential temporal backoff, the remote
240
+ * may come back). The two failure classes are deliberately kept SEPARATE; do not
241
+ * substitute `retryWithBackoff` here.
242
+ *
243
+ * Tests inject `recoveryRebaseRetries: 0` (no retry — the legacy one-shot shape)
244
+ * or a small explicit cap (assert the cap exhausts deterministically).
245
+ */
246
+ const DEFAULT_RECOVERY_REBASE_RETRIES = 4;
247
+
248
+ /**
249
+ * **Default max jitter (ms)** between recovery-rebase attempts — a SMALL
250
+ * livelock-breaking SPREAD between concurrent runners (NOT exponential outage
251
+ * backoff). Pure instant retry has a real hazard: two runners that begin
252
+ * retrying at the same instant re-fetch and re-rebase in LOCKSTEP, each moving
253
+ * the base the other just rebased onto, and can livelock. A uniformly-random
254
+ * `[0, mergeJitterMs]` ms sleep before each re-attempt de-correlates the two
255
+ * racers. Bounded and tiny — a contention nudge, not an outage wait. Tests pass
256
+ * `recoveryRebaseJitterMs: 0` for a deterministic latency-free loop, OR inject
257
+ * the `sleep`/`random` seams to drive the timeline reproducibly with a seeded RNG.
258
+ */
259
+ const DEFAULT_RECOVERY_REBASE_JITTER_MS = 100;
260
+
261
+ export interface IntegrationCoreInput {
262
+ /** The working clone/checkout (in-place) OR worktree dir the work branch lives in. */
263
+ cwd: string;
264
+ /** Name of the arbiter git remote (valid in `cwd`). */
265
+ arbiter: string;
266
+ /** The slug being integrated (its work branch is `work/<type>-<slug>`). */
267
+ slug: string;
268
+ /**
269
+ * The work BRANCH being integrated. The caller is ALWAYS on it (the agent
270
+ * built there / the lifecycle stage wrote there), so it carries the namespaced
271
+ * `work/<type>-<slug>` identity. When omitted, the core derives it from the
272
+ * branch HEAD is on (the robust default — the type is encoded IN the name). A
273
+ * caller that knows the type explicitly may pass it (e.g. the tasking path
274
+ * passes its `work/prds/ready-<slug>`).
275
+ */
276
+ branch?: string;
277
+ /**
278
+ * Which folder the item is being completed FROM: `tasks-ready` (the normal,
279
+ * freshly-built path — since claim no longer moves the body, a freshly-built
280
+ * task RESTS in the pool on `main`, task
281
+ * `cutover-claim-body-stays-and-complete-sources-from-backlog`),
282
+ * `tasks-backlog` (the `--allow-backlog` staged-drive path, prd
283
+ * `do-allow-backlog-drive-staged-tasks-without-promotion`: a human drove a
284
+ * STAGED task in place, so the done-move goes `tasks/backlog/ → tasks/done/`
285
+ * DIRECTLY — the explicit drive IS the promotion, never bouncing through the
286
+ * pool), `needs-attention` (the runner-owned recovery path), or `done` (the
287
+ * CONTINUE-BUILD path, task `complete-builds-on-already-done-moved-continue`).
288
+ * The HEAD resolved this; the core uses it for the done-move source folder and
289
+ * the recovery rebase. IGNORED when {@link lifecycle} is set (a non-task move).
290
+ * (`in-progress` is RETAINED in the union for the bounce/recovery surfaces that
291
+ * may still source from `in-progress/` until its folder removal, 9c.)
292
+ *
293
+ * `done` — the continue-build lifecycle state: a CONTINUE on a kept work
294
+ * branch whose `<slug>.md` is ALREADY in `work/done/` (a prior attempt moved
295
+ * it there), and THIS run produced NEW uncommitted source edits. The slug is
296
+ * already in `done/` ⇒ the step-2 `git mv` is SKIPPED (there is nothing to
297
+ * move), and the originTrust read + the divergent-done-move reconciliation
298
+ * are EXEMPTED (there is no first-time move on this commit — the prior
299
+ * attempt already went through the checkpoint, and the local + arbiter both
300
+ * already hold the slug in `done/` so there is nothing to reconcile). The
301
+ * build path still runs prepare → gate → `git add -A` → commit → rebase →
302
+ * integrate on the NEW work so it lands as a continuation commit on top of
303
+ * the kept (already-done-moved) tip. Mutually exclusive with
304
+ * {@link committedRecovery} (the clean-strand fast-path — `complete.ts`
305
+ * resolves `done` only when the working tree is DIRTY, and
306
+ * {@link committedRecovery} only when it is CLEAN; they cannot both be set
307
+ * for the same call). See `docs/adr/continue-build-already-done-moved.md`.
308
+ */
309
+ // `needs-attention` was a recovery source in the legacy folder model; it is
310
+ // gone (task `finish-needs-attention-folder-cutover-remove-legacy-recovery-readers`).
311
+ // The retired folder is no longer written, so a recovery `complete` sources
312
+ // from `tasks-ready` (the pool position, where claim now leaves the body)
313
+ // like a normal build.
314
+ source: 'tasks-ready' | 'tasks-backlog' | 'in-progress' | 'done';
315
+ /**
316
+ * Vestigial: was `true` when completing FROM `work/needs-attention/` (a
317
+ * recovery finish under the legacy folder model). The per-item-lock cutover
318
+ * (`cutover-needs-attention-becomes-lock-stuck-recovery-surface`) retired
319
+ * that folder — a stuck item is now the lock `state: stuck` and the body
320
+ * stays in `tasks/ready/`, so a recovery `complete` is structurally a normal
321
+ * build. EVERY caller now passes `false`; the field is preserved on the input
322
+ * type only so callers compile unchanged — every branch that read it has been
323
+ * deleted (the `if (recovering)` re-gate paths). A future cross-caller change
324
+ * can remove the field; out of this task's scope.
325
+ */
326
+ recovering: boolean;
327
+ /**
328
+ * **RECOVER an already-committed, already-done-moved STRANDED branch** (prd
329
+ * `ledger-integrity` story 6, the `finish-already-committed-branch` task). When
330
+ * `true`, the work is ALREADY committed on the work branch with the task already
331
+ * `git mv`'d into `work/done/` (a terminal push failed AFTER steps 2–3, leaving
332
+ * the green work stranded). So the core SKIPS steps 0–3 (prepare / gate / review /
333
+ * done-move / commit — they already ran) and runs ONLY the rebase→integrate TAIL
334
+ * (steps 4–5) from the kept commit, reusing the SAME
335
+ * `ledgerWrite.applyCompleteTransition` integrate primitive — no rebuild, no
336
+ * orphan branch.
337
+ *
338
+ * The detection is UNSPOOFABLE: before acting the core verifies the work-branch
339
+ * tip is genuinely AHEAD of `<arbiter>/main` (`isAncestor`, the SAME reachability
340
+ * predicate `gc.ts` uses). A tip ALREADY reachable on `<arbiter>/main` is
341
+ * already-integrated → a clean `already-integrated` no-op, NEVER a re-push /
342
+ * double-integrate. Mutually exclusive with `recovering` and `lifecycle` (both
343
+ * presuppose an un-committed source state this path has moved past); when set
344
+ * they are ignored.
345
+ */
346
+ committedRecovery?: boolean;
347
+ /**
348
+ * The declared per-repo ENV-PREP step (string | list), run ONCE before the
349
+ * FIRST `verify` on a fresh worktree to make the env ready (install deps,
350
+ * submodules, codegen). Unset ⇒ a no-op (NO default install — the deliberate
351
+ * difference from `verify`). Sequenced BEFORE `verify`; a failing prepare
352
+ * surfaces as `prepare-failed` and NEVER proceeds to `verify`/integrate. Skip
353
+ * is gated by a NON-COMMITTED prepared-ness marker in the worktree's git
354
+ * control area (`prepare.ts`), so it does not re-install per gate within one
355
+ * worktree. Honoured on the build paths (`do`/`run` fresh worktrees) +
356
+ * `complete`; the standalone `dorfl verify` CLI does NOT run it (the
357
+ * pure gate — a human prepares their own checkout).
358
+ */
359
+ prepare?: VerifyConfig;
360
+ /** The declared per-repo gate (string | list). Unset ⇒ the default command. */
361
+ verify?: VerifyConfig;
362
+ /**
363
+ * **The fresh-worktree gate toggle** (config `freshWorktreeGate`, ON by
364
+ * default). When `true`, the acceptance gate (`prepare` then `verify`) runs in
365
+ * a CLEAN throwaway worktree cut from the work branch REBASED onto the latest
366
+ * `<arbiter>/main` (the would-be-integrated tip) — so a green gate provably
367
+ * describes the MERGED artifact: gitignored/uncommitted state in this `cwd`
368
+ * cannot leak in (the worktree is cut from the committed, rebased tip), and a
369
+ * change the integration rebase introduces IS gated. The band then does the
370
+ * done-move + commit, rebases, runs the gate in the fresh worktree, reaps it,
371
+ * and only on green integrates. When `false` (or unset ⇒ treated as the
372
+ * caller's default; the CLI resolves the default to `true`), `prepare`+`verify`
373
+ * run in THIS `cwd` BEFORE the done-move exactly as before (the pre-rebase
374
+ * gate), byte-for-byte. The band simply HONOURS this boolean (caller-agnostic);
375
+ * the `run`-fleet downgrade at `perRepoMax > 1` lives in the `run` caller, NOT
376
+ * here. A `--skip-verify` skips the gate ENTIRELY regardless of this flag.
377
+ */
378
+ freshWorktreeGate?: boolean;
379
+ /** Skip the acceptance gate (human-only escape hatch; never used unattended). */
380
+ skipVerify?: boolean;
381
+ /** Run Gate 2 (the PR/code review gate) after the green `verify`. Default OFF. */
382
+ review?: boolean;
383
+ /** The review-gate SEAM (injectable). Required when `review` is on. */
384
+ reviewGate?: ReviewGate;
385
+ /** The model the REVIEW agent runs on (de-correlated from the builder). */
386
+ reviewModel?: string;
387
+ /** Bound the revise↔review loop (Gate 2). Defaults to 2. */
388
+ reviewMaxRounds?: number;
389
+ /** Integration mode the caller REQUESTED (`propose` default, or `merge`). */
390
+ mode: IntegrationMode;
391
+ /**
392
+ * **The UNTRUSTED-ORIGIN build-propose rule** (task
393
+ * `untrusted-origin-forces-build-propose`). When `true`, the operator EXPLICITLY
394
+ * passed `--merge` on this invocation — which OVERRIDES the untrusted-origin
395
+ * `propose` default (the operator is present; CLI always wins, no special
396
+ * force-key). The autonomous/CI build path passes no flag ⇒ leaves this unset ⇒
397
+ * an untrusted-origin task reliably forces `propose`. Resolved entirely from the
398
+ * task's stamped `originTrust:` frontmatter (read HERE from the build source
399
+ * file); a `trusted`/unset task is config-as-is (ZERO behaviour change). This
400
+ * rule touches the task BUILD transition ONLY: it never fires when
401
+ * {@link lifecycle} is set (the tasking/intake-emit transitions — a file landing
402
+ * on main is inert) nor on {@link committedRecovery} (already gated + moved).
403
+ */
404
+ explicitMerge?: boolean;
405
+ /**
406
+ * **The PR-INTENT axis** (config `noPR`, ADR §6). When `true` on the `propose`
407
+ * path, the branch is pushed (the safety-bearing step) but NO review request is
408
+ * opened (the explicit suppress-PR intent that re-homes the old `provider: none`
409
+ * use) — no warning, the no-PR outcome is intended. It does NOT pick a provider
410
+ * (`selectProvider` stays purely arbiter-derived); it is an intent LAYERED on
411
+ * top. Ignored in `merge` mode (it never opens a PR) and when `providerInstance`
412
+ * is injected (the test/embedding seam decides its own provider). Unset/false ⇒
413
+ * propose opens the PR via the arbiter-derived provider as normal.
414
+ */
415
+ noPR?: boolean;
416
+ /**
417
+ * Optional FULLY-FORMED review provider to use VERBATIM (highest precedence,
418
+ * above `openPr` and the arbiter-derived selection). The `run` path injects a
419
+ * stubbed `GitHubProvider` here in tests (a custom `gh` binary path) to drive
420
+ * the full propose pipeline incl. title/body/url — which the lossy `openPr`
421
+ * bridge cannot carry. Unset by `do`/`complete` (they use the arbiter-derived
422
+ * selection / `openPr`), so their behaviour is unchanged.
423
+ */
424
+ providerInstance?: ReviewProvider;
425
+ /** Optional injectable PR opener (legacy bridge); used in `propose` mode. */
426
+ openPr?: (opts: {
427
+ cwd: string;
428
+ branch: string;
429
+ env?: NodeJS.ProcessEnv;
430
+ }) => void;
431
+ /**
432
+ * Optional review-request BODY (propose mode) — advisory prose. The runner
433
+ * scaffolds a deterministic header (a pointer to `work/done/<slug>.md`) above
434
+ * it. Absent ⇒ today's `gh pr create --fill` (no regression).
435
+ */
436
+ body?: string;
437
+ /** Conventional-commit type for the completion commit. Defaults to `feat`. */
438
+ type?: string;
439
+ /** Commit summary. Defaults to the task `title` minus a leading `slug — `. */
440
+ message?: string;
441
+ /**
442
+ * Surface a needs-attention bounce ON THE ARBITER (the AUTONOMOUS variant). When
443
+ * set, the failure routings (`gate-failed` / `review-blocked` / `rebase-conflict`)
444
+ * pass this arbiter remote into the ledger write seam's
445
+ * `applyNeedsAttentionTransition` (which cherry-picks the move onto `main` +
446
+ * pushes the branch — observable + recoverable cross-machine). Unset ⇒ the human
447
+ * `complete` behaviour: route LOCALLY only.
448
+ */
449
+ surfaceArbiter?: string;
450
+ /** `--watch`: tail the Gate-2 review agent's session live. Observability only. */
451
+ watch?: boolean;
452
+ /** Where the tailed review lines are written (defaults to stderr). */
453
+ watchSink?: (line: string) => void;
454
+ /** Emit ANSI colour in the tailed review lines (the caller's TTY decision). */
455
+ color?: boolean;
456
+ /** The HOST-ONLY sessions root the review session FILE is generated under. */
457
+ sessionsDir?: string;
458
+ /** Environment for child GIT/provider processes (the identity-scoped env). */
459
+ env?: NodeJS.ProcessEnv;
460
+ /**
461
+ * Optional per-repo INTEGRATE serialiser (the `run` concurrency seam, sibling
462
+ * of the claim lock). When set, ONLY the rebase-to-integrate TAIL (step 4
463
+ * fetch+rebase through step 5 integrate) runs inside `integrateLock(key, fn)`,
464
+ * so two concurrent SAME-repo merge jobs land on `main` one-at-a-time: the
465
+ * loser re-fetches + rebases onto the winner's now-advanced `<arbiter>/main`
466
+ * INSIDE the lock, making its `${branch}:main` a clean fast-forward (a genuine
467
+ * code conflict then routes ONE to needs-attention, never both-land by timing).
468
+ * The front-of-band `prepare`+`verify` gate and the Gate-2 review agent run
469
+ * OUTSIDE the lock, so same-repo jobs still gate + review CONCURRENTLY (run's
470
+ * parallelism is preserved). Single-job callers (`do`/`--isolated`/`--remote`/
471
+ * `complete`) leave it unset ⇒ the tail runs directly, byte-for-byte unchanged.
472
+ * Keyed per repo (see {@link integrateLockKey}), so cross-repo integration
473
+ * stays fully concurrent.
474
+ */
475
+ integrateLock?: <T>(key: string, fn: () => Promise<T>) => Promise<T>;
476
+ /**
477
+ * The key {@link integrateLock} serialises on — the repo path (the SAME key the
478
+ * claim lock uses), so same-repo integrations serialise while distinct repos
479
+ * integrate concurrently. Ignored when {@link integrateLock} is unset.
480
+ */
481
+ integrateLockKey?: string;
482
+ /**
483
+ * **Race-1 (claim-vs-integrate) bounded re-rebase-and-retry cap** for the
484
+ * merge-mode `${branch}:main` push (task
485
+ * `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`). On a
486
+ * non-fast-forward rejection (a sibling same-repo CLAIM — under the SEPARATE
487
+ * claim lock — or integrate advanced `<arbiter>/main` during this job's push
488
+ * window) the step-4 tail RE-RUNS its rebase (reconciling sibling-ledger
489
+ * divergence) and RETRIES the push up to this cap. The `integrateLock` only
490
+ * serialises sibling INTEGRATES; a sibling CLAIM is on a DIFFERENT lock, so this
491
+ * retry (not the lock) is what makes claim-vs-integrate deterministic. Absent ⇒
492
+ * {@link DEFAULT_MERGE_RETRIES}. Tests inject a small cap (or `0` to assert the
493
+ * un-retried non-fast-forward route).
494
+ *
495
+ * **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
496
+ * promotions`):** the SEMANTICS changed — a CLEAN re-rebase no longer counts
497
+ * against a give-up budget; only a GENUINE conflict surfaced by the rebase
498
+ * (`rebaseConflictRoute` / `invariant-violation`) stops the loop. This value
499
+ * now serves as a LARGE liveness ceiling (default {@link DEFAULT_MERGE_RETRIES}
500
+ * = 1000) that bounds the pathological livelock tail, not as a small Race-1
501
+ * contention budget. The test seam is preserved: a small explicit cap (or `0`)
502
+ * still forces the un-retried / cap-exhausted route.
503
+ */
504
+ mergeRetries?: number;
505
+ /**
506
+ * Modest jitter (ms) on the refetch between merge-push retries — load-bearing
507
+ * under sustained parallel load (desynchronises a thundering-herd lockstep,
508
+ * task `c2-rebase-until-real-on-durable-main-promotions`). Each attempt sleeps
509
+ * a uniformly-random integer in `[0, mergeJitterMs]` ms. Defaults to
510
+ * {@link DEFAULT_MERGE_JITTER_MS} (25ms); tests can pass `0` for deterministic,
511
+ * latency-free retries.
512
+ */
513
+ mergeJitterMs?: number;
514
+ /**
515
+ * **Committed-recovery rebase RE-FETCH+RE-REBASE cap** (task `recovery-rebase-
516
+ * retry-against-moving-arbiter-main`). The recovery tail
517
+ * ({@link recoverAlreadyCommitted}) wraps its rebase onto `<arbiter>/main` in a
518
+ * bounded CONTENTION loop: on a conflicting rebase it `--abort`s, re-fetches
519
+ * `<arbiter>/main` (it may have advanced — `advance` runs land bursts of
520
+ * observation commits on main), and re-rebases, up to this cap of ADDITIONAL
521
+ * attempts after the first one. Only after the cap is exhausted does it return
522
+ * `rebase-conflict` exactly as today. Absent ⇒ {@link DEFAULT_RECOVERY_REBASE_
523
+ * RETRIES}. Tests inject `0` for the legacy one-shot shape, or a small explicit
524
+ * cap to assert the cap-exhausted route.
525
+ */
526
+ recoveryRebaseRetries?: number;
527
+ /**
528
+ * **Committed-recovery rebase JITTER (max ms)** between attempts — a SMALL
529
+ * livelock-breaking SPREAD, NOT exponential outage backoff. Each post-conflict
530
+ * sleep is a uniformly-random integer in `[0, recoveryRebaseJitterMs]` ms
531
+ * (drawn via the injectable {@link recoveryRebaseRandom}). Defaults to
532
+ * {@link DEFAULT_RECOVERY_REBASE_JITTER_MS}; tests pass `0` for a deterministic
533
+ * zero-delay schedule.
534
+ */
535
+ recoveryRebaseJitterMs?: number;
536
+ /**
537
+ * **Injectable sleep seam** for the committed-recovery rebase retry loop —
538
+ * reuses the {@link Sleep} type from `retry-backoff.ts` (the same seam
539
+ * `run.ts` / `needs-attention.ts` use). Defaults to {@link realSleep}; tests
540
+ * inject a capturing zero-sleep to assert the per-attempt delay schedule and to
541
+ * drive a moving-base scenario between attempts.
542
+ */
543
+ recoveryRebaseSleep?: Sleep;
544
+ /**
545
+ * **Injectable RNG** for the recovery-rebase jitter — `() => number` returning
546
+ * `[0, 1)` (same shape as `Math.random`). Defaults to `Math.random`; tests
547
+ * inject a seeded RNG (or a fixed constant) so the captured jitter timeline is
548
+ * reproducible.
549
+ */
550
+ recoveryRebaseRandom?: () => number;
551
+ /**
552
+ * Environment for the REVIEW-AGENT launch (Gate 2). Distinct from {@link env}
553
+ * because the review agent is an AGENT — it must NOT carry the runner identity
554
+ * (the agent must not act/commit as the bot; only the runner's own git
555
+ * transitions do). The caller passes the plain AMBIENT env here when an identity
556
+ * is configured. Unset ⇒ falls back to {@link env} (no identity ⇒ they are the
557
+ * same env, so this is byte-for-byte unchanged for every non-identity caller).
558
+ */
559
+ agentEnv?: NodeJS.ProcessEnv;
560
+ /** Sink for human-readable progress notes. */
561
+ note?: (message: string) => void;
562
+ /**
563
+ * A NON-TASK lifecycle move + title source (task
564
+ * `slice-output-through-integration`). When set, the band reads the title from
565
+ * its {@link IntegrationLifecycle.titlePath}, calls its
566
+ * {@link IntegrationLifecycle.stage} INSTEAD of the task `git mv → work/done/`,
567
+ * and uses the plain rebase ({@link recovering} is irrelevant). The `do prd:`
568
+ * TASKING transition supplies it; `do`/`complete`/`run` leave it unset (the
569
+ * task done-move is unchanged).
570
+ */
571
+ lifecycle?: IntegrationLifecycle;
572
+ }
573
+
574
+ /**
575
+ * The CORE's output — pure DATA. The core performed the gate/review/move/commit/
576
+ * rebase/integrate AND the failure routing, but NOT the caller-specific tail.
577
+ */
578
+ export interface IntegrationCoreResult {
579
+ /** What the core resolved (the tail maps this 1:1 onto its own outcome). */
580
+ outcome: IntegrationCoreOutcome;
581
+ /**
582
+ * True iff a FAILURE outcome was routed to `work/needs-attention/` via the
583
+ * shared mechanism. False on the success path and when a failure was NOT moved
584
+ * (e.g. a red re-gate that stays put in recovery).
585
+ */
586
+ routedToNeedsAttention: boolean;
587
+ /** The work branch that was processed (`work/<slug>`). */
588
+ branch: string;
589
+ /**
590
+ * A human-readable summary of a FAILURE terminal condition, ready for the tail
591
+ * to surface VERBATIM. Absent on the success path (the tail composes the
592
+ * success message from the integration result + its own switch/delete state).
593
+ */
594
+ reason?: string;
595
+ /** The completion commit message that was authored, on success. */
596
+ commitMessage?: string;
597
+ /**
598
+ * The integration result — carries the resolved mode + the PR url. Present on
599
+ * the success path; the tail reads `integration.mode` to decide its switch/ff
600
+ * behaviour, NEVER the requested mode.
601
+ */
602
+ integration?: IntegrateResult;
603
+ /**
604
+ * On a `review-blocked` outcome, the review gate's STRUCTURED block reason (the
605
+ * `formatBlockReason` of the blocking findings) — so a caller doing its OWN
606
+ * needs-attention routing can record the findings as the item-body prose. The
607
+ * tasking path (task `slice-acceptance-gate`) reads this: the core's build
608
+ * `applyNeedsAttentionTransition` is keyed on a TASK lock, so the tasking path
609
+ * routes the prd itself via the lock release's needs-attention redirect (amend the
610
+ * `prd:<slug>` unified lock `active -> stuck`, no folder move), using THIS
611
+ * findings text as the body. Absent on every non-`review-blocked` outcome. (The
612
+ * build path ignores it — its routing already records the findings in-body.)
613
+ */
614
+ reviewBlockReason?: string;
615
+ }
616
+
617
+ const DEFAULT_TYPE = 'feat';
618
+
619
+ /**
620
+ * Run the shared gate→integrate band for a prepared (claimed, built, on-its-work-
621
+ * branch) item. The HEAD has already resolved `cwd`/`arbiter`/`slug`/`source`/
622
+ * `recovering`; this runs the gate, the review gate, the effective-mode decision,
623
+ * the done-move + atomic commit + rebase + integrate, and routes ANY failure to
624
+ * needs-attention — returning pure DATA for the caller's tail.
625
+ *
626
+ * It never throws for the expected gate-failed / review-blocked / rebase-conflict
627
+ * cases (those are returned with the corresponding outcome). A genuinely
628
+ * unexpected git plumbing failure (e.g. the done-move itself, or nothing staged)
629
+ * throws — the caller's existing try/catch maps it.
630
+ */
631
+ export async function performIntegration(
632
+ input: IntegrationCoreInput,
633
+ ): Promise<IntegrationCoreResult> {
634
+ const cwd = input.cwd;
635
+ const env = input.env;
636
+ const arbiter = input.arbiter;
637
+ const slug = input.slug;
638
+ const source = input.source;
639
+ const recovering = input.recovering;
640
+ const note = input.note ?? (() => {});
641
+ // The work branch: the caller is on it (it carries the namespaced identity).
642
+ // Prefer the explicit `branch`; else read the branch HEAD is on; only fall
643
+ // back to a synthesised `work/task-<slug>` if HEAD is detached (a degenerate
644
+ // case that the on-branch invariant should preclude).
645
+ const branch = input.branch ?? resolveWorkBranch(cwd, slug, env);
646
+ // Captured from the Gate-2 review (when it runs + approves) so that AFTER the
647
+ // propose integrate — where the opened PR url is finally in scope — we can post
648
+ // the agent's deliberately-authored `review` prose as a PR comment (task
649
+ // `review-comment-prose-field`). Stays undefined when review is off / the agent
650
+ // emitted no `review` field, so the post is skipped (no-op). The verdict/routing
651
+ // decision uses neither.
652
+ let approvedVerdict: ReviewVerdict | undefined;
653
+ // The resolved integration mode. `merge` lands automatically on a green gate
654
+ // (and an `approve` when review is on); `propose` leaves the merge to a human.
655
+ // MUTABLE because the untrusted-origin build-propose rule below may force it to
656
+ // `propose` for a task BUILD (the build transition only) — see the rule after
657
+ // `sourcePath` is resolved.
658
+ let mode = input.mode;
659
+ // The fresh-worktree gate (task `gate-on-rebased-tip-fresh-worktree`): when ON
660
+ // the deterministic acceptance gate (`prepare`+`verify`) does NOT run here on
661
+ // the agent's PRE-rebase `cwd`; instead it runs LATER, in a clean throwaway
662
+ // worktree cut from the work branch REBASED onto `<arbiter>/main` (the
663
+ // would-be-integrated tip), inside the rebase-to-integrate tail. So a green gate
664
+ // provably describes the MERGED artifact. When OFF the front prepare+verify runs
665
+ // here exactly as before (byte-for-byte). The band HONOURS the boolean it is
666
+ // handed (caller-agnostic); the `run`-fleet `perRepoMax === 1` downgrade lives
667
+ // in the `run` caller, not here.
668
+ const freshWorktreeGate = input.freshWorktreeGate === true;
669
+
670
+ // RECOVER an already-committed, already-done-moved STRANDED branch (prd
671
+ // `ledger-integrity` story 6). The work + the done-move are ALREADY committed on
672
+ // the work branch (a terminal push failed AFTER steps 2–3); SKIP steps 0–3
673
+ // (prepare / gate / review / done-move / commit) and run ONLY the
674
+ // rebase→integrate TAIL from the kept commit, reusing the SAME integrate
675
+ // primitive. Returns BEFORE any of the build-path steps run.
676
+ if (input.committedRecovery) {
677
+ return await recoverAlreadyCommitted({
678
+ cwd,
679
+ arbiter,
680
+ slug,
681
+ branch,
682
+ mode,
683
+ noPR: input.noPR,
684
+ providerInstance: input.providerInstance,
685
+ openPr: input.openPr,
686
+ recoveryRebaseRetries: input.recoveryRebaseRetries,
687
+ recoveryRebaseJitterMs: input.recoveryRebaseJitterMs,
688
+ recoveryRebaseSleep: input.recoveryRebaseSleep,
689
+ recoveryRebaseRandom: input.recoveryRebaseRandom,
690
+ // Answered-merge land (task `committed-recovery-honours-fresh-worktree-gate`,
691
+ // prd `land-time-reverify-and-parallel-merge-ceiling`): the apply-rung
692
+ // dispatches an answered `merge` through this committed-recovery tail (the
693
+ // branch already carries its done-move commit, so the build path's
694
+ // `git mv`+`add -A`+commit would raise `IntegrationNothingStaged`). UNLIKE
695
+ // the original stranded-recovery caller (whose pre-strand build already
696
+ // gated), `<arbiter>/main` may have MOVED since this branch's last build,
697
+ // so the rebased tip MUST be re-verified before it lands or the load-bearing
698
+ // invariant ("main never receives a tree that fails verify") cannot hold on
699
+ // the merge path. Thread the gate inputs through; recovery runs the EXISTING
700
+ // `runFreshWorktreeGate` on the rebased tip when `freshWorktreeGate` is set
701
+ // (and not `--skip-verify`), routing a RED gate to needs-attention via the
702
+ // SAME seam the build path uses. With `freshWorktreeGate` unset (the
703
+ // stranded-recovery caller), recovery is byte-identical to before — no
704
+ // extra gate, no extra fetch.
705
+ freshWorktreeGate,
706
+ skipVerify: input.skipVerify,
707
+ prepare: input.prepare,
708
+ verify: input.verify,
709
+ surfaceArbiter: input.surfaceArbiter,
710
+ env,
711
+ note,
712
+ });
713
+ }
714
+
715
+ // The file whose `title:` seeds the commit summary + PR title. For a TASKING
716
+ // transition (a non-task `lifecycle`) this is the held prd it supplies; for a
717
+ // build it is the task in its source folder. Read BEFORE any move.
718
+ const lifecycle = input.lifecycle;
719
+ // CONTINUE-BUILD (`source: 'done'`, task
720
+ // `complete-builds-on-already-done-moved-continue`): on a dirty continue whose
721
+ // kept branch already holds the slug in `work/done/`, the file we read for the
722
+ // title + (otherwise) untrusted-origin lives there too — not in
723
+ // `in-progress/`/`needs-attention/`. The step-2 `git mv` is later SKIPPED for
724
+ // this source (the slug is already in done/), and the originTrust read +
725
+ // arbiter ledger placement/divergent-done-move reconcile are EXEMPTED below.
726
+ const sourcePath = lifecycle
727
+ ? lifecycle.titlePath
728
+ : source === 'done'
729
+ ? workItemPath(cwd, 'done', slug)
730
+ : workItemPath(cwd, source, slug);
731
+
732
+ // UNTRUSTED-ORIGIN BUILD-PROPOSE RULE (task `untrusted-origin-forces-build-propose`).
733
+ // A task born from an UNTRUSTED issue carries `originTrust: untrusted` (stamped
734
+ // at intake, propagated by the tasker). Its risk is the BUILD (it becomes code),
735
+ // so the build transition resolves to `propose` even when the requested mode is
736
+ // `merge` — moving the human checkpoint onto the becomes-code build. Precedence:
737
+ // explicit --merge > untrusted-origin ⇒ propose > config mode > default.
738
+ // An explicit `--merge` (input.explicitMerge) OVERRIDES the rule (the operator
739
+ // is present; CLI always wins, no special force-key). The autonomous/CI path
740
+ // passes no flag, so there an untrusted-origin task RELIABLY forces propose.
741
+ //
742
+ // SCOPE: the task BUILD transition ONLY. It NEVER fires for a `lifecycle`
743
+ // transition (tasking / intake-emit — a prd/task FILE landing on main is inert;
744
+ // intake's OWN per-emit resolver already decided that mode), and the source file
745
+ // here is the task being built. A `trusted`/unset task ⇒ untouched (zero
746
+ // behaviour change for the normal human path).
747
+ // CONTINUE-BUILD EXEMPTION (task
748
+ // `complete-builds-on-already-done-moved-continue`): the prior attempt that
749
+ // done-moved this task already went through the originTrust checkpoint (an
750
+ // untrusted task on a `merge` config proposed on that first attempt). The
751
+ // continue-build commit is layered on top of that kept tip, so re-evaluating
752
+ // the rule here would either double-checkpoint or be a no-op against the same
753
+ // frontmatter — exempt this state (the task file lives in `done/` and is now
754
+ // effectively an on-`main` artifact). The other states are unchanged.
755
+ if (
756
+ !lifecycle &&
757
+ source !== 'done' &&
758
+ mode === 'merge' &&
759
+ input.explicitMerge !== true &&
760
+ existsSync(sourcePath) &&
761
+ parseFrontmatter(readFileSync(sourcePath, 'utf8')).originTrust ===
762
+ 'untrusted'
763
+ ) {
764
+ mode = 'propose';
765
+ note(
766
+ `Untrusted-origin task '${slug}': forcing the BUILD transition to ` +
767
+ 'propose (a human reviews the becomes-code change before it merges). ' +
768
+ 'Pass --merge to override.',
769
+ );
770
+ }
771
+
772
+ // 0. Prepare: make the worktree's ENV READY before the gate (install deps,
773
+ // submodules, codegen). A fresh job worktree off the hub mirror has no
774
+ // `node_modules`, so `verify` would fail for lack of deps unless prepare
775
+ // runs FIRST. `prepare` is the SIBLING of `verify`, NOT baked into it: it
776
+ // runs ONCE before the first verify, gated by a NON-COMMITTED prepared-ness
777
+ // marker in the worktree's git control area (so it does not re-install per
778
+ // gate within one persistent worktree). Unset ⇒ a no-op (no default install).
779
+ // A FAILING prepare is a HARD STOP distinct from a red gate (`prepare-failed`):
780
+ // the env could not be made ready, so `verify` cannot be trusted — it NEVER
781
+ // proceeds to verify/integrate, and routes the item the SAME way a red gate
782
+ // does. (`--skip-verify` skips only the gate, not env-prep: a verify-skipped
783
+ // finish still needs a ready env; the marker keeps an already-prepared tree
784
+ // a no-op.)
785
+ //
786
+ // FRESH-WORKTREE GATE: when ON, this front prepare+verify is SKIPPED here and
787
+ // runs LATER on the rebased-tip throwaway worktree (see the tail). The OFF
788
+ // path below is byte-for-byte today's pre-rebase gate.
789
+ if (!freshWorktreeGate) {
790
+ const prep = await ensurePrepared({cwd, prepare: input.prepare, env});
791
+ if (!prep.noop && !prep.skipped) {
792
+ note('Running the env-prep step (prepare)…');
793
+ }
794
+ if (!prep.passed) {
795
+ const reason = `prepare (env-prep) failed (exit ${prep.exitCode})`;
796
+ // Bounce from in-progress/ straight to needs-attention/ (the SAME seam a red
797
+ // gate uses) — recording the prepare-failed reason + committing the move
798
+ // (with the agent's uncommitted work) as ONE atomic transition. We NEVER
799
+ // run `verify` on an env that could not be made ready.
800
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
801
+ cwd,
802
+ slug,
803
+ reason,
804
+ arbiter: input.surfaceArbiter,
805
+ env,
806
+ note,
807
+ });
808
+ return {
809
+ outcome: 'prepare-failed',
810
+ routedToNeedsAttention: routed.moved,
811
+ branch,
812
+ reason: routed.moved
813
+ ? `Env-prep (prepare) failed (exit ${prep.exitCode}); routed '${slug}' ` +
814
+ 'to work/needs-attention/ (the environment could not be made ready, ' +
815
+ 'so the acceptance gate was NOT run). Fix the prepare command, then ' +
816
+ 'return it to backlog/.'
817
+ : `Env-prep (prepare) failed (exit ${prep.exitCode}); not completing ` +
818
+ `'${slug}' (the environment could not be made ready, so the ` +
819
+ 'acceptance gate was NOT run). Fix the prepare command, then retry.',
820
+ };
821
+ }
822
+ }
823
+
824
+ // 1. Gate: bad work never proceeds to done. Default-on; --skip-verify is a
825
+ // human-only escape hatch (the autonomous runner never skips — ADR §8).
826
+ // FRESH-WORKTREE GATE: when ON this front gate is SKIPPED and runs LATER on
827
+ // the rebased-tip throwaway worktree (see the tail); the OFF branch below is
828
+ // byte-for-byte today's pre-rebase gate.
829
+ if (input.skipVerify) {
830
+ note('Skipping the acceptance gate (--skip-verify).');
831
+ } else if (!freshWorktreeGate) {
832
+ note('Running the acceptance gate (verify)…');
833
+ const gate = await runVerify({cwd, verify: input.verify, env});
834
+ if (!gate.passed) {
835
+ // Don't leave the item dangling in in-progress/: route it to
836
+ // needs-attention/ with the reason (ADR §12) THROUGH the ledger write
837
+ // seam's needs-attention transition. The item has NOT been committed/moved
838
+ // yet, so the move bounces it straight from in-progress/ — recording the
839
+ // reason + committing the move (with the agent's uncommitted work) as ONE
840
+ // atomic transition. No partial state.
841
+ const reason = `acceptance gate failed (exit ${gate.exitCode})`;
842
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
843
+ cwd,
844
+ slug,
845
+ reason,
846
+ // Autonomous caller (`do`) passes the arbiter so the seam both SURFACES
847
+ // the stuck state on `main` (OBSERVABLE, cross-machine visible) AND
848
+ // pushes the `work/<slug>` branch (RECOVERABLE — a requeue-continue on
849
+ // another machine, reading <arbiter>/work/<slug>, lands on the saved
850
+ // wip). The human `complete` leaves it unset → no surface, no push,
851
+ // local-only (a human is right there). One operation in one place: the
852
+ // push lives in the seam (it is HEAD's branch — `work/<slug>` — here),
853
+ // no bolted-on push to forget.
854
+ arbiter: input.surfaceArbiter,
855
+ env,
856
+ note,
857
+ });
858
+ return {
859
+ outcome: 'gate-failed',
860
+ routedToNeedsAttention: routed.moved,
861
+ branch,
862
+ reason: routed.moved
863
+ ? `Acceptance gate failed (exit ${gate.exitCode}); routed '${slug}' ` +
864
+ 'to work/needs-attention/ (surfaced by status; return to backlog/ ' +
865
+ 'once resolved). Fix the work, or use --skip-verify to override.'
866
+ : `Acceptance gate failed (exit ${gate.exitCode}); not completing ` +
867
+ `'${slug}'. Fix the work, or use --skip-verify to override.`,
868
+ };
869
+ }
870
+ }
871
+
872
+ // 1b. Gate 2 — the PR/code REVIEW gate (GATES prd `work/prds/tasked/review.md`). It is a
873
+ // JUDGEMENT gate layered ON TOP of the deterministic `verify` floor (ADR §8)
874
+ // — NEVER a replacement, and ALWAYS verify-THEN-review on the SAME tree.
875
+ //
876
+ // WHERE it runs depends on the fresh-worktree gate (MAINTAINER DECISION 2):
877
+ // - OFF: `verify` ran HERE on the pre-rebase `cwd`, so the review runs HERE
878
+ // too, on `cwd`, BEFORE the done-move — byte-for-byte today's order.
879
+ // - ON: the front `verify` was SKIPPED here and runs LATER on the rebased
880
+ // tip (step 4c). So the review is RELOCATED to run there too, AFTER the
881
+ // rebased-tip `verify` passes, inside the same fresh gate worktree — so
882
+ // verify-then-review holds on the SAME merged tree (the tree that lands),
883
+ // not split across two trees. (Letting verify move to the rebased tip
884
+ // while review stayed on the pre-rebase `cwd` would deliver the
885
+ // gate-the-merged-tree guarantee for verify but BREAK it for review,
886
+ // incoherent with this task's own goal.)
887
+ //
888
+ // Either way the verdict routes IDENTICALLY:
889
+ // approve → fall through to the done-move/commit/integrate unchanged;
890
+ // block → route to needs-attention via the SAME machinery the red gate
891
+ // uses (`applyNeedsAttentionTransition`, surfaced on
892
+ // `surfaceArbiter` for the autonomous `do` path), with the
893
+ // blocking findings recorded as the reason, no integrate.
894
+ if (input.review && !freshWorktreeGate) {
895
+ const reviewOutcome = await runGate2Review({
896
+ reviewCwd: cwd,
897
+ input,
898
+ slug,
899
+ branch,
900
+ cwd,
901
+ env,
902
+ note,
903
+ });
904
+ if (reviewOutcome.kind === 'blocked') {
905
+ return reviewOutcome.result;
906
+ }
907
+ // approve: carry the verdict (post-integrate PR comment) and write the per-run
908
+ // non-blocking-nits observation INTO `cwd` BEFORE the done-move + atomic commit
909
+ // (so it is swept into that SAME done-commit). A resolved `merge` then lands
910
+ // automatically (`merge` IS the auto-land mode); `propose` leaves it to a human.
911
+ approvedVerdict = reviewOutcome.verdict;
912
+ writeReviewNitsObservation({
913
+ cwd,
914
+ slug,
915
+ findings: reviewOutcome.verdict?.findings ?? [],
916
+ note,
917
+ });
918
+ }
919
+
920
+ // Read the title now, BEFORE the move, for the default commit summary AND the
921
+ // synthesised propose-mode PR TITLE (the source file is about to be git-mv'd
922
+ // away). The PR title is a SINGLE, capped line built runner-side from the
923
+ // task's `title:` frontmatter + the slug (`<type>(<slug>): <title>`) so it can
924
+ // never be the multi-line commit-subject run-on `--fill` would derive.
925
+ //
926
+ // When the lifecycle supplies an EXPLICIT `title`, use it DIRECTLY (no file read):
927
+ // the intake lone-task / prd path writes its output file in `stage()`, which runs
928
+ // AFTER this point, so a `titlePath` read would race the write and degrade the
929
+ // subject/PR title to the generic fallback. The `do prd:` tasking path leaves
930
+ // `title` unset and keeps reading its already-existing held prd (unchanged).
931
+ const explicitTitle = lifecycle?.title;
932
+ const taskTitle =
933
+ explicitTitle !== undefined ? explicitTitle : readTaskTitle(sourcePath);
934
+ const defaultMessage =
935
+ explicitTitle !== undefined
936
+ ? summaryFromTitle(explicitTitle, slug)
937
+ : defaultSummary(sourcePath, slug);
938
+ const prTitle = synthesiseProposeTitle({
939
+ type: (input.type ?? DEFAULT_TYPE).trim() || DEFAULT_TYPE,
940
+ slug,
941
+ title: taskTitle,
942
+ });
943
+
944
+ // 2. STAGE the item move into the index. For a build that is the task done-move
945
+ // (`work/<source>/<slug>.md → work/done/<slug>.md`); for a TASKING
946
+ // transition (a non-task `lifecycle`) it is the caller-supplied prd
947
+ // lifecycle move + emitted backlog files (the runner stages them, the agent
948
+ // never does git). Either way the subsequent `git add -A` folds the agent's
949
+ // uncommitted work + this staging into ONE atomic commit.
950
+ //
951
+ // The task done-move is ATOMIC AGAINST THE ARBITER (ledger-integrity
952
+ // defect 1 + its root defect 2). The LOCAL move here is the legacy clean
953
+ // `git mv work/<source>/<slug>.md → work/done/<slug>.md` (one folder, no
954
+ // fetch — every existing path is byte-for-byte unchanged); the ARBITER
955
+ // resolution + the one-slug-one-folder enforcement happen AFTER the rebase
956
+ // (step 4b, `reconcileDoneMoveAgainstArbiter`), against the freshly-fetched
957
+ // `<arbiter>/main`. That ordering is deliberate: a NEW fetch in THIS step
958
+ // would race a sibling job's integration on the SHARED bare-mirror refs (the
959
+ // very regression that orphaned this work, ADR §2), so we reuse the existing
960
+ // step-4 fetch's result instead. The reconciliation REMOVES any divergent
961
+ // `in-progress/`/`needs-attention/` ghost the merge would otherwise leave (so
962
+ // the move is a MOVE, not a COPY) and FAILS LOUD if the arbiter holds the slug
963
+ // in two folders with differing content.
964
+ if (lifecycle) {
965
+ await lifecycle.stage();
966
+ } else if (source === 'done') {
967
+ // CONTINUE-BUILD (`source: 'done'`, task
968
+ // `complete-builds-on-already-done-moved-continue`): the slug is ALREADY in
969
+ // `work/done/` on this kept branch (a prior attempt moved it there), so there
970
+ // is NOTHING to move — skip the step-2 `git mv`. The subsequent `git add -A`
971
+ // folds the agent's NEW uncommitted source edits into the atomic commit on
972
+ // top of the kept (already-done-moved) tip; no second move, no copy.
973
+ } else {
974
+ mkdirSync(workFolderPath(cwd, 'done'), {recursive: true});
975
+ await gitHard(
976
+ [
977
+ 'mv',
978
+ workItemRel(source, `${slug}.md`),
979
+ workItemRel('done', `${slug}.md`),
980
+ ],
981
+ cwd,
982
+ env,
983
+ );
984
+ }
985
+
986
+ // 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
987
+ // atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
988
+ await gitHard(['add', '-A'], cwd, env);
989
+ if (await nothingStaged(cwd, env)) {
990
+ throw new IntegrationNothingStaged(
991
+ `nothing to commit for '${slug}' — no work and no move staged. ` +
992
+ '(Did the agent produce changes? Is the task already done?)',
993
+ slug,
994
+ );
995
+ }
996
+ // SCOOP + REPORT agent-authored CAPTURED NOTES (task `runner-scoops-captured-notes`,
997
+ // advance-loop's reporting-channel fold-in). A rung's agent may write capture-bucket
998
+ // files (`work/notes/observations/*`, `work/findings/*`) during its run — its `capture-signal`
999
+ // reflex — but it does NO git (Rule A). The `git add -A` above already SWEEPS them into
1000
+ // THIS one runner-owned commit (the same way the review-nits observation rides it), so
1001
+ // they are TRACKED, not dropped/untracked. Rule B is extended HERE: the runner REPORTS
1002
+ // exactly which note files landed (honest reporting — what actually reached the commit,
1003
+ // read from the staged set, not assumed). This is the ONE shared place — BOTH the build
1004
+ // path (`do <task>`/`run`/`complete`) and the tasking path (`do prd:`, via the
1005
+ // `lifecycle` seam) route through it, so the channel is NOT forked. Zero notes ⇒ no
1006
+ // report (the no-note case is byte-for-byte unchanged).
1007
+ await reportScoopedNotes(cwd, env, note);
1008
+ const summary = input.message ?? defaultMessage;
1009
+ const type = (input.type ?? DEFAULT_TYPE).trim() || DEFAULT_TYPE;
1010
+ // The trailing transition tag: a build is `; done`, a TASKING transition is
1011
+ // `; tasked` (the lifecycle supplies it). Keeps the runner-owned commit subject
1012
+ // honest about WHICH lifecycle landed.
1013
+ const commitMessage = `${type}(${slug}): ${summary}; ${lifecycle?.commitTag ?? 'done'}`;
1014
+ await gitHard(['commit', '-q', '-m', commitMessage], cwd, env);
1015
+ note(`Committed: ${commitMessage}`);
1016
+
1017
+ // The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
1018
+ // ONLY region serialised per repo under the `run` concurrency seam: it is the
1019
+ // land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
1020
+ // race (the loser pushing a non-fast-forward `${branch}:main`). Wrapping ONLY
1021
+ // this tail keeps the front-of-band gate (`prepare`+`verify`) and the Gate-2
1022
+ // review agent CONCURRENT across same-repo jobs (run's parallelism); inside the
1023
+ // lock the loser re-fetches + rebases onto the winner's now-advanced main, so
1024
+ // its push is a clean fast-forward (a genuine conflict routes ONE to
1025
+ // needs-attention). Single-job callers pass no lock ⇒ the tail runs directly,
1026
+ // byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
1027
+ // integration stays fully concurrent.
1028
+ const runRebaseToIntegrateTail = async (): Promise<IntegrationCoreResult> => {
1029
+ // The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
1030
+ // sibling-slug ledger arm and the divergent-done-move recovery), factored so it
1031
+ // can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
1032
+ // (a sibling advancing main mid-push needs the SAME reconcile, not a bare
1033
+ // rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
1034
+ // `{route}` when a genuine conflict / invariant violation must stop the tail.
1035
+ const rebaseOntoMainWithReconcile = async (): Promise<{
1036
+ route?: IntegrationCoreResult;
1037
+ }> => {
1038
+ // 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
1039
+ // latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
1040
+ //
1041
+ // RECOVERY reconciliation: when completing FROM needs-attention/, the work
1042
+ // branch's history still carries the original `in-progress → needs-attention`
1043
+ // MOVE-ONLY commit, and `<arbiter>/main` was SURFACED with that same move
1044
+ // (the item is in needs-attention/ on main). Replaying that historical move
1045
+ // onto main conflicts (main has no in-progress/<slug>.md) — exactly the
1046
+ // rebase conflict the human hit doing this by hand. So we DROP that move-only
1047
+ // commit during the rebase: the replay becomes `wip + (needs-attention →
1048
+ // done)`, which applies cleanly onto the surfaced main (it HAS the item in
1049
+ // needs-attention/). The done-move thus SUPERSEDES the surfaced state — no
1050
+ // leftover/conflicting on-`main` surface for the human to resolve.
1051
+ // Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
1052
+ // EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
1053
+ // has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
1054
+ // be stale, causing a spurious rebase conflict); a regular clone (`do`/
1055
+ // `complete`) already has it, where the explicit refspec is harmless (the same
1056
+ // refspec `rebaseOntoArbiterMain` used before the convergence).
1057
+ await gitHard(
1058
+ [
1059
+ 'fetch',
1060
+ '--quiet',
1061
+ arbiter,
1062
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
1063
+ ],
1064
+ cwd,
1065
+ env,
1066
+ );
1067
+ // ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
1068
+ // freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
1069
+ // just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
1070
+ // the arbiter already holds the slug in >1 status folder with differing content
1071
+ // (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
1072
+ // — the arbiter holds the slug's source in a DIFFERENT folder than our local
1073
+ // done-move removed — which is the case that turns the rebased "move" into a
1074
+ // "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
1075
+ // done-move).
1076
+ // CONTINUE-BUILD EXEMPTION (task
1077
+ // `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
1078
+ // there was no first-time move on this commit (the slug was already in
1079
+ // `done/` on the kept branch + on the arbiter), so the divergent-done-move
1080
+ // reconcile reasoning does not apply. Skip the arbiter ledger placement
1081
+ // pre-check too: it is structurally an instrument FOR that reconcile, and a
1082
+ // continue-build is by construction already in `done/` on both sides.
1083
+ if (!lifecycle && source !== 'done') {
1084
+ const arbiterPlacement = readArbiterLedgerPlacement(
1085
+ cwd,
1086
+ arbiter,
1087
+ slug,
1088
+ env,
1089
+ );
1090
+ if (arbiterPlacement.error) {
1091
+ note(arbiterPlacement.error);
1092
+ return {
1093
+ route: {
1094
+ outcome: 'invariant-violation',
1095
+ routedToNeedsAttention: false,
1096
+ branch,
1097
+ reason: arbiterPlacement.error,
1098
+ },
1099
+ };
1100
+ }
1101
+ }
1102
+ // PLAIN rebase. After the per-item-lock cut-over (prd
1103
+ // `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
1104
+ // lands on a work branch: needs-attention is the lock `state: stuck` (not a
1105
+ // `git mv` to `needs-attention/`), the body rests in `backlog/` while
1106
+ // claimed, and the tasking/advancing markers are gone. So a recovery
1107
+ // complete's kept branch carries NO historical route-to-needs-attention
1108
+ // move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
1109
+ // (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
1110
+ // rebases are the same plain replay onto `<arbiter>/main`.
1111
+ void recovering;
1112
+ void lifecycle;
1113
+ // RENAME-DETECTION-OFF (task
1114
+ // `disable-rename-detection-on-continue-rebase`): scope
1115
+ // `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
1116
+ // durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
1117
+ // NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
1118
+ // rename `work/<from>/ → work/<to>/` (which would spuriously flag every
1119
+ // sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
1120
+ // location)` and force a FALSE needs-attention). Content-rename detection
1121
+ // (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
1122
+ // does NOT suppress this directory-rename conflict; only
1123
+ // `merge.directoryRenames=false` does. NEVER a persistent `git config`
1124
+ // write — the repo's config stays clean so a user's interactive
1125
+ // `git rebase` is unaffected. A GENUINE content conflict still surfaces
1126
+ // and still routes via `rebaseConflictRoute()` below.
1127
+ const rebase = await gitSoft(
1128
+ ['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`],
1129
+ cwd,
1130
+ env,
1131
+ );
1132
+ if (rebase.status !== 0) {
1133
+ // NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
1134
+ // LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
1135
+ // `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
1136
+ // status-folder move on `<arbiter>/main` between our base and this rebase) is
1137
+ // a benign ledger-only divergence with NO semantic judgement: the reconcile
1138
+ // ABORTS the rebase, then redoes OUR work as one clean commit on top of
1139
+ // `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
1140
+ // automatically). It is scoped STRICTLY to other slugs' ledger files — a
1141
+ // conflict touching ANY code file, or THIS slug's own ledger, returns `false`
1142
+ // (and leaves the rebase in progress), so the divergent-done-move recovery /
1143
+ // needs-attention route below handles it; it NEVER widens to code. The tasking
1144
+ // lifecycle is exempt (its move is not a task done-move).
1145
+ const siblingReconciled = lifecycle
1146
+ ? false
1147
+ : await reconcileSiblingLedgerConflict({
1148
+ cwd,
1149
+ arbiter,
1150
+ slug,
1151
+ env,
1152
+ note,
1153
+ });
1154
+ if (siblingReconciled) {
1155
+ // The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
1156
+ // arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
1157
+ // band. THIS slug's own move is untouched.
1158
+ note(
1159
+ `Reconciled a sibling-slug ledger conflict during the rebase onto ` +
1160
+ `${arbiter}/main (took the arbiter's version of the other slugs' ` +
1161
+ `work/<status>/<slug>.md ledger files; no code file was touched).`,
1162
+ );
1163
+ } else if (!lifecycle && source !== 'done') {
1164
+ // NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
1165
+ // DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
1166
+ // our local done-move did not remove — PR #86) is auto-RECONCILABLE without
1167
+ // any semantic judgement: redo the done-move arbiter-resolved (remove the
1168
+ // arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
1169
+ // only do this when the post-abort tree's ONLY divergence is the slug's ledger
1170
+ // file; a real code conflict still routes to needs-attention untouched.
1171
+ await gitSoft(['rebase', '--abort'], cwd, env);
1172
+ const recovered = await reconcileDivergentDoneMove({
1173
+ cwd,
1174
+ arbiter,
1175
+ slug,
1176
+ branch,
1177
+ localSource: source,
1178
+ env,
1179
+ note,
1180
+ });
1181
+ if (recovered) {
1182
+ // The branch is now cleanly on top of `<arbiter>/main` with the slug in
1183
+ // `done/` ONLY — fall through to integrate (skip the needs-attention
1184
+ // route below).
1185
+ note(
1186
+ `Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
1187
+ 'work/done/ ONLY (the divergent source folder was removed; the move ' +
1188
+ 'is a move, not a copy).',
1189
+ );
1190
+ } else {
1191
+ return {route: await rebaseConflictRoute()};
1192
+ }
1193
+ } else {
1194
+ await gitSoft(['rebase', '--abort'], cwd, env);
1195
+ return {route: await rebaseConflictRoute()};
1196
+ }
1197
+ }
1198
+ // Clean rebase (or a reconciled one): fall through to the gate + integrate.
1199
+ return {};
1200
+ };
1201
+
1202
+ // Run the step-4 rebase ONCE up front (before the slow fresh gate).
1203
+ const firstRebase = await rebaseOntoMainWithReconcile();
1204
+ if (firstRebase.route) {
1205
+ return firstRebase.route;
1206
+ }
1207
+
1208
+ // The rebase-conflict needs-attention route, factored so the divergent-ledger
1209
+ // recovery above can fall through to integrate while a genuine code conflict
1210
+ // still routes here.
1211
+ async function rebaseConflictRoute(): Promise<IntegrationCoreResult> {
1212
+ // Then route the item to needs-attention/ with the conflict reason (ADR
1213
+ // §12) THROUGH the ledger write seam's needs-attention transition, rather
1214
+ // than leaving it dangling in done/. The done-move was already committed
1215
+ // above, so the item sits in work/done/; the move bounces it from there and
1216
+ // commits the in-progress→needs-attention move (here done→needs-attention)
1217
+ // as ONE transition. No partial state.
1218
+ const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
1219
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1220
+ cwd,
1221
+ slug,
1222
+ reason,
1223
+ // Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
1224
+ // conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
1225
+ // (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
1226
+ // no surface, no push, local-only. The push lives in the seam (HEAD's
1227
+ // branch is `work/<slug>` here) — no bolted-on push.
1228
+ arbiter: input.surfaceArbiter,
1229
+ env,
1230
+ note,
1231
+ });
1232
+ return {
1233
+ outcome: 'rebase-conflict',
1234
+ routedToNeedsAttention: routed.moved,
1235
+ branch,
1236
+ commitMessage,
1237
+ reason: routed.moved
1238
+ ? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1239
+ `aborted (never auto-resolved) and '${slug}' was routed to ` +
1240
+ 'work/needs-attention/ (surfaced by status). Resolve against the ' +
1241
+ 'latest main, then return it to backlog/ and re-run.'
1242
+ : `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
1243
+ 'aborted (never auto-resolved). Resolve against the latest main, ' +
1244
+ 'then re-run complete.',
1245
+ };
1246
+ }
1247
+
1248
+ // 4c. FRESH-WORKTREE GATE (task `gate-on-rebased-tip-fresh-worktree`): when ON,
1249
+ // the acceptance gate (`prepare` then `verify`) runs HERE — on the work
1250
+ // branch tip the rebase above just produced (the would-be-integrated tip) —
1251
+ // rather than on the agent's pre-rebase `cwd`. We cut a CLEAN throwaway
1252
+ // worktree from `HEAD` (the rebased committed tip), `prepare` then `verify`
1253
+ // in it, REAP it (pass or fail), and only on GREEN fall through to integrate.
1254
+ // A gitignored/uncommitted file in `cwd` cannot leak into this gate (the
1255
+ // worktree is cut from the committed, rebased tip), and a change the
1256
+ // integration rebase introduced IS gated. A red gate routes the item the SAME
1257
+ // way the front gate did — EXCEPT the done-move already happened (steps 2–3),
1258
+ // so the bounce is from `work/done/` (the seam finds the slug wherever it
1259
+ // rests) instead of `work/in-progress/`. A `--skip-verify` skipped the gate
1260
+ // entirely at the front, so it never reaches here. The tasking `lifecycle`
1261
+ // path is exempt (its quality engine is the tasker loop, not this gate).
1262
+ if (freshWorktreeGate && !input.skipVerify && !lifecycle) {
1263
+ const tip = (
1264
+ await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)
1265
+ ).stdout.trim();
1266
+ const gated = await runFreshWorktreeGate({
1267
+ cwd,
1268
+ commit: tip,
1269
+ prepare: input.prepare,
1270
+ verify: input.verify,
1271
+ env,
1272
+ note,
1273
+ // GATE-2 REVIEW relocation (MAINTAINER DECISION 2): when `review` is ON,
1274
+ // the fresh gate runs it AFTER the rebased-tip verify, against the rebased
1275
+ // tip (the gate worktree) — so verify-THEN-review holds on the SAME merged
1276
+ // tree. The needs-attention ROUTING still targets `cwd` (the work branch +
1277
+ // ledger), so the verdict handling is identical to the OFF path.
1278
+ review: input.review
1279
+ ? (reviewCwd) =>
1280
+ runGate2Review({
1281
+ reviewCwd,
1282
+ input,
1283
+ slug,
1284
+ branch,
1285
+ cwd,
1286
+ env,
1287
+ note,
1288
+ })
1289
+ : undefined,
1290
+ });
1291
+ if (!gated.passed) {
1292
+ // prepare-failed or gate-failed on the rebased tip: route the item to
1293
+ // needs-attention through the SAME seam the front gate / rebase-conflict use.
1294
+ // The done-move was already committed (steps 2–3), so the slug sits in
1295
+ // work/done/; the seam bounces it from there (done → needs-attention). The
1296
+ // recovery path keeps it where it is (no re-route) exactly like the front gate.
1297
+ const outcome: IntegrationCoreOutcome =
1298
+ gated.kind === 'prepare' ? 'prepare-failed' : 'gate-failed';
1299
+ const what =
1300
+ gated.kind === 'prepare'
1301
+ ? `Env-prep (prepare) failed (exit ${gated.exitCode})`
1302
+ : `Acceptance gate failed (exit ${gated.exitCode})`;
1303
+ const reason =
1304
+ gated.kind === 'prepare'
1305
+ ? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
1306
+ : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
1307
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1308
+ cwd,
1309
+ slug,
1310
+ reason,
1311
+ arbiter: input.surfaceArbiter,
1312
+ env,
1313
+ note,
1314
+ });
1315
+ return {
1316
+ outcome,
1317
+ routedToNeedsAttention: routed.moved,
1318
+ branch,
1319
+ commitMessage,
1320
+ reason: routed.moved
1321
+ ? `${what} on the rebased tip; routed '${slug}' to ` +
1322
+ 'work/needs-attention/ (surfaced by status; return to backlog/ ' +
1323
+ 'once resolved). Fix the work, or use --skip-verify to override.'
1324
+ : `${what} on the rebased tip; not completing '${slug}'. Fix the ` +
1325
+ 'work, or use --skip-verify to override.',
1326
+ };
1327
+ }
1328
+ // GATE-2 REVIEW outcome on the rebased tip (MAINTAINER DECISION 2): the
1329
+ // rebased-tip verify PASSED, so the review ran AFTER it (verify-then-review on
1330
+ // the merged tree). Route a BLOCK exactly as the OFF-path front review does
1331
+ // (only the reviewed tree moved, not the routing). On APPROVE: carry the
1332
+ // verdict (post-integrate PR comment), write the per-run non-blocking-nits
1333
+ // observation. A resolved `merge` then lands automatically; `propose` leaves
1334
+ // the merge to a human.
1335
+ if (gated.review) {
1336
+ if (gated.review.kind === 'blocked') {
1337
+ // The done-move was already committed (steps 2–3), so the slug sits in
1338
+ // work/done/; the routing bounced it from there to needs-attention/.
1339
+ return gated.review.result;
1340
+ }
1341
+ approvedVerdict = gated.review.verdict;
1342
+ // The done-move + atomic commit already happened (steps 2–3, before this
1343
+ // rebased-tip gate), so — unlike the OFF path where the nits write rides the
1344
+ // upcoming commit — we write the observation into `cwd` and FOLD it into the
1345
+ // existing done-commit via `commit --amend` (the branch is not yet
1346
+ // integrated). The observation still lands in the SAME done-commit that
1347
+ // integrates, preserving the no-separate-commit/surface model.
1348
+ const nitsBefore = await stagedCaptureNotes(cwd, env);
1349
+ writeReviewNitsObservation({
1350
+ cwd,
1351
+ slug,
1352
+ findings: gated.review.verdict?.findings ?? [],
1353
+ note,
1354
+ });
1355
+ // Only amend when the write actually produced a new staged file (zero
1356
+ // non-blocking findings ⇒ no write ⇒ no amend, the done-commit unchanged).
1357
+ await gitHard(['add', '-A'], cwd, env);
1358
+ if (!(await nothingStaged(cwd, env))) {
1359
+ const nitsAfter = await stagedCaptureNotes(cwd, env);
1360
+ if (nitsAfter.length > nitsBefore.length) {
1361
+ await gitHard(['commit', '-q', '--amend', '--no-edit'], cwd, env);
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ // 5. Integrate per mode through the ledger write seam's COMPLETE transition
1368
+ // (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
1369
+ // already brought the branch up to date, so the seam's sole strategy uses
1370
+ // `integrate` (not `integrateWithRebase`) and never --forces. Provider
1371
+ // selection: an injected `openPr` wins (legacy bridge); otherwise pick by
1372
+ // the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
1373
+ // `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
1374
+ // `gh` degrades to push-only at runtime — never a hard failure (and the
1375
+ // start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
1376
+ // The seam is storage-agnostic: we hand it the work branch, the integration
1377
+ // mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
1378
+ // strategy.
1379
+ // Provider precedence: an injected fully-formed provider wins (the `run`
1380
+ // stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
1381
+ // bridge; else select PURELY from the arbiter URL (no override). The orthogonal
1382
+ // `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
1383
+ // provider, the integrator simply skips `openRequest` when it is set.
1384
+ const provider =
1385
+ input.providerInstance ??
1386
+ (input.openPr
1387
+ ? bridgeProvider(input.openPr)
1388
+ : selectProvider({
1389
+ arbiterUrl: await arbiterUrl(cwd, arbiter, env),
1390
+ }));
1391
+ // Race-1 (claim-vs-integrate, task
1392
+ // `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
1393
+ // and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
1394
+ // under the SEPARATE claim lock — or a sibling integrate advanced
1395
+ // `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
1396
+ // carries the sibling-ledger + divergent-done-move reconcile arms — a bare
1397
+ // re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
1398
+ // retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
1399
+ // main: each retry re-rebases to a clean fast-forward. A genuine code conflict
1400
+ // on a re-rebase routes to needs-attention via the SAME `route` the up-front
1401
+ // rebase uses. A persistent non-fast-forward past the cap also routes (never a
1402
+ // silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
1403
+ //
1404
+ // **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
1405
+ // promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
1406
+ // counts against a tiny give-up budget — only a GENUINE conflict surfaced by
1407
+ // `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
1408
+ // `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
1409
+ // precondition recheck reused verbatim: if the slug is GONE from its expected
1410
+ // source folder on the new `main` (a concurrent legitimate same-item winner
1411
+ // already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
1412
+ // definitively — never a silent re-push that would clobber the winner. The
1413
+ // `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
1414
+ // pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
1415
+ // modest jitter on the refetch desynchronises a herd so the tail is not
1416
+ // reached under sustained parallel load.
1417
+ const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
1418
+ const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
1419
+ let integration!: IntegrateResult;
1420
+ for (let mergeAttempt = 0; ; mergeAttempt++) {
1421
+ integration = await ledgerWrite.applyCompleteTransition({
1422
+ arbiter,
1423
+ branch,
1424
+ mode,
1425
+ provider,
1426
+ // PR-INTENT: when set (propose mode), push the branch but skip the PR.
1427
+ noPR: input.noPR,
1428
+ // Half A: an explicit single-line PR title (propose mode), so `gh` no longer
1429
+ // derives a run-on title from the commit subject via `--fill`.
1430
+ title: prTitle,
1431
+ // Half B: the propose-mode PR body — the agent's summary under a deterministic
1432
+ // runner header (task pointer). Undefined when no body was supplied (the
1433
+ // header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
1434
+ // regression). Ignored in merge mode by the provider/integrator.
1435
+ body: composeProposeBody({slug, body: input.body}),
1436
+ // Part (b) of the merged-branch hygiene task: when WE perform the merge
1437
+ // (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
1438
+ // INLINE right after the merge lands — the commits are now on `main`, so the
1439
+ // head is provably merged and safe to delete (ancestor-guarded inside the
1440
+ // integrator). Idempotent no-op when no remote head exists (the plain
1441
+ // `${branch}:main` push opened none); ignored in `propose` mode (its branch is
1442
+ // the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
1443
+ deleteMergedHead: true,
1444
+ cwd,
1445
+ env,
1446
+ });
1447
+ // Only the merge push can be non-fast-forward (propose pushes its own ref).
1448
+ if (integration.mergeNonFastForward !== true) {
1449
+ break;
1450
+ }
1451
+ if (mergeAttempt >= maxMergeRetries) {
1452
+ // LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
1453
+ // now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
1454
+ // false-contention budget). Route to needs-attention rather than looping
1455
+ // forever or force-pushing main. A RARE outcome under realistic load thanks
1456
+ // to the jitter below + the rebase-until-real-conflict semantics; tests
1457
+ // reach it deterministically by injecting a small `mergeRetries`.
1458
+ return await mergeNonFastForwardRoute(
1459
+ `integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1460
+ `non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
1461
+ `times); gave up cleanly without --force`,
1462
+ );
1463
+ }
1464
+ // Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
1465
+ // maximises mutual rejection under sustained parallel load (thundering herd).
1466
+ // A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
1467
+ // Skipped when `mergeJitterMs === 0` (the test seam).
1468
+ if (mergeJitterMs > 0) {
1469
+ await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
1470
+ }
1471
+ // A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
1472
+ // before retrying the push. A genuine conflict on the re-rebase routes via
1473
+ // `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
1474
+ // one-slug placement recheck IS the genuine-conflict terminator — see the
1475
+ // `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
1476
+ // (no route) loops without counting against a small budget.
1477
+ const reRebase = await rebaseOntoMainWithReconcile();
1478
+ if (reRebase.route) {
1479
+ return reRebase.route;
1480
+ }
1481
+ }
1482
+
1483
+ // The Race-1 needs-attention route for a merge that could not land (a genuine
1484
+ // re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
1485
+ // covers the cap-exhausted persistent-contention case). Mirrors
1486
+ // `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
1487
+ // slug sits in work/done/ and the seam bounces it from there.
1488
+ async function mergeNonFastForwardRoute(
1489
+ reason: string,
1490
+ ): Promise<IntegrationCoreResult> {
1491
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1492
+ cwd,
1493
+ slug,
1494
+ reason,
1495
+ arbiter: input.surfaceArbiter,
1496
+ env,
1497
+ note,
1498
+ });
1499
+ return {
1500
+ outcome: 'rebase-conflict',
1501
+ routedToNeedsAttention: routed.moved,
1502
+ branch,
1503
+ commitMessage,
1504
+ reason: routed.moved
1505
+ ? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1506
+ `non-fast-forward push (a sibling advanced main); '${slug}' was routed ` +
1507
+ `to work/needs-attention/ (surfaced by status). Resolve against the ` +
1508
+ `latest main, then return it to backlog/ and re-run.`
1509
+ : `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
1510
+ `non-fast-forward push (a sibling advanced main). Resolve against the ` +
1511
+ `latest main, then re-run complete.`,
1512
+ };
1513
+ }
1514
+
1515
+ // 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
1516
+ // refining `review-gate-pr-comment`): AFTER the propose integrate, where the
1517
+ // approved verdict (with its deliberately-authored `review` prose), the
1518
+ // resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
1519
+ // scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
1520
+ // (the audit trail; decided 2026-06-06). The `review` field is a first-class
1521
+ // AUTHORED review (the prompt requires it), NOT the residue around the JSON
1522
+ // — posting the residue was the bug
1523
+ // (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
1524
+ // reuses the SAME `provider` the integrate used (the core never imports `gh`).
1525
+ // The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
1526
+ // — by here the verdict has ALREADY routed (block never reaches this point; it
1527
+ // routed to needs-attention above) and the integrate has ALREADY happened.
1528
+ // The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
1529
+ // (the normal path — post on it directly); else, when a PR WAS opened but its
1530
+ // url was unparseable (`integration.requestOpened` true, `url` undefined — the
1531
+ // `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
1532
+ // BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
1533
+ // the provider resolves the branch's open PR and comments on it, instead of
1534
+ // silently dropping a review on a PR that genuinely exists. Only when NO PR was
1535
+ // opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
1536
+ // false) is it the honest clean no-op — and the branch-resolved fallback's own
1537
+ // "no PR resolvable" path is a clean no-op too (it tries first). Either way
1538
+ // `postPRComment*` never throws; the review stays in the run output. Because
1539
+ // this lives in the shared core, BOTH `do`/`complete` AND `run` post the
1540
+ // comment — no per-caller wiring.
1541
+ if (approvedVerdict?.review !== undefined) {
1542
+ if (integration.url !== undefined) {
1543
+ const posted = provider.postPRComment({
1544
+ cwd,
1545
+ url: integration.url,
1546
+ body: approvedVerdict.review,
1547
+ env,
1548
+ });
1549
+ note(posted.instruction);
1550
+ } else if (integration.requestOpened) {
1551
+ // A PR opened but its url was unparseable — resolve it from the branch
1552
+ // rather than dropping the review (the audit-trail fallback).
1553
+ const posted = provider.postPRCommentOnBranch({
1554
+ cwd,
1555
+ branch,
1556
+ body: approvedVerdict.review,
1557
+ env,
1558
+ });
1559
+ note(posted.instruction);
1560
+ }
1561
+ }
1562
+
1563
+ return {
1564
+ outcome: 'completed',
1565
+ routedToNeedsAttention: false,
1566
+ branch,
1567
+ commitMessage,
1568
+ integration,
1569
+ };
1570
+ };
1571
+
1572
+ // Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
1573
+ // it directly (an un-contended no-op, single-job behaviour unchanged).
1574
+ return input.integrateLock && input.integrateLockKey !== undefined
1575
+ ? await input.integrateLock(
1576
+ input.integrateLockKey,
1577
+ runRebaseToIntegrateTail,
1578
+ )
1579
+ : await runRebaseToIntegrateTail();
1580
+ }
1581
+
1582
+ /**
1583
+ * RECOVER an already-committed, already-done-moved STRANDED branch (prd
1584
+ * `ledger-integrity` story 6, the `finish-already-committed-branch` task). The
1585
+ * green work AND the `git mv → work/done/` are ALREADY committed on the work
1586
+ * branch (a terminal push failed AFTER `performIntegration`'s steps 2–3), and the
1587
+ * tip is NOT on the arbiter. This runs ONLY the rebase→integrate TAIL (steps 4–5)
1588
+ * from the kept commit — NO re-done-move, NO re-commit, NO rebuild, NO orphan
1589
+ * branch — reusing the SAME `ledgerWrite.applyCompleteTransition` integrate
1590
+ * primitive the build path uses.
1591
+ *
1592
+ * RE-GATE on the REBASED TIP (task `committed-recovery-honours-fresh-worktree-
1593
+ * gate`, prd `land-time-reverify-and-parallel-merge-ceiling`): when the caller
1594
+ * sets `freshWorktreeGate` (and not `skipVerify`), the EXISTING
1595
+ * `runFreshWorktreeGate` runs on the rebased tip AFTER the rebase loop and
1596
+ * BEFORE `applyCompleteTransition`, mirroring the build path's
1597
+ * `freshWorktreeGate && !skipVerify && !lifecycle` branch — a red gate routes
1598
+ * to needs-attention through the SAME shared seam, never integrates a clean-
1599
+ * rebase-but-broken merge. This is OPT-IN per caller: the original stranded-
1600
+ * recovery caller (`complete --integration`'s already-built strand, whose pre-
1601
+ * strand build already gated) leaves it UNSET and is byte-identical to before —
1602
+ * no extra gate, no extra fetch. The answered-merge apply-rung SETS it because
1603
+ * `<arbiter>/main` may have moved since the branch's last build, so the rebased
1604
+ * tip MUST be re-verified before it lands or the load-bearing invariant
1605
+ * ("main never receives a tree that fails verify") cannot hold on the merge
1606
+ * path.
1607
+ *
1608
+ * SAFETY — UNSPOOFABLE detection: BEFORE acting it fetches `<arbiter>/main` and
1609
+ * checks whether the kept tip is ALREADY reachable there (`isAncestor`, the SAME
1610
+ * predicate `gc.ts` uses). If so the work is already integrated → a clean
1611
+ * `already-integrated` no-op (never a re-push / double-integrate); a re-run after a
1612
+ * successful recovery hits this. Only when the tip is genuinely AHEAD does it
1613
+ * rebase + integrate. A rebase CONFLICT here is a genuine code conflict the human
1614
+ * resolves — the branch is aborted and the outcome is `rebase-conflict` (the kept
1615
+ * commit stays intact on the branch, recoverable), NEVER auto-resolved, NEVER
1616
+ * `--force` to main.
1617
+ */
1618
+ async function recoverAlreadyCommitted(params: {
1619
+ cwd: string;
1620
+ arbiter: string;
1621
+ slug: string;
1622
+ branch: string;
1623
+ mode: IntegrationMode;
1624
+ noPR?: boolean;
1625
+ providerInstance?: ReviewProvider;
1626
+ openPr?: (opts: {
1627
+ cwd: string;
1628
+ branch: string;
1629
+ env?: NodeJS.ProcessEnv;
1630
+ }) => void;
1631
+ recoveryRebaseRetries?: number;
1632
+ recoveryRebaseJitterMs?: number;
1633
+ recoveryRebaseSleep?: Sleep;
1634
+ recoveryRebaseRandom?: () => number;
1635
+ /**
1636
+ * Run the EXISTING `runFreshWorktreeGate` (`prepare` then `verify`) on the
1637
+ * rebased tip AFTER the rebase loop and BEFORE `applyCompleteTransition`
1638
+ * (task `committed-recovery-honours-fresh-worktree-gate`, prd
1639
+ * `land-time-reverify-and-parallel-merge-ceiling`). The original stranded-
1640
+ * recovery caller (`complete --integration`'s already-built strand) leaves
1641
+ * this UNSET ⇒ no gate, no extra fetch — byte-identical to before. The
1642
+ * answered-merge apply-rung sets it ⇒ the rebased tip is re-verified before
1643
+ * it lands, so the load-bearing invariant ("main never receives a tree that
1644
+ * fails verify") holds on the merge path: a clean rebase that fails verify
1645
+ * routes to needs-attention via the SAME `applyNeedsAttentionTransition`
1646
+ * seam the build path's `freshWorktreeGate && !skipVerify && !lifecycle`
1647
+ * branch uses — no fork of a second gate or a second integrate primitive.
1648
+ */
1649
+ freshWorktreeGate?: boolean;
1650
+ /** `--skip-verify` honoured exactly as the build path: skips the gate entirely. */
1651
+ skipVerify?: boolean;
1652
+ /** Env-prep config for the fresh gate (mirrors the build path). */
1653
+ prepare?: VerifyConfig;
1654
+ /** Acceptance gate config for the fresh gate (mirrors the build path). */
1655
+ verify?: VerifyConfig;
1656
+ /**
1657
+ * Autonomous needs-attention surface (mirrors the build path): when set, a
1658
+ * red rebased-tip gate cherry-picks the bounce onto `main` + pushes the
1659
+ * work branch (observable + cross-machine). Unset ⇒ local-only routing.
1660
+ */
1661
+ surfaceArbiter?: string;
1662
+ env: NodeJS.ProcessEnv | undefined;
1663
+ note: (message: string) => void;
1664
+ }): Promise<IntegrationCoreResult> {
1665
+ const {cwd, arbiter, slug, branch, mode, env, note} = params;
1666
+ const retries =
1667
+ params.recoveryRebaseRetries ?? DEFAULT_RECOVERY_REBASE_RETRIES;
1668
+ const jitterMs =
1669
+ params.recoveryRebaseJitterMs ?? DEFAULT_RECOVERY_REBASE_JITTER_MS;
1670
+ const sleep = params.recoveryRebaseSleep ?? realSleep;
1671
+ const random = params.recoveryRebaseRandom ?? Math.random;
1672
+
1673
+ // Helper: the explicit-refspec fetch (the build path's step-4 fetch shape — a
1674
+ // bare-mirror worktree's remote has no fetch refspec, so `<arbiter>/main` would
1675
+ // not otherwise resolve / would be stale). REUSED on EACH attempt (the root cause
1676
+ // of the moving-base race is a stale SINGLE fetch — see the loop below).
1677
+ const refetchMain = async (): Promise<void> => {
1678
+ await gitHard(
1679
+ [
1680
+ 'fetch',
1681
+ '--quiet',
1682
+ arbiter,
1683
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
1684
+ ],
1685
+ cwd,
1686
+ env,
1687
+ );
1688
+ };
1689
+
1690
+ await refetchMain();
1691
+
1692
+ const tip = (
1693
+ await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)
1694
+ ).stdout.trim();
1695
+ if (tip === '') {
1696
+ throw new Error(
1697
+ `cannot recover '${slug}': HEAD does not resolve (no committed work on ` +
1698
+ `${branch}?).`,
1699
+ );
1700
+ }
1701
+
1702
+ // UNSPOOFABLE detection: the kept tip ALREADY reachable on `<arbiter>/main`
1703
+ // means the work is already integrated — a clean no-op, NEVER a re-integration.
1704
+ // (`isAncestor` is the SAME reachability predicate `gc.ts` uses; do not fork it.)
1705
+ // KEPT before the retry loop: a no-op MUST short-circuit before we burn any
1706
+ // re-fetch/re-rebase budget.
1707
+ if (isAncestor(cwd, tip, `refs/remotes/${arbiter}/main`, env)) {
1708
+ const message =
1709
+ `Nothing to recover for '${slug}': its work branch tip is already on ` +
1710
+ `${arbiter}/main (already integrated). No re-push, no double-integrate.`;
1711
+ note(message);
1712
+ return {
1713
+ outcome: 'already-integrated',
1714
+ routedToNeedsAttention: false,
1715
+ branch,
1716
+ reason: message,
1717
+ };
1718
+ }
1719
+
1720
+ // The tip is genuinely AHEAD — rebase the kept commit onto the latest
1721
+ // `<arbiter>/main`. A clean rebase continues; a CONFLICT is wrapped in a
1722
+ // bounded CONTENTION loop (task `recovery-rebase-retry-against-moving-arbiter-
1723
+ // main`): on each conflict `--abort`, sleep a small jitter, RE-FETCH
1724
+ // `<arbiter>/main` (it may have advanced — `advance` runs land bursts of
1725
+ // `advance: surface observation:…` commits on main, so a one-shot rebase against
1726
+ // a stale fetched base can conflict against a main that already moved AGAIN),
1727
+ // then re-rebase. Only after the cap exhausts (a freshly-fetched main STILL
1728
+ // conflicts on every attempt) do we surface `rebase-conflict` (never auto-
1729
+ // resolved, NEVER `--force` to main — the kept commit stays on the branch,
1730
+ // recoverable; the human resolves and re-runs).
1731
+ //
1732
+ // This is the CONTENTION model (instant re-fetch+rebuild against the new base,
1733
+ // like `claim-cas.ts` / the Race-1 merge loop above), NOT the OUTAGE model in
1734
+ // `retry-backoff.ts` (exponential temporal backoff for an unreachable remote).
1735
+ // The jitter is a SMALL livelock-breaking SPREAD (two runners that begin
1736
+ // retrying at the same instant must NOT re-fetch/re-rebase in lockstep) — NOT
1737
+ // exponential outage backoff. The `--abort` is unconditional on conflict (never
1738
+ // leave the worktree mid-rebase between attempts).
1739
+ //
1740
+ // RECONCILE ARMS DECISION (this task): the recovery rebase is deliberately
1741
+ // BARE — it does NOT layer the sibling-ledger / divergent-done-move arms the
1742
+ // build path's `rebaseOntoMainWithReconcile()` carries. Reasoning: this tail
1743
+ // integrates a branch whose done-move was ALREADY committed in a prior run, so
1744
+ // there is no first-time slug relocation on THIS commit for the divergent-
1745
+ // done-move reconcile to act on, and a sibling-slug ledger conflict on the
1746
+ // re-fetched main is the same shape it would have hit on the original run (the
1747
+ // recovery is not the place to grow new reconcile semantics).
1748
+ //
1749
+ // RENAME-DETECTION composition (task
1750
+ // `disable-rename-detection-on-continue-rebase`): the rebase carries
1751
+ // `-c merge.directoryRenames=false` SCOPED to the invocation — written as a
1752
+ // small args array so every retry of THIS loop carries it too — so a single
1753
+ // durable folder-transition `git mv` out of a SPARSE source folder is NOT
1754
+ // misread as a whole-DIRECTORY rename and the post-rename heuristic does NOT
1755
+ // flag sibling files `<arbiter>/main` added into that folder as `CONFLICT
1756
+ // (file location)`. Content-rename detection (`-Xno-renames`/`merge.renames`/
1757
+ // `diff.renames`) is the WRONG knob and was verified ineffective for this
1758
+ // directory-rename conflict; only `merge.directoryRenames=false` suppresses
1759
+ // it. NEVER a persistent `git config` write — the repo's config stays clean.
1760
+ // A GENUINE same-path content conflict still surfaces and still routes to
1761
+ // `rebase-conflict` (the user's interactive `git rebase` is unaffected).
1762
+ note(
1763
+ `Recovering '${slug}': rebasing the kept ${branch} onto ${arbiter}/main…`,
1764
+ );
1765
+ const rebaseArgs = (): string[] => [
1766
+ '-c',
1767
+ 'merge.directoryRenames=false',
1768
+ 'rebase',
1769
+ `${arbiter}/main`,
1770
+ ];
1771
+ let attempt = 0;
1772
+ for (;;) {
1773
+ const rebase = await gitSoft(rebaseArgs(), cwd, env);
1774
+ if (rebase.status === 0) {
1775
+ break; // clean rebase ⇒ fall through to integrate
1776
+ }
1777
+ // ALWAYS abort on conflict — never leave mid-rebase between attempts.
1778
+ await gitSoft(['rebase', '--abort'], cwd, env);
1779
+ if (attempt >= retries) {
1780
+ const message =
1781
+ `Recovering '${slug}': rebasing the kept ${branch} onto ${arbiter}/main ` +
1782
+ `conflicted on every attempt (${attempt + 1} total, against a freshly-` +
1783
+ `fetched ${arbiter}/main each time); the rebase was aborted (never auto-` +
1784
+ 'resolved). The committed work is intact on the branch (recoverable). ' +
1785
+ 'Resolve against the latest main, then re-run.';
1786
+ note(message);
1787
+ return {
1788
+ outcome: 'rebase-conflict',
1789
+ routedToNeedsAttention: false,
1790
+ branch,
1791
+ reason: message,
1792
+ };
1793
+ }
1794
+ // Small livelock-breaking jitter (contention spread, NOT outage backoff).
1795
+ // Sleep happens BEFORE the re-fetch so a sleep-injection in tests can also
1796
+ // drive the timeline (e.g. advance the arbiter between attempts).
1797
+ const delay = jitterMs > 0 ? Math.floor(random() * (jitterMs + 1)) : 0;
1798
+ await sleep(delay);
1799
+ await refetchMain();
1800
+ attempt++;
1801
+ }
1802
+
1803
+ // FRESH-WORKTREE GATE on the REBASED TIP (task `committed-recovery-honours-
1804
+ // fresh-worktree-gate`, prd `land-time-reverify-and-parallel-merge-ceiling`):
1805
+ // when `freshWorktreeGate` is set (the answered-merge land caller) and not
1806
+ // `--skip-verify`, re-run the acceptance gate on the rebased tip BEFORE we
1807
+ // integrate, mirroring the build path's `freshWorktreeGate && !skipVerify &&
1808
+ // !lifecycle` branch (recovery never carries a lifecycle, so no lifecycle
1809
+ // guard is needed). A green gate ⇒ fall through to integrate exactly as today;
1810
+ // a red gate routes to needs-attention through the SAME shared seam
1811
+ // (`applyNeedsAttentionTransition`) the build path uses — NEVER integrates a
1812
+ // clean-rebase-but-broken merge. With `freshWorktreeGate` UNSET (the original
1813
+ // stranded-recovery caller, whose pre-strand build already gated) this whole
1814
+ // block is skipped and behaviour is byte-identical to before.
1815
+ if (params.freshWorktreeGate && !params.skipVerify) {
1816
+ const tip = (
1817
+ await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)
1818
+ ).stdout.trim();
1819
+ // No `review:` callback here: the recovery tail re-verifies an already-
1820
+ // reviewed, already-committed result, so Gate-2 review semantics do not
1821
+ // apply on this path.
1822
+ const gated = await runFreshWorktreeGate({
1823
+ cwd,
1824
+ commit: tip,
1825
+ prepare: params.prepare,
1826
+ verify: params.verify,
1827
+ env,
1828
+ note,
1829
+ });
1830
+ if (!gated.passed) {
1831
+ const outcome: IntegrationCoreOutcome =
1832
+ gated.kind === 'prepare' ? 'prepare-failed' : 'gate-failed';
1833
+ const what =
1834
+ gated.kind === 'prepare'
1835
+ ? `Env-prep (prepare) failed (exit ${gated.exitCode})`
1836
+ : `Acceptance gate failed (exit ${gated.exitCode})`;
1837
+ const reason =
1838
+ gated.kind === 'prepare'
1839
+ ? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
1840
+ : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
1841
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1842
+ cwd,
1843
+ slug,
1844
+ reason,
1845
+ arbiter: params.surfaceArbiter,
1846
+ env,
1847
+ note,
1848
+ });
1849
+ return {
1850
+ outcome,
1851
+ routedToNeedsAttention: routed.moved,
1852
+ branch,
1853
+ reason: routed.moved
1854
+ ? `${what} on the rebased tip during committed-recovery; routed ` +
1855
+ `'${slug}' to work/needs-attention/ (surfaced by status; return ` +
1856
+ 'to backlog/ once resolved). Fix the work, or use --skip-verify ' +
1857
+ 'to override.'
1858
+ : `${what} on the rebased tip during committed-recovery; not ` +
1859
+ `completing '${slug}'. Fix the work, or use --skip-verify to ` +
1860
+ 'override.',
1861
+ };
1862
+ }
1863
+ }
1864
+
1865
+ // Integrate the rebased kept commit through the SAME complete-transition
1866
+ // primitive the build path uses (no duplication of the integrate mechanism; the
1867
+ // branch is already rebased so it is the non-rebasing `integrate`, never
1868
+ // `--force`). Provider precedence matches the build path: injected instance >
1869
+ // legacy `openPr` bridge > arbiter-derived selection.
1870
+ const provider =
1871
+ params.providerInstance ??
1872
+ (params.openPr
1873
+ ? bridgeProvider(params.openPr)
1874
+ : selectProvider({arbiterUrl: await arbiterUrl(cwd, arbiter, env)}));
1875
+ const integration = await ledgerWrite.applyCompleteTransition({
1876
+ arbiter,
1877
+ branch,
1878
+ mode,
1879
+ provider,
1880
+ noPR: params.noPR,
1881
+ deleteMergedHead: true,
1882
+ cwd,
1883
+ env,
1884
+ });
1885
+ note(
1886
+ attempt === 0
1887
+ ? `Recovered '${slug}': integrated the kept commit from ${branch}.`
1888
+ : `Recovered '${slug}': integrated the kept commit from ${branch} ` +
1889
+ `(absorbed a moving ${arbiter}/main across ${attempt} re-fetch+re-` +
1890
+ `rebase attempt${attempt === 1 ? '' : 's'}).`,
1891
+ );
1892
+ return {
1893
+ outcome: 'completed',
1894
+ routedToNeedsAttention: false,
1895
+ branch,
1896
+ integration,
1897
+ };
1898
+ }
1899
+
1900
+ /**
1901
+ * Resolve the work branch the integration runs on: the branch HEAD is currently
1902
+ * on (the caller is ALWAYS on the work branch — the agent built there / the
1903
+ * lifecycle stage wrote there), which carries the namespaced `work/<type>-<slug>`
1904
+ * identity. Falls back to a synthesised `work/task-<slug>` ONLY for a detached
1905
+ * HEAD (a degenerate case the on-branch invariant precludes), so the push target
1906
+ * is always defined.
1907
+ */
1908
+ function resolveWorkBranch(
1909
+ cwd: string,
1910
+ slug: string,
1911
+ env: NodeJS.ProcessEnv | undefined,
1912
+ ): string {
1913
+ try {
1914
+ const head = git(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd, {
1915
+ env,
1916
+ }).trim();
1917
+ if (head.startsWith('work/')) {
1918
+ return head;
1919
+ }
1920
+ } catch {
1921
+ // detached HEAD or plumbing failure — fall through to the synthesised default
1922
+ }
1923
+ return workBranchRef('task', slug);
1924
+ }
1925
+
1926
+ /**
1927
+ * Raised when the atomic completion commit has NOTHING staged (no agent work and
1928
+ * no move) — a deliberate REFUSAL, mapped by `complete`'s try/catch to its
1929
+ * `refused` outcome (preserving its existing message verbatim). Exported so the
1930
+ * caller can `instanceof`-route it.
1931
+ *
1932
+ * Carries the {@link slug} so the autonomous-strand surface in `complete.ts`
1933
+ * (which catches this error in `performComplete`'s outer try/catch, OUTSIDE the
1934
+ * `runComplete` slug scope) can publish the `in-progress/ → needs-attention/`
1935
+ * tree-less move without re-deriving the slug from the error message.
1936
+ */
1937
+ export class IntegrationNothingStaged extends Error {
1938
+ constructor(
1939
+ message: string,
1940
+ readonly slug: string,
1941
+ ) {
1942
+ super(message);
1943
+ }
1944
+ }
1945
+
1946
+ /**
1947
+ * The arbiter's remote URL for `arbiter` in `cwd` (for provider auto-detection),
1948
+ * or `undefined` when it cannot be resolved. Read-only; soft (never throws).
1949
+ * Exported so the PR-INTENT pre-flight guard (`do.ts`) can resolve the arbiter
1950
+ * URL up front to decide whether a GitHub PR is even possible for this run.
1951
+ */
1952
+ export async function arbiterUrl(
1953
+ cwd: string,
1954
+ arbiter: string,
1955
+ env: NodeJS.ProcessEnv | undefined,
1956
+ ): Promise<string | undefined> {
1957
+ const res = await gitSoft(['remote', 'get-url', arbiter], cwd, env);
1958
+ if (res.status !== 0) {
1959
+ return undefined;
1960
+ }
1961
+ const url = res.stdout.trim();
1962
+ return url === '' ? undefined : url;
1963
+ }
1964
+
1965
+ /** Adapt the legacy `openPr` callback into the new ReviewProvider seam. */
1966
+ function bridgeProvider(
1967
+ openPr: (opts: {
1968
+ cwd: string;
1969
+ branch: string;
1970
+ env?: NodeJS.ProcessEnv;
1971
+ }) => void,
1972
+ ): ReviewProvider {
1973
+ return {
1974
+ name: 'none',
1975
+ async openRequest(req) {
1976
+ openPr({cwd: req.cwd, branch: req.branch, env: req.env});
1977
+ return {
1978
+ opened: true,
1979
+ instruction: `Opened a review for ${req.branch}.`,
1980
+ };
1981
+ },
1982
+ // The legacy `openPr` bridge has no comment channel (it returns no PR url),
1983
+ // so postPRComment degrades: it never opens a PR url to comment on, so the
1984
+ // in-core poster no-ops anyway. Implemented for the seam, surfacing the text.
1985
+ postPRComment(req) {
1986
+ return {
1987
+ posted: false,
1988
+ instruction:
1989
+ 'The legacy review bridge cannot post a comment; the review:\n' +
1990
+ req.body,
1991
+ };
1992
+ },
1993
+ // Likewise the bridge cannot resolve a PR from a branch — a clean no-op.
1994
+ postPRCommentOnBranch(req) {
1995
+ return {
1996
+ posted: false,
1997
+ instruction:
1998
+ 'The legacy review bridge cannot post a comment; the review:\n' +
1999
+ req.body,
2000
+ };
2001
+ },
2002
+ };
2003
+ }
2004
+
2005
+ /**
2006
+ * Default commit summary: the task's `title` frontmatter with any leading
2007
+ * `slug — ` (or `slug -`) prefix stripped, so a task titled
2008
+ * "complete — gate, mark done, …" yields "gate, mark done, …". Falls back to a
2009
+ * generic summary when the title is missing/unreadable.
2010
+ */
2011
+ function defaultSummary(inProgressPath: string, slug: string): string {
2012
+ let title: string | undefined;
2013
+ try {
2014
+ title = readTitle(readFileSync(inProgressPath, 'utf8'));
2015
+ } catch {
2016
+ title = undefined;
2017
+ }
2018
+ return summaryFromTitle(title, slug);
2019
+ }
2020
+
2021
+ /**
2022
+ * The PURE commit-summary derivation from a (possibly absent) item title — the
2023
+ * shared core of {@link defaultSummary} (file-read path) and the lifecycle's
2024
+ * EXPLICIT-title path (intake, whose output file is not written until {@link
2025
+ * IntegrationLifecycle.stage}, AFTER the title read). Strips a leading `slug — `
2026
+ * prefix; falls back to the generic summary when the title is missing/empty.
2027
+ */
2028
+ function summaryFromTitle(title: string | undefined, slug: string): string {
2029
+ if (!title) {
2030
+ return 'complete work task';
2031
+ }
2032
+ // Strip a leading "slug" followed by an em-dash / en-dash / hyphen separator.
2033
+ const prefix = new RegExp(`^${escapeRegExp(slug)}\\s*[—–-]\\s*`, 'i');
2034
+ return title.replace(prefix, '').trim() || title;
2035
+ }
2036
+
2037
+ /**
2038
+ * The sane single-line cap for a synthesised PR title (Half A). GitHub itself
2039
+ * accepts long titles, but a PR list/notification truncates ugly past ~72 chars;
2040
+ * we cap to keep the title scannable and guarantee it is never a run-on. Beyond
2041
+ * the cap we truncate and append an ellipsis (counted within the cap).
2042
+ */
2043
+ export const PR_TITLE_MAX = 72;
2044
+
2045
+ /**
2046
+ * The task's raw `title:` frontmatter (NOT the commit-summary-stripped form),
2047
+ * or undefined when missing/unreadable. Used as the human-authored source for
2048
+ * the synthesised PR title.
2049
+ */
2050
+ function readTaskTitle(taskPath: string): string | undefined {
2051
+ try {
2052
+ return readTitle(readFileSync(taskPath, 'utf8'));
2053
+ } catch {
2054
+ return undefined;
2055
+ }
2056
+ }
2057
+
2058
+ /**
2059
+ * Synthesise the propose-mode PR TITLE runner-side (Half A) from data the runner
2060
+ * already has — NO agent text: `<type>(<slug>): <title>`, reusing the `--type`
2061
+ * convention (default `feat`). It is FORCED to a single line (newlines → spaces,
2062
+ * runs of whitespace collapsed) and CAPPED to {@link PR_TITLE_MAX} (truncating
2063
+ * with a trailing `…`), so it can NEVER be the multi-line run-on `gh ... --fill`
2064
+ * derives from the commit subject. When the task `title:` is missing it falls
2065
+ * back to the slug alone (`<type>(<slug>)`). Exported for unit tests of the
2066
+ * single-line + cap guarantee.
2067
+ */
2068
+ export function synthesiseProposeTitle(input: {
2069
+ type: string;
2070
+ slug: string;
2071
+ title?: string;
2072
+ }): string {
2073
+ const type = input.type.trim() || DEFAULT_TYPE;
2074
+ // Strip a leading `slug — ` / `slug -` prefix (some task titles repeat the
2075
+ // slug; the `<slug>` scope already carries it) and flatten to one line.
2076
+ const prefix = new RegExp(`^${escapeRegExp(input.slug)}\\s*[—–-]\\s*`, 'i');
2077
+ const cleanTitle = (input.title ?? '')
2078
+ .replace(prefix, '')
2079
+ .replace(/\s+/g, ' ')
2080
+ .trim();
2081
+ const composed =
2082
+ cleanTitle === ''
2083
+ ? `${type}(${input.slug})`
2084
+ : `${type}(${input.slug}): ${cleanTitle}`;
2085
+ if (composed.length <= PR_TITLE_MAX) {
2086
+ return composed;
2087
+ }
2088
+ // Cap, reserving one char for the ellipsis (counted within the cap).
2089
+ return composed.slice(0, PR_TITLE_MAX - 1).trimEnd() + '…';
2090
+ }
2091
+
2092
+ /**
2093
+ * Compose the propose-mode PR BODY (Half B): the supplied advisory prose (the
2094
+ * build agent's final summary, or a human `--body`) UNDER a deterministic runner
2095
+ * header that points a reviewer back to the task file. Returns `undefined` when
2096
+ * no body was supplied — so the provider degrades to today's `gh ... --fill` (no
2097
+ * regression); the header is ONLY scaffolded when there IS prose to carry.
2098
+ * Exported for unit tests of the header + pointer.
2099
+ */
2100
+ export function composeProposeBody(input: {
2101
+ slug: string;
2102
+ body?: string;
2103
+ }): string | undefined {
2104
+ const prose = input.body?.trim();
2105
+ if (!prose) {
2106
+ return undefined;
2107
+ }
2108
+ const header = `Task: \`${workItemRel('done', `${input.slug}.md`)}\``;
2109
+ return `${header}\n\n${prose}`;
2110
+ }
2111
+
2112
+ /**
2113
+ * On a review APPROVE that carries ≥1 NON-BLOCKING finding, write ONE per-run
2114
+ * observation `work/notes/observations/review-nits-<slug>-<YYYY-MM-DD>.md` capturing all
2115
+ * of this run's non-blocking nits, so they get a durable, contract-native home
2116
+ * instead of evaporating (the block path already routes BLOCKING findings to
2117
+ * needs-attention/; the approve path dropped non-blocking ones — see
2118
+ * `work/findings/review-nonblocking-findings-disposition.md`).
2119
+ *
2120
+ * The RUNNER writes it (the review agent stays write-free). It is a PLAIN
2121
+ * pre-commit disk write — NOT the heavier `applyNeedsAttentionTransition`
2122
+ * move/commit/surface — so `performIntegration`'s subsequent done-move + atomic
2123
+ * `git add -A` commit sweeps it into the SAME done-commit on every path
2124
+ * (merge / propose / CI, `do` AND `run`); it is never left dangling/uncommitted.
2125
+ *
2126
+ * ZERO non-blocking findings ⇒ writes NOTHING (no empty-file spam). The file is
2127
+ * ONE-per-RUN (a content-derived, dated name), never an append to a shared ledger
2128
+ * — the dated `<slug>-<date>` name makes a later-abandoned run's nit-observation
2129
+ * trivially findable + deletable (lifecycle hygiene). Frontmatter mirrors the
2130
+ * `work/notes/observations/*.md` convention (`title` / `date` / `status: open`) plus a
2131
+ * `reviewOf:` back-pointer to the slug it came from, so it gets triaged like any
2132
+ * observation. (Identity stays the FILENAME — no `slug:` frontmatter — so the
2133
+ * lifecycle enumerate→resolve round-trip is total; see task
2134
+ * `observation-identity-is-its-filename-not-a-foreign-slug`.)
2135
+ */
2136
+ function writeReviewNitsObservation(params: {
2137
+ cwd: string;
2138
+ slug: string;
2139
+ findings: ReviewFinding[];
2140
+ note: (message: string) => void;
2141
+ }): void {
2142
+ const nits = params.findings.filter((f) => f.severity === 'non-blocking');
2143
+ // No empty observations: an approve with zero non-blocking findings writes none.
2144
+ if (nits.length === 0) {
2145
+ return;
2146
+ }
2147
+ const date = observationDate();
2148
+ const obsDir = workFolderPath(params.cwd, 'observations');
2149
+ mkdirSync(obsDir, {recursive: true});
2150
+ const filename = `review-nits-${params.slug}-${date}.md`;
2151
+ writeFileSync(
2152
+ join(obsDir, filename),
2153
+ renderReviewNitsObservation({slug: params.slug, date, nits}),
2154
+ );
2155
+ params.note(
2156
+ `Recorded ${nits.length} non-blocking review nit(s) for '${params.slug}' ` +
2157
+ `in ${workItemRel('observations', filename)}.`,
2158
+ );
2159
+ }
2160
+
2161
+ /** Today's date as `YYYY-MM-DD` (UTC), for the dated observation filename. */
2162
+ function observationDate(): string {
2163
+ return new Date().toISOString().slice(0, 10);
2164
+ }
2165
+
2166
+ /**
2167
+ * Render the per-run review-nits observation file body — `observations/`-convention
2168
+ * frontmatter (`title` / `date` / `status: open`) plus a `reviewOf:` back-pointer
2169
+ * naming the TASK the run reviewed, then each non-blocking finding (its
2170
+ * `question` + optional `context`), and a one-line note that these are review-gate
2171
+ * nits for triage (promote-to-task / keep / delete). Exported-free pure string
2172
+ * builder.
2173
+ *
2174
+ * Identity rule (task `observation-identity-is-its-filename-not-a-foreign-slug`):
2175
+ * the observation's IDENTITY is its FILENAME (`review-nits-<slug>-<date>.md`).
2176
+ * The frontmatter therefore does NOT emit `slug:` — emitting the reviewed task's
2177
+ * slug there collided with the (now-done) reviewed task AND broke the
2178
+ * enumerate→resolve round-trip (the lifecycle pool keyed off `fm.slug`, which
2179
+ * differed from the filename). The back-pointer lives in `reviewOf:` instead, a
2180
+ * clearly-different field whose name cannot be mistaken for identity.
2181
+ */
2182
+ function renderReviewNitsObservation(input: {
2183
+ slug: string;
2184
+ date: string;
2185
+ nits: ReviewFinding[];
2186
+ }): string {
2187
+ const findingBlocks = input.nits.map((f) => {
2188
+ const ctx = f.context ? `\n (${f.context})` : '';
2189
+ return `- ${f.question}${ctx}`;
2190
+ });
2191
+ return [
2192
+ '---',
2193
+ `title: review-gate non-blocking nits for '${input.slug}' (Gate 2 approve)`,
2194
+ `date: ${input.date}`,
2195
+ 'status: open',
2196
+ `reviewOf: ${input.slug}`,
2197
+ '---',
2198
+ '',
2199
+ '## Non-blocking review findings',
2200
+ '',
2201
+ `The PR/code review gate (Gate 2) APPROVED '${input.slug}' but raised the`,
2202
+ 'following non-blocking findings (nits). They do not block integration; this',
2203
+ 'is their durable home for triage — promote-to-task / keep / delete.',
2204
+ '',
2205
+ ...findingBlocks,
2206
+ '',
2207
+ ].join('\n');
2208
+ }
2209
+
2210
+ /** Read the `title:` scalar from a task's frontmatter block, or undefined. */
2211
+ function readTitle(content: string): string | undefined {
2212
+ const normalized = content.replace(/\r\n/g, '\n').replace(/^\uFEFF/, '');
2213
+ if (!normalized.startsWith('---\n')) {
2214
+ return undefined;
2215
+ }
2216
+ const lines = normalized.split('\n');
2217
+ const closing = lines.indexOf('---', 1);
2218
+ const block = closing === -1 ? lines.slice(1) : lines.slice(1, closing);
2219
+ for (const line of block) {
2220
+ const match = /^title\s*:\s*(.*)$/.exec(line);
2221
+ if (match) {
2222
+ const value = match[1].trim();
2223
+ return value === '' ? undefined : unquote(value);
2224
+ }
2225
+ }
2226
+ return undefined;
2227
+ }
2228
+
2229
+ function unquote(value: string): string {
2230
+ if (value.length >= 2) {
2231
+ const first = value[0];
2232
+ const last = value[value.length - 1];
2233
+ if ((first === '"' || first === "'") && last === first) {
2234
+ return value.slice(1, -1);
2235
+ }
2236
+ }
2237
+ return value;
2238
+ }
2239
+
2240
+ function escapeRegExp(value: string): string {
2241
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2242
+ }
2243
+
2244
+ /** True when the index has no staged changes against HEAD (nothing to commit). */
2245
+ async function nothingStaged(
2246
+ cwd: string,
2247
+ env: NodeJS.ProcessEnv | undefined,
2248
+ ): Promise<boolean> {
2249
+ // `diff --cached --quiet` exits 0 when there is NOTHING staged, 1 when there is.
2250
+ const res = await gitSoft(['diff', '--cached', '--quiet'], cwd, env);
2251
+ return res.status === 0;
2252
+ }
2253
+
2254
+ /**
2255
+ * The capture-bucket folders a rung's agent may write notes into (its
2256
+ * `capture-signal` reflex). REPORTED by {@link reportScoopedNotes} when the
2257
+ * runner's atomic commit scoops them. Deliberately NARROW (only the two capture
2258
+ * buckets) so accidental scratch files the agent left elsewhere are not announced
2259
+ * as captured signals — matching the observation's recommended scope.
2260
+ */
2261
+ const CAPTURE_NOTE_DIRS = [
2262
+ workFolderPrefix('observations'),
2263
+ workFolderPrefix('findings'),
2264
+ ] as const;
2265
+
2266
+ /**
2267
+ * SCOOP + REPORT the agent-authored CAPTURED NOTES this run's atomic commit is
2268
+ * landing (task `runner-scoops-captured-notes`). A rung's agent writes
2269
+ * capture-bucket files (`work/notes/observations/*`, `work/findings/*`) but does NO git
2270
+ * (Rule A); the caller's `git add -A` already STAGED them into THIS commit, so
2271
+ * they are tracked, not dropped. This extends Rule B: the runner REPORTS exactly
2272
+ * which note files landed — read from the STAGED set (`git diff --cached`), so it
2273
+ * reports what ACTUALLY reached the commit, never an assumption.
2274
+ *
2275
+ * It is honest reporting, the same model as the review-nits observation report:
2276
+ * a PLAIN read + `note(...)`, no extra git (the commit owns the files). Zero
2277
+ * captured notes ⇒ NOTHING is reported (the no-note case is byte-for-byte
2278
+ * unchanged). Read-only / best-effort: a failed status read reports nothing
2279
+ * rather than crashing the integrate. Because it lives in the shared core, BOTH
2280
+ * the build path AND the tasking path report identically — the channel is not
2281
+ * forked.
2282
+ */
2283
+ async function reportScoopedNotes(
2284
+ cwd: string,
2285
+ env: NodeJS.ProcessEnv | undefined,
2286
+ note: (message: string) => void,
2287
+ ): Promise<void> {
2288
+ const notes = await stagedCaptureNotes(cwd, env);
2289
+ if (notes.length === 0) {
2290
+ return;
2291
+ }
2292
+ note(
2293
+ `Scooped ${notes.length} agent-authored captured note` +
2294
+ `${notes.length === 1 ? '' : 's'} into this commit: ${notes.join(', ')}.`,
2295
+ );
2296
+ }
2297
+
2298
+ /**
2299
+ * The repo-relative paths of the capture-bucket files (`work/notes/observations/*`,
2300
+ * `work/findings/*`) STAGED for THIS commit — i.e. new-or-changed vs HEAD, exactly
2301
+ * what the runner is about to land. Read via `git diff --cached --name-only` so it
2302
+ * reflects the real staged set (`git add -A` already ran), filtered to the capture
2303
+ * buckets and sorted for a deterministic report. Best-effort: a non-zero status
2304
+ * reads as no notes (never crashes the integrate). Exported-free; pure read.
2305
+ */
2306
+ async function stagedCaptureNotes(
2307
+ cwd: string,
2308
+ env: NodeJS.ProcessEnv | undefined,
2309
+ ): Promise<string[]> {
2310
+ const res = await gitSoft(['diff', '--cached', '--name-only'], cwd, env);
2311
+ if (res.status !== 0) {
2312
+ return [];
2313
+ }
2314
+ return res.stdout
2315
+ .split('\n')
2316
+ .map((line) => line.trim())
2317
+ .filter((path) => CAPTURE_NOTE_DIRS.some((dir) => path.startsWith(dir)))
2318
+ .sort();
2319
+ }
2320
+
2321
+ /**
2322
+ * The DURABLE `work/` status folders a slug's ledger file can resting-live in (the
2323
+ * one-slug-one-folder set the invariant is asserted over). After the capstone
2324
+ * cut-over (task `cutover-retire-slicing-advancing-markers-and-trim-folder-sets`,
2325
+ * prd `ledger-status-per-item-lock-refs`) the ONLY `work/` moves on `main` are the
2326
+ * durable resting transitions, so the source a build completes FROM is `backlog/`
2327
+ * (claim no longer moves the body, task
2328
+ * `cutover-claim-body-stays-and-complete-sources-from-backlog`) and the canonical
2329
+ * done-move destination is `done/`. The task regime's won't-proceed terminal
2330
+ * `tasks/cancelled/` is also a durable resting folder, so the one-slug-one-folder
2331
+ * guard covers it (a slug in `tasks/cancelled/` AND another durable folder is a
2332
+ * corrupt ledger to refuse). The
2333
+ * transient `in-progress`/`needs-attention` are GONE from `main`'s tree (they are
2334
+ * per-item lock-ref state now).
2335
+ */
2336
+ /**
2337
+ * The folders {@link readArbiterLedgerPlacement} scans for a slug's source on the
2338
+ * arbiter: the durable `LEDGER_STATUS_FOLDERS` (`tasks-ready`/`done`/`cancelled`)
2339
+ * PLUS `tasks-backlog` (staging). Staging is included ONLY for the arbiter-side
2340
+ * source RESOLUTION of a `--allow-backlog` done-move (prd
2341
+ * `do-allow-backlog-drive-staged-tasks-without-promotion`) and the one-slug-one-
2342
+ * folder guard over the malformed "same slug in `tasks/ready/` AND `tasks/backlog/`"
2343
+ * state; it is DELIBERATELY NOT added to the shared `LEDGER_STATUS_FOLDERS` (which
2344
+ * ledger-lint's duplicate detection + the sibling-ledger reconcile reuse and which
2345
+ * deliberately omits the non-resting staging folder).
2346
+ */
2347
+ const ARBITER_PLACEMENT_FOLDERS = [
2348
+ ...LEDGER_STATUS_FOLDERS,
2349
+ 'tasks-backlog',
2350
+ ] as const satisfies readonly WorkFolderKey[];
2351
+
2352
+ /** One of the folders the arbiter placement read scans. */
2353
+ type ArbiterPlacementFolder = (typeof ARBITER_PLACEMENT_FOLDERS)[number];
2354
+
2355
+ /** The result of {@link readArbiterLedgerPlacement}. */
2356
+ interface ArbiterLedgerPlacement {
2357
+ /**
2358
+ * Set when the ONE-SLUG-ONE-FOLDER guard FAILED LOUD: the arbiter already holds
2359
+ * the slug in >1 status folder with DIFFERING content (a corrupt ledger). The
2360
+ * caller maps it to the `invariant-violation` outcome and refuses — nothing is
2361
+ * published over the corruption.
2362
+ */
2363
+ error?: string;
2364
+ /**
2365
+ * The NON-`done` status folders the ARBITER currently holds the slug in (e.g.
2366
+ * `['in-progress']` or `['needs-attention']`). Empty when the arbiter holds it
2367
+ * only in `done/`, holds it nowhere, or the tracking ref could not be read.
2368
+ */
2369
+ sourceFolders: string[];
2370
+ }
2371
+
2372
+ /**
2373
+ * Read WHICH `work/<folder>/<slug>.md` the ARBITER currently holds the slug in,
2374
+ * from the `<arbiter>/main` TRACKING REF (the source of truth). It is a pure READ
2375
+ * of the ref the caller has ALREADY fetched (the step-4 rebase fetch) — it does
2376
+ * NOT fetch, so it never races a sibling job's integration on the shared
2377
+ * bare-mirror refs (ADR §2; a new fetch here was the regression that orphaned
2378
+ * this very work).
2379
+ *
2380
+ * It ENFORCES the one-slug-one-folder invariant: if the arbiter already holds the
2381
+ * slug in MORE THAN ONE status folder it is a pre-existing corrupt ledger — it
2382
+ * FAILS LOUD (returns an `error`) rather than silently pick one, UNLESS it is
2383
+ * PROVABLY SAFE (every copy is byte-identical, so the canonical `done/`
2384
+ * destination is unambiguous), mirroring the manual `279b542` cleanup.
2385
+ *
2386
+ * It scans {@link ARBITER_PLACEMENT_FOLDERS} — the durable `LEDGER_STATUS_FOLDERS`
2387
+ * PLUS `tasks-backlog`, so a `--allow-backlog` staged drive (prd
2388
+ * `do-allow-backlog-drive-staged-tasks-without-promotion`) whose done-move sources
2389
+ * from `tasks/backlog/` is DISCOVERED here too (the arbiter is the authority for
2390
+ * the actual source folder; the local `source` is the fallback). Including staging
2391
+ * also makes the one-slug-one-folder guard cover the malformed "same slug in both
2392
+ * `tasks/ready/` and `tasks/backlog/`" state (the prd's decision 5): it FAILS LOUD
2393
+ * rather than the resolver silently arbitrating a collision the contract forbids.
2394
+ */
2395
+ function readArbiterLedgerPlacement(
2396
+ cwd: string,
2397
+ arbiter: string,
2398
+ slug: string,
2399
+ env: NodeJS.ProcessEnv | undefined,
2400
+ ): ArbiterLedgerPlacement {
2401
+ const arbiterRef = `${arbiter}/main`;
2402
+ const placements: {folder: ArbiterPlacementFolder; blob: string}[] = [];
2403
+ for (const folder of ARBITER_PLACEMENT_FOLDERS) {
2404
+ const path = workItemRel(folder, `${slug}.md`);
2405
+ const ls = run('git', ['ls-tree', arbiterRef, path], cwd, {env});
2406
+ const line = ls.stdout.trim();
2407
+ if (ls.status !== 0 || line === '') {
2408
+ continue;
2409
+ }
2410
+ const match = /^\d+ blob ([0-9a-f]+)\t/.exec(line);
2411
+ if (match) {
2412
+ placements.push({folder, blob: match[1]});
2413
+ }
2414
+ }
2415
+ const sourceFolders = placements
2416
+ .filter((p) => p.folder !== 'done')
2417
+ .map((p) => p.folder);
2418
+
2419
+ if (placements.length > 1) {
2420
+ const uniqueBlobs = new Set(placements.map((p) => p.blob));
2421
+ const folders = placements
2422
+ .map((p) => workFolderPrefix(p.folder))
2423
+ .join(', ');
2424
+ if (uniqueBlobs.size !== 1) {
2425
+ return {
2426
+ error:
2427
+ `one-slug-one-folder invariant violated: '${slug}' is present in more ` +
2428
+ `than one status folder on ${arbiterRef} (${folders}) with DIFFERING ` +
2429
+ `content — refusing to publish a corrupt ledger. Resolve the duplicate ` +
2430
+ `(keep the correct copy, delete the stale one) and re-run; ` +
2431
+ `'dorfl scan'/'gc' surfaces such duplicates.`,
2432
+ sourceFolders,
2433
+ };
2434
+ }
2435
+ // Provably safe (byte-identical copies): the duplicate is auto-cleaned by the
2436
+ // divergent-done-move reconciliation, which moves the slug to `done/` ONLY.
2437
+ }
2438
+ return {sourceFolders};
2439
+ }
2440
+
2441
+ /** The `work/<status>/` prefixes a ledger file can live under (no trailing `/`). */
2442
+ const LEDGER_FOLDER_PREFIXES = LEDGER_STATUS_FOLDERS.map((folder) =>
2443
+ workFolderPrefix(folder),
2444
+ );
2445
+
2446
+ /**
2447
+ * Classify a rebase-conflicted path: is it a SIBLING-slug ledger file (a
2448
+ * `work/<status>/<otherslug>.md` for some slug OTHER than `ourSlug`)? Returns
2449
+ * `false` for any code file AND for THIS slug's own ledger file (both must keep
2450
+ * routing to needs-attention — the sibling arm NEVER widens to code or own-ledger).
2451
+ */
2452
+ function isSiblingLedgerPath(path: string, ourSlug: string): boolean {
2453
+ const prefix = LEDGER_FOLDER_PREFIXES.find((p) => path.startsWith(p));
2454
+ if (prefix === undefined) {
2455
+ return false; // not a ledger file at all — a code file (or non-ledger work/ file).
2456
+ }
2457
+ const rest = path.slice(prefix.length);
2458
+ if (!rest.endsWith('.md') || rest.includes('/')) {
2459
+ return false; // not a `<slug>.md` directly under the status folder.
2460
+ }
2461
+ const otherSlug = rest.slice(0, -'.md'.length);
2462
+ return otherSlug !== ourSlug; // OUR own ledger is NOT a sibling — it routes as today.
2463
+ }
2464
+
2465
+ /**
2466
+ * Reconcile a SIBLING-SLUG ledger conflict during the step-4 rebase WITHOUT
2467
+ * aborting it (Race 2 of `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`).
2468
+ * Called WHILE the rebase is still in progress (right after `git rebase` returned
2469
+ * non-zero): a sibling same-repo job landed its OWN `work/<status>/<otherslug>.md`
2470
+ * move on `<arbiter>/main` between our base and this rebase, so replaying our
2471
+ * commit conflicts on that OTHER slug's ledger file — a benign ledger-only
2472
+ * divergence, NOT a real code conflict.
2473
+ *
2474
+ * STRICT SCOPE (the safety fence): it reconciles ONLY when EVERY conflicted path
2475
+ * is a SIBLING slug's ledger file ({@link isSiblingLedgerPath}). If ANY conflicted
2476
+ * path is a CODE file, a non-ledger `work/` file, or THIS slug's OWN ledger file,
2477
+ * it does NOTHING (returns `false`) so the caller aborts + routes to
2478
+ * needs-attention exactly as today — it NEVER widens to code or own-ledger.
2479
+ *
2480
+ * The resolution takes the ARBITER's (rebased-onto) version of each sibling
2481
+ * ledger file (`git checkout --ours` — during a rebase `--ours` is the base we are
2482
+ * replaying ONTO, i.e. `<arbiter>/main`), stages it, and `git rebase --continue`s,
2483
+ * looping until the rebase completes (a later replayed commit could re-conflict on
2484
+ * a sibling ledger). Returns `true` once the rebase finished cleanly (the caller
2485
+ * falls through to integrate); returns `false` — leaving the rebase in progress —
2486
+ * when the conflict is out of scope (the caller aborts + routes).
2487
+ */
2488
+ async function reconcileSiblingLedgerConflict(params: {
2489
+ cwd: string;
2490
+ arbiter: string;
2491
+ slug: string;
2492
+ env: NodeJS.ProcessEnv | undefined;
2493
+ note: (message: string) => void;
2494
+ }): Promise<boolean> {
2495
+ const {cwd, arbiter, slug, env} = params;
2496
+ const arbiterRef = `${arbiter}/main`;
2497
+
2498
+ // The conflicted (unmerged) paths of the failed rebase step. Read them WHILE the
2499
+ // rebase is still in progress (the caller invokes us right after the non-zero
2500
+ // rebase), BEFORE we abort — so we know what conflicted.
2501
+ const conflicted = (
2502
+ await gitSoft(['diff', '--name-only', '--diff-filter=U'], cwd, env)
2503
+ ).stdout
2504
+ .split('\n')
2505
+ .map((line) => line.trim())
2506
+ .filter((line) => line !== '');
2507
+ if (conflicted.length === 0) {
2508
+ return false; // not a conflict we can reason about here — defer to the caller.
2509
+ }
2510
+ // SCOPE GATE: every conflicted path MUST be a SIBLING slug's ledger file. Any
2511
+ // code file / own-ledger / non-ledger work file disqualifies the WHOLE
2512
+ // reconciliation (never widen to code) — the caller aborts + routes.
2513
+ if (!conflicted.every((path) => isSiblingLedgerPath(path, slug))) {
2514
+ return false;
2515
+ }
2516
+
2517
+ // Benign sibling-ledger divergence. Rather than the fragile in-progress
2518
+ // `git rebase --continue` (which mutates the shared-worktree branch ref mid-
2519
+ // rebase and flakes under same-repo fleet ref contention), ABORT and REDO our
2520
+ // own work as ONE clean commit on top of `<arbiter>/main` — the SAME safe
2521
+ // reset-and-redo pattern `reconcileDivergentDoneMove` uses. This automatically
2522
+ // takes the arbiter's version of EVERY sibling ledger file (they live in the
2523
+ // reset base, untouched) while preserving OUR agent edits + OUR done-move (kept
2524
+ // in the working tree by the mixed reset). NO semantic judgement, NO `--ours`/
2525
+ // `--theirs` heuristic on any code file.
2526
+ await gitSoft(['rebase', '--abort'], cwd, env);
2527
+
2528
+ // Re-point the branch onto `<arbiter>/main`, KEEPING the working tree (our edits
2529
+ // + our done-move): a mixed reset moves HEAD + index to the arbiter base but
2530
+ // leaves the working tree intact. Our own changes stay in the tree. (`HEAD` was
2531
+ // restored to our work-branch tip by the abort above.)
2532
+ const reset = await gitSoft(
2533
+ ['reset', '--mixed', '--quiet', arbiterRef],
2534
+ cwd,
2535
+ env,
2536
+ );
2537
+ if (reset.status !== 0) {
2538
+ return false;
2539
+ }
2540
+ // Take the ARBITER's placement of every SIBLING slug whose ledger conflicted: the
2541
+ // conflict means we touched a sibling's ledger file that the arbiter moved, so our
2542
+ // working-tree copy is STALE. Hard-restore EVERY ledger folder for each affected
2543
+ // sibling slug from the arbiter (index + working tree) and drop any stray copy our
2544
+ // tree still holds, so the sibling's OWN status-folder move (e.g. its done-move) is
2545
+ // honoured verbatim — never clobbered by our stale touch, never duplicated.
2546
+ const siblingSlugs = new Set<string>();
2547
+ for (const path of conflicted) {
2548
+ const prefix = LEDGER_FOLDER_PREFIXES.find((p) => path.startsWith(p));
2549
+ if (prefix !== undefined) {
2550
+ siblingSlugs.add(path.slice(prefix.length, -'.md'.length));
2551
+ }
2552
+ }
2553
+ for (const otherSlug of siblingSlugs) {
2554
+ for (const folder of LEDGER_STATUS_FOLDERS) {
2555
+ const ledgerPath = workItemRel(folder, `${otherSlug}.md`);
2556
+ const onArbiter =
2557
+ (
2558
+ await gitSoft(
2559
+ ['cat-file', '-e', `${arbiterRef}:${ledgerPath}`],
2560
+ cwd,
2561
+ env,
2562
+ )
2563
+ ).status === 0;
2564
+ if (onArbiter) {
2565
+ // The arbiter holds the sibling here — take its exact copy.
2566
+ await gitSoft(['checkout', arbiterRef, '--', ledgerPath], cwd, env);
2567
+ } else {
2568
+ // The arbiter does NOT hold the sibling here — drop any stale copy ours has.
2569
+ const abs = join(cwd, ledgerPath);
2570
+ if (existsSync(abs)) {
2571
+ rmSync(abs, {force: true});
2572
+ }
2573
+ }
2574
+ }
2575
+ }
2576
+ // Stage everything (our agent edits + our arbiter-aligned ledger move; the
2577
+ // sibling ledgers are already at the arbiter version) and commit ONE clean
2578
+ // commit on top of `<arbiter>/main`. Nothing staged ⇒ the work is already on the
2579
+ // arbiter (an already-integrated no-op) — treat as cleanly reconciled.
2580
+ await gitSoft(['add', '-A'], cwd, env);
2581
+ if ((await gitSoft(['diff', '--cached', '--quiet'], cwd, env)).status === 0) {
2582
+ return true;
2583
+ }
2584
+ await gitHard(
2585
+ [
2586
+ 'commit',
2587
+ '-q',
2588
+ '-m',
2589
+ `feat(${slug}): reconcile sibling-ledger rebase; done`,
2590
+ ],
2591
+ cwd,
2592
+ env,
2593
+ );
2594
+ return true;
2595
+ }
2596
+
2597
+ /**
2598
+ * Recover a DIVERGENT-BASE done-move whose plain rebase CONFLICTED (ledger-
2599
+ * integrity defect 1, the PR #86 ghost). The arbiter holds the slug's source in a
2600
+ * DIFFERENT folder than our local done-move removed, so replaying the local
2601
+ * `-work/<localsrc>/<slug>.md +work/done/<slug>.md` patch onto `<arbiter>/main`
2602
+ * (which lacks `<localsrc>`) conflicts on the ledger file.
2603
+ *
2604
+ * It reconciles WITHOUT any semantic judgement (so it is safe automatically,
2605
+ * unlike a real code conflict): RESET the work branch onto `<arbiter>/main` (the
2606
+ * working tree kept), then redo the done-move ARBITER-RESOLVED — remove the slug
2607
+ * from EVERY non-`done` folder the arbiter holds it in, write `work/done/<slug>.md`
2608
+ * — and commit ONE done commit on top of `<arbiter>/main`. The branch ends cleanly
2609
+ * on the arbiter with the slug in `done/` ONLY (the move is a MOVE, not a copy);
2610
+ * no further rebase is needed.
2611
+ *
2612
+ * Returns `true` on success (the caller falls through to integrate). Returns
2613
+ * `false` when this is NOT the divergent-ledger case (the arbiter holds the slug
2614
+ * only in `done/`/nowhere, or the placement read failed) so the caller routes the
2615
+ * genuine conflict to needs-attention unchanged.
2616
+ */
2617
+ async function reconcileDivergentDoneMove(params: {
2618
+ cwd: string;
2619
+ arbiter: string;
2620
+ slug: string;
2621
+ branch: string;
2622
+ /** The folder the LOCAL done-move removed the slug from (its `git mv` source). */
2623
+ localSource: 'tasks-ready' | 'tasks-backlog' | 'in-progress';
2624
+ env: NodeJS.ProcessEnv | undefined;
2625
+ note: (message: string) => void;
2626
+ }): Promise<boolean> {
2627
+ const {cwd, arbiter, slug, localSource, env} = params;
2628
+ const arbiterRef = `${arbiter}/main`;
2629
+
2630
+ // The arbiter's current placement (read from the already-fetched ref — no
2631
+ // fetch). This recovery applies ONLY to the divergent-LEDGER conflict: the
2632
+ // arbiter holds the slug's source in a DIFFERENT folder than our local done-move
2633
+ // removed it from. When the arbiter's source folder MATCHES our local source (or
2634
+ // the arbiter holds it only in `done/`/nowhere), the rebase conflict is a
2635
+ // genuine CODE conflict (e.g. the agent's edits vs an advanced main) — NEVER
2636
+ // auto-resolved; defer to the needs-attention route.
2637
+ const placement = readArbiterLedgerPlacement(cwd, arbiter, slug, env);
2638
+ if (placement.error || placement.sourceFolders.length === 0) {
2639
+ return false;
2640
+ }
2641
+ if (placement.sourceFolders.includes(localSource)) {
2642
+ // The arbiter still holds the slug in the SAME source folder we moved from —
2643
+ // the ledger placement agrees, so the conflict is NOT a divergent-ledger one.
2644
+ return false;
2645
+ }
2646
+
2647
+ // Capture the slug's ledger content (our tip's done/ copy, or any source copy)
2648
+ // BEFORE we reset — it is what lands in `done/`.
2649
+ let ledgerContent: string | undefined;
2650
+ // Scan the ARBITER-placement set (durable folders PLUS `tasks-backlog`) so a
2651
+ // `--allow-backlog` staged-drive's source copy is captured too. `done` first so
2652
+ // our tip's already-moved copy wins.
2653
+ const captureOrder: readonly ArbiterPlacementFolder[] = [
2654
+ 'done',
2655
+ ...ARBITER_PLACEMENT_FOLDERS,
2656
+ ];
2657
+ for (const folder of captureOrder) {
2658
+ const abs = workItemPath(cwd, folder, slug);
2659
+ if (existsSync(abs)) {
2660
+ ledgerContent = readFileSync(abs, 'utf8');
2661
+ break;
2662
+ }
2663
+ }
2664
+
2665
+ // Re-point the branch onto `<arbiter>/main`, KEEPING the working tree (the
2666
+ // agent's edits + our done/ file): a mixed reset moves HEAD + the index to the
2667
+ // arbiter base but leaves the working tree intact. We then fix only the LEDGER
2668
+ // placement against the arbiter and commit one clean done commit.
2669
+ const reset = await gitSoft(
2670
+ ['reset', '--mixed', '--quiet', arbiterRef],
2671
+ cwd,
2672
+ env,
2673
+ );
2674
+ if (reset.status !== 0) {
2675
+ return false;
2676
+ }
2677
+
2678
+ // Arbiter-resolved ledger placement: write `done/` from the captured content,
2679
+ // and remove every non-`done` copy (the arbiter's source folder is now checked
2680
+ // out by the reset; any stale local source is swept too).
2681
+ mkdirSync(workFolderPath(cwd, 'done'), {recursive: true});
2682
+ const donePath = workItemPath(cwd, 'done', slug);
2683
+ if (ledgerContent !== undefined) {
2684
+ writeFileSync(donePath, ledgerContent);
2685
+ }
2686
+ // Sweep every non-`done` copy the arbiter could hold the slug in, INCLUDING
2687
+ // `tasks-backlog` (a `--allow-backlog` staged drive), so the move is a MOVE not
2688
+ // a copy.
2689
+ for (const folder of ARBITER_PLACEMENT_FOLDERS) {
2690
+ if (folder === 'done') {
2691
+ continue;
2692
+ }
2693
+ const abs = workItemPath(cwd, folder, slug);
2694
+ if (existsSync(abs)) {
2695
+ rmSync(abs, {force: true});
2696
+ }
2697
+ }
2698
+
2699
+ // Stage everything (agent work + the arbiter-aligned ledger move) and commit a
2700
+ // single done commit on top of `<arbiter>/main`. Nothing staged ⇒ the slug is
2701
+ // already done on the arbiter (an already-integrated no-op) — treat as cleanly
2702
+ // reconciled.
2703
+ await gitSoft(['add', '-A'], cwd, env);
2704
+ if ((await gitSoft(['diff', '--cached', '--quiet'], cwd, env)).status === 0) {
2705
+ return true;
2706
+ }
2707
+ await gitHard(
2708
+ ['commit', '-q', '-m', `feat(${slug}): reconcile done-move; done`],
2709
+ cwd,
2710
+ env,
2711
+ );
2712
+ return true;
2713
+ }
2714
+
2715
+ /**
2716
+ * The outcome of the Gate-2 review run ({@link runGate2Review}): either it BLOCKED
2717
+ * (a ready-to-return {@link IntegrationCoreResult}) or it APPROVED (the verdict to
2718
+ * carry).
2719
+ */
2720
+ type Gate2ReviewOutcome =
2721
+ | {kind: 'blocked'; result: IntegrationCoreResult}
2722
+ | {
2723
+ kind: 'approved';
2724
+ verdict: ReviewVerdict | undefined;
2725
+ };
2726
+
2727
+ /**
2728
+ * Run the Gate-2 PR/code REVIEW gate against a given tree ({@param reviewCwd}) and
2729
+ * route a BLOCK to needs-attention, returning DATA the caller acts on. Factored out
2730
+ * of {@link performIntegration} so the SAME gate can run in TWO places without
2731
+ * forking its logic (MAINTAINER DECISION 2, task `gate-on-rebased-tip-fresh-worktree`):
2732
+ *
2733
+ * - fresh-worktree gate OFF: the caller invokes it at the FRONT on the pre-rebase
2734
+ * `cwd`, right after the front `verify` (today's order, byte-for-byte);
2735
+ * - fresh-worktree gate ON: the caller invokes it on the REBASED TIP (the fresh
2736
+ * gate worktree), right AFTER the rebased-tip `verify` passes — so
2737
+ * verify-THEN-review holds on the SAME merged tree.
2738
+ *
2739
+ * The review AGENT inspects {@param reviewCwd} (the tree under review); the
2740
+ * needs-attention ROUTING always targets `params.cwd` (where the work branch +
2741
+ * ledger live), so the routing is identical whichever tree was reviewed — ONLY the
2742
+ * source of the reviewed tree moved, not the verdict handling.
2743
+ *
2744
+ * It NEVER mutates `mode` or writes the nits observation itself (those are the
2745
+ * caller's concern, because WHEN they happen differs by path — pre-commit on OFF,
2746
+ * post-commit-amend on ON); it returns the verdict and lets the caller place those
2747
+ * effects correctly for its band position.
2748
+ */
2749
+ async function runGate2Review(params: {
2750
+ /** The tree the review AGENT inspects (pre-rebase `cwd` OFF; rebased tip ON). */
2751
+ reviewCwd: string;
2752
+ input: IntegrationCoreInput;
2753
+ slug: string;
2754
+ branch: string;
2755
+ /** Where the work branch + ledger live (the needs-attention routing target). */
2756
+ cwd: string;
2757
+ env: NodeJS.ProcessEnv | undefined;
2758
+ note: (message: string) => void;
2759
+ }): Promise<Gate2ReviewOutcome> {
2760
+ const {reviewCwd, input, slug, branch, cwd, env, note} = params;
2761
+ const reviewGate = input.reviewGate;
2762
+ if (!reviewGate) {
2763
+ // `review` on with no gate wired is a usage error — the floor must never be
2764
+ // silently skipped. (Production always wires `harnessReviewGate`.) The caller's
2765
+ // try/catch maps a thrown error to its usage-error outcome.
2766
+ throw new Error(
2767
+ `review is on but no review gate is configured — cannot run Gate 2 ` +
2768
+ `for '${slug}' (this is a wiring bug; the gate must not be skipped).`,
2769
+ );
2770
+ }
2771
+ const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
2772
+ note('Running the PR/code review gate (Gate 2)…');
2773
+ // CORROBORATED-APPROVAL semantics (NOT retry-until-pass): the gate runs the
2774
+ // reviewer up to `reviewMaxRounds` times on the SAME tip and approves ONLY if
2775
+ // EVERY round approves. A `block` is TERMINAL — it short-circuits the loop and
2776
+ // is never re-rolled, because the reviewer is stochastic and re-reviewing an
2777
+ // UNCHANGED tip after a block would just be a dice re-roll that could launder a
2778
+ // real reject into a pass. The extra rounds therefore exist to make a FALSE
2779
+ // APPROVE harder to slip through (a second reviewer gets a veto), never to give
2780
+ // blocked work a second chance. (A future builder-REVISE step that mutates the
2781
+ // tree between rounds is the ONLY thing that should make a block retryable; it
2782
+ // would change the artifact under review and is not implemented here.)
2783
+ let approved = false;
2784
+ let lastVerdict: ReviewVerdict | undefined;
2785
+ for (let round = 1; round <= maxRounds; round++) {
2786
+ let verdict: ReviewVerdict;
2787
+ try {
2788
+ verdict = await reviewGate({
2789
+ slug,
2790
+ cwd: reviewCwd,
2791
+ reviewModel: input.reviewModel,
2792
+ round,
2793
+ // `--watch` threading (task `watch-review-session`): when on, the production
2794
+ // gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
2795
+ watch: input.watch,
2796
+ watchSink: input.watchSink,
2797
+ color: input.color,
2798
+ sessionsDir: input.sessionsDir,
2799
+ // The review AGENT launches with the AMBIENT env, never the identity-scoped
2800
+ // `env` (an agent must not act as the bot). Falls back to `env` when no
2801
+ // identity is configured (unchanged for non-identity callers).
2802
+ env: input.agentEnv ?? env,
2803
+ });
2804
+ } catch (err) {
2805
+ if (!(err instanceof ReviewParseError)) {
2806
+ // Anything else (a harness/connection throw, a programmer bug) is NOT this
2807
+ // gate's concern — re-throw so the existing catch sites classify it.
2808
+ throw err;
2809
+ }
2810
+ // THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
2811
+ // the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
2812
+ // malformed JSON verdict, common on large diffs + weaker models, AFTER the
2813
+ // direction-2 repair pass could not salvage it). WITHOUT this catch the throw
2814
+ // escapes the core and `performComplete` maps it to the generic `usage-error`
2815
+ // (verbatim, no push, no surface) AFTER the green build but BEFORE the
2816
+ // done-move/push — STRANDING the lock + work branch with no PR.
2817
+ //
2818
+ // A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
2819
+ // the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
2820
+ // the same tip would just be the dice re-roll the corroboration loop forbids).
2821
+ // We route through the SAME work-preserving `applyNeedsAttentionTransition`
2822
+ // seam the block path uses (it PUSHES the work branch + surfaces the item on
2823
+ // `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
2824
+ // ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
2825
+ // path AND the fresh-worktree `review:` callback are covered by this ONE catch.
2826
+ // The recorded reason carries the parse-failure phrase the `failure-cause.ts`
2827
+ // signature matches → the `do`/`run` tail classifies it `transient-infra`
2828
+ // (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
2829
+ // and the direction-2 repair makes a re-run far more likely to parse). NEVER a
2830
+ // silent approve.
2831
+ const reason =
2832
+ `PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
2833
+ `${err.message}`;
2834
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
2835
+ cwd,
2836
+ slug,
2837
+ reason,
2838
+ arbiter: input.surfaceArbiter,
2839
+ env,
2840
+ note,
2841
+ });
2842
+ const message = routed.moved
2843
+ ? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2844
+ 'routed it to work/needs-attention/ (work branch pushed + surfaced; ' +
2845
+ 'transient-infra — re-run). NOT integrated.'
2846
+ : `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2847
+ 'NOT integrating.';
2848
+ note(message);
2849
+ return {
2850
+ kind: 'blocked',
2851
+ result: {
2852
+ outcome: 'review-unparseable',
2853
+ routedToNeedsAttention: routed.moved,
2854
+ branch,
2855
+ reason,
2856
+ },
2857
+ };
2858
+ }
2859
+ lastVerdict = verdict;
2860
+ if (verdict.verdict !== 'approve') {
2861
+ // A `block` is TERMINAL: stop now (never re-roll an unchanged tip) and route
2862
+ // the blocking findings to needs-attention below. `approved` stays false.
2863
+ approved = false;
2864
+ break;
2865
+ }
2866
+ // An `approve`: provisionally approved, but keep going — every remaining round
2867
+ // must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
2868
+ approved = true;
2869
+ }
2870
+ if (!approved) {
2871
+ // NON-approve verdict: route to needs-attention via the SAME seam the red gate
2872
+ // uses, NEVER integrate. We reach here EITHER because a round returned a
2873
+ // (terminal) block, OR because not every round corroborated the approve. The
2874
+ // reason records the last verdict's blocking findings (the proximate cause) plus
2875
+ // the `reviewMaxRounds` note (so a single-round block also reads correctly).
2876
+ const findingsReason = lastVerdict ? formatBlockReason(lastVerdict) : '';
2877
+ const reason =
2878
+ (findingsReason ? findingsReason + '\n' : '') +
2879
+ reviewRoundsExhaustedReason(maxRounds);
2880
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
2881
+ cwd,
2882
+ slug,
2883
+ reason,
2884
+ // Same autonomous-vs-human gate as the red-gate path: `do` passes the arbiter
2885
+ // (surface on main + push the branch), the human `complete` leaves it unset.
2886
+ arbiter: input.surfaceArbiter,
2887
+ env,
2888
+ note,
2889
+ });
2890
+ const message = routed.moved
2891
+ ? `PR/code review (Gate 2) blocked '${slug}'; routed it to ` +
2892
+ 'work/needs-attention/ (surfaced by status; the blocking findings are ' +
2893
+ 'recorded in the item body). NOT integrated.'
2894
+ : `PR/code review (Gate 2) blocked '${slug}'; NOT integrating.`;
2895
+ note(message);
2896
+ return {
2897
+ kind: 'blocked',
2898
+ result: {
2899
+ outcome: 'review-blocked',
2900
+ routedToNeedsAttention: routed.moved,
2901
+ branch,
2902
+ reason: message,
2903
+ // The structured block reason (the blocking findings ONLY) for a caller
2904
+ // doing its OWN routing (the tasking path); the build path ignores it.
2905
+ reviewBlockReason: findingsReason || message,
2906
+ },
2907
+ };
2908
+ }
2909
+ note(`PR/code review (Gate 2) approved '${slug}'.`);
2910
+ return {
2911
+ kind: 'approved',
2912
+ verdict: lastVerdict,
2913
+ };
2914
+ }
2915
+
2916
+ /** The result of {@link runFreshWorktreeGate}. */
2917
+ interface FreshGateResult {
2918
+ /** True iff BOTH `prepare` and `verify` passed on the rebased-tip worktree. */
2919
+ passed: boolean;
2920
+ /** Which step failed (when `!passed`): the env-prep step or the acceptance gate. */
2921
+ kind?: 'prepare' | 'verify';
2922
+ /** The non-zero exit code of the failing step (when `!passed`). */
2923
+ exitCode?: number;
2924
+ /**
2925
+ * The Gate-2 REVIEW outcome, present ONLY when a review gate was supplied to the
2926
+ * fresh gate AND `verify` passed (so the review ran AFTER it on the rebased tip).
2927
+ * The caller routes a `blocked` and acts on an `approved` (carry the verdict,
2928
+ * write the nits observation). Absent when no review was requested or `verify`
2929
+ * failed (the review never ran).
2930
+ */
2931
+ review?: Gate2ReviewOutcome;
2932
+ }
2933
+
2934
+ /**
2935
+ * Run the acceptance gate (`prepare` then `verify`) in a CLEAN THROWAWAY worktree
2936
+ * cut from `commit` (the work branch tip AFTER it was rebased onto `<arbiter>/main`
2937
+ * — the would-be-integrated tip), then REAP the worktree (pass OR fail). This is
2938
+ * the fresh-worktree gate (task `gate-on-rebased-tip-fresh-worktree`): a green
2939
+ * gate provably describes the MERGED artifact, because the worktree is cut from the
2940
+ * COMMITTED, rebased tip — gitignored/uncommitted state in the agent's `cwd` cannot
2941
+ * leak in, and a change the integration rebase introduced IS gated.
2942
+ *
2943
+ * The worktree is registered on `cwd`'s git common dir (`git worktree add --detach`
2944
+ * run IN `cwd`), so it works for BOTH isolation strategies: an in-place clone
2945
+ * (`<cwd>/.git`) and a job worktree cut from a bare hub mirror (the mirror's git
2946
+ * dir). It is a TRANSIENT gate sandbox — distinct from the agent's job worktree — and
2947
+ * is ALWAYS removed afterwards (`git worktree remove --force` + a dir cleanup
2948
+ * fallback), never leaked (cross-ref the worktree-hygiene/reap discipline in
2949
+ * `gc.ts`). The throwaway worktree is fresh (no deps), so `prepare` runs in it
2950
+ * before `verify` (forced — `useMarker: false` — since it is per-gate); a failing
2951
+ * `prepare` short-circuits and never runs `verify` (the env could not be made
2952
+ * ready), surfaced distinctly as `kind: 'prepare'`.
2953
+ *
2954
+ * GATE-2 REVIEW (MAINTAINER DECISION 2): when a `review` callback is supplied (the
2955
+ * caller resolved `review` ON), the review runs HERE — AFTER the rebased-tip
2956
+ * `verify` passes, against THIS fresh gate worktree (the rebased tip) — so
2957
+ * verify-THEN-review holds on the SAME merged tree. The review runs INSIDE the
2958
+ * worktree's lifetime (before it is reaped), so the review agent inspects the
2959
+ * rebased tip. Its outcome is returned in {@link FreshGateResult.review} for the
2960
+ * caller to route/act on; the worktree is reaped regardless of the verdict.
2961
+ */
2962
+ async function runFreshWorktreeGate(params: {
2963
+ cwd: string;
2964
+ commit: string;
2965
+ prepare?: VerifyConfig;
2966
+ verify?: VerifyConfig;
2967
+ env: NodeJS.ProcessEnv | undefined;
2968
+ note: (message: string) => void;
2969
+ /**
2970
+ * Run the Gate-2 review on the rebased-tip worktree AFTER `verify` passes
2971
+ * (verify-then-review on the merged tree). Supplied ONLY when `review` resolved
2972
+ * ON; absent ⇒ the fresh gate runs prepare+verify only (no review). Receives the
2973
+ * gate worktree dir (the tree to review) and returns the routed outcome.
2974
+ */
2975
+ review?: (reviewCwd: string) => Promise<Gate2ReviewOutcome>;
2976
+ }): Promise<FreshGateResult> {
2977
+ const {cwd, commit, env, note} = params;
2978
+ // A throwaway gate-sandbox dir OUTSIDE any tracked tree (the OS temp area), so it
2979
+ // can never be swept into a commit and is naturally disposable.
2980
+ const gateDir = mkdtempSync(join(tmpdir(), 'dorfl-fresh-gate-'));
2981
+ // `git worktree add` will refuse to add into a non-empty existing dir, so add a
2982
+ // child path under the (empty) mkdtemp dir.
2983
+ const worktreeDir = join(gateDir, 'tip');
2984
+ try {
2985
+ // Cut a CLEAN DETACHED worktree from the rebased tip. Detached (no branch) so it
2986
+ // never collides with the work branch already checked out in `cwd`.
2987
+ await gitHard(
2988
+ ['worktree', 'add', '--quiet', '--detach', worktreeDir, commit],
2989
+ cwd,
2990
+ env,
2991
+ );
2992
+ note(
2993
+ 'Running the acceptance gate (prepare then verify) on the rebased tip in ' +
2994
+ 'a clean throwaway worktree…',
2995
+ );
2996
+ // prepare: a fresh worktree has no deps, so install BEFORE verify. Forced per
2997
+ // gate (`useMarker: false`) — this worktree is throwaway. Unset ⇒ a no-op.
2998
+ const prep = await ensurePrepared({
2999
+ cwd: worktreeDir,
3000
+ prepare: params.prepare,
3001
+ env,
3002
+ useMarker: false,
3003
+ });
3004
+ if (!prep.passed) {
3005
+ return {passed: false, kind: 'prepare', exitCode: prep.exitCode};
3006
+ }
3007
+ const gate = await runVerify({
3008
+ cwd: worktreeDir,
3009
+ verify: params.verify,
3010
+ env,
3011
+ });
3012
+ if (!gate.passed) {
3013
+ return {passed: false, kind: 'verify', exitCode: gate.exitCode};
3014
+ }
3015
+ // GATE-2 REVIEW on the rebased tip, AFTER the green verify (verify-then-review
3016
+ // on the SAME merged tree). Runs while the worktree is still live (the review
3017
+ // agent inspects the rebased tip). The outcome is returned for the caller to
3018
+ // route/act on.
3019
+ const review = params.review ? await params.review(worktreeDir) : undefined;
3020
+ return {passed: true, review};
3021
+ } finally {
3022
+ // REAP the throwaway fresh-gate worktree (pass or fail) — never leak it. Remove the
3023
+ // git-registered worktree first (so the common dir has no dangling
3024
+ // registration), then best-effort drop the temp dir + prune.
3025
+ try {
3026
+ await gitSoft(['worktree', 'remove', '--force', worktreeDir], cwd, env);
3027
+ } catch {
3028
+ // best-effort
3029
+ }
3030
+ try {
3031
+ await gitSoft(['worktree', 'prune'], cwd, env);
3032
+ } catch {
3033
+ // best-effort
3034
+ }
3035
+ try {
3036
+ rmSync(gateDir, {recursive: true, force: true});
3037
+ } catch {
3038
+ // best-effort
3039
+ }
3040
+ }
3041
+ }
3042
+
3043
+ /** Run git, returning the raw result (no throw) — for soft checks. */
3044
+ function gitSoft(
3045
+ args: string[],
3046
+ cwd: string,
3047
+ env: NodeJS.ProcessEnv | undefined,
3048
+ ): Promise<RunResult> {
3049
+ return runAsync('git', args, cwd, {env});
3050
+ }
3051
+
3052
+ /** Run git; throw on non-zero (genuinely unexpected plumbing failures). */
3053
+ async function gitHard(
3054
+ args: string[],
3055
+ cwd: string,
3056
+ env: NodeJS.ProcessEnv | undefined,
3057
+ ): Promise<RunResult> {
3058
+ const result = await runAsync('git', args, cwd, {env});
3059
+ if (result.status !== 0) {
3060
+ throw new Error(
3061
+ `git ${args.join(' ')} failed (exit ${result.status}): ${result.stderr.trim()}`,
3062
+ );
3063
+ }
3064
+ return result;
3065
+ }