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/dist/intake.js CHANGED
@@ -4,8 +4,10 @@ import { runAsync } from './git.js';
4
4
  import { workFolderRel, workFolderPrefix } from './work-layout.js';
5
5
  import { paramCase } from './brand.js';
6
6
  import { ensureSafeSlug } from './slug-safety.js';
7
- import { performIntegration, } from './integration-core.js';
7
+ import { performIntegration, proposePointer, } from './integration-core.js';
8
+ import { proposeRequestNotOpened, } from './integrator.js';
8
9
  import { integrationFromFlags } from './complete.js';
10
+ import { assertFrontmatterFields, quoteYamlScalar, singleLineScalarProblem, } from './frontmatter.js';
9
11
  import { TASK_PLACEMENT_SLOTS, landingToSide } from './tasking.js';
10
12
  import { placementFolder, resolvePlacement, } from './placement.js';
11
13
  import { identityEnv, assertTransportAllowed, } from './identity.js';
@@ -154,20 +156,15 @@ export async function performIntake(options) {
154
156
  // silently pushing under an ambient credential. Resolve the arbiter URL softly
155
157
  // (a non-zero/unknown URL is skipped — the guard is a no-op without an identity
156
158
  // or a resolvable URL).
157
- if (options.identity !== undefined) {
158
- const urlRes = await runAsync('git', ['remote', 'get-url', arbiter], cwd, {
159
- env,
160
- });
161
- if (urlRes.status === 0) {
162
- try {
163
- assertTransportAllowed(options.identity, urlRes.stdout.trim());
164
- }
165
- catch (err) {
166
- const message = err instanceof Error ? err.message : String(err);
167
- note(message);
168
- return { exitCode: 1, outcome: 'usage-error', issueNumber, message };
169
- }
170
- }
159
+ const transportProblem = await intakeTransportProblem(options.identity, cwd, arbiter, env);
160
+ if (transportProblem !== undefined) {
161
+ note(transportProblem);
162
+ return {
163
+ exitCode: 1,
164
+ outcome: 'usage-error',
165
+ issueNumber,
166
+ message: transportProblem,
167
+ };
171
168
  }
172
169
  // 1. READ the issue + thread via the seam (the core never imports `gh`; only the
173
170
  // adapter shells out). A read failure surfaces as a usage error — `intake`
@@ -350,14 +347,16 @@ function createLockReleaser(params) {
350
347
  * Split out so the lock release is a clean `try`/`finally` in the caller (the lock
351
348
  * MUST release on every terminal path — success or handled failure). The agent
352
349
  * DRAFTS only; the runner owns every git/seam side-effect here.
350
+ *
351
+ * It composes the SAME two halves the CI phases run in two jobs (task
352
+ * `ci-split-intake`, `ci-phase-intake.ts`): {@link decideIntake} (the agent half:
353
+ * the decision agent and the lone-task review, no write) and
354
+ * {@link applyIntakeDecision} (the write half: the comment, the close, or the
355
+ * document render + integrate + completion comment).
353
356
  */
354
357
  async function decideAndDispatch(options, cwd, issue, comments, ctx) {
355
358
  const { arbiter, issueProvider, note } = ctx;
356
359
  const issueNumber = issue.number;
357
- // `env` here is the identity-scoped GIT/provider env (the runner's `gh`/git
358
- // ops). The AGENT launches (decision agent, lone-task review) use the AMBIENT
359
- // `options.env` — an agent must not act as the bot.
360
- const env = ctx.gitEnv;
361
360
  // TRIAGE (deterministic, under the lock, BEFORE the prompt): decide whether to run
362
361
  // the decision at all, built ENTIRELY on intake's own MARKER on the thread (no
363
362
  // sidecar/cursor/bot-identity). It SKIPS when intake has the last word
@@ -367,158 +366,459 @@ async function decideAndDispatch(options, cwd, issue, comments, ctx) {
367
366
  // is excluded from the human-comment check by construction.
368
367
  const triage = triageIntake(comments);
369
368
  if (triage.action === 'skip') {
370
- const message = triage.outcome === 'no-new-input'
371
- ? `Intake of issue #${issueNumber} found nothing new: it has the last word ` +
372
- `on the thread and has already seen every human comment up to it; doing ` +
373
- `nothing (the decision prompt did not run).`
374
- : `Intake of issue #${issueNumber} skipped: the issue was already ` +
375
- `transformed (a terminal intake marker is on the thread); a later human ` +
376
- `comment does not re-open it (the decision prompt did not run).`;
369
+ const message = intakeSkipMessage(issueNumber, triage.outcome);
377
370
  note(message);
378
371
  return { exitCode: 0, outcome: triage.outcome, issueNumber, message };
379
372
  }
380
- // DECIDE: prompt → VERDICT. The agent DRAFTS only (no git, no seam ops). Tests
381
- // inject a canned verdict (the dispatcher's testable seam); production wires the
382
- // harness. The prompt's judgement is not unit-tested — only the dispatch.
373
+ // DECIDE: prompt → VERDICT (+ the lone-task review). The agent DRAFTS only (no
374
+ // git, no seam ops).
375
+ const decided = await decideIntake(options, cwd, issue, comments, triage, note);
376
+ if (!decided.ok) {
377
+ return decided.result;
378
+ }
379
+ // DISPATCH on the decision. The runner owns every git/seam side-effect (the
380
+ // in-band boundary). PER-OUTCOME integration (spec `issue-intake` US #9): the
381
+ // resolved mode is keyed on the runtime artifact TYPE. The per-run `seen=`
382
+ // DELTA (the HUMAN comment ids intake READ this run, excluding its own
383
+ // marker-comments + already-seen ids) rides every comment intake posts.
384
+ return applyIntakeDecision(decided.decision, {
385
+ issueNumber,
386
+ cwd,
387
+ arbiter,
388
+ issueProvider,
389
+ integration: options.integration ?? { task: 'propose', spec: 'propose' },
390
+ mergeRetries: options.mergeRetries,
391
+ // The origin-trust STAMP, passed IN (not resolved here). Unset ⇒ unstamped
392
+ // (a local intake ⇒ human/trusted).
393
+ originTrust: options.originTrust,
394
+ noPR: options.noPR,
395
+ placement: intakePlacementInputs(options),
396
+ providerInstance: options.providerInstance,
397
+ seen: computeSeenDelta(comments),
398
+ // The identity-scoped GIT/provider env (the `gh` ops, push, PR).
399
+ env: ctx.gitEnv,
400
+ note,
401
+ });
402
+ }
403
+ /** The message of a triage SKIP (the decision prompt did not run). */
404
+ export function intakeSkipMessage(issueNumber, outcome) {
405
+ return outcome === 'no-new-input'
406
+ ? `Intake of issue #${issueNumber} found nothing new: it has the last word ` +
407
+ `on the thread and has already seen every human comment up to it; doing ` +
408
+ `nothing (the decision prompt did not run).`
409
+ : `Intake of issue #${issueNumber} skipped: the issue was already ` +
410
+ `transformed (a terminal intake marker is on the thread); a later human ` +
411
+ `comment does not re-open it (the decision prompt did not run).`;
412
+ }
413
+ /**
414
+ * The push-time transport-coherence guard (identity): the problem message when
415
+ * a configured identity forbids the arbiter's transport, else `undefined`. The
416
+ * arbiter URL is resolved softly (a non-zero/unknown URL is skipped); a no-op
417
+ * without an identity.
418
+ */
419
+ export async function intakeTransportProblem(identity, cwd, arbiter, env) {
420
+ if (identity === undefined) {
421
+ return undefined;
422
+ }
423
+ const urlRes = await runAsync('git', ['remote', 'get-url', arbiter], cwd, {
424
+ env,
425
+ });
426
+ if (urlRes.status !== 0) {
427
+ return undefined;
428
+ }
429
+ try {
430
+ assertTransportAllowed(identity, urlRes.stdout.trim());
431
+ return undefined;
432
+ }
433
+ catch (err) {
434
+ return err instanceof Error ? err.message : String(err);
435
+ }
436
+ }
437
+ /**
438
+ * The AGENT half of intake (task `ci-split-intake`): run the decision agent on the
439
+ * issue + thread, refuse a verdict whose title cannot be one frontmatter line,
440
+ * derive the content-derived slug, and (for a task) run the bounded lone-task
441
+ * review. Performs NO write (no git, no seam op); returns the
442
+ * {@link IntakeDecision} the write half acts on, or the failed
443
+ * {@link IntakeResult} (`agent-failed`, or `usage-error` for a verdict with no
444
+ * usable slug). The laptop `intake` and the CI agent job both run it.
445
+ */
446
+ export async function decideIntake(options, cwd, issue, comments, triage, note) {
447
+ const issueNumber = issue.number;
448
+ const failed = (outcome, message) => {
449
+ // Not noted: every caller returns this result, whose message the CLI
450
+ // prints as the run's result line (task `ci-phase-logs-each-line-once`).
451
+ return { ok: false, result: { exitCode: 1, outcome, issueNumber, message } };
452
+ };
453
+ // DECIDE: prompt → VERDICT. Tests inject a canned verdict (the dispatcher's
454
+ // testable seam); production wires the harness. The prompt's judgement is not
455
+ // unit-tested; only the dispatch is.
383
456
  const prompt = buildIntakeDecisionSpec(issue, comments, triage);
384
457
  let verdict;
385
458
  try {
386
459
  verdict = await runDecision(options, cwd, issue, comments, prompt);
460
+ // The VERDICT BOUNDARY for the drafted title (task
461
+ // `intake-frontmatter-title-injection-strips-origin-stamp`): a title that is
462
+ // not one line, holds a control character, or is blank is refused HERE, so
463
+ // it degrades onto `agent-failed` like any malformed verdict (never a silent
464
+ // emit). Runs for the injected `decide` seam and the parsed harness verdict.
465
+ assertIntakeVerdictTitle(verdict);
387
466
  }
388
467
  catch (err) {
389
468
  const detail = err instanceof Error ? err.message : String(err);
390
- const message = `Intake decision failed for issue #${issueNumber}: ${detail}`;
391
- note(message);
392
- return { exitCode: 1, outcome: 'agent-failed', issueNumber, message };
469
+ return failed('agent-failed', `Intake decision failed for issue #${issueNumber}: ${detail}`);
393
470
  }
394
- // DISPATCH on the verdict — the FULL four-outcome decision table (spec
395
- // `issue-intake`). The agent only DRAFTED the verdict; the runner owns every
396
- // git/seam side-effect below (the in-band boundary): the write + integrate
397
- // (task/spec) and the `postIssueComment` (ask/bounce).
398
- //
399
- // PER-OUTCOME integration (spec `issue-intake` US #9): the resolved mode is keyed on the runtime
400
- // artifact TYPE — a `task` verdict integrates with the task mode, a `spec`
401
- // verdict with the spec mode. Unset ⇒ propose for both. ask/bounce never
402
- // integrate, so the modes are no-ops for them.
403
- const modes = options.integration ?? { task: 'propose', spec: 'propose' };
404
- // The per-run `seen=` DELTA (the HUMAN comment ids intake READ this run, excluding
405
- // its own marker-comments + already-seen ids) the marker records on every comment
406
- // intake posts — the chain-model primitive the TRIAGE unions into `seenSet`.
407
- const seenDelta = computeSeenDelta(comments);
408
471
  switch (verdict.outcome) {
409
- case 'task':
410
- return dispatchTask({
411
- verdict,
412
- issueNumber,
413
- cwd,
414
- arbiter,
415
- integration: modes.task,
416
- // The resolved cross-job CAS-retry cap (config `mergeRetries`) — threaded so
417
- // intake's lone-task emit lands under the same per-repo cap the build path uses.
418
- mergeRetries: options.mergeRetries,
419
- // The origin-trust STAMP, passed IN (not resolved here): the emitted task
420
- // carries `origin: issue` + this verdict so the becomes-code checkpoint is
421
- // not laundered. Unset ⇒ unstamped (a local intake ⇒ human/trusted).
422
- originTrust: options.originTrust,
423
- noPR: options.noPR,
424
- // RUNNER-DETERMINISTIC PLACEMENT for the DIRECT-from-issue task (ADR
425
- // `untrusted-origin-carries-via-stamp-not-forced-staging`): the
426
- // configured-default + explicit-flag rungs, fed into the SHARED placement
427
- // resolver alongside the `originTrust` stamp above — the TASK twin of the
428
- // spec dispatch below. `dispatchTask` selects the untrusted-side default
429
- // (`untrustedTasksLandIn`) when the stamp is `untrusted`, else
430
- // `tasksLandIn`; the resolver decides `tasks/backlog/` (staging) vs
431
- // `tasks/ready/` (the pool). `intake` never places itself.
432
- tasksLandIn: options.tasksLandIn,
433
- untrustedTasksLandIn: options.untrustedTasksLandIn,
434
- explicitTasksLandIn: options.explicitTasksLandIn,
435
- providerInstance: options.providerInstance,
436
- issueProvider,
437
- // The bounded lone-task review seam (tests inject a canned verdict;
438
- // production wires the harness via the default below).
439
- reviewTask: resolveLoneTaskReviewGate(options),
440
- seen: seenDelta,
441
- env,
442
- // The lone-task review AGENT launches AMBIENT (an agent must not act as
443
- // the bot); `env` above is the identity-scoped git/provider env.
444
- agentEnv: options.env,
445
- note,
446
- });
447
- // The `spec` outcome is the parent-spec verdict; it dispatches through
448
- // `modes.spec`. HARD CUTOVER: the legacy ''prd'' outcome case is GONE.
449
- case 'spec':
450
- return dispatchSpec({
451
- verdict,
452
- issueNumber,
453
- cwd,
454
- arbiter,
455
- integration: modes.spec,
456
- // The resolved cross-job CAS-retry cap (config `mergeRetries`) — threaded so
457
- // intake's spec emit lands under the same per-repo cap the build path uses.
458
- mergeRetries: options.mergeRetries,
459
- // Same origin-trust stamp on the spec outcome (propagated onto its tasks
460
- // later by the tasker). Passed IN; not resolved here.
461
- originTrust: options.originTrust,
462
- noPR: options.noPR,
463
- // RUNNER-DETERMINISTIC PLACEMENT (task
464
- // `pre-prd-staging-pool-split-and-untrusted-prd-placement`): the
465
- // configured-default + explicit-flag rungs, fed into the SHARED placement
466
- // resolver alongside the `originTrust` stamp above. The resolver decides
467
- // `specs/proposed/` (staging) vs `specs/ready/` (the tasking pool); `intake` never
468
- // places itself.
469
- specsLandIn: options.specsLandIn,
470
- // The UNTRUSTED-side default, selected in `dispatchSpec` when the
471
- // `originTrust` stamp is `untrusted` (ADR
472
- // `untrusted-origin-carries-via-stamp-not-forced-staging`).
473
- untrustedSpecsLandIn: options.untrustedSpecsLandIn,
474
- explicitSpecsLandIn: options.explicitSpecsLandIn,
475
- providerInstance: options.providerInstance,
476
- issueProvider,
477
- seen: seenDelta,
478
- env,
479
- note,
480
- });
472
+ case 'task': {
473
+ // A content-derived slug, NEVER a counter (spec `issue-intake` US #8).
474
+ const slug = resolveSlug(verdict);
475
+ if (slug === '') {
476
+ return failed('usage-error', `Intake produced a 'task' verdict for issue #${issueNumber} with no usable ` +
477
+ `slug/title to derive a content-derived slug from (never a counter).`);
478
+ }
479
+ // BOUNDED INTERNAL REVIEW (observation
480
+ // `intake-lone-task-skips-adversarial-review-the-prd-path-gets`, rulings
481
+ // A/B/C): AFTER the `task` verdict and BEFORE the write/integrate, run a
482
+ // bounded (3-round, HARD-CAPPED) adversarial self-review on the SINGLE
483
+ // drafted task. It mutates the candidate body IN MEMORY. A launch/parse
484
+ // failure degrades onto `agent-failed` (never a silent emit of the
485
+ // un-reviewed task).
486
+ let review;
487
+ try {
488
+ review = await runLoneTaskReview({
489
+ slug,
490
+ issueNumber,
491
+ draftTitle: verdict.taskTitle ?? slug,
492
+ draftBody: verdict.taskBody,
493
+ gate: resolveLoneTaskReviewGate(options),
494
+ cwd,
495
+ // The review AGENT launches AMBIENT (never the identity-scoped env).
496
+ env: options.env,
497
+ note,
498
+ });
499
+ }
500
+ catch (err) {
501
+ const detail = err instanceof Error ? err.message : String(err);
502
+ return failed('agent-failed', `Intake lone-task review failed for issue #${issueNumber}: ${detail}`);
503
+ }
504
+ if (review.outcome === 'non-converge') {
505
+ // NON-CONVERGE (ruling C): FLIP the verdict TASK→ASK, reusing the EXISTING
506
+ // `asked` outcome + `kind=ask` marker. The ASK comment carries BOTH the
507
+ // proposed task DRAFT and the open question(s) in its BODY. NEVER write
508
+ // the task; the next intake run resumes via the triage gate.
509
+ note(`Intake's lone-task review did not converge for issue #${issueNumber} ` +
510
+ `(${review.passes} round(s)); flipping TASK→ASK with the draft + open ` +
511
+ `question(s) in the comment body.`);
512
+ return {
513
+ ok: true,
514
+ decision: {
515
+ kind: 'ask',
516
+ body: composeLoneTaskAskComment({
517
+ issueNumber,
518
+ slug,
519
+ draftTitle: review.title,
520
+ draftBody: review.body,
521
+ questions: review.questions,
522
+ }),
523
+ },
524
+ };
525
+ }
526
+ // CONVERGED: the (possibly edited) task is emitted; the refined body
527
+ // replaces the agent's first draft.
528
+ return {
529
+ ok: true,
530
+ decision: { kind: 'task', slug, title: review.title, body: review.body },
531
+ };
532
+ }
533
+ // The `spec` outcome is the parent-spec verdict. HARD CUTOVER: the legacy
534
+ // ''prd'' outcome case is GONE.
535
+ case 'spec': {
536
+ const slug = resolveSpecSlug(verdict);
537
+ if (slug === '') {
538
+ return failed('usage-error', `Intake produced a 'spec' verdict for issue #${issueNumber} with no usable ` +
539
+ `slug/title to derive a content-derived slug from (never a counter).`);
540
+ }
541
+ return {
542
+ ok: true,
543
+ decision: {
544
+ kind: 'spec',
545
+ slug,
546
+ title: verdict.specTitle ?? slug,
547
+ body: verdict.specBody,
548
+ humanOnly: verdict.specHumanOnly,
549
+ needsAnswers: verdict.specNeedsAnswers,
550
+ },
551
+ };
552
+ }
553
+ case 'ask':
554
+ // The drafted clarifying question; a thin fallback keeps the comment
555
+ // non-empty if the agent left it blank.
556
+ return {
557
+ ok: true,
558
+ decision: {
559
+ kind: 'ask',
560
+ body: verdict.question && verdict.question.trim() !== ''
561
+ ? verdict.question
562
+ : `Could you clarify issue #${issueNumber} so it can be acted on?`,
563
+ },
564
+ };
565
+ case 'bounce':
566
+ // The drafted bounce message; a thin fallback restates the "file separate
567
+ // issues" ask. A bounce is TERMINAL: the issue is CLOSED atomically.
568
+ return {
569
+ ok: true,
570
+ decision: {
571
+ kind: 'bounce',
572
+ body: verdict.bounceMessage && verdict.bounceMessage.trim() !== ''
573
+ ? verdict.bounceMessage
574
+ : `This issue looks like multiple unrelated concerns — please file ` +
575
+ `separate issues so each can be intaken on its own.`,
576
+ },
577
+ };
578
+ default:
579
+ // Unreachable from the parsed harness verdict (`parseIntakeVerdict` refuses
580
+ // any other token); an injected decider could still name one (the dead
581
+ // ''prd'' token). It dispatches NOTHING.
582
+ return failed('agent-failed', `Intake decision failed for issue #${issueNumber}: the verdict outcome ` +
583
+ `${JSON.stringify(verdict.outcome)} is not one of ask|task|spec|bounce.`);
584
+ }
585
+ }
586
+ /** The {@link IntakePlacementInputs} of a run's options. */
587
+ export function intakePlacementInputs(options) {
588
+ return {
589
+ tasksLandIn: options.tasksLandIn,
590
+ untrustedTasksLandIn: options.untrustedTasksLandIn,
591
+ explicitTasksLandIn: options.explicitTasksLandIn,
592
+ specsLandIn: options.specsLandIn,
593
+ untrustedSpecsLandIn: options.untrustedSpecsLandIn,
594
+ explicitSpecsLandIn: options.explicitSpecsLandIn,
595
+ };
596
+ }
597
+ /**
598
+ * RUNNER-DETERMINISTIC PLACEMENT of an intake document (ADR
599
+ * `untrusted-origin-carries-via-stamp-not-forced-staging`): the folder the
600
+ * document is written into. The SAME shared resolver and precedence chain the
601
+ * tasker uses (`explicit > configured default > built-in staging`); only the
602
+ * lifecycle SLOTS differ between a task (`tasks/backlog` vs `tasks/ready`) and a
603
+ * spec (`specs/proposed` vs `specs/ready`). The `originTrust` stamp selects the
604
+ * untrusted-side configured default (`untrusted*LandIn`) over the trusted one.
605
+ * Safety for an untrusted document landing in `ready` flows through its carried
606
+ * stamp at BUILD / tasking time, not the folder.
607
+ */
608
+ export function intakePlacementDir(kind, originTrust, placement) {
609
+ const untrusted = originTrust === 'untrusted';
610
+ if (kind === 'task') {
611
+ const configured = untrusted
612
+ ? placement.untrustedTasksLandIn
613
+ : placement.tasksLandIn;
614
+ const decision = resolvePlacement({
615
+ explicit: landingToSide(placement.explicitTasksLandIn),
616
+ configuredDefault: landingToSide(configured),
617
+ });
618
+ return placementFolder(TASK_PLACEMENT_SLOTS, decision.choice);
619
+ }
620
+ const configured = untrusted
621
+ ? placement.untrustedSpecsLandIn
622
+ : placement.specsLandIn;
623
+ const decision = resolvePlacement({
624
+ explicit: specLandingToSide(placement.explicitSpecsLandIn),
625
+ configuredDefault: specLandingToSide(configured),
626
+ });
627
+ return placementFolder(SPEC_PLACEMENT_SLOTS, decision.choice);
628
+ }
629
+ /**
630
+ * The WRITE half of intake (task `ci-split-intake`): act on a decision. An ask
631
+ * posts the question and leaves the issue open; a bounce closes the issue
632
+ * atomically with the bounce text; a task or spec is rendered (the title
633
+ * quoted, the frontmatter re-parsed), written onto a `work/intake-<type>-<slug>`
634
+ * branch and integrated, then the completion comment is posted. A render
635
+ * failure maps onto `agent-failed` (the input came from the agent) with nothing
636
+ * written. Runs no agent.
637
+ */
638
+ export async function applyIntakeDecision(decision, ctx) {
639
+ const { issueNumber, cwd, issueProvider, seen, env, note } = ctx;
640
+ switch (decision.kind) {
481
641
  case 'ask':
642
+ // STAMP the MARKER recording `kind=ask` (non-terminal: the TRIAGE owns
643
+ // that) + the `seen=` delta, so a re-run recognises this as intake's own
644
+ // turn and resumes only on genuine new human input.
482
645
  return dispatchComment({
483
646
  outcome: 'asked',
484
647
  cwd,
485
648
  issueNumber,
486
649
  issueProvider,
487
- // The drafted clarifying question; a thin fallback keeps the comment
488
- // non-empty if the agent left it blank.
489
- body: verdict.question && verdict.question.trim() !== ''
490
- ? verdict.question
491
- : `Could you clarify issue #${issueNumber} so it can be acted on?`,
492
- // STAMP the MARKER recording `kind=ask` (non-terminal — the TRIAGE owns
493
- // that) + the `seen=` delta, so a re-run recognises this as intake's own
494
- // turn and resumes only on genuine new human input.
650
+ body: decision.body,
495
651
  markerKind: 'ask',
496
- seen: seenDelta,
652
+ seen,
497
653
  env,
498
654
  note,
499
655
  });
500
656
  case 'bounce':
657
+ // STAMP `kind=bounced` (TERMINAL: the TRIAGE then SKIPS
658
+ // `already-terminal` on a later human comment) + the `seen=` delta.
501
659
  return dispatchComment({
502
660
  outcome: 'bounced',
503
661
  cwd,
504
662
  issueNumber,
505
663
  issueProvider,
506
- // The drafted bounce message; a thin fallback restates the "file separate
507
- // issues" ask. A bounce is TERMINAL: the issue is CLOSED atomically (this
508
- // text as the closing comment + reason not planned).
509
- body: verdict.bounceMessage && verdict.bounceMessage.trim() !== ''
510
- ? verdict.bounceMessage
511
- : `This issue looks like multiple unrelated concerns — please file ` +
512
- `separate issues so each can be intaken on its own.`,
513
- // STAMP `kind=bounced` (TERMINAL — the TRIAGE then SKIPS `already-terminal`
514
- // on a later human comment) + the `seen=` delta.
664
+ body: decision.body,
515
665
  markerKind: 'bounced',
516
- seen: seenDelta,
666
+ seen,
517
667
  env,
518
668
  note,
519
669
  });
670
+ case 'task':
671
+ case 'spec': {
672
+ // RENDER before any git: the renderer quotes the agent title and re-parses
673
+ // its own output, THROWING on anything that does not read back as
674
+ // written. That maps onto `agent-failed` with nothing written and no
675
+ // branch cut.
676
+ let content;
677
+ try {
678
+ content = renderIntakeDocument(decision, {
679
+ issueNumber,
680
+ originTrust: ctx.originTrust,
681
+ });
682
+ }
683
+ catch (err) {
684
+ const detail = err instanceof Error ? err.message : String(err);
685
+ const message = `Intake could not render the ${decision.kind} for issue #${issueNumber}: ${detail}`;
686
+ note(message);
687
+ return { exitCode: 1, outcome: 'agent-failed', issueNumber, message };
688
+ }
689
+ return emitIntakeDocument(decision, content, ctx);
690
+ }
520
691
  }
521
692
  }
693
+ /**
694
+ * Render an intake document from a decision and the TRUSTED issue number and
695
+ * origin-trust stamp ({@link renderBacklogTask} / {@link renderSpec}: the title
696
+ * quoted, every runner-owned key re-parsed), then assert once more, through
697
+ * the shared frontmatter reader, that `origin`, `originTrust`, `slug` and
698
+ * `issue` read back as the trusted values ({@link assertIntakeDocumentStamp}).
699
+ * THROWS on any disagreement. The CI apply job calls this on the verdict it
700
+ * received, so the stamp never comes from the agent job.
701
+ */
702
+ export function renderIntakeDocument(doc, trusted) {
703
+ const { issueNumber, originTrust } = trusted;
704
+ const rendered = doc.kind === 'task'
705
+ ? renderBacklogTask({
706
+ slug: doc.slug,
707
+ title: doc.title,
708
+ body: doc.body,
709
+ issueNumber,
710
+ originTrust,
711
+ })
712
+ : renderSpec({
713
+ slug: doc.slug,
714
+ title: doc.title,
715
+ body: doc.body,
716
+ issueNumber,
717
+ humanOnly: doc.humanOnly,
718
+ needsAnswers: doc.needsAnswers,
719
+ originTrust,
720
+ });
721
+ assertIntakeDocumentStamp(rendered, {
722
+ slug: doc.slug,
723
+ issueNumber,
724
+ originTrust,
725
+ });
726
+ return rendered;
727
+ }
728
+ /**
729
+ * The stamp check of a rendered intake document: its frontmatter must read back
730
+ * `slug`, `issue`, `origin` and `originTrust` EXACTLY as the trusted values
731
+ * (`origin: issue` + `originTrust` when a stamp is given, both absent
732
+ * otherwise). THROWS a `FrontmatterRenderError` otherwise.
733
+ */
734
+ export function assertIntakeDocumentStamp(rendered, trusted) {
735
+ assertFrontmatterFields(rendered, {
736
+ slug: trusted.slug,
737
+ issue: String(trusted.issueNumber),
738
+ origin: trusted.originTrust === undefined ? undefined : 'issue',
739
+ originTrust: trusted.originTrust,
740
+ });
741
+ }
742
+ /**
743
+ * Emit a RENDERED intake document: place it (runner-deterministic, from the
744
+ * trusted stamp and the placement inputs), onboard a `work/intake-<type>-<slug>`
745
+ * branch cut from the freshly-fetched `<arbiter>/main`, stage exactly the one
746
+ * document path and integrate it through {@link performIntegration} (`propose`
747
+ * PR, or `merge` with the CAS loop), then post the completion comment. The
748
+ * `intake-` producer prefix keeps the branch distinct from a later `do
749
+ * task:<slug>` build branch for the same slug.
750
+ */
751
+ export async function emitIntakeDocument(doc, content, ctx) {
752
+ const { issueNumber, cwd, arbiter, issueProvider, seen, env, note } = ctx;
753
+ const { kind, slug, title } = doc;
754
+ const relPath = `${intakePlacementDir(kind, ctx.originTrust, ctx.placement)}/${slug}.md`;
755
+ await switchToWorkBranch(cwd, arbiter, kind, slug, env);
756
+ const core = await performIntegration({
757
+ cwd,
758
+ arbiter,
759
+ slug,
760
+ // `source`/`recovering` are task-shaped and IGNORED when `lifecycle` is set.
761
+ source: 'in-progress',
762
+ recovering: false,
763
+ // An intake-emitted document has no `verify` floor of its own (it is a new
764
+ // backlog item / spec, not a build); skip the acceptance gate, exactly as
765
+ // the tasking transition does.
766
+ skipVerify: true,
767
+ // The EXPLICITLY-chosen per-type mode proceeds as-is (`merge` IS the
768
+ // auto-land mode, never downgraded).
769
+ mode: ctx.integration[kind],
770
+ // The cross-job merge-serialiser CAS-retry cap (config `mergeRetries`).
771
+ mergeRetries: ctx.mergeRetries,
772
+ ...(ctx.mergeJitterMs === undefined
773
+ ? {}
774
+ : { mergeJitterMs: ctx.mergeJitterMs }),
775
+ noPR: ctx.noPR,
776
+ providerInstance: ctx.providerInstance,
777
+ type: 'feat',
778
+ // A short PR body so an intake PR is never blank (`gh --fill`): it names the
779
+ // source issue WITHOUT a closing keyword (the issue must outlive the merge).
780
+ body: composeIntakeProposeBody({ kind, issueNumber }),
781
+ lifecycle: {
782
+ // The document IS the title source. Pass the DRAFTED title EXPLICITLY:
783
+ // `stage()` writes the file AFTER the core reads the title, so a
784
+ // `titlePath` read would race the write. `titlePath` stays set (the
785
+ // lifecycle contract requires it) but is IGNORED while `title` is present.
786
+ titlePath: join(cwd, relPath),
787
+ title,
788
+ commitTag: 'intake',
789
+ // The document RESTS at its placed path (staged or pool), so point the
790
+ // reviewer at THAT file, never the task-build `work/tasks/done/` default.
791
+ proposeHeader: proposePointer(kind === 'task' ? 'Task' : 'Spec', relPath),
792
+ stage: () => stageIntakeContent({ cwd, relPath, content, env }),
793
+ },
794
+ env,
795
+ note,
796
+ });
797
+ return integrationToIntakeResult(core, {
798
+ issueNumber,
799
+ slug,
800
+ relPath,
801
+ kind,
802
+ cwd,
803
+ issueProvider,
804
+ seen,
805
+ env,
806
+ note,
807
+ });
808
+ }
809
+ /**
810
+ * The propose PR prose of an intake emit (task
811
+ * `propose-pr-bodies-point-at-the-real-item`); the shared core puts the
812
+ * document pointer above it. It names the source issue as a plain `#<n>`
813
+ * reference and deliberately uses NO closing keyword (`closes`, `fixes`,
814
+ * `resolves`, ...): merging an intake PR only files the task or spec, and the
815
+ * issue stays open until that work is done (the close job owns closing it).
816
+ */
817
+ export function composeIntakeProposeBody(params) {
818
+ const { kind, issueNumber } = params;
819
+ return (`Intake drafted this ${kind} from issue #${issueNumber}. Merging this PR ` +
820
+ `files the ${kind}; it leaves the issue open.`);
821
+ }
522
822
  /**
523
823
  * DISPATCH the `ask` / `bounce` outcomes — the SHARED comment band, which now
524
824
  * BRANCHES on the outcome:
@@ -593,275 +893,6 @@ async function dispatchComment(params) {
593
893
  message,
594
894
  };
595
895
  }
596
- /**
597
- * DISPATCH the `task` outcome: derive a content-derived slug, write
598
- * `work/backlog/<slug>.md` (`covers: []`, NO `spec:`) carrying `issue: N` (the
599
- * lone-task closure link, NOT `Fixes #N`), and integrate via {@link
600
- * performIntegration}. The runner owns the git: it onboards a
601
- * `work/<slug>` branch off fresh `<arbiter>/main`, then the lifecycle `stage`
602
- * writes + stages the task and the band commits + rebases + integrates it. The
603
- * agent did NO git/seam ops.
604
- */
605
- async function dispatchTask(params) {
606
- const { verdict, issueNumber, cwd, arbiter, integration, mergeRetries, originTrust, noPR, tasksLandIn, untrustedTasksLandIn, explicitTasksLandIn, providerInstance, issueProvider, reviewTask, seen, env, agentEnv, note, } = params;
607
- // A content-derived slug — NEVER a counter (spec `issue-intake` US #8). Prefer the drafted
608
- // `taskSlug`, else derive from the drafted title; sanitise either through
609
- // `paramCase` so the filename + frontmatter slug are well-formed.
610
- const slug = resolveSlug(verdict);
611
- if (slug === '') {
612
- const message = `Intake produced a 'task' verdict for issue #${issueNumber} with no usable ` +
613
- `slug/title to derive a content-derived slug from (never a counter).`;
614
- note(message);
615
- return { exitCode: 1, outcome: 'usage-error', issueNumber, message };
616
- }
617
- // RUNNER-DETERMINISTIC PLACEMENT (task `intake-task-placement-symmetry`; the
618
- // untrusted-forces-staging rung RETIRED by ADR
619
- // `untrusted-origin-carries-via-stamp-not-forced-staging`). Resolve which
620
- // folder the runner writes the intake-authored task into BEFORE handing it to
621
- // the shared integrate band — the SAME precedence chain, the SAME shared
622
- // resolver, and (via `TASK_PLACEMENT_SLOTS` + `landingToSide` reused from
623
- // `tasking.ts`) the SAME task slots the tasker uses; only the intake dispatch
624
- // differs from the spec twin (`dispatchSpec`) in its lifecycle SLOTS. THIS
625
- // caller selects the trusted-vs-untrusted configured default by reading the
626
- // `originTrust` stamp: an untrusted task selects `untrustedTasksLandIn`
627
- // (default staging; `ready` when configured), a trusted/unset task selects
628
- // `tasksLandIn`. The agent (the intake decider) never influences placement.
629
- // Net vs the old hardcoded `tasks-ready`: an untrusted-author task now MERGES
630
- // a document to `main` in `backlog` (by default) carrying the stamp instead of
631
- // opening a document PR; safety for a `ready`-landed untrusted task flows
632
- // through its carried stamp at BUILD time (forces a code PR), not the folder.
633
- const configuredTasksLanding = originTrust === 'untrusted' ? untrustedTasksLandIn : tasksLandIn;
634
- const placementDecision = resolvePlacement({
635
- explicit: landingToSide(explicitTasksLandIn),
636
- configuredDefault: landingToSide(configuredTasksLanding),
637
- });
638
- const placementDir = placementFolder(TASK_PLACEMENT_SLOTS, placementDecision.choice);
639
- const relPath = `${placementDir}/${slug}.md`;
640
- // BOUNDED INTERNAL REVIEW (observation
641
- // `intake-lone-task-skips-adversarial-review-the-prd-path-gets`, rulings A/B/C):
642
- // the `do spec:` path gets `runTaskReviewLoop`; the lone-TASK path got NOTHING.
643
- // AFTER the `task` verdict and BEFORE the write/integrate, run a bounded (3-round,
644
- // HARD-CAPPED) adversarial self-review on the SINGLE drafted task. It mutates the
645
- // candidate body IN MEMORY (no `work/backlog/` write pre-convergence). A launch/
646
- // parse failure THROWS — `decideAndDispatch`'s try/catch maps it onto `agent-failed`
647
- // (never a silent emit of the un-reviewed task).
648
- let review;
649
- try {
650
- review = await runLoneTaskReview({
651
- slug,
652
- issueNumber,
653
- draftTitle: verdict.taskTitle ?? slug,
654
- draftBody: verdict.taskBody,
655
- gate: reviewTask,
656
- cwd,
657
- // The review AGENT launches AMBIENT (never the identity-scoped env).
658
- env: agentEnv,
659
- note,
660
- });
661
- }
662
- catch (err) {
663
- // A review-agent launch/parse FAILURE DEGRADES honestly onto the EXISTING
664
- // `agent-failed` outcome (exit 1) — NEVER a silent emit of the un-reviewed
665
- // task. The SAME try/catch discipline the decision step uses.
666
- const detail = err instanceof Error ? err.message : String(err);
667
- const message = `Intake lone-task review failed for issue #${issueNumber}: ${detail}`;
668
- note(message);
669
- return { exitCode: 1, outcome: 'agent-failed', issueNumber, message };
670
- }
671
- if (review.outcome === 'non-converge') {
672
- // NON-CONVERGE (ruling C): FLIP the verdict TASK→ASK, reusing the EXISTING
673
- // `asked` outcome + `kind=ask` marker. The ASK comment carries BOTH the proposed
674
- // task DRAFT and the open question(s) in its BODY (NOT a new marker kind) — the
675
- // human reacts to a concrete draft, strictly richer than a blank-question ask.
676
- // NEVER write `work/backlog/<slug>.md`; NEVER silently emit the under-refined
677
- // task. The next intake run resumes via the already-built triage gate.
678
- note(`Intake's lone-task review did not converge for issue #${issueNumber} ` +
679
- `(${review.passes} round(s)); flipping TASK→ASK with the draft + open ` +
680
- `question(s) in the comment body.`);
681
- return dispatchComment({
682
- outcome: 'asked',
683
- cwd,
684
- issueNumber,
685
- issueProvider,
686
- body: composeLoneTaskAskComment({
687
- issueNumber,
688
- slug,
689
- draftTitle: review.title,
690
- draftBody: review.body,
691
- questions: review.questions,
692
- }),
693
- markerKind: 'ask',
694
- seen,
695
- env,
696
- note,
697
- });
698
- }
699
- // CONVERGED: the (possibly edited) task is emitted via the EXISTING write/integrate
700
- // path below + the existing `task created` completion comment. The refined body
701
- // replaces the agent's first draft.
702
- const reviewedBody = review.body;
703
- // ONBOARD the task write onto a `work/intake-task-<slug>` branch cut from the
704
- // freshly-fetched `<arbiter>/main` (the SAME runner-owns-git discipline the
705
- // tasking path uses): the lifecycle `stage` writes the file ON THIS BRANCH and
706
- // the shared integrate core (`--propose` PR / `--merge` main) lands it. The
707
- // intake- producer prefix keeps it distinct from a later `do task:<slug>`
708
- // build branch for the same slug. The agent ran no git.
709
- await switchToWorkBranch(cwd, arbiter, 'task', slug, env);
710
- const taskContent = renderBacklogTask({
711
- slug,
712
- title: review.title,
713
- body: reviewedBody,
714
- issueNumber,
715
- originTrust,
716
- });
717
- const core = await performIntegration({
718
- cwd,
719
- arbiter,
720
- slug,
721
- // `source`/`recovering` are task-shaped and IGNORED when `lifecycle` is set.
722
- source: 'in-progress',
723
- recovering: false,
724
- // An intake-emitted task has no `verify` floor of its own (it is a new
725
- // backlog item, not a build); skip the acceptance gate, exactly as the
726
- // tasking transition does.
727
- skipVerify: true,
728
- // Default `propose` (the per-outcome KNOBS are a later task). The
729
- // EXPLICITLY-chosen mode proceeds as-is: a future `--merge-task` lands on main
730
- // (`merge` IS the auto-land mode, never downgraded).
731
- mode: integration,
732
- // The cross-job merge-serialiser CAS-retry cap (config `mergeRetries`) — the
733
- // git-alone floor of the cross-job land queue, resolved through the gate-
734
- // family precedence chain in the CLI and threaded here so intake's lone-task
735
- // emit lands under the same per-repo cap the build path uses. Unset ⇒ the
736
- // engine default (byte-for-byte unchanged).
737
- mergeRetries,
738
- noPR,
739
- providerInstance,
740
- type: 'feat',
741
- lifecycle: {
742
- // The emitted task IS the title source. Pass the DRAFTED title EXPLICITLY
743
- // (not a read-from-path): `stage()` WRITES `work/backlog/<slug>.md` AFTER the
744
- // core reads the title, so a `titlePath` read would race the write and degrade
745
- // the commit subject / PR title to the generic fallback. `titlePath` stays set
746
- // (the lifecycle contract requires it) but is IGNORED while `title` is present.
747
- titlePath: join(cwd, relPath),
748
- title: review.title,
749
- commitTag: 'intake',
750
- stage: () => stageIntakeContent({ cwd, relPath, content: taskContent, env }),
751
- },
752
- env,
753
- note,
754
- });
755
- return integrationToIntakeResult(core, {
756
- issueNumber,
757
- slug,
758
- relPath,
759
- cwd,
760
- issueProvider,
761
- seen,
762
- env,
763
- note,
764
- });
765
- }
766
- /**
767
- * DISPATCH the `spec` outcome (the SOLE parent-spec outcome after the HARD CUTOVER;
768
- * the legacy ''prd'' outcome token is GONE): derive a content-derived slug, write the spec file
769
- * (`work/specs/ready/<slug>.md`) carrying `issue: N` (the loop-closure linkage the
770
- * close JOB reaches via `task.spec: → spec issue:`; on a fanned spec the number
771
- * lives ONLY on the spec — a fanned task uses `spec:`, NOT its own `issue:`, which
772
- * is the lone-task outcome's link) + the gate axes the prompt JUDGED, integrate it
773
- * via {@link performIntegration}, then STOP. Tasking the emitted spec is the
774
- * SEPARATE `do spec:` step (NOT done here). A coupled-but-SMALL pair lands here too
775
- * (the spec vs BOUNCE line is SHARED VISION, not size — the over-bounce guard). The
776
- * runner owns the git exactly as the task branch does; the agent did NO git/seam ops.
777
- */
778
- async function dispatchSpec(params) {
779
- const { verdict, issueNumber, cwd, arbiter, integration, mergeRetries, originTrust, noPR, specsLandIn, untrustedSpecsLandIn, explicitSpecsLandIn, providerInstance, issueProvider, seen, env, note, } = params;
780
- // A content-derived slug — NEVER a counter (spec `issue-intake` US #8). Prefer the drafted
781
- // `specSlug`, else derive from the drafted title.
782
- const slug = resolveSpecSlug(verdict);
783
- if (slug === '') {
784
- const message = `Intake produced a 'spec' verdict for issue #${issueNumber} with no usable ` +
785
- `slug/title to derive a content-derived slug from (never a counter).`;
786
- note(message);
787
- return { exitCode: 1, outcome: 'usage-error', issueNumber, message };
788
- }
789
- // RUNNER-DETERMINISTIC PLACEMENT (task
790
- // `pre-prd-staging-pool-split-and-untrusted-prd-placement`; the
791
- // untrusted-forces-staging rung RETIRED by ADR
792
- // `untrusted-origin-carries-via-stamp-not-forced-staging`). Resolve which
793
- // folder the runner writes the intake-authored spec into BEFORE handing it to
794
- // the shared integrate band: the SAME precedence chain the tasker uses
795
- // (`explicit > configured default > built-in (staging)`), the SAME shared
796
- // resolver — only the lifecycle SLOTS differ. THIS caller selects the
797
- // trusted-vs-untrusted configured default by reading the `originTrust` stamp:
798
- // an untrusted spec selects `untrustedSpecsLandIn` (default staging; `ready`
799
- // when configured), a trusted/unset spec selects `specsLandIn`. The agent
800
- // (the intake decider) never influences placement; it returns the verdict and
801
- // the runner computes the destination from unforgeable inputs. Safety for an
802
- // untrusted spec landing in `ready` flows through its tasks' carried stamp,
803
- // not the folder.
804
- const configuredSpecsLanding = originTrust === 'untrusted' ? untrustedSpecsLandIn : specsLandIn;
805
- const placementDecision = resolvePlacement({
806
- explicit: specLandingToSide(explicitSpecsLandIn),
807
- configuredDefault: specLandingToSide(configuredSpecsLanding),
808
- });
809
- const placementDir = placementFolder(SPEC_PLACEMENT_SLOTS, placementDecision.choice);
810
- const relPath = `${placementDir}/${slug}.md`;
811
- // ONBOARD onto a `work/intake-spec-<slug>` branch off fresh `<arbiter>/main` —
812
- // the SAME runner-owns-git discipline the task branch uses; the intake-
813
- // producer prefix keeps it distinct from a `do spec:<slug>` tasking branch.
814
- await switchToWorkBranch(cwd, arbiter, 'spec', slug, env);
815
- const specContent = renderSpec({
816
- slug,
817
- title: verdict.specTitle ?? slug,
818
- body: verdict.specBody,
819
- issueNumber,
820
- humanOnly: verdict.specHumanOnly,
821
- needsAnswers: verdict.specNeedsAnswers,
822
- originTrust,
823
- });
824
- const core = await performIntegration({
825
- cwd,
826
- arbiter,
827
- slug,
828
- source: 'in-progress',
829
- recovering: false,
830
- // An intake-emitted spec has no `verify` floor of its own (it is a new spec,
831
- // not a build), exactly as the task branch + the tasking transition skip it.
832
- skipVerify: true,
833
- mode: integration,
834
- // The cross-job merge-serialiser CAS-retry cap (config `mergeRetries`) — same
835
- // rationale as the lone-task emit above; threaded so intake's spec emit lands
836
- // under the same per-repo cap. Unset ⇒ the engine default (byte-for-byte).
837
- mergeRetries,
838
- noPR,
839
- providerInstance,
840
- type: 'feat',
841
- lifecycle: {
842
- // The emitted spec IS the title source. Pass the DRAFTED title EXPLICITLY (same
843
- // race as the task path: `stage()` writes the spec file AFTER the title
844
- // read). `titlePath` stays set but is IGNORED while `title` is present.
845
- titlePath: join(cwd, relPath),
846
- title: verdict.specTitle ?? slug,
847
- commitTag: 'intake',
848
- stage: () => stageIntakeContent({ cwd, relPath, content: specContent, env }),
849
- },
850
- env,
851
- note,
852
- });
853
- return integrationToIntakeResult(core, {
854
- issueNumber,
855
- slug,
856
- relPath,
857
- kind: 'spec',
858
- cwd,
859
- issueProvider,
860
- seen,
861
- env,
862
- note,
863
- });
864
- }
865
896
  /**
866
897
  * Map the shared integrate band's {@link IntegrationCoreResult} onto the intake
867
898
  * {@link IntakeResult}. On `completed` the artifact was written + integrated; a
@@ -875,9 +906,18 @@ async function integrationToIntakeResult(core, ctx) {
875
906
  const kind = ctx.kind ?? 'task';
876
907
  const artifact = kind === 'spec' ? 'spec' : 'task';
877
908
  if (core.outcome === 'completed') {
909
+ // HONEST propose report (task `intake-reports-the-pr-it-actually-opened`):
910
+ // say "opened a PR" ONLY when the provider opened one. A degraded `gh pr
911
+ // create` (e.g. a repository that refuses PR creation by Actions) pushed the
912
+ // branch but opened nothing; the shared core already printed the provider's
913
+ // instruction, and the message names the branch the work is on.
914
+ const notOpened = proposeRequestNotOpened(core.integration);
878
915
  const landed = core.integration?.mode === 'merge'
879
916
  ? 'landed it on the arbiter main'
880
- : 'opened a PR carrying it (main untouched)';
917
+ : notOpened !== undefined
918
+ ? `pushed it on branch ${core.integration?.pushedRef ?? core.branch} ` +
919
+ 'but opened NO PR (main untouched; see the provider note above)'
920
+ : 'opened a PR carrying it (main untouched)';
881
921
  // Both a lone task and a spec carry `issue: N` as their closure link (the task
882
922
  // closes its own issue; a spec is reached via `task.spec: → spec issue:`). On the
883
923
  // task/spec path `intake` never closes the issue (CI's close-job does, via the
@@ -943,10 +983,14 @@ async function integrationToIntakeResult(core, ctx) {
943
983
  * - reports `task created` / `spec created` framed as CREATED — NEVER "issue
944
984
  * resolved/closed" (intake never closes on the task/spec path).
945
985
  * - LINKS the artifact by INTEGRATION MODE: the PR `url` in propose, the landed
946
- * `commit` (the additive {@link IntegrateResult.commit}) in merge. A degraded
947
- * propose (no `url`) or a failed merge-tip read (no `commit`) simply OMITS the
986
+ * `commit` (the additive {@link IntegrateResult.commit}) in merge. An opened PR
987
+ * whose `url` was unparseable or a failed merge-tip read (no `commit`) simply OMITS the
948
988
  * link — the comment still confirms what was created (the artifact is safe on the
949
989
  * branch/main regardless). No spec link beyond the slug.
990
+ * - says so when a propose land opened NO PR at all (the provider degraded, e.g. a
991
+ * repository that refuses PR creation by Actions, or a push-only provider): it
992
+ * names the pushed branch and quotes the provider's reason verbatim, instead of
993
+ * reading as if a PR carried the artifact.
950
994
  * - carries the FULL intake MARKER via the SHARED {@link stampIntakeMarker} helper
951
995
  * (`kind=created slug=<slug> seen=<id>,…`) so the triage's `already-terminal`
952
996
  * branch consumes it — the comment cannot re-trigger intake.
@@ -954,13 +998,21 @@ async function integrationToIntakeResult(core, ctx) {
954
998
  export function composeIntakeCompletionComment(params) {
955
999
  const { kind, slug, integration, seen } = params;
956
1000
  const artifact = kind === 'spec' ? 'spec' : 'task';
1001
+ // A propose land that opened NO PR (the provider degraded, or push-only) says
1002
+ // so, names the pushed branch and quotes the provider's reason, instead of
1003
+ // implying a PR (task `intake-reports-the-pr-it-actually-opened`).
1004
+ const notOpened = proposeRequestNotOpened(integration);
957
1005
  const link = integration?.mode === 'merge'
958
1006
  ? integration.commit !== undefined
959
1007
  ? `\n\nIt landed on \`main\` in commit ${integration.commit}.`
960
1008
  : ''
961
- : integration?.url !== undefined
962
- ? `\n\nIt is carried by the PR: ${integration.url}`
963
- : '';
1009
+ : notOpened !== undefined
1010
+ ? `\n\nIt is pushed on branch \`${integration.pushedRef}\`, but **no PR ` +
1011
+ `could be opened**, so nothing proposes it for review yet. The ` +
1012
+ `reason reported:\n\n${fenceVerbatim(notOpened)}`
1013
+ : integration?.url !== undefined
1014
+ ? `\n\nIt is carried by the PR: ${integration.url}`
1015
+ : '';
964
1016
  const body = `Created ${artifact} \`${slug}\` from this issue.${link}\n\n` +
965
1017
  `This is an informational update — the issue stays open (it remains in play ` +
966
1018
  `until the ${artifact} lands; intake does not change the issue's state).`;
@@ -968,6 +1020,17 @@ export function composeIntakeCompletionComment(params) {
968
1020
  // `already-terminal` branch recognises this terminal `created` comment.
969
1021
  return stampIntakeMarker(body, { kind: 'created', seen, slug });
970
1022
  }
1023
+ /**
1024
+ * Wrap `text` in a fenced code block that renders it VERBATIM: the fence is one
1025
+ * backtick longer than the longest backtick run inside `text` (minimum three), so
1026
+ * a provider instruction carrying inline code or a quoted PR body can never close
1027
+ * the fence early.
1028
+ */
1029
+ function fenceVerbatim(text) {
1030
+ const longest = Math.max(0, ...Array.from(text.matchAll(/`+/g), (m) => m[0].length));
1031
+ const fence = '`'.repeat(Math.max(3, longest + 1));
1032
+ return `${fence}text\n${text}\n${fence}`;
1033
+ }
971
1034
  /**
972
1035
  * Post the INFORMATIONAL completion comment for a SUCCESSFUL `task` / `spec`
973
1036
  * outcome (this task). It closes the loop the ASK/BOUNCE comments already close
@@ -1041,6 +1104,31 @@ function resolveSpecSlug(verdict) {
1041
1104
  : (verdict.specTitle ?? '');
1042
1105
  return ensureSafeSlug(paramCase(candidate));
1043
1106
  }
1107
+ /**
1108
+ * Refuse an intake verdict whose drafted title cannot be one frontmatter line
1109
+ * (task `intake-frontmatter-title-injection-strips-origin-stamp`). Only the
1110
+ * title the verdict's OUTCOME will render is checked (`taskTitle` for `task`,
1111
+ * `specTitle` for `spec`): it must be one line with no control character and
1112
+ * not blank. An ABSENT title stays legal (the dispatcher falls back to the
1113
+ * slug). THROWS; the caller maps the throw onto `agent-failed`.
1114
+ */
1115
+ export function assertIntakeVerdictTitle(verdict) {
1116
+ const [field, title] = verdict.outcome === 'task'
1117
+ ? ['taskTitle', verdict.taskTitle]
1118
+ : verdict.outcome === 'spec'
1119
+ ? ['specTitle', verdict.specTitle]
1120
+ : [undefined, undefined];
1121
+ if (field === undefined || title === undefined) {
1122
+ return;
1123
+ }
1124
+ const problem = singleLineScalarProblem(title);
1125
+ if (problem !== undefined) {
1126
+ throw new Error(`intake verdict '${field}' ${problem}.`);
1127
+ }
1128
+ if (title.trim() === '') {
1129
+ throw new Error(`intake verdict '${field}' is blank.`);
1130
+ }
1131
+ }
1044
1132
  /**
1045
1133
  * Render the backlog task file: the frontmatter (`title`/`slug`/`covers: []`, NO
1046
1134
  * `spec:` — its own source of truth, spec `issue-intake` decision table) carrying the lone-task
@@ -1054,9 +1142,13 @@ function resolveSpecSlug(verdict) {
1054
1142
  */
1055
1143
  export function renderBacklogTask(params) {
1056
1144
  const { slug, title, body, issueNumber, originTrust } = params;
1145
+ // The TITLE is agent-drafted (issue text any user can write): it goes through
1146
+ // the shared quoter, which THROWS on a multi-line / control-character value
1147
+ // (the verdict boundary already refused one; this is the backstop), so it can
1148
+ // never close the fence early and demote the origin-trust stamp below it.
1057
1149
  const lines = [
1058
1150
  '---',
1059
- `title: ${title}`,
1151
+ `title: ${quoteYamlScalar(title)}`,
1060
1152
  `slug: ${slug}`,
1061
1153
  `issue: ${issueNumber}`,
1062
1154
  ];
@@ -1081,7 +1173,19 @@ export function renderBacklogTask(params) {
1081
1173
  acceptanceCriteria: '- [ ] the issue is resolved',
1082
1174
  prompt: `Resolve issue #${issueNumber}: ${title}`,
1083
1175
  }).trimEnd();
1084
- return `${frontmatter}\n\n${drafted}\n`;
1176
+ const rendered = `${frontmatter}\n\n${drafted}\n`;
1177
+ // Re-parse what we rendered: every key is runner-owned, so the frontmatter
1178
+ // must read back EXACTLY these keys and values, else fail loudly.
1179
+ assertFrontmatterFields(rendered, {
1180
+ title,
1181
+ slug,
1182
+ issue: String(issueNumber),
1183
+ origin: originTrust === undefined ? undefined : 'issue',
1184
+ originTrust,
1185
+ covers: '[]',
1186
+ blockedBy: '[]',
1187
+ }, { exact: true });
1188
+ return rendered;
1085
1189
  }
1086
1190
  /**
1087
1191
  * Render the emitted spec file: the frontmatter (`title`/`slug` + the loop-closure
@@ -1097,9 +1201,11 @@ export function renderBacklogTask(params) {
1097
1201
  */
1098
1202
  export function renderSpec(params) {
1099
1203
  const { slug, title, body, issueNumber, humanOnly, needsAnswers, originTrust } = params;
1204
+ // The agent-drafted TITLE goes through the shared quoter (see
1205
+ // `renderBacklogTask`).
1100
1206
  const lines = [
1101
1207
  '---',
1102
- `title: ${title}`,
1208
+ `title: ${quoteYamlScalar(title)}`,
1103
1209
  `slug: ${slug}`,
1104
1210
  `issue: ${issueNumber}`,
1105
1211
  ];
@@ -1133,7 +1239,18 @@ export function renderSpec(params) {
1133
1239
  solution: '(to be detailed; this spec needs tasking via `do spec:`).',
1134
1240
  userStories: `1. As a user, I want issue #${issueNumber} addressed.`,
1135
1241
  }).trimEnd();
1136
- return `${frontmatter}\n\n${drafted}\n`;
1242
+ const rendered = `${frontmatter}\n\n${drafted}\n`;
1243
+ // Re-parse what we rendered (see `renderBacklogTask`).
1244
+ assertFrontmatterFields(rendered, {
1245
+ title,
1246
+ slug,
1247
+ issue: String(issueNumber),
1248
+ origin: originTrust === undefined ? undefined : 'issue',
1249
+ originTrust,
1250
+ humanOnly: humanOnly === true ? 'true' : undefined,
1251
+ needsAnswers: needsAnswers === true ? 'true' : undefined,
1252
+ }, { exact: true });
1253
+ return rendered;
1137
1254
  }
1138
1255
  /**
1139
1256
  * STAGE the intake artifact content into the index on the `work/<slug>` branch (the