toolroll 0.5.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 (626) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1113 -0
  3. package/THIRD_PARTY_NOTICES.md +449 -0
  4. package/dist/accent-colors.d.ts +48 -0
  5. package/dist/accent-colors.js +122 -0
  6. package/dist/action-ledger.d.ts +26 -0
  7. package/dist/action-ledger.js +75 -0
  8. package/dist/agent-fence.d.ts +42 -0
  9. package/dist/agent-fence.js +183 -0
  10. package/dist/agentconfig.d.ts +216 -0
  11. package/dist/agentconfig.js +454 -0
  12. package/dist/api-tokens.d.ts +16 -0
  13. package/dist/api-tokens.js +27 -0
  14. package/dist/approval-policy.d.ts +69 -0
  15. package/dist/approval-policy.js +137 -0
  16. package/dist/approval-rules-ui.d.ts +25 -0
  17. package/dist/approval-rules-ui.js +39 -0
  18. package/dist/assignment-adapters.d.ts +180 -0
  19. package/dist/assignment-adapters.js +239 -0
  20. package/dist/assignment-brief.d.ts +71 -0
  21. package/dist/assignment-brief.js +129 -0
  22. package/dist/assignment-delivery.d.ts +56 -0
  23. package/dist/assignment-delivery.js +160 -0
  24. package/dist/assignment-presentation.d.ts +26 -0
  25. package/dist/assignment-presentation.js +80 -0
  26. package/dist/assignment-status.d.ts +62 -0
  27. package/dist/assignment-status.js +152 -0
  28. package/dist/assignment-ui.d.ts +71 -0
  29. package/dist/assignment-ui.js +103 -0
  30. package/dist/assignment.d.ts +222 -0
  31. package/dist/assignment.js +399 -0
  32. package/dist/attest.d.ts +56 -0
  33. package/dist/attest.js +153 -0
  34. package/dist/backend.d.ts +97 -0
  35. package/dist/backend.js +166 -0
  36. package/dist/backup-ui.d.ts +22 -0
  37. package/dist/backup-ui.js +59 -0
  38. package/dist/backup.d.ts +84 -0
  39. package/dist/backup.js +421 -0
  40. package/dist/beads.d.ts +34 -0
  41. package/dist/beads.js +135 -0
  42. package/dist/bin.d.ts +2 -0
  43. package/dist/bin.js +22 -0
  44. package/dist/board.d.ts +150 -0
  45. package/dist/board.js +210 -0
  46. package/dist/boot-identity.d.ts +63 -0
  47. package/dist/boot-identity.js +99 -0
  48. package/dist/browser/THIRD_PARTY_NOTICES.txt +2295 -0
  49. package/dist/browser/workspace.css +4 -0
  50. package/dist/browser/workspace.js +225 -0
  51. package/dist/browser-crew.d.ts +11 -0
  52. package/dist/browser-crew.js +50 -0
  53. package/dist/browser-shell.d.ts +8 -0
  54. package/dist/browser-shell.js +60 -0
  55. package/dist/browser-workspace.d.ts +780 -0
  56. package/dist/browser-workspace.js +47 -0
  57. package/dist/budget-alerts.d.ts +19 -0
  58. package/dist/budget-alerts.js +46 -0
  59. package/dist/builder.d.ts +370 -0
  60. package/dist/builder.js +3262 -0
  61. package/dist/capscan.d.ts +35 -0
  62. package/dist/capscan.js +113 -0
  63. package/dist/chat-acceptance.d.ts +71 -0
  64. package/dist/chat-acceptance.js +120 -0
  65. package/dist/chat-actions.d.ts +265 -0
  66. package/dist/chat-actions.js +1273 -0
  67. package/dist/chat-channel.d.ts +109 -0
  68. package/dist/chat-channel.js +652 -0
  69. package/dist/chat-continuity.d.ts +32 -0
  70. package/dist/chat-continuity.js +317 -0
  71. package/dist/chat-controls.d.ts +113 -0
  72. package/dist/chat-controls.js +53 -0
  73. package/dist/chat-delivery-state.d.ts +159 -0
  74. package/dist/chat-delivery-state.js +309 -0
  75. package/dist/chat-delivery.d.ts +38 -0
  76. package/dist/chat-delivery.js +693 -0
  77. package/dist/chat-display.d.ts +1 -0
  78. package/dist/chat-display.js +18 -0
  79. package/dist/chat-evidence.d.ts +123 -0
  80. package/dist/chat-evidence.js +207 -0
  81. package/dist/chat-flow.d.ts +44 -0
  82. package/dist/chat-flow.js +215 -0
  83. package/dist/chat-inbox.d.ts +57 -0
  84. package/dist/chat-inbox.js +123 -0
  85. package/dist/chat-polish.d.ts +16 -0
  86. package/dist/chat-polish.js +135 -0
  87. package/dist/chat-review.d.ts +46 -0
  88. package/dist/chat-review.js +98 -0
  89. package/dist/chat-rooms.d.ts +123 -0
  90. package/dist/chat-rooms.js +216 -0
  91. package/dist/chat-task-actions.d.ts +44 -0
  92. package/dist/chat-task-actions.js +79 -0
  93. package/dist/check-progress.d.ts +51 -0
  94. package/dist/check-progress.js +290 -0
  95. package/dist/child-database.d.ts +12 -0
  96. package/dist/child-database.js +30 -0
  97. package/dist/claim.d.ts +688 -0
  98. package/dist/claim.js +1740 -0
  99. package/dist/cli.d.ts +137 -0
  100. package/dist/cli.js +1461 -0
  101. package/dist/codex-limits.d.ts +15 -0
  102. package/dist/codex-limits.js +145 -0
  103. package/dist/coding-context.d.ts +55 -0
  104. package/dist/coding-context.js +92 -0
  105. package/dist/coding-handoff.d.ts +57 -0
  106. package/dist/coding-handoff.js +277 -0
  107. package/dist/coding-provider.d.ts +72 -0
  108. package/dist/coding-provider.js +424 -0
  109. package/dist/coding-shipping-ui.d.ts +4 -0
  110. package/dist/coding-shipping-ui.js +12 -0
  111. package/dist/coding-types.d.ts +62 -0
  112. package/dist/coding-types.js +1 -0
  113. package/dist/coding-ui.d.ts +17 -0
  114. package/dist/coding-ui.js +364 -0
  115. package/dist/coding-update.d.ts +15 -0
  116. package/dist/coding-update.js +149 -0
  117. package/dist/coding-workspace.d.ts +124 -0
  118. package/dist/coding-workspace.js +768 -0
  119. package/dist/container-state.d.ts +5 -0
  120. package/dist/container-state.js +62 -0
  121. package/dist/containment.d.ts +197 -0
  122. package/dist/containment.js +559 -0
  123. package/dist/contest.d.ts +318 -0
  124. package/dist/contest.js +753 -0
  125. package/dist/control-setup.d.ts +35 -0
  126. package/dist/control-setup.js +40 -0
  127. package/dist/control-ui.d.ts +29 -0
  128. package/dist/control-ui.js +53 -0
  129. package/dist/controller-service.d.ts +1 -0
  130. package/dist/controller-service.js +19 -0
  131. package/dist/controller-supervisor.d.ts +30 -0
  132. package/dist/controller-supervisor.js +104 -0
  133. package/dist/converse.d.ts +304 -0
  134. package/dist/converse.js +849 -0
  135. package/dist/coordinator-proposals.d.ts +29 -0
  136. package/dist/coordinator-proposals.js +137 -0
  137. package/dist/coordinator.d.ts +186 -0
  138. package/dist/coordinator.js +447 -0
  139. package/dist/credentials-ui.d.ts +25 -0
  140. package/dist/credentials-ui.js +48 -0
  141. package/dist/daemon.d.ts +209 -0
  142. package/dist/daemon.js +604 -0
  143. package/dist/decision.d.ts +120 -0
  144. package/dist/decision.js +388 -0
  145. package/dist/demo.d.ts +55 -0
  146. package/dist/demo.js +992 -0
  147. package/dist/desktop-access.d.ts +28 -0
  148. package/dist/desktop-access.js +88 -0
  149. package/dist/desktop-bundle.d.ts +16 -0
  150. package/dist/desktop-bundle.js +74 -0
  151. package/dist/desktop-host.d.ts +54 -0
  152. package/dist/desktop-host.js +508 -0
  153. package/dist/desktop-update-gate.d.ts +12 -0
  154. package/dist/desktop-update-gate.js +91 -0
  155. package/dist/desktop-update-recovery.d.ts +14 -0
  156. package/dist/desktop-update-recovery.js +243 -0
  157. package/dist/desktop-update.d.ts +110 -0
  158. package/dist/desktop-update.js +740 -0
  159. package/dist/discord-api.d.ts +20 -0
  160. package/dist/discord-api.js +138 -0
  161. package/dist/discord-chat.d.ts +16 -0
  162. package/dist/discord-chat.js +381 -0
  163. package/dist/discord-settings.d.ts +7 -0
  164. package/dist/discord-settings.js +75 -0
  165. package/dist/discord.d.ts +10 -0
  166. package/dist/discord.js +169 -0
  167. package/dist/discover.d.ts +75 -0
  168. package/dist/discover.js +150 -0
  169. package/dist/dispatch.d.ts +100 -0
  170. package/dist/dispatch.js +311 -0
  171. package/dist/dispose.d.ts +161 -0
  172. package/dist/dispose.js +803 -0
  173. package/dist/email-settings.d.ts +34 -0
  174. package/dist/email-settings.js +50 -0
  175. package/dist/envelope.d.ts +47 -0
  176. package/dist/envelope.js +105 -0
  177. package/dist/evidence-pack.d.ts +185 -0
  178. package/dist/evidence-pack.js +258 -0
  179. package/dist/evidence.d.ts +345 -0
  180. package/dist/evidence.js +782 -0
  181. package/dist/exec.d.ts +285 -0
  182. package/dist/exec.js +1841 -0
  183. package/dist/exhaustion.d.ts +85 -0
  184. package/dist/exhaustion.js +141 -0
  185. package/dist/export-ui.d.ts +4 -0
  186. package/dist/export-ui.js +17 -0
  187. package/dist/export.d.ts +48 -0
  188. package/dist/export.js +305 -0
  189. package/dist/flow-actions.d.ts +77 -0
  190. package/dist/flow-actions.js +203 -0
  191. package/dist/flow-code.d.ts +50 -0
  192. package/dist/flow-code.js +159 -0
  193. package/dist/flow-draft.d.ts +38 -0
  194. package/dist/flow-draft.js +80 -0
  195. package/dist/flow-engine.d.ts +66 -0
  196. package/dist/flow-engine.js +416 -0
  197. package/dist/flow-insights.d.ts +95 -0
  198. package/dist/flow-insights.js +149 -0
  199. package/dist/flow-live.d.ts +24 -0
  200. package/dist/flow-live.js +150 -0
  201. package/dist/flow-people.d.ts +24 -0
  202. package/dist/flow-people.js +91 -0
  203. package/dist/flow-replies.d.ts +48 -0
  204. package/dist/flow-replies.js +113 -0
  205. package/dist/flow-scripts.d.ts +38 -0
  206. package/dist/flow-scripts.js +73 -0
  207. package/dist/flow-secrets.d.ts +13 -0
  208. package/dist/flow-secrets.js +45 -0
  209. package/dist/flow-sort.d.ts +82 -0
  210. package/dist/flow-sort.js +153 -0
  211. package/dist/flow-steps.d.ts +40 -0
  212. package/dist/flow-steps.js +408 -0
  213. package/dist/flow-triggers.d.ts +244 -0
  214. package/dist/flow-triggers.js +959 -0
  215. package/dist/flows-ui.d.ts +20 -0
  216. package/dist/flows-ui.js +175 -0
  217. package/dist/flows.d.ts +229 -0
  218. package/dist/flows.js +968 -0
  219. package/dist/fonts.d.ts +19 -0
  220. package/dist/fonts.js +19 -0
  221. package/dist/gaps.d.ts +35 -0
  222. package/dist/gaps.js +101 -0
  223. package/dist/gate-failure.d.ts +40 -0
  224. package/dist/gate-failure.js +66 -0
  225. package/dist/git.d.ts +54 -0
  226. package/dist/git.js +94 -0
  227. package/dist/google-mail.d.ts +58 -0
  228. package/dist/google-mail.js +151 -0
  229. package/dist/grant.d.ts +136 -0
  230. package/dist/grant.js +238 -0
  231. package/dist/graph.d.ts +164 -0
  232. package/dist/graph.js +383 -0
  233. package/dist/guides.d.ts +22 -0
  234. package/dist/guides.js +363 -0
  235. package/dist/held.d.ts +150 -0
  236. package/dist/held.js +799 -0
  237. package/dist/invoke.d.ts +112 -0
  238. package/dist/invoke.js +780 -0
  239. package/dist/issues.d.ts +41 -0
  240. package/dist/issues.js +131 -0
  241. package/dist/job-object-helper.ps1 +252 -0
  242. package/dist/jsonl-discriminants.d.ts +11 -0
  243. package/dist/jsonl-discriminants.js +93 -0
  244. package/dist/keys.d.ts +104 -0
  245. package/dist/keys.js +229 -0
  246. package/dist/kits-ui.d.ts +17 -0
  247. package/dist/kits-ui.js +46 -0
  248. package/dist/kits.d.ts +86 -0
  249. package/dist/kits.js +215 -0
  250. package/dist/knowledge-cli.d.ts +101 -0
  251. package/dist/knowledge-cli.js +92 -0
  252. package/dist/knowledge-ui.d.ts +24 -0
  253. package/dist/knowledge-ui.js +40 -0
  254. package/dist/lead-context.d.ts +8 -0
  255. package/dist/lead-context.js +56 -0
  256. package/dist/lead-follow.d.ts +22 -0
  257. package/dist/lead-follow.js +167 -0
  258. package/dist/lead-status.d.ts +75 -0
  259. package/dist/lead-status.js +285 -0
  260. package/dist/ledger-chain.d.ts +46 -0
  261. package/dist/ledger-chain.js +187 -0
  262. package/dist/ledger-csv.d.ts +4 -0
  263. package/dist/ledger-csv.js +11 -0
  264. package/dist/ledger-view.d.ts +24 -0
  265. package/dist/ledger-view.js +58 -0
  266. package/dist/limits-ui.d.ts +26 -0
  267. package/dist/limits-ui.js +67 -0
  268. package/dist/link.d.ts +72 -0
  269. package/dist/link.js +217 -0
  270. package/dist/live.d.ts +96 -0
  271. package/dist/live.js +366 -0
  272. package/dist/liveness.d.ts +30 -0
  273. package/dist/liveness.js +42 -0
  274. package/dist/log.d.ts +7 -0
  275. package/dist/log.js +25 -0
  276. package/dist/mailbox.d.ts +57 -0
  277. package/dist/mailbox.js +150 -0
  278. package/dist/maintenance.d.ts +11 -0
  279. package/dist/maintenance.js +35 -0
  280. package/dist/mate-cli.d.ts +52 -0
  281. package/dist/mate-cli.js +345 -0
  282. package/dist/mate-contract.d.ts +10 -0
  283. package/dist/mate-contract.js +30 -0
  284. package/dist/mate-doors.d.ts +69 -0
  285. package/dist/mate-doors.js +548 -0
  286. package/dist/mate-progress.d.ts +29 -0
  287. package/dist/mate-progress.js +121 -0
  288. package/dist/mate-tools.d.ts +94 -0
  289. package/dist/mate-tools.js +1878 -0
  290. package/dist/mate.d.ts +92 -0
  291. package/dist/mate.js +435 -0
  292. package/dist/mcp-connect.d.ts +103 -0
  293. package/dist/mcp-connect.js +252 -0
  294. package/dist/mcp.d.ts +27 -0
  295. package/dist/mcp.js +651 -0
  296. package/dist/memory-cli.d.ts +466 -0
  297. package/dist/memory-cli.js +111 -0
  298. package/dist/memory-pass.d.ts +160 -0
  299. package/dist/memory-pass.js +409 -0
  300. package/dist/metrics.d.ts +3 -0
  301. package/dist/metrics.js +56 -0
  302. package/dist/mobile-viewport.d.ts +3 -0
  303. package/dist/mobile-viewport.js +37 -0
  304. package/dist/model-catalog.d.ts +118 -0
  305. package/dist/model-catalog.js +376 -0
  306. package/dist/models-cli.d.ts +102 -0
  307. package/dist/models-cli.js +66 -0
  308. package/dist/models-ui.d.ts +34 -0
  309. package/dist/models-ui.js +68 -0
  310. package/dist/modes.d.ts +80 -0
  311. package/dist/modes.js +182 -0
  312. package/dist/monitoring-settings.d.ts +44 -0
  313. package/dist/monitoring-settings.js +173 -0
  314. package/dist/monitoring-ui.d.ts +20 -0
  315. package/dist/monitoring-ui.js +51 -0
  316. package/dist/monitoring.d.ts +34 -0
  317. package/dist/monitoring.js +221 -0
  318. package/dist/names.d.ts +67 -0
  319. package/dist/names.js +102 -0
  320. package/dist/observations.d.ts +40 -0
  321. package/dist/observations.js +211 -0
  322. package/dist/oidc.d.ts +71 -0
  323. package/dist/oidc.js +182 -0
  324. package/dist/onboard.d.ts +108 -0
  325. package/dist/onboard.js +325 -0
  326. package/dist/openrouter-models.d.ts +30 -0
  327. package/dist/openrouter-models.js +120 -0
  328. package/dist/operate.d.ts +118 -0
  329. package/dist/operate.js +11321 -0
  330. package/dist/peek-cli.d.ts +97 -0
  331. package/dist/peek-cli.js +215 -0
  332. package/dist/peek.d.ts +134 -0
  333. package/dist/peek.js +578 -0
  334. package/dist/phase-routing.d.ts +307 -0
  335. package/dist/phase-routing.js +658 -0
  336. package/dist/plan-auto.d.ts +14 -0
  337. package/dist/plan-auto.js +92 -0
  338. package/dist/plan.d.ts +186 -0
  339. package/dist/plan.js +401 -0
  340. package/dist/planner-source.d.ts +251 -0
  341. package/dist/planner-source.js +460 -0
  342. package/dist/planner.d.ts +75 -0
  343. package/dist/planner.js +992 -0
  344. package/dist/policy-ui.d.ts +20 -0
  345. package/dist/policy-ui.js +41 -0
  346. package/dist/policy.d.ts +111 -0
  347. package/dist/policy.js +231 -0
  348. package/dist/prepared-evidence.d.ts +16 -0
  349. package/dist/prepared-evidence.js +96 -0
  350. package/dist/pricing.d.ts +45 -0
  351. package/dist/pricing.js +77 -0
  352. package/dist/principal.d.ts +42 -0
  353. package/dist/principal.js +82 -0
  354. package/dist/probe.d.ts +46 -0
  355. package/dist/probe.js +79 -0
  356. package/dist/process-custody.d.ts +17 -0
  357. package/dist/process-custody.js +91 -0
  358. package/dist/process-liveness.d.ts +10 -0
  359. package/dist/process-liveness.js +81 -0
  360. package/dist/process-recovery-anchor.d.ts +75 -0
  361. package/dist/process-recovery-anchor.js +188 -0
  362. package/dist/process-recovery-coalition.d.ts +25 -0
  363. package/dist/process-recovery-coalition.js +105 -0
  364. package/dist/process-recovery-eligibility.d.ts +178 -0
  365. package/dist/process-recovery-eligibility.js +170 -0
  366. package/dist/process-recovery-native.d.ts +172 -0
  367. package/dist/process-recovery-native.js +592 -0
  368. package/dist/process-recovery-provenance.d.ts +163 -0
  369. package/dist/process-recovery-provenance.js +292 -0
  370. package/dist/process-recovery-services.d.ts +56 -0
  371. package/dist/process-recovery-services.js +191 -0
  372. package/dist/process-recovery-settlement.d.ts +32 -0
  373. package/dist/process-recovery-settlement.js +132 -0
  374. package/dist/process-recovery.d.ts +41 -0
  375. package/dist/process-recovery.js +91 -0
  376. package/dist/process-tree.d.ts +41 -0
  377. package/dist/process-tree.js +264 -0
  378. package/dist/project-access.d.ts +14 -0
  379. package/dist/project-access.js +34 -0
  380. package/dist/project-cli.d.ts +18 -0
  381. package/dist/project-cli.js +104 -0
  382. package/dist/project-delete-ui.d.ts +22 -0
  383. package/dist/project-delete-ui.js +48 -0
  384. package/dist/project-delete.d.ts +55 -0
  385. package/dist/project-delete.js +413 -0
  386. package/dist/project-knowledge.d.ts +111 -0
  387. package/dist/project-knowledge.js +241 -0
  388. package/dist/project-learning.d.ts +100 -0
  389. package/dist/project-learning.js +438 -0
  390. package/dist/project-memory.d.ts +81 -0
  391. package/dist/project-memory.js +264 -0
  392. package/dist/project-skills.d.ts +152 -0
  393. package/dist/project-skills.js +660 -0
  394. package/dist/project-tools.d.ts +219 -0
  395. package/dist/project-tools.js +796 -0
  396. package/dist/project.d.ts +61 -0
  397. package/dist/project.js +125 -0
  398. package/dist/prompt.d.ts +22 -0
  399. package/dist/prompt.js +72 -0
  400. package/dist/proof.d.ts +513 -0
  401. package/dist/proof.js +1140 -0
  402. package/dist/proposal.d.ts +82 -0
  403. package/dist/proposal.js +210 -0
  404. package/dist/provider-connection.d.ts +22 -0
  405. package/dist/provider-connection.js +98 -0
  406. package/dist/provider-limits.d.ts +35 -0
  407. package/dist/provider-limits.js +95 -0
  408. package/dist/provider.d.ts +279 -0
  409. package/dist/provider.js +935 -0
  410. package/dist/publish.d.ts +107 -0
  411. package/dist/publish.js +650 -0
  412. package/dist/pulls.d.ts +118 -0
  413. package/dist/pulls.js +240 -0
  414. package/dist/push.d.ts +95 -0
  415. package/dist/push.js +353 -0
  416. package/dist/quality.d.ts +8 -0
  417. package/dist/quality.js +6 -0
  418. package/dist/recipe-ui.d.ts +18 -0
  419. package/dist/recipe-ui.js +152 -0
  420. package/dist/recipes.d.ts +81 -0
  421. package/dist/recipes.js +332 -0
  422. package/dist/remote.d.ts +67 -0
  423. package/dist/remote.js +122 -0
  424. package/dist/render.d.ts +64 -0
  425. package/dist/render.js +587 -0
  426. package/dist/report-summary.d.ts +13 -0
  427. package/dist/report-summary.js +15 -0
  428. package/dist/repos.d.ts +65 -0
  429. package/dist/repos.js +277 -0
  430. package/dist/repository-context-ui.d.ts +2 -0
  431. package/dist/repository-context-ui.js +10 -0
  432. package/dist/repository-context.d.ts +67 -0
  433. package/dist/repository-context.js +376 -0
  434. package/dist/restart-certification.d.ts +134 -0
  435. package/dist/restart-certification.js +226 -0
  436. package/dist/result-actions.d.ts +28 -0
  437. package/dist/result-actions.js +270 -0
  438. package/dist/result-completion.d.ts +18 -0
  439. package/dist/result-completion.js +66 -0
  440. package/dist/result-review.d.ts +213 -0
  441. package/dist/result-review.js +430 -0
  442. package/dist/retention-ui.d.ts +12 -0
  443. package/dist/retention-ui.js +39 -0
  444. package/dist/retention.d.ts +72 -0
  445. package/dist/retention.js +288 -0
  446. package/dist/review-context.d.ts +243 -0
  447. package/dist/review-context.js +1043 -0
  448. package/dist/review-evidence.d.ts +33 -0
  449. package/dist/review-evidence.js +56 -0
  450. package/dist/reviewer.d.ts +97 -0
  451. package/dist/reviewer.js +202 -0
  452. package/dist/routine.d.ts +228 -0
  453. package/dist/routine.js +764 -0
  454. package/dist/runner.d.ts +266 -0
  455. package/dist/runner.js +399 -0
  456. package/dist/scan.d.ts +32 -0
  457. package/dist/scan.js +92 -0
  458. package/dist/scope.d.ts +695 -0
  459. package/dist/scope.js +1176 -0
  460. package/dist/scout-report.d.ts +42 -0
  461. package/dist/scout-report.js +108 -0
  462. package/dist/scout.d.ts +69 -0
  463. package/dist/scout.js +351 -0
  464. package/dist/serve.d.ts +313 -0
  465. package/dist/serve.js +21776 -0
  466. package/dist/session-brief.d.ts +8 -0
  467. package/dist/session-brief.js +24 -0
  468. package/dist/session-cli.d.ts +13 -0
  469. package/dist/session-cli.js +284 -0
  470. package/dist/session-contract.d.ts +183 -0
  471. package/dist/session-contract.js +122 -0
  472. package/dist/session-http.d.ts +19 -0
  473. package/dist/session-http.js +154 -0
  474. package/dist/session-server.d.ts +12 -0
  475. package/dist/session-server.js +37 -0
  476. package/dist/session-service.d.ts +22 -0
  477. package/dist/session-service.js +157 -0
  478. package/dist/setup-guide.d.ts +39 -0
  479. package/dist/setup-guide.js +93 -0
  480. package/dist/sign-in-guard.d.ts +45 -0
  481. package/dist/sign-in-guard.js +74 -0
  482. package/dist/skills-ui.d.ts +11 -0
  483. package/dist/skills-ui.js +58 -0
  484. package/dist/skills.d.ts +97 -0
  485. package/dist/skills.js +276 -0
  486. package/dist/slack-api.d.ts +55 -0
  487. package/dist/slack-api.js +229 -0
  488. package/dist/slack-chat.d.ts +28 -0
  489. package/dist/slack-chat.js +413 -0
  490. package/dist/slack-settings.d.ts +7 -0
  491. package/dist/slack-settings.js +75 -0
  492. package/dist/slack-state.d.ts +13 -0
  493. package/dist/slack-state.js +9 -0
  494. package/dist/slack.d.ts +13 -0
  495. package/dist/slack.js +167 -0
  496. package/dist/spend-ui.d.ts +28 -0
  497. package/dist/spend-ui.js +95 -0
  498. package/dist/spend.d.ts +142 -0
  499. package/dist/spend.js +313 -0
  500. package/dist/sqlite-runtime.d.ts +3 -0
  501. package/dist/sqlite-runtime.js +6 -0
  502. package/dist/sso-settings.d.ts +33 -0
  503. package/dist/sso-settings.js +103 -0
  504. package/dist/sso-ui.d.ts +22 -0
  505. package/dist/sso-ui.js +39 -0
  506. package/dist/storage.d.ts +15 -0
  507. package/dist/storage.js +84 -0
  508. package/dist/store.d.ts +7754 -0
  509. package/dist/store.js +23279 -0
  510. package/dist/structured-output.d.ts +58 -0
  511. package/dist/structured-output.js +103 -0
  512. package/dist/style-asset.d.ts +7 -0
  513. package/dist/style-asset.js +41 -0
  514. package/dist/subscription-chat.d.ts +46 -0
  515. package/dist/subscription-chat.js +275 -0
  516. package/dist/summary.d.ts +33 -0
  517. package/dist/summary.js +71 -0
  518. package/dist/supervisor.mjs +297 -0
  519. package/dist/surface.d.ts +63 -0
  520. package/dist/surface.js +352 -0
  521. package/dist/sync.d.ts +94 -0
  522. package/dist/sync.js +279 -0
  523. package/dist/task-composer.d.ts +13 -0
  524. package/dist/task-composer.js +52 -0
  525. package/dist/task-control.d.ts +165 -0
  526. package/dist/task-control.js +214 -0
  527. package/dist/task-outcome-cli.d.ts +10 -0
  528. package/dist/task-outcome-cli.js +92 -0
  529. package/dist/task-text.d.ts +30 -0
  530. package/dist/task-text.js +41 -0
  531. package/dist/team-cli.d.ts +15 -0
  532. package/dist/team-cli.js +618 -0
  533. package/dist/team-contract.d.ts +92 -0
  534. package/dist/team-contract.js +1 -0
  535. package/dist/team-http.d.ts +17 -0
  536. package/dist/team-http.js +144 -0
  537. package/dist/team-leads.d.ts +81 -0
  538. package/dist/team-leads.js +565 -0
  539. package/dist/team-runtime.d.ts +27 -0
  540. package/dist/team-runtime.js +264 -0
  541. package/dist/team-ui.d.ts +3 -0
  542. package/dist/team-ui.js +9 -0
  543. package/dist/team-updates.d.ts +8 -0
  544. package/dist/team-updates.js +57 -0
  545. package/dist/teammate-admin.d.ts +53 -0
  546. package/dist/teammate-admin.js +111 -0
  547. package/dist/teammate-desk.d.ts +60 -0
  548. package/dist/teammate-desk.js +154 -0
  549. package/dist/teammate-memory.d.ts +64 -0
  550. package/dist/teammate-memory.js +196 -0
  551. package/dist/teammate-question.d.ts +59 -0
  552. package/dist/teammate-question.js +175 -0
  553. package/dist/teammate-tools.d.ts +108 -0
  554. package/dist/teammate-tools.js +271 -0
  555. package/dist/teammate-week.d.ts +68 -0
  556. package/dist/teammate-week.js +132 -0
  557. package/dist/teammate-work.d.ts +76 -0
  558. package/dist/teammate-work.js +280 -0
  559. package/dist/teammates-ui.d.ts +20 -0
  560. package/dist/teammates-ui.js +166 -0
  561. package/dist/teammates.d.ts +180 -0
  562. package/dist/teammates.js +253 -0
  563. package/dist/teams-api.d.ts +33 -0
  564. package/dist/teams-api.js +204 -0
  565. package/dist/teams-chat.d.ts +26 -0
  566. package/dist/teams-chat.js +215 -0
  567. package/dist/teams-settings.d.ts +12 -0
  568. package/dist/teams-settings.js +57 -0
  569. package/dist/teams.d.ts +21 -0
  570. package/dist/teams.js +124 -0
  571. package/dist/telegram-flow.d.ts +49 -0
  572. package/dist/telegram-flow.js +110 -0
  573. package/dist/telegram-mate.d.ts +137 -0
  574. package/dist/telegram-mate.js +707 -0
  575. package/dist/telegram-progress.d.ts +14 -0
  576. package/dist/telegram-progress.js +129 -0
  577. package/dist/telegram-settings.d.ts +9 -0
  578. package/dist/telegram-settings.js +36 -0
  579. package/dist/telegram-status.d.ts +75 -0
  580. package/dist/telegram-status.js +265 -0
  581. package/dist/telegram-team.d.ts +85 -0
  582. package/dist/telegram-team.js +359 -0
  583. package/dist/telegram.d.ts +230 -0
  584. package/dist/telegram.js +1931 -0
  585. package/dist/templates.d.ts +65 -0
  586. package/dist/templates.js +118 -0
  587. package/dist/tool-launcher.d.ts +1 -0
  588. package/dist/tool-launcher.js +101 -0
  589. package/dist/tools-ui.d.ts +30 -0
  590. package/dist/tools-ui.js +40 -0
  591. package/dist/transitions-recipes.d.ts +1 -0
  592. package/dist/transitions-recipes.js +203 -0
  593. package/dist/tree-proof.d.ts +34 -0
  594. package/dist/tree-proof.js +58 -0
  595. package/dist/verification-evidence.d.ts +44 -0
  596. package/dist/verification-evidence.js +238 -0
  597. package/dist/version.d.ts +2 -0
  598. package/dist/version.js +10 -0
  599. package/dist/webhooks.d.ts +91 -0
  600. package/dist/webhooks.js +283 -0
  601. package/dist/work-index.d.ts +68 -0
  602. package/dist/work-index.js +384 -0
  603. package/dist/work-summary.d.ts +57 -0
  604. package/dist/work-summary.js +116 -0
  605. package/dist/workspace-motion.d.ts +4 -0
  606. package/dist/workspace-motion.js +133 -0
  607. package/dist/workspace-revision.d.ts +19 -0
  608. package/dist/workspace-revision.js +92 -0
  609. package/dist/workspace-ui.d.ts +204 -0
  610. package/dist/workspace-ui.js +411 -0
  611. package/dist/worktree-notices.d.ts +13 -0
  612. package/dist/worktree-notices.js +17 -0
  613. package/dist/worktree.d.ts +249 -0
  614. package/dist/worktree.js +720 -0
  615. package/package.json +122 -0
  616. package/scripts/canary-assertions.mjs +42 -0
  617. package/scripts/crash-canary.mjs +253 -0
  618. package/scripts/fixtures/crash-process.mjs +74 -0
  619. package/scripts/fixtures/pilot-scenarios.mjs +85 -0
  620. package/scripts/fixtures/restart-service.mjs +27 -0
  621. package/scripts/launchd-certification.mjs +143 -0
  622. package/scripts/pilot.mjs +121 -0
  623. package/scripts/proof-preflight.mjs +140 -0
  624. package/scripts/provider-canary.mjs +379 -0
  625. package/scripts/recovery-canary.mjs +135 -0
  626. package/scripts/restart-certification.mjs +98 -0
