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,2195 @@
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { LEDGER_STATUS_FOLDERS, workItemPath, workItemRel, workFolderPath, workFolderPrefix, } from './work-layout.js';
5
+ import { runVerify } from './verify.js';
6
+ import { ensurePrepared } from './prepare.js';
7
+ import { ReviewParseError, formatBlockReason, reviewRoundsExhaustedReason, } from './review-gate.js';
8
+ import { ledgerWrite } from './ledger-write.js';
9
+ import { selectProvider } from './github.js';
10
+ import { parseFrontmatter } from './frontmatter.js';
11
+ import { git, run, runAsync } from './git.js';
12
+ import { realSleep } from './retry-backoff.js';
13
+ import { workBranchRef } from './slug-namespace.js';
14
+ import { isAncestor } from './gc.js';
15
+ /**
16
+ * The default LIVENESS CEILING for the merge-mode `${branch}:main` push (the
17
+ * durable promotions `tasks/ready → tasks/done` and, via `lifecycle`, `prds/ready
18
+ * → prds/tasked`). Task `c2-rebase-until-real-on-durable-main-promotions` turned
19
+ * the previous SMALL FIXED CAP (was 5, the `run-fleet-claim-integrate-and-sibling-
20
+ * rebase-concurrency-safe` Race-1 budget) into rebase-until-real-conflict: a CLEAN
21
+ * re-rebase no longer counts against a tiny give-up budget — only a GENUINE
22
+ * conflict surfaced by {@link rebaseOntoMainWithReconcile} stops the loop (the
23
+ * step-4 rebase IS the source-folder precondition recheck for the slug-relocation
24
+ * family: if the slug is GONE from its expected source folder on the new `main`,
25
+ * the `git mv` replay fails and `rebaseConflictRoute` routes definitively — never a
26
+ * silent re-push that would clobber a concurrent legitimate same-item winner).
27
+ *
28
+ * The cap survives only as a LARGE liveness ceiling that bounds the pathological
29
+ * livelock tail (a sustained-parallel-load hot ref where the loser's round-trip is
30
+ * always beaten by another winner — classic CAS livelock). Combined with the
31
+ * modest jitter on the refetch below it desynchronises the herd, so a route-to-
32
+ * needs-attention from this loop becomes a RARE livelock signal rather than the
33
+ * ROUTINE false-contention signal it was at the old cap of 5.
34
+ *
35
+ * SCOPE (`work/notes/ideas/ledger-lock-evolution-per-item-ref-vs-rebase-until-real-
36
+ * conflict.md` `### C2` SCOPE box, repeated here because over-applying C2 is the
37
+ * one near-fatal mistake): the durable promotions are slug RELOCATIONS, not the
38
+ * same-path / append family. They MUST keep their source-folder precondition
39
+ * recheck — reused here verbatim as `rebaseOntoMainWithReconcile`'s rebase replay
40
+ * + arbiter ledger-placement read. Do NOT add a new conflict-detection path; the
41
+ * existing one IS the genuine-conflict terminator.
42
+ *
43
+ * Tests inject a small `mergeRetries` (or `0`) to exercise the old un-retried /
44
+ * cap-exhausted route deterministically — that test seam is preserved.
45
+ */
46
+ const DEFAULT_MERGE_RETRIES = 1000;
47
+ /**
48
+ * Default modest jitter (ms) on the refetch between merge-push retries — load-
49
+ * bearing under sustained parallel load: an instant lockstep refetch→re-push loop
50
+ * maximises mutual rejection (a thundering herd), fattening the livelock tail.
51
+ * Modest randomisation desynchronises the herd. Each attempt sleeps a UNIFORMLY-
52
+ * random integer in `[0, DEFAULT_MERGE_JITTER_MS]` ms. Small enough (≤25ms) that
53
+ * the additional latency under realistic contention is negligible vs the
54
+ * round-trip cost of a rebase+push, and small enough that the in-tree concurrency
55
+ * tests are not slowed measurably. Tests override via `mergeJitterMs: 0`.
56
+ */
57
+ const DEFAULT_MERGE_JITTER_MS = 25;
58
+ /**
59
+ * Promise-based sleep, used for the {@link DEFAULT_MERGE_JITTER_MS} merge-push
60
+ * retry jitter (C2). Internal — the legacy non-seamed sleep this module uses
61
+ * for the C2 jitter (kept for byte-for-byte compatibility with existing tests).
62
+ * The recovery-rebase loop uses the INJECTABLE {@link Sleep} seam from
63
+ * `retry-backoff.ts` instead, so its timeline is test-driveable.
64
+ */
65
+ function sleepMs(ms) {
66
+ return new Promise((resolve) => setTimeout(resolve, ms));
67
+ }
68
+ /**
69
+ * **Default cap on RE-FETCH+RE-REBASE attempts** in the committed-recovery tail
70
+ * (`recoverAlreadyCommitted`, task `recovery-rebase-retry-against-moving-arbiter
71
+ * -main`). The recovery's single fetch-then-rebase is a CONTENTION race against a
72
+ * concurrently-MOVING `<arbiter>/main` (a sibling `advance` run lands a burst of
73
+ * `advance: surface observation:…` commits); a one-shot rebase against a stale
74
+ * fetched base can conflict against a main that already moved AGAIN, surfacing a
75
+ * purely transient race as `rebase-conflict`. So the rebase is wrapped in a small
76
+ * bounded CONTENTION loop: re-fetch `<arbiter>/main` (it may have advanced) +
77
+ * re-rebase; on clean → integrate; on conflict → `--abort`, a small jitter sleep,
78
+ * try again. The cap stops the loop ONLY when every fresh-fetched attempt still
79
+ * conflicts (a genuinely persistent conflict ⇒ route to needs-attention exactly
80
+ * as today). Small on purpose — a few attempts ride out a `advance` burst (each
81
+ * burst is tens of commits over a few seconds); a real conflict surfaces fast.
82
+ *
83
+ * This is the CONTENTION model (instant re-fetch+rebuild, like `claim-cas.ts`
84
+ * and the Race-1 merge loop above), NOT the OUTAGE model in
85
+ * {@link file://./retry-backoff.ts} (exponential temporal backoff, the remote
86
+ * may come back). The two failure classes are deliberately kept SEPARATE; do not
87
+ * substitute `retryWithBackoff` here.
88
+ *
89
+ * Tests inject `recoveryRebaseRetries: 0` (no retry — the legacy one-shot shape)
90
+ * or a small explicit cap (assert the cap exhausts deterministically).
91
+ */
92
+ const DEFAULT_RECOVERY_REBASE_RETRIES = 4;
93
+ /**
94
+ * **Default max jitter (ms)** between recovery-rebase attempts — a SMALL
95
+ * livelock-breaking SPREAD between concurrent runners (NOT exponential outage
96
+ * backoff). Pure instant retry has a real hazard: two runners that begin
97
+ * retrying at the same instant re-fetch and re-rebase in LOCKSTEP, each moving
98
+ * the base the other just rebased onto, and can livelock. A uniformly-random
99
+ * `[0, mergeJitterMs]` ms sleep before each re-attempt de-correlates the two
100
+ * racers. Bounded and tiny — a contention nudge, not an outage wait. Tests pass
101
+ * `recoveryRebaseJitterMs: 0` for a deterministic latency-free loop, OR inject
102
+ * the `sleep`/`random` seams to drive the timeline reproducibly with a seeded RNG.
103
+ */
104
+ const DEFAULT_RECOVERY_REBASE_JITTER_MS = 100;
105
+ const DEFAULT_TYPE = 'feat';
106
+ /**
107
+ * Run the shared gate→integrate band for a prepared (claimed, built, on-its-work-
108
+ * branch) item. The HEAD has already resolved `cwd`/`arbiter`/`slug`/`source`/
109
+ * `recovering`; this runs the gate, the review gate, the effective-mode decision,
110
+ * the done-move + atomic commit + rebase + integrate, and routes ANY failure to
111
+ * needs-attention — returning pure DATA for the caller's tail.
112
+ *
113
+ * It never throws for the expected gate-failed / review-blocked / rebase-conflict
114
+ * cases (those are returned with the corresponding outcome). A genuinely
115
+ * unexpected git plumbing failure (e.g. the done-move itself, or nothing staged)
116
+ * throws — the caller's existing try/catch maps it.
117
+ */
118
+ export async function performIntegration(input) {
119
+ const cwd = input.cwd;
120
+ const env = input.env;
121
+ const arbiter = input.arbiter;
122
+ const slug = input.slug;
123
+ const source = input.source;
124
+ const recovering = input.recovering;
125
+ const note = input.note ?? (() => { });
126
+ // The work branch: the caller is on it (it carries the namespaced identity).
127
+ // Prefer the explicit `branch`; else read the branch HEAD is on; only fall
128
+ // back to a synthesised `work/task-<slug>` if HEAD is detached (a degenerate
129
+ // case that the on-branch invariant should preclude).
130
+ const branch = input.branch ?? resolveWorkBranch(cwd, slug, env);
131
+ // Captured from the Gate-2 review (when it runs + approves) so that AFTER the
132
+ // propose integrate — where the opened PR url is finally in scope — we can post
133
+ // the agent's deliberately-authored `review` prose as a PR comment (task
134
+ // `review-comment-prose-field`). Stays undefined when review is off / the agent
135
+ // emitted no `review` field, so the post is skipped (no-op). The verdict/routing
136
+ // decision uses neither.
137
+ let approvedVerdict;
138
+ // The resolved integration mode. `merge` lands automatically on a green gate
139
+ // (and an `approve` when review is on); `propose` leaves the merge to a human.
140
+ // MUTABLE because the untrusted-origin build-propose rule below may force it to
141
+ // `propose` for a task BUILD (the build transition only) — see the rule after
142
+ // `sourcePath` is resolved.
143
+ let mode = input.mode;
144
+ // The fresh-worktree gate (task `gate-on-rebased-tip-fresh-worktree`): when ON
145
+ // the deterministic acceptance gate (`prepare`+`verify`) does NOT run here on
146
+ // the agent's PRE-rebase `cwd`; instead it runs LATER, in a clean throwaway
147
+ // worktree cut from the work branch REBASED onto `<arbiter>/main` (the
148
+ // would-be-integrated tip), inside the rebase-to-integrate tail. So a green gate
149
+ // provably describes the MERGED artifact. When OFF the front prepare+verify runs
150
+ // here exactly as before (byte-for-byte). The band HONOURS the boolean it is
151
+ // handed (caller-agnostic); the `run`-fleet `perRepoMax === 1` downgrade lives
152
+ // in the `run` caller, not here.
153
+ const freshWorktreeGate = input.freshWorktreeGate === true;
154
+ // RECOVER an already-committed, already-done-moved STRANDED branch (prd
155
+ // `ledger-integrity` story 6). The work + the done-move are ALREADY committed on
156
+ // the work branch (a terminal push failed AFTER steps 2–3); SKIP steps 0–3
157
+ // (prepare / gate / review / done-move / commit) and run ONLY the
158
+ // rebase→integrate TAIL from the kept commit, reusing the SAME integrate
159
+ // primitive. Returns BEFORE any of the build-path steps run.
160
+ if (input.committedRecovery) {
161
+ return await recoverAlreadyCommitted({
162
+ cwd,
163
+ arbiter,
164
+ slug,
165
+ branch,
166
+ mode,
167
+ noPR: input.noPR,
168
+ providerInstance: input.providerInstance,
169
+ openPr: input.openPr,
170
+ recoveryRebaseRetries: input.recoveryRebaseRetries,
171
+ recoveryRebaseJitterMs: input.recoveryRebaseJitterMs,
172
+ recoveryRebaseSleep: input.recoveryRebaseSleep,
173
+ recoveryRebaseRandom: input.recoveryRebaseRandom,
174
+ // Answered-merge land (task `committed-recovery-honours-fresh-worktree-gate`,
175
+ // prd `land-time-reverify-and-parallel-merge-ceiling`): the apply-rung
176
+ // dispatches an answered `merge` through this committed-recovery tail (the
177
+ // branch already carries its done-move commit, so the build path's
178
+ // `git mv`+`add -A`+commit would raise `IntegrationNothingStaged`). UNLIKE
179
+ // the original stranded-recovery caller (whose pre-strand build already
180
+ // gated), `<arbiter>/main` may have MOVED since this branch's last build,
181
+ // so the rebased tip MUST be re-verified before it lands or the load-bearing
182
+ // invariant ("main never receives a tree that fails verify") cannot hold on
183
+ // the merge path. Thread the gate inputs through; recovery runs the EXISTING
184
+ // `runFreshWorktreeGate` on the rebased tip when `freshWorktreeGate` is set
185
+ // (and not `--skip-verify`), routing a RED gate to needs-attention via the
186
+ // SAME seam the build path uses. With `freshWorktreeGate` unset (the
187
+ // stranded-recovery caller), recovery is byte-identical to before — no
188
+ // extra gate, no extra fetch.
189
+ freshWorktreeGate,
190
+ skipVerify: input.skipVerify,
191
+ prepare: input.prepare,
192
+ verify: input.verify,
193
+ surfaceArbiter: input.surfaceArbiter,
194
+ env,
195
+ note,
196
+ });
197
+ }
198
+ // The file whose `title:` seeds the commit summary + PR title. For a TASKING
199
+ // transition (a non-task `lifecycle`) this is the held prd it supplies; for a
200
+ // build it is the task in its source folder. Read BEFORE any move.
201
+ const lifecycle = input.lifecycle;
202
+ // CONTINUE-BUILD (`source: 'done'`, task
203
+ // `complete-builds-on-already-done-moved-continue`): on a dirty continue whose
204
+ // kept branch already holds the slug in `work/done/`, the file we read for the
205
+ // title + (otherwise) untrusted-origin lives there too — not in
206
+ // `in-progress/`/`needs-attention/`. The step-2 `git mv` is later SKIPPED for
207
+ // this source (the slug is already in done/), and the originTrust read +
208
+ // arbiter ledger placement/divergent-done-move reconcile are EXEMPTED below.
209
+ const sourcePath = lifecycle
210
+ ? lifecycle.titlePath
211
+ : source === 'done'
212
+ ? workItemPath(cwd, 'done', slug)
213
+ : workItemPath(cwd, source, slug);
214
+ // UNTRUSTED-ORIGIN BUILD-PROPOSE RULE (task `untrusted-origin-forces-build-propose`).
215
+ // A task born from an UNTRUSTED issue carries `originTrust: untrusted` (stamped
216
+ // at intake, propagated by the tasker). Its risk is the BUILD (it becomes code),
217
+ // so the build transition resolves to `propose` even when the requested mode is
218
+ // `merge` — moving the human checkpoint onto the becomes-code build. Precedence:
219
+ // explicit --merge > untrusted-origin ⇒ propose > config mode > default.
220
+ // An explicit `--merge` (input.explicitMerge) OVERRIDES the rule (the operator
221
+ // is present; CLI always wins, no special force-key). The autonomous/CI path
222
+ // passes no flag, so there an untrusted-origin task RELIABLY forces propose.
223
+ //
224
+ // SCOPE: the task BUILD transition ONLY. It NEVER fires for a `lifecycle`
225
+ // transition (tasking / intake-emit — a prd/task FILE landing on main is inert;
226
+ // intake's OWN per-emit resolver already decided that mode), and the source file
227
+ // here is the task being built. A `trusted`/unset task ⇒ untouched (zero
228
+ // behaviour change for the normal human path).
229
+ // CONTINUE-BUILD EXEMPTION (task
230
+ // `complete-builds-on-already-done-moved-continue`): the prior attempt that
231
+ // done-moved this task already went through the originTrust checkpoint (an
232
+ // untrusted task on a `merge` config proposed on that first attempt). The
233
+ // continue-build commit is layered on top of that kept tip, so re-evaluating
234
+ // the rule here would either double-checkpoint or be a no-op against the same
235
+ // frontmatter — exempt this state (the task file lives in `done/` and is now
236
+ // effectively an on-`main` artifact). The other states are unchanged.
237
+ if (!lifecycle &&
238
+ source !== 'done' &&
239
+ mode === 'merge' &&
240
+ input.explicitMerge !== true &&
241
+ existsSync(sourcePath) &&
242
+ parseFrontmatter(readFileSync(sourcePath, 'utf8')).originTrust ===
243
+ 'untrusted') {
244
+ mode = 'propose';
245
+ note(`Untrusted-origin task '${slug}': forcing the BUILD transition to ` +
246
+ 'propose (a human reviews the becomes-code change before it merges). ' +
247
+ 'Pass --merge to override.');
248
+ }
249
+ // 0. Prepare: make the worktree's ENV READY before the gate (install deps,
250
+ // submodules, codegen). A fresh job worktree off the hub mirror has no
251
+ // `node_modules`, so `verify` would fail for lack of deps unless prepare
252
+ // runs FIRST. `prepare` is the SIBLING of `verify`, NOT baked into it: it
253
+ // runs ONCE before the first verify, gated by a NON-COMMITTED prepared-ness
254
+ // marker in the worktree's git control area (so it does not re-install per
255
+ // gate within one persistent worktree). Unset ⇒ a no-op (no default install).
256
+ // A FAILING prepare is a HARD STOP distinct from a red gate (`prepare-failed`):
257
+ // the env could not be made ready, so `verify` cannot be trusted — it NEVER
258
+ // proceeds to verify/integrate, and routes the item the SAME way a red gate
259
+ // does. (`--skip-verify` skips only the gate, not env-prep: a verify-skipped
260
+ // finish still needs a ready env; the marker keeps an already-prepared tree
261
+ // a no-op.)
262
+ //
263
+ // FRESH-WORKTREE GATE: when ON, this front prepare+verify is SKIPPED here and
264
+ // runs LATER on the rebased-tip throwaway worktree (see the tail). The OFF
265
+ // path below is byte-for-byte today's pre-rebase gate.
266
+ if (!freshWorktreeGate) {
267
+ const prep = await ensurePrepared({ cwd, prepare: input.prepare, env });
268
+ if (!prep.noop && !prep.skipped) {
269
+ note('Running the env-prep step (prepare)…');
270
+ }
271
+ if (!prep.passed) {
272
+ const reason = `prepare (env-prep) failed (exit ${prep.exitCode})`;
273
+ // Bounce from in-progress/ straight to needs-attention/ (the SAME seam a red
274
+ // gate uses) — recording the prepare-failed reason + committing the move
275
+ // (with the agent's uncommitted work) as ONE atomic transition. We NEVER
276
+ // run `verify` on an env that could not be made ready.
277
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
278
+ cwd,
279
+ slug,
280
+ reason,
281
+ arbiter: input.surfaceArbiter,
282
+ env,
283
+ note,
284
+ });
285
+ return {
286
+ outcome: 'prepare-failed',
287
+ routedToNeedsAttention: routed.moved,
288
+ branch,
289
+ reason: routed.moved
290
+ ? `Env-prep (prepare) failed (exit ${prep.exitCode}); routed '${slug}' ` +
291
+ 'to work/needs-attention/ (the environment could not be made ready, ' +
292
+ 'so the acceptance gate was NOT run). Fix the prepare command, then ' +
293
+ 'return it to backlog/.'
294
+ : `Env-prep (prepare) failed (exit ${prep.exitCode}); not completing ` +
295
+ `'${slug}' (the environment could not be made ready, so the ` +
296
+ 'acceptance gate was NOT run). Fix the prepare command, then retry.',
297
+ };
298
+ }
299
+ }
300
+ // 1. Gate: bad work never proceeds to done. Default-on; --skip-verify is a
301
+ // human-only escape hatch (the autonomous runner never skips — ADR §8).
302
+ // FRESH-WORKTREE GATE: when ON this front gate is SKIPPED and runs LATER on
303
+ // the rebased-tip throwaway worktree (see the tail); the OFF branch below is
304
+ // byte-for-byte today's pre-rebase gate.
305
+ if (input.skipVerify) {
306
+ note('Skipping the acceptance gate (--skip-verify).');
307
+ }
308
+ else if (!freshWorktreeGate) {
309
+ note('Running the acceptance gate (verify)…');
310
+ const gate = await runVerify({ cwd, verify: input.verify, env });
311
+ if (!gate.passed) {
312
+ // Don't leave the item dangling in in-progress/: route it to
313
+ // needs-attention/ with the reason (ADR §12) THROUGH the ledger write
314
+ // seam's needs-attention transition. The item has NOT been committed/moved
315
+ // yet, so the move bounces it straight from in-progress/ — recording the
316
+ // reason + committing the move (with the agent's uncommitted work) as ONE
317
+ // atomic transition. No partial state.
318
+ const reason = `acceptance gate failed (exit ${gate.exitCode})`;
319
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
320
+ cwd,
321
+ slug,
322
+ reason,
323
+ // Autonomous caller (`do`) passes the arbiter so the seam both SURFACES
324
+ // the stuck state on `main` (OBSERVABLE, cross-machine visible) AND
325
+ // pushes the `work/<slug>` branch (RECOVERABLE — a requeue-continue on
326
+ // another machine, reading <arbiter>/work/<slug>, lands on the saved
327
+ // wip). The human `complete` leaves it unset → no surface, no push,
328
+ // local-only (a human is right there). One operation in one place: the
329
+ // push lives in the seam (it is HEAD's branch — `work/<slug>` — here),
330
+ // no bolted-on push to forget.
331
+ arbiter: input.surfaceArbiter,
332
+ env,
333
+ note,
334
+ });
335
+ return {
336
+ outcome: 'gate-failed',
337
+ routedToNeedsAttention: routed.moved,
338
+ branch,
339
+ reason: routed.moved
340
+ ? `Acceptance gate failed (exit ${gate.exitCode}); routed '${slug}' ` +
341
+ 'to work/needs-attention/ (surfaced by status; return to backlog/ ' +
342
+ 'once resolved). Fix the work, or use --skip-verify to override.'
343
+ : `Acceptance gate failed (exit ${gate.exitCode}); not completing ` +
344
+ `'${slug}'. Fix the work, or use --skip-verify to override.`,
345
+ };
346
+ }
347
+ }
348
+ // 1b. Gate 2 — the PR/code REVIEW gate (GATES prd `work/prds/tasked/review.md`). It is a
349
+ // JUDGEMENT gate layered ON TOP of the deterministic `verify` floor (ADR §8)
350
+ // — NEVER a replacement, and ALWAYS verify-THEN-review on the SAME tree.
351
+ //
352
+ // WHERE it runs depends on the fresh-worktree gate (MAINTAINER DECISION 2):
353
+ // - OFF: `verify` ran HERE on the pre-rebase `cwd`, so the review runs HERE
354
+ // too, on `cwd`, BEFORE the done-move — byte-for-byte today's order.
355
+ // - ON: the front `verify` was SKIPPED here and runs LATER on the rebased
356
+ // tip (step 4c). So the review is RELOCATED to run there too, AFTER the
357
+ // rebased-tip `verify` passes, inside the same fresh gate worktree — so
358
+ // verify-then-review holds on the SAME merged tree (the tree that lands),
359
+ // not split across two trees. (Letting verify move to the rebased tip
360
+ // while review stayed on the pre-rebase `cwd` would deliver the
361
+ // gate-the-merged-tree guarantee for verify but BREAK it for review,
362
+ // incoherent with this task's own goal.)
363
+ //
364
+ // Either way the verdict routes IDENTICALLY:
365
+ // approve → fall through to the done-move/commit/integrate unchanged;
366
+ // block → route to needs-attention via the SAME machinery the red gate
367
+ // uses (`applyNeedsAttentionTransition`, surfaced on
368
+ // `surfaceArbiter` for the autonomous `do` path), with the
369
+ // blocking findings recorded as the reason, no integrate.
370
+ if (input.review && !freshWorktreeGate) {
371
+ const reviewOutcome = await runGate2Review({
372
+ reviewCwd: cwd,
373
+ input,
374
+ slug,
375
+ branch,
376
+ cwd,
377
+ env,
378
+ note,
379
+ });
380
+ if (reviewOutcome.kind === 'blocked') {
381
+ return reviewOutcome.result;
382
+ }
383
+ // approve: carry the verdict (post-integrate PR comment) and write the per-run
384
+ // non-blocking-nits observation INTO `cwd` BEFORE the done-move + atomic commit
385
+ // (so it is swept into that SAME done-commit). A resolved `merge` then lands
386
+ // automatically (`merge` IS the auto-land mode); `propose` leaves it to a human.
387
+ approvedVerdict = reviewOutcome.verdict;
388
+ writeReviewNitsObservation({
389
+ cwd,
390
+ slug,
391
+ findings: reviewOutcome.verdict?.findings ?? [],
392
+ note,
393
+ });
394
+ }
395
+ // Read the title now, BEFORE the move, for the default commit summary AND the
396
+ // synthesised propose-mode PR TITLE (the source file is about to be git-mv'd
397
+ // away). The PR title is a SINGLE, capped line built runner-side from the
398
+ // task's `title:` frontmatter + the slug (`<type>(<slug>): <title>`) so it can
399
+ // never be the multi-line commit-subject run-on `--fill` would derive.
400
+ //
401
+ // When the lifecycle supplies an EXPLICIT `title`, use it DIRECTLY (no file read):
402
+ // the intake lone-task / prd path writes its output file in `stage()`, which runs
403
+ // AFTER this point, so a `titlePath` read would race the write and degrade the
404
+ // subject/PR title to the generic fallback. The `do prd:` tasking path leaves
405
+ // `title` unset and keeps reading its already-existing held prd (unchanged).
406
+ const explicitTitle = lifecycle?.title;
407
+ const taskTitle = explicitTitle !== undefined ? explicitTitle : readTaskTitle(sourcePath);
408
+ const defaultMessage = explicitTitle !== undefined
409
+ ? summaryFromTitle(explicitTitle, slug)
410
+ : defaultSummary(sourcePath, slug);
411
+ const prTitle = synthesiseProposeTitle({
412
+ type: (input.type ?? DEFAULT_TYPE).trim() || DEFAULT_TYPE,
413
+ slug,
414
+ title: taskTitle,
415
+ });
416
+ // 2. STAGE the item move into the index. For a build that is the task done-move
417
+ // (`work/<source>/<slug>.md → work/done/<slug>.md`); for a TASKING
418
+ // transition (a non-task `lifecycle`) it is the caller-supplied prd
419
+ // lifecycle move + emitted backlog files (the runner stages them, the agent
420
+ // never does git). Either way the subsequent `git add -A` folds the agent's
421
+ // uncommitted work + this staging into ONE atomic commit.
422
+ //
423
+ // The task done-move is ATOMIC AGAINST THE ARBITER (ledger-integrity
424
+ // defect 1 + its root defect 2). The LOCAL move here is the legacy clean
425
+ // `git mv work/<source>/<slug>.md → work/done/<slug>.md` (one folder, no
426
+ // fetch — every existing path is byte-for-byte unchanged); the ARBITER
427
+ // resolution + the one-slug-one-folder enforcement happen AFTER the rebase
428
+ // (step 4b, `reconcileDoneMoveAgainstArbiter`), against the freshly-fetched
429
+ // `<arbiter>/main`. That ordering is deliberate: a NEW fetch in THIS step
430
+ // would race a sibling job's integration on the SHARED bare-mirror refs (the
431
+ // very regression that orphaned this work, ADR §2), so we reuse the existing
432
+ // step-4 fetch's result instead. The reconciliation REMOVES any divergent
433
+ // `in-progress/`/`needs-attention/` ghost the merge would otherwise leave (so
434
+ // the move is a MOVE, not a COPY) and FAILS LOUD if the arbiter holds the slug
435
+ // in two folders with differing content.
436
+ if (lifecycle) {
437
+ await lifecycle.stage();
438
+ }
439
+ else if (source === 'done') {
440
+ // CONTINUE-BUILD (`source: 'done'`, task
441
+ // `complete-builds-on-already-done-moved-continue`): the slug is ALREADY in
442
+ // `work/done/` on this kept branch (a prior attempt moved it there), so there
443
+ // is NOTHING to move — skip the step-2 `git mv`. The subsequent `git add -A`
444
+ // folds the agent's NEW uncommitted source edits into the atomic commit on
445
+ // top of the kept (already-done-moved) tip; no second move, no copy.
446
+ }
447
+ else {
448
+ mkdirSync(workFolderPath(cwd, 'done'), { recursive: true });
449
+ await gitHard([
450
+ 'mv',
451
+ workItemRel(source, `${slug}.md`),
452
+ workItemRel('done', `${slug}.md`),
453
+ ], cwd, env);
454
+ }
455
+ // 3. Commit: git add -A (the agent's uncommitted work + the move) into ONE
456
+ // atomic commit. Nothing to commit is FATAL (no-op-is-fatal, like claim.sh).
457
+ await gitHard(['add', '-A'], cwd, env);
458
+ if (await nothingStaged(cwd, env)) {
459
+ throw new IntegrationNothingStaged(`nothing to commit for '${slug}' — no work and no move staged. ` +
460
+ '(Did the agent produce changes? Is the task already done?)', slug);
461
+ }
462
+ // SCOOP + REPORT agent-authored CAPTURED NOTES (task `runner-scoops-captured-notes`,
463
+ // advance-loop's reporting-channel fold-in). A rung's agent may write capture-bucket
464
+ // files (`work/notes/observations/*`, `work/findings/*`) during its run — its `capture-signal`
465
+ // reflex — but it does NO git (Rule A). The `git add -A` above already SWEEPS them into
466
+ // THIS one runner-owned commit (the same way the review-nits observation rides it), so
467
+ // they are TRACKED, not dropped/untracked. Rule B is extended HERE: the runner REPORTS
468
+ // exactly which note files landed (honest reporting — what actually reached the commit,
469
+ // read from the staged set, not assumed). This is the ONE shared place — BOTH the build
470
+ // path (`do <task>`/`run`/`complete`) and the tasking path (`do prd:`, via the
471
+ // `lifecycle` seam) route through it, so the channel is NOT forked. Zero notes ⇒ no
472
+ // report (the no-note case is byte-for-byte unchanged).
473
+ await reportScoopedNotes(cwd, env, note);
474
+ const summary = input.message ?? defaultMessage;
475
+ const type = (input.type ?? DEFAULT_TYPE).trim() || DEFAULT_TYPE;
476
+ // The trailing transition tag: a build is `; done`, a TASKING transition is
477
+ // `; tasked` (the lifecycle supplies it). Keeps the runner-owned commit subject
478
+ // honest about WHICH lifecycle landed.
479
+ const commitMessage = `${type}(${slug}): ${summary}; ${lifecycle?.commitTag ?? 'done'}`;
480
+ await gitHard(['commit', '-q', '-m', commitMessage], cwd, env);
481
+ note(`Committed: ${commitMessage}`);
482
+ // The rebase-to-integrate TAIL (step 4 fetch+rebase → step 5 integrate) is the
483
+ // ONLY region serialised per repo under the `run` concurrency seam: it is the
484
+ // land-on-`main` band where two concurrent SAME-repo merge jobs would otherwise
485
+ // race (the loser pushing a non-fast-forward `${branch}:main`). Wrapping ONLY
486
+ // this tail keeps the front-of-band gate (`prepare`+`verify`) and the Gate-2
487
+ // review agent CONCURRENT across same-repo jobs (run's parallelism); inside the
488
+ // lock the loser re-fetches + rebases onto the winner's now-advanced main, so
489
+ // its push is a clean fast-forward (a genuine conflict routes ONE to
490
+ // needs-attention). Single-job callers pass no lock ⇒ the tail runs directly,
491
+ // byte-for-byte unchanged. The lock is keyed per repo, so cross-repo
492
+ // integration stays fully concurrent.
493
+ const runRebaseToIntegrateTail = async () => {
494
+ // The step-4 rebase-onto-`<arbiter>/main` (with BOTH reconciliation arms: the
495
+ // sibling-slug ledger arm and the divergent-done-move recovery), factored so it
496
+ // can run ONCE before the gate AND be RE-RUN in the Race-1 merge-push retry loop
497
+ // (a sibling advancing main mid-push needs the SAME reconcile, not a bare
498
+ // rebase). Returns `{}` on a clean rebase (fall through to gate/integrate) or
499
+ // `{route}` when a genuine conflict / invariant violation must stop the tail.
500
+ const rebaseOntoMainWithReconcile = async () => {
501
+ // 4. Rebase-before-integrate (ADR §10): rebase the work branch onto the
502
+ // latest <arbiter>/main. Clean → continue. Conflict → abort + stop.
503
+ //
504
+ // RECOVERY reconciliation: when completing FROM needs-attention/, the work
505
+ // branch's history still carries the original `in-progress → needs-attention`
506
+ // MOVE-ONLY commit, and `<arbiter>/main` was SURFACED with that same move
507
+ // (the item is in needs-attention/ on main). Replaying that historical move
508
+ // onto main conflicts (main has no in-progress/<slug>.md) — exactly the
509
+ // rebase conflict the human hit doing this by hand. So we DROP that move-only
510
+ // commit during the rebase: the replay becomes `wip + (needs-attention →
511
+ // done)`, which applies cleanly onto the surfaced main (it HAS the item in
512
+ // needs-attention/). The done-move thus SUPERSEDES the surfaced state — no
513
+ // leftover/conflicting on-`main` surface for the human to resolve.
514
+ // Fetch the arbiter's `main` into the `<arbiter>/main` remote-tracking ref
515
+ // EXPLICITLY. A `run` JOB WORKTREE is cut from a bare hub mirror whose remote
516
+ // has no fetch refspec (so `<arbiter>/main` would not otherwise resolve / would
517
+ // be stale, causing a spurious rebase conflict); a regular clone (`do`/
518
+ // `complete`) already has it, where the explicit refspec is harmless (the same
519
+ // refspec `rebaseOntoArbiterMain` used before the convergence).
520
+ await gitHard([
521
+ 'fetch',
522
+ '--quiet',
523
+ arbiter,
524
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
525
+ ], cwd, env);
526
+ // ONE-SLUG-ONE-FOLDER guard + divergent-base PRE-CHECK, read from the
527
+ // freshly-fetched `<arbiter>/main` (a READ of the tracking ref the fetch above
528
+ // just populated — NO new fetch, so no shared-mirror race). It (a) FAILS LOUD if
529
+ // the arbiter already holds the slug in >1 status folder with differing content
530
+ // (a corrupt ledger; never publish over it), and (b) detects the DIVERGENT base
531
+ // — the arbiter holds the slug's source in a DIFFERENT folder than our local
532
+ // done-move removed — which is the case that turns the rebased "move" into a
533
+ // "copy" (PR #86). The tasking lifecycle is exempt (its move is not a task
534
+ // done-move).
535
+ // CONTINUE-BUILD EXEMPTION (task
536
+ // `complete-builds-on-already-done-moved-continue`): on `source: 'done'`
537
+ // there was no first-time move on this commit (the slug was already in
538
+ // `done/` on the kept branch + on the arbiter), so the divergent-done-move
539
+ // reconcile reasoning does not apply. Skip the arbiter ledger placement
540
+ // pre-check too: it is structurally an instrument FOR that reconcile, and a
541
+ // continue-build is by construction already in `done/` on both sides.
542
+ if (!lifecycle && source !== 'done') {
543
+ const arbiterPlacement = readArbiterLedgerPlacement(cwd, arbiter, slug, env);
544
+ if (arbiterPlacement.error) {
545
+ note(arbiterPlacement.error);
546
+ return {
547
+ route: {
548
+ outcome: 'invariant-violation',
549
+ routedToNeedsAttention: false,
550
+ branch,
551
+ reason: arbiterPlacement.error,
552
+ },
553
+ };
554
+ }
555
+ }
556
+ // PLAIN rebase. After the per-item-lock cut-over (prd
557
+ // `ledger-status-per-item-lock-refs`, tasks 9a–9d) no transient status
558
+ // lands on a work branch: needs-attention is the lock `state: stuck` (not a
559
+ // `git mv` to `needs-attention/`), the body rests in `backlog/` while
560
+ // claimed, and the tasking/advancing markers are gone. So a recovery
561
+ // complete's kept branch carries NO historical route-to-needs-attention
562
+ // move-only commit to drop — the old `rebaseDroppingNeedsAttentionSurface`
563
+ // (drop-bookkeeping-rebase) is deleted and BOTH recovering and lifecycle
564
+ // rebases are the same plain replay onto `<arbiter>/main`.
565
+ void recovering;
566
+ void lifecycle;
567
+ // RENAME-DETECTION-OFF (task
568
+ // `disable-rename-detection-on-continue-rebase`): scope
569
+ // `-c merge.directoryRenames=false` to THIS rebase invocation, so a single
570
+ // durable folder-transition `git mv` out of a SPARSE work/<from>/ folder is
571
+ // NOT misread by git's directory-rename heuristic as a whole-DIRECTORY
572
+ // rename `work/<from>/ → work/<to>/` (which would spuriously flag every
573
+ // sibling file `<arbiter>/main` added into that folder as `CONFLICT (file
574
+ // location)` and force a FALSE needs-attention). Content-rename detection
575
+ // (`-Xno-renames`/`merge.renames`/`diff.renames`) is the wrong knob and
576
+ // does NOT suppress this directory-rename conflict; only
577
+ // `merge.directoryRenames=false` does. NEVER a persistent `git config`
578
+ // write — the repo's config stays clean so a user's interactive
579
+ // `git rebase` is unaffected. A GENUINE content conflict still surfaces
580
+ // and still routes via `rebaseConflictRoute()` below.
581
+ const rebase = await gitSoft(['-c', 'merge.directoryRenames=false', 'rebase', `${arbiter}/main`], cwd, env);
582
+ if (rebase.status !== 0) {
583
+ // NEVER auto-resolve a genuine CODE conflict. But FIRST, a SIBLING-SLUG
584
+ // LEDGER conflict (the replay conflicts ONLY on OTHER slugs'
585
+ // `work/<status>/<otherslug>.md` ledger files — a sibling job landed its own
586
+ // status-folder move on `<arbiter>/main` between our base and this rebase) is
587
+ // a benign ledger-only divergence with NO semantic judgement: the reconcile
588
+ // ABORTS the rebase, then redoes OUR work as one clean commit on top of
589
+ // `<arbiter>/main` (taking the arbiter's version of every sibling ledger file
590
+ // automatically). It is scoped STRICTLY to other slugs' ledger files — a
591
+ // conflict touching ANY code file, or THIS slug's own ledger, returns `false`
592
+ // (and leaves the rebase in progress), so the divergent-done-move recovery /
593
+ // needs-attention route below handles it; it NEVER widens to code. The tasking
594
+ // lifecycle is exempt (its move is not a task done-move).
595
+ const siblingReconciled = lifecycle
596
+ ? false
597
+ : await reconcileSiblingLedgerConflict({
598
+ cwd,
599
+ arbiter,
600
+ slug,
601
+ env,
602
+ note,
603
+ });
604
+ if (siblingReconciled) {
605
+ // The branch is now cleanly on top of `<arbiter>/main` with OUR work + the
606
+ // arbiter's sibling-ledger files — fall through to the fresh-gate + integrate
607
+ // band. THIS slug's own move is untouched.
608
+ note(`Reconciled a sibling-slug ledger conflict during the rebase onto ` +
609
+ `${arbiter}/main (took the arbiter's version of the other slugs' ` +
610
+ `work/<status>/<slug>.md ledger files; no code file was touched).`);
611
+ }
612
+ else if (!lifecycle && source !== 'done') {
613
+ // NEVER auto-resolve a genuine CODE conflict: abort the rebase. But FIRST, a
614
+ // DIVERGENT-LEDGER conflict (the arbiter holds the slug's source in a folder
615
+ // our local done-move did not remove — PR #86) is auto-RECONCILABLE without
616
+ // any semantic judgement: redo the done-move arbiter-resolved (remove the
617
+ // arbiter's actual source folder, add `done/`) on top of `<arbiter>/main`. We
618
+ // only do this when the post-abort tree's ONLY divergence is the slug's ledger
619
+ // file; a real code conflict still routes to needs-attention untouched.
620
+ await gitSoft(['rebase', '--abort'], cwd, env);
621
+ const recovered = await reconcileDivergentDoneMove({
622
+ cwd,
623
+ arbiter,
624
+ slug,
625
+ branch,
626
+ localSource: source,
627
+ env,
628
+ note,
629
+ });
630
+ if (recovered) {
631
+ // The branch is now cleanly on top of `<arbiter>/main` with the slug in
632
+ // `done/` ONLY — fall through to integrate (skip the needs-attention
633
+ // route below).
634
+ note(`Reconciled the done-move against ${arbiter}/main: '${slug}' is in ` +
635
+ 'work/done/ ONLY (the divergent source folder was removed; the move ' +
636
+ 'is a move, not a copy).');
637
+ }
638
+ else {
639
+ return { route: await rebaseConflictRoute() };
640
+ }
641
+ }
642
+ else {
643
+ await gitSoft(['rebase', '--abort'], cwd, env);
644
+ return { route: await rebaseConflictRoute() };
645
+ }
646
+ }
647
+ // Clean rebase (or a reconciled one): fall through to the gate + integrate.
648
+ return {};
649
+ };
650
+ // Run the step-4 rebase ONCE up front (before the slow fresh gate).
651
+ const firstRebase = await rebaseOntoMainWithReconcile();
652
+ if (firstRebase.route) {
653
+ return firstRebase.route;
654
+ }
655
+ // The rebase-conflict needs-attention route, factored so the divergent-ledger
656
+ // recovery above can fall through to integrate while a genuine code conflict
657
+ // still routes here.
658
+ async function rebaseConflictRoute() {
659
+ // Then route the item to needs-attention/ with the conflict reason (ADR
660
+ // §12) THROUGH the ledger write seam's needs-attention transition, rather
661
+ // than leaving it dangling in done/. The done-move was already committed
662
+ // above, so the item sits in work/done/; the move bounces it from there and
663
+ // commits the in-progress→needs-attention move (here done→needs-attention)
664
+ // as ONE transition. No partial state.
665
+ const reason = `rebase onto ${arbiter}/main conflicted (aborted, never auto-resolved)`;
666
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
667
+ cwd,
668
+ slug,
669
+ reason,
670
+ // Autonomous caller (`do`) passes the arbiter so the seam both surfaces the
671
+ // conflict on `main` (OBSERVABLE) AND pushes the `work/<slug>` branch
672
+ // (RECOVERABLE, cross-machine). The human `complete` leaves it unset →
673
+ // no surface, no push, local-only. The push lives in the seam (HEAD's
674
+ // branch is `work/<slug>` here) — no bolted-on push.
675
+ arbiter: input.surfaceArbiter,
676
+ env,
677
+ note,
678
+ });
679
+ return {
680
+ outcome: 'rebase-conflict',
681
+ routedToNeedsAttention: routed.moved,
682
+ branch,
683
+ commitMessage,
684
+ reason: routed.moved
685
+ ? `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
686
+ `aborted (never auto-resolved) and '${slug}' was routed to ` +
687
+ 'work/needs-attention/ (surfaced by status). Resolve against the ' +
688
+ 'latest main, then return it to backlog/ and re-run.'
689
+ : `Rebasing ${branch} onto ${arbiter}/main conflicted; the rebase was ` +
690
+ 'aborted (never auto-resolved). Resolve against the latest main, ' +
691
+ 'then re-run complete.',
692
+ };
693
+ }
694
+ // 4c. FRESH-WORKTREE GATE (task `gate-on-rebased-tip-fresh-worktree`): when ON,
695
+ // the acceptance gate (`prepare` then `verify`) runs HERE — on the work
696
+ // branch tip the rebase above just produced (the would-be-integrated tip) —
697
+ // rather than on the agent's pre-rebase `cwd`. We cut a CLEAN throwaway
698
+ // worktree from `HEAD` (the rebased committed tip), `prepare` then `verify`
699
+ // in it, REAP it (pass or fail), and only on GREEN fall through to integrate.
700
+ // A gitignored/uncommitted file in `cwd` cannot leak into this gate (the
701
+ // worktree is cut from the committed, rebased tip), and a change the
702
+ // integration rebase introduced IS gated. A red gate routes the item the SAME
703
+ // way the front gate did — EXCEPT the done-move already happened (steps 2–3),
704
+ // so the bounce is from `work/done/` (the seam finds the slug wherever it
705
+ // rests) instead of `work/in-progress/`. A `--skip-verify` skipped the gate
706
+ // entirely at the front, so it never reaches here. The tasking `lifecycle`
707
+ // path is exempt (its quality engine is the tasker loop, not this gate).
708
+ if (freshWorktreeGate && !input.skipVerify && !lifecycle) {
709
+ const tip = (await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)).stdout.trim();
710
+ const gated = await runFreshWorktreeGate({
711
+ cwd,
712
+ commit: tip,
713
+ prepare: input.prepare,
714
+ verify: input.verify,
715
+ env,
716
+ note,
717
+ // GATE-2 REVIEW relocation (MAINTAINER DECISION 2): when `review` is ON,
718
+ // the fresh gate runs it AFTER the rebased-tip verify, against the rebased
719
+ // tip (the gate worktree) — so verify-THEN-review holds on the SAME merged
720
+ // tree. The needs-attention ROUTING still targets `cwd` (the work branch +
721
+ // ledger), so the verdict handling is identical to the OFF path.
722
+ review: input.review
723
+ ? (reviewCwd) => runGate2Review({
724
+ reviewCwd,
725
+ input,
726
+ slug,
727
+ branch,
728
+ cwd,
729
+ env,
730
+ note,
731
+ })
732
+ : undefined,
733
+ });
734
+ if (!gated.passed) {
735
+ // prepare-failed or gate-failed on the rebased tip: route the item to
736
+ // needs-attention through the SAME seam the front gate / rebase-conflict use.
737
+ // The done-move was already committed (steps 2–3), so the slug sits in
738
+ // work/done/; the seam bounces it from there (done → needs-attention). The
739
+ // recovery path keeps it where it is (no re-route) exactly like the front gate.
740
+ const outcome = gated.kind === 'prepare' ? 'prepare-failed' : 'gate-failed';
741
+ const what = gated.kind === 'prepare'
742
+ ? `Env-prep (prepare) failed (exit ${gated.exitCode})`
743
+ : `Acceptance gate failed (exit ${gated.exitCode})`;
744
+ const reason = gated.kind === 'prepare'
745
+ ? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
746
+ : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
747
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
748
+ cwd,
749
+ slug,
750
+ reason,
751
+ arbiter: input.surfaceArbiter,
752
+ env,
753
+ note,
754
+ });
755
+ return {
756
+ outcome,
757
+ routedToNeedsAttention: routed.moved,
758
+ branch,
759
+ commitMessage,
760
+ reason: routed.moved
761
+ ? `${what} on the rebased tip; routed '${slug}' to ` +
762
+ 'work/needs-attention/ (surfaced by status; return to backlog/ ' +
763
+ 'once resolved). Fix the work, or use --skip-verify to override.'
764
+ : `${what} on the rebased tip; not completing '${slug}'. Fix the ` +
765
+ 'work, or use --skip-verify to override.',
766
+ };
767
+ }
768
+ // GATE-2 REVIEW outcome on the rebased tip (MAINTAINER DECISION 2): the
769
+ // rebased-tip verify PASSED, so the review ran AFTER it (verify-then-review on
770
+ // the merged tree). Route a BLOCK exactly as the OFF-path front review does
771
+ // (only the reviewed tree moved, not the routing). On APPROVE: carry the
772
+ // verdict (post-integrate PR comment), write the per-run non-blocking-nits
773
+ // observation. A resolved `merge` then lands automatically; `propose` leaves
774
+ // the merge to a human.
775
+ if (gated.review) {
776
+ if (gated.review.kind === 'blocked') {
777
+ // The done-move was already committed (steps 2–3), so the slug sits in
778
+ // work/done/; the routing bounced it from there to needs-attention/.
779
+ return gated.review.result;
780
+ }
781
+ approvedVerdict = gated.review.verdict;
782
+ // The done-move + atomic commit already happened (steps 2–3, before this
783
+ // rebased-tip gate), so — unlike the OFF path where the nits write rides the
784
+ // upcoming commit — we write the observation into `cwd` and FOLD it into the
785
+ // existing done-commit via `commit --amend` (the branch is not yet
786
+ // integrated). The observation still lands in the SAME done-commit that
787
+ // integrates, preserving the no-separate-commit/surface model.
788
+ const nitsBefore = await stagedCaptureNotes(cwd, env);
789
+ writeReviewNitsObservation({
790
+ cwd,
791
+ slug,
792
+ findings: gated.review.verdict?.findings ?? [],
793
+ note,
794
+ });
795
+ // Only amend when the write actually produced a new staged file (zero
796
+ // non-blocking findings ⇒ no write ⇒ no amend, the done-commit unchanged).
797
+ await gitHard(['add', '-A'], cwd, env);
798
+ if (!(await nothingStaged(cwd, env))) {
799
+ const nitsAfter = await stagedCaptureNotes(cwd, env);
800
+ if (nitsAfter.length > nitsBefore.length) {
801
+ await gitHard(['commit', '-q', '--amend', '--no-edit'], cwd, env);
802
+ }
803
+ }
804
+ }
805
+ }
806
+ // 5. Integrate per mode through the ledger write seam's COMPLETE transition
807
+ // (ADR §6 + `docs/adr/claim-ledger-vs-protected-main.md`). The rebase above
808
+ // already brought the branch up to date, so the seam's sole strategy uses
809
+ // `integrate` (not `integrateWithRebase`) and never --forces. Provider
810
+ // selection: an injected `openPr` wins (legacy bridge); otherwise pick by
811
+ // the arbiter's remote URL (a GitHub remote ⇒ `gh pr create`, else push-only
812
+ // `none`) — PURELY arbiter-derived, no override axis. A missing/unauthenticated
813
+ // `gh` degrades to push-only at runtime — never a hard failure (and the
814
+ // start-of-run unauthed case is caught UP FRONT by the pre-flight `gh` probe).
815
+ // The seam is storage-agnostic: we hand it the work branch, the integration
816
+ // mode, the provider, and the PR-INTENT (`noPR`) — `main` lives only in the
817
+ // strategy.
818
+ // Provider precedence: an injected fully-formed provider wins (the `run`
819
+ // stubbed-provider seam, carrying title/body/url); else the legacy `openPr`
820
+ // bridge; else select PURELY from the arbiter URL (no override). The orthogonal
821
+ // `noPR` INTENT (suppress the PR) is threaded SEPARATELY — it does NOT pick a
822
+ // provider, the integrator simply skips `openRequest` when it is set.
823
+ const provider = input.providerInstance ??
824
+ (input.openPr
825
+ ? bridgeProvider(input.openPr)
826
+ : selectProvider({
827
+ arbiterUrl: await arbiterUrl(cwd, arbiter, env),
828
+ }));
829
+ // Race-1 (claim-vs-integrate, task
830
+ // `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`): integrate,
831
+ // and on a non-fast-forward `${branch}:main` push (a SIBLING same-repo CLAIM —
832
+ // under the SEPARATE claim lock — or a sibling integrate advanced
833
+ // `<arbiter>/main` during our push window) RE-RUN the step-4 rebase (which
834
+ // carries the sibling-ledger + divergent-done-move reconcile arms — a bare
835
+ // re-rebase would MISS them) and RETRY the push, up to a small cap. INSTANT
836
+ // retry (contention, not an outage; see `claim-cas.ts`). We NEVER `--force`
837
+ // main: each retry re-rebases to a clean fast-forward. A genuine code conflict
838
+ // on a re-rebase routes to needs-attention via the SAME `route` the up-front
839
+ // rebase uses. A persistent non-fast-forward past the cap also routes (never a
840
+ // silent drop). `input.mergeRetries` overrides the cap (tests; `0` ⇒ no retry).
841
+ //
842
+ // **C2 rebase-until-real-conflict (task `c2-rebase-until-real-on-durable-main-
843
+ // promotions`):** the loop's TERMINATION CHANGED. A CLEAN re-rebase no longer
844
+ // counts against a tiny give-up budget — only a GENUINE conflict surfaced by
845
+ // `rebaseOntoMainWithReconcile` (a `route` ⇒ `rebase-conflict` or
846
+ // `invariant-violation`) stops the loop. The step-4 rebase IS the source-folder
847
+ // precondition recheck reused verbatim: if the slug is GONE from its expected
848
+ // source folder on the new `main` (a concurrent legitimate same-item winner
849
+ // already moved it), the `git mv` replay fails and `rebaseConflictRoute` routes
850
+ // definitively — never a silent re-push that would clobber the winner. The
851
+ // `maxMergeRetries` cap survives ONLY as a large liveness ceiling on the
852
+ // pathological livelock tail (default {@link DEFAULT_MERGE_RETRIES} = 1000);
853
+ // modest jitter on the refetch desynchronises a herd so the tail is not
854
+ // reached under sustained parallel load.
855
+ const maxMergeRetries = input.mergeRetries ?? DEFAULT_MERGE_RETRIES;
856
+ const mergeJitterMs = input.mergeJitterMs ?? DEFAULT_MERGE_JITTER_MS;
857
+ let integration;
858
+ for (let mergeAttempt = 0;; mergeAttempt++) {
859
+ integration = await ledgerWrite.applyCompleteTransition({
860
+ arbiter,
861
+ branch,
862
+ mode,
863
+ provider,
864
+ // PR-INTENT: when set (propose mode), push the branch but skip the PR.
865
+ noPR: input.noPR,
866
+ // Half A: an explicit single-line PR title (propose mode), so `gh` no longer
867
+ // derives a run-on title from the commit subject via `--fill`.
868
+ title: prTitle,
869
+ // Half B: the propose-mode PR body — the agent's summary under a deterministic
870
+ // runner header (task pointer). Undefined when no body was supplied (the
871
+ // header is only scaffolded when there IS a body) ⇒ today's `--fill` (no
872
+ // regression). Ignored in merge mode by the provider/integrator.
873
+ body: composeProposeBody({ slug, body: input.body }),
874
+ // Part (b) of the merged-branch hygiene task: when WE perform the merge
875
+ // (this resolved `merge` mode), reap the remote `work/<slug>` HEAD branch
876
+ // INLINE right after the merge lands — the commits are now on `main`, so the
877
+ // head is provably merged and safe to delete (ancestor-guarded inside the
878
+ // integrator). Idempotent no-op when no remote head exists (the plain
879
+ // `${branch}:main` push opened none); ignored in `propose` mode (its branch is
880
+ // the review surface, reaped later by `gc --remote-branches`). NEVER `--force`.
881
+ deleteMergedHead: true,
882
+ cwd,
883
+ env,
884
+ });
885
+ // Only the merge push can be non-fast-forward (propose pushes its own ref).
886
+ if (integration.mergeNonFastForward !== true) {
887
+ break;
888
+ }
889
+ if (mergeAttempt >= maxMergeRetries) {
890
+ // LIVENESS CEILING hit (default 1000; previously the small Race-1 cap of 5,
891
+ // now reinterpreted by C2 as the pathological-livelock-tail bound, NOT a
892
+ // false-contention budget). Route to needs-attention rather than looping
893
+ // forever or force-pushing main. A RARE outcome under realistic load thanks
894
+ // to the jitter below + the rebase-until-real-conflict semantics; tests
895
+ // reach it deterministically by injecting a small `mergeRetries`.
896
+ return await mergeNonFastForwardRoute(`integrating ${branch} onto ${arbiter}/main kept hitting a ` +
897
+ `non-fast-forward push (a sibling advanced main ${mergeAttempt + 1} ` +
898
+ `times); gave up cleanly without --force`);
899
+ }
900
+ // Modest jitter on the refetch (C2): an instant lockstep refetch→re-push loop
901
+ // maximises mutual rejection under sustained parallel load (thundering herd).
902
+ // A uniformly-random `[0, mergeJitterMs]` ms sleep desynchronises the herd.
903
+ // Skipped when `mergeJitterMs === 0` (the test seam).
904
+ if (mergeJitterMs > 0) {
905
+ await sleepMs(Math.floor(Math.random() * (mergeJitterMs + 1)));
906
+ }
907
+ // A sibling advanced main: re-run the step-4 rebase (with the reconcile arms)
908
+ // before retrying the push. A genuine conflict on the re-rebase routes via
909
+ // `rebaseOntoMainWithReconcile`'s `route` (the existing source-folder /
910
+ // one-slug placement recheck IS the genuine-conflict terminator — see the
911
+ // `DEFAULT_MERGE_RETRIES` docstring for the C2 SCOPE box). A clean re-rebase
912
+ // (no route) loops without counting against a small budget.
913
+ const reRebase = await rebaseOntoMainWithReconcile();
914
+ if (reRebase.route) {
915
+ return reRebase.route;
916
+ }
917
+ }
918
+ // The Race-1 needs-attention route for a merge that could not land (a genuine
919
+ // re-rebase conflict is handled by `rebaseOntoMainWithReconcile`'s `route`; this
920
+ // covers the cap-exhausted persistent-contention case). Mirrors
921
+ // `rebaseConflictRoute`: the done-move was already committed (steps 2–3), so the
922
+ // slug sits in work/done/ and the seam bounces it from there.
923
+ async function mergeNonFastForwardRoute(reason) {
924
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
925
+ cwd,
926
+ slug,
927
+ reason,
928
+ arbiter: input.surfaceArbiter,
929
+ env,
930
+ note,
931
+ });
932
+ return {
933
+ outcome: 'rebase-conflict',
934
+ routedToNeedsAttention: routed.moved,
935
+ branch,
936
+ commitMessage,
937
+ reason: routed.moved
938
+ ? `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
939
+ `non-fast-forward push (a sibling advanced main); '${slug}' was routed ` +
940
+ `to work/needs-attention/ (surfaced by status). Resolve against the ` +
941
+ `latest main, then return it to backlog/ and re-run.`
942
+ : `Integrating ${branch} onto ${arbiter}/main kept hitting a ` +
943
+ `non-fast-forward push (a sibling advanced main). Resolve against the ` +
944
+ `latest main, then re-run complete.`,
945
+ };
946
+ }
947
+ // 6. Make the Gate-2 review VISIBLE on the PR (task `review-comment-prose-field`,
948
+ // refining `review-gate-pr-comment`): AFTER the propose integrate, where the
949
+ // approved verdict (with its deliberately-authored `review` prose), the
950
+ // resolved `provider`, AND the opened PR url (`integration.url`) are ALL in
951
+ // scope, post `verdict.review` as a comment on that PR — INCLUDING on approve
952
+ // (the audit trail; decided 2026-06-06). The `review` field is a first-class
953
+ // AUTHORED review (the prompt requires it), NOT the residue around the JSON
954
+ // — posting the residue was the bug
955
+ // (`work/findings/review-comment-posts-agent-thinking-not-a-review.md`). It
956
+ // reuses the SAME `provider` the integrate used (the core never imports `gh`).
957
+ // The comment is ADVISORY: it changes no gate/verdict/merge/integration logic
958
+ // — by here the verdict has ALREADY routed (block never reaches this point; it
959
+ // routed to needs-attention above) and the integrate has ALREADY happened.
960
+ // The PR identity is resolved in PRECEDENCE: a parsed `integration.url` wins
961
+ // (the normal path — post on it directly); else, when a PR WAS opened but its
962
+ // url was unparseable (`integration.requestOpened` true, `url` undefined — the
963
+ // `gh pr create` exit-0-but-unparseable-stdout degradation), FALL BACK to the
964
+ // BRANCH-resolved comment (task `review-comment-fallback-on-unparsed-pr-url`):
965
+ // the provider resolves the branch's open PR and comments on it, instead of
966
+ // silently dropping a review on a PR that genuinely exists. Only when NO PR was
967
+ // opened at all (merge mode, or a degraded/push-only propose ⇒ `requestOpened`
968
+ // false) is it the honest clean no-op — and the branch-resolved fallback's own
969
+ // "no PR resolvable" path is a clean no-op too (it tries first). Either way
970
+ // `postPRComment*` never throws; the review stays in the run output. Because
971
+ // this lives in the shared core, BOTH `do`/`complete` AND `run` post the
972
+ // comment — no per-caller wiring.
973
+ if (approvedVerdict?.review !== undefined) {
974
+ if (integration.url !== undefined) {
975
+ const posted = provider.postPRComment({
976
+ cwd,
977
+ url: integration.url,
978
+ body: approvedVerdict.review,
979
+ env,
980
+ });
981
+ note(posted.instruction);
982
+ }
983
+ else if (integration.requestOpened) {
984
+ // A PR opened but its url was unparseable — resolve it from the branch
985
+ // rather than dropping the review (the audit-trail fallback).
986
+ const posted = provider.postPRCommentOnBranch({
987
+ cwd,
988
+ branch,
989
+ body: approvedVerdict.review,
990
+ env,
991
+ });
992
+ note(posted.instruction);
993
+ }
994
+ }
995
+ return {
996
+ outcome: 'completed',
997
+ routedToNeedsAttention: false,
998
+ branch,
999
+ commitMessage,
1000
+ integration,
1001
+ };
1002
+ };
1003
+ // Serialise ONLY this tail per repo when the `run` seam is wired; absent ⇒ run
1004
+ // it directly (an un-contended no-op, single-job behaviour unchanged).
1005
+ return input.integrateLock && input.integrateLockKey !== undefined
1006
+ ? await input.integrateLock(input.integrateLockKey, runRebaseToIntegrateTail)
1007
+ : await runRebaseToIntegrateTail();
1008
+ }
1009
+ /**
1010
+ * RECOVER an already-committed, already-done-moved STRANDED branch (prd
1011
+ * `ledger-integrity` story 6, the `finish-already-committed-branch` task). The
1012
+ * green work AND the `git mv → work/done/` are ALREADY committed on the work
1013
+ * branch (a terminal push failed AFTER `performIntegration`'s steps 2–3), and the
1014
+ * tip is NOT on the arbiter. This runs ONLY the rebase→integrate TAIL (steps 4–5)
1015
+ * from the kept commit — NO re-done-move, NO re-commit, NO rebuild, NO orphan
1016
+ * branch — reusing the SAME `ledgerWrite.applyCompleteTransition` integrate
1017
+ * primitive the build path uses.
1018
+ *
1019
+ * RE-GATE on the REBASED TIP (task `committed-recovery-honours-fresh-worktree-
1020
+ * gate`, prd `land-time-reverify-and-parallel-merge-ceiling`): when the caller
1021
+ * sets `freshWorktreeGate` (and not `skipVerify`), the EXISTING
1022
+ * `runFreshWorktreeGate` runs on the rebased tip AFTER the rebase loop and
1023
+ * BEFORE `applyCompleteTransition`, mirroring the build path's
1024
+ * `freshWorktreeGate && !skipVerify && !lifecycle` branch — a red gate routes
1025
+ * to needs-attention through the SAME shared seam, never integrates a clean-
1026
+ * rebase-but-broken merge. This is OPT-IN per caller: the original stranded-
1027
+ * recovery caller (`complete --integration`'s already-built strand, whose pre-
1028
+ * strand build already gated) leaves it UNSET and is byte-identical to before —
1029
+ * no extra gate, no extra fetch. The answered-merge apply-rung SETS it because
1030
+ * `<arbiter>/main` may have moved since the branch's last build, so the rebased
1031
+ * tip MUST be re-verified before it lands or the load-bearing invariant
1032
+ * ("main never receives a tree that fails verify") cannot hold on the merge
1033
+ * path.
1034
+ *
1035
+ * SAFETY — UNSPOOFABLE detection: BEFORE acting it fetches `<arbiter>/main` and
1036
+ * checks whether the kept tip is ALREADY reachable there (`isAncestor`, the SAME
1037
+ * predicate `gc.ts` uses). If so the work is already integrated → a clean
1038
+ * `already-integrated` no-op (never a re-push / double-integrate); a re-run after a
1039
+ * successful recovery hits this. Only when the tip is genuinely AHEAD does it
1040
+ * rebase + integrate. A rebase CONFLICT here is a genuine code conflict the human
1041
+ * resolves — the branch is aborted and the outcome is `rebase-conflict` (the kept
1042
+ * commit stays intact on the branch, recoverable), NEVER auto-resolved, NEVER
1043
+ * `--force` to main.
1044
+ */
1045
+ async function recoverAlreadyCommitted(params) {
1046
+ const { cwd, arbiter, slug, branch, mode, env, note } = params;
1047
+ const retries = params.recoveryRebaseRetries ?? DEFAULT_RECOVERY_REBASE_RETRIES;
1048
+ const jitterMs = params.recoveryRebaseJitterMs ?? DEFAULT_RECOVERY_REBASE_JITTER_MS;
1049
+ const sleep = params.recoveryRebaseSleep ?? realSleep;
1050
+ const random = params.recoveryRebaseRandom ?? Math.random;
1051
+ // Helper: the explicit-refspec fetch (the build path's step-4 fetch shape — a
1052
+ // bare-mirror worktree's remote has no fetch refspec, so `<arbiter>/main` would
1053
+ // not otherwise resolve / would be stale). REUSED on EACH attempt (the root cause
1054
+ // of the moving-base race is a stale SINGLE fetch — see the loop below).
1055
+ const refetchMain = async () => {
1056
+ await gitHard([
1057
+ 'fetch',
1058
+ '--quiet',
1059
+ arbiter,
1060
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
1061
+ ], cwd, env);
1062
+ };
1063
+ await refetchMain();
1064
+ const tip = (await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)).stdout.trim();
1065
+ if (tip === '') {
1066
+ throw new Error(`cannot recover '${slug}': HEAD does not resolve (no committed work on ` +
1067
+ `${branch}?).`);
1068
+ }
1069
+ // UNSPOOFABLE detection: the kept tip ALREADY reachable on `<arbiter>/main`
1070
+ // means the work is already integrated — a clean no-op, NEVER a re-integration.
1071
+ // (`isAncestor` is the SAME reachability predicate `gc.ts` uses; do not fork it.)
1072
+ // KEPT before the retry loop: a no-op MUST short-circuit before we burn any
1073
+ // re-fetch/re-rebase budget.
1074
+ if (isAncestor(cwd, tip, `refs/remotes/${arbiter}/main`, env)) {
1075
+ const message = `Nothing to recover for '${slug}': its work branch tip is already on ` +
1076
+ `${arbiter}/main (already integrated). No re-push, no double-integrate.`;
1077
+ note(message);
1078
+ return {
1079
+ outcome: 'already-integrated',
1080
+ routedToNeedsAttention: false,
1081
+ branch,
1082
+ reason: message,
1083
+ };
1084
+ }
1085
+ // The tip is genuinely AHEAD — rebase the kept commit onto the latest
1086
+ // `<arbiter>/main`. A clean rebase continues; a CONFLICT is wrapped in a
1087
+ // bounded CONTENTION loop (task `recovery-rebase-retry-against-moving-arbiter-
1088
+ // main`): on each conflict `--abort`, sleep a small jitter, RE-FETCH
1089
+ // `<arbiter>/main` (it may have advanced — `advance` runs land bursts of
1090
+ // `advance: surface observation:…` commits on main, so a one-shot rebase against
1091
+ // a stale fetched base can conflict against a main that already moved AGAIN),
1092
+ // then re-rebase. Only after the cap exhausts (a freshly-fetched main STILL
1093
+ // conflicts on every attempt) do we surface `rebase-conflict` (never auto-
1094
+ // resolved, NEVER `--force` to main — the kept commit stays on the branch,
1095
+ // recoverable; the human resolves and re-runs).
1096
+ //
1097
+ // This is the CONTENTION model (instant re-fetch+rebuild against the new base,
1098
+ // like `claim-cas.ts` / the Race-1 merge loop above), NOT the OUTAGE model in
1099
+ // `retry-backoff.ts` (exponential temporal backoff for an unreachable remote).
1100
+ // The jitter is a SMALL livelock-breaking SPREAD (two runners that begin
1101
+ // retrying at the same instant must NOT re-fetch/re-rebase in lockstep) — NOT
1102
+ // exponential outage backoff. The `--abort` is unconditional on conflict (never
1103
+ // leave the worktree mid-rebase between attempts).
1104
+ //
1105
+ // RECONCILE ARMS DECISION (this task): the recovery rebase is deliberately
1106
+ // BARE — it does NOT layer the sibling-ledger / divergent-done-move arms the
1107
+ // build path's `rebaseOntoMainWithReconcile()` carries. Reasoning: this tail
1108
+ // integrates a branch whose done-move was ALREADY committed in a prior run, so
1109
+ // there is no first-time slug relocation on THIS commit for the divergent-
1110
+ // done-move reconcile to act on, and a sibling-slug ledger conflict on the
1111
+ // re-fetched main is the same shape it would have hit on the original run (the
1112
+ // recovery is not the place to grow new reconcile semantics).
1113
+ //
1114
+ // RENAME-DETECTION composition (task
1115
+ // `disable-rename-detection-on-continue-rebase`): the rebase carries
1116
+ // `-c merge.directoryRenames=false` SCOPED to the invocation — written as a
1117
+ // small args array so every retry of THIS loop carries it too — so a single
1118
+ // durable folder-transition `git mv` out of a SPARSE source folder is NOT
1119
+ // misread as a whole-DIRECTORY rename and the post-rename heuristic does NOT
1120
+ // flag sibling files `<arbiter>/main` added into that folder as `CONFLICT
1121
+ // (file location)`. Content-rename detection (`-Xno-renames`/`merge.renames`/
1122
+ // `diff.renames`) is the WRONG knob and was verified ineffective for this
1123
+ // directory-rename conflict; only `merge.directoryRenames=false` suppresses
1124
+ // it. NEVER a persistent `git config` write — the repo's config stays clean.
1125
+ // A GENUINE same-path content conflict still surfaces and still routes to
1126
+ // `rebase-conflict` (the user's interactive `git rebase` is unaffected).
1127
+ note(`Recovering '${slug}': rebasing the kept ${branch} onto ${arbiter}/main…`);
1128
+ const rebaseArgs = () => [
1129
+ '-c',
1130
+ 'merge.directoryRenames=false',
1131
+ 'rebase',
1132
+ `${arbiter}/main`,
1133
+ ];
1134
+ let attempt = 0;
1135
+ for (;;) {
1136
+ const rebase = await gitSoft(rebaseArgs(), cwd, env);
1137
+ if (rebase.status === 0) {
1138
+ break; // clean rebase ⇒ fall through to integrate
1139
+ }
1140
+ // ALWAYS abort on conflict — never leave mid-rebase between attempts.
1141
+ await gitSoft(['rebase', '--abort'], cwd, env);
1142
+ if (attempt >= retries) {
1143
+ const message = `Recovering '${slug}': rebasing the kept ${branch} onto ${arbiter}/main ` +
1144
+ `conflicted on every attempt (${attempt + 1} total, against a freshly-` +
1145
+ `fetched ${arbiter}/main each time); the rebase was aborted (never auto-` +
1146
+ 'resolved). The committed work is intact on the branch (recoverable). ' +
1147
+ 'Resolve against the latest main, then re-run.';
1148
+ note(message);
1149
+ return {
1150
+ outcome: 'rebase-conflict',
1151
+ routedToNeedsAttention: false,
1152
+ branch,
1153
+ reason: message,
1154
+ };
1155
+ }
1156
+ // Small livelock-breaking jitter (contention spread, NOT outage backoff).
1157
+ // Sleep happens BEFORE the re-fetch so a sleep-injection in tests can also
1158
+ // drive the timeline (e.g. advance the arbiter between attempts).
1159
+ const delay = jitterMs > 0 ? Math.floor(random() * (jitterMs + 1)) : 0;
1160
+ await sleep(delay);
1161
+ await refetchMain();
1162
+ attempt++;
1163
+ }
1164
+ // FRESH-WORKTREE GATE on the REBASED TIP (task `committed-recovery-honours-
1165
+ // fresh-worktree-gate`, prd `land-time-reverify-and-parallel-merge-ceiling`):
1166
+ // when `freshWorktreeGate` is set (the answered-merge land caller) and not
1167
+ // `--skip-verify`, re-run the acceptance gate on the rebased tip BEFORE we
1168
+ // integrate, mirroring the build path's `freshWorktreeGate && !skipVerify &&
1169
+ // !lifecycle` branch (recovery never carries a lifecycle, so no lifecycle
1170
+ // guard is needed). A green gate ⇒ fall through to integrate exactly as today;
1171
+ // a red gate routes to needs-attention through the SAME shared seam
1172
+ // (`applyNeedsAttentionTransition`) the build path uses — NEVER integrates a
1173
+ // clean-rebase-but-broken merge. With `freshWorktreeGate` UNSET (the original
1174
+ // stranded-recovery caller, whose pre-strand build already gated) this whole
1175
+ // block is skipped and behaviour is byte-identical to before.
1176
+ if (params.freshWorktreeGate && !params.skipVerify) {
1177
+ const tip = (await gitSoft(['rev-parse', '--verify', '--quiet', 'HEAD'], cwd, env)).stdout.trim();
1178
+ // No `review:` callback here: the recovery tail re-verifies an already-
1179
+ // reviewed, already-committed result, so Gate-2 review semantics do not
1180
+ // apply on this path.
1181
+ const gated = await runFreshWorktreeGate({
1182
+ cwd,
1183
+ commit: tip,
1184
+ prepare: params.prepare,
1185
+ verify: params.verify,
1186
+ env,
1187
+ note,
1188
+ });
1189
+ if (!gated.passed) {
1190
+ const outcome = gated.kind === 'prepare' ? 'prepare-failed' : 'gate-failed';
1191
+ const what = gated.kind === 'prepare'
1192
+ ? `Env-prep (prepare) failed (exit ${gated.exitCode})`
1193
+ : `Acceptance gate failed (exit ${gated.exitCode})`;
1194
+ const reason = gated.kind === 'prepare'
1195
+ ? `prepare (env-prep) failed (exit ${gated.exitCode}) on the rebased tip`
1196
+ : `acceptance gate failed (exit ${gated.exitCode}) on the rebased tip`;
1197
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
1198
+ cwd,
1199
+ slug,
1200
+ reason,
1201
+ arbiter: params.surfaceArbiter,
1202
+ env,
1203
+ note,
1204
+ });
1205
+ return {
1206
+ outcome,
1207
+ routedToNeedsAttention: routed.moved,
1208
+ branch,
1209
+ reason: routed.moved
1210
+ ? `${what} on the rebased tip during committed-recovery; routed ` +
1211
+ `'${slug}' to work/needs-attention/ (surfaced by status; return ` +
1212
+ 'to backlog/ once resolved). Fix the work, or use --skip-verify ' +
1213
+ 'to override.'
1214
+ : `${what} on the rebased tip during committed-recovery; not ` +
1215
+ `completing '${slug}'. Fix the work, or use --skip-verify to ` +
1216
+ 'override.',
1217
+ };
1218
+ }
1219
+ }
1220
+ // Integrate the rebased kept commit through the SAME complete-transition
1221
+ // primitive the build path uses (no duplication of the integrate mechanism; the
1222
+ // branch is already rebased so it is the non-rebasing `integrate`, never
1223
+ // `--force`). Provider precedence matches the build path: injected instance >
1224
+ // legacy `openPr` bridge > arbiter-derived selection.
1225
+ const provider = params.providerInstance ??
1226
+ (params.openPr
1227
+ ? bridgeProvider(params.openPr)
1228
+ : selectProvider({ arbiterUrl: await arbiterUrl(cwd, arbiter, env) }));
1229
+ const integration = await ledgerWrite.applyCompleteTransition({
1230
+ arbiter,
1231
+ branch,
1232
+ mode,
1233
+ provider,
1234
+ noPR: params.noPR,
1235
+ deleteMergedHead: true,
1236
+ cwd,
1237
+ env,
1238
+ });
1239
+ note(attempt === 0
1240
+ ? `Recovered '${slug}': integrated the kept commit from ${branch}.`
1241
+ : `Recovered '${slug}': integrated the kept commit from ${branch} ` +
1242
+ `(absorbed a moving ${arbiter}/main across ${attempt} re-fetch+re-` +
1243
+ `rebase attempt${attempt === 1 ? '' : 's'}).`);
1244
+ return {
1245
+ outcome: 'completed',
1246
+ routedToNeedsAttention: false,
1247
+ branch,
1248
+ integration,
1249
+ };
1250
+ }
1251
+ /**
1252
+ * Resolve the work branch the integration runs on: the branch HEAD is currently
1253
+ * on (the caller is ALWAYS on the work branch — the agent built there / the
1254
+ * lifecycle stage wrote there), which carries the namespaced `work/<type>-<slug>`
1255
+ * identity. Falls back to a synthesised `work/task-<slug>` ONLY for a detached
1256
+ * HEAD (a degenerate case the on-branch invariant precludes), so the push target
1257
+ * is always defined.
1258
+ */
1259
+ function resolveWorkBranch(cwd, slug, env) {
1260
+ try {
1261
+ const head = git(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd, {
1262
+ env,
1263
+ }).trim();
1264
+ if (head.startsWith('work/')) {
1265
+ return head;
1266
+ }
1267
+ }
1268
+ catch {
1269
+ // detached HEAD or plumbing failure — fall through to the synthesised default
1270
+ }
1271
+ return workBranchRef('task', slug);
1272
+ }
1273
+ /**
1274
+ * Raised when the atomic completion commit has NOTHING staged (no agent work and
1275
+ * no move) — a deliberate REFUSAL, mapped by `complete`'s try/catch to its
1276
+ * `refused` outcome (preserving its existing message verbatim). Exported so the
1277
+ * caller can `instanceof`-route it.
1278
+ *
1279
+ * Carries the {@link slug} so the autonomous-strand surface in `complete.ts`
1280
+ * (which catches this error in `performComplete`'s outer try/catch, OUTSIDE the
1281
+ * `runComplete` slug scope) can publish the `in-progress/ → needs-attention/`
1282
+ * tree-less move without re-deriving the slug from the error message.
1283
+ */
1284
+ export class IntegrationNothingStaged extends Error {
1285
+ slug;
1286
+ constructor(message, slug) {
1287
+ super(message);
1288
+ this.slug = slug;
1289
+ }
1290
+ }
1291
+ /**
1292
+ * The arbiter's remote URL for `arbiter` in `cwd` (for provider auto-detection),
1293
+ * or `undefined` when it cannot be resolved. Read-only; soft (never throws).
1294
+ * Exported so the PR-INTENT pre-flight guard (`do.ts`) can resolve the arbiter
1295
+ * URL up front to decide whether a GitHub PR is even possible for this run.
1296
+ */
1297
+ export async function arbiterUrl(cwd, arbiter, env) {
1298
+ const res = await gitSoft(['remote', 'get-url', arbiter], cwd, env);
1299
+ if (res.status !== 0) {
1300
+ return undefined;
1301
+ }
1302
+ const url = res.stdout.trim();
1303
+ return url === '' ? undefined : url;
1304
+ }
1305
+ /** Adapt the legacy `openPr` callback into the new ReviewProvider seam. */
1306
+ function bridgeProvider(openPr) {
1307
+ return {
1308
+ name: 'none',
1309
+ async openRequest(req) {
1310
+ openPr({ cwd: req.cwd, branch: req.branch, env: req.env });
1311
+ return {
1312
+ opened: true,
1313
+ instruction: `Opened a review for ${req.branch}.`,
1314
+ };
1315
+ },
1316
+ // The legacy `openPr` bridge has no comment channel (it returns no PR url),
1317
+ // so postPRComment degrades: it never opens a PR url to comment on, so the
1318
+ // in-core poster no-ops anyway. Implemented for the seam, surfacing the text.
1319
+ postPRComment(req) {
1320
+ return {
1321
+ posted: false,
1322
+ instruction: 'The legacy review bridge cannot post a comment; the review:\n' +
1323
+ req.body,
1324
+ };
1325
+ },
1326
+ // Likewise the bridge cannot resolve a PR from a branch — a clean no-op.
1327
+ postPRCommentOnBranch(req) {
1328
+ return {
1329
+ posted: false,
1330
+ instruction: 'The legacy review bridge cannot post a comment; the review:\n' +
1331
+ req.body,
1332
+ };
1333
+ },
1334
+ };
1335
+ }
1336
+ /**
1337
+ * Default commit summary: the task's `title` frontmatter with any leading
1338
+ * `slug — ` (or `slug -`) prefix stripped, so a task titled
1339
+ * "complete — gate, mark done, …" yields "gate, mark done, …". Falls back to a
1340
+ * generic summary when the title is missing/unreadable.
1341
+ */
1342
+ function defaultSummary(inProgressPath, slug) {
1343
+ let title;
1344
+ try {
1345
+ title = readTitle(readFileSync(inProgressPath, 'utf8'));
1346
+ }
1347
+ catch {
1348
+ title = undefined;
1349
+ }
1350
+ return summaryFromTitle(title, slug);
1351
+ }
1352
+ /**
1353
+ * The PURE commit-summary derivation from a (possibly absent) item title — the
1354
+ * shared core of {@link defaultSummary} (file-read path) and the lifecycle's
1355
+ * EXPLICIT-title path (intake, whose output file is not written until {@link
1356
+ * IntegrationLifecycle.stage}, AFTER the title read). Strips a leading `slug — `
1357
+ * prefix; falls back to the generic summary when the title is missing/empty.
1358
+ */
1359
+ function summaryFromTitle(title, slug) {
1360
+ if (!title) {
1361
+ return 'complete work task';
1362
+ }
1363
+ // Strip a leading "slug" followed by an em-dash / en-dash / hyphen separator.
1364
+ const prefix = new RegExp(`^${escapeRegExp(slug)}\\s*[—–-]\\s*`, 'i');
1365
+ return title.replace(prefix, '').trim() || title;
1366
+ }
1367
+ /**
1368
+ * The sane single-line cap for a synthesised PR title (Half A). GitHub itself
1369
+ * accepts long titles, but a PR list/notification truncates ugly past ~72 chars;
1370
+ * we cap to keep the title scannable and guarantee it is never a run-on. Beyond
1371
+ * the cap we truncate and append an ellipsis (counted within the cap).
1372
+ */
1373
+ export const PR_TITLE_MAX = 72;
1374
+ /**
1375
+ * The task's raw `title:` frontmatter (NOT the commit-summary-stripped form),
1376
+ * or undefined when missing/unreadable. Used as the human-authored source for
1377
+ * the synthesised PR title.
1378
+ */
1379
+ function readTaskTitle(taskPath) {
1380
+ try {
1381
+ return readTitle(readFileSync(taskPath, 'utf8'));
1382
+ }
1383
+ catch {
1384
+ return undefined;
1385
+ }
1386
+ }
1387
+ /**
1388
+ * Synthesise the propose-mode PR TITLE runner-side (Half A) from data the runner
1389
+ * already has — NO agent text: `<type>(<slug>): <title>`, reusing the `--type`
1390
+ * convention (default `feat`). It is FORCED to a single line (newlines → spaces,
1391
+ * runs of whitespace collapsed) and CAPPED to {@link PR_TITLE_MAX} (truncating
1392
+ * with a trailing `…`), so it can NEVER be the multi-line run-on `gh ... --fill`
1393
+ * derives from the commit subject. When the task `title:` is missing it falls
1394
+ * back to the slug alone (`<type>(<slug>)`). Exported for unit tests of the
1395
+ * single-line + cap guarantee.
1396
+ */
1397
+ export function synthesiseProposeTitle(input) {
1398
+ const type = input.type.trim() || DEFAULT_TYPE;
1399
+ // Strip a leading `slug — ` / `slug -` prefix (some task titles repeat the
1400
+ // slug; the `<slug>` scope already carries it) and flatten to one line.
1401
+ const prefix = new RegExp(`^${escapeRegExp(input.slug)}\\s*[—–-]\\s*`, 'i');
1402
+ const cleanTitle = (input.title ?? '')
1403
+ .replace(prefix, '')
1404
+ .replace(/\s+/g, ' ')
1405
+ .trim();
1406
+ const composed = cleanTitle === ''
1407
+ ? `${type}(${input.slug})`
1408
+ : `${type}(${input.slug}): ${cleanTitle}`;
1409
+ if (composed.length <= PR_TITLE_MAX) {
1410
+ return composed;
1411
+ }
1412
+ // Cap, reserving one char for the ellipsis (counted within the cap).
1413
+ return composed.slice(0, PR_TITLE_MAX - 1).trimEnd() + '…';
1414
+ }
1415
+ /**
1416
+ * Compose the propose-mode PR BODY (Half B): the supplied advisory prose (the
1417
+ * build agent's final summary, or a human `--body`) UNDER a deterministic runner
1418
+ * header that points a reviewer back to the task file. Returns `undefined` when
1419
+ * no body was supplied — so the provider degrades to today's `gh ... --fill` (no
1420
+ * regression); the header is ONLY scaffolded when there IS prose to carry.
1421
+ * Exported for unit tests of the header + pointer.
1422
+ */
1423
+ export function composeProposeBody(input) {
1424
+ const prose = input.body?.trim();
1425
+ if (!prose) {
1426
+ return undefined;
1427
+ }
1428
+ const header = `Task: \`${workItemRel('done', `${input.slug}.md`)}\``;
1429
+ return `${header}\n\n${prose}`;
1430
+ }
1431
+ /**
1432
+ * On a review APPROVE that carries ≥1 NON-BLOCKING finding, write ONE per-run
1433
+ * observation `work/notes/observations/review-nits-<slug>-<YYYY-MM-DD>.md` capturing all
1434
+ * of this run's non-blocking nits, so they get a durable, contract-native home
1435
+ * instead of evaporating (the block path already routes BLOCKING findings to
1436
+ * needs-attention/; the approve path dropped non-blocking ones — see
1437
+ * `work/findings/review-nonblocking-findings-disposition.md`).
1438
+ *
1439
+ * The RUNNER writes it (the review agent stays write-free). It is a PLAIN
1440
+ * pre-commit disk write — NOT the heavier `applyNeedsAttentionTransition`
1441
+ * move/commit/surface — so `performIntegration`'s subsequent done-move + atomic
1442
+ * `git add -A` commit sweeps it into the SAME done-commit on every path
1443
+ * (merge / propose / CI, `do` AND `run`); it is never left dangling/uncommitted.
1444
+ *
1445
+ * ZERO non-blocking findings ⇒ writes NOTHING (no empty-file spam). The file is
1446
+ * ONE-per-RUN (a content-derived, dated name), never an append to a shared ledger
1447
+ * — the dated `<slug>-<date>` name makes a later-abandoned run's nit-observation
1448
+ * trivially findable + deletable (lifecycle hygiene). Frontmatter mirrors the
1449
+ * `work/notes/observations/*.md` convention (`title` / `date` / `status: open`) plus a
1450
+ * `reviewOf:` back-pointer to the slug it came from, so it gets triaged like any
1451
+ * observation. (Identity stays the FILENAME — no `slug:` frontmatter — so the
1452
+ * lifecycle enumerate→resolve round-trip is total; see task
1453
+ * `observation-identity-is-its-filename-not-a-foreign-slug`.)
1454
+ */
1455
+ function writeReviewNitsObservation(params) {
1456
+ const nits = params.findings.filter((f) => f.severity === 'non-blocking');
1457
+ // No empty observations: an approve with zero non-blocking findings writes none.
1458
+ if (nits.length === 0) {
1459
+ return;
1460
+ }
1461
+ const date = observationDate();
1462
+ const obsDir = workFolderPath(params.cwd, 'observations');
1463
+ mkdirSync(obsDir, { recursive: true });
1464
+ const filename = `review-nits-${params.slug}-${date}.md`;
1465
+ writeFileSync(join(obsDir, filename), renderReviewNitsObservation({ slug: params.slug, date, nits }));
1466
+ params.note(`Recorded ${nits.length} non-blocking review nit(s) for '${params.slug}' ` +
1467
+ `in ${workItemRel('observations', filename)}.`);
1468
+ }
1469
+ /** Today's date as `YYYY-MM-DD` (UTC), for the dated observation filename. */
1470
+ function observationDate() {
1471
+ return new Date().toISOString().slice(0, 10);
1472
+ }
1473
+ /**
1474
+ * Render the per-run review-nits observation file body — `observations/`-convention
1475
+ * frontmatter (`title` / `date` / `status: open`) plus a `reviewOf:` back-pointer
1476
+ * naming the TASK the run reviewed, then each non-blocking finding (its
1477
+ * `question` + optional `context`), and a one-line note that these are review-gate
1478
+ * nits for triage (promote-to-task / keep / delete). Exported-free pure string
1479
+ * builder.
1480
+ *
1481
+ * Identity rule (task `observation-identity-is-its-filename-not-a-foreign-slug`):
1482
+ * the observation's IDENTITY is its FILENAME (`review-nits-<slug>-<date>.md`).
1483
+ * The frontmatter therefore does NOT emit `slug:` — emitting the reviewed task's
1484
+ * slug there collided with the (now-done) reviewed task AND broke the
1485
+ * enumerate→resolve round-trip (the lifecycle pool keyed off `fm.slug`, which
1486
+ * differed from the filename). The back-pointer lives in `reviewOf:` instead, a
1487
+ * clearly-different field whose name cannot be mistaken for identity.
1488
+ */
1489
+ function renderReviewNitsObservation(input) {
1490
+ const findingBlocks = input.nits.map((f) => {
1491
+ const ctx = f.context ? `\n (${f.context})` : '';
1492
+ return `- ${f.question}${ctx}`;
1493
+ });
1494
+ return [
1495
+ '---',
1496
+ `title: review-gate non-blocking nits for '${input.slug}' (Gate 2 approve)`,
1497
+ `date: ${input.date}`,
1498
+ 'status: open',
1499
+ `reviewOf: ${input.slug}`,
1500
+ '---',
1501
+ '',
1502
+ '## Non-blocking review findings',
1503
+ '',
1504
+ `The PR/code review gate (Gate 2) APPROVED '${input.slug}' but raised the`,
1505
+ 'following non-blocking findings (nits). They do not block integration; this',
1506
+ 'is their durable home for triage — promote-to-task / keep / delete.',
1507
+ '',
1508
+ ...findingBlocks,
1509
+ '',
1510
+ ].join('\n');
1511
+ }
1512
+ /** Read the `title:` scalar from a task's frontmatter block, or undefined. */
1513
+ function readTitle(content) {
1514
+ const normalized = content.replace(/\r\n/g, '\n').replace(/^\uFEFF/, '');
1515
+ if (!normalized.startsWith('---\n')) {
1516
+ return undefined;
1517
+ }
1518
+ const lines = normalized.split('\n');
1519
+ const closing = lines.indexOf('---', 1);
1520
+ const block = closing === -1 ? lines.slice(1) : lines.slice(1, closing);
1521
+ for (const line of block) {
1522
+ const match = /^title\s*:\s*(.*)$/.exec(line);
1523
+ if (match) {
1524
+ const value = match[1].trim();
1525
+ return value === '' ? undefined : unquote(value);
1526
+ }
1527
+ }
1528
+ return undefined;
1529
+ }
1530
+ function unquote(value) {
1531
+ if (value.length >= 2) {
1532
+ const first = value[0];
1533
+ const last = value[value.length - 1];
1534
+ if ((first === '"' || first === "'") && last === first) {
1535
+ return value.slice(1, -1);
1536
+ }
1537
+ }
1538
+ return value;
1539
+ }
1540
+ function escapeRegExp(value) {
1541
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1542
+ }
1543
+ /** True when the index has no staged changes against HEAD (nothing to commit). */
1544
+ async function nothingStaged(cwd, env) {
1545
+ // `diff --cached --quiet` exits 0 when there is NOTHING staged, 1 when there is.
1546
+ const res = await gitSoft(['diff', '--cached', '--quiet'], cwd, env);
1547
+ return res.status === 0;
1548
+ }
1549
+ /**
1550
+ * The capture-bucket folders a rung's agent may write notes into (its
1551
+ * `capture-signal` reflex). REPORTED by {@link reportScoopedNotes} when the
1552
+ * runner's atomic commit scoops them. Deliberately NARROW (only the two capture
1553
+ * buckets) so accidental scratch files the agent left elsewhere are not announced
1554
+ * as captured signals — matching the observation's recommended scope.
1555
+ */
1556
+ const CAPTURE_NOTE_DIRS = [
1557
+ workFolderPrefix('observations'),
1558
+ workFolderPrefix('findings'),
1559
+ ];
1560
+ /**
1561
+ * SCOOP + REPORT the agent-authored CAPTURED NOTES this run's atomic commit is
1562
+ * landing (task `runner-scoops-captured-notes`). A rung's agent writes
1563
+ * capture-bucket files (`work/notes/observations/*`, `work/findings/*`) but does NO git
1564
+ * (Rule A); the caller's `git add -A` already STAGED them into THIS commit, so
1565
+ * they are tracked, not dropped. This extends Rule B: the runner REPORTS exactly
1566
+ * which note files landed — read from the STAGED set (`git diff --cached`), so it
1567
+ * reports what ACTUALLY reached the commit, never an assumption.
1568
+ *
1569
+ * It is honest reporting, the same model as the review-nits observation report:
1570
+ * a PLAIN read + `note(...)`, no extra git (the commit owns the files). Zero
1571
+ * captured notes ⇒ NOTHING is reported (the no-note case is byte-for-byte
1572
+ * unchanged). Read-only / best-effort: a failed status read reports nothing
1573
+ * rather than crashing the integrate. Because it lives in the shared core, BOTH
1574
+ * the build path AND the tasking path report identically — the channel is not
1575
+ * forked.
1576
+ */
1577
+ async function reportScoopedNotes(cwd, env, note) {
1578
+ const notes = await stagedCaptureNotes(cwd, env);
1579
+ if (notes.length === 0) {
1580
+ return;
1581
+ }
1582
+ note(`Scooped ${notes.length} agent-authored captured note` +
1583
+ `${notes.length === 1 ? '' : 's'} into this commit: ${notes.join(', ')}.`);
1584
+ }
1585
+ /**
1586
+ * The repo-relative paths of the capture-bucket files (`work/notes/observations/*`,
1587
+ * `work/findings/*`) STAGED for THIS commit — i.e. new-or-changed vs HEAD, exactly
1588
+ * what the runner is about to land. Read via `git diff --cached --name-only` so it
1589
+ * reflects the real staged set (`git add -A` already ran), filtered to the capture
1590
+ * buckets and sorted for a deterministic report. Best-effort: a non-zero status
1591
+ * reads as no notes (never crashes the integrate). Exported-free; pure read.
1592
+ */
1593
+ async function stagedCaptureNotes(cwd, env) {
1594
+ const res = await gitSoft(['diff', '--cached', '--name-only'], cwd, env);
1595
+ if (res.status !== 0) {
1596
+ return [];
1597
+ }
1598
+ return res.stdout
1599
+ .split('\n')
1600
+ .map((line) => line.trim())
1601
+ .filter((path) => CAPTURE_NOTE_DIRS.some((dir) => path.startsWith(dir)))
1602
+ .sort();
1603
+ }
1604
+ /**
1605
+ * The DURABLE `work/` status folders a slug's ledger file can resting-live in (the
1606
+ * one-slug-one-folder set the invariant is asserted over). After the capstone
1607
+ * cut-over (task `cutover-retire-slicing-advancing-markers-and-trim-folder-sets`,
1608
+ * prd `ledger-status-per-item-lock-refs`) the ONLY `work/` moves on `main` are the
1609
+ * durable resting transitions, so the source a build completes FROM is `backlog/`
1610
+ * (claim no longer moves the body, task
1611
+ * `cutover-claim-body-stays-and-complete-sources-from-backlog`) and the canonical
1612
+ * done-move destination is `done/`. The task regime's won't-proceed terminal
1613
+ * `tasks/cancelled/` is also a durable resting folder, so the one-slug-one-folder
1614
+ * guard covers it (a slug in `tasks/cancelled/` AND another durable folder is a
1615
+ * corrupt ledger to refuse). The
1616
+ * transient `in-progress`/`needs-attention` are GONE from `main`'s tree (they are
1617
+ * per-item lock-ref state now).
1618
+ */
1619
+ /**
1620
+ * The folders {@link readArbiterLedgerPlacement} scans for a slug's source on the
1621
+ * arbiter: the durable `LEDGER_STATUS_FOLDERS` (`tasks-ready`/`done`/`cancelled`)
1622
+ * PLUS `tasks-backlog` (staging). Staging is included ONLY for the arbiter-side
1623
+ * source RESOLUTION of a `--allow-backlog` done-move (prd
1624
+ * `do-allow-backlog-drive-staged-tasks-without-promotion`) and the one-slug-one-
1625
+ * folder guard over the malformed "same slug in `tasks/ready/` AND `tasks/backlog/`"
1626
+ * state; it is DELIBERATELY NOT added to the shared `LEDGER_STATUS_FOLDERS` (which
1627
+ * ledger-lint's duplicate detection + the sibling-ledger reconcile reuse and which
1628
+ * deliberately omits the non-resting staging folder).
1629
+ */
1630
+ const ARBITER_PLACEMENT_FOLDERS = [
1631
+ ...LEDGER_STATUS_FOLDERS,
1632
+ 'tasks-backlog',
1633
+ ];
1634
+ /**
1635
+ * Read WHICH `work/<folder>/<slug>.md` the ARBITER currently holds the slug in,
1636
+ * from the `<arbiter>/main` TRACKING REF (the source of truth). It is a pure READ
1637
+ * of the ref the caller has ALREADY fetched (the step-4 rebase fetch) — it does
1638
+ * NOT fetch, so it never races a sibling job's integration on the shared
1639
+ * bare-mirror refs (ADR §2; a new fetch here was the regression that orphaned
1640
+ * this very work).
1641
+ *
1642
+ * It ENFORCES the one-slug-one-folder invariant: if the arbiter already holds the
1643
+ * slug in MORE THAN ONE status folder it is a pre-existing corrupt ledger — it
1644
+ * FAILS LOUD (returns an `error`) rather than silently pick one, UNLESS it is
1645
+ * PROVABLY SAFE (every copy is byte-identical, so the canonical `done/`
1646
+ * destination is unambiguous), mirroring the manual `279b542` cleanup.
1647
+ *
1648
+ * It scans {@link ARBITER_PLACEMENT_FOLDERS} — the durable `LEDGER_STATUS_FOLDERS`
1649
+ * PLUS `tasks-backlog`, so a `--allow-backlog` staged drive (prd
1650
+ * `do-allow-backlog-drive-staged-tasks-without-promotion`) whose done-move sources
1651
+ * from `tasks/backlog/` is DISCOVERED here too (the arbiter is the authority for
1652
+ * the actual source folder; the local `source` is the fallback). Including staging
1653
+ * also makes the one-slug-one-folder guard cover the malformed "same slug in both
1654
+ * `tasks/ready/` and `tasks/backlog/`" state (the prd's decision 5): it FAILS LOUD
1655
+ * rather than the resolver silently arbitrating a collision the contract forbids.
1656
+ */
1657
+ function readArbiterLedgerPlacement(cwd, arbiter, slug, env) {
1658
+ const arbiterRef = `${arbiter}/main`;
1659
+ const placements = [];
1660
+ for (const folder of ARBITER_PLACEMENT_FOLDERS) {
1661
+ const path = workItemRel(folder, `${slug}.md`);
1662
+ const ls = run('git', ['ls-tree', arbiterRef, path], cwd, { env });
1663
+ const line = ls.stdout.trim();
1664
+ if (ls.status !== 0 || line === '') {
1665
+ continue;
1666
+ }
1667
+ const match = /^\d+ blob ([0-9a-f]+)\t/.exec(line);
1668
+ if (match) {
1669
+ placements.push({ folder, blob: match[1] });
1670
+ }
1671
+ }
1672
+ const sourceFolders = placements
1673
+ .filter((p) => p.folder !== 'done')
1674
+ .map((p) => p.folder);
1675
+ if (placements.length > 1) {
1676
+ const uniqueBlobs = new Set(placements.map((p) => p.blob));
1677
+ const folders = placements
1678
+ .map((p) => workFolderPrefix(p.folder))
1679
+ .join(', ');
1680
+ if (uniqueBlobs.size !== 1) {
1681
+ return {
1682
+ error: `one-slug-one-folder invariant violated: '${slug}' is present in more ` +
1683
+ `than one status folder on ${arbiterRef} (${folders}) with DIFFERING ` +
1684
+ `content — refusing to publish a corrupt ledger. Resolve the duplicate ` +
1685
+ `(keep the correct copy, delete the stale one) and re-run; ` +
1686
+ `'dorfl scan'/'gc' surfaces such duplicates.`,
1687
+ sourceFolders,
1688
+ };
1689
+ }
1690
+ // Provably safe (byte-identical copies): the duplicate is auto-cleaned by the
1691
+ // divergent-done-move reconciliation, which moves the slug to `done/` ONLY.
1692
+ }
1693
+ return { sourceFolders };
1694
+ }
1695
+ /** The `work/<status>/` prefixes a ledger file can live under (no trailing `/`). */
1696
+ const LEDGER_FOLDER_PREFIXES = LEDGER_STATUS_FOLDERS.map((folder) => workFolderPrefix(folder));
1697
+ /**
1698
+ * Classify a rebase-conflicted path: is it a SIBLING-slug ledger file (a
1699
+ * `work/<status>/<otherslug>.md` for some slug OTHER than `ourSlug`)? Returns
1700
+ * `false` for any code file AND for THIS slug's own ledger file (both must keep
1701
+ * routing to needs-attention — the sibling arm NEVER widens to code or own-ledger).
1702
+ */
1703
+ function isSiblingLedgerPath(path, ourSlug) {
1704
+ const prefix = LEDGER_FOLDER_PREFIXES.find((p) => path.startsWith(p));
1705
+ if (prefix === undefined) {
1706
+ return false; // not a ledger file at all — a code file (or non-ledger work/ file).
1707
+ }
1708
+ const rest = path.slice(prefix.length);
1709
+ if (!rest.endsWith('.md') || rest.includes('/')) {
1710
+ return false; // not a `<slug>.md` directly under the status folder.
1711
+ }
1712
+ const otherSlug = rest.slice(0, -'.md'.length);
1713
+ return otherSlug !== ourSlug; // OUR own ledger is NOT a sibling — it routes as today.
1714
+ }
1715
+ /**
1716
+ * Reconcile a SIBLING-SLUG ledger conflict during the step-4 rebase WITHOUT
1717
+ * aborting it (Race 2 of `run-fleet-claim-integrate-and-sibling-rebase-concurrency-safe`).
1718
+ * Called WHILE the rebase is still in progress (right after `git rebase` returned
1719
+ * non-zero): a sibling same-repo job landed its OWN `work/<status>/<otherslug>.md`
1720
+ * move on `<arbiter>/main` between our base and this rebase, so replaying our
1721
+ * commit conflicts on that OTHER slug's ledger file — a benign ledger-only
1722
+ * divergence, NOT a real code conflict.
1723
+ *
1724
+ * STRICT SCOPE (the safety fence): it reconciles ONLY when EVERY conflicted path
1725
+ * is a SIBLING slug's ledger file ({@link isSiblingLedgerPath}). If ANY conflicted
1726
+ * path is a CODE file, a non-ledger `work/` file, or THIS slug's OWN ledger file,
1727
+ * it does NOTHING (returns `false`) so the caller aborts + routes to
1728
+ * needs-attention exactly as today — it NEVER widens to code or own-ledger.
1729
+ *
1730
+ * The resolution takes the ARBITER's (rebased-onto) version of each sibling
1731
+ * ledger file (`git checkout --ours` — during a rebase `--ours` is the base we are
1732
+ * replaying ONTO, i.e. `<arbiter>/main`), stages it, and `git rebase --continue`s,
1733
+ * looping until the rebase completes (a later replayed commit could re-conflict on
1734
+ * a sibling ledger). Returns `true` once the rebase finished cleanly (the caller
1735
+ * falls through to integrate); returns `false` — leaving the rebase in progress —
1736
+ * when the conflict is out of scope (the caller aborts + routes).
1737
+ */
1738
+ async function reconcileSiblingLedgerConflict(params) {
1739
+ const { cwd, arbiter, slug, env } = params;
1740
+ const arbiterRef = `${arbiter}/main`;
1741
+ // The conflicted (unmerged) paths of the failed rebase step. Read them WHILE the
1742
+ // rebase is still in progress (the caller invokes us right after the non-zero
1743
+ // rebase), BEFORE we abort — so we know what conflicted.
1744
+ const conflicted = (await gitSoft(['diff', '--name-only', '--diff-filter=U'], cwd, env)).stdout
1745
+ .split('\n')
1746
+ .map((line) => line.trim())
1747
+ .filter((line) => line !== '');
1748
+ if (conflicted.length === 0) {
1749
+ return false; // not a conflict we can reason about here — defer to the caller.
1750
+ }
1751
+ // SCOPE GATE: every conflicted path MUST be a SIBLING slug's ledger file. Any
1752
+ // code file / own-ledger / non-ledger work file disqualifies the WHOLE
1753
+ // reconciliation (never widen to code) — the caller aborts + routes.
1754
+ if (!conflicted.every((path) => isSiblingLedgerPath(path, slug))) {
1755
+ return false;
1756
+ }
1757
+ // Benign sibling-ledger divergence. Rather than the fragile in-progress
1758
+ // `git rebase --continue` (which mutates the shared-worktree branch ref mid-
1759
+ // rebase and flakes under same-repo fleet ref contention), ABORT and REDO our
1760
+ // own work as ONE clean commit on top of `<arbiter>/main` — the SAME safe
1761
+ // reset-and-redo pattern `reconcileDivergentDoneMove` uses. This automatically
1762
+ // takes the arbiter's version of EVERY sibling ledger file (they live in the
1763
+ // reset base, untouched) while preserving OUR agent edits + OUR done-move (kept
1764
+ // in the working tree by the mixed reset). NO semantic judgement, NO `--ours`/
1765
+ // `--theirs` heuristic on any code file.
1766
+ await gitSoft(['rebase', '--abort'], cwd, env);
1767
+ // Re-point the branch onto `<arbiter>/main`, KEEPING the working tree (our edits
1768
+ // + our done-move): a mixed reset moves HEAD + index to the arbiter base but
1769
+ // leaves the working tree intact. Our own changes stay in the tree. (`HEAD` was
1770
+ // restored to our work-branch tip by the abort above.)
1771
+ const reset = await gitSoft(['reset', '--mixed', '--quiet', arbiterRef], cwd, env);
1772
+ if (reset.status !== 0) {
1773
+ return false;
1774
+ }
1775
+ // Take the ARBITER's placement of every SIBLING slug whose ledger conflicted: the
1776
+ // conflict means we touched a sibling's ledger file that the arbiter moved, so our
1777
+ // working-tree copy is STALE. Hard-restore EVERY ledger folder for each affected
1778
+ // sibling slug from the arbiter (index + working tree) and drop any stray copy our
1779
+ // tree still holds, so the sibling's OWN status-folder move (e.g. its done-move) is
1780
+ // honoured verbatim — never clobbered by our stale touch, never duplicated.
1781
+ const siblingSlugs = new Set();
1782
+ for (const path of conflicted) {
1783
+ const prefix = LEDGER_FOLDER_PREFIXES.find((p) => path.startsWith(p));
1784
+ if (prefix !== undefined) {
1785
+ siblingSlugs.add(path.slice(prefix.length, -'.md'.length));
1786
+ }
1787
+ }
1788
+ for (const otherSlug of siblingSlugs) {
1789
+ for (const folder of LEDGER_STATUS_FOLDERS) {
1790
+ const ledgerPath = workItemRel(folder, `${otherSlug}.md`);
1791
+ const onArbiter = (await gitSoft(['cat-file', '-e', `${arbiterRef}:${ledgerPath}`], cwd, env)).status === 0;
1792
+ if (onArbiter) {
1793
+ // The arbiter holds the sibling here — take its exact copy.
1794
+ await gitSoft(['checkout', arbiterRef, '--', ledgerPath], cwd, env);
1795
+ }
1796
+ else {
1797
+ // The arbiter does NOT hold the sibling here — drop any stale copy ours has.
1798
+ const abs = join(cwd, ledgerPath);
1799
+ if (existsSync(abs)) {
1800
+ rmSync(abs, { force: true });
1801
+ }
1802
+ }
1803
+ }
1804
+ }
1805
+ // Stage everything (our agent edits + our arbiter-aligned ledger move; the
1806
+ // sibling ledgers are already at the arbiter version) and commit ONE clean
1807
+ // commit on top of `<arbiter>/main`. Nothing staged ⇒ the work is already on the
1808
+ // arbiter (an already-integrated no-op) — treat as cleanly reconciled.
1809
+ await gitSoft(['add', '-A'], cwd, env);
1810
+ if ((await gitSoft(['diff', '--cached', '--quiet'], cwd, env)).status === 0) {
1811
+ return true;
1812
+ }
1813
+ await gitHard([
1814
+ 'commit',
1815
+ '-q',
1816
+ '-m',
1817
+ `feat(${slug}): reconcile sibling-ledger rebase; done`,
1818
+ ], cwd, env);
1819
+ return true;
1820
+ }
1821
+ /**
1822
+ * Recover a DIVERGENT-BASE done-move whose plain rebase CONFLICTED (ledger-
1823
+ * integrity defect 1, the PR #86 ghost). The arbiter holds the slug's source in a
1824
+ * DIFFERENT folder than our local done-move removed, so replaying the local
1825
+ * `-work/<localsrc>/<slug>.md +work/done/<slug>.md` patch onto `<arbiter>/main`
1826
+ * (which lacks `<localsrc>`) conflicts on the ledger file.
1827
+ *
1828
+ * It reconciles WITHOUT any semantic judgement (so it is safe automatically,
1829
+ * unlike a real code conflict): RESET the work branch onto `<arbiter>/main` (the
1830
+ * working tree kept), then redo the done-move ARBITER-RESOLVED — remove the slug
1831
+ * from EVERY non-`done` folder the arbiter holds it in, write `work/done/<slug>.md`
1832
+ * — and commit ONE done commit on top of `<arbiter>/main`. The branch ends cleanly
1833
+ * on the arbiter with the slug in `done/` ONLY (the move is a MOVE, not a copy);
1834
+ * no further rebase is needed.
1835
+ *
1836
+ * Returns `true` on success (the caller falls through to integrate). Returns
1837
+ * `false` when this is NOT the divergent-ledger case (the arbiter holds the slug
1838
+ * only in `done/`/nowhere, or the placement read failed) so the caller routes the
1839
+ * genuine conflict to needs-attention unchanged.
1840
+ */
1841
+ async function reconcileDivergentDoneMove(params) {
1842
+ const { cwd, arbiter, slug, localSource, env } = params;
1843
+ const arbiterRef = `${arbiter}/main`;
1844
+ // The arbiter's current placement (read from the already-fetched ref — no
1845
+ // fetch). This recovery applies ONLY to the divergent-LEDGER conflict: the
1846
+ // arbiter holds the slug's source in a DIFFERENT folder than our local done-move
1847
+ // removed it from. When the arbiter's source folder MATCHES our local source (or
1848
+ // the arbiter holds it only in `done/`/nowhere), the rebase conflict is a
1849
+ // genuine CODE conflict (e.g. the agent's edits vs an advanced main) — NEVER
1850
+ // auto-resolved; defer to the needs-attention route.
1851
+ const placement = readArbiterLedgerPlacement(cwd, arbiter, slug, env);
1852
+ if (placement.error || placement.sourceFolders.length === 0) {
1853
+ return false;
1854
+ }
1855
+ if (placement.sourceFolders.includes(localSource)) {
1856
+ // The arbiter still holds the slug in the SAME source folder we moved from —
1857
+ // the ledger placement agrees, so the conflict is NOT a divergent-ledger one.
1858
+ return false;
1859
+ }
1860
+ // Capture the slug's ledger content (our tip's done/ copy, or any source copy)
1861
+ // BEFORE we reset — it is what lands in `done/`.
1862
+ let ledgerContent;
1863
+ // Scan the ARBITER-placement set (durable folders PLUS `tasks-backlog`) so a
1864
+ // `--allow-backlog` staged-drive's source copy is captured too. `done` first so
1865
+ // our tip's already-moved copy wins.
1866
+ const captureOrder = [
1867
+ 'done',
1868
+ ...ARBITER_PLACEMENT_FOLDERS,
1869
+ ];
1870
+ for (const folder of captureOrder) {
1871
+ const abs = workItemPath(cwd, folder, slug);
1872
+ if (existsSync(abs)) {
1873
+ ledgerContent = readFileSync(abs, 'utf8');
1874
+ break;
1875
+ }
1876
+ }
1877
+ // Re-point the branch onto `<arbiter>/main`, KEEPING the working tree (the
1878
+ // agent's edits + our done/ file): a mixed reset moves HEAD + the index to the
1879
+ // arbiter base but leaves the working tree intact. We then fix only the LEDGER
1880
+ // placement against the arbiter and commit one clean done commit.
1881
+ const reset = await gitSoft(['reset', '--mixed', '--quiet', arbiterRef], cwd, env);
1882
+ if (reset.status !== 0) {
1883
+ return false;
1884
+ }
1885
+ // Arbiter-resolved ledger placement: write `done/` from the captured content,
1886
+ // and remove every non-`done` copy (the arbiter's source folder is now checked
1887
+ // out by the reset; any stale local source is swept too).
1888
+ mkdirSync(workFolderPath(cwd, 'done'), { recursive: true });
1889
+ const donePath = workItemPath(cwd, 'done', slug);
1890
+ if (ledgerContent !== undefined) {
1891
+ writeFileSync(donePath, ledgerContent);
1892
+ }
1893
+ // Sweep every non-`done` copy the arbiter could hold the slug in, INCLUDING
1894
+ // `tasks-backlog` (a `--allow-backlog` staged drive), so the move is a MOVE not
1895
+ // a copy.
1896
+ for (const folder of ARBITER_PLACEMENT_FOLDERS) {
1897
+ if (folder === 'done') {
1898
+ continue;
1899
+ }
1900
+ const abs = workItemPath(cwd, folder, slug);
1901
+ if (existsSync(abs)) {
1902
+ rmSync(abs, { force: true });
1903
+ }
1904
+ }
1905
+ // Stage everything (agent work + the arbiter-aligned ledger move) and commit a
1906
+ // single done commit on top of `<arbiter>/main`. Nothing staged ⇒ the slug is
1907
+ // already done on the arbiter (an already-integrated no-op) — treat as cleanly
1908
+ // reconciled.
1909
+ await gitSoft(['add', '-A'], cwd, env);
1910
+ if ((await gitSoft(['diff', '--cached', '--quiet'], cwd, env)).status === 0) {
1911
+ return true;
1912
+ }
1913
+ await gitHard(['commit', '-q', '-m', `feat(${slug}): reconcile done-move; done`], cwd, env);
1914
+ return true;
1915
+ }
1916
+ /**
1917
+ * Run the Gate-2 PR/code REVIEW gate against a given tree ({@param reviewCwd}) and
1918
+ * route a BLOCK to needs-attention, returning DATA the caller acts on. Factored out
1919
+ * of {@link performIntegration} so the SAME gate can run in TWO places without
1920
+ * forking its logic (MAINTAINER DECISION 2, task `gate-on-rebased-tip-fresh-worktree`):
1921
+ *
1922
+ * - fresh-worktree gate OFF: the caller invokes it at the FRONT on the pre-rebase
1923
+ * `cwd`, right after the front `verify` (today's order, byte-for-byte);
1924
+ * - fresh-worktree gate ON: the caller invokes it on the REBASED TIP (the fresh
1925
+ * gate worktree), right AFTER the rebased-tip `verify` passes — so
1926
+ * verify-THEN-review holds on the SAME merged tree.
1927
+ *
1928
+ * The review AGENT inspects {@param reviewCwd} (the tree under review); the
1929
+ * needs-attention ROUTING always targets `params.cwd` (where the work branch +
1930
+ * ledger live), so the routing is identical whichever tree was reviewed — ONLY the
1931
+ * source of the reviewed tree moved, not the verdict handling.
1932
+ *
1933
+ * It NEVER mutates `mode` or writes the nits observation itself (those are the
1934
+ * caller's concern, because WHEN they happen differs by path — pre-commit on OFF,
1935
+ * post-commit-amend on ON); it returns the verdict and lets the caller place those
1936
+ * effects correctly for its band position.
1937
+ */
1938
+ async function runGate2Review(params) {
1939
+ const { reviewCwd, input, slug, branch, cwd, env, note } = params;
1940
+ const reviewGate = input.reviewGate;
1941
+ if (!reviewGate) {
1942
+ // `review` on with no gate wired is a usage error — the floor must never be
1943
+ // silently skipped. (Production always wires `harnessReviewGate`.) The caller's
1944
+ // try/catch maps a thrown error to its usage-error outcome.
1945
+ throw new Error(`review is on but no review gate is configured — cannot run Gate 2 ` +
1946
+ `for '${slug}' (this is a wiring bug; the gate must not be skipped).`);
1947
+ }
1948
+ const maxRounds = Math.max(1, input.reviewMaxRounds ?? 2);
1949
+ note('Running the PR/code review gate (Gate 2)…');
1950
+ // CORROBORATED-APPROVAL semantics (NOT retry-until-pass): the gate runs the
1951
+ // reviewer up to `reviewMaxRounds` times on the SAME tip and approves ONLY if
1952
+ // EVERY round approves. A `block` is TERMINAL — it short-circuits the loop and
1953
+ // is never re-rolled, because the reviewer is stochastic and re-reviewing an
1954
+ // UNCHANGED tip after a block would just be a dice re-roll that could launder a
1955
+ // real reject into a pass. The extra rounds therefore exist to make a FALSE
1956
+ // APPROVE harder to slip through (a second reviewer gets a veto), never to give
1957
+ // blocked work a second chance. (A future builder-REVISE step that mutates the
1958
+ // tree between rounds is the ONLY thing that should make a block retryable; it
1959
+ // would change the artifact under review and is not implemented here.)
1960
+ let approved = false;
1961
+ let lastVerdict;
1962
+ for (let round = 1; round <= maxRounds; round++) {
1963
+ let verdict;
1964
+ try {
1965
+ verdict = await reviewGate({
1966
+ slug,
1967
+ cwd: reviewCwd,
1968
+ reviewModel: input.reviewModel,
1969
+ round,
1970
+ // `--watch` threading (task `watch-review-session`): when on, the production
1971
+ // gate tails the review session live. OFF ⇒ the plain sync launch, unchanged.
1972
+ watch: input.watch,
1973
+ watchSink: input.watchSink,
1974
+ color: input.color,
1975
+ sessionsDir: input.sessionsDir,
1976
+ // The review AGENT launches with the AMBIENT env, never the identity-scoped
1977
+ // `env` (an agent must not act as the bot). Falls back to `env` when no
1978
+ // identity is configured (unchanged for non-identity callers).
1979
+ env: input.agentEnv ?? env,
1980
+ });
1981
+ }
1982
+ catch (err) {
1983
+ if (!(err instanceof ReviewParseError)) {
1984
+ // Anything else (a harness/connection throw, a programmer bug) is NOT this
1985
+ // gate's concern — re-throw so the existing catch sites classify it.
1986
+ throw err;
1987
+ }
1988
+ // THE GATE RAN BUT ITS VERDICT WAS UNREADABLE (direction 1, the safety net):
1989
+ // the reviewer did NOT block — the gate's OUTPUT could not be parsed (a
1990
+ // malformed JSON verdict, common on large diffs + weaker models, AFTER the
1991
+ // direction-2 repair pass could not salvage it). WITHOUT this catch the throw
1992
+ // escapes the core and `performComplete` maps it to the generic `usage-error`
1993
+ // (verbatim, no push, no surface) AFTER the green build but BEFORE the
1994
+ // done-move/push — STRANDING the lock + work branch with no PR.
1995
+ //
1996
+ // A parse failure in ANY round is TERMINAL: route IMMEDIATELY, never re-roll
1997
+ // the remaining rounds (mirroring the block-is-terminal rule — re-reviewing
1998
+ // the same tip would just be the dice re-roll the corroboration loop forbids).
1999
+ // We route through the SAME work-preserving `applyNeedsAttentionTransition`
2000
+ // seam the block path uses (it PUSHES the work branch + surfaces the item on
2001
+ // `surfaceArbiter` for the autonomous path), targeting `cwd` (the work branch +
2002
+ // ledger), NOT the throwaway `reviewCwd` — so BOTH the direct `!freshWorktreeGate`
2003
+ // path AND the fresh-worktree `review:` callback are covered by this ONE catch.
2004
+ // The recorded reason carries the parse-failure phrase the `failure-cause.ts`
2005
+ // signature matches → the `do`/`run` tail classifies it `transient-infra`
2006
+ // (retry the SAME work: the gate output is STOCHASTIC, so a re-run CAN differ,
2007
+ // and the direction-2 repair makes a re-run far more likely to parse). NEVER a
2008
+ // silent approve.
2009
+ const reason = `PR/code review (Gate 2) ran but its verdict could not be parsed: ` +
2010
+ `${err.message}`;
2011
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
2012
+ cwd,
2013
+ slug,
2014
+ reason,
2015
+ arbiter: input.surfaceArbiter,
2016
+ env,
2017
+ note,
2018
+ });
2019
+ const message = routed.moved
2020
+ ? `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2021
+ 'routed it to work/needs-attention/ (work branch pushed + surfaced; ' +
2022
+ 'transient-infra — re-run). NOT integrated.'
2023
+ : `PR/code review (Gate 2) produced an UNPARSEABLE verdict for '${slug}'; ` +
2024
+ 'NOT integrating.';
2025
+ note(message);
2026
+ return {
2027
+ kind: 'blocked',
2028
+ result: {
2029
+ outcome: 'review-unparseable',
2030
+ routedToNeedsAttention: routed.moved,
2031
+ branch,
2032
+ reason,
2033
+ },
2034
+ };
2035
+ }
2036
+ lastVerdict = verdict;
2037
+ if (verdict.verdict !== 'approve') {
2038
+ // A `block` is TERMINAL: stop now (never re-roll an unchanged tip) and route
2039
+ // the blocking findings to needs-attention below. `approved` stays false.
2040
+ approved = false;
2041
+ break;
2042
+ }
2043
+ // An `approve`: provisionally approved, but keep going — every remaining round
2044
+ // must ALSO approve for the gate to pass (corroboration, not first-approve-wins).
2045
+ approved = true;
2046
+ }
2047
+ if (!approved) {
2048
+ // NON-approve verdict: route to needs-attention via the SAME seam the red gate
2049
+ // uses, NEVER integrate. We reach here EITHER because a round returned a
2050
+ // (terminal) block, OR because not every round corroborated the approve. The
2051
+ // reason records the last verdict's blocking findings (the proximate cause) plus
2052
+ // the `reviewMaxRounds` note (so a single-round block also reads correctly).
2053
+ const findingsReason = lastVerdict ? formatBlockReason(lastVerdict) : '';
2054
+ const reason = (findingsReason ? findingsReason + '\n' : '') +
2055
+ reviewRoundsExhaustedReason(maxRounds);
2056
+ const routed = await ledgerWrite.applyNeedsAttentionTransition({
2057
+ cwd,
2058
+ slug,
2059
+ reason,
2060
+ // Same autonomous-vs-human gate as the red-gate path: `do` passes the arbiter
2061
+ // (surface on main + push the branch), the human `complete` leaves it unset.
2062
+ arbiter: input.surfaceArbiter,
2063
+ env,
2064
+ note,
2065
+ });
2066
+ const message = routed.moved
2067
+ ? `PR/code review (Gate 2) blocked '${slug}'; routed it to ` +
2068
+ 'work/needs-attention/ (surfaced by status; the blocking findings are ' +
2069
+ 'recorded in the item body). NOT integrated.'
2070
+ : `PR/code review (Gate 2) blocked '${slug}'; NOT integrating.`;
2071
+ note(message);
2072
+ return {
2073
+ kind: 'blocked',
2074
+ result: {
2075
+ outcome: 'review-blocked',
2076
+ routedToNeedsAttention: routed.moved,
2077
+ branch,
2078
+ reason: message,
2079
+ // The structured block reason (the blocking findings ONLY) for a caller
2080
+ // doing its OWN routing (the tasking path); the build path ignores it.
2081
+ reviewBlockReason: findingsReason || message,
2082
+ },
2083
+ };
2084
+ }
2085
+ note(`PR/code review (Gate 2) approved '${slug}'.`);
2086
+ return {
2087
+ kind: 'approved',
2088
+ verdict: lastVerdict,
2089
+ };
2090
+ }
2091
+ /**
2092
+ * Run the acceptance gate (`prepare` then `verify`) in a CLEAN THROWAWAY worktree
2093
+ * cut from `commit` (the work branch tip AFTER it was rebased onto `<arbiter>/main`
2094
+ * — the would-be-integrated tip), then REAP the worktree (pass OR fail). This is
2095
+ * the fresh-worktree gate (task `gate-on-rebased-tip-fresh-worktree`): a green
2096
+ * gate provably describes the MERGED artifact, because the worktree is cut from the
2097
+ * COMMITTED, rebased tip — gitignored/uncommitted state in the agent's `cwd` cannot
2098
+ * leak in, and a change the integration rebase introduced IS gated.
2099
+ *
2100
+ * The worktree is registered on `cwd`'s git common dir (`git worktree add --detach`
2101
+ * run IN `cwd`), so it works for BOTH isolation strategies: an in-place clone
2102
+ * (`<cwd>/.git`) and a job worktree cut from a bare hub mirror (the mirror's git
2103
+ * dir). It is a TRANSIENT gate sandbox — distinct from the agent's job worktree — and
2104
+ * is ALWAYS removed afterwards (`git worktree remove --force` + a dir cleanup
2105
+ * fallback), never leaked (cross-ref the worktree-hygiene/reap discipline in
2106
+ * `gc.ts`). The throwaway worktree is fresh (no deps), so `prepare` runs in it
2107
+ * before `verify` (forced — `useMarker: false` — since it is per-gate); a failing
2108
+ * `prepare` short-circuits and never runs `verify` (the env could not be made
2109
+ * ready), surfaced distinctly as `kind: 'prepare'`.
2110
+ *
2111
+ * GATE-2 REVIEW (MAINTAINER DECISION 2): when a `review` callback is supplied (the
2112
+ * caller resolved `review` ON), the review runs HERE — AFTER the rebased-tip
2113
+ * `verify` passes, against THIS fresh gate worktree (the rebased tip) — so
2114
+ * verify-THEN-review holds on the SAME merged tree. The review runs INSIDE the
2115
+ * worktree's lifetime (before it is reaped), so the review agent inspects the
2116
+ * rebased tip. Its outcome is returned in {@link FreshGateResult.review} for the
2117
+ * caller to route/act on; the worktree is reaped regardless of the verdict.
2118
+ */
2119
+ async function runFreshWorktreeGate(params) {
2120
+ const { cwd, commit, env, note } = params;
2121
+ // A throwaway gate-sandbox dir OUTSIDE any tracked tree (the OS temp area), so it
2122
+ // can never be swept into a commit and is naturally disposable.
2123
+ const gateDir = mkdtempSync(join(tmpdir(), 'dorfl-fresh-gate-'));
2124
+ // `git worktree add` will refuse to add into a non-empty existing dir, so add a
2125
+ // child path under the (empty) mkdtemp dir.
2126
+ const worktreeDir = join(gateDir, 'tip');
2127
+ try {
2128
+ // Cut a CLEAN DETACHED worktree from the rebased tip. Detached (no branch) so it
2129
+ // never collides with the work branch already checked out in `cwd`.
2130
+ await gitHard(['worktree', 'add', '--quiet', '--detach', worktreeDir, commit], cwd, env);
2131
+ note('Running the acceptance gate (prepare then verify) on the rebased tip in ' +
2132
+ 'a clean throwaway worktree…');
2133
+ // prepare: a fresh worktree has no deps, so install BEFORE verify. Forced per
2134
+ // gate (`useMarker: false`) — this worktree is throwaway. Unset ⇒ a no-op.
2135
+ const prep = await ensurePrepared({
2136
+ cwd: worktreeDir,
2137
+ prepare: params.prepare,
2138
+ env,
2139
+ useMarker: false,
2140
+ });
2141
+ if (!prep.passed) {
2142
+ return { passed: false, kind: 'prepare', exitCode: prep.exitCode };
2143
+ }
2144
+ const gate = await runVerify({
2145
+ cwd: worktreeDir,
2146
+ verify: params.verify,
2147
+ env,
2148
+ });
2149
+ if (!gate.passed) {
2150
+ return { passed: false, kind: 'verify', exitCode: gate.exitCode };
2151
+ }
2152
+ // GATE-2 REVIEW on the rebased tip, AFTER the green verify (verify-then-review
2153
+ // on the SAME merged tree). Runs while the worktree is still live (the review
2154
+ // agent inspects the rebased tip). The outcome is returned for the caller to
2155
+ // route/act on.
2156
+ const review = params.review ? await params.review(worktreeDir) : undefined;
2157
+ return { passed: true, review };
2158
+ }
2159
+ finally {
2160
+ // REAP the throwaway fresh-gate worktree (pass or fail) — never leak it. Remove the
2161
+ // git-registered worktree first (so the common dir has no dangling
2162
+ // registration), then best-effort drop the temp dir + prune.
2163
+ try {
2164
+ await gitSoft(['worktree', 'remove', '--force', worktreeDir], cwd, env);
2165
+ }
2166
+ catch {
2167
+ // best-effort
2168
+ }
2169
+ try {
2170
+ await gitSoft(['worktree', 'prune'], cwd, env);
2171
+ }
2172
+ catch {
2173
+ // best-effort
2174
+ }
2175
+ try {
2176
+ rmSync(gateDir, { recursive: true, force: true });
2177
+ }
2178
+ catch {
2179
+ // best-effort
2180
+ }
2181
+ }
2182
+ }
2183
+ /** Run git, returning the raw result (no throw) — for soft checks. */
2184
+ function gitSoft(args, cwd, env) {
2185
+ return runAsync('git', args, cwd, { env });
2186
+ }
2187
+ /** Run git; throw on non-zero (genuinely unexpected plumbing failures). */
2188
+ async function gitHard(args, cwd, env) {
2189
+ const result = await runAsync('git', args, cwd, { env });
2190
+ if (result.status !== 0) {
2191
+ throw new Error(`git ${args.join(' ')} failed (exit ${result.status}): ${result.stderr.trim()}`);
2192
+ }
2193
+ return result;
2194
+ }
2195
+ //# sourceMappingURL=integration-core.js.map