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
@@ -3,7 +3,7 @@ import { changedFiles } from "../git/git-diff.js";
3
3
  import { buildCommitMessage, commitTrailer, extractCommitBody, extractSummary, summaryOverCap, fallbackSummary, formatCommitBody, appendSelfCheckNotes, stampedSubject, } from "../git/commit-message.js";
4
4
  import { logEventBestEffort } from "../events/events.js";
5
5
  import { enqueueMerge, entryRevisionFields, queuedMerges } from "../merge/merge-queue.js";
6
- import { hasUnmergedPin } from "../runner/leftover.js";
6
+ import { hasUnmergedPin } from "../runner/runner-leftover.js";
7
7
  import { plural } from "../text/phrases.js";
8
8
  import { squash } from "../text/text.js";
9
9
  import { recordDismissedFiling, recordDismissedFlow } from "./run-dismiss.js";
@@ -56,75 +56,114 @@ function revisionFields(ctx) {
56
56
  : undefined;
57
57
  return { revisionRound, priorReview };
58
58
  }
59
+ /** The bounded follow-up turn both recovery paths share: fold the follow-up's commits past the
60
+ * head it started from, and end the run aborted (returning the outcome to surface) when the
61
+ * follow-up aborted, else hand back the follow-up run. */
62
+ async function requestSummaryFollowUp(ctx, over) {
63
+ const base = await headOf(ctx.wt, "HEAD");
64
+ const followUp = await ctx.requestSummary(ctx.wt, over);
65
+ await foldAgentCommitsSince(ctx, ctx.wt, base);
66
+ if (followUp?.aborted)
67
+ return { aborted: await ctx.finishAbortedRun() };
68
+ return { followUp };
69
+ }
70
+ /** Recover a summary when the reply left none: take the shared follow-up turn, refill `pending`
71
+ * from it, fall back to the changed paths when it still yields none, and warn about the recovery.
72
+ * Returns an outcome only when the follow-up aborted, else `undefined`. */
73
+ async function recoverMissingSummary(ctx, pending) {
74
+ const result = await requestSummaryFollowUp(ctx);
75
+ if ("aborted" in result)
76
+ return result.aborted;
77
+ const followUp = result.followUp;
78
+ if (followUp) {
79
+ pending.summary = extractSummary(followUp.finalText);
80
+ pending.body = pending.body ?? extractCommitBody(followUp.finalText);
81
+ }
82
+ if (pending.summary === null) {
83
+ pending.summary = fallbackSummary(await changedFiles(ctx.wt), ctx.role, ctx.state.runs);
84
+ pending.summaryDerived = true;
85
+ }
86
+ ctx.warn(missingSummaryWarning(followUp, pending.summary));
87
+ return undefined;
88
+ }
89
+ /** The missing-SUMMARY warning's four-way wording: "recovered it with a follow-up turn", or the
90
+ * derived-subject variants naming the follow-up's failure or absence. */
91
+ function missingSummaryWarning(followUp, summary) {
92
+ return (`reply had no SUMMARY line — ` +
93
+ (followUp && extractSummary(followUp.finalText) !== null
94
+ ? "recovered it with a follow-up turn"
95
+ : followUp && !followUp.ok
96
+ ? `the follow-up run failed (${followUp.errorMessage || "no error message"}); ` +
97
+ `subject derived from the changed files: "${summary}"`
98
+ : followUp
99
+ ? `follow-up gave none; subject derived from the changed files: "${summary}"`
100
+ : `no follow-up session was available; subject derived from the changed files: "${summary}"`));
101
+ }
102
+ /** Shorten a SUMMARY the cap would cut mid-word: take the shared follow-up turn asking for it
103
+ * restated short, keep the restated subject when it fits, and warn. Returns an outcome only when
104
+ * the follow-up aborted, else `undefined`. */
105
+ async function shortenOverCapSummary(ctx, pending) {
106
+ const over = summaryOverCap(ctx.finalText);
107
+ if (over === null)
108
+ return undefined;
109
+ // A SUMMARY the cap would cut mid-word gets one follow-up turn asking for it restated short.
110
+ const result = await requestSummaryFollowUp(ctx, over);
111
+ if ("aborted" in result)
112
+ return result.aborted;
113
+ const followUp = result.followUp;
114
+ const short = followUp && summaryOverCap(followUp.finalText) === null ? extractSummary(followUp.finalText) : null;
115
+ if (short !== null) {
116
+ pending.summary = short;
117
+ pending.body = pending.body ?? extractCommitBody(followUp.finalText);
118
+ }
119
+ ctx.warn(short !== null
120
+ ? `SUMMARY was ${over} characters — shortened with a follow-up turn`
121
+ : `SUMMARY was ${over} characters — follow-up gave no shorter one; subject truncated`);
122
+ return undefined;
123
+ }
59
124
  /** Derive the commit's subject and body from the reply's SUMMARY block, running one bounded
60
125
  * follow-up turn when the reply left none or a SUMMARY the cap would cut mid-word. Returns the
61
126
  * aborted-run outcome when a follow-up aborts, otherwise the description. */
