dorfl 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (418) hide show
  1. package/dist/advance-ci-template.d.ts +17 -16
  2. package/dist/advance-ci-template.d.ts.map +1 -1
  3. package/dist/advance-ci-template.js +42 -53
  4. package/dist/advance-ci-template.js.map +1 -1
  5. package/dist/advance-drivers.d.ts +8 -0
  6. package/dist/advance-drivers.d.ts.map +1 -1
  7. package/dist/advance-drivers.js +28 -4
  8. package/dist/advance-drivers.js.map +1 -1
  9. package/dist/advance-isolated.d.ts.map +1 -1
  10. package/dist/advance-isolated.js +3 -2
  11. package/dist/advance-isolated.js.map +1 -1
  12. package/dist/advance-lifecycle-template.d.ts +41 -40
  13. package/dist/advance-lifecycle-template.d.ts.map +1 -1
  14. package/dist/advance-lifecycle-template.js +309 -489
  15. package/dist/advance-lifecycle-template.js.map +1 -1
  16. package/dist/advance-loop-driver.d.ts.map +1 -1
  17. package/dist/advance-loop-driver.js +3 -2
  18. package/dist/advance-loop-driver.js.map +1 -1
  19. package/dist/advance-treeless-publish.d.ts +31 -0
  20. package/dist/advance-treeless-publish.d.ts.map +1 -1
  21. package/dist/advance-treeless-publish.js +40 -0
  22. package/dist/advance-treeless-publish.js.map +1 -1
  23. package/dist/advance.d.ts +63 -3
  24. package/dist/advance.d.ts.map +1 -1
  25. package/dist/advance.js +80 -12
  26. package/dist/advance.js.map +1 -1
  27. package/dist/advancing-lock.d.ts +9 -0
  28. package/dist/advancing-lock.d.ts.map +1 -1
  29. package/dist/advancing-lock.js +21 -1
  30. package/dist/advancing-lock.js.map +1 -1
  31. package/dist/agent-env.d.ts +61 -0
  32. package/dist/agent-env.d.ts.map +1 -0
  33. package/dist/agent-env.js +144 -0
  34. package/dist/agent-env.js.map +1 -0
  35. package/dist/agent-stop.d.ts +35 -0
  36. package/dist/agent-stop.d.ts.map +1 -1
  37. package/dist/agent-stop.js +62 -0
  38. package/dist/agent-stop.js.map +1 -1
  39. package/dist/apply-merge-action.d.ts +76 -34
  40. package/dist/apply-merge-action.d.ts.map +1 -1
  41. package/dist/apply-merge-action.js +339 -129
  42. package/dist/apply-merge-action.js.map +1 -1
  43. package/dist/apply-stuck-action.js +2 -2
  44. package/dist/apply-stuck-action.js.map +1 -1
  45. package/dist/ci-agent-result.d.ts +151 -0
  46. package/dist/ci-agent-result.d.ts.map +1 -0
  47. package/dist/ci-agent-result.js +314 -0
  48. package/dist/ci-agent-result.js.map +1 -0
  49. package/dist/ci-handoff-apply.d.ts +162 -0
  50. package/dist/ci-handoff-apply.d.ts.map +1 -0
  51. package/dist/ci-handoff-apply.js +497 -0
  52. package/dist/ci-handoff-apply.js.map +1 -0
  53. package/dist/ci-handoff-format.d.ts +369 -0
  54. package/dist/ci-handoff-format.d.ts.map +1 -0
  55. package/dist/ci-handoff-format.js +646 -0
  56. package/dist/ci-handoff-format.js.map +1 -0
  57. package/dist/ci-handoff-lfs.d.ts +146 -0
  58. package/dist/ci-handoff-lfs.d.ts.map +1 -0
  59. package/dist/ci-handoff-lfs.js +397 -0
  60. package/dist/ci-handoff-lfs.js.map +1 -0
  61. package/dist/ci-handoff.d.ts +99 -0
  62. package/dist/ci-handoff.d.ts.map +1 -0
  63. package/dist/ci-handoff.js +277 -0
  64. package/dist/ci-handoff.js.map +1 -0
  65. package/dist/ci-lock-outputs.d.ts +93 -0
  66. package/dist/ci-lock-outputs.d.ts.map +1 -0
  67. package/dist/ci-lock-outputs.js +223 -0
  68. package/dist/ci-lock-outputs.js.map +1 -0
  69. package/dist/ci-phase-build.d.ts +178 -0
  70. package/dist/ci-phase-build.d.ts.map +1 -0
  71. package/dist/ci-phase-build.js +989 -0
  72. package/dist/ci-phase-build.js.map +1 -0
  73. package/dist/ci-phase-driver.d.ts +247 -0
  74. package/dist/ci-phase-driver.d.ts.map +1 -0
  75. package/dist/ci-phase-driver.js +370 -0
  76. package/dist/ci-phase-driver.js.map +1 -0
  77. package/dist/ci-phase-intake.d.ts +172 -0
  78. package/dist/ci-phase-intake.d.ts.map +1 -0
  79. package/dist/ci-phase-intake.js +555 -0
  80. package/dist/ci-phase-intake.js.map +1 -0
  81. package/dist/ci-phase-tasking.d.ts +205 -0
  82. package/dist/ci-phase-tasking.d.ts.map +1 -0
  83. package/dist/ci-phase-tasking.js +906 -0
  84. package/dist/ci-phase-tasking.js.map +1 -0
  85. package/dist/ci-phase-treeless.d.ts +224 -0
  86. package/dist/ci-phase-treeless.d.ts.map +1 -0
  87. package/dist/ci-phase-treeless.js +1482 -0
  88. package/dist/ci-phase-treeless.js.map +1 -0
  89. package/dist/ci-read-token.d.ts +47 -0
  90. package/dist/ci-read-token.d.ts.map +1 -0
  91. package/dist/ci-read-token.js +80 -0
  92. package/dist/ci-read-token.js.map +1 -0
  93. package/dist/cli.d.ts.map +1 -1
  94. package/dist/cli.js +447 -22
  95. package/dist/cli.js.map +1 -1
  96. package/dist/close-job-template.d.ts +10 -1
  97. package/dist/close-job-template.d.ts.map +1 -1
  98. package/dist/close-job-template.js +46 -14
  99. package/dist/close-job-template.js.map +1 -1
  100. package/dist/close-job.d.ts +20 -5
  101. package/dist/close-job.d.ts.map +1 -1
  102. package/dist/close-job.js +65 -12
  103. package/dist/close-job.js.map +1 -1
  104. package/dist/complete.d.ts.map +1 -1
  105. package/dist/complete.js +20 -1
  106. package/dist/complete.js.map +1 -1
  107. package/dist/config.d.ts +41 -44
  108. package/dist/config.d.ts.map +1 -1
  109. package/dist/config.js +2 -4
  110. package/dist/config.js.map +1 -1
  111. package/dist/continue-branch.d.ts +63 -15
  112. package/dist/continue-branch.d.ts.map +1 -1
  113. package/dist/continue-branch.js +75 -8
  114. package/dist/continue-branch.js.map +1 -1
  115. package/dist/crashed-run-save.d.ts +73 -0
  116. package/dist/crashed-run-save.d.ts.map +1 -0
  117. package/dist/crashed-run-save.js +86 -0
  118. package/dist/crashed-run-save.js.map +1 -0
  119. package/dist/cwd-section.d.ts.map +1 -1
  120. package/dist/cwd-section.js +10 -2
  121. package/dist/cwd-section.js.map +1 -1
  122. package/dist/decision-engine.d.ts +10 -0
  123. package/dist/decision-engine.d.ts.map +1 -1
  124. package/dist/decision-engine.js.map +1 -1
  125. package/dist/do-config.d.ts +2 -24
  126. package/dist/do-config.d.ts.map +1 -1
  127. package/dist/do-config.js +1 -33
  128. package/dist/do-config.js.map +1 -1
  129. package/dist/do.d.ts +149 -1
  130. package/dist/do.d.ts.map +1 -1
  131. package/dist/do.js +115 -25
  132. package/dist/do.js.map +1 -1
  133. package/dist/dorfl-item-template.d.ts +98 -0
  134. package/dist/dorfl-item-template.d.ts.map +1 -0
  135. package/dist/dorfl-item-template.js +504 -0
  136. package/dist/dorfl-item-template.js.map +1 -0
  137. package/dist/env-config.d.ts.map +1 -1
  138. package/dist/env-config.js +7 -5
  139. package/dist/env-config.js.map +1 -1
  140. package/dist/frontmatter.d.ts +52 -0
  141. package/dist/frontmatter.d.ts.map +1 -1
  142. package/dist/frontmatter.js +136 -2
  143. package/dist/frontmatter.js.map +1 -1
  144. package/dist/gc.d.ts +13 -0
  145. package/dist/gc.d.ts.map +1 -1
  146. package/dist/gc.js +16 -2
  147. package/dist/gc.js.map +1 -1
  148. package/dist/git.d.ts +18 -1
  149. package/dist/git.d.ts.map +1 -1
  150. package/dist/git.js +70 -5
  151. package/dist/git.js.map +1 -1
  152. package/dist/harness.d.ts +44 -5
  153. package/dist/harness.d.ts.map +1 -1
  154. package/dist/harness.js +24 -1
  155. package/dist/harness.js.map +1 -1
  156. package/dist/index.d.ts +11 -4
  157. package/dist/index.d.ts.map +1 -1
  158. package/dist/index.js +7 -3
  159. package/dist/index.js.map +1 -1
  160. package/dist/install-ci-action-pins.d.ts +123 -0
  161. package/dist/install-ci-action-pins.d.ts.map +1 -0
  162. package/dist/install-ci-action-pins.js +190 -0
  163. package/dist/install-ci-action-pins.js.map +1 -0
  164. package/dist/install-ci-actions-pr-creation.d.ts +70 -0
  165. package/dist/install-ci-actions-pr-creation.d.ts.map +1 -0
  166. package/dist/install-ci-actions-pr-creation.js +106 -0
  167. package/dist/install-ci-actions-pr-creation.js.map +1 -0
  168. package/dist/install-ci-capabilities/dorfl-item.d.ts +12 -0
  169. package/dist/install-ci-capabilities/dorfl-item.d.ts.map +1 -0
  170. package/dist/install-ci-capabilities/dorfl-item.js +26 -0
  171. package/dist/install-ci-capabilities/dorfl-item.js.map +1 -0
  172. package/dist/install-ci-core.d.ts +155 -37
  173. package/dist/install-ci-core.d.ts.map +1 -1
  174. package/dist/install-ci-core.js +304 -200
  175. package/dist/install-ci-core.js.map +1 -1
  176. package/dist/install-ci-github.d.ts +47 -2
  177. package/dist/install-ci-github.d.ts.map +1 -1
  178. package/dist/install-ci-github.js +113 -1
  179. package/dist/install-ci-github.js.map +1 -1
  180. package/dist/install-ci.d.ts +25 -6
  181. package/dist/install-ci.d.ts.map +1 -1
  182. package/dist/install-ci.js +72 -36
  183. package/dist/install-ci.js.map +1 -1
  184. package/dist/intake-marker.d.ts +6 -1
  185. package/dist/intake-marker.d.ts.map +1 -1
  186. package/dist/intake-marker.js.map +1 -1
  187. package/dist/intake-triage.d.ts.map +1 -1
  188. package/dist/intake-triage.js +6 -2
  189. package/dist/intake-triage.js.map +1 -1
  190. package/dist/intake-trigger-template.d.ts +31 -25
  191. package/dist/intake-trigger-template.d.ts.map +1 -1
  192. package/dist/intake-trigger-template.js +107 -292
  193. package/dist/intake-trigger-template.js.map +1 -1
  194. package/dist/intake.d.ts +186 -4
  195. package/dist/intake.d.ts.map +1 -1
  196. package/dist/intake.js +533 -416
  197. package/dist/intake.js.map +1 -1
  198. package/dist/integration-core.d.ts +154 -1
  199. package/dist/integration-core.d.ts.map +1 -1
  200. package/dist/integration-core.js +759 -486
  201. package/dist/integration-core.js.map +1 -1
  202. package/dist/integrator.d.ts +12 -0
  203. package/dist/integrator.d.ts.map +1 -1
  204. package/dist/integrator.js +21 -0
  205. package/dist/integrator.js.map +1 -1
  206. package/dist/isolation.d.ts +12 -2
  207. package/dist/isolation.d.ts.map +1 -1
  208. package/dist/isolation.js +19 -8
  209. package/dist/isolation.js.map +1 -1
  210. package/dist/item-lock.d.ts +93 -11
  211. package/dist/item-lock.d.ts.map +1 -1
  212. package/dist/item-lock.js +233 -65
  213. package/dist/item-lock.js.map +1 -1
  214. package/dist/land-question-residue.d.ts +58 -0
  215. package/dist/land-question-residue.d.ts.map +1 -0
  216. package/dist/land-question-residue.js +95 -0
  217. package/dist/land-question-residue.js.map +1 -0
  218. package/dist/lifecycle-gather.d.ts +16 -0
  219. package/dist/lifecycle-gather.d.ts.map +1 -1
  220. package/dist/lifecycle-gather.js +54 -6
  221. package/dist/lifecycle-gather.js.map +1 -1
  222. package/dist/merge-answer.d.ts +41 -0
  223. package/dist/merge-answer.d.ts.map +1 -0
  224. package/dist/merge-answer.js +60 -0
  225. package/dist/merge-answer.js.map +1 -0
  226. package/dist/merge-question-surfacer.d.ts +105 -30
  227. package/dist/merge-question-surfacer.d.ts.map +1 -1
  228. package/dist/merge-question-surfacer.js +202 -91
  229. package/dist/merge-question-surfacer.js.map +1 -1
  230. package/dist/merge-question-tick.d.ts +64 -0
  231. package/dist/merge-question-tick.d.ts.map +1 -0
  232. package/dist/merge-question-tick.js +96 -0
  233. package/dist/merge-question-tick.js.map +1 -0
  234. package/dist/mint-adr.d.ts.map +1 -1
  235. package/dist/mint-adr.js +20 -2
  236. package/dist/mint-adr.js.map +1 -1
  237. package/dist/needs-attention.d.ts +72 -2
  238. package/dist/needs-attention.d.ts.map +1 -1
  239. package/dist/needs-attention.js +275 -38
  240. package/dist/needs-attention.js.map +1 -1
  241. package/dist/phase-recorder.d.ts +168 -0
  242. package/dist/phase-recorder.d.ts.map +1 -0
  243. package/dist/phase-recorder.js +218 -0
  244. package/dist/phase-recorder.js.map +1 -0
  245. package/dist/phase.d.ts +94 -0
  246. package/dist/phase.d.ts.map +1 -0
  247. package/dist/phase.js +148 -0
  248. package/dist/phase.js.map +1 -0
  249. package/dist/pi-harness.d.ts.map +1 -1
  250. package/dist/pi-harness.js +19 -6
  251. package/dist/pi-harness.js.map +1 -1
  252. package/dist/prepare.d.ts.map +1 -1
  253. package/dist/prepare.js +4 -0
  254. package/dist/prepare.js.map +1 -1
  255. package/dist/prompt.d.ts +4 -0
  256. package/dist/prompt.d.ts.map +1 -1
  257. package/dist/prompt.js +48 -1
  258. package/dist/prompt.js.map +1 -1
  259. package/dist/protocol/CLAIM-PROTOCOL.md +3 -1
  260. package/dist/protocol/WORK-CONTRACT.md +1 -1
  261. package/dist/reap-branches.d.ts +48 -0
  262. package/dist/reap-branches.d.ts.map +1 -1
  263. package/dist/reap-branches.js +64 -0
  264. package/dist/reap-branches.js.map +1 -1
  265. package/dist/ref-write.d.ts +198 -0
  266. package/dist/ref-write.d.ts.map +1 -0
  267. package/dist/ref-write.js +79 -0
  268. package/dist/ref-write.js.map +1 -0
  269. package/dist/repo-config.js +8 -8
  270. package/dist/repo-config.js.map +1 -1
  271. package/dist/repo-mirror.d.ts +36 -0
  272. package/dist/repo-mirror.d.ts.map +1 -1
  273. package/dist/repo-mirror.js +91 -1
  274. package/dist/repo-mirror.js.map +1 -1
  275. package/dist/review-verdict.d.ts +1 -1
  276. package/dist/review-verdict.js +1 -1
  277. package/dist/run.d.ts +7 -1
  278. package/dist/run.d.ts.map +1 -1
  279. package/dist/run.js +28 -4
  280. package/dist/run.js.map +1 -1
  281. package/dist/scan.d.ts +19 -1
  282. package/dist/scan.d.ts.map +1 -1
  283. package/dist/scan.js +21 -8
  284. package/dist/scan.js.map +1 -1
  285. package/dist/sidecar.d.ts +14 -0
  286. package/dist/sidecar.d.ts.map +1 -1
  287. package/dist/sidecar.js +21 -0
  288. package/dist/sidecar.js.map +1 -1
  289. package/dist/single-job-warning.d.ts +63 -0
  290. package/dist/single-job-warning.d.ts.map +1 -0
  291. package/dist/single-job-warning.js +110 -0
  292. package/dist/single-job-warning.js.map +1 -0
  293. package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +3 -1
  294. package/dist/skills/setup/protocol/WORK-CONTRACT.md +1 -1
  295. package/dist/spec-complete.d.ts +13 -6
  296. package/dist/spec-complete.d.ts.map +1 -1
  297. package/dist/spec-complete.js +23 -11
  298. package/dist/spec-complete.js.map +1 -1
  299. package/dist/start.d.ts.map +1 -1
  300. package/dist/start.js +47 -14
  301. package/dist/start.js.map +1 -1
  302. package/dist/status.d.ts +12 -0
  303. package/dist/status.d.ts.map +1 -1
  304. package/dist/status.js +11 -3
  305. package/dist/status.js.map +1 -1
  306. package/dist/tasking.d.ts +246 -1
  307. package/dist/tasking.d.ts.map +1 -1
  308. package/dist/tasking.js +452 -358
  309. package/dist/tasking.js.map +1 -1
  310. package/dist/triage-persist.d.ts.map +1 -1
  311. package/dist/triage-persist.js +21 -2
  312. package/dist/triage-persist.js.map +1 -1
  313. package/dist/verify-workflow-template.d.ts.map +1 -1
  314. package/dist/verify-workflow-template.js +8 -4
  315. package/dist/verify-workflow-template.js.map +1 -1
  316. package/dist/verify.d.ts.map +1 -1
  317. package/dist/verify.js +4 -0
  318. package/dist/verify.js.map +1 -1
  319. package/dist/watch-session.d.ts +42 -13
  320. package/dist/watch-session.d.ts.map +1 -1
  321. package/dist/watch-session.js +52 -23
  322. package/dist/watch-session.js.map +1 -1
  323. package/dist/work-layout.d.ts +10 -4
  324. package/dist/work-layout.d.ts.map +1 -1
  325. package/dist/work-layout.js +11 -3
  326. package/dist/work-layout.js.map +1 -1
  327. package/dist/workspace.d.ts +65 -4
  328. package/dist/workspace.d.ts.map +1 -1
  329. package/dist/workspace.js +159 -11
  330. package/dist/workspace.js.map +1 -1
  331. package/package.json +1 -1
  332. package/src/advance-ci-template.ts +80 -81
  333. package/src/advance-drivers.ts +45 -6
  334. package/src/advance-isolated.ts +3 -5
  335. package/src/advance-lifecycle-template.ts +375 -538
  336. package/src/advance-loop-driver.ts +3 -5
  337. package/src/advance-treeless-publish.ts +61 -0
  338. package/src/advance.ts +145 -15
  339. package/src/advancing-lock.ts +36 -1
  340. package/src/agent-env.ts +174 -0
  341. package/src/agent-stop.ts +81 -0
  342. package/src/apply-merge-action.ts +401 -154
  343. package/src/apply-stuck-action.ts +2 -2
  344. package/src/ci-agent-result.ts +421 -0
  345. package/src/ci-handoff-apply.ts +737 -0
  346. package/src/ci-handoff-format.ts +926 -0
  347. package/src/ci-handoff-lfs.ts +519 -0
  348. package/src/ci-handoff.ts +392 -0
  349. package/src/ci-lock-outputs.ts +273 -0
  350. package/src/ci-phase-build.ts +1328 -0
  351. package/src/ci-phase-driver.ts +548 -0
  352. package/src/ci-phase-intake.ts +861 -0
  353. package/src/ci-phase-tasking.ts +1255 -0
  354. package/src/ci-phase-treeless.ts +2028 -0
  355. package/src/ci-read-token.ts +90 -0
  356. package/src/cli.ts +591 -29
  357. package/src/close-job-template.ts +52 -15
  358. package/src/close-job.ts +77 -12
  359. package/src/complete.ts +19 -1
  360. package/src/config.ts +43 -48
  361. package/src/continue-branch.ts +121 -16
  362. package/src/crashed-run-save.ts +162 -0
  363. package/src/cwd-section.ts +10 -0
  364. package/src/decision-engine.ts +10 -0
  365. package/src/do-config.ts +2 -57
  366. package/src/do.ts +170 -28
  367. package/src/dorfl-item-template.ts +556 -0
  368. package/src/env-config.ts +7 -5
  369. package/src/frontmatter.ts +155 -2
  370. package/src/gc.ts +27 -2
  371. package/src/git.ts +83 -5
  372. package/src/harness.ts +58 -6
  373. package/src/index.ts +50 -1
  374. package/src/install-ci-action-pins.ts +209 -0
  375. package/src/install-ci-actions-pr-creation.ts +186 -0
  376. package/src/install-ci-capabilities/dorfl-item.ts +33 -0
  377. package/src/install-ci-core.ts +388 -233
  378. package/src/install-ci-github.ts +149 -1
  379. package/src/install-ci.ts +83 -37
  380. package/src/intake-marker.ts +6 -1
  381. package/src/intake-triage.ts +7 -3
  382. package/src/intake-trigger-template.ts +126 -327
  383. package/src/intake.ts +725 -544
  384. package/src/integration-core.ts +975 -523
  385. package/src/integrator.ts +28 -0
  386. package/src/isolation.ts +29 -10
  387. package/src/item-lock.ts +308 -92
  388. package/src/land-question-residue.ts +122 -0
  389. package/src/lifecycle-gather.ts +82 -10
  390. package/src/merge-answer.ts +82 -0
  391. package/src/merge-question-surfacer.ts +320 -112
  392. package/src/merge-question-tick.ts +182 -0
  393. package/src/mint-adr.ts +28 -2
  394. package/src/needs-attention.ts +350 -42
  395. package/src/phase-recorder.ts +350 -0
  396. package/src/phase.ts +177 -0
  397. package/src/pi-harness.ts +25 -5
  398. package/src/prepare.ts +4 -0
  399. package/src/prompt.ts +68 -1
  400. package/src/reap-branches.ts +107 -0
  401. package/src/ref-write.ts +324 -0
  402. package/src/repo-config.ts +8 -8
  403. package/src/repo-mirror.ts +107 -1
  404. package/src/review-verdict.ts +1 -1
  405. package/src/run.ts +47 -6
  406. package/src/scan.ts +37 -7
  407. package/src/sidecar.ts +36 -0
  408. package/src/single-job-warning.ts +135 -0
  409. package/src/spec-complete.ts +36 -14
  410. package/src/start.ts +55 -16
  411. package/src/status.ts +29 -3
  412. package/src/tasking.ts +593 -378
  413. package/src/triage-persist.ts +24 -2
  414. package/src/verify-workflow-template.ts +8 -4
  415. package/src/verify.ts +4 -0
  416. package/src/watch-session.ts +86 -28
  417. package/src/work-layout.ts +11 -3
  418. package/src/workspace.ts +210 -14
