tumwater 0.4.3 → 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 (164) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-run.js +2 -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/session-ledger.js +22 -26
  11. package/dist/src/agent/subscription-account.js +2 -83
  12. package/dist/src/backlog/backlog-archive.js +3 -4
  13. package/dist/src/backlog/backlog-eligibility.js +14 -36
  14. package/dist/src/backlog/backlog-question-answer.js +1 -1
  15. package/dist/src/backlog/backlog.js +2 -1
  16. package/dist/src/baseline/main-guardrails.js +2 -2
  17. package/dist/src/baseline/main-watch.js +84 -50
  18. package/dist/src/brief/brief.js +26 -13
  19. package/dist/src/budget/budget.js +4 -5
  20. package/dist/src/build/build-check-report.js +10 -0
  21. package/dist/src/build/build-check-scoped.js +0 -2
  22. package/dist/src/build/build-stage.js +6 -4
  23. package/dist/src/change/change-data.js +1 -4
  24. package/dist/src/cli/cli-completion-command.js +75 -0
  25. package/dist/src/cli/cli-doctor-command.js +103 -0
  26. package/dist/src/cli/cli-init.js +2 -2
  27. package/dist/src/cli/cli-query-commands.js +1 -1
  28. package/dist/src/cli/cli-role-command.js +2 -41
  29. package/dist/src/cli/cli-start-summary.js +4 -3
  30. package/dist/src/cli/cli-start-supervisor.js +190 -0
  31. package/dist/src/cli/cli-start.js +4 -187
  32. package/dist/src/cli/completion.js +3 -3
  33. package/dist/src/cli/follow-view.js +20 -2
  34. package/dist/src/cli/help.js +2 -7
  35. package/dist/src/cli.js +19 -171
  36. package/dist/src/collections.js +11 -14
  37. package/dist/src/concurrency/lock.js +1 -1
  38. package/dist/src/config/config-keys.js +61 -20
  39. package/dist/src/config/config-request.js +11 -1
  40. package/dist/src/config/config-schema.js +1 -1
  41. package/dist/src/config/config-validation-roles.js +6 -22
  42. package/dist/src/config/config-validation.js +0 -1
  43. package/dist/src/config/config-views.js +17 -16
  44. package/dist/src/config/config-write.js +122 -79
  45. package/dist/src/config/config.js +1 -9
  46. package/dist/src/doctor/doctor-backlog-plans.js +29 -96
  47. package/dist/src/doctor/doctor-backlog.js +10 -10
  48. package/dist/src/doctor/doctor-checks.js +4 -4
  49. package/dist/src/doctor/doctor-claude-checks.js +27 -9
  50. package/dist/src/doctor/doctor-contention-checks.js +13 -5
  51. package/dist/src/doctor/doctor-fallback-checks.js +46 -24
  52. package/dist/src/doctor/doctor-holds.js +2 -2
  53. package/dist/src/doctor/doctor-orphans.js +3 -2
  54. package/dist/src/doctor/doctor-project-checks.js +21 -8
  55. package/dist/src/events/event-format.js +15 -11
  56. package/dist/src/events/event-legacy.js +2 -2
  57. package/dist/src/events/event-notify.js +4 -4
  58. package/dist/src/events/event-window.js +1 -1
  59. package/dist/src/events/events.js +8 -0
  60. package/dist/src/failure/failure-cluster.js +4 -13
  61. package/dist/src/failure/failure-data.js +4 -4
  62. package/dist/src/failure/failure-render.js +2 -2
  63. package/dist/src/failure/failure-state-change.js +50 -86
  64. package/dist/src/gates/gate-polls.js +5 -6
  65. package/dist/src/gates/startup-gates.js +2 -1
  66. package/dist/src/git/git-diff.js +6 -2
  67. package/dist/src/gui/gui-args.js +12 -0
  68. package/dist/src/gui/gui-config-commands.js +2 -64
  69. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  70. package/dist/src/gui/gui-endpoint-common.js +19 -1
  71. package/dist/src/gui/gui-operator-commands.js +21 -25
  72. package/dist/src/gui/gui-prompt-commands.js +2 -2
  73. package/dist/src/gui/gui-server.js +1 -2
  74. package/dist/src/gui/gui-team-commands.js +2 -1
  75. package/dist/src/history/history.js +4 -4
  76. package/dist/src/merge/merge-check-failures.js +12 -9
  77. package/dist/src/merge/merge-core.js +6 -31
  78. package/dist/src/merge/merge-drain.js +3 -2
  79. package/dist/src/merge/merge-post-review-drain.js +1 -1
  80. package/dist/src/merge/merge-queue-render.js +3 -3
  81. package/dist/src/merge/merge-stack.js +17 -18
  82. package/dist/src/merge/merge-to-main.js +13 -58
  83. package/dist/src/merge/merge-verify.js +4 -3
  84. package/dist/src/operator/operator-commands.js +4 -3
  85. package/dist/src/orchestrator/orchestrator-poll.js +18 -38
  86. package/dist/src/orchestrator/orchestrator-scheduling.js +5 -9
  87. package/dist/src/orchestrator/scheduling-scope.js +17 -26
  88. package/dist/src/orchestrator/scheduling-skip.js +9 -13
  89. package/dist/src/paths.js +2 -2
  90. package/dist/src/pi/pi-event-line.js +27 -0
  91. package/dist/src/pi/pi-listed-models.js +13 -0
  92. package/dist/src/pi/pi-stream.js +9 -2
  93. package/dist/src/pi/pi-watchdogs.js +31 -10
  94. package/dist/src/process/process-group.js +2 -21
  95. package/dist/src/process/process.js +12 -4
  96. package/dist/src/process/run-marker.js +2 -2
  97. package/dist/src/prompt/gate-prompts.js +1 -44
  98. package/dist/src/redeploy/redeployer.js +21 -1
  99. package/dist/src/review/diff-rules.js +35 -21
  100. package/dist/src/review/post-merge-review.js +47 -40
  101. package/dist/src/review/review-precheck.js +3 -3
  102. package/dist/src/review/review.js +13 -22
  103. package/dist/src/roles/legacy-role-migration.js +6 -3
  104. package/dist/src/roles/role-render.js +6 -32
  105. package/dist/src/roles/role-view.js +2 -13
  106. package/dist/src/run/ci-parity.js +19 -8
  107. package/dist/src/run/run-apply.js +0 -9
  108. package/dist/src/run/run-stage.js +6 -6
  109. package/dist/src/run/run-verdict.js +13 -4
  110. package/dist/src/run/stage-check.js +17 -24
  111. package/dist/src/runner/runner-leftover.js +9 -26
  112. package/dist/src/runner/runner-outcome.js +1 -1
  113. package/dist/src/runner/runner-pi.js +6 -12
  114. package/dist/src/runner/runner-recovery.js +4 -12
  115. package/dist/src/runner/runner-revision.js +1 -2
  116. package/dist/src/runner/runner-run.js +7 -16
  117. package/dist/src/runner/runner-state-load.js +6 -19
  118. package/dist/src/runner/runner-state.js +0 -14
  119. package/dist/src/scheduling/backoff.js +8 -3
  120. package/dist/src/scheduling/claim-entries.js +2 -1
  121. package/dist/src/status/status-budget.js +4 -43
  122. package/dist/src/status/status-data.js +34 -38
  123. package/dist/src/status/status-roles.js +1 -2
  124. package/dist/src/team/team-hold.js +7 -5
  125. package/dist/src/team/team-orchestrator-info.js +53 -21
  126. package/dist/src/team/team-polls.js +36 -86
  127. package/dist/src/text/datetime.js +16 -7
  128. package/dist/src/text/phrases.js +9 -0
  129. package/dist/src/text/suggest.js +1 -1
  130. package/dist/src/text/text.js +3 -2
  131. package/dist/src/ui/alert-feeds.js +0 -41
  132. package/dist/src/ui/badges.js +14 -75
  133. package/dist/src/ui/gui/gui-client-boot.js +0 -6
  134. package/dist/src/ui/gui/gui-client-drawer-config.js +1 -16
  135. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  136. package/dist/src/ui/gui/gui-client-operator.js +7 -5
  137. package/dist/src/ui/gui/gui-client-team.js +8 -2
  138. package/dist/src/ui/progress-data.js +54 -4
  139. package/dist/src/ui/run-progress-model.js +12 -3
  140. package/dist/src/ui/status-payload.js +10 -25
  141. package/dist/src/ui/status-render.js +10 -17
  142. package/dist/src/ui/team-alert-backlog.js +15 -205
  143. package/dist/src/ui/team-alert-shared.js +6 -29
  144. package/dist/src/ui/team-alerts.js +4 -10
  145. package/dist/src/ui/tui/tui-backlog.js +2 -2
  146. package/dist/src/ui/tui/tui-editors.js +5 -4
  147. package/dist/src/ui/tui/tui-input.js +8 -6
  148. package/dist/src/ui/tui/tui-pane-activity.js +2 -1
  149. package/dist/src/ui/tui/tui-pane-health.js +21 -23
  150. package/dist/src/ui/tui/tui-pane-history.js +7 -5
  151. package/dist/src/ui/tui/tui-pane-pending.js +19 -15
  152. package/dist/src/ui/tui/tui-pane-session.js +4 -3
  153. package/dist/src/ui/tui/tui-panes.js +9 -1
  154. package/dist/src/ui/tui/tui.js +3 -5
  155. package/dist/src/verdict/fix-claim.js +36 -7
  156. package/dist/src/verdict/reply-contract.js +3 -4
  157. package/package.json +2 -2
  158. package/dist/src/review/review-followup.js +0 -87
  159. package/dist/src/review/review-run.js +0 -395
  160. /package/dist/src/cli/{brief-command.js → cli-brief-command.js} +0 -0
  161. /package/dist/src/cli/{config-commands.js → cli-config-commands.js} +0 -0
  162. /package/dist/src/cli/{log-commands.js → cli-log-commands.js} +0 -0
  163. /package/dist/src/cli/{notify-command.js → cli-notify-command.js} +0 -0
  164. /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.4**: 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": "1f6978ed5d8061b12afc907b017f0164426f49ee",