62
127
  async function resolveCommitDescription(ctx) {
63
- const s = ctx.state;
64
128
  // The commit subject and body come from the reply's closing block. A run that changed files
65
129
  // without one — a cut-off final message, or plain non-compliance — gets one bounded follow-up
66
130
  // turn in its own session to produce it (the session still holds everything the run did);
67
131
  // only if that too yields nothing is the subject derived from the changed paths.
68
- let summary = extractSummary(ctx.finalText);
69
- let body = extractCommitBody(ctx.finalText);
70
- // True when no reply named the change, so the subject below is derived from its paths.
71
- let summaryDerived = false;
72
- if (summary === null) {
73
- const base = await headOf(ctx.wt, "HEAD");
74
- const followUp = await ctx.requestSummary(ctx.wt);
75
- await foldAgentCommitsSince(ctx, ctx.wt, base);
76
- if (followUp?.aborted)
77
- return ctx.finishAbortedRun();
78
- if (followUp) {
79
- summary = extractSummary(followUp.finalText);
80
- body = body ?? extractCommitBody(followUp.finalText);
81
- }
82
- if (summary === null) {
83
- summary = fallbackSummary(await changedFiles(ctx.wt), ctx.role, s.runs);
84
- summaryDerived = true;
85
- }
86
- ctx.warn(`reply had no SUMMARY line — ` +
87
- (followUp && extractSummary(followUp.finalText) !== null
88
- ? "recovered it with a follow-up turn"
89
- : followUp && !followUp.ok
90
- ? `the follow-up run failed (${followUp.errorMessage || "no error message"}); ` +
91
- `subject derived from the changed files: "${summary}"`
92
- : followUp
93
- ? `follow-up gave none; subject derived from the changed files: "${summary}"`
94
- : `no follow-up session was available; subject derived from the changed files: "${summary}"`));
95
- }
96
- else {
97
- // A SUMMARY the cap would cut mid-word gets one follow-up turn asking for it restated short.
98
- const over = summaryOverCap(ctx.finalText);
99
- if (over !== null) {
100
- const base = await headOf(ctx.wt, "HEAD");
101
- const followUp = await ctx.requestSummary(ctx.wt, over);
102
- await foldAgentCommitsSince(ctx, ctx.wt, base);
103
- if (followUp?.aborted)
104
- return ctx.finishAbortedRun();
105
- const short = followUp && summaryOverCap(followUp.finalText) === null ? extractSummary(followUp.finalText) : null;
106
- if (short !== null) {
107
- summary = short;
108
- body = body ?? extractCommitBody(followUp.finalText);
109
- }
110
- ctx.warn(short !== null
111
- ? `SUMMARY was ${over} characters — shortened with a follow-up turn`
112
- : `SUMMARY was ${over} characters — follow-up gave no shorter one; subject truncated`);
113
- }
114
- }
115
- return { summary, body, summaryDerived };
132
+ const pending = {
133
+ summary: extractSummary(ctx.finalText),
134
+ body: extractCommitBody(ctx.finalText),
135
+ // True when no reply named the change, so the subject below is derived from its paths.
136
+ summaryDerived: false,
137
+ };
138
+ const outcome = pending.summary === null
139
+ ? await recoverMissingSummary(ctx, pending)
140
+ : await shortenOverCapSummary(ctx, pending);
141
+ if (outcome)
142
+ return outcome;
143
+ return { summary: pending.summary, body: pending.body, summaryDerived: pending.summaryDerived };
116
144
  }
117
145
  /** Run the gate's deterministic backlog checks before the commit, giving the author's session
118
146
  * one bounded fix-up turn, and end the run `no_change` when the fix-up reverts the whole change
119
147
  * (leaving nothing to commit). Returns an outcome only when the run must end early; otherwise
120
148
  * mutates `desc.summary`/`desc.body` with any fix-up recovery, exactly as the inline block did. */
