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
@@ -309,24 +309,24 @@ export function checkBacklogHeadings(root) {
309
309
  `later entries are invisible; keep one heading per name (the gate blocks only a merge ` +
310
310
  `that adds another copy — remove the duplicate in a deliberate edit)`);
311
311
  }
312
- /** Stalled entries — plans or bugs held by a **Needs review …** or **Needs replan …** note,
312
+ /** Stalled entries — plans or bugs held by a **Needs review …** note,
313
313
  * parked until the plan role splits or replans them, plus the held-for-operator entries
314
314
  * parked until the operator releases them. No role takes a held entry (the claim gate refuses
315
315
  * one) and nothing clears the note on its own, so the state is visible only as the
316
- * `[needs review]`/`[needs replan]` marks in the backlog listings; this names them so an
316
+ * `[needs review]` marks in the backlog listings; this names them so an
317
317
  * operator gets a single-line health signal from `tumwater doctor`. Reads the tree at `root`
318
318
  * — the primary checkout IS main's tree — like checkRefusedEntries, which is also where
319
319
  * refusal notes (a different hold) are surfaced. entryHold (src/backlog/backlog-eligibility.ts)
320
320
  * already classifies the notes, so this check adds no parsing: only the entries whose hold is
321
- * exactly needs-review, needs-replan, or held-for-operator are collected, from both PLANS.md
321
+ * exactly needs-review or held-for-operator are collected, from both PLANS.md
322
322
  * and BUGS.md. A warn, never a fail — the checkRefusedEntries precedent: a parked entry is
323
- * operator signal, not a broken environment; the plan role resolves the first two itself, and
323
+ * operator signal, not a broken environment; the plan role resolves the first itself, and
324
324
  * a held-for-operator entry is the operator's to release (`tumwater plan|bug --release`). */
