tumwater 0.4.1 → 0.4.2

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 (186) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +14 -0
  4. package/dist/src/agent/claude/claude-stream.js +27 -31
  5. package/dist/src/agent/codex/codex-rollout.js +38 -24
  6. package/dist/src/agent/codex/codex-stream.js +71 -38
  7. package/dist/src/agent/image-input.js +18 -87
  8. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  9. package/dist/src/agent/opencode/opencode-stream.js +4 -4
  10. package/dist/src/agent/run-agent.js +11 -2
  11. package/dist/src/agent/run-settle.js +22 -13
  12. package/dist/src/agent/run-spawn.js +3 -12
  13. package/dist/src/agent/run-stream.js +2 -10
  14. package/dist/src/agent/session-ledger.js +16 -0
  15. package/dist/src/backlog/backlog-archive.js +64 -165
  16. package/dist/src/backlog/backlog-eligibility.js +50 -58
  17. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  18. package/dist/src/backlog/backlog-index-data.js +2 -2
  19. package/dist/src/backlog/backlog-md.js +38 -27
  20. package/dist/src/backlog/backlog-release.js +11 -19
  21. package/dist/src/backlog/backlog-structure.js +14 -1
  22. package/dist/src/backlog/backlog-write.js +1 -1
  23. package/dist/src/backlog/backlog.js +5 -4
  24. package/dist/src/backlog/question-answer.js +30 -14
  25. package/dist/src/baseline/main-baseline.js +30 -20
  26. package/dist/src/baseline/main-guardrails.js +54 -17
  27. package/dist/src/baseline/main-watch.js +1 -1
  28. package/dist/src/budget/budget-wiring.js +0 -5
  29. package/dist/src/budget/budget.js +2 -1
  30. package/dist/src/build/build-check-scoped.js +70 -41
  31. package/dist/src/change/change-data.js +4 -2
  32. package/dist/src/cli/cli-command-args.js +199 -98
  33. package/dist/src/cli/cli-flag-specs.js +1 -4
  34. package/dist/src/cli/cli-review-commands.js +2 -1
  35. package/dist/src/cli/cli-role-command.js +27 -50
  36. package/dist/src/cli/cli-start.js +62 -12
  37. package/dist/src/cli/follow-view.js +3 -1
  38. package/dist/src/cli/help.js +6 -7
  39. package/dist/src/cli/log-commands.js +3 -1
  40. package/dist/src/cli.js +3 -7
  41. package/dist/src/collections.js +21 -7
  42. package/dist/src/config/config-fallbacks.js +0 -4
  43. package/dist/src/config/config-live.js +179 -147
  44. package/dist/src/config/config-views.js +2 -7
  45. package/dist/src/config/config-write.js +19 -26
  46. package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
  47. package/dist/src/doctor/doctor-backlog-plans.js +8 -8
  48. package/dist/src/doctor/doctor-backlog.js +22 -36
  49. package/dist/src/doctor/doctor-check-names.js +2 -2
  50. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  51. package/dist/src/doctor/doctor-contention-checks.js +1 -1
  52. package/dist/src/doctor/doctor-holds.js +13 -11
  53. package/dist/src/doctor/doctor-model-checks.js +8 -184
  54. package/dist/src/doctor/doctor-notify.js +2 -5
  55. package/dist/src/doctor/doctor-opencode-checks.js +85 -48
  56. package/dist/src/doctor/doctor-project-checks.js +66 -17
  57. package/dist/src/doctor/doctor-role-notes.js +6 -24
  58. package/dist/src/doctor/doctor.js +6 -6
  59. package/dist/src/doctor/probe.js +1 -1
  60. package/dist/src/events/event-format.js +5 -15
  61. package/dist/src/events/event-labels.js +0 -1
  62. package/dist/src/events/event-legacy.js +1 -1
  63. package/dist/src/events/event-window.js +1 -1
  64. package/dist/src/events/events.js +15 -2
  65. package/dist/src/failure/failure-state-change.js +183 -68
  66. package/dist/src/failure/time-spend.js +1 -1
  67. package/dist/src/files/json-object.js +15 -2
  68. package/dist/src/gates/startup-gate.js +28 -28
  69. package/dist/src/git/slot-salvage.js +5 -4
  70. package/dist/src/gui/gui-admin-endpoints.js +2 -3
  71. package/dist/src/gui/gui-backlog-commands.js +1 -1
  72. package/dist/src/gui/gui-backlog-endpoints.js +1 -7
  73. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  74. package/dist/src/gui/gui-operator-commands.js +0 -12
  75. package/dist/src/gui/gui-prompt-commands.js +26 -20
  76. package/dist/src/gui/gui-report-endpoints.js +2 -1
  77. package/dist/src/gui/gui-server.js +1 -2
  78. package/dist/src/history/history.js +102 -67
  79. package/dist/src/inbox/inbox-submit.js +1 -5
  80. package/dist/src/inbox/prompt-commands.js +24 -19
  81. package/dist/src/init/init.js +68 -15
  82. package/dist/src/merge/merge-core.js +1 -1
  83. package/dist/src/merge/merge-post-review-drain.js +1 -1
  84. package/dist/src/operator/operator-commands.js +68 -47
  85. package/dist/src/operator/retire.js +2 -1
  86. package/dist/src/orchestrator/retention.js +6 -5
  87. package/dist/src/paths.js +0 -6
  88. package/dist/src/pi/pi-watchdogs.js +20 -1
  89. package/dist/src/pi-extension/context-shake.js +55 -31
  90. package/dist/src/process/process.js +23 -0
  91. package/dist/src/process/supervisor.js +17 -1
  92. package/dist/src/prompt/gate-notes.js +9 -9
  93. package/dist/src/prompt/notes-backlog.js +11 -24
  94. package/dist/src/prompt/prompt-followup.js +4 -12
  95. package/dist/src/prompt/prompt.js +4 -5
  96. package/dist/src/report/report-data.js +10 -225
  97. package/dist/src/report/report-fold.js +20 -295
  98. package/dist/src/report/report-main-red.js +45 -42
  99. package/dist/src/report/report-render.js +66 -39
  100. package/dist/src/report/report-since-data.js +218 -0
  101. package/dist/src/report/report-usage-fold.js +288 -0
  102. package/dist/src/report/report.js +2 -1
  103. package/dist/src/review/known-flakes.js +3 -3
  104. package/dist/src/review/post-merge-review-accept.js +3 -2
  105. package/dist/src/review/post-merge-review.js +9 -9
  106. package/dist/src/review/quarantine-render.js +2 -1
  107. package/dist/src/roles/role-catalog.js +18 -30
  108. package/dist/src/roles/role-guidance.js +18 -43
  109. package/dist/src/roles/role-view.js +1 -2
  110. package/dist/src/run/ci-parity.js +5 -6
  111. package/dist/src/run/qa-coverage.js +4 -4
  112. package/dist/src/run/run-detail-data.js +23 -7
  113. package/dist/src/run/run-detail-render.js +6 -0
  114. package/dist/src/run/run-detail.js +44 -22
  115. package/dist/src/run/run-dismiss.js +43 -0
  116. package/dist/src/run/run-prompt.js +100 -56
  117. package/dist/src/run/run-resume.js +32 -31
  118. package/dist/src/run/run-stage.js +4 -21
  119. package/dist/src/run/run-timing.js +18 -4
  120. package/dist/src/run/run-verdict.js +8 -73
  121. package/dist/src/run/stage-backlog-checks.js +2 -29
  122. package/dist/src/run/stage-check.js +4 -12
  123. package/dist/src/runner/runner-outcome.js +0 -5
  124. package/dist/src/runner/runner-run.js +2 -79
  125. package/dist/src/runner/runner-state-load.js +0 -10
  126. package/dist/src/runner/runner.js +1 -32
  127. package/dist/src/scheduling/claims.js +3 -10
  128. package/dist/src/status/status-data.js +5 -9
  129. package/dist/src/status/status-role-notes.js +18 -14
  130. package/dist/src/status/status-roles.js +2 -35
  131. package/dist/src/text/datetime.js +8 -1
  132. package/dist/src/text/text.js +12 -0
  133. package/dist/src/ui/alert-feeds.js +7 -212
  134. package/dist/src/ui/badges.js +2 -3
  135. package/dist/src/ui/gui/gui-client-boot.js +19 -4
  136. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  137. package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
  138. package/dist/src/ui/gui/gui-client-report.js +1 -2
  139. package/dist/src/ui/gui/gui-client-settings.js +0 -23
  140. package/dist/src/ui/gui/gui-client-team.js +26 -0
  141. package/dist/src/ui/gui/gui-icons.js +7 -1
  142. package/dist/src/ui/status-model.js +2 -2
  143. package/dist/src/ui/status-payload.js +3 -3
  144. package/dist/src/ui/status-render.js +3 -3
  145. package/dist/src/ui/team-alert-backlog.js +23 -54
  146. package/dist/src/ui/team-alert-shared.js +23 -10
  147. package/dist/src/ui/team-alert-status.js +8 -3
  148. package/dist/src/ui/team-alert-tail.js +1 -14
  149. package/dist/src/ui/team-alerts.js +6 -17
  150. package/dist/src/ui/transcript-tail.js +137 -94
  151. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  152. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  153. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  154. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  155. package/dist/src/ui/tui/tui-editor-budget.js +105 -93
  156. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  157. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  158. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  159. package/dist/src/ui/tui/tui-editor-pause.js +209 -183
  160. package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
  161. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  162. package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
  163. package/dist/src/ui/tui/tui-editor-release.js +50 -42
  164. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  165. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  166. package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
  167. package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
  168. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  169. package/dist/src/ui/tui/tui-frame.js +1 -1
  170. package/dist/src/ui/tui/tui-keys.js +1 -1
  171. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  172. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  173. package/dist/src/ui/tui/tui-pane-health.js +3 -24
  174. package/dist/src/ui/tui/tui-pane-history.js +20 -24
  175. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  176. package/dist/src/ui/tui/tui-pane-session.js +3 -2
  177. package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
  178. package/dist/src/ui/tui/tui-pane.js +201 -174
  179. package/dist/src/ui/tui/tui-panes.js +2 -3
  180. package/dist/src/ui/tui/tui.js +3 -3
  181. package/dist/src/verdict/fix-claim.js +18 -6
  182. package/dist/src/verdict/refusal.js +30 -6
  183. package/package.json +1 -1
  184. package/dist/src/doctor/image-probe.js +0 -257
  185. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  186. package/dist/src/ui/team-alert-merge.js +0 -183