121
149
  async function runStageSelfCheck(ctx, desc) {
122
- const s = ctx.state;
123
150
  // The gate's deterministic backlog checks run here, before the commit, while the authoring
124
151
  // session can still be continued: a finding that reaches review costs a queue slot,
125
152
  // a verification, and a rejection that discards the work. One bounded follow-up turn on the
126
153
  // author's own session gets to fix them; the gate still has the final say, and the change commits
127
154
  // and queues either way.
155
+ const moved = await stagedBacklogMoves(ctx);
156
+ const movedFindings = recordStagedClaims(ctx, moved);
157
+ const findings = await ctx.stageCheck(ctx.wt);
158
+ findings.push(...movedFindings);
159
+ if (findings.length === 0)
160
+ return undefined;
161
+ return applyStageFixUp(ctx, desc, findings);
162
+ }
163
+ /** Decide whether this is a claim-staging run and, if so, read the backlog entries its diff moved
164
+ * out of their actionable section (plans/parallel-work-instances.md "Claims", part 4/7). Returns
165
+ * `[]` for every non-claim-staging run and on any unreadable file or git hiccup. */
166
+ async function stagedBacklogMoves(ctx) {
128
167
  // Claims made at staging (plans/parallel-work-instances.md "Claims", part 4/7): an
129
168
  // unassigned run of a MULTI-instance base whose diff moved a backlog entry out of its
130
169
  // actionable section records the claim so a sibling is not assigned it while this change
@@ -139,6 +178,13 @@ async function runStageSelfCheck(ctx, desc) {
139
178
  configuredInstances(ctx.config, ctx.role) > 1
140
179
  ? await stagedMovedEntries(ctx.wt, ctx.mainBranch, ctx.role).catch(() => [])
141
180
  : [];
181
+ return moved;
182
+ }
183
+ /** Record the run's staged claim for the first claimable moved entry (when it has none yet) and
184
+ * return the moved-entry findings in the order the inline body pushed them: `assignedMoved`
185
+ * first, then `heldMoved`. */
186
+ function recordStagedClaims(ctx, moved) {
187
+ const s = ctx.state;
142
188
  const heldBySiblings = new Set(s.claim === undefined ? (s.heldElsewhere ?? []).map(entryKey) : []);
143
189
  const heldMoved = heldMovedFinding(heldBySiblings, moved);
144
190
  const claimable = moved.filter((m) => !heldBySiblings.has(m.key));
@@ -157,45 +203,50 @@ async function runStageSelfCheck(ctx, desc) {
157
203
  });
158
204
  }
159
205
  const assignedMoved = s.claim ? assignedMovedFinding(s.claim.key, moved) : undefined;
160
- const findings = await ctx.stageCheck(ctx.wt);
206
+ const findings = [];
161
207
  if (assignedMoved)
162
208
  findings.push(assignedMoved);
163
209
  if (heldMoved)
164
210
  findings.push(heldMoved);
