tumwater 0.4.2 → 0.4.4

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 (354) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +27 -13
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +6 -27
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -29,12 +29,10 @@ export function planWithSalvagedWork(role, plan, restored, note) {
29
29
  restored,
30
30
  };
31
31
  }
32
- /** Resolve how a run starts, consuming the resume flags as a side effect (a flag is consumed
33
- * here so a resume that fails falls back to a normal fresh run; another shutdown mid-resume
34
- * sets it again) and reclaiming the interrupted run's re-queued prompt. Owns every read and
35
- * write of the resume flags, so a new cause or handoff rule is added here. */
36
- function resolveRunStartDecision(opts) {
37
- const s = opts.state;
32
+ /** A run-start's first phase: read the last merge's failure and clear it, so a merge that
33
+ * failed non-terminally (kept its pin) still feeds the error streak when this run's recovery
34
+ * re-queues that pin (finishRecoveryRun). */
35
+ function readPriorMergeFailure(s) {
38
36
  // The last merge's failure, read before the clear below: a merge that failed
39
37
  // non-terminally kept its pin (the merger's own vocabulary — review_error, merge_conflict,
40
38
  // merge_blocked — with the detail it wrote into `lastError`), and when this run's recovery
@@ -43,6 +41,13 @@ function resolveRunStartDecision(opts) {
43
41
  ? (s.lastError ?? `merge failed: ${s.lastResult}`)
44
42
  : undefined;
45
43
  s.lastError = undefined;
44
+ return priorMergeFailure;
45
+ }
46
+ /** A run-start's second phase: resolve, from the session ledger, which session a continue
47
+ * would reach and whether the backend can resume it, capturing the pending resume cause
48
+ * before the flags are consumed. */
49
+ function resolveResumeTarget(opts) {
50
+ const s = opts.state;
46
51
  // A run interrupted by a harness shutdown left its pi session and its worktree's
47
52
  // uncommitted edits in place: resume that session instead of starting fresh. The flag
48
53
  // is consumed here so a resume that fails falls back to a normal fresh run; another
@@ -69,7 +74,6 @@ function resolveRunStartDecision(opts) {
69
74
  const resumable = resumePending && !resumeMissing
70
75
  ? backendSessionResumable(dir, { kind: "author", agent }, opts.claudeConfigDir)
71
76
  : false;
72
- const resumableSession = resumable;
73
77
  // A newest line our backend cannot resume is not ours to continue: another kind's or
74
78
  // another agent's session, OR our own author line whose session the backend no longer holds
75
79
  // (a `--resume` that answered "No conversation found" mid-run, leaving `resumeMissing`, or a
@@ -83,48 +87,82 @@ function resolveRunStartDecision(opts) {
83
87
  // forward.
84
88
  // Captured before the flag is consumed below; a stale cause must not leak into a later resume.
85
89
  const pendingResumeCause = s.resumeCause;
90
+ return { latest, resumable, pendingResumeCause };
91
+ }
92
+ /** A run-start's third phase: decide whether this run resumes its interrupted session or hands
93
+ * off to a fresh session over the kept worktree. */
94
+ function resumeMode(latest, resumable, budgetCapped, imageReroute, resumeBlocked) {
95
+ const handoff = (latest !== null && !resumable) || budgetCapped || imageReroute;
96
+ // A budget-capped handback never resumes the session whose seam the cap moved off of, even
97
+ // when its transcript is still resumable: it hands off instead. An image reroute likewise.
98
+ const resuming = resumable && !resumeBlocked && !budgetCapped && !imageReroute;
99
+ const handingOff = handoff && !resumeBlocked;
100
+ return { resuming, handingOff };
101
+ }
102
+ /** A run-start's last phase: derive which cause reaches the bridge, naming a quiet-kill, the
103
+ * cut-off streak, the resume's own cause, or a plain restart/handoff. */
104
+ function resumeCauseFor(handingOff, budgetCapped, imageReroute, pendingResumeCause, cutOffStreak) {
105
+ // Why the resume: a named quiet-kill means the last run died on a stalled tool call (the
106
+ // bridge then warns against re-running it unchanged) or on the run deadline while still
107
+ // making progress (the bridge asks for the smallest finish against the same limit); a
108
+ // cut-off streak means the last run ran out of context and pi compacted the session (the
109
+ // bridge asks for the smallest finish); otherwise a shutdown/crash. A handoff names itself,
110
+ // except a budget-capped one, which names the cap that ended the run, and an image reroute,
111
+ // whose bridge names the blind read.
112
+ const resumeCause = handingOff
113
+ ? budgetCapped
114
+ ? "budget-capped"
115
+ : imageReroute
116
+ ? "image-reroute"
117
+ : "handoff"
118
+ : pendingResumeCause ?? (cutOffStreak > 0 ? "cut-off" : "restart");
119
+ return resumeCause;
120
+ }
121
+ /** Resolve how a run starts, consuming the resume flags as a side effect (a flag is consumed
122
+ * here so a resume that fails falls back to a normal fresh run; another shutdown mid-resume
123
+ * sets it again) and reclaiming the interrupted run's re-queued prompt. Owns every read and
124
+ * write of the resume flags, so a new cause or handoff rule is added here. The body composes
125
+ * the four named phases — readPriorMergeFailure, resolveResumeTarget, resumeMode and
126
+ * resumeCauseFor — in order. */
127
+ function resolveRunStartDecision(opts) {
128
+ const s = opts.state;
129
+ const priorMergeFailure = readPriorMergeFailure(s);
130
+ const { latest, resumable, pendingResumeCause } = resolveResumeTarget(opts);
86
131
  // A budget-capped API claude run is a handback onto the fallback seam: the kept worktree must
87
132
  // survive, but the next run is a FRESH session — the fallback seam may be a different backend,
88
133
  // and claude keeps no flags across `--resume` (plans/agent-backends.md §7, part 7a/7).
89
134
  const budgetCapped = pendingResumeCause === "budget-capped";
90
- const handoff = (latest !== null && !resumable) || budgetCapped;
91
- s.resumePending = false;
92
- s.resumeCause = undefined;
93
- s.resumeMissing = undefined;
135
+ // An image-blind run with an image-capable peer tier ends `aborted` with this cause
136
+ // (plans/image-input.md part 4/5): the next run runs a FRESH session on the TARGET seam
137
+ // over the kept worktree — never `--continue` — because the old session carries pi's
138
+ // "image omitted" note and the target may be a different backend.
139
+ const imageReroute = pendingResumeCause === "image-reroute";
94
140
  // An interruption during the review gate leaves the author's work fully committed — there
95
141
  // is nothing left to finish in its session. Recover (and re-review) the leftover commits
96
142
  // via a fresh run instead: continuing the author session would burn a run on finished
97
143
  // work, and any uncommitted edits are the reviewer's stray output, discarded by the fresh
98
144
  // path's reset below.
99
145
  const resumeBlocked = s.phase === "review";
100
- // A budget-capped handback never resumes the session whose seam the cap moved off of, even
101
- // when its transcript is still resumable: it hands off instead.
102
- const resuming = resumableSession && !resumeBlocked && !budgetCapped;
103
- const handingOff = handoff && !resumeBlocked;
146
+ const { resuming, handingOff } = resumeMode(latest, resumable, budgetCapped, imageReroute, resumeBlocked);
147
+ s.resumePending = false;
148
+ s.resumeCause = undefined;
149
+ s.resumeMissing = undefined;
104
150
  // Reclaim the prompt the interrupted run re-queued for its resume (src/inbox/pending-prompt.ts):
105
151
  // the resumed session still owns that request in its context, so this run's bookkeeping
106
152
  // operates on the queue's copy. A handoff runs a FRESH session, so it does not reclaim — the
107
153
  // fresh run dequeues the re-queued request like any other. Consumed even when this run does
108
154
  // not resume, so a stale record never survives into a later resume.
109
155
  opts.pending.reclaimForResume(s, resuming);
110
- // Why the resume: a named quiet-kill means the last run died on a stalled tool call (the
111
- // bridge then warns against re-running it unchanged) or on the run deadline while still
112
- // making progress (the bridge asks for the smallest finish against the same limit); a
113
- // cut-off streak means the last run ran out of context and pi compacted the session (the
114
- // bridge asks for the smallest finish); otherwise a shutdown/crash. A handoff names itself,
115
- // except a budget-capped one, which names the cap that ended the run.
116
- const resumeCause = handingOff
117
- ? budgetCapped
118
- ? "budget-capped"
119
- : "handoff"
120
- : pendingResumeCause ?? ((s.cutOffStreak ?? 0) > 0 ? "cut-off" : "restart");
121
- return { priorMergeFailure, resuming, handoff: handingOff, resumeCause };
156
+ const resumeCause = resumeCauseFor(handingOff, budgetCapped, imageReroute, pendingResumeCause, s.cutOffStreak ?? 0);
157
+ return { priorMergeFailure, resuming, handoff: handingOff, resumeCause, imageReroute };
122
158
  }
123
159
  /** Build the run's prompt from the resolved decision: the resume bridge on a resume, the
124
- * handoff bridge on a handoff, the assembled prompt otherwise. Returns null only when a
160
+ * handoff bridge (with the image-reroute facts) on a handoff, the assembled prompt otherwise.
161
+ * Reads the image-reroute facts before consuming their flag, and returns null only when a
125
162
  * handoff/fresh assemble yields nothing. The raw user prompt is read by the caller only when a
126
163
  * prompt exists. */
127
164
  function buildRunStartPrompt(opts, d) {
165
+ const s = opts.state;
128
166
  const agent = opts.agent ?? "pi";
129
167
  let prompt;
130
168
  if (d.resuming) {
@@ -132,11 +170,20 @@ function buildRunStartPrompt(opts, d) {
132
170
  }
133
171
  else {
134
172
  const assembled = opts.runPrompt(agent);
173
+ // The image-reroute handoff note names the model the blind run was on and the image it
174
+ // read; those facts ride the one-shot marker, which is consumed here. A handoff's fresh
175
+ // session also needs the to-be-run model named for its own side of the note, but the
176
+ // bridge already carries the facts that matter.
177
+ const rerouteFacts = d.imageReroute && s.imageReroute
178
+ ? { from: s.imageReroute.from ?? "the prior model", path: s.imageReroute.path ?? "an image" }
179
+ : undefined;
180
+ if (d.imageReroute)
181
+ s.imageReroute = undefined;
135
182
  prompt =
136
183
  assembled === null
137
184
  ? null
138
185
  : d.handoff
139
- ? buildHandoffPrompt(opts.role, assembled, d.resumeCause)
186
+ ? buildHandoffPrompt(opts.role, assembled, d.resumeCause, rerouteFacts)
140
187
  : assembled;
141
188
  }
142
189
  return prompt;
@@ -3,7 +3,7 @@ import { changedFiles } from "../git/git-diff.js";
3
3
  import { buildCommitMessage, commitTrailer, extractCommitBody, extractSummary, summaryOverCap, fallbackSummary, formatCommitBody, appendSelfCheckNotes, stampedSubject, } from "../git/commit-message.js";
4
4
  import { logEventBestEffort } from "../events/events.js";
5
5
  import { enqueueMerge, entryRevisionFields, queuedMerges } from "../merge/merge-queue.js";
6
- import { hasUnmergedPin } from "../runner/leftover.js";
6
+ import { hasUnmergedPin } from "../runner/runner-leftover.js";
7
7
  import { plural } from "../text/phrases.js";
8
8
  import { squash } from "../text/text.js";
9
9
  import { recordDismissedFiling, recordDismissedFlow } from "./run-dismiss.js";
@@ -56,75 +56,114 @@ function revisionFields(ctx) {
56
56
  : undefined;
57
57
  return { revisionRound, priorReview };
58
58
  }
59
+ /** The bounded follow-up turn both recovery paths share: fold the follow-up's commits past the
60
+ * head it started from, and end the run aborted (returning the outcome to surface) when the
61
+ * follow-up aborted, else hand back the follow-up run. */
62
+ async function requestSummaryFollowUp(ctx, over) {
63
+ const base = await headOf(ctx.wt, "HEAD");
64
+ const followUp = await ctx.requestSummary(ctx.wt, over);
65
+ await foldAgentCommitsSince(ctx, ctx.wt, base);
66
+ if (followUp?.aborted)
67
+ return { aborted: await ctx.finishAbortedRun() };
68
+ return { followUp };
69
+ }
70
+ /** Recover a summary when the reply left none: take the shared follow-up turn, refill `pending`
71
+ * from it, fall back to the changed paths when it still yields none, and warn about the recovery.
72
+ * Returns an outcome only when the follow-up aborted, else `undefined`. */
73
+ async function recoverMissingSummary(ctx, pending) {
74
+ const result = await requestSummaryFollowUp(ctx);
75
+ if ("aborted" in result)
76
+ return result.aborted;
77
+ const followUp = result.followUp;
78
+ if (followUp) {
79
+ pending.summary = extractSummary(followUp.finalText);
80
+ pending.body = pending.body ?? extractCommitBody(followUp.finalText);
81
+ }
82
+ if (pending.summary === null) {
83
+ pending.summary = fallbackSummary(await changedFiles(ctx.wt), ctx.role, ctx.state.runs);
84
+ pending.summaryDerived = true;
85
+ }
86
+ ctx.warn(missingSummaryWarning(followUp, pending.summary));
87
+ return undefined;
88
+ }
89
+ /** The missing-SUMMARY warning's four-way wording: "recovered it with a follow-up turn", or the
90
+ * derived-subject variants naming the follow-up's failure or absence. */
91
+ function missingSummaryWarning(followUp, summary) {
92
+ return (`reply had no SUMMARY line — ` +
93
+ (followUp && extractSummary(followUp.finalText) !== null
94
+ ? "recovered it with a follow-up turn"
95
+ : followUp && !followUp.ok
96
+ ? `the follow-up run failed (${followUp.errorMessage || "no error message"}); ` +
97
+ `subject derived from the changed files: "${summary}"`
98
+ : followUp
99
+ ? `follow-up gave none; subject derived from the changed files: "${summary}"`
100
+ : `no follow-up session was available; subject derived from the changed files: "${summary}"`));
101
+ }
102
+ /** Shorten a SUMMARY the cap would cut mid-word: take the shared follow-up turn asking for it
103
+ * restated short, keep the restated subject when it fits, and warn. Returns an outcome only when
104
+ * the follow-up aborted, else `undefined`. */
105
+ async function shortenOverCapSummary(ctx, pending) {
106
+ const over = summaryOverCap(ctx.finalText);
107
+ if (over === null)
108
+ return undefined;
109
+ // A SUMMARY the cap would cut mid-word gets one follow-up turn asking for it restated short.
110
+ const result = await requestSummaryFollowUp(ctx, over);
111
+ if ("aborted" in result)
112
+ return result.aborted;
113
+ const followUp = result.followUp;
114
+ const short = followUp && summaryOverCap(followUp.finalText) === null ? extractSummary(followUp.finalText) : null;
115
+ if (short !== null) {
116
+ pending.summary = short;
117
+ pending.body = pending.body ?? extractCommitBody(followUp.finalText);
118
+ }
119
+ ctx.warn(short !== null
120
+ ? `SUMMARY was ${over} characters — shortened with a follow-up turn`
121
+ : `SUMMARY was ${over} characters — follow-up gave no shorter one; subject truncated`);
122
+ return undefined;
123
+ }
59
124
  /** Derive the commit's subject and body from the reply's SUMMARY block, running one bounded
60
125
  * follow-up turn when the reply left none or a SUMMARY the cap would cut mid-word. Returns the
61
126
  * aborted-run outcome when a follow-up aborts, otherwise the description. */
62
127
  async function resolveCommitDescription(ctx) {
63
- const s = ctx.state;
64
128
  // The commit subject and body come from the reply's closing block. A run that changed files
65
129
  // without one — a cut-off final message, or plain non-compliance — gets one bounded follow-up
66
130
  // turn in its own session to produce it (the session still holds everything the run did);
67
131
  // only if that too yields nothing is the subject derived from the changed paths.
68
- let summary = extractSummary(ctx.finalText);
69
- let body = extractCommitBody(ctx.finalText);
70
- // True when no reply named the change, so the subject below is derived from its paths.
71
- let summaryDerived = false;
72
- if (summary === null) {
73
- const base = await headOf(ctx.wt, "HEAD");
74
- const followUp = await ctx.requestSummary(ctx.wt);
75
- await foldAgentCommitsSince(ctx, ctx.wt, base);
76
- if (followUp?.aborted)
77
- return ctx.finishAbortedRun();
78
- if (followUp) {
79
- summary = extractSummary(followUp.finalText);
80
- body = body ?? extractCommitBody(followUp.finalText);
81
- }
82
- if (summary === null) {
83
- summary = fallbackSummary(await changedFiles(ctx.wt), ctx.role, s.runs);
84
- summaryDerived = true;
85
- }
86
- ctx.warn(`reply had no SUMMARY line — ` +
87
- (followUp && extractSummary(followUp.finalText) !== null
88
- ? "recovered it with a follow-up turn"
89
- : followUp && !followUp.ok
90
- ? `the follow-up run failed (${followUp.errorMessage || "no error message"}); ` +
91
- `subject derived from the changed files: "${summary}"`
92
- : followUp
93
- ? `follow-up gave none; subject derived from the changed files: "${summary}"`
94
- : `no follow-up session was available; subject derived from the changed files: "${summary}"`));
95
- }
96
- else {
97
- // A SUMMARY the cap would cut mid-word gets one follow-up turn asking for it restated short.
98
- const over = summaryOverCap(ctx.finalText);
99
- if (over !== null) {
100
- const base = await headOf(ctx.wt, "HEAD");
101
- const followUp = await ctx.requestSummary(ctx.wt, over);
102
- await foldAgentCommitsSince(ctx, ctx.wt, base);
103
- if (followUp?.aborted)
104
- return ctx.finishAbortedRun();
105
- const short = followUp && summaryOverCap(followUp.finalText) === null ? extractSummary(followUp.finalText) : null;
106
- if (short !== null) {
107
- summary = short;
108
- body = body ?? extractCommitBody(followUp.finalText);
109
- }
110
- ctx.warn(short !== null
111
- ? `SUMMARY was ${over} characters — shortened with a follow-up turn`
112
- : `SUMMARY was ${over} characters — follow-up gave no shorter one; subject truncated`);
113
- }
114
- }
115
- return { summary, body, summaryDerived };
132
+ const pending = {
133
+ summary: extractSummary(ctx.finalText),
134
+ body: extractCommitBody(ctx.finalText),
135
+ // True when no reply named the change, so the subject below is derived from its paths.
136
+ summaryDerived: false,
137
+ };
138
+ const outcome = pending.summary === null
139
+ ? await recoverMissingSummary(ctx, pending)
140
+ : await shortenOverCapSummary(ctx, pending);
141
+ if (outcome)
142
+ return outcome;
143
+ return { summary: pending.summary, body: pending.body, summaryDerived: pending.summaryDerived };
116
144
  }
117
- /** Run the gate's deterministic backlog checks before the commit, giving the author's session
145
+ /** Run the gate's remaining checks before the commit, giving the author's session
118
146
  * one bounded fix-up turn, and end the run `no_change` when the fix-up reverts the whole change
119
147
  * (leaving nothing to commit). Returns an outcome only when the run must end early; otherwise
120
148
  * mutates `desc.summary`/`desc.body` with any fix-up recovery, exactly as the inline block did. */
121
149
  async function runStageSelfCheck(ctx, desc) {
122
- const s = ctx.state;
123
- // The gate's deterministic backlog checks run here, before the commit, while the authoring
124
- // session can still be continued: a finding that reaches review costs a queue slot,
125
- // a verification, and a rejection that discards the work. One bounded follow-up turn on the
126
- // author's own session gets to fix them; the gate still has the final say, and the change commits
127
- // and queues either way.
150
+ // The gate's remaining checks (a protected edit, a leaked credential) run here, before the
151
+ // commit, while the authoring session can still be continued: a finding that reaches review
152
+ // costs a queue slot, a verification, and a rejection that discards the work. One bounded
153
+ // follow-up turn on the author's own session gets to fix them, and the change commits and
154
+ // queues either way.
155
+ const moved = await stagedBacklogMoves(ctx);
156
+ const movedFindings = recordStagedClaims(ctx, moved);
157
+ const findings = await ctx.stageCheck(ctx.wt);
158
+ findings.push(...movedFindings);
159
+ if (findings.length === 0)
160
+ return undefined;
161
+ return applyStageFixUp(ctx, desc, findings);
162
+ }
163
+ /** Decide whether this is a claim-staging run and, if so, read the backlog entries its diff moved
164
+ * out of their actionable section (plans/parallel-work-instances.md "Claims", part 4/7). Returns
165
+ * `[]` for every non-claim-staging run and on any unreadable file or git hiccup. */
166
+ async function stagedBacklogMoves(ctx) {
128
167
  // Claims made at staging (plans/parallel-work-instances.md "Claims", part 4/7): an
129
168
  // unassigned run of a MULTI-instance base whose diff moved a backlog entry out of its
130
169
  // actionable section records the claim so a sibling is not assigned it while this change
@@ -139,6 +178,13 @@ async function runStageSelfCheck(ctx, desc) {
139
178
  configuredInstances(ctx.config, ctx.role) > 1
140
179
  ? await stagedMovedEntries(ctx.wt, ctx.mainBranch, ctx.role).catch(() => [])
141
180
  : [];
181
+ return moved;
182
+ }
183
+ /** Record the run's staged claim for the first claimable moved entry (when it has none yet) and
184
+ * return the moved-entry findings in the order the inline body pushed them: `assignedMoved`
185
+ * first, then `heldMoved`. */
186
+ function recordStagedClaims(ctx, moved) {
187
+ const s = ctx.state;
142
188
  const heldBySiblings = new Set(s.claim === undefined ? (s.heldElsewhere ?? []).map(entryKey) : []);
143
189
  const heldMoved = heldMovedFinding(heldBySiblings, moved);
144
190
  const claimable = moved.filter((m) => !heldBySiblings.has(m.key));
@@ -157,45 +203,50 @@ async function runStageSelfCheck(ctx, desc) {
157
203
  });
158
204
  }
159
205
  const assignedMoved = s.claim ? assignedMovedFinding(s.claim.key, moved) : undefined;
160
- const findings = await ctx.stageCheck(ctx.wt);
206
+ const findings = [];
161
207
  if (assignedMoved)
162
208
  findings.push(assignedMoved);
163
209
  if (heldMoved)
164
210
  findings.push(heldMoved);
165
- if (findings.length > 0) {
166
- const before = findings.length;
167
- const fixBase = await headOf(ctx.wt, "HEAD");
168
- const fixUp = await ctx.requestStageFix(ctx.wt, findings, {
169
- summary: desc.summaryDerived ? null : desc.summary,
170
- body: desc.body ? formatCommitBody(desc.body) : "",
171
- });
172
- await foldAgentCommitsSince(ctx, ctx.wt, fixBase);
173
- if (fixUp?.aborted)
211
+ return findings;
212
+ }
213
+ /** Run the author's one bounded fix-up turn over the stage findings, fold its commits, recover
214
+ * `desc.summary`/`desc.body` from the reply, and end the run `no_change` when the follow-up left
215
+ * the worktree clean; otherwise stage-check again and log the outcome warning. Returns an outcome
216
+ * only when it ends the run early, else `undefined`. */
217
+ async function applyStageFixUp(ctx, desc, findings) {
218
+ const before = findings.length;
219
+ const fixBase = await headOf(ctx.wt, "HEAD");
220
+ const fixUp = await ctx.requestStageFix(ctx.wt, findings, {
221
+ summary: desc.summaryDerived ? null : desc.summary,
222
+ body: desc.body ? formatCommitBody(desc.body) : "",
223
+ });
224
+ await foldAgentCommitsSince(ctx, ctx.wt, fixBase);
225
+ if (fixUp) {
226
+ if (fixUp.aborted)
174
227
  return ctx.finishAbortedRun();
175
- if (fixUp) {
176
- // The fix-up reply answers the findings, not the change, so it never replaces the
177
- // change's description (BUGS.md 2026-10-21): its SUMMARY is taken only over a subject
178
- // derived from the paths, and its RISK/VERIFIED are appended as self-check notes.
179
- const fixSummary = desc.summaryDerived ? extractSummary(fixUp.finalText) : null;
180
- if (fixSummary !== null)
181
- desc.summary = fixSummary;
182
- desc.body = appendSelfCheckNotes(desc.body, extractCommitBody(fixUp.finalText));
183
- }
184
- // A fix-up turn may resolve its findings by reverting the whole change. With nothing left
185
- // in the worktree there is nothing to commit or queue: end the run no_change here instead
186
- // of handing a clean tree to commitAll. The worktree is left clean and no pin is taken.
187
- // isDirty, not changedFiles: a `git status` that fails must throw, not read as clean — on
188
- // 2026-10-21 a spawn failure here ended bugfix-2's 17-minute run no_change and released
189
- // its edits unpinned; thrown, the run ends unsettled and its next run gets them back.
190
- if (!(await isDirty(ctx.wt))) {
191
- logSelfCheckOutcome(ctx, "stage self-check: the follow-up turn left no change; nothing to land", findings);
192
- return { result: "no_change" };
193
- }
194
- const remaining = await ctx.stageCheck(ctx.wt);
195
- logSelfCheckOutcome(ctx, remaining.length === 0
196
- ? `stage self-check: ${plural(before, "finding")} — fixed by the follow-up turn`
197
- : `stage self-check: ${plural(before, "finding")}; ${remaining.length} still open, queued for the gate`, findings, remaining);
228
+ // The fix-up reply answers the findings, not the change, so it never replaces the
229
+ // change's description (BUGS.md 2026-10-21): its SUMMARY is taken only over a subject
230
+ // derived from the paths, and its RISK/VERIFIED are appended as self-check notes.
231
+ const fixSummary = desc.summaryDerived ? extractSummary(fixUp.finalText) : null;
232
+ if (fixSummary !== null)
233
+ desc.summary = fixSummary;
234
+ desc.body = appendSelfCheckNotes(desc.body, extractCommitBody(fixUp.finalText));
235
+ }
236
+ // A fix-up turn may resolve its findings by reverting the whole change. With nothing left
237
+ // in the worktree there is nothing to commit or queue: end the run no_change here instead
238
+ // of handing a clean tree to commitAll. The worktree is left clean and no pin is taken.
239
+ // isDirty, not changedFiles: a `git status` that fails must throw, not read as clean — on
240
+ // 2026-10-21 a spawn failure here ended bugfix-2's 17-minute run no_change and released
241
+ // its edits unpinned; thrown, the run ends unsettled and its next run gets them back.
242
+ if (!(await isDirty(ctx.wt))) {
243
+ logSelfCheckOutcome(ctx, "stage self-check: the follow-up turn left no change; nothing to land", findings);
244
+ return { result: "no_change" };
198
245
  }
246
+ const remaining = await ctx.stageCheck(ctx.wt);
247
+ logSelfCheckOutcome(ctx, remaining.length === 0
248
+ ? `stage self-check: ${plural(before, "finding")} — fixed by the follow-up turn`
249
+ : `stage self-check: ${plural(before, "finding")}; ${remaining.length} still open, queued for the gate`, findings, remaining);
199
250
  return undefined;
200
251
  }
201
252
  /** Compute the run's friction signal over this run's pre-commit authoring runs: minutes of wall
@@ -89,16 +89,22 @@ export async function runTimedRoleRun(signal, acquire, release, run, now = Date.
89
89
  /** Sleep up to ms, but wake immediately when `signal` aborts — so shutdown (SIGTERM →
90
90
  * abort) is prompt instead of waiting out the current poll cycle. The listener is removed
91
91
  * on either exit path so long-running orchestrators don't accumulate one per poll, and an
92
- * already-aborted signal returns synchronously through runOnAbort. Exported as a unit-test
92
+ * already-aborted signal returns synchronously through runOnAbort. Without a signal it is a
93
+ * plain setTimeout sleep. The review gate's retry wait (src/review/review.ts, whose signal is
94
+ * optional) shares this helper rather than keeping a second copy. Exported as a unit-test
93
95
  * seam, like runTimedRoleRun above. */
94
96
  export function sleepInterruptible(ms, signal) {
95
97
  return new Promise((resolve) => {
98
+ if (signal === undefined) {
99
+ setTimeout(resolve, ms);
100
+ return;
101
+ }
96
102
  const onAbort = () => {
97
103
  clearTimeout(timer);
98
104
  resolve();
99
105
  };
100
106
  const timer = setTimeout(() => {
101
- signal.removeEventListener("abort", onAbort);
107
+ signal?.removeEventListener("abort", onAbort);
102
108
  resolve();
103
109
  }, ms);
104
110
  runOnAbort(signal, onAbort);
@@ -108,9 +108,24 @@ export class RunUsage {
108
108
  * through foldMergeUsage: those never advance the run's pre-edit counter, which measures
109
109
  * the authoring search. */
110
110
  fold(s, run, authoring = true, provider, pair) {
111
+ this.foldLifetimeTotals(s, run);
112
+ this.foldCost(s, run, provider, pair);
113
+ this.foldAccount(run);
114
+ this.foldPromptTokens(run, authoring);
115
+ this.turns += run.turns;
116
+ this.foldEpisodes(run, provider);
117
+ }
118
+ /** Grow the role's lifetime totals on `s` from this run: its generated tokens and the real
119
+ * spend both accumulate through the healed counter, and the peak context is the new max. */
120
+ foldLifetimeTotals(s, run) {
111
121
  s.generatedTokens = counter(s.generatedTokens) + run.outputTokens;
112
122
  s.peakContextTokens = Math.max(counter(s.peakContextTokens), run.peakContextTokens);
113
123
  s.totalCostUsd = counter(s.totalCostUsd) + run.costUsd;
124
+ }
125
+ /** Split this run's cost real from notional: a subscription run's cost is notional and never
126
+ * charges the daily cap; an API run's — or a run that ever saw overage — is real money.
127
+ * totalCostUsd still counts both: it is lifetime usage, not a budget figure. */
128
+ foldCost(s, run, provider, pair) {
114
129
  // The billing split (plans/agent-backends.md §7): a subscription run's cost is notional
115
130
  // and never charges the daily cap; an API run's — or a run that ever saw overage — is real
116
131
  // money. totalCostUsd still counts both: it is lifetime usage, not a budget figure.
@@ -126,12 +141,21 @@ export class RunUsage {
126
141
  this.notionalCostUsd += run.costUsd;
127
142
  this.bumpProviderCost(provider, run.costUsd, 'notionalCostUsd');
128
143
  }
144
+ }
145
+ /** Carry the newest account readings this run reported: the billing state, the subscription
146
+ * rate windows, and the agent they came from (whose name is only meaningful beside them). */
147
+ foldAccount(run) {
129
148
  if (run.billing !== undefined)
130
149
  this.billing = run.billing;
131
150
  if (run.rateWindows !== undefined)
132
151
  this.rateWindows = run.rateWindows;
133
152
  if (run.billing !== undefined || run.rateWindows !== undefined)
134
153
  this.agent = run.agent;
154
+ }
155
+ /** Advance the token counters: the prompt and cache-read shares count every run, while the
156
+ * pre-edit prefix freezes at the first edit — authoring-only, and skipped for a merge/review
157
+ * run. */
158
+ foldPromptTokens(run, authoring) {
135
159
  this.promptTokens += run.promptTokens;
136
160
  this.cacheReadTokens += run.cacheReadTokens;
137
161
  // The pre-edit prefix freezes at the run's first edit: every authoring run while no edit
@@ -143,7 +167,13 @@ export class RunUsage {
143
167
  if (run.firstEditTurn !== undefined)
144
168
  this.editSeen = true;
145
169
  }
146
- this.turns += run.turns;
170
+ }
171
+ /** Stamp the team-wide hold's failure observations for a run that ENDED on them: pi exits on
172
+ * a 429 or backend error, so "now" is when the provider refused. A run that merely logged one
173
+ * inside pi's own auto-retry and then finished would stamp a false observation, possibly hours
174
+ * late. The backend kind travels with its stamp (src/pi/pi-failure.ts classified the text at
175
+ * parse time); a 429 never ends up here — pi-stream checks rate-limit first. */
176
+ foldEpisodes(run, provider) {
147
177
  // The team-wide hold's rate-limit input (lastRateLimit above), from the same
148
178
  // once-per-run choke point. Only a run that ENDED on the 429 counts: pi exits on the error,
149
179
  // so "now" is when the provider refused — a run that merely logged one inside pi's own