tumwater 0.4.2 → 0.4.4

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 (354) 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} +27 -13
  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 +6 -27
  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 +8 -0
  14. package/dist/src/agent/codex/codex-args.js +5 -5
  15. package/dist/src/agent/codex/codex-backend.js +2 -2
  16. package/dist/src/agent/codex/codex-env.js +28 -15
  17. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  18. package/dist/src/agent/codex/codex-rollout.js +93 -56
  19. package/dist/src/agent/codex/codex-stream.js +1 -1
  20. package/dist/src/agent/env-scrub.js +18 -0
  21. package/dist/src/agent/image-input.js +68 -11
  22. package/dist/src/agent/notes-shim.js +2 -2
  23. package/dist/src/agent/opencode/opencode-backend.js +26 -24
  24. package/dist/src/agent/opencode/opencode-catalog.js +85 -36
  25. package/dist/src/agent/opencode/opencode-config.js +32 -14
  26. package/dist/src/agent/opencode/opencode-env.js +12 -13
  27. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  28. package/dist/src/agent/session-ledger.js +23 -27
  29. package/dist/src/agent/subscription-account.js +2 -83
  30. package/dist/src/backlog/backlog-archive.js +3 -4
  31. package/dist/src/backlog/backlog-cli-view.js +81 -41
  32. package/dist/src/backlog/backlog-cli.js +57 -26
  33. package/dist/src/backlog/backlog-eligibility.js +56 -122
  34. package/dist/src/backlog/backlog-md.js +5 -5
  35. package/dist/src/backlog/backlog-parts.js +65 -27
  36. package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +1 -1
  37. package/dist/src/backlog/backlog-release.js +12 -12
  38. package/dist/src/backlog/backlog-structure.js +114 -61
  39. package/dist/src/backlog/backlog.js +3 -2
  40. package/dist/src/baseline/main-baseline.js +148 -84
  41. package/dist/src/baseline/main-guardrails.js +2 -2
  42. package/dist/src/baseline/main-watch.js +84 -50
  43. package/dist/src/brief/brief.js +26 -13
  44. package/dist/src/budget/budget-fallback-view.js +100 -243
  45. package/dist/src/budget/budget-wiring.js +29 -65
  46. package/dist/src/budget/budget.js +4 -5
  47. package/dist/src/budget/fallback-breaker.js +2 -2
  48. package/dist/src/build/build-check-detect.js +43 -14
  49. package/dist/src/build/build-check-events.js +2 -2
  50. package/dist/src/build/build-check-report.js +10 -0
  51. package/dist/src/build/build-check-scoped.js +40 -18
  52. package/dist/src/build/build-check.js +13 -5
  53. package/dist/src/build/build-stage.js +6 -4
  54. package/dist/src/change/change-data.js +1 -4
  55. package/dist/src/cli/cli-command-args.js +89 -52
  56. package/dist/src/cli/cli-completion-command.js +75 -0
  57. package/dist/src/cli/{config-commands.js → cli-config-commands.js} +82 -71
  58. package/dist/src/cli/cli-doctor-command.js +103 -0
  59. package/dist/src/cli/cli-flag-specs.js +42 -22
  60. package/dist/src/cli/cli-init.js +36 -14
  61. package/dist/src/cli/{log-commands.js → cli-log-commands.js} +43 -23
  62. package/dist/src/cli/{notify-command.js → cli-notify-command.js} +2 -2
  63. package/dist/src/cli/cli-query-commands.js +2 -2
  64. package/dist/src/cli/{question-commands.js → cli-question-commands.js} +86 -60
  65. package/dist/src/cli/cli-repo-gate.js +2 -2
  66. package/dist/src/cli/cli-role-command.js +2 -41
  67. package/dist/src/cli/cli-start-summary.js +4 -3
  68. package/dist/src/cli/cli-start-supervisor.js +190 -0
  69. package/dist/src/cli/cli-start.js +94 -244
  70. package/dist/src/cli/completion.js +85 -49
  71. package/dist/src/cli/follow-view.js +20 -2
  72. package/dist/src/cli/help.js +6 -13
  73. package/dist/src/cli.js +70 -176
  74. package/dist/src/collections.js +39 -27
  75. package/dist/src/concurrency/check-slots.js +70 -0
  76. package/dist/src/concurrency/lock.js +23 -1
  77. package/dist/src/concurrency/run-permit.js +49 -0
  78. package/dist/src/concurrency/semaphore.js +46 -250
  79. package/dist/src/config/config-editable-keys.js +4 -7
  80. package/dist/src/config/config-fallbacks.js +81 -101
  81. package/dist/src/config/config-field-checks.js +66 -37
  82. package/dist/src/config/config-keys.js +66 -40
  83. package/dist/src/config/config-live.js +4 -28
  84. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  85. package/dist/src/config/config-request.js +163 -102
  86. package/dist/src/config/config-schema.js +10 -12
  87. package/dist/src/config/config-selector-checks.js +1 -1
  88. package/dist/src/config/config-validation-agents.js +2 -2
  89. package/dist/src/config/config-validation-roles.js +8 -72
  90. package/dist/src/config/config-validation.js +4 -39
  91. package/dist/src/config/config-views.js +26 -50
  92. package/dist/src/config/config-write.js +128 -102
  93. package/dist/src/config/config.js +5 -14
  94. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  95. package/dist/src/doctor/doctor-backlog-plans.js +53 -120
  96. package/dist/src/doctor/doctor-backlog.js +16 -14
  97. package/dist/src/doctor/doctor-check-names.js +3 -6
  98. package/dist/src/doctor/doctor-checks.js +8 -8
  99. package/dist/src/doctor/doctor-claude-checks.js +70 -36
  100. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  101. package/dist/src/doctor/doctor-contention-checks.js +57 -117
  102. package/dist/src/doctor/doctor-fallback-checks.js +48 -26
  103. package/dist/src/doctor/doctor-holds.js +8 -47
  104. package/dist/src/doctor/doctor-install.js +1 -1
  105. package/dist/src/doctor/doctor-launch-services.js +1 -1
  106. package/dist/src/doctor/doctor-model-checks.js +178 -94
  107. package/dist/src/doctor/doctor-notify.js +2 -2
  108. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  109. package/dist/src/doctor/doctor-orphans.js +122 -21
  110. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  111. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  112. package/dist/src/doctor/doctor-principles.js +3 -3
  113. package/dist/src/doctor/doctor-project-checks.js +24 -11
  114. package/dist/src/doctor/doctor-role-notes.js +1 -1
  115. package/dist/src/doctor/doctor.js +8 -11
  116. package/dist/src/events/event-format.js +44 -64
  117. package/dist/src/events/event-labels.js +1 -5
  118. package/dist/src/events/event-legacy.js +3 -5
  119. package/dist/src/events/{notify.js → event-notify.js} +6 -6
  120. package/dist/src/events/event-read.js +1 -1
  121. package/dist/src/events/event-window.js +1 -1
  122. package/dist/src/events/events.js +8 -0
  123. package/dist/src/failure/failure-cluster.js +9 -18
  124. package/dist/src/failure/failure-data.js +12 -8
  125. package/dist/src/failure/failure-render.js +46 -36
  126. package/dist/src/failure/failure-state-change.js +54 -120
  127. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  128. package/dist/src/files/files.js +30 -0
  129. package/dist/src/files/json-object.js +8 -8
  130. package/dist/src/files/stat-cache.js +12 -8
  131. package/dist/src/gates/budget-gates.js +85 -84
  132. package/dist/src/gates/gate-polls.js +32 -74
  133. package/dist/src/gates/pause-gates.js +16 -2
  134. package/dist/src/gates/readiness.js +1 -1
  135. package/dist/src/gates/role-cap-gates.js +44 -35
  136. package/dist/src/gates/{startup-gate.js → startup-gates.js} +9 -8
  137. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  138. package/dist/src/git/git-diff.js +6 -2
  139. package/dist/src/git/git.js +75 -45
  140. package/dist/src/git/worktree-pool.js +3 -6
  141. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  142. package/dist/src/gui/gui-args.js +12 -0
  143. package/dist/src/gui/gui-backlog-commands.js +4 -4
  144. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  145. package/dist/src/gui/gui-command.js +43 -24
  146. package/dist/src/gui/gui-config-commands.js +2 -64
  147. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  148. package/dist/src/gui/gui-endpoint-common.js +19 -1
  149. package/dist/src/gui/gui-history-endpoints.js +100 -52
  150. package/dist/src/gui/gui-operator-commands.js +43 -48
  151. package/dist/src/gui/gui-prompt-commands.js +25 -25
  152. package/dist/src/gui/gui-report-endpoints.js +1 -1
  153. package/dist/src/gui/gui-server.js +1 -2
  154. package/dist/src/gui/gui-team-commands.js +2 -1
  155. package/dist/src/history/history-data.js +71 -44
  156. package/dist/src/history/history-queued.js +49 -27
  157. package/dist/src/history/history.js +54 -36
  158. package/dist/src/inbox/prompt-commands.js +59 -42
  159. package/dist/src/init/init.js +88 -40
  160. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  161. package/dist/src/merge/merge-check-failures.js +12 -9
  162. package/dist/src/merge/merge-core.js +7 -32
  163. package/dist/src/merge/merge-drain.js +11 -13
  164. package/dist/src/merge/merge-pipeline.js +6 -7
  165. package/dist/src/merge/merge-post-review-drain.js +11 -14
  166. package/dist/src/merge/merge-queue-render.js +3 -3
  167. package/dist/src/merge/merge-requeue.js +1 -1
  168. package/dist/src/merge/merge-slot.js +0 -8
  169. package/dist/src/merge/merge-stack.js +17 -18
  170. package/dist/src/merge/merge-to-main.js +13 -58
  171. package/dist/src/merge/merge-verify.js +12 -21
  172. package/dist/src/notes-cli.js +52 -42
  173. package/dist/src/operator/operator-commands.js +80 -47
  174. package/dist/src/operator/operator-intent.js +3 -3
  175. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  176. package/dist/src/orchestrator/orchestrator-poll.js +44 -69
  177. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  178. package/dist/src/orchestrator/orchestrator.js +8 -21
  179. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  180. package/dist/src/orchestrator/scheduling-scope.js +18 -27
  181. package/dist/src/orchestrator/scheduling-skip.js +10 -14
  182. package/dist/src/orchestrator/startup-migrations.js +1 -1
  183. package/dist/src/paths.js +10 -9
  184. package/dist/src/pi/pi-args.js +5 -2
  185. package/dist/src/pi/pi-backend.js +2 -2
  186. package/dist/src/pi/pi-bin.js +3 -3
  187. package/dist/src/pi/pi-event-line.js +28 -1
  188. package/dist/src/pi/pi-listed-models.js +13 -0
  189. package/dist/src/pi/pi-models.js +43 -29
  190. package/dist/src/pi/pi-settings.js +43 -20
  191. package/dist/src/pi/pi-stream.js +41 -8
  192. package/dist/src/pi/pi-watchdogs.js +43 -15
  193. package/dist/src/pi/pi.js +2 -2
  194. package/dist/src/pi-extension/context-shake.js +32 -20
  195. package/dist/src/pi-extension/kill-guard.js +69 -0
  196. package/dist/src/process/process-group.js +51 -26
  197. package/dist/src/process/process.js +20 -12
  198. package/dist/src/process/run-marker.js +33 -15
  199. package/dist/src/process/supervisor.js +40 -28
  200. package/dist/src/prompt/dialect.js +1 -1
  201. package/dist/src/prompt/gate-notes.js +2 -2
  202. package/dist/src/prompt/gate-prompts.js +1 -44
  203. package/dist/src/prompt/notes-backlog.js +66 -37
  204. package/dist/src/prompt/principles.js +6 -4
  205. package/dist/src/prompt/prompt-followup.js +12 -4
  206. package/dist/src/prompt/prompt.js +88 -38
  207. package/dist/src/redeploy/redeployer.js +21 -1
  208. package/dist/src/report/report-data.js +53 -37
  209. package/dist/src/report/report-fold.js +16 -9
  210. package/dist/src/report/report-render.js +21 -194
  211. package/dist/src/report/report-since-data.js +4 -3
  212. package/dist/src/report/report-since-render.js +173 -0
  213. package/dist/src/report/report-usage-fold.js +8 -8
  214. package/dist/src/report/report.js +46 -22
  215. package/dist/src/review/diff-rules.js +35 -21
  216. package/dist/src/review/post-merge-review-render.js +9 -3
  217. package/dist/src/review/post-merge-review.js +48 -41
  218. package/dist/src/review/review-precheck.js +14 -4
  219. package/dist/src/review/review.js +48 -170
  220. package/dist/src/review/secret-leaks.js +26 -16
  221. package/dist/src/roles/legacy-role-migration.js +6 -3
  222. package/dist/src/roles/role-catalog.js +11 -3
  223. package/dist/src/roles/role-guidance.js +16 -12
  224. package/dist/src/roles/role-render.js +7 -46
  225. package/dist/src/roles/role-view.js +10 -33
  226. package/dist/src/run/acceptance-greps.js +88 -45
  227. package/dist/src/run/ci-parity.js +19 -8
  228. package/dist/src/run/run-apply.js +12 -15
  229. package/dist/src/run/run-detail-data.js +121 -68
  230. package/dist/src/run/run-detail-render.js +92 -35
  231. package/dist/src/run/run-detail.js +72 -41
  232. package/dist/src/run/run-prompt.js +63 -50
  233. package/dist/src/run/run-resume.js +76 -29
  234. package/dist/src/run/run-stage.js +141 -90
  235. package/dist/src/run/run-timing.js +8 -2
  236. package/dist/src/run/run-usage.js +31 -1
  237. package/dist/src/run/run-verdict.js +128 -50
  238. package/dist/src/run/stage-check.js +60 -50
  239. package/dist/src/run/stage-checks-shared.js +28 -0
  240. package/dist/src/run/stage-name-checks.js +111 -92
  241. package/dist/src/run/stage-path-checks.js +69 -42
  242. package/dist/src/run/stage-prose-checks.js +55 -40
  243. package/dist/src/runner/{leftover.js → runner-leftover.js} +9 -26
  244. package/dist/src/runner/runner-outcome.js +7 -2
  245. package/dist/src/runner/runner-pi.js +9 -22
  246. package/dist/src/runner/runner-recovery.js +5 -13
  247. package/dist/src/runner/{revision.js → runner-revision.js} +1 -2
  248. package/dist/src/runner/runner-run.js +103 -50
  249. package/dist/src/runner/runner-state-load.js +18 -28
  250. package/dist/src/runner/runner-state.js +0 -14
  251. package/dist/src/runner/runner.js +41 -25
  252. package/dist/src/scheduling/backoff.js +8 -3
  253. package/dist/src/scheduling/claim-entries.js +2 -1
  254. package/dist/src/scheduling/claims.js +0 -4
  255. package/dist/src/scheduling/scheduling.js +53 -66
  256. package/dist/src/status/status-budget.js +4 -43
  257. package/dist/src/status/status-data.js +35 -39
  258. package/dist/src/status/status-role-notes.js +2 -2
  259. package/dist/src/status/status-roles.js +9 -18
  260. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  261. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  262. package/dist/src/team/team-hold.js +105 -70
  263. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +55 -36
  264. package/dist/src/team/team-polls.js +53 -201
  265. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  266. package/dist/src/team/team-state.js +4 -13
  267. package/dist/src/text/datetime.js +16 -7
  268. package/dist/src/text/markdown.js +1 -1
  269. package/dist/src/text/phrases.js +29 -15
  270. package/dist/src/text/suggest.js +1 -1
  271. package/dist/src/text/text.js +6 -5
  272. package/dist/src/ui/alert-feeds.js +46 -111
  273. package/dist/src/ui/badges.js +17 -88
  274. package/dist/src/ui/gui/gui-client-boot.js +2 -8
  275. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  276. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -33
  277. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  278. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  279. package/dist/src/ui/gui/gui-client-history.js +28 -14
  280. package/dist/src/ui/gui/gui-client-model.js +0 -12
  281. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  282. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  283. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  284. package/dist/src/ui/gui/gui-client-report.js +1 -1
  285. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  286. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  287. package/dist/src/ui/gui/gui-client-team.js +12 -6
  288. package/dist/src/ui/gui/gui-client.js +7 -1
  289. package/dist/src/ui/progress-data.js +107 -44
  290. package/dist/src/ui/run-progress-model.js +20 -4
  291. package/dist/src/ui/status-model.js +2 -24
  292. package/dist/src/ui/status-payload.js +11 -30
  293. package/dist/src/ui/status-render.js +13 -24
  294. package/dist/src/ui/team-alert-backlog.js +14 -204
  295. package/dist/src/ui/team-alert-shared.js +6 -29
  296. package/dist/src/ui/team-alert-status.js +9 -170
  297. package/dist/src/ui/team-alert-tail.js +19 -215
  298. package/dist/src/ui/team-alerts.js +8 -15
  299. package/dist/src/ui/transcript-run.js +27 -16
  300. package/dist/src/ui/tui/tui-backlog.js +2 -2
  301. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  302. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  303. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  304. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  305. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  306. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  307. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  308. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  309. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  310. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  311. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  312. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  313. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  314. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  315. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  316. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  317. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  318. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  319. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  320. package/dist/src/ui/tui/tui-editors.js +5 -10
  321. package/dist/src/ui/tui/tui-frame.js +182 -134
  322. package/dist/src/ui/tui/tui-guard.js +20 -0
  323. package/dist/src/ui/tui/tui-input.js +167 -98
  324. package/dist/src/ui/tui/tui-keymap.js +51 -29
  325. package/dist/src/ui/tui/tui-keys.js +1 -1
  326. package/dist/src/ui/tui/tui-pane-activity.js +5 -4
  327. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  328. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  329. package/dist/src/ui/tui/tui-pane-health.js +252 -230
  330. package/dist/src/ui/tui/tui-pane-history.js +67 -74
  331. package/dist/src/ui/tui/tui-pane-pending.js +288 -247
  332. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  333. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  334. package/dist/src/ui/tui/tui-pane-session.js +15 -10
  335. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  336. package/dist/src/ui/tui/tui-pane.js +14 -11
  337. package/dist/src/ui/tui/tui-panes.js +38 -33
  338. package/dist/src/ui/tui/tui.js +4 -7
  339. package/dist/src/verdict/fix-claim.js +107 -56
  340. package/dist/src/verdict/refusal.js +1 -1
  341. package/dist/src/verdict/reply-contract.js +3 -4
  342. package/package.json +5 -5
  343. package/dist/src/concurrency/provider-lease.js +0 -105
  344. package/dist/src/review/review-followup.js +0 -87
  345. package/dist/src/review/review-run.js +0 -395
  346. package/dist/src/runner/model-fallback.js +0 -76
  347. package/dist/src/runner/runner-fallback.js +0 -66
  348. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  349. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  350. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  351. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  352. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  353. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  354. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