3
+ "builtAt": 1793345933702,
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,7 @@ 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,
124
125
  });
125
126
  let aborted = false;
126
127
  // 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) {
@@ -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,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` (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
- /** 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
  }
@@ -54,11 +54,10 @@ export function needsReviewNote(body) {
54
54
  * for the bugfix role's wait-for-the-next-failure note ("by bugfix: …"), which parks the
55
55
  * entry for selfcheck to lift when the cluster fails again (selfcheck step 4) and which no
56
56
  * role can act on until then. entryHold returns "needs-review" for every such note (all of
57
- * them park the entry), so the stalled-with-the-plan-role surfaces — the stalled-entries team
58
- * alert (team-alert-backlog.ts) and the doctor's `stalled entries` row
59
- * (checkStalledEntries) — classify by this agreed subset, keeping the bugfix parks out of the
60
- * plan-role blame. Reads the note's text after the NEEDS_REVIEW_PREFIX once, so the alert and
61
- * the doctor row cannot disagree on which notes the plan role clears; the eligibility mark
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
62
61
  * (holdMark) continues to hold every needs-review note, a by-bugfix park included. */
63
62
  export function isPlanStalled(entry) {
64
63
  const note = needsReviewNote(entry.body);
@@ -279,49 +278,28 @@ export function backlogHolds(root) {
279
278
  * `tumwater backlog` prints. A surface derives its marks from the one hold decision and the
280
279
  * one claim lookup rather than re-walking the entries. An eligible entry nobody is working on
281
280
  * renders as the empty string, so a client can append the mark without a separate check.
282
- * Questions carry no holds. backlogHolds alone still drives the allocator. */
283
- 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) {
284
287
  const holds = backlogHolds(root);
285
- const working = backlogInProgress(root);
288
+ const inProgress = working ?? backlogInProgress(root);
286
289
  const mark = (hold, runner) => holdMark(hold) + inProgressMark(runner ?? null);
287
290
  return {
288
- plans: holds.plans.map((h, i) => mark(h, working.plans[i])),
289
- bugs: holds.bugs.map((h, i) => mark(h, working.bugs[i])),
291
+ plans: holds.plans.map((h, i) => mark(h, inProgress.plans[i])),
292
+ bugs: holds.bugs.map((h, i) => mark(h, inProgress.bugs[i])),
290
293
  };
291
294
  }
292
295
  /** The display noun for a backlog-file discriminator — the `"plans" | "bugs"` `file` on
293
- * 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
294
297
  * and the doctor's Stalled row take): BUGS.md is a "Bug" and PLANS.md a "Plan", and the
295
298
  * lowercase form is the `Refused plan|bug:` label and the `tumwater plan|bug --release`
296
299
  * command noun. */
297
300
  export function entryKindLabel(file) {
298
301
  return file === "bugs" ? "Bug" : "Plan";
299
302
  }
300
- /** The planned plans and open bugs parked with the plan role — whose Needs-review hold the plan
301
- * role clears by splitting or replanning (the feature role's too-large note, the convergence hold,
302
- * a final-round rejection), in file order: the `stalled-entries` team alert's input, each
303
- * carrying its backlog file, 0-based index and the hold. Same `backlogHolds(root)` + the
304
- * planned/`openBugEntries` readers `refusedEntries` uses, so the alert and the doctor's
305
- * `stalled entries` row classify through the same `entryHold` + `isPlanStalled` and cannot
306
- * disagree on which holds are role-resolvable. The bugfix role's wait-for-the-next-failure
307
- * parks ("by bugfix:") are excluded: selfcheck lifts them, not the plan role. A missing
308
- * file yields `[]`. */
309
- export function stalledEntries(root) {
310
- const holds = backlogHolds(root);
311
- const out = [];
312
- const entriesOf = (entries, held, file) => {
313
- entries.forEach((e, i) => {
314
- const hold = held[i];
315
- // Only holds the plan role can clear surface as stalled: the bugfix role's
316
- // wait-for-the-next-failure parks stay out of the plan-role blame.
317
- if (hold === "needs-review" && isPlanStalled(e))
318
- out.push({ file, index: i, title: e.title, hold });
319
- });
320
- };
321
- entriesOf(plannedPlanEntries(root), holds.plans, "plans");
322
- entriesOf(openBugEntries(root), holds.bugs, "bugs");
323
- return out;
324
- }
325
303
  /** The planned plans, then the open bugs, whose hold is `"refused"`, in file order: the
326
304
  * entries that wait on an operator decision (the `refused` team alert's input), each carrying
327
305
  * the backlog file and its 0-based position so a surface opening the row can land on it.
@@ -7,7 +7,7 @@
7
7
  * output helpers, so every surface can drive it without ending the process.
8
8
  *
9
9
  * Shared by every read/answer surface: the CLI's `tumwater questions` command body wraps it in
10
- * src/cli/question-commands.ts (the say/fail/editor layer), while the TUI's answer box
10
+ * src/cli/cli-question-commands.ts (the say/fail/editor layer), while the TUI's answer box
11
11
  * (src/ui/tui/tui-keys.ts) and the dashboard's POST route (src/gui/gui-endpoint-commands.ts)
12
12
  * import the non-exiting answer core directly. It lives with the rest of the BUGS/PLANS/QUESTIONS
13
13
  * markdown (src/backlog/), not under src/cli/, so the non-command surfaces never depend on the
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { cachedTextByStat } from "../files/stat-cache.js";
3
+ import { DAY_MS } from "../text/datetime.js";
3
4
  import { entryDates, entryLags, finishedArchiveOf, firstDate, lastDate, parseEntryDetails } from "./backlog-md.js";
4
5
  import { CHECK_DECLARATION_MARKER } from "../roles/role-guidance.js";
5
6
  /** The stat-cached file readers of the project backlog data shown on both dashboards: planned
@@ -104,7 +105,7 @@ export function completionDate(title, dateRe) {
104
105
  /** Whole days between a filing stamp and `now`, floored at 0 so a future-dated entry reads as
105
106
  * fresh (0d) rather than negative. Shared by the suffix and the `--list --json` `ageDays` field. */
106
107
  export function ageInDays(since, now = Date.now()) {
107
- return Math.max(0, Math.floor((now - Date.parse(since)) / 86400000));
108
+ return Math.max(0, Math.floor((now - Date.parse(since)) / DAY_MS));
108
109
  }
109
110
  /** The ` · since YYYY-MM-DD (Nd)`/`(today)` tail a listing line gains for its entry's filing
110
111
  * stamp — the one place the wording and the today/`Nd` choice live, so the plans/bugs list
@@ -13,7 +13,7 @@
13
13
  * Both take an injected `now` so tests never read the clock. */
14
14
  import { readEventsSince } from "../events/event-window.js";
15
15
  import { loadQuarantine } from "../review/known-flakes.js";
16
- import { DAY_MS, clockHm, humanAgo } from "../text/datetime.js";
16
+ import { DAY_MS, HOUR_MS, clockHm, humanAgo } from "../text/datetime.js";
17
17
  import { shortSha } from "../text/format.js";
18
18
  import { roleLabel } from "../roles/role-labels.js";
19
19
  import { plural } from "../text/phrases.js";
@@ -193,5 +193,5 @@ function redDurationPhrase(ms) {
193
193
  const minutes = Math.round(ms / 60_000);
194
194
  if (minutes < 24 * 60)
195
195
  return `${minutes} min`;
196
- return `${Math.round(ms / 3_600_000)} h`;
196
+ return `${Math.round(ms / HOUR_MS)} h`;
197
197
  }