165
- if (findings.length > 0) {
166
- const before = findings.length;
167
- const fixBase = await headOf(ctx.wt, "HEAD");
168
- const fixUp = await ctx.requestStageFix(ctx.wt, findings, {
169
- summary: desc.summaryDerived ? null : desc.summary,
170
- body: desc.body ? formatCommitBody(desc.body) : "",
171
- });
172
- await foldAgentCommitsSince(ctx, ctx.wt, fixBase);
173
- if (fixUp?.aborted)
211
+ return findings;
212
+ }
213
+ /** Run the author's one bounded fix-up turn over the stage findings, fold its commits, recover
214
+ * `desc.summary`/`desc.body` from the reply, and end the run `no_change` when the follow-up left
215
+ * the worktree clean; otherwise stage-check again and log the outcome warning. Returns an outcome
216
+ * only when it ends the run early, else `undefined`. */
217
+ async function applyStageFixUp(ctx, desc, findings) {
218
+ const before = findings.length;
219
+ const fixBase = await headOf(ctx.wt, "HEAD");
220
+ const fixUp = await ctx.requestStageFix(ctx.wt, findings, {
221
+ summary: desc.summaryDerived ? null : desc.summary,
222
+ body: desc.body ? formatCommitBody(desc.body) : "",
223
+ });
224
+ await foldAgentCommitsSince(ctx, ctx.wt, fixBase);
225
+ if (fixUp) {
226
+ if (fixUp.aborted)
174
227
  return ctx.finishAbortedRun();
175
- if (fixUp) {
176
- // The fix-up reply answers the findings, not the change, so it never replaces the
177
- // change's description (BUGS.md 2026-10-21): its SUMMARY is taken only over a subject
178
- // derived from the paths, and its RISK/VERIFIED are appended as self-check notes.
179
- const fixSummary = desc.summaryDerived ? extractSummary(fixUp.finalText) : null;
180
- if (fixSummary !== null)
181
- desc.summary = fixSummary;
182
- desc.body = appendSelfCheckNotes(desc.body, extractCommitBody(fixUp.finalText));
183
- }
184
- // A fix-up turn may resolve its findings by reverting the whole change. With nothing left
185
- // in the worktree there is nothing to commit or queue: end the run no_change here instead
186
- // of handing a clean tree to commitAll. The worktree is left clean and no pin is taken.
187
- // isDirty, not changedFiles: a `git status` that fails must throw, not read as clean — on
188
- // 2026-10-21 a spawn failure here ended bugfix-2's 17-minute run no_change and released
189
- // its edits unpinned; thrown, the run ends unsettled and its next run gets them back.
190
- if (!(await isDirty(ctx.wt))) {
191
- logSelfCheckOutcome(ctx, "stage self-check: the follow-up turn left no change; nothing to land", findings);
192
- return { result: "no_change" };
193
- }
194
- const remaining = await ctx.stageCheck(ctx.wt);
195
- logSelfCheckOutcome(ctx, remaining.length === 0
196
- ? `stage self-check: ${plural(before, "finding")} — fixed by the follow-up turn`
197
- : `stage self-check: ${plural(before, "finding")}; ${remaining.length} still open, queued for the gate`, findings, remaining);
228
+ // The fix-up reply answers the findings, not the change, so it never replaces the
229
+ // change's description (BUGS.md 2026-10-21): its SUMMARY is taken only over a subject
230
+ // derived from the paths, and its RISK/VERIFIED are appended as self-check notes.
231
+ const fixSummary = desc.summaryDerived ? extractSummary(fixUp.finalText) : null;
232
+ if (fixSummary !== null)
233
+ desc.summary = fixSummary;
234
+ desc.body = appendSelfCheckNotes(desc.body, extractCommitBody(fixUp.finalText));
235
+ }
236
+ // A fix-up turn may resolve its findings by reverting the whole change. With nothing left
237
+ // in the worktree there is nothing to commit or queue: end the run no_change here instead
238
+ // of handing a clean tree to commitAll. The worktree is left clean and no pin is taken.
239
+ // isDirty, not changedFiles: a `git status` that fails must throw, not read as clean — on
240
+ // 2026-10-21 a spawn failure here ended bugfix-2's 17-minute run no_change and released
241
+ // its edits unpinned; thrown, the run ends unsettled and its next run gets them back.
242
+ if (!(await isDirty(ctx.wt))) {
243
+ logSelfCheckOutcome(ctx, "stage self-check: the follow-up turn left no change; nothing to land", findings);
244
+ return { result: "no_change" };
198
245
  }
246
+ const remaining = await ctx.stageCheck(ctx.wt);
247
+ logSelfCheckOutcome(ctx, remaining.length === 0
248
+ ? `stage self-check: ${plural(before, "finding")} — fixed by the follow-up turn`
249
+ : `stage self-check: ${plural(before, "finding")}; ${remaining.length} still open, queued for the gate`, findings, remaining);
199
250
  return undefined;
200
251
  }
201
252
  /** Compute the run's friction signal over this run's pre-commit authoring runs: minutes of wall
@@ -89,16 +89,22 @@ export async function runTimedRoleRun(signal, acquire, release, run, now = Date.
89
89
  /** Sleep up to ms, but wake immediately when `signal` aborts — so shutdown (SIGTERM →
90
90
  * abort) is prompt instead of waiting out the current poll cycle. The listener is removed
91
91
  * on either exit path so long-running orchestrators don't accumulate one per poll, and an
92
- * already-aborted signal returns synchronously through runOnAbort. Exported as a unit-test
92
+ * already-aborted signal returns synchronously through runOnAbort. Without a signal it is a
93
+ * plain setTimeout sleep. The review gate's retry wait (src/review/review.ts, whose signal is
94
+ * optional) shares this helper rather than keeping a second copy. Exported as a unit-test
93
95
  * seam, like runTimedRoleRun above. */
