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
@@ -1,257 +0,0 @@
1
- /** The doctor's live image-input probe (plans/image-input.md "Doctor", part 2/5): a claimed
2
- * image-input capability in pi's models.json is only a claim — doctor shows each claimed model
3
- * a real image and checks the answer. This module owns three things: the in-process PNG
4
- * generator (a random 4-digit code drawn with a built-in 5×7 bitmap, encoded with
5
- * `zlib.deflateSync` + `zlib.crc32` is newer than the `>=20.3` node floor, so the CRC-32 here is
6
- * hand-rolled), the probe runner (the resolved pi binary in a run's environment, bounded like
7
- * the doctor's other binary probes), and the `.tumwater/state/image-probes.json` cache the
8
- * doctor check reads back. No new dependency; runs entirely offline against the operator's pi. */
9
- import fs from "node:fs";
10
- import os from "node:os";
11
- import path from "node:path";
12
- import { randomInt } from "node:crypto";
13
- import zlib from "node:zlib";
14
- import { DAY_MS } from "../text/datetime.js";
15
- import { PiStreamParser } from "../pi/pi-stream.js";
16
- import { resolveBackendBin } from "../pi/pi-bin.js";
17
- import { imageProbesPath } from "../paths.js";
18
- import { removeTree } from "../files/files.js";
19
- import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
20
- import { isJsonObject, validEntries } from "../files/json-object.js";
21
- import { boundedProbe } from "./probe.js";
22
- /** How long a cached image-probe verdict stays valid while the claim is unchanged (7 days) —
23
- * a model's endpoint can change underneath a stale pass, so the answer is dated. */
24
- export const IMAGE_PROBE_TTL_MS = 7 * DAY_MS;
25
- /** The probe's deadline (90 s): a local model may need to load, far beyond the quick credential
26
- * probes, but a doctor run is still a pre-flight — a hung backend must lose, not stall the
27
- * report. */
28
- const PROBE_TIMEOUT_MS = 90_000;
29
- /** Cap on the probe's captured stdout, the same guard as the auth probes: a doctor run must not
30
- * grow an unbounded buffer off a runaway log. */
31
- const PROBE_MAX_OUTPUT_BYTES = 1024 * 1024;
32
- /** The cache-file key for one seam — agent/provider/model, the natural identity of a team
33
- * model, joined on `/` (agent and provider never contain it and a model id is fully qualified,
34
- * so the key cannot collide with another seam's). */
35
- export function imageProbeKey(agent, provider, model) {
36
- return [agent ?? "", provider ?? "", model ?? ""].join("/");
37
- }
38
- /** Whether a cached entry still stands for the current claim: it exists, it recorded a real
39
- * verdict (never `could-not-verify`, which is never cached), it tested the image-input claim,
40
- * and it is younger than the TTL. */
41
- export function imageProbeEntryFresh(entry, now = Date.now()) {
42
- return (entry !== undefined &&
43
- (entry.verdict === "pass" || entry.verdict === "fail") &&
44
- entry.claim &&
45
- now - entry.at <= IMAGE_PROBE_TTL_MS);
46
- }
47
- /** Whether a parsed cache entry has the shape of an `ImageProbeCacheEntry`. */
48
- function isImageProbeCacheEntry(value) {
49
- return (isJsonObject(value) &&
50
- (value.verdict === "pass" || value.verdict === "fail") &&
51
- typeof value.detail === "string" &&
52
- typeof value.at === "number" &&
53
- Number.isFinite(value.at) &&
54
- typeof value.claim === "boolean");
55
- }
56
- /** Read the probe cache, or an empty cache when the file is missing or torn (the harness's
57
- * no-data policy: a crashed writer never takes down a reader). An entry of a foreign shape is
58
- * dropped, so it reads as unprobed, and the doctor's next cache write leaves it out. */
59
- export function readImageProbeCache(root) {
60
- return validEntries(readJsonFile(imageProbesPath(root)), isImageProbeCacheEntry);
61
- }
62
- /** Write the probe cache atomically (tmp+rename) so a concurrent reader never sees a torn
63
- * file. */
64
- export function writeImageProbeCache(root, cache) {
65
- writeJsonAtomic(imageProbesPath(root), cache);
66
- }
67
- // ── PNG generator ─────────────────────────────────────────────────────────────────────────
68
- /** Each digit as 7 rows of 5 bits, '1' = drawn (black); the standard 5×7 seven-segment-ish
69
- * font, big enough for a vision model to read and small enough to stay a trivial payload. */
70
- const DIGIT_BITMAP = {
71
- "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
72
- "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
73
- "2": ["01110", "10001", "00001", "00110", "01000", "10000", "11111"],
74
- "3": ["11111", "00010", "00100", "00010", "00001", "10001", "01110"],
75
- "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
76
- "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
77
- "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
78
- "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
79
- "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
80
- "9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
81
- };
82
- /** A hand-rolled CRC-32 (the PNG chunk checksum): `zlib.crc32` is newer than the `>=20.3` node
83
- * floor, so this bitwise table version is the one home of the polynomial. */
84
- const CRC32_TABLE = (() => {
85
- const table = new Uint32Array(256);
86
- for (let n = 0; n < 256; n++) {
87
- let c = n;
88
- for (let k = 0; k < 8; k++)
89
- c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
90
- table[n] = c >>> 0;
91
- }
92
- return table;
93
- })();
94
- function crc32(buf) {
95
- let c = 0xffffffff;
96
- for (let i = 0; i < buf.length; i++)
97
- c = (CRC32_TABLE[(c ^ (buf[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8);
98
- return (c ^ 0xffffffff) >>> 0;
99
- }
100
- /** One PNG chunk: 4-byte big-endian length, the 4-byte ASCII type, the data, and the CRC over
101
- * type+data. */
102
- function pngChunk(type, data) {
103
- const len = Buffer.alloc(4);
104
- len.writeUInt32BE(data.length, 0);
105
- const typeBuf = Buffer.from(type, "ascii");
106
- const crc = Buffer.alloc(4);
107
- crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
108
- return Buffer.concat([len, typeBuf, data, crc]);
109
- }
110
- /** A PNG showing `code` black on white, drawn with the 5×7 bitmap scaled `scale`-fold (each
111
- * font cell becomes a `scale`×`scale` block) and one `scale`-wide gap between digits. Truecolor
112
- * (color type 2) so any reader handles it and the decode-check in the tests is trivial. Exported
113
- * for tests (and the doctor's own validity check of a generated image). */
114
- export function makeProbePng(code, scale = 8) {
115
- const digits = [...code].map((d) => DIGIT_BITMAP[d] ?? DIGIT_BITMAP["0"]);
116
- const width = digits.length * 5 * scale + (digits.length - 1) * scale;
117
- const height = 7 * scale;
118
- // Truecolor RGB scanlines, each prefixed with filter byte 0 (none).
119
- const raw = Buffer.alloc(height * (1 + width * 3));
120
- for (let y = 0; y < height; y++) {
121
- const rowStart = y * (1 + width * 3);
122
- raw[rowStart] = 0;
123
- for (let x = 0; x < width; x++) {
124
- // Which font cell does this pixel fall in?
125
- const cell = Math.floor(x / (5 * scale + scale));
126
- const inCellX = x - cell * (5 * scale + scale);
127
- const cellDigit = digits[cell];
128
- const row = Math.floor(y / scale);
129
- const col = Math.floor(inCellX / scale);
130
- const bit = inCellX < 5 * scale && cellDigit !== undefined
131
- ? ((cellDigit[row] ?? "")[col] ?? "")
132
- : "";
133
- const on = bit === "1";
134
- const p = rowStart + 1 + x * 3;
135
- raw[p] = on ? 0 : 255;
136
- raw[p + 1] = on ? 0 : 255;
137
- raw[p + 2] = on ? 0 : 255;
138
- }
139
- }
140
- const ihdr = Buffer.alloc(13);
141
- ihdr.writeUInt32BE(width, 0);
142
- ihdr.writeUInt32BE(height, 4);
143
- ihdr[8] = 8; // bit depth
144
- ihdr[9] = 2; // color type: truecolor RGB
145
- const idat = zlib.deflateSync(raw, { level: 9 });
146
- return Buffer.concat([
147
- Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
148
- pngChunk("IHDR", ihdr),
149
- pngChunk("IDAT", idat),
150
- pngChunk("IEND", Buffer.alloc(0)),
151
- ]);
152
- }
153
- /** A random 4-digit code for one probe attempt. */
154
- function randomCode() {
155
- let out = "";
156
- for (let i = 0; i < 4; i++)
157
- out += String(randomInt(0, 10));
158
- return out;
159
- }
160
- // ── Probe runner ──────────────────────────────────────────────────────────────────────────
161
- /** Parse the resolved probe's stdout (pi's `--mode json` event stream) the same way a real run
162
- * is parsed, so a live probe sees the same final text and error classification the harness
163
- * would. */
164
- function parseProbeOutput(stdout) {
165
- const parser = new PiStreamParser();
166
- parser.feed(stdout);
167
- return {
168
- finalText: parser.finalText,
169
- configError: parser.configError,
170
- transientRateLimit: parser.transientRateLimit,
171
- transientBackend: parser.transientBackend,
172
- };
173
- }
174
- /** Whether an answer looks like a (wrong) read of the 4-digit code rather than a refusal: the
175
- * text contains a 4-digit run of digits (e.g. "The number is 5825."). A model that cannot see
176
- * at all does not emit a number. */
177
- function isFourDigitNumber(text) {
178
- return /\b\d{4}\b/.test(text);
179
- }
180
- /** Probe one claimed pi seam with a real image (plans/image-input.md "Doctor"). Runs the
181
- * resolved pi binary in a run's environment (the caller passes `env`, typically
182
- * `childEnv({ PATH: pathEnv })` which inherits `PI_CODING_AGENT_DIR`, so the probe reads the
183
- * same models.json a real run would):
184
- *
185
- * `pi --print --mode json --no-session --no-tools --no-mcp --provider P --model M
186
- * --thinking <level> @<png> "Reply with only the 4-digit number shown in the image."`
187
- *
188
- * Pass: the final assistant text contains the code. A wrong answer retries once with a fresh
189
- * code (a misread is not blindness). Two wrong answers that are not both numbers fail (a
190
- * refusal or empty answer is blindness), and so does a 4xx. Two wrong *numbers* — the model
191
- * read a number, wrongly — cannot be told from a flaky misread, so it is `could-not-verify`
192
- * rather than `fail` and is never cached: the next doctor run probes again instead of turning
193
- * a capable seam text-only for a week. A timeout, spawn failure, 429, 5xx, or connection error
194
- * is also `could-not-verify` — it says nothing about capability and is never cached. `code` is
195
- * a test seam (a real probe always draws a fresh random code); `env` and `timeoutMs` are
196
- * injectable so tests need no real pi. */
197
- export async function runImageProbe(config, seam, env, timeoutMs = PROBE_TIMEOUT_MS, code) {
198
- const envBin = env.TUMWATER_PI_BIN?.trim();
199
- // Resolve from the run's own environment (env override → config), exactly as a real run's
200
- // spawn would rather than only the harness process env — so tests can point a fake pi there.
201
- const bin = envBin
202
- ? envBin.includes("/")
203
- ? path.resolve(envBin)
204
- : envBin
205
- : resolveBackendBin(config).bin;
206
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-image-probe-"));
207
- const wrong = []; // the per-attempt "answered …" detail for a wrong answer
208
- const misread = []; // whether each wrong answer was a (wrong) 4-digit number
209
- try {
210
- for (let attempt = 0; attempt < 2; attempt++) {
211
- const thisCode = code ?? randomCode();
212
- const png = makeProbePng(thisCode);
213
- const pngPath = path.join(dir, `probe-${attempt}.png`);
214
- fs.writeFileSync(pngPath, png);
215
- const args = ["--print", "--mode", "json", "--no-session", "--no-tools", "--no-mcp"];
216
- if (seam.provider)
217
- args.push("--provider", seam.provider);
218
- if (seam.model)
219
- args.push("--model", seam.model);
220
- if (seam.thinking)
221
- args.push("--thinking", seam.thinking);
222
- args.push(`@${pngPath}`, "Reply with only the 4-digit number shown in the image.");
223
- const result = await boundedProbe(bin, args, env, timeoutMs, PROBE_MAX_OUTPUT_BYTES);
224
- if (result === null)
225
- return { verdict: "could-not-verify", detail: "the probe could not run or timed out" };
226
- const parsed = parseProbeOutput(result.stdout);
227
- if (parsed.configError)
228
- return { verdict: "fail", detail: "the provider rejected the request (4xx)" };
229
- if (parsed.transientRateLimit)
230
- return { verdict: "could-not-verify", detail: "the provider rate-limited the request (429)" };
231
- if (parsed.transientBackend)
232
- return { verdict: "could-not-verify", detail: "the provider backend failed (5xx or connection)" };
233
- const text = parsed.finalText.trim();
234
- if (text.includes(thisCode))
235
- return { verdict: "pass", detail: `answered "${text}"` };
236
- // A wrong 4-digit number says "I looked and got the digits wrong"; a refusal or empty
237
- // answer says "I cannot see". The drawn code is exactly 4 digits, so a 4-digit answer is
238
- // a plausible (wrong) read.
239
- misread.push(isFourDigitNumber(text));
240
- wrong.push(text ? `answered "${text}"` : "answered nothing");
241
- }
242
- // A refusal or empty answer on either attempt is blindness — the two-strikes rule: fail,
243
- // cached. Two wrong *numbers* cannot be told from a flaky misread, so it is could-not-verify
244
- // and never cached: the next doctor run probes again instead of turning a capable seam
245
- // text-only for a week. Either way the detail names what the model said, so the doctor
246
- // line, the alert, and a bug report can tell blindness from a misread.
247
- return misread[0] && misread[1]
248
- ? {
249
- verdict: "could-not-verify",
250
- detail: `two misreads (${wrong.join(", ")}) — the model read a number, wrongly; not blindness, re-probe on the next doctor run`,
251
- }
252
- : { verdict: "fail", detail: `two wrong answers (${wrong[1]})` };
253
- }
254
- finally {
255
- removeTree(dir);
256
- }
257
- }
@@ -1,181 +0,0 @@
1
- /** The backlog-feed team alerts, in emission order: stale open bugs, a failed notify page,
2
- * waiting bugs, stranded inbox prompts, a stalled merge queue, skipped merge-queue files, and
3
- * merges refused by an uncommitted primary checkout. One of the per-domain builders split out of
4
- * src/ui/team-alerts.ts; the shared types and wording helpers live in team-alert-shared.ts. */
5
- import { moreDetail, openBacklogAction } from "./team-alert-shared.js";
6
- import { plural } from "../text/phrases.js";
7
- import { roleLabel } from "../roles/role-labels.js";
8
- import { duration } from "./run-progress-model.js";
9
- import { humanAgo } from "../text/datetime.js";
10
- import { joinWithMore, squash } from "../text/text.js";
11
- // The per-arm builders below follow the push convention shared with the sibling splits: each
12
- // takes the `out` array plus the one input field its arm reads and pushes exactly the alert the
13
- // arm pushes, so buildBacklogFeedAlerts's emission order is its call order.
14
- function pushStaleBugsAlert(out, staleBugs) {
15
- // A stale bug (doctor's STALE_BUG_DAYS rule, src/doctor/doctor-backlog-bugs.ts) sits when no
16
- // role fixes it — the bugfix role is disabled or failing — so it asks (amber) the way the
17
- // refused alert does, naming the oldest, its age, and the doctor's retract advice. Its
18
- // callers pass it oldest-first (alert-feeds.ts staleOpenBugs sorts most-days-waited first),
19
- // so the first element is the most-stale bug and is named with its position and age; the rest
20
- // are folded through entryListDetail like the questions and refused alerts, so the wording and
21
- // counts cannot drift.
22
- if (staleBugs.length) {
23
- const first = staleBugs[0];
24
- const rest = staleBugs.slice(1);
25
- out.push({
26
- key: "stale-bugs",
27
- tone: "amber",
28
- title: `${plural(staleBugs.length, "open bug waited", "open bugs waited")} 3+ days for a fix`,
29
- detail: `#${first.position} "${first.title}" has waited ${first.days}d for a fix` +
30
- moreDetail(rest.map((r) => r.title)) +
31
- ` · check that the bugfix role is enabled and not failing, or retract it with \`tumwater bug --cancel ${first.position}\``,
32
- // The action lands the operator on the first stale bug.
33
- actions: openBacklogAction({ file: "bugs", index: first.index }),
34
- });
35
- }
36
- }
37
- function pushNotifyAlert(out, notifyFailure, now) {
38
- // A failed notify page (doctor's NOTIFY_CHECK_WINDOW_MS rule, src/doctor/doctor-notify.ts
39
- // checkNotify) leaves the team unsupervised — a dead webhook must not page the operator when
40
- // something else goes wrong — so a page that failed within the last day asks (amber) the way
41
- // the stale-bug alert does, naming the count, the age of the latest, its squashed message,
42
- // and the `tumwater notify --test` fix. Its callers feed it the latest failure's message via
43
- // alert-feeds.ts's notifyFailures (the same isNotifyFailure filter and window), so the
44
- // alert and the doctor cannot drift.
45
- if (notifyFailure) {
46
- out.push({
47
- key: "notify",
48
- tone: "amber",
49
- title: `${plural(notifyFailure.count, "failed notify page", "failed notify pages")} in the last day`,
50
- detail: `the notify hook could not reach you ${humanAgo(notifyFailure.latestTs, now)}: ${squash(notifyFailure.message, 160)} — check it with \`tumwater notify --test\``,
51
- actions: [],
52
- });
53
- }
54
- }
55
- function pushWaitingBugsAlert(out, staleWaitingBugs) {
56
- // A waiting bug (doctor's STALE_WAIT_DAYS rule, src/doctor/doctor-backlog-bugs.ts
57
- // checkWaitingBugs) whose flake has not come back parks indefinitely, so a hold that has
58
- // outlived a week asks (amber) the way the stale-bug alert does, naming the oldest hold, its
59
- // age, and the release-or-keep advice. Its callers pass it most-days-first (alert-feeds.ts
60
- // staleWaitingBugs sorts by days descending, ties in file order), so the first element is the
61
- // longest-held bug and is named with its position and days; the rest are folded through
62
- // entryListDetail like the questions and refused alerts, so the wording and counts cannot
63
- // drift.
64
- if (staleWaitingBugs.length) {
65
- const first = staleWaitingBugs[0];
66
- const rest = staleWaitingBugs.slice(1);
67
- out.push({
68
- key: "waiting-bugs", // vocabulary-ok: the waiting-bugs amber alert's key
69
- tone: "amber",
70
- title: `${plural(staleWaitingBugs.length, "waiting bug held", "waiting bugs held")} 7+ days`,
71
- detail: `#${first.position} "${first.title}" has waited ${first.days}d for its next failure` +
72
- moreDetail(rest.map((r) => `#${r.position} "${r.title}"`)) +
73
- ` · the hold has outlived a week and this surface omits the doctor's recurrence scan — \`tumwater doctor\` reports whether the flake came back; release it with \`tumwater bug --release ${first.position}\`, or keep the hold if the failure is still expected`,
74
- // The action lands the operator on the first stale waiting bug.
75
- actions: openBacklogAction({ file: "bugs", index: first.index }),
76
- });
77
- }
78
- }
79
- function pushStalePromptsAlert(out, stalePrompts) {
80
- // A stranded inbox prompt (doctor's STALE_PROMPT_MS rule, src/doctor/doctor-backlog.ts) sits
81
- // when a role is paused, disabled, or failing and never reaches the run that would dequeue the
82
- // prompt, so it asks (amber) the way the stale-bug alert does, naming the oldest prompt, its
83
- // wait, and the deliver-or-drop advice. Its callers feed it oldest-first (alert-feeds.ts
84
- // stalePrompts sorts by queuedAtMs ascending), so the first element is the most-stale prompt
85
- // and is named with its role, position and wait; the rest are folded through entryListDetail
86
- // like the questions and refused alerts, so the wording and counts cannot drift.
87
- if (stalePrompts.length) {
88
- const first = stalePrompts[0];
89
- const rest = stalePrompts.slice(1);
90
- out.push({
91
- key: "stale-prompts",
92
- tone: "amber",
93
- title: `${plural(stalePrompts.length, "queued prompt waited", "queued prompts waited")} 1+ day for its role`,
94
- detail: `inbox prompt for ${roleLabel(first.role)} #${first.position} has waited ${duration(first.waitedMs)} — the role is not dequeuing it (paused, disabled, or failing); resume or enable it, or drop the prompt with \`tumwater prompt --cancel ${first.position} --role ${first.role}\`` +
95
- moreDetail(rest.map((p) => `${roleLabel(p.role)} #${p.position}`)),
96
- // The action lands the operator on the Inbox tab (the dashboard's backlog arm reads `act:
97
- // "queued"` as the Inbox view), where the stranded prompt is cancellable.
98
- actions: [{ label: "Open inbox", act: "queued" }],
99
- });
100
- }
101
- }
102
- function pushStaleMergesAlert(out, staleMerges) {
103
- // A stalled merge queue (the doctor's MERGE_QUEUE_STALE_MS rule,
104
- // src/doctor/doctor-backlog.ts, set by the stale-merges alert) sits when a queued change
105
- // never starts — neither verified nor flagged — so a drain that has stopped reads healthy
106
- // except for this row. Its callers feed it oldest-first (alert-feeds.ts staleMerges reads
107
- // queuedMerges in queue-file order), so the first element is the longest-waiting merge and is
108
- // named with its role, summary and wait; the rest are folded through joinWithMore like the
109
- // actions and no-image alerts, so the wording and counts cannot drift.
110
- if (staleMerges.length) {
111
- const first = staleMerges[0];
112
- const rest = staleMerges.slice(1);
113
- out.push({
114
- key: "stale-merges",
115
- tone: "amber",
116
- title: `${plural(staleMerges.length, "queued merge waited", "queued merges waited")} 6+ hours to start`,
117
- detail: `${roleLabel(first.role)} — "${first.summary}" — has waited ${duration(first.waitedMs)} to start` +
118
- moreDetail(rest.map((m) => roleLabel(m.role)), (labels) => joinWithMore(labels, 3)) +
119
- ` — the merge queue has stopped moving; run \`tumwater queue\` to see it, or drop the entry with \`tumwater queue --cancel <n>\``,
120
- // The header's `merge queue: N` badge and the Pending view already carry the drill-in.
121
- actions: [],
122
- });
123
- }
124
- }
125
- function pushSkippedMergesAlert(out, skippedMergeFiles) {
126
- // A merge-queue file `readMergeQueue` skips (torn, foreign, or vanished mid-listing) is the
127
- // always-on twin of the doctor's `merge-queue files` row (doctor-backlog.ts
128
- // skippedMergeQueueFiles, feeding this from alert-feeds.ts's skippedMergeFiles): it sits
129
- // in .tumwater/merge-queue/ forever because the drain drops only an unreadable head, so a
130
- // skipped file after a healthy entry strands its merge until someone removes it. The stale-
131
- // merges alert above reads only readable entries, so it can never cover one. The doctor's row
132
- // and this alert share the one relative-path list, so they name the same files; the count and
133
- // the first skipped file are shown, the rest folded through joinWithMore.
134
- if (skippedMergeFiles.length) {
135
- const [first, ...rest] = skippedMergeFiles;
136
- out.push({
137
- key: "skipped-merges",
138
- tone: "amber",
139
- title: `${plural(skippedMergeFiles.length, "merge-queue file is", "merge-queue files are")} unreadable`,
140
- detail: `the queue reader skips ${first} — a torn or foreign file never clears itself, so its merge is stranded; remove it (a crashed enqueue's merge ref still recovers on the next run)` +
141
- moreDetail(rest, (labels) => joinWithMore(labels, 3)),
142
- actions: [],
143
- });
144
- }
145
- }
146
- function pushPrimaryOverwriteAlert(out, primaryOverwrite, now) {
147
- // A merge the primary checkout blocked (merge-git.ts primaryCheckoutBlockers, the
148
- // overwriteBlockedReason `warning` the merge path writes) — the always-on twin of the doctor's
149
- // `uncommitted backlog` row, but read off events so it reaches the status poll (the doctor's
150
- // git read is async and never gets there). A team stuck retrying one dirty file is a single
151
- // amber banner until the file is committed and the refusals age out; alert-feeds.ts's
152
- // primaryOverwriteBlocks feeds it the last day's refusals, counting them and naming the
153
- // latest's age, squashed message, and the commit fix.
154
- if (primaryOverwrite) {
155
- out.push({
156
- key: "primary-overwrite",
157
- tone: "amber",
158
- title: `${plural(primaryOverwrite.count, "merge was", "merges were")} refused by files uncommitted in your primary checkout`,
159
- detail: `most recently ${humanAgo(primaryOverwrite.latestTs, now)}: ${squash(primaryOverwrite.message, 160)} — roles cannot commit your checkout; commit the file(s) on main with \`tumwater doctor --commit-backlog\` (or by hand) so merges can proceed`,
160
- actions: [],
161
- });
162
- }
163
- }
164
- /** The backlog-feed alerts, in emission order: stale open bugs, a failed notify page, waiting
165
- * bugs, stranded inbox prompts, a stalled merge queue, skipped merge-queue files, and merges
166
- * refused by an uncommitted primary checkout. Extracted from teamAlerts (part 3/5 of the
167
- * teamAlerts split) so the shared module stays small; the input names exactly the fields this
168
- * run reads. Each arm is one module-private builder named push<Alert>Alert, called here in arm
169
- * order, so a backlog-feed alert is found by name and the caller's emission order is preserved. */
170
- export function buildBacklogFeedAlerts(input) {
171
- const out = [];
172
- const { staleBugs, notifyFailure, staleWaitingBugs, stalePrompts, staleMerges, skippedMergeFiles, primaryOverwrite, now, } = input;
173
- pushStaleBugsAlert(out, staleBugs);
174
- pushNotifyAlert(out, notifyFailure, now);
175
- pushWaitingBugsAlert(out, staleWaitingBugs);
176
- pushStalePromptsAlert(out, stalePrompts);
177
- pushStaleMergesAlert(out, staleMerges);
178
- pushSkippedMergesAlert(out, skippedMergeFiles);
179
- pushPrimaryOverwriteAlert(out, primaryOverwrite, now);
180
- return out;
181
- }
@@ -1,183 +0,0 @@
1
- /** The quarantine, flapped-check, and post-merge review team alerts, in emission order: a stale
2
- * or oversized flake quarantine, checks flapping more than once, a burst of post-merge review
3
- * rejections, a post-merge review one failed run from being dropped, a post-merge review the
4
- * drain dropped leaves its commit on main unreviewed, a main tip the watch's diff rules
5
- * rejected, and the ci role's full suite stalled over three days without covering a new tip.
6
- * One of the per-domain builders split out of src/ui/team-alerts.ts; the shared types and
7
- * wording helpers live in team-alert-shared.ts. */
8
- import { seeFailures } from "./team-alert-shared.js";
9
- import { CI_PARITY_STALE_MS, ciParityStalled } from "../run/ci-parity.js";
10
- import { QA_COVERAGE_STALE_MS } from "../run/qa-coverage.js";
11
- import { plural } from "../text/phrases.js";
12
- import { humanAgo, DAY_MS } from "../text/datetime.js";
13
- import { squash } from "../text/text.js";
14
- import { roleLabel } from "../roles/role-labels.js";
15
- import { shortSha } from "../text/format.js";
16
- // A stale or oversized flake quarantine — the always-on twin of the doctor's `flaky checks`
17
- // row (main-guardrails.ts checkFlakyChecks, quarantineWarning): a quarantined test counts as
18
- // a known failure until its filed bug leaves BUGS.md `## Open`, so an old entry means the
19
- // team is merging on a suite whose red is being excused. alert-feeds.ts's quarantineAlert
20
- // feeds it from the same quarantineWarning the doctor calls, so the alert and the doctor
21
- // cannot drift on when the list is a problem; the warning phrase and the oldest test's age
22
- // are shown, folded through squash like other long names.
23
- function pushQuarantineAlert(out, quarantined, now) {
24
- if (quarantined) {
25
- out.push({
26
- key: "quarantine",
27
- tone: "amber",
28
- title: `${plural(quarantined.count, "test")} in the flake quarantine — the gate is excusing their failures`,
29
- detail: `${quarantined.warning} · oldest: ${squash(quarantined.oldestTest, 80)} (${humanAgo(quarantined.oldestFirstSeen, now)}) — a quarantined test counts as a known failure until its filed bug leaves BUGS.md \`## Open\`, so fix the test or file the missing bug`,
30
- actions: seeFailures(),
31
- });
32
- }
33
- }
34
- // Checks that flapped more than once in the last day — the always-on twin of the doctor's
35
- // `flaky checks` row (doctor-project-checks.ts checkFlakyChecks): the gate's single re-run
36
- // absorbs a one-off flake, but a check flapping twice silently pays that re-run on every
37
- // merge it touches, so the tally belongs on every observer surface while it happens.
38
- // alert-feeds.ts's flappedChecksAlert feeds it from the same repeatedFlakeCounts and
39
- // flappedChecksPhrase the doctor calls, so the alert and the doctor cannot drift on the
40
- // count or the wording.
41
- function pushFlappedChecksAlert(out, flappedChecks) {
42
- if (flappedChecks) {
43
- out.push({
44
- key: "flapped-checks",
45
- tone: "amber",
46
- title: `${plural(flappedChecks.count, "check has", "checks have")} flapped more than once in the last day`,
47
- detail: `${flappedChecks.detail} — a flapping check pays the gate's one re-run on every merge it touches; fix it or file it with \`tumwater bug\``,
48
- actions: seeFailures(),
49
- });
50
- }
51
- }
52
- // Last day's post-merge review rejections — the reviewer-fix signal for the `review.afterMerge`
53
- // tiering (src/review/post-merge-review.ts `reviewMergedCommit`). A post-merge rejection is the
54
- // costly kind: the commit already reached main, so the rejection is answered only by the
55
- // reviewer's own fix, and the always-on signal for a day crossing POST_MERGE_REJECT_WARN is
56
- // this alert. alert-feeds.ts's postMergeChurnAlert feeds it
57
- // from the same `afterMerge: true` `review_rejected` events the report's
58
- // postMergeRejectionsByRole folds, so the alert and the report cannot disagree on which
59
- // rejections are post-merge.
60
- function pushPostMergeChurnAlert(out, postMergeChurn) {
61
- if (postMergeChurn) {
62
- out.push({
63
- key: "post-merge-churn",
64
- tone: "amber",
65
- title: `${plural(postMergeChurn.count, "post-merge review rejection", "post-merge review rejections")} in the last day`,
66
- detail: `${postMergeChurn.roles.map((r) => `${roleLabel(r.role)} ×${r.count}`).join(", ")} — each commit already reached main and stays there with the reviewer's recorded reasons (never re-reviewed — Forward, never back); narrow \`review.afterMerge\` for the noisy role or raise the authoring bar`,
67
- actions: seeFailures(),
68
- });
69
- }
70
- }
71
- // A post-merge review one failed run from being dropped — the always-on twin of the doctor's
72
- // `post-merge reviews` row (src/doctor/doctor-backlog.ts checkPostReviews) — the last moment
73
- // an operator can fix the reviewer backend or deliberately accept the commit unreviewed.
74
- // postReviewRiskAlert feeds it from the same atRiskPostReviews traversal and phrase the doctor
75
- // calls, so the alert and the doctor cannot drift on the trigger or the wording.
76
- function pushPostMergeRiskAlert(out, postReviewRisk) {
77
- if (postReviewRisk) {
78
- out.push({
79
- key: "post-merge-risk",
80
- tone: "amber",
81
- title: `${plural(postReviewRisk.count, "post-merge review is", "post-merge reviews are")} one failed run from being dropped`,
82
- detail: postReviewRisk.detail,
83
- actions: seeFailures(),
84
- });
85
- }
86
- }
87
- // A post-merge review the drain actually dropped — the commit it leaves on main unreviewed
88
- // (src/merge/merge-post-review-drain.ts `startPostReview` at POST_REVIEW_FAILURE_LIMIT).
89
- // Reads only the `warning` event the drop branch writes, after the at-risk alert is moot:
90
- // the entry is gone, so no surface names the commit without this.
91
- // droppedPostReviewsAlert feeds it from the same DROPPED_POST_REVIEW_MARKER the drain's
92
- // cannot drift on what a drop is (the per-run failed-review warning never matches).
93
- function pushDroppedPostReviewsAlert(out, droppedPostReviews, now) {
94
- if (droppedPostReviews) {
95
- out.push({
96
- key: "dropped-post-reviews",
97
- tone: "amber",
98
- title: `${plural(droppedPostReviews.count, "post-merge review was dropped", "post-merge reviews were dropped")} in the last day — ${plural(droppedPostReviews.count, "the commit sits", "the commits sit")} on main unreviewed`,
99
- detail: `most recently ${humanAgo(droppedPostReviews.latestTs, now)}: ${squash(droppedPostReviews.latestMessage, 160)} — the reviewer kept failing past \`POST_REVIEW_FAILURE_LIMIT\`, so the commit is beyond the review gate; fix the reviewer backend so later commits are reviewed`,
100
- actions: [{ label: "See failures", act: "view", arg: "failures" }],
101
- });
102
- }
103
- }
104
- // A main tip the watch's five diff rules rejected in the trailing day — the always-on
105
- // surface for the `warning` main-watch.ts's `watchRules` logs (`main's tip <short sha> fails
106
- // the diff rules: <reason>`). The gate judges one change; the watch judges each first-parent
107
- // commit's own diff, so a conflict marker, a protected edit, a leaked secret, a phantom fix
108
- // claim or a broken backlog structure that reached main through a gate blind spot sits on
109
- // main with only that event log to say so. alert-feeds.ts's mainRuleFailures feeds it
110
- // from the same marker, so the alert and the watch cannot drift on what a rule failure is (a
111
- // `main_check` or `review_rejected` carrying the same text never matches).
112
- function pushMainDiffRulesAlert(out, mainRuleFailure, now) {
113
- if (mainRuleFailure) {
114
- out.push({
115
- key: "main-diff-rules",
116
- tone: "amber",
117
- title: `${plural(mainRuleFailure.count, "diff-rule failure on main", "diff-rule failures on main")} in the last day`,
118
- detail: `most recently ${humanAgo(mainRuleFailure.latestTs, now)}: ${squash(mainRuleFailure.latestMessage, 160)} — the gate approved a diff the watch's five rules reject, so main carries what the gate would refuse; repair the tip or revert it with \`tumwater revert <sha>\``,
119
- actions: seeFailures(),
120
- });
121
- }
122
- }
123
- // The ci role's full suite silently stopped covering new tips — the always-on twin of the
124
- // doctor's `ci parity` row (doctor-project-checks.ts checkCiParity), read from the same
125
- // ciParityStalled predicate and CI_PARITY_STALE_MS threshold so the alert and the row cannot
126
- // disagree on when a post-merge gap is a stall. A `stale` record is normal right after a
127
- // merge (main moves on every merge and the role runs on its own clock), but one that stays
128
- // stale past three days means the role that is now the full suite's own runner has silently
129
- // stopped covering new tips (disabled, paused, wedged, or failing before it records). The
130
- // fix is a role toggle the role drawer already has, so there is no action button here.
131
- function pushCiStaleAlert(out, ciParity, now) {
132
- if (ciParity && ciParityStalled(ciParity, now)) {
133
- out.push({
134
- key: "ci-stale",
135
- tone: "amber",
136
- title: `main's full suite has not covered a tip in over ${CI_PARITY_STALE_MS / DAY_MS} days`,
137
- detail: `the ci role's last full-suite run covered ${shortSha(ciParity.covered)} ${humanAgo(ciParity.lastRunAt, now)}; main has moved since, so no full-suite run covers the current tip — the ci role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
138
- actions: [],
139
- });
140
- }
141
- }
142
- // The qa role's flow coverage silently stopped advancing — the always-on twin of the
143
- // doctor's `qa coverage` row (doctor-project-checks.ts checkQaCoverage), fed from the same
144
- // qaCoverageStalled predicate via alert-feeds.ts's qaCoverageAlert so the alert and the
145
- // row cannot disagree on when a quiet qa is a stall. A missing ledger (no record yet) is not
146
- // a stall, so this fires only once a recorded flow's newest exercise is over three days old —
147
- // the team's only flow-level observer has silently stopped recording (disabled, paused,
148
- // wedged, or failing before it records). The fix is a role toggle the role drawer already has,
149
- // so there is no action button here.
150
- function pushQaStaleAlert(out, qaCoverage, now) {
151
- if (qaCoverage) {
152
- out.push({
153
- key: "qa-stale",
154
- tone: "amber",
155
- title: `qa's flow coverage has not advanced in over ${QA_COVERAGE_STALE_MS / DAY_MS} days`,
156
- detail: `the qa role's newest flow ${qaCoverage.newestFlow} was exercised ${humanAgo(qaCoverage.newestAt, now)}; coverage has not advanced since, so the qa role is disabled, paused, or unable to record; resume or enable it, or check \`tumwater doctor\``,
157
- actions: [],
158
- });
159
- }
160
- }
161
- /** The quarantine, flapped-check, and post-merge review alerts, in emission order: a
162
- * stale or oversized flake quarantine, checks flapping more than once, a
163
- * burst of post-merge review rejections, a post-merge review one failed run from being
164
- * dropped, a post-merge review the drain dropped leaves its commit on main unreviewed, a
165
- * main tip the watch's diff rules rejected, and the ci role's full suite stalled over three
166
- * days without covering a new tip.
167
- * Extracted from teamAlerts (part 4/5 of the teamAlerts split) so the shared module stays
168
- * small; the input names exactly the fields this run reads (including `now`, which the
169
- * quarantine and dropped-post-reviews age lines need). The caller pushes the returned alerts
170
- * at the position this run used to occupy, so the emitted key order is unchanged. */
171
- export function buildMergeAlerts(input) {
172
- const out = [];
173
- const { quarantined, flappedChecks, postMergeChurn, postReviewRisk, droppedPostReviews, mainRuleFailure, ciParity, qaCoverage, now } = input;
174
- pushQuarantineAlert(out, quarantined, now);
175
- pushFlappedChecksAlert(out, flappedChecks);
176
- pushPostMergeChurnAlert(out, postMergeChurn);
177
- pushPostMergeRiskAlert(out, postReviewRisk);
178
- pushDroppedPostReviewsAlert(out, droppedPostReviews, now);
179
- pushMainDiffRulesAlert(out, mainRuleFailure, now);
180
- pushCiStaleAlert(out, ciParity, now);
181
- pushQaStaleAlert(out, qaCoverage, now);
182
- return out;
183
- }