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
@@ -14,7 +14,7 @@
14
14
  * CLI command package for domain logic.
15
15
  */
16
16
  import path from "node:path";
17
- import { fencedHeadingTitle, fenceTracker, nextSectionHeading } from "./backlog-md.js";
17
+ import { fencedHeadingTitle, fenceTracker, firstDate, nextSectionHeading } from "./backlog-md.js";
18
18
  import { answeredQuestionEntries, entryMatches, openQuestionEntries, ageInDays, ageSuffix } from "./backlog.js";
19
19
  import { normalizeEntryBody } from "./backlog-write.js";
20
20
  import { collapseWhitespace, firstTrimmedLineStartingWith, trimLeadingBlankLines, trimTrailingBlankLines, truncate } from "../text/text.js";
@@ -43,10 +43,11 @@ function firstBodySuffix(body) {
43
43
  /** The open question's filing stamp, `YYYY-MM-DD`, read from the heading's `(asked by <who>
44
44
  * YYYY-MM-DD)` clause — the first `YYYY-MM-DD` after an `asked by` word in the title text — or
45
45
  * null when the heading carries no such clause. Taken from the title text only, not the body:
46
- * the stamp is the entry's own, and the body may name other dates. */
46
+ * the stamp is the entry's own, and the body may name other dates. Routed through
47
+ * backlog-md.ts's firstDate - the one first-match mechanism filingDate (backlog.ts) uses too -
48
+ * with the `asked by` verb in place of the plans/bugs stamp verbs. */
47
49
  export function questionFilingDate(title) {
48
- const m = /(?:asked by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/.exec(title);
49
- return m === null ? null : m[1];
50
+ return firstDate(title, /(?:asked by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/);
50
51
  }
51
52
  /** The role id named in the heading's `(asked by <role> …)` clause — the first word after
52
53
  * `asked by` — or null when the heading has no such clause. Taken from the title only, like
@@ -273,40 +274,56 @@ function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
273
274
  if (expectedTitle !== undefined && block.title !== expectedTitle)
274
275
  return { status: "moved", title: block.title };
275
276
  const moved = lines.slice(block.start, block.end);
276
- trimTrailingBlankLines(moved);
277
+ const rebuilt = cutOpenBlock(lines, scanned, block);
278
+ const text = appendAnsweredEntry(rebuilt, moved, decision, scanned.answeredIdx !== -1);
279
+ writeTextAtomic(file, text);
280
+ return { status: "answered", title: block.title, decision };
281
+ }
282
+ /** Cut `block` out of `## Open`, returning the rebuilt file text (as lines). When the section
283
+ * is left with no content at all, restores the `_None yet._` placeholder the skeleton carries,
284
+ * so an empty outbox still reads as an intentionally empty section. */
285
+ function cutOpenBlock(lines, scan, block) {
277
286
  // Cut the block out of ## Open: everything before it, everything from its end on. When the
278
287
  // section is left with no content at all, restore the `_None yet._` placeholder the skeleton
279
288
  // carries, so an empty outbox still reads as an intentionally empty section.
280
289
  // Emptiness is judged against the Open section only (openIdx+1 .. openEnd): with an
281
290
  // intermediate section between Open and Answered, judging against answeredIdx would keep
282
291
  // the placeholder from returning and, worse, the empty rebuild would drop that section.
283
- const openContent = lines.slice(scanned.openIdx + 1, scanned.openEnd);
284
- const remaining = openContent.filter((_, i) => i < block.start - scanned.openIdx - 1 || i >= block.end - scanned.openIdx - 1);
292
+ const openContent = lines.slice(scan.openIdx + 1, scan.openEnd);
293
+ const remaining = openContent.filter((_, i) => i < block.start - scan.openIdx - 1 || i >= block.end - scan.openIdx - 1);
285
294
  const openEmpty = remaining.every((l) => l.trim() === "");
286
295
  const rebuilt = [];
287
296
  if (openEmpty) {
288
- rebuilt.push(...lines.slice(0, scanned.openIdx + 1), "", "_None yet._",
297
+ rebuilt.push(...lines.slice(0, scan.openIdx + 1), "", "_None yet._",
289
298
  // Resume at the section that followed Open (openEnd, not answeredIdx — an intermediate
290
299
  // section between the two must survive) with the blank separator line the dropped Open
291
300
  // content used to carry, so `_None yet._` never reads as a paragraph glued to a heading.
292
- "", ...lines.slice(scanned.openEnd));
301
+ "", ...lines.slice(scan.openEnd));
293
302
  }
294
303
  else {
295
304
  rebuilt.push(...lines.slice(0, block.start), ...lines.slice(block.end));
296
305
  }
306
+ return rebuilt;
307
+ }
308
+ /** Append the moved block at the end of `## Answered` (growing the section when the file has
309
+ * none, stripping a real `_None yet._` placeholder from it), returning the full text to write.
310
+ * `hasAnswered` is whether the file already held a `## Answered` section. */
311
+ function appendAnsweredEntry(rebuilt, moved, decision, hasAnswered) {
312
+ // The moved block's trailing blank lines are dropped, so the appended entry never reads
313
+ // as a spill of blank lines before the dated answer stamp.
314
+ trimTrailingBlankLines(moved);
297
315
  // Append the block at the end of ## Answered: before the next `## ` heading when one
298
316
  // follows, at the file's end otherwise — the section is last in the documented skeleton.
299
317
  const stamp = `**Answered ${formatDate(new Date())} by the user:**`;
300
318
  const stamped = decision.includes("\n") ? [stamp, "", ...decision.split("\n")] : [`${stamp} ${decision}`];
301
319
  const answerLines = moved.concat("", ...stamped, "");
302
- if (scanned.answeredIdx === -1) {
320
+ if (!hasAnswered) {
303
321
  // No ## Answered section: grow the file's end with the skeleton section plus the block.
304
322
  let out = rebuilt.join("\n");
305
323
  if (out !== "" && !out.endsWith("\n"))
306
324
  out += "\n";
307
325
  out += `\n## Answered\n\n${answerLines.join("\n")}`;
308
- writeTextAtomic(file, out);
309
- return { status: "answered", title: block.title, decision };
326
+ return out;
310
327
  }
311
328
  const fenced2 = fenceTracker();
312
329
  let answeredAt = -1;
@@ -342,6 +359,5 @@ function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
342
359
  trimTrailingBlankLines(head);
343
360
  trimLeadingBlankLines(tail);
344
361
  const result = head.concat("", answerLines, tail);
345
- writeTextAtomic(file, result.join("\n"));
346
- return { status: "answered", title: block.title, decision };
362
+ return result.join("\n");
347
363
  }
@@ -174,6 +174,15 @@ export function noteInheritedGreen(sha, from) {
174
174
  * verdict anyway (every code merge seeds one), so this bound only caps the rev-list on a cold
175
175
  * cache. */
176
176
  const INHERIT_WALK_MAX = 64;
177
+ /** The body of a `git rev-list --first-parent --max-count=<max> <ref>` read — the walk the three
178
+ * first-parent scans in this module share, spelled the same on purpose so a change to the walk
179
+ * shape (the bound, the `--first-parent` flag) lands once. `inheritedGreen` and `watchedBaseline`
180
+ * walk a commit's ancestors (`${sha}^` / `${tip}^`, the commit itself excluded);
181
+ * `newestGreenAboveBuild` walks `tip` itself and down. Never throws: a git failure is an empty
182
+ * list, and every caller already treats that as a no-inheritance / no-verdict answer. */
183
+ async function firstParentWalk(wt, ref, max) {
184
+ return gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${max}`, ref));
185
+ }
177
186
  /** A green for `sha` carried over from its nearest verdict-bearing first-parent ancestor, or null
178
187
  * when there is none to carry. The nearest ancestor with a cached verdict must be GREEN, and the
179
188
  * tree diff from it to `sha` must touch only `exemptPaths` (config.review.exemptPaths, the same
@@ -185,7 +194,7 @@ const INHERIT_WALK_MAX = 64;
185
194
  * past. An uncached ancestor is walked through, since the single diff from the green covers it.
186
195
  * Never throws: any git failure means no inheritance, and the caller runs the suite as before. */
187
196
  async function inheritedGreen(wt, sha, exemptPaths) {
188
- const ancestors = gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${INHERIT_WALK_MAX}`, `${sha}^`));
197
+ const ancestors = await firstParentWalk(wt, `${sha}^`, INHERIT_WALK_MAX);
189
198
  for (const ancestor of ancestors) {
190
199
  const verdict = baselineCache.get(ancestor);
191
200
  if (verdict === undefined)
@@ -229,7 +238,7 @@ export async function watchedBaseline(wt, sha) {
229
238
  const own = baselineCache.get(tip);
230
239
  if (own)
231
240
  return own;
232
- const ancestors = gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${INHERIT_WALK_MAX}`, `${tip}^`));
241
+ const ancestors = await firstParentWalk(wt, `${tip}^`, INHERIT_WALK_MAX);
233
242
  for (const ancestor of ancestors) {
234
243
  const verdict = baselineCache.get(ancestor);
235
244
  if (verdict)
@@ -249,6 +258,23 @@ const REDEPLOY_WALK_MAX = 64;
249
258
  export function watchVerifiesMain(root, config) {
250
259
  return detectBuildCheck(root, config) !== null && mergeCommandOf(config) !== undefined;
251
260
  }
261
+ /** The newest first-parent commit below `tip`, down to and through `buildSha`, with a green
262
+ * cached verdict — the walk+scan redeployTarget's watched and un-watched branches share. Only
263
+ * their fallback differs: `notOnHistory` when `buildSha` is not on the first-parent walk at all
264
+ * (the caller keeps today's answer, `tip`), `onHistoryNone` when it is but nothing above it is
265
+ * green (watched keeps the build; un-watched keeps `tip`). Never throws: a git failure leaves
266
+ * the walk empty, so the fallback answers. */
267
+ async function newestGreenAboveBuild(wt, tip, buildSha, notOnHistory, onHistoryNone) {
268
+ const walk = await firstParentWalk(wt, tip, REDEPLOY_WALK_MAX);
269
+ const buildAt = walk.indexOf(buildSha);
270
+ if (buildAt === -1)
271
+ return notOnHistory;
272
+ for (const sha of walk.slice(0, buildAt)) {
273
+ if (baselineCache.get(sha)?.status === "green")
274
+ return sha;
275
+ }
276
+ return onHistoryNone;
277
+ }
252
278
  /** The commit self-redeploy should run: main's newest first-parent commit with a green full-check
253
279
  * verdict in the cache (observed or inherited) that is newer than the running build `buildSha`,
254
280
  * looking at most REDEPLOY_WALK_MAX commits down from `tip`.
@@ -275,28 +301,12 @@ export async function redeployTarget(wt, tip, buildSha, watched = false) {
275
301
  if (watched) {
276
302
  if (tip === "")
277
303
  return tip;
278
- const walk = gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${REDEPLOY_WALK_MAX}`, tip));
279
- const buildAt = walk.indexOf(buildSha);
280
- if (buildAt === -1)
281
- return tip;
282
- for (const sha of walk.slice(0, buildAt)) {
283
- if (baselineCache.get(sha)?.status === "green")
284
- return sha;
285
- }
286
- return buildSha;
304
+ return newestGreenAboveBuild(wt, tip, buildSha, tip, buildSha);
287
305
  }
288
306
  const red = baselineCache.get(tip);
289
307
  if (tip === "" || red?.status !== "red" || !(red.redFrom ?? []).includes(mirrorWorktreePath(wt)))
290
308
  return tip;
291
- const walk = gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${REDEPLOY_WALK_MAX}`, tip));
292
- const buildAt = walk.indexOf(buildSha);
293
- if (buildAt === -1)
294
- return tip;
295
- for (const sha of walk.slice(0, buildAt)) {
296
- if (baselineCache.get(sha)?.status === "green")
297
- return sha;
298
- }
299
- return tip;
309
+ return newestGreenAboveBuild(wt, tip, buildSha, tip, tip);
300
310
  }
301
311
  /** Verify main's own build/test suite at `wt`'s HEAD — which must be pristine main (the caller
302
312
  * is the fresh-run path right after resetWorktreeToMain; a dirty or ahead worktree would
@@ -49,25 +49,22 @@ export const QUARANTINE_WARN_COUNT = 10;
49
49
  /** How many of the oldest quarantined tests a warning names before folding the rest into a
50
50
  * remainder count. */
51
51
  const QUARANTINE_DETAIL_MAX = 3;
52
- /** G1 over `events`: warns when main has been red for more than RED_MAIN_WARN_MS, over the
53
- * trailing 24 h of `events` from `now`. Only `main_check` events with `durationMs` > 0 and
54
- * `main_red_hold` events count; a `main_check` with `durationMs` 0 (cached or inherited) is
55
- * ignored; a pass (including one naming only quarantined tests, recorded passed) ends the red.
56
- * The phrase names how long main has had no passing full check, the sha it has been red since,
57
- * its clock time, the newest red's first failing test when one is named, and — when the newest
58
- * `main_culprit` in the window blames the same sha the phrase names — the merge the main watch
59
- * blamed (`; the main watch blames <role>'s merge <sha>`, or `; the main watch blames merge
60
- * <sha>` when no runner is named), the one rule the dashboard alert and this phrase share.
61
- * Returns null when main is green, or red for RED_MAIN_WARN_MS or less. */
62
- export function redMainWarning(events, now) {
63
- const cutoff = now - DAY_MS;
52
+ /** True when `e` is the newest `main_culprit` seen so far: no contender yet, or a later `ts`.
53
+ * The newest-in-window pick that `scanRedWindow` folds into its loop. */
54
+ function isNewerCulprit(c, e) {
55
+ return c === undefined || e.ts > c.ts;
56
+ }
57
+ /** Scans the trailing 24 h of `events` (cutoff `now - DAY_MS`), collecting the events that open,
58
+ * extend or end a red and picking the newest in-window `main_culprit` with a non-empty string
59
+ * `commit`. Owns the window scan and the newest-culprit pick. */
60
+ function scanRedWindow(events, cutoff) {
64
61
  const relevant = [];
65
62
  let culprit;
66
63
  for (const e of events) {
67
64
  if (typeof e.ts !== "number" || e.ts < cutoff)
68
65
  continue;
69
66
  if (e.type === "main_culprit") {
70
- if (typeof e.commit === "string" && e.commit.length > 0 && (culprit === undefined || e.ts > culprit.ts)) {
67
+ if (typeof e.commit === "string" && e.commit.length > 0 && isNewerCulprit(culprit, e)) {
71
68
  culprit = e;
72
69
  }
73
70
  continue;
@@ -75,6 +72,13 @@ export function redMainWarning(events, now) {
75
72
  if (isRedMain(e) || isPassingMainCheck(e))
76
73
  relevant.push(e);
77
74
  }
75
+ return { relevant, culprit };
76
+ }
77
+ /** Derives the red spell over `window`'s `relevant` events: the start event and duration from
78
+ * `now`, whether the spell is unbroken, and the 30-minute threshold (null when main is green,
79
+ * or red for RED_MAIN_WARN_MS or less). Owns the red-spell derivation and the 30-minute bail. */
80
+ function redSpell(window, now) {
81
+ const relevant = window.relevant;
78
82
  if (relevant.length === 0)
79
83
  return null;
80
84
  let newest = relevant[0];
@@ -99,19 +103,52 @@ export function redMainWarning(events, now) {
99
103
  if (durationMs <= RED_MAIN_WARN_MS)
100
104
  return null;
101
105
  const unbroken = lastPass === undefined && reds.length > 1;
102
- const duration = unbroken || durationMs >= DAY_MS ? "more than 24 h" : redDurationPhrase(durationMs);
103
- const failing = Array.isArray(newest.failingTests) && typeof newest.failingTests[0] === "string" ? newest.failingTests[0] : undefined;
104
- const head = `main has had no passing full check for ${duration}: red since ${shortSha(startEv.sha)} at ${clockHm(startEv.ts)}`;
106
+ return { startEv, newest, durationMs, unbroken };
107
+ }
108
+ /** "more than 24 h" for an unbroken or full-window red, else the short duration phrase. Owns
109
+ * the duration wording. */
110
+ function redSpellDuration(spell) {
111
+ return spell.unbroken || spell.durationMs >= DAY_MS ? "more than 24 h" : redDurationPhrase(spell.durationMs);
112
+ }
113
+ /** The newest red's first failing test, when one is named. Owns the first-failing-test read. */
114
+ function redFailingTest(newest) {
115
+ return Array.isArray(newest.failingTests) && typeof newest.failingTests[0] === "string" ? newest.failingTests[0] : undefined;
116
+ }
117
+ /** The blame clause naming the merge the main watch blamed, when the newest `culprit` in the
118
+ * window blames the same sha the phrase names. Owns the blame clause. */
119
+ function redBlameClause(culprit, newest) {
105
120
  // The blame names the merge the main watch blamed for the red — the same `main_culprit`
106
121
  // commit-equals-tip rule the dashboard alert uses (status-polls only publishes a blame whose
107
122
  // commit is the derived tip sha), so a red the watch did not blame never borrows another
108
123
  // head's culprit. A culprit naming another commit, or none, leaves the phrase as today.
109
124
  const shouldBlame = culprit !== undefined && culprit.commit === newest.sha;
110
- const blame = shouldBlame
125
+ return shouldBlame
111
126
  ? typeof culprit.runner === "string" && culprit.runner.length > 0
112
127
  ? `; the main watch blames ${roleLabel(culprit.runner)}'s merge ${shortSha(culprit.commit)}`
113
128
  : `; the main watch blames merge ${shortSha(culprit.commit)}`
114
129
  : "";
130
+ }
131
+ /** G1 over `events`: warns when main has been red for more than RED_MAIN_WARN_MS, over the
132
+ * trailing 24 h of `events` from `now`. Only `main_check` events with `durationMs` > 0 and
133
+ * `main_red_hold` events count; a `main_check` with `durationMs` 0 (cached or inherited) is
134
+ * ignored; a pass (including one naming only quarantined tests, recorded passed) ends the red.
135
+ * The phrase names how long main has had no passing full check, the sha it has been red since,
136
+ * its clock time, the newest red's first failing test when one is named, and — when the newest
137
+ * `main_culprit` in the window blames the same sha the phrase names — the merge the main watch
138
+ * blamed (`; the main watch blames <role>'s merge <sha>`, or `; the main watch blames merge
139
+ * <sha>` when no runner is named), the one rule the dashboard alert and this phrase share.
140
+ * Returns null when main is green, or red for RED_MAIN_WARN_MS or less. The per-job work is
141
+ * split into the module-private helpers `scanRedWindow`, `redSpell`, `redSpellDuration`,
142
+ * `redFailingTest` and `redBlameClause`, one per job, so each is found by name. */
143
+ export function redMainWarning(events, now) {
144
+ const window = scanRedWindow(events, now - DAY_MS);
145
+ const spell = redSpell(window, now);
146
+ if (spell === null)
147
+ return null;
148
+ const duration = redSpellDuration(spell);
149
+ const head = `main has had no passing full check for ${duration}: red since ${shortSha(spell.startEv.sha)} at ${clockHm(spell.startEv.ts)}`;
150
+ const failing = redFailingTest(spell.newest);
151
+ const blame = redBlameClause(window.culprit, spell.newest);
115
152
  return failing ? `${head} — ${squash(failing, 120)}${blame}` : `${head}${blame}`;
116
153
  }
117
154
  /** G3 over `entries` (the loadQuarantine list): warns when the list holds more than
@@ -6,7 +6,7 @@ import { detectBuildCheck } from "../build/build-check-detect.js";
6
6
  import { git, gitTry } from "../git/git-run.js";
7
7
  import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
8
8
  import { errorMessage } from "../text/text.js";
9
- import { fileQuarantineBugsOnMain } from "../backlog/backlog-archive.js";
9
+ import { fileQuarantineBugsOnMain } from "../backlog/backlog-file-bugs.js";
10
10
  import { loadQuarantine, markQuarantineFiled, onlyQuarantined, quarantinedTests, quarantineTests, releaseQuarantine, } from "../review/known-flakes.js";
11
11
  import { shortSha } from "../text/format.js";
12
12
  import { treeDiffFiles } from "../git/git-diff.js";
@@ -59,11 +59,6 @@ function assignRunnerBudgetConfig(runners, liveConfig, view) {
59
59
  // the reload, can never run on the wrong model.
60
60
  for (const r of runners) {
61
61
  r.config = gateRoleConfig(r.role, liveConfig, view.roleConfig, view.directorConfig);
62
- // An image reroute must never spend past the daily cap: while the gate has the team on
63
- // its free fallback pairs, a reroute looks only among those pairs (plans/image-input.md
64
- // "Reroute target" "Regime"). Set beside the config assignment so the scheduler's regime
65
- // and the roles' never disagree.
66
- r.budgetFallbackActive = view.onFallback;
67
62
  }
68
63
  }
69
64
  function handBackFallbackRuns(root, runners, liveConfig, accounts, view) {
@@ -8,6 +8,7 @@
8
8
  * without importing either — the one-way rule that previously kept these functions in
9
9
  * runner-state.ts. */
10
10
  import { MODEL_TIERS } from "../config/config-schema.js";
11
+ import { compareCodeUnits } from "../collections.js";
11
12
  import { nonNegativeNumber, setOwn } from "../files/json-object.js";
12
13
  import { dayAt, dayKey } from "../text/datetime.js";
13
14
  /** The local calendar day as YYYY-MM-DD — the same local-time convention as every other
@@ -76,7 +77,7 @@ export function topDailySpenders(states, now = Date.now(), limit = 3) {
76
77
  return states
77
78
  .map((s) => ({ role: s.role, spentUsd: dailyCost(s, now) }))
78
79
  .filter((r) => r.spentUsd > 0)
79
- .sort((a, b) => b.spentUsd - a.spentUsd || (a.role < b.role ? -1 : a.role > b.role ? 1 : 0))
80
+ .sort((a, b) => b.spentUsd - a.spentUsd || compareCodeUnits(a.role, b.role))
80
81
  .slice(0, limit);
81
82
  }
82
83
  /** True when today's spend has reached the daily cost budget — the view is non-null, its cap is
@@ -5,34 +5,11 @@ import { MERGE_SCOPES, SCOPE_WORDS, buildCheckEvent, buildCheckSkipWarning, runW
5
5
  import { CHECK_TIER, withCheckPermit } from "../concurrency/check-permit.js";
6
6
  import { sampleHostLoad, sampleSleepClock } from "./host-sleep.js";
7
7
  import { npmInstall } from "./dep-install.js";
8
- /** Run the project's declared check for a named scope — the detect → run → build_check event →
9
- * skip-warning sequence the review gate's pre-check (scope "gate"), the merge path's in-lock
10
- * re-check (scope "merge"), and the batch merger's one check over the whole stacked tree (scope
11
- * "batch") previously each ran inline, kept in one place so the event's shape and the skip warning
12
- * cannot drift between them. Split out of build-check.ts — which keeps the check's execution and
13
- * outcome classification (runBuildCheck, BuildCheckOutcome) — because orchestration is its own
14
- * concern: the permit, the event emission, the verdict-less retry, the merge-scope unverified
15
- * remapping, and the skip warning. Every run is an event with its cost: the deterministic checks
16
- * are where the team's compute goes after the authoring run, and "how long does npm test take per
17
- * merge" must be answerable from the feed, not by timing it by hand. Returns null when no check is
18
- * declared (nothing to run — the caller passes, exactly as before this split), otherwise the
19
- * declared check plus its classified outcome. A "skipped" outcome also logs its standard warning
20
- * here (wording keyed on the scope, the timeout as actually set — and, when its deadline fired
21
- * late, as actually enforced); "failed" and "passed" are the caller's to decide (deterministic
22
- * reject vs. verifiedHead / baseline seeding). A timeout or signal kill at a merge scope is
23
- * remapped to a deterministic "failed" — the tree is unverified, so it must not merge. A check
24
- * killed by a signal the harness did not send is retried once at any scope — the first run's death
25
- * says nothing about the tree (it is another run's `pkill`), so one verdict from a clean attempt is
26
- * owed before the skip is honored; a timeout whose deadline demonstrably fired late (the harness's
27
- * own host-sleep evidence) earns that same one retry at any scope, and a failed run the host slept
28
- * through does too; each attempt is priced as its own build_check event, carrying when the check
29
- * itself ran (buildCheckRunFields). A configured `check.gateCommand` replaces the command at scope
30
- * "gate" only (same cwd and timeout); the in-lock command (Fast merge "Defaults", mergeCommandOf) —
31
- * `check.mergeCommand` when set, else `check.gateCommand`, else the full check — replaces it at the
32
- * merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
33
- * detects on its own, keeps running check.command. `permitWait` hears
34
- * about a wait for the check permit (the merge cell's "waiting for a check slot"). Never throws. */
35
- export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad) {
8
+ /** Resolve which command to detect for a scope and the effective deadline it runs under — the
9
+ * command-swap/detect rule (gateCommandOf/mergeCommandOf over detectBuildCheck), the
10
+ * merge-scope starved deadline, and the cheaper flag. Returns null when no check is declared
11
+ * (nothing to run). */
12
+ function resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad) {
36
13
  const swapped = scope === "gate" ? gateCommandOf(config) : MERGE_SCOPES.has(scope) ? mergeCommandOf(config) : undefined;
37
14
  const check = detectBuildCheck(wt, swapped === undefined ? config : { check: { ...config?.check, command: swapped } });
38
15
  if (!check)
@@ -50,6 +27,12 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
50
27
  ? starvedDeadlineMs(checkTimeoutMs(check, timeoutMs), load)
51
28
  : checkTimeoutMs(check, timeoutMs);
52
29
  const runCheck = { ...check, timeoutMs: effectiveMs };
30
+ return { check, cheaper, runCheck, effectiveMs, load };
31
+ }
32
+ /** Run the resolved check under one permit, with the one clean retry for a verdict-less death —
33
+ * the one-permit-two-attempt rule. Prices the first attempt as its own build_check event and
34
+ * returns the final outcome and its run duration. */
35
+ async function runScopedCheckWithRetry(root, role, scope, wt, runCheck, timeoutMs, config, sampleSleep, install, permitWait, cheaper) {
53
36
  // One permit covers both attempts (the killed-check retry re-runs under the permit it
54
37
  // holds); durationMs prices the run itself, not the wait for a permit.
55
38
  let durationMs = 0;
@@ -70,6 +53,12 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
70
53
  granted: () => permitWait?.granted?.(),
71
54
  waited: (waitedMs, parked) => logCheckWaitEvent(root, role, scope, waitedMs, parked),
72
55
  }, `${role} ${scope}`);