94
96
  export function sleepInterruptible(ms, signal) {
95
97
  return new Promise((resolve) => {
98
+ if (signal === undefined) {
99
+ setTimeout(resolve, ms);
100
+ return;
101
+ }
96
102
  const onAbort = () => {
97
103
  clearTimeout(timer);
98
104
  resolve();
99
105
  };
100
106
  const timer = setTimeout(() => {
101
- signal.removeEventListener("abort", onAbort);
107
+ signal?.removeEventListener("abort", onAbort);
102
108
  resolve();
103
109
  }, ms);
104
110
  runOnAbort(signal, onAbort);
@@ -108,9 +108,24 @@ export class RunUsage {
108
108
  * through foldMergeUsage: those never advance the run's pre-edit counter, which measures
109
109
  * the authoring search. */
110
110
  fold(s, run, authoring = true, provider, pair) {
111
+ this.foldLifetimeTotals(s, run);
112
+ this.foldCost(s, run, provider, pair);
113
+ this.foldAccount(run);
114
+ this.foldPromptTokens(run, authoring);
115
+ this.turns += run.turns;
116
+ this.foldEpisodes(run, provider);
117
+ }
118
+ /** Grow the role's lifetime totals on `s` from this run: its generated tokens and the real
119
+ * spend both accumulate through the healed counter, and the peak context is the new max. */
120
+ foldLifetimeTotals(s, run) {
111
121
  s.generatedTokens = counter(s.generatedTokens) + run.outputTokens;
112
122
  s.peakContextTokens = Math.max(counter(s.peakContextTokens), run.peakContextTokens);
113
123
  s.totalCostUsd = counter(s.totalCostUsd) + run.costUsd;
124
+ }
125
+ /** Split this run's cost real from notional: a subscription run's cost is notional and never
126
+ * charges the daily cap; an API run's — or a run that ever saw overage — is real money.
127
+ * totalCostUsd still counts both: it is lifetime usage, not a budget figure. */
128
+ foldCost(s, run, provider, pair) {
114
129
  // The billing split (plans/agent-backends.md §7): a subscription run's cost is notional
115
130
  // and never charges the daily cap; an API run's — or a run that ever saw overage — is real
116
131
  // money. totalCostUsd still counts both: it is lifetime usage, not a budget figure.
@@ -126,12 +141,21 @@ export class RunUsage {
126
141
  this.notionalCostUsd += run.costUsd;
127
142
  this.bumpProviderCost(provider, run.costUsd, 'notionalCostUsd');
128
143
  }
144
+ }
145
+ /** Carry the newest account readings this run reported: the billing state, the subscription
146
+ * rate windows, and the agent they came from (whose name is only meaningful beside them). */
147
+ foldAccount(run) {
129
148
  if (run.billing !== undefined)
130
149
  this.billing = run.billing;
131
150
  if (run.rateWindows !== undefined)
132
151
  this.rateWindows = run.rateWindows;
133
152
  if (run.billing !== undefined || run.rateWindows !== undefined)
134
153
  this.agent = run.agent;
154
+ }
155
+ /** Advance the token counters: the prompt and cache-read shares count every run, while the
156
+ * pre-edit prefix freezes at the first edit — authoring-only, and skipped for a merge/review
157
+ * run. */
158
+ foldPromptTokens(run, authoring) {
135
159
  this.promptTokens += run.promptTokens;
136
160
  this.cacheReadTokens += run.cacheReadTokens;
137
161
  // The pre-edit prefix freezes at the run's first edit: every authoring run while no edit
@@ -143,7 +167,13 @@ export class RunUsage {
143
167
  if (run.firstEditTurn !== undefined)
144
168
  this.editSeen = true;
145
169
  }
146
- this.turns += run.turns;
170
+ }
171
+ /** Stamp the team-wide hold's failure observations for a run that ENDED on them: pi exits on
172
+ * a 429 or backend error, so "now" is when the provider refused. A run that merely logged one
173
+ * inside pi's own auto-retry and then finished would stamp a false observation, possibly hours
174
+ * late. The backend kind travels with its stamp (src/pi/pi-failure.ts classified the text at
175
+ * parse time); a 429 never ends up here — pi-stream checks rate-limit first. */
176
+ foldEpisodes(run, provider) {
147
177
  // The team-wide hold's rate-limit input (lastRateLimit above), from the same
148
178
  // once-per-run choke point. Only a run that ENDED on the 429 counts: pi exits on the error,
149
179
  // so "now" is when the provider refused — a run that merely logged one inside pi's own
@@ -1,3 +1,4 @@
1
+ import { imageReroute } from "../agent/image-input.js";
1
2
  import { DIRECTOR_ROLE } from "../roles/roles.js";
2
3
  import { baseRoleOf } from "../roles/runner-ids.js";
3
4
  import { isDirty, deleteRef } from "../git/git.js";
@@ -9,6 +10,7 @@ import { errorMessage } from "../text/text.js";
9
10
  import { recordParity } from "./ci-parity.js";
10
11
  import { resetWorktreeToMain } from "../git/worktree.js";
11
12
  import { recordDismissedFiling, recordDismissedFlow } from "./run-dismiss.js";
13
+ import { readPiListedModels } from "../pi/pi-listed-models.js";
12
14
  import { rejectedRefName } from "../paths.js";
13
15
  import { logEventBestEffort } from "../events/events.js";
14
16
  import { subscriptionWindowRejected } from "../agent/claude/claude-account.js";
@@ -23,6 +25,17 @@ function abortWithResume(s, ctx, pi, lastError, result, resumeCause) {
23
25
  ctx.pending.requeueForResume(s, ctx.userPrompt);
24
26
  return { result, resumeCause };
25
27
  }
28
+ /** End a run as an unfulfilled error: record `lastError`, re-queue the raw prompt for a later
29
+ * fresh run (requeueUnfulfilled) and return the error outcome — the terminal the timeout and
30
+ * image-blind arms share, filed here so the "record lastError, re-queue unfulfilled, end as
31
+ * error" contract cannot drift between them (the resume-arming twin is abortWithResume above;
32
+ * failedRunArm's error path keeps its own split because it arms a resume for a dirty worktree
33
+ * and sets lastError before that choice). */
34
+ function unfulfilledError(s, ctx, message) {
35
+ s.lastError = message;
36
+ ctx.pending.requeueUnfulfilled(ctx.userPrompt);
37
+ return { result: "error" };
38
+ }
26
39
  /** Classify what the finished run left behind; null means the run is fulfillable and staging
27
40
  * should take over. Never throws: every branch returns an outcome, recording what went wrong
28
41
  * in `state.lastError` or a warning event. */
@@ -134,11 +147,9 @@ function progressingTimeoutArm(ctx) {
134
147
  function timeoutArm(ctx) {
135
148
  const { pi, state: s } = ctx;
136
149
  if (pi.timedOut) {
137
- s.lastError = pi.errorMessage ?? "timed out";
138
150
  // The request never ran to completion and no work merged: put it back so the next
139
151
  // run retries it. (A killed run's half-done edits are discarded by the reset.)
140
- ctx.pending.requeueUnfulfilled(ctx.userPrompt);
141
- return { result: "error" };
152
+ return unfulfilledError(s, ctx, pi.errorMessage ?? "timed out");
142
153
  }
143
154
  return undefined;
144
155
  }
@@ -179,21 +190,63 @@ function budgetCappedArm(ctx) {
179
190
  return undefined;
180
191
  }
181
192
  /** The image-blind decision: a run stopped the moment its agent read an image its model cannot
182
- * see fails with `lastError` and the prompt re-queues. The peer-tier reroute was subtracted
183
- * (part 5g/6), so a blind read is never handed to another tier (plans/image-input.md "Failing
184
- * the run"). */
193
+ * see. One hop to an image-capable reroute target is taken when one exists; otherwise the
194
+ * run fails and the prompt re-queues (plans/image-input.md "Failing the run"). */
185
195
  function imageBlindArm(ctx) {
186
196
  const { pi, state: s } = ctx;
187
197
  // A run the runner killed the moment its agent read an image its model cannot see
188
198
  // (plans/image-input.md "Failing the run"): pi strips such an image from the request, so
189
199
  // the agent was about to answer about a picture it never saw. The run was deliberately
190
200
  // stopped before it could work on, so its half-done edits are discarded exactly as a
191
- // timed-out run's are — nothing to resume, and the prompt re-queues to run fresh later.
192
- // The error backoff and streak apply as for any error.
201
+ // timed-out run's are — nothing to resume, and the prompt re-queues to run fresh on a
202
+ // model that CAN see (part 4 adds the reroute arm here). The error backoff and streak apply
203
+ // as for any error.
193
204
  if (pi.imageBlind) {
194
- s.lastError = pi.errorMessage ?? `read ${pi.imageBlind.path}, but the model cannot read images`;
195
- ctx.pending.requeueUnfulfilled(ctx.userPrompt);
196
- return { result: "error" };
205
+ // One hop only (plans/image-input.md "Rerunning on a model that can read images"): a
206
+ // run that ALREADY started on the image-capable target and still read blind is not
207
+ // rerouted a second time — it fails, so a misreadable image cannot bounce the role
208
+ // between tiers forever. Set at run start by runPhase when it routes a run onto the
209
+ // target (blind-read handoff or attachment preflight).
210
+ if (s.imageRerouteActive) {
211
+ // No work was done (the run was killed at the first read): nothing to resume, and the
212
+ // prompt re-queues to run fresh later like any other error. The error backoff/streak
213
+ // apply as usual.
214
+ return unfulfilledError(s, ctx, pi.errorMessage ?? `read ${pi.imageBlind.path}, but the model cannot read images`);
215
+ }
216
+ // Otherwise, when the role's tier has an image-capable reroute target, the failed work
217
+ // is not discarded: the next run starts directly on that target over the kept worktree.
218
+ // The one-shot field carries the target seam plus the `from`/`path` facts the handoff
219
+ // note needs; the run ends `aborted` with the `image-reroute` resume cause, and
220
+ // applyRunOutcome schedules it promptly (nextRunAt = now) while planRunStart hands the
221
+ // kept worktree off to a FRESH session on the target.
222
+ const regime = ctx.rerouteRegime ?? "primary";
223
+ const target = ctx.config !== undefined ? imageReroute(ctx.config, ctx.role, regime, undefined, null, null, readPiListedModels(ctx.root))
224
+ : null;
225
+ if (target) {
226
+ const from = ctx.runModel ?? target.config.model ?? "the model";
227
+ s.imageReroute = {
228
+ tier: target.tier,
229
+ backend: target.config.agent ?? "pi",
230
+ provider: target.config.provider,
231
+ model: target.config.model,
232
+ thinking: target.config.thinking,
233
+ from,
234
+ path: pi.imageBlind.path,
235
+ };
236
+ s.lastError = `read ${pi.imageBlind.path}, but ${from} cannot read images — rerouting to ${target.tier}`;
237
+ ctx.pending.requeueForResume(s, ctx.userPrompt);
238
+ logEventBestEffort(ctx.root, {
239
+ source: ctx.role,
240
+ type: "image_reroute",
241
+ run: s.runs,
242
+ from,
243
+ to: target.config.model,
244
+ tier: target.tier,
245
+ preflight: false,
246
+ });
247
+ return { result: "aborted", resumeCause: "image-reroute" };
248
+ }
249
+ return unfulfilledError(s, ctx, pi.errorMessage ?? `read ${pi.imageBlind.path}, but the model cannot read images`);
197
250
  }
198
251
  return undefined;
199
252
  }
@@ -300,51 +353,67 @@ function noChangeArm(ctx, changed) {
300
353
  // diagnoseNoChange (src/verdict/no-change.ts) tells which, so the warning event below is
301
354
  // diagnosable on its own.
302
355
  const diagnosis = diagnoseNoChange(pi);
303
- if (!pi.nothingToDo) {
304
- ctx.warn(`the agent finished without changes and without declaring nothing-to-do` +
305
- (diagnosis.notes.length ? ` (${diagnosis.notes.join(", ")})` : ""));
306
- }
356
+ warnUndeclaredNoChange(ctx, pi, diagnosis.notes);
307
357
  // A cut-off run did real work and was NOT fulfilled: a director prompt goes back
308
358
  // to the inbox to rerun fresh; a role runner resumes the just-compacted session
309
359
  // next run (see the cutOff handling in run()).
310
360
  if (diagnosis.cutOff)
311
361
  ctx.pending.requeueForResume(s, ctx.userPrompt);
312
- // A cut-off run did real work but was truncated before declaring its outcome: the FLOW
313
- // line it left mid-stream is not a finished verdict, so recording it would advance the
314
- // rotation past a check that did not complete. Only a run that was not cut off records.
315
- if (ctx.flow && !diagnosis.cutOff) {
316
- // The ledger only orders the qa menu, so a failed write warns rather than failing the run.
317
- recordDismissedFlow(ctx, ctx.root, ctx.flow.flow, ctx.flow.result, ctx.flow.result === "bug" ? extractSummary(pi.finalText) ?? undefined : undefined);
318
- }
319
- // A fulfilled feedback run that filed records its per-install ledger entry and logs the
320
- // issue_filed event so the filing is visible in the operator's activity feed. Recording is
321
- // bookkeeping that never fails the run: a failed ledger write warns and the no_change
322
- // outcome stands. A cut-off run records nothing — truncation is not a filing the run
323
- // declared (the same guard the FLOW line carries).
324
- if (ctx.filed && !diagnosis.cutOff) {
325
- recordDismissedFiling(ctx, ctx.root, ctx.filed);
362
+ recordNoChangeBookkeeping(ctx, pi, diagnosis.cutOff);
363
+ return {
364
+ result: "no_change",
365
+ cutOff: diagnosis.cutOff || undefined,
366
+ summary: noChangeSummary(ctx, pi, diagnosis.cutOff),
367
+ };
368
+ }
369
+ return undefined;
370
+ }
371
+ /** Warn when a no-change run declared neither a sentinel nor nothing-to-do, so the non-
372
+ * compliance or truncation the diagnosis saw is diagnosable on its own. */
373
+ function warnUndeclaredNoChange(ctx, pi, notes) {
374
+ if (!pi.nothingToDo) {
375
+ ctx.warn(`the agent finished without changes and without declaring nothing-to-do` +
376
+ (notes.length ? ` (${notes.join(", ")})` : ""));
377
+ }
378
+ }
379
+ /** A no-change run's bookkeeping: the qa flow, the filed feedback ledger entry and the ci
380
+ * parity coverage, each recorded only when its observer saw it and the run was not cut off. */
381
+ function recordNoChangeBookkeeping(ctx, pi, cutOff) {
382
+ // A cut-off run did real work but was truncated before declaring its outcome: the FLOW
383
+ // line it left mid-stream is not a finished verdict, so recording it would advance the
384
+ // rotation past a check that did not complete. Only a run that was not cut off records.
385
+ if (ctx.flow && !cutOff) {
386
+ // The ledger only orders the qa menu, so a failed write warns rather than failing the run.
387
+ recordDismissedFlow(ctx, ctx.root, ctx.flow.flow, ctx.flow.result, ctx.flow.result === "bug" ? extractSummary(pi.finalText) ?? undefined : undefined);
388
+ }
389
+ // A fulfilled feedback run that filed records its per-install ledger entry and logs the
390
+ // issue_filed event so the filing is visible in the operator's activity feed. Recording is
391
+ // bookkeeping that never fails the run: a failed ledger write warns and the no_change
392
+ // outcome stands. A cut-off run records nothing — truncation is not a filing the run
393
+ // declared (the same guard the FLOW line carries).
394
+ if (ctx.filed && !cutOff) {
395
+ recordDismissedFiling(ctx, ctx.root, ctx.filed);
396
+ }
397
+ // A fulfilled ci run that declared its coverage records it in the parity ledger so the
398
+ // next fresh session's block knows what the last run covered (part 3/3). The ledger is
399
+ // bookkeeping that never fails the run: a failed write warns and the no_change outcome
400
+ // stands. A cut-off run records nothing — truncation is not a coverage the run declared.
401
+ if (ctx.ci && !cutOff) {
402
+ try {
403
+ recordParity(ctx.root, ctx.ci.sha, ctx.ci.result);
326
404
  }
327
- // A fulfilled ci run that declared its coverage records it in the parity ledger so the
328
- // next fresh session's block knows what the last run covered (part 3/3). The ledger is
329
- // bookkeeping that never fails the run: a failed write warns and the no_change outcome
330
- // stands. A cut-off run records nothing — truncation is not a coverage the run declared.
331
- if (ctx.ci && !diagnosis.cutOff) {
332
- try {
333
- recordParity(ctx.root, ctx.ci.sha, ctx.ci.result);
334
- }
335
- catch (err) {
336
- ctx.warn(`ci parity not recorded: ${errorMessage(err)}`);
337
- }
405
+ catch (err) {
406
+ ctx.warn(`ci parity not recorded: ${errorMessage(err)}`);
338
407
  }
339
- // A prompt-driven (director) run that answers a question in prose ends `no_change` with
340
- // no SUMMARY block, so its reply would be lost — the dashboard reads a bare "No change"
341
- // and the feed carries nothing (BUGS.md 2026-10-14). Keep the prose above the sentinel as
342
- // the run's summary so `lastSummary` and the run_end event show the answer where the
343
- // operator asked the question. A role runner's bare nothing-to-do stays summary-less.
344
- const summary = ctx.userPrompt !== null && !diagnosis.cutOff
345
- ? (extractNoChangeAnswer(pi.finalText) ?? undefined)
346
- : undefined;
347
- return { result: "no_change", cutOff: diagnosis.cutOff || undefined, summary };
348
408
  }
349
- return undefined;
409
+ }
410
+ /** A prompt-driven (director) run that answers a question in prose ends `no_change` with
411
+ * no SUMMARY block, so its reply would be lost — the dashboard reads a bare "No change"
412
+ * and the feed carries nothing (BUGS.md 2026-10-14). Keep the prose above the sentinel as
413
+ * the run's summary so `lastSummary` and the run_end event show the answer where the
414
+ * operator asked the question. A role runner's bare nothing-to-do stays summary-less. */
415
+ function noChangeSummary(ctx, pi, cutOff) {
416
+ return ctx.userPrompt !== null && !cutOff
417
+ ? (extractNoChangeAnswer(pi.finalText) ?? undefined)
418
+ : undefined;
350
419
  }