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
package/src/intake.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type {Phase} from './phase.js';
1
2
  import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
2
3
  import {dirname, join} from 'node:path';
3
4
  import {runAsync, type RunResult} from './git.js';
@@ -6,12 +7,22 @@ import {paramCase} from './brand.js';
6
7
  import {ensureSafeSlug} from './slug-safety.js';
7
8
  import {
8
9
  performIntegration,
10
+ proposePointer,
9
11
  type IntegrationCoreResult,
10
12
  } from './integration-core.js';
11
- import type {IntegrateResult, ReviewProvider} from './integrator.js';
13
+ import {
14
+ proposeRequestNotOpened,
15
+ type IntegrateResult,
16
+ type ReviewProvider,
17
+ } from './integrator.js';
12
18
  import {integrationFromFlags} from './complete.js';
13
19
  import type {IntegrationMode, SpecsLandIn, TasksLandIn} from './config.js';
14
- import type {OriginTrust} from './frontmatter.js';
20
+ import {
21
+ assertFrontmatterFields,
22
+ quoteYamlScalar,
23
+ singleLineScalarProblem,
24
+ type OriginTrust,
25
+ } from './frontmatter.js';
15
26
  import {TASK_PLACEMENT_SLOTS, landingToSide} from './tasking.js';
16
27
  import {
17
28
  placementFolder,
@@ -393,6 +404,13 @@ export interface PerformIntakeOptions {
393
404
  env?: NodeJS.ProcessEnv;
394
405
  /** Sink for human-readable progress notes. */
395
406
  note?: (message: string) => void;
407
+ /**
408
+ * The CI phase this run executes as (`--phase lock|agent|apply`, CI-only;
409
+ * see `phase.ts`). `undefined` ⇒ today's single process. Threaded next to
410
+ * `env` and `note` so the per-path phase splits can branch on it; the phase
411
+ * guards read the process-wide active phase.
412
+ */
413
+ phase?: Phase;
396
414
  }
397
415
 
398
416
  const DEFAULT_ARBITER = 'origin';
@@ -608,19 +626,20 @@ export async function performIntake(
608
626
  // silently pushing under an ambient credential. Resolve the arbiter URL softly
609
627
  // (a non-zero/unknown URL is skipped — the guard is a no-op without an identity
610
628
  // or a resolvable URL).
611
- if (options.identity !== undefined) {
612
- const urlRes = await runAsync('git', ['remote', 'get-url', arbiter], cwd, {
613
- env,
614
- });
615
- if (urlRes.status === 0) {
616
- try {
617
- assertTransportAllowed(options.identity, urlRes.stdout.trim());
618
- } catch (err) {
619
- const message = err instanceof Error ? err.message : String(err);
620
- note(message);
621
- return {exitCode: 1, outcome: 'usage-error', issueNumber, message};
622
- }
623
- }
629
+ const transportProblem = await intakeTransportProblem(
630
+ options.identity,
631
+ cwd,
632
+ arbiter,
633
+ env,
634
+ );
635
+ if (transportProblem !== undefined) {
636
+ note(transportProblem);
637
+ return {
638
+ exitCode: 1,
639
+ outcome: 'usage-error',
640
+ issueNumber,
641
+ message: transportProblem,
642
+ };
624
643
  }
625
644
 
626
645
  // 1. READ the issue + thread via the seam (the core never imports `gh`; only the
@@ -823,6 +842,12 @@ function createLockReleaser(params: {
823
842
  * Split out so the lock release is a clean `try`/`finally` in the caller (the lock
824
843
  * MUST release on every terminal path — success or handled failure). The agent
825
844
  * DRAFTS only; the runner owns every git/seam side-effect here.
845
+ *
846
+ * It composes the SAME two halves the CI phases run in two jobs (task
847
+ * `ci-split-intake`, `ci-phase-intake.ts`): {@link decideIntake} (the agent half:
848
+ * the decision agent and the lone-task review, no write) and
849
+ * {@link applyIntakeDecision} (the write half: the comment, the close, or the
850
+ * document render + integrate + completion comment).
826
851
  */
827
852
  async function decideAndDispatch(
828
853
  options: PerformIntakeOptions,
@@ -839,10 +864,6 @@ async function decideAndDispatch(
839
864
  ): Promise<IntakeResult> {
840
865
  const {arbiter, issueProvider, note} = ctx;
841
866
  const issueNumber = issue.number;
842
- // `env` here is the identity-scoped GIT/provider env (the runner's `gh`/git
843
- // ops). The AGENT launches (decision agent, lone-task review) use the AMBIENT
844
- // `options.env` — an agent must not act as the bot.
845
- const env = ctx.gitEnv;
846
867
 
847
868
  // TRIAGE (deterministic, under the lock, BEFORE the prompt): decide whether to run
848
869
  // the decision at all, built ENTIRELY on intake's own MARKER on the thread (no
@@ -853,163 +874,617 @@ async function decideAndDispatch(
853
874
  // is excluded from the human-comment check by construction.
854
875
  const triage = triageIntake(comments);
855
876
  if (triage.action === 'skip') {
856
- const message =
857
- triage.outcome === 'no-new-input'
858
- ? `Intake of issue #${issueNumber} found nothing new: it has the last word ` +
859
- `on the thread and has already seen every human comment up to it; doing ` +
860
- `nothing (the decision prompt did not run).`
861
- : `Intake of issue #${issueNumber} skipped: the issue was already ` +
862
- `transformed (a terminal intake marker is on the thread); a later human ` +
863
- `comment does not re-open it (the decision prompt did not run).`;
877
+ const message = intakeSkipMessage(issueNumber, triage.outcome);
864
878
  note(message);
865
879
  return {exitCode: 0, outcome: triage.outcome, issueNumber, message};
866
880
  }
867
881
 
868
- // DECIDE: prompt → VERDICT. The agent DRAFTS only (no git, no seam ops). Tests
869
- // inject a canned verdict (the dispatcher's testable seam); production wires the
870
- // harness. The prompt's judgement is not unit-tested — only the dispatch.
882
+ // DECIDE: prompt → VERDICT (+ the lone-task review). The agent DRAFTS only (no
883
+ // git, no seam ops).
884
+ const decided = await decideIntake(
885
+ options,
886
+ cwd,
887
+ issue,
888
+ comments,
889
+ triage,
890
+ note,
891
+ );
892
+ if (!decided.ok) {
893
+ return decided.result;
894
+ }
895
+
896
+ // DISPATCH on the decision. The runner owns every git/seam side-effect (the
897
+ // in-band boundary). PER-OUTCOME integration (spec `issue-intake` US #9): the
898
+ // resolved mode is keyed on the runtime artifact TYPE. The per-run `seen=`
899
+ // DELTA (the HUMAN comment ids intake READ this run, excluding its own
900
+ // marker-comments + already-seen ids) rides every comment intake posts.
901
+ return applyIntakeDecision(decided.decision, {
902
+ issueNumber,
903
+ cwd,
904
+ arbiter,
905
+ issueProvider,
906
+ integration: options.integration ?? {task: 'propose', spec: 'propose'},
907
+ mergeRetries: options.mergeRetries,
908
+ // The origin-trust STAMP, passed IN (not resolved here). Unset ⇒ unstamped
909
+ // (a local intake ⇒ human/trusted).
910
+ originTrust: options.originTrust,
911
+ noPR: options.noPR,
912
+ placement: intakePlacementInputs(options),
913
+ providerInstance: options.providerInstance,
914
+ seen: computeSeenDelta(comments),
915
+ // The identity-scoped GIT/provider env (the `gh` ops, push, PR).
916
+ env: ctx.gitEnv,
917
+ note,
918
+ });
919
+ }
920
+
921
+ /** The message of a triage SKIP (the decision prompt did not run). */
922
+ export function intakeSkipMessage(
923
+ issueNumber: number,
924
+ outcome: 'no-new-input' | 'already-terminal',
925
+ ): string {
926
+ return outcome === 'no-new-input'
927
+ ? `Intake of issue #${issueNumber} found nothing new: it has the last word ` +
928
+ `on the thread and has already seen every human comment up to it; doing ` +
929
+ `nothing (the decision prompt did not run).`
930
+ : `Intake of issue #${issueNumber} skipped: the issue was already ` +
931
+ `transformed (a terminal intake marker is on the thread); a later human ` +
932
+ `comment does not re-open it (the decision prompt did not run).`;
933
+ }
934
+
935
+ /**
936
+ * The push-time transport-coherence guard (identity): the problem message when
937
+ * a configured identity forbids the arbiter's transport, else `undefined`. The
938
+ * arbiter URL is resolved softly (a non-zero/unknown URL is skipped); a no-op
939
+ * without an identity.
940
+ */
941
+ export async function intakeTransportProblem(
942
+ identity: Identity | undefined,
943
+ cwd: string,
944
+ arbiter: string,
945
+ env: NodeJS.ProcessEnv | undefined,
946
+ ): Promise<string | undefined> {
947
+ if (identity === undefined) {
948
+ return undefined;
949
+ }
950
+ const urlRes = await runAsync('git', ['remote', 'get-url', arbiter], cwd, {
951
+ env,
952
+ });
953
+ if (urlRes.status !== 0) {
954
+ return undefined;
955
+ }
956
+ try {
957
+ assertTransportAllowed(identity, urlRes.stdout.trim());
958
+ return undefined;
959
+ } catch (err) {
960
+ return err instanceof Error ? err.message : String(err);
961
+ }
962
+ }
963
+
964
+ /** A decided task or spec document, before it is rendered (no write yet). */
965
+ export type IntakeDocumentDecision =
966
+ | {
967
+ kind: 'task';
968
+ /** The content-derived, `slug-safety.ts`-checked slug. */
969
+ slug: string;
970
+ title: string;
971
+ /** The (reviewed) drafted body; absent ⇒ the default scaffold. */
972
+ body: string | undefined;
973
+ }
974
+ | {
975
+ kind: 'spec';
976
+ slug: string;
977
+ title: string;
978
+ body: string | undefined;
979
+ /** The gate axes as the prompt judged them. */
980
+ humanOnly?: boolean;
981
+ needsAnswers?: boolean;
982
+ };
983
+
984
+ /**
985
+ * What the AGENT half of intake decided ({@link decideIntake}), before any
986
+ * write: the comment text of an ask (a lone-task review that did not converge
987
+ * is an ask carrying the draft) or a bounce, or a document to emit.
988
+ */
989
+ export type IntakeDecision =
990
+ | {kind: 'ask'; body: string}
991
+ | {kind: 'bounce'; body: string}
992
+ | IntakeDocumentDecision;
993
+
994
+ /**
995
+ * The AGENT half of intake (task `ci-split-intake`): run the decision agent on the
996
+ * issue + thread, refuse a verdict whose title cannot be one frontmatter line,
997
+ * derive the content-derived slug, and (for a task) run the bounded lone-task
998
+ * review. Performs NO write (no git, no seam op); returns the
999
+ * {@link IntakeDecision} the write half acts on, or the failed
1000
+ * {@link IntakeResult} (`agent-failed`, or `usage-error` for a verdict with no
1001
+ * usable slug). The laptop `intake` and the CI agent job both run it.
1002
+ */
1003
+ export async function decideIntake(
1004
+ options: PerformIntakeOptions,
1005
+ cwd: string,
1006
+ issue: Issue,
1007
+ comments: IssueComment[],
1008
+ triage: IntakeTriageDecision | undefined,
1009
+ note: (message: string) => void,
1010
+ ): Promise<
1011
+ {ok: true; decision: IntakeDecision} | {ok: false; result: IntakeResult}
1012
+ > {
1013
+ const issueNumber = issue.number;
1014
+ const failed = (
1015
+ outcome: 'agent-failed' | 'usage-error',
1016
+ message: string,
1017
+ ): {ok: false; result: IntakeResult} => {
1018
+ // Not noted: every caller returns this result, whose message the CLI
1019
+ // prints as the run's result line (task `ci-phase-logs-each-line-once`).
1020
+ return {ok: false, result: {exitCode: 1, outcome, issueNumber, message}};
1021
+ };
1022
+
1023
+ // DECIDE: prompt → VERDICT. Tests inject a canned verdict (the dispatcher's
1024
+ // testable seam); production wires the harness. The prompt's judgement is not
1025
+ // unit-tested; only the dispatch is.
871
1026
  const prompt = buildIntakeDecisionSpec(issue, comments, triage);
872
1027
  let verdict: IntakeVerdict;
873
1028
  try {
874
1029
  verdict = await runDecision(options, cwd, issue, comments, prompt);
1030
+ // The VERDICT BOUNDARY for the drafted title (task
1031
+ // `intake-frontmatter-title-injection-strips-origin-stamp`): a title that is
1032
+ // not one line, holds a control character, or is blank is refused HERE, so
1033
+ // it degrades onto `agent-failed` like any malformed verdict (never a silent
1034
+ // emit). Runs for the injected `decide` seam and the parsed harness verdict.
1035
+ assertIntakeVerdictTitle(verdict);
875
1036
  } catch (err) {
876
1037
  const detail = err instanceof Error ? err.message : String(err);
877
- const message = `Intake decision failed for issue #${issueNumber}: ${detail}`;
878
- note(message);
879
- return {exitCode: 1, outcome: 'agent-failed', issueNumber, message};
1038
+ return failed(
1039
+ 'agent-failed',
1040
+ `Intake decision failed for issue #${issueNumber}: ${detail}`,
1041
+ );
880
1042
  }
881
1043
 
882
- // DISPATCH on the verdict — the FULL four-outcome decision table (spec
883
- // `issue-intake`). The agent only DRAFTED the verdict; the runner owns every
884
- // git/seam side-effect below (the in-band boundary): the write + integrate
885
- // (task/spec) and the `postIssueComment` (ask/bounce).
886
- //
887
- // PER-OUTCOME integration (spec `issue-intake` US #9): the resolved mode is keyed on the runtime
888
- // artifact TYPE — a `task` verdict integrates with the task mode, a `spec`
889
- // verdict with the spec mode. Unset ⇒ propose for both. ask/bounce never
890
- // integrate, so the modes are no-ops for them.
891
- const modes = options.integration ?? {task: 'propose', spec: 'propose'};
892
- // The per-run `seen=` DELTA (the HUMAN comment ids intake READ this run, excluding
893
- // its own marker-comments + already-seen ids) the marker records on every comment
894
- // intake posts — the chain-model primitive the TRIAGE unions into `seenSet`.
895
- const seenDelta = computeSeenDelta(comments);
896
1044
  switch (verdict.outcome) {
897
- case 'task':
898
- return dispatchTask({
899
- verdict,
900
- issueNumber,
901
- cwd,
902
- arbiter,
903
- integration: modes.task,
904
- // The resolved cross-job CAS-retry cap (config `mergeRetries`) — threaded so
905
- // intake's lone-task emit lands under the same per-repo cap the build path uses.
906
- mergeRetries: options.mergeRetries,
907
- // The origin-trust STAMP, passed IN (not resolved here): the emitted task
908
- // carries `origin: issue` + this verdict so the becomes-code checkpoint is
909
- // not laundered. Unset ⇒ unstamped (a local intake ⇒ human/trusted).
910
- originTrust: options.originTrust,
911
- noPR: options.noPR,
912
- // RUNNER-DETERMINISTIC PLACEMENT for the DIRECT-from-issue task (ADR
913
- // `untrusted-origin-carries-via-stamp-not-forced-staging`): the
914
- // configured-default + explicit-flag rungs, fed into the SHARED placement
915
- // resolver alongside the `originTrust` stamp above — the TASK twin of the
916
- // spec dispatch below. `dispatchTask` selects the untrusted-side default
917
- // (`untrustedTasksLandIn`) when the stamp is `untrusted`, else
918
- // `tasksLandIn`; the resolver decides `tasks/backlog/` (staging) vs
919
- // `tasks/ready/` (the pool). `intake` never places itself.
920
- tasksLandIn: options.tasksLandIn,
921
- untrustedTasksLandIn: options.untrustedTasksLandIn,
922
- explicitTasksLandIn: options.explicitTasksLandIn,
923
- providerInstance: options.providerInstance,
924
- issueProvider,
925
- // The bounded lone-task review seam (tests inject a canned verdict;
926
- // production wires the harness via the default below).
927
- reviewTask: resolveLoneTaskReviewGate(options),
928
- seen: seenDelta,
929
- env,
930
- // The lone-task review AGENT launches AMBIENT (an agent must not act as
931
- // the bot); `env` above is the identity-scoped git/provider env.
932
- agentEnv: options.env,
933
- note,
934
- });
935
- // The `spec` outcome is the parent-spec verdict; it dispatches through
936
- // `modes.spec`. HARD CUTOVER: the legacy ''prd'' outcome case is GONE.
937
- case 'spec':
938
- return dispatchSpec({
939
- verdict,
940
- issueNumber,
941
- cwd,
942
- arbiter,
943
- integration: modes.spec,
944
- // The resolved cross-job CAS-retry cap (config `mergeRetries`) — threaded so
945
- // intake's spec emit lands under the same per-repo cap the build path uses.
946
- mergeRetries: options.mergeRetries,
947
- // Same origin-trust stamp on the spec outcome (propagated onto its tasks
948
- // later by the tasker). Passed IN; not resolved here.
949
- originTrust: options.originTrust,
950
- noPR: options.noPR,
951
- // RUNNER-DETERMINISTIC PLACEMENT (task
952
- // `pre-prd-staging-pool-split-and-untrusted-prd-placement`): the
953
- // configured-default + explicit-flag rungs, fed into the SHARED placement
954
- // resolver alongside the `originTrust` stamp above. The resolver decides
955
- // `specs/proposed/` (staging) vs `specs/ready/` (the tasking pool); `intake` never
956
- // places itself.
957
- specsLandIn: options.specsLandIn,
958
- // The UNTRUSTED-side default, selected in `dispatchSpec` when the
959
- // `originTrust` stamp is `untrusted` (ADR
960
- // `untrusted-origin-carries-via-stamp-not-forced-staging`).
961
- untrustedSpecsLandIn: options.untrustedSpecsLandIn,
962
- explicitSpecsLandIn: options.explicitSpecsLandIn,
963
- providerInstance: options.providerInstance,
964
- issueProvider,
965
- seen: seenDelta,
966
- env,
967
- note,
968
- });
1045
+ case 'task': {
1046
+ // A content-derived slug, NEVER a counter (spec `issue-intake` US #8).
1047
+ const slug = resolveSlug(verdict);
1048
+ if (slug === '') {
1049
+ return failed(
1050
+ 'usage-error',
1051
+ `Intake produced a 'task' verdict for issue #${issueNumber} with no usable ` +
1052
+ `slug/title to derive a content-derived slug from (never a counter).`,
1053
+ );
1054
+ }
1055
+ // BOUNDED INTERNAL REVIEW (observation
1056
+ // `intake-lone-task-skips-adversarial-review-the-prd-path-gets`, rulings
1057
+ // A/B/C): AFTER the `task` verdict and BEFORE the write/integrate, run a
1058
+ // bounded (3-round, HARD-CAPPED) adversarial self-review on the SINGLE
1059
+ // drafted task. It mutates the candidate body IN MEMORY. A launch/parse
1060
+ // failure degrades onto `agent-failed` (never a silent emit of the
1061
+ // un-reviewed task).
1062
+ let review: LoneTaskReviewResult;
1063
+ try {
1064
+ review = await runLoneTaskReview({
1065
+ slug,
1066
+ issueNumber,
1067
+ draftTitle: verdict.taskTitle ?? slug,
1068
+ draftBody: verdict.taskBody,
1069
+ gate: resolveLoneTaskReviewGate(options),
1070
+ cwd,
1071
+ // The review AGENT launches AMBIENT (never the identity-scoped env).
1072
+ env: options.env,
1073
+ note,
1074
+ });
1075
+ } catch (err) {
1076
+ const detail = err instanceof Error ? err.message : String(err);
1077
+ return failed(
1078
+ 'agent-failed',
1079
+ `Intake lone-task review failed for issue #${issueNumber}: ${detail}`,
1080
+ );
1081
+ }
1082
+ if (review.outcome === 'non-converge') {
1083
+ // NON-CONVERGE (ruling C): FLIP the verdict TASK→ASK, reusing the EXISTING
1084
+ // `asked` outcome + `kind=ask` marker. The ASK comment carries BOTH the
1085
+ // proposed task DRAFT and the open question(s) in its BODY. NEVER write
1086
+ // the task; the next intake run resumes via the triage gate.
1087
+ note(
1088
+ `Intake's lone-task review did not converge for issue #${issueNumber} ` +
1089
+ `(${review.passes} round(s)); flipping TASK→ASK with the draft + open ` +
1090
+ `question(s) in the comment body.`,
1091
+ );
1092
+ return {
1093
+ ok: true,
1094
+ decision: {
1095
+ kind: 'ask',
1096
+ body: composeLoneTaskAskComment({
1097
+ issueNumber,
1098
+ slug,
1099
+ draftTitle: review.title,
1100
+ draftBody: review.body,
1101
+ questions: review.questions,
1102
+ }),
1103
+ },
1104
+ };
1105
+ }
1106
+ // CONVERGED: the (possibly edited) task is emitted; the refined body
1107
+ // replaces the agent's first draft.
1108
+ return {
1109
+ ok: true,
1110
+ decision: {kind: 'task', slug, title: review.title, body: review.body},
1111
+ };
1112
+ }
1113
+ // The `spec` outcome is the parent-spec verdict. HARD CUTOVER: the legacy
1114
+ // ''prd'' outcome case is GONE.
1115
+ case 'spec': {
1116
+ const slug = resolveSpecSlug(verdict);
1117
+ if (slug === '') {
1118
+ return failed(
1119
+ 'usage-error',
1120
+ `Intake produced a 'spec' verdict for issue #${issueNumber} with no usable ` +
1121
+ `slug/title to derive a content-derived slug from (never a counter).`,
1122
+ );
1123
+ }
1124
+ return {
1125
+ ok: true,
1126
+ decision: {
1127
+ kind: 'spec',
1128
+ slug,
1129
+ title: verdict.specTitle ?? slug,
1130
+ body: verdict.specBody,
1131
+ humanOnly: verdict.specHumanOnly,
1132
+ needsAnswers: verdict.specNeedsAnswers,
1133
+ },
1134
+ };
1135
+ }
1136
+ case 'ask':
1137
+ // The drafted clarifying question; a thin fallback keeps the comment
1138
+ // non-empty if the agent left it blank.
1139
+ return {
1140
+ ok: true,
1141
+ decision: {
1142
+ kind: 'ask',
1143
+ body:
1144
+ verdict.question && verdict.question.trim() !== ''
1145
+ ? verdict.question
1146
+ : `Could you clarify issue #${issueNumber} so it can be acted on?`,
1147
+ },
1148
+ };
1149
+ case 'bounce':
1150
+ // The drafted bounce message; a thin fallback restates the "file separate
1151
+ // issues" ask. A bounce is TERMINAL: the issue is CLOSED atomically.
1152
+ return {
1153
+ ok: true,
1154
+ decision: {
1155
+ kind: 'bounce',
1156
+ body:
1157
+ verdict.bounceMessage && verdict.bounceMessage.trim() !== ''
1158
+ ? verdict.bounceMessage
1159
+ : `This issue looks like multiple unrelated concerns — please file ` +
1160
+ `separate issues so each can be intaken on its own.`,
1161
+ },
1162
+ };
1163
+ default:
1164
+ // Unreachable from the parsed harness verdict (`parseIntakeVerdict` refuses
1165
+ // any other token); an injected decider could still name one (the dead
1166
+ // ''prd'' token). It dispatches NOTHING.
1167
+ return failed(
1168
+ 'agent-failed',
1169
+ `Intake decision failed for issue #${issueNumber}: the verdict outcome ` +
1170
+ `${JSON.stringify((verdict as {outcome: unknown}).outcome)} is not one of ask|task|spec|bounce.`,
1171
+ );
1172
+ }
1173
+ }
1174
+
1175
+ /**
1176
+ * The placement inputs of an intake document: the configured-default rungs
1177
+ * (trusted and untrusted side) and the operator's explicit overrides, per
1178
+ * lifecycle. The agent never influences placement.
1179
+ */
1180
+ export interface IntakePlacementInputs {
1181
+ tasksLandIn?: TasksLandIn;
1182
+ untrustedTasksLandIn?: TasksLandIn;
1183
+ explicitTasksLandIn?: TasksLandIn;
1184
+ specsLandIn?: SpecsLandIn;
1185
+ untrustedSpecsLandIn?: SpecsLandIn;
1186
+ explicitSpecsLandIn?: SpecsLandIn;
1187
+ }
1188
+
1189
+ /** The {@link IntakePlacementInputs} of a run's options. */
1190
+ export function intakePlacementInputs(
1191
+ options: PerformIntakeOptions,
1192
+ ): IntakePlacementInputs {
1193
+ return {
1194
+ tasksLandIn: options.tasksLandIn,
1195
+ untrustedTasksLandIn: options.untrustedTasksLandIn,
1196
+ explicitTasksLandIn: options.explicitTasksLandIn,
1197
+ specsLandIn: options.specsLandIn,
1198
+ untrustedSpecsLandIn: options.untrustedSpecsLandIn,
1199
+ explicitSpecsLandIn: options.explicitSpecsLandIn,
1200
+ };
1201
+ }
1202
+
1203
+ /**
1204
+ * RUNNER-DETERMINISTIC PLACEMENT of an intake document (ADR
1205
+ * `untrusted-origin-carries-via-stamp-not-forced-staging`): the folder the
1206
+ * document is written into. The SAME shared resolver and precedence chain the
1207
+ * tasker uses (`explicit > configured default > built-in staging`); only the
1208
+ * lifecycle SLOTS differ between a task (`tasks/backlog` vs `tasks/ready`) and a
1209
+ * spec (`specs/proposed` vs `specs/ready`). The `originTrust` stamp selects the
1210
+ * untrusted-side configured default (`untrusted*LandIn`) over the trusted one.
1211
+ * Safety for an untrusted document landing in `ready` flows through its carried
1212
+ * stamp at BUILD / tasking time, not the folder.
1213
+ */
1214
+ export function intakePlacementDir(
1215
+ kind: IntakeArtifactType,
1216
+ originTrust: OriginTrust | undefined,
1217
+ placement: IntakePlacementInputs,
1218
+ ): string {
1219
+ const untrusted = originTrust === 'untrusted';
1220
+ if (kind === 'task') {
1221
+ const configured = untrusted
1222
+ ? placement.untrustedTasksLandIn
1223
+ : placement.tasksLandIn;
1224
+ const decision = resolvePlacement({
1225
+ explicit: landingToSide(placement.explicitTasksLandIn),
1226
+ configuredDefault: landingToSide(configured),
1227
+ });
1228
+ return placementFolder(TASK_PLACEMENT_SLOTS, decision.choice);
1229
+ }
1230
+ const configured = untrusted
1231
+ ? placement.untrustedSpecsLandIn
1232
+ : placement.specsLandIn;
1233
+ const decision = resolvePlacement({
1234
+ explicit: specLandingToSide(placement.explicitSpecsLandIn),
1235
+ configuredDefault: specLandingToSide(configured),
1236
+ });
1237
+ return placementFolder(SPEC_PLACEMENT_SLOTS, decision.choice);
1238
+ }
1239
+
1240
+ /** What the WRITE half of intake ({@link applyIntakeDecision}) acts with. */
1241
+ export interface IntakeWriteContext {
1242
+ issueNumber: number;
1243
+ cwd: string;
1244
+ arbiter: string;
1245
+ /** The issue seam the comments / close / completion comment go through. */
1246
+ issueProvider: IssueProvider;
1247
+ /** The per-type document modes. */
1248
+ integration: IntakeIntegrationModes;
1249
+ /** The resolved cross-job CAS-retry cap (undefined ⇒ engine default). */
1250
+ mergeRetries?: number;
1251
+ /** The origin-trust stamp (unset ⇒ emit unstamped ⇒ human/trusted). */
1252
+ originTrust?: OriginTrust;
1253
+ noPR?: boolean;
1254
+ placement: IntakePlacementInputs;
1255
+ providerInstance?: ReviewProvider;
1256
+ /** The `seen=` ids the marker of every posted comment records. */
1257
+ seen: string[];
1258
+ /** The identity-scoped GIT/provider env (push, PR, comments). */
1259
+ env: NodeJS.ProcessEnv | undefined;
1260
+ note: (message: string) => void;
1261
+ /** The merge-mode CAS-loop jitter (tests pass 0). */
1262
+ mergeJitterMs?: number;
1263
+ }
1264
+
1265
+ /**
1266
+ * The WRITE half of intake (task `ci-split-intake`): act on a decision. An ask
1267
+ * posts the question and leaves the issue open; a bounce closes the issue
1268
+ * atomically with the bounce text; a task or spec is rendered (the title
1269
+ * quoted, the frontmatter re-parsed), written onto a `work/intake-<type>-<slug>`
1270
+ * branch and integrated, then the completion comment is posted. A render
1271
+ * failure maps onto `agent-failed` (the input came from the agent) with nothing
1272
+ * written. Runs no agent.
1273
+ */
1274
+ export async function applyIntakeDecision(
1275
+ decision: IntakeDecision,
1276
+ ctx: IntakeWriteContext,
1277
+ ): Promise<IntakeResult> {
1278
+ const {issueNumber, cwd, issueProvider, seen, env, note} = ctx;
1279
+ switch (decision.kind) {
969
1280
  case 'ask':
1281
+ // STAMP the MARKER recording `kind=ask` (non-terminal: the TRIAGE owns
1282
+ // that) + the `seen=` delta, so a re-run recognises this as intake's own
1283
+ // turn and resumes only on genuine new human input.
970
1284
  return dispatchComment({
971
1285
  outcome: 'asked',
972
1286
  cwd,
973
1287
  issueNumber,
974
1288
  issueProvider,
975
- // The drafted clarifying question; a thin fallback keeps the comment
976
- // non-empty if the agent left it blank.
977
- body:
978
- verdict.question && verdict.question.trim() !== ''
979
- ? verdict.question
980
- : `Could you clarify issue #${issueNumber} so it can be acted on?`,
981
- // STAMP the MARKER recording `kind=ask` (non-terminal — the TRIAGE owns
982
- // that) + the `seen=` delta, so a re-run recognises this as intake's own
983
- // turn and resumes only on genuine new human input.
1289
+ body: decision.body,
984
1290
  markerKind: 'ask',
985
- seen: seenDelta,
1291
+ seen,
986
1292
  env,
987
1293
  note,
988
1294
  });
989
1295
  case 'bounce':
1296
+ // STAMP `kind=bounced` (TERMINAL: the TRIAGE then SKIPS
1297
+ // `already-terminal` on a later human comment) + the `seen=` delta.
990
1298
  return dispatchComment({
991
1299
  outcome: 'bounced',
992
1300
  cwd,
993
1301
  issueNumber,
994
1302
  issueProvider,
995
- // The drafted bounce message; a thin fallback restates the "file separate
996
- // issues" ask. A bounce is TERMINAL: the issue is CLOSED atomically (this
997
- // text as the closing comment + reason not planned).
998
- body:
999
- verdict.bounceMessage && verdict.bounceMessage.trim() !== ''
1000
- ? verdict.bounceMessage
1001
- : `This issue looks like multiple unrelated concerns — please file ` +
1002
- `separate issues so each can be intaken on its own.`,
1003
- // STAMP `kind=bounced` (TERMINAL — the TRIAGE then SKIPS `already-terminal`
1004
- // on a later human comment) + the `seen=` delta.
1303
+ body: decision.body,
1005
1304
  markerKind: 'bounced',
1006
- seen: seenDelta,
1305
+ seen,
1007
1306
  env,
1008
1307
  note,
1009
1308
  });
1309
+ case 'task':
1310
+ case 'spec': {
1311
+ // RENDER before any git: the renderer quotes the agent title and re-parses
1312
+ // its own output, THROWING on anything that does not read back as
1313
+ // written. That maps onto `agent-failed` with nothing written and no
1314
+ // branch cut.
1315
+ let content: string;
1316
+ try {
1317
+ content = renderIntakeDocument(decision, {
1318
+ issueNumber,
1319
+ originTrust: ctx.originTrust,
1320
+ });
1321
+ } catch (err) {
1322
+ const detail = err instanceof Error ? err.message : String(err);
1323
+ const message = `Intake could not render the ${decision.kind} for issue #${issueNumber}: ${detail}`;
1324
+ note(message);
1325
+ return {exitCode: 1, outcome: 'agent-failed', issueNumber, message};
1326
+ }
1327
+ return emitIntakeDocument(decision, content, ctx);
1328
+ }
1010
1329
  }
1011
1330
  }
1012
1331
 
1332
+ /**
1333
+ * Render an intake document from a decision and the TRUSTED issue number and
1334
+ * origin-trust stamp ({@link renderBacklogTask} / {@link renderSpec}: the title
1335
+ * quoted, every runner-owned key re-parsed), then assert once more, through
1336
+ * the shared frontmatter reader, that `origin`, `originTrust`, `slug` and
1337
+ * `issue` read back as the trusted values ({@link assertIntakeDocumentStamp}).
1338
+ * THROWS on any disagreement. The CI apply job calls this on the verdict it
1339
+ * received, so the stamp never comes from the agent job.
1340
+ */
1341
+ export function renderIntakeDocument(
1342
+ doc: IntakeDocumentDecision,
1343
+ trusted: {issueNumber: number; originTrust: OriginTrust | undefined},
1344
+ ): string {
1345
+ const {issueNumber, originTrust} = trusted;
1346
+ const rendered =
1347
+ doc.kind === 'task'
1348
+ ? renderBacklogTask({
1349
+ slug: doc.slug,
1350
+ title: doc.title,
1351
+ body: doc.body,
1352
+ issueNumber,
1353
+ originTrust,
1354
+ })
1355
+ : renderSpec({
1356
+ slug: doc.slug,
1357
+ title: doc.title,
1358
+ body: doc.body,
1359
+ issueNumber,
1360
+ humanOnly: doc.humanOnly,
1361
+ needsAnswers: doc.needsAnswers,
1362
+ originTrust,
1363
+ });
1364
+ assertIntakeDocumentStamp(rendered, {
1365
+ slug: doc.slug,
1366
+ issueNumber,
1367
+ originTrust,
1368
+ });
1369
+ return rendered;
1370
+ }
1371
+
1372
+ /**
1373
+ * The stamp check of a rendered intake document: its frontmatter must read back
1374
+ * `slug`, `issue`, `origin` and `originTrust` EXACTLY as the trusted values
1375
+ * (`origin: issue` + `originTrust` when a stamp is given, both absent
1376
+ * otherwise). THROWS a `FrontmatterRenderError` otherwise.
1377
+ */
1378
+ export function assertIntakeDocumentStamp(
1379
+ rendered: string,
1380
+ trusted: {
1381
+ slug: string;
1382
+ issueNumber: number;
1383
+ originTrust: OriginTrust | undefined;
1384
+ },
1385
+ ): void {
1386
+ assertFrontmatterFields(rendered, {
1387
+ slug: trusted.slug,
1388
+ issue: String(trusted.issueNumber),
1389
+ origin: trusted.originTrust === undefined ? undefined : 'issue',
1390
+ originTrust: trusted.originTrust,
1391
+ });
1392
+ }
1393
+
1394
+ /**
1395
+ * Emit a RENDERED intake document: place it (runner-deterministic, from the
1396
+ * trusted stamp and the placement inputs), onboard a `work/intake-<type>-<slug>`
1397
+ * branch cut from the freshly-fetched `<arbiter>/main`, stage exactly the one
1398
+ * document path and integrate it through {@link performIntegration} (`propose`
1399
+ * PR, or `merge` with the CAS loop), then post the completion comment. The
1400
+ * `intake-` producer prefix keeps the branch distinct from a later `do
1401
+ * task:<slug>` build branch for the same slug.
1402
+ */
1403
+ export async function emitIntakeDocument(
1404
+ doc: IntakeDocumentDecision,
1405
+ content: string,
1406
+ ctx: IntakeWriteContext,
1407
+ ): Promise<IntakeResult> {
1408
+ const {issueNumber, cwd, arbiter, issueProvider, seen, env, note} = ctx;
1409
+ const {kind, slug, title} = doc;
1410
+ const relPath = `${intakePlacementDir(kind, ctx.originTrust, ctx.placement)}/${slug}.md`;
1411
+
1412
+ await switchToWorkBranch(cwd, arbiter, kind, slug, env);
1413
+
1414
+ const core = await performIntegration({
1415
+ cwd,
1416
+ arbiter,
1417
+ slug,
1418
+ // `source`/`recovering` are task-shaped and IGNORED when `lifecycle` is set.
1419
+ source: 'in-progress',
1420
+ recovering: false,
1421
+ // An intake-emitted document has no `verify` floor of its own (it is a new
1422
+ // backlog item / spec, not a build); skip the acceptance gate, exactly as
1423
+ // the tasking transition does.
1424
+ skipVerify: true,
1425
+ // The EXPLICITLY-chosen per-type mode proceeds as-is (`merge` IS the
1426
+ // auto-land mode, never downgraded).
1427
+ mode: ctx.integration[kind],
1428
+ // The cross-job merge-serialiser CAS-retry cap (config `mergeRetries`).
1429
+ mergeRetries: ctx.mergeRetries,
1430
+ ...(ctx.mergeJitterMs === undefined
1431
+ ? {}
1432
+ : {mergeJitterMs: ctx.mergeJitterMs}),
1433
+ noPR: ctx.noPR,
1434
+ providerInstance: ctx.providerInstance,
1435
+ type: 'feat',
1436
+ // A short PR body so an intake PR is never blank (`gh --fill`): it names the
1437
+ // source issue WITHOUT a closing keyword (the issue must outlive the merge).
1438
+ body: composeIntakeProposeBody({kind, issueNumber}),
1439
+ lifecycle: {
1440
+ // The document IS the title source. Pass the DRAFTED title EXPLICITLY:
1441
+ // `stage()` writes the file AFTER the core reads the title, so a
1442
+ // `titlePath` read would race the write. `titlePath` stays set (the
1443
+ // lifecycle contract requires it) but is IGNORED while `title` is present.
1444
+ titlePath: join(cwd, relPath),
1445
+ title,
1446
+ commitTag: 'intake',
1447
+ // The document RESTS at its placed path (staged or pool), so point the
1448
+ // reviewer at THAT file, never the task-build `work/tasks/done/` default.
1449
+ proposeHeader: proposePointer(kind === 'task' ? 'Task' : 'Spec', relPath),
1450
+ stage: () => stageIntakeContent({cwd, relPath, content, env}),
1451
+ },
1452
+ env,
1453
+ note,
1454
+ });
1455
+
1456
+ return integrationToIntakeResult(core, {
1457
+ issueNumber,
1458
+ slug,
1459
+ relPath,
1460
+ kind,
1461
+ cwd,
1462
+ issueProvider,
1463
+ seen,
1464
+ env,
1465
+ note,
1466
+ });
1467
+ }
1468
+
1469
+ /**
1470
+ * The propose PR prose of an intake emit (task
1471
+ * `propose-pr-bodies-point-at-the-real-item`); the shared core puts the
1472
+ * document pointer above it. It names the source issue as a plain `#<n>`
1473
+ * reference and deliberately uses NO closing keyword (`closes`, `fixes`,
1474
+ * `resolves`, ...): merging an intake PR only files the task or spec, and the
1475
+ * issue stays open until that work is done (the close job owns closing it).
1476
+ */
1477
+ export function composeIntakeProposeBody(params: {
1478
+ kind: IntakeArtifactType;
1479
+ issueNumber: number;
1480
+ }): string {
1481
+ const {kind, issueNumber} = params;
1482
+ return (
1483
+ `Intake drafted this ${kind} from issue #${issueNumber}. Merging this PR ` +
1484
+ `files the ${kind}; it leaves the issue open.`
1485
+ );
1486
+ }
1487
+
1013
1488
  /**
1014
1489
  * DISPATCH the `ask` / `bounce` outcomes — the SHARED comment band, which now
1015
1490
  * BRANCHES on the outcome:
@@ -1111,400 +1586,6 @@ async function dispatchComment(params: {
1111
1586
  };
1112
1587
  }
1113
1588
 
1114
- /**
1115
- * DISPATCH the `task` outcome: derive a content-derived slug, write
1116
- * `work/backlog/<slug>.md` (`covers: []`, NO `spec:`) carrying `issue: N` (the
1117
- * lone-task closure link, NOT `Fixes #N`), and integrate via {@link
1118
- * performIntegration}. The runner owns the git: it onboards a
1119
- * `work/<slug>` branch off fresh `<arbiter>/main`, then the lifecycle `stage`
1120
- * writes + stages the task and the band commits + rebases + integrates it. The
1121
- * agent did NO git/seam ops.
1122
- */
1123
- async function dispatchTask(params: {
1124
- verdict: IntakeVerdict;
1125
- issueNumber: number;
1126
- cwd: string;
1127
- arbiter: string;
1128
- integration: IntegrationMode;
1129
- /** The resolved cross-job CAS-retry cap (undefined ⇒ engine default). */
1130
- mergeRetries: number | undefined;
1131
- /** The origin-trust stamp passed IN (unset ⇒ emit unstamped ⇒ human/trusted). */
1132
- originTrust: OriginTrust | undefined;
1133
- noPR: boolean | undefined;
1134
- /** The per-repo TRUSTED-side TASK-PLACEMENT default (configured-default rung when the task is trusted/unset). */
1135
- tasksLandIn: TasksLandIn | undefined;
1136
- /**
1137
- * The per-repo UNTRUSTED-side TASK-PLACEMENT default, selected as the
1138
- * configured-default rung when `originTrust` is `untrusted` (ADR
1139
- * `untrusted-origin-carries-via-stamp-not-forced-staging`).
1140
- */
1141
- untrustedTasksLandIn: TasksLandIn | undefined;
1142
- /** The OPERATOR's EXPLICIT task-placement override (the TOP rung). */
1143
- explicitTasksLandIn: TasksLandIn | undefined;
1144
- providerInstance: ReviewProvider | undefined;
1145
- /** The issue seam the completion comment is posted back through (runner-owned). */
1146
- issueProvider: IssueProvider;
1147
- /** The bounded lone-task review seam (tests inject a canned verdict; prod: harness). */
1148
- reviewTask: LoneTaskReviewGate;
1149
- /** The per-run `seen=` delta of HUMAN comment ids the completion marker records. */
1150
- seen: string[];
1151
- /** The identity-scoped GIT/provider env (push, PR, completion comment). */
1152
- env: NodeJS.ProcessEnv | undefined;
1153
- /** The AMBIENT env for the lone-task review AGENT launch (never the identity). */
1154
- agentEnv: NodeJS.ProcessEnv | undefined;
1155
- note: (message: string) => void;
1156
- }): Promise<IntakeResult> {
1157
- const {
1158
- verdict,
1159
- issueNumber,
1160
- cwd,
1161
- arbiter,
1162
- integration,
1163
- mergeRetries,
1164
- originTrust,
1165
- noPR,
1166
- tasksLandIn,
1167
- untrustedTasksLandIn,
1168
- explicitTasksLandIn,
1169
- providerInstance,
1170
- issueProvider,
1171
- reviewTask,
1172
- seen,
1173
- env,
1174
- agentEnv,
1175
- note,
1176
- } = params;
1177
-
1178
- // A content-derived slug — NEVER a counter (spec `issue-intake` US #8). Prefer the drafted
1179
- // `taskSlug`, else derive from the drafted title; sanitise either through
1180
- // `paramCase` so the filename + frontmatter slug are well-formed.
1181
- const slug = resolveSlug(verdict);
1182
- if (slug === '') {
1183
- const message =
1184
- `Intake produced a 'task' verdict for issue #${issueNumber} with no usable ` +
1185
- `slug/title to derive a content-derived slug from (never a counter).`;
1186
- note(message);
1187
- return {exitCode: 1, outcome: 'usage-error', issueNumber, message};
1188
- }
1189
- // RUNNER-DETERMINISTIC PLACEMENT (task `intake-task-placement-symmetry`; the
1190
- // untrusted-forces-staging rung RETIRED by ADR
1191
- // `untrusted-origin-carries-via-stamp-not-forced-staging`). Resolve which
1192
- // folder the runner writes the intake-authored task into BEFORE handing it to
1193
- // the shared integrate band — the SAME precedence chain, the SAME shared
1194
- // resolver, and (via `TASK_PLACEMENT_SLOTS` + `landingToSide` reused from
1195
- // `tasking.ts`) the SAME task slots the tasker uses; only the intake dispatch
1196
- // differs from the spec twin (`dispatchSpec`) in its lifecycle SLOTS. THIS
1197
- // caller selects the trusted-vs-untrusted configured default by reading the
1198
- // `originTrust` stamp: an untrusted task selects `untrustedTasksLandIn`
1199
- // (default staging; `ready` when configured), a trusted/unset task selects
1200
- // `tasksLandIn`. The agent (the intake decider) never influences placement.
1201
- // Net vs the old hardcoded `tasks-ready`: an untrusted-author task now MERGES
1202
- // a document to `main` in `backlog` (by default) carrying the stamp instead of
1203
- // opening a document PR; safety for a `ready`-landed untrusted task flows
1204
- // through its carried stamp at BUILD time (forces a code PR), not the folder.
1205
- const configuredTasksLanding =
1206
- originTrust === 'untrusted' ? untrustedTasksLandIn : tasksLandIn;
1207
- const placementDecision = resolvePlacement({
1208
- explicit: landingToSide(explicitTasksLandIn),
1209
- configuredDefault: landingToSide(configuredTasksLanding),
1210
- });
1211
- const placementDir = placementFolder(
1212
- TASK_PLACEMENT_SLOTS,
1213
- placementDecision.choice,
1214
- );
1215
- const relPath = `${placementDir}/${slug}.md`;
1216
-
1217
- // BOUNDED INTERNAL REVIEW (observation
1218
- // `intake-lone-task-skips-adversarial-review-the-prd-path-gets`, rulings A/B/C):
1219
- // the `do spec:` path gets `runTaskReviewLoop`; the lone-TASK path got NOTHING.
1220
- // AFTER the `task` verdict and BEFORE the write/integrate, run a bounded (3-round,
1221
- // HARD-CAPPED) adversarial self-review on the SINGLE drafted task. It mutates the
1222
- // candidate body IN MEMORY (no `work/backlog/` write pre-convergence). A launch/
1223
- // parse failure THROWS — `decideAndDispatch`'s try/catch maps it onto `agent-failed`
1224
- // (never a silent emit of the un-reviewed task).
1225
- let review: LoneTaskReviewResult;
1226
- try {
1227
- review = await runLoneTaskReview({
1228
- slug,
1229
- issueNumber,
1230
- draftTitle: verdict.taskTitle ?? slug,
1231
- draftBody: verdict.taskBody,
1232
- gate: reviewTask,
1233
- cwd,
1234
- // The review AGENT launches AMBIENT (never the identity-scoped env).
1235
- env: agentEnv,
1236
- note,
1237
- });
1238
- } catch (err) {
1239
- // A review-agent launch/parse FAILURE DEGRADES honestly onto the EXISTING
1240
- // `agent-failed` outcome (exit 1) — NEVER a silent emit of the un-reviewed
1241
- // task. The SAME try/catch discipline the decision step uses.
1242
- const detail = err instanceof Error ? err.message : String(err);
1243
- const message = `Intake lone-task review failed for issue #${issueNumber}: ${detail}`;
1244
- note(message);
1245
- return {exitCode: 1, outcome: 'agent-failed', issueNumber, message};
1246
- }
1247
-
1248
- if (review.outcome === 'non-converge') {
1249
- // NON-CONVERGE (ruling C): FLIP the verdict TASK→ASK, reusing the EXISTING
1250
- // `asked` outcome + `kind=ask` marker. The ASK comment carries BOTH the proposed
1251
- // task DRAFT and the open question(s) in its BODY (NOT a new marker kind) — the
1252
- // human reacts to a concrete draft, strictly richer than a blank-question ask.
1253
- // NEVER write `work/backlog/<slug>.md`; NEVER silently emit the under-refined
1254
- // task. The next intake run resumes via the already-built triage gate.
1255
- note(
1256
- `Intake's lone-task review did not converge for issue #${issueNumber} ` +
1257
- `(${review.passes} round(s)); flipping TASK→ASK with the draft + open ` +
1258
- `question(s) in the comment body.`,
1259
- );
1260
- return dispatchComment({
1261
- outcome: 'asked',
1262
- cwd,
1263
- issueNumber,
1264
- issueProvider,
1265
- body: composeLoneTaskAskComment({
1266
- issueNumber,
1267
- slug,
1268
- draftTitle: review.title,
1269
- draftBody: review.body,
1270
- questions: review.questions,
1271
- }),
1272
- markerKind: 'ask',
1273
- seen,
1274
- env,
1275
- note,
1276
- });
1277
- }
1278
-
1279
- // CONVERGED: the (possibly edited) task is emitted via the EXISTING write/integrate
1280
- // path below + the existing `task created` completion comment. The refined body
1281
- // replaces the agent's first draft.
1282
- const reviewedBody = review.body;
1283
-
1284
- // ONBOARD the task write onto a `work/intake-task-<slug>` branch cut from the
1285
- // freshly-fetched `<arbiter>/main` (the SAME runner-owns-git discipline the
1286
- // tasking path uses): the lifecycle `stage` writes the file ON THIS BRANCH and
1287
- // the shared integrate core (`--propose` PR / `--merge` main) lands it. The
1288
- // intake- producer prefix keeps it distinct from a later `do task:<slug>`
1289
- // build branch for the same slug. The agent ran no git.
1290
- await switchToWorkBranch(cwd, arbiter, 'task', slug, env);
1291
-
1292
- const taskContent = renderBacklogTask({
1293
- slug,
1294
- title: review.title,
1295
- body: reviewedBody,
1296
- issueNumber,
1297
- originTrust,
1298
- });
1299
-
1300
- const core = await performIntegration({
1301
- cwd,
1302
- arbiter,
1303
- slug,
1304
- // `source`/`recovering` are task-shaped and IGNORED when `lifecycle` is set.
1305
- source: 'in-progress',
1306
- recovering: false,
1307
- // An intake-emitted task has no `verify` floor of its own (it is a new
1308
- // backlog item, not a build); skip the acceptance gate, exactly as the
1309
- // tasking transition does.
1310
- skipVerify: true,
1311
- // Default `propose` (the per-outcome KNOBS are a later task). The
1312
- // EXPLICITLY-chosen mode proceeds as-is: a future `--merge-task` lands on main
1313
- // (`merge` IS the auto-land mode, never downgraded).
1314
- mode: integration,
1315
- // The cross-job merge-serialiser CAS-retry cap (config `mergeRetries`) — the
1316
- // git-alone floor of the cross-job land queue, resolved through the gate-
1317
- // family precedence chain in the CLI and threaded here so intake's lone-task
1318
- // emit lands under the same per-repo cap the build path uses. Unset ⇒ the
1319
- // engine default (byte-for-byte unchanged).
1320
- mergeRetries,
1321
- noPR,
1322
- providerInstance,
1323
- type: 'feat',
1324
- lifecycle: {
1325
- // The emitted task IS the title source. Pass the DRAFTED title EXPLICITLY
1326
- // (not a read-from-path): `stage()` WRITES `work/backlog/<slug>.md` AFTER the
1327
- // core reads the title, so a `titlePath` read would race the write and degrade
1328
- // the commit subject / PR title to the generic fallback. `titlePath` stays set
1329
- // (the lifecycle contract requires it) but is IGNORED while `title` is present.
1330
- titlePath: join(cwd, relPath),
1331
- title: review.title,
1332
- commitTag: 'intake',
1333
- stage: () =>
1334
- stageIntakeContent({cwd, relPath, content: taskContent, env}),
1335
- },
1336
- env,
1337
- note,
1338
- });
1339
-
1340
- return integrationToIntakeResult(core, {
1341
- issueNumber,
1342
- slug,
1343
- relPath,
1344
- cwd,
1345
- issueProvider,
1346
- seen,
1347
- env,
1348
- note,
1349
- });
1350
- }
1351
-
1352
- /**
1353
- * DISPATCH the `spec` outcome (the SOLE parent-spec outcome after the HARD CUTOVER;
1354
- * the legacy ''prd'' outcome token is GONE): derive a content-derived slug, write the spec file
1355
- * (`work/specs/ready/<slug>.md`) carrying `issue: N` (the loop-closure linkage the
1356
- * close JOB reaches via `task.spec: → spec issue:`; on a fanned spec the number
1357
- * lives ONLY on the spec — a fanned task uses `spec:`, NOT its own `issue:`, which
1358
- * is the lone-task outcome's link) + the gate axes the prompt JUDGED, integrate it
1359
- * via {@link performIntegration}, then STOP. Tasking the emitted spec is the
1360
- * SEPARATE `do spec:` step (NOT done here). A coupled-but-SMALL pair lands here too
1361
- * (the spec vs BOUNCE line is SHARED VISION, not size — the over-bounce guard). The
1362
- * runner owns the git exactly as the task branch does; the agent did NO git/seam ops.
1363
- */
1364
- async function dispatchSpec(params: {
1365
- verdict: IntakeVerdict;
1366
- issueNumber: number;
1367
- cwd: string;
1368
- arbiter: string;
1369
- integration: IntegrationMode;
1370
- /** The resolved cross-job CAS-retry cap (undefined ⇒ engine default). */
1371
- mergeRetries: number | undefined;
1372
- /** The origin-trust stamp passed IN (unset ⇒ emit unstamped ⇒ human/trusted). */
1373
- originTrust: OriginTrust | undefined;
1374
- noPR: boolean | undefined;
1375
- /** The per-repo TRUSTED-side SPEC-PLACEMENT default (configured-default rung when the spec is trusted/unset). */
1376
- specsLandIn: SpecsLandIn | undefined;
1377
- /**
1378
- * The per-repo UNTRUSTED-side SPEC-PLACEMENT default, selected as the
1379
- * configured-default rung when `originTrust` is `untrusted` (ADR
1380
- * `untrusted-origin-carries-via-stamp-not-forced-staging`).
1381
- */
1382
- untrustedSpecsLandIn: SpecsLandIn | undefined;
1383
- /** The OPERATOR's EXPLICIT spec-placement override (the TOP rung). */
1384
- explicitSpecsLandIn: SpecsLandIn | undefined;
1385
- providerInstance: ReviewProvider | undefined;
1386
- /** The issue seam the completion comment is posted back through (runner-owned). */
1387
- issueProvider: IssueProvider;
1388
- /** The per-run `seen=` delta of HUMAN comment ids the completion marker records. */
1389
- seen: string[];
1390
- env: NodeJS.ProcessEnv | undefined;
1391
- note: (message: string) => void;
1392
- }): Promise<IntakeResult> {
1393
- const {
1394
- verdict,
1395
- issueNumber,
1396
- cwd,
1397
- arbiter,
1398
- integration,
1399
- mergeRetries,
1400
- originTrust,
1401
- noPR,
1402
- specsLandIn,
1403
- untrustedSpecsLandIn,
1404
- explicitSpecsLandIn,
1405
- providerInstance,
1406
- issueProvider,
1407
- seen,
1408
- env,
1409
- note,
1410
- } = params;
1411
-
1412
- // A content-derived slug — NEVER a counter (spec `issue-intake` US #8). Prefer the drafted
1413
- // `specSlug`, else derive from the drafted title.
1414
- const slug = resolveSpecSlug(verdict);
1415
- if (slug === '') {
1416
- const message =
1417
- `Intake produced a 'spec' verdict for issue #${issueNumber} with no usable ` +
1418
- `slug/title to derive a content-derived slug from (never a counter).`;
1419
- note(message);
1420
- return {exitCode: 1, outcome: 'usage-error', issueNumber, message};
1421
- }
1422
- // RUNNER-DETERMINISTIC PLACEMENT (task
1423
- // `pre-prd-staging-pool-split-and-untrusted-prd-placement`; the
1424
- // untrusted-forces-staging rung RETIRED by ADR
1425
- // `untrusted-origin-carries-via-stamp-not-forced-staging`). Resolve which
1426
- // folder the runner writes the intake-authored spec into BEFORE handing it to
1427
- // the shared integrate band: the SAME precedence chain the tasker uses
1428
- // (`explicit > configured default > built-in (staging)`), the SAME shared
1429
- // resolver — only the lifecycle SLOTS differ. THIS caller selects the
1430
- // trusted-vs-untrusted configured default by reading the `originTrust` stamp:
1431
- // an untrusted spec selects `untrustedSpecsLandIn` (default staging; `ready`
1432
- // when configured), a trusted/unset spec selects `specsLandIn`. The agent
1433
- // (the intake decider) never influences placement; it returns the verdict and
1434
- // the runner computes the destination from unforgeable inputs. Safety for an
1435
- // untrusted spec landing in `ready` flows through its tasks' carried stamp,
1436
- // not the folder.
1437
- const configuredSpecsLanding =
1438
- originTrust === 'untrusted' ? untrustedSpecsLandIn : specsLandIn;
1439
- const placementDecision = resolvePlacement({
1440
- explicit: specLandingToSide(explicitSpecsLandIn),
1441
- configuredDefault: specLandingToSide(configuredSpecsLanding),
1442
- });
1443
- const placementDir = placementFolder(
1444
- SPEC_PLACEMENT_SLOTS,
1445
- placementDecision.choice,
1446
- );
1447
- const relPath = `${placementDir}/${slug}.md`;
1448
-
1449
- // ONBOARD onto a `work/intake-spec-<slug>` branch off fresh `<arbiter>/main` —
1450
- // the SAME runner-owns-git discipline the task branch uses; the intake-
1451
- // producer prefix keeps it distinct from a `do spec:<slug>` tasking branch.
1452
- await switchToWorkBranch(cwd, arbiter, 'spec', slug, env);
1453
-
1454
- const specContent = renderSpec({
1455
- slug,
1456
- title: verdict.specTitle ?? slug,
1457
- body: verdict.specBody,
1458
- issueNumber,
1459
- humanOnly: verdict.specHumanOnly,
1460
- needsAnswers: verdict.specNeedsAnswers,
1461
- originTrust,
1462
- });
1463
-
1464
- const core = await performIntegration({
1465
- cwd,
1466
- arbiter,
1467
- slug,
1468
- source: 'in-progress',
1469
- recovering: false,
1470
- // An intake-emitted spec has no `verify` floor of its own (it is a new spec,
1471
- // not a build), exactly as the task branch + the tasking transition skip it.
1472
- skipVerify: true,
1473
- mode: integration,
1474
- // The cross-job merge-serialiser CAS-retry cap (config `mergeRetries`) — same
1475
- // rationale as the lone-task emit above; threaded so intake's spec emit lands
1476
- // under the same per-repo cap. Unset ⇒ the engine default (byte-for-byte).
1477
- mergeRetries,
1478
- noPR,
1479
- providerInstance,
1480
- type: 'feat',
1481
- lifecycle: {
1482
- // The emitted spec IS the title source. Pass the DRAFTED title EXPLICITLY (same
1483
- // race as the task path: `stage()` writes the spec file AFTER the title
1484
- // read). `titlePath` stays set but is IGNORED while `title` is present.
1485
- titlePath: join(cwd, relPath),
1486
- title: verdict.specTitle ?? slug,
1487
- commitTag: 'intake',
1488
- stage: () =>
1489
- stageIntakeContent({cwd, relPath, content: specContent, env}),
1490
- },
1491
- env,
1492
- note,
1493
- });
1494
-
1495
- return integrationToIntakeResult(core, {
1496
- issueNumber,
1497
- slug,
1498
- relPath,
1499
- kind: 'spec',
1500
- cwd,
1501
- issueProvider,
1502
- seen,
1503
- env,
1504
- note,
1505
- });
1506
- }
1507
-
1508
1589
  /**
1509
1590
  * Map the shared integrate band's {@link IntegrationCoreResult} onto the intake
1510
1591
  * {@link IntakeResult}. On `completed` the artifact was written + integrated; a
@@ -1534,10 +1615,19 @@ async function integrationToIntakeResult(
1534
1615
  const kind = ctx.kind ?? 'task';
1535
1616
  const artifact = kind === 'spec' ? 'spec' : 'task';
1536
1617
  if (core.outcome === 'completed') {
1618
+ // HONEST propose report (task `intake-reports-the-pr-it-actually-opened`):
1619
+ // say "opened a PR" ONLY when the provider opened one. A degraded `gh pr
1620
+ // create` (e.g. a repository that refuses PR creation by Actions) pushed the
1621
+ // branch but opened nothing; the shared core already printed the provider's
1622
+ // instruction, and the message names the branch the work is on.
1623
+ const notOpened = proposeRequestNotOpened(core.integration);
1537
1624
  const landed =
1538
1625
  core.integration?.mode === 'merge'
1539
1626
  ? 'landed it on the arbiter main'
1540
- : 'opened a PR carrying it (main untouched)';
1627
+ : notOpened !== undefined
1628
+ ? `pushed it on branch ${core.integration?.pushedRef ?? core.branch} ` +
1629
+ 'but opened NO PR (main untouched; see the provider note above)'
1630
+ : 'opened a PR carrying it (main untouched)';
1541
1631
  // Both a lone task and a spec carry `issue: N` as their closure link (the task
1542
1632
  // closes its own issue; a spec is reached via `task.spec: → spec issue:`). On the
1543
1633
  // task/spec path `intake` never closes the issue (CI's close-job does, via the
@@ -1607,10 +1697,14 @@ async function integrationToIntakeResult(
1607
1697
  * - reports `task created` / `spec created` framed as CREATED — NEVER "issue
1608
1698
  * resolved/closed" (intake never closes on the task/spec path).
1609
1699
  * - LINKS the artifact by INTEGRATION MODE: the PR `url` in propose, the landed
1610
- * `commit` (the additive {@link IntegrateResult.commit}) in merge. A degraded
1611
- * propose (no `url`) or a failed merge-tip read (no `commit`) simply OMITS the
1700
+ * `commit` (the additive {@link IntegrateResult.commit}) in merge. An opened PR
1701
+ * whose `url` was unparseable or a failed merge-tip read (no `commit`) simply OMITS the
1612
1702
  * link — the comment still confirms what was created (the artifact is safe on the
1613
1703
  * branch/main regardless). No spec link beyond the slug.
1704
+ * - says so when a propose land opened NO PR at all (the provider degraded, e.g. a
1705
+ * repository that refuses PR creation by Actions, or a push-only provider): it
1706
+ * names the pushed branch and quotes the provider's reason verbatim, instead of
1707
+ * reading as if a PR carried the artifact.
1614
1708
  * - carries the FULL intake MARKER via the SHARED {@link stampIntakeMarker} helper
1615
1709
  * (`kind=created slug=<slug> seen=<id>,…`) so the triage's `already-terminal`
1616
1710
  * branch consumes it — the comment cannot re-trigger intake.
@@ -1623,14 +1717,22 @@ export function composeIntakeCompletionComment(params: {
1623
1717
  }): string {
1624
1718
  const {kind, slug, integration, seen} = params;
1625
1719
  const artifact = kind === 'spec' ? 'spec' : 'task';
1720
+ // A propose land that opened NO PR (the provider degraded, or push-only) says
1721
+ // so, names the pushed branch and quotes the provider's reason, instead of
1722
+ // implying a PR (task `intake-reports-the-pr-it-actually-opened`).
1723
+ const notOpened = proposeRequestNotOpened(integration);
1626
1724
  const link =
1627
1725
  integration?.mode === 'merge'
1628
1726
  ? integration.commit !== undefined
1629
1727
  ? `\n\nIt landed on \`main\` in commit ${integration.commit}.`
1630
1728
  : ''
1631
- : integration?.url !== undefined
1632
- ? `\n\nIt is carried by the PR: ${integration.url}`
1633
- : '';
1729
+ : notOpened !== undefined
1730
+ ? `\n\nIt is pushed on branch \`${integration!.pushedRef}\`, but **no PR ` +
1731
+ `could be opened**, so nothing proposes it for review yet. The ` +
1732
+ `reason reported:\n\n${fenceVerbatim(notOpened)}`
1733
+ : integration?.url !== undefined
1734
+ ? `\n\nIt is carried by the PR: ${integration.url}`
1735
+ : '';
1634
1736
  const body =
1635
1737
  `Created ${artifact} \`${slug}\` from this issue.${link}\n\n` +
1636
1738
  `This is an informational update — the issue stays open (it remains in play ` +
@@ -1640,6 +1742,21 @@ export function composeIntakeCompletionComment(params: {
1640
1742
  return stampIntakeMarker(body, {kind: 'created', seen, slug});
1641
1743
  }
1642
1744
 
1745
+ /**
1746
+ * Wrap `text` in a fenced code block that renders it VERBATIM: the fence is one
1747
+ * backtick longer than the longest backtick run inside `text` (minimum three), so
1748
+ * a provider instruction carrying inline code or a quoted PR body can never close
1749
+ * the fence early.
1750
+ */
1751
+ function fenceVerbatim(text: string): string {
1752
+ const longest = Math.max(
1753
+ 0,
1754
+ ...Array.from(text.matchAll(/`+/g), (m) => m[0].length),
1755
+ );
1756
+ const fence = '`'.repeat(Math.max(3, longest + 1));
1757
+ return `${fence}text\n${text}\n${fence}`;
1758
+ }
1759
+
1643
1760
  /**
1644
1761
  * Post the INFORMATIONAL completion comment for a SUCCESSFUL `task` / `spec`
1645
1762
  * outcome (this task). It closes the loop the ASK/BOUNCE comments already close
@@ -1743,6 +1860,33 @@ function resolveSpecSlug(verdict: IntakeVerdict): string {
1743
1860
  return ensureSafeSlug(paramCase(candidate));
1744
1861
  }
1745
1862
 
1863
+ /**
1864
+ * Refuse an intake verdict whose drafted title cannot be one frontmatter line
1865
+ * (task `intake-frontmatter-title-injection-strips-origin-stamp`). Only the
1866
+ * title the verdict's OUTCOME will render is checked (`taskTitle` for `task`,
1867
+ * `specTitle` for `spec`): it must be one line with no control character and
1868
+ * not blank. An ABSENT title stays legal (the dispatcher falls back to the
1869
+ * slug). THROWS; the caller maps the throw onto `agent-failed`.
1870
+ */
1871
+ export function assertIntakeVerdictTitle(verdict: IntakeVerdict): void {
1872
+ const [field, title] =
1873
+ verdict.outcome === 'task'
1874
+ ? ['taskTitle', verdict.taskTitle]
1875
+ : verdict.outcome === 'spec'
1876
+ ? ['specTitle', verdict.specTitle]
1877
+ : [undefined, undefined];
1878
+ if (field === undefined || title === undefined) {
1879
+ return;
1880
+ }
1881
+ const problem = singleLineScalarProblem(title);
1882
+ if (problem !== undefined) {
1883
+ throw new Error(`intake verdict '${field}' ${problem}.`);
1884
+ }
1885
+ if (title.trim() === '') {
1886
+ throw new Error(`intake verdict '${field}' is blank.`);
1887
+ }
1888
+ }
1889
+
1746
1890
  /**
1747
1891
  * Render the backlog task file: the frontmatter (`title`/`slug`/`covers: []`, NO
1748
1892
  * `spec:` — its own source of truth, spec `issue-intake` decision table) carrying the lone-task
@@ -1768,9 +1912,13 @@ export function renderBacklogTask(params: {
1768
1912
  originTrust?: OriginTrust;
1769
1913
  }): string {
1770
1914
  const {slug, title, body, issueNumber, originTrust} = params;
1915
+ // The TITLE is agent-drafted (issue text any user can write): it goes through
1916
+ // the shared quoter, which THROWS on a multi-line / control-character value
1917
+ // (the verdict boundary already refused one; this is the backstop), so it can
1918
+ // never close the fence early and demote the origin-trust stamp below it.
1771
1919
  const lines = [
1772
1920
  '---',
1773
- `title: ${title}`,
1921
+ `title: ${quoteYamlScalar(title)}`,
1774
1922
  `slug: ${slug}`,
1775
1923
  `issue: ${issueNumber}`,
1776
1924
  ];
@@ -1796,7 +1944,23 @@ export function renderBacklogTask(params: {
1796
1944
  acceptanceCriteria: '- [ ] the issue is resolved',
1797
1945
  prompt: `Resolve issue #${issueNumber}: ${title}`,
1798
1946
  }).trimEnd();
1799
- return `${frontmatter}\n\n${drafted}\n`;
1947
+ const rendered = `${frontmatter}\n\n${drafted}\n`;
1948
+ // Re-parse what we rendered: every key is runner-owned, so the frontmatter
1949
+ // must read back EXACTLY these keys and values, else fail loudly.
1950
+ assertFrontmatterFields(
1951
+ rendered,
1952
+ {
1953
+ title,
1954
+ slug,
1955
+ issue: String(issueNumber),
1956
+ origin: originTrust === undefined ? undefined : 'issue',
1957
+ originTrust,
1958
+ covers: '[]',
1959
+ blockedBy: '[]',
1960
+ },
1961
+ {exact: true},
1962
+ );
1963
+ return rendered;
1800
1964
  }
1801
1965
 
1802
1966
  /**
@@ -1828,9 +1992,11 @@ export function renderSpec(params: {
1828
1992
  }): string {
1829
1993
  const {slug, title, body, issueNumber, humanOnly, needsAnswers, originTrust} =
1830
1994
  params;
1995
+ // The agent-drafted TITLE goes through the shared quoter (see
1996
+ // `renderBacklogTask`).
1831
1997
  const lines = [
1832
1998
  '---',
1833
- `title: ${title}`,
1999
+ `title: ${quoteYamlScalar(title)}`,
1834
2000
  `slug: ${slug}`,
1835
2001
  `issue: ${issueNumber}`,
1836
2002
  ];
@@ -1865,7 +2031,22 @@ export function renderSpec(params: {
1865
2031
  solution: '(to be detailed; this spec needs tasking via `do spec:`).',
1866
2032
  userStories: `1. As a user, I want issue #${issueNumber} addressed.`,
1867
2033
  }).trimEnd();
1868
- return `${frontmatter}\n\n${drafted}\n`;
2034
+ const rendered = `${frontmatter}\n\n${drafted}\n`;
2035
+ // Re-parse what we rendered (see `renderBacklogTask`).
2036
+ assertFrontmatterFields(
2037
+ rendered,
2038
+ {
2039
+ title,
2040
+ slug,
2041
+ issue: String(issueNumber),
2042
+ origin: originTrust === undefined ? undefined : 'issue',
2043
+ originTrust,
2044
+ humanOnly: humanOnly === true ? 'true' : undefined,
2045
+ needsAnswers: needsAnswers === true ? 'true' : undefined,
2046
+ },
2047
+ {exact: true},
2048
+ );
2049
+ return rendered;
1869
2050
  }
1870
2051
 
1871
2052
  /**