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
@@ -57,15 +57,11 @@ export function newestRunRow(root, role, classes, models) {
57
57
  function newestRunNumber(root, role, classes, models) {
58
58
  return newestRunRow(root, role, classes, models)?.run ?? null;
59
59
  }
60
- /** The invocation half of cmdRunDetail: rule out the arity and flag mistakes before any run is
61
- * resolved, so the shared unknown-role wording and the usage string are the whole validation
62
- * surface, found by this one name. Succeeds only for a well-formed call; returns the parsed
63
- * bare/role/classes/models for the resolver. The role is checked first, so a typo is named
64
- * whatever the count (knownRoleIdsCached: the built-ins plus user-defined roles, read through
65
- * the never-throwing cached loader so a transiently broken tumwater.json cannot take a
66
- * read-only view down). The arity, unknown-role and flag-dependency failures all `fail` with
67
- * the usage. */
68
- function parseRunDetailInvocation(root, positionals, { last, transcript, prompt, grep, result, model }) {
60
+ /** The positional and role half of parseRunDetailInvocation's validation: the bare/last arity,
61
+ * the canonical role id, the unknown-role guard (knownRoleIdsCached) and the three arity
62
+ * `fail`s — one name so the whole positional surface is found by this helper. Returns whether
63
+ * the call is bare (`--last` with no role) and the resolved role. */
64
+ function runDetailPositionals(root, positionals, last) {
69
65
  // Positional arity by form: cli.ts has already peeled the flags off and gated them (only
70
66
  // --json and --last are admitted), so anything left over that does not fit the chosen form
71
67
  // is a mistake. The role is checked first, so a typo is named whatever the count; then
@@ -91,6 +87,13 @@ function parseRunDetailInvocation(root, positionals, { last, transcript, prompt,
91
87
  }
92
88
  fail(`usage: ${RUN_DETAIL_USAGE}`);
93
89
  }
90
+ return { bare, role };
91
+ }
92
+ /** The flag-dependency half of parseRunDetailInvocation's validation: the four `fail` guards —
93
+ * `--prompt`/`--grep` need `--transcript`, `--result`/`--model` need `--last` — one name so the
94
+ * whole dependency surface is found by this helper. */
95
+ function checkRunDetailFlagDeps(flags) {
96
+ const { last, transcript, prompt, grep, result, model } = flags;
94
97
  if (prompt && !transcript)
95
98
  fail(`history --prompt needs --transcript (usage: ${RUN_DETAIL_USAGE})`);
96
99
  if (grep !== null && !transcript)
@@ -99,10 +102,67 @@ function parseRunDetailInvocation(root, positionals, { last, transcript, prompt,
99
102
  fail(`history --result needs --last on a one-run trail (usage: ${RUN_DETAIL_USAGE})`);
100
103
  if (model !== null && !last)
101
104
  fail(`history --model needs --last on a one-run trail (usage: ${RUN_DETAIL_USAGE})`);
105
+ }
106
+ /** The invocation half of cmdRunDetail: rule out the arity and flag mistakes before any run is
107
+ * resolved, so the shared unknown-role wording and the usage string are the whole validation
108
+ * surface, found by this one name. Succeeds only for a well-formed call; returns the parsed
109
+ * bare/role/classes/models for the resolver. Delegates the positional/role arity to
110
+ * runDetailPositionals and the flag-dependency guards to checkRunDetailFlagDeps, then parses
111
+ * the --result class and --model selector lists. */
112
+ function parseRunDetailInvocation(root, positionals, { last, transcript, prompt, grep, result, model }) {
113
+ const { bare, role } = runDetailPositionals(root, positionals, last);
114
+ checkRunDetailFlagDeps({ last, transcript, prompt, grep, result, model });
102
115
  const classes = result !== null ? parseHistoryResultList(result) : null;
103
116
  const models = model !== null ? parseHistoryModelList(model) : null;
104
117
  return { bare, role, classes, models };
105
118
  }
119
+ /** The not-found branch of cmdRunDetail for the --last form: the scan came up empty, not found
120
+ * by construction, the last-form wording — `sayJson(null)` under --json, else
121
+ * runNotFoundLastMessage. */
122
+ function runDetailLastMiss(parsed, role, json) {
123
+ // The --last scan came up empty: not found by construction, the last-form wording.
124
+ if (json)
125
+ sayJson(null);
126
+ else
127
+ say(runNotFoundLastMessage(parsed.bare ? null : role, parsed.classes, parsed.models));
128
+ }
129
+ /** The not-found branch of cmdRunDetail for the numbered form: not found stays exit 0 in both
130
+ * renderings, but --json still owes a parseable document in every exit-0 case (`null`), else
131
+ * the runNotFoundMessage prose line. */
132
+ function runDetailNumberMiss(role, runNumber, json) {
133
+ // Not found stays exit 0 in both renderings, but --json still owes a parseable document in
134
+ // every exit-0 case (the report --json precedent, the invariant this command's own doc
135
+ // comment claims): `null` is the JSON answer for a single-record lookup that missed — the
136
+ // prose line would end a jq pipe mid-team-incident.
137
+ if (json)
138
+ sayJson(null);
139
+ else
140
+ say(runNotFoundMessage(role, runNumber));
141
+ }
142
+ /** The render half of cmdRunDetail: build the --stat/--patch footer and the --transcript lines,
143
+ * then emit the --json payload or the human-rendered trail (renderRunDetail plus the footer and
144
+ * transcript text). */
145
+ async function emitRunDetail(root, role, runNumber, detail, json, opts) {
146
+ // --json swaps the renderer for the collector's own payload, exactly as history --json: a
147
+ // JSON document in every exit-0 case, never prose.
148
+ const footer = {};
149
+ if (opts.stat)
150
+ footer.stat = await runDetailStat(root, detail);
151
+ if (opts.patch)
152
+ footer.patch = await runDetailPatch(root, detail);
153
+ const transcriptLines = opts.transcript
154
+ ? runTranscriptLines(root, role, runNumber, detail, { includePrompts: opts.prompt, grep: opts.grep ?? undefined })
155
+ : null;
156
+ if (json) {
157
+ sayJson({ ...detail, ...footer, ...(transcriptLines === null ? {} : { transcript: transcriptLines.found }) });
158
+ }
159
+ else {
160
+ const transcriptText = transcriptLines === null
161
+ ? []
162
+ : [transcriptLines.found.length > 0 ? transcriptLines.found.join("\n") : transcriptLines.note];
163
+ say([renderRunDetail(detail), ...Object.values(footer), ...transcriptText].join("\n\n"));
164
+ }
165
+ }
106
166
  /** The resolution half of cmdRunDetail: pick the run to open and read its detail. Bare `--last`
107
167
  * takes the newest run of any role (its role from the row); `--last` with a role the newest run
108
168
  * by number; the numbered path the user's positive integer. An empty or unreadable scan is null
@@ -150,41 +210,12 @@ export async function cmdRunDetail(root, positionals, json, { last = false, stat
150
210
  const parsed = parseRunDetailInvocation(root, positionals, { last, transcript, prompt, grep, result, model });
151
211
  const { role, runNumber, detail } = resolveRunDetailTarget(root, parsed, positionals, last);
152
212
  if (runNumber === null) {
153
- // The --last scan came up empty: not found by construction, the last-form wording.
154
- if (json)
155
- sayJson(null);
156
- else
157
- say(runNotFoundLastMessage(parsed.bare ? null : role, parsed.classes, parsed.models));
213
+ runDetailLastMiss(parsed, role, json);
158
214
  return;
159
215
  }
160
216
  if (detail === null) {
161
- // Not found stays exit 0 in both renderings, but --json still owes a parseable document in
162
- // every exit-0 case (the report --json precedent, the invariant this command's own doc
163
- // comment claims): `null` is the JSON answer for a single-record lookup that missed — the
164
- // prose line would end a jq pipe mid-team-incident.
165
- if (json)
166
- sayJson(null);
167
- else
168
- say(runNotFoundMessage(role, runNumber));
217
+ runDetailNumberMiss(role, runNumber, json);
169
218
  return;
170
219
  }
171
- // --json swaps the renderer for the collector's own payload, exactly as history --json: a
172
- // JSON document in every exit-0 case, never prose.
173
- const footer = {};
174
- if (stat)
175
- footer.stat = await runDetailStat(root, detail);
176
- if (patch)
177
- footer.patch = await runDetailPatch(root, detail);
178
- const transcriptLines = transcript
179
- ? runTranscriptLines(root, role, runNumber, detail, { includePrompts: prompt, grep: grep ?? undefined })
180
- : null;
181
- if (json) {
182
- sayJson({ ...detail, ...footer, ...(transcriptLines === null ? {} : { transcript: transcriptLines.found }) });
183
- }
184
- else {
185
- const transcriptText = transcriptLines === null
186
- ? []
187
- : [transcriptLines.found.length > 0 ? transcriptLines.found.join("\n") : transcriptLines.note];
188
- say([renderRunDetail(detail), ...Object.values(footer), ...transcriptText].join("\n\n"));
189
- }
220
+ await emitRunDetail(root, role, runNumber, detail, json, { stat, patch, transcript, prompt, grep });
190
221
  }
@@ -23,7 +23,7 @@ import { readFeedbackFilings, renderFilingsBlock } from "./feedback-filings.js";
23
23
  import { roleNotesPath } from "../paths.js";
24
24
  import * as path from "node:path";
25
25
  import { pathTokens } from "../text/text.js";
26
- import { isDirectory, readTextOrNull, statOrNull } from "../files/files.js";
26
+ import { isDirectory, readRegularTextOrNull, readTextOrNull, statOrNull } from "../files/files.js";
27
27
  import { dayKey } from "../text/datetime.js";
28
28
  import { appendPromptNote, prependPromptNote } from "../text/format.js";
29
29
  import { ROLE_NOTES_MAX_BYTES } from "../agent/role-notes-write.js";
@@ -55,9 +55,11 @@ function missingEntryPaths(root, file, range) {
55
55
  * brief text reader apply to their hand-editable files. A UTF-8 character is never
56
56
  * fewer than one byte, so a note the tool accepted (≤ 4096 bytes) is always ≤ 4096 chars
57
57
  * and never trips this bound; only an over-cap file is cut, with truncateWithNote's visible
58
- * marker naming the loss. */
58
+ * marker naming the loss. A symlinked note file reads as absent (readRegularTextOrNull never
59
+ * follows a symlink out of the worktree, INV-G3/G4's lstat policy): a hostile note that is a
60
+ * symlink cannot pull an arbitrary host file's text into this role's prompt. */
59
61
  function readRoleNote(root, role) {
60
- const text = readTextOrNull(roleNotesPath(root, role));
62
+ const text = readRegularTextOrNull(roleNotesPath(root, role));
61
63
  return text === null ? undefined : truncateWithNote(text, ROLE_NOTES_MAX_BYTES, "role notes");
62
64
  }
63
65
  /** The bugfix run's tally of checks the merge gate excused as flaky in the last day, or
@@ -199,55 +201,40 @@ function collectRoleRequest(root, role, preview) {
199
201
  function collectSelfcheckDigest(root, base) {
200
202
  return base === "selfcheck" || base === "feedback" ? selfcheckDigest(root) : undefined;
201
203
  }
202
- /** The `qa` observer's flow-coverage ledger: rerendered from disk each run, degrading to no
203
- * block on a missing or unreadable ledger (plans/observer-roles.md 2/2). */
204
- function collectQaCoverageBlock(root, base) {
205
- // The `qa` observer's flow rotation needs a memory of what it last exercised; every run
206
- // is a fresh session, and a passing cheap check leaves nothing in the repo. The ledger is
207
- // runtime state, and a missing or unreadable one degrades to no block.
208
- let coverage;
209
- if (base === "qa") {
210
- try {
211
- coverage = renderCoverageBlock(readQaCoverage(root));
212
- }
213
- catch {
214
- coverage = undefined;
215
- }
204
+ /** One observer-ledger block: rendered from disk only for its owning role, degrading to no
205
+ * block on a missing or unreadable ledger (a throw) so the run prompt stays byte-unchanged in
206
+ * the common first-run case. The `qa` (flow coverage), `ci` (parity) and `feedback` (filings)
207
+ * ledgers are runtime state a fresh session must not depend on, and the three collect*Block
208
+ * functions each rendered their ledger through this same try/catch guard before the helper. */
209
+ function runtimeLedgerBlock(base, role, render) {
210
+ if (base !== role)
211
+ return undefined;
212
+ try {
213
+ return render();
214
+ }
215
+ catch {
216
+ return undefined;
216
217
  }
217
- return coverage;
218
218
  }
219
- /** The `ci` observer's parity block: what the last run covered (tip and lock hash) so a
220
- * fresh session can decide nothing-to-do without re-running the whole suite (part 3/3).
221
- * Runtime state, and a missing/unreadable record degrades to no block, exactly like qa's. */
219
+ /** The `qa` observer's flow-coverage ledger, rerendered from disk each run (the flow rotation
220
+ * needs a memory of what it last exercised — every run is a fresh session, and a passing cheap
221
+ * check leaves nothing in the repo), degrading to no block (plans/observer-roles.md 2/2). */
222
+ function collectQaCoverageBlock(root, base) {
223
+ return runtimeLedgerBlock(base, "qa", () => renderCoverageBlock(readQaCoverage(root)));
224
+ }
225
+ /** The `ci` observer's parity block: what the last run covered (tip and lock hash) so a fresh
226
+ * session can decide nothing-to-do without re-running the whole suite (part 3/3). Degrades to
227
+ * no block exactly like qa's. */
222
228
  function collectCiParityBlock(root, config, base) {
223
- let ciParity;
224
- if (base === "ci") {
225
- try {
226
- ciParity = parityBlockFor(root, config);
227
- }
228
- catch {
229
- ciParity = undefined;
230
- }
231
- }
232
- return ciParity;
229
+ return runtimeLedgerBlock(base, "ci", () => parityBlockFor(root, config));
233
230
  }
234
231
  /** The `feedback` observer's per-install filing ledger: what this install already filed as
235
232
  * GitHub issues on znation/tumwater, so the fresh session's charter dedupes against a
236
- * "previously filed" block instead of the network alone. Runtime state, and a missing or
237
- * unreadable ledger degrades to no block (plans/feedback-role.md 2/2). */
233
+ * "previously filed" block instead of the network alone. Degrades to no block
234
+ * (plans/feedback-role.md 2/2); the render returns undefined for an empty ledger, so the
235
+ * prompt is unchanged in the common first-run case. */
238
236
  function collectFeedbackFilingsBlock(root, base) {
239
- // The render returns undefined for an empty ledger, so the prompt is unchanged in the common
240
- // first-run case.
241
- let filings;
242
- if (base === "feedback") {
243
- try {
244
- filings = renderFilingsBlock(readFeedbackFilings(root));
245
- }
246
- catch {
247
- filings = undefined;
248
- }
249
- }
250
- return filings;
237
+ return runtimeLedgerBlock(base, "feedback", () => renderFilingsBlock(readFeedbackFilings(root)));
251
238
  }
252
239
  /** The clean role's deterministic backlog repair: stranded plan headings in the primary
253
240
  * checkout's PLANS.md (plans, part 3/4), rendered like the digest and coverage blocks. */
@@ -258,7 +245,7 @@ function collectBacklogStructureBlock(root, base) {
258
245
  /** Read the role's own notes plus their saved day and live backlog state (notes-backlog.ts),
259
246
  * returning all three in one place. */
260
247
  function readPromptNotes(root, role) {
261
- // The role's notes (PLANS.md "Role notebook"): its own earlier runs' note. A missing,
248
+ // The role's own notes (readRoleNote): its earlier runs' note. A missing,
262
249
  // empty, or unreadable file yields undefined, so the block is omitted; the write
263
250
  // instruction in buildRunPrompt is present either way. The director branch above never
264
251
  // reads this path, so it carries no notes.
@@ -312,15 +299,12 @@ function collectRoleDirectives(root, config, base, check) {
312
299
  : undefined;
313
300
  return { planTargetNote, flakyChecks, noCheck };
314
301
  }
315
- /** Append the shared claim/held/discard/cut-off tail — identical for a director and a role —
316
- * in its current order, and return the finished prompt. */
317
- function appendRunEpilogue(root, role, state, prompt, userPrompt, rejectedReview) {
302
+ function appendAssignmentNotes(prompt, root, role, claim, userPrompt) {
318
303
  // A work instance holding a claim is told which entry is its one task (plans/parallel-work-
319
304
  // instances.md "Claims", part 4/7) — the harness picked it, so the charter's choosing step
320
305
  // is replaced. A run that dequeued a user request runs that request instead and keeps the
321
306
  // claim for the next non-request run. The range is looked up by key in the current checkout,
322
307
  // so it survives a moved section boundary.
323
- const claim = role === DIRECTOR_ROLE ? undefined : state.claim;
324
308
  if (claim && userPrompt === null) {
325
309
  const range = eligibleEntries(root, role).find((e) => e.key === claim.key);
326
310
  prompt = appendPromptNote(prompt, buildAssignmentNote(claim, range, range ? missingEntryPaths(root, claim.file, range) : []));
@@ -328,20 +312,32 @@ function appendRunEpilogue(root, role, state, prompt, userPrompt, rejectedReview
328
312
  if (merged.length > 0)
329
313
  prompt = appendPromptNote(prompt, buildMergedPrerequisitesNote(merged));
330
314
  }
315
+ return prompt;
316
+ }
317
+ function appendWaitingPartsNote(prompt, root, claim, userPrompt) {
331
318
  if (claim && userPrompt === null && claim.file === "PLANS.md") {
332
319
  const waiting = waitingPartRanges(root, claim.title);
333
320
  if (waiting.length > 0)
334
321
  prompt = appendPromptNote(prompt, buildWaitingPartsNote(waiting));
335
322
  }
323
+ return prompt;
324
+ }
325
+ function appendHeldElsewhereNote(prompt, role, claim, userPrompt, state) {
336
326
  if (!claim && userPrompt === null && role !== DIRECTOR_ROLE && state.heldElsewhere?.length) {
337
327
  prompt = appendPromptNote(prompt, buildHeldEntriesNote(state.heldElsewhere));
338
328
  }
329
+ return prompt;
330
+ }
331
+ function prependRejectedReviewNote(prompt, role, rejectedReview) {
339
332
  // The director builds its prompt through buildDirectorPrompt, which does not take the rejected
340
333
  // note, so the standing rejection leads its prompt here instead (a director-authored change can
341
334
  // be rejected too). Kept uniform with the authoring roles: the reasons open the run.
342
335
  if (rejectedReview !== undefined && role === DIRECTOR_ROLE) {
343
336
  prompt = prependPromptNote(prompt, rejectedReview);
344
337
  }
338
+ return prompt;
339
+ }
340
+ function appendRunMemoryNotes(prompt, state) {
345
341
  // Likewise a change leftover recovery discarded as unmergeable: named until the role queues
346
342
  // its next change (the discarding run itself appends it after recovery — see runOnce).
347
343
  const discard = state.conflictDiscard;
@@ -355,6 +351,23 @@ function appendRunEpilogue(root, role, state, prompt, userPrompt, rejectedReview
355
351
  }
356
352
  return prompt;
357
353
  }
354
+ /** Append the shared claim/held/discard/cut-off tail — identical for a director and a role —
355
+ * in its current order, and return the finished prompt. It delegates each note group to a
356
+ * named module-private helper: appendAssignmentNotes (which folds in the merged-prerequisite
357
+ * follow-up), appendWaitingPartsNote, appendHeldElsewhereNote, prependRejectedReviewNote and
358
+ * appendRunMemoryNotes. */
359
+ function appendRunEpilogue(root, role, state, prompt, userPrompt, rejectedReview) {
360
+ // The shared cross-run tail every role prompt carries, ordinally: the claim assignment (with
361
+ // its merged-prerequisite and waiting-part follow-ups), the held-elsewhere note, the
362
+ // director's leading rejection, and the discard and cut-off run memory.
363
+ const claim = role === DIRECTOR_ROLE ? undefined : state.claim;
364
+ prompt = appendAssignmentNotes(prompt, root, role, claim, userPrompt);
365
+ prompt = appendWaitingPartsNote(prompt, root, claim, userPrompt);
366
+ prompt = appendHeldElsewhereNote(prompt, role, claim, userPrompt, state);
367
+ prompt = prependRejectedReviewNote(prompt, role, rejectedReview);
368
+ prompt = appendRunMemoryNotes(prompt, state);
369
+ return prompt;
370
+ }
358
371
  /** Assemble the prompt a role's next run operates on — the whole "what should this run see"
359
372
  * concern, split out of runner.ts so the runner owns only lifecycle and the prompt owns only
360
373
  * content. Returns null when the role has nothing to run this run (a director with an empty
@@ -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;