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,1255 @@
1
+ /**
2
+ * **The tasking path split into the three CI phases** (spec
3
+ * `ci-agent-job-without-write-token` §3 tasking row, ADR
4
+ * `ci-agent-job-holds-no-write-token`, task `ci-split-tasking`).
5
+ *
6
+ * `do spec:<slug>` / `advance spec:<slug>` with `--phase` run ONE of three
7
+ * halves of the one tasking pipeline (`tasking.ts`: {@link runTaskingAgentHalf}
8
+ * is the agent half, {@link integrateTaskingCandidates} /
9
+ * {@link surfaceTaskingBlock} / {@link persistTaskingCandidates} the write half),
10
+ * never a second implementation:
11
+ *
12
+ * - **lock** ({@link performTaskingLockPhase}): fetch `<arbiter>/main` (its tip
13
+ * is `baseSha`); in a throwaway worktree of it, check the spec rests in
14
+ * `work/specs/ready/` and passes the agent tasking gate (and, for `advance`,
15
+ * classifies on the `task-spec` rung); take the tasking lock
16
+ * (`acquireTaskingLock`, the unified `spec:<slug>` lock, `action: task`);
17
+ * publish `acquired`, `needsAgent`, `rung`, `baseSha`, `lockSha`,
18
+ * `handoffName` and `agentTimeoutMinutes`. No agent, no repository code.
19
+ * - **agent** ({@link performTaskingAgentPhase}): check, read-only, that the
20
+ * lock ref still equals `lockSha`; on a `work/spec-<slug>` branch cut from
21
+ * `baseSha`, run the tasker, its review rounds, and the one-round task-set
22
+ * review (which the laptop path runs inside `performIntegration`; it is an
23
+ * agent, so it runs HERE), then hand over `tasking-land` (the candidates, the
24
+ * PR body, the review verdict and prose, the trimmed spec body) or
25
+ * `tasking-surface` (the candidates to save, the reason). Writes nothing to
26
+ * the arbiter: every write seam records into a recorder that knows no write.
27
+ * A tasker failure writes no handoff and fails the job.
28
+ * - **apply** ({@link performTaskingApplyPhase}): refuse to write unless the
29
+ * lock ref still equals `lockSha`; act on the agent job's result first
30
+ * (`failure` / timeout surfaces the spec, a real cancel only releases the
31
+ * lock); read the handoff as hostile and never take a bundle: it re-commits
32
+ * only VALIDATED candidate files ({@link checkTaskingCandidate}) and the
33
+ * validated trimmed spec body ({@link checkTrimmedSpecBody}) on a fresh
34
+ * `work/spec-<slug>` branch cut from `baseSha`, then integrates with the spec
35
+ * move and the review OFF (it already ran), or saves the candidates on the
36
+ * work branch, closes a stale PR in propose mode and surfaces. Every lock
37
+ * release is leased on `lockSha`.
38
+ */
39
+
40
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
41
+ import {basename, dirname, join} from 'node:path';
42
+ import {classifyTick} from './advance-classify.js';
43
+ import {readItemSignals} from './advance.js';
44
+ import {
45
+ HANDOFF_LIMITS,
46
+ HandoffRejected,
47
+ handoffName as deriveHandoffName,
48
+ type HandoffRecord,
49
+ type TaskingLandProducts,
50
+ type TaskingSurfaceProducts,
51
+ } from './ci-handoff-format.js';
52
+ import {readHandoff, writeHandoff} from './ci-handoff.js';
53
+ import type {LockOutputs} from './ci-lock-outputs.js';
54
+ import type {AgentJobResult, GithubApiGet} from './ci-agent-result.js';
55
+ import {
56
+ PhaseDriverError,
57
+ agentTimeoutMinutesAt,
58
+ arbiterLockSha,
59
+ boundHandoffText,
60
+ acquireNotingOnce,
61
+ checkLockOwnership,
62
+ emitLockOutputs,
63
+ fetchArbiterMain,
64
+ noSmudgeEnv,
65
+ releaseLockLeased,
66
+ requireHeldLock,
67
+ resolveAgentResult,
68
+ runnerTempFrom,
69
+ withBaseWorktree,
70
+ type HeldLock,
71
+ } from './ci-phase-driver.js';
72
+ import type {DoOptions} from './do.js';
73
+ import {
74
+ FrontmatterRenderError,
75
+ assertFrontmatterFields,
76
+ parseFrontmatter,
77
+ unquoteYamlScalar,
78
+ type Frontmatter,
79
+ } from './frontmatter.js';
80
+ import {runAsync} from './git.js';
81
+ import {identityEnv} from './identity.js';
82
+ import {runReviewRounds} from './integration-core.js';
83
+ import {leaseLockReleases} from './item-lock.js';
84
+ import {formatBlockReason} from './review-gate.js';
85
+ import {ledgerRead} from './ledger-read.js';
86
+ import type {Phase} from './phase.js';
87
+ import {createPhaseRecorder, runAgentPhase} from './phase-recorder.js';
88
+ import {isSafeSlug} from './slug-safety.js';
89
+ import {
90
+ SlugResolutionError,
91
+ resolveAdvanceArg,
92
+ resolveSlug,
93
+ } from './slug-namespace.js';
94
+ import {
95
+ STAGED_TASKS_DIR,
96
+ collectEmittedTasks,
97
+ composeTaskingProposeBody,
98
+ decompositionUnclearReason,
99
+ gateRefusalReason,
100
+ integrateTaskingCandidates,
101
+ newOrChangedStagedTasks,
102
+ persistTaskingCandidates,
103
+ resolveAgentGate,
104
+ reviewFailureReason,
105
+ runTaskingAgentHalf,
106
+ snapshotPool,
107
+ snapshotStagedTasks,
108
+ surfaceTaskingBlock,
109
+ switchToWorkBranch,
110
+ taskGateBlockedReason,
111
+ taskGateUnparseableReason,
112
+ taskPlacementDir,
113
+ type PerformTaskOptions,
114
+ type TaskResult,
115
+ } from './tasking.js';
116
+ import {acquireTaskingLock, releaseTaskingLock} from './tasking-lock.js';
117
+ import {workFolderRel, workItemPath, workItemRel} from './work-layout.js';
118
+
119
+ const DEFAULT_ARBITER = 'origin';
120
+
121
+ /** The verb a tasking phase runs under (they classify the argument differently). */
122
+ export type TaskingPhaseVerb = 'do' | 'advance';
123
+
124
+ /** Options of a tasking phase run: the `do` pipeline's options plus the phase inputs. */
125
+ export interface TaskingPhaseOptions extends DoOptions {
126
+ /** Which of the three jobs this process is. */
127
+ phase: Phase;
128
+ /** `do spec:<slug>` or `advance spec:<slug>`. */
129
+ verb: TaskingPhaseVerb;
130
+ /** agent: write the handoff here (empty or absent); apply: read it (under {@link runnerTemp}). */
131
+ handoffDir?: string;
132
+ /** `$RUNNER_TEMP` (the apply phase); defaults to the env's. */
133
+ runnerTemp?: string;
134
+ /** The lock job's outputs (agent / apply); defaults to `DORFL_LOCK_OUTPUTS`. */
135
+ lockOutputs?: LockOutputs;
136
+ /** `github.run_attempt` (the lock phase names the artifact with it); default the env's, else 1. */
137
+ runAttempt?: string;
138
+ /** The `$GITHUB_OUTPUT` file the lock phase appends to; default the env's. */
139
+ githubOutput?: string;
140
+ /** The merge-mode CAS-loop jitter (tests pass 0). */
141
+ mergeJitterMs?: number;
142
+ /** apply: `needs.agent.result` (`--agent-result`), REQUIRED. Only `success` reads the handoff. */
143
+ agentResult?: AgentJobResult;
144
+ /** apply: the agent job's timeout (`--agent-timeout-minutes`); default the lock outputs'. */
145
+ agentTimeoutMinutes?: number;
146
+ /** apply: the Actions API reader (tests stub it); default `fetch` with `GITHUB_TOKEN`. */
147
+ actionsApi?: GithubApiGet;
148
+ }
149
+
150
+ /** How a tasking phase run ended. */
151
+ export type TaskingPhaseOutcome =
152
+ /** lock: the spec is locked; the agent phase runs next. */
153
+ | 'locked'
154
+ /** lock: nothing to task at the arbiter's `main`. */
155
+ | 'no-op'
156
+ /** lock: the agent tasking gate refused the spec (nothing was written). */
157
+ | 'gate-refused'
158
+ /** lock: another run holds the spec's lock. */
159
+ | 'lost'
160
+ /** agent / apply: the lock ref no longer equals `lockSha`; nothing was done. */
161
+ | 'stale-lock'
162
+ /** agent: the handoff was written (see `intent`). */
163
+ | 'handed-over'
164
+ /** agent: the tasker (or the phase) failed; no handoff (the job fails). */
165
+ | 'agent-failed'
166
+ /** apply: the tasks and the spec move landed on `main`; the lock was released. */
167
+ | 'landed'
168
+ /** apply: the work branch was pushed (and a PR requested); the lock stays held. */
169
+ | 'proposed'
170
+ /** apply: the spec was surfaced to needs-attention and its lock released. */
171
+ | 'surfaced'
172
+ /** apply: surfacing the spec did not land on the arbiter. */
173
+ | 'surface-unmoved'
174
+ /** apply: the spec on `main` changed since the lock job; nothing was written. */
175
+ | 'stale'
176
+ /** apply: the agent job was cancelled (not timed out); the lock was only released. */
177
+ | 'released'
178
+ /** apply: the cancelled agent job's leased lock release was refused. */
179
+ | 'release-refused'
180
+ /** apply: the handoff broke a rule; the spec was surfaced, nothing from it landed. */
181
+ | 'rejected'
182
+ /** A usage or environment problem. */
183
+ | 'usage-error';
184
+
185
+ /** The result of one tasking phase run. */
186
+ export interface TaskingPhaseResult {
187
+ /** See "A handled outcome is green" in `ci-phase-driver.ts`. */
188
+ exitCode: 0 | 1 | 2 | 3 | 4;
189
+ outcome: TaskingPhaseOutcome;
190
+ /**
191
+ * The result line: the CLI prints it (`>> ` or `error: `), so the phase does
192
+ * NOT also `note` it (each line appears once in the job log).
193
+ */
194
+ message: string;
195
+ slug?: string;
196
+ /** lock: the facts published. */
197
+ lockOutputs?: LockOutputs;
198
+ /** agent: the handed-over intent kind. */
199
+ intent?: HandoffRecord['intent']['kind'];
200
+ /** apply: the repository paths of the tasks the runner committed. */
201
+ emitted?: string[];
202
+ }
203
+
204
+ /**
205
+ * Run one phase of the tasking path. The CLI calls this for `do spec:<slug>
206
+ * --phase` and `advance spec:<slug> --phase`.
207
+ */
208
+ export async function performTaskingPhase(
209
+ options: TaskingPhaseOptions,
210
+ ): Promise<TaskingPhaseResult> {
211
+ try {
212
+ switch (options.phase) {
213
+ case 'lock':
214
+ return await performTaskingLockPhase(options);
215
+ case 'agent':
216
+ return await performTaskingAgentPhase(options);
217
+ case 'apply':
218
+ return await performTaskingApplyPhase(options);
219
+ }
220
+ } catch (err) {
221
+ if (err instanceof PhaseDriverError || err instanceof SlugResolutionError) {
222
+ return {exitCode: 1, outcome: 'usage-error', message: err.message};
223
+ }
224
+ throw err;
225
+ }
226
+ }
227
+
228
+ /** The spec slug a tasking phase acts on, from the (trusted) workflow argument. */
229
+ function taskingSlug(options: TaskingPhaseOptions, repoPath: string): string {
230
+ const read = options.read ?? ledgerRead;
231
+ const resolved =
232
+ options.verb === 'advance'
233
+ ? resolveAdvanceArg({arg: options.arg, repoPath, read})
234
+ : resolveSlug({arg: options.arg, repoPath, read});
235
+ if (resolved.namespace !== 'spec') {
236
+ throw new PhaseDriverError(
237
+ `the tasking phases take a spec; ${options.arg} is not one`,
238
+ );
239
+ }
240
+ return resolved.slug;
241
+ }
242
+
243
+ /**
244
+ * The `performTask` options the tasking halves read, mapped from the `do`
245
+ * options exactly as `performDo` maps them for `do spec:<slug>`.
246
+ */
247
+ function taskOptionsFor(
248
+ options: TaskingPhaseOptions,
249
+ slug: string,
250
+ cwd: string,
251
+ env: NodeJS.ProcessEnv,
252
+ agentEnv: NodeJS.ProcessEnv,
253
+ ): PerformTaskOptions {
254
+ return {
255
+ slug,
256
+ cwd,
257
+ arbiter: options.arbiter ?? DEFAULT_ARBITER,
258
+ doer: 'agent',
259
+ autoTask: options.autoTask,
260
+ explicit: true,
261
+ dorfl: options.dorfl,
262
+ harness: options.harness,
263
+ agentCmd: options.agentCmd,
264
+ model: options.model,
265
+ sessionsDir: options.sessionsDir,
266
+ integration: options.taskingIntegration ?? options.integration,
267
+ mergeRetries: options.mergeRetries,
268
+ tasksLandIn: options.tasksLandIn,
269
+ untrustedTasksLandIn: options.untrustedTasksLandIn,
270
+ explicitTasksLandIn: options.explicitTasksLandIn,
271
+ noPR: options.noPR,
272
+ providerInstance: options.providerInstance,
273
+ reviewLoop: options.reviewLoop,
274
+ taskerLoopMax: options.taskerLoopMax,
275
+ reviewExecutions: options.reviewExecutions,
276
+ taskerLoopModel: options.taskerLoopModel,
277
+ review: options.review,
278
+ reviewGate: options.taskReviewGate,
279
+ acceptanceReviewModel: options.reviewModel,
280
+ env,
281
+ agentEnv,
282
+ note: options.note,
283
+ };
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // lock
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * The lock phase: check the spec at the arbiter's `main`, take the tasking lock
292
+ * and publish the trusted facts. Runs no agent and no repository code. A spec
293
+ * that is not taskable at `baseSha` is refused BEFORE any write.
294
+ */
295
+ export async function performTaskingLockPhase(
296
+ options: TaskingPhaseOptions,
297
+ ): Promise<TaskingPhaseResult> {
298
+ const note = options.note ?? (() => {});
299
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
300
+ const cwd = options.cwd;
301
+ const env = identityEnv(options.identity, options.env ?? process.env);
302
+ const githubOutput = options.githubOutput ?? env.GITHUB_OUTPUT;
303
+ const publish = (facts: LockOutputs): LockOutputs => {
304
+ emitLockOutputs(facts, {githubOutput, note});
305
+ return facts;
306
+ };
307
+
308
+ const baseSha = await fetchArbiterMain({cwd, arbiter, env});
309
+ const verdict = await withBaseWorktree(
310
+ {cwd, baseSha, env},
311
+ async (base): Promise<{slug: string; skip?: string; refused?: string}> => {
312
+ const slug = taskingSlug(options, base);
313
+ const item = `spec:${slug}`;
314
+ if (options.verb === 'advance') {
315
+ const signals = readItemSignals({
316
+ repoPath: base,
317
+ type: 'spec',
318
+ slug,
319
+ item,
320
+ });
321
+ const kind = classifyTick({type: 'spec', ...signals}).kind;
322
+ if (kind !== 'task-spec') {
323
+ if (kind === 'no-op') {
324
+ return {slug, skip: `no-op for ${item} at ${baseSha}`};
325
+ }
326
+ throw new PhaseDriverError(
327
+ `--phase splits only the build and tasking paths so far; ${item} ` +
328
+ `classifies as ${kind} at ${baseSha}`,
329
+ );
330
+ }
331
+ }
332
+ const specPath = workItemPath(base, 'specs-ready', slug);
333
+ if (!existsSync(specPath)) {
334
+ return {
335
+ slug,
336
+ skip:
337
+ `'${slug}' is not in ${workFolderRel('specs-ready')}/ at ` +
338
+ `${arbiter}/main (${baseSha}): it was already tasked, or removed`,
339
+ };
340
+ }
341
+ const specFm = parseFrontmatter(readFileSync(specPath, 'utf8'));
342
+ const eligibility = resolveAgentGate(
343
+ base,
344
+ slug,
345
+ specFm,
346
+ options.autoTask,
347
+ true,
348
+ );
349
+ if (!eligibility.taskable) {
350
+ return {
351
+ slug,
352
+ refused: gateRefusalReason(slug, specFm, eligibility, {
353
+ slug,
354
+ cwd: base,
355
+ autoTask: options.autoTask,
356
+ explicit: true,
357
+ }),
358
+ };
359
+ }
360
+ return {slug};
361
+ },
362
+ );
363
+ const slug = verdict.slug;
364
+ const item = `spec:${slug}`;
365
+ if (verdict.skip !== undefined) {
366
+ return {
367
+ exitCode: 0,
368
+ outcome: 'no-op',
369
+ slug,
370
+ message: verdict.skip,
371
+ lockOutputs: publish({acquired: false, baseSha}),
372
+ };
373
+ }
374
+ if (verdict.refused !== undefined) {
375
+ return {
376
+ exitCode: 1,
377
+ outcome: 'gate-refused',
378
+ slug,
379
+ message: verdict.refused,
380
+ lockOutputs: publish({acquired: false, rung: 'task-spec', baseSha}),
381
+ };
382
+ }
383
+ if ((await arbiterLockSha({cwd, arbiter, item, env})) !== undefined) {
384
+ const message = `'${slug}' is already locked on ${arbiter}; backing off.`;
385
+ return {
386
+ exitCode: 0,
387
+ outcome: 'lost',
388
+ slug,
389
+ message,
390
+ lockOutputs: publish({acquired: false, rung: 'task-spec', baseSha}),
391
+ };
392
+ }
393
+
394
+ const acquired = await acquireNotingOnce(note, (acquireNote) =>
395
+ acquireTaskingLock({slug, cwd, arbiter, env, note: acquireNote}),
396
+ );
397
+ if (acquired.exitCode !== 0) {
398
+ const outcome =
399
+ acquired.outcome === 'lost' || acquired.outcome === 'contended'
400
+ ? 'lost'
401
+ : 'usage-error';
402
+ return {
403
+ // A lock lost to another run is handled, so green ("A handled
404
+ // outcome is green", `ci-phase-driver.ts`).
405
+ exitCode: outcome === 'lost' ? 0 : acquired.exitCode,
406
+ outcome,
407
+ slug,
408
+ message: acquired.message,
409
+ lockOutputs: publish({acquired: false, rung: 'task-spec', baseSha}),
410
+ };
411
+ }
412
+ const lockSha = await arbiterLockSha({cwd, arbiter, item, env});
413
+ if (lockSha === undefined) {
414
+ throw new Error(`the lock of ${item} vanished right after the acquire`);
415
+ }
416
+ const facts = publish({
417
+ acquired: true,
418
+ needsAgent: true,
419
+ rung: 'task-spec',
420
+ baseSha,
421
+ lockSha,
422
+ handoffName: deriveHandoffName(
423
+ item,
424
+ options.runAttempt ?? env.GITHUB_RUN_ATTEMPT ?? '1',
425
+ ),
426
+ agentTimeoutMinutes: agentTimeoutMinutesAt(cwd, baseSha, env),
427
+ });
428
+ return {
429
+ exitCode: 0,
430
+ outcome: 'locked',
431
+ slug,
432
+ message: `locked ${item} at ${baseSha} (lock ${lockSha})`,
433
+ lockOutputs: facts,
434
+ };
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // agent
439
+ // ---------------------------------------------------------------------------
440
+
441
+ /** The candidate task files keyed by slug (their file name without `.md`). */
442
+ function candidatesBySlug(
443
+ emitTasks: Record<string, string>,
444
+ ): Record<string, string> {
445
+ const out: Record<string, string> = {};
446
+ for (const [rel, content] of Object.entries(emitTasks)) {
447
+ out[basename(rel).replace(/\.md$/i, '')] = content;
448
+ }
449
+ return out;
450
+ }
451
+
452
+ /** A bounded reason for the handoff record. */
453
+ function reasonText(text: string): string {
454
+ return boundHandoffText(text, HANDOFF_LIMITS.reasonChars);
455
+ }
456
+
457
+ /**
458
+ * The agent half's work under the recorder: the tasker, the loop and the
459
+ * task-set review, mapped to the handoff record. Throws on a tasker failure.
460
+ */
461
+ async function taskingHandover(params: {
462
+ options: TaskingPhaseOptions;
463
+ slug: string;
464
+ item: string;
465
+ cwd: string;
466
+ env: NodeJS.ProcessEnv;
467
+ note: (message: string) => void;
468
+ }): Promise<HandoffRecord> {
469
+ const {options, slug, item, cwd, env, note} = params;
470
+ const specPath = workItemPath(cwd, 'specs-ready', slug);
471
+ if (!existsSync(specPath)) {
472
+ throw new Error(
473
+ `the spec ${workItemRel('specs-ready', `${slug}.md`)} is missing at the base`,
474
+ );
475
+ }
476
+ const specFm = parseFrontmatter(readFileSync(specPath, 'utf8'));
477
+ const taskOptions = taskOptionsFor(options, slug, cwd, env, env);
478
+ const half = await runTaskingAgentHalf(taskOptions, {
479
+ cwd,
480
+ slug,
481
+ specFm,
482
+ doer: 'agent',
483
+ note,
484
+ });
485
+ const surface = (
486
+ reason: string,
487
+ candidates: Record<string, string>,
488
+ ): HandoffRecord => ({
489
+ schema: 1,
490
+ item,
491
+ intent: {kind: 'tasking-surface'},
492
+ products: {
493
+ candidates,
494
+ reason: reasonText(reason),
495
+ } satisfies TaskingSurfaceProducts,
496
+ });
497
+ switch (half.kind) {
498
+ case 'agent-failed':
499
+ throw new Error(`Agent failed tasking '${slug}' (${half.detail}).`);
500
+ case 'review-leg-failed': {
501
+ const paths = newOrChangedStagedTasks(cwd, half.before);
502
+ return surface(
503
+ reviewFailureReason(slug, half.error, paths),
504
+ candidatesBySlug(collectEmittedTasks(cwd, paths)),
505
+ );
506
+ }
507
+ case 'decomposition-unclear':
508
+ return surface(decompositionUnclearReason(slug, half.questions), {});
509
+ case 'candidates':
510
+ break;
511
+ }
512
+ const emitTasks = collectEmittedTasks(
513
+ cwd,
514
+ newOrChangedStagedTasks(cwd, half.before),
515
+ );
516
+
517
+ // THE TASK-SET ACCEPTANCE GATE, one round (the laptop path runs it inside
518
+ // `performIntegration` with `reviewMaxRounds: 1`). It launches an agent, so
519
+ // it runs here; the apply phase integrates with the review OFF.
520
+ let reviewVerdict: TaskingLandProducts['reviewVerdict'];
521
+ let reviewProse: string | undefined;
522
+ if (taskOptions.review === true) {
523
+ if (taskOptions.reviewGate === undefined) {
524
+ throw new Error(
525
+ `review is on but no task-set review gate is configured for '${slug}' ` +
526
+ '(a wiring bug; the gate must not be skipped)',
527
+ );
528
+ }
529
+ note('Running the task-set acceptance review…');
530
+ const rounds = await runReviewRounds({
531
+ reviewGate: taskOptions.reviewGate,
532
+ slug,
533
+ reviewCwd: cwd,
534
+ maxRounds: 1,
535
+ reviewModel: taskOptions.acceptanceReviewModel,
536
+ sessionsDir: taskOptions.sessionsDir,
537
+ env,
538
+ });
539
+ if (rounds.kind === 'unparseable') {
540
+ return surface(taskGateUnparseableReason(slug, rounds.reason), {});
541
+ }
542
+ if (rounds.kind === 'blocked') {
543
+ const findings =
544
+ rounds.verdict === undefined ? '' : formatBlockReason(rounds.verdict);
545
+ return surface(
546
+ taskGateBlockedReason(slug, findings === '' ? undefined : findings),
547
+ {},
548
+ );
549
+ }
550
+ reviewVerdict = 'approve';
551
+ reviewProse = rounds.verdict?.review;
552
+ }
553
+
554
+ return {
555
+ schema: 1,
556
+ item,
557
+ intent: {kind: 'tasking-land'},
558
+ products: {
559
+ candidates: candidatesBySlug(emitTasks),
560
+ prBody: boundHandoffText(
561
+ composeTaskingProposeBody(slug, emitTasks) ?? '',
562
+ HANDOFF_LIMITS.commentChars,
563
+ ),
564
+ ...(reviewVerdict === undefined ? {} : {reviewVerdict}),
565
+ ...(reviewProse === undefined
566
+ ? {}
567
+ : {
568
+ reviewProse: boundHandoffText(
569
+ reviewProse,
570
+ HANDOFF_LIMITS.commentChars,
571
+ ),
572
+ }),
573
+ specBody: readFileSync(specPath, 'utf8'),
574
+ } satisfies TaskingLandProducts,
575
+ };
576
+ }
577
+
578
+ /**
579
+ * The agent phase: after a read-only lock-ownership check, run the tasker, its
580
+ * review rounds and the task-set review on a `work/spec-<slug>` branch cut from
581
+ * `baseSha`, and write the handoff. Holds a read-only token: every write seam
582
+ * records into a recorder that knows no write, so a write attempt fails the
583
+ * job. Any failure writes no handoff and fails the job (the apply job then
584
+ * surfaces the spec).
585
+ */
586
+ export async function performTaskingAgentPhase(
587
+ options: TaskingPhaseOptions,
588
+ ): Promise<TaskingPhaseResult> {
589
+ const note = options.note ?? (() => {});
590
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
591
+ const cwd = options.cwd;
592
+ const env = options.env ?? process.env;
593
+ const held = requireHeldLock({
594
+ lockOutputs: options.lockOutputs,
595
+ env,
596
+ rung: 'task-spec',
597
+ });
598
+ if (options.handoffDir === undefined) {
599
+ throw new PhaseDriverError('the agent phase needs a handoff directory');
600
+ }
601
+ const slug = taskingSlug(options, options.repoPath ?? cwd);
602
+ const item = `spec:${slug}`;
603
+
604
+ const ownership = await checkLockOwnership({
605
+ cwd,
606
+ arbiter,
607
+ item,
608
+ lockSha: held.lockSha,
609
+ env,
610
+ });
611
+ if (!ownership.owned) {
612
+ return {
613
+ exitCode: 0,
614
+ outcome: 'stale-lock',
615
+ slug,
616
+ message: ownership.message,
617
+ };
618
+ }
619
+
620
+ let record: HandoffRecord;
621
+ try {
622
+ await switchToWorkBranch(cwd, arbiter, slug, env, held.baseSha);
623
+ const outcome = await runAgentPhase(createPhaseRecorder({record: []}), () =>
624
+ taskingHandover({options, slug, item, cwd, env, note}),
625
+ );
626
+ if (outcome.halted) {
627
+ throw new Error(
628
+ `the tasking reached a write (${outcome.intent.seam}.${outcome.intent.method})`,
629
+ );
630
+ }
631
+ record = outcome.result;
632
+ writeHandoff({dir: options.handoffDir, rung: 'task-spec', record});
633
+ } catch (err) {
634
+ const message = err instanceof Error ? err.message : String(err);
635
+ return {exitCode: 1, outcome: 'agent-failed', slug, message};
636
+ }
637
+ const kind = record.intent.kind;
638
+ const message = `handed over ${kind} for ${item}`;
639
+ return {exitCode: 0, outcome: 'handed-over', slug, message, intent: kind};
640
+ }
641
+
642
+ // ---------------------------------------------------------------------------
643
+ // apply: the hostile-handoff checks
644
+ // ---------------------------------------------------------------------------
645
+
646
+ /** The spec's origin stamp at `baseSha`: the one every candidate carries. */
647
+ export type OriginStamp = Pick<Frontmatter, 'origin' | 'originTrust'>;
648
+
649
+ /** A top-level `key: value` frontmatter line (the grammar `parseFrontmatter` reads). */
650
+ const TOP_LEVEL_KEY_RE = /^([A-Za-z0-9_.]+)\s*:\s*(.*)$/;
651
+
652
+ /**
653
+ * The frontmatter of `content` split into its block lines and everything from
654
+ * the closing fence on, or `undefined` when it does not parse: the document must
655
+ * open with a `---` line and close the fence (the rule `parseFrontmatter` uses;
656
+ * an unclosed fence reads as NO frontmatter, so every key in it is silently
657
+ * ignored). A leading BOM and CRLF line ends are normalised away.
658
+ */
659
+ function splitFrontmatter(
660
+ content: string,
661
+ ): {block: string[]; rest: string[]} | undefined {
662
+ const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
663
+ if (!normalized.startsWith('---\n')) return undefined;
664
+ const lines = normalized.split('\n');
665
+ const closing = lines.indexOf('---', 1);
666
+ if (closing === -1) return undefined;
667
+ return {block: lines.slice(1, closing), rest: lines.slice(closing)};
668
+ }
669
+
670
+ /** One top-level key of a frontmatter block, with its indented continuation lines. */
671
+ interface KeyEntry {
672
+ key: string;
673
+ /** The raw value, then each continuation line (a block-list item). */
674
+ raw: string[];
675
+ /** The block line indexes the entry spans, `[start, end)`. */
676
+ start: number;
677
+ end: number;
678
+ }
679
+
680
+ /** Every top-level key of a frontmatter block, in order (duplicates kept). */
681
+ function keyEntries(block: string[]): KeyEntry[] {
682
+ const out: KeyEntry[] = [];
683
+ for (let i = 0; i < block.length; i++) {
684
+ const line = block[i];
685
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
686
+ const m = TOP_LEVEL_KEY_RE.exec(line);
687
+ if (m === null) continue;
688
+ let end = i + 1;
689
+ while (end < block.length && /^\s+\S/.test(block[end])) end++;
690
+ out.push({
691
+ key: m[1],
692
+ raw: [m[2].trim(), ...block.slice(i + 1, end).map((l) => l.trim())],
693
+ start: i,
694
+ end,
695
+ });
696
+ i = end - 1;
697
+ }
698
+ return out;
699
+ }
700
+
701
+ /** The gate booleans a candidate may set; the apply job checks only that they parse. */
702
+ const CANDIDATE_BOOLEAN_KEYS = ['humanOnly', 'needsAnswers'] as const;
703
+ /** The origin stamp keys the apply job overwrites on every candidate. */
704
+ const STAMP_KEYS = ['origin', 'originTrust'] as const;
705
+
706
+ /**
707
+ * Check ONE candidate task file of a hostile handoff and return the content the
708
+ * apply job commits (spec §3 "Structured products", task `ci-split-tasking`).
709
+ *
710
+ * - The path is derived, never taken: `work/tasks/backlog/<slug>.md`, and the
711
+ * slug must be safe (`slug-safety.ts`; the record validation already refused
712
+ * any other key, so `..` or a folder can never reach here).
713
+ * - It must be NEW relative to `baseSha`: a candidate that names a staged task
714
+ * already on the base is an edit to a task this run did not produce.
715
+ * - Its frontmatter must parse (an opening AND a closing `---`): an unclosed
716
+ * fence reads as no frontmatter, which would silently drop the stamp.
717
+ * - `humanOnly` / `needsAnswers` must each appear at most once and read as a
718
+ * boolean. Their VALUES are not judged: the review loop that sets them ran in
719
+ * the hostile job, and every candidate lands staged for a human to promote.
720
+ * - `origin` / `originTrust` are OVERWRITTEN with the spec's stamp at `baseSha`:
721
+ * every such line is dropped (with its continuation lines) and exactly one of
722
+ * each is written, so a candidate that repeats `originTrust: trusted` cannot
723
+ * keep one past the stamp (observation
724
+ * `tasker-drafted-duplicate-origintrust-launders-propagated-stamp`); then the
725
+ * result is re-parsed with `assertFrontmatterFields`, and any duplicate or
726
+ * mismatch rejects it.
727
+ *
728
+ * Throws {@link HandoffRejected} on any breach.
729
+ */
730
+ export function checkTaskingCandidate(params: {
731
+ slug: string;
732
+ content: string;
733
+ stamp: OriginStamp;
734
+ existsAtBase: boolean;
735
+ }): string {
736
+ const {slug, content, stamp} = params;
737
+ const path = `${STAGED_TASKS_DIR}/${slug}.md`;
738
+ if (!isSafeSlug(slug)) {
739
+ throw new HandoffRejected(
740
+ 'field',
741
+ `candidate '${slug}' is not a safe slug`,
742
+ );
743
+ }
744
+ if (params.existsAtBase) {
745
+ throw new HandoffRejected(
746
+ 'ledger',
747
+ `candidate ${path} already exists at the base: an edit to a ` +
748
+ 'pre-existing staged task this run did not produce',
749
+ );
750
+ }
751
+ const fm = splitFrontmatter(content);
752
+ if (fm === undefined) {
753
+ throw new HandoffRejected(
754
+ 'field',
755
+ `candidate ${path}: its frontmatter does not parse (it must open and ` +
756
+ 'close a --- fence at the top)',
757
+ );
758
+ }
759
+ const entries = keyEntries(fm.block);
760
+ for (const key of CANDIDATE_BOOLEAN_KEYS) {
761
+ const found = entries.filter((e) => e.key === key);
762
+ if (found.length > 1) {
763
+ throw new HandoffRejected(
764
+ 'field',
765
+ `candidate ${path}: '${key}' appears ${found.length} times`,
766
+ );
767
+ }
768
+ const value = found[0];
769
+ if (value === undefined) continue;
770
+ const v = unquoteYamlScalar(value.raw[0]).toLowerCase();
771
+ if (value.raw.length > 1 || (v !== 'true' && v !== 'false')) {
772
+ throw new HandoffRejected(
773
+ 'field',
774
+ `candidate ${path}: '${key}' does not read as a boolean`,
775
+ );
776
+ }
777
+ }
778
+ const drop = new Set<number>();
779
+ for (const e of entries) {
780
+ if ((STAMP_KEYS as readonly string[]).includes(e.key)) {
781
+ for (let i = e.start; i < e.end; i++) drop.add(i);
782
+ }
783
+ }
784
+ const block = fm.block.filter((_, i) => !drop.has(i));
785
+ if (stamp.origin !== undefined) block.push(`origin: ${stamp.origin}`);
786
+ if (stamp.originTrust !== undefined) {
787
+ block.push(`originTrust: ${stamp.originTrust}`);
788
+ }
789
+ const stamped = ['---', ...block, ...fm.rest].join('\n');
790
+ try {
791
+ assertFrontmatterFields(stamped, {
792
+ origin: stamp.origin,
793
+ originTrust: stamp.originTrust,
794
+ });
795
+ } catch (err) {
796
+ if (!(err instanceof FrontmatterRenderError)) throw err;
797
+ throw new HandoffRejected('field', `candidate ${path}: ${err.message}`);
798
+ }
799
+ return stamped;
800
+ }
801
+
802
+ /**
803
+ * The spec frontmatter keys a trimmed body must keep EXACTLY as they are at
804
+ * `baseSha`: the gate keys (`humanOnly`, `needsAnswers`, `taskedAfter`), the
805
+ * issue link and the origin stamp, plus `slug` (the tasked-spec identity
806
+ * `taskedAfter` of other specs resolves against).
807
+ */
808
+ export const SPEC_PINNED_KEYS = [
809
+ 'humanOnly',
810
+ 'needsAnswers',
811
+ 'taskedAfter',
812
+ 'issue',
813
+ 'origin',
814
+ 'originTrust',
815
+ 'slug',
816
+ ] as const;
817
+
818
+ /** The headings a trimmed spec keeps (its durable framing, TASKING-PROTOCOL §6). */
819
+ export const SPEC_REQUIRED_HEADINGS = [
820
+ 'Problem Statement',
821
+ 'Solution',
822
+ 'User Stories',
823
+ ] as const;
824
+
825
+ function hasHeading(content: string, heading: string): boolean {
826
+ return content
827
+ .replace(/\r\n/g, '\n')
828
+ .split('\n')
829
+ .some((line) => {
830
+ const m = /^#{1,6}[ \t]+(.*?)[ \t#]*$/.exec(line);
831
+ return m !== null && m[1] === heading;
832
+ });
833
+ }
834
+
835
+ /**
836
+ * Check the trimmed spec body of a hostile `tasking-land` against the spec at
837
+ * `baseSha`, and return the body the apply job commits as the tasked spec. Its
838
+ * frontmatter must parse; every {@link SPEC_PINNED_KEYS} key must read EXACTLY
839
+ * as at `baseSha` (the same top-level lines, in the same order, with the same
840
+ * continuation lines, so neither a first-wins nor a last-wins reader can see
841
+ * another value); and every {@link SPEC_REQUIRED_HEADINGS} heading the base
842
+ * spec has must still be there. Throws {@link HandoffRejected} otherwise.
843
+ */
844
+ export function checkTrimmedSpecBody(params: {
845
+ slug: string;
846
+ body: string;
847
+ baseSpec: string;
848
+ }): string {
849
+ const {slug, body, baseSpec} = params;
850
+ const what = `the trimmed spec body of '${slug}'`;
851
+ const fm = splitFrontmatter(body);
852
+ if (fm === undefined) {
853
+ throw new HandoffRejected(
854
+ 'field',
855
+ `${what}: its frontmatter does not parse (it must open and close a --- fence at the top)`,
856
+ );
857
+ }
858
+ const baseEntries = keyEntries(splitFrontmatter(baseSpec)?.block ?? []);
859
+ const bodyEntries = keyEntries(fm.block);
860
+ for (const key of SPEC_PINNED_KEYS) {
861
+ const want = baseEntries.filter((e) => e.key === key).map((e) => e.raw);
862
+ const got = bodyEntries.filter((e) => e.key === key).map((e) => e.raw);
863
+ if (JSON.stringify(want) !== JSON.stringify(got)) {
864
+ throw new HandoffRejected(
865
+ 'field',
866
+ `${what} changes the gate key '${key}' (it must keep its value at the base)`,
867
+ );
868
+ }
869
+ }
870
+ for (const heading of SPEC_REQUIRED_HEADINGS) {
871
+ if (hasHeading(baseSpec, heading) && !hasHeading(body, heading)) {
872
+ throw new HandoffRejected(
873
+ 'field',
874
+ `${what} drops the required '## ${heading}' heading`,
875
+ );
876
+ }
877
+ }
878
+ return body;
879
+ }
880
+
881
+ // ---------------------------------------------------------------------------
882
+ // apply
883
+ // ---------------------------------------------------------------------------
884
+
885
+ /** What the apply phase works with past the ownership check. */
886
+ interface ApplyContext {
887
+ options: TaskingPhaseOptions;
888
+ held: HeldLock;
889
+ slug: string;
890
+ item: string;
891
+ arbiter: string;
892
+ cwd: string;
893
+ env: NodeJS.ProcessEnv;
894
+ note: (message: string) => void;
895
+ }
896
+
897
+ /**
898
+ * The apply phase: check the lock is still this run's, act on the agent job's
899
+ * result, validate the handoff as hostile, then resume at the tasking write
900
+ * half. Runs no agent and no repository code.
901
+ */
902
+ export async function performTaskingApplyPhase(
903
+ options: TaskingPhaseOptions,
904
+ ): Promise<TaskingPhaseResult> {
905
+ const note = options.note ?? (() => {});
906
+ const arbiter = options.arbiter ?? DEFAULT_ARBITER;
907
+ const cwd = options.cwd;
908
+ const env = noSmudgeEnv(
909
+ identityEnv(options.identity, options.env ?? process.env),
910
+ );
911
+ const held = requireHeldLock({
912
+ lockOutputs: options.lockOutputs,
913
+ env,
914
+ rung: 'task-spec',
915
+ });
916
+ if (options.agentResult === undefined) {
917
+ throw new PhaseDriverError(
918
+ 'the apply phase needs the agent job result (--agent-result, ' +
919
+ 'needs.agent.result): only success reads the handoff',
920
+ );
921
+ }
922
+ const agentResult = options.agentResult;
923
+ const slug = taskingSlug(options, options.repoPath ?? cwd);
924
+ const item = `spec:${slug}`;
925
+
926
+ // Lock ownership BEFORE the first write (spec §8): a lock that was released,
927
+ // reaped or re-taken since the lock job ran means this run writes nothing.
928
+ const ownership = await checkLockOwnership({
929
+ cwd,
930
+ arbiter,
931
+ item,
932
+ lockSha: held.lockSha,
933
+ env,
934
+ });
935
+ if (!ownership.owned) {
936
+ return {
937
+ exitCode: 0,
938
+ outcome: 'stale-lock',
939
+ slug,
940
+ message: ownership.message,
941
+ };
942
+ }
943
+
944
+ // Every lock release from here on (the merge land's, the surface's) is
945
+ // leased on the sha this run owns.
946
+ const restoreLease = leaseLockReleases(item, held.lockSha);
947
+ try {
948
+ const ctx: ApplyContext = {
949
+ options,
950
+ held,
951
+ slug,
952
+ item,
953
+ arbiter,
954
+ cwd,
955
+ env,
956
+ note,
957
+ };
958
+ const decision = await resolveAgentResult({
959
+ result: agentResult,
960
+ held,
961
+ agentTimeoutMinutes: options.agentTimeoutMinutes,
962
+ api: options.actionsApi,
963
+ env,
964
+ });
965
+ switch (decision.action) {
966
+ case 'read-handoff':
967
+ return await applyOwned(ctx);
968
+ case 'deterministic':
969
+ return await surfaceSpec(
970
+ ctx,
971
+ 'the agent job was skipped (needsAgent: false), but the tasking ' +
972
+ 'path has no rung that runs without an agent; the handoff was not read',
973
+ );
974
+ case 'surface':
975
+ return await surfaceSpec(ctx, decision.reason);
976
+ case 'release': {
977
+ const released = await releaseLockLeased({
978
+ cwd,
979
+ arbiter,
980
+ item,
981
+ expectedSha: held.lockSha,
982
+ env,
983
+ });
984
+ note(released.message);
985
+ const reason = decision.reason;
986
+ return released.released
987
+ ? {
988
+ exitCode: 0,
989
+ outcome: 'released',
990
+ slug,
991
+ message: `Released '${slug}': ${reason}.`,
992
+ }
993
+ : {
994
+ exitCode: 1,
995
+ outcome: 'release-refused',
996
+ slug,
997
+ message: `Could not release '${slug}' (${released.message}): ${reason}.`,
998
+ };
999
+ }
1000
+ }
1001
+ } finally {
1002
+ restoreLease();
1003
+ }
1004
+ }
1005
+
1006
+ /**
1007
+ * Surface the spec to needs-attention because the agent job did not succeed
1008
+ * (decision 5): the question sidecar and `needsAnswers: true` on `main`, the lock
1009
+ * released (leased). Nothing from the agent job is read or pushed.
1010
+ */
1011
+ async function surfaceSpec(
1012
+ ctx: ApplyContext,
1013
+ reason: string,
1014
+ ): Promise<TaskingPhaseResult> {
1015
+ const {slug, cwd, arbiter, env, note} = ctx;
1016
+ const routed = await releaseTaskingLock({
1017
+ slug,
1018
+ cwd,
1019
+ arbiter,
1020
+ routeToNeedsAttention: {reason},
1021
+ env,
1022
+ note,
1023
+ });
1024
+ const moved = routed.outcome === 'released';
1025
+ const message = moved
1026
+ ? `Surfaced '${slug}' to needs-attention: ${reason}`
1027
+ : `Could not surface '${slug}' (${routed.message}): ${reason}`;
1028
+ return {
1029
+ exitCode: moved ? 0 : 1,
1030
+ outcome: moved ? 'surfaced' : 'surface-unmoved',
1031
+ slug,
1032
+ message,
1033
+ };
1034
+ }
1035
+
1036
+ /** The spec at `baseSha` (trusted), or a usage error when it is not there. */
1037
+ async function specAtBase(ctx: ApplyContext): Promise<{
1038
+ content: string;
1039
+ blob: string;
1040
+ }> {
1041
+ const rel = workItemRel('specs-ready', `${ctx.slug}.md`);
1042
+ const show = await runAsync(
1043
+ 'git',
1044
+ ['cat-file', 'blob', `${ctx.held.baseSha}:${rel}`],
1045
+ ctx.cwd,
1046
+ {env: ctx.env},
1047
+ );
1048
+ const blob = await runAsync(
1049
+ 'git',
1050
+ ['rev-parse', `${ctx.held.baseSha}:${rel}`],
1051
+ ctx.cwd,
1052
+ {env: ctx.env},
1053
+ );
1054
+ if (show.status !== 0 || blob.status !== 0) {
1055
+ throw new PhaseDriverError(
1056
+ `the spec ${rel} is not at the lock job's base ${ctx.held.baseSha}`,
1057
+ );
1058
+ }
1059
+ return {content: show.stdout, blob: blob.stdout.trim()};
1060
+ }
1061
+
1062
+ /** Whether `work/tasks/backlog/<slug>.md` exists at `baseSha`. */
1063
+ async function stagedTaskAtBase(
1064
+ ctx: ApplyContext,
1065
+ slug: string,
1066
+ ): Promise<boolean> {
1067
+ const r = await runAsync(
1068
+ 'git',
1069
+ ['cat-file', '-e', `${ctx.held.baseSha}:${STAGED_TASKS_DIR}/${slug}.md`],
1070
+ ctx.cwd,
1071
+ {env: ctx.env},
1072
+ );
1073
+ return r.status === 0;
1074
+ }
1075
+
1076
+ /** Validate every candidate of the record (throws {@link HandoffRejected}). */
1077
+ async function checkCandidates(
1078
+ ctx: ApplyContext,
1079
+ candidates: Record<string, string>,
1080
+ stamp: OriginStamp,
1081
+ ): Promise<Record<string, string>> {
1082
+ const out: Record<string, string> = {};
1083
+ for (const [slug, content] of Object.entries(candidates)) {
1084
+ out[slug] = checkTaskingCandidate({
1085
+ slug,
1086
+ content,
1087
+ stamp,
1088
+ existsAtBase: await stagedTaskAtBase(ctx, slug),
1089
+ });
1090
+ }
1091
+ return out;
1092
+ }
1093
+
1094
+ /** Write the checked candidates to `work/tasks/backlog/<slug>.md`. */
1095
+ function writeCandidates(cwd: string, candidates: Record<string, string>) {
1096
+ for (const [slug, content] of Object.entries(candidates)) {
1097
+ const abs = join(cwd, STAGED_TASKS_DIR, `${slug}.md`);
1098
+ mkdirSync(dirname(abs), {recursive: true});
1099
+ writeFileSync(abs, content);
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * The apply phase once the lock is known to be this run's: read the handoff,
1105
+ * check it, rebuild the commit on a fresh `work/spec-<slug>` branch cut from
1106
+ * `baseSha`, and resume at the write half.
1107
+ */
1108
+ async function applyOwned(ctx: ApplyContext): Promise<TaskingPhaseResult> {
1109
+ const {options, held, slug, item, arbiter, cwd, env, note} = ctx;
1110
+ const mode = options.taskingIntegration ?? options.integration ?? 'propose';
1111
+ const rejected = async (reason: string): Promise<TaskingPhaseResult> => {
1112
+ note(reason);
1113
+ const r = await surfaceTaskingBlock({
1114
+ slug,
1115
+ cwd,
1116
+ arbiter,
1117
+ reason,
1118
+ message: `the handoff was rejected; parked '${slug}' for your attention`,
1119
+ lockedBlob: undefined,
1120
+ release: releaseTaskingLock,
1121
+ mode,
1122
+ provider: options.providerInstance,
1123
+ env,
1124
+ note,
1125
+ });
1126
+ // A rejected handoff whose surface landed is handled, so green ("A
1127
+ // handled outcome is green", `ci-phase-driver.ts`).
1128
+ const surfaced = r.outcome === 'needs-attention';
1129
+ return {
1130
+ exitCode: surfaced ? 0 : 1,
1131
+ outcome: surfaced ? 'rejected' : 'surface-unmoved',
1132
+ slug,
1133
+ message: `${reason}; ${r.message}`,
1134
+ };
1135
+ };
1136
+
1137
+ const base = await specAtBase(ctx);
1138
+ const baseFm = parseFrontmatter(base.content);
1139
+ const stamp: OriginStamp = {
1140
+ origin: baseFm.origin,
1141
+ originTrust: baseFm.originTrust,
1142
+ };
1143
+
1144
+ let record: HandoffRecord;
1145
+ let candidates: Record<string, string>;
1146
+ let specBody: string | undefined;
1147
+ try {
1148
+ if (options.handoffDir === undefined) {
1149
+ throw new HandoffRejected('layout', 'no handoff directory was given');
1150
+ }
1151
+ record = readHandoff({
1152
+ dir: options.handoffDir,
1153
+ runnerTemp: runnerTempFrom(options.runnerTemp, env),
1154
+ trust: {item, rung: 'task-spec'},
1155
+ }).record;
1156
+ const products = record.products as {candidates: Record<string, string>};
1157
+ candidates = await checkCandidates(ctx, products.candidates, stamp);
1158
+ if (record.intent.kind === 'tasking-land') {
1159
+ const land = record.products as TaskingLandProducts;
1160
+ if (
1161
+ land.reviewVerdict !== undefined &&
1162
+ land.reviewVerdict !== 'approve'
1163
+ ) {
1164
+ throw new HandoffRejected(
1165
+ 'field',
1166
+ `products.reviewVerdict is ${land.reviewVerdict}: a blocked task-set ` +
1167
+ 'review is a tasking-surface, never a tasking-land',
1168
+ );
1169
+ }
1170
+ specBody = checkTrimmedSpecBody({
1171
+ slug,
1172
+ body: land.specBody,
1173
+ baseSpec: base.content,
1174
+ });
1175
+ }
1176
+ } catch (err) {
1177
+ if (!(err instanceof HandoffRejected)) throw err;
1178
+ return rejected(err.message);
1179
+ }
1180
+
1181
+ // A FRESH work branch cut from `baseSha`: nothing from the agent job but the
1182
+ // checked files above reaches it.
1183
+ await switchToWorkBranch(cwd, arbiter, slug, env, held.baseSha);
1184
+ const before = snapshotStagedTasks(cwd);
1185
+ const poolBefore = snapshotPool(cwd);
1186
+ writeCandidates(cwd, candidates);
1187
+
1188
+ if (record.intent.kind === 'tasking-surface') {
1189
+ const surfaceProducts = record.products as TaskingSurfaceProducts;
1190
+ await persistTaskingCandidates(cwd, slug, before, arbiter, env, note);
1191
+ const r = await surfaceTaskingBlock({
1192
+ slug,
1193
+ cwd,
1194
+ arbiter,
1195
+ reason: surfaceProducts.reason,
1196
+ message: `parked '${slug}' for your attention (no tasks landed)`,
1197
+ lockedBlob: base.blob,
1198
+ release: releaseTaskingLock,
1199
+ mode,
1200
+ provider: options.providerInstance,
1201
+ env,
1202
+ note,
1203
+ });
1204
+ return fromTaskResult(r, mode);
1205
+ }
1206
+
1207
+ const land = record.products as TaskingLandProducts;
1208
+ writeFileSync(workItemPath(cwd, 'specs-ready', slug), specBody as string);
1209
+ const stagedEmitted = newOrChangedStagedTasks(cwd, before);
1210
+ const r = await integrateTaskingCandidates({
1211
+ slug,
1212
+ cwd,
1213
+ arbiter,
1214
+ stagedEmitted,
1215
+ emitTasks: collectEmittedTasks(cwd, stagedEmitted),
1216
+ poolBefore,
1217
+ placementDir: taskPlacementDir(options, baseFm),
1218
+ loopTag: undefined,
1219
+ lockedBlob: base.blob,
1220
+ release: releaseTaskingLock,
1221
+ mode,
1222
+ // The task-set review already ran in the agent phase.
1223
+ review: false,
1224
+ approvedReviewProse: land.reviewProse,
1225
+ mergeRetries: options.mergeRetries,
1226
+ mergeJitterMs: options.mergeJitterMs,
1227
+ noPR: options.noPR,
1228
+ providerInstance: options.providerInstance,
1229
+ env,
1230
+ agentEnv: env,
1231
+ note,
1232
+ });
1233
+ return fromTaskResult(r, mode);
1234
+ }
1235
+
1236
+ /** Map the tasking write half's {@link TaskResult} to the apply phase's. */
1237
+ function fromTaskResult(r: TaskResult, mode: string): TaskingPhaseResult {
1238
+ const outcome: TaskingPhaseOutcome =
1239
+ r.outcome === 'tasked'
1240
+ ? mode === 'merge'
1241
+ ? 'landed'
1242
+ : 'proposed'
1243
+ : r.outcome === 'needs-attention'
1244
+ ? 'surfaced'
1245
+ : r.outcome === 'stale'
1246
+ ? 'stale'
1247
+ : 'usage-error';
1248
+ return {
1249
+ exitCode: r.exitCode,
1250
+ outcome,
1251
+ slug: r.slug,
1252
+ message: r.message,
1253
+ ...(r.emitted === undefined ? {} : {emitted: r.emitted}),
1254
+ };
1255
+ }