@@ -12,7 +12,7 @@ import { escapeRegExp } from "../text/text.js";
12
12
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
13
13
  import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-model-checks.js";
14
14
  /** The doctor's opencode credential and leak readiness check, split out of doctor-model-checks.ts
15
- * (whose shared seam/model framework and the pi, tier-models and image-input checks stay there):
15
+ * (whose shared seam/model framework and the pi and tier-models checks stay there):
16
16
  * checkOpencode and its helpers (opencodeProviderOf, opencodeAuthLists, jsonKeyPresent,
17
17
  * opencodeConfigLeaks). This is the doctor's only check that spawns the `opencode` CLI and reads
18
18
  * its catalog and the operator's global opencode config — a distinct concern from the
@@ -108,6 +108,80 @@ function opencodeConfigLeaks(configHome) {
108
108
  warns.push(`${agentsMd} still loads into team runs`);
109
109
  return warns;
110
110
  }
111
+ /** A catalog model's price text — "free" when free, the billed-demotion phrase when `billedAt`
112
+ * is set, else the per-M prices — so opencodeModelRow can name the price without the nested
113
+ * ternary and its two `?? 0` fallbacks. */
114
+ function opencodePriceText(free, billedAt, inputPerM, outputPerM) {
115
+ if (free)
116
+ return "free";
117
+ if (billedAt !== undefined)
118
+ return `no longer free (billed ${formatDate(new Date(billedAt))})`;
119
+ return `$${inputPerM ?? 0}/$${outputPerM ?? 0} per M`;
120
+ }
121
+ /** One opencode catalog model's row: today's per-model body from the cache key through the
122
+ * dropped-effort warning, returning the catalog note and any warnings, or undefined when the
123
+ * model is missing from the cache (the caller owns that failure message). `warnings` holds the
124
+ * billed-at demotion line and the dropped-effort line in today's order. */
125
+ function opencodeModelRow(cache, model, thinking) {
126
+ const key = opencodeModelKey(model);
127
+ const entry = cache.models[key];
128
+ if (entry === undefined)
129
+ return undefined;
130
+ const warnings = [];
131
+ const free = opencodeModelFree(cache, key);
132
+ if (entry.billedAt !== undefined) {
133
+ warnings.push(`${model} was free in the catalog but billed once on ${formatDate(new Date(entry.billedAt))} — the budget gate now prices it`);
134
+ }
135
+ const window = entry.context !== undefined ? `, ${tokenWindowLabel(entry.context)}` : "";
136
+ const note = `${model} — ${opencodePriceText(free, entry.billedAt, entry.inputPerM, entry.outputPerM)}${window}`;
137
+ // A model with no effort list at all supports no thinking level, so a requested level is
138
+ // dropped just the same as one the list omits.
139
+ if (thinking !== undefined && thinking !== "" && !entry.efforts?.includes(thinking)) {
140
+ warnings.push(`${model} has no ${thinking} effort — that thinking level is dropped`);
141
+ }
142
+ return { note, warnings };
143
+ }
144
+ /** The team's opencode database and spill directory sizes as one information line (`db …,
145
+ * spill …`), reading the db, the spill dir and their byte formatting — today's storage tail. */
146
+ function opencodeStorageNote(root) {
147
+ const db = statOrNull(opencodeDbPath(root));
148
+ const spillDir = opencodeSpillDir(root);
149
+ const spill = spillDir !== "" && isDirectory(spillDir) ? treeBytes(spillDir) : null;
150
+ return `db ${formatBytes(db?.size ?? 0)}${spill !== null ? `, spill ${formatBytes(spill)}` : ", no spill dir"}`;
151
+ }
152
+ /** Walk the opencode-routed model pairs into today's per-model fails, warns and notes: the
153
+ * catalog-miss failure, the catalog note and its warnings from opencodeModelRow, and the
154
+ * non-gateway credential probe each provider. Returns the three arrays checkOpencode assembles. */
155
+ async function collectOpencodeModelRows(cache, models, config, pathEnv, timeoutMs) {
156
+ const fails = [];
157
+ const warns = [];
158
+ const notes = [];
159
+ for (const pair of models) {
160
+ const model = pair.model;
161
+ const row = opencodeModelRow(cache, model, pair.thinking);
162
+ if (row === undefined) {
163
+ fails.push(`${model} is not in the opencode catalog cache`);
164
+ continue;
165
+ }
166
+ notes.push(row.note);
167
+ warns.push(...row.warnings);
168
+ const provider = opencodeProviderOf(model);
169
+ if (provider !== undefined && !(await opencodeAuthLists(config, provider, pathEnv, timeoutMs))) {
170
+ warns.push(`${model} is a non-gateway provider opencode does not list a credential for`);
171
+ }
172
+ }
173
+ return { fails, warns, notes };
174
+ }
175
+ /** The check's outcome from the accumulated fails/warns/notes and the storage tail: the joined
176
+ * base line ("opencode ready — …" when nothing is wrong, else the found lines) and the
177
+ * fail/warn/ok verdict by severity. */
178
+ function opencodeOutcome(fails, warns, notes, sizeNotes) {
179
+ const found = [...fails, ...warns];
180
+ const base = found.length === 0 ? `opencode ready — ${notes.join("; ")}; ${sizeNotes}` : found.join("; ");
181
+ if (fails.length > 0)
182
+ return failOutcome(base);
183
+ return (warns.length > 0 ? warnOutcome : okOutcome)(base);
184
+ }
111
185
  /** OpenCode model, credential and leak readiness (plans/opencode-backend.md §7, part 7/7):
112
186
  * every model routed to opencode must be in the catalog cache — an unknown model fails, a
113
187
  * cached one reads "free" or its per-M prices and context window, and a thinking level with no
@@ -120,57 +194,20 @@ function opencodeConfigLeaks(configHome) {
120
194
  * PATH or HOME. A model's freeness is read from opencodeModelFree — the same call the budget
121
195
  * gate and status use — so a catalog-free model that an observed bill demoted to billed reads
122
196
  * as billed here, not free, keeping the row from drifting from what the team is billed. The
123
- * version half of the opencode pre-flight is the sibling checkOpencodeVersion in
124
- * doctor-checks.ts. */
197
+ * per-model catalog row lives in opencodeModelRow, the database-and-spill size line in
198
+ * opencodeStorageNote; both are the two extracted jobs the reader names by. The version half of
199
+ * the opencode pre-flight is the sibling checkOpencodeVersion in doctor-checks.ts. */
125
200
  export async function checkOpencode(root, pathEnv = envPath(), home = os.homedir(), xdgConfigHome = process.env.XDG_CONFIG_HOME ?? "", timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
126
201
  const config = loadConfigForCheck(root);
127
202
  if ("level" in config)
128
203
  return config;
129
204
  if (!opencodeSeamsInUse(config))
130
205
  return okOutcome("no model jobs run on opencode");
131
- const models = seamedModels(config, "opencode");
132
- const cache = readOpencodeCache(root);
133
- const fails = [];
134
- const warns = [];
135
- const notes = [];
136
- for (const pair of models) {
137
- const key = opencodeModelKey(pair.model);
138
- const entry = cache.models[key];
139
- if (entry === undefined) {
140
- fails.push(`${pair.model} is not in the opencode catalog cache`);
141
- continue;
142
- }
143
- const free = opencodeModelFree(cache, key);
144
- if (entry.billedAt !== undefined) {
145
- warns.push(`${pair.model} was free in the catalog but billed once on ${formatDate(new Date(entry.billedAt))} — the budget gate now prices it`);
146
- }
147
- const price = free
148
- ? "free"
149
- : entry.billedAt !== undefined
150
- ? `no longer free (billed ${formatDate(new Date(entry.billedAt))})`
151
- : `$${entry.inputPerM ?? 0}/$${entry.outputPerM ?? 0} per M`;
152
- const window = entry.context !== undefined ? `, ${tokenWindowLabel(entry.context)}` : "";
153
- notes.push(`${pair.model} — ${price}${window}`);
154
- // A model with no effort list at all supports no thinking level, so a requested level is
155
- // dropped just the same as one the list omits.
156
- if (pair.thinking !== undefined &&
157
- pair.thinking !== "" &&
158
- !entry.efforts?.includes(pair.thinking)) {
159
- warns.push(`${pair.model} has no ${pair.thinking} effort — that thinking level is dropped`);
160
- }
161
- const provider = opencodeProviderOf(pair.model);
162
- if (provider !== undefined && !(await opencodeAuthLists(config, provider, pathEnv, timeoutMs))) {
163
- warns.push(`${pair.model} is a non-gateway provider opencode does not list a credential for`);
164
- }
165
- }
166
- warns.push(...opencodeConfigLeaks(xdgConfigHome !== "" ? xdgConfigHome : path.join(home, ".config")));
167
- const db = statOrNull(opencodeDbPath(root));
168
- const spillDir = opencodeSpillDir(root);
169
- const spill = spillDir !== "" && isDirectory(spillDir) ? treeBytes(spillDir) : null;
170
- const sizeNotes = `db ${formatBytes(db?.size ?? 0)}${spill !== null ? `, spill ${formatBytes(spill)}` : ", no spill dir"}`;
171
- const found = [...fails, ...warns];
172
- const base = found.length === 0 ? `opencode ready — ${notes.join("; ")}; ${sizeNotes}` : found.join("; ");
173
- if (fails.length > 0)
174
- return failOutcome(base);
175
- return (warns.length > 0 ? warnOutcome : okOutcome)(base);
206
+ const rows = await collectOpencodeModelRows(readOpencodeCache(root), seamedModels(config, "opencode"), config, pathEnv, timeoutMs);
207
+ const warns = [
208
+ ...rows.warns,
209
+ ...opencodeConfigLeaks(xdgConfigHome !== "" ? xdgConfigHome : path.join(home, ".config")),
210
+ ];
211
+ const sizeNotes = opencodeStorageNote(root);
212
+ return opencodeOutcome(rows.fails, warns, rows.notes, sizeNotes);
176
213
  }
