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
@@ -10,7 +10,12 @@ import {
10
10
  import {resolveSidecarIdentity, sidecarPathFor} from './sidecar.js';
11
11
  import type {TriageAutoKind} from './triage-gate.js';
12
12
  import {renderTaskBody, renderSpecBody} from './buildable-body.js';
13
- import {setFrontmatterMarker} from './frontmatter.js';
13
+ import {
14
+ assertFrontmatterFields,
15
+ FrontmatterRenderError,
16
+ quoteYamlScalar,
17
+ setFrontmatterMarker,
18
+ } from './frontmatter.js';
14
19
  import {ensureSafeSlug} from './slug-safety.js';
15
20
 
16
21
  /**
@@ -476,6 +481,21 @@ export async function promoteObservation(
476
481
  'promotedFrom',
477
482
  item,
478
483
  );
484
+ // Re-parse the RUNNER-OWNED keys before anything is written (task
485
+ // `intake-frontmatter-title-injection-strips-origin-stamp`). The stamp above
486
+ // replaces the FIRST `slug:` line, but the frontmatter parser keeps the LAST,
487
+ // so a drafted body that repeats `slug:` (or `promotedFrom:`) would still set
488
+ // the item's identity. Refuse it loudly; the observation stays for a retry.
489
+ try {
490
+ assertFrontmatterFields(content, {slug: newSlug, promotedFrom: item});
491
+ } catch (err) {
492
+ if (!(err instanceof FrontmatterRenderError)) throw err;
493
+ return {
494
+ outcome: 'usage-error',
495
+ exitCode: 1,
496
+ message: `promote ${item}: ${err.message}`,
497
+ };
498
+ }
479
499
 
480
500
  // The note + its answered sidecar `git rm` IN THE SAME create commit (promote =
481
501
  // ONE atomic commit). A CAS LOSER never reaches the commit, so it leaves both
@@ -620,7 +640,9 @@ function buildPromotedBody(
620
640
  const hasQuestions = openQuestions.trim() !== '';
621
641
  const frontmatter: string[] = [
622
642
  '---',
623
- `title: ${slug}`,
643
+ // The title is the (sanitised, agent-draftable) slug, still written through
644
+ // the shared frontmatter quoter so every runner-rendered title has one shape.
645
+ `title: ${quoteYamlScalar(slug)}`,
624
646
  `slug: ${slug}`,
625
647
  `needsAnswers: ${hasQuestions ? 'true' : 'false'}`,
626
648
  // A SPEC is a north-star doc, not a blockable task — only the task shape carries
@@ -21,6 +21,7 @@
21
21
  * trigger surface — the forward seam the spec's Applied Answers q3 names.
22
22
  */
23
23
 
24
+ import {ACTION_PINS, pinnedUses} from './install-ci-action-pins.js';
24
25
  import type {ResolvedCIConfig} from './install-ci-core.js';
25
26
  import {shouldDropCheckoutCredentials} from './install-ci-core.js';
26
27
 
@@ -101,9 +102,9 @@ concurrency:
101
102
  group: ${VERIFY_CHECK_CONTEXT}-\${{ github.ref }}
102
103
  cancel-in-progress: true
103
104
 
104
- # NO \`workflows\` permission (US #9). \`contents: read\` is all the gate needs.
105
- permissions:
106
- contents: read
105
+ # Nothing at workflow level: the job grants its own scopes. NO \`workflows\`
106
+ # permission (US #9).
107
+ permissions: {}
107
108
 
108
109
  jobs:
109
110
  # The job name IS the GitHub check context — it must equal VERIFY_CHECK_CONTEXT
@@ -111,8 +112,11 @@ jobs:
111
112
  # context this workflow does not produce.
112
113
  ${VERIFY_CHECK_CONTEXT}:
113
114
  runs-on: ubuntu-latest
115
+ # \`contents: read\` is all the gate needs.
116
+ permissions:
117
+ contents: read
114
118
  steps:
115
- - uses: actions/checkout@v5
119
+ - uses: ${pinnedUses(ACTION_PINS.checkout)}
116
120
  with:
117
121
  fetch-depth: 0${checkoutCredentials}
118
122
  - uses: ./.github/actions/dorfl-setup
package/src/verify.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import {spawn} from 'node:child_process';
2
+ import {assertAgentOrRepoCodeAllowed} from './phase.js';
2
3
 
3
4
  /**
4
5
  * The per-repo acceptance gate, run as `dorfl verify`. It is a
@@ -149,6 +150,9 @@ export const VERIFY_OUTPUT_TAIL_LINES = 20;
149
150
  export async function runVerify(
150
151
  options: RunVerifyOptions,
151
152
  ): Promise<RunVerifyResult> {
153
+ // PHASE GUARD: the gate runs repository code, never in the CI lock/apply
154
+ // phases (their jobs hold the write token; see `phase.ts`).
155
+ assertAgentOrRepoCodeAllowed('verify');
152
156
  let commands: string[];
153
157
  try {
154
158
  commands = resolveVerifyCommands(options.verify);
@@ -1,4 +1,5 @@
1
1
  import {open, type FileHandle} from 'node:fs/promises';
2
+ import type {CutOffTurn} from './harness.js';
2
3
 
3
4
  /**
4
5
  * The **`do --watch` observer** (tasks `do-watch` + `do-watch-session-log-format`)
@@ -151,6 +152,14 @@ interface SessionLogRecord {
151
152
  content?: unknown;
152
153
  /** The Anthropic-API turn-termination reason (mirrored by pi into the session log). */
153
154
  stop_reason?: unknown;
155
+ /**
156
+ * pi's OWN normalised turn-termination reason (`stop` / `toolUse` / `length`
157
+ * / `error` / `aborted`), the key real pi session logs carry on every
158
+ * assistant message.
159
+ */
160
+ stopReason?: unknown;
161
+ /** The provider's error text pi records on an `error`-stopped turn. */
162
+ errorMessage?: unknown;
154
163
  /** The turn's token usage (mirrored by pi). `output` OR `output_tokens` is the produced-token count. */
155
164
  usage?: unknown;
156
165
  };
