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
@@ -14,7 +14,7 @@ import { eventRole, readEvents } from "../events/event-read.js";
14
14
  import { eventFeedItem, eventMatchesGrep } from "../events/event-format.js";
15
15
  import { readRunDetail, runCommitSha, runMergedSha } from "../run/run-detail-data.js";
16
16
  import { renderRunDetail, runDetailPatch, runDetailStat, runTranscriptLines } from "../run/run-detail-render.js";
17
- import { resolveRevertTarget } from "../operator/revert.js";
17
+ import { resolveRevertTarget } from "../operator/operator-revert.js";
18
18
  import { rolePayload } from "../roles/role-view.js";
19
19
  import { clampCount, intQuery, rejectBadRole, sinceMsQuery, untilMsQuery } from "./gui-args.js";
20
20
  import { badRequest, sendJson } from "./gui-http-body.js";
@@ -124,36 +124,67 @@ function windowOrCount(q, res) {
124
124
  return { kind: "count" };
125
125
  }
126
126
  export function handleHistory(q, res, root) {
127
+ const req = historyRequest(q, res);
128
+ if (req === null)
129
+ return;
130
+ const ctl = req.windowCtl;
131
+ if (ctl.kind === "window")
132
+ serveHistoryWindow(res, root, req, ctl.sinceMs, ctl.untilMs);
133
+ else
134
+ serveHistoryCount(q, res, root, req);
135
+ }
136
+ /** Parse the request prefix `GET /api/history` shares across both shapes: the `role`/`grep`
137
+ * filters, the `result` class list (resultClassesQuery, a 400 on an unknown class), the `model`
138
+ * selector list, the `summary` flag's mere presence, and the rival `sinceMs`-vs-`n` shape
139
+ * (windowOrCount, a 400 on both or a bad cap). Any 400 is already sent and yields null. */
140
+ function historyRequest(q, res) {
127
141
  const role = q.get("role") || null; // "" and absent both read all roles
128
142
  const grep = q.get("grep") || null; // "" and absent both filter nothing
129
143
  // `model` is a comma list of exact selectors (trimmed, deduplicated); "", absent and a list
130
144
  // with no non-empty piece all filter nothing, and an unknown selector keeps no rows.
131
145
  const classes = resultClassesQuery(q, res);
132
146
  if (classes === null)
133
- return;
134
- const inClasses = (rows) => (classes.length ? rows.filter((r) => classes.includes(historyClass(r.result))) : rows);
147
+ return null;
135
148
  const modelPieces = splitCommaListUnique(q.get("model") ?? "");
136
- const onModels = (rows) => (modelPieces.length ? runRowsOnModels(rows, modelPieces) : rows);
137
149
  const summary = q.get("summary") !== null; // the flag's presence, whatever its value
138
150
  const windowCtl = windowOrCount(q, res);
139
151
  if (windowCtl === null)
140
- return;
141
- if (windowCtl.kind === "window") {
142
- const { sinceMs, untilMs } = windowCtl;
143
- const { rows, covered } = readRunRowsSince(root, sinceMs, role, untilMs ?? null);
144
- const matched = onModels(inClasses(grep === null ? rows : runRowsMatchingGrep(rows, grep)));
145
- sendJson(res, 200, { rows: matched, covered, note: covered ? "" : SPARSE_WINDOW_NOTE, ...(summary ? { summary: summarizeRunRows(matched), modelSummary: summarizeRunRowsByModel(matched) } : {}) });
146
- return;
147
- }
152
+ return null;
153
+ return { role, grep, classes, modelPieces, summary, windowCtl };
154
+ }
155
+ /** The shared filter chain both shapes apply to the served rows: grep first, then the `result`
156
+ * outcome classes, then the `model` selectors — the CLI's `history --grep`, `--result` and
157
+ * `--model` rules, in that order. */
158
+ function historyRows(rows, req) {
159
+ const grepped = req.grep === null ? rows : runRowsMatchingGrep(rows, req.grep);
160
+ const classed = req.classes.length ? grepped.filter((r) => req.classes.includes(historyClass(r.result))) : grepped;
161
+ return req.modelPieces.length ? runRowsOnModels(classed, req.modelPieces) : classed;
162
+ }
163
+ /** The optional additive fold both answers share: when `summary` is present, `{ summary,
164
+ * modelSummary }` added to the 200 payload; absent, nothing (so the response is byte-for-byte
165
+ * today's). */
166
+ function historySummaryFields(rows, summary) {
167
+ return summary ? { summary: summarizeRunRows(rows), modelSummary: summarizeRunRowsByModel(rows) } : {};
168
+ }
169
+ /** Answer the trailing-window shape ({ rows, covered, note }): rows read through readRunRowsSince
170
+ * from `sinceMs` (untilMs drops the rows newer than now − untilMs when present, exactly as the
171
+ * CLI's `history --since … --until` window reads), then the shared historyRows filter, then the
172
+ * optional summary fold — key order rows, covered, note, summary, modelSummary. */
173
+ function serveHistoryWindow(res, root, req, sinceMs, untilMs) {
174
+ const { rows, covered } = readRunRowsSince(root, sinceMs, req.role, untilMs ?? null);
175
+ const matched = historyRows(rows, req);
176
+ sendJson(res, 200, { rows: matched, covered, note: covered ? "" : SPARSE_WINDOW_NOTE, ...historySummaryFields(matched, req.summary) });
177
+ }
178
+ /** Answer the newest-`n` count shape: `n` parses through intQuery (non-negative, default
179
+ * HISTORY_DEFAULT_RUNS) and is clamped to [1, HISTORY_MAX_RUNS] — the GUI clamps where the CLI
180
+ * fails fast — then rows read through readRunRows, filtered and answered through the shared
181
+ * historyRows/historySummaryFields, key order rows, summary, modelSummary. */
182
+ function serveHistoryCount(q, res, root, req) {
148
183
  const parsed = intQuery(q, res, "n", "non-negative", HISTORY_DEFAULT_RUNS);
149
184
  if (parsed === null)
150
185
  return;
151
- const n = clampCount(parsed, HISTORY_MAX_RUNS);
152
- let rows = readRunRows(root, n, role);
153
- if (grep !== null)
154
- rows = runRowsMatchingGrep(rows, grep);
155
- rows = onModels(inClasses(rows));
156
- sendJson(res, 200, summary ? { rows, summary: summarizeRunRows(rows), modelSummary: summarizeRunRowsByModel(rows) } : { rows });
186
+ const rows = historyRows(readRunRows(root, clampCount(parsed, HISTORY_MAX_RUNS), req.role), req);
187
+ sendJson(res, 200, { rows, ...historySummaryFields(rows, req.summary) });
157
188
  }
