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
@@ -39,56 +39,99 @@ export function emptyGrepCriteria(body) {
39
39
  * shell syntax (an unquoted `|;&<>$()` or backtick) or leaves a quote open. Nothing runs through
40
40
  * a shell, so `*` stays literal. */
41
41
  export function grepArgv(cmd) {
42
- const words = [];
43
- let cur = "";
44
- let inWord = false;
45
- let quote = null;
46
- for (let i = 0; i < cmd.length; i++) {
47
- const c = cmd[i];
48
- if (quote === "'") {
49
- if (c === "'")
50
- quote = null;
51
- else
52
- cur += c;
53
- }
54
- else if (quote === '"') {
55
- const next = cmd[i + 1];
56
- if (c === "\\" && (next === '"' || next === "\\")) {
57
- cur += next;
58
- i++;
59
- }
60
- else if (c === '"')
61
- quote = null;
62
- else
63
- cur += c;
64
- }
65
- else if (c === "'" || c === '"') {
66
- quote = c;
67
- inWord = true;
68
- }
69
- else if (c === "\\") {
70
- if (i + 1 < cmd.length)
71
- cur += cmd[++i];
72
- inWord = true;
73
- }
74
- else if (/[|;&<>$()`]/.test(c)) {
75
- return undefined;
76
- }
77
- else if (/\s/.test(c)) {
78
- if (inWord)
79
- words.push(cur);
80
- cur = "";
81
- inWord = false;
42
+ const words = splitShellWords(cmd);
43
+ return words === undefined ? undefined : grepInvocation(words);
44
+ }
45
+ /** Take one character at `i` inside a single-quoted run (`s.quote === "'"`): a `'` closes the
46
+ * quote, anything else stays literal. Returns the index after the character. Inside single
47
+ * quotes everything is literal. */
48
+ function takeSingleQuoted(cmd, i, s) {
49
+ const c = cmd[i];
50
+ if (c === "'")
51
+ s.quote = null;
52
+ else
53
+ s.cur += c;
54
+ return i + 1;
55
+ }
56
+ /** Take one character at `i` inside a double-quoted run (`s.quote === '"'`): a `\\`-escaped
57
+ * `"` or `\\` appends the escaped character and consumes two characters, a `"` closes the
58
+ * quote, anything else stays literal. Returns the index after the consumed character(s). Double
59
+ * quotes honour only `\"` and `\\`. */
60
+ function takeDoubleQuoted(cmd, i, s) {
61
+ const c = cmd[i];
62
+ const next = cmd[i + 1];
63
+ if (c === "\\" && (next === '"' || next === "\\")) {
64
+ s.cur += next;
65
+ return i + 2;
66
+ }
67
+ if (c === '"')
68
+ s.quote = null;
69
+ else
70
+ s.cur += c;
71
+ return i + 1;
72
+ }
73
+ /** Take one character at `i` outside any quote, or return `undefined` when it is refused shell
74
+ * syntax: an unquoted `'`/`"` opens the quote and marks a word, an unquoted `\\` appends the
75
+ * next character (if any) and marks a word, an unquoted `|;&<>$()` or backtick refuses the
76
+ * command, whitespace ends a word (pushing `cur` when one is open) and resets the buffer, and
77
+ * anything else appends and marks a word. Returns the index after the consumed character(s), or
78
+ * `undefined` for the shell refusal. */
79
+ function takeUnquoted(cmd, i, s) {
80
+ const c = cmd[i];
81
+ if (c === "'" || c === '"') {
82
+ s.quote = c;
83
+ s.inWord = true;
84
+ return i + 1;
85
+ }
86
+ if (c === "\\") {
87
+ if (i + 1 < cmd.length) {
88
+ s.cur += cmd[i + 1];
89
+ s.inWord = true;
90
+ return i + 2;
82
91
  }
92
+ s.inWord = true;
93
+ return i + 1;
94
+ }
95
+ if (/[|;&<>$()`]/.test(c))
96
+ return undefined;
97
+ if (/\s/.test(c)) {
98
+ if (s.inWord)
99
+ s.words.push(s.cur);
100
+ s.cur = "";
101
+ s.inWord = false;
102
+ return i + 1;
103
+ }
104
+ s.cur += c;
105
+ s.inWord = true;
106
+ return i + 1;
107
+ }
108
+ /** Walk the whole command, dispatching each character to the quote-aware helper, and reject an
109
+ * unterminated quote. Returns the word list. */
110
+ function splitShellWords(cmd) {
111
+ const s = { words: [], cur: "", inWord: false, quote: null };
112
+ let i = 0;
113
+ while (i < cmd.length) {
114
+ if (s.quote === "'")
115
+ i = takeSingleQuoted(cmd, i, s);
116
+ else if (s.quote === '"')
117
+ i = takeDoubleQuoted(cmd, i, s);
83
118
  else {
84
- cur += c;
85
- inWord = true;
119
+ const next = takeUnquoted(cmd, i, s);
120
+ if (next === undefined)
121
+ return undefined;
122
+ i = next;
86
123
  }
87
124
  }
88
- if (quote !== null)
125
+ if (s.quote !== null)
89
126
  return undefined;
90
- if (inWord)
91
- words.push(cur);
127
+ if (s.inWord)
128
+ s.words.push(s.cur);
129
+ return s.words;
130
+ }
131
+ /** The shape pick: what invocation does this word list name? `git grep …` gives file `git` and
132
+ * the args after `git`; `grep …` gives file `grep` and the args after `grep`; anything else is
133
+ * not a grep we run. */
134
+ function grepInvocation(words) {
92
135
  if (words[0] === "git" && words[1] === "grep")
93
136
  return { file: "git", args: words.slice(1) };
94
137
  if (words[0] === "grep")
@@ -46,7 +46,7 @@ export const ERROR_STREAK_WARN = 3;
46
46
  export function errorStreakAtWarn(s) {
47
47
  return (s.consecutiveErrors ?? 0) >= ERROR_STREAK_WARN;
48
48
  }
49
- /** Consecutive failed runs after which the error-streak circuit breaker (src/gates/streak-gate.ts)
49
+ /** Consecutive failed runs after which the error-streak circuit breaker (src/gates/streak-gates.ts)
50
50
  * auto-pauses the role through the per-role pause marker — the same act-on-it escalation the
51
51
  * budget gate and the pause gates perform, applied to a role failing on its own cause. Ten,
52
52
  * not the warn bar: at 3 the failure is diagnosed but possibly transient, and by run 4 the
@@ -68,6 +68,13 @@ export const CUT_OFF_RESUME_LIMIT = 3;
68
68
  * consumed 44% of the team over two days). Mirrors CUT_OFF_RESUME_LIMIT; the streak itself
69
69
  * keeps counting (RunnerState.quietKillStreak) so one warning per episode can be raised. */
70
70
  export const QUIET_KILL_RESUME_LIMIT = 3;
71
+ /** Bump a streak counter to `current + 1` when `hit`, and reset it to 0 otherwise — the one
72
+ * home of the cut-off, quiet-kill and no-change streaks' shared shape in applyRunOutcome, so
73
+ * the three count identically. (The consecutive-error streak does NOT share it: it resumes
74
+ * from a `queued`-ended run rather than resetting, so it keeps its own longer arm.) */
75
+ function bumpOrResetStreak(current, hit) {
76
+ return hit ? (current ?? 0) + 1 : 0;
77
+ }
71
78
  /** Record a finished run on the role's state and schedule its next run from the outcome.
72
79
  * Split out of RoleRunner.run() (runner.ts) so the scheduling policy — which outcomes retry
73
80
  * promptly, which back off and on which ladder, how cut-off resumes are bounded — sits with
@@ -78,11 +85,10 @@ export const QUIET_KILL_RESUME_LIMIT = 3;
78
85
  export function applyRunOutcome(s, cfg, role, outcome) {
79
86
  s.running = false;
80
87
  s.parkedSince = undefined;
81
- s.parkedOn = undefined;
82
88
  // The cut-off streak counts EVERY consecutive run truncated at the context ceiling, past the
83
89
  // resume limit too: the next fresh run's prompt names how many attempts the window has eaten
84
90
  // (buildCutOffNote), so the count must not freeze at the limit. Any other outcome resets it.
85
- s.cutOffStreak = outcome.cutOff ? (s.cutOffStreak ?? 0) + 1 : 0;
91
+ s.cutOffStreak = bumpOrResetStreak(s.cutOffStreak, outcome.cutOff);
86
92
  // The error streak counts consecutive failed runs; any other result resets it
87
93
  // (BUGS.md 2026-09-15: 44 identical failures raised no alarm). A run whose leftover
88
94
  // recovery re-queued a pin its last merge failed counts too, even though the run
@@ -106,7 +112,7 @@ export function applyRunOutcome(s, cfg, role, outcome) {
106
112
  // The quiet-kill streak counts consecutive watchdog kills; any other result resets it.
107
113
  // run-finalize.ts warns once when it crosses QUIET_KILL_RESUME_LIMIT, and the branch below uses
108
114
  // it to bound how many times a starved session is resumed (BUGS.md 2026-09-18).
109
- s.quietKillStreak = outcome.result === "quiet_killed" ? (s.quietKillStreak ?? 0) + 1 : 0;
115
+ s.quietKillStreak = bumpOrResetStreak(s.quietKillStreak, outcome.result === "quiet_killed");
110
116
  // The no-change streak counts consecutive completed runs that ended `no_change` (the
111
117
  // resting state cell's ` · no change ×N` suffix — status-model.ts rolePhase). Any other
112
118
  // completed result resets it, so a role that merges between no-ops never shows a stale
@@ -115,7 +121,7 @@ export function applyRunOutcome(s, cfg, role, outcome) {
115
121
  // `quiet_killed`/`user_aborted` all reset it and only a `no_change` run grows it.
116
122
  // Observational only — never warns, unlike the error/quiet-kill streaks: a legitimately
117
123
  // idle maintenance role with an empty backlog can hold a long streak and that is expected.
118
- s.noChangeStreak = outcome.result === "no_change" ? (s.noChangeStreak ?? 0) + 1 : 0;
124
+ s.noChangeStreak = bumpOrResetStreak(s.noChangeStreak, outcome.result === "no_change");
119
125
  // The yield ring records the result beside the streaks: a merge or a counted empty
120
126
  // stretches the role's min-run gap (yieldMultiplier, read by isEligible), an error-class
121
127
  // result is skipped entirely — no evidence either way (yield-scaled clocks, PLANS.md).
@@ -329,7 +335,7 @@ export function applyMergeOutcome(s, result, change) {
329
335
  // leave the streak unchanged and keep the fast minimum-interval retry the queued run
330
336
  // scheduled. This merge outcome is the accumulation point (the authoring run ends
331
337
  // `queued`, which no longer resets the streak, see applyRunOutcome) — the same field the
332
- // warn, the breaker (src/gates/streak-gate.ts), and the error-storm observation
338
+ // warn, the breaker (src/gates/streak-gates.ts), and the error-storm observation
333
339
  // (src/team/team-polls.ts) already read. No new machinery, no new constants: the warn
334
340
  // bar and the breaker bar are ERROR_STREAK_WARN and ERROR_STREAK_BREAKER themselves.
335
341
  if (s.revision === undefined) {
@@ -46,45 +46,59 @@ export function readRunDetail(root, role, runNumber) {
46
46
  // same bound.
47
47
  const all = readEvents(root, HISTORY_SCAN_MAX_EVENTS);
48
48
  const events = all.filter((e) => e.source === role);
49
- // Newest run_start for this role+run: the block's lower bound.
50
- let startIdx = -1;
51
- for (let i = events.length - 1; i >= 0; i--) {
52
- const e = events[i];
53
- if (e.type === "run_start" && e.run === runNumber) {
54
- startIdx = i;
55
- break;
56
- }
57
- }
49
+ const startIdx = newestRunStartIndex(events, runNumber);
58
50
  if (startIdx < 0) {
59
51
  // The start is gone (rotation) or the run never ran. A surviving run_end for the same
60
52
  // number still answers — unpaired, undated at the front — else there is no such run here.
61
- for (let i = events.length - 1; i >= 0; i--) {
62
- const e = events[i];
63
- if (e.type === "run_end" && e.run === runNumber)
64
- return detailFrom(root, role, runNumber, all, events, [e], e, null);
65
- }
53
+ const loneEnd = newestRunEnd(events, runNumber);
54
+ if (loneEnd !== null)
55
+ return detailFrom(root, role, runNumber, all, events, [loneEnd], loneEnd, null);
66
56
  return null;
67
57
  }
68
58
  const start = events[startIdx];
69
- // The block's upper bound: the first run_end for this run after the start ends it; a
70
- // later run_start (this run's end lost to rotation) closes the block exclusive — the
71
- // events after it belong to the next run, not this one. No end and no next start reads as
72
- // in flight: everything since the start belongs to the open run.
73
- let end = null;
74
- let nextStart = null;
59
+ const { end, nextStart } = runBlockBounds(events, startIdx, runNumber);
60
+ const block = collectRunBlock(events, startIdx, end, nextStart);
61
+ return detailFrom(root, role, runNumber, all, events, block, end, start);
62
+ }
63
+ /** The newest run_start for this role+run — the block's lower bound — or -1 when none (the
64
+ * start rotated out or the run never ran). A backward scan: a counter reset can repeat a run
65
+ * number, and the operator asking by number means the most recent one. */
66
+ function newestRunStartIndex(events, runNumber) {
67
+ for (let i = events.length - 1; i >= 0; i--) {
68
+ const e = events[i];
69
+ if (e.type === "run_start" && e.run === runNumber)
70
+ return i;
71
+ }
72
+ return -1;
73
+ }
74
+ /** The newest surviving run_end for this role+run, or null. Read only on the no-start path:
75
+ * the start is gone (rotation) or the run never ran, but a surviving run_end still answers —
76
+ * unpaired, undated at the front — else there is no such run here. */
77
+ function newestRunEnd(events, runNumber) {
78
+ for (let i = events.length - 1; i >= 0; i--) {
79
+ const e = events[i];
80
+ if (e.type === "run_end" && e.run === runNumber)
81
+ return e;
82
+ }
83
+ return null;
84
+ }
85
+ /** The block's upper bound for the run opened by `startIdx`: the first run_end for this run
86
+ * after it ends the block; else a later run_start (this run's end lost to rotation) closes it
87
+ * exclusive — the events after belong to the next run, not this one. Neither afterwards reads
88
+ * as in flight: everything since the start belongs to the open run. */
89
+ function runBlockBounds(events, startIdx, runNumber) {
75
90
  for (let i = startIdx + 1; i < events.length; i++) {
76
91
  const e = events[i];
77
- if (e.type === "run_end" && e.run === runNumber) {
78
- end = e;
79
- break;
80
- }
81
- if (e.type === "run_start") {
82
- nextStart = e;
83
- break;
84
- }
92
+ if (e.type === "run_end" && e.run === runNumber)
93
+ return { end: e, nextStart: null };
94
+ if (e.type === "run_start")
95
+ return { end: null, nextStart: e };
85
96
  }
86
- // The block itself: the start event plus everything this role logged up to the bound —
87
- // inclusive at a paired end, exclusive at a next run's start.
97
+ return { end: null, nextStart: null };
98
+ }
99
+ /** The block itself: the start event plus everything this role logged up to the bound —
100
+ * inclusive at a paired end, exclusive at a next run's start. */
101
+ function collectRunBlock(events, startIdx, end, nextStart) {
88
102
  const block = [];
89
103
  for (let i = startIdx; i < events.length; i++) {
90
104
  const e = events[i];
@@ -92,19 +106,12 @@ export function readRunDetail(root, role, runNumber) {
92
106
  break;
93
107
  block.push(e);
94
108
  }
95
- return detailFrom(root, role, runNumber, all, events, block, end, start);
109
+ return block;
96
110
  }
97
- /** Fill the payload around a collected block: usage from the run_end via eventUsage/usageText,
98
- * duration via runSpanMs against the role's own start map (the shared pairing helper, so this
99
- * view and the history rows cannot drift on what a span is), events oldest-first. */
100
- function detailFrom(root, role, runNumber, all, scoped, block, end, start) {
101
- const usageNums = end === null ? { tokens: 0, costUsd: 0 } : eventUsage(end);
102
- // The run_end's prompt-token total and its cache-read subset, corrupt-field-guarded reads
103
- // (nonNegativeNumber, the same guard peakContextTokens applies) with the cache subset
104
- // clamped to the total so the rendered share never exceeds 100%; 0 on an in-flight run.
105
- const promptTokens = end === null ? 0 : nonNegativeNumber(end.promptTokens, 0);
106
- const cacheReadTokens = end === null ? 0 : Math.min(nonNegativeNumber(end.cacheReadTokens, 0), promptTokens);
107
- const preEditPromptTokens = end === null ? 0 : Math.min(nonNegativeNumber(end.preEditPromptTokens, 0), promptTokens);
111
+ /** The verdict a run reports: the `run_end`'s outcome text, resolved through its merge for a
112
+ * run that ended `queued` (its result becomes the merge's result and `merge` the merge_done/
113
+ * merge_failed event carrying it) — the same fresh claim cursor walk the history rows use. */
114
+ function runResultOf(end, scoped) {
108
115
  let result = end === null ? null : outcomeText(end);
109
116
  let merge = null;
110
117
  if (end !== null && result === "queued") {
@@ -117,42 +124,88 @@ function detailFrom(root, role, runNumber, all, scoped, block, end, start) {
117
124
  merge = resolved.outcome;
118
125
  }
119
126
  }
120
- const mergedEvent = mergedEventOf(result, merge, scoped, block);
121
- const detail = {
122
- role,
123
- run: runNumber,
127
+ return { result, merge };
128
+ }
129
+ /** The token and cost totals from the run_end; both zero on an in-flight run. */
130
+ function runUsageOf(end) {
131
+ if (end === null)
132
+ return { tokens: 0, costUsd: 0 };
133
+ const { tokens, costUsd } = eventUsage(end);
134
+ return { tokens, costUsd };
135
+ }
136
+ /** The run's start/end timestamps and its span: `startTs`/`endTs` null when rotation cut that
137
+ * side, `durationMs` null whenever the pair is incomplete (no fabricated span). */
138
+ function runTimesOf(start, end, scoped) {
139
+ return {
124
140
  startTs: start === null ? null : start.ts,
125
- model: typeof start?.model === "string" && start.model.length > 0 ? start.model : null,
126
141
  endTs: end === null ? null : end.ts,
127
142
  durationMs: end === null ? null : runSpanMs(end, runStartMap(scoped)),
128
- result,
129
- tokens: usageNums.tokens,
130
- costUsd: usageNums.costUsd,
131
- usage: end === null ? "" : usageText(end),
132
- // The peak is a corrupt-field-guarded read of the run_end (nonNegativeNumber, the same
133
- // guard promptStatsFor applies); the window comes from the shared seamContextWindowForRole —
134
- // same seam the role view and status table print — with loadConfigSafe falling back to
135
- // defaults, so a broken tumwater.json yields a null window rather than failing this read-only
136
- // view.
137
- peakContextTokens: end === null ? 0 : nonNegativeNumber(end.peakContextTokens, 0),
138
- // The run's prompt-token total and its cache-read subset, spread so a prompt-free or
139
- // in-flight run keeps today's exact payload (no prompt/cache keys) and a cache subset > 0.
143
+ };
144
+ }
145
+ /** The model the run's `run_start` named, or null when it carried none or sits outside the scan. */
146
+ function runModelOf(start) {
147
+ return typeof start?.model === "string" && start.model.length > 0 ? start.model : null;
148
+ }
149
+ /** The run's usage as its header line; empty when the run carries neither total. */
150
+ function runUsageTextOf(end) {
151
+ return end === null ? "" : usageText(end);
152
+ }
153
+ /** The run_end's prompt-token total and its cache-read and pre-edit subsets, corrupt-field-
154
+ * guarded reads (nonNegativeNumber) with the subsets clamped to the total so the rendered share
155
+ * never exceeds 100%; all three keys absent on a prompt-free or in-flight run. */
156
+ function runPromptFields(end) {
157
+ const promptTokens = end === null ? 0 : nonNegativeNumber(end.promptTokens, 0);
158
+ const cacheReadTokens = end === null ? 0 : Math.min(nonNegativeNumber(end.cacheReadTokens, 0), promptTokens);
159
+ const preEditPromptTokens = end === null ? 0 : Math.min(nonNegativeNumber(end.preEditPromptTokens, 0), promptTokens);
160
+ return {
140
161
  ...(promptTokens > 0 ? { promptTokens } : {}),
141
162
  ...(cacheReadTokens > 0 ? { cacheReadTokens } : {}),
142
163
  ...(preEditPromptTokens > 0 ? { preEditPromptTokens } : {}),
143
- // The run_end's ceiling-truncation flag, spread so an in-flight, older, or normal run
144
- // keeps today's exact payload (no cutOff key).
145
- ...(end !== null && end.cutOff === true ? { cutOff: true } : {}),
164
+ };
165
+ }
166
+ /** The run_end's ceiling-truncation flag, absent when the run did not cut off. */
167
+ function runCutOffField(end) {
168
+ return end !== null && end.cutOff === true ? { cutOff: true } : {};
169
+ }
170
+ /** The run's peak context, a corrupt-field-guarded read of the run_end; 0 when in flight. */
171
+ function runPeakContextOf(end) {
172
+ return end === null ? 0 : nonNegativeNumber(end.peakContextTokens, 0);
173
+ }
174
+ /** The end-of-run → on-main landing latency (the landing `merged` event's ts minus the run
175
+ * end's), absent without both an end and a landing event — or a landing before the end, or a
176
+ * negative difference. */
177
+ function runMergeLatencyField(end, mergedEvent) {
178
+ return end !== null && mergedEvent !== null && mergedEvent.ts - end.ts >= 0
179
+ ? { mergeAfterRunMs: mergedEvent.ts - end.ts }
180
+ : {};
181
+ }
182
+ /** Fill the payload around a collected block: each concern resolves in its own named helper
183
+ * (runResultOf, runUsageOf, runTimesOf, runModelOf, runUsageTextOf, runPromptFields,
184
+ * runCutOffField, runPeakContextOf, runMergeLatencyField), and this assembles their results.
185
+ * Events stay oldest-first; usage/duration pair through the shared event-read helpers so this
186
+ * view and the history rows cannot drift on what a span is. */
187
+ function detailFrom(root, role, runNumber, all, scoped, block, end, start) {
188
+ const { result, merge } = runResultOf(end, scoped);
189
+ const mergedEvent = mergedEventOf(result, merge, scoped, block);
190
+ const detail = {
191
+ role,
192
+ run: runNumber,
193
+ ...runTimesOf(start, end, scoped),
194
+ model: runModelOf(start),
195
+ ...runUsageOf(end),
196
+ usage: runUsageTextOf(end),
197
+ result,
198
+ // The window comes from the shared seamContextWindowForRole — same seam the role view and
199
+ // status table print — with loadConfigSafe falling back to defaults, so a broken
200
+ // tumwater.json yields a null window rather than failing this read-only view.
201
+ peakContextTokens: runPeakContextOf(end),
202
+ ...runPromptFields(end),
203
+ ...runCutOffField(end),
146
204
  contextWindow: seamContextWindowForRole(loadConfigSafe(root).config ?? defaultConfig(), role, root),
147
205
  events: block,
148
206
  merge,
149
207
  mergedSha: mergedShaOf(result, merge, scoped, block),
150
- // The end-of-run → on-main landing latency (the landing `merged` event's ts minus the run
151
- // end's), spread so a run without both an end and a landing event — or a landing before its
152
- // end, or a negative difference — keeps today's exact payload (no key).
153
- ...(end !== null && mergedEvent !== null && mergedEvent.ts - end.ts >= 0
154
- ? { mergeAfterRunMs: mergedEvent.ts - end.ts }
155
- : {}),
208
+ ...runMergeLatencyField(end, mergedEvent),
156
209
  reverted: null,
157
210
  };
158
211
  detail.reverted = revertedOf(detail, all);
@@ -31,30 +31,41 @@ export function runDetailStat(root, d) {
31
31
  export function runDetailPatch(root, d) {
32
32
  return runCommitShow(root, runCommitSha(d), "--patch");
33
33
  }
34
- /** The human view of a RunDetail: one summary line — result (or the in-flight/unpaired
35
- * marker), duration, usage, and the model the run started on when its `run_start` carried one
36
- * (after the usage, before the pinned-commit sha), plus the pinned commit sha when the block's
37
- * `merge_queued`/`merge_done` event carries one — then each event through formatEvent, the
38
- * shared renderer `logs` and the
39
- * TUI use, so the trail reads exactly like the feed it came from. A queued run whose merge
40
- * has finished shows that verdict in the header and ends with the merge's outcome line; a
41
- * leftover-recovery run's verdict is the failed attempt it re-queued (resolveQueuedResult),
42
- * logged before the run began, so that outcome line leads the trail instead — the trail stays
43
- * in time order. */
44
- export function renderRunDetail(d) {
45
- const summary = [];
34
+ /** The run's result cell, always present: the outcome label when the run finished, the
35
+ * in-flight/unpaired marker when it has not. Owns the first summary cell. */
36
+ function runResultCell(d) {
46
37
  if (d.endTs === null)
47
- summary.push(d.startTs === null ? "unpaired" : "in flight");
48
- else
49
- summary.push(d.result ? eventTypeLabel(d.result) : "?");
38
+ return d.startTs === null ? "unpaired" : "in flight";
39
+ return d.result ? eventTypeLabel(d.result) : "?";
40
+ }
41
+ /** The run's cut-off cell, the ceiling-truncation marker straight after the result cell. It
42
+ * only rides a completed end (cutOff is set from the run_end), so it can never follow in
43
+ * flight/unpaired. */
44
+ function runCutOffCell(d) {
50
45
  // The ceiling-truncation marker, straight after the result cell. It only rides a
51
46
  // completed end (cutOff is set from the run_end), so it can never follow in flight/unpaired.
52
- if (d.cutOff === true)
53
- summary.push("cut off at the context ceiling");
54
- if (d.durationMs !== null)
55
- summary.push(shortSpanPhrase(d.durationMs));
56
- if (d.usage !== "")
57
- summary.push(d.usage);
47
+ if (d.cutOff !== true)
48
+ return null;
49
+ return "cut off at the context ceiling";
50
+ }
51
+ /** The run's duration cell. */
52
+ function runDurationCell(d) {
53
+ if (d.durationMs === null)
54
+ return null;
55
+ return shortSpanPhrase(d.durationMs);
56
+ }
57
+ /** The run's usage cell. */
58
+ function runUsageCell(d) {
59
+ if (d.usage === "")
60
+ return null;
61
+ return d.usage;
62
+ }
63
+ /** The run's prompt cell: the prompt-token total and the share the backend served from cache,
64
+ * the input side a cost-only header hides. The `${n}% cached` wording and rounding are the
65
+ * usage report's `Prompt tokens by role` share; a run with prompt tokens but no cache reads
66
+ * shows no parenthetical, and a run that reported neither keeps today's header byte-identical
67
+ * (the omit-when-zero convention). */
68
+ function runPromptCell(d) {
58
69
  // The run's prompt-token total and the share the backend served from cache, the input
59
70
  // side a cost-only header hides. The `${n}% cached` wording and rounding are the usage
60
71
  // report's `Prompt tokens by role` share; a run with prompt tokens but no cache reads
@@ -62,37 +73,71 @@ export function renderRunDetail(d) {
62
73
  // byte-identical (the omit-when-zero convention).
63
74
  const prompt = d.promptTokens ?? 0;
64
75
  const cached = Math.min(d.cacheReadTokens ?? 0, prompt);
65
- if (prompt > 0)
66
- summary.push(cached > 0 ? `prompt ${compactTokens(prompt)} tok (${Math.round((100 * cached) / prompt)}% cached)` : `prompt ${compactTokens(prompt)} tok`);
76
+ if (prompt <= 0)
77
+ return null;
78
+ return cached > 0 ? `prompt ${compactTokens(prompt)} tok (${Math.round((100 * cached) / prompt)}% cached)` : `prompt ${compactTokens(prompt)} tok`;
79
+ }
80
+ /** The run's pre-edit prompt-token share, the `… before first edit` signal a cost-only header
81
+ * hides: how much of the prompt budget went out before the run made its first edit. Clamped
82
+ * to the total (run-detail-data.ts), written after the prompt cell and before the
83
+ * peak-context cell; a run that reported no pre-edit reading — and an in-flight run — keeps
84
+ * today's header byte-identical. */
85
+ function runPreEditCell(d) {
67
86
  // The run's pre-edit prompt-token share, the `… before first edit` signal a cost-only
68
87
  // header hides: how much of the prompt budget went out before the run made its first
69
88
  // edit. Clamped to the total (run-detail-data.ts), written after the prompt cell and
70
89
  // before the peak-context cell; a run that reported no pre-edit reading — and an
71
90
  // in-flight run — keeps today's header byte-identical.
91
+ const prompt = d.promptTokens ?? 0;
72
92
  const preEdit = Math.min(d.preEditPromptTokens ?? 0, prompt);
73
- if (preEdit > 0)
74
- summary.push(`pre-edit ${compactTokens(preEdit)} tok`);
93
+ if (preEdit <= 0)
94
+ return null;
95
+ return `pre-edit ${compactTokens(preEdit)} tok`;
96
+ }
97
+ /** The run's peak-context cell against the seam's declared window, the one header every surface
98
+ * renders. Omitted when the run reported no peak (byte-identical to today's header). */
99
+ function runPeakContextCell(d) {
75
100
  // The run's peak context against the seam's declared window, the one header every surface
76
101
  // renders. Omitted when the run reported no peak (byte-identical to today's header).
77
- if ((d.peakContextTokens ?? 0) > 0)
78
- summary.push(`peak ctx ${contextShareLabel(d.peakContextTokens ?? 0, d.contextWindow)}`);
79
- if (d.model !== null)
80
- summary.push(d.model);
102
+ if ((d.peakContextTokens ?? 0) <= 0)
103
+ return null;
104
+ return `peak ctx ${contextShareLabel(d.peakContextTokens ?? 0, d.contextWindow)}`;
105
+ }
106
+ /** The run's model cell, the model the run started on. */
107
+ function runModelCell(d) {
108
+ if (d.model === null)
109
+ return null;
110
+ return d.model;
111
+ }
112
+ /** The summary cells in today's push order — result, cut-off, duration, usage, prompt,
113
+ * pre-edit, peak-context, model — each present only when its guard omits nothing. */
114
+ function runSummaryCells(d) {
115
+ return [runResultCell(d), runCutOffCell(d), runDurationCell(d), runUsageCell(d), runPromptCell(d), runPreEditCell(d), runPeakContextCell(d), runModelCell(d)].filter((cell) => cell !== null);
116
+ }
117
+ /** The header's ` · `-separated suffix cells after the summary: the pinned commit, `merged
118
+ * as`, end-of-run landing latency, and the deliberate-revert verdict. */
119
+ function runHeaderSuffixCells(d) {
81
120
  const sha = runCommitSha(d);
82
- let header = `${roleLabel(d.role)} run #${d.run} — ${summary.join(" · ")}`;
121
+ const cells = [];
83
122
  if (sha !== null)
84
- header += ` · commit ${shortSha(sha)}`;
123
+ cells.push(`commit ${shortSha(sha)}`);
85
124
  if (d.mergedSha !== null && d.mergedSha !== sha)
86
- header += ` · merged as ${shortSha(d.mergedSha)}`;
125
+ cells.push(`merged as ${shortSha(d.mergedSha)}`);
87
126
  // The end-of-run → on-main landing latency, the per-change twin of the day report's `Merge
88
127
  // wait median`. shortSpanPhrase is the duration cell's renderer, so both spans read alike; a
89
128
  // run without the field (rejected/failed merge, in flight, landing rotated out, no-op
90
129
  // fast-forward) keeps today's header byte-identical.
91
130
  if (d.mergeAfterRunMs !== undefined)
92
- header += ` · merged ${shortSpanPhrase(d.mergeAfterRunMs)} after the run`;
131
+ cells.push(`merged ${shortSpanPhrase(d.mergeAfterRunMs)} after the run`);
93
132
  if (d.reverted !== null) {
94
- header += ` · reverted ${shortSha(d.reverted.sha)} (by operator)`; // vocabulary-ok: the deliberate-revert writer this header names
133
+ cells.push(`reverted ${shortSha(d.reverted.sha)} (by operator)`); // vocabulary-ok: the deliberate-revert writer this header names
95
134
  }
135
+ return cells;
136
+ }
137
+ /** The trail lines: the run's events through the shared formatEvent, with the merge outcome
138
+ * line placed before the first event when it predates it (a leftover-recovery run) and after
139
+ * the last otherwise, keeping the trail in time order. */
140
+ function runTrailLines(d) {
96
141
  const trail = d.events.map(formatEvent);
97
142
  if (d.merge !== null) {
98
143
  const first = d.events[0];
@@ -101,7 +146,19 @@ export function renderRunDetail(d) {
101
146
  else
102
147
  trail.push(formatEvent(d.merge));
103
148
  }
104
- return [header, ...trail].join("\n");
149
+ return trail;
150
+ }
151
+ /** The human view of a RunDetail: one summary line assembled from the per-cell and
152
+ * per-trail-step helpers — the result (or in-flight/unpaired marker), cut-off, duration,
153
+ * usage, prompt, pre-edit, peak-context and model cells, then the committed sha, `merged as`,
154
+ * merge-latency and reverted suffix cells — and each event through formatEvent. A queued run
155
+ * whose merge has finished shows that verdict in the header and ends with the merge's outcome
156
+ * line; a leftover-recovery run's verdict is the failed attempt it re-queued
157
+ * (resolveQueuedResult), logged before the run began, so that outcome line leads the trail
158
+ * instead — the trail stays in time order. */
159
+ export function renderRunDetail(d) {
160
+ const header = [`${roleLabel(d.role)} run #${d.run} — ${runSummaryCells(d).join(" · ")}`, ...runHeaderSuffixCells(d)].join(" · ");
161
+ return [header, ...runTrailLines(d)].join("\n");
105
162
  }
106
163
  /** The transcript block of `--transcript`: the rendered lines of the agent runs that started in the
107
164
  * run's window, or `found: []` plus the `note` the text view prints in their place. The