@@ -292,7 +301,7 @@ function assistantContentText(content: unknown): string {
292
301
  * (task `harness-agent-output`) — the agent's final ANSWER, surfaced through
293
302
  * the harness seam as `LaunchResult.output`. A thin delegate to
294
303
  * {@link lastAssistantTurn} (one walk, returning the `.text`); the turn reader
295
- * ALSO carries the `stop_reason`/`usage` the pi adapter uses for the
304
+ * ALSO carries the stop reason + `usage` the pi adapter uses for the
296
305
  * `LaunchResult.outputCapped` cap-truncation signal.
297
306
  *
298
307
  * Kept as a PURE `string → string | undefined` function on purpose: the pi
@@ -315,8 +324,9 @@ export function lastAssistantText(jsonl: string): string | undefined {
315
324
  }
316
325
 
317
326
  /**
318
- * The last assistant turn's TEXT + its Anthropic-API turn-termination signal
319
- * (`stop_reason` + `usage.output`/`usage.output_tokens` token count). Reused by
327
+ * The last assistant turn's TEXT + its turn-termination signal (the stop
328
+ * reason, read by {@link readStopReason}, + the `usage.output`/`usage.output_tokens`
329
+ * token count). Reused by
320
330
  * the pi adapter to populate BOTH `LaunchResult.output` (the `.text`) AND
321
331
  * `LaunchResult.outputCapped` (the cap-truncation signal — see
322
332
  * {@link isOutputCappedTurn}). One walk over the `.jsonl`, not two. `text` is the
@@ -328,16 +338,34 @@ export function lastAssistantText(jsonl: string): string | undefined {
328
338
  export interface LastAssistantTurn {
329
339
  /** The last assistant turn's concatenated `text` parts (its answer). */
330
340
  text?: string;
331
- /** The turn's `stop_reason` (raw — `null`, `'end_turn'`, `'max_tokens'`, …). */
332
- stopReason?: string | null;
341
+ /**
342
+ * The last TEXT turn's stop reason, read by {@link readStopReason} (pi's
343
+ * `stopReason`: `'stop'`, `'toolUse'`, `'length'`, …; or a raw `stop_reason`
344
+ * such as `'end_turn'` / `'max_tokens'` for another harness shape).
345
+ */
346
+ stopReason?: string;
333
347
  /** The turn's produced output-token count (`usage.output` OR `usage.output_tokens`). */
334
348
  outputTokens?: number;
349
+ /**
350
+ * The stop reason of the FINAL assistant message in the log, WHATEVER it
351
+ * carried (text, tool calls, or only a thinking block), read by the SAME
352
+ * {@link readStopReason} as {@link stopReason}. Unlike {@link stopReason}
353
+ * (which belongs to the last TEXT turn), this is how the run actually ENDED:
354
+ * a turn that spent its whole output budget thinking has no text, so only this
355
+ * field sees it. `undefined` when the log has no assistant message or the
356
+ * final one carries no stop reason. Read by {@link cutOffTurnOf}.
357
+ */
358
+ finalStopReason?: string;
359
+ /** The final assistant message's `errorMessage` (pi records it on an `error` stop). */
360
+ finalErrorMessage?: string;
335
361
  }