@@ -267,13 +267,10 @@ export function checkFlakyChecks(root, now = Date.now()) {
267
267
  return warnOutcome(phrases.join("; "));
268
268
  }
269
269
  /** A trailing day of at least `POST_MERGE_REJECT_WARN` post-merge `review_rejected` events — the
270
- * audit twin of the always-on `post-merge-churn` amber alert (src/ui/alert-feeds.ts
271
- * `postMergeChurnAlert`). Each rejection is an already-landed commit the reviewer turned back, so
272
- * it stays on main with the reviewer's reasons, so a churn day must not read as a clean
273
- * main. Shares the alert's
274
- * window, threshold and
275
- * event selection through `postMergeChurn`, so the doctor row and the alert cannot disagree on
276
- * when churn fires. ok/warn only — a churn burst does not stop the team. */
270
+ * audit of a day that must not read as a clean main: each rejection is an already-landed commit
271
+ * the reviewer turned back, so it stays on main with the reviewer's reasons. Reads the
272
+ * `POST_MERGE_REJECT_WINDOW_MS` window and
273
+ * threshold through `postMergeChurn`. ok/warn only — a churn burst does not stop the team. */
277
274
  export function checkPostMergeChurn(root) {
278
275
  const { events } = readEventsSince(root, POST_MERGE_REJECT_WINDOW_MS);
279
276
  const churn = postMergeChurn(events);
@@ -284,10 +281,10 @@ export function checkPostMergeChurn(root) {
284
281
  .join(", ")}) — each commit had already reached main and stays there with the reviewer's reasons; narrow \`review.afterMerge\` for the noisy role or raise the authoring bar`);
