tumwater 0.4.2 → 0.4.3

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 (305) 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} +25 -12
  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 +1 -8
  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 +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -196,64 +196,79 @@ function spanningGarbled(joined, joinAt, patterns) {
196
196
  }
197
197
  return null;
198
198
  }
199
- /** (e) Added lines, outside record prose, that match GARBLED_WORDING: at most one hit per line,
200
- * reported as `file:line "matched text"` in a single finding. A phrase split across two
201
- * consecutive added lines is reported as `file:N-M`. */
202
- export function garbledWordingFindings(diff) {
203
- const hits = [];
199
+ /** The added lines of a diff, in order, each with the added line that precedes it. */
200
+ function addedDiffLines(diff) {
201
+ const out = [];
204
202
  let file = null;
205
203
  let lineNo = 0;
206
- let prevAdded = null;
204
+ let prev = null;
207
205
  for (const line of diff.split("\n")) {
208
206
  const header = /^diff --git a\/.* b\/(.*)$/.exec(line);
209
207
  if (header) {
210
208
  file = header[1];
211
- prevAdded = null;
209
+ prev = null;
212
210
  continue;
213
211
  }
214
212
  const hunk = /^@@ .*\+(\d+)(?:,\d+)? @@/.exec(line);
215
213
  if (hunk) {
216
214
  lineNo = Number(hunk[1]);
217
- prevAdded = null;
215
+ prev = null;
218
216
  continue;
219
217
  }
220
218
  if (!line.startsWith("+") || line.startsWith("+++ ")) {
221
- prevAdded = null;
219
+ prev = null;
222
220
  continue;
223
221
  }
224
- const cur = line.slice(1);
225
- if (file !== null && !isRecordProse(file)) {
226
- let hit = false;
227
- for (const re of GARBLED_WORDING) {
228
- const m = re.exec(cur);
229
- if (m) {
230
- hits.push(`${file}:${lineNo} "${m[0]}"`);
231
- hit = true;
232
- break;
233
- }
234
- }
235
- const prose = proseOf(file, cur);
236
- if (!hit && prose !== null) {
237
- const m = DOUBLED_WORD.exec(prose);
238
- if (m) {
239
- hits.push(`${file}:${lineNo} "${m[0]}"`);
240
- hit = true;
241
- }
242
- }
243
- if (!hit && prevAdded !== null) {
244
- const head = prevAdded.trimEnd();
245
- const joined = head + " " + cur.replace(/^\s*(?:\/\/+|\*|#)\s*/, "");
246
- let split = spanningGarbled(joined, head.length, GARBLED_WORDING);
247
- if (!split && prose !== null && proseOf(file, prevAdded) !== null) {
248
- split = spanningGarbled(joined, head.length, [DOUBLED_WORD]);
249
- }
250
- if (split)
251
- hits.push(`${file}:${lineNo - 1}-${lineNo} "${split}"`);
252
- }
253
- }
254
- prevAdded = cur;
222
+ const text = line.slice(1);
223
+ if (file !== null)
224
+ out.push({ file, lineNo, text, prev });
225
+ prev = text;
255
226
  lineNo++;
256
227
  }
228
+ return out;
229
+ }
230
+ /** The first GARBLED_WORDING hit on a single added line, or the DOUBLED_WORD hit in its prose. */
231
+ function singleLineGarbledHit(file, lineNo, cur) {
232
+ for (const re of GARBLED_WORDING) {
233
+ const m = re.exec(cur);
234
+ if (m)
235
+ return `${file}:${lineNo} "${m[0]}"`;
236
+ }
237
+ const prose = proseOf(file, cur);
238
+ if (prose !== null) {
239
+ const m = DOUBLED_WORD.exec(prose);
240
+ if (m)
241
+ return `${file}:${lineNo} "${m[0]}"`;
242
+ }
243
+ return null;
244
+ }
245
+ /** A garbled or doubled phrase split across two consecutive added lines, joined at their seam. */
246
+ function spanningGarbledHit(file, lineNo, cur, prevAdded) {
247
+ if (prevAdded === null)
248
+ return null;
249
+ const head = prevAdded.trimEnd();
250
+ const joined = head + " " + cur.replace(/^\s*(?:\/\/+|\*|#)\s*/, "");
251
+ let split = spanningGarbled(joined, head.length, GARBLED_WORDING);
252
+ const prose = proseOf(file, cur);
253
+ if (!split && prose !== null && proseOf(file, prevAdded) !== null) {
254
+ split = spanningGarbled(joined, head.length, [DOUBLED_WORD]);
255
+ }
256
+ if (split)
257
+ return `${file}:${lineNo - 1}-${lineNo} "${split}"`;
258
+ return null;
259
+ }
260
+ /** (e) Added lines, outside record prose, that match GARBLED_WORDING: at most one hit per line,
261
+ * reported as `file:line "matched text"` in a single finding. A phrase split across two
262
+ * consecutive added lines is reported as `file:N-M`. */
263
+ export function garbledWordingFindings(diff) {
264
+ const hits = [];
265
+ for (const { file, lineNo, text, prev } of addedDiffLines(diff)) {
266
+ if (isRecordProse(file))
267
+ continue;
268
+ const hit = singleLineGarbledHit(file, lineNo, text) ?? spanningGarbledHit(file, lineNo, text, prev);
269
+ if (hit !== null)
270
+ hits.push(hit);
271
+ }
257
272
  if (hits.length === 0)
258
273
  return [];
259
274
  const lead = 'added lines carry garbled rename wording (reword them; a rote word swap such as "tick" → "run" reads wrong here or leaves a word doubled): '; // vocabulary-ok: quotes the retired word
@@ -10,7 +10,7 @@ import { mergeToMain } from "../merge/merge-to-main.js";
10
10
  import { MERGE_LOCK_WAIT_MS } from "../paths.js";
11
11
  import { resolveRunVerdict } from "../run/run-verdict.js";
12
12
  import { modelTrailerLine } from "../git/commit-message.js";
13
- import { seamAgent } from "../config/model-selector.js";
13
+ import { seamAgent } from "../config/config-model-selector.js";
14
14
  import { stageRunMerge } from "../run/run-stage.js";
15
15
  import { stageCheckFindings } from "../run/stage-check.js";
16
16
  /** Merge the worktree branch on main (see src/merge/merge-to-main.ts for the
@@ -62,6 +62,11 @@ export async function handlePiResultPhase(ctx, pi, userPrompt, revisionRound, wt
62
62
  flow,
63
63
  filed,
64
64
  ci,
65
+ // The image-reroute arm resolves its target from the live config and the current
66
+ // regime, and names the model this run ran on as the handoff's `from`.
67
+ config: ctx.config,
68
+ rerouteRegime: ctx.rerouteRegime(),
69
+ runModel: cfg.model,
65
70
  warn: (message) => ctx.warn(message),
66
71
  merge: (w, sum) => mergePhase(ctx, w, sum),
67
72
  finishAbortedRun: () => ctx.finishAbortedRun(userPrompt, wt),
@@ -3,7 +3,6 @@ import { backendSessionResumable } from "../agent/agent-backend.js";
3
3
  import { HOLD_BASE_MS } from "../team/team-hold.js";
4
4
  import { backendKindPhrase } from "../text/phrases.js";
5
5
  import { configForRole, resolvedBackendConfig, seamHoldKey, seamPairName } from "../config/config-views.js";
6
- import { ensureProviderLease } from "../concurrency/provider-lease.js";
7
6
  import { buildSummaryRequestPrompt } from "../prompt/prompt-followup.js";
8
7
  import { readClaudeAccount, subscriptionWindowRejected } from "../agent/claude/claude-account.js";
9
8
  import { observeOpencodeRun, opencodeModelKey } from "../agent/opencode/opencode-catalog.js";
@@ -223,12 +222,6 @@ export class RunnerPi {
223
222
  // Names the seam in per-run provider warnings; omitted when pi picks its own default model.
224
223
  const on = opts.config.model !== undefined ? ` on ${pair}` : "";
225
224
  const fold = (run) => authoring ? this.host.foldUsage(run, provider, pair) : this.host.foldMergeUsage(run, provider, pair);
226
- // The agent run re-keys the calling run's provider lease to the provider THIS agent run uses,
227
- // if it is not already there (plans/provider-concurrency.md §5): a budget handback or a
228
- // model-failure switch that moved this agent run onto another seam moves the run's lease with
229
- // it, so a claude-authored run whose gate agent run lands on omlx is counted there. No-op when
230
- // the run carries no lease (a bare RunnerPi unit test, `tumwater history`).
231
- await ensureProviderLease(opts.config);
232
225
  const pi = await runPi(opts);
233
226
  this.warnFallbackClone(pi);
234
227
  this.observeFreeModelCost(pi, opts.config);
@@ -358,7 +351,7 @@ export class RunnerPi {
358
351
  }
359
352
  /** Run pi for this runner's authoring run in worktree `wt`: the shared per-runner wiring and
360
353
  * the transient-failure retry, with usage folded into the run's counters. It is the ONE
361
- * entry point that carries the role notes (PLANS.md "Role notebook": TUMWATER_NOTES_PATH,
354
+ * entry point that carries the role notes (TUMWATER_NOTES_PATH,
362
355
  * read by the `tumwater-notes` command): every other agent run (runRolePi's merge/conflict path,
363
356
  * runMergePi, runGatePi) leaves it unset, and the director is excluded because its work is
364
357
  * the operator's prompt, not a recurring search.
@@ -409,8 +402,8 @@ export class RunnerPi {
409
402
  * abort.
410
403
  *
411
404
  * The follow-up continues this run's own session, so it must run on the same pair that
412
- * session ran on: the caller passes the run's effective config (the fallback pair while a
413
- * model-fallback episode is active). A caller without one rides the role's own config. The
405
+ * session ran on: the caller passes the run's effective config (a budget cap's fallback
406
+ * pair while a cap episode is active). A caller without one rides the role's own config. The
414
407
  * shared per-runner wiring (runnerPiOpts) takes the follow-up's hard caps overriding the
415
408
  * authoring run's budget: one short reply on a warm session. The agent run takes the runner's
416
409
  * SHARED transient-failure retry (BUGS.md 2026-10-01): a 429 on the follow-up turn waits its
@@ -48,7 +48,7 @@ export async function pinAndResetPhase(ctx, wt, sha) {
48
48
  await resetWorktreeToMain(wt, ctx.mainBranch);
49
49
  return true;
50
50
  }
51
- /** End a run whose leftover recovery found work to salvage (src/runner/leftover.ts) without an
51
+ /** End a run whose leftover recovery found work to salvage (runner-leftover.ts) without an
52
52
  * authoring run — the leftover owns the role's one merge ref until its merge resolves. A
53
53
  * run's dequeued user prompt goes back to its queue, since nothing ran it. A pin put on
54
54
  * the merge queue ends the run `queued` exactly like a fresh changed run (the merge-queue
@@ -39,7 +39,7 @@ export const REVISION_BACKSTOP = 6;
39
39
  * sees exactly what would not converge. PLANS.md plans only, by design: the convergence hold
40
40
  * parks a revised plan whose author hit the round cap, so only the plan role needs to clear it;
41
41
  * a role's own too-large Needs-review note on a BUGS.md entry is the bug path (the
42
- * stalledEntries/operatorHeldEntries comments in src/backlog/backlog-eligibility.ts), which the
42
+ * stalledEntries comment in src/backlog/backlog-eligibility.ts), which the
43
43
  * plan role resolves by splitting it. A bug that cannot converge stays with the existing re-pick
44
44
  * behaviour (reviewer-feedback delivery, part 4/4, addresses that churn).
45
45
  * Written through the primary worktree lock, as every other backlog write is, and then committed
@@ -6,23 +6,25 @@ import { abortSync, ensureWorktree, resetWorktreeToMain } from "../git/worktree.
6
6
  import { leaseSlot } from "../git/worktree-pool.js";
7
7
  import { slotCount } from "../config/config.js";
8
8
  import { useWorktree } from "../git/worktree-use.js";
9
+ import { readPiListedModels } from "../pi/pi-listed-models.js";
9
10
  import { branchName, worktreePath, mergeRefName, rejectedRefName } from "../paths.js";
10
11
  import { logEventBestEffort } from "../events/events.js";
11
12
  import { buildConflictHandbackNote, buildSalvageRestoreNote } from "../prompt/gate-prompts.js";
12
13
  import { buildConflictDiscardNote, buildMainKnownRedNote, buildRevisionNote, buildRejectedReviewNote, } from "../prompt/gate-notes.js";
13
- import { modelSelectorField, resolvedBackendConfig, reviewRunConfig, reviewedAfterMerge, scheduleConfigForRole, seamHoldKey, } from "../config/config-views.js";
14
- import { fallbackRoleConfig } from "../config/config-fallbacks.js";
14
+ import { modelSelectorField, resolvedBackendConfig, reviewRunConfig, reviewedAfterMerge, scheduleConfigForRole, seamHoldKey, withModelOverrides, } from "../config/config-views.js";
15
+ import { imageReroute, seamImageInput } from "../agent/image-input.js";
16
+ import { piModelsPath, readPiProviders } from "../pi/pi-models.js";
17
+ import { peekPrompt, peekRolePrompt } from "../inbox/inbox.js";
15
18
  import { planRunStart, planWithSalvagedWork, takesSalvagedWork } from "../run/run-resume.js";
16
19
  import { restoreSalvagedWork } from "../git/slot-salvage.js";
17
20
  import { DEFAULT_BACKEND } from "../agent/agent-backend.js";
18
21
  import { foldAgentCommitsSince } from "../run/run-stage.js";
19
22
  import { planBacklogFull } from "../scheduling/claims.js";
20
- import { modelFallbackActive, modelFallbackProbe } from "./model-fallback.js";
21
23
  import { finalizeRun } from "../run/run-finalize.js";
22
24
  import { errorMessage } from "../text/text.js";
23
25
  import { appendPromptNote, prependPromptNote } from "../text/format.js";
24
- import { hasUnmergedPin, recoverLeftover } from "./leftover.js";
25
- import { applyRevision, applyWithConflicts } from "./revision.js";
26
+ import { hasUnmergedPin, recoverLeftover } from "./runner-leftover.js";
27
+ import { applyRevision, applyWithConflicts } from "./runner-revision.js";
26
28
  import { conflictedFileHunks, hasConflictMarkers, mainCommitsTouching } from "../merge/merge-git.js";
27
29
  import { changedFiles } from "../git/git-diff.js";
28
30
  import { handlePiResultPhase } from "./runner-outcome.js";
@@ -57,10 +59,9 @@ async function markerBearingFiles(wt) {
57
59
  const files = await changedFiles(wt);
58
60
  return files.filter((f) => hasConflictMarkers(wt, [f]));
59
61
  }
60
- /** Run one full run of a role runner: resolve the config (including the model-fallback probe),
61
- * log the start, run the pipeline, then let finalizeRun schedule the next run. Never throws: a
62
- * failed run is an "error" result, saved and logged like any other so the runner stays
63
- * resumable and observable. */
62
+ /** Run one full run of a role runner: resolve the config, log the start, run the pipeline, then
63
+ * let finalizeRun schedule the next run. Never throws: a failed run is an "error" result,
64
+ * saved and logged like any other so the runner stays resumable and observable. */
64
65
  export async function runPhase(ctx) {
65
66
  const s = ctx.state;
66
67
  // This role's view of the config (per-role provider/model/thinking + minRunIntervalSeconds
@@ -73,20 +74,67 @@ export async function runPhase(ctx) {
73
74
  // the one home of the rule, so the scheduler that admits plan and the nextRunAt this run
74
75
  // writes cannot disagree.
75
76
  const planBacklogOpen = !planBacklogFull(ctx.root, ctx.config);
77
+ // This run's effective config: the role's own resolved config (a run always resolves its
78
+ // role's own config — the model-fallback episode is gone, Subtract part 5o/6) unless an image
79
+ // reroute below moves it. The budget gate's fallback pair, when a cap episode is active, is
80
+ // installed into the live config itself, so the config here already names the pair the run
81
+ // uses.
76
82
  const primaryCfg = scheduleConfigForRole(ctx.config, ctx.role, planBacklogOpen);
77
- // Model-fallback episode (src/runner/model-fallback.ts): while one is active the run runs
78
- // on the role's tier fallback pair, and once the cooldown elapses it runs on the primary
79
- // as the probe. Resolved at run start so the authoring run below and the end-of-run fold
80
- // agree on which pair ran. Null fallback (the tier resolves to pause) keeps the feature off.
81
- const now = Date.now();
82
- const fallback = fallbackRoleConfig(ctx.config, ctx.role);
83
- const probing = fallback !== null && modelFallbackProbe(s.modelFallback, now);
84
- const onFallback = fallback !== null && modelFallbackActive(s.modelFallback, now);
85
- // The fallback pair keeps primaryCfg's gap (they resolve the same one outside the plan
86
- // schedule's zero-gap window), so a plan on its model fallback still schedules the same gap.
87
- let cfg = onFallback && fallback !== null
88
- ? { ...fallback, minRunIntervalSeconds: primaryCfg.minRunIntervalSeconds }
89
- : primaryCfg;
83
+ let cfg = primaryCfg;
84
+ // Image-input reroute (plans/image-input.md part 4/5). A pending one-shot marker means
85
+ // THIS run is the blind-read reroute: start directly on the image-capable target the
86
+ // prior run recorded, over the kept worktree, in a fresh session. The marker is consumed
87
+ // (cleared) here so it does not route a later run; imageRerouteActive stays true for the
88
+ // whole run so the verdict's one-hop guard fails a second blind read instead of
89
+ // rerouting again. A preflight (below) sets the same active flag without a marker.
90
+ s.imageRerouteActive = false;
91
+ let preflightBlocked = false;
92
+ if (s.imageReroute) {
93
+ const r = s.imageReroute;
94
+ cfg = withModelOverrides(ctx.config, r.tier, {
95
+ backend: r.backend,
96
+ provider: r.provider,
97
+ model: r.model,
98
+ thinking: r.thinking,
99
+ });
100
+ // The marker is NOT cleared here: planRunStart reads its `from`/`path` when it builds
101
+ // the handoff note (and clears it then), so a run skipped before the plan (an empty
102
+ // inbox) leaves the reroute pending for the next eligible run.
103
+ s.imageRerouteActive = true;
104
+ }
105
+ else {
106
+ // Preflight (plans/image-input.md "Attachments"): a queued prompt carrying an
107
+ // `[image attached: …]` line needs a model that can read images BEFORE any run starts,
108
+ // so a text-only run hops straight to the target instead of spending a failed run
109
+ // learning it; with no target the run errors without spawning pi.
110
+ const nextPrompt = ctx.role === DIRECTOR_ROLE ? peekPrompt(ctx.root) : peekRolePrompt(ctx.root, ctx.role);
111
+ const carriesImages = nextPrompt !== null && /^\[image attached: .+\]$/m.test(nextPrompt);
112
+ if (carriesImages) {
113
+ const providers = readPiProviders(piModelsPath());
114
+ const listed = readPiListedModels(ctx.root);
115
+ if (seamImageInput(cfg, providers, listed) !== true) {
116
+ const target = imageReroute(ctx.config, ctx.role, ctx.rerouteRegime(), undefined, null, null, listed);
117
+ if (target !== null) {
118
+ cfg = target.config;
119
+ s.imageRerouteActive = true;
120
+ logEventBestEffort(ctx.root, {
121
+ source: ctx.role,
122
+ type: "image_reroute",
123
+ run: s.runs + 1,
124
+ from: primaryCfg.model,
125
+ to: target.config.model,
126
+ tier: target.tier,
127
+ preflight: true,
128
+ });
129
+ }
130
+ else {
131
+ preflightBlocked = true;
132
+ s.lastError =
133
+ `an inbox message for ${ctx.role} attaches an image, but no configured tier can read it`;
134
+ }
135
+ }
136
+ }
137
+ }
90
138
  // Capture what this run runs on (the orchestrator's budget handback matches it against
91
139
  // the fallback pair) and clear any stale handback flag: an abort request that lands while
92
140
  // the runner is idle must not name a later run's resume.
@@ -122,11 +170,17 @@ export async function runPhase(ctx) {
122
170
  source: ctx.role,
123
171
  type: "run_start",
124
172
  run: runNumber,
173
+ // A run routed onto the image-capable target — a blind-read handoff or an attachment
174
+ // preflight — is stamped so the feed shows the reroute and the model it landed on.
175
+ ...(s.imageRerouteActive ? { reroute: "image" } : {}),
125
176
  ...modelSelectorField(cfg),
126
177
  });
127
178
  let outcome;
128
179
  try {
129
- outcome = await ctx.runOnce(cfg, { fallback, probe: probing, primary: primaryCfg });
180
+ // The preflight can end the run before any pi spawns: a text-only runner with an image
181
+ // attachment and no image-capable target has nothing to run, so it errors without
182
+ // spending a model call. Everything else runs the pipeline as usual.
183
+ outcome = preflightBlocked ? { result: "error" } : await ctx.runOnce(cfg);
130
184
  }
131
185
  catch (err) {
132
186
  outcome = { result: "error" };
@@ -170,7 +224,7 @@ export async function runPhase(ctx) {
170
224
  /** The run pipeline's start half: resume-or-fresh policy, then run in the director's own
171
225
  * worktree or on a leased pool slot. Returns the setup's outcome; a null plan means nothing to
172
226
  * run. */
173
- export async function runOncePhase(ctx, cfg, fallbackCtx) {
227
+ export async function runOncePhase(ctx, cfg) {
174
228
  const s = ctx.state;
175
229
  // How this run starts — resume the interrupted session or run fresh, and which prompt —
176
230
  // is the resume policy, not runner mechanics: it lives in src/run/run-resume.ts, which also
@@ -192,14 +246,25 @@ export async function runOncePhase(ctx, cfg, fallbackCtx) {
192
246
  if (plan === null)
193
247
  return { result: "skipped" };
194
248
  if (ctx.role === DIRECTOR_ROLE) {
249
+ // The image-reroute one-shot marker routes exactly ONE run onto the image-capable
250
+ // target (plans/image-input.md part 4/5, INV-J2): for a role runner, planRunStart consumes
251
+ // it after reading its handoff-note facts. The director never resumes, so planRunStart's
252
+ // image-reroute branch never runs for it and the marker would otherwise linger and
253
+ // re-route every later director run off its own tier. It is cleared here — after this
254
+ // run is confirmed to run (an empty-inbox skip above returns before this, leaving the
255
+ // reroute pending for the next eligible run) — so the rerouted director prompt is
256
+ // delivered exactly once and the director is back on its own tier next.
257
+ // A promptless recovery run delivers no prompt, so it leaves a pending reroute for the next.
258
+ if (plan.prompt !== null)
259
+ s.imageReroute = undefined;
195
260
  // The director keeps its dedicated worktree (it runs outside the permit, consumes its
196
261
  // config-request file at that exact path, and never resumes). Hold it from before
197
262
  // ensureWorktree — creating or resetting it is part of the use — to the run's end
198
263
  // (plans/disk-floor.md, part 2/4), so a concurrent pressure reclaim never cleans a tree
199
264
  // this run is creating or working in.
200
- return useWorktree(ctx.root, worktreePath(ctx.root, ctx.role), () => runWithWorktreePhase(ctx, plan, cfg, fallbackCtx));
265
+ return useWorktree(ctx.root, worktreePath(ctx.root, ctx.role), () => runWithWorktreePhase(ctx, plan, cfg));
201
266
  }
202
- return runOnceInLeasePhase(ctx, plan, cfg, fallbackCtx);
267
+ return runOnceInLeasePhase(ctx, plan, cfg);
203
268
  }
204
269
  /** Whether a finished role run's leased slot must stay pinned for the next run's resume —
205
270
  * the resume flag a verdict armed inside the run (`resumePending`), an abort or quiet kill,
@@ -220,7 +285,7 @@ export function slotPinnedForResume(state, outcome) {
220
285
  * leases next. A run an exception ends releases its slot unsettled, so edits it leaves there
221
286
  * are saved for the role's next run, and a run on a fresh checkout first takes back any such
222
287
  * work an earlier run of the role left (restoreSalvagedPhase). */
223
- async function runOnceInLeasePhase(ctx, plan, cfg, fallbackCtx) {
288
+ async function runOnceInLeasePhase(ctx, plan, cfg) {
224
289
  const lease = await leaseSlot(ctx.root, {
225
290
  role: ctx.role,
226
291
  purpose: "run",
@@ -238,7 +303,7 @@ async function runOnceInLeasePhase(ctx, plan, cfg, fallbackCtx) {
238
303
  await checkoutRoleBranchPhase(ctx, lease.dir);
239
304
  run = await restoreSalvagedPhase(ctx, plan, lease.dir);
240
305
  }
241
- outcome = await runWithWorktreePhase(ctx, run, cfg, fallbackCtx, lease.dir);
306
+ outcome = await runWithWorktreePhase(ctx, run, cfg, lease.dir);
242
307
  return outcome;
243
308
  }
244
309
  finally {
@@ -288,7 +353,7 @@ async function checkoutRoleBranchPhase(ctx, dir) {
288
353
  }
289
354
  await git(dir, "clean", "-fd");
290
355
  }
291
- async function runWithWorktreePhase(ctx, plan, cfg, fallbackCtx,
356
+ async function runWithWorktreePhase(ctx, plan, cfg,
292
357
  /** The leased slot the run runs in; omitted by the director, which ensures its own. */
293
358
  leasedDir) {
294
359
  const s = ctx.state;
@@ -309,7 +374,7 @@ leasedDir) {
309
374
  });
310
375
  }
311
376
  else {
312
- // Salvage a commit a previous run left unmerged (src/runner/leftover.ts): recovery puts it
377
+ // Salvage a commit a previous run left unmerged (runner-leftover.ts): recovery puts it
313
378
  // back on the merge queue, so it merges through the orchestrator's merge pipeline — the
314
379
  // same gate as a fresh run's change, and main keeps exactly one writer (an in-run
315
380
  // recovery merge raced the slot's batches into `merge_blocked`). A salvaged leftover
@@ -526,17 +591,14 @@ leasedDir) {
526
591
  await foldAgentCommitsSince(ctx, wt, runBase);
527
592
  // A write that landed in the primary checkout never reaches the worktree's commit, so the run
528
593
  // can read as no_change with its work orphaned (BUGS.md, 2026-10-20). The before/after diff
529
- // cannot attribute paths to this run when roles overlap, so only warn: the verdict, resume
530
- // handling and model-fallback fold below stay as they were.
594
+ // cannot attribute paths to this run when roles overlap, so only warn: the verdict and
595
+ // resume handling below stay as they were.
531
596
  const primaryAfter = primaryBefore === null ? null : await changedFiles(ctx.root).catch(() => null);
532
597
  const strayed = primaryAfter === null ? [] : primaryAfter.filter((f) => !primaryBefore.includes(f));
533
598
  if (strayed.length > 0 && !pi.aborted) {
534
599
  const shown = strayed.slice(0, 10).join(", ") + (strayed.length > 10 ? `, … (${strayed.length} paths)` : "");
535
600
  ctx.warn(`wrote outside the worktree (possibly another writer's): ${shown}`);
536
601
  }
537
- // Fold the authoring run's verdict into the role's model-fallback episode NOW, before the
538
- // post-run handlers spend any more pi runs: only this run is evidence about the primary.
539
- ctx.foldModelFallback(fallbackCtx, pi);
540
602
  // The `qa` observer's reply ends with a result-carrying `FLOW:` line (plans/observer-roles.md
541
603
  // 2/2). Extracted once here, recorded only at the success returns below — an interrupted or
542
604
  // failed run must not advance the rotation. The harness records it, never pi.
@@ -139,8 +139,8 @@ export function loadRunnerState(root, role) {
139
139
  r && typeof r.patchId === "string" && typeof r.sessionDir === "string" ? r : undefined;
140
140
  }
141
141
  // The nested scheduling records carry the same numeric class — epoch-ms stamps, recovery
142
- // counts, and the fallback episode's clock — and are read by comparison or arithmetic
143
- // (`ctx.now - claim.at > CLAIM_IDLE_MAX_MS`, `prior.count + 1`, `cooldownMs * 2`), so a
142
+ // counts, and retry/review counts — and are read by comparison or arithmetic
143
+ // (`ctx.now - claim.at > CLAIM_IDLE_MAX_MS`, `prior.count + 1`, `attempts + 1`), so a
144
144
  // foreign value there is healed the same way the top-level fields are.
145
145
  if (s.lastReview !== undefined)
146
146
  s.lastReview = healRecord(s.lastReview, (v) => {
@@ -201,13 +201,16 @@ export function loadRunnerState(root, role) {
201
201
  });
202
202
  if (s.mergeFlakeExcused !== undefined && typeof s.mergeFlakeExcused !== "string")
203
203
  delete s.mergeFlakeExcused;
204
- if (s.modelFallback !== undefined)
205
- s.modelFallback = healRecord(s.modelFallback, (v) => {
206
- v.failures = healNumber(v.failures, 0);
207
- v.since = healNumber(v.since, 0);
208
- v.probeAt = healNumber(v.probeAt, 0);
209
- v.cooldownMs = healNumber(v.cooldownMs, 0);
210
- });
204
+ // The image-reroute marker is a one-shot nested record consumed by the next run; a torn
205
+ // scalar is dropped (its consumers read/mutate fields), and the tier is repaired to a
206
+ // known tier name so a corrupt tier cannot route a rerouted run to a phantom seam.
207
+ if (s.imageReroute !== undefined) {
208
+ const healed = healRecord(s.imageReroute, () => { });
209
+ s.imageReroute =
210
+ healed && (healed.tier === "small" || healed.tier === "default" || healed.tier === "strong")
211
+ ? healed
212
+ : undefined;
213
+ }
211
214
  return s;
212
215
  }
213
216
  /** Per-role cache of the persisted live claim, keyed by the state file's stat — one stat per
@@ -6,15 +6,12 @@ import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
6
6
  import { promptPreview } from "../inbox/inbox.js";
7
7
  import { assembleRunPrompt } from "../run/run-prompt.js";
8
8
  import { RunnerPi } from "./runner-pi.js";
9
- import { configForRole, seamHoldKey, } from "../config/config-views.js";
10
- import { fallbackRoleConfig } from "../config/config-fallbacks.js";
9
+ import { configForRole, seamHoldKey, withModelOverrides, } from "../config/config-views.js";
11
10
  import { PendingPrompt } from "../inbox/pending-prompt.js";
12
- import { modelFallbackActive } from "./model-fallback.js";
13
11
  import { clearBackoff } from "../scheduling/backoff.js";
14
12
  import { RunUsage } from "../run/run-usage.js";
15
13
  import { runOncePhase, runPhase, } from "./runner-run.js";
16
14
  import { finishAbortedRunPhase, finishRecoveryRunPhase, pinAndResetPhase, } from "./runner-recovery.js";
17
- import { foldModelFallbackPhase } from "./runner-fallback.js";
18
15
  /** One role runner: owns a persistent worktree + branch and runs one run at a time. */
19
16
  export class RoleRunner {
20
17
  root;
@@ -73,6 +70,13 @@ export class RoleRunner {
73
70
  * resolve to, since different roles may run on different pairs.
74
71
  * Transient — never persisted — and captured fresh at every run start. */
75
72
  runPair;
73
+ /** Whether the budget gate currently has the team on its free fallback pairs (src/gates/
74
+ * budget-gates.ts onFallback, set alongside `config` by the orchestrator's poll): an
75
+ * image reroute may then look only among the free fallback pairs, never a priced primary
76
+ * (plans/image-input.md "Reroute target" "Regime"). Pure scheduler input, never whether
77
+ * the runner itself is mid-fallback; defaults false, so standalone-runner tests stay on the
78
+ * primary regime unless they set it. */
79
+ budgetFallbackActive = false;
76
80
  /** The merge failure this run's leftover recovery is retrying, if any (a retriable merger
77
81
  * outcome that kept the pin, which recovery re-queued): set in finishRecoveryRun and
78
82
  * attached to the returned RunOutcome so applyRunOutcome can feed it into the error streak
@@ -117,7 +121,6 @@ export class RoleRunner {
117
121
  this.state.resumePending = true;
118
122
  this.state.running = false;
119
123
  this.state.parkedSince = undefined;
120
- this.state.parkedOn = undefined;
121
124
  }
122
125
  save() {
123
126
  saveRunnerState(this.root, this.state);
@@ -174,22 +177,39 @@ export class RoleRunner {
174
177
  runModelPair() {
175
178
  return this.state.running ? (this.runPair ?? null) : null;
176
179
  }
177
- /** The config this role's NEXT run will run on: the tier fallback pair while the episode is
178
- * active (a due probe runs the primary), else the role's own resolved config. The hold and
179
- * start passes read this so a role mid-episode is keyed by the provider it will actually use.
180
- * Pure read of config + state + the caller's clock. */
181
- runConfig(now) {
182
- const fallback = fallbackRoleConfig(this.config, this.role);
183
- if (fallback !== null && modelFallbackActive(this.state.modelFallback, now))
184
- return fallback;
180
+ /** Which pair set an image reroute may look among (plans/image-input.md "Reroute target"
181
+ * "Regime"): the free fallback pairs while the budget gate is on fallback; otherwise the
182
+ * primary tiers. One home for the rule, shared by the target-resolution sites (the verdict's
183
+ * reroute arm, the attachment preflight). */
184
+ rerouteRegime() {
185
+ return this.budgetFallbackActive ? "fallback" : "primary";
186
+ }
187
+ /** The config this role's NEXT run will run on: a pending image reroute's target, else the
188
+ * role's own resolved config (configForRole). The hold and start passes read this so a role
189
+ * is keyed by the provider it will use. The budget gate's fallback pair, when a cap episode
190
+ * is active, is installed into the live config itself, so this read already names the pair
191
+ * the run uses. */
192
+ runConfig() {
193
+ // A pending image-input reroute (plans/image-input.md part 4/5) outranks the role's own
194
+ // tier: the next run starts on the image-capable target the blind-read run recorded,
195
+ // rebuilt from the stored seam over the live config.
196
+ if (this.state.imageReroute) {
197
+ const r = this.state.imageReroute;
198
+ return withModelOverrides(this.config, r.tier, {
199
+ backend: r.backend,
200
+ provider: r.provider,
201
+ model: r.model,
202
+ thinking: r.thinking,
203
+ });
204
+ }
185
205
  return configForRole(this.config, this.role);
186
206
  }
187
- /** The seam key `runConfig(now)` resolves to (config-views.ts seamHoldKey): the agent id
207
+ /** The seam key `runConfig()` resolves to (config-views.ts seamHoldKey): the agent id
188
208
  * for a non-pi backend (claude seams carry no pi provider), else the pi provider. The hold
189
209
  * and start passes read only this, so a claude seam is gated by a `claude` hold and pi's
190
210
  * default seams (undefined) are not. */
191
- runProvider(now) {
192
- return seamHoldKey(this.runConfig(now));
211
+ runProvider() {
212
+ return seamHoldKey(this.runConfig());
193
213
  }
194
214
  /** The signal every pi run of the current run watches: harness shutdown OR a per-run user
195
215
  * abort (Node ≥ 20's AbortSignal.any). */
@@ -258,19 +278,15 @@ export class RoleRunner {
258
278
  this.usage.fold(this.state, run, false, provider, pair);
259
279
  }
260
280
  /** Run one full run of this role runner (the pipeline lives in src/runner/runner-run.ts):
261
- * `runPhase` resolves the config and fallback episode, logs run_start, runs the pipeline,
281
+ * `runPhase` resolves the role's own config, logs run_start, runs the pipeline,
262
282
  * and finalizes. Never throws: a failed run is an "error" result, saved and logged. */
263
283
  async run() {
264
284
  return runPhase(this.runContext());
265
285
  }
266
- /** Thin delegate to foldModelFallbackPhase (src/runner/runner-fallback.ts). */
267
- foldModelFallback(fallbackCtx, pi) {
268
- foldModelFallbackPhase(this.runContext(), fallbackCtx, pi);
269
- }
270
286
  /** The run pipeline's entry (src/runner/runner-run.ts): a thin delegate so `run` assembles
271
287
  * phase context once and tests can still override `runOnce` to bypass the pipeline. */
272
- async runOnce(cfg, fallbackCtx) {
273
- return runOncePhase(this.runContext(), cfg, fallbackCtx);
288
+ async runOnce(cfg) {
289
+ return runOncePhase(this.runContext(), cfg);
274
290
  }
275
291
  /** Assemble the run pipeline's host context (src/runner/runner-run.ts) from this runner's
276
292
  * fields. `config` and `recoveryFailure` are live getters, so a mid-run change is seen as
@@ -293,13 +309,14 @@ export class RoleRunner {
293
309
  warn: (message) => this.warn(message),
294
310
  runSignal: () => this.runSignal(),
295
311
  runPrompt: (backend) => this.runPrompt(backend),
312
+ rerouteRegime: () => this.rerouteRegime(),
296
313
  get recoveryFailure() {
297
314
  return self.recoveryFailure;
298
315
  },
299
316
  setRecoveryFailure: (message) => {
300
317
  self.recoveryFailure = message;
301
318
  },
302
- runOnce: (cfg, fallbackCtx) => this.runOnce(cfg, fallbackCtx),
319
+ runOnce: (cfg) => this.runOnce(cfg),
303
320
  setRunPair: (pair) => {
304
321
  self.runPair = pair;
305
322
  },
@@ -312,7 +329,6 @@ export class RoleRunner {
312
329
  },
313
330
  finishAbortedRun: (userPrompt, wt) => this.finishAbortedRun(userPrompt, wt),
314
331
  pinAndReset: (wt, sha) => this.pinAndReset(wt, sha),
315
- foldModelFallback: (fallbackCtx, pi) => this.foldModelFallback(fallbackCtx, pi),
316
332
  finishRecoveryRun: (recovered, userPrompt, wt, priorMergeFailure) => this.finishRecoveryRun(recovered, userPrompt, wt, priorMergeFailure),
317
333
  };
318
334
  }
@@ -152,10 +152,6 @@ export function workReserveCount(root, runners, opts) {
152
152
  continue;
153
153
  if (r.state.running || opts.queuedMergeRoles.has(r.role))
154
154
  continue;
155
- // A work role whose provider is full (capped and at its cap) cannot use a reserved permit
156
- // anyway, so it must not withhold one from another provider's maintenance run (§6).
157
- if (opts.providerHasHeadroom !== undefined && !opts.providerHasHeadroom(r))
158
- continue;
159
155
  const eligible = eligibleFor(base);
160
156
  const holdsClaim = r.state.claim !== undefined && eligible.has(r.state.claim.key);
161
157
  const hasFreeEntry = [...eligible].some((k) => !(heldByBase.get(base)?.has(k) ?? false));