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,1482 @@
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
+ import { answersMerge, defaultRungExecutor, findItemPath, readItemSignals, sidecarTypeFor, treelessAgentNeed, } from './advance.js';
61
+ import { classifyTick } from './advance-classify.js';
62
+ import { DEFAULT_TREELESS_JITTER_MS, checkTreelessPublishScope, } from './advance-treeless-publish.js';
63
+ import { acquireAdvancingLock } from './advancing-lock.js';
64
+ import { detectAnsweredMergeAction, performMergeAction, prepareMergeLand, } from './apply-merge-action.js';
65
+ import { HANDOFF_LIMITS, HandoffRejected, canonicalHandoffItem, handoffName as deriveHandoffName, } from './ci-handoff-format.js';
66
+ import { readHandoff, writeHandoff } from './ci-handoff.js';
67
+ import { validateApplyHandoff } from './ci-handoff-apply.js';
68
+ import { readLockOutputsFromEnv } from './ci-lock-outputs.js';
69
+ import { PhaseDriverError, agentTimeoutMinutesAt, arbiterLockSha, arbiterRefSha, boundHandoffText, acquireNotingOnce, checkLockOwnership, emitLockOutputs, fetchArbiterMain, noSmudgeEnv, pushHandoffLfs, releaseLockLeased, requireHeldLock, resolveAgentResult, runnerTempFrom, taskSourceAtBase, withBaseWorktree, } from './ci-phase-driver.js';
70
+ import { harnessApplyDecider } from './apply-decide.js';
71
+ import { runAsync } from './git.js';
72
+ import { identityEnv } from './identity.js';
73
+ import { integrationLand } from './integration-core.js';
74
+ import { leaseLockReleases } from './item-lock.js';
75
+ import { ledgerRead } from './ledger-read.js';
76
+ import { ledgerWrite } from './ledger-write.js';
77
+ import { surfaceStuckToNeedsAttention } from './needs-attention.js';
78
+ import { createPhaseRecorder, runAgentPhase } from './phase-recorder.js';
79
+ import { refWrite } from './ref-write.js';
80
+ import { sidecarPathFor } from './sidecar.js';
81
+ import { ensureSafeSlug } from './slug-safety.js';
82
+ import { SlugResolutionError, parseSlugArg, resolveAdvanceArg, workBranchRef, } from './slug-namespace.js';
83
+ import { harnessSurfaceGate, } from './surface-gate.js';
84
+ import { harnessTriageGate } from './triage-gate.js';
85
+ const DEFAULT_ARBITER = 'origin';
86
+ /** The tree-less rungs this module splits. */
87
+ export const TREELESS_PHASE_RUNGS = [
88
+ 'surface',
89
+ 'triage-observation',
90
+ 'apply',
91
+ ];
92
+ /** Whether `rung` is one of the tree-less rungs this module splits. */
93
+ export function isTreelessPhaseRung(rung) {
94
+ return TREELESS_PHASE_RUNGS.includes(rung ?? '');
95
+ }
96
+ /** The handoff intent each tree-less rung hands over. */
97
+ const INTENT_FOR_RUNG = {
98
+ surface: 'surface',
99
+ 'triage-observation': 'triage',
100
+ apply: 'apply-decision',
101
+ };
102
+ /**
103
+ * Run one phase of a tree-less rung. The CLI calls this for `advance <item>
104
+ * --phase` when the item classifies (lock) or was classified (agent, apply) on
105
+ * the `surface`, `triage-observation` or `apply` rung.
106
+ */
107
+ export async function performTreelessPhase(options) {
108
+ try {
109
+ switch (options.phase) {
110
+ case 'lock':
111
+ return await performTreelessLockPhase(options);
112
+ case 'agent':
113
+ return await performTreelessAgentPhase(options);
114
+ case 'apply':
115
+ return await performTreelessApplyPhase(options);
116
+ }
117
+ }
118
+ catch (err) {
119
+ if (err instanceof PhaseDriverError || err instanceof SlugResolutionError) {
120
+ return { exitCode: 1, outcome: 'usage-error', message: err.message };
121
+ }
122
+ throw err;
123
+ }
124
+ }
125
+ /** The item a tree-less phase acts on, from the (trusted) workflow argument. */
126
+ function treelessItem(options, repoPath) {
127
+ const resolved = resolveAdvanceArg({
128
+ arg: options.arg,
129
+ repoPath,
130
+ read: options.read ?? ledgerRead,
131
+ });
132
+ return {
133
+ namespace: resolved.namespace,
134
+ slug: resolved.slug,
135
+ item: `${resolved.namespace}:${resolved.slug}`,
136
+ };
137
+ }
138
+ /** Classify `resolved` in the tree at `dir` (read-only, no model). */
139
+ function classifyIn(dir, resolved) {
140
+ const type = sidecarTypeFor(resolved.namespace);
141
+ const signals = readItemSignals({
142
+ repoPath: dir,
143
+ type,
144
+ slug: resolved.slug,
145
+ item: resolved.item,
146
+ });
147
+ return classifyTick({ type, ...signals });
148
+ }
149
+ /** The rung input for `resolved` with `context`. */
150
+ function rungInput(resolved, classification, context) {
151
+ return {
152
+ item: resolved.item,
153
+ namespace: resolved.namespace,
154
+ slug: resolved.slug,
155
+ classification,
156
+ context,
157
+ };
158
+ }
159
+ /**
160
+ * The kept `work/task-<slug>` tip of an answered merge, when it carries work
161
+ * `mainSha` lacks; `undefined` when the branch is absent or already on `main`
162
+ * (nothing to land: the laptop's `already-integrated`). Reads the arbiter and
163
+ * fetches the branch's objects only: nothing is checked out and no repository
164
+ * code runs, so the lock phase may call it.
165
+ */
166
+ async function keptMergeTip(params) {
167
+ const { cwd, arbiter, slug, mainSha, env } = params;
168
+ const branch = workBranchRef('task', slug);
169
+ const ref = `refs/heads/${branch}`;
170
+ if ((await arbiterRefSha({ cwd, arbiter, ref, env })) === undefined) {
171
+ return undefined;
172
+ }
173
+ const tracking = `refs/remotes/${arbiter}/${branch}`;
174
+ const fetched = await runAsync('git', ['fetch', '--quiet', '--no-tags', arbiter, `+${ref}:${tracking}`], cwd, { env });
175
+ if (fetched.status !== 0) {
176
+ throw new Error(`fetching ${branch} from ${arbiter} failed: ${fetched.stderr.trim()}`);
177
+ }
178
+ const tip = (await runAsync('git', ['rev-parse', '--verify', tracking], cwd, { env })).stdout.trim();
179
+ const onMain = await runAsync('git', ['merge-base', '--is-ancestor', tip, mainSha], cwd, { env });
180
+ return onMain.status === 0 ? undefined : tip;
181
+ }
182
+ /**
183
+ * The agent need of a tree-less rung as the CI phases see it:
184
+ * {@link treelessAgentNeed} (the tree), refined for an answered `merge` by the
185
+ * arbiter's kept branch ({@link keptMergeTip}), which the tree cannot show. The
186
+ * lock phase publishes the result (and the branch tip as `continueTip`); the
187
+ * apply phase recomputes it on its fresh checkout.
188
+ */
189
+ async function treelessPhaseNeed(input, params) {
190
+ const need = treelessAgentNeed(input);
191
+ if (need.needsAgent !== true || input.classification.kind !== 'apply') {
192
+ return { need };
193
+ }
194
+ const merge = detectAnsweredMergeAction(input.context.cwd, input.item);
195
+ if (merge === undefined)
196
+ return { need };
197
+ const tip = await keptMergeTip({ ...params, slug: input.slug });
198
+ return tip === undefined
199
+ ? { need: { needsAgent: false } }
200
+ : { need, continueTip: tip };
201
+ }
202
+ /** Dispatch the tree-less rung to its body. */
203
+ function dispatchTreeless(executor, rung, input) {
204
+ switch (rung) {
205
+ case 'triage-observation':
206
+ return executor.triageObservation(input);
207
+ case 'surface':
208
+ return executor.surface(input);
209
+ case 'apply':
210
+ return executor.apply(input);
211
+ }
212
+ }
213
+ /** The held lock of an agent or apply phase, whose rung must be a tree-less one. */
214
+ function requireTreelessLock(options, env) {
215
+ const facts = options.lockOutputs ?? readLockOutputsFromEnv(env);
216
+ if (!isTreelessPhaseRung(facts.rung)) {
217
+ throw new PhaseDriverError(`the lock job classified the rung ${String(facts.rung)}, not a tree-less rung`);
218
+ }
219
+ const held = requireHeldLock({ lockOutputs: facts, env, rung: facts.rung });
220
+ return { ...held, rung: facts.rung };
221
+ }
222
+ // ---------------------------------------------------------------------------
223
+ // lock
224
+ // ---------------------------------------------------------------------------
225
+ /**
226
+ * The lock phase: classify the item at the arbiter's `main`, take the advancing
227
+ * lock and publish the trusted facts. Runs no agent and no repository code.
228
+ */
229
+ export async function performTreelessLockPhase(options) {
230
+ const note = options.note ?? (() => { });
231
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
232
+ const cwd = options.cwd;
233
+ const env = identityEnv(options.identity, options.env ?? process.env);
234
+ const githubOutput = options.githubOutput ?? env.GITHUB_OUTPUT;
235
+ const publish = (facts) => {
236
+ emitLockOutputs(facts, { githubOutput, note });
237
+ return facts;
238
+ };
239
+ const baseSha = await fetchArbiterMain({ cwd, arbiter, env });
240
+ const verdict = await withBaseWorktree({ cwd, baseSha, env }, async (base) => {
241
+ const resolved = treelessItem(options, base);
242
+ const classification = classifyIn(base, resolved);
243
+ const kind = classification.kind;
244
+ if (kind === 'no-op') {
245
+ return {
246
+ resolved,
247
+ skip: `no-op for ${resolved.item} at ${baseSha} (${classification.reason ?? 'nothing to advance'})`,
248
+ };
249
+ }
250
+ if (kind === 'invariant-violation') {
251
+ return {
252
+ resolved,
253
+ invariant: `refusing to advance ${resolved.item}: the \`needsAnswers\` flag and ` +
254
+ `the sidecar disagree at ${baseSha} (${classification.reason ?? 'invariant violation'}). ` +
255
+ 'A human must reconcile them.',
256
+ };
257
+ }
258
+ if (!isTreelessPhaseRung(kind)) {
259
+ throw new PhaseDriverError(`${resolved.item} classifies as ${kind} at ${baseSha}, not a tree-less rung`);
260
+ }
261
+ if (findItemPath(base, resolved.namespace, resolved.slug) === undefined) {
262
+ return {
263
+ resolved,
264
+ skip: `no-op for ${resolved.item}: its item file is not on ${arbiter}/main (${baseSha})`,
265
+ };
266
+ }
267
+ const { need, continueTip } = await treelessPhaseNeed(rungInput(resolved, classification, { ...options, cwd: base }), { cwd, arbiter, mainSha: baseSha, env });
268
+ if (need.needsAgent === undefined) {
269
+ throw new PhaseDriverError(`--phase does not split ${resolved.item} yet: ${need.unsplit}`);
270
+ }
271
+ return {
272
+ resolved,
273
+ rung: kind,
274
+ needsAgent: need.needsAgent,
275
+ ...(continueTip === undefined ? {} : { continueTip }),
276
+ takeOverProposeKept: answersMerge(base, resolved.item, resolved.namespace, classification),
277
+ };
278
+ });
279
+ const { item } = verdict.resolved;
280
+ if ('skip' in verdict) {
281
+ return {
282
+ exitCode: 0,
283
+ outcome: 'no-op',
284
+ item,
285
+ message: verdict.skip,
286
+ lockOutputs: publish({ acquired: false, baseSha }),
287
+ };
288
+ }
289
+ if ('invariant' in verdict) {
290
+ return {
291
+ exitCode: 1,
292
+ outcome: 'invariant-violation',
293
+ item,
294
+ message: verdict.invariant,
295
+ lockOutputs: publish({ acquired: false, baseSha }),
296
+ };
297
+ }
298
+ const { rung, needsAgent, continueTip, takeOverProposeKept } = verdict;
299
+ // An answered `merge` may take over the lock a finished propose build of the
300
+ // same item keeps held (task `wire-merge-questions-into-the-advance-tick`,
301
+ // decisions 2 and 4), so it does not back off on a held lock here: the
302
+ // acquire below takes the lock over ONLY when it carries the `propose-pr`
303
+ // marker, and reports any other hold as `lost`.
304
+ if (!takeOverProposeKept &&
305
+ (await arbiterLockSha({ cwd, arbiter, item, env })) !== undefined) {
306
+ const message = `'${item}' is already locked on ${arbiter}; backing off.`;
307
+ return {
308
+ exitCode: 0,
309
+ outcome: 'lost',
310
+ item,
311
+ message,
312
+ lockOutputs: publish({ acquired: false, rung, baseSha }),
313
+ };
314
+ }
315
+ const acquired = await acquireNotingOnce(note, (acquireNote) => acquireAdvancingLock({
316
+ item,
317
+ cwd,
318
+ arbiter,
319
+ acquireUnified: true,
320
+ takeOverProposeKept,
321
+ env,
322
+ note: acquireNote,
323
+ }));
324
+ if (acquired.exitCode !== 0) {
325
+ const outcome = acquired.outcome === 'usage-error' ? 'usage-error' : 'lost';
326
+ return {
327
+ // A lock lost to another run is handled, so green ("A handled
328
+ // outcome is green", `ci-phase-driver.ts`).
329
+ exitCode: outcome === 'lost' ? 0 : acquired.exitCode,
330
+ outcome,
331
+ item,
332
+ message: acquired.message,
333
+ lockOutputs: publish({ acquired: false, rung, baseSha }),
334
+ };
335
+ }
336
+ const lockSha = await arbiterLockSha({ cwd, arbiter, item, env });
337
+ if (lockSha === undefined) {
338
+ throw new Error(`the lock of ${item} vanished right after the acquire`);
339
+ }
340
+ const facts = publish({
341
+ acquired: true,
342
+ needsAgent,
343
+ rung,
344
+ baseSha,
345
+ lockSha,
346
+ // An answered merge's kept branch as the lock job saw it: the apply job
347
+ // pushes the rebased tip leased on it (decision 7).
348
+ ...(continueTip === undefined ? {} : { continueTip }),
349
+ handoffName: deriveHandoffName(item, options.runAttempt ?? env.GITHUB_RUN_ATTEMPT ?? '1'),
350
+ agentTimeoutMinutes: agentTimeoutMinutesAt(cwd, baseSha, env),
351
+ });
352
+ return {
353
+ exitCode: 0,
354
+ outcome: 'locked',
355
+ item,
356
+ message: `locked ${item} on the ${rung} rung at ${baseSha} (lock ${lockSha}; ` +
357
+ `${needsAgent ? 'an agent runs next' : 'no agent is needed'})`,
358
+ lockOutputs: facts,
359
+ };
360
+ }
361
+ const RECORDED = 'recorded for the CI apply phase (the agent job writes nothing)';
362
+ /**
363
+ * The advance context of the agent phase: the real agent seams, each wrapped to
364
+ * capture its emit, and every write seam a stub that writes nothing.
365
+ */
366
+ function agentPhaseContext(options, dir, captured) {
367
+ const surfaceGate = options.surfaceGate ?? harnessSurfaceGate();
368
+ const triageGate = options.triageGate ?? harnessTriageGate();
369
+ const decider = options.applyDecide ?? harnessApplyDecider();
370
+ return {
371
+ ...options,
372
+ cwd: dir,
373
+ surfaceGate: async (input) => {
374
+ const emit = await surfaceGate(input);
375
+ captured.surface = emit;
376
+ return emit;
377
+ },
378
+ triageGate: async (input) => {
379
+ const emit = await triageGate(input);
380
+ captured.triage = emit;
381
+ return emit;
382
+ },
383
+ applyDecide: async (input) => {
384
+ const verdict = await decider(input);
385
+ captured.verdict = verdict;
386
+ return verdict;
387
+ },
388
+ surfacePersist: (o) => {
389
+ captured.surfacePersisted = true;
390
+ return {
391
+ outcome: o.questions.length > 0 ? 'surfaced' : 'nothing',
392
+ sidecarPath: sidecarPathFor(o.item),
393
+ entryCount: o.questions.length,
394
+ };
395
+ },
396
+ applyPersist: (o) => ({
397
+ outcome: o.appendQuestions !== undefined && o.appendQuestions.length > 0
398
+ ? 'repaused'
399
+ : o.dispose !== undefined
400
+ ? 'disposed'
401
+ : 'resolved',
402
+ sidecarPath: sidecarPathFor(o.item),
403
+ itemPath: o.itemPath,
404
+ message: RECORDED,
405
+ }),
406
+ stampTriaged: () => ({ message: RECORDED }),
407
+ autoDisposition: (o) => {
408
+ captured.autoDisposed = true;
409
+ return {
410
+ outcome: 'deleted',
411
+ commit: '',
412
+ itemPath: o.itemPath,
413
+ message: RECORDED,
414
+ };
415
+ },
416
+ promote: async () => ({
417
+ outcome: 'promoted',
418
+ exitCode: 0,
419
+ message: RECORDED,
420
+ }),
421
+ mintAdr: async () => ({ outcome: 'minted', exitCode: 0, message: RECORDED }),
422
+ stuckAction: async () => ({ outcome: 'keep', message: RECORDED }),
423
+ mergeAction: async (input) => {
424
+ if (input.workspacesDir === '') {
425
+ throw new Error('the answered merge action needs a workspacesDir to check the ' +
426
+ 'work branch out (createJob)');
427
+ }
428
+ const prepared = await prepareMergeLand(input);
429
+ captured.merge = prepared;
430
+ return mergeResultFor(prepared.preparation);
431
+ },
432
+ };
433
+ }
434
+ /**
435
+ * The merge-action result the rung body sees for the agent half's preparation
436
+ * (it only steers the rung's recording stubs; the handoff comes from the
437
+ * preparation itself, {@link mergeHandover}).
438
+ */
439
+ function mergeResultFor(p) {
440
+ switch (p.kind) {
441
+ case 'integrate':
442
+ return { outcome: 'landed', message: RECORDED };
443
+ case 'restale':
444
+ return { outcome: 'restale', message: p.message, main: p.main };
445
+ case 'needs-attention':
446
+ return { outcome: 'refused', message: p.reason };
447
+ case 'already-integrated':
448
+ return { outcome: 'already-integrated', message: p.message };
449
+ }
450
+ }
451
+ /**
452
+ * The handoff of an answered merge's agent half: `integrate` with the rebased
453
+ * tip, `merge-restale`, or `needs-attention` (the red rebased tip bundled, or
454
+ * nothing on a rebase conflict). A branch that landed since the lock job ran
455
+ * has nothing to hand over, so the agent job fails and the apply job surfaces
456
+ * the item.
457
+ */
458
+ function mergeHandover(item, p, baseSha) {
459
+ switch (p.kind) {
460
+ case 'integrate':
461
+ return {
462
+ record: { schema: 1, item, intent: { kind: 'integrate' }, products: {} },
463
+ bundle: { repo: p.repo, workBranch: p.workBranch, baseSha },
464
+ };
465
+ case 'restale':
466
+ return {
467
+ record: {
468
+ schema: 1,
469
+ item,
470
+ intent: { kind: 'merge-restale' },
471
+ products: {},
472
+ },
473
+ };
474
+ case 'needs-attention': {
475
+ const reason = (t) => boundHandoffText(t, HANDOFF_LIMITS.reasonChars);
476
+ const products = {
477
+ reason: reason(p.reason),
478
+ ...(p.questions === undefined || p.questions.length === 0
479
+ ? {}
480
+ : { questions: p.questions.map(reason) }),
481
+ };
482
+ return {
483
+ record: {
484
+ schema: 1,
485
+ item,
486
+ intent: { kind: 'needs-attention' },
487
+ products,
488
+ },
489
+ ...(p.bundle === undefined ? {} : { bundle: { ...p.bundle, baseSha } }),
490
+ };
491
+ }
492
+ case 'already-integrated':
493
+ throw new Error(`the answered merge of ${item} has nothing to land: ${p.message}`);
494
+ }
495
+ }
496
+ /**
497
+ * The surface questions of a captured emit, bounded for the handoff, each with
498
+ * the context and suggested default the agent gave it. A question with neither
499
+ * travels as its bare text (the plain form every schema-1 reader accepts), so
500
+ * only a question that has more to carry needs the object form.
501
+ */
502
+ function questionEntries(emit) {
503
+ const bound = (t) => boundHandoffText(t, HANDOFF_LIMITS.reasonChars);
504
+ const entries = [];
505
+ for (const q of emit?.questions ?? []) {
506
+ const question = bound(q.question);
507
+ if (question.trim() === '')
508
+ continue;
509
+ if (q.context === undefined && q.default === undefined) {
510
+ entries.push(question);
511
+ continue;
512
+ }
513
+ entries.push({
514
+ question,
515
+ ...(q.context === undefined ? {} : { context: bound(q.context) }),
516
+ ...(q.default === undefined ? {} : { default: bound(q.default) }),
517
+ });
518
+ }
519
+ return entries;
520
+ }
521
+ /** The ask's follow-up questions of a verdict, kept separate (never joined). */
522
+ function askQuestions(verdict) {
523
+ const questions = verdict.questions !== undefined && verdict.questions.length > 0
524
+ ? verdict.questions
525
+ : [verdict.question ?? ''];
526
+ return questions.map((q) => boundHandoffText(q, HANDOFF_LIMITS.reasonChars));
527
+ }
528
+ /** Control characters and line separators (a one-line handoff field refuses them). */
529
+ // eslint-disable-next-line no-control-regex
530
+ const LINE_BREAKING_RE = /[\u0000-\u001f\u007f\u2028\u2029]+/g;
531
+ /** A drafted title as the handoff's one-line field (or `fallback`). */
532
+ function oneLine(text, fallback) {
533
+ const line = (text ?? '')
534
+ .replace(LINE_BREAKING_RE, ' ')
535
+ .trim()
536
+ .slice(0, HANDOFF_LIMITS.documentTitleChars)
537
+ .trim();
538
+ return line === '' ? fallback : line;
539
+ }
540
+ /**
541
+ * Map the agentic apply verdict to the `apply-decision` products. A mint keeps
542
+ * the laptop defaults: no drafted slug is the observation's slug (sanitised as
543
+ * `promoteObservation` / `mintAdr` sanitise it), no drafted body is `''` (the
544
+ * writer then builds the self-contained body from the note, as it does when the
545
+ * verdict carries none), and no title is the slug (the ADR default; a task or
546
+ * spec draft's title is not used by the writer today).
547
+ */
548
+ function decisionProducts(verdict, itemSlug) {
549
+ const reason = (r) => r === undefined || r.trim() === ''
550
+ ? {}
551
+ : { reason: boundHandoffText(r, HANDOFF_LIMITS.reasonChars) };
552
+ const mint = (outcome, slug, title, body) => {
553
+ const safe = ensureSafeSlug(slug ?? itemSlug) || itemSlug;
554
+ return {
555
+ outcome,
556
+ slug: safe,
557
+ title: oneLine(title, safe),
558
+ body: boundHandoffText(body ?? '', HANDOFF_LIMITS.documentChars),
559
+ };
560
+ };
561
+ switch (verdict.outcome) {
562
+ case 'task':
563
+ return mint('task', verdict.taskSlug, verdict.taskTitle, verdict.taskBody);
564
+ case 'spec':
565
+ return mint('spec', verdict.specSlug, verdict.specTitle, verdict.specBody);
566
+ case 'adr':
567
+ return mint('adr', verdict.adrSlug, verdict.adrTitle, verdict.adrBody);
568
+ case 'dispose':
569
+ return { outcome: 'dispose', ...reason(verdict.disposeReason) };
570
+ case 'resolve':
571
+ return { outcome: 'resolve', ...reason(verdict.resolveReason) };
572
+ case 'ask':
573
+ return {
574
+ outcome: 'ask',
575
+ questions: askQuestions(verdict),
576
+ };
577
+ default:
578
+ throw new Error(`the decision verdict ${String(verdict.outcome)} cannot be handed over`);
579
+ }
580
+ }
581
+ /**
582
+ * Run the rung body under the recorder and map what its agents decided to the
583
+ * handoff record. Throws when the rung failed or reached nothing to hand over.
584
+ */
585
+ async function treelessHandover(params) {
586
+ const { options, rung, resolved, dir, captured } = params;
587
+ const { item } = resolved;
588
+ const classification = classifyIn(dir, resolved);
589
+ if (classification.kind !== rung) {
590
+ throw new Error(`${item} classifies as ${classification.kind} at the base, not ${rung}`);
591
+ }
592
+ const exec = await dispatchTreeless(options.executor ?? defaultRungExecutor, rung, rungInput(resolved, classification, agentPhaseContext(options, dir, captured)));
593
+ // The answered merge: what its agent half found IS the handover (a red gate
594
+ // or a conflict ends the rung as `merge-refused`, which is still handed over).
595
+ if (captured.merge !== undefined) {
596
+ return mergeHandover(item, captured.merge.preparation, params.baseSha);
597
+ }
598
+ if (exec.exitCode !== 0) {
599
+ throw new Error(`the ${rung} rung failed: ${exec.message}`);
600
+ }
601
+ const nothing = () => {
602
+ throw new Error(`the ${rung} rung of ${item} reached nothing to hand over (${exec.outcome}: ${exec.message})`);
603
+ };
604
+ return { record: capturedRecord(rung, resolved, captured, nothing) };
605
+ }
606
+ /** The handoff record of what the rung's agents decided (see {@link treelessHandover}). */
607
+ function capturedRecord(rung, resolved, captured, nothing) {
608
+ const { item } = resolved;
609
+ switch (rung) {
610
+ case 'surface':
611
+ if (!captured.surfacePersisted)
612
+ nothing();
613
+ return {
614
+ schema: 1,
615
+ item,
616
+ intent: { kind: 'surface' },
617
+ products: {
618
+ questions: questionEntries(captured.surface),
619
+ },
620
+ };
621
+ case 'triage-observation': {
622
+ const t = captured.triage;
623
+ if (captured.autoDisposed && t?.auto === true) {
624
+ return {
625
+ schema: 1,
626
+ item,
627
+ intent: { kind: 'triage' },
628
+ products: {
629
+ disposition: t.kind,
630
+ target: canonicalHandoffItem(t.existing),
631
+ ...(t.reason.trim() === ''
632
+ ? {}
633
+ : {
634
+ reason: boundHandoffText(t.reason, HANDOFF_LIMITS.reasonChars),
635
+ }),
636
+ },
637
+ };
638
+ }
639
+ if (!captured.surfacePersisted)
640
+ nothing();
641
+ return {
642
+ schema: 1,
643
+ item,
644
+ intent: { kind: 'triage' },
645
+ products: {
646
+ disposition: 'keep',
647
+ questions: questionEntries(captured.surface),
648
+ },
649
+ };
650
+ }
651
+ case 'apply':
652
+ if (captured.verdict === undefined)
653
+ nothing();
654
+ return {
655
+ schema: 1,
656
+ item,
657
+ intent: { kind: 'apply-decision' },
658
+ products: decisionProducts(captured.verdict, resolved.slug),
659
+ };
660
+ }
661
+ }
662
+ /**
663
+ * The agent phase: after a read-only lock-ownership check, run the rung body in
664
+ * a worktree of `baseSha` with the real agents and no write, and write the
665
+ * handoff. Holds a read-only token. Any failure writes no handoff and fails the
666
+ * job (the apply job then surfaces the item).
667
+ */
668
+ export async function performTreelessAgentPhase(options) {
669
+ const note = options.note ?? (() => { });
670
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
671
+ const cwd = options.cwd;
672
+ const env = options.env ?? process.env;
673
+ const held = requireTreelessLock(options, env);
674
+ if (options.handoffDir === undefined) {
675
+ throw new PhaseDriverError('the agent phase needs a handoff directory');
676
+ }
677
+ if (held.needsAgent === false) {
678
+ throw new PhaseDriverError(`the lock job said the ${held.rung} rung needs no agent; the agent job ` +
679
+ 'must be skipped');
680
+ }
681
+ const resolved = treelessItem(options, options.repoPath ?? cwd);
682
+ const { item } = resolved;
683
+ const ownership = await checkLockOwnership({
684
+ cwd,
685
+ arbiter,
686
+ item,
687
+ lockSha: held.lockSha,
688
+ env,
689
+ });
690
+ if (!ownership.owned) {
691
+ return {
692
+ exitCode: 0,
693
+ outcome: 'stale-lock',
694
+ item,
695
+ message: ownership.message,
696
+ };
697
+ }
698
+ let record;
699
+ const captured = { surfacePersisted: false, autoDisposed: false };
700
+ try {
701
+ // A read-only fetch, so the lock job's base is in this checkout.
702
+ await fetchArbiterMain({ cwd, arbiter, env });
703
+ const handover = await withBaseWorktree({ cwd, baseSha: held.baseSha, env }, async (dir) => {
704
+ const outcome = await runAgentPhase(createPhaseRecorder({ record: [] }), () => treelessHandover({
705
+ options,
706
+ rung: held.rung,
707
+ resolved,
708
+ dir,
709
+ baseSha: held.baseSha,
710
+ captured,
711
+ }));
712
+ if (outcome.halted) {
713
+ throw new Error(`the ${held.rung} rung reached a write (${outcome.intent.seam}.${outcome.intent.method})`);
714
+ }
715
+ return outcome.result;
716
+ });
717
+ record = handover.record;
718
+ const written = writeHandoff({
719
+ dir: options.handoffDir,
720
+ rung: held.rung,
721
+ record,
722
+ bundle: handover.bundle,
723
+ });
724
+ if (written.lfsMissing.length > 0) {
725
+ note(`the local LFS store lacks ${written.lfsMissing.length} object(s) a new ` +
726
+ `commit points at (${written.lfsMissing.slice(0, 5).join(', ')}); the ` +
727
+ 'apply job will reject the handoff');
728
+ }
729
+ }
730
+ catch (err) {
731
+ const message = err instanceof Error ? err.message : String(err);
732
+ return { exitCode: 1, outcome: 'agent-failed', item, message };
733
+ }
734
+ finally {
735
+ // The answered merge's job worktree, once its bundle is written.
736
+ captured.merge?.dispose();
737
+ }
738
+ const kind = record.intent.kind;
739
+ const message = `handed over ${kind} for ${item}`;
740
+ return { exitCode: 0, outcome: 'handed-over', item, message, intent: kind };
741
+ }
742
+ /**
743
+ * The apply phase: check the lock is still this run's, act on the agent job's
744
+ * result, validate the handoff as hostile, then run the rung's write half on a
745
+ * fresh checkout of `<arbiter>/main`, publish and release. Runs no agent and no
746
+ * repository code.
747
+ */
748
+ export async function performTreelessApplyPhase(options) {
749
+ const note = options.note ?? (() => { });
750
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
751
+ const cwd = options.cwd;
752
+ const env = noSmudgeEnv(identityEnv(options.identity, options.env ?? process.env));
753
+ const held = requireTreelessLock(options, env);
754
+ if (options.agentResult === undefined) {
755
+ throw new PhaseDriverError('the apply phase needs the agent job result (--agent-result, ' +
756
+ 'needs.agent.result): only success reads the handoff');
757
+ }
758
+ const agentResult = options.agentResult;
759
+ const resolved = treelessItem(options, options.repoPath ?? cwd);
760
+ const { item } = resolved;
761
+ // Lock ownership BEFORE the first write (spec §8).
762
+ const ownership = await checkLockOwnership({
763
+ cwd,
764
+ arbiter,
765
+ item,
766
+ lockSha: held.lockSha,
767
+ env,
768
+ });
769
+ if (!ownership.owned) {
770
+ return {
771
+ exitCode: 0,
772
+ outcome: 'stale-lock',
773
+ item,
774
+ message: ownership.message,
775
+ };
776
+ }
777
+ // Every lock release from here on (the surface's, the final one) is leased
778
+ // on the sha this run owns.
779
+ const restoreLease = leaseLockReleases(item, held.lockSha);
780
+ try {
781
+ const ctx = {
782
+ options,
783
+ held,
784
+ resolved,
785
+ arbiter,
786
+ cwd,
787
+ env,
788
+ note,
789
+ };
790
+ const decision = await resolveAgentResult({
791
+ result: agentResult,
792
+ held,
793
+ agentTimeoutMinutes: options.agentTimeoutMinutes,
794
+ api: options.actionsApi,
795
+ env,
796
+ });
797
+ switch (decision.action) {
798
+ case 'deterministic':
799
+ return await runRungAndPublish(ctx, undefined);
800
+ case 'read-handoff': {
801
+ if (held.needsAgent === false) {
802
+ // No agent was needed: whatever the agent job left is not read.
803
+ note('the lock job said no agent is needed; the handoff is not read ' +
804
+ 'and the rung runs deterministically');
805
+ return await runRungAndPublish(ctx, undefined);
806
+ }
807
+ if (isAnsweredMergeRun(held)) {
808
+ let validated;
809
+ try {
810
+ validated = readAnsweredMergeHandoff(ctx);
811
+ }
812
+ catch (err) {
813
+ if (!(err instanceof HandoffRejected))
814
+ throw err;
815
+ return await surfaceItem(ctx, err.message, 'rejected');
816
+ }
817
+ return await applyAnsweredMerge(ctx, validated);
818
+ }
819
+ let record;
820
+ try {
821
+ record = readTreelessHandoff(ctx);
822
+ }
823
+ catch (err) {
824
+ if (!(err instanceof HandoffRejected))
825
+ throw err;
826
+ return await surfaceItem(ctx, err.message, 'rejected');
827
+ }
828
+ return await runRungAndPublish(ctx, record);
829
+ }
830
+ case 'surface':
831
+ return await surfaceItem(ctx, decision.reason, 'surfaced');
832
+ case 'release': {
833
+ const released = await releaseLockLeased({
834
+ cwd,
835
+ arbiter,
836
+ item,
837
+ expectedSha: held.lockSha,
838
+ env,
839
+ });
840
+ note(released.message);
841
+ return released.released
842
+ ? {
843
+ exitCode: 0,
844
+ outcome: 'released',
845
+ item,
846
+ message: `Released '${item}': ${decision.reason}.`,
847
+ }
848
+ : {
849
+ exitCode: 1,
850
+ outcome: 'release-refused',
851
+ item,
852
+ message: `Could not release '${item}' (${released.message}): ${decision.reason}.`,
853
+ };
854
+ }
855
+ }
856
+ }
857
+ finally {
858
+ restoreLease();
859
+ }
860
+ }
861
+ /**
862
+ * Read the handoff as hostile. `readHandoff` validates the item, the intent
863
+ * kind against the rung, every enum, slug and item reference and every limit;
864
+ * on top, the rules the record format cannot know: the rung's own intent kind,
865
+ * a triage auto-disposition only under `observationTriage: auto` (the triage
866
+ * gate never runs otherwise), and a triage target that is not the note itself.
867
+ * Throws {@link HandoffRejected}.
868
+ */
869
+ function readTreelessHandoff(ctx) {
870
+ const { options, held, resolved, env } = ctx;
871
+ if (options.handoffDir === undefined) {
872
+ throw new HandoffRejected('layout', 'no handoff directory was given');
873
+ }
874
+ const record = readHandoff({
875
+ dir: options.handoffDir,
876
+ runnerTemp: runnerTempFrom(options.runnerTemp, env),
877
+ trust: { item: resolved.item, rung: held.rung },
878
+ }).record;
879
+ if (record.intent.kind !== INTENT_FOR_RUNG[held.rung]) {
880
+ throw new HandoffRejected('kind-for-rung', `a ${held.rung} run hands over ${INTENT_FOR_RUNG[held.rung]}, not ${record.intent.kind}`);
881
+ }
882
+ if (record.intent.kind === 'triage') {
883
+ const t = record.products;
884
+ if (t.disposition !== 'keep' && options.observationTriage !== 'auto') {
885
+ throw new HandoffRejected('field', `products.disposition is ${t.disposition}, but observationTriage is ` +
886
+ `${options.observationTriage ?? 'unset'}: only 'auto' runs the triage gate`);
887
+ }
888
+ if (t.target === resolved.item) {
889
+ throw new HandoffRejected('field', 'products.target names the observation itself');
890
+ }
891
+ }
892
+ return record;
893
+ }
894
+ /**
895
+ * Whether this run carries an answered merge's agent job: the lock job
896
+ * publishes `continueTip` on the apply rung only for an answered `merge` whose
897
+ * kept branch carries work `main` lacks (trusted lock outputs, never the
898
+ * artifact).
899
+ */
900
+ function isAnsweredMergeRun(held) {
901
+ return held.rung === 'apply' && held.continueTip !== undefined;
902
+ }
903
+ /** The intents an answered merge's agent job may hand over. */
904
+ const ANSWERED_MERGE_INTENTS = [
905
+ 'integrate',
906
+ 'merge-restale',
907
+ 'needs-attention',
908
+ ];
909
+ /**
910
+ * Read an answered merge's handoff as hostile, bundle included
911
+ * (`validateApplyHandoff`: the bundle's one ref is the item's work branch, its
912
+ * history descends from `baseSha`, and every path, size and LFS rule holds). The
913
+ * integration mode is the trusted `merge`: the human's answer is the checkpoint,
914
+ * so the untrusted-origin rule (a BUILD rule) does not apply. Throws
915
+ * {@link HandoffRejected}.
916
+ */
917
+ function readAnsweredMergeHandoff(ctx) {
918
+ const { options, held, resolved, arbiter, cwd, env } = ctx;
919
+ if (options.handoffDir === undefined) {
920
+ throw new HandoffRejected('layout', 'no handoff directory was given');
921
+ }
922
+ const validated = validateApplyHandoff({
923
+ dir: options.handoffDir,
924
+ runnerTemp: runnerTempFrom(options.runnerTemp, env),
925
+ repo: cwd,
926
+ trust: {
927
+ item: resolved.item,
928
+ rung: held.rung,
929
+ baseSha: held.baseSha,
930
+ arbiter,
931
+ integrationMode: 'merge',
932
+ },
933
+ env,
934
+ });
935
+ const kind = validated.handoff.record.intent.kind;
936
+ if (!ANSWERED_MERGE_INTENTS.includes(kind)) {
937
+ throw new HandoffRejected('kind-for-rung', `an answered merge hands over ${ANSWERED_MERGE_INTENTS.join(', ')}, not ${kind}`);
938
+ }
939
+ return validated;
940
+ }
941
+ /**
942
+ * The write half of an answered merge (spec §3, the `apply, kind: merge` row).
943
+ * `merge-restale` re-pauses through the rung body. Otherwise, after checking the
944
+ * item still carries the answered `merge` on a fresh `main`: the handoff's LFS
945
+ * objects first, then the rebased branch pushed leased on `continueTip` (a
946
+ * branch that moved writes nothing more), then either the needs-attention route
947
+ * or the land through the unchanged compare-and-swap loop in merge mode, with
948
+ * the gated tip (a land after a lost race carries `Landed-Without-Regate`), and
949
+ * only then the answer recorded and published on the new `main`.
950
+ */
951
+ async function applyAnsweredMerge(ctx, validated) {
952
+ const { options, held, resolved, arbiter, cwd, env, note } = ctx;
953
+ const { item, slug } = resolved;
954
+ const record = validated.handoff.record;
955
+ if (record.intent.kind === 'merge-restale') {
956
+ return runRungAndPublish(ctx, record);
957
+ }
958
+ // Nothing is written unless the item still carries its answered merge.
959
+ const stale = await answeredMergeGone(ctx);
960
+ if (stale !== undefined)
961
+ return releaseAfter(ctx, stale);
962
+ const lfs = await pushHandoffLfs({
963
+ cwd,
964
+ arbiter,
965
+ objects: validated.lfsObjects,
966
+ env,
967
+ note,
968
+ });
969
+ if (!lfs.ok) {
970
+ return surfaceItem(ctx, lfs.reason, lfs.rejected ? 'rejected' : 'surfaced');
971
+ }
972
+ const bundle = validated.bundle;
973
+ if (bundle !== undefined) {
974
+ await gitHard(['checkout', '--quiet', '-B', bundle.workBranch, bundle.tip], cwd, env);
975
+ // The rebased branch (decision 7): the agent job rebased it locally; publish
976
+ // it leased on the tip the LOCK job observed, before any other ref.
977
+ const pushed = await refWrite.pushLeasedWorkBranch({
978
+ arbiter,
979
+ branch: bundle.workBranch,
980
+ commit: bundle.tip,
981
+ expectedTip: held.continueTip,
982
+ cwd,
983
+ env,
984
+ });
985
+ if (pushed.status !== 0) {
986
+ const message = `${bundle.workBranch} on ${arbiter} moved since the lock job saw it ` +
987
+ `at ${held.continueTip}, so the leased push of the rebased branch was ` +
988
+ `refused (${pushed.stderr.trim()}). Nothing was written and the lock ` +
989
+ `of ${item} is still held: inspect the branch, then \`dorfl ` +
990
+ `release-lock ${item}\` to retry the item.`;
991
+ return { exitCode: 1, outcome: 'stale-lease', item, message };
992
+ }
993
+ }
994
+ if (record.intent.kind === 'needs-attention') {
995
+ const { reason, questions } = record.products;
996
+ const routed = bundle !== undefined
997
+ ? await ledgerWrite.applyNeedsAttentionTransition({
998
+ cwd,
999
+ slug,
1000
+ reason,
1001
+ questions,
1002
+ arbiter,
1003
+ env,
1004
+ note,
1005
+ })
1006
+ : await ledgerWrite.applyTreelessNeedsAttentionTransition({
1007
+ cwd,
1008
+ slug,
1009
+ reason,
1010
+ questions,
1011
+ arbiter,
1012
+ env,
1013
+ note,
1014
+ });
1015
+ const message = routed.moved
1016
+ ? `Surfaced '${item}' to needs-attention: ${reason}`
1017
+ : `Could not surface '${item}' (${routed.reasonNotMoved ?? 'unknown'}): ${reason}`;
1018
+ return {
1019
+ exitCode: routed.moved ? 0 : 1,
1020
+ outcome: routed.moved ? 'surfaced' : 'surface-unmoved',
1021
+ item,
1022
+ message,
1023
+ };
1024
+ }
1025
+ // `integrate`: the bundle is required by the intent table.
1026
+ if (bundle === undefined) {
1027
+ throw new Error('an answered-merge integrate handoff carries no bundle');
1028
+ }
1029
+ const commitMessage = (await gitHard(['log', '-1', '--format=%s', bundle.tip], cwd, env)).trim();
1030
+ const land = await integrationLand.land({
1031
+ cwd,
1032
+ arbiter,
1033
+ slug,
1034
+ branch: bundle.workBranch,
1035
+ lifecycle: false,
1036
+ source: taskSourceAtBase(cwd, held.baseSha, slug, env),
1037
+ surfaceArbiter: arbiter,
1038
+ commitMessage,
1039
+ env,
1040
+ note,
1041
+ mode: 'merge',
1042
+ title: commitMessage,
1043
+ mergeRetries: options.mergeRetries ?? options.doOptions?.mergeRetries,
1044
+ mergeJitterMs: options.mergeJitterMs,
1045
+ // The landed-vs-gated report (decision 2): the agent job gated the
1046
+ // rebased tip, so a land after a lost race says it landed another tree.
1047
+ gatedTip: bundle.tip,
1048
+ });
1049
+ const landed = land.outcome === 'completed' &&
1050
+ (land.integration?.mergedToMain === true ||
1051
+ land.integration?.alreadyLanded === true);
1052
+ if (!landed) {
1053
+ const message = land.reason ?? `the land ended as ${land.outcome}`;
1054
+ if (land.routedToNeedsAttention) {
1055
+ return { exitCode: 0, outcome: 'surfaced', item, message };
1056
+ }
1057
+ return releaseAfter(ctx, {
1058
+ exitCode: 1,
1059
+ outcome: 'land-failed',
1060
+ item,
1061
+ message,
1062
+ });
1063
+ }
1064
+ note(`landed ${bundle.workBranch} on ${arbiter}/main`);
1065
+ // The answer is recorded AFTER the land, a second write to `main`, as on the
1066
+ // laptop.
1067
+ return runRungAndPublish(ctx, record, { landed: true });
1068
+ }
1069
+ /**
1070
+ * The `stale` result when the item no longer carries an answered `merge` on
1071
+ * the arbiter's current `main` (answered again, landed, or gone), else
1072
+ * `undefined`. Read-only.
1073
+ */
1074
+ async function answeredMergeGone(ctx) {
1075
+ const { resolved, arbiter, cwd, env } = ctx;
1076
+ const { item } = resolved;
1077
+ const tip = await fetchArbiterMain({ cwd, arbiter, env });
1078
+ return withBaseWorktree({ cwd, baseSha: tip, env }, async (dir) => {
1079
+ const kind = classifyIn(dir, resolved).kind;
1080
+ const verb = kind === 'apply' ? detectAnsweredMergeAction(dir, item)?.verb : undefined;
1081
+ if (verb === 'merge')
1082
+ return undefined;
1083
+ return {
1084
+ exitCode: 1,
1085
+ outcome: 'stale',
1086
+ item,
1087
+ message: `${item} no longer carries an answered merge on ${arbiter}/main ` +
1088
+ `(${tip}: ${kind}${verb === undefined ? '' : `, answered ${verb}`}); nothing was written`,
1089
+ };
1090
+ });
1091
+ }
1092
+ /** Release the lock (leased on `lockSha`) after `result`, folding a refused release into it. */
1093
+ async function releaseAfter(ctx, result) {
1094
+ const { held, resolved, arbiter, cwd, env, note } = ctx;
1095
+ const released = await releaseLockLeased({
1096
+ cwd,
1097
+ arbiter,
1098
+ item: resolved.item,
1099
+ expectedSha: held.lockSha,
1100
+ env,
1101
+ });
1102
+ note(released.message);
1103
+ if (!released.released) {
1104
+ return {
1105
+ ...result,
1106
+ exitCode: result.exitCode === 0 ? 1 : result.exitCode,
1107
+ message: `${result.message} (${released.message})`,
1108
+ };
1109
+ }
1110
+ return result;
1111
+ }
1112
+ async function gitHard(args, cwd, env) {
1113
+ const r = await runAsync('git', args, cwd, { env });
1114
+ if (r.status !== 0) {
1115
+ throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim()}`);
1116
+ }
1117
+ return r.stdout;
1118
+ }
1119
+ /** A gate the apply phase never launches: the handoff did not answer it. */
1120
+ function noAgent(what) {
1121
+ throw new PhaseDriverError(`the apply phase launches no agent (${what}); the handoff does not answer it`);
1122
+ }
1123
+ /**
1124
+ * Replay the checked handoff's surface questions as the surface gate's emit,
1125
+ * with each question's context and suggested default, so the rung persists the
1126
+ * same sidecar the laptop does.
1127
+ */
1128
+ function replaySurface(questions) {
1129
+ return async () => ({
1130
+ questions: (questions ?? []).map((q) => typeof q === 'string'
1131
+ ? { question: q }
1132
+ : {
1133
+ question: q.question,
1134
+ ...(q.context === undefined ? {} : { context: q.context }),
1135
+ ...(q.default === undefined ? {} : { default: q.default }),
1136
+ }),
1137
+ });
1138
+ }
1139
+ /** Replay the checked `apply-decision` as the decider's verdict. */
1140
+ function replayDecision(p) {
1141
+ return async () => {
1142
+ switch (p.outcome) {
1143
+ case 'task':
1144
+ return {
1145
+ outcome: 'task',
1146
+ taskSlug: p.slug,
1147
+ taskTitle: p.title,
1148
+ taskBody: p.body,
1149
+ };
1150
+ case 'spec':
1151
+ return {
1152
+ outcome: 'spec',
1153
+ specSlug: p.slug,
1154
+ specTitle: p.title,
1155
+ specBody: p.body,
1156
+ };
1157
+ case 'adr':
1158
+ return {
1159
+ outcome: 'adr',
1160
+ adrSlug: p.slug,
1161
+ adrTitle: p.title,
1162
+ adrBody: p.body,
1163
+ };
1164
+ case 'dispose':
1165
+ return { outcome: 'dispose', disposeReason: p.reason };
1166
+ case 'resolve':
1167
+ return { outcome: 'resolve', resolveReason: p.reason };
1168
+ case 'ask':
1169
+ // One follow-up question per handed-over question (never joined).
1170
+ return { outcome: 'ask', questions: p.questions ?? [] };
1171
+ }
1172
+ };
1173
+ }
1174
+ /**
1175
+ * The answered merge's action in the apply phase, which never runs the
1176
+ * repository's code: the land the apply phase itself just made (`landed`), the
1177
+ * agent job's `merge-restale`, and, with no agent job, `hold` / `drop` (the
1178
+ * laptop dispatcher, which touches nothing for them) or a `merge` whose kept
1179
+ * branch is absent or already on `main` (`already-integrated`, as the laptop
1180
+ * reports it). Anything else would need the agent job, so it throws.
1181
+ */
1182
+ function replayMergeAction(record, landed) {
1183
+ return async (input) => {
1184
+ const kind = record?.intent.kind;
1185
+ if (kind === 'integrate' && landed) {
1186
+ return {
1187
+ outcome: 'landed',
1188
+ message: `merge-question for ${input.item} answered MERGE: landed ` +
1189
+ `\`work/task-${input.slug}\` (rebased and gated by the agent job).`,
1190
+ };
1191
+ }
1192
+ if (kind === 'merge-restale') {
1193
+ // The follow-up is asked against the fresh `main` this rung runs on
1194
+ // (the base worktree's HEAD), which it records as its `askedAtMain`: a
1195
+ // re-answer while `main` stays put then lands (no re-stale livelock).
1196
+ const head = await runAsync('git', ['rev-parse', 'HEAD'], input.cwd, {
1197
+ env: input.env,
1198
+ });
1199
+ const main = head.status === 0 ? head.stdout.trim() : '';
1200
+ return {
1201
+ outcome: 'restale',
1202
+ message: `merge-question for ${input.item} answered MERGE, but ` +
1203
+ 'strictMergeApproval is ON and `main` moved since the question was ' +
1204
+ 'asked (checked by the agent job): re-surfacing the merge-question.',
1205
+ ...(main === '' ? {} : { main }),
1206
+ };
1207
+ }
1208
+ if (record === undefined) {
1209
+ if (input.action.verb !== 'merge')
1210
+ return performMergeAction(input);
1211
+ return {
1212
+ outcome: 'already-integrated',
1213
+ message: `merge-question for ${input.item} answered MERGE: ` +
1214
+ `\`work/task-${input.slug}\` is absent or already on main (nothing to land).`,
1215
+ };
1216
+ }
1217
+ return noAgent('the answered merge action');
1218
+ };
1219
+ }
1220
+ /**
1221
+ * The agent seams of the apply phase: replays of the checked handoff, and a
1222
+ * throwing gate for every seam the handoff does not answer (all of them when
1223
+ * the rung runs deterministically).
1224
+ */
1225
+ function replayGates(record, landed = false) {
1226
+ const gates = {
1227
+ surfaceGate: async () => noAgent('the surface-questions agent'),
1228
+ triageGate: async () => noAgent('the triage gate'),
1229
+ applyDecide: async () => noAgent('the apply decision agent'),
1230
+ mergeAction: replayMergeAction(record, landed),
1231
+ };
1232
+ if (record === undefined)
1233
+ return gates;
1234
+ if (record.intent.kind === 'surface') {
1235
+ gates.surfaceGate = replaySurface(record.products.questions);
1236
+ }
1237
+ else if (record.intent.kind === 'triage') {
1238
+ const t = record.products;
1239
+ gates.surfaceGate = replaySurface(t.questions);
1240
+ gates.triageGate = async () => t.disposition === 'keep'
1241
+ ? { auto: false }
1242
+ : {
1243
+ auto: true,
1244
+ kind: t.disposition,
1245
+ existing: t.target,
1246
+ reason: t.reason ?? '',
1247
+ };
1248
+ }
1249
+ else if (record.intent.kind === 'apply-decision') {
1250
+ gates.applyDecide = replayDecision(record.products);
1251
+ }
1252
+ return gates;
1253
+ }
1254
+ /** Whether `HEAD` of `dir` is now on the arbiter's `main` (after the publish). */
1255
+ async function publishedToMain(dir, arbiter, env) {
1256
+ await runAsync('git', [
1257
+ 'fetch',
1258
+ '--quiet',
1259
+ '--no-tags',
1260
+ arbiter,
1261
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
1262
+ ], dir, { env });
1263
+ const r = await runAsync('git', ['merge-base', '--is-ancestor', 'HEAD', `refs/remotes/${arbiter}/main`], dir, { env });
1264
+ return r.status === 0;
1265
+ }
1266
+ /**
1267
+ * Run the rung body's write half on a FRESH checkout of `<arbiter>/main`: check
1268
+ * the item still classifies on the lock job's rung with the same agent need,
1269
+ * run the rung with the replayed (or refusing) agent seams and the real write
1270
+ * seams, check the publish scope and publish. Then release the lock (leased),
1271
+ * whatever happened past the ownership check.
1272
+ */
1273
+ async function runRungAndPublish(ctx, record, opts = {}) {
1274
+ const landed = opts.landed === true;
1275
+ const { options, held, resolved, arbiter, cwd, env, note } = ctx;
1276
+ const { item } = resolved;
1277
+ const tip = await fetchArbiterMain({ cwd, arbiter, env });
1278
+ const result = await withBaseWorktree({ cwd, baseSha: tip, env }, async (dir) => {
1279
+ const classification = classifyIn(dir, resolved);
1280
+ const input = rungInput(resolved, classification, {
1281
+ ...options,
1282
+ cwd: dir,
1283
+ arbiter,
1284
+ note,
1285
+ ...replayGates(record, landed),
1286
+ });
1287
+ let need;
1288
+ if (classification.kind === held.rung && landed) {
1289
+ // The land already happened, so the kept branch is on main and the
1290
+ // need would recompute to none: an agent job did run, and its
1291
+ // answered merge is what is left to record.
1292
+ need =
1293
+ detectAnsweredMergeAction(dir, item)?.verb === 'merge'
1294
+ ? { needsAgent: true }
1295
+ : { unsplit: 'no answered merge left to record after the land' };
1296
+ }
1297
+ else if (classification.kind === held.rung) {
1298
+ need = (await treelessPhaseNeed(input, { cwd, arbiter, mainSha: tip, env })).need;
1299
+ }
1300
+ if (need?.needsAgent !== (record !== undefined)) {
1301
+ const what = need === undefined
1302
+ ? `classifies as ${classification.kind}, not ${held.rung}`
1303
+ : need.needsAgent === undefined
1304
+ ? `needs ${need.unsplit}`
1305
+ : need.needsAgent
1306
+ ? 'now needs an agent, but none ran'
1307
+ : 'now needs no agent, but one ran';
1308
+ return {
1309
+ exitCode: 1,
1310
+ outcome: 'stale',
1311
+ item,
1312
+ message: `${item} ${what} on ${arbiter}/main (${tip}); ` +
1313
+ (landed
1314
+ ? 'its merge landed, but the answer was not recorded'
1315
+ : 'nothing was written'),
1316
+ };
1317
+ }
1318
+ const exec = await dispatchTreeless(options.executor ?? defaultRungExecutor, held.rung, input);
1319
+ if (exec.exitCode !== 0) {
1320
+ return {
1321
+ exitCode: exec.exitCode,
1322
+ outcome: 'rung-failed',
1323
+ item,
1324
+ message: exec.message,
1325
+ rungOutcome: exec.outcome,
1326
+ };
1327
+ }
1328
+ const scope = await checkTreelessPublishScope({
1329
+ cwd: dir,
1330
+ base: tip,
1331
+ env,
1332
+ });
1333
+ if (scope.kind === 'refused') {
1334
+ return {
1335
+ exitCode: 1,
1336
+ outcome: 'publish-refused',
1337
+ item,
1338
+ message: `${exec.message} ${scope.message}; nothing was published`,
1339
+ rungOutcome: exec.outcome,
1340
+ };
1341
+ }
1342
+ if (scope.kind === 'rung-commit') {
1343
+ await refWrite.publishTreelessResult({
1344
+ cwd: dir,
1345
+ arbiter,
1346
+ retries: options.publishRetries ?? 1000,
1347
+ jitterMs: options.publishJitterMs ?? DEFAULT_TREELESS_JITTER_MS,
1348
+ env,
1349
+ note,
1350
+ });
1351
+ if (!(await publishedToMain(dir, arbiter, env))) {
1352
+ return {
1353
+ exitCode: 1,
1354
+ outcome: 'publish-failed',
1355
+ item,
1356
+ message: `${exec.message} The result did not reach ${arbiter}/main.`,
1357
+ rungOutcome: exec.outcome,
1358
+ };
1359
+ }
1360
+ }
1361
+ return {
1362
+ exitCode: 0,
1363
+ outcome: 'applied',
1364
+ item,
1365
+ message: exec.message,
1366
+ rungOutcome: exec.outcome,
1367
+ };
1368
+ });
1369
+ // The release, leased on `lockSha`, AFTER the publish: the lock covers the
1370
+ // write. It runs whatever happened above, so a refused or failed publish
1371
+ // leaves the item for the next tick rather than locked.
1372
+ const released = await releaseLockLeased({
1373
+ cwd,
1374
+ arbiter,
1375
+ item,
1376
+ expectedSha: held.lockSha,
1377
+ env,
1378
+ });
1379
+ note(released.message);
1380
+ if (!released.released) {
1381
+ return {
1382
+ ...result,
1383
+ exitCode: result.exitCode === 0 ? 1 : result.exitCode,
1384
+ message: `${result.message} (${released.message})`,
1385
+ };
1386
+ }
1387
+ return result;
1388
+ }
1389
+ /**
1390
+ * Surface the item to needs-attention (decision 5, and a rejected handoff): a
1391
+ * `kind: stuck` question in its sidecar and `needsAnswers: true` on `main`, then
1392
+ * the lock released (leased). Nothing from the agent job is read or written.
1393
+ */
1394
+ async function surfaceItem(ctx, reason, outcome) {
1395
+ const { resolved, arbiter, cwd, env, note } = ctx;
1396
+ const { item } = resolved;
1397
+ const r = await surfaceStuckToNeedsAttention({
1398
+ cwd,
1399
+ slug: resolved.slug,
1400
+ item,
1401
+ reason,
1402
+ arbiter,
1403
+ env,
1404
+ note,
1405
+ });
1406
+ const message = r.surfaced
1407
+ ? `Surfaced '${item}' to needs-attention: ${reason}`
1408
+ : `Could not surface '${item}' (${r.reasonNotSurfaced ?? 'unknown'}): ${reason}`;
1409
+ if (!r.surfaced) {
1410
+ return { exitCode: 1, outcome: 'surface-unmoved', item, message };
1411
+ }
1412
+ // A clean surface is green, a rejected handoff's included ("A handled
1413
+ // outcome is green", `ci-phase-driver.ts`).
1414
+ return {
1415
+ exitCode: 0,
1416
+ outcome,
1417
+ item,
1418
+ message,
1419
+ };
1420
+ }
1421
+ function pathForRung(rung) {
1422
+ if (rung === 'build-task')
1423
+ return 'build';
1424
+ if (rung === 'task-spec')
1425
+ return 'tasking';
1426
+ if (isTreelessPhaseRung(rung))
1427
+ return 'treeless';
1428
+ return undefined;
1429
+ }
1430
+ /**
1431
+ * Route `advance <item> --phase` to its split path. The agent and apply phases
1432
+ * follow the lock job's trusted `rung` output. The lock phase classifies the
1433
+ * item at the arbiter's `main` (read-only; the chosen path's lock phase fetches
1434
+ * and classifies again, and refuses a rung it does not split). Anything that
1435
+ * does not classify (a pending sidecar, an unreadable argument, no lock
1436
+ * outputs) falls back to the argument's namespace, so the path's own lock or
1437
+ * usage report runs: `spec:` is tasking, `obs:` is tree-less, anything else is
1438
+ * the build path.
1439
+ */
1440
+ export async function advancePhasePath(params) {
1441
+ const explicit = parseSlugArg(params.arg).explicit;
1442
+ const fallback = explicit === 'spec'
1443
+ ? 'tasking'
1444
+ : explicit === 'observation'
1445
+ ? 'treeless'
1446
+ : 'build';
1447
+ const env = params.env ?? process.env;
1448
+ if (params.phase !== 'lock') {
1449
+ try {
1450
+ const facts = params.lockOutputs ?? readLockOutputsFromEnv(env);
1451
+ return pathForRung(facts.rung) ?? fallback;
1452
+ }
1453
+ catch {
1454
+ return fallback;
1455
+ }
1456
+ }
1457
+ try {
1458
+ const cwd = params.cwd;
1459
+ const baseSha = await fetchArbiterMain({
1460
+ cwd,
1461
+ arbiter: params.arbiter ?? DEFAULT_ARBITER,
1462
+ env,
1463
+ });
1464
+ const kind = await withBaseWorktree({ cwd, baseSha, env }, async (base) => {
1465
+ const resolved = resolveAdvanceArg({
1466
+ arg: params.arg,
1467
+ repoPath: base,
1468
+ read: params.read ?? ledgerRead,
1469
+ });
1470
+ return classifyIn(base, {
1471
+ namespace: resolved.namespace,
1472
+ slug: resolved.slug,
1473
+ item: `${resolved.namespace}:${resolved.slug}`,
1474
+ }).kind;
1475
+ });
1476
+ return pathForRung(kind) ?? fallback;
1477
+ }
1478
+ catch {
1479
+ return fallback;
1480
+ }
1481
+ }
1482
+ //# sourceMappingURL=ci-phase-treeless.js.map