285
282
  }
286
283
  /** The one home of the doctor's `flaky checks` sentence: names each check that flapped more than
287
- * once, most frequent first, with how many times it flapped — the same wording the always-on
288
- * flapped-checks alert carries, so the doctor row and the alert cannot drift. null when `flapped`
284
+ * once, most frequent first, with how many times it flapped — the one sentence the doctor's
285
+ * `flaky checks` row shows. null when `flapped`
289
286
  * is empty (nothing crossed the bar). */
290
- export function flappedChecksPhrase(flapped) {
287
+ function flappedChecksPhrase(flapped) {
291
288
  if (flapped.length === 0)
292
289
  return null;
293
290
  const render = ([k, n]) => `"${k}" ×${n}`;
@@ -325,7 +322,7 @@ running = null) {
325
322
  : running?.restartPending
326
323
  ? "; auto-restart is under way"
327
324
  : "; run `npm run build` and restart `tumwater start` (auto-restart does this for a running team)";
328
- return warnOutcome(`dist/ from ${sha} is stale — main has ${stale.aheadCommits} later commit(s) touching ${STALE_INPUTS_LABEL}${next}`);
325
+ return warnOutcome(`dist/ from ${sha} is stale — main has ${plural(stale.aheadCommits, "later commit")} touching ${STALE_INPUTS_LABEL}${next}`);
329
326
  }
330
327
  return okOutcome(`dist/ from ${sha}, matches main`);
331
328
  }