@@ -8,13 +8,12 @@
8
8
  import { truncateExample } from "./failure-cluster.js";
9
9
  import { cutSplitsSurrogatePair, isNonBlankString } from "../text/text.js";
10
10
  import { finiteNumber, isJsonObject, stringList } from "../files/json-object.js";
11
- import { orchestratorStopPhrase, backendKindPhrase, budgetPhrase, teamPausePhrase, teamResumePhrase, holdPhrase, plural, rolePausePhrase, roleResumePhrase, rolesPhrase, shortSpanPhrase, rendersAsBackendFailure, PAUSE_EXPIRED_SUFFIX } from "../text/phrases.js";
11
+ import { orchestratorStopPhrase, backendKindPhrase, budgetPhrase, teamPausePhrase, teamResumePhrase, holdPhrase, plural, rolePausePhrase, roleResumePhrase, rolesPhrase, rendersAsBackendFailure, PAUSE_EXPIRED_SUFFIX } from "../text/phrases.js";
12
12
  import { shortSha } from "../text/format.js";
13
13
  import { deferredReasonText, untilClockSuffix, gigabytes, windowSuffix, windowResetPhrase, backendLabel } from "../events/event-format.js";
14
- import { claudeUtilizationPct } from "../agent/claude/claude-account.js";
15
14
  /** The transition events the digest replays: the decisions the harness made about itself (the
16
15
  * cap/team gates and the team hold, the error-streak breaker's pause, the role's own daily
17
- * cap, model fallbacks, live-config edits, self-hosted redeploys, need-based deferrals,
16
+ * cap, live-config edits, self-hosted redeploys, need-based deferrals,
18
17
  * orchestrator lifecycle, and the gate holds/lifts that stop and restart work — low disk,
19
18
  * quiet hours, the maintenance allowance and the subscription-window reserve). Everything the
20
19
  * set omits is deliberately excluded and named in STATE_CHANGE_EXCLUDED_TYPES; the two sets
@@ -25,48 +24,55 @@ import { claudeUtilizationPct } from "../agent/claude/claude-account.js";
25
24
  * selfcheck role's load-bearing rule asks it to
26
25
  * judge — whether the harness's RESPONSE to a failure was wrong (plans/telemetry-role.md) —
27
26
  * so they sit beside the outcomes rather than being dropped. */