336
362
 
337
363
  export function lastAssistantTurn(jsonl: string): LastAssistantTurn {
338
364
  let lastText: string | undefined;
339
- let lastStopReason: string | null | undefined = undefined;
365
+ let lastStopReason: string | undefined = undefined;
340
366
  let lastOutputTokens: number | undefined = undefined;
367
+ let finalStopReason: string | undefined = undefined;
368
+ let finalErrorMessage: string | undefined = undefined;
341
369
  for (const line of jsonl.split('\n')) {
342
370
  const trimmed = line.trim();
343
371
  if (trimmed === '') {
@@ -360,10 +388,17 @@ export function lastAssistantTurn(jsonl: string): LastAssistantTurn {
360
388
  if (!message || message.role !== 'assistant') {
361
389
  continue;
362
390
  }
391
+ // EVERY assistant message (text or not) moves the "how did the run end"
392
+ // pointer: the final one wins.
393
+ finalStopReason = readStopReason(message);
394
+ finalErrorMessage =
395
+ typeof message.errorMessage === 'string' && message.errorMessage !== ''
396
+ ? message.errorMessage
397
+ : undefined;
363
398
  const text = assistantContentText(message.content);
364
399
  if (text !== '') {
365
400
  lastText = text; // a later text turn supersedes an earlier one.
366
- lastStopReason = readStopReason(message.stop_reason);
401
+ lastStopReason = readStopReason(message);
367
402
  lastOutputTokens = readOutputTokens(message.usage);
368
403
  }
369
404
  }
@@ -371,37 +406,60 @@ export function lastAssistantTurn(jsonl: string): LastAssistantTurn {
371
406
  text: lastText,
372
407
  stopReason: lastStopReason,
373
408
  outputTokens: lastOutputTokens,
409
+ finalStopReason,
410
+ finalErrorMessage,
374
411
  };
375
412
  }
376
413
 
377
414
  /**
378
- * Is this assistant turn's signal an OUTPUT-CAP truncation — the turn did NOT
379
- * end naturally? `stop_reason` `null`/`None`/`undefined` (the turn was cut off) OR
380
- * `'max_tokens'` (the model hit its output-token cap), together with a positive
381
- * produced-token count. The `null`/`None` form is what pi's session log records
382
- * when the `--print` run is truncated before the turn closes (observation
383
- * `tasker-review-edits-payload-caps-the-verdict-response`); `'max_tokens'` is the
384
- * standard API cap signal. Both name the same structural cause: the verdict never
385
- * finished.
415
+ * Was the run's FINAL assistant turn CUT OFF rather than ended normally (task
416
+ * `a-truncated-agent-turn-routes-as-agent-failed`)? `length` (the per-turn
417
+ * output-token cap: observed with a turn whose whole 16,384-token budget went
418
+ * into a thinking block, no text, no tool call) and `error` (a provider error)
419
+ * are the two cut-off causes; every other reason (`stop`, `toolUse`, `aborted`,
420
+ * or none recorded) returns `undefined`. `aborted` is deliberately NOT a cut-off
421
+ * here: it is what a deliberate kill (the deadline stop) leaves, which has its
422
+ * own checkpoint route.
423
+ */
424
+ export function cutOffTurnOf(turn: LastAssistantTurn): CutOffTurn | undefined {
425
+ if (turn.finalStopReason !== 'length' && turn.finalStopReason !== 'error') {
426
+ return undefined;
427
+ }
428
+ return turn.finalErrorMessage === undefined
429
+ ? {cause: turn.finalStopReason}
430
+ : {cause: turn.finalStopReason, errorMessage: turn.finalErrorMessage};
431
+ }
432
+
433
+ /**
434
+ * Did this assistant turn HIT THE OUTPUT-TOKEN CAP (and nothing else)? True iff
435
+ * its stop reason is pi's `'length'` (or the raw API `'max_tokens'`, for a
436
+ * harness that mirrors the provider's own key) AND it produced a positive
437
+ * token count (observation `tasker-review-edits-payload-caps-the-verdict-response`:
438
+ * the verdict never finished). A normal end (`'stop'`, `'toolUse'`, `'end_turn'`,
439
+ * …), an error/abort, or NO recorded stop reason is NOT a cap: an unknown reason
440
+ * proves nothing, and inferring a cap from it named every pi parse failure a cap
441
+ * truncation (observation `output-capped-signal-reads-the-wrong-stop-reason-key`).
386
442
  */
387
443
  export function isOutputCappedTurn(turn: LastAssistantTurn): boolean {
388
444
  const cappedReason =
389
- turn.stopReason === null ||
390
- turn.stopReason === undefined ||
391
- turn.stopReason === 'None' ||
392
- turn.stopReason === 'max_tokens';
445
+ turn.stopReason === 'length' || turn.stopReason === 'max_tokens';
393
446
  return cappedReason && (turn.outputTokens ?? 0) > 0;
394
447
  }
395
448
 
396
- /** Read `stop_reason` defensively as a string-or-null (pi may emit `None`/`null`). */
397
- function readStopReason(raw: unknown): string | null | undefined {
398
- if (raw === null) {
399
- return null;
400
- }
401
- if (typeof raw === 'string') {
402
- return raw;
403
- }
404
- return undefined;
449
+ /**
450
+ * THE one stop-reason reader, shared by the output-cap signal
451
+ * ({@link isOutputCappedTurn}, via `stopReason`) and the cut-off signal
452
+ * ({@link cutOffTurnOf}, via `finalStopReason`) so the two can never disagree
453
+ * about a turn. Reads pi's normalised camelCase `message.stopReason` (what real
454
+ * pi session logs carry), falling back to a snake_case `message.stop_reason` for
455
+ * other harness shapes. Anything that is not a string (absent, `null`) is
456
+ * `undefined`: no recorded reason.
457
+ */
458
+ function readStopReason(
459
+ message: NonNullable<SessionLogRecord['message']>,
460
+ ): string | undefined {
461
+ const raw = message.stopReason ?? message.stop_reason;
462
+ return typeof raw === 'string' ? raw : undefined;
405
463
  }
406
464
 
407
465
  /** Read the produced output-token count from `usage.output` OR `usage.output_tokens`. */
@@ -217,18 +217,26 @@ export type TaskResolutionFolder =
217
217
  | 'tasks-backlog';
218
218
 
219
219
  /**
220
- * The task LIFECYCLE folders a `task:<slug>` / lone-task `issue:` can reside
221
- * in (spec-complete.ts + close-job.ts `TASK_FOLDERS`): `tasks-ready`, `in-progress`,
222
- * `done`. `needs-attention` was dropped post-cutover
220
+ * The task LIFECYCLE folders a `spec:<slug>` task / lone-task `issue:` can reside
221
+ * in (spec-complete.ts + close-job.ts `TASK_FOLDERS`): EVERY folder a task body can
222
+ * rest in, staged to terminal: `tasks-backlog`, `tasks-ready`, `in-progress`,
223
+ * `done`, `cancelled`. `tasks-backlog` and `cancelled` were added by task
224
+ * `spec-complete-counts-staged-and-cancelled-tasks`: tasking stages new tasks in
225
+ * `tasks/backlog/` by default, so a scan that skipped it declared a spec complete
226
+ * (and the CI close-job closed its issue) while work was still staged; `cancelled`
227
+ * is scanned so a caller can tell "all cancelled" apart from "no tasks".
228
+ * `needs-attention` was dropped post-cutover
223
229
  * (`finish-needs-attention-folder-cutover-remove-legacy-recovery-readers`, ratified
224
230
  * in `docs/adr/needs-attention-folder-cutover-followup-nits.md`): the folder is
225
231
  * retired — a stuck item is the per-item lock `state: stuck` with the body resting
226
232
  * in `tasks/ready/`, so no task ever resides in `work/needs-attention/`.
227
233
  */
228
234
  export const TASK_LIFECYCLE_FOLDERS = [
235
+ 'tasks-backlog',
229
236
  'tasks-ready',
230
237
  'in-progress',
231
238
  'done',
239
+ 'cancelled',
232
240
  ] as const satisfies readonly WorkFolderKey[];
233
241
 
234
242
  /** One of the task lifecycle folders (spec-complete.ts / close-job.ts). */
package/src/workspace.ts CHANGED
@@ -2,11 +2,19 @@ import {
2
2
  existsSync,
3
3
  lstatSync,
4
4
  readFileSync,
5
+ realpathSync,
5
6
  rmSync,
6
7
  writeFileSync,
7
8
  } from 'node:fs';
8
9
  import {basename, dirname, join} from 'node:path';
9
- import {git} from './git.js';
10
+ import {git, run} from './git.js';
11
+ // Call-time use only (gc.ts imports this module too; an ESM cycle is fine for
12
+ // functions/constants read inside function bodies).
13
+ import {
14
+ evaluateDeletionSafety,
15
+ RETAIN_REASON_TEXT,
16
+ type RetainReason,
17
+ } from './gc.js';
10
18
  import {
11
19
  encodeRepoKey,
12
20
  ensureMirror,
@@ -15,8 +23,9 @@ import {
15
23
  import {
16
24
  branchAheadOfArbiter,
17
25
  rebaseContinuedBranchOntoMain,
18
- pushContinuedBranchWithStaleLeaseRetry,
26
+ type ContinueRebaseFailure,
19
27
  } from './continue-branch.js';
28
+ import {refWrite} from './ref-write.js';
20
29
  import {type HarnessRecord} from './harness.js';
21
30
  import {brand} from './brand.js';
22
31
  import {workBranchRef, type SlugNamespace} from './slug-namespace.js';
@@ -170,6 +179,14 @@ export interface CreateJobOptions {
170
179
  workspacesDir: string;
171
180
  /** The initial harness block to persist in the record (default: null adapter). */
172
181
  harness?: HarnessRecord;
182
+ /**
183
+ * Rebase a kept branch LOCALLY only: skip the `--force-with-lease` push of the
184
+ * rebased tip (ADR `ci-agent-job-holds-no-write-token` decision 7). The CI
185
+ * agent job holds a read-only token, so its continue rebase stays local and
186
+ * the apply job pushes the tip, leased on the one the lock job observed
187
+ * (task `ci-split-answered-merge-action`). Default `false`: today's push.
188
+ */
189
+ localContinue?: boolean;
173
190
  env?: NodeJS.ProcessEnv;
174
191
  }
175
192
 
@@ -201,12 +218,21 @@ export interface Job {
201
218
  */
202
219
  continued: boolean;
203
220
  /**
204
- * True iff a CONTINUE rebase onto fresh main CONFLICTED (aborted, never
205
- * auto-resolved). The caller (`run`'s pipeline / `do --remote`) routes the
206
- * item to needs-attention via the §10 path; the worktree is left on the
207
- * un-rebased kept branch as the never-lose-work signal.
221
+ * True iff a CONTINUE rebase onto fresh main did not apply (aborted, never
222
+ * auto-resolved): a genuine conflict OR any other rebase failure, which
223
+ * {@link continueRebaseFailure} tells apart. The caller (`run`'s pipeline /
224
+ * `do --remote`) routes the item to needs-attention via the §10 path; the
225
+ * worktree is left on the un-rebased kept branch as the never-lose-work
226
+ * signal.
208
227
  */
209
228
  continueRebaseConflict: boolean;
229
+ /**
230
+ * Why the continue rebase did not apply (set exactly when
231
+ * {@link continueRebaseConflict} is): the conflicting paths or git's report,
232
+ * and the `main` sha rebased onto. Render it with
233
+ * `describeContinueRebaseFailure` in the needs-attention reason.
234
+ */
235
+ continueRebaseFailure?: ContinueRebaseFailure;
210
236
  /**
211
237
  * Set iff the CONTINUE reconcile push to the arbiter FAILED TERMINALLY (the
212
238
  * stale-lease retry cap was exhausted, or a non-stale-lease rejection such as
@@ -229,6 +255,48 @@ export interface Job {
229
255
 
230
256
  const DEFAULT_HARNESS: HarnessRecord = {adapter: 'null'};
231
257
 
258
+ /**
259
+ * Carry the commit identity git resolves in the checkout `cwd` (`user.name` /
260
+ * `user.email`, read with `env`) into `env`'s own git config
261
+ * (`GIT_CONFIG_COUNT` / `GIT_CONFIG_KEY_<n>` / `GIT_CONFIG_VALUE_<n>`), so a
262
+ * job worktree cut from the hub mirror commits as that checkout does.
263
+ *
264
+ * WHY (task `a-failed-rebase-reports-its-real-cause`): the hub mirror is its
265
+ * OWN repository under `workspacesDir`, so it does not see the checkout's
266
+ * local config. The generated CI workflows set the identity with a plain
267
+ * `git config user.name` / `user.email` IN THE CHECKOUT, and a GitHub-hosted
268
+ * runner has no global identity, so in the CI agent job the continue rebase of
269
+ * an answered merge (which rewrites commits, so needs a committer) failed with
270
+ * git's `unable to auto-detect email address`, reported as a conflict.
271
+ *
272
+ * Returns `env` unchanged when the checkout resolves no complete identity (git
273
+ * then behaves as before). The value is exactly what git already uses in the
274
+ * checkout, so this changes nothing for a caller whose identity was global.
275
+ */
276
+ export function withCheckoutCommitIdentity(
277
+ cwd: string,
278
+ env: NodeJS.ProcessEnv | undefined,
279
+ ): NodeJS.ProcessEnv | undefined {
280
+ const read = (key: string): string =>
281
+ run('git', ['config', '--get', key], cwd, {env}).stdout.trim();
282
+ const name = read('user.name');
283
+ const email = read('user.email');
284
+ if (name === '' || email === '') {
285
+ return env;
286
+ }
287
+ const base = env ?? process.env;
288
+ const parsed = Number(base.GIT_CONFIG_COUNT ?? '0');
289
+ const count = Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
290
+ return {
291
+ ...base,
292
+ GIT_CONFIG_COUNT: String(count + 2),
293
+ [`GIT_CONFIG_KEY_${count}`]: 'user.name',
294
+ [`GIT_CONFIG_VALUE_${count}`]: name,
295
+ [`GIT_CONFIG_KEY_${count + 1}`]: 'user.email',
296
+ [`GIT_CONFIG_VALUE_${count + 1}`]: email,
297
+ };
298
+ }
299
+
232
300
  /**
233
301
  * Create (or recreate) a job: ensure the hub mirror via `repo-mirror`, then
234
302
  * `git worktree add` a per-job worktree OUTSIDE the hub at
@@ -275,7 +343,7 @@ export function createJob(options: CreateJobOptions): Job {
275
343
  env,
276
344
  });
277
345
  let continued = false;
278
- let continueRebaseConflict = false;
346
+ let continueRebaseFailure: ContinueRebaseFailure | undefined;
279
347
  let continuePushFailure: string | undefined;
280
348
 
281
349
  if (continueFromKept) {
@@ -302,9 +370,9 @@ export function createJob(options: CreateJobOptions): Job {
302
370
  // NEVER --force, NEVER to main (§11). A CONFLICT → aborted (never
303
371
  // auto-resolved) + flagged so the caller routes to needs-attention.
304
372
  const rebase = rebaseContinuedBranchOntoMain(dir, 'main', env);
305
- if (rebase.kind === 'conflict') {
306
- continueRebaseConflict = true;
307
- } else {
373
+ if (rebase.kind !== 'clean') {
374
+ continueRebaseFailure = rebase;
375
+ } else if (options.localContinue !== true) {
308
376
  // Push the rebased tip with --force-with-lease, SURVIVING a stale-lease
309
377
  // ("stale info") rejection: re-fetch the arbiter `work/<slug>` + main,
310
378
  // re-rebase onto current main, and retry (bounded) instead of stranding the
@@ -320,7 +388,7 @@ export function createJob(options: CreateJobOptions): Job {
320
388
  // to needs-attention — the kept work stays committed + recoverable on the
321
389
  // branch — instead of stranding it.
322
390
  try {
323
- const pushed = pushContinuedBranchWithStaleLeaseRetry({
391
+ const pushed = refWrite.pushContinuedBranch({
324
392
  cwd: dir,
325
393
  branch,
326
394
  arbiter: 'origin',
@@ -328,14 +396,30 @@ export function createJob(options: CreateJobOptions): Job {
328
396
  expectedRemoteTip,
329
397
  env,
330
398
  });
331
- if (pushed.kind === 'conflict') {
332
- continueRebaseConflict = true;
399
+ if (pushed.kind !== 'pushed') {
400
+ continueRebaseFailure = pushed;
333
401
  }
334
402
  } catch (err) {
335
403
  continuePushFailure = err instanceof Error ? err.message : String(err);
336
404
  }
337
405
  }
338
406
  } else {
407
+ // NEVER-LOSE-WORK guard (task
408
+ // `a-fresh-claim-does-not-discard-a-retained-worktrees-unsaved-work`): a
409
+ // RETAINED worktree at this work-id (a crashed run whose lock was released
410
+ // some way other than `requeue`, e.g. `release-lock`) may hold work the
411
+ // arbiter lacks. Apply the SAME deletion-safety predicate `gc` and the
412
+ // requeue save use; if it does not hold, REFUSE loudly (throw) and leave the
413
+ // worktree untouched instead of clearing it below.
414
+ refuseToClearUnsavedWorktree({
415
+ dir,
416
+ branch,
417
+ slug,
418
+ type: options.type ?? 'task',
419
+ mirrorPath: mirror.path,
420
+ env,
421
+ });
422
+
339
423
  // Clear any stale registration for this work-id (idempotent re-create): a
340
424
  // leftover worktree dir or branch from a prior crashed run would block the
341
425
  // `worktree add`. We use git's own removal, never a bare rm -rf (ADR §4).
@@ -366,7 +450,8 @@ export function createJob(options: CreateJobOptions): Job {
366
450
  record,
367
451
  mirror,
368
452
  continued,
369
- continueRebaseConflict,
453
+ continueRebaseConflict: continueRebaseFailure !== undefined,
454
+ ...(continueRebaseFailure === undefined ? {} : {continueRebaseFailure}),
370
455
  continuePushFailure,
371
456
  dispose() {
372
457
  git(['worktree', 'remove', '--force', dir], mirror.path, {env});
@@ -376,6 +461,117 @@ export function createJob(options: CreateJobOptions): Job {
376
461
  };
377
462
  }
378
463
 
464
+ /**
465
+ * Thrown by {@link createJob} when its FRESH-cut path finds a RETAINED job
466
+ * worktree at the work-id that holds work the arbiter lacks (the gc
467
+ * deletion-safety predicate, {@link evaluateDeletionSafety}, does not hold).
468
+ * Clearing it would silently discard that work, so the claim is refused and the
469
+ * worktree is left untouched. The message names the path and the recovery.
470
+ *
471
+ * A caller that best-effort reaps a leaked worktree after `createJob` threw
472
+ * (`do --isolated`'s `reapPreparedWorktreeLeak`) MUST skip that reap for this
473
+ * error: the worktree is not a leak, it is the work being protected.
474
+ */
475
+ export class RetainedWorktreeUnsavedWorkError extends Error {
476
+ readonly dir: string;
477
+ /** The branch the retained worktree is on (its HEAD branch, else the job branch). */
478
+ readonly retainedBranch: string;
479
+ readonly reason: RetainReason;
480
+ constructor(input: {
481
+ message: string;
482
+ dir: string;
483
+ retainedBranch: string;
484
+ reason: RetainReason;
485
+ }) {
486
+ super(input.message);
487
+ this.name = 'RetainedWorktreeUnsavedWorkError';
488
+ this.dir = input.dir;
489
+ this.retainedBranch = input.retainedBranch;
490
+ this.reason = input.reason;
491
+ }
492
+ }
493
+
494
+ /**
495
+ * The claim-time never-lose-work guard: if `dir` is a real git working tree (a
496
+ * retained job worktree, not an orphan dir / dangling symlink the self-heal in
497
+ * {@link forceClearWorktreePath} handles) and the shared deletion-safety
498
+ * predicate does NOT hold for it (dirty tree, or a tip the arbiter lacks),
499
+ * throw {@link RetainedWorktreeUnsavedWorkError}. A worktree that is provably
500
+ * safe (clean AND merged/pushed) passes, and the caller clears it as before.
501
+ */
502
+ function refuseToClearUnsavedWorktree(input: {
503
+ dir: string;
504
+ branch: string;
505
+ slug: string;
506
+ type: SlugNamespace;
507
+ mirrorPath: string;
508
+ env: NodeJS.ProcessEnv | undefined;
509
+ }): void {
510
+ const {dir, branch, slug, type, mirrorPath, env} = input;
511
+ if (!isGitWorkingTreeRoot(dir, env)) {
512
+ return;
513
+ }
514
+ // Judge the worktree by the branch it is ACTUALLY on (a same-slug run of the
515
+ // other type shares this work-id), falling back to the job branch when HEAD
516
+ // is detached (e.g. a run killed mid-rebase).
517
+ const head = run('git', ['symbolic-ref', '-q', '--short', 'HEAD'], dir, {
518
+ env,
519
+ });
520
+ const retainedBranch =
521
+ head.status === 0 && head.stdout.trim() !== ''
522
+ ? head.stdout.trim()
523
+ : branch;
524
+ const verdict = evaluateDeletionSafety({
525
+ dir,
526
+ branch: retainedBranch,
527
+ env,
528
+ });
529
+ if (verdict.safe) {
530
+ return;
531
+ }
532
+ const reason = verdict.reason ?? 'unmerged-commits';
533
+ const save =
534
+ type === 'task' && retainedBranch === branch
535
+ ? `Save it with \`dorfl requeue ${slug}\` (with the lock held, it commits ` +
536
+ `any residue as a wip commit and pushes ${branch}, so the next claim ` +
537
+ `continues from it), or push ${retainedBranch} from ${dir} yourself.`
538
+ : `Save it by pushing ${retainedBranch} from ${dir}, then release the lock.`;
539
+ throw new RetainedWorktreeUnsavedWorkError({
540
+ dir,
541
+ retainedBranch,
542
+ reason,
543
+ message:
544
+ `refusing to cut a fresh ${branch} for '${slug}': the retained job ` +
545
+ `worktree ${dir} (on ${retainedBranch}) holds work the arbiter lacks ` +
546
+ `(${RETAIN_REASON_TEXT[reason]}), and clearing it would discard that ` +
547
+ `work. The worktree is left untouched. ${save} To discard it on purpose, ` +
548
+ `run \`git -C ${mirrorPath} worktree remove --force ${dir}\` and claim again.`,
549
+ });
550
+ }
551
+
552
+ /**
553
+ * True iff `dir` is itself the ROOT of a git working tree (a registered job
554
+ * worktree). An orphan directory git does not manage, or a dangling symlink,
555
+ * is not (it holds no git-tracked work, and the self-heal removes it).
556
+ */
557
+ function isGitWorkingTreeRoot(
558
+ dir: string,
559
+ env: NodeJS.ProcessEnv | undefined,
560
+ ): boolean {
561
+ if (!existsSync(dir)) {
562
+ return false;
563
+ }
564
+ const top = run('git', ['rev-parse', '--show-toplevel'], dir, {env});
565
+ if (top.status !== 0) {
566
+ return false;
567
+ }
568
+ try {
569
+ return realpathSync(top.stdout.trim()) === realpathSync(dir);
570
+ } catch {
571
+ return false;
572
+ }
573
+ }
574
+
379
575
  /**
380
576
  * Write (or overwrite) a job's record to its SIBLING path ({@link jobRecordPath},
381
577
  * `<work-id>.json` next to the worktree), OUTSIDE the checked-out tree. `dir` is