@@ -344,7 +341,7 @@ function currentHead(root) {
344
341
  * on every merge and the ci role runs on its own clock, so a post-merge gap is normal and must
345
342
  * not turn the Health card or the exit code red. A stale verdict that stays stale past
346
343
  * `CI_PARITY_STALE_MS` (ciParityStalled) is a stall — the full suite has silently stopped
347
- * covering new tips — and warns, alongside the standing `ci-stale` alert. Read-only against
344
+ * covering new tips — and warns. Read-only against
348
345
  * .tumwater/. */
349
346
  export function checkCiParity(root, config, now = Date.now()) {
350
347
  const entry = readCiParity(root);
@@ -360,9 +357,9 @@ export function checkCiParity(root, config, now = Date.now()) {
360
357
  }
361
358
  // A stale verdict is normal right after a merge (main moves and the ci role runs on its own
362
359
  // clock), but one that stays stale past CI_PARITY_STALE_MS is a stall: the full suite has
363
- // silently stopped covering new tips. The doctor warn and the standing `ci-stale` alert share
364
- // this one predicate (ciParityStalled), so the row and the alert cannot disagree on when a
365
- // gap is a problem. Inside the window the existing friendly stale wording stands.
360
+ // silently stopped covering new tips. This warn is the one place ciParityStalled is applied —
361
+ // the row is the standing signal. Inside the window the existing friendly stale wording
362
+ // stands.
366
363
  if (ciParityStalled({ state: "stale", lastRunAt: entry.lastRunAt }, now)) {
367
364
  return warnOutcome(`stale and stalled — the last run covered ${shortSha(entry.tip)} @ lock ${shortSha(entry.lockHash)} ${age} ago (${entry.result}); main is now ${shortSha(tip)} @ lock ${shortSha(lockHash)} — no full-suite run has covered a tip in over ${CI_PARITY_STALE_MS / DAY_MS} days, so the ci role is disabled, paused, or unable to record`);
368
365
  }
@@ -375,8 +372,8 @@ export function checkCiParity(root, config, now = Date.now()) {
375
372
  * is not a stall); a fresh ledger reads ok naming the newest exercised flow and its age. A
376
373
  * ledger whose newest exercise is older than `QA_COVERAGE_STALE_MS` (qaCoverageStalled) is a
377
374
  * `warn` naming that flow and its age — the role is disabled, paused, or unable to record —
378
- * alongside the standing `qa-stale` alert, both through the one `qaCoverageStalled` predicate
379
- * so the row and the alert cannot disagree. Read-only against .tumwater/. */
375
+ * via the one `qaCoverageStalled` predicate, this row the standing signal. Read-only against
376
+ * .tumwater/. */
380
377
  export function checkQaCoverage(root, now = Date.now()) {
381
378
  const coverage = readQaCoverage(root);
382
379
  const entries = Object.entries(coverage);
@@ -396,3 +393,55 @@ export function checkQaCoverage(root, now = Date.now()) {
396
393
  }
397
394
  return okOutcome(`newest flow coverage: ${newestFlow} exercised ${age} ago`);
398
395
  }
396
+ /** How far back the `main diff rules` row looks — one day, the same 24 h trailing-day convention
397
+ * every event-reading doctor row uses, so a diff-rule failure the main watch logged stays a
398
+ * standing warn until a day passes. Owned here (the check that reads it). */
399
+ const MAIN_RULE_FAILURE_WINDOW_MS = 24 * 60 * 60_000;
400
+ /** The exact substring src/baseline/main-watch.ts's `watchRules` logs in its warning, so this
401
+ * check matches a real rule failure and cannot drift from the wording the watch writes. */
402
+ const MAIN_RULE_FAILURE_MARKER = "fails the diff rules";
403
+ /** The doctor's `main diff rules` row: a `warning` the main watch logged when the review gate's
404
+ * diff rules rejected main's tip in the trailing day. The gate judges one change; the watch
405
+ * judges each first-parent commit's own diff, so a conflict marker, a protected edit, a leaked
406
+ * secret, a phantom fix claim or a broken backlog structure that reached main through a gate
407
+ * blind spot sits on main with only this event log to say so. Reads only the `warning` event
408
+ * the watch already writes, so the row cannot drift from the watch's wording; the most recent
409
+ * hit names the tip and the rule. ok/warn only — it never fails a scripted pre-flight. */
410
+ export function checkMainDiffRules(root) {
411
+ const { events } = readEventsSince(root, MAIN_RULE_FAILURE_WINDOW_MS);
412
+ const hits = events.filter((e) => e.type === "warning" && typeof e.message === "string" && e.message.includes(MAIN_RULE_FAILURE_MARKER));
413
+ if (hits.length === 0)
414
+ return okOutcome("no diff-rule failure on main in the last day");
415
+ const latest = hits[hits.length - 1];
416
+ return warnOutcome(`${plural(hits.length, "diff-rule failure on main", "diff-rule failures on main")} in the last day — most recently ${String(latest.message)} — the gate approved a diff the watch's rules reject; repair the tip or revert it with \`tumwater revert <sha>\``);
417
+ }
418
+ /** The exact prefix src/merge/merge-post-review-drain.ts logs when it drops a post-merge
419
+ * review at POST_REVIEW_FAILURE_LIMIT. The drain drops in two ways and logs each with its own
420
+ * tail — "it stays on main unreviewed" after failed runs (the commit never got a review) and
421
+ * "the fix stays unreviewed" after failed fix merges (the fix ref is deleted, main unmoved) —
422
+ * so this check matches the shared "dropping the post-merge review of" prefix both share, not
423
+ * either tail. The drain also files the drop in BUGS.md as best-effort ordinary backlog work
424
+ * (fileDroppedReview), but a skipped filing (off-base checkout or a dirty BUGS.md) leaves only
425
+ * this event log standing, so this check matches the drain's own wording. */
426
+ const DROPPED_REVIEW_MARKER = "dropping the post-merge review of";
427
+ /** How far back the `dropped post-merge reviews` row looks — one day, the same 24 h trailing-day
428
+ * convention every event-reading doctor row uses. Owned here (the check that reads it). */
429
+ const DROPPED_REVIEW_WINDOW_MS = 24 * 60 * 60_000;
430
+ /** The doctor's `dropped post-merge reviews` row: a `warning` the post-merge-review drain
431
+ * (src/merge/merge-post-review-drain.ts) wrote when it dropped a review at
432
+ * POST_REVIEW_FAILURE_LIMIT — after failed runs its commit stays on main unreviewed, and
433
+ * after failed fix merges the fix ref is deleted (main unmoved) so the commit stays
434
+ * unreviewed; either way the review is gone and no longer in the queue the `post-merge
435
+ * reviews` row reads. The drain files the dropped review in BUGS.md as best-effort ordinary
436
+ * backlog work, so when that filing is skipped this row is the standing signal naming the
437
+ * dropped review. Reads only the `warning` event the drain already writes, so it cannot drift
438
+ * from the drain's wording; the most recent hit names the commit. ok/warn only — an
439
+ * unreviewed commit is operator signal, not a broken environment. */
440
+ export function checkDroppedPostMergeReviews(root) {
441
+ const { events } = readEventsSince(root, DROPPED_REVIEW_WINDOW_MS);
442
+ const hits = events.filter((e) => e.type === "warning" && typeof e.message === "string" && e.message.includes(DROPPED_REVIEW_MARKER));
443
+ if (hits.length === 0)
444
+ return okOutcome("no post-merge review was dropped in the last day");
445
+ const latest = hits[hits.length - 1];
446
+ return warnOutcome(`${plural(hits.length, "post-merge review was dropped", "post-merge reviews were dropped")} in the last day — the dropped review is gone and its commit stays unreviewed; most recently ${String(latest.message)}`);
447
+ }
@@ -1,7 +1,4 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { readTextOrNull } from "../files/files.js";
4
- import { roleNotesPath } from "../paths.js";
1
+ import { readRoleNotesOverCap } from "../status/status-role-notes.js";
5
2
  import { ROLE_NOTES_MAX_BYTES } from "../agent/role-notes-write.js";
6
3
  import { agree, plural } from "../text/phrases.js";
7
4
  import { okOutcome, warnOutcome } from "./outcome.js";
@@ -14,34 +11,19 @@ const ROLE_NOTES_NAMED_CAP = 3;
14
11
  * ROLE_NOTES_MAX_BYTES *characters* on read (truncateWithNote's text.length bound), but every
15
12
  * operator surface reads the file whole — so a hand edit or old build can leave a note the
16
13
  * operator sees and the role never does. This row warns on that gap without touching the file.
14
+ * The over-cap walk is shared with the status snapshot's collectRoleNotesOverCap
15
+ * (src/status/status-role-notes.ts), so the row and the status alert cannot disagree about
16
+ * which notes are over the bound; null means the notes dir is missing or unreadable.
17
17
  * Read-only against .tumwater/, like every check. Never throws. */
18
18
  export function checkRoleNotes(root) {
19
- const notesDir = path.dirname(roleNotesPath(root, "role"));
20
- let entries;
21
- try {
22
- entries = fs.readdirSync(notesDir);
23
- }
24
- catch {
19
+ const over = readRoleNotesOverCap(root);
20
+ if (over === null) {
25
21
  // Missing or unreadable notes dir: nothing recorded yet (the write path creates it).
26
22
  return okOutcome("no role notes recorded yet");
27
23
  }
28
- // The bound is the read's own unit — truncateWithNote cuts on text.length (UTF-16
29
- // characters), not the write path's Buffer.byteLength, so compare characters too or a
30
- // multibyte note the prompt reads in full would false-positive; never statSync().size.
31
- const over = [];
32
- for (const name of entries) {
33
- if (!name.endsWith(".md"))
34
- continue;
35
- const text = readTextOrNull(path.join(notesDir, name));
36
- if (text === null)
37
- continue;
38
- if (text.length > ROLE_NOTES_MAX_BYTES)
39
- over.push({ role: name.slice(0, -3), chars: text.length });
40
- }
41
24
  if (over.length === 0) {
42
25
  return okOutcome(`every role note is within the ${ROLE_NOTES_MAX_BYTES}-character read bound`);
43
26
  }
44
- over.sort((a, b) => b.chars - a.chars);
45
27
  const first = over
46
28
  .slice(0, ROLE_NOTES_NAMED_CAP)
47
29
  .map((o) => `${o.role} (${o.chars})`)
@@ -14,10 +14,10 @@ import { failOutcome } from "./outcome.js";
14
14
  import { errorMessage } from "../text/text.js";
15
15
  import { checkBackendBinary, checkBrief, checkBootstrap, checkClaudeTranscripts, checkExecPath, checkFunes, checkGitBinary, checkConfigKeyNames, checkInit, checkOpencodeVersion, checkNodeVersion, checkPiExtensions, checkRepo, } from "./doctor-checks.js";
16
16
  import { checkPrinciples } from "./doctor-principles.js";
17
- import { checkBuild, checkBuildCheck, checkCiParity, checkDiskSpace, checkEventLog, checkFlakyChecks, checkPostMergeChurn, checkQaCoverage, checkStateDir, } from "./doctor-project-checks.js";
17
+ import { checkBuild, checkBuildCheck, checkCiParity, checkDiskSpace, checkDroppedPostMergeReviews, checkEventLog, checkFlakyChecks, checkMainDiffRules, checkPostMergeChurn, checkQaCoverage, checkStateDir, } from "./doctor-project-checks.js";
18
18
  import { sampleFreeBytes } from "../gates/disk-gate.js";
19
19
  import { checkCheckContention, checkLeftoverRoleBranches, checkMergeLock, checkProviderConcurrency, checkProviderContention, checkTumwaterPins, checkWorkInstances, checkWorktreePool, } from "./doctor-contention-checks.js";
20
- import { checkFeedbackFiling, checkImageInput, checkTierModels } from "./doctor-model-checks.js";
20
+ import { checkFeedbackFiling, checkTierModels } from "./doctor-model-checks.js";
21
21
  import { checkFallbackChain, checkFallbackModel } from "./doctor-fallback-checks.js";
22
22
  import { checkAnthroApiKey, checkClaudeAuth, checkClaudeVersion } from "./doctor-claude-checks.js";
23
23
  import { checkOpencode } from "./doctor-opencode-checks.js";
@@ -25,7 +25,7 @@ import { checkCodexAccount, checkCodexAuth, checkCodexHome, checkCodexLeaks, che
25
25
  import { backendQualifiedSelector } from "../config/config-views.js";
26
26
  import { seamsInUse } from "../config/config-fallbacks.js";
27
27
  import { checkBacklogHeadings, checkFixClaims, checkLiveFinished, checkPlanDependencies, checkStalledEntries, checkStrandedPlans, checkValidationGaps } from "./doctor-backlog-plans.js";
28
- import { checkRefusedEntries, checkRegressedFixes, checkStaleBugs, checkStaleQuestions, checkWaitingBugs } from "./doctor-backlog-bugs.js";
28
+ import { checkRefusedEntries, checkRegressedFixes, checkStaleBugs, checkStaleQuestions } from "./doctor-backlog-bugs.js";
29
29
  import { checkMergeQueueFiles, checkPostReviews, checkStaleQueuedMerges, checkStalePrompts, checkUncommittedBacklog } from "./doctor-backlog.js";
30
30
  /** Pre-flight environment check (`tumwater doctor`). The harness's preconditions are
31
31
  * scattered across fail-fast checks that each command re-runs on its own (requireReadyRepo in
@@ -61,7 +61,7 @@ export async function runDoctorCheck(run) {
61
61
  * just the named checks, in report order; a skipped check is never evaluated, and a named
62
62
  * conditional check that returns null leaves no row. A check that throws becomes its own fail
63
63
  * row, and every other check still runs. */
64
- export async function runDoctor(root, pathEnv = envPath(), probe = systemProcessProbe, probeImages = "never", only, diskSample = sampleFreeBytes) {
64
+ export async function runDoctor(root, pathEnv = envPath(), probe = systemProcessProbe, only, diskSample = sampleFreeBytes) {
65
65
  // Loaded once for the checks that read config (repo's baseBranch); a broken file stays
66
66
  // undefined — checkInit reports it verbatim — so doctor still runs every other check.
67
67
  const { config } = loadConfigSafe(root);
@@ -107,7 +107,6 @@ export async function runDoctor(root, pathEnv = envPath(), probe = systemProcess
107
107
  { name: "over-cap model", run: () => checkFallbackModel(root, undefined, (orchestratorAlive(root, info) && info?.fallbackDemoted) || null) },
108
108
  { name: "fallback chain", run: () => checkFallbackChain(root) },
109
109
  { name: "models for each tier", run: () => checkTierModels(root) },
110
- { name: "image input", run: () => checkImageInput(root, undefined, undefined, undefined, probeImages, pathEnv) },
111
110
  { name: "claude auth", run: () => checkClaudeAuth(root, pathEnv) },
112
111
  { name: "claude api key", run: () => checkAnthroApiKey(root, undefined, pathEnv) },
113
112
  { name: "claude version", run: () => checkClaudeVersion(root, pathEnv) },
@@ -165,11 +164,12 @@ export async function runDoctor(root, pathEnv = envPath(), probe = systemProcess
165
164
  { name: "plan dependencies", run: () => checkPlanDependencies(root) },
166
165
  { name: "stale questions", run: () => checkStaleQuestions(root) },
167
166
  { name: "stale bugs", run: () => checkStaleBugs(root) },
168
- { name: "waiting bugs", run: () => checkWaitingBugs(root) },
169
167
  { name: "regressed fixes", run: () => checkRegressedFixes(root) },
170
168
  { name: "stale merge-queue entries", run: () => checkStaleQueuedMerges(root, Date.now()) },
171
169
  { name: "merge-queue files", run: () => checkMergeQueueFiles(root) },
172
170
  { name: "post-merge reviews", run: () => checkPostReviews(root) },
171
+ { name: "main diff rules", run: () => checkMainDiffRules(root) },
172
+ { name: "dropped post-merge reviews", run: () => checkDroppedPostMergeReviews(root) },
173
173
  { name: "stale prompts", run: () => checkStalePrompts(root, Date.now()) },
174
174
  { name: "uncommitted backlog", run: () => checkUncommittedBacklog(root, config) },
175
175
  // The running team's own view of its build (staleness and what auto-restart made of it)
@@ -20,7 +20,7 @@ export const AUTH_CHECK_MAX_OUTPUT_BYTES = 1024 * 1024;
20
20
  * spawn closes for git). stderr is ignored rather than piped, since nothing reads it and a
21
21
  * full stderr pipe would block the child. `timeoutMs` and `maxOutputBytes` are test seams;
22
22
  * production callers pass their own named constants. One home of the shape, shared by the
23
- * model checks' credential/version probes, the tier-model probes, and the image-input probe —
23
+ * model checks' credential/version probes, and the tier-model probes —
24
24
  * before the split each doctor module carried its own byte-for-byte copy. The credential probes'
25
25
  * named budgets (AUTH_CHECK_TIMEOUT_MS, AUTH_CHECK_MAX_OUTPUT_BYTES) live beside it here, so the
26
26
  * backend check modules share them without importing them from one another. */
@@ -213,11 +213,9 @@ function runEventMessage(e) {
213
213
  ? "resuming the run cut off at the context ceiling (compacted pi session, same workspace)"
214
214
  : e.cause === "handoff"
215
215
  ? "handing the interrupted run off to a fresh session on another agent (same workspace, uncommitted edits kept)"
216
- : e.cause === "image-reroute"
217
- ? "handing the blind-read run off to a fresh session on an image-capable model (same workspace, uncommitted edits kept)"
218
- : e.cause === "salvaged"
219
- ? "handing the work an earlier run left unsaved to a fresh session"
220
- : "resuming the run a shutdown interrupted (same agent session and workspace)") + restored);
216
+ : e.cause === "salvaged"
217
+ ? "handing the work an earlier run left unsaved to a fresh session"
218
+ : "resuming the run a shutdown interrupted (same agent session and workspace)") + restored);
221
219
  }
222
220
  case "wake":
223
221
  return `woke (${textOr(e.reason)})`;
@@ -238,7 +236,7 @@ function runEventMessage(e) {
238
236
  // Priority headroom (part 7/7): a reserve appearing or vanishing is the scheduler working
239
237
  // as designed; it names the permits withheld for the work tier so an operator can see why
240
238
  // maintenance concurrency dipped.
241
- return `${finiteNumber(e.reserve, 0)} permit(s) reserved for work roles that have work to take`;
239
+ return `${plural(finiteNumber(e.reserve, 0), "permit")} reserved for work roles that have work to take`;
242
240
  default: return textOr(e.type);
243
241
  }
244
242
  }
@@ -616,13 +614,6 @@ function imageRestartEventMessage(e) {
616
614
  : "";
617
615
  return `blind image read: ${textOr(e.path)} on ${textOr(e.model, "the model")}${action}`;
618
616
  }
619
- case "image_reroute":
620
- // The run's work moves to a model that can read the image (plans/image-input.md part
621
- // 4/5). Routine-with-explanation — no warning prefix: names the hop the way the
622
- // handoff reads, and whether the traits-of-attachment preflight avoided a failed run.
623
- return e.preflight === true
624
- ? `image attachment routed to ${textOr(e.to, "the target")} (${textOr(e.tier, "a peer tier")}) to read its image — no failed run`
625
- : `image rerouted to ${textOr(e.to, "the target")} (${textOr(e.tier, "a peer tier")}) — next run goes there after a blind read on ${textOr(e.from, "the prior model")}`;
626
617
  case "build_stale":
627
618
  // Self-hosting teams only (src/redeploy/redeploy.ts): the code main describes is not the
628
619
  // code running. Not a warning prefix — a stale build is a state, and auto-restart resolves
@@ -632,7 +623,7 @@ function imageRestartEventMessage(e) {
632
623
  return `restart pending — main ${shortSha(e.head)} is green; finishing current runs (no new runs start)`;
633
624
  case "restart": {
634
625
  const aborted = finiteNumber(e.abortedRuns, 0);
635
- return `restarting onto update ${shortSha(e.to)} (finished current work in ${minutes(e.finishedMs)}m${aborted > 0 ? `, ${aborted} run(s) resume after the restart` : ""})`;
626
+ return `restarting onto update ${shortSha(e.to)} (finished current work in ${minutes(e.finishedMs)}m${aborted > 0 ? `, ${plural(aborted, "run")} resume after the restart` : ""})`;
636
627
  }
637
628
  case "restart_refused":
638
629
  // The restart is refused, not failed: the running build stays and the gate is re-asked
@@ -798,7 +789,6 @@ export function eventMessage(e) {
798
789
  case "orchestrator_start":
799
790
  case "orchestrator_stop": return configEventMessage(e);
800
791
  case "image_blind":
801
- case "image_reroute":
802
792
  case "build_stale":
803
793
  case "restart_pending":
804
794
  case "restart":
@@ -84,7 +84,6 @@ export const EVENT_TYPE_LABELS = {
84
84
  model_fallback_ended: "fallback model ended",
85
85
  opencode_free_model_billed: "free model billed",
86
86
  image_blind: "image not seen",
87
- image_reroute: "image sent to another model",
88
87
  build_stale: "update available",
89
88
  restart_pending: "update waits for idle",
90
89
  restart: "restarted onto update",
@@ -34,7 +34,7 @@ const LEGACY_EVENT_TYPES = {
34
34
  /** The ordered field rewrites; later parts of the series append here, and they run in order. */
35
35
  const LEGACY_EVENT_REWRITES = [
36
36
  {
37
- // The run number rode `tick` on run_start, run_end, prompt_taken, image_blind, image_reroute.
37
+ // The run number rode `tick` on run_start, run_end, prompt_taken, image_blind.
38
38
  name: "tick-to-run", // retired-name-ok: legacy name
39
39
  apply(ev) {
40
40
  const e = ev;
@@ -349,7 +349,7 @@ export function eventsSince(root, sinceMs, now, keep = () => true) {
349
349
  return readEventsSince(root, sinceMs).events.filter((ev) => keep(ev) && ev.ts >= now - sinceMs);
350
350
  }
351
351
  /** The `--since` / `?sinceMs=` trailing window's bounds and identity slice, shared by the two
352
- * trailing-window collectors (`collectReportSince` in report-data.ts and
352
+ * trailing-window collectors (`collectReportSince` in report-since-data.ts and
353
353
  * `collectFailureReportSince` in failure-data.ts): an open-ended window (cutoff = now − sinceMs,
354
354
  * end = now) when `untilMs` is absent, else the bounded [now − sinceMs, now − untilMs). Both
355
355
  * collectors budget events with the same open/closed comparisons against `cutoff`/`end` (and
@@ -58,8 +58,21 @@ export function logEvent(root, event) {
58
58
  ensureParentDir(file);
59
59
  rotateIfLarge(file, EVENTS_MAX_BYTES, EVENTS_KEEP);
60
60
  appendEventLine(file, JSON.stringify(full) + "\n");
61
- for (const listener of listeners)
62
- listener(full);
61
+ // The event is committed to the log before dispatch, so a subscriber's own failure must not
62
+ // make the write look like it failed to the caller, starve the other subscribers of this or
63
+ // any later event, or crash an unwrapped writer (the orchestrator's poll-loop warn site)
64
+ // for one listener's bug. Each listener is isolated: its throw is surfaced loudly on stderr
65
+ // (a terminal boundary, so the text is sanitized) and the dispatch moves on. The set is
66
+ // snapshotted so a listener that unsubscribes itself mid-dispatch cannot skip or double-run
67
+ // a sibling.
68
+ for (const listener of [...listeners]) {
69
+ try {
70
+ listener(full);
71
+ }
72
+ catch (err) {
73
+ process.stderr.write(`tumwater: event subscriber failed: ${stripTerminalControls(errorMessage(err))}\n`);
74
+ }
75
+ }
63
76
  return full;
64
77
  }
65
78
  /** Log a warning event: the harness's "something is off but the loop continues" signal. The