28
- export const STATE_CHANGE_TYPES = new Set([
29
- "budget_paused",
30
- "budget_fallback",
31
- "budget_resumed",
32
- "team_paused",
33
- "team_resumed",
34
- "role_paused",
35
- "role_resumed",
36
- "role_streak_paused",
37
- "role_cap_paused",
38
- "role_cap_resumed",
39
- "model_fallback_started",
40
- "model_fallback_ended",
41
- "rate_limit_hold",
42
- "rate_limit_resumed",
43
- "max_concurrent_changed",
44
- "retention_changed",
45
- "config_changed",
46
- "build_stale",
47
- "restart_pending",
48
- "restart",
49
- "restart_refused",
50
- "restart_blocked",
51
- "run_deferred",
52
- "orchestrator_start",
53
- "orchestrator_stop",
54
- "supervisor_exit",
55
- "disk_low",
56
- "disk_ok",
57
- "quiet_hours_started",
58
- "quiet_hours_ended",
59
- "maintenance_quota_hold",
60
- "maintenance_quota_resumed",
61
- "subscription_window_reserved",
62
- "subscription_window_limited",
63
- "subscription_window_reset",
64
- "provider_concurrency_changed",
65
- "model_changed",
66
- "bootstrap_complete",
67
- "fallback_switched",
68
- "budget_handback",
69
- ]);
27
+ const STATE_CHANGE_FAMILY = {
28
+ // Budget and window reopening.
29
+ budget_paused: budgetStateChange,
30
+ budget_fallback: budgetStateChange,
31
+ budget_resumed: budgetStateChange,
32
+ // Pauses and resumes of teams, roles and caps.
33
+ team_paused: pauseStateChange,
34
+ team_resumed: pauseStateChange,
35
+ role_paused: pauseStateChange,
36
+ role_resumed: pauseStateChange,
37
+ role_streak_paused: pauseStateChange,
38
+ role_cap_paused: pauseStateChange,
39
+ role_cap_resumed: pauseStateChange,
40
+ // Model and fallback wiring.
41
+ model_changed: modelStateChange,
42
+ fallback_switched: modelStateChange,
43
+ budget_handback: modelStateChange,
44
+ // Provider hold and resume.
45
+ rate_limit_hold: providerHoldStateChange,
46
+ rate_limit_resumed: providerHoldStateChange,
47
+ // Live-config edits.
48
+ max_concurrent_changed: liveConfigStateChange,
49
+ retention_changed: liveConfigStateChange,
50
+ config_changed: liveConfigStateChange,
51
+ // Self-hosted redeploys.
52
+ build_stale: updateStateChange,
53
+ restart_pending: updateStateChange,
54
+ restart: updateStateChange,
55
+ restart_refused: updateStateChange,
56
+ restart_blocked: updateStateChange,
57
+ // Need-based deferrals and gate holds/lifts.
58
+ run_deferred: waitStateChange,
59
+ disk_low: waitStateChange,
60
+ disk_ok: waitStateChange,
61
+ quiet_hours_started: waitStateChange,
62
+ quiet_hours_ended: waitStateChange,
63
+ maintenance_quota_hold: waitStateChange,
64
+ maintenance_quota_resumed: waitStateChange,
65
+ subscription_window_limited: waitStateChange,
66
+ subscription_window_reset: waitStateChange,
67
+ // Orchestrator lifecycle.
68
+ orchestrator_start: lifecycleStateChange,
69
+ orchestrator_stop: lifecycleStateChange,
70
+ supervisor_exit: lifecycleStateChange,
71
+ bootstrap_complete: lifecycleStateChange,
72
+ };
73
+ /** The event types the digest replays, derived from STATE_CHANGE_FAMILY's keys so the filter
74
+ * set can never drift from the dispatch. */
75
+ export const STATE_CHANGE_TYPES = new Set(Object.keys(STATE_CHANGE_FAMILY));
70
76
  /** The event types the digest deliberately does not replay, one per EVENT_TYPE_LABELS key that
71
77
  * is not a STATE_CHANGE_TYPES member. Together with STATE_CHANGE_TYPES it partitions
72
78
  * EVENT_TYPE_LABELS' keys, so a new event type must be classified in one set or the other — the
@@ -120,13 +126,13 @@ export const STATE_CHANGE_EXCLUDED_TYPES = new Set([
120
126
  "check_wait",
121
127
  // Observations and actions.
122
128
  "disk_reclaim",
123
- "subscription_window_warning",
124
129
  "image_blind",
125
130
  // State-adjacent but not self-decisions.
126
131
  "state_migrated",
127
132
  "restart_forced",
128
133
  "backend_binary",
129
134
  "opencode_free_model_billed",
135
+ "image_reroute",
130
136
  ]);
131
137
  /** The Team state changes section's caps: newest N transitions, each line's payload capped at
132
138
  * STATE_CHANGE_MAX (a marked word-boundary cut per truncateExample), each free field within it
@@ -171,16 +177,10 @@ function field(v) {
171
177
  }
172
178
  /** The backend a fallback-ish decision line names, as "provider/model", defaulting each half
173
179
  * to pi's own default when the event omits it and slicing both through field. Shared by the
174
- * budget_fallback and model_fallback lines so the default and its slice rule stay in one place. */
180
+ * budget_fallback line so the default and its slice rule stay in one place. */
175
181
  function providerModel(ev) {
176
182
  return `${field(ev.provider ?? "pi default")}/${field(ev.model ?? "pi default")}`;
177
183
  }
