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
@@ -3,8 +3,7 @@
3
3
  * "Billing" and §8 "The subscription usage window"). Persisted at
4
4
  * `.tumwater/state/<backend>-account.json` (plan design grounds/codex-backend.md "Groundwork:
5
5
  * the subscription account is per backend", item 1), written only by the orchestrator process,
6
- * so the sibling parts that throttle or display the subscription window read one answer instead
7
- * of re-deriving it per surface.
6
+ * so the sibling parts read one answer instead of re-deriving it per surface.
8
7
  *
9
8
  * The file is a cache, never a source of truth: a run that reports billing overwrites it, and a
10
9
  * missing or unreadable file reads as "no subscription". `resetsAt` is stored as epoch
@@ -28,85 +27,6 @@ export function subscriptionWindowRejected(run) {
28
27
  run.billing === "subscription" &&
29
28
  (run.rateWindows ?? []).some((w) => w.status === "rejected" && w.isUsingOverage !== true));
30
29
  }
31
- /** The subscription account's used-up usage window, or null: a window reading `rejected` with
32
- * no overage, or (with `includeOverage`, the default) a window whose current reading shows
33
- * `isUsingOverage`, and a `resetsAt` still ahead of `now`; among several, the one that resets
34
- * last. With a `reserve` (config `backends.<id>.windowReserve`), a window whose utilization
35
- * has reached its type's fraction counts too, and comes back with `reserve: true`; a rejected
36
- * or overage window outranks a reserve one, so the flag is set only when no window is used up
37
- * for real. The budget gate's window move (pollBudgetGate) reads the default; the team hold
38
- * (windowObservation) passes `includeOverage: false`, because overage never holds a
39
- * seam. Only subscription billing produces one; a missing account reads null. */
40
- export function windowUsedUp(account, now, includeOverage = true, reserve) {
41
- if (account?.billing !== "subscription")
42
- return null;
43
- let furthest = null;
44
- let reserved = null;
45
- for (const [type, w] of Object.entries(account.windows)) {
46
- if (typeof w.resetsAt !== "number" || w.resetsAt <= now)
47
- continue;
48
- const used = w.isUsingOverage === true ? includeOverage : w.status === "rejected";
49
- if (used) {
50
- if (furthest === null || w.resetsAt > furthest.resetsAt)
51
- furthest = { type, resetsAt: w.resetsAt };
52
- continue;
53
- }
54
- if (windowReserved(w, reserve?.[type]) && (reserved === null || w.resetsAt > reserved.resetsAt))
55
- reserved = { type, resetsAt: w.resetsAt, reserve: true };
56
- }
57
- return furthest ?? reserved;
58
- }
59
- /** Has this window's utilization reached its reserve fraction? False without a fraction or a
60
- * reading. */
61
- export function windowReserved(w, fraction) {
62
- return fraction !== undefined && typeof w.utilization === "number" && w.utilization >= fraction;
63
- }
64
- /** Status severity for ordering: a rejected window limits, an allowed_warning nears its limit,
65
- * every other reading (allowed/ok) rides at the bottom, and a reset one no longer limits. */
66
- const WINDOW_RANK = { rejected: 3, allowed_warning: 2, reset: 0 };
67
- /** The window type as a compact label: `five_hour` → `5h`, `seven_day` → `7d`; an unknown
68
- * type falls back to its words with underscores as spaces. The one home of the window spelling
69
- * the status badge and the role view share. */
70
- export function windowLabel(type) {
71
- const m = /^(\w+)_(hour|day|week)$/.exec(type);
72
- if (!m)
73
- return type.replace(/_/g, " ");
74
- const words = { one: "1", two: "2", three: "3", four: "4", five: "5", six: "6", seven: "7", eight: "8", nine: "9", ten: "10" };
75
- const unit = m[2] === "hour" ? "h" : m[2] === "day" ? "d" : "w";
76
- const word = m[1] ?? "";
77
- return `${words[word] ?? word}${unit}`;
78
- }
79
- /** The fraction of a subscription usage window consumed (0–1) as a whole-number percent: a
80
- * `0.64` utilization reads `64`. The one home of the fraction→percent conversion, shared by
81
- * the status badge, the doctor's hold reason and the event formatter so they render a window's
82
- * fill identically (the browser dashboard's own window line is embedded client-side and keeps
83
- * its inline copy). */
84
- export function utilizationPct(utilization) {
85
- return Math.round(utilization * 100);
86
- }
87
- /** Order two window entries by the windowEntries rule: status severity, then the reported
88
- * utilization (a missing reading sorts below any number), then the earlier reset, then the
89
- * type name. Exported so a list of per-backend blocks can be ordered by their primary entry
90
- * the same way windowEntries orders one account's windows (part 3a/9). */
91
- export function compareWindowEntries(a, b) {
92
- const rank = (WINDOW_RANK[b.status] ?? 1) - (WINDOW_RANK[a.status] ?? 1);
93
- if (rank !== 0)
94
- return rank;
95
- const util = (b.utilization ?? -1) - (a.utilization ?? -1);
96
- if (util !== 0)
97
- return util;
98
- const reset = (a.resetsAt ?? Number.POSITIVE_INFINITY) - (b.resetsAt ?? Number.POSITIVE_INFINITY);
99
- if (reset !== 0)
100
- return reset;
101
- return a.type.localeCompare(b.type);
102
- }
103
- /** The account's windows as a list, most constrained first (by compareWindowEntries). Empty
104
- * when the account reported none. */
105
- export function windowEntries(account) {
106
- return Object.entries(account.windows)
107
- .map(([type, w]) => ({ type, ...w }))
108
- .sort((a, b) => compareWindowEntries(a, b));
109
- }
110
30
  /** Read the persisted account state for a backend, or null when it is absent or unreadable. A
111
31
  * file without a usable top-level `observedAt` reads as absent (the plan's torn-file rule),
112
32
  * and a window whose `resetsAt` is at or before `now` reads back with `status: "reset"` — the
@@ -144,8 +64,7 @@ function readWindow(value, fileObservedAt, now) {
144
64
  const resetsAt = finiteNumber(value.resetsAt, Number.NaN);
145
65
  const resets = Number.isFinite(resetsAt);
146
66
  // utilization heals like the two stamps above: JSON's `1e999` parses to Infinity, and a
147
- // non-finite fraction would render as `Infinity%` in the badges and poison the
148
- // windowEntries comparator (Infinity - Infinity is NaN).
67
+ // non-finite fraction would poison any consumer's utilization read.
149
68
  const utilization = finiteNumber(value.utilization, undefined);
150
69
  const reset = resets && resetsAt <= now;
151
70
  const window = {
@@ -319,10 +319,9 @@ function archiveCommitWhat(plan) {
319
319
  }
320
320
  /** One live backlog file's finished-history rows as liveFinishedHistory reports them, phrased
321
321
  * and joined: `file ## <section> holds N finished entries (X KB)` rows separated by "; " — the
322
- * shared detail head the doctor's `backlog archive` check (checkLiveFinished) and the always-on
323
- * `backlog-archive` alert (src/ui/team-alert-backlog.ts) both render, so the row shape, the
324
- * plural, and the KB rounding cannot drift between the two. Each caller appends its own trailing
325
- * explanation, so the two texts stay byte-identical up to that suffix. */
322
+ * detail head the doctor's `backlog archive` check (checkLiveFinished) renders, so the row
323
+ * shape, the plural, and the KB rounding stay stable. Each caller appends its own trailing
324
+ * explanation. */
326
325
  export function liveFinishedRowList(rows) {
327
326
  return rows.map((l) => `${l.file} ## ${l.section} holds ${plural(l.entries, "finished entry", "finished entries")} (${Math.round(l.bytes / 1024)} KB)`).join("; ");
328
327
  }