158
189
  /** How many of the log's newest events `GET /api/logs` scans by default, and the most it will
159
190
  * scan however large `n` asks: the CLI's `-n` shape (n bounds the scanned tail, not the printed
@@ -245,47 +276,64 @@ function transcriptText(root, role, run, detail, includePrompts, grep) {
245
276
  * payload's `role` is that run's; `result`/`model` narrow it the same way, and a miss is a 404
246
277
  * worded for any role. */
247
278
  export async function handleRun(q, res, root) {
248
- let role = q.get("role");
279
+ const target = runSelector(q, res, root);
280
+ if (target === null)
281
+ return;
282
+ const detail = readRunDetail(root, target.role, target.run);
283
+ if (detail === null) {
284
+ sendJson(res, 404, { error: runNotFoundMessage(target.role, target.run) });
285
+ return;
286
+ }
287
+ sendJson(res, 200, await runPayload(root, target.role, target.run, detail, q));
288
+ }
289
+ /** Resolve which run the request names — validating the role, then the numbered or `last` form. */
290
+ function runSelector(q, res, root) {
291
+ const role = q.get("role");
249
292
  const bareLast = role === null && q.get("last") !== null;
250
293
  if (!bareLast && rejectBadRole(root, res, role))
251
- return;
252
- let run;
253
- if (q.get("last") === null) {
254
- if (q.get("result") !== null)
255
- return badRequest(res, "result needs last");
256
- if (q.get("model") !== null)
257
- return badRequest(res, "model needs last");
258
- run = intQuery(q, res, "run", "positive");
259
- if (run === null)
260
- return;
294
+ return null;
295
+ return q.get("last") !== null ? lastRunSelector(q, res, root, role) : numberedRunSelector(q, res, role);
296
+ }
297
+ /** Resolve the explicit `run=N` selector of /api/run (three 400s, the miss worded by intQuery). */
298
+ function numberedRunSelector(q, res, role) {
299
+ if (q.get("result") !== null) {
300
+ badRequest(res, "result needs last");
301
+ return null;
261
302
  }
262
- else {
263
- if (q.get("run") !== null)
264
- return badRequest(res, "last cannot be combined with run (a number and the newest run are rival selectors)");
265
- const classes = resultClassesQuery(q, res);
266
- if (classes === null)
267
- return;
268
- const models = splitCommaListUnique(q.get("model") ?? "");
269
- const classFilter = classes.length ? classes : null;
270
- const modelFilter = models.length ? models : null;
271
- const row = newestRunRow(root, role, classFilter, modelFilter);
272
- if (row !== null)
273
- role = row.role;
274
- run = row?.run ?? null;
275
- if (run === null) {
276
- sendJson(res, 404, { error: runNotFoundLastMessage(role, classFilter, modelFilter) });
277
- return;
278
- }
303
+ if (q.get("model") !== null) {
304
+ badRequest(res, "model needs last");
305
+ return null;
279
306
  }
280
- const detail = readRunDetail(root, role, run);
281
- if (detail === null) {
282
- sendJson(res, 404, { error: runNotFoundMessage(role, run) });
283
- return;
307
+ const run = intQuery(q, res, "run", "positive");
308
+ if (run === null)
309
+ return null;
310
+ return { role: role, run };
311
+ }
312
+ /** Resolve the `last` selector, narrowed by `result`/`model`, to the role's newest run. */
313
+ function lastRunSelector(q, res, root, role) {
314
+ if (q.get("run") !== null) {
315
+ badRequest(res, "last cannot be combined with run (a number and the newest run are rival selectors)");
316
+ return null;
317
+ }
318
+ const classes = resultClassesQuery(q, res);
319
+ if (classes === null)
320
+ return null;
321
+ const models = splitCommaListUnique(q.get("model") ?? "");
322
+ const classFilter = classes.length ? classes : null;
323
+ const modelFilter = models.length ? models : null;
324
+ const row = newestRunRow(root, role, classFilter, modelFilter);
325
+ if (row === null) {
326
+ sendJson(res, 404, { error: runNotFoundLastMessage(role, classFilter, modelFilter) });
327
+ return null;
284
328
  }
329
+ return { role: row.role, run: row.run };
330
+ }
331
+ /** Assemble the 200 payload — the collector plus its rendered, stat and flag-gated fields. */
332
+ async function runPayload(root, role, run, detail, q) {
285
333
  const grep = (q.get("grep") ?? "").trim();
286
334
  const mergedAs = runMergedSha(detail);
287
335
  const target = mergedAs === null ? null : await resolveRevertTarget(root, mergedAs);
288
- sendJson(res, 200, {
336
+ return {
289
337
  ...detail,
290
338
  text: renderRunDetail(detail),
291
339
  stat: await runDetailStat(root, detail),
@@ -293,7 +341,7 @@ export async function handleRun(q, res, root) {
293
341
  hasCommit: runCommitSha(detail) !== null,
294
342
  ...(q.get("patch") !== null ? { patch: await runDetailPatch(root, detail) } : {}),
295
343
  ...(q.get("transcript") !== null ? { transcript: transcriptText(root, role, run, detail, q.get("prompt") !== null, grep === "" ? undefined : grep) } : {}),
296
- });
344
+ };
297
345
  }
298
346
  /** Handle GET /api/role?role=<id>: one role's inspection payload — rolePayload's object,
299
347
  * the same collection behind `tumwater role <id>` — so the drawer's Notes and Next run
@@ -18,15 +18,15 @@ import { loadConfigSafe } from "../config/config.js";
18
18
  import { saveRoleNote } from "../agent/role-notes-write.js";
19
19
  import { roleNotesPath, MERGE_LOCK_WAIT_MS } from "../paths.js";
20
20
  import { DIRECTOR_ROLE } from "../roles/roles.js";
21
- import { artifactPhrase, retireRole } from "../operator/retire.js";
22
- import { previewRevert, revertFollowUp, revertMergedCommit } from "../operator/revert.js";
21
+ import { artifactPhrase, retireRole } from "../operator/operator-retire.js";
22
+ import { previewRevert, revertFollowUp, revertMergedCommit } from "../operator/operator-revert.js";
23
23
  import { cancelQueuedMergeBySha } from "../merge/merge-cancel.js";
24
24
  import { acceptPostReviewBySha } from "../review/post-merge-review-accept.js";
25
25
  import { moveQueuedMergeToTopBySha } from "../merge/merge-top.js";
26
26
  import { readMergeQueue } from "../merge/merge-queue.js";
27
27
  import { mergeChanges } from "../merge/merge-slot.js";
28
28
  import { queueInFlight } from "../status/status-polls.js";
29
- import { orchestratorAlive } from "../team/orchestrator-info.js";
29
+ import { orchestratorAlive } from "../team/team-orchestrator-info.js";
30
30
  import { shortSha } from "../text/format.js";
31
31
  import { editBriefText } from "../brief/brief-edit.js";
32
32
  import { requireStringField, validRoleIds } from "./gui-args.js";
@@ -34,6 +34,21 @@ import { badRequest, sendJson } from "./gui-http-body.js";
34
34
  import { errorMessage, gotSuffix } from "../text/text.js";
35
35
  import { readPostBody, readRoleBody, sendRequestResult } from "./gui-endpoint-common.js";
36
36
  import { wakeInSecondsMs } from "./gui-team-commands.js";
37
+ /** The shared body of the dashboard's Health/Action buttons that run a domain function and
38
+ * answer its text lines joined with "; ": readPostBody (400/413 sent on a null return) plus the
39
+ * one sendJson built from the caller's `ok` predicate and line renderer. kill-orphans,
40
+ * drop-broken-pins, retire-leftovers and resync-install each restated this read-then-reply
41
+ * flow, differing only in what they run and how they say ok; this is the one home so the body
42
+ * discipline and the "; " joining cannot drift between them. commit-backlog and
43
+ * archive-backlog deliberately do not ride it (they carry extra payload fields or multi-branch
44
+ * replies), and neither does sendRequestResult's marker family (a different reply shape). */
45
+ async function runLinesAction(req, res, root, run, ok, lines) {
46
+ const body = await readPostBody(req, res, "{}");
47
+ if (!body)
48
+ return;
49
+ const report = await run(root);
50
+ sendJson(res, 200, { ok: ok(report), message: lines(report).join("; ") });
51
+ }
37
52
  /** Handle POST /api/reset-counters: the dashboard's counter reset — the same marker-writing
38
53
  * core `tumwater reset-counters` calls (requestResetCounters). Same body discipline and
39
54
  * `{}` → every configured role default as /api/wake. */
@@ -97,22 +112,14 @@ export async function handleReclaim(req, res, root) {
97
112
  * killOrphans `tumwater doctor --kill-orphans` runs, answering its lines joined with "; ". Same
98
113
  * body discipline as /api/reclaim; `ok` is false when the scan failed or a signal was refused. */
99
114
  export async function handleKillOrphans(req, res, root, kill = killOrphans) {
100
- const body = await readPostBody(req, res, "{}");
101
- if (!body)
102
- return;
103
- const report = await kill(root);
104
- sendJson(res, 200, { ok: !("error" in report) && report.failed.length === 0, message: orphanKillLines(report).join("; ") });
115
+ return runLinesAction(req, res, root, kill, (r) => !("error" in r) && r.failed.length === 0, orphanKillLines);
105
116
  }
106
117
  /** Handle POST /api/doctor-drop-broken-pins: the dashboard Health card's Drop broken pins
107
118
  * button — the same dropBrokenPins `tumwater doctor --drop-broken-pins` runs, answering its
108
119
  * lines joined with "; ". `ok` is false when any pin could not be dropped. Same body discipline
109
120
  * as /api/kill-orphans; `drop` is injectable so a test need not touch git. */
110
121
  export async function handleDropBrokenPins(req, res, root, drop = dropBrokenPins) {
111
- const body = await readPostBody(req, res, "{}");
112
- if (!body)
113
- return;
114
- const result = await drop(root);
115
- sendJson(res, 200, { ok: result.failed.length === 0, message: brokenPinDropLines(result).join("; ") });
122
+ return runLinesAction(req, res, root, drop, (r) => r.failed.length === 0, brokenPinDropLines);
116
123
  }
117
124
  /** Handle POST /api/doctor-retire-leftovers: the dashboard Health card's Retire leftovers
118
125
  * button — the same retireLeftovers `tumwater doctor --retire-leftovers` runs (with the loaded
@@ -120,11 +127,7 @@ export async function handleDropBrokenPins(req, res, root, drop = dropBrokenPins
120
127
  * retire was refused. Same body discipline as /api/kill-orphans; `retire` is injectable so a
121
128
  * test need not touch git. */
122
129
  export async function handleRetireLeftovers(req, res, root, retire = (r) => retireLeftovers(r, loadConfigSafe(r).config)) {
123
- const body = await readPostBody(req, res, "{}");
124
- if (!body)
125
- return;
126
- const report = await retire(root);
127
- sendJson(res, 200, { ok: report.failed.length === 0, message: leftoverRetireLines(report).join("; ") });
130
+ return runLinesAction(req, res, root, retire, (r) => r.failed.length === 0, leftoverRetireLines);
128
131
  }
129
132
  /** Handle POST /api/doctor-commit-backlog: the dashboard Health card's Commit backlog button —
130
133
  * the same commitUncommittedBacklog `tumwater doctor --commit-backlog` runs, answering its
@@ -177,11 +180,7 @@ export async function handleArchiveBacklog(req, res, root, archive = (r) => arch
177
180
  * hold the merge lock or touch the registry. resyncRootInstall never throws, so a lock/blog
178
181
  * failure lands as the failure line, not a 500. */
179
182
  export async function handleResyncInstall(req, res, root, resync = resyncRootInstall) {
180
- const body = await readPostBody(req, res, "{}");
181
- if (!body)
182
- return;
183
- const r = await resync(root);
184
- sendJson(res, 200, { ok: r.ok, message: rootInstallResyncLines(r).join("; ") });
183
+ return runLinesAction(req, res, root, resync, (r) => r.ok, rootInstallResyncLines);
185
184
  }
186
185
  /** Handle POST /api/abort: the dashboard's per-row abort control — the same marker-writing
187
186
  * core `tumwater abort` calls (requestAbort). The role is required and validated like
@@ -139,14 +139,20 @@ export async function handlePromptRole(req, res, root) {
139
139
  ], "\n");
140
140
  sendJson(res, 200, { ok: true, message });
141
141
  }
142
- /** The file-addressed prompt handlers' shared role prologue: validate the optional role
143
- * (400 sent on a present-but-invalid one and null returned, so the caller stops) and default
144
- * an absent role to the director's queue. The same optional-role rule handlePromptCancel,
145
- * handlePromptEdit and handlePromptTop state once instead of a guard-plus-default pair. */
146
- function promptFileRole(body, res, root) {
142
+ /** The file-addressed prompt handlers' shared role+file prologue: the optional role (400 on
143
+ * a present-but-invalid one, absent defaulting to the director's queue) and the queue-file
144
+ * name (400 on a bad one) validate exactly like /api/transcript and /api/prompt-cancel,
145
+ * with nothing read on disk, and return the resolved role and file — or null when a 400 was
146
+ * sent (the caller stops). handlePromptCancel, handlePromptEdit and handlePromptTop state the
147
+ * guard-plus-default pair once here instead of at each call site, so the three endpoints
148
+ * cannot drift on which role or file they accept. */
149
+ function promptFileTarget(body, res, root) {
147
150
  if (body.role !== undefined && rejectBadRole(root, res, body.role))
148
151
  return null;
149
- return body.role === undefined ? DIRECTOR_ROLE : body.role;
152
+ const role = body.role === undefined ? DIRECTOR_ROLE : body.role;
153
+ if (rejectBadQueueFileName(res, body.file))
154
+ return null;
155
+ return { role, file: body.file };
150
156
  }
151
157
  /** Handle POST /api/prompt-cancel: retract one queued prompt addressed by its queue file —
152
158
  * the dashboard's per-row cancel affordance, the file-addressed twin of the CLI's
@@ -160,12 +166,10 @@ export async function handlePromptCancel(req, res, root) {
160
166
  const body = await readPostBody(req, res, '{"role": "feature", "file": "<stamp>-<seq>-<pid>.md"}');
161
167
  if (!body)
162
168
  return;
163
- const role = promptFileRole(body, res, root);
164
- if (role === null)
169
+ const target = promptFileTarget(body, res, root);
170
+ if (target === null)
165
171
  return;
166
- if (rejectBadQueueFileName(res, body.file))
167
- return;
168
- const outcome = cancelQueuedFile(root, role, body.file);
172
+ const outcome = cancelQueuedFile(root, target.role, target.file);
169
173
  sendJson(res, 200, outcome.status === "cancelled"
170
174
  ? { ok: true, status: "cancelled", preview: promptPreview(outcome.text) }
171
175
  : OK_GONE);
@@ -186,23 +190,21 @@ export async function handlePromptEdit(req, res, root) {
186
190
  const body = await readPostBody(req, res, '{"role": "qa", "file": "<stamp>.md", "text": "..."}');
187
191
  if (!body)
188
192
  return;
189
- const role = promptFileRole(body, res, root);
190
- if (role === null)
193
+ const target = promptFileTarget(body, res, root);
194
+ if (target === null)
191
195
  return;
192
- if (rejectBadQueueFileName(res, body.file))
193
- return;
194
- const fields = checkedPromptFields(res, body, role);
196
+ const fields = checkedPromptFields(res, body, target.role);
195
197
  if (!fields)
196
198
  return;
197
199
  // Image-input gate (plans/image-input.md "Attachments", part 5/5): an image edited into
198
200
  // the queued prompt answers 400 naming the fix when the target role cannot read it (and no
199
201
  // tier reroutes it) — the same gate submitRolePrompt enforces — before anything is written.
200
202
  // The edit handler does not flash a reroute notice, so it ignores the helper's return.
201
- if (promptImageGate(res, root, role, (fields.images?.length ?? 0) > 0) === null)
203
+ if (promptImageGate(res, root, target.role, (fields.images?.length ?? 0) > 0) === null)
202
204
  return;
203
205
  let outcome;
204
206
  try {
205
- outcome = editQueuedFile(root, role, body.file, fields.text, fields.images);
207
+ outcome = editQueuedFile(root, target.role, target.file, fields.text, fields.images);
206
208
  }
207
209
  catch (err) {
208
210
  badRequest(res, errorMessage(err));
@@ -225,14 +227,12 @@ export async function handlePromptTop(req, res, root) {
225
227
  const body = await readPostBody(req, res, '{"role": "qa", "file": "<stamp>.md"}');
226
228
  if (!body)
227
229
  return;
228
- const role = promptFileRole(body, res, root);
229
- if (role === null)
230
- return;
231
- if (rejectBadQueueFileName(res, body.file))
230
+ const target = promptFileTarget(body, res, root);
231
+ if (target === null)
232
232
  return;
233
233
  let outcome;
234
234
  try {
235
- outcome = topQueuedFile(root, role, body.file);
235
+ outcome = topQueuedFile(root, target.role, target.file);
236
236
  }
237
237
  catch (err) {
238
238
  badRequest(res, errorMessage(err));
@@ -9,7 +9,7 @@ import { collectReport } from "../report/report-data.js";
9
9
  import { collectReportSince } from "../report/report-since-data.js";
10
10
  import { collectFailureReport, collectFailureReportSince } from "../failure/failure-data.js";
11
11
  import { renderFailureMarkdown } from "../failure/failure-render.js";
12
- import { renderSinceReportMarkdown } from "../report/report-render.js";
12
+ import { renderSinceReportMarkdown } from "../report/report-since-render.js";
13
13
  import { loadQuarantine } from "../review/known-flakes.js";
14
14
  import { quarantineRows, quarantineViewRows } from "../review/quarantine-render.js";
15
15
  import { quarantineWarning } from "../baseline/main-guardrails.js";
@@ -179,58 +179,85 @@ export function runRowsOnModels(rows, models) {
179
179
  * readEvents returns them), filtered to `role` when given, newest first. Pure: the CLI, the
180
180
  * GUI, and the tests share this collector, and none of them writes anything. */
181
181
  export function runRows(events, limit, role, join) {
182
- const wanted = role === null ? null : new Set(asList(role));
183
- const scoped = wanted === null ? events : events.filter((e) => wanted.has(e.source));
184
- // The start pairing is the shared helper (events.ts), so history's dash-on-unpaired rule
185
- // and the digest's fold cannot drift into different notions of a run's span.
186
- const starts = runStartMap(scoped);
187
- const models = runStartModelMap(scoped);
188
- // The merge bookkeeping resolveQueuedResult joins over: per role, the merge_queued pins
189
- // (oldest first) and the merge_done/merge_failed outcomes (oldest first). Both ride the role
190
- // filter with their run, so the scoped events hold them exactly when they hold the row.
191
- // A since-shaped scan hands a WIDER join read instead (readRunRowsSince): its cutoff cut
192
- // the pins of runs that span it, so the maps read the unfiltered-by-cutoff evidence and
193
- // resolveQueuedResult guards each claim on the row's run_start being whole in that read.
194
- const joinScoped = join
195
- ? wanted === null
196
- ? join.events
197
- : join.events.filter((e) => wanted.has(e.source))
198
- : scoped;
199
- const joinStarts = join ? runStartMap(joinScoped) : starts;
200
- const joinModels = join ? runStartModelMap(joinScoped) : models;
201
- const floorTs = join ? join.floorTs : null;
202
- const { mergeQueuedByRunner, outcomeByRunner } = bucketMergeEvents(joinScoped);
203
- const claimTop = new Map();
182
+ const wanted = roleWantSet(role);
183
+ const scoped = scopedEvents(events, wanted);
184
+ const evidence = runRowEvidence(scoped, join, wanted);
204
185
  const rows = [];
205
186
  for (let i = scoped.length - 1; i >= 0 && rows.length < limit; i--) {
206
187
  const e = scoped[i];
207
188
  if (e?.type !== "run_end")
208
189
  continue;
209
- const rawResult = outcomeText(e);
210
- const result = rawResult === "queued"
211
- ? (resolveQueuedResult(e, mergeQueuedByRunner, outcomeByRunner, claimTop, joinStarts, floorTs)
212
- ?.result ?? rawResult)
213
- : rawResult;
214
- const usage = eventUsage(e);
215
- const peak = nonNegativeNumber(e.peakContextTokens, 0);
216
- rows.push({
217
- ts: e.ts,
218
- time: formatTimestamp(e.ts),
219
- tokens: usage.tokens,
220
- costUsd: usage.costUsd,
221
- ...(peak > 0 ? { peakContextTokens: peak } : {}),
222
- ...(e.cutOff === true ? { cutOff: true } : {}),
223
- role: eventRole(e),
224
- run: eventRun(e),
225
- result,
226
- model: runStartModel(e, join ? joinModels : models),
227
- durationMs: runSpanMs(e, starts),
228
- usage: usageText(e),
229
- detail: squash(textOr(e.summary, textOr(e.error, "")), DETAIL_MAX),
230
- });
190
+ rows.push(runRowFromEnd(e, evidence));
231
191
  }
232
192
  return rows;
233
193
  }
194
+ /** The role the scan is filtered to, as a direct set lookup: null means no filter (every
195
+ * event is wanted); otherwise the role list folded into a Set, so both the row scan and the
196
+ * merge-join read test membership in one `has`. */
197
+ function roleWantSet(role) {
198
+ return role === null ? null : new Set(asList(role));
199
+ }
200
+ /** The `events` held by the scan: all of them when `wanted` is null (no role filter), else
201
+ * those whose `source` the role set holds — the one role filter, used for both the row scan
202
+ * and the join read, so the two cannot drift. */
203
+ function scopedEvents(events, wanted) {
204
+ return wanted === null ? events : events.filter((e) => wanted.has(e.source));
205
+ }
206
+ /** Build the row scan's evidence from the scoped events and an optional wider join read. The
207
+ * start pairing is the shared helper (events.ts), so history's dash-on-unpaired rule and the
208
+ * digest's fold cannot drift into different notions of a run's span. The merge bookkeeping
209
+ * resolveQueuedResult joins over: per role, the merge_queued pins (oldest first) and the
210
+ * merge_done/merge_failed outcomes (oldest first). Both ride the role filter with their run,
211
+ * so the scoped events hold them exactly when they hold the row.
212
+ * A since-shaped scan hands a WIDER join read instead (readRunRowsSince): its cutoff cut the
213
+ * pins of runs that span it, so the maps read the unfiltered-by-cutoff evidence and
214
+ * resolveQueuedResult guards each claim on the row's run_start being whole in that read. */
215
+ function runRowEvidence(scoped, join, wanted) {
216
+ const starts = runStartMap(scoped);
217
+ const models = runStartModelMap(scoped);
218
+ const joinScoped = join ? scopedEvents(join.events, wanted) : scoped;
219
+ const { mergeQueuedByRunner, outcomeByRunner } = bucketMergeEvents(joinScoped);
220
+ return {
221
+ starts,
222
+ joinStarts: join ? runStartMap(joinScoped) : starts,
223
+ joinModels: join ? runStartModelMap(joinScoped) : models,
224
+ floorTs: join ? join.floorTs : null,
225
+ mergeQueuedByRunner,
226
+ outcomeByRunner,
227
+ claimTop: new Map(),
228
+ };
229
+ }
230
+ /** One run_end's result label: the run's own unless it is `queued`, in which case the queued
231
+ * row is resolved through its change's merge evidence (resolveQueuedResult's conservative
232
+ * rule) and falls back to the raw `queued` label when the merge is still in the pipeline or
233
+ * the evidence is untrustworthy. */
234
+ function queuedRowResult(e, rawResult, evidence) {
235
+ if (rawResult !== "queued")
236
+ return rawResult;
237
+ return (resolveQueuedResult(e, evidence.mergeQueuedByRunner, evidence.outcomeByRunner, evidence.claimTop, evidence.joinStarts, evidence.floorTs)?.result ?? rawResult);
238
+ }
239
+ /** Assemble one row from its `run_end` and the shared evidence: the raw usage numbers folded
240
+ * into the payload, the positive-only peak and cut-off flags, the join-or-plain model map,
241
+ * the paired duration, and the detail squash. */
242
+ function runRowFromEnd(e, evidence) {
243
+ const usage = eventUsage(e);
244
+ const peak = nonNegativeNumber(e.peakContextTokens, 0);
245
+ return {
246
+ ts: e.ts,
247
+ time: formatTimestamp(e.ts),
248
+ tokens: usage.tokens,
249
+ costUsd: usage.costUsd,
250
+ ...(peak > 0 ? { peakContextTokens: peak } : {}),
251
+ ...(e.cutOff === true ? { cutOff: true } : {}),
252
+ role: eventRole(e),
253
+ run: eventRun(e),
254
+ result: queuedRowResult(e, outcomeText(e), evidence),
255
+ model: runStartModel(e, evidence.joinModels),
256
+ durationMs: runSpanMs(e, evidence.starts),
257
+ usage: usageText(e),
258
+ detail: squash(textOr(e.summary, textOr(e.error, "")), DETAIL_MAX),
259
+ };
260
+ }
234
261
  /** The last `limit` completed runs read from root's event log, filtered to `role` when
235
262
  * given (newest first) — the scan cmdHistory and the GUI's handleHistory share, so the two
236
263
  * surfaces cannot drift. A window twice the ask plus slack: run_start lines and unrelated
@@ -3,7 +3,7 @@ import { groupBy } from "../collections.js";
3
3
  /** The merge bookkeeping resolveQueuedResult joins over: per role, the `merge_queued` pins
4
4
  * and the `merge_done`/`merge_failed` outcomes, each list oldest-first (the scan order). Exported
5
5
  * as one helper with exactly three call sites — readRunRows' history-row join, the failure
6
- * digest's time-and-spend fold (time-spend.ts), and run-detail-data.ts — so the collectors
6
+ * digest's time-and-spend fold (failure-time-spend.ts), and run-detail-data.ts — so the collectors
7
7
  * cannot disagree about which events feed the join or in what order they sit: a fourth
8
8
  * merge-bookkeeping event
9
9
  * type, or a changed order rule, is edited once here. Callers bucket only the events they
@@ -58,41 +58,63 @@ export function bucketMergeEvents(events) {
58
58
  * text plus the pin and outcome of the attempt that produced it (and that pin's sha), or null
59
59
  * to keep the raw label. Pure apart from the cursor it owns. */
60
60
  export function resolveQueuedResult(end, mergeQueuedByRunner, outcomeByRunner, claimTop, joinStarts, floorTs) {
61
- // Interlock relief (plans/interlock-relief.md): a held run's commit stayed on the branch and
62
- // enqueued nothing, so its queued label names the held commit, not a merge this run made.
63
- // Returning null keeps that row's raw queued label and, crucially, leaves the earlier change's
64
- // merge pins unclaimed, so the verdict that belongs to the queued entry is not credited to the
65
- // held run. One guard covers all three call sites (history-data.ts, time-spend.ts,
66
- // run-detail-data.ts).
67
- if (end.heldOnBranch === true)
61
+ if (!queuedRowEvidenceWhole(end, joinStarts, floorTs))
68
62
  return null;
69
- if (floorTs !== null) {
70
- const startTs = joinStarts.get(`${end.source}#${end.run}`);
71
- if (startTs !== undefined && startTs < floorTs)
72
- return null;
73
- }
74
63
  const queuedList = mergeQueuedByRunner.get(end.source);
75
64
  if (!queuedList || queuedList.length === 0)
76
65
  return null;
77
- let top = claimTop.get(end.source) ?? queuedList.length - 1;
78
- while (top >= 0 && queuedList[top].ts > end.ts)
79
- top--;
66
+ const top = claimQueuedPin(end, queuedList, claimTop);
80
67
  if (top < 0)
81
68
  return null;
82
- claimTop.set(end.source, top - 1);
83
69
  const outcomes = outcomeByRunner.get(end.source) ?? [];
84
- // A recovery run's row: the attempt it re-enqueued, not the change's final verdict, so the
85
- // final verdict shows once (on the authoring row) and each retriable failure once (here).
86
70
  const recovered = recoveredAttemptVerdict(queuedList, top, outcomes);
87
71
  if (recovered !== null)
88
72
  return recovered;
89
- // The authoring row: walk forward while the next pin is a recovery re-enqueue and resolve to
90
- // the last attempt's verdict. `findLast` picks the newest same-sha outcome so an un-rebased
91
- // retry (recovery re-enqueues the identical sha, logged by both pins) does not re-match the
92
- // first attempt's outcome inside the recovery pin's walk. The returned pin/outcome ride the
93
- // result so a caller correlating sibling evidence (the failure digest's review-failure
94
- // message) can key on THIS attempt's position, not a sha a pre-review rebase may have moved
95
- // (BUGS.md 2026-10-10).
73
+ return authoringRowVerdict(queuedList, outcomes, top);
74
+ }
75
+ /** True when the row's join evidence is whole enough to resolve it: neither the interlock-relief
76
+ * held-run guard nor the since-window floor keeps the row's raw queued label. Interlock relief
77
+ * (plans/interlock-relief.md): a held run's commit stayed on the branch and enqueued nothing, so
78
+ * its queued label names the held commit, not a merge this run made. Returning false keeps that
79
+ * row's raw queued label and, crucially, leaves the earlier change's merge pins unclaimed, so the
80
+ * verdict that belongs to the queued entry is not credited to the held run. One guard covers all
81
+ * three call sites (history-data.ts, failure-time-spend.ts, run-detail-data.ts). When `floorTs` (a
82
+ * since-window's cutoff, null in the count view) sits after the run_start's ts, the row's join
83
+ * evidence is not whole in the windowed read and the row keeps its raw queued label. A MISSING
84
+ * start needs no guard — it joins, because rotation drops a prefix, so every pin older than the
85
+ * lost start is lost with it and nothing can be mis-claimed (and a skipped run has no start at all
86
+ * but is never queued). */
87
+ function queuedRowEvidenceWhole(end, joinStarts, floorTs) {
88
+ if (end.heldOnBranch === true)
89
+ return false;
90
+ if (floorTs !== null) {
91
+ const startTs = joinStarts.get(`${end.source}#${end.run}`);
92
+ if (startTs !== undefined && startTs < floorTs)
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+ /** Claim the newest unclaimed merge_queued pin at or before the row's end and consume it from the
98
+ * caller's `claimTop` cursor. The cursor (mutated in place) walks the pin list newest-first and
99
+ * hands each queued run the newest unclaimed pin at or before its end, so several queued runs
100
+ * cannot claim one pin. Returns the pin's index, or -1 when no pin is at or before the
101
+ * row's end. */
102
+ function claimQueuedPin(end, queuedList, claimTop) {
103
+ let top = claimTop.get(end.source) ?? queuedList.length - 1;
104
+ while (top >= 0 && queuedList[top].ts > end.ts)
105
+ top--;
106
+ if (top < 0)
107
+ return -1;
108
+ claimTop.set(end.source, top - 1);
109
+ return top;
110
+ }
111
+ /** The authoring row: walk forward while the next pin is a recovery re-enqueue and resolve to the
112
+ * last attempt's verdict. `findLast` picks the newest same-sha outcome so an un-rebased retry
113
+ * (recovery re-enqueues the identical sha, logged by both pins) does not re-match the first
114
+ * attempt's outcome inside the recovery pin's walk. The returned pin/outcome ride the result so a
115
+ * caller correlating sibling evidence (the failure digest's review-failure message) can key on
116
+ * THIS attempt's position, not a sha a pre-review rebase may have moved (BUGS.md 2026-10-10). */
117
+ function authoringRowVerdict(queuedList, outcomes, top) {
96
118
  let idx = top;
97
119
  let resolved = null;
98
120
  for (;;) {
@@ -147,7 +169,7 @@ function recoveredAttemptVerdict(queuedList, idx, outcomes) {
147
169
  return verdict === null || endsChange(verdict.result) ? null : verdict;
148
170
  }
149
171
  /** True when a pin re-enqueues the previous attempt's kept pin rather than starting a fresh
150
- * change. `recoverLeftover` (src/runner/leftover.ts) is the one `merge_queued` producer whose
172
+ * change. `recoverLeftover` (runner-leftover.ts) is the one `merge_queued` producer whose
151
173
  * summary starts with this label, so a fresh change's pin after a conflict-cap discard ends
152
174
  * `resolveQueuedResult`'s walk. */
153
175
  function isRecoveryPin(pin) {