tumwater 0.4.3 → 0.4.5

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 (255) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-run.js +3 -1
  4. package/dist/src/agent/claude/claude-account.js +6 -20
  5. package/dist/src/agent/codex/codex-account.js +5 -6
  6. package/dist/src/agent/codex/codex-args.js +5 -5
  7. package/dist/src/agent/codex/codex-rollout.js +1 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +24 -22
  9. package/dist/src/agent/opencode/opencode-catalog.js +35 -9
  10. package/dist/src/agent/opencode/write-guard-plugin/index.js +11 -8
  11. package/dist/src/agent/session-ledger.js +22 -26
  12. package/dist/src/agent/subscription-account.js +25 -41
  13. package/dist/src/agent/write-guard.js +50 -13
  14. package/dist/src/backlog/backlog-archive.js +3 -4
  15. package/dist/src/backlog/backlog-eligibility.js +21 -39
  16. package/dist/src/backlog/backlog-index.js +2 -2
  17. package/dist/src/backlog/backlog-md.js +13 -2
  18. package/dist/src/backlog/backlog-question-answer.js +1 -1
  19. package/dist/src/backlog/backlog-release.js +3 -11
  20. package/dist/src/backlog/backlog-write.js +19 -11
  21. package/dist/src/backlog/backlog.js +2 -1
  22. package/dist/src/baseline/main-baseline.js +47 -72
  23. package/dist/src/baseline/main-guardrails.js +13 -9
  24. package/dist/src/baseline/main-red.js +100 -12
  25. package/dist/src/baseline/main-watch.js +134 -54
  26. package/dist/src/brief/brief.js +26 -13
  27. package/dist/src/budget/budget-fallback-view.js +96 -28
  28. package/dist/src/budget/budget.js +4 -5
  29. package/dist/src/build/build-check-detect.js +22 -3
  30. package/dist/src/build/build-check-events.js +25 -5
  31. package/dist/src/build/build-check-report.js +47 -1
  32. package/dist/src/build/build-check-rerun.js +62 -0
  33. package/dist/src/build/build-check-scoped.js +13 -4
  34. package/dist/src/build/build-check.js +2 -1
  35. package/dist/src/build/build-info.js +4 -0
  36. package/dist/src/build/build-stage.js +6 -4
  37. package/dist/src/build/dep-install.js +1 -2
  38. package/dist/src/build/host-sleep.js +9 -0
  39. package/dist/src/change/change-data.js +1 -4
  40. package/dist/src/change/change-render.js +2 -1
  41. package/dist/src/cli/cli-args.js +34 -2
  42. package/dist/src/cli/cli-command-args.js +12 -4
  43. package/dist/src/cli/cli-completion-command.js +75 -0
  44. package/dist/src/cli/cli-doctor-command.js +103 -0
  45. package/dist/src/cli/cli-init.js +7 -3
  46. package/dist/src/cli/cli-query-commands.js +11 -7
  47. package/dist/src/cli/cli-review-commands.js +3 -3
  48. package/dist/src/cli/cli-role-command.js +339 -284
  49. package/dist/src/cli/cli-start-summary.js +4 -3
  50. package/dist/src/cli/cli-start-supervisor.js +190 -0
  51. package/dist/src/cli/cli-start.js +14 -189
  52. package/dist/src/cli/completion.js +9 -5
  53. package/dist/src/cli/follow-view.js +20 -2
  54. package/dist/src/cli/help.js +3 -8
  55. package/dist/src/cli.js +123 -303
  56. package/dist/src/collections.js +31 -13
  57. package/dist/src/concurrency/check-permit.js +30 -3
  58. package/dist/src/concurrency/lock.js +74 -15
  59. package/dist/src/config/config-editable-keys.js +9 -0
  60. package/dist/src/config/config-fallbacks.js +16 -5
  61. package/dist/src/config/config-keys.js +61 -20
  62. package/dist/src/config/config-request.js +11 -1
  63. package/dist/src/config/config-schema.js +4 -4
  64. package/dist/src/config/config-validation-roles.js +6 -22
  65. package/dist/src/config/config-validation.js +2 -1
  66. package/dist/src/config/config-views.js +62 -39
  67. package/dist/src/config/config-write.js +122 -79
  68. package/dist/src/config/config.js +13 -14
  69. package/dist/src/doctor/doctor-backlog-bugs.js +33 -1
  70. package/dist/src/doctor/doctor-backlog-plans.js +29 -96
  71. package/dist/src/doctor/doctor-backlog.js +14 -16
  72. package/dist/src/doctor/doctor-check-names.js +2 -0
  73. package/dist/src/doctor/doctor-checks.js +16 -9
  74. package/dist/src/doctor/doctor-claude-checks.js +29 -11
  75. package/dist/src/doctor/doctor-contention-checks.js +20 -11
  76. package/dist/src/doctor/doctor-fallback-checks.js +83 -26
  77. package/dist/src/doctor/doctor-holds.js +9 -12
  78. package/dist/src/doctor/doctor-model-checks.js +7 -0
  79. package/dist/src/doctor/doctor-opencode-checks.js +2 -2
  80. package/dist/src/doctor/doctor-orphans.js +106 -29
  81. package/dist/src/doctor/doctor-project-checks.js +52 -22
  82. package/dist/src/doctor/doctor.js +5 -3
  83. package/dist/src/events/event-format.js +78 -109
  84. package/dist/src/events/event-labels.js +2 -1
  85. package/dist/src/events/event-legacy.js +2 -2
  86. package/dist/src/events/event-notify.js +4 -4
  87. package/dist/src/events/event-window.js +1 -1
  88. package/dist/src/events/events.js +8 -0
  89. package/dist/src/failure/failure-cluster.js +4 -13
  90. package/dist/src/failure/failure-data.js +10 -8
  91. package/dist/src/failure/failure-render.js +2 -2
  92. package/dist/src/failure/failure-state-change.js +64 -90
  93. package/dist/src/files/file-queue.js +32 -19
  94. package/dist/src/files/files.js +2 -2
  95. package/dist/src/files/json-object.js +3 -2
  96. package/dist/src/gates/budget-gates.js +37 -2
  97. package/dist/src/gates/gate-polls.js +13 -13
  98. package/dist/src/gates/{maintenance-quota.js → maintenance-quota-gates.js} +1 -1
  99. package/dist/src/gates/startup-gates.js +20 -2
  100. package/dist/src/git/git-diff.js +6 -2
  101. package/dist/src/git/git.js +8 -0
  102. package/dist/src/git/worktree-pool.js +18 -5
  103. package/dist/src/gui/gui-admin-endpoints.js +11 -1
  104. package/dist/src/gui/gui-args.js +14 -2
  105. package/dist/src/gui/gui-backlog-endpoints.js +2 -1
  106. package/dist/src/gui/gui-config-commands.js +2 -64
  107. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  108. package/dist/src/gui/gui-endpoint-common.js +19 -1
  109. package/dist/src/gui/gui-history-endpoints.js +3 -3
  110. package/dist/src/gui/gui-operator-commands.js +23 -28
  111. package/dist/src/gui/gui-prompt-commands.js +2 -2
  112. package/dist/src/gui/gui-server.js +1 -2
  113. package/dist/src/gui/gui-team-commands.js +2 -1
  114. package/dist/src/history/history.js +14 -10
  115. package/dist/src/inbox/director-request.js +92 -0
  116. package/dist/src/merge/merge-backlog-conflicts.js +33 -11
  117. package/dist/src/merge/merge-batch.js +13 -235
  118. package/dist/src/merge/merge-cancel.js +2 -1
  119. package/dist/src/merge/merge-core.js +18 -52
  120. package/dist/src/merge/merge-drain.js +106 -101
  121. package/dist/src/merge/merge-pipeline.js +6 -4
  122. package/dist/src/merge/merge-post-review-drain.js +9 -3
  123. package/dist/src/merge/merge-questions.js +3 -3
  124. package/dist/src/merge/merge-queue-render.js +4 -4
  125. package/dist/src/merge/merge-queue.js +2 -1
  126. package/dist/src/merge/merge-requeue.js +62 -19
  127. package/dist/src/merge/merge-slot.js +41 -10
  128. package/dist/src/merge/merge-to-main.js +16 -210
  129. package/dist/src/merge/merge-top.js +2 -1
  130. package/dist/src/merge/merge-verify.js +9 -6
  131. package/dist/src/model-check/model-check-gate.js +206 -0
  132. package/dist/src/model-check/model-check-probe.js +109 -0
  133. package/dist/src/model-check/model-check.js +101 -0
  134. package/dist/src/operator/operator-commands.js +10 -26
  135. package/dist/src/orchestrator/orchestrator-launch.js +6 -1
  136. package/dist/src/orchestrator/orchestrator-poll.js +50 -43
  137. package/dist/src/orchestrator/orchestrator-scheduling.js +12 -16
  138. package/dist/src/orchestrator/orchestrator.js +14 -1
  139. package/dist/src/paths.js +24 -2
  140. package/dist/src/pi/pi-event-line.js +27 -0
  141. package/dist/src/pi/pi-listed-models.js +13 -0
  142. package/dist/src/pi/pi-run-result.js +16 -0
  143. package/dist/src/pi/pi-stream.js +9 -2
  144. package/dist/src/pi/pi-watchdogs.js +42 -10
  145. package/dist/src/process/process-group.js +2 -21
  146. package/dist/src/process/process.js +36 -4
  147. package/dist/src/process/run-marker.js +2 -2
  148. package/dist/src/prompt/gate-notes.js +10 -4
  149. package/dist/src/prompt/gate-prompts.js +14 -58
  150. package/dist/src/prompt/principles.js +13 -3
  151. package/dist/src/prompt/prompt-followup.js +14 -5
  152. package/dist/src/redeploy/redeploy-probes.js +53 -18
  153. package/dist/src/redeploy/redeploy-target.js +100 -0
  154. package/dist/src/redeploy/redeployer.js +120 -46
  155. package/dist/src/report/report-main-red.js +2 -2
  156. package/dist/src/report/report-render.js +2 -2
  157. package/dist/src/review/diff-rules.js +35 -21
  158. package/dist/src/review/known-flakes.js +4 -2
  159. package/dist/src/review/post-merge-review-accept.js +2 -1
  160. package/dist/src/review/post-merge-review.js +53 -41
  161. package/dist/src/review/review-precheck.js +75 -31
  162. package/dist/src/review/review.js +13 -22
  163. package/dist/src/roles/legacy-role-migration.js +6 -3
  164. package/dist/src/roles/role-catalog.js +13 -3
  165. package/dist/src/roles/role-render.js +157 -108
  166. package/dist/src/roles/role-view.js +271 -81
  167. package/dist/src/roles/roles.js +1 -1
  168. package/dist/src/run/ci-parity.js +34 -10
  169. package/dist/src/run/run-apply.js +0 -15
  170. package/dist/src/run/run-detail-render.js +2 -2
  171. package/dist/src/run/run-finalize.js +4 -0
  172. package/dist/src/run/run-stage.js +6 -6
  173. package/dist/src/run/run-timing.js +2 -2
  174. package/dist/src/run/run-usage.js +9 -1
  175. package/dist/src/run/run-verdict.js +92 -55
  176. package/dist/src/run/stage-check.js +17 -24
  177. package/dist/src/runner/runner-leftover.js +9 -26
  178. package/dist/src/runner/runner-outcome.js +1 -2
  179. package/dist/src/runner/runner-pi.js +12 -12
  180. package/dist/src/runner/runner-recovery.js +9 -16
  181. package/dist/src/runner/runner-revision.js +3 -3
  182. package/dist/src/runner/runner-run.js +155 -118
  183. package/dist/src/runner/runner-state-load.js +94 -61
  184. package/dist/src/runner/runner-state.js +3 -16
  185. package/dist/src/runner/runner.js +39 -20
  186. package/dist/src/{orchestrator/scheduling-admit.js → scheduling/admit.js} +2 -2
  187. package/dist/src/scheduling/backoff.js +14 -9
  188. package/dist/src/scheduling/claim-entries.js +2 -1
  189. package/dist/src/scheduling/claims.js +10 -12
  190. package/dist/src/{orchestrator/scheduling-scope.js → scheduling/scope.js} +15 -74
  191. package/dist/src/{orchestrator/scheduling-skip.js → scheduling/skip.js} +113 -83
  192. package/dist/src/status/status-budget.js +3 -8
  193. package/dist/src/status/status-data.js +46 -41
  194. package/dist/src/status/status-polls.js +82 -25
  195. package/dist/src/status/status-role-notes.js +25 -7
  196. package/dist/src/status/status-roles.js +57 -27
  197. package/dist/src/team/team-hold.js +7 -5
  198. package/dist/src/team/team-orchestrator-info.js +104 -21
  199. package/dist/src/team/team-polls.js +36 -86
  200. package/dist/src/team/team-state.js +25 -9
  201. package/dist/src/text/datetime.js +16 -7
  202. package/dist/src/text/format.js +20 -2
  203. package/dist/src/text/phrases.js +66 -10
  204. package/dist/src/text/suggest.js +15 -2
  205. package/dist/src/text/text.js +33 -3
  206. package/dist/src/ui/alert-feeds.js +0 -41
  207. package/dist/src/ui/badges.js +76 -52
  208. package/dist/src/ui/gui/gui-client-boot.js +0 -6
  209. package/dist/src/ui/gui/gui-client-drawer-config.js +11 -20
  210. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  211. package/dist/src/ui/gui/gui-client-model.js +4 -0
  212. package/dist/src/ui/gui/gui-client-operator.js +14 -15
  213. package/dist/src/ui/gui/gui-client-pending.js +10 -3
  214. package/dist/src/ui/gui/gui-client-report-chart.js +22 -15
  215. package/dist/src/ui/gui/gui-client-settings.js +5 -1
  216. package/dist/src/ui/gui/gui-client-team.js +15 -2
  217. package/dist/src/ui/progress-data.js +54 -4
  218. package/dist/src/ui/run-progress-model.js +12 -3
  219. package/dist/src/ui/status-model.js +21 -6
  220. package/dist/src/ui/status-payload.js +23 -24
  221. package/dist/src/ui/status-render.js +12 -13
  222. package/dist/src/ui/team-alert-backlog.js +15 -205
  223. package/dist/src/ui/team-alert-shared.js +8 -31
  224. package/dist/src/ui/team-alert-standings.js +96 -0
  225. package/dist/src/ui/team-alert-status.js +39 -12
  226. package/dist/src/ui/team-alerts.js +9 -11
  227. package/dist/src/ui/tui/tui-backlog.js +14 -2
  228. package/dist/src/ui/tui/tui-editor-interval.js +2 -2
  229. package/dist/src/ui/tui/tui-editor-role-model.js +5 -1
  230. package/dist/src/ui/tui/tui-editors.js +5 -4
  231. package/dist/src/ui/tui/tui-input.js +10 -8
  232. package/dist/src/ui/tui/tui-pane-activity.js +2 -1
  233. package/dist/src/ui/tui/tui-pane-backlog.js +2 -2
  234. package/dist/src/ui/tui/tui-pane-health.js +46 -44
  235. package/dist/src/ui/tui/tui-pane-history.js +162 -475
  236. package/dist/src/ui/tui/tui-pane-pending-selection.js +346 -0
  237. package/dist/src/ui/tui/tui-pane-pending.js +59 -331
  238. package/dist/src/ui/tui/tui-pane-queued.js +2 -2
  239. package/dist/src/ui/tui/tui-pane-session.js +4 -3
  240. package/dist/src/ui/tui/tui-pane-trail.js +420 -0
  241. package/dist/src/ui/tui/tui-pane.js +3 -3
  242. package/dist/src/ui/tui/tui-panes.js +9 -1
  243. package/dist/src/ui/tui/tui.js +4 -5
  244. package/dist/src/verdict/fix-claim.js +46 -11
  245. package/dist/src/verdict/reply-contract.js +5 -8
  246. package/package.json +9 -7
  247. package/dist/src/merge/merge-check-failures.js +0 -209
  248. package/dist/src/merge/merge-stack.js +0 -236
  249. package/dist/src/review/review-followup.js +0 -87
  250. package/dist/src/review/review-run.js +0 -395
  251. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  252. /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
  253. /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
  254. /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
  255. /package/dist/src/cli/{question-commands.js → cli-question-commands.js} +0 -0