@@ -10,6 +10,12 @@ import { groupBlockedSeries, partsLabel } from "./backlog-index-data.js";
10
10
  import { rejectUnknownArgs } from "../cli/cli-flag-specs.js";
11
11
  import { refuseTeamWorkspace } from "../cli/cli-repo-gate.js";
12
12
  import { sectionNoun, showEntry } from "./backlog-write.js";
13
+ /** `bug --list --done -n` / `plan --list --done -n` ceiling: bounds how many finished
14
+ * entries one list shows, so a typo'd huge count fails fast with the shared `must be between
15
+ * 1 and <max>` wording (the logs/history/report pattern) instead of silently meaning
16
+ * "everything". The finished lists are small (PLANS.md/BUGS.md), so the cap never meaningfully
17
+ * binds — it exists for the uniform count-flag contract and the clear over-range rebuke. */
18
+ const BACKLOG_MAX_LISTED = 100_000;
13
19
  /** Ascending positions as runs: consecutive ones join with an en dash (`13–17`), runs separate
14
20
  * with commas (`13–15, 18`). */
15
21
  function positionRuns(positions) {
@@ -59,7 +65,7 @@ function parseListArgs(args, command) {
59
65
  }
60
66
  if (arg === "-n") {
61
67
  rejectRepeatFlag(count !== null, "-n");
62
- count = parseCountFlag("-n", args[++i]);
68
+ count = parseCountFlag("-n", args[++i], BACKLOG_MAX_LISTED);
63
69
  continue;
64
70
  }
65
71
  if (arg === "--list") {
@@ -114,10 +120,10 @@ function announceFinishedList(root, fileName, bugSection, grep, count, json) {
114
120
  const header = pattern === null ? `${what} in ${fileName}, newest first:` : `${what} in ${fileName} matching "${pattern}", newest first:`;
115
121
  say(`${header}\n${lines.join("\n")}`);
116
122
  }
117
- /** The active half of the list shell: the open entries with their holds, in-progress claim
118
- * marks and age suffixes, optionally reduced to the ready ones, as `--json` or prose, grouping
119
- * a plan's blocked parts into one line (groupBlockedSeries). */
120
- function announceActiveList(root, fileName, command, bugSection, sectionTitle, grep, ready, json) {
123
+ /** The active list's read-and-filter phase: reads the section's entries and each position's
124
+ * hold and live claimer, numbers them unfiltered, keeps the `--grep` matches under those
125
+ * positions, drops to the `--ready` survivors, counts what `--ready` hid, and names the noun. */
126
+ function activeListView(root, bugSection, sectionTitle, grep, ready) {
121
127
  const entries = bugSection ? openBugEntries(root) : plannedPlanEntries(root);
122
128
  const holds = backlogHolds(root);
123
129
  const entryHolds = bugSection ? holds.bugs : holds.plans;
@@ -128,47 +134,62 @@ function announceActiveList(root, fileName, command, bugSection, sectionTitle, g
128
134
  const numbered = entries.map((entry, i) => ({ entry, position: i + 1 }));
129
135
  const matches = grep === null ? numbered : numbered.filter((m) => entryMatches(m.entry, grep));
130
136
  const kept = ready ? matches.filter((m) => (entryHolds[m.position - 1] ?? null) === null) : matches;
131
- const hidden = matches.length - kept.length;
132
- if (json) {
133
- sayJson({
134
- entries: kept.map(({ entry, position }) => {
135
- const since = filingDate(entry.title);
136
- return {
137
- position,
138
- title: entry.title,
139
- body: entry.body,
140
- hold: entryHolds[position - 1] ?? null,
141
- inProgress: entryRunners[position - 1] ?? null,
142
- // The filing stamp the entry's own heading carries, and how many days it has waited
143
- // since then (both present only when the heading names a stamp — an undated entry
144
- // keeps the pre-existing shape with neither key).
145
- ...(since === null ? {} : { since, ageDays: ageInDays(since) }),
146
- };
147
- }),
148
- });
149
- return;
150
- }
151
- const noun = sectionNoun(sectionTitle);
152
- if (grep === null && entries.length === 0) {
153
- say(`no ${noun}s`);
154
- return;
155
- }
156
- if (matches.length === 0) {
157
- say(`no ${noun}s match "${grep}"`);
158
- return;
159
- }
160
- if (ready && kept.length === 0) {
161
- say(`no ${noun}s ready (${hidden} held; run \`tumwater ${command} --list\` to see them)`);
162
- return;
163
- }
137
+ return {
138
+ entries,
139
+ entryHolds,
140
+ entryRunners,
141
+ matches,
142
+ kept,
143
+ hidden: matches.length - kept.length,
144
+ noun: sectionNoun(sectionTitle),
145
+ grep,
146
+ ready,
147
+ };
148
+ }
149
+ /** The `--json` body: each kept entry as its position, title, verbatim body, hold,
150
+ * in-progress claim and — only when the heading names a filing stamp — its `since`/`ageDays`. */
151
+ function activeListJson(view) {
152
+ const { kept, entryHolds, entryRunners } = view;
153
+ return {
154
+ entries: kept.map(({ entry, position }) => {
155
+ const since = filingDate(entry.title);
156
+ return {
157
+ position,
158
+ title: entry.title,
159
+ body: entry.body,
160
+ hold: entryHolds[position - 1] ?? null,
161
+ inProgress: entryRunners[position - 1] ?? null,
162
+ // The filing stamp the entry's own heading carries, and how many days it has waited
163
+ // since then (both present only when the heading names a stamp — an undated entry
164
+ // keeps the pre-existing shape with neither key).
165
+ ...(since === null ? {} : { since, ageDays: ageInDays(since) }),
166
+ };
167
+ }),
168
+ };
169
+ }
170
+ /** The prose `no …` line for an empty list, a `--grep` with no match, or a `--ready` list
171
+ * whose every match is held; null when `view.kept` has rows to print. */
172
+ function activeListEmptyLine(view, command) {
173
+ const { entries, matches, kept, hidden, noun, grep, ready } = view;
174
+ if (grep === null && entries.length === 0)
175
+ return `no ${noun}s`;
176
+ if (matches.length === 0)
177
+ return `no ${noun}s match "${grep}"`;
178
+ if (ready && kept.length === 0)
179
+ return `no ${noun}s ready (${hidden} held; run \`tumwater ${command} --list\` to see them)`;
180
+ return null;
181
+ }
182
+ /** The prose body of a non-empty active list: the bug list's numbered `line` rows, or the
183
+ * plan list's `groupBlockedSeries` rows, each with the hidden-held notice when `hidden > 0`. */
184
+ function activeListProse(view, fileName, command, bugSection) {
185
+ const { kept, entryHolds, entryRunners, hidden, noun } = view;
164
186
  const hiddenNote = `(${hidden} held entries hidden; run \`tumwater ${command} --list\` to see them)`;
165
187
  const line = (entry, i) => `${entry.title}${holdMark(entryHolds[i] ?? null)}${inProgressMark(entryRunners[i] ?? null)}${entryAgeSuffix(entry.title)}`;
166
188
  if (bugSection) {
167
189
  const bugLines = kept.map(({ entry, position }) => `${position}. ${line(entry, position - 1)}`);
168
190
  if (hidden > 0)
169
191
  bugLines.push(hiddenNote);
170
- say(`${noun}s in ${fileName}:\n${bugLines.join("\n")}`);
171
- return;
192
+ return `${noun}s in ${fileName}:\n${bugLines.join("\n")}`;
172
193
  }
173
194
  // Plans: a series' blocked parts show as one line naming their positions, as the prompt's
174
195
  // backlog index does; numbering is unchanged so --show/--edit/--cancel <n> reach every part.
@@ -189,7 +210,26 @@ function announceActiveList(root, fileName, command, bugSection, sectionTitle, g
189
210
  lines.push("(each blocked series is one line; --show <n> prints a part, --json lists every entry)");
190
211
  if (hidden > 0)
191
212
  lines.push(hiddenNote);
192
- say(`${noun}s in ${fileName}:\n${lines.join("\n")}`);
213
+ return `${noun}s in ${fileName}:\n${lines.join("\n")}`;
214
+ }
215
+ /** The active half of the list shell: the open entries with their holds, in-progress claim
216
+ * marks and age suffixes, optionally reduced to the ready ones, as `--json` or prose, grouping
217
+ * a plan's blocked parts into one line (groupBlockedSeries). Orchestrates the four named
218
+ * phases below — the read-and-filter view (activeListView), the `--json` body (activeListJson),
219
+ * the empty-list prose line (activeListEmptyLine) and the bug/plan prose body
220
+ * (activeListProse). */
221
+ function announceActiveList(root, fileName, command, bugSection, sectionTitle, grep, ready, json) {
222
+ const view = activeListView(root, bugSection, sectionTitle, grep, ready);
223
+ if (json) {
224
+ sayJson(activeListJson(view));
225
+ return;
226
+ }
227
+ const empty = activeListEmptyLine(view, command);
228
+ if (empty !== null) {
229
+ say(empty);
230
+ return;
231
+ }
232
+ say(activeListProse(view, fileName, command, bugSection));
193
233
  }
194
234
  /** The shared shell of the `bug --list` and `plan --list` CLI cases — the read half of the
195
235
  * operator queue whose `--edit`/`--cancel` flags address entries by position. Reads the same
@@ -238,8 +238,35 @@ export async function topAndAnnounce(root, args, command, top) {
238
238
  * $VISUAL/$EDITOR and the saved buffer goes through `editText`. A saved buffer, positional or
239
239
  * `--file` text that rebuilds the entry byte-for-byte writes and commits nothing (`unchanged
240
240
  * <command> <n>: <title>`), and an out-of-range position fails
241
- * before the editor launches. */
241
+ * before the editor launches. The flag vocabulary, editor round-trip, unchanged report and
242
+ * commit-plus-hold announcement each live in their own named helper below. */
242
243
  export async function editAndAnnounce(root, args, command, edit, editText) {
244
+ const { path, json, positionals, position } = parseEditArgs(args, command);
245
+ await requireReadyRepo(root);
246
+ let text = null;
247
+ if (path === null && positionals.length === 0) {
248
+ const opened = openEditInEditor(root, command, position);
249
+ if (!opened.changed) {
250
+ sayUnchangedEdit(command, position, json, opened.file, opened.title);
251
+ return;
252
+ }
253
+ text = opened.text;
254
+ }
255
+ else if (path !== null)
256
+ text = readPromptFileArg(path);
257
+ const edited = text === null ? edit(position, positionals) : editText(position, text);
258
+ if (edited.unchanged) {
259
+ sayUnchangedEdit(command, position, json, edited.file, edited.title);
260
+ return;
261
+ }
262
+ await announceEdited(root, command, position, json, edited);
263
+ }
264
+ /** The flag vocabulary of the `--edit` shell: `--file <path>` is extracted and the repeat
265
+ * of the flag refused; the remaining tokens go through scanProseFlags (the `--json` boolean
266
+ * at most once, an `--edit <n>` position required here through scanProseFlags' one valued
267
+ * flag), and a stray positional alongside `--file` is refused. `fail` returns never, so on
268
+ * return `position` is a `number`. */
269
+ function parseEditArgs(args, command) {
243
270
  const fileFlag = args.indexOf("--file");
244
271
  const path = fileFlag >= 0 ? promptFileValue(args[fileFlag + 1]) : null;
245
272
  const rest = path === null ? args : args.filter((_, i) => i !== fileFlag && i !== fileFlag + 1);
@@ -253,32 +280,36 @@ export async function editAndAnnounce(root, args, command, edit, editText) {
253
280
  rejectStrayWithFile(positionals[0]);
254
281
  if (position === null)
255
282
  fail(`--edit needs a position (usage: tumwater ${command} --edit <n>)`);
256
- await requireReadyRepo(root);
257
- const sayUnchanged = (file, title) => {
258
- if (json)
259
- sayJson({ file, title, changed: false });
260
- else
261
- say(`unchanged ${command} ${position}: ${title}`);
262
- };
263
- let text = null;
264
- if (path === null && positionals.length === 0) {
265
- const shown = showEntry(root, command, position);
266
- const before = renderShownEntry(shown);
267
- const after = editInEditor(before, `${command}-${position}.md`);
268
- if (after.trim() === before.trim()) {
269
- const file = command === "bug" ? "BUGS.md" : "PLANS.md";
270
- sayUnchanged(file, shown.title);
271
- return;
272
- }
273
- text = after;
274
- }
275
- else if (path !== null)
276
- text = readPromptFileArg(path);
277
- const edited = text === null ? edit(position, positionals) : editText(position, text);
278
- if (edited.unchanged) {
279
- sayUnchanged(edited.file, edited.title);
280
- return;
283
+ return { path, json, positionals, position };
284
+ }
285
+ /** The no-op wording when an edit changes nothing: a `--json` `{ file, title, changed: false }`
286
+ * payload, else a single `unchanged <command> <n>: <title>` line. */
287
+ function sayUnchangedEdit(command, position, json, file, title) {
288
+ if (json)
289
+ sayJson({ file, title, changed: false });
290
+ else
291
+ say(`unchanged ${command} ${position}: ${title}`);
292
+ }
293
+ /** The editor round-trip taken when the `--edit` shell has neither `--file` nor positionals:
294
+ * the entry rendered as `--show` prints it (showEntry/renderShownEntry) opens in
295
+ * $VISUAL/$EDITOR, and a saved buffer that trims to the same text reports `{ changed: false }`
296
+ * with the backing file's name and the shown title; otherwise the saved text is returned as
297
+ * `{ changed: true, text }`. */
298
+ function openEditInEditor(root, command, position) {
299
+ const shown = showEntry(root, command, position);
300
+ const before = renderShownEntry(shown);
301
+ const after = editInEditor(before, `${command}-${position}.md`);
302
+ if (after.trim() === before.trim()) {
303
+ return { changed: false, file: command === "bug" ? "BUGS.md" : "PLANS.md", title: shown.title };
281
304
  }
305
+ return { changed: true, text: after };
306
+ }
307
+ /** The commit-and-announce tail of the `--edit` shell: the changed entry is committed through
308
+ * commitOperatorBacklogEdit with the `Edit <command>: <title>` subject (warned on an
309
+ * uncommitted worktree), the still-held role from the one before `position` is picked for the
310
+ * hold report, and the `--json` payload (with `hold` when held) or the prose `edited …` line
311
+ * plus stillHeldNote is printed. */
312
+ async function announceEdited(root, command, position, json, edited) {
282
313
  warnIfUncommitted(await commitOperatorBacklogEdit(root, edited.file, stampedSubject("operator", `Edit ${command}: ${edited.title}`)), edited.file); // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
283
314
  const holds = backlogHolds(root);
284
315
  const hold = (command === "bug" ? holds.bugs : holds.plans)[position - 1] ?? null;
@@ -10,53 +10,21 @@
10
10
  import path from "node:path";
11
11
  import { statOrNull } from "../files/files.js";
12
12
  import { allLiveClaims } from "../runner/runner-state-load.js";
13
- import { escapeRegExp, firstTrimmedLineStartingWith } from "../text/text.js";
14
- import { HELD_TAG_PREFIX, NEEDS_REVIEW_PREFIX, PRIOR_HELD_PREFIX } from "../roles/role-guidance.js";
13
+ import { firstTrimmedLineStartingWith } from "../text/text.js";
14
+ import { NEEDS_REVIEW_PREFIX } from "../roles/role-guidance.js";
15
15
  import { baseRoleOf } from "../roles/runner-ids.js";
16
16
  import { roleLabel } from "../roles/role-labels.js";
17
17
  import { actionableEntryRangesFor } from "./backlog-index-data.js";
18
18
  import { entryKey, partKey, plannedPartKeys, plansWaitingOn, requiredParts, seriesPart } from "./backlog-parts.js";
19
19
  import { finishedFiles, openBugEntries, plannedPlanEntries } from "./backlog.js";
20
- /** The number of holds an entry's count must reach before a Needs-review note
21
- * becomes a `held-for-operator` hold (the operator's decision, surfaced by parts 2/3 and 3/3). */
22
- export const OPERATOR_HOLD_THRESHOLD = 3;
23
- /** The held count a body records: the integer in the first Needs-review note
24
- * line's `(held N)` tag, else 0. A present but non-numeric `(held …)` tag is 0 (and stops the
25
- * scan), so a malformed tag never throws. Pure over the body text — the data-layer read half of
26
- * the convergence backstop. No writer emits a `(held N)` tag anymore and the `**Previously held
27
- * N**` markers left on disk are never read, so a freshly written flat note always reads 0 and a
28
- * new hold never escalates to the operator's desk. */
29
- export function heldCount(body) {
30
- const tagRe = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`);
31
- for (const raw of body.split("\n")) {
32
- const line = raw.trimStart();
33
- const note = line.startsWith(NEEDS_REVIEW_PREFIX);
34
- if (!note)
35
- continue;
36
- const tag = tagRe.exec(line);
37
- if (tag !== null)
38
- return Number(tag[1]);
39
- if (line.includes(HELD_TAG_PREFIX))
40
- return 0;
41
- }
42
- return 0;
43
- }
44
- /** The `(held N)` count on a single Needs-review note line, or undefined when the
45
- * line is not such a note or carries no numeric tag. The per-line form of heldCount, so a writer
46
- * that drops notes (withoutHeldForOperatorNotes) can gate on the same threshold without scanning
47
- * the whole body. */
48
- function lineHeldCount(line) {
49
- const trimmed = line.trimStart();
50
- const isNote = trimmed.startsWith(NEEDS_REVIEW_PREFIX);
51
- if (!isNote)
52
- return undefined;
53
- const tag = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`).exec(trimmed);
54
- return tag === null ? undefined : Number(tag[1]);
55
- }
56
20
  /** The `**Refused …` note prefix a refusing run writes. Every hold note — this one and the
57
21
  * imported Needs-review prefix — is matched as a line prefix (hasNoteLine)
58
22
  * so a mention in prose never holds an entry. */
59
23
  const REFUSED_PREFIX = "**Refused ";
24
+ /** The legacy marker the old increment rule left beside a Needs-review note's prior held count
25
+ * (`**Previously held N−1**` beside the live `(held N)`), dropped by withoutNeedsReviewNotes so
26
+ * a release leaves no orphan. No writer emits it anymore; only survives on live notes. */
27
+ const PRIOR_HELD_PREFIX = "**Previously held ";
60
28
  /** True when `body` carries a line (ignoring leading indentation) that begins with `prefix`.
61
29
  * The one home of the hold-note rule: a note holds its entry only when written as its own
62
30
  * line, never when its prefix is quoted inside prose. */
@@ -80,6 +48,24 @@ export function refusalNote(body) {
80
48
  export function needsReviewNote(body) {
81
49
  return noteText(body, NEEDS_REVIEW_PREFIX);
82
50
  }
51
+ /** True when `entry` is parked with the plan role — its Needs-review note is the feature
52
+ * role's too-large note, the automatic convergence hold, or a final-round rejection, all of
53
+ * which the plan role clears by splitting or replanning (role-catalog.ts `plan` find). False
54
+ * for the bugfix role's wait-for-the-next-failure note ("by bugfix: …"), which parks the
55
+ * entry for selfcheck to lift when the cluster fails again (selfcheck step 4) and which no
56
+ * role can act on until then. entryHold returns "needs-review" for every such note (all of
57
+ * them park the entry), so the doctor's `stalled entries` row (checkStalledEntries) — the
58
+ * stalled-with-the-plan-role surface — classifies by this agreed subset, keeping the bugfix
59
+ * parks out of the plan-role blame. Reads the note's text after the NEEDS_REVIEW_PREFIX once,
60
+ * so the row cannot disagree on which notes the plan role clears; the eligibility mark
61
+ * (holdMark) continues to hold every needs-review note, a by-bugfix park included. */
62
+ export function isPlanStalled(entry) {
63
+ const note = needsReviewNote(entry.body);
64
+ if (note === null)
65
+ return false;
66
+ // A "by bugfix:" note is a wait for evidence that selfcheck clears, not plan-role work.
67
+ return !note.includes("by bugfix");
68
+ }
83
69
  /** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
84
70
  * applies). When a dropped note sat between two blank lines, the blank line after it goes too,
85
71
  * so releasing leaves one blank line where the note was. Every other line is kept. */
@@ -101,33 +87,25 @@ function withoutNoteLines(lines, prefix) {
101
87
  export function withoutRefusalNotes(lines) {
102
88
  return withoutNoteLines(lines, REFUSED_PREFIX);
103
89
  }
104
- /** `lines` with every Needs-review note whose `(held N)` count reached
105
- * OPERATOR_HOLD_THRESHOLD removed, together with its `**Previously held N−1**` line (the prior
106
- * count the increment rule recorded beside the live `(held N)`), so a `--release` lift leaves
107
- * neither behind and the entry becomes eligible. Drops nothing when no note is at or past the
108
- * threshold, so release still cannot lift a live first/second Needs-review hold. Consecutive
109
- * dropped lines are collapsed to one blank line, the same rule withoutNoteLines applies to a
110
- * single dropped note. */
111
- export function withoutHeldForOperatorNotes(lines) {
112
- const drop = new Set();
113
- const priorHeld = new Set();
114
- lines.forEach((raw, i) => {
115
- const c = lineHeldCount(raw);
116
- if (c !== undefined && c >= OPERATOR_HOLD_THRESHOLD) {
117
- priorHeld.add(c - 1);
118
- drop.add(i);
119
- }
120
- });
121
- if (priorHeld.size === 0)
90
+ /** `lines` without its Needs-review note lines and the legacy `**Previously held N**`
91
+ * markers the old increment rule left beside them, the release's lift when the entry has no
92
+ * Refused note: a `--release` that finds no Refused note drops every Needs-review note line so
93
+ * the entry becomes eligible, and takes each `**Previously held N**` marker in the slice with
94
+ * it, so the lift leaves no orphan whether or not the note still carries a `(held N)` tag.
95
+ * Nothing changes when the slice carries no Needs-review note, so a marker that sits on an
96
+ * eligible entry stays put. Every other line, Refused notes included, is kept, so the
97
+ * Needs-review lift never lifts a refusal. */
98
+ export function withoutNeedsReviewNotes(lines) {
99
+ // A lift only happens when the slice carries a Needs-review note; without one, the slice
100
+ // (and any legacy prior-held marker on it) is left byte for byte.
101
+ if (!lines.some((raw) => raw.trimStart().startsWith(NEEDS_REVIEW_PREFIX)))
122
102
  return lines;
103
+ const drop = new Set();
123
104
  lines.forEach((raw, i) => {
124
- if (drop.has(i))
125
- return;
126
105
  const trimmed = raw.trimStart();
127
- if (!trimmed.startsWith(PRIOR_HELD_PREFIX))
128
- return;
129
- const count = /\d+/.exec(trimmed.slice(PRIOR_HELD_PREFIX.length));
130
- if (count !== null && priorHeld.has(Number(count[0])))
106
+ // Every Needs-review note goes, and so does each `**Previously held N**` marker the old
107
+ // increment rule wrote with it.
108
+ if (trimmed.startsWith(NEEDS_REVIEW_PREFIX) || trimmed.startsWith(PRIOR_HELD_PREFIX))
131
109
  drop.add(i);
132
110
  });
133
111
  const out = [];
@@ -140,7 +118,7 @@ export function withoutHeldForOperatorNotes(lines) {
140
118
  while (j < lines.length && drop.has(j))
141
119
  j++;
142
120
  // A dropped run between two blank lines drops the blank after it too, so the lift leaves
143
- // one blank line where the note was (the same rule withoutNoteLines applies to one).
121
+ // one blank line where the note was (the same rule withoutNoteLines applies to one note).
144
122
  if (j < lines.length && (lines[i - 1] ?? "x").trim() === "" && (lines[j] ?? "x").trim() === "")
145
123
  j++;
146
124
  i = j - 1;
@@ -209,13 +187,10 @@ export function entryHold(entry, planned) {
209
187
  export function entryHoldAgainst(entry, plannedParts) {
210
188
  if (hasNoteLine(entry.body, REFUSED_PREFIX))
211
189
  return "refused";
212
- if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX)) {
213
- // A note whose held count reached OPERATOR_HOLD_THRESHOLD stops out of the role loop:
214
- // neither the plan role (planBacklogFull) nor this role takes it.
215
- return heldCount(entry.body) >= OPERATOR_HOLD_THRESHOLD
216
- ? "held-for-operator" // vocabulary-ok: the operator-held string names the operator hold decision
217
- : "needs-review";
218
- }
190
+ // A Needs-review note — the feature role's too-large note or the convergence hold's — parks the
191
+ // entry for the plan role to split or replan, at any count: nothing escalates to the operator.
192
+ if (hasNoteLine(entry.body, NEEDS_REVIEW_PREFIX))
193
+ return "needs-review";
219
194
  const refs = requiredParts(entry.title);
220
195
  if (refs.length === 0)
221
196
  return null;
@@ -235,8 +210,6 @@ export function holdMark(hold) {
235
210
  return " [blocked: refused]";
236
211
  if (hold === "needs-review")
237
212
  return " [blocked: needs review]";
238
- if (hold === "held-for-operator")
239
- return " [blocked: held for operator]"; // vocabulary-ok: this hold names the operator-held wording for the operator decision
240
213
  return ` [blocked: requires ${hold.blockedBy.join(", ")}]`;
241
214
  }
242
215
  /** The line `plan|bug --release` and `--top` print when the entry they touched is still held:
@@ -305,67 +278,28 @@ export function backlogHolds(root) {
305
278
  * `tumwater backlog` prints. A surface derives its marks from the one hold decision and the
306
279
  * one claim lookup rather than re-walking the entries. An eligible entry nobody is working on
307
280
  * renders as the empty string, so a client can append the mark without a separate check.
308
- * Questions carry no holds. backlogHolds alone still drives the allocator. */
309
- export function backlogMarks(root) {
281
+ * Questions carry no holds. backlogHolds alone still drives the allocator. A caller that
282
+ * already resolved the claim lookup (for the same poll's marks AND its own parallel checks,
283
+ * e.g. statusPayload's `working` or the TUI's `runners`) passes it as `working` so the claims
284
+ * walk is done once, not re-run inside both calls — the same shared single read the module's
285
+ * other single-home polls observe. */
286
+ export function backlogMarks(root, working) {
310
287
  const holds = backlogHolds(root);
311
- const working = backlogInProgress(root);
288
+ const inProgress = working ?? backlogInProgress(root);
312
289
  const mark = (hold, runner) => holdMark(hold) + inProgressMark(runner ?? null);
313
290
  return {
314
- plans: holds.plans.map((h, i) => mark(h, working.plans[i])),
315
- bugs: holds.bugs.map((h, i) => mark(h, working.bugs[i])),
316
- };
317
- }
318
- /** A planned plan or open bug whose hold is `"held-for-operator"`, in file order: the
319
- * entries parked until the operator lifts them with `tumwater plan --release` or `tumwater
320
- * bug --release` (the `operator-held` team alert's input), each carrying the backlog file
321
- * and its 0-based position so a surface opening the row can land on it, the same RefusedEntry
322
- * shape refusedEntries returns. The convergence hold writes only on plans, but a role's own
323
- * too-large note can sit on a bug and reach this threshold, so a bug can be held this way too.
324
- * A missing file yields `[]`. */
325
- export function operatorHeldEntries(root) {
326
- const holds = backlogHolds(root);
327
- const out = [];
328
- const entriesOf = (entries, held, file) => {
329
- entries.forEach((e, i) => {
330
- if (held[i] === "held-for-operator")
331
- out.push({ file, index: i, title: e.title }); // vocabulary-ok: the held-for-operator hold's key
332
- });
291
+ plans: holds.plans.map((h, i) => mark(h, inProgress.plans[i])),
292
+ bugs: holds.bugs.map((h, i) => mark(h, inProgress.bugs[i])),
333
293
  };
334
- entriesOf(plannedPlanEntries(root), holds.plans, "plans");
335
- entriesOf(openBugEntries(root), holds.bugs, "bugs");
336
- return out;
337
294
  }
338
295
  /** The display noun for a backlog-file discriminator — the `"plans" | "bugs"` `file` on
339
- * RefusedEntry and StalledEntry (and the same discriminator the prompt's named-hold wording
296
+ * RefusedEntry (and the same discriminator the prompt's named-hold wording
340
297
  * and the doctor's Stalled row take): BUGS.md is a "Bug" and PLANS.md a "Plan", and the
341
298
  * lowercase form is the `Refused plan|bug:` label and the `tumwater plan|bug --release`
342
- * command noun the operator-held alert body interpolates. */
299
+ * command noun. */
343
300
  export function entryKindLabel(file) {
344
301
  return file === "bugs" ? "Bug" : "Plan";
345
302
  }
346
- /** The planned plans and open bugs whose hold is exactly `"needs-review"`, in file order: the
347
- * entries parked by a Needs-review note until the plan role splits or replans them (the
348
- * `stalled-entries` team alert's input), each carrying its backlog file, 0-based index and the
349
- * hold. Same `backlogHolds(root)` + the planned/`openBugEntries` readers `operatorHeldEntries`
350
- * uses, so the alert and the doctor's `stalled entries` row classify through the same
351
- * `entryHold` and cannot disagree on which holds are role-resolvable. A held-for-operator
352
- * entry (a note at the held threshold) is
353
- * excluded — it feeds `operatorHeldEntries` and the `operator-held` alert instead. A missing
354
- * file yields `[]`. */
355
- export function stalledEntries(root) {
356
- const holds = backlogHolds(root);
357
- const out = [];
358
- const entriesOf = (entries, held, file) => {
359
- entries.forEach((e, i) => {
360
- const hold = held[i];
361
- if (hold === "needs-review")
362
- out.push({ file, index: i, title: e.title, hold });
363
- });
364
- };
365
- entriesOf(plannedPlanEntries(root), holds.plans, "plans");
366
- entriesOf(openBugEntries(root), holds.bugs, "bugs");
367
- return out;
368
- }
369
303
  /** The planned plans, then the open bugs, whose hold is `"refused"`, in file order: the
370
304
  * entries that wait on an operator decision (the `refused` team alert's input), each carrying
371
305
  * the backlog file and its 0-based position so a surface opening the row can land on it.