178
- /** A utilization fraction as its whole-number percent, read through finiteNumber so a torn or
179
- * hand-edited subscription event renders 0% rather than NaN — the same corrupt-field rule the
180
- * feed's own subscription lines apply. */
181
- function pct(v) {
182
- return claudeUtilizationPct(finiteNumber(v, 0));
183
- }
184
184
  /** A per-family builder of one state-change line, one per event family, so the budget, pause,
185
185
  * model-wiring, provider-hold, live-config, update, wait and lifecycle wordings are each found
186
186
  * by name. Each is a total function over any HarnessEvent — its `default` returns the bare type
@@ -250,18 +250,6 @@ function pauseStateChange(ev) {
250
250
  function modelStateChange(ev) {
251
251
  let text;
252
252
  switch (ev.type) {
253
- case "model_fallback_started":
254
- // The role tripped off its primary onto its fallback after provider-class failures.
255
- text = `model fallback — ${providerModel(ev)} engaged (${field(ev.reason ?? "backend failure")})`;
256
- break;
257
- case "model_fallback_ended":
258
- // The return counterpart: names the primary the role resumed on and how long the
259
- // off-model episode ran.
260
- text = `model fallback ended — back on ${providerModel(ev)} after ${shortSpanPhrase(finiteNumber(ev.durationMs, 0))}`;
261
- break;
262
- case "provider_concurrency_changed":
263
- text = `${field(ev.provider)} concurrency ${field(ev.from)} → ${field(ev.to)}`;
264
- break;
265
253
  case "model_changed":
266
254
  // A model_cleared (or a roles.<id>.model-only) live edit arrives with `to: null`;
267
255
  // name pi's default the way the feed does so the digest never renders a bare
@@ -401,9 +389,6 @@ function waitStateChange(ev) {
401
389
  case "maintenance_quota_resumed":
402
390
  text = `maintenance allowance reopened — ${field(ev.maint)}/${field(ev.allowance)} in 24h`;
403
391
  break;
404
- case "subscription_window_reserved":
405
- text = `waiting: ${backendLabel(ev)} window ${field(ev.windowType)} at ${pct(ev.utilization)}% past its ${pct(ev.reserve)}% reserve`;
406
- break;
407
392
  case "subscription_window_limited":
408
393
  text = `waiting: ${backendLabel(ev)} window ${field(ev.windowType)} rejected${windowResetPhrase(ev)}`;
409
394
  break;
@@ -442,56 +427,5 @@ function lifecycleStateChange(ev) {
442
427
  * read as complete or stand mid-word where a key or value should be; the render adds the
443
428
  * timestamp and a roleCell-sliced role, so no unbounded field reaches the page. */