56
+ return { raw, durationMs };
57
+ }
58
+ /** Classify the raw outcome: the merge-scope skip and slept-through rules become a deterministic
59
+ * unverified reject; anything else passes through. Never logs — the caller warns (see
60
+ * warnScopedCheck). Returns the unverified reason only when a rule fired. */
61
+ function unverifiedOutcomeFor(scope, check, raw, effectiveMs, load, durationMs) {
73
62
  // A signal kill at a merge scope, or a timeout whose deadline fired on time (real slowness, a
74
63
  // genuinely hung or oversized suite), made no verdict about the tree — and this is the check
75
64
  // whose whole job is to catch a semantic conflict before it merges, so it rejects
@@ -89,6 +78,8 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
89
78
  (((raw.status === "skipped" &&
90
79
  (raw.skipReason === "timeout" || raw.skipReason === "killed" || raw.skipReason === "install")) ||
91
80
  sleepFailed));
81
+ if (!unverifiedSkip)
82
+ return { outcome: raw, unverifiedReason: null };
92
83
  const unverifiedReason = raw.skipReason === "install"
93
84
  ? installFailedPhrase(SCOPE_WORDS[scope].label, raw.install, UNVERIFIED_TREE)
94
85
  : raw.skipReason === "killed"
@@ -99,17 +90,19 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
99
90
  // An on-time kill (the late-deadline wording already names its own cause) names the
100
91
  // starved host when the load average says the team was the check's competition.
101
92
  (raw.run?.deadlineLateMs ?? 0) > DEADLINE_LATE_TOLERANCE_MS ? "" : loadedHostPhrase(load)}`;
102
- const outcome = unverifiedSkip
103
- ? {
104
- status: "failed",
105
- script: checkScriptName(check),
106
- outputTail: [unverifiedReason],
107
- run: raw.run,
108
- unverified: true,
109
- ...(raw.install ? { install: raw.install } : {}),
110
- }
111
- : raw;
112
- logEventBestEffort(root, buildCheckEvent(role, scope, outcome, durationMs, cheaper));
93
+ const outcome = {
94
+ status: "failed",
95
+ script: checkScriptName(check),
96
+ outputTail: [unverifiedReason],
97
+ run: raw.run,
98
+ unverified: true,
99
+ ...(raw.install ? { install: raw.install } : {}),
100
+ };
101
+ return { outcome, unverifiedReason };
102
+ }
103
+ /** Warn for the two verdict-less roads: a skipped check (buildCheckSkipWarning) or an unverified
104
+ * merge reject. Reads the scope word table and the killed signal. */
105
+ function warnScopedCheck(root, role, scope, outcome, effectiveMs, durationMs, unverifiedReason) {
113
106
  if (outcome.status === "skipped") {
114
107
  // Environmental — deliberately NOT fail-closed, so a hung build script cannot wedge every
115
108
  // code run into the 3-strike discard (gate) or a merge behind the merge lock.
@@ -118,8 +111,44 @@ export async function runScopedBuildCheck(root, role, scope, wt, config, timeout
118
111
  ? { signal: outcome.killedBy, durationMs }
119
112
  : undefined, outcome.run, outcome.install));
120
113
  }
121
- else if (unverifiedSkip) {
114
+ else if (unverifiedReason) {
122
115
  warnEventBestEffort(root, role, `${unverifiedReason}; rejecting the merge`);
123
116
  }
124
- return { check, outcome };
117
+ }
118
+ /** Run the project's declared check for a named scope — the detect → run → build_check event →
119
+ * skip-warning sequence the review gate's pre-check (scope "gate"), the merge path's in-lock
120
+ * re-check (scope "merge"), and the batch merger's one check over the whole stacked tree (scope
121
+ * "batch") previously each ran inline, kept in one place so the event's shape and the skip warning
122
+ * cannot drift between them. Split out of build-check.ts — which keeps the check's execution and
123
+ * outcome classification (runBuildCheck, BuildCheckOutcome) — because orchestration is its own
124
+ * concern: the permit, the event emission, the verdict-less retry, the merge-scope unverified
125
+ * remapping, and the skip warning. Every run is an event with its cost: the deterministic checks
126
+ * are where the team's compute goes after the authoring run, and "how long does npm test take per
127
+ * merge" must be answerable from the feed, not by timing it by hand. Returns null when no check is
128
+ * declared (nothing to run — the caller passes, exactly as before this split), otherwise the
129
+ * declared check plus its classified outcome. A "skipped" outcome also logs its standard warning
130
+ * here (wording keyed on the scope, the timeout as actually set — and, when its deadline fired
131
+ * late, as actually enforced); "failed" and "passed" are the caller's to decide (deterministic
132
+ * reject vs. verifiedHead / baseline seeding). A timeout or signal kill at a merge scope is
133
+ * remapped to a deterministic "failed" — the tree is unverified, so it must not merge. A check
134
+ * killed by a signal the harness did not send is retried once at any scope — the first run's death
135
+ * says nothing about the tree (it is another run's `pkill`), so one verdict from a clean attempt is
136
+ * owed before the skip is honored; a timeout whose deadline demonstrably fired late (the harness's
137
+ * own host-sleep evidence) earns that same one retry at any scope, and a failed run the host slept
138
+ * through does too; each attempt is priced as its own build_check event, carrying when the check
139
+ * itself ran (buildCheckRunFields). A configured `check.gateCommand` replaces the command at scope
140
+ * "gate" only (same cwd and timeout); the in-lock command (Fast merge "Defaults", mergeCommandOf) —
141
+ * `check.mergeCommand` when set, else `check.gateCommand`, else the full check — replaces it at the
142
+ * merge and batch scopes, and those events then carry `full: false`. The red-main baseline, which
143
+ * detects on its own, keeps running check.command. `permitWait` hears
144
+ * about a wait for the check permit (the merge cell's "waiting for a check slot"). Never throws. */
145
+ export async function runScopedBuildCheck(root, role, scope, wt, config, timeoutMs = BUILD_CHECK_TIMEOUT_MS, sampleSleep = sampleSleepClock, install = npmInstall, permitWait, sampleLoad = sampleHostLoad) {
146
+ const r = resolveScopedCheck(wt, scope, config, timeoutMs, sampleLoad);
147
+ if (!r)
148
+ return null;
149
+ const { raw, durationMs } = await runScopedCheckWithRetry(root, role, scope, wt, r.runCheck, timeoutMs, config, sampleSleep, install, permitWait, r.cheaper);
150
+ const { outcome, unverifiedReason } = unverifiedOutcomeFor(scope, r.check, raw, r.effectiveMs, r.load, durationMs);
151
+ logEventBestEffort(root, buildCheckEvent(role, scope, outcome, durationMs, r.cheaper));
152
+ warnScopedCheck(root, role, scope, outcome, r.effectiveMs, durationMs, unverifiedReason);
153
+ return { check: r.check, outcome };
125
154
  }
@@ -10,6 +10,7 @@ import { knownRoleIdsCached, loadConfigSafe } from "../config/config.js";
10
10
  import { aheadOfMain, branchExists, countCommitsInRange, currentBranch, targetBranch } from "../git/git.js";
11
11
  import { gitTry } from "../git/git-run.js";
12
12
  import { aheadOfMainDiff, aheadOfMainFiles, changedFiles } from "../git/git-diff.js";
13
+ import { compareCodeUnits } from "../collections.js";
13
14
  import { isAbsolute } from "node:path";
14
15
  import { branchName } from "../paths.js";
15
16
  import { isUsableWorktree } from "../git/worktree.js";
@@ -152,7 +153,8 @@ async function cappedWorkingDiff(wt, spec = []) {
152
153
  return (`[uncommitted diff truncated: the full diff is ${full.length} bytes; showing --stat]\n\n` + stat);
153
154
  }
154
155
  /** The paths two or more distinct roles hold: a role's duplicate path counts once, roles stay
155
- * in holdings order, paths sort by plain `<` comparison. */
156
+ * in holdings order, paths sort in code-unit order (`compareCodeUnits`, the locale-independent
157
+ * order a plain `<` comparison gives). */
156
158
  export function fileOverlaps(holdings) {
157
159
  const byPath = new Map();
158
160
  for (const { role, files } of holdings) {
@@ -166,7 +168,7 @@ export function fileOverlaps(holdings) {
166
168
  }
167
169
  return [...byPath.entries()]
168
170
  .filter(([, roles]) => roles.length >= 2)
169
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
171
+ .sort(([a], [b]) => compareCodeUnits(a, b))
170
172
  .map(([path, roles]) => ({ path, roles }));
171
173
  }
172
174
  /** Keep only the overlaps naming a listed role; each kept entry still names every holder. */