325
325
  export function checkStalledEntries(root) {
326
326
  const planned = plannedPlanEntries(root);
327
327
  const collect = (entry, file) => {
328
328
  const hold = entryHold(entry, planned);
329
- return hold === "needs-review" || hold === "needs-replan" || hold === "held-for-operator" // vocabulary-ok: the held-for-operator hold's key
329
+ return hold === "needs-review" || hold === "held-for-operator" // vocabulary-ok: the held-for-operator hold's key
330
330
  ? { file, entry }
331
331
  : null;
332
332
  };
@@ -336,16 +336,16 @@ export function checkStalledEntries(root) {
336
336
  ].filter((x) => x !== null);
337
337
  const [first, ...rest] = stalled;
338
338
  if (!first)
339
- return okOutcome("no planned plan or open bug is held by a Needs-review, Needs-replan, or held-for-operator note"); // vocabulary-ok: names the held-for-operator hold
339
+ return okOutcome("no planned plan or open bug is held by a Needs-review or held-for-operator note"); // vocabulary-ok: names the held-for-operator hold
340
340
  const more = andMore(rest, (s) => quoteHeading(s.entry.title));
341
341
  const kind = entryKindLabel(first.file); // "Bug" | "Plan"
342
342
  const head = `${first.file === "bugs" ? "BUGS.md" : "PLANS.md"} ${kind.toLowerCase()}`;
343
343
  const hold = entryHold(first.entry, planned);
344
- const note = (first.entry.body.split(/\n/).find((line) => line.trimStart().startsWith("**Needs review ") || line.trimStart().startsWith("**Needs replan ")) ?? "").replace(/^\s*\*\*/, "").replace("**", "");
344
+ const note = (first.entry.body.split(/\n/).find((line) => line.trimStart().startsWith("**Needs review ")) ?? "").replace(/^\s*\*\*/, "").replace("**", "");
345
345
  if (hold === "held-for-operator") { // vocabulary-ok: the held-for-operator hold's key
346
346
  return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held for the operator ${heldCount(first.entry.body)} times${more} — release it with tumwater ${kind.toLowerCase()} --release`); // vocabulary-ok: a held-for-operator entry is the operator's to release
347
347
  }
348
- return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held by a ${hold === "needs-review" ? "Needs review" : "Needs replan"} note ` +
348
+ return warnOutcome(`${head} ${quoteHeading(first.entry.title)} is held by a Needs review note ` +
349
349
  `(${truncate(note, REFUSAL_NOTE_MAX)})${more} — the plan role resolves it by splitting or replanning`);
350
350
  }
351
351
  /** Plan dependencies — mistakes in the planned plans' `requires … landed` clauses that the claim
@@ -50,13 +50,6 @@ export const STALE_QUESTION_DAYS = 7;
50
50
  * opinionated default with no knob, like STALE_QUESTION_DAYS. Three days is long enough that a
51
51
  * bug filed late one evening and fixed the next day never warns. */
52
52
  export const STALE_BUG_DAYS = 3;
53
- /** The whole days a bug may stay held by a Waiting for the next failure note before the
54
- * waiting-bugs check warns that its flake has not come back. A note parks a bug until it fails
55
- * again, but the failure may never come, so a hold has to lapse or the entry parks indefinitely;
56
- * one week without a recurrence is long enough that a recurring flake would almost surely have
57
- * shown up, and short enough that a bug which never returns stops squatting in the backlog as
58
- * an ok. An opinionated default with no knob, like STALE_BUG_DAYS. */
59
- export const STALE_WAIT_DAYS = 7;
60
53
  /** The `(and N more …)` suffix three one-line doctor details append after naming their first
61
54
  * suspect (fix claims, stranded plans, duplicated headings): the rest stay visible but
62
55
  * shortened, since a doctor check renders one line. `render` makes each extra item's short
@@ -100,7 +93,7 @@ export function readDocChecked(p, file, missingDetail) {
100
93
  }
101
94
  /** The standard single-file check's read prologue for a backlog document at `root`: the
102
95
  * readDocChecked call every per-file doctor check (checkFixClaims, checkValidationGaps,
103
- * checkStrandedPlans, checkStaleQuestions, and the stale/waiting bug checks) repeats, with the
96
+ * checkStrandedPlans, checkStaleQuestions, and the stale bug checks) repeats, with the
104
97
  * `no <file> — nothing to verify` missing-detail derived from the file name so the file triple
105
98
  * (path, display name, message) and the message template cannot drift across the seven call
106
99
  * sites. Returns the document, or the outcome the caller should return as-is. */
@@ -111,11 +104,9 @@ export function readBacklogChecked(root, file) {
111
104
  * warns: 6 hours, fixed. A verify plus a merge completes in minutes (plans/merge-queue.md), so a
112
105
  * full six-hour silence while the team is healthy — not on a hold, which doctor already reports
113
106
  * separately — is never a merely busy pipeline: it reads as a drain that has stalled. The one
114
- * opinionated default, like STALE_QUESTION_DAYS, so no knob. The always-on `stale-merges` team
115
- * alert (src/ui/alert-feeds.ts staleMerges) reuses this same window through
116
- * staleQueuedMergeRows, so the doctor's row and the alert cannot drift on the threshold (the
107
+ * opinionated default, like STALE_QUESTION_DAYS, so no knob (the dead-alert feed is gone; the
117
108
  * STALE_PROMPT_MS/STALE_BUG_DAYS precedent). */
118
- export const MERGE_QUEUE_STALE_MS = 6 * HOUR_MS;
109
+ const MERGE_QUEUE_STALE_MS = 6 * HOUR_MS;
119
110
  /** The window of the merge-wait phrase on the stale merge-queue row. */
120
111
  const MERGE_WAIT_WINDOW_MS = DAY_MS;
121
112
  /** Stale queued merges — a change that has sat in the durable merge queue too long without starting
@@ -151,15 +142,14 @@ export function checkStaleQueuedMerges(root, now) {
151
142
  return withWait(warnOutcome(`merge-queue entry of ${roleLabel(first.role)} — ${quoteHeading(first.summary)} — has waited ` +
152
143
  `${first.age} to start${more}; a queued change the pipeline neither approves nor flags reads as healthy, so the merge queue has stopped moving`));
153
144
  }
154
- /** The one traversal shared by the doctor's `stale merge-queue entries` row
155
- * (checkStaleQueuedMerges) and the always-on `stale-merges` team alert
156
- * (src/ui/alert-feeds.ts staleMerges): every queued merge whose role is not in the in-flight
145
+ /** The one traversal for the doctor's `stale merge-queue entries` row
146
+ * (checkStaleQueuedMerges): every queued merge whose role is not in the in-flight
157
147
  * marker and whose wait strictly exceeds MERGE_QUEUE_STALE_MS (6 h), in queue-file order (oldest
158
148
  * first). The in-flight set comes from readMergeMarker via mergeChanges (an absent or
159
149
  * never-throwing unreadable marker contributes the empty set) — a slow review is not a stalled
160
- * drain. Each row carries { role, summary, waitedMs }; the doctor's row adds its age phrase and
161
- * the alert names the wait, so the rule and its threshold live in one place and cannot drift. */
162
- export function staleQueuedMergeRows(root, now) {
150
+ * drain. Each row carries { role, summary, waitedMs }, which the row renders with its age
151
+ * phrase, so the rule and its threshold live in one place and cannot drift. */
152
+ function staleQueuedMergeRows(root, now) {
163
153
  const marker = readMergeMarker(root);
164
154
  const inFlight = new Set(marker ? mergeChanges(marker).map((c) => c.role) : []);
165
155
  return queuedMerges(root)
@@ -167,9 +157,8 @@ export function staleQueuedMergeRows(root, now) {
167
157
  .map((e) => ({ role: e.role, summary: e.summary, waitedMs: now - e.enqueuedAt }));
168
158
  }
169
159
  /** The merge-queue files `readMergeQueue` skips — torn, foreign, or vanished mid-listing — as
170
- * checkout-relative paths. One traversal shared by the doctor's `checkMergeQueueFiles` row and
171
- * the always-on `skipped-merges` team alert (src/ui/alert-feeds.ts skippedMergeFiles), so
172
- * the two surfaces select the same files and name the same paths and cannot drift. */
160
+ * checkout-relative paths. The one traversal behind the doctor's `checkMergeQueueFiles` row, so
161
+ * the row names the same paths the reader actually skips and cannot drift. */
173
162
  export function skippedMergeQueueFiles(root) {
174
163
  return unreadableMergeQueueFiles(root).map((f) => path.relative(root, f));
175
164
  }
@@ -203,9 +192,8 @@ export const POST_REVIEW_AT_RISK_FAILURES = POST_REVIEW_FAILURE_LIMIT - 1;
203
192
  * is not waiting on a verdict and must not read as one failed run from being dropped.
204
193
  * A review that has already landed its fix (`fixed` set) is left out too — it is a settled
205
194
  * record naming both shas, not a verdict-less entry the next run could drop.
206
- * One traversal shared by the doctor's `post-merge reviews` row (checkPostReviews) and the
207
- * always-on `post-merge-risk` team alert (src/ui/alert-feeds.ts postReviewRiskAlert), so the
208
- * two surfaces select the same entries and cannot drift. Reads .tumwater/state/post-review.json
195
+ * One traversal used by the doctor's `post-merge reviews` row (checkPostReviews), so the row's
196
+ * selection lives in one place. Reads .tumwater/state/post-review.json
209
197
  * through readPostReviews, which degrades a missing or torn file to the well-formed subset or
210
198
  * `[]` and never throws. */
211
199
  export function atRiskPostReviews(root) {
@@ -215,8 +203,8 @@ export function atRiskPostReviews(root) {
215
203
  }
216
204
  /** The doctor's one sentence naming the post-merge reviews one failed run from being dropped
217
205
  * (the checkStaleQueuedMerges phrase precedent): the first in full — role, short commit, summary
218
- * and the failures count — the rest folded through andMore, and the one sentence every surface
219
- * shows, so the doctor row and the alert cannot drift on the wording. null for an empty list. */
206
+ * and the failures count — the rest folded through andMore, the one sentence the row shows, so
207
+ * the wording lives in one place. null for an empty list. */
220
208
  export function atRiskPostReviewsPhrase(rows) {
221
209
  const [first, ...rest] = rows;
222
210
  if (!first)
@@ -229,8 +217,8 @@ export function atRiskPostReviewsPhrase(rows) {
229
217
  * drain drops an entry at POST_REVIEW_FAILURE_LIMIT failed runs) — the last moment an operator
230
218
  * can fix the reviewer backend or deliberately accept the commit unreviewed before the drain
231
219
  * drops it from the queue and it stays on main unreviewed. Reads .tumwater/state/post-review.json
232
- * through the shared atRiskPostReviews traversal the always-on post-merge-risk team alert
233
- * (src/ui/alert-feeds.ts postReviewRiskAlert) uses, so the two surfaces cannot drift. A warn,
220
+ * through the shared atRiskPostReviews traversal, so the selection and wording live in one
221
+ * place. A warn,
234
222
  * never a fail — the checkStaleQueuedMerges precedent: a failing reviewer is operator signal,
235
223
  * not a broken environment; readPostReviews never throws (a missing or torn file degrades to the
236
224
  * well-formed subset or `[]`), so the check needs no read-error arm. */
@@ -242,16 +230,14 @@ export function checkPostReviews(root) {
242
230
  /** How long a queued prompt may wait for its role to dequeue it before the stale-prompt check
243
231
  * warns: a day, fixed. An opinionated default with no knob, like MERGE_QUEUE_STALE_MS: a day is
244
232
  * long enough that a healthy role's next run would have dequeued the prompt, and short enough
245
- * that a paused or disabled role's stranded prompt shows up. The always-on stale-prompt team
246
- * alert (src/ui/alert-feeds.ts stalePrompts) reuses this same threshold, so it cannot drift
247
- * from the doctor's check — the STALE_BUG_DAYS precedent. */
248
- export const STALE_PROMPT_MS = DAY_MS;
233
+ * that a paused or disabled role's stranded prompt shows up. The stale-prompt check reads this
234
+ * same threshold, so it cannot drift — the STALE_BUG_DAYS precedent. */
235
+ const STALE_PROMPT_MS = DAY_MS;
249
236
  /** The queued inbox prompts the doctor's stale-prompt rule calls stale — deliverable now (a
250
237
  * `--at` prompt still counting down is never stale) and queued more than STALE_PROMPT_MS,
251
238
  * oldest-first, `queuedAtMs` narrowed to a number. One traversal shared by the doctor's
252
- * `checkStalePrompts` check and the always-on `stalePrompts` team alert (src/ui/alert-feeds.ts),
253
- * so the two surfaces read the same rule at the same threshold and cannot drift. */
254
- export function staleQueuedPromptRows(root, now) {
239
+ * `checkStalePrompts` check, so the rule lives in one place at one threshold. */
240
+ function staleQueuedPromptRows(root, now) {
255
241
  return queuedListRows(root, null, listWideScope(root))
256
242
  .filter((p) => p.queuedAtMs !== null && deliverableAt(p.notBeforeMs, now) && now - p.queuedAtMs > STALE_PROMPT_MS)
257
243
  .sort((a, b) => a.queuedAtMs - b.queuedAtMs)
@@ -264,7 +250,7 @@ export function staleQueuedPromptRows(root, now) {
264
250
  * views (`tumwater prompt --list`, the dashboard Inbox tab, the TUI Inbox pane) show its age —
265
251
  * and only when the operator looks. This names the oldest due-but-undelivered prompt, its role,
266
252
  * and the deliver-or-drop commands. Reads the queue through the shared `staleQueuedPromptRows`
267
- * traversal the always-on stale-prompts alert uses (no new reader in src/inbox/), keeping only
253
+ * traversal (no new reader in src/inbox/), keeping only
268
254
  * rows that are deliverable now — a `--at 7d` prompt still counting down is never stale — and
269
255
  * stamped more than STALE_PROMPT_MS ago. A warn, never a fail — the checkStaleQueuedMerges
270
256
  * precedent: a stranded prompt is operator signal, not a broken environment. */
@@ -29,7 +29,6 @@ export const DOCTOR_CHECK_NAMES = [
29
29
  "over-cap model",
30
30
  "fallback chain",
31
31
  "models for each tier",
32
- "image input",
33
32
  "claude auth",
34
33
  "claude api key",
35
34
  "claude version",
@@ -74,11 +73,12 @@ export const DOCTOR_CHECK_NAMES = [
74
73
  "plan dependencies",
75
74
  "stale questions",
76
75
  "stale bugs",
77
- "waiting bugs",
78
76
  "regressed fixes",
79
77
  "stale merge-queue entries",
80
78
  "merge-queue files",
81
79
  "post-merge reviews",
80
+ "main diff rules",
81
+ "dropped post-merge reviews",
82
82
  "stale prompts",
83
83
  "uncommitted backlog",
84
84
  "update",
@@ -10,8 +10,8 @@ import { belowNodeFloor } from "../version.js";
10
10
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
11
11
  import { claudeSeamsInUse, loadConfigForCheck, versionFromProbe, seamedModels, } from "./doctor-model-checks.js";
12
12
  /** The doctor's claude credential and version readiness checks, split out of
13
- * doctor-model-checks.ts (whose shared seam/model framework and the pi, tier-models and
14
- * image-input checks stay there): checkClaudeAuth, checkAnthroApiKey and checkClaudeVersion.
13
+ * doctor-model-checks.ts (whose shared seam/model framework and the pi and tier-models
14
+ * checks stay there): checkClaudeAuth, checkAnthroApiKey and checkClaudeVersion.
15
15
  * These are the doctor's only checks that spawn the `claude` binary and read its auth — a
16
16
  * distinct concern from the filesystem/git checks, kept together so a change to how doctor
17
17
  * spawns or scrubs claude does not touch the other model checks. The shared report contract
@@ -517,7 +517,7 @@ export function providerConcurrencyProblems(config, piDefault) {
517
517
  const capSet = new Set(capKeys);
518
518
  const keyless = seams.filter((s) => seamConcurrencyKey(s, piDefault) === undefined);
519
519
  if (capSet.size > 0 && keyless.length > 0) {
520
- warnings.push(`${keyless.length} model job(s) resolve to no provider key (pi's default provider is unreadable, so they cannot be capped): ${keyless.map((s) => s.label).join(", ")}`);
520
+ warnings.push(`${plural(keyless.length, "model job")} ${agree(keyless.length, "resolves", "resolve")} to no provider key (pi's default provider is unreadable, so they cannot be capped): ${keyless.map((s) => s.label).join(", ")}`);
521
521
  }
522
522
  return warnings;
523
523
  }
@@ -1,4 +1,5 @@
1
1
  import { roleLabel } from "../roles/role-labels.js";
2
+ import { sortEntriesByKey } from "../collections.js";
2
3
  import { pausedRoles, pausedRolesUntil, standingTeamPause } from "../team/team-state.js";
3
4
  import { MODEL_TIERS } from "../config/config-schema.js";
4
5
  import { withModelOverrides } from "../config/config-views.js";
@@ -32,6 +33,12 @@ export function loadRunnerStates(root, config) {
32
33
  .map((id) => loadRunnerState(root, id))
33
34
  .sort((a, b) => a.role.localeCompare(b.role));
34
35
  }
36
+ /** A doctor row's `not checked` outcome when tumwater.json is unreadable: the row blanks its
37
+ * verdict rather than claim a state it cannot see (checkInit owns that error). Every check that
38
+ * reads the config returns this same outcome when the file is unreadable. */
39
+ function uncheckedConfigOutcome() {
40
+ return okOutcome("not checked — tumwater.json unreadable");
41
+ }
35
42
  /** The `pause` check: names a standing operator pause so doctor does not print "ready to run"
36
43
  * over a team that starts no run. Read-only: it reads the same team and per-role pause
37
44
  * markers the scheduler reads (standingTeamPause, pausedRoles, pausedRolesUntil — all treat
@@ -95,14 +102,11 @@ export function checkQuietHours(config, now = new Date()) {
95
102
  * per-role caps reached alone are ok because the operator chose them by name. */
96
103
  export function checkBudget(root, config, published, now = Date.now()) {
97
104
  if (config === undefined)
98
- return okOutcome("not checked — tumwater.json unreadable");
105
+ return uncheckedConfigOutcome();
99
106
  const states = loadRunnerStates(root, config);
100
107
  const spent = published?.spentUsd ?? teamDailyCost(states, now);
101
108
  const caps = config.maxDailyCostUsdPerRole;
102
- const capped = [...roleCapVerdicts(states.map((s) => ({ role: s.role, state: s })), caps, now)]
103
- .filter(([, v]) => v.over)
104
- .sort(([a], [b]) => a.localeCompare(b))
105
- .map(([base, v]) => `${base} (${budgetPhrase(v.spentUsd, caps?.[base])})`);
109
+ const capped = sortEntriesByKey([...roleCapVerdicts(states.map((s) => ({ role: s.role, state: s })), caps, now)].filter(([, v]) => v.over)).map(([base, v]) => `${base} (${budgetPhrase(v.spentUsd, caps?.[base])})`);
106
110
  const cappedPhrase = capped.length > 0 ? `; capped roles: ${capped.join(", ")}` : "";
107
111
  const cap = config.maxDailyCostUsd;
108
112
  if (!(cap > 0))
@@ -124,7 +128,7 @@ export function checkBudget(root, config, published, now = Date.now()) {
124
128
  * because it stops a whole tier. */
125
129
  export function checkMaintenanceQuota(root, config, aliveTeam, now = Date.now()) {
126
130
  if (config === undefined)
127
- return okOutcome("not checked — tumwater.json unreadable");
131
+ return uncheckedConfigOutcome();
128
132
  const runners = loadRunnerStates(root, config).map((s) => ({ role: s.role, running: aliveTeam && s.running === true }));
129
133
  const queued = aliveTeam ? new Set(readMergeQueue(root).entries.map((e) => e.role)) : new Set();
130
134
  const { allowance, used, held } = maintenanceQuotaForPoll(root, config.maintenancePerWorkMerge, runners, queued, now);
@@ -201,9 +205,7 @@ export function checkProviderHolds(root, now = Date.now()) {
201
205
  standing.delete(typeof ev.provider === "string" ? ev.provider : undefined);
202
206
  }
203
207
  }
204
- const held = [...standing.entries()]
205
- .filter(([, h]) => h.end > now)
206
- .sort(([a], [b]) => (a ?? "").localeCompare(b ?? ""));
208
+ const held = sortEntriesByKey([...standing.entries()].filter(([, h]) => h.end > now));
207
209
  if (held.length === 0)
208
210
  return okOutcome("no provider holds");
209
211
  const lines = held.map(([provider, h]) => {
@@ -221,7 +223,7 @@ export function checkProviderHolds(root, now = Date.now()) {
221
223
  * fail: a failing role does not stop the team from running. */
222
224
  export function checkErrorStreaks(root, config) {
223
225
  if (config === undefined)
224
- return okOutcome("not checked — tumwater.json unreadable");
226
+ return uncheckedConfigOutcome();
225
227
  const failing = loadRunnerStates(root, config)
226
228
  .filter((s) => errorStreakAtWarn(s));
227
229
  if (failing.length === 0)
@@ -252,7 +254,7 @@ export function checkErrorStreaks(root, config) {
252
254
  * the team from running. `now` is injected so a test can pin a red's age. */
253
255
  export function checkMainRed(root, config, now = Date.now()) {
254
256
  if (config === undefined)
255
- return okOutcome("not checked — tumwater.json unreadable");
257
+ return uncheckedConfigOutcome();
256
258
  const mainCheck = mainCheckForPoll(root, config);
257
259
  const ids = runnerIds(config);
258
260
  const held = loadRunnerStates(root, config)
@@ -1,31 +1,22 @@
1
1
  import { childEnv } from "../process/process.js";
2
2
  import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
3
- import { enabledRoleIds, defaultConfig, loadConfigSafe } from "../config/config.js";
4
- import { configForRole } from "../config/config-views.js";
5
- import { resolveTierFallbacks, seamsInUse } from "../config/config-fallbacks.js";
6
- import { cacheReadUnpriced, piModelsPath, piPairFree, readPiProviders } from "../pi/pi-models.js";
7
- import { MODEL_TIERS } from "../config/config-schema.js";
8
- import { imageReroute, seamImageInput } from "../agent/image-input.js";
9
- import { modelLabel, usesPiModels } from "../agent/backend-traits.js";
10
- import { backendById } from "../agent/agent-backend.js";
11
- import { imageProbeEntryFresh, imageProbeKey, readImageProbeCache, runImageProbe, writeImageProbeCache, } from "./image-probe.js";
12
- import { readClaudeAccount } from "../agent/claude/claude-account.js";
3
+ import { defaultConfig, loadConfigSafe } from "../config/config.js";
4
+ import { seamsInUse } from "../config/config-fallbacks.js";
5
+ import { cacheReadUnpriced, piModelsPath, readPiProviders } from "../pi/pi-models.js";
13
6
  import { envPath } from "../files/files.js";
14
7
  import { piListedModels, piProviderAuth } from "./doctor-pi-checks.js";
15
- import { readPiListedModels } from "../pi/pi-listed-models.js";
16
8
  import { agree } from "../text/phrases.js";
17
- import { formatDate } from "../text/datetime.js";
18
- import { errorMessage, joinParts } from "../text/text.js";
9
+ import { joinParts } from "../text/text.js";
19
10
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
20
11
  import { belowNodeFloor } from "../version.js";
21
12
  /** The one phrase a configured-models check uses when the config declares no models at all:
22
13
  * informational (capability falls to pi's own defaults), not a warning. Shared by
23
- * checkTierModels and checkImageInput so the two checks word the same "nothing configured"
24
- * stance identically. */
14
+ * checkTierModels so the check words the same "nothing configured"
15
+ * stance. */
25
16
  const PHRASE_NO_MODELS_DECLARED = "no models declared — every model job uses pi's own default";
26
17
  /** The one phrase a configured-models check uses when pi's definitions file could not be read
27
18
  * at all: the check cannot answer what it was asked, so it warns instead of guessing. Shared by
28
- * checkTierModels and checkImageInput so the two checks word the "cannot check" stance
19
+ * checkTierModels so the check words the "cannot check" stance
29
20
  * identically; each appends its own modelsPath after the shared stem, and checkTierModels
30
21
  * also appends its ompNote. */
31
22
  const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model definitions at ";
@@ -33,7 +24,7 @@ const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model de
33
24
  * repo-setup checks stay there, and whose state/disk/build checks live in
34
25
  * doctor-project-checks.ts), and split apart again so one theme owns one module: this file holds
35
26
  * the shared config guard, the seam framework (seamPairs and the per-backend seam predicates),
36
- * and the declared-tier and image-input checks (checkTierModels, checkImageInput). The cap
27
+ * and the declared-tier check (checkTierModels). The cap
37
28
  * fallback price and fallback-chain checks (checkFallbackModel, checkFallbackChain) live in
38
29
  * doctor-fallback-checks.ts, importing this file's loadConfigForCheck just as the per-backend
39
30
  * modules do. The claude, opencode, codex and pi provider checks live in their own per-backend
@@ -277,173 +268,6 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
277
268
  return failOutcome(`${base}${ompNote}`);
278
269
  return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
279
270
  }
280
- /** The doctor's image-input capability check (plans/image-input.md "Doctor", parts 1-2/5):
281
- * which of the team's seams claim to read images, and whether every text-only enabled role can
282
- * reroute to one that does. Part 2 adds the live probe — with `probeImages` "missing" (the
283
- * terminal `tumwater doctor` default) each claimed pi model with no fresh cached verdict is
284
- * shown a real image and the answer is checked; with "all" (the `--reprobe` flag, the
285
- * dashboard's Re-probe models button and the TUI Health tab's Ctrl+P) every claimed model is
286
- * re-probed; with "never" (the TUI Health tab and GUI doctor endpoint on entry and refresh)
287
- * nothing is spawned and only cached verdicts are read, so a refresh never spends a model call.
288
- * claude seams are never probed — every Claude model reads images, and a probe would spend
289
- * subscription window to confirm it.
290
- *
291
- * Covers the same seams as `tier models` (seamPairs — the tiers, enabled roles and the
292
- * reviewer) plus the tiers' fallback pairs (the reroute part picks its target among them, so a
293
- * text-only fallback would promise a reroute no fallback can serve). A claimed model whose probe
294
- * failed (fresh verdict) is dropped from the readers and reads false from seamImageInput, so the
295
- * reroute sweep never sends a text-only role to a model that lost its probe. `providers`,
296
- * `account`, `pathEnv` and `probe` are injectable so tests need no real pi install or account. */
297
- export async function checkImageInput(root, modelsPath = piModelsPath(), providers = readPiProviders(modelsPath), account, probeImages = "never", pathEnv = envPath(), probe = runImageProbe) {
298
- const config = loadConfigForCheck(root);
299
- if ("level" in config)
300
- return config;
301
- // `tier models` already fails on a missing/unknown pair, so this check names only what it can
302
- // answer; unknown (null) pairs are left to that check. A missing definitions file only blocks
303
- // this check when a seam actually needs it — an all-claude team reads images without it.
304
- const accountNow = account === undefined ? readClaudeAccount(root) : account;
305
- // The free-ness lookup reads the image-input check's one claude account (part 2a/9).
306
- const accounts = (b) => (b === "claude" ? accountNow : null);
307
- const listed = readPiListedModels(root);
308
- const pairs = seamPairs(config);
309
- const needsDefinitions = pairs.some((p) => usesPiModels(p.agent ?? "pi"));
310
- if (providers === null && needsDefinitions)
311
- return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}`);
312
- const label = modelLabel;
313
- // The catalog claims (raw `input`, ignoring the cache): what to probe and report, including a
314
- // model whose earlier probe failed (it must still be named and re-probed by --reprobe).
315
- const claimed = new Map();
316
- for (const pair of pairs) {
317
- if (pair.model === undefined)
318
- continue;
319
- if (seamImageInput(pair, providers, undefined, listed) === true)
320
- claimed.set(imageProbeKey(pair.agent, pair.provider, pair.model), {
321
- agent: pair.agent,
322
- provider: pair.provider,
323
- model: pair.model,
324
- thinking: pair.thinking,
325
- });
326
- }
327
- // The tiers' fallback pairs: a reroute target must itself read images to help, and part 4's
328
- // fallback regime picks among these, so a text-only fallback team is a coverage gap here.
329
- const resolved = resolveTierFallbacks(config, (p) => piPairFree(providers, p, accounts));
330
- for (const tier of MODEL_TIERS) {
331
- const pair = resolved[tier].pair;
332
- if (pair?.model === undefined)
333
- continue;
334
- // The readers (seamImageInput, imageReroute) resolve a pair with no backend to the "pi"
335
- // agent, so the cache key must carry "pi" — keying it `undefined` would write a verdict the
336
- // readers never read.
337
- const agent = pair.backend ?? "pi";
338
- if (seamImageInput({ ...pair, agent }, providers, undefined, listed) === true)
339
- claimed.set(imageProbeKey(agent, pair.provider, pair.model), {
340
- agent,
341
- provider: pair.provider,
342
- model: pair.model,
343
- thinking: pair.thinking,
344
- });
345
- }
346
- const probes = readImageProbeCache(root);
347
- const now = Date.now();
348
- const env = childEnv({ PATH: pathEnv });
349
- const readsItems = [];
350
- const failed = [];
351
- const couldNot = [];
352
- let probedAny = false;
353
- // Settle one claimed seam: probe when asked and uncached, read a fresh cached verdict
354
- // otherwise, or report "not verified yet". Only pass/fail are cached; could-not-verify is
355
- // reported but never cached.
356
- for (const seam of claimed.values()) {
357
- const agent = seam.agent;
358
- // A backend that registers an imageInput hook trusts the hook (claude today, which reads
359
- // every model); a backend without one is probed through its model catalog.
360
- if (agent !== undefined && backendById(agent)?.imageInput !== undefined) {
361
- readsItems.push(label(agent, seam.provider, seam.model));
362
- continue;
363
- }
364
- const key = imageProbeKey(agent, seam.provider, seam.model);
365
- const cached = probes[key];
366
- const valid = imageProbeEntryFresh(cached, now);
367
- const wantProbe = probeImages !== "never" && (probeImages === "all" || !valid);
368
- if (wantProbe) {
369
- probedAny = true;
370
- const result = await probe(config, { provider: seam.provider, model: seam.model, thinking: seam.thinking }, env);
371
- if (result.verdict === "pass") {
372
- probes[key] = { verdict: "pass", detail: result.detail, at: now, claim: true };
373
- readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(now))})`);
374
- }
375
- else if (result.verdict === "fail") {
376
- probes[key] = { verdict: "fail", detail: result.detail, at: now, claim: true };
377
- failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${result.detail}`);
378
- }
379
- else {
380
- couldNot.push(`${label(agent, seam.provider, seam.model)} could not be verified — ${result.detail}`);
381
- }
382
- }
383
- else if (cached && imageProbeEntryFresh(cached, now)) {
384
- if (cached.verdict === "pass") {
385
- readsItems.push(`${label(agent, seam.provider, seam.model)} (verified ${formatDate(new Date(cached.at))})`);
386
- }
387
- else {
388
- failed.push(`${label(agent, seam.provider, seam.model)} failed the image probe — ${cached.detail}`);
389
- }
390
- }
391
- else {
392
- readsItems.push(`${label(agent, seam.provider, seam.model)} (not verified yet — run \`tumwater doctor\`)`);
393
- }
394
- }
395
- let saveProblem = null;
396
- if (probedAny) {
397
- try {
398
- writeImageProbeCache(root, probes);
399
- }
400
- catch (err) {
401
- saveProblem = errorMessage(err);
402
- }
403
- }
404
- const verdict = () => {
405
- // Text-only enabled roles: which can reroute to a tier that reads images (honoring failed
406
- // probes), and which cannot.
407
- const reroutes = [];
408
- const noTarget = [];
409
- for (const role of enabledRoleIds(config)) {
410
- const rc = configForRole(config, role);
411
- if (rc.model === undefined)
412
- continue;
413
- if (seamImageInput(rc, providers, probes, listed) !== false)
414
- continue; // reads images, or unknown (to tier models)
415
- const rr = imageReroute(config, role, "primary", modelsPath, accountNow, probes, null, listed);
416
- if (rr && seamImageInput(rr.config, providers, probes, listed) === true)
417
- reroutes.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)}) — reroutes to ${rr.tier} (${label(rr.config.agent, rr.config.provider, rr.config.model)})`);
418
- else
419
- noTarget.push(`${role} text-only (${label(rc.agent, rc.provider, rc.model)})`);
420
- }
421
- // A team that declared no models at all leaves capability to pi's own defaults — the same
422
- // "nothing configured" stance as tier models, informational rather than a warning.
423
- const declared = pairs.some((p) => p.model !== undefined) ||
424
- MODEL_TIERS.some((t) => resolved[t].pair?.model !== undefined);
425
- if (!declared)
426
- return okOutcome(PHRASE_NO_MODELS_DECLARED);
427
- const readsLine = readsItems.length > 0 ? `reads images: ${[...readsItems].sort().join(", ")}` : "";
428
- const details = joinParts([readsLine, ...reroutes]);
429
- if (failed.length > 0)
430
- return failOutcome(`${failed.join("; ")} — remove "image" from its input in pi's models.json or fix the endpoint, then run \`tumwater doctor --reprobe\``);
431
- if (couldNot.length > 0)
432
- return warnOutcome(`${couldNot.join("; ")}; ${details}`.replace(/;\s*$/, ""));
433
- if (noTarget.length > 0)
434
- return warnOutcome(`${noTarget.join("; ")} — no configured model reads images, so a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one`);
435
- if (readsItems.length === 0)
436
- return warnOutcome("no configured model reads images — a run that reads an image fails; declare image input on a vision model in pi's models.json, or point a tier at one");
437
- return okOutcome(details);
438
- };
439
- const outcome = verdict();
440
- if (saveProblem === null)
441
- return outcome;
442
- return {
443
- level: outcome.level === "ok" ? "warn" : outcome.level,
444
- detail: `${outcome.detail} — could not save the image-probe cache (${saveProblem}), so the next doctor probes again`,
445
- };
446
- }
447
271
  /** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
448
272
  * the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
449
273
  * resolves and `gh auth status` exits 0 before a run tries to file — the codex-login pattern
@@ -6,11 +6,8 @@ import { errorMessage, squash } from "../text/text.js";
6
6
  import { okOutcome, warnOutcome } from "./outcome.js";
7
7
  /** How far back the `notify` check looks for failed pages: a day, fixed. An opinionated
8
8
  * default with no knob, like STALE_BUG_DAYS: long enough that a transient hook failure shows
9
- * up, short enough that a page that did not go through is still the operator's business. The
10
- * always-on notify team alert (src/ui/alert-feeds.ts notifyFailures) reuses this same
11
- * threshold and the same isNotifyFailure window, so it cannot drift from the doctor's check
12
- * — the STALE_BUG_DAYS precedent. */
13
- export const NOTIFY_CHECK_WINDOW_MS = DAY_MS;
9
+ * up, short enough that a page that did not go through is still the operator's business. */
10
+ const NOTIFY_CHECK_WINDOW_MS = DAY_MS;
14
11
  /** The `notify` check: whether a notify command is configured, and a warning when it failed to
15
12
  * page within the last day (a nonzero exit, a kill, or a failed start — the warnings
16
13
  * spawnNotifyCommand logs). Warns but never fails, like the hold rows: a dead webhook must not