444
429
  export function describeStateChange(ev) {
445
- switch (ev.type) {
446
- case "budget_paused":
447
- case "budget_fallback":
448
- case "budget_resumed":
449
- return truncateExample(budgetStateChange(ev), STATE_CHANGE_MAX);
450
- case "team_paused":
451
- case "team_resumed":
452
- case "role_paused":
453
- case "role_resumed":
454
- case "role_streak_paused":
455
- case "role_cap_paused":
456
- case "role_cap_resumed":
457
- return truncateExample(pauseStateChange(ev), STATE_CHANGE_MAX);
458
- case "model_fallback_started":
459
- case "model_fallback_ended":
460
- case "provider_concurrency_changed":
461
- case "model_changed":
462
- case "fallback_switched":
463
- case "budget_handback":
464
- return truncateExample(modelStateChange(ev), STATE_CHANGE_MAX);
465
- case "rate_limit_hold":
466
- case "rate_limit_resumed":
467
- return truncateExample(providerHoldStateChange(ev), STATE_CHANGE_MAX);
468
- case "max_concurrent_changed":
469
- case "retention_changed":
470
- case "config_changed":
471
- return truncateExample(liveConfigStateChange(ev), STATE_CHANGE_MAX);
472
- case "build_stale":
473
- case "restart_pending":
474
- case "restart":
475
- case "restart_refused":
476
- case "restart_blocked":
477
- return truncateExample(updateStateChange(ev), STATE_CHANGE_MAX);
478
- case "run_deferred":
479
- case "disk_low":
480
- case "disk_ok":
481
- case "quiet_hours_started":
482
- case "quiet_hours_ended":
483
- case "maintenance_quota_hold":
484
- case "maintenance_quota_resumed":
485
- case "subscription_window_reserved":
486
- case "subscription_window_limited":
487
- case "subscription_window_reset":
488
- return truncateExample(waitStateChange(ev), STATE_CHANGE_MAX);
489
- case "orchestrator_start":
490
- case "orchestrator_stop":
491
- case "supervisor_exit":
492
- case "bootstrap_complete":
493
- return truncateExample(lifecycleStateChange(ev), STATE_CHANGE_MAX);
494
- default:
495
- return truncateExample(ev.type, STATE_CHANGE_MAX);
496
- }
430
+ return truncateExample(STATE_CHANGE_FAMILY[ev.type]?.(ev) ?? ev.type, STATE_CHANGE_MAX);
497
431
  }
@@ -87,92 +87,125 @@ function resolveQueuedRuns(runEvents, allEvents) {
87
87
  }