package/README.md CHANGED
@@ -42,7 +42,7 @@ and is also outside `npm test`.
42
42
  ## Status
43
43
 
44
44
  <!-- tumwater:status:start -->
45
- **v0.4.3**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
45
+ **v0.4.5**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
46
46
  which files tumwater bugs from tumwater's own event log, and `feedback`, which files them as GitHub
47
47
  issues on znation/tumwater, are opt-in (`roles.selfcheck.enabled`, `roles.feedback.enabled`).
48
48
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "45348f61e3e060de873bbd10ce8cdea30e4102d6",
3
- "builtAt": 1793264084617,
2
+ "sha": "6440aaa1705f2accb491fa2155a5da6aadee05d8",
3
+ "builtAt": 1793604373395,
4
4
  "root": "/home/runner/work/tumwater/tumwater"
5
5
  }
@@ -107,6 +107,7 @@ export function runAgent(opts) {
107
107
  const wd = startPiWatchdogs({
108
108
  progressCount: () => parser.progressCount,
109
109
  openToolCalls: () => parser.openToolCalls,
110
+ pendingReply: () => parser.pendingReply,
110
111
  runTimeoutMs: opts.config.runTimeoutSeconds * 1000,
111
112
  quietMs: opts.config.quietTimeoutSeconds * 1000,
112
113
  stallMs: Math.max(0, opts.config.toolCallStallSeconds) * 1000,
@@ -120,7 +121,8 @@ export function runAgent(opts) {
120
121
  // deadline stays right for them.
121
122
  extendOnProgress: opts.kind === "gate",
122
123
  kill: () => terminateChild(child),
123
- onToolCallStalled: opts.onToolCallStalled,
124
+ onStallWarning: opts.onStallWarning,
125
+ onUnansweredReply: opts.onUnansweredReply,
124
126
  });
125
127
  let aborted = false;
126
128
  // A blind image read on an authoring run deliberately kills it, like the watchdogs do.
@@ -2,8 +2,8 @@
2
2
  * subscription-account.ts) — which billing pays for claude runs, and the newest subscription
3
3
  * usage-window reading each run reported (plans/agent-backends.md §7 "Billing" and §8 "The
4
4
  * subscription usage window"). Persisted at `.tumwater/state/claude-account.json`, written only
5
- * by the orchestrator process, so the sibling parts that throttle or display the subscription
6
- * window (6b/7–6e/7) read one answer instead of re-deriving it per surface.
5
+ * by the orchestrator process, so the sibling parts read one answer instead of re-deriving it
6
+ * per surface.
7
7
  *
8
8
  * Everything here is a thin wrapper over the generic per-backend store (found
9
9
  * grounds/codex-backend.md "Groundwork: the subscription account is per backend", item 1),
@@ -17,7 +17,7 @@ import { spawnSync } from "node:child_process";
17
17
  import os from "node:os";
18
18
  import { resolvedBackendConfig } from "../../config/config-views.js";
19
19
  import { parseJsonObject } from "../../files/json-object.js";
20
- import { observeAccount, readAccount, subscriptionWindowRejected, windowEntries, windowLabel, windowReserved, windowUsedUp, writeAccount, } from "../subscription-account.js";
20
+ import { observeAccount, readAccount, subscriptionWindowRejected, writeAccount, } from "../subscription-account.js";
21
21
  import { claudeBackend, claudeConfigDir } from "./claude-backend.js";
22
22
  import { claudeEnv, claudeThirdPartyProvider } from "./claude-env.js";
23
23
  /** Read the persisted claude account state, or null when it is absent or unreadable. A file
@@ -43,23 +43,9 @@ export function writeClaudeAccount(root, account) {
43
43
  export function observeClaudeAccount(root, run, now = Date.now()) {
44
44
  return observeAccount(root, "claude", run, now);
45
45
  }
46
- /** The subscription account's used-up usage window, or null — see subscription-account.ts's
47
- * windowUsedUp for the rule. */
48
- export function claudeWindowUsedUp(account, now, includeOverage = true, reserve) {
49
- return windowUsedUp(account, now, includeOverage, reserve);
50
- }
51
- /** Re-export of the per-backend helpers under their historical claude names for callers that
52
- * predate the per-backend store (budget gates, team holds, the doctor, the role view, the
53
- * status poll). */
54
- export { subscriptionWindowRejected, windowReserved };
55
- /** The window type as a compact label — subscription-account.ts's windowLabel, under its
56
- * historical claude name. */
57
- export function claudeWindowLabel(type) {
58
- return windowLabel(type);
59
- }
60
- export function claudeWindowEntries(account) {
61
- return windowEntries(account);
62
- }
46
+ /** Re-export of the per-backend rejection signal under its historical claude name for callers
47
+ * that predate the per-backend store (the runner's run-verdict and hold wiring). */
48
+ export { subscriptionWindowRejected };
63
49
  /** Seed the claude account file before the first run (plans/agent-backends.md §7):
64
50
  * `ANTHROPIC_API_KEY` set means claude prefers the key, so billing is API; otherwise
65
51
  * `claude auth status` decides — a claude.ai OAuth login is a subscription, any other login is
@@ -84,12 +84,11 @@ export function parseCodexLoginStatus(stdout) {
84
84
  return "api";
85
85
  return null;
86
86
  }
87
- /** The refusal message when a codex seam is API-key logged in (decision 5): Codex reports no
88
- * cost, so per-token billing would spend money the budget cap cannot see. The one home of the
89
- * remedy text the startup gate, the live config editor, and the doctor all print, so the remedy
90
- * command and its wording cannot drift between them. `home` is the codex home named in the
91
- * remedy: the literal `<home>` placeholder where the call site does not resolve the home, else
92
- * the resolved `CODEX_HOME` path. */
87
+ /** The refusal message for a codex logged in with an API key, shared by the startup gate, the
88
+ * live-config warn and the doctor's codex auth check (decision 5): Codex reports no cost, so
89
+ * per-token billing would spend money the budget cap cannot see. `home` names the codex home
90
+ * the operator should re-login under; the gate and the live warn pass the literal "<home>"
91
+ * placeholder because they do not resolve one, while the doctor passes the resolved home. */
93
92
  export function codexApiKeyRefusalMessage(home) {
94
93
  return `Codex is logged in with an API key; tumwater runs Codex on a ChatGPT subscription only — run \`CODEX_HOME=${home} codex login --device-auth\``;
95
94
  }
@@ -106,11 +106,11 @@ export const EFFORT_BY_THINKING = {
106
106
  * spawn on a model that lists it. */
107
107
  const KNOWN_EFFORTS = new Set(Object.values(EFFORT_BY_THINKING));
108
108
  /** Map a thinking level to the Codex `model_reasoning_effort` it means, gated by the model's own
109
- * catalog. `levels` is the model's `supported_reasoning_levels` (design finding 14), which a
110
- * stream-minted catalog (part 8/9) supplies; until then the caller passes `undefined`, and the
111
- * result is returned unclamped. An unknown thinking level returns `undefined` (no effort flag) —
112
- * the level simply has no Codex effort, unlike a thinking level that maps but the model does not
113
- * support, which is also `undefined`. */
109
+ * catalog. `levels` is the model's `supported_reasoning_levels` (design finding 14), which the
110
+ * codex catalog (part 8/9) supplies; when the caller has no catalog it passes `undefined`, and
111
+ * the result is returned unclamped. An unknown thinking level returns `undefined` (no effort
112
+ * flag) — the level simply has no Codex effort, unlike a thinking level that maps but the model
113
+ * does not support, which is also `undefined`. */
114
114
  export function codexEffort(thinking, levels) {
115
115
  if (!Object.hasOwn(EFFORT_BY_THINKING, thinking))
116
116
  return undefined;
@@ -163,7 +163,7 @@ function rateLimitLines(lastRateLimits) {
163
163
  /** The model this turn switched to when its `turn_context.model` differs from what the run
164
164
  * requested (finding 24): the model itself, else undefined for no switch. */
165
165
  function switchedToModel(model, requestedModel) {
166
- return model !== undefined && requestedModel !== undefined && model !== requestedModel ? model : undefined;
166
+ return model !== undefined && model !== requestedModel ? model : undefined;
167
167
  }
168
168
  /** Assemble the reading's canonical `run_usage`, `billing` and up-to-two `rate_limit` lines
169
169
  * (in that order), plus the `switchedTo` model comparison, from scanned records. */
@@ -79,42 +79,44 @@ export function opencodeSpillDir(root) {
79
79
  function catalogModelKey(model) {
80
80
  return opencodeModelKey(model);
81
81
  }
82
- /** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
83
- * 10 s) and return whether it reported success — the existence check that decides a resume.
84
- * Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
85
- * direction: `--session` would otherwise silently create a missing session. */
86
- function opencodeSessionExists(id, env, bin, cwd) {
82
+ /** Run the bounded `opencode session export <id> --standalone` spawn that both the resume
83
+ * existence check and the reconciling export issue, under the same env/cwd and the shared
84
+ * 10 s bound. `capture` is false for the existence check, which reads only the exit status:
85
+ * `stdio: "ignore"` keeps the export — which carries every message of the session — out of
86
+ * memory and below the default 1 MiB `maxBuffer`, whose `ENOBUFS` kill would otherwise make a
87
+ * large but real session read as absent and mint a fresh one. Returns the spawn result, or null
88
+ * when the spawn itself threw — a missing binary or an invalid option, resolved the same way
89
+ * both callers treated a throw. The command line, env, cwd and timeout live here once, so the
90
+ * existence check and the reconciling export cannot drift on what they run. */
91
+ function runSessionExport(id, env, bin, cwd, capture = true) {
87
92
  try {
88
- const res = spawnSync(bin, ["session", "export", id, "--standalone"], {
93
+ return spawnSync(bin, ["session", "export", id, "--standalone"], {
89
94
  env,
90
95
  cwd,
91
96
  timeout: EXPORT_TIMEOUT_MS,
92
- stdio: "ignore",
97
+ encoding: "utf8",
98
+ stdio: capture ? "pipe" : "ignore",
93
99
  });
94
- return res.status === 0;
95
100
  }
96
101
  catch {
97
- return false;
102
+ return null;
98
103
  }
99
104
  }
105
+ /** Run `opencode session export <id> --standalone` (the same database the run used, bounded at
106
+ * 10 s) and return whether it reported success — the existence check that decides a resume.
107
+ * Nonzero exit, a timeout, or a spawn failure all read as "no such session", which is the safe
108
+ * direction: `--session` would otherwise silently create a missing session. */
109
+ function opencodeSessionExists(id, env, bin, cwd) {
110
+ const res = runSessionExport(id, env, bin, cwd, false);
111
+ return res?.status === 0;
112
+ }
100
113
  /** Run the reconciling export after a successful run and return its two canonical lines
101
114
  * (`run_cost`, `run_usage`) from the session's `info`. Anything that goes wrong — a spawn or
102
115
  * timeout, a nonzero exit, unparseable or malformed output — returns `[]`, and the caller
103
116
  * (afterRun) reports `opencode_usage_partial` so the run keeps its streamed numbers. */
104
117
  function opencodeExport(result) {
105
- let res;
106
- try {
107
- res = spawnSync(result.bin, ["session", "export", result.id, "--standalone"], {
108
- env: result.env,
109
- cwd: result.cwd,
110
- timeout: EXPORT_TIMEOUT_MS,
111
- encoding: "utf8",
112
- });
113
- }
114
- catch {
115
- return [];
116
- }
117
- if (res.status !== 0 || !res.stdout)
118
+ const res = runSessionExport(result.id, result.env, result.bin, result.cwd);
119
+ if (res?.status !== 0 || !res.stdout)
118
120
  return [];
119
121
  let parsed;
120
122
  try {
@@ -9,6 +9,7 @@
9
9
  import { spawnSync } from "node:child_process";
10
10
  import { isJsonObject, validEntries } from "../../files/json-object.js";
11
11
  import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
12
+ import { cachedByStat } from "../../files/stat-cache.js";
12
13
  import { errorMessage } from "../../text/text.js";
13
14
  import { DAY_MS } from "../../text/datetime.js";
14
15
  import { opencodeModelsPath } from "../../paths.js";
@@ -21,6 +22,25 @@ const CATALOG_URL = "https://models.dev/api.json";
21
22
  const CATALOG_FETCH_TIMEOUT_MS = 30_000;
22
23
  /** How long a fetched cache is trusted before it is refreshed. */
23
24
  const CATALOG_CACHE_TTL_MS = DAY_MS;
25
+ /** Per-catalog cache of the parsed catalog, keyed by the models.json path: the status
26
+ * snapshot and the budget gate both poll it every second while the file changes only when a
27
+ * refresh re-fetches models.dev — and it grows with the models.dev catalog — so an
28
+ * unchanged file costs one stat per poll instead of a re-read plus a JSON.parse and a
29
+ * validation of the whole file (the same stat-keyed contract readOpencodeCache's siblings
30
+ * readCodexCatalog, readPiProviders and readMergeQueue already use). Any write invalidates
31
+ * via dev/ino/mtime/size (stat-cache.cachedByStat's freshness check). */
32
+ const catalogCache = new Map();
33
+ /** A structural copy of a parsed catalog, so a caller that mutates its own result (e.g.
34
+ * observeOpencodeRun sets `billedAt` on an entry) never reaches the shared cache entry; the
35
+ * next poll re-derives from the file. The entries are plain data (`efforts` arrays included)
36
+ * so a shallow model-object spread suffices. */
37
+ function cloneCache(cache) {
38
+ const models = {};
39
+ for (const [key, entry] of Object.entries(cache.models)) {
40
+ models[key] = entry.efforts !== undefined ? { ...entry, efforts: [...entry.efforts] } : { ...entry };
41
+ }
42
+ return { fetchedAt: cache.fetchedAt, cliVersion: cache.cliVersion, models };
43
+ }
24
44
  const defaultFetch = async (url, timeoutMs) => {
25
45
  const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
26
46
  if (!res.ok)
@@ -160,15 +180,21 @@ function isOpencodeCatalogModel(value) {
160
180
  * fills the file in. Entries that are not well-formed catalog models are dropped, so a foreign
161
181
  * entry reads as a model missing from the cache — not free. */
162
182
  export function readOpencodeCache(root) {
163
- const existing = readJsonFile(opencodeModelsPath(root));
164
- if (existing !== null && typeof existing.fetchedAt === "number" && isJsonObject(existing.models)) {
165
- return {
166
- fetchedAt: existing.fetchedAt,
167
- cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
168
- models: validEntries(existing.models, isOpencodeCatalogModel),
169
- };
170
- }
171
- return { fetchedAt: 0, models: {} };
183
+ const file = opencodeModelsPath(root);
184
+ const cached = cachedByStat(catalogCache, file, file, () => {
185
+ // readJsonFile is the shared "missing or torn reads as no data" policy; a torn write
186
+ // or a malformed file reads as an empty cache (fetchedAt 0), exactly as before.
187
+ const existing = readJsonFile(file);
188
+ if (existing !== null && typeof existing.fetchedAt === "number" && isJsonObject(existing.models)) {
189
+ return {
190
+ fetchedAt: existing.fetchedAt,
191
+ cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
192
+ models: validEntries(existing.models, isOpencodeCatalogModel),
193
+ };
194
+ }
195
+ return { fetchedAt: 0, models: {} };
196
+ }, cloneCache);
197
+ return cached ?? { fetchedAt: 0, models: {} };
172
198
  }
173
199
  /** Persist the catalog cache atomically under `.tumwater/state/`. */
174
200
  export function writeOpencodeCache(root, cache) {
@@ -4,20 +4,23 @@
4
4
  *
5
5
  * OpenCode 2.x has no hook that blocks a tool from running. It has a permission `evaluate` hook
6
6
  * that may turn a decision to `deny` with a `message`, which reaches the model as the tool's error
7
- * while the run goes on. Every file-writing tool asks for the `edit` action with the absolute
8
- * paths it writes as `resources`, and the hook runs under `--auto` too, since `--auto` only
9
- * answers what would otherwise be asked. Checked live on opencode 2.1.0 on 2026-10-23. A
10
- * configured plugin path must be a directory, which is why this is `index.js` in a directory of
11
- * its own.
7
+ * while the run goes on. The file-writing tools ask for the `edit` action (edit in place) and the
8
+ * `write` action (create or overwrite a file — a new path a `../` climb can point outside the
9
+ * checkout) with the absolute paths they write as `resources`, and the hook runs under `--auto`
10
+ * too, since `--auto` only answers what would otherwise be asked. Checked live on opencode 2.1.0
11
+ * on 2026-10-23. A configured plugin path must be a directory, which is why this is `index.js` in
12
+ * a directory of its own.
12
13
  *
13
14
  * The plugin's context names the run's directory (`location.directory`, the worktree OpenCode
14
15
  * runs in), which is the run directory the rule reads its checkout from. A failure of its own
15
16
  * leaves the decision alone, so a broken guard cannot stop every edit of a run. */
16
17
  import { strayWriteRefusal } from "../../write-guard.js";
17
- /** Deny an `edit` decision whose resources include a stray path from a run in `runDir`, naming
18
- * the first such path's refusal. Every other decision is left as it is. Exported for tests. */
18
+ /** Deny an `edit` or `write` decision whose resources include a stray path from a run in
19
+ * `runDir`, naming the first such path's refusal. Every other decision is left as it is. Exported
20
+ * for tests. */
21
+ const FILE_WRITE_ACTIONS = new Set(["edit", "write"]);
19
22
  export function guardEvaluate(event, runDir) {
20
- if (event.action !== "edit" || !Array.isArray(event.resources))
23
+ if (!FILE_WRITE_ACTIONS.has(event.action) || !Array.isArray(event.resources))
21
24
  return;
22
25
  for (const resource of event.resources) {
23
26
  if (typeof resource !== "string" || resource === "")
@@ -191,6 +191,24 @@ export function pruneSessionLedgers(sessionsRoot, days) {
191
191
  /** How long one `opencode session delete <id> --standalone` may take before it gives up: the
192
192
  * session export measured 0.18 s live, so 10 s is a generous bound on a slow or wedged CLI. */
193
193
  const SESSION_DELETE_TIMEOUT_MS = 10_000;
194
+ /** Run one bounded headless CLI session delete (`bin args…`) under `env`, best-effort: a
195
+ * spawn that throws (a missing binary, an unwritable env) returns false without throwing, so
196
+ * a failed delete never fails the prune. Shared by the OpenCode and Codex session purges below
197
+ * so neither drift on the 10 s bound, the status-0 success rule, or the swallow policy. */
198
+ function sessionDeleteSucceeds(bin, args, env) {
199
+ try {
200
+ const res = spawnSync(bin, args, {
201
+ env,
202
+ timeout: SESSION_DELETE_TIMEOUT_MS,
203
+ stdio: "ignore",
204
+ });
205
+ return res.status === 0;
206
+ }
207
+ catch {
208
+ // Best effort: an unwritable env or a missing binary must not fail the prune.
209
+ return false;
210
+ }
211
+ }
194
212
  /** Delete each OpenCode session the ledger records as expired, with one
195
213
  * `opencode session delete <id> --standalone` each (bounded), plus the spill files older than
196
214
  * the window in the team's shell spill dir. The session's spill files and server state live
@@ -209,18 +227,8 @@ export function pruneOpencodeSessions(sessionsRoot, days, opts) {
209
227
  for (const record of readSessions(path.dirname(file))) {
210
228
  if (record.agent !== "opencode" || record.at >= cutoff)
211
229
  continue;
212
- try {
213
- const res = spawnSync(opts.bin, ["session", "delete", record.id, "--standalone"], {
214
- env: opts.env,
215
- timeout: SESSION_DELETE_TIMEOUT_MS,
216
- stdio: "ignore",
217
- });
218
- if (res.status === 0)
219
- deleted += 1;
220
- }
221
- catch {
222
- // Best effort: an unwritable env or a missing binary must not fail the prune.
223
- }
230
+ if (sessionDeleteSucceeds(opts.bin, ["session", "delete", record.id, "--standalone"], opts.env))
231
+ deleted += 1;
224
232
  }
225
233
  });
226
234
  }
@@ -276,20 +284,8 @@ export function pruneCodexSessions(sessionsRoot, days, opts) {
276
284
  for (const id of expired) {
277
285
  if (live.has(id))
278
286
  continue;
279
- if (opts.bin !== undefined) {
280
- try {
281
- const res = spawnSync(opts.bin, ["delete", "--force", id], {
282
- env: opts.env,
283
- timeout: SESSION_DELETE_TIMEOUT_MS,
284
- stdio: "ignore",
285
- });
286
- if (res.status === 0)
287
- deleted += 1;
288
- }
289
- catch {
290
- // Best effort: an unwritable env or a missing binary must not fail the prune.
291
- }
292
- }
287
+ if (opts.bin !== undefined && sessionDeleteSucceeds(opts.bin, ["delete", "--force", id], opts.env))
288
+ deleted += 1;
293
289
  // Fallback: unlink the home's rollout for this session so neither the ledger line nor
294
290
  // its file outlives the window. A real `codex delete` removes the rollout itself, so a
295
291
  // successful delete has nothing left to unlink; a failed or skipped delete does, and
@@ -3,8 +3,8 @@
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 free-ness rule and the window displays read one answer instead of re-deriving it per
7
+ * surface.
8
8
  *
9
9
  * The file is a cache, never a source of truth: a run that reports billing overwrites it, and a
10
10
  * missing or unreadable file reads as "no subscription". `resetsAt` is stored as epoch
@@ -15,6 +15,7 @@
15
15
  * keeps today's shape. */
16
16
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
17
17
  import { finiteNumber, isJsonObject, setOwn } from "../files/json-object.js";
18
+ import { cachedByStat } from "../files/stat-cache.js";
18
19
  import { accountPath } from "../paths.js";
19
20
  /** True when a failed run ended on a subscription usage window the account reported as
20
21
  * `rejected` without overage — the signal the team hold throttles on (plans/agent-backends.md
@@ -28,45 +29,12 @@ export function subscriptionWindowRejected(run) {
28
29
  run.billing === "subscription" &&
29
30
  (run.rateWindows ?? []).some((w) => w.status === "rejected" && w.isUsingOverage !== true));
30
31
  }
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` (the subscription-window reserve), a window whose utilization has
35
- * reached its type's fraction counts too and comes back with `reserve: true`; a rejected or
36
- * 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
32
  /** Status severity for ordering: a rejected window limits, an allowed_warning nears its limit,
65
33
  * every other reading (allowed/ok) rides at the bottom, and a reset one no longer limits. */
66
34
  const WINDOW_RANK = { rejected: 3, allowed_warning: 2, reset: 0 };
67
35
  /** The window type as a compact label: `five_hour` → `5h`, `seven_day` → `7d`; an unknown
68
36
  * 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. */
37
+ * the status badge and the role view share (the browser dashboard keeps its inline copy). */
70
38
  export function windowLabel(type) {
71
39
  const m = /^(\w+)_(hour|day|week)$/.exec(type);
72
40
  if (!m)
@@ -77,10 +45,8 @@ export function windowLabel(type) {
77
45
  return `${words[word] ?? word}${unit}`;
78
46
  }
79
47
  /** 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). */
48
+ * `0.64` utilization reads `64`. The one node-side home of the fraction→percent conversion
49
+ * (the browser dashboard's own window line is embedded client-side and keeps its inline copy). */
84
50
  export function utilizationPct(utilization) {
85
51
  return Math.round(utilization * 100);
86
52
  }
@@ -107,12 +73,30 @@ export function windowEntries(account) {
107
73
  .map(([type, w]) => ({ type, ...w }))
108
74
  .sort((a, b) => compareWindowEntries(a, b));
109
75
  }
76
+ /** The parsed raw body of one backend's account file, keyed by that file's own stat — the same
77
+ * stat-keyed contract cachedByStat gives every observer that polls a slowly-changing file. The
78
+ * account file is written only by the orchestrator on a run's billing/window report, so it
79
+ * changes no faster than runs; on the status snapshot path readAccount ran per backend per poll
80
+ * as one more readJsonFile (read + parse) alongside the already stat-cached marker reads, and
81
+ * budget-gates/role-view read it on other polling surfaces. Writes go through writeJsonAtomic's
82
+ * tmp+rename (writeAccount), which bumps dev/ino/mtime/size, so a refresh invalidates like any
83
+ * edit. The `now`-dependent heal is NOT cached: only the raw body is, and readAccount reapplies
84
+ * its window/observedAt rules against each call's own `now` on a per-call copy, so a window
85
+ * whose `resetsAt` lapses still reads back as reset on the very call it does. Bounded inside
86
+ * cachedByStat. */
87
+ const accountBodyCache = new Map();
88
+ /** Serve `file`'s parsed raw JSON-object body from accountBodyCache: null when the file is
89
+ * missing, unreadable, torn, or not a plain object — the same no-data policy as readJsonFile —
90
+ * and a fresh per-call copy so readAccount's owns the object it reads fields off. Never throws. */
91
+ function readAccountBody(file) {
92
+ return cachedByStat(accountBodyCache, file, file, () => readJsonFile(file), (body) => ({ ...body }));
93
+ }
110
94
  /** Read the persisted account state for a backend, or null when it is absent or unreadable. A
111
95
  * file without a usable top-level `observedAt` reads as absent (the plan's torn-file rule),
112
96
  * and a window whose `resetsAt` is at or before `now` reads back with `status: "reset"` — the
113
97
  * reader, not every consumer, decides that the window no longer limits. */
114
98
  export function readAccount(root, backend, now = Date.now()) {
115
- const doc = readJsonFile(accountPath(root, backend));
99
+ const doc = readAccountBody(accountPath(root, backend));
116
100
  if (doc === null)
117
101
  return null;
118
102
  const billing = doc.billing === "subscription" || doc.billing === "api" ? (doc.billing) : undefined;
@@ -27,25 +27,62 @@ function isWithin(p, dir) {
27
27
  const rel = path.relative(dir, p);
28
28
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
29
29
  }
30
- /** `p` with its longest existing prefix resolved through realpath, so a symlink, a `/tmp` that is
31
- * `/private/tmp`, or a path typed in the wrong case on a case-insensitive disk compares as the
32
- * file it reaches. The part that does not exist yet (a file a write would create) is appended
33
- * unchanged. */
30
+ /** `p` with its symlinks resolved as the file it reaches: each component is peeled and a
31
+ * symlink is followed to its target, so a symlinked checkout or a `/tmp` that is `/private/tmp`
32
+ * compares as the path it reaches. The not-yet-existing tail (a file a write would create) is
33
+ * appended unchanged. A broken symlink — target absent, the write/create case, where
34
+ * `realpathSync` throws ENOENT and the old longest-existing-prefix walk read the link as its
35
+ * own location inside the checkout — is followed to its target instead: the write would follow
36
+ * it, so the guard must see where it points. */
34
37
  function realish(p) {
35
- let existing = p;
36
- const rest = [];
37
- for (;;) {
38
+ // Peel the path component by component, exactly as `realpath` does: a regular component is
39
+ // kept, a symlink is replaced by the components its target resolves to, and the first absent
40
+ // component ends the walk, so a not-yet-existing tail is appended unchanged. Replacing the
41
+ // link with its target — instead of keeping the link's own path — is what catches a broken
42
+ // symlink: its target is absent (the write/create case, where `realpathSync` throws ENOENT
43
+ // and the old longest-existing-prefix walk read the link as its location inside the
44
+ // checkout), yet the write would still follow it, so the guard must see where it points. A
45
+ // depth bound stops a symlink cycle (a → b → a) from looping instead of escaping through the
46
+ // tail.
47
+ let q = path.resolve(p).split(path.sep).filter(Boolean);
48
+ const out = [];
49
+ let depth = 0;
50
+ while (q.length > 0) {
51
+ const part = q[0]; // the loop's length check guarantees a first component
52
+ const here = path.join(path.sep, ...out, part);
53
+ let st;
38
54
  try {
39
- return path.join(fs.realpathSync.native(existing), ...rest);
55
+ st = fs.lstatSync(here);
40
56
  }
41
57
  catch {
42
- const parent = path.dirname(existing);
43
- if (parent === existing)
44
- return p;
45
- rest.unshift(path.basename(existing));
46
- existing = parent;
58
+ out.push(part, ...q.slice(1)); // absent: this and the rest are the not-yet-existing tail
59
+ break;
60
+ }
61
+ if (!st.isSymbolicLink()) {
62
+ out.push(part);
63
+ q = q.slice(1);
64
+ }
65
+ else if (depth++ > 100) {
66
+ out.push(part, ...q.slice(1)); // cycle: give up rather than loop
67
+ break;
68
+ }
69
+ else {
70
+ let raw;
71
+ try {
72
+ raw = fs.readlinkSync(here); // a relative target is relative to the link's dir
73
+ }
74
+ catch {
75
+ out.push(part, ...q.slice(1)); // unreadable: treat as the not-yet-existing tail
76
+ break;
77
+ }
78
+ // The target, absolute-resolved, becomes the whole remaining path to peel: the link's
79
+ // own directory is dropped and replaced by wherever the link points.
80
+ const target = path.isAbsolute(raw) ? raw : path.resolve(path.join(path.sep, ...out), raw);
81
+ q = [...target.split(path.sep).filter(Boolean), ...q.slice(1)];
82
+ out.length = 0;
47
83
  }
48
84
  }
85
+ return path.join(path.sep, ...out);
49
86
  }
50
87
  /** A tool's path argument as pi's tools resolve it (pi `resolveToCwd`): a leading `@` dropped,
51
88
  * `~` expanded to the home directory, and a relative path taken from `base`. */
@@ -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
  }