@@ -0,0 +1,2028 @@
1
+ /**
2
+ * **The tree-less rungs split into the three CI phases** (spec
3
+ * `ci-agent-job-without-write-token` §3 surface, triage and apply rows, ADR
4
+ * `ci-agent-job-holds-no-write-token`, task `ci-split-treeless-rungs`).
5
+ *
6
+ * `advance <item> --phase` on a `surface`, `triage-observation` or `apply`
7
+ * rung runs ONE of three halves of the one rung body in `advance.ts`
8
+ * (`triageRung`, `surfaceRung`, `applyRung` with `applyAgenticDecision` and
9
+ * `maybeRunStuckAction`), never a second implementation. The phase boundary is
10
+ * the rung's existing injectable seams: its agent seams (`surfaceGate`,
11
+ * `triageGate`, `applyDecide`) on one side, its write seams (`surfacePersist`,
12
+ * `applyPersist`, `stampTriaged`, `autoDisposition`, `promote`, `mintAdr`,
13
+ * `stuckAction`) on the other.
14
+ *
15
+ * - **lock** ({@link performTreelessLockPhase}): fetch `<arbiter>/main` (its
16
+ * tip is `baseSha`); in a throwaway worktree of it, classify the item and
17
+ * work out whether the rung launches an agent (`treelessAgentNeed`, decision
18
+ * 11: the triage back-fill and settled no-op, the surface short-circuit, a
19
+ * `kind: stuck` answer and a task/spec content answer need none); take the
20
+ * advancing lock (the unified per-item lock, `action: advance`); publish
21
+ * `acquired`, `needsAgent`, `rung`, `baseSha`, `lockSha`, `handoffName` and
22
+ * `agentTimeoutMinutes`. No agent, no repository code. An answered
23
+ * `kind: merge` entry answered `merge` also publishes `continueTip`, the
24
+ * kept `work/task-<slug>` tip it observed, and needs the agent job only when
25
+ * that branch carries work `main` lacks (task
26
+ * `ci-split-answered-merge-action`).
27
+ * - **agent** ({@link performTreelessAgentPhase}): check, read-only, that the
28
+ * lock ref still equals `lockSha`; run the rung body in a worktree of
29
+ * `baseSha` with the REAL agent seams, each wrapped to capture what the agent
30
+ * emitted, and every write seam replaced by a stub that writes nothing; hand
31
+ * over what the agents decided: `surface` (the surface questions), `triage`
32
+ * (the triage gate's disposition, or `keep` plus the surface questions on
33
+ * the fall-through) or `apply-decision` (the agentic verdict). For an
34
+ * answered merge the rung's merge-action seam runs the agent half of the
35
+ * land instead (`prepareMergeLand`: the `createJob` checkout, the LOCAL
36
+ * rebase, the optional `strictMergeApproval` re-stale check, the
37
+ * fresh-worktree gate on the rebased tip) and hands over `integrate` (the
38
+ * rebased tip bundled), `merge-restale` or `needs-attention`. Writes nothing
39
+ * to the arbiter. A rung that fails writes no handoff and fails the job.
40
+ * - **apply** ({@link performTreelessApplyPhase}): refuse to write unless the
41
+ * lock ref still equals `lockSha`; act on the agent job's result first
42
+ * (`failure` / timeout surfaces the item, a real cancel only releases the
43
+ * lock, `skipped` with `needsAgent: false` runs the deterministic rung); read
44
+ * the handoff as hostile (`readHandoff`: the item, the rung's intent kind,
45
+ * every enum and every slug); then, on a FRESH checkout of `<arbiter>/main`,
46
+ * re-classify the item (a rung or agent need that changed is `stale`, nothing
47
+ * written) and run the SAME rung body with the agent seams replaced by
48
+ * replays of the checked handoff and the real write seams, so the commits are
49
+ * today's; check the publish carries only the rung's own commit
50
+ * (`checkTreelessPublishScope`), publish it (`publishTreelessResult`), and
51
+ * release the lock, leased on `lockSha`. It launches no agent: a gate the
52
+ * handoff does not answer throws. An answered merge's handoff is validated
53
+ * as a bundle (`validateApplyHandoff`, the trusted merge mode); its apply
54
+ * pushes the bundle's LFS objects, then the rebased branch leased on
55
+ * `continueTip` (a stale lease writes nothing), lands it through the
56
+ * unchanged compare-and-swap loop (`integrationLand`, with the gated tip),
57
+ * and only then records the answer on a fresh checkout of the new `main`
58
+ * (two writes to `main`, as on the laptop).
59
+ */
60
+
61
+ import type {
62
+ AdvanceContext,
63
+ AdvanceOutcome,
64
+ RungExecInput,
65
+ RungExecResult,
66
+ RungExecutor,
67
+ TreelessAgentNeed,
68
+ } from './advance.js';
69
+ import {
70
+ answersMerge,
71
+ defaultRungExecutor,
72
+ findItemPath,
73
+ readItemSignals,
74
+ sidecarTypeFor,
75
+ treelessAgentNeed,
76
+ } from './advance.js';
77
+ import {classifyTick} from './advance-classify.js';
78
+ import {
79
+ DEFAULT_TREELESS_JITTER_MS,
80
+ checkTreelessPublishScope,
81
+ } from './advance-treeless-publish.js';
82
+ import {acquireAdvancingLock} from './advancing-lock.js';
83
+ import {
84
+ detectAnsweredMergeAction,
85
+ performMergeAction,
86
+ prepareMergeLand,
87
+ type MergeActionHandler,
88
+ type MergeActionResult,
89
+ type MergeLandPreparation,
90
+ type PreparedMergeLand,
91
+ } from './apply-merge-action.js';
92
+ import type {AgentJobResult, GithubApiGet} from './ci-agent-result.js';
93
+ import {
94
+ HANDOFF_LIMITS,
95
+ HandoffRejected,
96
+ canonicalHandoffItem,
97
+ handoffName as deriveHandoffName,
98
+ type ApplyDecisionProducts,
99
+ type HandoffQuestionEntry,
100
+ type HandoffRecord,
101
+ type NeedsAttentionProducts,
102
+ type SurfaceProducts,
103
+ type TriageProducts,
104
+ } from './ci-handoff-format.js';
105
+ import {readHandoff, writeHandoff} from './ci-handoff.js';
106
+ import {validateApplyHandoff, type ApplyHandoff} from './ci-handoff-apply.js';
107
+ import {readLockOutputsFromEnv, type LockOutputs} from './ci-lock-outputs.js';
108
+ import {
109
+ PhaseDriverError,
110
+ agentTimeoutMinutesAt,
111
+ arbiterLockSha,
112
+ arbiterRefSha,
113
+ boundHandoffText,
114
+ acquireNotingOnce,
115
+ checkLockOwnership,
116
+ emitLockOutputs,
117
+ fetchArbiterMain,
118
+ noSmudgeEnv,
119
+ pushHandoffLfs,
120
+ releaseLockLeased,
121
+ requireHeldLock,
122
+ resolveAgentResult,
123
+ runnerTempFrom,
124
+ taskSourceAtBase,
125
+ withBaseWorktree,
126
+ type HeldLock,
127
+ } from './ci-phase-driver.js';
128
+ import type {DecisionVerdict} from './decision-engine.js';
129
+ import {harnessApplyDecider, type ApplyDecider} from './apply-decide.js';
130
+ import {runAsync} from './git.js';
131
+ import {identityEnv, type Identity} from './identity.js';
132
+ import {integrationLand} from './integration-core.js';
133
+ import {leaseLockReleases} from './item-lock.js';
134
+ import {ledgerRead, type LedgerReadStrategy} from './ledger-read.js';
135
+ import {ledgerWrite} from './ledger-write.js';
136
+ import {surfaceStuckToNeedsAttention} from './needs-attention.js';
137
+ import type {Phase} from './phase.js';
138
+ import {createPhaseRecorder, runAgentPhase} from './phase-recorder.js';
139
+ import {refWrite} from './ref-write.js';
140
+ import {sidecarPathFor} from './sidecar.js';
141
+ import {ensureSafeSlug} from './slug-safety.js';
142
+ import {
143
+ SlugResolutionError,
144
+ parseSlugArg,
145
+ resolveAdvanceArg,
146
+ workBranchRef,
147
+ type SlugNamespace,
148
+ } from './slug-namespace.js';
149
+ import {
150
+ harnessSurfaceGate,
151
+ type SurfaceEmit,
152
+ type SurfaceGate,
153
+ type SurfaceQuestion,
154
+ } from './surface-gate.js';
155
+ import {harnessTriageGate, type TriageEmit} from './triage-gate.js';
156
+
157
+ const DEFAULT_ARBITER = 'origin';
158
+
159
+ /** The tree-less rungs this module splits. */
160
+ export const TREELESS_PHASE_RUNGS = [
161
+ 'surface',
162
+ 'triage-observation',
163
+ 'apply',
164
+ ] as const;
165
+ export type TreelessPhaseRung = (typeof TREELESS_PHASE_RUNGS)[number];
166
+
167
+ /** Whether `rung` is one of the tree-less rungs this module splits. */
168
+ export function isTreelessPhaseRung(
169
+ rung: string | undefined,
170
+ ): rung is TreelessPhaseRung {
171
+ return (TREELESS_PHASE_RUNGS as readonly string[]).includes(rung ?? '');
172
+ }
173
+
174
+ /** The handoff intent each tree-less rung hands over. */
175
+ const INTENT_FOR_RUNG = {
176
+ surface: 'surface',
177
+ 'triage-observation': 'triage',
178
+ apply: 'apply-decision',
179
+ } as const satisfies Record<TreelessPhaseRung, HandoffRecord['intent']['kind']>;
180
+
181
+ /** Options of a tree-less phase run: the advance context plus the phase inputs. */
182
+ export interface TreelessPhaseOptions extends AdvanceContext {
183
+ /** Which of the three jobs this process is. */
184
+ phase: Phase;
185
+ /** The (trusted) workflow argument: `obs:<slug>`, `task:<slug>`, `spec:<slug>`, a bare slug. */
186
+ arg: string;
187
+ /** The checkout the argument is resolved in (agent / apply); default `cwd`. */
188
+ repoPath?: string;
189
+ /** The read seam of the resolver. */
190
+ read?: LedgerReadStrategy;
191
+ /** The git identity of the lock and apply writes. */
192
+ identity?: Identity;
193
+ /** Environment for the phase's own git processes. */
194
+ env?: NodeJS.ProcessEnv;
195
+ /** agent: write the handoff here (empty or absent); apply: read it (under {@link runnerTemp}). */
196
+ handoffDir?: string;
197
+ /** `$RUNNER_TEMP` (the apply phase); defaults to the env's. */
198
+ runnerTemp?: string;
199
+ /** The lock job's outputs (agent / apply); defaults to `DORFL_LOCK_OUTPUTS`. */
200
+ lockOutputs?: LockOutputs;
201
+ /** `github.run_attempt` (the lock phase names the artifact with it); default the env's, else 1. */
202
+ runAttempt?: string;
203
+ /** The `$GITHUB_OUTPUT` file the lock phase appends to; default the env's. */
204
+ githubOutput?: string;
205
+ /** apply: `needs.agent.result` (`--agent-result`), REQUIRED. Only `success` reads the handoff. */
206
+ agentResult?: AgentJobResult;
207
+ /** apply: the agent job's timeout (`--agent-timeout-minutes`); default the lock outputs'. */
208
+ agentTimeoutMinutes?: number;
209
+ /** apply: the Actions API reader (tests stub it); default `fetch` with `GITHUB_TOKEN`. */
210
+ actionsApi?: GithubApiGet;
211
+ /** The rung bodies (tests inject one); default the production {@link defaultRungExecutor}. */
212
+ executor?: RungExecutor;
213
+ /** apply: the tree-less publish's liveness ceiling (default 1000, as the laptop driver). */
214
+ publishRetries?: number;
215
+ /** apply: the tree-less publish's retry jitter in ms (tests pass 0). */
216
+ publishJitterMs?: number;
217
+ /** apply (answered merge): the land's compare-and-swap liveness ceiling; default `doOptions.mergeRetries`. */
218
+ mergeRetries?: number;
219
+ /** apply (answered merge): the land's refetch jitter in ms (tests pass 0). */
220
+ mergeJitterMs?: number;
221
+ }
222
+
223
+ /** How a tree-less phase run ended. */
224
+ export type TreelessPhaseOutcome =
225
+ /** lock: the item is locked; the agent phase (or, `needsAgent: false`, the apply phase) runs next. */
226
+ | 'locked'
227
+ /** lock: nothing to do at the arbiter's `main` (a pending sidecar, or the item is gone). */
228
+ | 'no-op'
229
+ /** lock: the item's `needsAnswers` flag and sidecar disagree; nothing was written. */
230
+ | 'invariant-violation'
231
+ /** lock: another run holds the item's lock. */
232
+ | 'lost'
233
+ /** agent / apply: the lock ref no longer equals `lockSha`; nothing was done. */
234
+ | 'stale-lock'
235
+ /** agent: the handoff was written (see `intent`). */
236
+ | 'handed-over'
237
+ /** agent: the rung (or the phase) failed; no handoff (the job fails). */
238
+ | 'agent-failed'
239
+ /** apply: the rung ran, its result is published and the lock released. */
240
+ | 'applied'
241
+ /** apply: the rung itself failed (its commit, if any, is not published); the lock was released. */
242
+ | 'rung-failed'
243
+ /** apply: the item was surfaced to needs-attention and its lock released. */
244
+ | 'surfaced'
245
+ /**
246
+ * apply (answered merge): the kept work branch moved on the arbiter since the
247
+ * lock job saw it (`continueTip`), so the leased push of the rebased branch
248
+ * was refused; nothing was written and the lock stays held.
249
+ */
250
+ | 'stale-lease'
251
+ /** apply (answered merge): the land did not reach `main` and did not surface the item; the lock was released. */
252
+ | 'land-failed'
253
+ /** apply: surfacing the item did not land on the arbiter (the lock stays held). */
254
+ | 'surface-unmoved'
255
+ /** apply: the handoff broke a rule; nothing from it was written, the item was surfaced. */
256
+ | 'rejected'
257
+ /** apply: the item no longer classifies as the lock job saw it; nothing was written. */
258
+ | 'stale'
259
+ /** apply: the publish would carry commits that are not the rung's own; nothing was published. */
260
+ | 'publish-refused'
261
+ /** apply: the rung's commit did not reach the arbiter's `main`. */
262
+ | 'publish-failed'
263
+ /** apply: the agent job was cancelled (not timed out); the lock was only released. */
264
+ | 'released'
265
+ /** apply: the cancelled agent job's leased lock release was refused. */
266
+ | 'release-refused'
267
+ /** A usage or environment problem. */
268
+ | 'usage-error';
269
+
270
+ /** The result of one tree-less phase run. */
271
+ export interface TreelessPhaseResult {
272
+ /** See "A handled outcome is green" in `ci-phase-driver.ts`. */
273
+ exitCode: 0 | 1 | 2 | 3;
274
+ outcome: TreelessPhaseOutcome;
275
+ /**
276
+ * The result line: the CLI prints it (`>> ` or `error: `), so the phase does
277
+ * NOT also `note` it (each line appears once in the job log).
278
+ */
279
+ message: string;
280
+ /** The canonical item (`<namespace>:<slug>`). */
281
+ item?: string;
282
+ /** lock: the facts published. */
283
+ lockOutputs?: LockOutputs;
284
+ /** agent: the handed-over intent kind. */
285
+ intent?: HandoffRecord['intent']['kind'];
286
+ /** apply: the rung body's own outcome. */
287
+ rungOutcome?: AdvanceOutcome;
288
+ }
289
+
290
+ /**
291
+ * Run one phase of a tree-less rung. The CLI calls this for `advance <item>
292
+ * --phase` when the item classifies (lock) or was classified (agent, apply) on
293
+ * the `surface`, `triage-observation` or `apply` rung.
294
+ */
295
+ export async function performTreelessPhase(
296
+ options: TreelessPhaseOptions,
297
+ ): Promise<TreelessPhaseResult> {
298
+ try {
299
+ switch (options.phase) {
300
+ case 'lock':
301
+ return await performTreelessLockPhase(options);
302
+ case 'agent':
303
+ return await performTreelessAgentPhase(options);
304
+ case 'apply':
305
+ return await performTreelessApplyPhase(options);
306
+ }
307
+ } catch (err) {
308
+ if (err instanceof PhaseDriverError || err instanceof SlugResolutionError) {
309
+ return {exitCode: 1, outcome: 'usage-error', message: err.message};
310
+ }
311
+ throw err;
312
+ }
313
+ }
314
+
315
+ /** The resolved item a phase acts on. */
316
+ interface ResolvedItem {
317
+ namespace: SlugNamespace;
318
+ slug: string;
319
+ item: string;
320
+ }
321
+
322
+ /** The item a tree-less phase acts on, from the (trusted) workflow argument. */
323
+ function treelessItem(
324
+ options: TreelessPhaseOptions,
325
+ repoPath: string,
326
+ ): ResolvedItem {
327
+ const resolved = resolveAdvanceArg({
328
+ arg: options.arg,
329
+ repoPath,
330
+ read: options.read ?? ledgerRead,
331
+ });
332
+ return {
333
+ namespace: resolved.namespace,
334
+ slug: resolved.slug,
335
+ item: `${resolved.namespace}:${resolved.slug}`,
336
+ };
337
+ }
338
+
339
+ /** Classify `resolved` in the tree at `dir` (read-only, no model). */
340
+ function classifyIn(
341
+ dir: string,
342
+ resolved: ResolvedItem,
343
+ ): ReturnType<typeof classifyTick> {
344
+ const type = sidecarTypeFor(resolved.namespace);
345
+ const signals = readItemSignals({
346
+ repoPath: dir,
347
+ type,
348
+ slug: resolved.slug,
349
+ item: resolved.item,
350
+ });
351
+ return classifyTick({type, ...signals});
352
+ }
353
+
354
+ /** The rung input for `resolved` with `context`. */
355
+ function rungInput(
356
+ resolved: ResolvedItem,
357
+ classification: ReturnType<typeof classifyTick>,
358
+ context: AdvanceContext,
359
+ ): RungExecInput {
360
+ return {
361
+ item: resolved.item,
362
+ namespace: resolved.namespace,
363
+ slug: resolved.slug,
364
+ classification,
365
+ context,
366
+ };
367
+ }
368
+
369
+ /**
370
+ * The kept `work/task-<slug>` tip of an answered merge, when it carries work
371
+ * `mainSha` lacks; `undefined` when the branch is absent or already on `main`
372
+ * (nothing to land: the laptop's `already-integrated`). Reads the arbiter and
373
+ * fetches the branch's objects only: nothing is checked out and no repository
374
+ * code runs, so the lock phase may call it.
375
+ */
376
+ async function keptMergeTip(params: {
377
+ cwd: string;
378
+ arbiter: string;
379
+ slug: string;
380
+ mainSha: string;
381
+ env: NodeJS.ProcessEnv;
382
+ }): Promise<string | undefined> {
383
+ const {cwd, arbiter, slug, mainSha, env} = params;
384
+ const branch = workBranchRef('task', slug);
385
+ const ref = `refs/heads/${branch}`;
386
+ if ((await arbiterRefSha({cwd, arbiter, ref, env})) === undefined) {
387
+ return undefined;
388
+ }
389
+ const tracking = `refs/remotes/${arbiter}/${branch}`;
390
+ const fetched = await runAsync(
391
+ 'git',
392
+ ['fetch', '--quiet', '--no-tags', arbiter, `+${ref}:${tracking}`],
393
+ cwd,
394
+ {env},
395
+ );
396
+ if (fetched.status !== 0) {
397
+ throw new Error(
398
+ `fetching ${branch} from ${arbiter} failed: ${fetched.stderr.trim()}`,
399
+ );
400
+ }
401
+ const tip = (
402
+ await runAsync('git', ['rev-parse', '--verify', tracking], cwd, {env})
403
+ ).stdout.trim();
404
+ const onMain = await runAsync(
405
+ 'git',
406
+ ['merge-base', '--is-ancestor', tip, mainSha],
407
+ cwd,
408
+ {env},
409
+ );
410
+ return onMain.status === 0 ? undefined : tip;
411
+ }
412
+
413
+ /**
414
+ * The agent need of a tree-less rung as the CI phases see it:
415
+ * {@link treelessAgentNeed} (the tree), refined for an answered `merge` by the
416
+ * arbiter's kept branch ({@link keptMergeTip}), which the tree cannot show. The
417
+ * lock phase publishes the result (and the branch tip as `continueTip`); the
418
+ * apply phase recomputes it on its fresh checkout.
419
+ */
420
+ async function treelessPhaseNeed(
421
+ input: RungExecInput,
422
+ params: {
423
+ cwd: string;
424
+ arbiter: string;
425
+ mainSha: string;
426
+ env: NodeJS.ProcessEnv;
427
+ },
428
+ ): Promise<{need: TreelessAgentNeed; continueTip?: string}> {
429
+ const need = treelessAgentNeed(input);
430
+ if (need.needsAgent !== true || input.classification.kind !== 'apply') {
431
+ return {need};
432
+ }
433
+ const merge = detectAnsweredMergeAction(input.context.cwd, input.item);
434
+ if (merge === undefined) return {need};
435
+ const tip = await keptMergeTip({...params, slug: input.slug});
436
+ return tip === undefined
437
+ ? {need: {needsAgent: false}}
438
+ : {need, continueTip: tip};
439
+ }
440
+
441
+ /** Dispatch the tree-less rung to its body. */
442
+ function dispatchTreeless(
443
+ executor: RungExecutor,
444
+ rung: TreelessPhaseRung,
445
+ input: RungExecInput,
446
+ ): Promise<RungExecResult> {
447
+ switch (rung) {
448
+ case 'triage-observation':
449
+ return executor.triageObservation(input);
450
+ case 'surface':
451
+ return executor.surface(input);
452
+ case 'apply':
453
+ return executor.apply(input);
454
+ }
455
+ }
456
+
457
+ /** The held lock of an agent or apply phase, whose rung must be a tree-less one. */
458
+ function requireTreelessLock(
459
+ options: TreelessPhaseOptions,
460
+ env: NodeJS.ProcessEnv,
461
+ ): HeldLock & {rung: TreelessPhaseRung} {
462
+ const facts = options.lockOutputs ?? readLockOutputsFromEnv(env);
463
+ if (!isTreelessPhaseRung(facts.rung)) {
464
+ throw new PhaseDriverError(
465
+ `the lock job classified the rung ${String(facts.rung)}, not a tree-less rung`,
466
+ );
467
+ }
468
+ const held = requireHeldLock({lockOutputs: facts, env, rung: facts.rung});
469
+ return {...held, rung: facts.rung};
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // lock
474
+ // ---------------------------------------------------------------------------
475
+
476
+ /**
477
+ * The lock phase: classify the item at the arbiter's `main`, take the advancing
478
+ * lock and publish the trusted facts. Runs no agent and no repository code.
479
+ */
480
+ export async function performTreelessLockPhase(
481
+ options: TreelessPhaseOptions,
482
+ ): Promise<TreelessPhaseResult> {
483
+ const note = options.note ?? (() => {});
484
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
485
+ const cwd = options.cwd;
486
+ const env = identityEnv(options.identity, options.env ?? process.env);
487
+ const githubOutput = options.githubOutput ?? env.GITHUB_OUTPUT;
488
+ const publish = (facts: LockOutputs): LockOutputs => {
489
+ emitLockOutputs(facts, {githubOutput, note});
490
+ return facts;
491
+ };
492
+
493
+ const baseSha = await fetchArbiterMain({cwd, arbiter, env});
494
+ type Verdict = {resolved: ResolvedItem} & (
495
+ | {skip: string}
496
+ | {invariant: string}
497
+ | {
498
+ rung: TreelessPhaseRung;
499
+ needsAgent: boolean;
500
+ continueTip?: string;
501
+ takeOverProposeKept: boolean;
502
+ }
503
+ );
504
+ const verdict = await withBaseWorktree(
505
+ {cwd, baseSha, env},
506
+ async (base): Promise<Verdict> => {
507
+ const resolved = treelessItem(options, base);
508
+ const classification = classifyIn(base, resolved);
509
+ const kind = classification.kind;
510
+ if (kind === 'no-op') {
511
+ return {
512
+ resolved,
513
+ skip: `no-op for ${resolved.item} at ${baseSha} (${classification.reason ?? 'nothing to advance'})`,
514
+ };
515
+ }
516
+ if (kind === 'invariant-violation') {
517
+ return {
518
+ resolved,
519
+ invariant:
520
+ `refusing to advance ${resolved.item}: the \`needsAnswers\` flag and ` +
521
+ `the sidecar disagree at ${baseSha} (${classification.reason ?? 'invariant violation'}). ` +
522
+ 'A human must reconcile them.',
523
+ };
524
+ }
525
+ if (!isTreelessPhaseRung(kind)) {
526
+ throw new PhaseDriverError(
527
+ `${resolved.item} classifies as ${kind} at ${baseSha}, not a tree-less rung`,
528
+ );
529
+ }
530
+ if (findItemPath(base, resolved.namespace, resolved.slug) === undefined) {
531
+ return {
532
+ resolved,
533
+ skip: `no-op for ${resolved.item}: its item file is not on ${arbiter}/main (${baseSha})`,
534
+ };
535
+ }
536
+ const {need, continueTip} = await treelessPhaseNeed(
537
+ rungInput(resolved, classification, {...options, cwd: base}),
538
+ {cwd, arbiter, mainSha: baseSha, env},
539
+ );
540
+ if (need.needsAgent === undefined) {
541
+ throw new PhaseDriverError(
542
+ `--phase does not split ${resolved.item} yet: ${need.unsplit}`,
543
+ );
544
+ }
545
+ return {
546
+ resolved,
547
+ rung: kind,
548
+ needsAgent: need.needsAgent,
549
+ ...(continueTip === undefined ? {} : {continueTip}),
550
+ takeOverProposeKept: answersMerge(
551
+ base,
552
+ resolved.item,
553
+ resolved.namespace,
554
+ classification,
555
+ ),
556
+ };
557
+ },
558
+ );
559
+ const {item} = verdict.resolved;
560
+ if ('skip' in verdict) {
561
+ return {
562
+ exitCode: 0,
563
+ outcome: 'no-op',
564
+ item,
565
+ message: verdict.skip,
566
+ lockOutputs: publish({acquired: false, baseSha}),
567
+ };
568
+ }
569
+ if ('invariant' in verdict) {
570
+ return {
571
+ exitCode: 1,
572
+ outcome: 'invariant-violation',
573
+ item,
574
+ message: verdict.invariant,
575
+ lockOutputs: publish({acquired: false, baseSha}),
576
+ };
577
+ }
578
+ const {rung, needsAgent, continueTip, takeOverProposeKept} = verdict;
579
+ // An answered `merge` may take over the lock a finished propose build of the
580
+ // same item keeps held (task `wire-merge-questions-into-the-advance-tick`,
581
+ // decisions 2 and 4), so it does not back off on a held lock here: the
582
+ // acquire below takes the lock over ONLY when it carries the `propose-pr`
583
+ // marker, and reports any other hold as `lost`.
584
+ if (
585
+ !takeOverProposeKept &&
586
+ (await arbiterLockSha({cwd, arbiter, item, env})) !== undefined
587
+ ) {
588
+ const message = `'${item}' is already locked on ${arbiter}; backing off.`;
589
+ return {
590
+ exitCode: 0,
591
+ outcome: 'lost',
592
+ item,
593
+ message,
594
+ lockOutputs: publish({acquired: false, rung, baseSha}),
595
+ };
596
+ }
597
+ const acquired = await acquireNotingOnce(note, (acquireNote) =>
598
+ acquireAdvancingLock({
599
+ item,
600
+ cwd,
601
+ arbiter,
602
+ acquireUnified: true,
603
+ takeOverProposeKept,
604
+ env,
605
+ note: acquireNote,
606
+ }),
607
+ );
608
+ if (acquired.exitCode !== 0) {
609
+ const outcome = acquired.outcome === 'usage-error' ? 'usage-error' : 'lost';
610
+ return {
611
+ // A lock lost to another run is handled, so green ("A handled
612
+ // outcome is green", `ci-phase-driver.ts`).
613
+ exitCode: outcome === 'lost' ? 0 : acquired.exitCode,
614
+ outcome,
615
+ item,
616
+ message: acquired.message,
617
+ lockOutputs: publish({acquired: false, rung, baseSha}),
618
+ };
619
+ }
620
+ const lockSha = await arbiterLockSha({cwd, arbiter, item, env});
621
+ if (lockSha === undefined) {
622
+ throw new Error(`the lock of ${item} vanished right after the acquire`);
623
+ }
624
+ const facts = publish({
625
+ acquired: true,
626
+ needsAgent,
627
+ rung,
628
+ baseSha,
629
+ lockSha,
630
+ // An answered merge's kept branch as the lock job saw it: the apply job
631
+ // pushes the rebased tip leased on it (decision 7).
632
+ ...(continueTip === undefined ? {} : {continueTip}),
633
+ handoffName: deriveHandoffName(
634
+ item,
635
+ options.runAttempt ?? env.GITHUB_RUN_ATTEMPT ?? '1',
636
+ ),
637
+ agentTimeoutMinutes: agentTimeoutMinutesAt(cwd, baseSha, env),
638
+ });
639
+ return {
640
+ exitCode: 0,
641
+ outcome: 'locked',
642
+ item,
643
+ message:
644
+ `locked ${item} on the ${rung} rung at ${baseSha} (lock ${lockSha}; ` +
645
+ `${needsAgent ? 'an agent runs next' : 'no agent is needed'})`,
646
+ lockOutputs: facts,
647
+ };
648
+ }
649
+
650
+ // ---------------------------------------------------------------------------
651
+ // agent
652
+ // ---------------------------------------------------------------------------
653
+
654
+ /** What the agent phase's wrapped agent seams captured, and which write seams the rung reached. */
655
+ interface Captured {
656
+ surface?: SurfaceEmit;
657
+ triage?: TriageEmit;
658
+ verdict?: DecisionVerdict;
659
+ surfacePersisted: boolean;
660
+ autoDisposed: boolean;
661
+ /** The answered merge's agent half, whose job worktree the handoff bundle reads. */
662
+ merge?: PreparedMergeLand;
663
+ }
664
+
665
+ const RECORDED =
666
+ 'recorded for the CI apply phase (the agent job writes nothing)';
667
+
668
+ /**
669
+ * The advance context of the agent phase: the real agent seams, each wrapped to
670
+ * capture its emit, and every write seam a stub that writes nothing.
671
+ */
672
+ function agentPhaseContext(
673
+ options: TreelessPhaseOptions,
674
+ dir: string,
675
+ captured: Captured,
676
+ ): AdvanceContext {
677
+ const surfaceGate = options.surfaceGate ?? harnessSurfaceGate();
678
+ const triageGate = options.triageGate ?? harnessTriageGate();
679
+ const decider = options.applyDecide ?? harnessApplyDecider();
680
+ return {
681
+ ...options,
682
+ cwd: dir,
683
+ surfaceGate: async (input) => {
684
+ const emit = await surfaceGate(input);
685
+ captured.surface = emit;
686
+ return emit;
687
+ },
688
+ triageGate: async (input) => {
689
+ const emit = await triageGate(input);
690
+ captured.triage = emit;
691
+ return emit;
692
+ },
693
+ applyDecide: async (input) => {
694
+ const verdict = await decider(input);
695
+ captured.verdict = verdict;
696
+ return verdict;
697
+ },
698
+ surfacePersist: (o) => {
699
+ captured.surfacePersisted = true;
700
+ return {
701
+ outcome: o.questions.length > 0 ? 'surfaced' : 'nothing',
702
+ sidecarPath: sidecarPathFor(o.item),
703
+ entryCount: o.questions.length,
704
+ };
705
+ },
706
+ applyPersist: (o) => ({
707
+ outcome:
708
+ o.appendQuestions !== undefined && o.appendQuestions.length > 0
709
+ ? 'repaused'
710
+ : o.dispose !== undefined
711
+ ? 'disposed'
712
+ : 'resolved',
713
+ sidecarPath: sidecarPathFor(o.item),
714
+ itemPath: o.itemPath,
715
+ message: RECORDED,
716
+ }),
717
+ stampTriaged: () => ({message: RECORDED}),
718
+ autoDisposition: (o) => {
719
+ captured.autoDisposed = true;
720
+ return {
721
+ outcome: 'deleted',
722
+ commit: '',
723
+ itemPath: o.itemPath,
724
+ message: RECORDED,
725
+ };
726
+ },
727
+ promote: async () => ({
728
+ outcome: 'promoted',
729
+ exitCode: 0,
730
+ message: RECORDED,
731
+ }),
732
+ mintAdr: async () => ({outcome: 'minted', exitCode: 0, message: RECORDED}),
733
+ stuckAction: async () => ({outcome: 'keep', message: RECORDED}),
734
+ mergeAction: async (input) => {
735
+ if (input.workspacesDir === '') {
736
+ throw new Error(
737
+ 'the answered merge action needs a workspacesDir to check the ' +
738
+ 'work branch out (createJob)',
739
+ );
740
+ }
741
+ const prepared = await prepareMergeLand(input);
742
+ captured.merge = prepared;
743
+ return mergeResultFor(prepared.preparation);
744
+ },
745
+ };
746
+ }
747
+
748
+ /**
749
+ * The merge-action result the rung body sees for the agent half's preparation
750
+ * (it only steers the rung's recording stubs; the handoff comes from the
751
+ * preparation itself, {@link mergeHandover}).
752
+ */
753
+ function mergeResultFor(p: MergeLandPreparation): MergeActionResult {
754
+ switch (p.kind) {
755
+ case 'integrate':
756
+ return {outcome: 'landed', message: RECORDED};
757
+ case 'restale':
758
+ return {outcome: 'restale', message: p.message, main: p.main};
759
+ case 'needs-attention':
760
+ return {outcome: 'refused', message: p.reason};
761
+ case 'already-integrated':
762
+ return {outcome: 'already-integrated', message: p.message};
763
+ }
764
+ }
765
+
766
+ /** What the agent phase writes: the record, and the work branch to bundle. */
767
+ interface TreelessHandover {
768
+ record: HandoffRecord;
769
+ bundle?: {repo: string; workBranch: string; baseSha: string};
770
+ }
771
+
772
+ /**
773
+ * The handoff of an answered merge's agent half: `integrate` with the rebased
774
+ * tip, `merge-restale`, or `needs-attention` (the red rebased tip bundled, or
775
+ * nothing on a rebase conflict). A branch that landed since the lock job ran
776
+ * has nothing to hand over, so the agent job fails and the apply job surfaces
777
+ * the item.
778
+ */
779
+ function mergeHandover(
780
+ item: string,
781
+ p: MergeLandPreparation,
782
+ baseSha: string,
783
+ ): TreelessHandover {
784
+ switch (p.kind) {
785
+ case 'integrate':
786
+ return {
787
+ record: {schema: 1, item, intent: {kind: 'integrate'}, products: {}},
788
+ bundle: {repo: p.repo, workBranch: p.workBranch, baseSha},
789
+ };
790
+ case 'restale':
791
+ return {
792
+ record: {
793
+ schema: 1,
794
+ item,
795
+ intent: {kind: 'merge-restale'},
796
+ products: {},
797
+ },
798
+ };
799
+ case 'needs-attention': {
800
+ const reason = (t: string): string =>
801
+ boundHandoffText(t, HANDOFF_LIMITS.reasonChars);
802
+ const products: NeedsAttentionProducts = {
803
+ reason: reason(p.reason),
804
+ ...(p.questions === undefined || p.questions.length === 0
805
+ ? {}
806
+ : {questions: p.questions.map(reason)}),
807
+ };
808
+ return {
809
+ record: {
810
+ schema: 1,
811
+ item,
812
+ intent: {kind: 'needs-attention'},
813
+ products,
814
+ },
815
+ ...(p.bundle === undefined ? {} : {bundle: {...p.bundle, baseSha}}),
816
+ };
817
+ }
818
+ case 'already-integrated':
819
+ throw new Error(
820
+ `the answered merge of ${item} has nothing to land: ${p.message}`,
821
+ );
822
+ }
823
+ }
824
+
825
+ /**
826
+ * The surface questions of a captured emit, bounded for the handoff, each with
827
+ * the context and suggested default the agent gave it. A question with neither
828
+ * travels as its bare text (the plain form every schema-1 reader accepts), so
829
+ * only a question that has more to carry needs the object form.
830
+ */
831
+ function questionEntries(
832
+ emit: SurfaceEmit | undefined,
833
+ ): HandoffQuestionEntry[] {
834
+ const bound = (t: string): string =>
835
+ boundHandoffText(t, HANDOFF_LIMITS.reasonChars);
836
+ const entries: HandoffQuestionEntry[] = [];
837
+ for (const q of emit?.questions ?? []) {
838
+ const question = bound(q.question);
839
+ if (question.trim() === '') continue;
840
+ if (q.context === undefined && q.default === undefined) {
841
+ entries.push(question);
842
+ continue;
843
+ }
844
+ entries.push({
845
+ question,
846
+ ...(q.context === undefined ? {} : {context: bound(q.context)}),
847
+ ...(q.default === undefined ? {} : {default: bound(q.default)}),
848
+ });
849
+ }
850
+ return entries;
851
+ }
852
+
853
+ /** The ask's follow-up questions of a verdict, kept separate (never joined). */
854
+ function askQuestions(verdict: DecisionVerdict): string[] {
855
+ const questions =
856
+ verdict.questions !== undefined && verdict.questions.length > 0
857
+ ? verdict.questions
858
+ : [verdict.question ?? ''];
859
+ return questions.map((q) => boundHandoffText(q, HANDOFF_LIMITS.reasonChars));
860
+ }
861
+
862
+ /** Control characters and line separators (a one-line handoff field refuses them). */
863
+ // eslint-disable-next-line no-control-regex
864
+ const LINE_BREAKING_RE = /[\u0000-\u001f\u007f\u2028\u2029]+/g;
865
+
866
+ /** A drafted title as the handoff's one-line field (or `fallback`). */
867
+ function oneLine(text: string | undefined, fallback: string): string {
868
+ const line = (text ?? '')
869
+ .replace(LINE_BREAKING_RE, ' ')
870
+ .trim()
871
+ .slice(0, HANDOFF_LIMITS.documentTitleChars)
872
+ .trim();
873
+ return line === '' ? fallback : line;
874
+ }
875
+
876
+ /**
877
+ * Map the agentic apply verdict to the `apply-decision` products. A mint keeps
878
+ * the laptop defaults: no drafted slug is the observation's slug (sanitised as
879
+ * `promoteObservation` / `mintAdr` sanitise it), no drafted body is `''` (the
880
+ * writer then builds the self-contained body from the note, as it does when the
881
+ * verdict carries none), and no title is the slug (the ADR default; a task or
882
+ * spec draft's title is not used by the writer today).
883
+ */
884
+ function decisionProducts(
885
+ verdict: DecisionVerdict,
886
+ itemSlug: string,
887
+ ): ApplyDecisionProducts {
888
+ const reason = (r: string | undefined): {reason?: string} =>
889
+ r === undefined || r.trim() === ''
890
+ ? {}
891
+ : {reason: boundHandoffText(r, HANDOFF_LIMITS.reasonChars)};
892
+ const mint = (
893
+ outcome: 'task' | 'spec' | 'adr',
894
+ slug: string | undefined,
895
+ title: string | undefined,
896
+ body: string | undefined,
897
+ ): ApplyDecisionProducts => {
898
+ const safe = ensureSafeSlug(slug ?? itemSlug) || itemSlug;
899
+ return {
900
+ outcome,
901
+ slug: safe,
902
+ title: oneLine(title, safe),
903
+ body: boundHandoffText(body ?? '', HANDOFF_LIMITS.documentChars),
904
+ };
905
+ };
906
+ switch (verdict.outcome) {
907
+ case 'task':
908
+ return mint(
909
+ 'task',
910
+ verdict.taskSlug,
911
+ verdict.taskTitle,
912
+ verdict.taskBody,
913
+ );
914
+ case 'spec':
915
+ return mint(
916
+ 'spec',
917
+ verdict.specSlug,
918
+ verdict.specTitle,
919
+ verdict.specBody,
920
+ );
921
+ case 'adr':
922
+ return mint('adr', verdict.adrSlug, verdict.adrTitle, verdict.adrBody);
923
+ case 'dispose':
924
+ return {outcome: 'dispose', ...reason(verdict.disposeReason)};
925
+ case 'resolve':
926
+ return {outcome: 'resolve', ...reason(verdict.resolveReason)};
927
+ case 'ask':
928
+ return {
929
+ outcome: 'ask',
930
+ questions: askQuestions(verdict),
931
+ };
932
+ default:
933
+ throw new Error(
934
+ `the decision verdict ${String(verdict.outcome)} cannot be handed over`,
935
+ );
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Run the rung body under the recorder and map what its agents decided to the
941
+ * handoff record. Throws when the rung failed or reached nothing to hand over.
942
+ */
943
+ async function treelessHandover(params: {
944
+ options: TreelessPhaseOptions;
945
+ rung: TreelessPhaseRung;
946
+ resolved: ResolvedItem;
947
+ dir: string;
948
+ baseSha: string;
949
+ captured: Captured;
950
+ }): Promise<TreelessHandover> {
951
+ const {options, rung, resolved, dir, captured} = params;
952
+ const {item} = resolved;
953
+ const classification = classifyIn(dir, resolved);
954
+ if (classification.kind !== rung) {
955
+ throw new Error(
956
+ `${item} classifies as ${classification.kind} at the base, not ${rung}`,
957
+ );
958
+ }
959
+ const exec = await dispatchTreeless(
960
+ options.executor ?? defaultRungExecutor,
961
+ rung,
962
+ rungInput(
963
+ resolved,
964
+ classification,
965
+ agentPhaseContext(options, dir, captured),
966
+ ),
967
+ );
968
+ // The answered merge: what its agent half found IS the handover (a red gate
969
+ // or a conflict ends the rung as `merge-refused`, which is still handed over).
970
+ if (captured.merge !== undefined) {
971
+ return mergeHandover(item, captured.merge.preparation, params.baseSha);
972
+ }
973
+ if (exec.exitCode !== 0) {
974
+ throw new Error(`the ${rung} rung failed: ${exec.message}`);
975
+ }
976
+ const nothing = (): never => {
977
+ throw new Error(
978
+ `the ${rung} rung of ${item} reached nothing to hand over (${exec.outcome}: ${exec.message})`,
979
+ );
980
+ };
981
+ return {record: capturedRecord(rung, resolved, captured, nothing)};
982
+ }
983
+
984
+ /** The handoff record of what the rung's agents decided (see {@link treelessHandover}). */
985
+ function capturedRecord(
986
+ rung: TreelessPhaseRung,
987
+ resolved: ResolvedItem,
988
+ captured: Captured,
989
+ nothing: () => never,
990
+ ): HandoffRecord {
991
+ const {item} = resolved;
992
+ switch (rung) {
993
+ case 'surface':
994
+ if (!captured.surfacePersisted) nothing();
995
+ return {
996
+ schema: 1,
997
+ item,
998
+ intent: {kind: 'surface'},
999
+ products: {
1000
+ questions: questionEntries(captured.surface),
1001
+ } satisfies SurfaceProducts,
1002
+ };
1003
+ case 'triage-observation': {
1004
+ const t = captured.triage;
1005
+ if (captured.autoDisposed && t?.auto === true) {
1006
+ return {
1007
+ schema: 1,
1008
+ item,
1009
+ intent: {kind: 'triage'},
1010
+ products: {
1011
+ disposition: t.kind,
1012
+ target: canonicalHandoffItem(t.existing),
1013
+ ...(t.reason.trim() === ''
1014
+ ? {}
1015
+ : {
1016
+ reason: boundHandoffText(
1017
+ t.reason,
1018
+ HANDOFF_LIMITS.reasonChars,
1019
+ ),
1020
+ }),
1021
+ } satisfies TriageProducts,
1022
+ };
1023
+ }
1024
+ if (!captured.surfacePersisted) nothing();
1025
+ return {
1026
+ schema: 1,
1027
+ item,
1028
+ intent: {kind: 'triage'},
1029
+ products: {
1030
+ disposition: 'keep',
1031
+ questions: questionEntries(captured.surface),
1032
+ } satisfies TriageProducts,
1033
+ };
1034
+ }
1035
+ case 'apply':
1036
+ if (captured.verdict === undefined) nothing();
1037
+ return {
1038
+ schema: 1,
1039
+ item,
1040
+ intent: {kind: 'apply-decision'},
1041
+ products: decisionProducts(
1042
+ captured.verdict as DecisionVerdict,
1043
+ resolved.slug,
1044
+ ),
1045
+ };
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * The agent phase: after a read-only lock-ownership check, run the rung body in
1051
+ * a worktree of `baseSha` with the real agents and no write, and write the
1052
+ * handoff. Holds a read-only token. Any failure writes no handoff and fails the
1053
+ * job (the apply job then surfaces the item).
1054
+ */
1055
+ export async function performTreelessAgentPhase(
1056
+ options: TreelessPhaseOptions,
1057
+ ): Promise<TreelessPhaseResult> {
1058
+ const note = options.note ?? (() => {});
1059
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
1060
+ const cwd = options.cwd;
1061
+ const env = options.env ?? process.env;
1062
+ const held = requireTreelessLock(options, env);
1063
+ if (options.handoffDir === undefined) {
1064
+ throw new PhaseDriverError('the agent phase needs a handoff directory');
1065
+ }
1066
+ if (held.needsAgent === false) {
1067
+ throw new PhaseDriverError(
1068
+ `the lock job said the ${held.rung} rung needs no agent; the agent job ` +
1069
+ 'must be skipped',
1070
+ );
1071
+ }
1072
+ const resolved = treelessItem(options, options.repoPath ?? cwd);
1073
+ const {item} = resolved;
1074
+
1075
+ const ownership = await checkLockOwnership({
1076
+ cwd,
1077
+ arbiter,
1078
+ item,
1079
+ lockSha: held.lockSha,
1080
+ env,
1081
+ });
1082
+ if (!ownership.owned) {
1083
+ return {
1084
+ exitCode: 0,
1085
+ outcome: 'stale-lock',
1086
+ item,
1087
+ message: ownership.message,
1088
+ };
1089
+ }
1090
+
1091
+ let record: HandoffRecord;
1092
+ const captured: Captured = {surfacePersisted: false, autoDisposed: false};
1093
+ try {
1094
+ // A read-only fetch, so the lock job's base is in this checkout.
1095
+ await fetchArbiterMain({cwd, arbiter, env});
1096
+ const handover = await withBaseWorktree(
1097
+ {cwd, baseSha: held.baseSha, env},
1098
+ async (dir) => {
1099
+ const outcome = await runAgentPhase(
1100
+ createPhaseRecorder({record: []}),
1101
+ () =>
1102
+ treelessHandover({
1103
+ options,
1104
+ rung: held.rung,
1105
+ resolved,
1106
+ dir,
1107
+ baseSha: held.baseSha,
1108
+ captured,
1109
+ }),
1110
+ );
1111
+ if (outcome.halted) {
1112
+ throw new Error(
1113
+ `the ${held.rung} rung reached a write (${outcome.intent.seam}.${outcome.intent.method})`,
1114
+ );
1115
+ }
1116
+ return outcome.result;
1117
+ },
1118
+ );
1119
+ record = handover.record;
1120
+ const written = writeHandoff({
1121
+ dir: options.handoffDir,
1122
+ rung: held.rung,
1123
+ record,
1124
+ bundle: handover.bundle,
1125
+ });
1126
+ if (written.lfsMissing.length > 0) {
1127
+ note(
1128
+ `the local LFS store lacks ${written.lfsMissing.length} object(s) a new ` +
1129
+ `commit points at (${written.lfsMissing.slice(0, 5).join(', ')}); the ` +
1130
+ 'apply job will reject the handoff',
1131
+ );
1132
+ }
1133
+ } catch (err) {
1134
+ const message = err instanceof Error ? err.message : String(err);
1135
+ return {exitCode: 1, outcome: 'agent-failed', item, message};
1136
+ } finally {
1137
+ // The answered merge's job worktree, once its bundle is written.
1138
+ captured.merge?.dispose();
1139
+ }
1140
+ const kind = record.intent.kind;
1141
+ const message = `handed over ${kind} for ${item}`;
1142
+ return {exitCode: 0, outcome: 'handed-over', item, message, intent: kind};
1143
+ }
1144
+
1145
+ // ---------------------------------------------------------------------------
1146
+ // apply
1147
+ // ---------------------------------------------------------------------------
1148
+
1149
+ /** What the apply phase works with past the ownership check. */
1150
+ interface ApplyContext {
1151
+ options: TreelessPhaseOptions;
1152
+ held: HeldLock & {rung: TreelessPhaseRung};
1153
+ resolved: ResolvedItem;
1154
+ arbiter: string;
1155
+ cwd: string;
1156
+ env: NodeJS.ProcessEnv;
1157
+ note: (message: string) => void;
1158
+ }
1159
+
1160
+ /**
1161
+ * The apply phase: check the lock is still this run's, act on the agent job's
1162
+ * result, validate the handoff as hostile, then run the rung's write half on a
1163
+ * fresh checkout of `<arbiter>/main`, publish and release. Runs no agent and no
1164
+ * repository code.
1165
+ */
1166
+ export async function performTreelessApplyPhase(
1167
+ options: TreelessPhaseOptions,
1168
+ ): Promise<TreelessPhaseResult> {
1169
+ const note = options.note ?? (() => {});
1170
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
1171
+ const cwd = options.cwd;
1172
+ const env = noSmudgeEnv(
1173
+ identityEnv(options.identity, options.env ?? process.env),
1174
+ );
1175
+ const held = requireTreelessLock(options, env);
1176
+ if (options.agentResult === undefined) {
1177
+ throw new PhaseDriverError(
1178
+ 'the apply phase needs the agent job result (--agent-result, ' +
1179
+ 'needs.agent.result): only success reads the handoff',
1180
+ );
1181
+ }
1182
+ const agentResult = options.agentResult;
1183
+ const resolved = treelessItem(options, options.repoPath ?? cwd);
1184
+ const {item} = resolved;
1185
+
1186
+ // Lock ownership BEFORE the first write (spec §8).
1187
+ const ownership = await checkLockOwnership({
1188
+ cwd,
1189
+ arbiter,
1190
+ item,
1191
+ lockSha: held.lockSha,
1192
+ env,
1193
+ });
1194
+ if (!ownership.owned) {
1195
+ return {
1196
+ exitCode: 0,
1197
+ outcome: 'stale-lock',
1198
+ item,
1199
+ message: ownership.message,
1200
+ };
1201
+ }
1202
+
1203
+ // Every lock release from here on (the surface's, the final one) is leased
1204
+ // on the sha this run owns.
1205
+ const restoreLease = leaseLockReleases(item, held.lockSha);
1206
+ try {
1207
+ const ctx: ApplyContext = {
1208
+ options,
1209
+ held,
1210
+ resolved,
1211
+ arbiter,
1212
+ cwd,
1213
+ env,
1214
+ note,
1215
+ };
1216
+ const decision = await resolveAgentResult({
1217
+ result: agentResult,
1218
+ held,
1219
+ agentTimeoutMinutes: options.agentTimeoutMinutes,
1220
+ api: options.actionsApi,
1221
+ env,
1222
+ });
1223
+ switch (decision.action) {
1224
+ case 'deterministic':
1225
+ return await runRungAndPublish(ctx, undefined);
1226
+ case 'read-handoff': {
1227
+ if (held.needsAgent === false) {
1228
+ // No agent was needed: whatever the agent job left is not read.
1229
+ note(
1230
+ 'the lock job said no agent is needed; the handoff is not read ' +
1231
+ 'and the rung runs deterministically',
1232
+ );
1233
+ return await runRungAndPublish(ctx, undefined);
1234
+ }
1235
+ if (isAnsweredMergeRun(held)) {
1236
+ let validated: ApplyHandoff;
1237
+ try {
1238
+ validated = readAnsweredMergeHandoff(ctx);
1239
+ } catch (err) {
1240
+ if (!(err instanceof HandoffRejected)) throw err;
1241
+ return await surfaceItem(ctx, err.message, 'rejected');
1242
+ }
1243
+ return await applyAnsweredMerge(ctx, validated);
1244
+ }
1245
+ let record: HandoffRecord;
1246
+ try {
1247
+ record = readTreelessHandoff(ctx);
1248
+ } catch (err) {
1249
+ if (!(err instanceof HandoffRejected)) throw err;
1250
+ return await surfaceItem(ctx, err.message, 'rejected');
1251
+ }
1252
+ return await runRungAndPublish(ctx, record);
1253
+ }
1254
+ case 'surface':
1255
+ return await surfaceItem(ctx, decision.reason, 'surfaced');
1256
+ case 'release': {
1257
+ const released = await releaseLockLeased({
1258
+ cwd,
1259
+ arbiter,
1260
+ item,
1261
+ expectedSha: held.lockSha,
1262
+ env,
1263
+ });
1264
+ note(released.message);
1265
+ return released.released
1266
+ ? {
1267
+ exitCode: 0,
1268
+ outcome: 'released',
1269
+ item,
1270
+ message: `Released '${item}': ${decision.reason}.`,
1271
+ }
1272
+ : {
1273
+ exitCode: 1,
1274
+ outcome: 'release-refused',
1275
+ item,
1276
+ message: `Could not release '${item}' (${released.message}): ${decision.reason}.`,
1277
+ };
1278
+ }
1279
+ }
1280
+ } finally {
1281
+ restoreLease();
1282
+ }
1283
+ }
1284
+
1285
+ /**
1286
+ * Read the handoff as hostile. `readHandoff` validates the item, the intent
1287
+ * kind against the rung, every enum, slug and item reference and every limit;
1288
+ * on top, the rules the record format cannot know: the rung's own intent kind,
1289
+ * a triage auto-disposition only under `observationTriage: auto` (the triage
1290
+ * gate never runs otherwise), and a triage target that is not the note itself.
1291
+ * Throws {@link HandoffRejected}.
1292
+ */
1293
+ function readTreelessHandoff(ctx: ApplyContext): HandoffRecord {
1294
+ const {options, held, resolved, env} = ctx;
1295
+ if (options.handoffDir === undefined) {
1296
+ throw new HandoffRejected('layout', 'no handoff directory was given');
1297
+ }
1298
+ const record = readHandoff({
1299
+ dir: options.handoffDir,
1300
+ runnerTemp: runnerTempFrom(options.runnerTemp, env),
1301
+ trust: {item: resolved.item, rung: held.rung},
1302
+ }).record;
1303
+ if (record.intent.kind !== INTENT_FOR_RUNG[held.rung]) {
1304
+ throw new HandoffRejected(
1305
+ 'kind-for-rung',
1306
+ `a ${held.rung} run hands over ${INTENT_FOR_RUNG[held.rung]}, not ${record.intent.kind}`,
1307
+ );
1308
+ }
1309
+ if (record.intent.kind === 'triage') {
1310
+ const t = record.products as TriageProducts;
1311
+ if (t.disposition !== 'keep' && options.observationTriage !== 'auto') {
1312
+ throw new HandoffRejected(
1313
+ 'field',
1314
+ `products.disposition is ${t.disposition}, but observationTriage is ` +
1315
+ `${options.observationTriage ?? 'unset'}: only 'auto' runs the triage gate`,
1316
+ );
1317
+ }
1318
+ if (t.target === resolved.item) {
1319
+ throw new HandoffRejected(
1320
+ 'field',
1321
+ 'products.target names the observation itself',
1322
+ );
1323
+ }
1324
+ }
1325
+ return record;
1326
+ }
1327
+
1328
+ /**
1329
+ * Whether this run carries an answered merge's agent job: the lock job
1330
+ * publishes `continueTip` on the apply rung only for an answered `merge` whose
1331
+ * kept branch carries work `main` lacks (trusted lock outputs, never the
1332
+ * artifact).
1333
+ */
1334
+ function isAnsweredMergeRun(held: HeldLock): boolean {
1335
+ return held.rung === 'apply' && held.continueTip !== undefined;
1336
+ }
1337
+
1338
+ /** The intents an answered merge's agent job may hand over. */
1339
+ const ANSWERED_MERGE_INTENTS: readonly HandoffRecord['intent']['kind'][] = [
1340
+ 'integrate',
1341
+ 'merge-restale',
1342
+ 'needs-attention',
1343
+ ];
1344
+
1345
+ /**
1346
+ * Read an answered merge's handoff as hostile, bundle included
1347
+ * (`validateApplyHandoff`: the bundle's one ref is the item's work branch, its
1348
+ * history descends from `baseSha`, and every path, size and LFS rule holds). The
1349
+ * integration mode is the trusted `merge`: the human's answer is the checkpoint,
1350
+ * so the untrusted-origin rule (a BUILD rule) does not apply. Throws
1351
+ * {@link HandoffRejected}.
1352
+ */
1353
+ function readAnsweredMergeHandoff(ctx: ApplyContext): ApplyHandoff {
1354
+ const {options, held, resolved, arbiter, cwd, env} = ctx;
1355
+ if (options.handoffDir === undefined) {
1356
+ throw new HandoffRejected('layout', 'no handoff directory was given');
1357
+ }
1358
+ const validated = validateApplyHandoff({
1359
+ dir: options.handoffDir,
1360
+ runnerTemp: runnerTempFrom(options.runnerTemp, env),
1361
+ repo: cwd,
1362
+ trust: {
1363
+ item: resolved.item,
1364
+ rung: held.rung,
1365
+ baseSha: held.baseSha,
1366
+ arbiter,
1367
+ integrationMode: 'merge',
1368
+ },
1369
+ env,
1370
+ });
1371
+ const kind = validated.handoff.record.intent.kind;
1372
+ if (!ANSWERED_MERGE_INTENTS.includes(kind)) {
1373
+ throw new HandoffRejected(
1374
+ 'kind-for-rung',
1375
+ `an answered merge hands over ${ANSWERED_MERGE_INTENTS.join(', ')}, not ${kind}`,
1376
+ );
1377
+ }
1378
+ return validated;
1379
+ }
1380
+
1381
+ /**
1382
+ * The write half of an answered merge (spec §3, the `apply, kind: merge` row).
1383
+ * `merge-restale` re-pauses through the rung body. Otherwise, after checking the
1384
+ * item still carries the answered `merge` on a fresh `main`: the handoff's LFS
1385
+ * objects first, then the rebased branch pushed leased on `continueTip` (a
1386
+ * branch that moved writes nothing more), then either the needs-attention route
1387
+ * or the land through the unchanged compare-and-swap loop in merge mode, with
1388
+ * the gated tip (a land after a lost race carries `Landed-Without-Regate`), and
1389
+ * only then the answer recorded and published on the new `main`.
1390
+ */
1391
+ async function applyAnsweredMerge(
1392
+ ctx: ApplyContext,
1393
+ validated: ApplyHandoff,
1394
+ ): Promise<TreelessPhaseResult> {
1395
+ const {options, held, resolved, arbiter, cwd, env, note} = ctx;
1396
+ const {item, slug} = resolved;
1397
+ const record = validated.handoff.record;
1398
+ if (record.intent.kind === 'merge-restale') {
1399
+ return runRungAndPublish(ctx, record);
1400
+ }
1401
+
1402
+ // Nothing is written unless the item still carries its answered merge.
1403
+ const stale = await answeredMergeGone(ctx);
1404
+ if (stale !== undefined) return releaseAfter(ctx, stale);
1405
+
1406
+ const lfs = await pushHandoffLfs({
1407
+ cwd,
1408
+ arbiter,
1409
+ objects: validated.lfsObjects,
1410
+ env,
1411
+ note,
1412
+ });
1413
+ if (!lfs.ok) {
1414
+ return surfaceItem(ctx, lfs.reason, lfs.rejected ? 'rejected' : 'surfaced');
1415
+ }
1416
+
1417
+ const bundle = validated.bundle;
1418
+ if (bundle !== undefined) {
1419
+ await gitHard(
1420
+ ['checkout', '--quiet', '-B', bundle.workBranch, bundle.tip],
1421
+ cwd,
1422
+ env,
1423
+ );
1424
+ // The rebased branch (decision 7): the agent job rebased it locally; publish
1425
+ // it leased on the tip the LOCK job observed, before any other ref.
1426
+ const pushed = await refWrite.pushLeasedWorkBranch({
1427
+ arbiter,
1428
+ branch: bundle.workBranch,
1429
+ commit: bundle.tip,
1430
+ expectedTip: held.continueTip as string,
1431
+ cwd,
1432
+ env,
1433
+ });
1434
+ if (pushed.status !== 0) {
1435
+ const message =
1436
+ `${bundle.workBranch} on ${arbiter} moved since the lock job saw it ` +
1437
+ `at ${held.continueTip}, so the leased push of the rebased branch was ` +
1438
+ `refused (${pushed.stderr.trim()}). Nothing was written and the lock ` +
1439
+ `of ${item} is still held: inspect the branch, then \`dorfl ` +
1440
+ `release-lock ${item}\` to retry the item.`;
1441
+ return {exitCode: 1, outcome: 'stale-lease', item, message};
1442
+ }
1443
+ }
1444
+
1445
+ if (record.intent.kind === 'needs-attention') {
1446
+ const {reason, questions} = record.products as NeedsAttentionProducts;
1447
+ const routed =
1448
+ bundle !== undefined
1449
+ ? await ledgerWrite.applyNeedsAttentionTransition({
1450
+ cwd,
1451
+ slug,
1452
+ reason,
1453
+ questions,
1454
+ arbiter,
1455
+ env,
1456
+ note,
1457
+ })
1458
+ : await ledgerWrite.applyTreelessNeedsAttentionTransition({
1459
+ cwd,
1460
+ slug,
1461
+ reason,
1462
+ questions,
1463
+ arbiter,
1464
+ env,
1465
+ note,
1466
+ });
1467
+ const message = routed.moved
1468
+ ? `Surfaced '${item}' to needs-attention: ${reason}`
1469
+ : `Could not surface '${item}' (${routed.reasonNotMoved ?? 'unknown'}): ${reason}`;
1470
+ return {
1471
+ exitCode: routed.moved ? 0 : 1,
1472
+ outcome: routed.moved ? 'surfaced' : 'surface-unmoved',
1473
+ item,
1474
+ message,
1475
+ };
1476
+ }
1477
+
1478
+ // `integrate`: the bundle is required by the intent table.
1479
+ if (bundle === undefined) {
1480
+ throw new Error('an answered-merge integrate handoff carries no bundle');
1481
+ }
1482
+ const commitMessage = (
1483
+ await gitHard(['log', '-1', '--format=%s', bundle.tip], cwd, env)
1484
+ ).trim();
1485
+ const land = await integrationLand.land({
1486
+ cwd,
1487
+ arbiter,
1488
+ slug,
1489
+ branch: bundle.workBranch,
1490
+ lifecycle: false,
1491
+ source: taskSourceAtBase(cwd, held.baseSha, slug, env),
1492
+ surfaceArbiter: arbiter,
1493
+ commitMessage,
1494
+ env,
1495
+ note,
1496
+ mode: 'merge',
1497
+ title: commitMessage,
1498
+ mergeRetries: options.mergeRetries ?? options.doOptions?.mergeRetries,
1499
+ mergeJitterMs: options.mergeJitterMs,
1500
+ // The landed-vs-gated report (decision 2): the agent job gated the
1501
+ // rebased tip, so a land after a lost race says it landed another tree.
1502
+ gatedTip: bundle.tip,
1503
+ });
1504
+ const landed =
1505
+ land.outcome === 'completed' &&
1506
+ (land.integration?.mergedToMain === true ||
1507
+ land.integration?.alreadyLanded === true);
1508
+ if (!landed) {
1509
+ const message = land.reason ?? `the land ended as ${land.outcome}`;
1510
+ if (land.routedToNeedsAttention) {
1511
+ return {exitCode: 0, outcome: 'surfaced', item, message};
1512
+ }
1513
+ return releaseAfter(ctx, {
1514
+ exitCode: 1,
1515
+ outcome: 'land-failed',
1516
+ item,
1517
+ message,
1518
+ });
1519
+ }
1520
+ note(`landed ${bundle.workBranch} on ${arbiter}/main`);
1521
+ // The answer is recorded AFTER the land, a second write to `main`, as on the
1522
+ // laptop.
1523
+ return runRungAndPublish(ctx, record, {landed: true});
1524
+ }
1525
+
1526
+ /**
1527
+ * The `stale` result when the item no longer carries an answered `merge` on
1528
+ * the arbiter's current `main` (answered again, landed, or gone), else
1529
+ * `undefined`. Read-only.
1530
+ */
1531
+ async function answeredMergeGone(
1532
+ ctx: ApplyContext,
1533
+ ): Promise<TreelessPhaseResult | undefined> {
1534
+ const {resolved, arbiter, cwd, env} = ctx;
1535
+ const {item} = resolved;
1536
+ const tip = await fetchArbiterMain({cwd, arbiter, env});
1537
+ return withBaseWorktree({cwd, baseSha: tip, env}, async (dir) => {
1538
+ const kind = classifyIn(dir, resolved).kind;
1539
+ const verb =
1540
+ kind === 'apply' ? detectAnsweredMergeAction(dir, item)?.verb : undefined;
1541
+ if (verb === 'merge') return undefined;
1542
+ return {
1543
+ exitCode: 1,
1544
+ outcome: 'stale',
1545
+ item,
1546
+ message:
1547
+ `${item} no longer carries an answered merge on ${arbiter}/main ` +
1548
+ `(${tip}: ${kind}${verb === undefined ? '' : `, answered ${verb}`}); nothing was written`,
1549
+ };
1550
+ });
1551
+ }
1552
+
1553
+ /** Release the lock (leased on `lockSha`) after `result`, folding a refused release into it. */
1554
+ async function releaseAfter(
1555
+ ctx: ApplyContext,
1556
+ result: TreelessPhaseResult,
1557
+ ): Promise<TreelessPhaseResult> {
1558
+ const {held, resolved, arbiter, cwd, env, note} = ctx;
1559
+ const released = await releaseLockLeased({
1560
+ cwd,
1561
+ arbiter,
1562
+ item: resolved.item,
1563
+ expectedSha: held.lockSha,
1564
+ env,
1565
+ });
1566
+ note(released.message);
1567
+ if (!released.released) {
1568
+ return {
1569
+ ...result,
1570
+ exitCode: result.exitCode === 0 ? 1 : result.exitCode,
1571
+ message: `${result.message} (${released.message})`,
1572
+ };
1573
+ }
1574
+ return result;
1575
+ }
1576
+
1577
+ async function gitHard(
1578
+ args: string[],
1579
+ cwd: string,
1580
+ env: NodeJS.ProcessEnv,
1581
+ ): Promise<string> {
1582
+ const r = await runAsync('git', args, cwd, {env});
1583
+ if (r.status !== 0) {
1584
+ throw new Error(
1585
+ `git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim()}`,
1586
+ );
1587
+ }
1588
+ return r.stdout;
1589
+ }
1590
+
1591
+ /** A gate the apply phase never launches: the handoff did not answer it. */
1592
+ function noAgent(what: string): never {
1593
+ throw new PhaseDriverError(
1594
+ `the apply phase launches no agent (${what}); the handoff does not answer it`,
1595
+ );
1596
+ }
1597
+
1598
+ /**
1599
+ * Replay the checked handoff's surface questions as the surface gate's emit,
1600
+ * with each question's context and suggested default, so the rung persists the
1601
+ * same sidecar the laptop does.
1602
+ */
1603
+ function replaySurface(
1604
+ questions: HandoffQuestionEntry[] | undefined,
1605
+ ): SurfaceGate {
1606
+ return async () => ({
1607
+ questions: (questions ?? []).map(
1608
+ (q): SurfaceQuestion =>
1609
+ typeof q === 'string'
1610
+ ? {question: q}
1611
+ : {
1612
+ question: q.question,
1613
+ ...(q.context === undefined ? {} : {context: q.context}),
1614
+ ...(q.default === undefined ? {} : {default: q.default}),
1615
+ },
1616
+ ),
1617
+ });
1618
+ }
1619
+
1620
+ /** Replay the checked `apply-decision` as the decider's verdict. */
1621
+ function replayDecision(p: ApplyDecisionProducts): ApplyDecider {
1622
+ return async (): Promise<DecisionVerdict> => {
1623
+ switch (p.outcome) {
1624
+ case 'task':
1625
+ return {
1626
+ outcome: 'task',
1627
+ taskSlug: p.slug,
1628
+ taskTitle: p.title,
1629
+ taskBody: p.body,
1630
+ };
1631
+ case 'spec':
1632
+ return {
1633
+ outcome: 'spec',
1634
+ specSlug: p.slug,
1635
+ specTitle: p.title,
1636
+ specBody: p.body,
1637
+ };
1638
+ case 'adr':
1639
+ return {
1640
+ outcome: 'adr',
1641
+ adrSlug: p.slug,
1642
+ adrTitle: p.title,
1643
+ adrBody: p.body,
1644
+ };
1645
+ case 'dispose':
1646
+ return {outcome: 'dispose', disposeReason: p.reason};
1647
+ case 'resolve':
1648
+ return {outcome: 'resolve', resolveReason: p.reason};
1649
+ case 'ask':
1650
+ // One follow-up question per handed-over question (never joined).
1651
+ return {outcome: 'ask', questions: p.questions ?? []};
1652
+ }
1653
+ };
1654
+ }
1655
+
1656
+ type AgentSeams = Pick<
1657
+ AdvanceContext,
1658
+ 'surfaceGate' | 'triageGate' | 'applyDecide' | 'mergeAction'
1659
+ >;
1660
+
1661
+ /**
1662
+ * The answered merge's action in the apply phase, which never runs the
1663
+ * repository's code: the land the apply phase itself just made (`landed`), the
1664
+ * agent job's `merge-restale`, and, with no agent job, `hold` / `drop` (the
1665
+ * laptop dispatcher, which touches nothing for them) or a `merge` whose kept
1666
+ * branch is absent or already on `main` (`already-integrated`, as the laptop
1667
+ * reports it). Anything else would need the agent job, so it throws.
1668
+ */
1669
+ function replayMergeAction(
1670
+ record: HandoffRecord | undefined,
1671
+ landed: boolean,
1672
+ ): MergeActionHandler {
1673
+ return async (input) => {
1674
+ const kind = record?.intent.kind;
1675
+ if (kind === 'integrate' && landed) {
1676
+ return {
1677
+ outcome: 'landed',
1678
+ message:
1679
+ `merge-question for ${input.item} answered MERGE: landed ` +
1680
+ `\`work/task-${input.slug}\` (rebased and gated by the agent job).`,
1681
+ };
1682
+ }
1683
+ if (kind === 'merge-restale') {
1684
+ // The follow-up is asked against the fresh `main` this rung runs on
1685
+ // (the base worktree's HEAD), which it records as its `askedAtMain`: a
1686
+ // re-answer while `main` stays put then lands (no re-stale livelock).
1687
+ const head = await runAsync('git', ['rev-parse', 'HEAD'], input.cwd, {
1688
+ env: input.env,
1689
+ });
1690
+ const main = head.status === 0 ? head.stdout.trim() : '';
1691
+ return {
1692
+ outcome: 'restale',
1693
+ message:
1694
+ `merge-question for ${input.item} answered MERGE, but ` +
1695
+ 'strictMergeApproval is ON and `main` moved since the question was ' +
1696
+ 'asked (checked by the agent job): re-surfacing the merge-question.',
1697
+ ...(main === '' ? {} : {main}),
1698
+ };
1699
+ }
1700
+ if (record === undefined) {
1701
+ if (input.action.verb !== 'merge') return performMergeAction(input);
1702
+ return {
1703
+ outcome: 'already-integrated',
1704
+ message:
1705
+ `merge-question for ${input.item} answered MERGE: ` +
1706
+ `\`work/task-${input.slug}\` is absent or already on main (nothing to land).`,
1707
+ };
1708
+ }
1709
+ return noAgent('the answered merge action');
1710
+ };
1711
+ }
1712
+
1713
+ /**
1714
+ * The agent seams of the apply phase: replays of the checked handoff, and a
1715
+ * throwing gate for every seam the handoff does not answer (all of them when
1716
+ * the rung runs deterministically).
1717
+ */
1718
+ function replayGates(
1719
+ record: HandoffRecord | undefined,
1720
+ landed = false,
1721
+ ): AgentSeams {
1722
+ const gates: AgentSeams = {
1723
+ surfaceGate: async () => noAgent('the surface-questions agent'),
1724
+ triageGate: async () => noAgent('the triage gate'),
1725
+ applyDecide: async () => noAgent('the apply decision agent'),
1726
+ mergeAction: replayMergeAction(record, landed),
1727
+ };
1728
+ if (record === undefined) return gates;
1729
+ if (record.intent.kind === 'surface') {
1730
+ gates.surfaceGate = replaySurface(
1731
+ (record.products as SurfaceProducts).questions,
1732
+ );
1733
+ } else if (record.intent.kind === 'triage') {
1734
+ const t = record.products as TriageProducts;
1735
+ gates.surfaceGate = replaySurface(t.questions);
1736
+ gates.triageGate = async (): Promise<TriageEmit> =>
1737
+ t.disposition === 'keep'
1738
+ ? {auto: false}
1739
+ : {
1740
+ auto: true,
1741
+ kind: t.disposition,
1742
+ existing: t.target as string,
1743
+ reason: t.reason ?? '',
1744
+ };
1745
+ } else if (record.intent.kind === 'apply-decision') {
1746
+ gates.applyDecide = replayDecision(
1747
+ record.products as ApplyDecisionProducts,
1748
+ );
1749
+ }
1750
+ return gates;
1751
+ }
1752
+
1753
+ /** Whether `HEAD` of `dir` is now on the arbiter's `main` (after the publish). */
1754
+ async function publishedToMain(
1755
+ dir: string,
1756
+ arbiter: string,
1757
+ env: NodeJS.ProcessEnv,
1758
+ ): Promise<boolean> {
1759
+ await runAsync(
1760
+ 'git',
1761
+ [
1762
+ 'fetch',
1763
+ '--quiet',
1764
+ '--no-tags',
1765
+ arbiter,
1766
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
1767
+ ],
1768
+ dir,
1769
+ {env},
1770
+ );
1771
+ const r = await runAsync(
1772
+ 'git',
1773
+ ['merge-base', '--is-ancestor', 'HEAD', `refs/remotes/${arbiter}/main`],
1774
+ dir,
1775
+ {env},
1776
+ );
1777
+ return r.status === 0;
1778
+ }
1779
+
1780
+ /**
1781
+ * Run the rung body's write half on a FRESH checkout of `<arbiter>/main`: check
1782
+ * the item still classifies on the lock job's rung with the same agent need,
1783
+ * run the rung with the replayed (or refusing) agent seams and the real write
1784
+ * seams, check the publish scope and publish. Then release the lock (leased),
1785
+ * whatever happened past the ownership check.
1786
+ */
1787
+ async function runRungAndPublish(
1788
+ ctx: ApplyContext,
1789
+ record: HandoffRecord | undefined,
1790
+ opts: {landed?: boolean} = {},
1791
+ ): Promise<TreelessPhaseResult> {
1792
+ const landed = opts.landed === true;
1793
+ const {options, held, resolved, arbiter, cwd, env, note} = ctx;
1794
+ const {item} = resolved;
1795
+ const tip = await fetchArbiterMain({cwd, arbiter, env});
1796
+ const result = await withBaseWorktree(
1797
+ {cwd, baseSha: tip, env},
1798
+ async (dir): Promise<TreelessPhaseResult> => {
1799
+ const classification = classifyIn(dir, resolved);
1800
+ const input = rungInput(resolved, classification, {
1801
+ ...options,
1802
+ cwd: dir,
1803
+ arbiter,
1804
+ note,
1805
+ ...replayGates(record, landed),
1806
+ });
1807
+ let need: TreelessAgentNeed | undefined;
1808
+ if (classification.kind === held.rung && landed) {
1809
+ // The land already happened, so the kept branch is on main and the
1810
+ // need would recompute to none: an agent job did run, and its
1811
+ // answered merge is what is left to record.
1812
+ need =
1813
+ detectAnsweredMergeAction(dir, item)?.verb === 'merge'
1814
+ ? {needsAgent: true}
1815
+ : {unsplit: 'no answered merge left to record after the land'};
1816
+ } else if (classification.kind === held.rung) {
1817
+ need = (
1818
+ await treelessPhaseNeed(input, {cwd, arbiter, mainSha: tip, env})
1819
+ ).need;
1820
+ }
1821
+ if (need?.needsAgent !== (record !== undefined)) {
1822
+ const what =
1823
+ need === undefined
1824
+ ? `classifies as ${classification.kind}, not ${held.rung}`
1825
+ : need.needsAgent === undefined
1826
+ ? `needs ${need.unsplit}`
1827
+ : need.needsAgent
1828
+ ? 'now needs an agent, but none ran'
1829
+ : 'now needs no agent, but one ran';
1830
+ return {
1831
+ exitCode: 1,
1832
+ outcome: 'stale',
1833
+ item,
1834
+ message:
1835
+ `${item} ${what} on ${arbiter}/main (${tip}); ` +
1836
+ (landed
1837
+ ? 'its merge landed, but the answer was not recorded'
1838
+ : 'nothing was written'),
1839
+ };
1840
+ }
1841
+ const exec = await dispatchTreeless(
1842
+ options.executor ?? defaultRungExecutor,
1843
+ held.rung,
1844
+ input,
1845
+ );
1846
+ if (exec.exitCode !== 0) {
1847
+ return {
1848
+ exitCode: exec.exitCode,
1849
+ outcome: 'rung-failed',
1850
+ item,
1851
+ message: exec.message,
1852
+ rungOutcome: exec.outcome,
1853
+ };
1854
+ }
1855
+ const scope = await checkTreelessPublishScope({
1856
+ cwd: dir,
1857
+ base: tip,
1858
+ env,
1859
+ });
1860
+ if (scope.kind === 'refused') {
1861
+ return {
1862
+ exitCode: 1,
1863
+ outcome: 'publish-refused',
1864
+ item,
1865
+ message: `${exec.message} ${scope.message}; nothing was published`,
1866
+ rungOutcome: exec.outcome,
1867
+ };
1868
+ }
1869
+ if (scope.kind === 'rung-commit') {
1870
+ await refWrite.publishTreelessResult({
1871
+ cwd: dir,
1872
+ arbiter,
1873
+ retries: options.publishRetries ?? 1000,
1874
+ jitterMs: options.publishJitterMs ?? DEFAULT_TREELESS_JITTER_MS,
1875
+ env,
1876
+ note,
1877
+ });
1878
+ if (!(await publishedToMain(dir, arbiter, env))) {
1879
+ return {
1880
+ exitCode: 1,
1881
+ outcome: 'publish-failed',
1882
+ item,
1883
+ message: `${exec.message} The result did not reach ${arbiter}/main.`,
1884
+ rungOutcome: exec.outcome,
1885
+ };
1886
+ }
1887
+ }
1888
+ return {
1889
+ exitCode: 0,
1890
+ outcome: 'applied',
1891
+ item,
1892
+ message: exec.message,
1893
+ rungOutcome: exec.outcome,
1894
+ };
1895
+ },
1896
+ );
1897
+
1898
+ // The release, leased on `lockSha`, AFTER the publish: the lock covers the
1899
+ // write. It runs whatever happened above, so a refused or failed publish
1900
+ // leaves the item for the next tick rather than locked.
1901
+ const released = await releaseLockLeased({
1902
+ cwd,
1903
+ arbiter,
1904
+ item,
1905
+ expectedSha: held.lockSha,
1906
+ env,
1907
+ });
1908
+ note(released.message);
1909
+ if (!released.released) {
1910
+ return {
1911
+ ...result,
1912
+ exitCode: result.exitCode === 0 ? 1 : result.exitCode,
1913
+ message: `${result.message} (${released.message})`,
1914
+ };
1915
+ }
1916
+ return result;
1917
+ }
1918
+
1919
+ /**
1920
+ * Surface the item to needs-attention (decision 5, and a rejected handoff): a
1921
+ * `kind: stuck` question in its sidecar and `needsAnswers: true` on `main`, then
1922
+ * the lock released (leased). Nothing from the agent job is read or written.
1923
+ */
1924
+ async function surfaceItem(
1925
+ ctx: ApplyContext,
1926
+ reason: string,
1927
+ outcome: 'surfaced' | 'rejected',
1928
+ ): Promise<TreelessPhaseResult> {
1929
+ const {resolved, arbiter, cwd, env, note} = ctx;
1930
+ const {item} = resolved;
1931
+ const r = await surfaceStuckToNeedsAttention({
1932
+ cwd,
1933
+ slug: resolved.slug,
1934
+ item,
1935
+ reason,
1936
+ arbiter,
1937
+ env,
1938
+ note,
1939
+ });
1940
+ const message = r.surfaced
1941
+ ? `Surfaced '${item}' to needs-attention: ${reason}`
1942
+ : `Could not surface '${item}' (${r.reasonNotSurfaced ?? 'unknown'}): ${reason}`;
1943
+ if (!r.surfaced) {
1944
+ return {exitCode: 1, outcome: 'surface-unmoved', item, message};
1945
+ }
1946
+ // A clean surface is green, a rejected handoff's included ("A handled
1947
+ // outcome is green", `ci-phase-driver.ts`).
1948
+ return {
1949
+ exitCode: 0,
1950
+ outcome,
1951
+ item,
1952
+ message,
1953
+ };
1954
+ }
1955
+
1956
+ // ---------------------------------------------------------------------------
1957
+ // routing `advance <item> --phase`
1958
+ // ---------------------------------------------------------------------------
1959
+
1960
+ /** Which split path an `advance <item> --phase` run belongs to. */
1961
+ export type AdvancePhasePath = 'build' | 'tasking' | 'treeless';
1962
+
1963
+ function pathForRung(rung: string | undefined): AdvancePhasePath | undefined {
1964
+ if (rung === 'build-task') return 'build';
1965
+ if (rung === 'task-spec') return 'tasking';
1966
+ if (isTreelessPhaseRung(rung)) return 'treeless';
1967
+ return undefined;
1968
+ }
1969
+
1970
+ /**
1971
+ * Route `advance <item> --phase` to its split path. The agent and apply phases
1972
+ * follow the lock job's trusted `rung` output. The lock phase classifies the
1973
+ * item at the arbiter's `main` (read-only; the chosen path's lock phase fetches
1974
+ * and classifies again, and refuses a rung it does not split). Anything that
1975
+ * does not classify (a pending sidecar, an unreadable argument, no lock
1976
+ * outputs) falls back to the argument's namespace, so the path's own lock or
1977
+ * usage report runs: `spec:` is tasking, `obs:` is tree-less, anything else is
1978
+ * the build path.
1979
+ */
1980
+ export async function advancePhasePath(params: {
1981
+ phase: Phase;
1982
+ arg: string;
1983
+ cwd: string;
1984
+ arbiter?: string;
1985
+ env?: NodeJS.ProcessEnv;
1986
+ lockOutputs?: LockOutputs;
1987
+ read?: LedgerReadStrategy;
1988
+ }): Promise<AdvancePhasePath> {
1989
+ const explicit = parseSlugArg(params.arg).explicit;
1990
+ const fallback: AdvancePhasePath =
1991
+ explicit === 'spec'
1992
+ ? 'tasking'
1993
+ : explicit === 'observation'
1994
+ ? 'treeless'
1995
+ : 'build';
1996
+ const env = params.env ?? process.env;
1997
+ if (params.phase !== 'lock') {
1998
+ try {
1999
+ const facts = params.lockOutputs ?? readLockOutputsFromEnv(env);
2000
+ return pathForRung(facts.rung) ?? fallback;
2001
+ } catch {
2002
+ return fallback;
2003
+ }
2004
+ }
2005
+ try {
2006
+ const cwd = params.cwd;
2007
+ const baseSha = await fetchArbiterMain({
2008
+ cwd,
2009
+ arbiter: params.arbiter ?? DEFAULT_ARBITER,
2010
+ env,
2011
+ });
2012
+ const kind = await withBaseWorktree({cwd, baseSha, env}, async (base) => {
2013
+ const resolved = resolveAdvanceArg({
2014
+ arg: params.arg,
2015
+ repoPath: base,
2016
+ read: params.read ?? ledgerRead,
2017
+ });
2018
+ return classifyIn(base, {
2019
+ namespace: resolved.namespace,
2020
+ slug: resolved.slug,
2021
+ item: `${resolved.namespace}:${resolved.slug}`,
2022
+ }).kind;
2023
+ });
2024
+ return pathForRung(kind) ?? fallback;
2025
+ } catch {
2026
+ return fallback;
2027
+ }
2028
+ }