88
88
  /** Classify one run's loss cause into the key/kind/example triple the ranking folds, or null
89
89
  * when the run prices no loss. The branch order is the contract: refused, then cut-off, then
90
- * no_change, then clustered, then the three queued outcomes; the deadline-killed merge-block
91
- * special case still overrides `key`/`example` inside its branch. Reads the per-runner join
92
- * indexes from `indexes` so the ladder names each rule by a single lookup. */
90
+ * no_change, then clustered, then the three queued outcomes — each rule owned by its own
91
+ * named helper below, so the `??` chain reads left to right in the same order. Reads the
92
+ * per-runner join indexes from `indexes` so each helper names a rule by a single lookup. */
93
93
  function lossCauseFor(ev, cls, resolvedOutcome, indexes) {
94
- let key = null;
95
- let kind = "error-cluster";
96
- let example = "";
97
- if (ev.result === "refused") {
98
- // A refusal is a deliberate objection that blocks its entry, not quiet idleness — the
99
- // reason live in the event + lastSummary (src/verdict/refusal.ts). Rank it as its own
100
- // per-role cause so the digest surfaces the objection instead of pricing the run
101
- // exactly like a silently idle one; the branch sits BEFORE the no_change one because a
102
- // refused run is also no_change-class in the outcome table. The example rides the
103
- // refusal's summary the way a review-rejected cause rides its reason.
104
- key = `refused\u0000${eventRole(ev)}`;
105
- kind = "refused";
106
- example = typeof ev.summary === "string" ? truncateExample(ev.summary) : "";
107
- }
108
- else if (cls === "no_change" && ev.cutOff === true) {
109
- // A run truncated at its model's context ceiling ends no_change with cutOff: true
110
- // (diagnoseNoChange in src/run/run-verdict.ts), and finalizeRun rides that flag onto
111
- // the run_end. Rank it as its own per-role cause so the digest separates the churn of
112
- // a context ceiling — a cost of a long session, which the harness resumes — from
113
- // deliberate idleness; the branch sits BEFORE the no_change one because a cut-off run
114
- // is also no_change-class in the outcome table. No example, like the no_change cause.
115
- key = `cutoff\u0000${eventRole(ev)}`;
116
- kind = "cutoff";
117
- }
118
- else if (cls === "no_change") {
119
- key = `no_change\u0000${eventRole(ev)}`;
120
- kind = "no_change";
121
- }
122
- else if (CLUSTERED_RESULTS.has(String(ev.result))) {
123
- // A clustered result with no error text still owns its time: an abort's run_end never
124
- // carries one (planRunStart clears s.lastError, run-finalize logs error: undefined),
125
- // and a malformed error run can lose it too, so both take the same placeholder cause the
126
- // error-clusters section already itemizes rather than dropping the span itemized nowhere.
127
- ({ key, example } = pooledClusterCause(ev.error));
128
- }
129
- else if (ev.result === "queued" && resolvedOutcome?.result === "rejected") {
130
- key = `review-rejected\u0000${eventRole(ev)}`;
131
- kind = "review-rejected";
132
- // Match the merge ATTEMPT, not the pin's sha: every verification rebases its pin onto main
133
- // before the gate (syncPinToMain), so `review_rejected.head` is the rebased commit while
134
- // `merge_queued` keeps the authored one — exact-sha equality names no reason for a rebased
135
- // rejection (BUGS.md 2026-10-10). The rejection for this attempt is the one inside the
136
- // claimed pin and its outcome, the window no other attempt can occupy.
137
- const rej = newestAttemptEvent(indexes.rejectedByRunner, eventRole(ev), resolvedOutcome);
138
- const reasons = stringList(rej?.reasons);
139
- example = reasons[0] !== undefined ? truncateExample(reasons[0]) : "";
140
- }
141
- else if (ev.result === "queued" && resolvedOutcome?.result === "review_error") {
142
- // The review process itself failed (a dead backend, a timeout, no parseable verdict):
143
- // the pin was kept and re-queued, and the matching `review_failed` event carries the
144
- // message the review-failure section already clusters. Price the authoring span under
145
- // that cluster's key — a review timeout pools into the shared `timed out after <dur>`
146
- // cause via poolTimeoutKey — and fall back to the shared placeholder when no event is
147
- // visible, so the span is itemized rather than hidden. The match keys on the merge
148
- // ATTEMPT, not the pin's sha: every verification rebases its pin onto main before the gate
149
- // (syncPinToMain), so `review_failed.head` is the rebased commit while `merge_queued`
150
- // keeps the authored one — exact-sha equality missed every rebased verification (BUGS.md
151
- // 2026-10-10). The review failure for this attempt is the one that falls inside the
152
- // claimed pin and its outcome, the window no other attempt can occupy.
153
- const failed = newestAttemptEvent(indexes.reviewFailedByRunner, eventRole(ev), resolvedOutcome);
154
- ({ key, example } = pooledClusterCause(failed?.message));
155
- }
156
- else if (ev.result === "queued" &&
157
- (resolvedOutcome?.result === "merge_conflict" || resolvedOutcome?.result === "merge_blocked")) {
158
- // The merge failed to merge (a conflict, or a blocked fast-forward/rebase): this
159
- // authoring span burned. The `merge_failed` event carries no message — writeMergeOutcome
160
- // logs only result/commit/durationMs/usage — so the cause is named by its own result and
161
- // takes the shared no-text placeholder, the same fallback a message-less aborted run_end
162
- // uses, rather than dropping a span the error-class cell already priced (BUGS.md
163
- // 2026-10-10).
164
- key = `${resolvedOutcome.result}\u0000${eventRole(ev)}`;
165
- kind = resolvedOutcome.result === "merge_conflict" ? "merge-conflict" : "merge-blocked";
166
- example = errorTextOrPlaceholder(undefined);
167
- // A merge-scope check the harness killed at its own deadline is the team's load, not a
168
- // verdict on the change: split it into its own cause so the ranking names it.
169
- const killed = newestAttemptEvent(indexes.deadlineKilledByRunner, eventRole(ev), resolvedOutcome);
170
- if (kind === "merge-blocked" && killed) {
171
- key = `merge_blocked_deadline\u0000${eventRole(ev)}`;
172
- example = `${String(killed.scope)} build check killed at its ${nonNegativeNumber(killed.timeoutMs, 0) / 1000}s deadline (tumwater's own timeout, not a test failure)`;
173
- }
94
+ return (refusedLoss(ev) ??
95
+ cutoffLoss(ev, cls) ??
96
+ noChangeLoss(ev, cls) ??
97
+ clusteredLoss(ev) ??
98
+ reviewRejectedLoss(ev, resolvedOutcome, indexes) ??
99
+ reviewErrorLoss(ev, resolvedOutcome, indexes) ??
100
+ mergeFailureLoss(ev, resolvedOutcome, indexes));
101
+ }
102
+ /** The refused rule: a run whose verdict refused prices its own per-role cause, not no_change. */
103
+ function refusedLoss(ev) {
104
+ if (ev.result !== "refused")
105
+ return null;
106
+ // A refusal is a deliberate objection that blocks its entry, not quiet idleness — the
107
+ // reason live in the event + lastSummary (src/verdict/refusal.ts). Rank it as its own
108
+ // per-role cause so the digest surfaces the objection instead of pricing the run
109
+ // exactly like a silently idle one; the branch sits BEFORE the no_change one because a
110
+ // refused run is also no_change-class in the outcome table. The example rides the
111
+ // refusal's summary the way a review-rejected cause rides its reason.
112
+ return {
113
+ key: `refused\u0000${eventRole(ev)}`,
114
+ kind: "refused",
115
+ example: typeof ev.summary === "string" ? truncateExample(ev.summary) : "",
116
+ };
117
+ }
118
+ /** The cut-off rule: a no_change run truncated at its context ceiling prices its own cause. */
119
+ function cutoffLoss(ev, cls) {
120
+ if (cls !== "no_change" || ev.cutOff !== true)
121
+ return null;
122
+ // A run truncated at its model's context ceiling ends no_change with cutOff: true
123
+ // (diagnoseNoChange in src/run/run-verdict.ts), and finalizeRun rides that flag onto
124
+ // the run_end. Rank it as its own per-role cause so the digest separates the churn of
125
+ // a context ceiling — a cost of a long session, which the harness resumes — from
126
+ // deliberate idleness; the branch sits BEFORE the no_change one because a cut-off run
127
+ // is also no_change-class in the outcome table. No example, like the no_change cause.
128
+ return { key: `cutoff\u0000${eventRole(ev)}`, kind: "cutoff", example: "" };
129
+ }
130
+ /** The no_change rule: a silent no_change run prices its own per-role cause. */
131
+ function noChangeLoss(ev, cls) {
132
+ if (cls !== "no_change")
133
+ return null;
134
+ return { key: `no_change\u0000${eventRole(ev)}`, kind: "no_change", example: "" };
135
+ }
136
+ /** The clustered rule: a clustered result with no error text prices under the pooled cluster. */
137
+ function clusteredLoss(ev) {
138
+ if (!CLUSTERED_RESULTS.has(String(ev.result)))
139
+ return null;
140
+ // A clustered result with no error text still owns its time: an abort's run_end never
141
+ // carries one (planRunStart clears s.lastError, run-finalize logs error: undefined),
142
+ // and a malformed error run can lose it too, so both take the same placeholder cause the
143
+ // error-clusters section already itemizes rather than dropping the span itemized nowhere.
144
+ const pooled = pooledClusterCause(ev.error);
145
+ return { key: pooled.key, kind: "error-cluster", example: pooled.example };
146
+ }
147
+ /** The review-rejected rule: a queued run whose merge was rejected prices its reason. */
148
+ function reviewRejectedLoss(ev, resolvedOutcome, indexes) {
149
+ if (ev.result !== "queued" || resolvedOutcome?.result !== "rejected")
150
+ return null;
151
+ // Match the merge ATTEMPT, not the pin's sha: every verification rebases its pin onto main
152
+ // before the gate (syncPinToMain), so `review_rejected.head` is the rebased commit while
153
+ // `merge_queued` keeps the authored one — exact-sha equality names no reason for a rebased
154
+ // rejection (BUGS.md 2026-10-10). The rejection for this attempt is the one inside the
155
+ // claimed pin and its outcome, the window no other attempt can occupy.
156
+ const rej = newestAttemptEvent(indexes.rejectedByRunner, eventRole(ev), resolvedOutcome);
157
+ const reasons = stringList(rej?.reasons);
158
+ return {
159
+ key: `review-rejected\u0000${eventRole(ev)}`,
160
+ kind: "review-rejected",
161
+ example: reasons[0] !== undefined ? truncateExample(reasons[0]) : "",
162
+ };
163
+ }
164
+ /** The review_error rule: a queued run whose review process failed prices under its cluster. */
165
+ function reviewErrorLoss(ev, resolvedOutcome, indexes) {
166
+ if (ev.result !== "queued" || resolvedOutcome?.result !== "review_error")
167
+ return null;
168
+ // The review process itself failed (a dead backend, a timeout, no parseable verdict):
169
+ // the pin was kept and re-queued, and the matching `review_failed` event carries the
170
+ // message the review-failure section already clusters. Price the authoring span under
171
+ // that cluster's key — a review timeout pools into the shared `timed out after <dur>`
172
+ // cause via poolTimeoutKey — and fall back to the shared placeholder when no event is
173
+ // visible, so the span is itemized rather than hidden. The match keys on the merge
174
+ // ATTEMPT, not the pin's sha: every verification rebases its pin onto main before the gate
175
+ // (syncPinToMain), so `review_failed.head` is the rebased commit while `merge_queued`
176
+ // keeps the authored one — exact-sha equality missed every rebased verification (BUGS.md
177
+ // 2026-10-10). The review failure for this attempt is the one that falls inside the
178
+ // claimed pin and its outcome, the window no other attempt can occupy.
179
+ const failed = newestAttemptEvent(indexes.reviewFailedByRunner, eventRole(ev), resolvedOutcome);
180
+ const pooled = pooledClusterCause(failed?.message);
181
+ return { key: pooled.key, kind: "error-cluster", example: pooled.example };
182
+ }
183
+ /** The merge-failure rule: a queued run whose merge conflicted or was blocked prices its own
184
+ * cause, with the deadline-killed merge-block special case overriding key/example. */
185
+ function mergeFailureLoss(ev, resolvedOutcome, indexes) {
186
+ if (ev.result !== "queued" ||
187
+ (resolvedOutcome?.result !== "merge_conflict" && resolvedOutcome?.result !== "merge_blocked"))
188
+ return null;
189
+ // The merge failed to merge (a conflict, or a blocked fast-forward/rebase): this
190
+ // authoring span burned. The `merge_failed` event carries no message — writeMergeOutcome
191
+ // logs only result/commit/durationMs/usage — so the cause is named by its own result and
192
+ // takes the shared no-text placeholder, the same fallback a message-less aborted run_end
193
+ // uses, rather than dropping a span the error-class cell already priced (BUGS.md
194
+ // 2026-10-10).
195
+ const key = `${resolvedOutcome.result}\u0000${eventRole(ev)}`;
196
+ const kind = resolvedOutcome.result === "merge_conflict" ? "merge-conflict" : "merge-blocked";
197
+ const example = errorTextOrPlaceholder(undefined);
198
+ // A merge-scope check the harness killed at its own deadline is the team's load, not a
199
+ // verdict on the change: split it into its own cause so the ranking names it.
200
+ const killed = newestAttemptEvent(indexes.deadlineKilledByRunner, eventRole(ev), resolvedOutcome);
201
+ if (kind === "merge-blocked" && killed) {
202
+ return {
203
+ key: `merge_blocked_deadline\u0000${eventRole(ev)}`,
204
+ kind,
205
+ example: `${String(killed.scope)} build check killed at its ${nonNegativeNumber(killed.timeoutMs, 0) / 1000}s deadline (tumwater's own timeout, not a test failure)`,
206
+ };
174
207
  }
175
- return key === null ? null : { key, kind, example };
208
+ return { key, kind, example };
176
209
  }
177
210
  /** The pooled cluster key and truncated example a failure-message source's span prices under:
178
211
  * the shared placeholder when the source carries no message, the pooled render
@@ -117,6 +117,25 @@ export function readTextOrNull(file) {
117
117
  return null; // Missing or unreadable — no data.
118
118
  }
119
119
  }
120
+ /** Read a text file whole, but only when it is a regular file: a symlink (or directory,
121
+ * gitlink, FIFO) is never followed, so a hostile entry that points out of the worktree cannot
122
+ * pull an arbitrary host file's text into whatever reads it (the same lstat policy INV-G3 and
123
+ * INV-G4 pin for the merge gates). A symlink holds no text of its own and its target could
124
+ * name a sensitive host path, so it reads as absent. Null when the file is missing/unreadable,
125
+ * or not a regular file. This is the reader for the two prompt-input files whose text is
126
+ * injected into every model run — PRINCIPLES.md and a role's notes — and their operator
127
+ * readers, so the prompt and the surfaces reporting on those files cannot disagree;
128
+ * readTextOrNull stays for paths where following a symlink is acceptable or impossible. */
129
+ export function readRegularTextOrNull(file) {
130
+ try {
131
+ if (!fs.lstatSync(file).isFile())
132
+ return null; // not a regular file: never followed
133
+ return fs.readFileSync(file, "utf8");
134
+ }
135
+ catch {
136
+ return null; // Missing or unreadable — no data.
137
+ }
138
+ }
120
139
  /** Read a text file whole, or null only when it does not exist (ENOENT) — for the paths that
121
140
  * must never mistake an unreadable file for an absent one. A permission or I/O failure (EACCES,
122
141
  * EISDIR, EMFILE) is rethrown so the caller fails loudly instead of treating the file as gone:
@@ -351,6 +370,17 @@ export function removeQuiet(file) {
351
370
  // Already gone (or unremovable) — cleanup must not throw.
352
371
  }
353
372
  }
373
+ /** Delete `file` and return whether it was there to delete — the toggle-lift idiom shared by
374
+ * resumeTeam and cancelScheduledStop (the team's marker-lift functions): true when a marker
375
+ * was present and removed, false when it was already gone. Relies on removeQuiet's
376
+ * never-throws delete, so a marker vanishing between an existsSync check and the unlink reads
377
+ * as success rather than a failure to surface from a toggle. */
378
+ export function removeIfPresent(file) {
379
+ if (!fs.existsSync(file))
380
+ return false;
381
+ removeQuiet(file);
382
+ return true;
383
+ }
354
384
  /** Delete a directory tree, retrying the transient filesystem races (`ENOTEMPTY`/`EBUSY`/
355
385
  * `EPERM`) that recursive `rmSync` otherwise turns into a hard failure. Node defaults
356
386
  * `maxRetries` to 0, and on macOS an entry appearing between `rmSync`'s walk and its `rmdir`
@@ -16,8 +16,8 @@ export function isJsonObject(value) {
16
16
  * when it is an array and [] otherwise — the one home of the "an event/record field is the
17
17
  * list or it is nothing" read every formatter applies (event-format.ts's review_rejected
18
18
  * reasons, dep_install packages, config_changed keys; src/failure/failure-state-change.ts's
19
- * config_changed keys; time-spend.ts's review-rejection reasons; team-state.ts's paused-roles
20
- * list). A torn or hand-edited line
19
+ * config_changed keys; failure-time-spend.ts's review-rejection reasons; team-state.ts's
20
+ * paused-roles list). A torn or hand-edited line
21
21
  * never throws and never renders a placeholder: the field reads as absent, and the caller's
22
22
  * empty-list branch fires. Foreign entries inside an array are dropped for the same reason —
23
23
  * letting them through made `join` render "[object Object]" and handed
@@ -36,9 +36,9 @@ export function stringList(value) {
36
36
  * build_stale, slot_wait, restart, and permit_reserve renderers (aheadCommits, waitedMs,
37
37
  * abortedRuns, reserve → 0); phrases.ts's budgetPhrase (spentUsd, capUsd → 0); redeploy.ts's
38
38
  * autoRestartRecord (lastAt → null); build/build-info.ts's readBuildInfo (builtAt → 0);
39
- * failure/failure-state-change.ts's model_fallback_ended (durationMs → 0) and build_stale
40
- * renderer (aheadCommits → 0); gates/maintenance-quota.ts's foldLine and seed (ev.ts → null);
41
- * team/orchestrator-info.ts's isFiniteNumber (v → undefined); team/team-state.ts's
39
+ * failure/failure-state-change.ts's build_stale renderer (aheadCommits → 0);
40
+ * gates/maintenance-quota.ts's foldLine and seed (ev.ts → null);
41
+ * team/team-orchestrator-info.ts's isFiniteNumber (v → undefined); team/team-state.ts's
42
42
  * standingMarker, pausedRoles, and standingRolesUntil (until → undefined);
43
43
  * operator/operator-requests.ts's consumeWakeRequest (notBeforeMs → undefined);
44
44
  * git/slots-state.ts's readSlotRecord (pinnedAt, lastReleasedAt → null); git/worktree-use.ts's
@@ -57,9 +57,9 @@ export function finiteNumber(value, fallback) {
57
57
  * runner/runner-state-load.ts's healNumber (every numeric RunnerState field → 0),
58
58
  * run/run-usage.ts's counter (each persisted usage count → 0), pi/pi-stream.ts's usageNumber
59
59
  * (one wire usage number → 0), ui/progress-data.ts's message_end read (totalTokens → the
60
- * previous context, output and its peak floor → 0), time-spend.ts's runDurationMs (an event's
61
- * durationMs → null, the "no own duration" marker) and its merge_blocked_deadline example
62
- * (the deadline-killed check's timeoutMs → 0, so a torn deadline prices as 0s, never NaN);
60
+ * previous context, output and its peak floor → 0), failure-time-spend.ts's runDurationMs (an
61
+ * event's durationMs → null, the "no own duration" marker) and its merge_blocked_deadline
62
+ * example (the deadline-killed check's timeoutMs → 0, so a torn deadline prices as 0s, never NaN);
63
63
  * pi-extension/context-budget.ts's
64
64
  * contextNote (usedTokens, usedWindow → null), pi-extension/context-shake.ts's percentOf
65
65
  * (tokens, contextWindow → null), merge/merge-queue.ts's isMergeEntry (run, enqueuedAt,
@@ -1,4 +1,4 @@
1
- import { readTextOrNull, sameStatIdentity, statOrNull } from "./files.js";
1
+ import { readRegularTextOrNull, sameStatIdentity, statOrNull } from "./files.js";
2
2
  /** Safety cap so a stat-keyed cache can never grow unbounded (e.g. many short-lived roots in
3
3
  * tests). Evicting only costs one re-read per file on the next call. */
4
4
  const MAX_STAT_CACHED = 64;
@@ -32,15 +32,19 @@ export function cachedByStat(cache, key, file, load, clone) {
32
32
  return clone(value);
33
33
  }
34
34
  /** One stat-cached read of a text file, or null when it is missing or unreadable — the shared
35
- * load half over `cachedByStat` for the readers that parse a markdown/text file: readTextOrNull
36
- * reads it, and a null (no data) propagates through so a render path never throws on the file's
37
- * state. `parse` runs only on a cache miss and receives the file's text (never null); `clone`
38
- * hands each caller its own copy. The callers: backlog.ts's sectionEntries and
39
- * sectionCompletionDates, and backlog-structure.ts's strandedPlanEntriesFor and
40
- * actionableEntryRangesFor, and backlog-index.ts's renderBacklogIndexBlock. */
35
+ * load half over `cachedByStat` for the readers that parse a markdown/text file: it reads the
36
+ * file through readRegularTextOrNull, so a symlink (or directory/gitlink) is never followed —
37
+ * a hostile entry dropped at a backlog file path cannot pull an arbitrary host file's text into
38
+ * the run prompts and surfaces these readers feed (the same lstat policy INV-G3/G4 pin for the
39
+ * prompt readers and merge gates) — and a null (no data) propagates through so a render path
40
+ * never throws on the file's state. `parse` runs only on a cache miss and receives the file's
41
+ * text (never null); `clone` hands each caller its own copy. The callers: backlog.ts's
42
+ * sectionEntries and sectionCompletionDates, backlog-structure.ts's strandedPlanEntriesFor and
43
+ * actionableEntryRangesFor, backlog-index-data.ts's actionableEntryRangesFor, and
44
+ * backlog-index.ts's renderBacklogIndexBlock. */
41
45
  export function cachedTextByStat(cache, key, file, parse, clone) {
42
46
  return cachedByStat(cache, key, file, () => {
43
- const md = readTextOrNull(file); // Missing or unreadable — no data.
47
+ const md = readRegularTextOrNull(file); // Missing, unreadable, or not a regular file — no data.
44
48
  return md === null ? null : parse(md);
45
49
  }, clone);
46
50
  }