package/dist/claim.js ADDED
@@ -0,0 +1,1740 @@
1
+ /**
2
+ * The claim, and the fence around it.
3
+ *
4
+ * This is the record §4 calls the one that matters, and the reason is a
5
+ * specific 3am failure. A runner takes a task, starts work, and then stops
6
+ * being reachable — the machine sleeps, the process is OOM-killed, the network
7
+ * partitions. The scheduler cannot tell "dead" from "slow", so it waits for the
8
+ * lease to expire and gives the task to someone else. Then the first runner
9
+ * wakes up, finishes the work it was doing, and reports success for a task that
10
+ * another runner is now halfway through.
11
+ *
12
+ * Both runners are behaving correctly. Without a fence, the control plane
13
+ * believes the last one to speak.
14
+ *
15
+ * So a lease carries two things. `lease_id` is immutable and identifies one
16
+ * grant of one task to one runner. `lease_generation` counts how many times
17
+ * that task has been granted at all, and it only ever goes up. Acquiring is a
18
+ * compare-and-swap on the generation; every later call carries the lease it
19
+ * thinks it holds, and anything whose generation has been superseded is
20
+ * refused. The late completion above is refused not because we detected the
21
+ * crash — we never did — but because the world moved on without it.
22
+ *
23
+ * Losing a race here is ordinary, not an error. Several runners asking for the
24
+ * same task at the same moment is the system working; exactly one wins.
25
+ *
26
+ * Time is a parameter everywhere. Expiry decided by an implicit clock is
27
+ * untestable, and a lease is exactly the kind of thing that has to be provable
28
+ * rather than probable.
29
+ */
30
+ import { randomUUID } from "node:crypto";
31
+ import { attendedLivenessState } from "./liveness.js";
32
+ import { authenticate } from "./runner.js";
33
+ import { DEFAULT_MAX_OPEN_DECISIONS, missingCapability, scopeApprovedForDispatch, taskReadinessBlocker, } from "./dispatch.js";
34
+ /** attendedLivenessState over the store's ISO columns. */
35
+ function attendedWatchState(lastBeatAt, now, absoluteExpiry) {
36
+ return attendedLivenessState(lastBeatAt === null ? null : Date.parse(lastBeatAt), now.getTime(), Date.parse(absoluteExpiry));
37
+ }
38
+ import { BUILT_IN, } from "./store.js";
39
+ import { digestOf } from "./scope.js";
40
+ import { parseExecutionPlanDocument } from "./plan.js";
41
+ import { sealUnchangedPlanUnderMode } from "./plan-auto.js";
42
+ import { routeFromJson } from "./phase-routing.js";
43
+ import { contractChangesOf, decodePlannerSource, encodePlannerSource, canonicalContractJson, PLANNER_SOURCE_LIMITS, describeContractChanges, encodePlanContractRecord, plannerContractOf, plannerSourceDigest, } from "./planner-source.js";
44
+ import { storeEvidence, readVerifiedArtifact, EVIDENCE_CAPS } from "./evidence.js";
45
+ /** How stale a mirror's last complete sync may be before admission refuses. */
46
+ export const SYNC_MAX_AGE_MS = 15 * 60_000;
47
+ /**
48
+ * Long enough that an ordinary build does not lose its lease mid-thought,
49
+ * short enough that a dead runner's work is picked up the same night rather
50
+ * than at breakfast.
51
+ */
52
+ export const DEFAULT_LEASE_MS = 15 * 60_000;
53
+ /**
54
+ * Take the task, if it is free.
55
+ *
56
+ * Free means no claim, or a claim whose lease expired, or one already released.
57
+ * A live lease belonging to somebody else is refused with who holds it and
58
+ * until when, because "no" without a reason is indistinguishable from a bug.
59
+ */
60
+ export function acquire(store, taskRef, runner, options) {
61
+ // Replay detection is ACQUIRE-SPECIFIC (external dispatch, finding 36):
62
+ // the stored payload is untouched — the flag rides only the returned
63
+ // copy, so a second replay cannot double-stamp, and Store.replay's T→T
64
+ // contract holds for every other user.
65
+ const replayed = store.hasMutationRecord(options.mutation ?? {});
66
+ const result = inTransaction(store, () => acquireLocked(store, taskRef, runner, options));
67
+ return replayed && result.ok ? { ...result, replayed: true } : result;
68
+ }
69
+ /**
70
+ * The body of `acquire`, for callers already holding the transaction.
71
+ * `acquireIfReady` needs its readiness check and the CAS to be one atomic
72
+ * step, and SQLite does not nest transactions.
73
+ */
74
+ function acquireLocked(store, taskRef, runner, options) {
75
+ const { now, ttlMs = DEFAULT_LEASE_MS, newLeaseId = randomUUID, mutation = {} } = options;
76
+ const db = store.handle;
77
+ // Idempotency wraps the CAS, not the other way round: a retried
78
+ // acquire must hand back the first answer rather than take a second lease.
79
+ return (store.replay(mutation, "acquire", () => {
80
+ const stamp = now.toISOString();
81
+ // THE RUNNER GATE, first leg (MCP gateway spec v6). Identity is
82
+ // proven INSIDE this transaction — the row re-read, the hash
83
+ // re-compared — so a takeover between the caller's own auth and
84
+ // this CAS never lets a stale process ride its successor's
85
+ // authority. Then the repo tuple: authority derives from
86
+ // task_ref.repo ONLY; a task placed nowhere cannot be authorized
87
+ // by a repo-scoped runner, and a runner not bound to the task's
88
+ // repo never even holds a claim.
89
+ const gate = authenticate(store, runner, options.token);
90
+ if (!gate.ok)
91
+ return { ok: false, reason: "unauthenticated", detail: gate.reason };
92
+ const placedRef = store.refForId(taskRef);
93
+ const placedRepo = placedRef?.repo ?? null;
94
+ if (placedRepo === null)
95
+ return { ok: false, reason: "unplaced" };
96
+ if (!gate.runner.repos.includes(placedRepo)) {
97
+ return { ok: false, reason: "unauthorized-repo", repo: placedRepo };
98
+ }
99
+ // THE COORDINATOR QUARANTINE in the one primitive every claim road
100
+ // shares (MCP spec v6, round-3 f1): a coordinator-filed task whose
101
+ // scope has not been sealed by the password ceremony claims for
102
+ // NOBODY — not the pre-approval planner road, not an attended
103
+ // authorization, not a raw CLI claim. One SQL predicate, so roads
104
+ // cannot diverge. After the seal it is ordinary in every respect.
105
+ if (placedRef !== null && placedRef.coordinatorCid !== null && !store.scopeSealed(placedRef.externalId)) {
106
+ return { ok: false, reason: "coordinator-filed" };
107
+ }
108
+ // The reservation gate lives HERE, in the one primitive every
109
+ // acquisition path shares (queue-columns review, finding 1): tick's
110
+ // acquireIfReady, the raw CLI claim, and the tournament resume all
111
+ // pass through this line, so a task reserved for one worker can
112
+ // never be taken by another, whatever the caller's snapshot said.
113
+ // Scheduling, not authority: nothing about WHAT may build changes.
114
+ const reservedFor = store.assignedRunnerOf(taskRef);
115
+ if (reservedFor !== null && reservedFor !== runner) {
116
+ return { ok: false, reason: "reserved", reservedFor };
117
+ }
118
+ // The external-mirror gate (dispatch v3 §2): every acquisition path
119
+ // shares this line, so a stale, closed, revoked, or plane-blocked
120
+ // mirror never starts a build — whatever the caller's snapshot said.
121
+ // An ordinary task costs one indexed lookup.
122
+ const mirrorWhy = store.mirrorAdmissionRefusal(taskRef, now, options.syncMaxAgeMs ?? SYNC_MAX_AGE_MS);
123
+ if (mirrorWhy !== null && mirrorWhy !== "not-a-mirror") {
124
+ return { ok: false, reason: "external", detail: mirrorWhy };
125
+ }
126
+ // The attended gate (Phase 2, v6 W10/Q4), in the one primitive every
127
+ // acquisition path shares — raw CLI claims included: a task with an
128
+ // OPEN attended authorization dispatches only to its named runner,
129
+ // and a task whose ONLY authority is attended dispatches only while
130
+ // the operator is watching, with the one attempt unspent.
131
+ // An authorization past its absolute expiry gates NOTHING (round-6
132
+ // finding 8): it is a corpse the sweep will close, and letting it
133
+ // keep refusing other runners on an approved task would be a
134
+ // permanent lock nobody signed.
135
+ // THE MODE BELT on the one primitive every claim road shares (Codex
136
+ // people round 2, finding 2): a mode-sealed approval whose signature
137
+ // no longer stands — revoked, expired by clock, or a dead signer —
138
+ // does not dispatch, CLI claim included. Live claims already taken
139
+ // are untouched; this fences only NEW takes.
140
+ if (!store.modeApprovalLive(taskRef, now)) {
141
+ return {
142
+ ok: false,
143
+ reason: "mode-ended",
144
+ message: "the operating mode that approved this has ended — the approval falls back to a person",
145
+ };
146
+ }
147
+ const attendedRow = store.openAuthorizationFor(taskRef);
148
+ const attendedOpen = attendedRow !== null && Date.parse(attendedRow.absoluteExpiry) > now.getTime() ? attendedRow : null;
149
+ if (attendedOpen !== null) {
150
+ if (attendedOpen.runner !== runner) {
151
+ return { ok: false, reason: "attended-held", runner: attendedOpen.runner };
152
+ }
153
+ const scopeApproved = db
154
+ .prepare(`SELECT 1 AS hit FROM task_scope
155
+ JOIN task_ref ON task_ref.id = ? AND task_scope.task_id = task_ref.external_id
156
+ WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL AND (COALESCE(task_scope.approval_basis, 'password') <> 'mode'
157
+ OR EXISTS (SELECT 1 FROM operating_mode om
158
+ JOIN approver signer ON signer.name = om.signed_by
159
+ WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
160
+ AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
161
+ AND signer.revoked_at IS NULL AND signer.role = 'approver'))`)
162
+ .get(taskRef, now.toISOString());
163
+ if (scopeApproved === undefined) {
164
+ const watching = attendedWatchState(attendedOpen.lastBeatAt, now, attendedOpen.absoluteExpiry);
165
+ if ((watching !== "live" && watching !== "grace") || attendedOpen.attemptRun !== null) {
166
+ return { ok: false, reason: "attended-only" };
167
+ }
168
+ }
169
+ }
170
+ const existing = latest(db, taskRef);
171
+ if (existing !== undefined && isLive(existing, stamp)) {
172
+ return {
173
+ ok: false,
174
+ reason: "held",
175
+ by: String(existing["runner"]),
176
+ until: String(existing["expires_at"]),
177
+ };
178
+ }
179
+ const generation = existing === undefined ? 1 : Number(existing["lease_generation"]) + 1;
180
+ const claim = {
181
+ taskRef,
182
+ leaseId: newLeaseId(),
183
+ generation,
184
+ runner,
185
+ acquiredAt: stamp,
186
+ expiresAt: new Date(now.getTime() + ttlMs).toISOString(),
187
+ heartbeatAt: stamp,
188
+ };
189
+ // The compare-and-swap, enforced by UNIQUE (task_ref, lease_generation)
190
+ // rather than by anything this code does. Two runners that both read the
191
+ // same generation both try to write generation + 1; the database admits
192
+ // one. OR IGNORE turns the loser's constraint violation into a row count
193
+ // of zero, because losing a race is an ordinary outcome and not an error.
194
+ const { changes } = db
195
+ .prepare(`INSERT OR IGNORE INTO claim
196
+ (lease_id, task_ref, lease_generation, runner, acquired_at, expires_at, heartbeat_at, released_at, incarnation)
197
+ VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)`)
198
+ .run(claim.leaseId, taskRef, claim.generation, runner, claim.acquiredAt, claim.expiresAt, claim.heartbeatAt, options.incarnation ?? null);
199
+ if (Number(changes) === 0) {
200
+ const winner = latest(db, taskRef);
201
+ return {
202
+ ok: false,
203
+ reason: "held",
204
+ by: winner === undefined ? "unknown" : String(winner["runner"]),
205
+ until: winner === undefined ? stamp : String(winner["expires_at"]),
206
+ };
207
+ }
208
+ return { ok: true, claim, reclaimed: existing !== undefined };
209
+ },
210
+ // Only a lease that was actually granted is worth remembering. Recording
211
+ // the refusal would make this key a permanent "no" for a task that is
212
+ // free again five minutes later.
213
+ result => result.ok));
214
+ }
215
+ /** The newest lease on a task, held or not. */
216
+ function latest(db, taskRef) {
217
+ return db
218
+ .prepare("SELECT * FROM claim WHERE task_ref = ? ORDER BY lease_generation DESC LIMIT 1")
219
+ .get(taskRef);
220
+ }
221
+ /** DESIGN §8 gate 6: above this many open decisions, stop dispatching the parkers.
222
+ * `missingCapability` remains re-exported for callers of the old claim seam. */
223
+ export { DEFAULT_MAX_OPEN_DECISIONS, missingCapability } from "./dispatch.js";
224
+ /**
225
+ * Take the task, if it is free *and still worth taking*.
226
+ *
227
+ * `listReady` then `acquire` is two reads of a world that moves between them:
228
+ * a hold placed, a blocker reopened, the task cancelled — and the CAS admits
229
+ * the claim anyway, because the CAS only defends against other claimants. An
230
+ * unattended pass has nobody watching who would notice the stale dispatch, so
231
+ * the readiness conditions are re-proved inside the same transaction as the
232
+ * acquire, against rows the write lock has already pinned.
233
+ *
234
+ * "Not ready" is a different answer from "held": held means somebody else got
235
+ * it, which is a race being won; not-ready means nobody should have it, and
236
+ * says why.
237
+ */
238
+ export function acquireIfReady(store, taskRef, runner, options) {
239
+ const role = options.dispatchRole ?? "builder";
240
+ return inTransaction(store, () => {
241
+ const why = taskReadinessBlocker(store, taskRef, options.now);
242
+ if (why !== null)
243
+ return { ok: false, reason: "not-ready", message: why.message };
244
+ // The role's own precondition, re-read where the write lock has pinned
245
+ // it — never an early return past the shared gates below. A planner
246
+ // dispatches exactly when the operator asked and no promise exists yet;
247
+ // a builder dispatches exactly when the promise is approved. A
248
+ // mode-sealed approval additionally re-proves its signature is STILL
249
+ // the live mode (belt to the demotion sweep — R-REVOKE's next gate
250
+ // holds even in the window before an expired mode is durably closed).
251
+ const approvedScope = scopeApprovedForDispatch(store, taskRef, options.now);
252
+ if (role === "planner") {
253
+ const ref = store.refForId(taskRef);
254
+ if (ref?.plan !== "requested") {
255
+ return { ok: false, reason: "not-ready", message: "no plan was requested" };
256
+ }
257
+ if (approvedScope) {
258
+ return { ok: false, reason: "not-ready", message: "the scope is already approved — nothing left to plan" };
259
+ }
260
+ }
261
+ // The deliverable is re-read here (v34): a scout dispatches exactly on
262
+ // a report task with an approved scope, and a builder never on one —
263
+ // the caller's survey chose the role, the row proves it.
264
+ if (role === "scout" || role === "builder") {
265
+ const deliverable = store.refForId(taskRef)?.deliverable ?? "branch";
266
+ if (role === "scout" && deliverable !== "report") {
267
+ return { ok: false, reason: "not-ready", message: "this task delivers a branch — a scout has nothing to report on it" };
268
+ }
269
+ if (role === "builder" && deliverable === "report") {
270
+ return { ok: false, reason: "not-ready", message: "this task delivers a report — a scout, never a builder, takes it" };
271
+ }
272
+ if (role === "scout" && !approvedScope) {
273
+ return { ok: false, reason: "not-ready", message: "the scope is not approved" };
274
+ }
275
+ }
276
+ let attendedAuthority = false;
277
+ if (role !== "planner" && !approvedScope) {
278
+ // The authority union (v6 W1): an unapproved scope still dispatches
279
+ // when a LIVE attended authorization names THIS runner and its one
280
+ // attempt is unspent — the authorization IS the authority. Everything
281
+ // else about readiness still gates below and in acquireLocked.
282
+ const authorization = store.openAuthorizationFor(taskRef);
283
+ const watching = authorization === null
284
+ ? null
285
+ : attendedWatchState(authorization.lastBeatAt, options.now, authorization.absoluteExpiry);
286
+ attendedAuthority =
287
+ authorization !== null &&
288
+ authorization.runner === runner &&
289
+ (watching === "live" || watching === "grace") &&
290
+ authorization.attemptRun === null;
291
+ if (!attendedAuthority) {
292
+ return { ok: false, reason: "not-ready", message: "the scope is not approved" };
293
+ }
294
+ }
295
+ // Capabilities are re-read inside the same transaction as the CAS, like
296
+ // every other readiness fact: a key that expired between the survey and
297
+ // the take must not be dispatched on the survey's answer.
298
+ const gap = missingCapability(store, taskRef, options.repo ?? null, options.now);
299
+ if (gap !== null)
300
+ return { ok: false, reason: "capability", message: gap };
301
+ // Capacity (§8 gate 3), counted where the claim lands — two overlapping
302
+ // passes cannot both see a free slot that only exists once. Enforced for
303
+ // registered runners; a runner the store has never met is a test driving
304
+ // the API directly, and the CLI always registers.
305
+ const registered = store.getRunner(runner);
306
+ if (registered !== null && !attendedAuthority) {
307
+ // Capacity governs UNATTENDED work (v6 W2 + round-6 finding 7): an
308
+ // attended session's bound is one-held-per-runner, so a full
309
+ // unattended ledger must not refuse the operator who is watching.
310
+ const held = store.liveClaimCount(runner, options.now);
311
+ if (held >= registered.runner.capacity) {
312
+ return {
313
+ ok: false,
314
+ reason: "capacity",
315
+ message: `${runner} holds ${held} of ${registered.runner.capacity} slot(s) — a free CPU against a full ledger is not capacity`,
316
+ };
317
+ }
318
+ }
319
+ // Quota, same gate: an exhausted credential refuses; a half-open one
320
+ // admits exactly this dispatch as its probe, consumed here so a racing
321
+ // pass cannot also treat it as open.
322
+ const scope = options.model ?? "";
323
+ // Quota is keyed to the credential that actually exhausts: the RESOLVED
324
+ // provider, never a fixed binary name — codex quota must not collide
325
+ // with claude's, nor bypass it (Codex provider review, high finding 5).
326
+ const quota = store.quotaState(runner, options.provider ?? "claude", scope, options.now);
327
+ if (quota !== null && quota.state === "exhausted") {
328
+ return {
329
+ ok: false,
330
+ reason: "quota",
331
+ message: `${runner}'s provider quota is exhausted (${quota.reason})${quota.resetAt === null ? "" : ` until ${quota.resetAt}`} — a free slot against an exhausted quota is not capacity`,
332
+ };
333
+ }
334
+ // THE ROUTED PROVIDER'S READINESS (v47), re-read inside the same
335
+ // transaction: a provider THIS runner has reported unavailable never
336
+ // claims — no substitution, no second-best. Unknown passes; only a
337
+ // recorded unavailable refuses, and it says what was observed.
338
+ const readiness = store.runnerReadinessOf(runner, options.provider ?? "claude");
339
+ if (readiness !== null && readiness.state === "unavailable") {
340
+ return {
341
+ ok: false,
342
+ reason: "provider-unavailable",
343
+ message: `${readiness.provider} is reported unavailable on ${runner} (${readiness.reason}; observed ${readiness.observedAt}) — nothing substitutes for a routed provider`,
344
+ };
345
+ }
346
+ // The attention budget (§8, gate 6), proved where every other readiness
347
+ // fact is proved. A phone with thirty open questions answers none of
348
+ // them; above the budget, tasks with a *measured* habit of parking step
349
+ // aside so the night keeps building what builds. First-time parkers pass
350
+ // — a rate nobody measured is not a rate.
351
+ const budget = options.maxOpenDecisions ?? DEFAULT_MAX_OPEN_DECISIONS;
352
+ if (store.countUnanswered() >= budget) {
353
+ // A planner exists to generate questions; above the budget it is
354
+ // refused outright — no zero-rate first-timer pass (finding 2).
355
+ if (role === "planner") {
356
+ return {
357
+ ok: false,
358
+ reason: "attention-budget",
359
+ message: `${store.countUnanswered()} decisions already wait — a planner would only add more; answer some first`,
360
+ };
361
+ }
362
+ const rate = store.refForId(taskRef)?.parkRate ?? 0;
363
+ if (rate > 0) {
364
+ return {
365
+ ok: false,
366
+ reason: "attention-budget",
367
+ message: `${store.countUnanswered()} decisions already wait and this task parks ${Math.round(rate * 100)}% of its attempts — answer some before it may add more`,
368
+ };
369
+ }
370
+ }
371
+ const taken = acquireLocked(store, taskRef, runner, options);
372
+ // The half-open probe slot is consumed only WITH the claim it admits —
373
+ // an attention refusal or a lost CAS must not re-arm the quota as
374
+ // exhausted with no probe in flight (finding 3). Same transaction, so
375
+ // consume-with-claim is all-or-nothing.
376
+ if (taken.ok && quota !== null) {
377
+ store.consumeHalfOpen(runner, options.provider ?? "claude", scope, options.now);
378
+ }
379
+ return taken;
380
+ });
381
+ }
382
+ /**
383
+ * The FALLBACK-ADMISSION claim (E3d, review finding 4): the one gate a
384
+ * pending-admission chain cycle dispatches through. It enforces everything
385
+ * `acquireIfReady` enforces — task state, holds, blockers, the approved
386
+ * scope + live-mode belt, capability, capacity, and quota — with EXACTLY
387
+ * ONE deliberate difference: a BACKOFF hold does not gate, because the
388
+ * pending admission IS the system's answer to the predecessor's failure
389
+ * (waiting out the exhausted entry's backoff to run a different credential
390
+ * would be waiting for nothing). Quota is keyed by the PINNED entry's
391
+ * provider, model, and auth mode — the credential that would actually
392
+ * spend — so an exhausted fallback credential refuses here, before any
393
+ * claim exists.
394
+ */
395
+ export function acquireFallback(store, taskRef, runner, options) {
396
+ return inTransaction(store, () => {
397
+ const db = store.handle;
398
+ const stamp = options.now.toISOString();
399
+ const task = db
400
+ .prepare(`SELECT task.id, task.state FROM task
401
+ JOIN task_ref ON task_ref.external_id = task.id AND task_ref.backend = ?
402
+ WHERE task_ref.id = ?`)
403
+ .get(BUILT_IN, taskRef);
404
+ if (task === undefined)
405
+ return { ok: false, reason: "not-ready", message: "no such task" };
406
+ // A task the strikes already ended (state failed) or that concluded is
407
+ // TERMINAL: its pending cycle never spends (the reviewer's third-strike
408
+ // scenario) — the cycle simply waits out as an inert record.
409
+ if (String(task["state"]) !== "queued") {
410
+ return { ok: false, reason: "not-ready", message: `state is ${String(task["state"])}, not queued` };
411
+ }
412
+ // Every hold EXCEPT backoff gates: an operator hold, a decision hold,
413
+ // an incident hold all still mean "do not spend".
414
+ const hold = db
415
+ .prepare(`SELECT reason FROM hold
416
+ WHERE task_ref = ? AND owner_kind <> 'backoff' AND (until IS NULL OR until > ?) LIMIT 1`)
417
+ .get(taskRef, stamp);
418
+ if (hold !== undefined)
419
+ return { ok: false, reason: "not-ready", message: `held: ${String(hold["reason"])}` };
420
+ const blocker = db
421
+ .prepare(`SELECT blocker.id FROM task_edge
422
+ JOIN task AS blocker ON blocker.id = task_edge.blocker
423
+ WHERE task_edge.blocked = ? AND blocker.state <> 'done'
424
+ ORDER BY blocker.id LIMIT 1`)
425
+ .get(String(task["id"]));
426
+ if (blocker !== undefined)
427
+ return { ok: false, reason: "not-ready", message: `waiting on ${String(blocker["id"])}` };
428
+ // The approved scope + live-mode belt, verbatim from acquireIfReady: a
429
+ // chain approval IS a scope approval, and a mode-sealed one must still
430
+ // stand.
431
+ const approvedScope = db
432
+ .prepare(`SELECT 1 AS hit FROM task_scope
433
+ JOIN task_ref ON task_ref.id = ? AND task_scope.task_id = task_ref.external_id
434
+ WHERE task_scope.approved_digest = task_scope.digest AND task_scope.approved_at IS NOT NULL AND (COALESCE(task_scope.approval_basis, 'password') <> 'mode'
435
+ OR EXISTS (SELECT 1 FROM operating_mode om
436
+ JOIN approver signer ON signer.name = om.signed_by
437
+ WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
438
+ AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
439
+ AND signer.revoked_at IS NULL AND signer.role = 'approver'))`)
440
+ .get(taskRef, stamp);
441
+ if (approvedScope === undefined) {
442
+ return { ok: false, reason: "not-ready", message: "the scope is not approved" };
443
+ }
444
+ const gap = missingCapability(store, taskRef, options.repo ?? null, options.now);
445
+ if (gap !== null)
446
+ return { ok: false, reason: "capability", message: gap };
447
+ // The pinned entry's provider must not be one this runner reports
448
+ // unavailable (v47): the approved chain is the ONLY substitution road,
449
+ // and it still never dispatches onto a provider known to be missing.
450
+ const entryReadiness = store.runnerReadinessOf(runner, options.provider);
451
+ if (entryReadiness !== null && entryReadiness.state === "unavailable") {
452
+ return {
453
+ ok: false,
454
+ reason: "provider-unavailable",
455
+ message: `${entryReadiness.provider} is reported unavailable on ${runner} (${entryReadiness.reason}; observed ${entryReadiness.observedAt}) — the fallback entry cannot run here`,
456
+ };
457
+ }
458
+ const registered = store.getRunner(runner);
459
+ if (registered !== null) {
460
+ const held = store.liveClaimCount(runner, options.now);
461
+ if (held >= registered.runner.capacity) {
462
+ return {
463
+ ok: false,
464
+ reason: "capacity",
465
+ message: `${runner} holds ${held} of ${registered.runner.capacity} slot(s) — a free CPU against a full ledger is not capacity`,
466
+ };
467
+ }
468
+ }
469
+ // Quota, keyed by the PINNED credential that would spend.
470
+ const quota = store.quotaState(runner, options.provider, options.model, options.now, options.authMode);
471
+ if (quota !== null && quota.state === "exhausted") {
472
+ return {
473
+ ok: false,
474
+ reason: "quota",
475
+ message: `${runner}'s ${options.provider} quota is exhausted (${quota.reason})${quota.resetAt === null ? "" : ` until ${quota.resetAt}`} — the fallback entry's own credential is spent`,
476
+ };
477
+ }
478
+ // The attention budget, exactly as the ordinary road holds it (E3d
479
+ // verify, R4): a fallback attempt can park and add a decision like any
480
+ // other, so above the budget a task with a measured parking habit steps
481
+ // aside here too — backoff is the ONE exemption, not this.
482
+ const budget = options.maxOpenDecisions ?? DEFAULT_MAX_OPEN_DECISIONS;
483
+ if (store.countUnanswered() >= budget) {
484
+ const rate = store.refForId(taskRef)?.parkRate ?? 0;
485
+ if (rate > 0) {
486
+ return {
487
+ ok: false,
488
+ reason: "attention-budget",
489
+ message: `${store.countUnanswered()} decisions already wait and this task parks ${Math.round(rate * 100)}% of its attempts — answer some before it may add more`,
490
+ };
491
+ }
492
+ }
493
+ const taken = acquireLocked(store, taskRef, runner, options);
494
+ if (taken.ok && quota !== null) {
495
+ store.consumeHalfOpen(runner, options.provider, options.model, options.now, options.authMode);
496
+ }
497
+ return taken;
498
+ });
499
+ }
500
+ /**
501
+ * "I am still here." Extends the lease, and tells a superseded runner that it
502
+ * has been superseded — which is the cheapest moment for it to find out, well
503
+ * before it has finished work nobody will accept.
504
+ */
505
+ export function heartbeat(store, leaseId, now, ttlMs = DEFAULT_LEASE_MS) {
506
+ const db = store.handle;
507
+ const expiresAt = new Date(now.getTime() + ttlMs).toISOString();
508
+ return inTransaction(store, () => {
509
+ const { changes } = db
510
+ .prepare(`UPDATE claim SET heartbeat_at = ?, expires_at = ?
511
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
512
+ .run(now.toISOString(), expiresAt, leaseId);
513
+ if (Number(changes) === 0)
514
+ return refusal(db, leaseId);
515
+ const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
516
+ return { ok: true, claim: readClaim(row) };
517
+ });
518
+ }
519
+ /**
520
+ * The fence, as a predicate on the write rather than a check before it.
521
+ *
522
+ * Checking first and updating second leaves a window: a reclaim committing
523
+ * between the two makes the check's answer stale, and the update — matching on
524
+ * lease id alone — succeeds anyway. Both runners then believe they hold the
525
+ * task, which is the precise failure this module was written to make
526
+ * impossible. Putting the condition inside the statement closes it, because
527
+ * SQLite evaluates it against the row it is about to write.
528
+ */
529
+ const NOT_SUPERSEDED = `NOT EXISTS (
530
+ SELECT 1 FROM claim AS newer
531
+ WHERE newer.task_ref = claim.task_ref
532
+ AND newer.lease_generation > claim.lease_generation
533
+ )`;
534
+ /**
535
+ * Why a fenced write matched nothing. Worth the extra read: "you were
536
+ * superseded" and "I have never heard of this lease" send a runner to very
537
+ * different places.
538
+ */
539
+ function refusal(db, leaseId) {
540
+ const row = db.prepare("SELECT 1 AS hit FROM claim WHERE lease_id = ?").get(leaseId);
541
+ return { ok: false, reason: row === undefined ? "unknown" : "fenced" };
542
+ }
543
+ /**
544
+ * Hand the task back.
545
+ *
546
+ * Accepted only from the lease that currently holds it. A completion arriving
547
+ * on a superseded lease is the failure this whole module exists for, and it is
548
+ * rejected without touching anything — the work is not lost, it is simply not
549
+ * this runner's to report, and the record says so rather than overwriting a
550
+ * live claim with a dead runner's opinion.
551
+ */
552
+ export function release(store, leaseId, now) {
553
+ const db = store.handle;
554
+ return inTransaction(store, () => {
555
+ const { changes } = db
556
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
557
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
558
+ .run(now.toISOString(), leaseId);
559
+ if (Number(changes) === 0) {
560
+ // A repeat of a hand-back this same lease already made is not a fence —
561
+ // nobody took the task away, the runner simply said so twice because its
562
+ // first acknowledgement was lost. M1 asks for duplicate completion to be
563
+ // reconciled rather than refused, and telling an honest runner it was
564
+ // superseded would send it to stop when it should carry on.
565
+ //
566
+ // Deliberately not filtered by supersession. A lease that released and
567
+ // was then reacquired by somebody else still *completed*: its work was
568
+ // accepted at the time, and the honest answer to a late retry is "you
569
+ // already did this", not "you were fenced" — which would say its work
570
+ // never counted. Fencing is for a lease that never finished.
571
+ //
572
+ // But *how* the lease came to be released decides everything. Only a
573
+ // release the runner itself made counts as "you already did this". A
574
+ // lease the reaper or dead-runner recovery took back was never handed
575
+ // in by anybody — the world moved on without it, and the answer to its
576
+ // late retry is the fence.
577
+ return duplicateOrRefusal(db, leaseId, ["released", "completed"]);
578
+ }
579
+ const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
580
+ store.bumpWake();
581
+ return { ok: true, claim: readClaim(row) };
582
+ });
583
+ }
584
+ /**
585
+ * Whether an already-released lease was released in a way the caller may
586
+ * treat as its own doing. Anything else — reaped, recovered, or provenance
587
+ * unknown — is the fence, because accepting it would let a reclaimed lease's
588
+ * late retry pass as an ordinary duplicate.
589
+ */
590
+ function duplicateOrRefusal(db, leaseId, own) {
591
+ const released = db
592
+ .prepare("SELECT * FROM claim WHERE lease_id = ? AND released_at IS NOT NULL")
593
+ .get(leaseId);
594
+ if (released !== undefined && own.includes(String(released["released_by"] ?? ""))) {
595
+ return { ok: true, claim: readClaim(released), duplicate: true };
596
+ }
597
+ if (released !== undefined)
598
+ return { ok: false, reason: "fenced" };
599
+ return refusal(db, leaseId);
600
+ }
601
+ /**
602
+ * Release the lease and write the task's terminal state, as one step.
603
+ *
604
+ * Release-then-mark is a window: between the two, the freed task is back in
605
+ * the ready set, and another pass can claim it before the terminal state
606
+ * lands — two builders for one task, the second dispatched by our own
607
+ * bookkeeping. Here the state is written inside the same transaction as the
608
+ * fenced release, so a task is never simultaneously free and unfinished.
609
+ *
610
+ * A fenced or unknown lease writes nothing: a runner the world moved past
611
+ * does not get to say how the task ended. A duplicate release reports
612
+ * `duplicate` and also writes nothing — the first completion already said,
613
+ * and a retry changing the answer would make "done" negotiable.
614
+ */
615
+ export function completeFenced(store, leaseId, state, now, mutation = {}) {
616
+ const db = store.handle;
617
+ return inTransaction(store, () => {
618
+ const stoppedRun = db.prepare("SELECT r.id FROM run r JOIN run_stop s ON s.run = r.id WHERE r.lease_id = ? AND r.outcome IS NULL LIMIT 1").get(leaseId);
619
+ if (stoppedRun !== undefined) {
620
+ const runId = Number(stoppedRun["id"]);
621
+ const run = store.getRun(runId);
622
+ const task = store.refForId(run.taskRef);
623
+ if (task !== null)
624
+ interruptIfStopped(store, { leaseId, runId, taskId: task.externalId, now });
625
+ return { ok: false, reason: "stopped" };
626
+ }
627
+ const { changes } = db
628
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
629
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
630
+ .run(now.toISOString(), leaseId);
631
+ if (Number(changes) === 0) {
632
+ // Only a completion this same lease already made reads as a duplicate.
633
+ // The DURABLE disposition (released_by) reproduces the original arm —
634
+ // whatever the task or the tracker did since (finding 33). A lease
635
+ // the reaper or recovery released was never *accepted*; a late
636
+ // completion after that is exactly the stale commit the fence keeps
637
+ // out.
638
+ const released = db
639
+ .prepare("SELECT * FROM claim WHERE lease_id = ? AND released_at IS NOT NULL")
640
+ .get(leaseId);
641
+ if (released !== undefined) {
642
+ const by = String(released["released_by"] ?? "");
643
+ if (by === "completed" || by === "disowned") {
644
+ return {
645
+ ok: true,
646
+ arm: by === "disowned" ? "disowned" : "completed",
647
+ claim: readClaim(released),
648
+ duplicate: true,
649
+ };
650
+ }
651
+ return { ok: false, reason: "fenced" };
652
+ }
653
+ return refusal(db, leaseId);
654
+ }
655
+ const row = db.prepare("SELECT * FROM claim WHERE lease_id = ?").get(leaseId);
656
+ const claim = readClaim(row);
657
+ const taskId = db
658
+ .prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = ?")
659
+ .get(claim.taskRef, BUILT_IN);
660
+ // The completion gate (external dispatch, v4 §24): the latch is read
661
+ // INSIDE this transaction, on a FRESH win only. A latched mirror's
662
+ // completion is DISOWNED — the task is cancelled, the disposition is
663
+ // written durably, and the caller never sees the completed arm, so
664
+ // publication is impossible by construction.
665
+ const disowned = taskId !== undefined && !store.mirrorAllowsCompletion(String(taskId["external_id"]));
666
+ if (disowned) {
667
+ db.prepare("UPDATE claim SET released_by = 'disowned' WHERE lease_id = ?").run(leaseId);
668
+ }
669
+ if (taskId !== undefined) {
670
+ store.replay(mutation, "completeFenced", () => {
671
+ if (disowned) {
672
+ // Cancellation goes through the one floor — a disowned
673
+ // completion is a typed machine reason, not a bare state write.
674
+ store.applyCancellation(String(taskId["external_id"]), { kind: "machine", code: "disowned-completion" }, now, null);
675
+ }
676
+ else {
677
+ db.prepare("UPDATE task SET state = ?, updated_at = ? WHERE id = ?").run(state, now.toISOString(), String(taskId["external_id"]));
678
+ }
679
+ return true;
680
+ });
681
+ }
682
+ store.bumpWake();
683
+ return { ok: true, arm: disowned ? "disowned" : "completed", claim };
684
+ });
685
+ }
686
+ /**
687
+ * Seal a park, fenced at the write (§7).
688
+ *
689
+ * The builder proved its lease after the agent ran; this transaction proves
690
+ * it again *in the same statement that releases it*, because everything a
691
+ * park creates — the decision, its hold, the outbox row — is only true if
692
+ * this lease still spoke for the task at the moment of sealing. A superseded
693
+ * lease creates none of it: no decision, no hold, no notification, and the
694
+ * run is finalized as the fenced refusal it was. A crash anywhere inside
695
+ * rolls all of it back together; there is no instant at which a decision
696
+ * exists without its hold.
697
+ *
698
+ * `released_by = 'parked'` so a late retry from this lease is answered with
699
+ * the fence, not "duplicate": a park hands the task to a person, and nothing
700
+ * the runner says afterwards is that person's answer.
701
+ */
702
+ /**
703
+ * Continuation admission (Phase 2E, A4 / v3 R7): the AUTHORIZATION is the
704
+ * claimable unit — the finished parent task is never re-queued, its state,
705
+ * strikes, holds, and dependents untouched. Every shared gate still runs
706
+ * (acquireLocked: reservation, external mirror, the attended gates, the
707
+ * lease CAS); the queued-state readiness gate alone is replaced by
708
+ * construction. Liveness, the named runner, and the unspent attempt are
709
+ * re-proved HERE at claim time and again at the final dispatch proof.
710
+ */
711
+ export function acquireContinuation(store, authorization, runner, options) {
712
+ const live = store.readAuthorization(authorization.id);
713
+ if (live === null ||
714
+ live.closedAt !== null ||
715
+ live.attemptRun !== null ||
716
+ live.parentRun === null ||
717
+ live.runner !== runner) {
718
+ return { ok: false, reason: "attended-only", message: "the authorization is not an open continuation for this runner" };
719
+ }
720
+ const watching = attendedWatchState(live.lastBeatAt, options.now, live.absoluteExpiry);
721
+ if (watching !== "live" && watching !== "grace") {
722
+ return { ok: false, reason: "attended-only", message: "the operator is not watching" };
723
+ }
724
+ if (store.activeTournamentTerms(live.taskRef) !== null) {
725
+ return { ok: false, reason: "contest-open", message: "a tournament raced onto this task — continuation waits" };
726
+ }
727
+ const blocked = store.continuationBlockOf(live.parentRun);
728
+ if (blocked !== null) {
729
+ return { ok: false, reason: "continuation-blocked", message: blocked };
730
+ }
731
+ return acquire(store, live.taskRef, runner, options);
732
+ }
733
+ /**
734
+ * The HELD park (Phase 2E, v2 S1f): the decision is recorded, paged, and
735
+ * causally linked to the session turn that produced it — and NOTHING else
736
+ * ends. The lease stays; the run stays open; the process stays held. The
737
+ * conversation continues when the answer is injected as the next turn.
738
+ * The one-unresolved-per-run partial unique refuses a second open park.
739
+ */
740
+ export function finalizeParkHeld(store, args) {
741
+ const { runId, taskId, decision, artifactIds, sessionTurn, now } = args;
742
+ return inTransaction(store, () => {
743
+ const held = store.heldSessionOf(runId);
744
+ if (held === null || held.endedAt !== null || held.state !== "open") {
745
+ return { ok: false, reason: "not-held" };
746
+ }
747
+ const run = store.getRun(runId);
748
+ if (run === null || run.outcome !== null)
749
+ return { ok: false, reason: "not-held" };
750
+ let decisionId;
751
+ try {
752
+ decisionId = store.saveDecision({
753
+ run: runId,
754
+ urgency: decision.urgency,
755
+ recap: decision.recap,
756
+ question: decision.question,
757
+ options: decision.options,
758
+ recommendation: decision.recommendation,
759
+ ...(decision.assignee === null ? {} : { assignee: decision.assignee }),
760
+ ...(decision.deadline === null ? {} : { deadline: decision.deadline }),
761
+ }, now);
762
+ }
763
+ catch {
764
+ // The partial unique: one unresolved question per run at a time.
765
+ return { ok: false, reason: "decision-open" };
766
+ }
767
+ store.handle.prepare("UPDATE decision SET session_turn = ? WHERE id = ?").run(sessionTurn, decisionId);
768
+ for (const artifact of artifactIds)
769
+ store.linkEvidence(decisionId, artifact);
770
+ store.enqueueNotification({
771
+ source: { run: runId },
772
+ dedupeKey: `decision:${decisionId}`,
773
+ kind: "decision",
774
+ subject: `${taskId} asked a question mid-session`,
775
+ body: `${oneLine(decision.question, 200)}\n\`toolroll decide ${decisionId}\``,
776
+ pushClass: "decision",
777
+ link: `/d/${decisionId}`,
778
+ }, now);
779
+ return { ok: true, decisionId };
780
+ });
781
+ }
782
+ /**
783
+ * Validate the optional accepted formatting child against the open planner
784
+ * root. The child remains open until the root's fence settles, so a crash or
785
+ * takeover can never leave it masquerading as successfully delivered work.
786
+ */
787
+ function plannerRepairChild(store, root, repairRunId) {
788
+ if (repairRunId === null)
789
+ return null;
790
+ if (root.role !== "planner" || root.outcome !== null || root.providerStartedAt === null) {
791
+ throw new Error(`run ${root.id} cannot own a structured planner correction`);
792
+ }
793
+ const accepted = store.getRun(repairRunId);
794
+ if (accepted === null || accepted.id === root.id || accepted.role !== "planner" || accepted.outcome !== null) {
795
+ throw new Error(`run ${repairRunId} is not an open planner correction for ${root.id}`);
796
+ }
797
+ const sameCustody = (run) => run.taskRef === root.taskRef &&
798
+ run.leaseId === root.leaseId &&
799
+ run.runner === root.runner &&
800
+ run.provider === root.provider &&
801
+ run.model === root.model &&
802
+ run.sessionId !== null &&
803
+ run.sessionId === root.sessionId &&
804
+ run.branch === root.branch &&
805
+ run.worktree === root.worktree &&
806
+ run.baseRevision === root.baseRevision &&
807
+ run.providerStartedAt !== null;
808
+ if (!sameCustody(accepted)) {
809
+ throw new Error(`run ${repairRunId} does not share planner ${root.id}'s exact custody and session`);
810
+ }
811
+ let cursor = accepted;
812
+ for (let edge = 1; edge <= 2; edge += 1) {
813
+ if (cursor.parentRun === root.id)
814
+ return accepted;
815
+ const parent = cursor.parentRun === null ? null : store.getRun(cursor.parentRun);
816
+ if (parent === null ||
817
+ parent.role !== "planner" ||
818
+ parent.outcome !== "failed" ||
819
+ !sameCustody(parent)) {
820
+ throw new Error(`run ${repairRunId} is outside planner ${root.id}'s bounded linear correction chain`);
821
+ }
822
+ cursor = parent;
823
+ }
824
+ throw new Error(`run ${repairRunId} is outside planner ${root.id}'s bounded linear correction chain`);
825
+ }
826
+ export function finalizeParkFenced(store, args) {
827
+ const { leaseId, runId, taskId, decision, artifactIds, now } = args;
828
+ const db = store.handle;
829
+ return inTransaction(store, () => {
830
+ const run = store.getRun(runId);
831
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
832
+ // A run that names another lease or is already finished cannot be
833
+ // sealed by this call — that is a caller defect, not a race to absorb.
834
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a park seals exactly one`);
835
+ }
836
+ const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
837
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
838
+ if (stopped !== null)
839
+ return { ok: false, reason: "stopped" };
840
+ const { changes } = db
841
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'parked'
842
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
843
+ .run(now.toISOString(), leaseId);
844
+ if (Number(changes) === 0) {
845
+ if (repairRun !== null)
846
+ store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
847
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
848
+ return refusal(db, leaseId);
849
+ }
850
+ const decisionId = store.saveDecision({
851
+ run: runId,
852
+ urgency: decision.urgency,
853
+ recap: decision.recap,
854
+ question: decision.question,
855
+ options: decision.options,
856
+ recommendation: decision.recommendation,
857
+ ...(decision.assignee === null ? {} : { assignee: decision.assignee }),
858
+ ...(decision.deadline === null ? {} : { deadline: decision.deadline }),
859
+ }, now);
860
+ for (const artifact of artifactIds)
861
+ store.linkEvidence(decisionId, artifact);
862
+ // The hold is indefinite by construction. The decision's deadline is
863
+ // attention metadata; wiring it into `until` would dispatch the task,
864
+ // unanswered, the moment the deadline passed — expiry never chooses.
865
+ store.holdOwned({
866
+ taskRef: run.taskRef,
867
+ ownerKind: "decision",
868
+ ownerId: String(decisionId),
869
+ reason: `decision:${decisionId} — ${oneLine(decision.question, 80)}`,
870
+ until: null,
871
+ }, now);
872
+ store.finishRun(runId, { outcome: "parked", reason: `decision:${decisionId}`, now });
873
+ if (repairRun !== null) {
874
+ store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
875
+ }
876
+ store.enqueueNotification({
877
+ source: { run: runId },
878
+ dedupeKey: `decision:${decisionId}`,
879
+ kind: "decision",
880
+ subject: `${taskId} parked a decision`,
881
+ body: `${oneLine(decision.question, 200)}\n\`toolroll decide ${decisionId}\``,
882
+ // The push stamp (arc 3): class + machine-minted link, at enqueue
883
+ // or never. Fixed phrases ride the push service; this subject does not.
884
+ pushClass: "decision",
885
+ link: `/d/${decisionId}`,
886
+ }, now);
887
+ return { ok: true, decisionId };
888
+ });
889
+ }
890
+ /**
891
+ * Seal a park whose payload never became a decision (§6's bounded repair ran
892
+ * out). Same fence, same atomicity, different record: an incident that stays
893
+ * in every brief until a person resolves it, holding the task so the next
894
+ * pass does not spend the same tokens hitting the same wall nightly. The
895
+ * malformed payload is preserved as evidence — a person may still be able to
896
+ * read what the agent meant.
897
+ */
898
+ export function finalizeMalformedFenced(store, args) {
899
+ const { leaseId, runId, taskId, problems, now } = args;
900
+ const db = store.handle;
901
+ return inTransaction(store, () => {
902
+ const run = store.getRun(runId);
903
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
904
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a park seals exactly one`);
905
+ }
906
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
907
+ if (stopped !== null)
908
+ return { ok: false, reason: "stopped" };
909
+ const { changes } = db
910
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'parked'
911
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
912
+ .run(now.toISOString(), leaseId);
913
+ if (Number(changes) === 0) {
914
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
915
+ return refusal(db, leaseId);
916
+ }
917
+ const incidentId = store.createIncident({ run: runId, kind: "malformed-decision" }, now);
918
+ store.holdOwned({
919
+ taskRef: run.taskRef,
920
+ ownerKind: "incident",
921
+ ownerId: String(incidentId),
922
+ reason: `malformed-decision — the agent tried to park ${taskId} and could not say what`,
923
+ until: null,
924
+ }, now);
925
+ store.finishRun(runId, { outcome: "failed", reason: "malformed-decision", now });
926
+ store.enqueueNotification({
927
+ source: { run: runId },
928
+ dedupeKey: `malformed:${runId}`,
929
+ kind: "malformed-decision",
930
+ pushClass: "attention",
931
+ link: `/r/${runId}`,
932
+ subject: `${taskId}: the agent parked but could not say what`,
933
+ body: [
934
+ `Repair ran out. The task is held until somebody looks.`,
935
+ ...problems.slice(0, 5).map(problem => `- ${oneLine(problem.message, 120)}`),
936
+ `The raw payload is preserved in run ${runId}'s evidence.`,
937
+ ].join("\n"),
938
+ }, now);
939
+ return { ok: true, incidentId };
940
+ });
941
+ }
942
+ /** 1m, 2m, 4m, 8m, 16m — doubling, capped. Indexed by strikes-1. */
943
+ const BACKOFF_MS = [60_000, 120_000, 240_000, 480_000, 960_000];
944
+ export const MAX_STRIKES = 3;
945
+ /**
946
+ * Seal an operator-stopped attempt (v52): ONE fenced transaction for the
947
+ * claim's release as `interrupted`, the run's ending as `failed` /
948
+ * `interrupted` (the same words dead-runner recovery writes, so the
949
+ * recovered-draft road inherits the preserved work exactly as after a
950
+ * crash), every open run the attempt owns, the task's return to the
951
+ * queue under the stop's own hold, and the stop's settlement. Nothing
952
+ * here is a failure: no strike, no backoff, no incident, no automatic
953
+ * repair task, no notification page. A commit the attempt already made
954
+ * is kept as a reviewable artifact — `committed` records that it exists.
955
+ *
956
+ * A lease the world moved past (reaped, recovered, superseded) still
957
+ * ends the run as interrupted — that IS the honest ending — but touches
958
+ * no task state: the task belongs to whoever holds it now.
959
+ */
960
+ export function finalizeInterruptedFenced(store, args) {
961
+ const { leaseId, runId, taskId, stopRun, now } = args;
962
+ const db = store.handle;
963
+ return inTransaction(store, () => {
964
+ const run = store.getRun(runId);
965
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
966
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — an interruption seals exactly one`);
967
+ }
968
+ const { changes } = db
969
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'interrupted'
970
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
971
+ .run(now.toISOString(), leaseId);
972
+ const fenced = Number(changes) === 0;
973
+ if (args.message !== undefined && args.message.trim() !== "") {
974
+ store.recordOutcomeFacts(runId, { handoff: args.message });
975
+ }
976
+ // Owned descendants first (a repair turn, a correction) — the parent's
977
+ // ending settles the stop, and every owned row ends in the same write.
978
+ for (const owned of store.ownedRunsOf(runId)) {
979
+ if (owned === runId)
980
+ continue;
981
+ const child = store.getRun(owned);
982
+ if (child !== null && child.outcome === null) {
983
+ store.finishRun(owned, { outcome: "failed", reason: "interrupted", now, stopSettlement: "interrupted" });
984
+ }
985
+ }
986
+ store.finishRun(runId, {
987
+ outcome: "failed",
988
+ reason: "interrupted",
989
+ ...(args.committed === undefined ? {} : { committed: args.committed }),
990
+ now,
991
+ stopSettlement: "interrupted",
992
+ });
993
+ store.settleRunStop(stopRun, "interrupted", now);
994
+ if (fenced)
995
+ return { ok: true, stopRun, requeued: false, fenced: true };
996
+ // Back to the queue, where the stop's hold keeps it — a paused task
997
+ // reads as queued-and-held, exactly like an operator pause.
998
+ const requeued = db
999
+ .prepare("UPDATE task SET state = 'queued', updated_at = ? WHERE id = ? AND state = 'running'")
1000
+ .run(now.toISOString(), taskId);
1001
+ store.bumpWake();
1002
+ return { ok: true, stopRun, requeued: Number(requeued.changes) > 0, fenced: false };
1003
+ });
1004
+ }
1005
+ /**
1006
+ * THE STOP FENCE at settlement (v52): when a stop applies to the run —
1007
+ * its own or an owning ancestor's — seal the attempt as interrupted and
1008
+ * answer with the seal; otherwise null, and the caller's own ending
1009
+ * proceeds. Every fenced finalizer asks this first, inside its own
1010
+ * transaction, so a stop that commits before terminal settlement wins
1011
+ * whatever the attempt was about to say.
1012
+ */
1013
+ export function interruptIfStopped(store, args) {
1014
+ const stop = store.applicableStopFor(args.runId);
1015
+ if (stop === null)
1016
+ return null;
1017
+ const run = store.getRun(args.runId);
1018
+ const where = run?.worktree ?? null;
1019
+ return finalizeInterruptedFenced(store, {
1020
+ ...args,
1021
+ stopRun: stop.run,
1022
+ message: args.message ?? `stopped by ${stop.requestedBy} (run #${stop.run}) — ${where === null ? "the attempt's evidence is on record" : `the work is preserved in ${where}`}`,
1023
+ });
1024
+ }
1025
+ /**
1026
+ * Seal a failed attempt: one fenced transaction for the release, the run,
1027
+ * the strike, the hold, and the page (§6, gnhf adopted rather than
1028
+ * reinvented).
1029
+ *
1030
+ * Strikes count only top-level builder attempts; built, no-change, and
1031
+ * parked reset them elsewhere; refusals, fenced attempts, and repair
1032
+ * children never reach this function. Under three strikes the task
1033
+ * requeues behind a doubling backoff hold the failure owns; at three it
1034
+ * stalls — an 'attempts-exhausted' incident, the task marked failed, held,
1035
+ * and paged once — because a fourth identical attempt at 3am is a token
1036
+ * bonfire, not persistence. A commit-stage failure takes neither road: the
1037
+ * worktree holds unpreserved work, ordinary re-pooling would refuse it as
1038
+ * dirty forever, so it is an immediate incident naming the checkout.
1039
+ */
1040
+ export function finalizeFailureFenced(store, args) {
1041
+ const { leaseId, runId, taskId, failureClass, message, now } = args;
1042
+ const db = store.handle;
1043
+ return inTransaction(store, () => {
1044
+ const run = store.getRun(runId);
1045
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1046
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
1047
+ }
1048
+ if (run.role !== "builder") {
1049
+ throw new Error(`run ${runId} is a ${run.role} run — only top-level builder attempts take strikes`);
1050
+ }
1051
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1052
+ if (stopped !== null)
1053
+ return { ok: false, reason: "stopped" };
1054
+ const { changes } = db
1055
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
1056
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1057
+ .run(now.toISOString(), leaseId);
1058
+ if (Number(changes) === 0) {
1059
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1060
+ return refusal(db, leaseId);
1061
+ }
1062
+ store.finishRun(runId, { outcome: "failed", reason: failureClass, now });
1063
+ if (failureClass === "commit-failure") {
1064
+ // The work exists and is uncommitted; nothing times its way out of
1065
+ // that. A person (or an explicit repair) proves the checkout.
1066
+ const incidentId = store.createIncident({ run: runId, kind: "commit-failure" }, now);
1067
+ store.holdOwned({
1068
+ taskRef: run.taskRef,
1069
+ ownerKind: "incident",
1070
+ ownerId: String(incidentId),
1071
+ reason: `commit-failure — uncommitted work preserved in ${args.worktree}`,
1072
+ until: null,
1073
+ }, now);
1074
+ store.enqueueNotification({
1075
+ source: { run: runId },
1076
+ dedupeKey: `commit-failure:${runId}`,
1077
+ kind: "commit-failure",
1078
+ pushClass: "attention",
1079
+ link: `/r/${runId}`,
1080
+ subject: `${taskId}: the commit itself failed`,
1081
+ body: `${oneLine(message, 200)}\nThe work is preserved, uncommitted, in ${args.worktree}. Prove it and \`toolroll task requeue ${taskId}\`.`,
1082
+ }, now);
1083
+ return { ok: true, disposition: "commit-incident", incidentId };
1084
+ }
1085
+ const strikes = store.addStrike(run.taskRef);
1086
+ if (strikes >= MAX_STRIKES) {
1087
+ const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
1088
+ store.holdOwned({
1089
+ taskRef: run.taskRef,
1090
+ ownerKind: "incident",
1091
+ ownerId: String(incidentId),
1092
+ reason: `attempts-exhausted — ${strikes} consecutive failures, last: ${failureClass}`,
1093
+ until: null,
1094
+ }, now);
1095
+ db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
1096
+ store.enqueueNotification({
1097
+ source: { run: runId },
1098
+ dedupeKey: `stalled:${run.taskRef}`,
1099
+ kind: "attempts-exhausted",
1100
+ pushClass: "attention",
1101
+ link: `/r/${runId}`,
1102
+ subject: `${taskId} stalled after ${strikes} straight failures`,
1103
+ body: `Last failure (${failureClass}): ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
1104
+ }, now);
1105
+ return { ok: true, disposition: "stalled", strikes, incidentId };
1106
+ }
1107
+ // Under the limit: requeue behind a doubling pause the failure owns.
1108
+ // Replacing the previous backoff hold is correct — this attempt's strike
1109
+ // already reflects the whole streak.
1110
+ const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
1111
+ const until = new Date(now.getTime() + wait);
1112
+ store.holdOwned({
1113
+ taskRef: run.taskRef,
1114
+ ownerKind: "backoff",
1115
+ ownerId: String(run.taskRef),
1116
+ reason: `retry ${strikes}/${MAX_STRIKES} after ${failureClass} — backing off ${Math.round(wait / 60_000)}m`,
1117
+ until,
1118
+ }, now);
1119
+ store.enqueueNotification({
1120
+ source: { run: runId },
1121
+ dedupeKey: `run:${runId}:failed`,
1122
+ kind: "build-failed",
1123
+ subject: `${taskId}: attempt failed (${failureClass}), retry ${strikes}/${MAX_STRIKES}`,
1124
+ body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
1125
+ }, now);
1126
+ return { ok: true, disposition: "backoff", strikes, until: until.toISOString() };
1127
+ });
1128
+ }
1129
+ /** Untrusted text on its way into a subject line: one line, bounded. */
1130
+ function oneLine(text, cap) {
1131
+ const flat = text.replace(/\s+/g, " ").trim();
1132
+ return flat.length <= cap ? flat : `${flat.slice(0, cap)}…`;
1133
+ }
1134
+ /** The live claim on a task, if there is one. */
1135
+ export const MAX_PLAN_STRIKES = 3;
1136
+ /** Backoff for planner retries: shorter than the builder's — a planner that
1137
+ * cannot start is usually a transient, and nothing downstream is waiting on
1138
+ * a workspace. */
1139
+ const PLAN_BACKOFF_MS = [60_000, 2 * 60_000, 4 * 60_000];
1140
+ /**
1141
+ * Seal a successful planning run: the proposed scope, the plan document
1142
+ * artifact, the drafted state, the run outcome, and the page to the
1143
+ * operator exist together or — if the lease was superseded — not at all.
1144
+ * The scope proposal is authority-bearing (it is what the approve card
1145
+ * will restate), which is why this is the park discipline, not the DONE
1146
+ * one (Codex planning review, question 2).
1147
+ */
1148
+ export function finalizePlanFenced(store, args) {
1149
+ const { leaseId, runId, taskId, plan, now } = args;
1150
+ const db = store.handle;
1151
+ // The ingestion record (contract handoff, task 1) is composed here from
1152
+ // the filed terms and the drafted plan; its file and row land inside the
1153
+ // transaction below, only after the stale-source check admits the draft.
1154
+ const filed = args.source?.contract.scope ?? null;
1155
+ const contractChanges = filed === null ? [] : contractChangesOf(filed, plan);
1156
+ // A hand-built plan (the pre-source road) carries no amendment field.
1157
+ const amendment = plan.amendment ?? null;
1158
+ const record = args.source === undefined
1159
+ ? null
1160
+ : {
1161
+ version: 1,
1162
+ sourceDigest: args.source.sourceDigest,
1163
+ sourceArtifact: args.sourceArtifact ?? null,
1164
+ filed: filed === null ? null : { goal: filed.goal, outOfScope: filed.outOfScope, touches: filed.touches, acceptance: filed.acceptance },
1165
+ proposed: { goal: plan.goal, outOfScope: plan.outOfScope, touches: plan.touches, acceptance: plan.acceptance },
1166
+ amendment,
1167
+ changes: contractChanges,
1168
+ };
1169
+ return inTransaction(store, () => {
1170
+ const run = store.getRun(runId);
1171
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1172
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a plan seals exactly one`);
1173
+ }
1174
+ if (run.role !== "planner") {
1175
+ throw new Error(`run ${runId} is a ${run.role} run — only planner runs draft plans`);
1176
+ }
1177
+ const repairRun = plannerRepairChild(store, run, args.repairRunId ?? null);
1178
+ if (store.lookupRef(taskId)?.id !== run.taskRef)
1179
+ throw new Error("a planner can finalize only its own task");
1180
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1181
+ if (stopped !== null)
1182
+ return { ok: false, reason: "stopped" };
1183
+ const invalidSource = (detail, kind = "malformed") => {
1184
+ const failed = finalizePlanFailureFenced(store, { leaseId, runId, taskId, kind, message: detail, now });
1185
+ if (repairRun !== null)
1186
+ store.finishRun(repairRun.id, { outcome: "refused", reason: failed.ok ? "source-invalid" : "fenced", now });
1187
+ return failed.ok ? { ok: false, reason: "source-invalid", detail } : failed;
1188
+ };
1189
+ const sources = store.artifactsFor(runId).filter(one => one.kind === "plan-contract" && one.key.endsWith("/planner-source.json"));
1190
+ // Legacy rows have no input stamp or artifact. Every current dispatch
1191
+ // stamps even an empty scope; dropping a caller argument cannot bypass custody.
1192
+ if (args.source !== undefined || sources.length > 0 || run.scopeDigest !== null) {
1193
+ if (args.source === undefined || args.evidenceRoot === undefined || sources.length !== 1 || sources[0].id !== args.sourceArtifact) {
1194
+ return invalidSource("the planner's admitted source inventory is missing, ambiguous, or was not presented");
1195
+ }
1196
+ const artifact = sources[0];
1197
+ let verified;
1198
+ try {
1199
+ verified = readVerifiedArtifact(args.evidenceRoot, artifact);
1200
+ }
1201
+ catch {
1202
+ return invalidSource("the planner source file cannot be read");
1203
+ }
1204
+ if (!verified.ok || artifact.truncated)
1205
+ return invalidSource("the planner source bytes no longer verify");
1206
+ const recorded = decodePlannerSource(verified.content);
1207
+ if (recorded === null || recorded.taskId !== taskId || !verified.content.equals(encodePlannerSource(args.source)) || run.scopeDigest !== (recorded.contract.scope?.digest ?? "")) {
1208
+ return invalidSource("the supplied planning source is not the source recorded for this run");
1209
+ }
1210
+ if (contractChanges.length > 0 && (typeof amendment !== "string" || !amendment.trim() || amendment.length > PLANNER_SOURCE_LIMITS.amendment)) {
1211
+ return invalidSource("a planner changed the filed contract without a bounded, explicit amendment");
1212
+ }
1213
+ }
1214
+ // THE SOURCE RECHECK (contract handoff, task 1): the filed request is
1215
+ // re-derived from durable state inside this very transaction and must
1216
+ // still be the one the planner read. A scope rewritten, approved, or
1217
+ // re-termed while the planner ran is the NEWER source; an old draft
1218
+ // never overwrites it. The attempt ends refused in words — no strike,
1219
+ // the planner did nothing wrong — and the task stays requested. The
1220
+ // claim releases under the same fence the ingestion would have used.
1221
+ if (args.source !== undefined) {
1222
+ const current = plannerContractOf(store, taskId);
1223
+ const currentDigest = current.ok ? plannerSourceDigest(current.contract) : null;
1224
+ const currentAnswers = store.answeredDecisionsFor(taskId, PLANNER_SOURCE_LIMITS.answers + 1).map(one => ({ question: one.question, choice: one.choice ?? "", note: one.note }));
1225
+ if (currentDigest !== args.source.sourceDigest || canonicalContractJson(currentAnswers) !== canonicalContractJson(args.source.answers)) {
1226
+ const detail = current.ok
1227
+ ? describeSourceDrift(args.source, current.contract)
1228
+ : `the filed request can no longer be read: ${current.message}`;
1229
+ const released = db
1230
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
1231
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1232
+ .run(now.toISOString(), leaseId);
1233
+ if (Number(released.changes) === 0) {
1234
+ if (repairRun !== null)
1235
+ store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
1236
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1237
+ return refusal(db, leaseId);
1238
+ }
1239
+ if (repairRun !== null)
1240
+ store.finishRun(repairRun.id, { outcome: "refused", reason: "stale-source", now });
1241
+ store.finishRun(runId, { outcome: "refused", reason: "stale-source", now });
1242
+ store.enqueueNotification({
1243
+ source: { run: runId },
1244
+ dedupeKey: `plan-stale-source:${run.taskRef}:${runId}`,
1245
+ kind: "plan-stale-source",
1246
+ link: `/t/${encodeURIComponent(taskId)}`,
1247
+ subject: `${taskId}: the filed request changed while the planner ran`,
1248
+ body: `${oneLine(detail, 300)}\nNothing from that draft was ingested; the current terms stand and the next pass plans against them.`,
1249
+ }, now);
1250
+ return { ok: false, reason: "stale-source", detail };
1251
+ }
1252
+ }
1253
+ if (record !== null && args.evidenceRoot !== undefined) {
1254
+ const recordBytes = encodePlanContractRecord(record);
1255
+ if (recordBytes.length > EVIDENCE_CAPS["plan-contract"])
1256
+ return invalidSource("the required plan amendment record exceeds its explicit evidence cap");
1257
+ try {
1258
+ const recordId = storeEvidence(store, args.evidenceRoot, runId, "plan-contract", "plan-contract.json", recordBytes, record.changes.length === 0
1259
+ ? "plan ingestion: filed contract reproduced exactly (verified tree)"
1260
+ : `plan ingestion: ${record.changes.length} contract change(s), amendment ${record.amendment === null ? "absent" : "stated"} (verified tree)`, now, { captureStatus: "ok" });
1261
+ const captured = store.getArtifact(recordId);
1262
+ if (captured === null || captured.truncated || !readVerifiedArtifact(args.evidenceRoot, captured).ok) {
1263
+ return invalidSource("the required plan contract record did not seal completely", "failure");
1264
+ }
1265
+ }
1266
+ catch {
1267
+ return invalidSource("the required plan contract record could not be saved", "failure");
1268
+ }
1269
+ }
1270
+ const { changes } = db
1271
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
1272
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1273
+ .run(now.toISOString(), leaseId);
1274
+ if (Number(changes) === 0) {
1275
+ if (repairRun !== null)
1276
+ store.finishRun(repairRun.id, { outcome: "refused", reason: "fenced", now });
1277
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1278
+ return refusal(db, leaseId);
1279
+ }
1280
+ store.saveScope({
1281
+ taskId,
1282
+ goal: plan.goal,
1283
+ outOfScope: plan.outOfScope,
1284
+ touches: plan.touches,
1285
+ acceptance: plan.acceptance,
1286
+ proposedAt: now.toISOString(),
1287
+ digest: digestOf({ goal: plan.goal, outOfScope: plan.outOfScope, touches: plan.touches, acceptance: plan.acceptance }),
1288
+ budgetMicrousd: filed?.terms.budgetMicrousd ?? null,
1289
+ approvedAt: null,
1290
+ approvedBy: null,
1291
+ approvedDigest: null,
1292
+ }, {}, filed === null ? {} : {
1293
+ proposedVia: filed.proposedVia,
1294
+ riskLevel: filed.terms.riskLevel,
1295
+ qualityMode: filed.terms.qualityMode,
1296
+ ...(filed.terms.profile == null ? {} : { profile: filed.terms.profile }),
1297
+ ...(filed.terms.profile != null && routeFromJson(filed.terms.routeJson) !== null &&
1298
+ canonicalContractJson([...new Set(filed.acceptance.flatMap(one => one.evidence))].sort()) === canonicalContractJson([...new Set(plan.acceptance.flatMap(one => one.evidence))].sort())
1299
+ ? { route: routeFromJson(filed.terms.routeJson) } : {}),
1300
+ });
1301
+ let verifiedPlan = false;
1302
+ if (args.artifact !== null) {
1303
+ const artifactId = store.saveArtifact({ run: runId, kind: "plan", ...args.artifact }, now);
1304
+ if (args.evidenceRoot !== undefined && !args.artifact.truncated) {
1305
+ try {
1306
+ const captured = store.getArtifact(artifactId);
1307
+ const verified = captured === null ? null : readVerifiedArtifact(args.evidenceRoot, captured);
1308
+ verifiedPlan = verified?.ok === true && verified.content.equals(Buffer.from(plan.plan, "utf8")) && parseExecutionPlanDocument(plan.plan).ok;
1309
+ }
1310
+ catch {
1311
+ verifiedPlan = false;
1312
+ }
1313
+ }
1314
+ }
1315
+ store.setPlanState(run.taskRef, "drafted");
1316
+ const autoApproved = sealUnchangedPlanUnderMode(store, taskId, args.source?.sourceDigest ?? "", runId, args.source !== undefined && filed !== null && contractChanges.length === 0 && amendment === null && verifiedPlan, now);
1317
+ store.resetPlanStrikes(run.taskRef);
1318
+ store.finishRun(runId, { outcome: "built", reason: "plan-drafted", now });
1319
+ if (repairRun !== null) {
1320
+ store.finishRun(repairRun.id, { outcome: "no-change", reason: "structured planner output repaired", now });
1321
+ }
1322
+ store.enqueueNotification({
1323
+ source: { run: runId },
1324
+ dedupeKey: `plan:${run.taskRef}:${runId}`,
1325
+ kind: "plan-ready",
1326
+ subject: autoApproved ? `${taskId}: unchanged plan auto-approved` : `${taskId}: plan ready for review${contractChanges.length === 0 ? "" : ` — ${contractChanges.length} contract change${contractChanges.length === 1 ? "" : "s"} to check`}`,
1327
+ body: `The planner proposes: ${oneLine(plan.goal, 200)}\n` +
1328
+ (contractChanges.length === 0
1329
+ ? filed === null
1330
+ ? ""
1331
+ : "The filed goal, exclusions, touches, and acceptance criteria are reproduced exactly.\n"
1332
+ : `It AMENDS the filed contract (${oneLine(describeContractChanges(contractChanges).join("; "), 300)})${amendment === null ? "" : ` — because: ${oneLine(amendment, 200)}`}\n`) +
1333
+ (autoApproved ? "Approved under the signed operating mode. The next build may proceed with the original terms and required quality checks." : "Review, edit, and approve the scope — nothing builds until you do."),
1334
+ }, now);
1335
+ return { ok: true, changes: contractChanges.length, amendment };
1336
+ });
1337
+ }
1338
+ /** Which part of the filed request moved, in words — for the refusal,
1339
+ * the page, and the notification. */
1340
+ function describeSourceDrift(source, current) {
1341
+ const was = source.contract;
1342
+ const parts = [];
1343
+ if ((was.scope?.digest ?? null) !== (current.scope?.digest ?? null)) {
1344
+ parts.push(was.scope === null
1345
+ ? "a scope was filed after planning started"
1346
+ : current.scope === null
1347
+ ? "the filed scope was removed"
1348
+ : "the filed scope was rewritten");
1349
+ }
1350
+ else if (was.scope !== null && current.scope !== null && was.scope.approval.approvedDigest !== current.scope.approval.approvedDigest) {
1351
+ parts.push("the scope's approval changed");
1352
+ }
1353
+ else if (was.scope !== null && current.scope !== null && JSON.stringify(was.scope.terms) !== JSON.stringify(current.scope.terms)) {
1354
+ parts.push("the scope's execution terms changed");
1355
+ }
1356
+ if (JSON.stringify(was.task) !== JSON.stringify(current.task))
1357
+ parts.push("the task's terms changed");
1358
+ if (JSON.stringify(was.revision) !== JSON.stringify(current.revision))
1359
+ parts.push("the revision brief changed");
1360
+ return `the filed request changed while the planner ran: ${parts.length === 0 ? "its source identity moved" : parts.join("; ")} (planned against ${source.sourceDigest.slice(0, 12)})`;
1361
+ }
1362
+ /**
1363
+ * Seal a running build's plan-revision proposal (adaptive execution plans).
1364
+ *
1365
+ * A builder that finds repository evidence invalidating a named dependency,
1366
+ * risk, or assumption in the plan it was given files ONE proposal and stops
1367
+ * without committing — the park discipline, applied to the road rather than
1368
+ * to a question. The ledger row, the hold when one is owed, the run's
1369
+ * outcome, and the page exist together or, if the lease was superseded,
1370
+ * not at all. Structurally `finalizePlanFenced`'s twin: the same
1371
+ * open-attempt assertion, the same fenced release, the same all-or-nothing
1372
+ * transaction.
1373
+ *
1374
+ * Two dispositions, decided by the caller's classification and recorded
1375
+ * here:
1376
+ *
1377
+ * plan-only the signed scope and publication authority are
1378
+ * byte-identical to what this build started under, so
1379
+ * nothing authority-bearing moved and the revision is
1380
+ * APPLIED. The claim releases plainly, the task returns
1381
+ * to the ready set, and the next attempt reads the new
1382
+ * plan — that is the "resume" the subject line promises.
1383
+ *
1384
+ * authority-change something else moved the signed scope or the
1385
+ * publication authority WHILE this build ran. Whatever
1386
+ * the revision proposes, it is never auto-applied: the
1387
+ * row lands 'blocked', a `revision` hold keeps the task
1388
+ * out of every ready set until a person accepts or
1389
+ * rejects it, and the page names exactly which fields
1390
+ * changed.
1391
+ *
1392
+ * The lease is released either way — the attempt is over — and the run is
1393
+ * finished as `refused`, reusing the existing outcome vocabulary rather
1394
+ * than growing it: no work was committed, and nothing failed.
1395
+ */
1396
+ export function finalizeRevisionFenced(store, args) {
1397
+ const { leaseId, runId, taskId, taskRef, revision, now } = args;
1398
+ const db = store.handle;
1399
+ return inTransaction(store, () => {
1400
+ const run = store.getRun(runId);
1401
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1402
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a plan revision seals exactly one`);
1403
+ }
1404
+ if (run.role !== "builder") {
1405
+ throw new Error(`run ${runId} is a ${run.role} run — only builder runs file plan revisions`);
1406
+ }
1407
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1408
+ if (stopped !== null)
1409
+ return { ok: false, reason: "stopped" };
1410
+ const { changes } = db
1411
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
1412
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1413
+ .run(now.toISOString(), leaseId);
1414
+ if (Number(changes) === 0) {
1415
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1416
+ return refusal(db, leaseId);
1417
+ }
1418
+ const applied = revision.authorityKind === "plan-only";
1419
+ const revisionId = store.insertPlanRevision({ taskRef, ...revision, kind: "builder-proposal", status: applied ? "applied" : "blocked" }, now);
1420
+ const moved = revision.changedFields
1421
+ .map(field => (field === "signed-scope" ? "the signed scope" : "publication authority"))
1422
+ .join(" and ");
1423
+ if (!applied) {
1424
+ store.holdOwned({
1425
+ taskRef,
1426
+ ownerKind: "revision",
1427
+ ownerId: String(revisionId),
1428
+ reason: `plan-revision-blocked — ${moved} changed while this build ran, so revision ${revision.revision} waits for a person`,
1429
+ until: null,
1430
+ }, now);
1431
+ }
1432
+ store.finishRun(runId, { outcome: "refused", reason: applied ? "plan-revised" : "plan-revision-blocked", now });
1433
+ store.enqueueNotification({
1434
+ source: { run: runId },
1435
+ dedupeKey: `plan-revision:${taskRef}:${revisionId}`,
1436
+ kind: applied ? "plan-revised" : "plan-revision-blocked",
1437
+ ...(applied ? {} : { pushClass: "attention" }),
1438
+ link: `/t/${encodeURIComponent(taskId)}`,
1439
+ subject: `${taskId}: plan revision ${revision.revision} ${applied ? "applied — resuming" : "awaiting your approval"}`,
1440
+ body: applied
1441
+ ? `The build found the plan wrong and rewrote it: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing was committed. The next attempt builds against the new plan.`
1442
+ : `The build proposed a new plan, but ${moved} changed while it ran — so nothing was applied: ${oneLine(revision.reason, 200)}\nEvidence: ${oneLine(revision.evidenceLink ?? "none given", 200)}\nNothing runs on this task until you accept or reject the revision.`,
1443
+ }, now);
1444
+ return { ok: true, revisionId, authorityKind: revision.authorityKind };
1445
+ });
1446
+ }
1447
+ /**
1448
+ * The planner's own fenced failure finalizer (Codex planning review,
1449
+ * finding 6): releases exactly the planner claim, finishes the run,
1450
+ * counts a SEPARATE planning strike — a planner that cannot finish must
1451
+ * never spend the builder's three attempts — and eventually leaves a
1452
+ * durable incident + hold + page. It never marks the task done, never
1453
+ * touches builder strikes, never commits, never publishes.
1454
+ *
1455
+ * A malformed payload goes straight to its incident with no strike: the
1456
+ * protocol failed, not the weather, and retrying the same session buys
1457
+ * nothing without the repair machinery (deliberately not wired for
1458
+ * planners in v1).
1459
+ */
1460
+ export function finalizePlanFailureFenced(store, args) {
1461
+ const { leaseId, runId, taskId, kind, message, now } = args;
1462
+ const db = store.handle;
1463
+ return inTransaction(store, () => {
1464
+ const run = store.getRun(runId);
1465
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1466
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
1467
+ }
1468
+ if (run.role !== "planner") {
1469
+ throw new Error(`run ${runId} is a ${run.role} run — this finalizer seals planner attempts only`);
1470
+ }
1471
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1472
+ if (stopped !== null)
1473
+ return { ok: false, reason: "stopped" };
1474
+ const { changes } = db
1475
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
1476
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1477
+ .run(now.toISOString(), leaseId);
1478
+ if (Number(changes) === 0) {
1479
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1480
+ return refusal(db, leaseId);
1481
+ }
1482
+ const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-plan";
1483
+ store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
1484
+ if (kind === "malformed") {
1485
+ const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
1486
+ store.holdOwned({
1487
+ taskRef: run.taskRef,
1488
+ ownerKind: "incident",
1489
+ ownerId: String(incidentId),
1490
+ reason: `${malformedKind} — the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
1491
+ until: null,
1492
+ }, now);
1493
+ store.enqueueNotification({
1494
+ source: { run: runId },
1495
+ dedupeKey: `${malformedKind}:${runId}`,
1496
+ kind: malformedKind,
1497
+ pushClass: "attention",
1498
+ link: `/r/${runId}`,
1499
+ subject: `${taskId}: the planner's ${args.malformed === "decision" ? "question" : "plan"} failed validation`,
1500
+ body: `${oneLine(message, 300)}\nResolve the incident to let planning retry.`,
1501
+ }, now);
1502
+ return { ok: true, disposition: "malformed-incident", incidentId };
1503
+ }
1504
+ const strikes = store.addPlanStrike(run.taskRef);
1505
+ if (strikes >= MAX_PLAN_STRIKES) {
1506
+ const incidentId = store.createIncident({ run: runId, kind: "plan-attempts-exhausted" }, now);
1507
+ store.holdOwned({
1508
+ taskRef: run.taskRef,
1509
+ ownerKind: "incident",
1510
+ ownerId: String(incidentId),
1511
+ reason: `plan-attempts-exhausted — ${strikes} straight planning failures`,
1512
+ until: null,
1513
+ }, now);
1514
+ store.enqueueNotification({
1515
+ source: { run: runId },
1516
+ dedupeKey: `plan-stalled:${run.taskRef}`,
1517
+ kind: "plan-attempts-exhausted",
1518
+ pushClass: "attention",
1519
+ link: `/r/${runId}`,
1520
+ subject: `${taskId}: planning stalled after ${strikes} straight failures`,
1521
+ body: `Last failure: ${oneLine(message, 200)}\nResolve the incident to let planning retry, or write the scope yourself.`,
1522
+ }, now);
1523
+ return { ok: true, disposition: "exhausted", incidentId, strikes };
1524
+ }
1525
+ const wait = PLAN_BACKOFF_MS[Math.min(strikes, PLAN_BACKOFF_MS.length) - 1];
1526
+ const until = new Date(now.getTime() + wait);
1527
+ store.holdOwned({
1528
+ taskRef: run.taskRef,
1529
+ // Its own owner id: a planning pause must never displace the
1530
+ // builder's backoff for the same task, or vice versa.
1531
+ ownerKind: "backoff",
1532
+ ownerId: `plan:${run.taskRef}`,
1533
+ reason: `plan retry ${strikes}/${MAX_PLAN_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
1534
+ until,
1535
+ }, now);
1536
+ store.enqueueNotification({
1537
+ source: { run: runId },
1538
+ dedupeKey: `plan-run:${runId}:failed`,
1539
+ kind: "plan-failed",
1540
+ subject: `${taskId}: planning attempt failed, retry ${strikes}/${MAX_PLAN_STRIKES}`,
1541
+ body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
1542
+ }, now);
1543
+ return { ok: true, disposition: "backoff", strikes };
1544
+ });
1545
+ }
1546
+ /**
1547
+ * Seal a successful scouting run (mate arc §10): the report artifact, the
1548
+ * run outcome, the task's terminal state, and the page to the operator
1549
+ * exist together or — if the lease was superseded — not at all. Nothing
1550
+ * here touches a branch, a publication, or a scope: the deliverable is
1551
+ * the artifact, and the task is done the moment it verifies.
1552
+ */
1553
+ export function finalizeScoutFenced(store, args) {
1554
+ const { leaseId, runId, taskId, report, now } = args;
1555
+ const db = store.handle;
1556
+ return inTransaction(store, () => {
1557
+ const run = store.getRun(runId);
1558
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1559
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a report seals exactly one`);
1560
+ }
1561
+ if (run.role !== "scout") {
1562
+ throw new Error(`run ${runId} is a ${run.role} run — only scout runs deliver reports`);
1563
+ }
1564
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1565
+ if (stopped !== null)
1566
+ return { ok: false, reason: "stopped" };
1567
+ const { changes } = db
1568
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'completed'
1569
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1570
+ .run(now.toISOString(), leaseId);
1571
+ if (Number(changes) === 0) {
1572
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1573
+ return refusal(db, leaseId);
1574
+ }
1575
+ store.saveArtifact({ run: runId, kind: "report", ...args.artifact }, now);
1576
+ store.resetStrikes(run.taskRef);
1577
+ store.finishRun(runId, { outcome: "built", reason: "report-delivered", now });
1578
+ const done = store.setTaskState(taskId, "done", now);
1579
+ if (!done.ok) {
1580
+ // A mirror the tracker closed meanwhile: the report stands as evidence
1581
+ // on the run; the task keeps the tracker's word for its state.
1582
+ store.finishRun(runId, { outcome: "built", reason: `report-delivered (task ${done.reason})`, now });
1583
+ }
1584
+ store.enqueueNotification({
1585
+ source: { run: runId },
1586
+ dedupeKey: `report:${run.taskRef}:${runId}`,
1587
+ kind: "report-ready",
1588
+ subject: `${taskId}: report ready — ${oneLine(report.title, 120)}`,
1589
+ body: `${oneLine(report.summary, 300)}\nRead it on the task page${report.followUps.length > 0 ? `; ${report.followUps.length} follow-up(s) file with a tap` : ""}.`,
1590
+ }, now);
1591
+ return { ok: true };
1592
+ });
1593
+ }
1594
+ /**
1595
+ * The scout's fenced failure finalizer: the BUILDER's discipline, not the
1596
+ * planner's — a scout task has no builder attempts, so its strikes are the
1597
+ * task's own (three stall it, `task requeue` clears them), and a malformed
1598
+ * report is a straight incident with no strike. Never a branch, never a
1599
+ * repair session, never a commit.
1600
+ */
1601
+ export function finalizeScoutFailureFenced(store, args) {
1602
+ const { leaseId, runId, taskId, kind, message, now } = args;
1603
+ const db = store.handle;
1604
+ return inTransaction(store, () => {
1605
+ const run = store.getRun(runId);
1606
+ if (run === null || run.leaseId !== leaseId || run.outcome !== null) {
1607
+ throw new Error(`run ${runId} is not ${leaseId}'s open attempt — a failure seals exactly one`);
1608
+ }
1609
+ if (run.role !== "scout") {
1610
+ throw new Error(`run ${runId} is a ${run.role} run — this finalizer seals scout attempts only`);
1611
+ }
1612
+ const stopped = interruptIfStopped(store, { leaseId, runId, taskId, now });
1613
+ if (stopped !== null)
1614
+ return { ok: false, reason: "stopped" };
1615
+ const { changes } = db
1616
+ .prepare(`UPDATE claim SET released_at = ?, released_by = 'released'
1617
+ WHERE lease_id = ? AND released_at IS NULL AND ${NOT_SUPERSEDED}`)
1618
+ .run(now.toISOString(), leaseId);
1619
+ if (Number(changes) === 0) {
1620
+ store.finishRun(runId, { outcome: "refused", reason: "fenced", now });
1621
+ return refusal(db, leaseId);
1622
+ }
1623
+ const malformedKind = args.malformed === "decision" ? "malformed-decision" : "malformed-report";
1624
+ store.finishRun(runId, { outcome: "failed", reason: kind === "malformed" ? malformedKind : oneLine(message, 120), now });
1625
+ if (kind === "malformed") {
1626
+ const incidentId = store.createIncident({ run: runId, kind: malformedKind }, now);
1627
+ store.holdOwned({
1628
+ taskRef: run.taskRef,
1629
+ ownerKind: "incident",
1630
+ ownerId: String(incidentId),
1631
+ reason: `${malformedKind} — the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
1632
+ until: null,
1633
+ }, now);
1634
+ store.enqueueNotification({
1635
+ source: { run: runId },
1636
+ dedupeKey: `${malformedKind}:${runId}`,
1637
+ kind: malformedKind,
1638
+ pushClass: "attention",
1639
+ link: `/r/${runId}`,
1640
+ subject: `${taskId}: the scout's ${args.malformed === "decision" ? "question" : "report"} failed validation`,
1641
+ body: `${oneLine(message, 300)}\nResolve the incident to let scouting retry.`,
1642
+ }, now);
1643
+ return { ok: true, disposition: "malformed-incident", incidentId };
1644
+ }
1645
+ const strikes = store.addStrike(run.taskRef);
1646
+ if (strikes >= MAX_STRIKES) {
1647
+ const incidentId = store.createIncident({ run: runId, kind: "attempts-exhausted" }, now);
1648
+ store.holdOwned({
1649
+ taskRef: run.taskRef,
1650
+ ownerKind: "incident",
1651
+ ownerId: String(incidentId),
1652
+ reason: `attempts-exhausted — ${strikes} consecutive scouting failures`,
1653
+ until: null,
1654
+ }, now);
1655
+ db.prepare("UPDATE task SET state = 'failed', updated_at = ? WHERE id = ?").run(now.toISOString(), taskId);
1656
+ store.enqueueNotification({
1657
+ source: { run: runId },
1658
+ dedupeKey: `stalled:${run.taskRef}`,
1659
+ kind: "attempts-exhausted",
1660
+ pushClass: "attention",
1661
+ link: `/r/${runId}`,
1662
+ subject: `${taskId} stalled after ${strikes} straight scouting failures`,
1663
+ body: `Last failure: ${oneLine(message, 200)}\nIt will not be retried. Read the runs, then \`toolroll task requeue ${taskId}\`.`,
1664
+ }, now);
1665
+ return { ok: true, disposition: "stalled", incidentId, strikes };
1666
+ }
1667
+ const wait = BACKOFF_MS[Math.min(strikes, BACKOFF_MS.length) - 1];
1668
+ const until = new Date(now.getTime() + wait);
1669
+ store.holdOwned({
1670
+ taskRef: run.taskRef,
1671
+ ownerKind: "backoff",
1672
+ ownerId: String(run.taskRef),
1673
+ reason: `scout retry ${strikes}/${MAX_STRIKES} — backing off ${Math.round(wait / 60_000)}m`,
1674
+ until,
1675
+ }, now);
1676
+ store.enqueueNotification({
1677
+ source: { run: runId },
1678
+ dedupeKey: `run:${runId}:failed`,
1679
+ kind: "scout-failed",
1680
+ subject: `${taskId}: scouting attempt failed, retry ${strikes}/${MAX_STRIKES}`,
1681
+ body: `${oneLine(message, 200)}\nNext attempt no earlier than ${until.toISOString()}.`,
1682
+ }, now);
1683
+ return { ok: true, disposition: "backoff", strikes };
1684
+ });
1685
+ }
1686
+ export function currentClaim(store, taskRef, now) {
1687
+ const row = latest(store.handle, taskRef);
1688
+ if (row === undefined || !isLive(row, now.toISOString()))
1689
+ return null;
1690
+ return readClaim(row);
1691
+ }
1692
+ /**
1693
+ * Release every lease that has run out.
1694
+ *
1695
+ * Expiry alone already frees a task for acquisition, so this is not what makes
1696
+ * reclaim work — it is what makes it *visible*. A daemon reaping on a tick
1697
+ * turns "this lease is being ignored because its timestamp is in the past" into
1698
+ * a released row somebody can read, which is the difference between a system
1699
+ * that recovers and a system that appears to have lost the work.
1700
+ */
1701
+ export function reap(store, now) {
1702
+ const stamp = now.toISOString();
1703
+ const db = store.handle;
1704
+ // One transaction, so the list returned is exactly the list released — a
1705
+ // lease expiring between the read and the write belongs to the next reap.
1706
+ return inTransaction(store, () => {
1707
+ const expired = db
1708
+ .prepare("SELECT * FROM claim WHERE released_at IS NULL AND expires_at <= ?")
1709
+ .all(stamp);
1710
+ if (expired.length === 0)
1711
+ return [];
1712
+ db.prepare("UPDATE claim SET released_at = ?, released_by = 'reaped' WHERE released_at IS NULL AND expires_at <= ?").run(stamp, stamp);
1713
+ return expired.map(readClaim);
1714
+ });
1715
+ }
1716
+ function isLive(row, stamp) {
1717
+ return row["released_at"] === null && String(row["expires_at"]) > stamp;
1718
+ }
1719
+ function readClaim(row) {
1720
+ return {
1721
+ taskRef: Number(row["task_ref"]),
1722
+ leaseId: String(row["lease_id"]),
1723
+ generation: Number(row["lease_generation"]),
1724
+ runner: String(row["runner"]),
1725
+ acquiredAt: String(row["acquired_at"]),
1726
+ expiresAt: String(row["expires_at"]),
1727
+ heartbeatAt: String(row["heartbeat_at"]),
1728
+ };
1729
+ }
1730
+ /**
1731
+ * IMMEDIATE rather than DEFERRED: the write lock is taken up front, so two
1732
+ * processes racing for the same task queue behind one another instead of both
1733
+ * reading, both deciding they won, and one failing at commit time.
1734
+ */
1735
+ function inTransaction(store, body) {
1736
+ // The store's transact: same BEGIN IMMEDIATE, plus reentrancy — a caller
1737
+ // composing a fenced finalizer with more writes (completion + publication
1738
+ // intent) joins one transaction instead of dying on a nested BEGIN.
1739
+ return store.transact(body);
1740
+ }